From 0881685915239227b75c3ce255ad232ef4287ea8 Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Wed, 20 Aug 2025 14:34:16 +0530 Subject: [PATCH] feat: add video streaming settings page --- mobile/apps/photos/lib/db/files_db.dart | 55 ++++ mobile/apps/photos/lib/l10n/intl_en.arb | 5 +- .../lib/services/video_preview_service.dart | 33 +++ .../video_streaming_settings_page.dart | 256 ++++++++++++++++++ 4 files changed, 348 insertions(+), 1 deletion(-) create mode 100644 mobile/apps/photos/lib/ui/settings/streaming/video_streaming_settings_page.dart diff --git a/mobile/apps/photos/lib/db/files_db.dart b/mobile/apps/photos/lib/db/files_db.dart index 1957f55179..1abd97f405 100644 --- a/mobile/apps/photos/lib/db/files_db.dart +++ b/mobile/apps/photos/lib/db/files_db.dart @@ -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 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 = {}; + for (final result in results) { + ids.add(result[columnUploadedFileID] as int); + } + return ids.length; + } + + Future 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, diff --git a/mobile/apps/photos/lib/l10n/intl_en.arb b/mobile/apps/photos/lib/l10n/intl_en.arb index 017637a8b8..e8af029098 100644 --- a/mobile/apps/photos/lib/l10n/intl_en.arb +++ b/mobile/apps/photos/lib/l10n/intl_en.arb @@ -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." } \ No newline at end of file diff --git a/mobile/apps/photos/lib/services/video_preview_service.dart b/mobile/apps/photos/lib/services/video_preview_service.dart index 32b7ccb5e6..d3959312c4 100644 --- a/mobile/apps/photos/lib/services/video_preview_service.dart +++ b/mobile/apps/photos/lib/services/video_preview_service.dart @@ -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 _items = LinkedHashMap(); @@ -104,6 +114,27 @@ class VideoPreviewService { } } + Future 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 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); } } diff --git a/mobile/apps/photos/lib/ui/settings/streaming/video_streaming_settings_page.dart b/mobile/apps/photos/lib/ui/settings/streaming/video_streaming_settings_page.dart new file mode 100644 index 0000000000..12bf4f20a9 --- /dev/null +++ b/mobile/apps/photos/lib/ui/settings/streaming/video_streaming_settings_page.dart @@ -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 createState() => + _VideoStreamingSettingsPageState(); +} + +class _VideoStreamingSettingsPageState + extends State { + @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: [ + 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 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 createState() => + VideoStreamingStatusWidgetState(); +} + +class VideoStreamingStatusWidgetState + extends State { + @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(); + }, + ), + ], + ); + } +}