feat: add video streaming settings page

This commit is contained in:
Prateek Sunal
2025-08-20 14:34:16 +05:30
parent 3e13932d03
commit 0881685915
4 changed files with 348 additions and 1 deletions

View File

@@ -1,4 +1,5 @@
import "dart:async";
import "dart:convert" show jsonDecode;
import "dart:io";
import "package:computer/computer.dart";
@@ -1759,6 +1760,60 @@ class FilesDB with SqlDbBase {
return ids.length;
}
Future<int> remoteVideosCount() async {
final db = await instance.sqliteAsyncDB;
final results = await db.getAll(
'''
SELECT DISTINCT $columnUploadedFileID FROM $filesTable
WHERE $columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1
AND $columnFileType = ?
''',
[
getInt(FileType.video),
],
);
final ids = <int>{};
for (final result in results) {
ids.add(result[columnUploadedFileID] as int);
}
return ids.length;
}
Future<int> skippedVideosCount() async {
// skipped because video size > 500 MB || duration > 60
final db = await instance.sqliteAsyncDB;
final results = await db.getAll(
'''
SELECT DISTINCT $columnUploadedFileID FROM $filesTable
WHERE $columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1
AND (($columnFileSize IS NOT NULL AND $columnFileSize > 524288000)
OR ($columnDuration IS NOT NULL AND $columnDuration > 60))
AND $columnFileType = ?
''',
[getInt(FileType.video)],
);
int length = results.length;
// get video files <= 10 MB
final results2 = await db.getAll('''
SELECT DISTINCT $columnUploadedFileID FROM $filesTable
WHERE $columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1
AND ($columnFileSize IS NOT NULL AND $columnFileSize <= 10485760)
AND $columnFileType = ?
''', [
getInt(FileType.video),
]);
for (final result in results2) {
// decode pub magic metadata and check sv == 1
final pubMagicEncodedJson = result[columnPubMMdEncodedJson];
final pubMagicMetadata = jsonDecode(pubMagicEncodedJson);
if (pubMagicMetadata['sv'] == 1) length++;
}
return length;
}
///Returns "columnName1 = ?, columnName2 = ?, ..."
String _generateUpdateAssignmentsWithPlaceholders({
required int? fileGenId,

View File

@@ -1827,5 +1827,8 @@
"type": "int"
}
}
}
},
"videosProcessed": "Videos processed",
"totalVideos": "Total videos",
"videoStreamingDescription": "Video streaming allows you to play videos without downloading them first. This is useful for large videos that you don't want to store on your device."
}

View File

