mirror of
https://github.com/ente-io/ente.git
synced 2025-08-08 07:28:26 +00:00
[mob] streaming feedbacks resolved (#5112)
## Description This PR deals with following: - [x] Android Artifacts fixes - [x] Queuing Fixes - [x] Document functions better - [x] Make UX similar to native video player - [x] Check for seekbar changes ## Tests
This commit is contained in:
commit
cdfdc83083
@ -450,84 +450,84 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
background_fetch: 39f11371c0dce04b001c4bfd5e782bcccb0a85e2
|
||||
battery_info: 09f5c9ee65394f2291c8c6227bedff345b8a730c
|
||||
connectivity_plus: ddd7f30999e1faaef5967c23d5b6d503d10434db
|
||||
cupertino_http: 947a233f40cfea55167a49f2facc18434ea117ba
|
||||
dart_ui_isolate: d5bcda83ca4b04f129d70eb90110b7a567aece14
|
||||
device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
|
||||
background_fetch: 94b36ee293e82972852dba8ede1fbcd3bd3d9d57
|
||||
battery_info: a06b00c06a39bc94c92beebf600f1810cb6c8c87
|
||||
connectivity_plus: 3f6c9057f4cd64198dc826edfb0542892f825343
|
||||
cupertino_http: 94ac07f5ff090b8effa6c5e2c47871d48ab7c86c
|
||||
dart_ui_isolate: 46f6714abe6891313267153ef6f9748d8ecfcab1
|
||||
device_info_plus: 335f3ce08d2e174b9fdc3db3db0f4e3b1f66bd89
|
||||
ffmpeg-kit-ios-full-gpl: 80adc341962e55ef709e36baa8ed9a70cf4ea62b
|
||||
ffmpeg_kit_flutter_full_gpl: 8d15c14c0c3aba616fac04fe44b3d27d02e3c330
|
||||
file_saver: 503e386464dbe118f630e17b4c2e1190fa0cf808
|
||||
ffmpeg_kit_flutter_full_gpl: ce18b888487c05c46ed252cd2e7956812f2e3bd1
|
||||
file_saver: 6cdbcddd690cb02b0c1a0c225b37cd805c2bf8b6
|
||||
Firebase: 98e6bf5278170668a7983e12971a66b2cd57fc8c
|
||||
firebase_core: 2bedc3136ec7c7b8561c6123ed0239387b53f2af
|
||||
firebase_messaging: 15d114e1a41fc31e4fbabcd48d765a19eec94a38
|
||||
firebase_core: 085320ddfaacb80d1a96eac3a87857afcc150db1
|
||||
firebase_messaging: d398edc15fe825f832836e74f6ac61e8cd2f3ad3
|
||||
FirebaseCore: a282032ae9295c795714ded2ec9c522fc237f8da
|
||||
FirebaseCoreInternal: d98ab91e2d80a56d7b246856a8885443b302c0c2
|
||||
FirebaseInstallations: 6ef4a1c7eb2a61ee1f74727d7f6ce2e72acf1414
|
||||
FirebaseMessaging: c9ec7b90c399c7a6100297e9d16f8a27fc7f7152
|
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
|
||||
flutter_image_compress: 5a5e9aee05b6553048b8df1c3bc456d0afaac433
|
||||
flutter_inappwebview_ios: 6f63631e2c62a7c350263b13fa5427aedefe81d4
|
||||
flutter_local_notifications: 4cde75091f6327eb8517fa068a0a5950212d2086
|
||||
flutter_native_splash: edf599c81f74d093a4daf8e17bd7a018854bc778
|
||||
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
|
||||
flutter_sodium: c84426b4de738514b5b66cfdeb8a06634e72fe0b
|
||||
fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c
|
||||
flutter_email_sender: cd533cdc7ea5eda6fabb2c7f78521c71207778a4
|
||||
flutter_image_compress: 4b058288a81f76e5e80340af37c709abafff34c4
|
||||
flutter_inappwebview_ios: b89ba3482b96fb25e00c967aae065701b66e9b99
|
||||
flutter_local_notifications: ad39620c743ea4c15127860f4b5641649a988100
|
||||
flutter_native_splash: 35ddbc7228eafcb3969dcc5f1fbbe27c1145a4f0
|
||||
flutter_secure_storage: 2c2ff13db9e0a5647389bff88b0ecac56e3f3418
|
||||
flutter_sodium: 152647449ba89a157fd48d7e293dcd6d29c6ab0e
|
||||
fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038
|
||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||
GoogleUtilities: 26a3abef001b6533cf678d3eb38fd3f614b7872d
|
||||
home_widget: 0434835a4c9a75704264feff6be17ea40e0f0d57
|
||||
image_editor_common: d6f6644ae4a6de80481e89fe6d0a8c49e30b4b43
|
||||
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
|
||||
in_app_purchase_storekit: 8c3b0b3eb1b0f04efbff401c3de6266d4258d433
|
||||
integration_test: 252f60fa39af5e17c3aa9899d35d908a0721b573
|
||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
||||
image_editor_common: 3de87e7c4804f4ae24c8f8a998362b98c105cac1
|
||||
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
|
||||
in_app_purchase_storekit: e126ef1b89e4a9fdf07e28f005f82632b4609437
|
||||
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||
libwebp: 1786c9f4ff8a279e4dac1e8f385004d5fc253009
|
||||
local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3
|
||||
local_auth_ios: 5046a18c018dd973247a0564496c8898dbb5adf9
|
||||
local_auth_darwin: 553ce4f9b16d3fdfeafce9cf042e7c9f77c1c391
|
||||
local_auth_ios: f7a1841beef3151d140a967c2e46f30637cdf451
|
||||
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
|
||||
maps_launcher: 2e5b6a2d664ec6c27f82ffa81b74228d770ab203
|
||||
media_extension: 6d30dc1431ebaa63f43c397c37917b1a0a597a4c
|
||||
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
|
||||
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
|
||||
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
|
||||
motion_sensors: 03f55b7c637a7e365a0b5f9697a449f9059d5d91
|
||||
motionphoto: d4a432b8c8f22fb3ad966258597c0103c9c5ff16
|
||||
move_to_background: 39a5b79b26d577b0372cbe8a8c55e7aa9fcd3a2d
|
||||
maps_launcher: edf829809ba9e894d70e569bab11c16352dedb45
|
||||
media_extension: a1fec16ee9c8241a6aef9613578ebf097d6c5e64
|
||||
media_kit_libs_ios_video: 5a18affdb97d1f5d466dc79988b13eff6c5e2854
|
||||
media_kit_native_event_loop: 5fba1a849a6c87a34985f1e178a0de5bd444a0cf
|
||||
media_kit_video: 1746e198cb697d1ffb734b1d05ec429d1fcd1474
|
||||
motion_sensors: 741e702c17467b9569a92165dda8d4d88c6167f1
|
||||
motionphoto: 584b43031ead3060225cdff08fa49818879801d2
|
||||
move_to_background: 155f7bfbd34d43ad847cb630d2d2d87c17199710
|
||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||
native_video_player: d12af78a1a4a8cf09775a5177d5b392def6fd23c
|
||||
objective_c: 77e887b5ba1827970907e10e832eec1683f3431d
|
||||
onnxruntime: e7c2ae44385191eaad5ae64c935a72debaddc997
|
||||
native_video_player: b65c58951ede2f93d103a25366bdebca95081265
|
||||
objective_c: 89e720c30d716b036faf9c9684022048eee1eee2
|
||||
onnxruntime: f9b296392c96c42882be020a59dbeac6310d81b2
|
||||
onnxruntime-c: a909204639a1f035f575127ac406f781ac797c9c
|
||||
onnxruntime-objc: b6fab0f1787aa6f7190c2013f03037df4718bd8b
|
||||
open_mail_app: 794172f6a22cd16319d3ddaf45e945b2f74952b0
|
||||
open_mail_app: 06d5a4162866388a92b1df3deb96e56be20cf45c
|
||||
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
|
||||
package_info_plus: 115f4ad11e0698c8c1c5d8a689390df880f47e85
|
||||
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||
photo_manager: ff695c7a1dd5bc379974953a2b5c0a293f7c4c8a
|
||||
privacy_screen: 1a131c052ceb3c3659934b003b0d397c2381a24e
|
||||
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
|
||||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
|
||||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
|
||||
photo_manager: d2fbcc0f2d82458700ee6256a15018210a81d413
|
||||
privacy_screen: 3159a541f5d3a31bea916cfd4e58f9dc722b3fd4
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
receive_sharing_intent: df9c334dc9feadcbd3266e5cb49c8443405e1c9f
|
||||
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
|
||||
receive_sharing_intent: f6a12b7e8f7ed745f61c982de8a65de88db44a44
|
||||
screen_brightness_ios: 5ed898fa50fa82a26171c086ca5e28228f932576
|
||||
SDWebImage: 73c6079366fea25fa4bb9640d5fb58f0893facd8
|
||||
SDWebImageWebPCoder: e38c0a70396191361d60c092933e22c20d5b1380
|
||||
Sentry: f8374b5415bc38dfb5645941b3ae31230fbeae57
|
||||
sentry_flutter: 0eb93e5279eb41e2392212afe1ccd2fecb4f8cbe
|
||||
share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad
|
||||
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||
sqflite_darwin: a553b1fd6fe66f53bbb0fe5b4f5bab93f08d7a13
|
||||
sentry_flutter: 0a211008f52553ba5dd81ceb71f48d78f0f1f6ab
|
||||
share_plus: 011d6fb4f9d2576b83179a3a5c5e323202cdabcf
|
||||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
|
||||
sqflite_darwin: 44bb54cc302bff1fbe5752293aba1820b157cf1c
|
||||
sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb
|
||||
sqlite3_flutter_libs: c00457ebd31e59fa6bb830380ddba24d44fbcd3b
|
||||
system_info_plus: 5393c8da281d899950d751713575fbf91c7709aa
|
||||
sqlite3_flutter_libs: 9379996d65aa23dcda7585a5b58766cebe0aa042
|
||||
system_info_plus: 555ce7047fbbf29154726db942ae785c29211740
|
||||
Toast: 1f5ea13423a1e6674c4abdac5be53587ae481c4e
|
||||
ua_client_hints: 46bb5817a868f9e397c0ba7e3f2f5c5d90c35156
|
||||
uni_links: d97da20c7701486ba192624d99bffaaffcfc298a
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3
|
||||
video_thumbnail: c4e2a3c539e247d4de13cd545344fd2d26ffafd1
|
||||
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
|
||||
wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47
|
||||
ua_client_hints: 0b48eae1134283f5b131ee0871fa878377f07a01
|
||||
uni_links: ed8c961e47ed9ce42b6d91e1de8049e38a4b3152
|
||||
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
|
||||
video_player_avfoundation: 2cef49524dd1f16c5300b9cd6efd9611ce03639b
|
||||
video_thumbnail: b637e0ad5f588ca9945f6e2c927f73a69a661140
|
||||
volume_controller: ca1cde542ee70fad77d388f82e9616488110942b
|
||||
wakelock_plus: 04623e3f525556020ebd4034310f20fe7fda8b49
|
||||
|
||||
PODFILE CHECKSUM: 20e086e6008977d43a3d40260f3f9bffcac748dd
|
||||
|
||||
|
@ -239,7 +239,7 @@ Future<void> _init(bool isBackground, {String via = ''}) async {
|
||||
.init(preferences, NetworkClient.instance.enteDio, packageInfo);
|
||||
|
||||
if (!isBackground) {
|
||||
VideoPlayerMediaKit.ensureInitialized(iOS: true);
|
||||
VideoPlayerMediaKit.ensureInitialized(iOS: true, android: true);
|
||||
}
|
||||
|
||||
_logger.info("UserService init $tlog");
|
||||
|
@ -50,7 +50,7 @@ class PreviewVideoStore {
|
||||
final cacheManager = DefaultCacheManager();
|
||||
final videoCacheManager = VideoCacheManager.instance;
|
||||
|
||||
LinkedHashSet<EnteFile> files = LinkedHashSet();
|
||||
LinkedHashSet<EnteFile> fileQueue = LinkedHashSet();
|
||||
int uploadingFileId = -1;
|
||||
|
||||
final _dio = NetworkClient.instance.enteDio;
|
||||
@ -60,7 +60,7 @@ class PreviewVideoStore {
|
||||
|
||||
Future.delayed(
|
||||
const Duration(seconds: 10),
|
||||
PreviewVideoStore.instance.putFilesForPreviewCreation,
|
||||
_putFilesForPreviewCreation,
|
||||
);
|
||||
}
|
||||
|
||||
@ -82,16 +82,17 @@ class PreviewVideoStore {
|
||||
Bus.instance.fire(VideoStreamingChanged());
|
||||
|
||||
if (isVideoStreamingEnabled) {
|
||||
putFilesForPreviewCreation().ignore();
|
||||
await FileDataService.instance.syncFDStatus();
|
||||
_putFilesForPreviewCreation().ignore();
|
||||
} else {
|
||||
clearQueue();
|
||||
}
|
||||
}
|
||||
|
||||
clearQueue() {
|
||||
void clearQueue() {
|
||||
fileQueue.clear();
|
||||
_items.clear();
|
||||
Bus.instance.fire(PreviewUpdatedEvent(_items));
|
||||
files.clear();
|
||||
}
|
||||
|
||||
DateTime? get videoStreamingCutoff {
|
||||
@ -111,36 +112,39 @@ class PreviewVideoStore {
|
||||
}
|
||||
|
||||
try {
|
||||
if (!enteFile.isUploaded) return;
|
||||
final file = await getFile(enteFile, isOrigin: true);
|
||||
if (file == null) return;
|
||||
if (!enteFile.isUploaded) {
|
||||
_removeFile(enteFile);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// check if playlist already exist
|
||||
await getPlaylist(enteFile);
|
||||
final resultUrl = await getPreviewUrl(enteFile);
|
||||
final _ = await getPreviewUrl(enteFile);
|
||||
|
||||
if (ctx != null && ctx.mounted) {
|
||||
showShortToast(ctx, 'Video preview already exists');
|
||||
}
|
||||
debugPrint("previewUrl $resultUrl");
|
||||
_items.removeWhere((key, value) => value.file == enteFile);
|
||||
Bus.instance.fire(PreviewUpdatedEvent(_items));
|
||||
_removeFile(enteFile);
|
||||
return;
|
||||
} catch (e, s) {
|
||||
if (e is DioException && e.response?.statusCode == 404) {
|
||||
_logger.info("No preview found for $enteFile");
|
||||
} else {
|
||||
_logger.warning("Failed to get playlist for $enteFile", e, s);
|
||||
rethrow;
|
||||
_retryFile(enteFile, e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var (props, result) = await checkFileForPreviewCreation(enteFile);
|
||||
|
||||
// elimination case for <=10 MB with H.264
|
||||
var (props, result, file) = await _checkFileForPreviewCreation(enteFile);
|
||||
if (result) {
|
||||
_removeFile(enteFile);
|
||||
return;
|
||||
}
|
||||
|
||||
// check if there is already a preview in processing
|
||||
if (uploadingFileId >= 0) {
|
||||
if (uploadingFileId == enteFile.uploadedFileID) return;
|
||||
|
||||
@ -153,9 +157,11 @@ class PreviewVideoStore {
|
||||
collectionID: enteFile.collectionID ?? 0,
|
||||
);
|
||||
Bus.instance.fire(PreviewUpdatedEvent(_items));
|
||||
files.add(enteFile);
|
||||
fileQueue.add(enteFile);
|
||||
return;
|
||||
}
|
||||
|
||||
// everything is fine, let's process
|
||||
uploadingFileId = enteFile.uploadedFileID!;
|
||||
_items[enteFile.uploadedFileID!] = PreviewItem(
|
||||
status: PreviewItemStatus.compressing,
|
||||
@ -166,17 +172,31 @@ class PreviewVideoStore {
|
||||
);
|
||||
Bus.instance.fire(PreviewUpdatedEvent(_items));
|
||||
|
||||
props = await getVideoPropsAsync(file);
|
||||
// get file
|
||||
file ??= await getFile(enteFile, isOrigin: true);
|
||||
if (file == null) {
|
||||
_retryFile(enteFile, "Unable to fetch file");
|
||||
return;
|
||||
}
|
||||
|
||||
// check metadata for bitrate, codec, color space
|
||||
props ??= await getVideoPropsAsync(file);
|
||||
final fileSize = enteFile.fileSize ?? file.lengthSync();
|
||||
|
||||
final videoData = List.from(props?.propData?["streams"] ?? [])
|
||||
.firstWhereOrNull((e) => e["type"] == "video");
|
||||
|
||||
final codec = videoData["codec_name"]?.toString().toLowerCase();
|
||||
final codecIsH264 = codec?.contains("h264") ?? false;
|
||||
|
||||
final bitrate = props?.duration?.inSeconds != null
|
||||
? (fileSize * 8) / props!.duration!.inSeconds
|
||||
: null;
|
||||
|
||||
final colorSpace = videoData["color_space"]?.toString().toLowerCase();
|
||||
final isColorGood = colorSpace == "bt709";
|
||||
|
||||
// create temp file & directory for preview generation
|
||||
final String tempDir = Configuration.instance.getTempDirectory();
|
||||
final String prefix =
|
||||
"${tempDir}_${enteFile.uploadedFileID}_${newID("pv")}";
|
||||
@ -190,15 +210,14 @@ class PreviewVideoStore {
|
||||
final keyinfo = File('$prefix/mykey.keyinfo');
|
||||
keyinfo.writeAsStringSync("data:text/plain;base64,${key.base64}\n"
|
||||
"${keyfile.path}\n");
|
||||
|
||||
_logger.info(
|
||||
'Generating HLS Playlist ${enteFile.displayName} at $prefix/output.m3u8}',
|
||||
);
|
||||
|
||||
FFmpegSession? session;
|
||||
final colorSpace = videoData["color_space"]?.toString().toLowerCase();
|
||||
final isColorGood = colorSpace == "bt709";
|
||||
final codecIsH264 = codec?.contains("h264") ?? false;
|
||||
|
||||
// case 1, if it's already a good stream
|
||||
if (bitrate != null && bitrate <= 4000 * 1000 && codecIsH264) {
|
||||
session = await FFmpegKit.execute(
|
||||
'-i "${file.path}" '
|
||||
@ -208,7 +227,8 @@ class PreviewVideoStore {
|
||||
'-hls_list_size 0 -hls_key_info_file ${keyinfo.path} '
|
||||
'$prefix/output.m3u8',
|
||||
);
|
||||
} else if (bitrate != null &&
|
||||
} // case 2, if it's bitrate is good, but codec is not
|
||||
else if (bitrate != null &&
|
||||
codec != null &&
|
||||
bitrate <= 2000 * 1000 &&
|
||||
!codecIsH264) {
|
||||
@ -223,10 +243,9 @@ class PreviewVideoStore {
|
||||
'-hls_list_size 0 -hls_key_info_file ${keyinfo.path} '
|
||||
'$prefix/output.m3u8',
|
||||
);
|
||||
}
|
||||
|
||||
if (colorSpace != null && isColorGood) {
|
||||
session ??= await FFmpegKit.execute(
|
||||
} // case 3, if it's color space is good
|
||||
else if (colorSpace != null && isColorGood) {
|
||||
session = await FFmpegKit.execute(
|
||||
'-i "${file.path}" '
|
||||
'-metadata:s:v:0 rotate=0 '
|
||||
'-vf "scale=-2:720,fps=30" '
|
||||
@ -235,20 +254,21 @@ class PreviewVideoStore {
|
||||
'-hls_list_size 0 -hls_key_info_file ${keyinfo.path} '
|
||||
'$prefix/output.m3u8',
|
||||
);
|
||||
} // case 4, make it compatible
|
||||
else {
|
||||
session = await FFmpegKit.execute(
|
||||
'-i "${file.path}" '
|
||||
'-metadata:s:v:0 rotate=0 '
|
||||
'-vf "scale=-2:720,fps=30,format=yuv420p10le,zscale=transfer=linear,tonemap=tonemap=hable:desat=0:peak=10,zscale=transfer=bt709:matrix=bt709:primaries=bt709,format=yuv420p" '
|
||||
'-color_primaries bt709 -color_trc bt709 -colorspace bt709 '
|
||||
'-x264-params "colorprim=bt709:transfer=bt709:colormatrix=bt709" '
|
||||
'-c:v libx264 -b:v 2000k -crf 23 -preset medium '
|
||||
'-c:a aac -b:a 128k -f hls -hls_time 2 -hls_flags single_file '
|
||||
'-hls_list_size 0 -hls_key_info_file ${keyinfo.path} '
|
||||
'$prefix/output.m3u8',
|
||||
);
|
||||
}
|
||||
|
||||
session ??= await FFmpegKit.execute(
|
||||
'-i "${file.path}" '
|
||||
'-metadata:s:v:0 rotate=0 '
|
||||
'-vf "scale=-2:720,fps=30,format=yuv420p10le,zscale=transfer=linear,tonemap=tonemap=hable:desat=0:peak=10,zscale=transfer=bt709:matrix=bt709:primaries=bt709,format=yuv420p" '
|
||||
'-color_primaries bt709 -color_trc bt709 -colorspace bt709 '
|
||||
'-x264-params "colorprim=bt709:transfer=bt709:colormatrix=bt709" '
|
||||
'-c:v libx264 -b:v 2000k -crf 23 -preset medium '
|
||||
'-c:a aac -b:a 128k -f hls -hls_time 2 -hls_flags single_file '
|
||||
'-hls_list_size 0 -hls_key_info_file ${keyinfo.path} '
|
||||
'$prefix/output.m3u8',
|
||||
);
|
||||
|
||||
final returnCode = await session.getReturnCode();
|
||||
|
||||
String? error;
|
||||
@ -264,14 +284,15 @@ class PreviewVideoStore {
|
||||
Bus.instance.fire(PreviewUpdatedEvent(_items));
|
||||
|
||||
_logger.info('Playlist Generated ${enteFile.displayName}');
|
||||
|
||||
final playlistFile = File("$prefix/output.m3u8");
|
||||
final previewFile = File("$prefix/output.ts");
|
||||
final result = await _uploadPreviewVideo(enteFile, previewFile);
|
||||
|
||||
final String objectID = result.$1;
|
||||
final objectSize = result.$2;
|
||||
|
||||
// Logic to fetch width & height of preview
|
||||
//-allowed_extensions ALL -i "https://example.com/stream.m3u8" -frames:v 1 -c copy frame.ts
|
||||
// Fetch resolution of generated stream by decrypting a single frame
|
||||
final FFmpegSession session2 = await FFmpegKit.execute(
|
||||
'-allowed_extensions ALL -i "$prefix/output.m3u8" -frames:v 1 -c copy "$prefix/frame.ts"',
|
||||
);
|
||||
@ -286,8 +307,8 @@ class PreviewVideoStore {
|
||||
width = props2?.width;
|
||||
height = props2?.height;
|
||||
}
|
||||
} catch (_) {
|
||||
_logger.warning("Failed to get width and height", _);
|
||||
} catch (err, sT) {
|
||||
_logger.warning("Failed to fetch resolution of stream", err, sT);
|
||||
}
|
||||
|
||||
await _reportVideoPreview(
|
||||
@ -302,7 +323,7 @@ class PreviewVideoStore {
|
||||
_logger.info("Video preview uploaded for $enteFile");
|
||||
} catch (err, sT) {
|
||||
error = "Failed to upload video preview\nError: $err";
|
||||
_logger.shout("Video preview uploaded for $enteFile", err, sT);
|
||||
_logger.shout("Something went wrong with preview upload", err, sT);
|
||||
}
|
||||
} else if (ReturnCode.isCancel(returnCode)) {
|
||||
_logger.warning("FFmpeg command cancelled");
|
||||
@ -313,14 +334,13 @@ class PreviewVideoStore {
|
||||
"FFmpeg command failed with return code $returnCode",
|
||||
output ?? "Error not found",
|
||||
);
|
||||
if (kDebugMode) {
|
||||
_logger.severe(output);
|
||||
}
|
||||
error = "Failed to generate video preview\nError: $output";
|
||||
}
|
||||
|
||||
if (error == null) {
|
||||
// update previewIds
|
||||
FileDataService.instance.syncFDStatus().ignore();
|
||||
|
||||
_items[enteFile.uploadedFileID!] = PreviewItem(
|
||||
status: PreviewItemStatus.uploaded,
|
||||
file: enteFile,
|
||||
@ -328,37 +348,49 @@ class PreviewVideoStore {
|
||||
collectionID: enteFile.collectionID ?? 0,
|
||||
);
|
||||
} else {
|
||||
if (_items[enteFile.uploadedFileID!]!.retryCount < 3) {
|
||||
_items[enteFile.uploadedFileID!] = PreviewItem(
|
||||
status: PreviewItemStatus.retry,
|
||||
file: enteFile,
|
||||
retryCount: _items[enteFile.uploadedFileID!]!.retryCount + 1,
|
||||
collectionID: enteFile.collectionID ?? 0,
|
||||
);
|
||||
files.add(enteFile);
|
||||
} else {
|
||||
_items[enteFile.uploadedFileID!] = PreviewItem(
|
||||
status: PreviewItemStatus.failed,
|
||||
file: enteFile,
|
||||
retryCount: _items[enteFile.uploadedFileID!]!.retryCount,
|
||||
collectionID: enteFile.collectionID ?? 0,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
_retryFile(enteFile, error);
|
||||
}
|
||||
Bus.instance.fire(PreviewUpdatedEvent(_items));
|
||||
} finally {
|
||||
// reset uploading status if this was getting processed
|
||||
if (uploadingFileId == enteFile.uploadedFileID!) {
|
||||
uploadingFileId = -1;
|
||||
}
|
||||
if (files.isNotEmpty) {
|
||||
final file = files.first;
|
||||
files.remove(file);
|
||||
_logger.info("[chunk] Processing ${_items.length} items for streaming");
|
||||
// process next file
|
||||
if (fileQueue.isNotEmpty) {
|
||||
final file = fileQueue.first;
|
||||
fileQueue.remove(file);
|
||||
await chunkAndUploadVideo(ctx, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _removeFile(EnteFile enteFile) {
|
||||
_items.remove(enteFile.uploadedFileID!);
|
||||
Bus.instance.fire(PreviewUpdatedEvent(_items));
|
||||
}
|
||||
|
||||
void _retryFile(EnteFile enteFile, Object error) {
|
||||
if (_items[enteFile.uploadedFileID!]!.retryCount < 3) {
|
||||
_items[enteFile.uploadedFileID!] = PreviewItem(
|
||||
status: PreviewItemStatus.retry,
|
||||
file: enteFile,
|
||||
retryCount: _items[enteFile.uploadedFileID!]!.retryCount + 1,
|
||||
collectionID: enteFile.collectionID ?? 0,
|
||||
);
|
||||
fileQueue.add(enteFile);
|
||||
} else {
|
||||
_items[enteFile.uploadedFileID!] = PreviewItem(
|
||||
status: PreviewItemStatus.failed,
|
||||
file: enteFile,
|
||||
retryCount: _items[enteFile.uploadedFileID!]!.retryCount,
|
||||
collectionID: enteFile.collectionID ?? 0,
|
||||
error: error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reportVideoPreview(
|
||||
EnteFile file,
|
||||
File playlist, {
|
||||
@ -528,7 +560,7 @@ class PreviewVideoStore {
|
||||
final previewURL = response2.data["url"];
|
||||
if (objectKey != null) {
|
||||
unawaited(
|
||||
downloadAndCacheVideo(
|
||||
_downloadAndCacheVideo(
|
||||
previewURL,
|
||||
_getVideoPreviewKey(objectKey),
|
||||
),
|
||||
@ -557,7 +589,7 @@ class PreviewVideoStore {
|
||||
}
|
||||
}
|
||||
|
||||
Future downloadAndCacheVideo(String url, String key) async {
|
||||
Future _downloadAndCacheVideo(String url, String key) async {
|
||||
final file = await videoCacheManager.downloadFile(url, key: key);
|
||||
return file;
|
||||
}
|
||||
@ -579,37 +611,35 @@ class PreviewVideoStore {
|
||||
}
|
||||
}
|
||||
|
||||
Future<(FFProbeProps?, bool)> checkFileForPreviewCreation(
|
||||
Future<(FFProbeProps?, bool, File?)> _checkFileForPreviewCreation(
|
||||
EnteFile enteFile,
|
||||
) async {
|
||||
final fileSize = enteFile.fileSize;
|
||||
FFProbeProps? props;
|
||||
File? file;
|
||||
bool result = false;
|
||||
|
||||
if (fileSize != null && fileSize <= 10 * 1024 * 1024) {
|
||||
final file = await getFile(enteFile, isOrigin: true);
|
||||
if (file != null) {
|
||||
props = await getVideoPropsAsync(file);
|
||||
final videoData = List.from(props?.propData?["streams"] ?? [])
|
||||
.firstWhereOrNull((e) => e["type"] == "video");
|
||||
try {
|
||||
final isFileUnder10MB = fileSize != null && fileSize <= 10 * 1024 * 1024;
|
||||
if (isFileUnder10MB) {
|
||||
file = await getFile(enteFile, isOrigin: true);
|
||||
if (file != null) {
|
||||
props = await getVideoPropsAsync(file);
|
||||
final videoData = List.from(props?.propData?["streams"] ?? [])
|
||||
.firstWhereOrNull((e) => e["type"] == "video");
|
||||
|
||||
final codec = videoData["codec_name"]?.toString().toLowerCase();
|
||||
final codecIsH264 = codec?.contains("h264") ?? false;
|
||||
|
||||
if (codecIsH264) {
|
||||
if (_items.containsKey(enteFile.uploadedFileID!)) {
|
||||
_items.remove(enteFile.uploadedFileID!);
|
||||
Bus.instance.fire(PreviewUpdatedEvent(_items));
|
||||
}
|
||||
return (props, true);
|
||||
final codec = videoData["codec_name"]?.toString().toLowerCase();
|
||||
result = codec?.contains("h264") ?? false;
|
||||
}
|
||||
}
|
||||
} catch (e, sT) {
|
||||
_logger.warning("Failed to check props", e, sT);
|
||||
}
|
||||
return (props, false);
|
||||
return (props, result, file);
|
||||
}
|
||||
|
||||
// get all files after cutoff date and add it to queue for preview creation
|
||||
// only run when video streaming is enabled
|
||||
Future<void> putFilesForPreviewCreation() async {
|
||||
// generate stream for all files after cutoff date
|
||||
Future<void> _putFilesForPreviewCreation() async {
|
||||
if (!isVideoStreamingEnabled) return;
|
||||
|
||||
final cutoff = videoStreamingCutoff;
|
||||
@ -625,16 +655,18 @@ class PreviewVideoStore {
|
||||
final allFiles = files
|
||||
.where((file) => previewIds?[file.uploadedFileID] == null)
|
||||
.sorted((a, b) {
|
||||
// put higher duration videos last
|
||||
final first = a.duration == null || a.duration! >= 10 * 60 ? 1 : 0;
|
||||
final second = b.duration == null || b.duration! >= 10 * 60 ? 1 : 0;
|
||||
// put higher duration videos last along with remote files
|
||||
final first = (a.localID == null ? 2 : 0) +
|
||||
(a.duration == null || a.duration! >= 10 * 60 ? 1 : 0);
|
||||
final second = (b.localID == null ? 2 : 0) +
|
||||
(b.duration == null || b.duration! >= 10 * 60 ? 1 : 0);
|
||||
return first.compareTo(second);
|
||||
}).toList();
|
||||
|
||||
// set all video status to be in queue
|
||||
// set all video status to in queue
|
||||
for (final enteFile in allFiles) {
|
||||
final (_, result) = await checkFileForPreviewCreation(enteFile);
|
||||
|
||||
// elimination case for <=10 MB with H.264
|
||||
final (_, result, _) = await _checkFileForPreviewCreation(enteFile);
|
||||
if (result) {
|
||||
allFiles.remove(enteFile);
|
||||
continue;
|
||||
@ -646,14 +678,13 @@ class PreviewVideoStore {
|
||||
collectionID: enteFile.collectionID ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
Bus.instance.fire(PreviewUpdatedEvent(_items));
|
||||
|
||||
final file = allFiles.first;
|
||||
allFiles.remove(file);
|
||||
|
||||
this.files.addAll(allFiles);
|
||||
_logger.info("[init] Processing ${_items.length} items for streaming");
|
||||
|
||||
// take first file and put it for stream generation
|
||||
final file = allFiles.removeAt(0);
|
||||
fileQueue.addAll(allFiles);
|
||||
await chunkAndUploadVideo(null, file);
|
||||
}
|
||||
}
|
||||
|
@ -244,6 +244,7 @@ class _PreviewVideoWidgetState extends State<PreviewVideoWidget> {
|
||||
}
|
||||
});
|
||||
_chewieController = ChewieController(
|
||||
progressIndicatorDelay: const Duration(milliseconds: 200),
|
||||
videoPlayerController: _videoPlayerController!,
|
||||
aspectRatio: _videoPlayerController!.value.aspectRatio,
|
||||
autoPlay: widget.autoPlay!,
|
||||
@ -254,6 +255,7 @@ class _PreviewVideoWidgetState extends State<PreviewVideoWidget> {
|
||||
customControls: VideoControls(
|
||||
file: widget.file,
|
||||
onStreamChange: widget.onStreamChange,
|
||||
playbackCallback: widget.playbackCallback,
|
||||
),
|
||||
);
|
||||
return Container(
|
||||
|
@ -1,13 +1,18 @@
|
||||
// ignore_for_file: implementation_imports
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:chewie/chewie.dart';
|
||||
import "package:chewie/chewie.dart";
|
||||
import "package:chewie/src/helpers/utils.dart";
|
||||
import "package:chewie/src/notifiers/index.dart";
|
||||
import 'package:flutter/material.dart';
|
||||
import "package:photos/models/file/file.dart";
|
||||
import "package:photos/theme/colors.dart";
|
||||
import "package:photos/theme/ente_theme.dart";
|
||||
import "package:photos/ui/common/loading_widget.dart";
|
||||
import "package:photos/ui/viewer/file/preview_status_widget.dart";
|
||||
import "package:photos/utils/debouncer.dart";
|
||||
import "package:photos/ui/viewer/file/video_control/custom_progress_bar.dart";
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
class VideoControls extends StatefulWidget {
|
||||
@ -15,9 +20,11 @@ class VideoControls extends StatefulWidget {
|
||||
super.key,
|
||||
required this.file,
|
||||
required this.onStreamChange,
|
||||
required this.playbackCallback,
|
||||
});
|
||||
final EnteFile file;
|
||||
final void Function()? onStreamChange;
|
||||
final void Function(bool)? playbackCallback;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
@ -25,9 +32,10 @@ class VideoControls extends StatefulWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _VideoControlsState extends State<VideoControls> {
|
||||
VideoPlayerValue? _latestValue;
|
||||
bool _hideStuff = true;
|
||||
class _VideoControlsState extends State<VideoControls>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late PlayerNotifier notifier;
|
||||
late VideoPlayerValue _latestValue;
|
||||
Timer? _hideTimer;
|
||||
Timer? _initTimer;
|
||||
Timer? _showAfterExpandCollapseTimer;
|
||||
@ -36,34 +44,35 @@ class _VideoControlsState extends State<VideoControls> {
|
||||
Timer? _bufferingDisplayTimer;
|
||||
bool _displayBufferingIndicator = false;
|
||||
|
||||
final barHeight = 120.0;
|
||||
final barHeight = 48.0 * 1.5;
|
||||
final marginSize = 5.0;
|
||||
|
||||
late VideoPlayerController controller;
|
||||
ChewieController? chewieController;
|
||||
ChewieController? _chewieController;
|
||||
|
||||
void _bufferingTimerTimeout() {
|
||||
_displayBufferingIndicator = true;
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
// We know that _chewieController is set in didChangeDependencies
|
||||
ChewieController get chewieController => _chewieController!;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
notifier = Provider.of<PlayerNotifier>(context, listen: false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_latestValue!.hasError) {
|
||||
return chewieController!.errorBuilder != null
|
||||
? chewieController!.errorBuilder!(
|
||||
context,
|
||||
chewieController!.videoPlayerController.value.errorDescription!,
|
||||
)
|
||||
: Center(
|
||||
child: Icon(
|
||||
Icons.error,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
size: 42,
|
||||
),
|
||||
);
|
||||
if (_latestValue.hasError) {
|
||||
return chewieController.errorBuilder?.call(
|
||||
context,
|
||||
chewieController.videoPlayerController.value.errorDescription!,
|
||||
) ??
|
||||
Center(
|
||||
child: Icon(
|
||||
Icons.error,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
size: 42,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return MouseRegion(
|
||||
@ -73,43 +82,35 @@ class _VideoControlsState extends State<VideoControls> {
|
||||
child: GestureDetector(
|
||||
onTap: () => _cancelAndRestartTimer(),
|
||||
child: AbsorbPointer(
|
||||
absorbing: _hideStuff,
|
||||
absorbing: notifier.hideStuff,
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
if (_latestValue != null &&
|
||||
!_latestValue!.isPlaying &&
|
||||
_latestValue!.isBuffering ||
|
||||
_displayBufferingIndicator)
|
||||
const Align(
|
||||
alignment: Alignment.center,
|
||||
child: Center(
|
||||
child: EnteLoadingWidget(
|
||||
size: 32,
|
||||
color: fillBaseDark,
|
||||
padding: 0,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Positioned.fill(child: _buildHitArea()),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
left: false,
|
||||
right: false,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
PreviewStatusWidget(
|
||||
showControls: !_hideStuff,
|
||||
file: widget.file,
|
||||
isPreviewPlayer: true,
|
||||
onStreamChange: widget.onStreamChange,
|
||||
children: [
|
||||
if (_displayBufferingIndicator)
|
||||
_chewieController?.bufferingBuilder?.call(context) ??
|
||||
const Center(
|
||||
child: EnteLoadingWidget(
|
||||
size: 32,
|
||||
color: fillBaseDark,
|
||||
padding: 0,
|
||||
),
|
||||
_buildBottomBar(context),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildHitArea(),
|
||||
SafeArea(
|
||||
top: false,
|
||||
left: false,
|
||||
right: false,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
PreviewStatusWidget(
|
||||
showControls: !notifier.hideStuff,
|
||||
file: widget.file,
|
||||
isPreviewPlayer: true,
|
||||
onStreamChange: widget.onStreamChange,
|
||||
),
|
||||
if (!chewieController.isLive) _buildBottomBar(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -134,9 +135,9 @@ class _VideoControlsState extends State<VideoControls> {
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
final oldController = chewieController;
|
||||
chewieController = ChewieController.of(context);
|
||||
controller = chewieController!.videoPlayerController;
|
||||
final oldController = _chewieController;
|
||||
_chewieController = ChewieController.of(context);
|
||||
controller = chewieController.videoPlayerController;
|
||||
|
||||
if (oldController != chewieController) {
|
||||
_dispose();
|
||||
@ -146,35 +147,75 @@ class _VideoControlsState extends State<VideoControls> {
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
Widget _buildBottomBar(
|
||||
AnimatedOpacity _buildBottomBar(
|
||||
BuildContext context,
|
||||
) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.only(bottom: 60),
|
||||
height: 100,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _hideStuff ? 0.0 : 1.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: _SeekBarAndDuration(
|
||||
controller: controller,
|
||||
latestValue: _latestValue,
|
||||
updateDragging: (bool value) {
|
||||
setState(() {
|
||||
_dragging = value;
|
||||
});
|
||||
},
|
||||
return AnimatedOpacity(
|
||||
opacity: notifier.hideStuff ? 0.0 : 1.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Container(
|
||||
height: 40,
|
||||
margin: const EdgeInsets.only(bottom: 60),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
16,
|
||||
4,
|
||||
16,
|
||||
4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
border: Border.all(
|
||||
color: strokeFaintDark,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
formatDuration(_latestValue.position),
|
||||
style: getEnteTextTheme(
|
||||
context,
|
||||
).mini.copyWith(
|
||||
color: textBaseDark,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _buildProgressBar(),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
formatDuration(
|
||||
_latestValue.duration,
|
||||
),
|
||||
style: getEnteTextTheme(
|
||||
context,
|
||||
).mini.copyWith(
|
||||
color: textBaseDark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHitArea() {
|
||||
final bool isFinished = (_latestValue.position >= _latestValue.duration) &&
|
||||
_latestValue.duration.inSeconds > 0;
|
||||
final bool showPlayButton = true && !_dragging && !notifier.hideStuff;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (_latestValue != null) {
|
||||
if (_latestValue.isPlaying) {
|
||||
if (_displayTapped) {
|
||||
setState(() {
|
||||
_hideStuff = !_hideStuff;
|
||||
notifier.hideStuff = true;
|
||||
});
|
||||
} else {
|
||||
_cancelAndRestartTimer();
|
||||
@ -183,19 +224,32 @@ class _VideoControlsState extends State<VideoControls> {
|
||||
_playPause();
|
||||
|
||||
setState(() {
|
||||
_hideStuff = true;
|
||||
notifier.hideStuff = true;
|
||||
});
|
||||
}
|
||||
widget.playbackCallback?.call(notifier.hideStuff);
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _latestValue != null && !_hideStuff && !_dragging ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Center(
|
||||
child: _PlayPauseButton(
|
||||
_playPause,
|
||||
_latestValue!.isPlaying,
|
||||
),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
color: Colors
|
||||
.transparent, // The Gesture Detector doesn't expand to the full size of the container without this; Not sure why!
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
margin: EdgeInsets.symmetric(
|
||||
horizontal: marginSize,
|
||||
),
|
||||
child: CenterPlayButton(
|
||||
backgroundColor: Colors.black54,
|
||||
iconColor: Colors.white,
|
||||
isFinished: isFinished,
|
||||
isPlaying: controller.value.isPlaying,
|
||||
show: showPlayButton,
|
||||
onPressed: _playPause,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -206,9 +260,10 @@ class _VideoControlsState extends State<VideoControls> {
|
||||
_startHideTimer();
|
||||
|
||||
setState(() {
|
||||
_hideStuff = false;
|
||||
notifier.hideStuff = false;
|
||||
_displayTapped = true;
|
||||
});
|
||||
widget.playbackCallback?.call(notifier.hideStuff);
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
@ -216,25 +271,28 @@ class _VideoControlsState extends State<VideoControls> {
|
||||
|
||||
_updateState();
|
||||
|
||||
if ((controller.value.isPlaying) || chewieController!.autoPlay) {
|
||||
if (controller.value.isPlaying || chewieController.autoPlay) {
|
||||
_startHideTimer();
|
||||
}
|
||||
|
||||
if (chewieController!.showControlsOnInitialize) {
|
||||
if (chewieController.showControlsOnInitialize) {
|
||||
_initTimer = Timer(const Duration(milliseconds: 200), () {
|
||||
setState(() {
|
||||
_hideStuff = false;
|
||||
notifier.hideStuff = false;
|
||||
});
|
||||
widget.playbackCallback?.call(notifier.hideStuff);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _playPause() {
|
||||
final bool isFinished = _latestValue!.position >= _latestValue!.duration;
|
||||
final bool isFinished = (_latestValue.position >= _latestValue.duration) &&
|
||||
_latestValue.duration.inSeconds > 0;
|
||||
|
||||
setState(() {
|
||||
if (controller.value.isPlaying) {
|
||||
_hideStuff = false;
|
||||
notifier.hideStuff = false;
|
||||
widget.playbackCallback?.call(notifier.hideStuff);
|
||||
_hideTimer?.cancel();
|
||||
controller.pause();
|
||||
} else {
|
||||
@ -246,7 +304,7 @@ class _VideoControlsState extends State<VideoControls> {
|
||||
});
|
||||
} else {
|
||||
if (isFinished) {
|
||||
controller.seekTo(const Duration(seconds: 0));
|
||||
controller.seekTo(Duration.zero);
|
||||
}
|
||||
controller.play();
|
||||
}
|
||||
@ -255,19 +313,32 @@ class _VideoControlsState extends State<VideoControls> {
|
||||
}
|
||||
|
||||
void _startHideTimer() {
|
||||
_hideTimer = Timer(const Duration(seconds: 2), () {
|
||||
final hideControlsTimer = chewieController.hideControlsTimer.isNegative
|
||||
? ChewieController.defaultHideControlsTimer
|
||||
: chewieController.hideControlsTimer;
|
||||
_hideTimer = Timer(hideControlsTimer, () {
|
||||
setState(() {
|
||||
_hideStuff = true;
|
||||
notifier.hideStuff = true;
|
||||
widget.playbackCallback?.call(notifier.hideStuff);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _bufferingTimerTimeout() {
|
||||
_displayBufferingIndicator = true;
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _updateState() {
|
||||
if (!mounted) return;
|
||||
|
||||
// display the progress bar indicator only after the buffering delay if it has been set
|
||||
if (chewieController?.progressIndicatorDelay != null) {
|
||||
if (chewieController.progressIndicatorDelay != null) {
|
||||
if (controller.value.isBuffering) {
|
||||
_bufferingDisplayTimer ??= Timer(
|
||||
chewieController!.progressIndicatorDelay!,
|
||||
chewieController.progressIndicatorDelay!,
|
||||
_bufferingTimerTimeout,
|
||||
);
|
||||
} else {
|
||||
@ -278,239 +349,104 @@ class _VideoControlsState extends State<VideoControls> {
|
||||
} else {
|
||||
_displayBufferingIndicator = controller.value.isBuffering;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_latestValue = controller.value;
|
||||
});
|
||||
}
|
||||
|
||||
Widget _buildProgressBar() {
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
return Expanded(
|
||||
child: CustomProgressBar(
|
||||
controller,
|
||||
onDragStart: () {
|
||||
setState(() {
|
||||
_dragging = true;
|
||||
});
|
||||
|
||||
_hideTimer?.cancel();
|
||||
},
|
||||
onDragUpdate: () {
|
||||
_hideTimer?.cancel();
|
||||
},
|
||||
onDragEnd: () {
|
||||
setState(() {
|
||||
_dragging = false;
|
||||
});
|
||||
|
||||
_startHideTimer();
|
||||
},
|
||||
colors: ChewieProgressColors(
|
||||
playedColor: colorScheme.primary300,
|
||||
handleColor: backgroundElevatedLight,
|
||||
bufferedColor: backgroundElevatedLight.withOpacity(0.5),
|
||||
backgroundColor: fillMutedDark,
|
||||
),
|
||||
draggableProgressBar: chewieController.draggableProgressBar,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SeekBarAndDuration extends StatelessWidget {
|
||||
final VideoPlayerController? controller;
|
||||
final VideoPlayerValue? latestValue;
|
||||
final Function(bool) updateDragging;
|
||||
|
||||
const _SeekBarAndDuration({
|
||||
required this.controller,
|
||||
required this.latestValue,
|
||||
required this.updateDragging,
|
||||
class CenterPlayButton extends StatelessWidget {
|
||||
const CenterPlayButton({
|
||||
super.key,
|
||||
required this.backgroundColor,
|
||||
this.iconColor,
|
||||
required this.show,
|
||||
required this.isPlaying,
|
||||
required this.isFinished,
|
||||
this.onPressed,
|
||||
});
|
||||
|
||||
final Color backgroundColor;
|
||||
final Color? iconColor;
|
||||
final bool show;
|
||||
final bool isPlaying;
|
||||
final bool isFinished;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
),
|
||||
return AnimatedOpacity(
|
||||
opacity: show ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
16,
|
||||
4,
|
||||
16,
|
||||
4,
|
||||
),
|
||||
width: 54,
|
||||
height: 54,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(8),
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: strokeFaintDark,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (latestValue?.position == null)
|
||||
Text(
|
||||
"0:00",
|
||||
style: getEnteTextTheme(
|
||||
context,
|
||||
).mini.copyWith(
|
||||
color: textBaseDark,
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
_secondsToDuration(latestValue!.position.inSeconds),
|
||||
style: getEnteTextTheme(
|
||||
context,
|
||||
).mini.copyWith(
|
||||
color: textBaseDark,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _SeekBar(controller!, updateDragging),
|
||||
),
|
||||
Text(
|
||||
_secondsToDuration(
|
||||
latestValue?.duration.inSeconds ?? 0,
|
||||
),
|
||||
style: getEnteTextTheme(
|
||||
context,
|
||||
).mini.copyWith(
|
||||
color: textBaseDark,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onPressed,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return ScaleTransition(scale: animation, child: child);
|
||||
},
|
||||
switchInCurve: Curves.easeInOutQuart,
|
||||
switchOutCurve: Curves.easeInOutQuart,
|
||||
child: isPlaying
|
||||
? const Icon(
|
||||
Icons.pause,
|
||||
size: 32,
|
||||
key: ValueKey("pause"),
|
||||
color: Colors.white,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.play_arrow,
|
||||
size: 36,
|
||||
key: ValueKey("play"),
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Returns the duration in the format "h:mm:ss" or "m:ss".
|
||||
String _secondsToDuration(int totalSeconds) {
|
||||
final hours = totalSeconds ~/ 3600;
|
||||
final minutes = (totalSeconds % 3600) ~/ 60;
|
||||
final seconds = totalSeconds % 60;
|
||||
|
||||
if (hours > 0) {
|
||||
return '${hours.toString().padLeft(1, '0')}:${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
} else {
|
||||
return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _SeekBar extends StatefulWidget {
|
||||
final VideoPlayerController controller;
|
||||
final Function(bool) updateDragging;
|
||||
const _SeekBar(
|
||||
this.controller,
|
||||
this.updateDragging,
|
||||
);
|
||||
|
||||
@override
|
||||
State<_SeekBar> createState() => _SeekBarState();
|
||||
}
|
||||
|
||||
class _SeekBarState extends State<_SeekBar> {
|
||||
double _sliderValue = 0.0;
|
||||
final _debouncer = Debouncer(
|
||||
const Duration(milliseconds: 300),
|
||||
executionInterval: const Duration(milliseconds: 300),
|
||||
);
|
||||
bool _controllerWasPlaying = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.controller.addListener(updateSlider);
|
||||
}
|
||||
|
||||
void updateSlider() {
|
||||
if (widget.controller.value.isInitialized) {
|
||||
setState(() {
|
||||
_sliderValue = widget.controller.value.position.inSeconds.toDouble();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_debouncer.cancelDebounceTimer();
|
||||
widget.controller.removeListener(updateSlider);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = getEnteColorScheme(context);
|
||||
return SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 1.0,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8.0),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14.0),
|
||||
activeTrackColor: colorScheme.primary300,
|
||||
inactiveTrackColor: fillMutedDark,
|
||||
thumbColor: backgroundElevatedLight,
|
||||
overlayColor: fillMutedDark,
|
||||
),
|
||||
child: Slider(
|
||||
min: 0.0,
|
||||
max: widget.controller.value.duration.inSeconds.toDouble(),
|
||||
value: _sliderValue,
|
||||
onChangeStart: (value) async {
|
||||
widget.updateDragging(true);
|
||||
_controllerWasPlaying = widget.controller.value.isPlaying;
|
||||
if (_controllerWasPlaying) {
|
||||
await widget.controller.pause();
|
||||
}
|
||||
},
|
||||
onChanged: (value) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_sliderValue = value;
|
||||
});
|
||||
}
|
||||
|
||||
_debouncer.run(() async {
|
||||
await widget.controller.seekTo(Duration(seconds: value.toInt()));
|
||||
});
|
||||
},
|
||||
divisions: 4500,
|
||||
onChangeEnd: (value) async {
|
||||
await widget.controller.seekTo(Duration(seconds: value.toInt()));
|
||||
|
||||
if (_controllerWasPlaying) {
|
||||
await widget.controller.play();
|
||||
}
|
||||
widget.updateDragging(false);
|
||||
},
|
||||
allowedInteraction: SliderInteraction.tapAndSlide,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlayPauseButton extends StatefulWidget {
|
||||
final void Function() playPause;
|
||||
final bool isPlaying;
|
||||
const _PlayPauseButton(
|
||||
this.playPause,
|
||||
this.isPlaying,
|
||||
);
|
||||
|
||||
@override
|
||||
State<_PlayPauseButton> createState() => _PlayPauseButtonState();
|
||||
}
|
||||
|
||||
class _PlayPauseButtonState extends State<_PlayPauseButton> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 54,
|
||||
height: 54,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withOpacity(0.3),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: strokeFaintDark,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: widget.playPause,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
transitionBuilder: (Widget child, Animation<double> animation) {
|
||||
return ScaleTransition(scale: animation, child: child);
|
||||
},
|
||||
switchInCurve: Curves.easeInOutQuart,
|
||||
switchOutCurve: Curves.easeInOutQuart,
|
||||
child: widget.isPlaying
|
||||
? const Icon(
|
||||
Icons.pause,
|
||||
size: 32,
|
||||
key: ValueKey("pause"),
|
||||
color: Colors.white,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.play_arrow,
|
||||
size: 36,
|
||||
key: ValueKey("play"),
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -0,0 +1,41 @@
|
||||
// ignore_for_file: implementation_imports
|
||||
|
||||
import "package:chewie/src/chewie_progress_colors.dart";
|
||||
import "package:chewie/src/progress_bar.dart";
|
||||
import "package:flutter/material.dart";
|
||||
import "package:flutter/widgets.dart";
|
||||
import "package:video_player/video_player.dart";
|
||||
|
||||
class CustomProgressBar extends StatelessWidget {
|
||||
CustomProgressBar(
|
||||
this.controller, {
|
||||
ChewieProgressColors? colors,
|
||||
this.onDragEnd,
|
||||
this.onDragStart,
|
||||
this.onDragUpdate,
|
||||
super.key,
|
||||
this.draggableProgressBar = true,
|
||||
}) : colors = colors ?? ChewieProgressColors();
|
||||
|
||||
final VideoPlayerController controller;
|
||||
final ChewieProgressColors colors;
|
||||
final Function()? onDragStart;
|
||||
final Function()? onDragEnd;
|
||||
final Function()? onDragUpdate;
|
||||
final bool draggableProgressBar;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return VideoProgressBar(
|
||||
controller,
|
||||
barHeight: 1.5,
|
||||
handleHeight: 8,
|
||||
drawShadow: true,
|
||||
colors: colors,
|
||||
onDragEnd: onDragEnd,
|
||||
onDragStart: onDragStart,
|
||||
onDragUpdate: onDragUpdate,
|
||||
draggableProgressBar: draggableProgressBar,
|
||||
);
|
||||
}
|
||||
}
|
@ -210,10 +210,10 @@ class _VideoWidgetNativeState extends State<VideoWidgetNative>
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
_showControls.value = !_showControls.value;
|
||||
_elTooltipController.hide();
|
||||
if (widget.playbackCallback != null) {
|
||||
widget.playbackCallback!(!_showControls.value);
|
||||
}
|
||||
_elTooltipController.hide();
|
||||
},
|
||||
child: Container(
|
||||
constraints: const BoxConstraints.expand(),
|
||||
|
@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -21,10 +29,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: "7d328c4d898a61efc3cd93655a0955858e29a0aa647f0f9e02d59b3bb275e2e8"
|
||||
sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.6"
|
||||
version: "5.8.0+1"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: e485c7a39ff2b384fa1d7e09b4e25f755804de8384358049124830b04fc4f93a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1,6 +1,14 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -21,10 +29,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio
|
||||
sha256: "7d328c4d898a61efc3cd93655a0955858e29a0aa647f0f9e02d59b3bb275e2e8"
|
||||
sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.6"
|
||||
version: "5.8.0+1"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: e485c7a39ff2b384fa1d7e09b4e25f755804de8384358049124830b04fc4f93a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
ente_cast:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -38,10 +38,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio
|
||||
sha256: "7d328c4d898a61efc3cd93655a0955858e29a0aa647f0f9e02d59b3bb275e2e8"
|
||||
sha256: "253a18bbd4851fecba42f7343a1df3a9a4c1d31a2c1b37e221086b4fa8c8dbc9"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.6"
|
||||
version: "5.8.0+1"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: e485c7a39ff2b384fa1d7e09b4e25f755804de8384358049124830b04fc4f93a
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
ente_cast:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -259,11 +259,11 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: forked_video_player_plus
|
||||
resolved-ref: "2d8908efe9d7533ec76abe2e59444547c4031f28"
|
||||
ref: mybranched
|
||||
resolved-ref: "539079ac2758086ef4dfb602a5f8785bf5295fb3"
|
||||
url: "https://github.com/ente-io/chewie.git"
|
||||
source: git
|
||||
version: "1.7.1"
|
||||
version: "1.10.0"
|
||||
cli_util:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1842,18 +1842,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: "7e76fad405b3e4016cd39d08f455a4eb5199723cf594cd1b8916d47140d93017"
|
||||
sha256: "67eae327b1b0faf761964a1d2e5d323c797f3799db0e85aa232db8d9e922bc35"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.0"
|
||||
version: "8.2.1"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
sha256: "9bc8ba46813a4cc42c66ab781470711781940780fd8beddd0c3da62506d3a6c6"
|
||||
sha256: "205ec83335c2ab9107bbba3f8997f9356d72ca3c715d2f038fc773d0366b4c76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
version: "3.1.0"
|
||||
page_transition:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -2922,18 +2922,18 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: wakelock_plus
|
||||
sha256: f268ca2116db22e57577fb99d52515a24bdc1d570f12ac18bb762361d43b043d
|
||||
sha256: "36c88af0b930121941345306d259ec4cc4ecca3b151c02e3a9e71aede83c615e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.4"
|
||||
version: "1.2.10"
|
||||
wakelock_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wakelock_plus_platform_interface
|
||||
sha256: "422d1cdbb448079a8a62a5a770b69baa489f8f7ca21aef47800c726d404f9d16"
|
||||
sha256: "70e780bc99796e1db82fe764b1e7dcb89a86f1e5b3afb1db354de50f2e41eb7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
version: "1.2.2"
|
||||
wallpaper_manager_flutter:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -12,7 +12,7 @@ description: ente photos application
|
||||
# Read more about iOS versioning at
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
|
||||
version: 0.9.99+999
|
||||
version: 0.9.99+1002
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
@ -31,7 +31,7 @@ dependencies:
|
||||
chewie:
|
||||
git:
|
||||
url: https://github.com/ente-io/chewie.git
|
||||
ref: forked_video_player_plus
|
||||
ref: mybranched
|
||||
collection: # dart
|
||||
computer:
|
||||
git: "https://github.com/ente-io/computer.git"
|
||||
@ -144,7 +144,7 @@ dependencies:
|
||||
url: https://github.com/ente-io/onnxruntime.git
|
||||
ref: ios_only
|
||||
open_mail_app: ^0.4.5
|
||||
package_info_plus: ^4.1.0
|
||||
package_info_plus: ^8.2.1
|
||||
page_transition: ^2.0.2
|
||||
panorama:
|
||||
git:
|
||||
|
Loading…
x
Reference in New Issue
Block a user