From f0414439f3fce9ade9b7857ac2347f46c6d9b836 Mon Sep 17 00:00:00 2001 From: PartyDonut <42371342+PartyDonut@users.noreply.github.com> Date: Sun, 23 Feb 2025 15:53:17 +0100 Subject: [PATCH] feature: Version selection (#235) Co-authored-by: PartyDonut --- lib/l10n/app_en.arb | 3 +- lib/models/items/episode_model.dart | 3 +- lib/models/items/item_stream_model.dart | 9 +- lib/models/items/media_streams_model.dart | 115 ++++++++++++------ lib/models/items/movie_model.dart | 3 +- lib/models/playback/playback_model.dart | 10 +- lib/models/video_stream_model.dart | 3 +- .../items/episode_details_provider.dart | 8 ++ .../items/movies_details_provider.dart | 7 +- .../components/media_stream_information.dart | 28 ++++- .../episode_detail_screen.dart | 3 + .../details_screens/movie_detail_screen.dart | 7 +- .../media/components/next_up_episode.dart | 3 + 13 files changed, 142 insertions(+), 60 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index cd42be8..8133a73 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1178,5 +1178,6 @@ "homeStreamingQualityDesc": "Maximum streaming quality when connected to home network", "qualityOptionsTitle": "Quality options", "qualityOptionsOriginal": "Original", - "qualityOptionsAuto": "Auto" + "qualityOptionsAuto": "Auto", + "version": "Version" } \ No newline at end of file diff --git a/lib/models/items/episode_model.dart b/lib/models/items/episode_model.dart index 89c61c3..7f94a18 100644 --- a/lib/models/items/episode_model.dart +++ b/lib/models/items/episode_model.dart @@ -173,8 +173,7 @@ class EpisodeModel extends ItemStreamModel with EpisodeModelMappable { parentImages: ImagesData.fromBaseItemParent(item, ref), canDelete: item.canDelete, canDownload: item.canDownload, - mediaStreams: - MediaStreamsModel.fromMediaStreamsList(item.mediaSources?.firstOrNull, item.mediaStreams ?? [], ref), + mediaStreams: MediaStreamsModel.fromMediaStreamsList(item.mediaSources, ref), jellyType: item.type, ); diff --git a/lib/models/items/item_stream_model.dart b/lib/models/items/item_stream_model.dart index fcc84a8..4f23416 100644 --- a/lib/models/items/item_stream_model.dart +++ b/lib/models/items/item_stream_model.dart @@ -1,3 +1,6 @@ +import 'package:dart_mappable/dart_mappable.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart'; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart' as dto; import 'package:fladder/models/item_base_model.dart'; @@ -7,9 +10,6 @@ import 'package:fladder/models/items/item_shared_models.dart'; import 'package:fladder/models/items/media_streams_model.dart'; import 'package:fladder/models/items/movie_model.dart'; import 'package:fladder/models/items/overview_model.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - -import 'package:dart_mappable/dart_mappable.dart'; part 'item_stream_model.mapper.dart'; @@ -55,8 +55,7 @@ class ItemStreamModel extends ItemBaseModel with ItemStreamModelMappable { parentImages: ImagesData.fromBaseItemParent(item, ref), canDelete: item.canDelete, canDownload: item.canDownload, - mediaStreams: - MediaStreamsModel.fromMediaStreamsList(item.mediaSources?.firstOrNull, item.mediaStreams ?? [], ref), + mediaStreams: MediaStreamsModel.fromMediaStreamsList(item.mediaSources, ref), ); } diff --git a/lib/models/items/media_streams_model.dart b/lib/models/items/media_streams_model.dart index 7d8c885..711fd62 100644 --- a/lib/models/items/media_streams_model.dart +++ b/lib/models/items/media_streams_model.dart @@ -12,19 +12,23 @@ import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/video_properties.dart'; class MediaStreamsModel { + final int? versionStreamIndex; final int? defaultAudioStreamIndex; final int? defaultSubStreamIndex; - final List videoStreams; - final List audioStreams; - final List subStreams; + final List versionStreams; MediaStreamsModel({ + this.versionStreamIndex, this.defaultAudioStreamIndex, this.defaultSubStreamIndex, - required this.videoStreams, - required this.audioStreams, - required this.subStreams, + required this.versionStreams, }); + VersionStreamModel? get currentVersionStream => versionStreams.elementAtOrNull(versionStreamIndex ?? 0); + + List get videoStreams => currentVersionStream?.videoStreams ?? []; + List get audioStreams => currentVersionStream?.audioStreams ?? []; + List get subStreams => currentVersionStream?.subStreams ?? []; + bool get isNull { return defaultAudioStreamIndex == null || defaultSubStreamIndex == null || @@ -92,44 +96,61 @@ class MediaStreamsModel { } static MediaStreamsModel fromMediaStreamsList( - dto.MediaSourceInfo? mediaSource, List streams, Ref ref) { + List? mediaSource, + Ref ref, + ) { return MediaStreamsModel( - defaultAudioStreamIndex: mediaSource?.defaultAudioStreamIndex, - defaultSubStreamIndex: mediaSource?.defaultSubtitleStreamIndex, - videoStreams: streams - .where((element) => element.type == dto.MediaStreamType.video) - .map( - (e) => VideoStreamModel.fromMediaStream(e), - ) - .sortByExternal(), - audioStreams: streams - .where((element) => element.type == dto.MediaStreamType.audio) - .map( - (e) => AudioStreamModel.fromMediaStream(e), - ) - .sortByExternal(), - subStreams: streams - .where((element) => element.type == dto.MediaStreamType.subtitle) - .map( - (sub) => SubStreamModel.fromMediaStream(sub, ref), - ) - .sortByExternal(), - ); + defaultAudioStreamIndex: mediaSource?.firstOrNull?.defaultAudioStreamIndex, + defaultSubStreamIndex: mediaSource?.firstOrNull?.defaultSubtitleStreamIndex, + versionStreams: mediaSource?.mapIndexed( + (index, element) { + final streams = element.mediaStreams ?? []; + return VersionStreamModel( + name: element.name ?? "", + index: index, + id: element.id, + defaultAudioStreamIndex: element.defaultAudioStreamIndex, + defaultSubStreamIndex: element.defaultSubtitleStreamIndex, + videoStreams: streams + .where((element) => element.type == dto.MediaStreamType.video) + .map( + (e) => VideoStreamModel.fromMediaStream(e), + ) + .sortByExternal(), + audioStreams: streams + .where((element) => element.type == dto.MediaStreamType.audio) + .map( + (e) => AudioStreamModel.fromMediaStream(e), + ) + .sortByExternal(), + subStreams: streams + .where((element) => element.type == dto.MediaStreamType.subtitle) + .map( + (sub) => SubStreamModel.fromMediaStream(sub, ref), + ) + .sortByExternal()); + }, + ).toList() ?? + []); } MediaStreamsModel copyWith({ + int? versionStreamIndex, int? defaultAudioStreamIndex, int? defaultSubStreamIndex, - List? videoStreams, - List? audioStreams, - List? subStreams, + List? versionStreams, }) { + final streamIndexChanged = versionStreamIndex != this.versionStreamIndex && versionStreamIndex != null; + final currentVersionStreams = versionStreams ?? this.versionStreams; return MediaStreamsModel( - defaultAudioStreamIndex: defaultAudioStreamIndex ?? this.defaultAudioStreamIndex, - defaultSubStreamIndex: defaultSubStreamIndex ?? this.defaultSubStreamIndex, - videoStreams: videoStreams ?? this.videoStreams, - audioStreams: audioStreams ?? this.audioStreams, - subStreams: subStreams ?? this.subStreams, + versionStreamIndex: versionStreamIndex ?? this.versionStreamIndex, + defaultAudioStreamIndex: streamIndexChanged + ? currentVersionStreams.elementAtOrNull(versionStreamIndex)?.defaultAudioStreamIndex + : defaultAudioStreamIndex ?? this.defaultAudioStreamIndex, + defaultSubStreamIndex: streamIndexChanged + ? currentVersionStreams.elementAtOrNull(versionStreamIndex)?.defaultSubStreamIndex + : defaultSubStreamIndex ?? this.defaultSubStreamIndex, + versionStreams: versionStreams ?? this.versionStreams, ); } @@ -154,6 +175,28 @@ class StreamModel { }); } +class VersionStreamModel { + final String name; + final int index; + final String? id; + final int? defaultAudioStreamIndex; + final int? defaultSubStreamIndex; + final List videoStreams; + final List audioStreams; + final List subStreams; + + VersionStreamModel({ + required this.name, + required this.index, + this.id, + required this.defaultAudioStreamIndex, + required this.defaultSubStreamIndex, + required this.videoStreams, + required this.audioStreams, + required this.subStreams, + }); +} + class VideoStreamModel extends StreamModel { final int width; final int height; diff --git a/lib/models/items/movie_model.dart b/lib/models/items/movie_model.dart index 32b92cb..d550b43 100644 --- a/lib/models/items/movie_model.dart +++ b/lib/models/items/movie_model.dart @@ -96,8 +96,7 @@ class MovieModel extends ItemStreamModel with MovieModelMappable { parentImages: ImagesData.fromBaseItemParent(item, ref), canDelete: item.canDelete, canDownload: item.canDownload, - mediaStreams: - MediaStreamsModel.fromMediaStreamsList(item.mediaSources?.firstOrNull, item.mediaStreams ?? [], ref), + mediaStreams: MediaStreamsModel.fromMediaStreamsList(item.mediaSources, ref), ); } } diff --git a/lib/models/playback/playback_model.dart b/lib/models/playback/playback_model.dart index 466edd9..ab53363 100644 --- a/lib/models/playback/playback_model.dart +++ b/lib/models/playback/playback_model.dart @@ -210,7 +210,7 @@ class PlaybackModelHelper { enableDirectPlay: type != PlaybackType.transcode, enableDirectStream: type != PlaybackType.transcode, maxStreamingBitrate: qualityOptions.enabledFirst.keys.firstOrNull?.bitRate, - mediaSourceId: firstItemToPlay.id, + mediaSourceId: streamModel?.currentVersionStream?.id, ), ); @@ -219,9 +219,7 @@ class PlaybackModelHelper { final mediaSource = playbackInfo.mediaSources?.first; - final mediaStreamsWithUrls = MediaStreamsModel.fromMediaStreamsList( - playbackInfo.mediaSources?.firstOrNull, playbackInfo.mediaSources?.firstOrNull?.mediaStreams ?? [], ref) - .copyWith( + final mediaStreamsWithUrls = MediaStreamsModel.fromMediaStreamsList(playbackInfo.mediaSources, ref).copyWith( defaultAudioStreamIndex: streamModel?.defaultAudioStreamIndex, defaultSubStreamIndex: streamModel?.defaultSubStreamIndex, ); @@ -352,9 +350,7 @@ class PlaybackModelHelper { final mediaSource = playbackInfo.mediaSources?.first; - final mediaStreamsWithUrls = MediaStreamsModel.fromMediaStreamsList( - playbackInfo.mediaSources?.firstOrNull, playbackInfo.mediaSources?.firstOrNull?.mediaStreams ?? [], ref) - .copyWith( + final mediaStreamsWithUrls = MediaStreamsModel.fromMediaStreamsList(playbackInfo.mediaSources, ref).copyWith( defaultAudioStreamIndex: audioIndex, defaultSubStreamIndex: subIndex, ); diff --git a/lib/models/video_stream_model.dart b/lib/models/video_stream_model.dart index 2e0a1e4..0a8f966 100644 --- a/lib/models/video_stream_model.dart +++ b/lib/models/video_stream_model.dart @@ -199,8 +199,7 @@ class VideoStream { playbackUrl: playbackUrl, playbackType: playType, playSessionId: info.playSessionId ?? "", - mediaStreamsModel: MediaStreamsModel.fromMediaStreamsList( - info.mediaSources?.firstOrNull, info.mediaSources?.firstOrNull?.mediaStreams ?? [], ref), + mediaStreamsModel: MediaStreamsModel.fromMediaStreamsList(info.mediaSources, ref), ); } } diff --git a/lib/providers/items/episode_details_provider.dart b/lib/providers/items/episode_details_provider.dart index 6b84223..1f3fef6 100644 --- a/lib/providers/items/episode_details_provider.dart +++ b/lib/providers/items/episode_details_provider.dart @@ -106,4 +106,12 @@ class EpisodeDetailsProvider extends StateNotifier { defaultAudioStreamIndex: index, ))); } + + void setVersionIndex(int index) { + state = state.copyWith( + episode: state.episode?.copyWith( + mediaStreams: state.episode?.mediaStreams.copyWith( + versionStreamIndex: index, + ))); + } } diff --git a/lib/providers/items/movies_details_provider.dart b/lib/providers/items/movies_details_provider.dart index 4b6e9c9..d9b67a8 100644 --- a/lib/providers/items/movies_details_provider.dart +++ b/lib/providers/items/movies_details_provider.dart @@ -1,11 +1,12 @@ import 'package:chopper/chopper.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/items/movie_model.dart'; import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/related_provider.dart'; import 'package:fladder/providers/service_provider.dart'; import 'package:fladder/providers/sync_provider.dart'; -import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'movies_details_provider.g.dart'; @@ -49,4 +50,8 @@ class MovieDetails extends _$MovieDetails { void setAudioIndex(int index) { state = state?.copyWith(mediaStreams: state?.mediaStreams.copyWith(defaultAudioStreamIndex: index)); } + + void setVersionIndex(int index) { + state = state?.copyWith(mediaStreams: state?.mediaStreams.copyWith(versionStreamIndex: index)); + } } diff --git a/lib/screens/details_screens/components/media_stream_information.dart b/lib/screens/details_screens/components/media_stream_information.dart index 4493d31..dd8ee9c 100644 --- a/lib/screens/details_screens/components/media_stream_information.dart +++ b/lib/screens/details_screens/components/media_stream_information.dart @@ -8,10 +8,16 @@ import 'package:fladder/util/localization_helper.dart'; class MediaStreamInformation extends ConsumerWidget { final MediaStreamsModel mediaStream; + final Function(int index)? onVersionIndexChanged; final Function(int index)? onAudioIndexChanged; final Function(int index)? onSubIndexChanged; - const MediaStreamInformation( - {required this.mediaStream, this.onAudioIndexChanged, this.onSubIndexChanged, super.key}); + const MediaStreamInformation({ + required this.mediaStream, + required this.onVersionIndexChanged, + this.onAudioIndexChanged, + this.onSubIndexChanged, + super.key, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -19,6 +25,23 @@ class MediaStreamInformation extends ConsumerWidget { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (mediaStream.versionStreams.isNotEmpty && mediaStream.versionStreams.length > 1) + _StreamOptionSelect( + label: Text(context.localized.version), + current: mediaStream.currentVersionStream?.name ?? "", + itemBuilder: (context) => mediaStream.versionStreams + .map((e) => PopupMenuItem( + value: e, + padding: EdgeInsets.zero, + child: textWidget( + context, + selected: mediaStream.currentVersionStream == e, + label: e.name, + ), + onTap: () => onVersionIndexChanged?.call(e.index), + )) + .toList(), + ), if (mediaStream.videoStreams.isNotEmpty) _StreamOptionSelect( label: Text(context.localized.video), @@ -112,6 +135,7 @@ class _StreamOptionSelect extends StatelessWidget { shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), enabled: itemList.length > 1, itemBuilder: itemBuilder, + enableFeedback: false, menuPadding: const EdgeInsets.symmetric(vertical: 16), padding: padding, child: Padding( diff --git a/lib/screens/details_screens/episode_detail_screen.dart b/lib/screens/details_screens/episode_detail_screen.dart index 42778bf..2660510 100644 --- a/lib/screens/details_screens/episode_detail_screen.dart +++ b/lib/screens/details_screens/episode_detail_screen.dart @@ -131,6 +131,9 @@ class _ItemDetailScreenState extends ConsumerState { padding: padding, child: MediaStreamInformation( mediaStream: details.episode!.mediaStreams, + onVersionIndexChanged: (index) { + ref.read(providerInstance.notifier).setVersionIndex(index); + }, onSubIndexChanged: (index) { ref.read(providerInstance.notifier).setSubIndex(index); }, diff --git a/lib/screens/details_screens/movie_detail_screen.dart b/lib/screens/details_screens/movie_detail_screen.dart index 444cf4b..52cea60 100644 --- a/lib/screens/details_screens/movie_detail_screen.dart +++ b/lib/screens/details_screens/movie_detail_screen.dart @@ -1,5 +1,3 @@ -import 'package:fladder/models/settings/home_settings_model.dart'; -import 'package:fladder/util/adaptive_layout.dart'; import 'package:flutter/material.dart'; import 'package:auto_route/auto_route.dart'; @@ -7,6 +5,7 @@ import 'package:ficonsax/ficonsax.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/models/item_base_model.dart'; +import 'package:fladder/models/settings/home_settings_model.dart'; import 'package:fladder/providers/items/movies_details_provider.dart'; import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/screens/details_screens/components/media_stream_information.dart'; @@ -18,6 +17,7 @@ import 'package:fladder/screens/shared/media/expanding_overview.dart'; import 'package:fladder/screens/shared/media/external_urls.dart'; import 'package:fladder/screens/shared/media/people_row.dart'; import 'package:fladder/screens/shared/media/poster_row.dart'; +import 'package:fladder/util/adaptive_layout.dart'; import 'package:fladder/util/item_base_model/item_base_model_extensions.dart'; import 'package:fladder/util/item_base_model/play_item_helpers.dart'; import 'package:fladder/util/list_padding.dart'; @@ -126,6 +126,9 @@ class _ItemDetailScreenState extends ConsumerState { ).padding(padding), if (details.mediaStreams.isNotEmpty) MediaStreamInformation( + onVersionIndexChanged: (index) { + ref.read(providerInstance.notifier).setVersionIndex(index); + }, onSubIndexChanged: (index) { ref.read(providerInstance.notifier).setSubIndex(index); }, diff --git a/lib/screens/shared/media/components/next_up_episode.dart b/lib/screens/shared/media/components/next_up_episode.dart index 755262f..fabdd75 100644 --- a/lib/screens/shared/media/components/next_up_episode.dart +++ b/lib/screens/shared/media/components/next_up_episode.dart @@ -86,6 +86,9 @@ class NextUpEpisode extends ConsumerWidget { children: [ MediaStreamInformation( mediaStream: nextEpisode.mediaStreams, + onVersionIndexChanged: (index) => onChanged?.call(nextEpisode.copyWith( + mediaStreams: nextEpisode.mediaStreams.copyWith(versionStreamIndex: index), + )), onAudioIndexChanged: (index) => onChanged?.call(nextEpisode.copyWith( mediaStreams: nextEpisode.mediaStreams.copyWith(defaultAudioStreamIndex: index))), onSubIndexChanged: (index) => onChanged?.call(nextEpisode.copyWith(