feat: add video streaming settings page
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user