diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 42b4f6f..c0934c6 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1220,5 +1220,9 @@ "hasLikedDirector": "Has liked director", "hasLikedActor": "Has liked actor", "latest": "Latest", - "recommended": "Recommended" + "recommended": "Recommended", + "playbackType": "Playback type", + "playbackTypeDirect": "Direct", + "playbackTypeTranscode": "Transcode", + "playbackTypeOffline": "Offline" } \ No newline at end of file diff --git a/lib/models/items/episode_model.dart b/lib/models/items/episode_model.dart index f21a7a3..cb6d7b0 100644 --- a/lib/models/items/episode_model.dart +++ b/lib/models/items/episode_model.dart @@ -202,14 +202,28 @@ extension EpisodeListExtensions on List { final episodes = where((e) => e.season > 0 && e.status == EpisodeStatus.available).toList(); if (episodes.isEmpty) return null; - final lastWatchedIndex = [ - episodes.lastIndexWhere((e) => e.userData.progress != 0), - episodes.lastIndexWhere((e) => e.userData.played), - ].reduce((a, b) => a > b ? a : b); + final lastProgressIndex = episodes.lastIndexWhere((e) => e.userData.progress != 0); + final lastPlayedIndex = episodes.lastIndexWhere((e) => e.userData.played); + final lastWatchedIndex = [lastProgressIndex, lastPlayedIndex].reduce((a, b) => a > b ? a : b); - if (lastWatchedIndex >= 0 && lastWatchedIndex + 1 < episodes.length) { - final next = episodes.sublist(lastWatchedIndex + 1).firstWhereOrNull((e) => e.status == EpisodeStatus.available); - if (next != null) return next; + if (lastWatchedIndex >= 0) { + final current = episodes[lastWatchedIndex]; + if (!current.userData.played && current.userData.progress != 0) { + return current; + } + + final nextIndex = lastWatchedIndex + 1; + if (nextIndex < episodes.length) { + final next = episodes[nextIndex]; + if (!next.userData.played && next.userData.progress != 0) { + return next; + } + + final nextUnplayed = episodes.sublist(nextIndex).firstWhereOrNull( + (e) => e.status == EpisodeStatus.available && !e.userData.played, + ); + if (nextUnplayed != null) return nextUnplayed; + } } return episodes.firstOrNull; diff --git a/lib/models/playback/playback_model.dart b/lib/models/playback/playback_model.dart index 4832e09..e035e88 100644 --- a/lib/models/playback/playback_model.dart +++ b/lib/models/playback/playback_model.dart @@ -1,5 +1,7 @@ import 'dart:developer'; +import 'package:flutter/material.dart'; + import 'package:chopper/chopper.dart'; import 'package:collection/collection.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -16,6 +18,7 @@ import 'package:fladder/models/items/series_model.dart'; import 'package:fladder/models/items/trick_play_model.dart'; import 'package:fladder/models/playback/direct_playback_model.dart'; import 'package:fladder/models/playback/offline_playback_model.dart'; +import 'package:fladder/models/playback/playback_options_dialogue.dart'; import 'package:fladder/models/playback/transcode_playback_model.dart'; import 'package:fladder/models/syncing/sync_item.dart'; import 'package:fladder/models/video_stream_model.dart'; @@ -49,10 +52,10 @@ extension PlaybackModelExtension on PlaybackModel? { AudioStreamModel? get defaultAudioStream => this?.audioStreams?.firstWhereOrNull((element) => element.index == this?.mediaStreams?.defaultAudioStreamIndex); - String? get label => switch (this) { - DirectPlaybackModel _ => PlaybackType.directStream.name, - TranscodePlaybackModel _ => PlaybackType.transcode.name, - OfflinePlaybackModel _ => PlaybackType.offline.name, + String? label(BuildContext context) => switch (this) { + DirectPlaybackModel _ => PlaybackType.directStream.name(context), + TranscodePlaybackModel _ => PlaybackType.transcode.name(context), + OfflinePlaybackModel _ => PlaybackType.offline.name(context), _ => null }; } @@ -118,12 +121,12 @@ class PlaybackModelHelper { ref.read(videoPlayerProvider).pause(); ref.read(mediaPlaybackProvider.notifier).update((state) => state.copyWith(buffering: true)); final currentModel = ref.read(playBackModel); - final newModel = (await createServerPlaybackModel( - newItem, + final newModel = (await createPlaybackModel( null, + newItem, oldModel: currentModel, )) ?? - await createOfflinePlaybackModel( + await _createOfflinePlaybackModel( newItem, ref.read(syncProvider.notifier).getSyncedItem(newItem), oldModel: currentModel, @@ -133,7 +136,7 @@ class PlaybackModelHelper { return newModel; } - Future createOfflinePlaybackModel( + Future _createOfflinePlaybackModel( ItemBaseModel item, SyncedItem? syncedItem, { PlaybackModel? oldModel, @@ -157,46 +160,92 @@ class PlaybackModelHelper { ); } - Future getNextUpEpisode(String itemId) async { - final response = await api.showsNextUpGet(parentId: itemId, fields: [ItemFields.overview]); - final episode = response.body?.items?.firstOrNull; - if (episode == null) { - return null; + Future createPlaybackModel( + BuildContext? context, + ItemBaseModel? item, { + PlaybackModel? oldModel, + List? libraryQueue, + bool showPlaybackOptions = false, + Duration? startPosition, + }) async { + if (item == null) return null; + final userId = ref.read(userProvider)?.id; + if (userId?.isEmpty == true) return null; + + final queue = oldModel?.queue ?? libraryQueue ?? await collectQueue(item); + + final firstItemToPlay = switch (item) { + SeriesModel _ || SeasonModel _ => (queue.whereType().toList().nextUp), + _ => item, + }; + + if (firstItemToPlay == null) return null; + + final fullItem = (await api.usersUserIdItemsItemIdGet(itemId: firstItemToPlay.id)).body; + + if (fullItem == null) return null; + + SyncedItem? syncedItem = ref.read(syncProvider.notifier).getSyncedItem(fullItem); + + final firstItemIsSynced = syncedItem != null && syncedItem.status == SyncStatus.complete; + + final options = { + PlaybackType.directStream, + PlaybackType.transcode, + if (firstItemIsSynced) PlaybackType.offline, + }; + + if ((showPlaybackOptions || firstItemIsSynced) && context != null) { + final playbackType = await showPlaybackTypeSelection( + context: context, + options: options, + ); + + if (!context.mounted) return null; + + return switch (playbackType) { + PlaybackType.directStream || PlaybackType.transcode => await _createServerPlaybackModel( + fullItem, + playbackType, + oldModel: oldModel, + libraryQueue: queue, + startPosition: startPosition, + ), + PlaybackType.offline => await _createOfflinePlaybackModel(fullItem, syncedItem), + null => null + }; } else { - return EpisodeModel.fromBaseDto(episode, ref); + return (await _createServerPlaybackModel( + fullItem, + PlaybackType.directStream, + startPosition: startPosition, + oldModel: oldModel, + libraryQueue: queue, + )) ?? + await _createOfflinePlaybackModel(fullItem, syncedItem); } } - Future createServerPlaybackModel( - ItemBaseModel? item, + Future _createServerPlaybackModel( + ItemBaseModel item, PlaybackType? type, { PlaybackModel? oldModel, - List? libraryQueue, + required List libraryQueue, Duration? startPosition, }) async { try { - if (item == null) return null; final userId = ref.read(userProvider)?.id; if (userId?.isEmpty == true) return null; - final queue = oldModel?.queue ?? libraryQueue ?? await collectQueue(item); - - final firstItemToPlay = switch (item) { - SeriesModel _ || SeasonModel _ => (await getNextUpEpisode(item.id) ?? queue.first), - _ => item, - }; - - final fullItem = await api.usersUserIdItemsItemIdGet(itemId: firstItemToPlay.id); - Map qualityOptions = getVideoQualityOptions( VideoQualitySettings( maxBitRate: ref.read(videoPlayerSettingsProvider.select((value) => value.maxHomeBitrate)), - videoBitRate: firstItemToPlay.streamModel?.videoStreams.firstOrNull?.bitRate ?? 0, - videoCodec: firstItemToPlay.streamModel?.videoStreams.firstOrNull?.codec, + videoBitRate: item.streamModel?.videoStreams.firstOrNull?.bitRate ?? 0, + videoCodec: item.streamModel?.videoStreams.firstOrNull?.codec, ), ); - final streamModel = firstItemToPlay.streamModel; + final streamModel = item.streamModel; final audioStreamIndex = selectAudioStream( ref.read(userProvider.select((value) => value?.userConfiguration?.rememberAudioSelections ?? true)), oldModel?.mediaStreams?.currentAudioStream, @@ -209,7 +258,7 @@ class PlaybackModelHelper { streamModel?.defaultSubStreamIndex); final Response response = await api.itemsItemIdPlaybackInfoPost( - itemId: firstItemToPlay.id, + itemId: item.id, body: PlaybackInfoDto( startTimeTicks: startPosition?.toRuntimeTicks, audioStreamIndex: audioStreamIndex, @@ -238,9 +287,9 @@ class PlaybackModelHelper { defaultSubStreamIndex: subStreamIndex, ); - final mediaSegments = await api.mediaSegmentsGet(id: firstItemToPlay.id); - final trickPlay = (await api.getTrickPlay(item: fullItem.body, ref: ref))?.body; - final chapters = fullItem.body?.overview.chapters ?? []; + final mediaSegments = await api.mediaSegmentsGet(id: item.id); + final trickPlay = (await api.getTrickPlay(item: item, ref: ref))?.body; + final chapters = item.overview.chapters ?? []; final mediaPath = isValidVideoUrl(mediaSource.path ?? ""); @@ -263,8 +312,8 @@ class PlaybackModelHelper { final playbackUrl = joinAll([ref.read(userProvider)!.server, "Videos", mediaSource.id!, "stream?$params"]); return DirectPlaybackModel( - item: fullItem.body ?? item, - queue: queue, + item: item, + queue: libraryQueue, mediaSegments: mediaSegments?.body, chapters: chapters, playbackInfo: playbackInfo, @@ -275,8 +324,8 @@ class PlaybackModelHelper { ); } else if ((mediaSource.supportsTranscoding ?? false) && mediaSource.transcodingUrl != null) { return TranscodePlaybackModel( - item: fullItem.body ?? item, - queue: queue, + item: item, + queue: libraryQueue, mediaSegments: mediaSegments?.body, chapters: chapters, trickPlay: trickPlay, diff --git a/lib/models/playback/playback_options_dialogue.dart b/lib/models/playback/playback_options_dialogue.dart new file mode 100644 index 0000000..7be861e --- /dev/null +++ b/lib/models/playback/playback_options_dialogue.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; + +import 'package:fladder/models/video_stream_model.dart'; +import 'package:fladder/screens/shared/adaptive_dialog.dart'; +import 'package:fladder/util/localization_helper.dart'; + +Future showPlaybackTypeSelection({ + required BuildContext context, + required Set options, +}) async { + PlaybackType? playbackType; + + await showDialogAdaptive( + context: context, + builder: (context) { + return PlaybackDialogue( + options: options, + onClose: (type) { + playbackType = type; + Navigator.of(context).pop(); + }, + ); + }, + ); + return playbackType; +} + +class PlaybackDialogue extends StatelessWidget { + final Set options; + final Function(PlaybackType type) onClose; + const PlaybackDialogue({required this.options, required this.onClose, super.key}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16).add(const EdgeInsets.only(top: 16, bottom: 8)), + child: Text( + context.localized.playbackType, + style: Theme.of(context).textTheme.titleLarge, + ), + ), + const Divider(), + ...options.map((type) => ListTile( + title: Text(type.name(context)), + leading: Icon(type.icon), + onTap: () { + onClose(type); + }, + )) + ], + ); + } +} diff --git a/lib/models/video_stream_model.dart b/lib/models/video_stream_model.dart index 4083802..1abb49d 100644 --- a/lib/models/video_stream_model.dart +++ b/lib/models/video_stream_model.dart @@ -2,8 +2,8 @@ import 'package:flutter/material.dart'; // ignore_for_file: public_member_api_docs, sort_constructors_first import 'package:collection/collection.dart'; -import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:iconsax_plus/iconsax_plus.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/models/items/chapters_model.dart'; @@ -12,6 +12,7 @@ import 'package:fladder/models/items/media_segments_model.dart'; import 'package:fladder/models/items/media_streams_model.dart'; import 'package:fladder/models/syncing/sync_item.dart'; import 'package:fladder/providers/user_provider.dart'; +import 'package:fladder/util/localization_helper.dart'; enum PlaybackType { directStream, @@ -24,16 +25,11 @@ enum PlaybackType { PlaybackType.transcode => IconsaxPlusLinear.convert, }; - String get name { - switch (this) { - case PlaybackType.directStream: - return "Direct"; - case PlaybackType.offline: - return "Offline"; - case PlaybackType.transcode: - return "Transcoding"; - } - } + String name(BuildContext context) => switch (this) { + PlaybackType.directStream => context.localized.playbackTypeDirect, + PlaybackType.offline => context.localized.playbackTypeOffline, + PlaybackType.transcode => context.localized.playbackTypeTranscode + }; } class VideoPlayback { diff --git a/lib/screens/shared/adaptive_dialog.dart b/lib/screens/shared/adaptive_dialog.dart index 1e2439f..49fdef8 100644 --- a/lib/screens/shared/adaptive_dialog.dart +++ b/lib/screens/shared/adaptive_dialog.dart @@ -16,8 +16,11 @@ Future showDialogAdaptive( return showDialog( context: context, useSafeArea: false, - builder: (context) => Dialog.fullscreen( - child: builder(context), + builder: (context) => Padding( + padding: MediaQuery.paddingOf(context), + child: Dialog.fullscreen( + child: builder(context), + ), ), ); } diff --git a/lib/screens/video_player/components/video_playback_information.dart b/lib/screens/video_player/components/video_playback_information.dart index f6b9241..049ef8e 100644 --- a/lib/screens/video_player/components/video_playback_information.dart +++ b/lib/screens/video_player/components/video_playback_information.dart @@ -95,7 +95,7 @@ class _VideoPlaybackInformation extends ConsumerWidget { children: [ Row( mainAxisSize: MainAxisSize.min, - children: [const Text('type: '), Text(playbackModel.label ?? "")], + children: [const Text('type: '), Text(playbackModel.label(context) ?? "")], ), if (sessionInfo.transCodeInfo != null) ...[ Text("Transcoding", style: Theme.of(context).textTheme.titleMedium), diff --git a/lib/screens/video_player/video_player_controls.dart b/lib/screens/video_player/video_player_controls.dart index 52a6f41..4216b0f 100644 --- a/lib/screens/video_player/video_player_controls.dart +++ b/lib/screens/video_player/video_player_controls.dart @@ -461,14 +461,14 @@ class _DesktopControlsState extends ConsumerState { ), ), const Spacer(), - if (playbackModel.label != null) + if (playbackModel != null) InkWell( onTap: () => showVideoPlaybackInformation(context), child: Card( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), child: Text( - playbackModel?.label ?? "", + playbackModel.label(context) ?? "", ), ), ), diff --git a/lib/util/item_base_model/play_item_helpers.dart b/lib/util/item_base_model/play_item_helpers.dart index 583a697..b00c264 100644 --- a/lib/util/item_base_model/play_item_helpers.dart +++ b/lib/util/item_base_model/play_item_helpers.dart @@ -9,16 +9,12 @@ import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/photos_model.dart'; import 'package:fladder/models/media_playback_model.dart'; import 'package:fladder/models/playback/playback_model.dart'; -import 'package:fladder/models/syncing/sync_item.dart'; -import 'package:fladder/models/video_stream_model.dart'; import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/book_viewer_provider.dart'; import 'package:fladder/providers/items/book_details_provider.dart'; -import 'package:fladder/providers/sync_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; import 'package:fladder/screens/book_viewer/book_viewer_screen.dart'; import 'package:fladder/screens/photo_viewer/photo_viewer_screen.dart'; -import 'package:fladder/screens/shared/adaptive_dialog.dart'; import 'package:fladder/screens/shared/fladder_snackbar.dart'; import 'package:fladder/screens/video_player/video_player.dart'; import 'package:fladder/util/adaptive_layout/adaptive_layout.dart'; @@ -210,33 +206,12 @@ extension ItemBaseModelExtensions on ItemBaseModel? { _showLoadingIndicator(context); - SyncedItem? syncedItem = ref.read(syncProvider.notifier).getSyncedItem(this); - - final options = { - PlaybackType.directStream, - PlaybackType.transcode, - if (syncedItem != null && syncedItem.status == SyncStatus.complete) PlaybackType.offline, - }; - - PlaybackModel? model; - - if (showPlaybackOption) { - final playbackType = await _showPlaybackTypeSelection( - context: context, - options: options, - ); - - model = switch (playbackType) { - PlaybackType.directStream || PlaybackType.transcode => await ref - .read(playbackModelHelper) - .createServerPlaybackModel(itemModel, playbackType, startPosition: startPosition), - PlaybackType.offline => await ref.read(playbackModelHelper).createOfflinePlaybackModel(itemModel, syncedItem), - null => null - }; - } else { - model = (await ref.read(playbackModelHelper).createServerPlaybackModel(itemModel, PlaybackType.directStream)) ?? - await ref.read(playbackModelHelper).createOfflinePlaybackModel(itemModel, syncedItem); - } + PlaybackModel? model = await ref.read(playbackModelHelper).createPlaybackModel( + context, + itemModel, + showPlaybackOptions: showPlaybackOption, + startPosition: startPosition, + ); await _playVideo(context, startPosition: startPosition, current: model, ref: ref); } @@ -267,69 +242,17 @@ extension ItemBaseModelsBooleans on List { expandedList.shuffle(); } - PlaybackModel? model = await ref.read(playbackModelHelper).createServerPlaybackModel( + PlaybackModel? model = await ref.read(playbackModelHelper).createPlaybackModel( + context, expandedList.firstOrNull, - PlaybackType.directStream, libraryQueue: expandedList, ); if (context.mounted) { await _playVideo(context, ref: ref, queue: expandedList, current: model); if (context.mounted) { - RefreshState.of(context).refresh(); + RefreshState.maybeOf(context)?.refresh(); } } } } - -Future _showPlaybackTypeSelection({ - required BuildContext context, - required Set options, -}) async { - PlaybackType? playbackType; - - await showDialogAdaptive( - context: context, - builder: (context) { - return PlaybackDialogue( - options: options, - onClose: (type) { - playbackType = type; - Navigator.of(context).pop(); - }, - ); - }, - ); - return playbackType; -} - -class PlaybackDialogue extends StatelessWidget { - final Set options; - final Function(PlaybackType type) onClose; - const PlaybackDialogue({required this.options, required this.onClose, super.key}); - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16).add(const EdgeInsets.only(top: 16, bottom: 8)), - child: Text( - "Playback type", - style: Theme.of(context).textTheme.titleLarge, - ), - ), - const Divider(), - ...options.map((type) => ListTile( - title: Text(type.name), - leading: Icon(type.icon), - onTap: () { - onClose(type); - }, - )) - ], - ); - } -}