@@ -23,11 +23,13 @@ import "package:photos/models/base/id.dart";
import "package:photos/models/ffmpeg/ffprobe_props.dart";
import "package:photos/models/file/file.dart";
import "package:photos/models/file/file_type.dart";
import "package:photos/models/metadata/file_magic.dart";
import "package:photos/models/preview/playlist_data.dart";
import "package:photos/models/preview/preview_item.dart";
import "package:photos/models/preview/preview_item_status.dart";
import "package:photos/service_locator.dart";
import "package:photos/services/collections_service.dart";
import "package:photos/services/file_magic_service.dart";
import "package:photos/services/filedata/model/file_data.dart";
import "package:photos/services/isolated_ffmpeg_service.dart";
import "package:photos/ui/notification/toast.dart";
@@ -40,6 +42,14 @@ import "package:shared_preferences/shared_preferences.dart";
const _maxRetryCount = 3;
class StreamingStatus {
final double netProcessedItems;
final int processed;
final int total;
StreamingStatus(this.netProcessedItems, this.processed, this.total);
}
class VideoPreviewService {
final _logger = Logger("VideoPreviewService");
final LinkedHashMap<int, PreviewItem> _items = LinkedHashMap();
@@ -104,6 +114,27 @@ class VideoPreviewService {
}
}
Future<StreamingStatus> getStatus() async {
try {
final filesDB = FilesDB.instance;
final int totalRemoteVideos = await filesDB.remoteVideosCount();
final int processed = fileDataService.previewIds.length;
final skippedVideosCount = await filesDB.skippedVideosCount();
final netTotal = totalRemoteVideos - skippedVideosCount;
final double netProcessedItems =
netTotal == 0 ? 0 : (processed / netTotal).clamp(0, 1);
final status =
StreamingStatus(netProcessedItems, processed, totalRemoteVideos);
_logger.info("Streaming Status: $status");
return status;
} catch (e, s) {
_logger.severe('Error getting ML status', e, s);
rethrow;
}
}
Future<void> chunkAndUploadVideo(
BuildContext? ctx,
EnteFile enteFile, [
@@ -776,6 +807,8 @@ class VideoPreviewService {
_logger.info(
"[init] Ignoring file ${enteFile.displayName} for preview due to codec",
);
await FileMagicService.instance
.updatePublicMagicMetadata([enteFile], {streamVersionKey: 1});
return (props, skipFile, file);
}
}

View File

@@ -0,0 +1,256 @@
import "dart:async";
import "package:flutter/material.dart";
import "package:photos/generated/l10n.dart";
import "package:photos/l10n/l10n.dart";
import "package:photos/service_locator.dart";
import "package:photos/services/video_preview_service.dart";
import "package:photos/theme/ente_theme.dart";
import "package:photos/ui/common/loading_widget.dart";
import "package:photos/ui/common/web_page.dart";
import "package:photos/ui/components/buttons/button_widget.dart";
import "package:photos/ui/components/buttons/icon_button_widget.dart";
import "package:photos/ui/components/captioned_text_widget.dart";
import "package:photos/ui/components/menu_item_widget/menu_item_widget.dart";
import "package:photos/ui/components/menu_section_title.dart";
import "package:photos/ui/components/models/button_type.dart";
import "package:photos/ui/components/title_bar_title_widget.dart";
import "package:photos/ui/components/title_bar_widget.dart";
import "package:photos/ui/components/toggle_switch_widget.dart";
class VideoStreamingSettingsPage extends StatefulWidget {
const VideoStreamingSettingsPage({super.key});
@override
State<VideoStreamingSettingsPage> createState() =>
_VideoStreamingSettingsPageState();
}
class _VideoStreamingSettingsPageState
extends State<VideoStreamingSettingsPage> {
@override
void initState() {
super.initState();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
final hasEnabled = flagService.hasGrantedMLConsent;
return Scaffold(
body: CustomScrollView(
primary: false,
slivers: <Widget>[
TitleBarWidget(
flexibleSpaceTitle: TitleBarTitleWidget(
title: S.of(context).videoStreaming,
),
actionIcons: [
IconButtonWidget(
icon: Icons.close_outlined,
iconButtonType: IconButtonType.secondary,
onTap: () {
Navigator.pop(context);
if (Navigator.canPop(context)) Navigator.pop(context);
if (Navigator.canPop(context)) Navigator.pop(context);
},
),
],
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 16),
child: Column(
children: [
Text(
S.of(context).videoStreamingDescription,
textAlign: TextAlign.left,
style: getEnteTextTheme(context).mini.copyWith(
color: getEnteColorScheme(context).textMuted,
),
),
],
),
),
),
if (hasEnabled)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16)
.copyWith(top: 20),
child: _getMlSettings(context),
),
)
else
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
ButtonWidget(
buttonType: ButtonType.primary,
labelText: context.l10n.enable,
onTap: () async {
await toggleVideoStreaming();
},
),
const SizedBox(height: 12),
ButtonWidget(
buttonType: ButtonType.secondary,
labelText: context.l10n.moreDetails,
onTap: () async {
Navigator.of(context).push(
MaterialPageRoute(
builder: (BuildContext context) {
return WebPage(
S.of(context).help,
"https://help.ente.io/photos/faq/video-streaming",
);
},
),
).ignore();
},
),
],
),
),
),
],
),
);
}
Future<void> toggleVideoStreaming() async {
final isEnabled = VideoPreviewService.instance.isVideoStreamingEnabled;
await VideoPreviewService.instance.setIsVideoStreamingEnabled(!isEnabled);
}
Widget _getMlSettings(BuildContext context) {
final hasEnabled = flagService.hasGrantedMLConsent;
if (!hasEnabled) {
return const SizedBox.shrink();
}
return Column(
children: [
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: S.of(context).enabled,
),
trailingWidget: ToggleSwitchWidget(
value: () => hasEnabled,
onChanged: () async {
await toggleVideoStreaming();
},
),
singleBorderRadius: 8,
isGestureDetectorDisabled: true,
),
const SizedBox(height: 12),
const VideoStreamingStatusWidget(),
],
);
}
}
class VideoStreamingStatusWidget extends StatefulWidget {
const VideoStreamingStatusWidget({
super.key,
});
@override
State<VideoStreamingStatusWidget> createState() =>
VideoStreamingStatusWidgetState();
}
class VideoStreamingStatusWidgetState
extends State<VideoStreamingStatusWidget> {
@override
void initState() {
super.initState();
}
@override
void dispose() {
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Row(
children: [
MenuSectionTitle(title: S.of(context).status),
Expanded(child: Container()),
],
),
FutureBuilder(
future: VideoPreviewService.instance.getStatus(),
builder: (context, snapshot) {
if (snapshot.hasData) {
final double netProcessed = snapshot.data!.netProcessedItems;
final int processed = snapshot.data!.processed;
final int total = snapshot.data!.total;
return Column(
children: [
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: S.of(context).processed,
),
trailingWidget: Text(
total < 1
? 'NA'
: netProcessed == 0
? '0%'
: '${(netProcessed * 100.0).toStringAsFixed(2)}%',
style: Theme.of(context).textTheme.bodySmall,
),
singleBorderRadius: 8,
alignCaptionedTextToLeft: true,
isGestureDetectorDisabled: true,
key: ValueKey("pending_items_" + netProcessed.toString()),
),
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: S.of(context).videosProcessed,
),
trailingWidget: Text(
'$processed',
style: Theme.of(context).textTheme.bodySmall,
),
singleBorderRadius: 8,
alignCaptionedTextToLeft: true,
isGestureDetectorDisabled: true,
key: ValueKey(
"videos_processed_item_" + processed.toString(),
),
),
MenuItemWidget(
captionedTextWidget: CaptionedTextWidget(
title: S.of(context).totalVideos,
),
trailingWidget: Text(
'$processed',
style: Theme.of(context).textTheme.bodySmall,
),
singleBorderRadius: 8,
alignCaptionedTextToLeft: true,
isGestureDetectorDisabled: true,
key: ValueKey("total_videos_item_" + total.toString()),
),
],
);
}
return const EnteLoadingWidget();
},
),
],
);
}
}