feature: Version selection (#235)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2025-02-23 15:53:17 +01:00 committed by GitHub
parent 935d6fe176
commit f0414439f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 142 additions and 60 deletions

View file

@ -1178,5 +1178,6 @@
"homeStreamingQualityDesc": "Maximum streaming quality when connected to home network", "homeStreamingQualityDesc": "Maximum streaming quality when connected to home network",
"qualityOptionsTitle": "Quality options", "qualityOptionsTitle": "Quality options",
"qualityOptionsOriginal": "Original", "qualityOptionsOriginal": "Original",
"qualityOptionsAuto": "Auto" "qualityOptionsAuto": "Auto",
"version": "Version"
} }

View file

@ -173,8 +173,7 @@ class EpisodeModel extends ItemStreamModel with EpisodeModelMappable {
parentImages: ImagesData.fromBaseItemParent(item, ref), parentImages: ImagesData.fromBaseItemParent(item, ref),
canDelete: item.canDelete, canDelete: item.canDelete,
canDownload: item.canDownload, canDownload: item.canDownload,
mediaStreams: mediaStreams: MediaStreamsModel.fromMediaStreamsList(item.mediaSources, ref),
MediaStreamsModel.fromMediaStreamsList(item.mediaSources?.firstOrNull, item.mediaStreams ?? [], ref),
jellyType: item.type, jellyType: item.type,
); );

View file

@ -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';
import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart' as dto; import 'package:fladder/jellyfin/jellyfin_open_api.swagger.dart' as dto;
import 'package:fladder/models/item_base_model.dart'; 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/media_streams_model.dart';
import 'package:fladder/models/items/movie_model.dart'; import 'package:fladder/models/items/movie_model.dart';
import 'package:fladder/models/items/overview_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'; part 'item_stream_model.mapper.dart';
@ -55,8 +55,7 @@ class ItemStreamModel extends ItemBaseModel with ItemStreamModelMappable {
parentImages: ImagesData.fromBaseItemParent(item, ref), parentImages: ImagesData.fromBaseItemParent(item, ref),
canDelete: item.canDelete, canDelete: item.canDelete,
canDownload: item.canDownload, canDownload: item.canDownload,
mediaStreams: mediaStreams: MediaStreamsModel.fromMediaStreamsList(item.mediaSources, ref),
MediaStreamsModel.fromMediaStreamsList(item.mediaSources?.firstOrNull, item.mediaStreams ?? [], ref),
); );
} }

View file

@ -12,19 +12,23 @@ import 'package:fladder/util/localization_helper.dart';
import 'package:fladder/util/video_properties.dart'; import 'package:fladder/util/video_properties.dart';
class MediaStreamsModel { class MediaStreamsModel {
final int? versionStreamIndex;
final int? defaultAudioStreamIndex; final int? defaultAudioStreamIndex;
final int? defaultSubStreamIndex; final int? defaultSubStreamIndex;
final List<VideoStreamModel> videoStreams; final List<VersionStreamModel> versionStreams;
final List<AudioStreamModel> audioStreams;
final List<SubStreamModel> subStreams;
MediaStreamsModel({ MediaStreamsModel({
this.versionStreamIndex,
this.defaultAudioStreamIndex, this.defaultAudioStreamIndex,
this.defaultSubStreamIndex, this.defaultSubStreamIndex,
required this.videoStreams, required this.versionStreams,
required this.audioStreams,
required this.subStreams,
}); });
VersionStreamModel? get currentVersionStream => versionStreams.elementAtOrNull(versionStreamIndex ?? 0);
List<VideoStreamModel> get videoStreams => currentVersionStream?.videoStreams ?? [];
List<AudioStreamModel> get audioStreams => currentVersionStream?.audioStreams ?? [];
List<SubStreamModel> get subStreams => currentVersionStream?.subStreams ?? [];
bool get isNull { bool get isNull {
return defaultAudioStreamIndex == null || return defaultAudioStreamIndex == null ||
defaultSubStreamIndex == null || defaultSubStreamIndex == null ||
@ -92,44 +96,61 @@ class MediaStreamsModel {
} }
static MediaStreamsModel fromMediaStreamsList( static MediaStreamsModel fromMediaStreamsList(
dto.MediaSourceInfo? mediaSource, List<dto.MediaStream> streams, Ref ref) { List<dto.MediaSourceInfo>? mediaSource,
Ref ref,
) {
return MediaStreamsModel( return MediaStreamsModel(
defaultAudioStreamIndex: mediaSource?.defaultAudioStreamIndex, defaultAudioStreamIndex: mediaSource?.firstOrNull?.defaultAudioStreamIndex,
defaultSubStreamIndex: mediaSource?.defaultSubtitleStreamIndex, defaultSubStreamIndex: mediaSource?.firstOrNull?.defaultSubtitleStreamIndex,
videoStreams: streams versionStreams: mediaSource?.mapIndexed(
.where((element) => element.type == dto.MediaStreamType.video) (index, element) {
.map( final streams = element.mediaStreams ?? [];
(e) => VideoStreamModel.fromMediaStream(e), return VersionStreamModel(
) name: element.name ?? "",
.sortByExternal(), index: index,
audioStreams: streams id: element.id,
.where((element) => element.type == dto.MediaStreamType.audio) defaultAudioStreamIndex: element.defaultAudioStreamIndex,
.map( defaultSubStreamIndex: element.defaultSubtitleStreamIndex,
(e) => AudioStreamModel.fromMediaStream(e), videoStreams: streams
) .where((element) => element.type == dto.MediaStreamType.video)
.sortByExternal(), .map(
subStreams: streams (e) => VideoStreamModel.fromMediaStream(e),
.where((element) => element.type == dto.MediaStreamType.subtitle) )
.map( .sortByExternal(),
(sub) => SubStreamModel.fromMediaStream(sub, ref), audioStreams: streams
) .where((element) => element.type == dto.MediaStreamType.audio)
.sortByExternal(), .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({ MediaStreamsModel copyWith({
int? versionStreamIndex,
int? defaultAudioStreamIndex, int? defaultAudioStreamIndex,
int? defaultSubStreamIndex, int? defaultSubStreamIndex,
List<VideoStreamModel>? videoStreams, List<VersionStreamModel>? versionStreams,
List<AudioStreamModel>? audioStreams,
List<SubStreamModel>? subStreams,
}) { }) {
final streamIndexChanged = versionStreamIndex != this.versionStreamIndex && versionStreamIndex != null;
final currentVersionStreams = versionStreams ?? this.versionStreams;
return MediaStreamsModel( return MediaStreamsModel(
defaultAudioStreamIndex: defaultAudioStreamIndex ?? this.defaultAudioStreamIndex, versionStreamIndex: versionStreamIndex ?? this.versionStreamIndex,
defaultSubStreamIndex: defaultSubStreamIndex ?? this.defaultSubStreamIndex, defaultAudioStreamIndex: streamIndexChanged
videoStreams: videoStreams ?? this.videoStreams, ? currentVersionStreams.elementAtOrNull(versionStreamIndex)?.defaultAudioStreamIndex
audioStreams: audioStreams ?? this.audioStreams, : defaultAudioStreamIndex ?? this.defaultAudioStreamIndex,
subStreams: subStreams ?? this.subStreams, 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<VideoStreamModel> videoStreams;
final List<AudioStreamModel> audioStreams;
final List<SubStreamModel> 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 { class VideoStreamModel extends StreamModel {
final int width; final int width;
final int height; final int height;

View file

@ -96,8 +96,7 @@ class MovieModel extends ItemStreamModel with MovieModelMappable {
parentImages: ImagesData.fromBaseItemParent(item, ref), parentImages: ImagesData.fromBaseItemParent(item, ref),
canDelete: item.canDelete, canDelete: item.canDelete,
canDownload: item.canDownload, canDownload: item.canDownload,
mediaStreams: mediaStreams: MediaStreamsModel.fromMediaStreamsList(item.mediaSources, ref),
MediaStreamsModel.fromMediaStreamsList(item.mediaSources?.firstOrNull, item.mediaStreams ?? [], ref),
); );
} }
} }

View file

@ -210,7 +210,7 @@ class PlaybackModelHelper {
enableDirectPlay: type != PlaybackType.transcode, enableDirectPlay: type != PlaybackType.transcode,
enableDirectStream: type != PlaybackType.transcode, enableDirectStream: type != PlaybackType.transcode,
maxStreamingBitrate: qualityOptions.enabledFirst.keys.firstOrNull?.bitRate, 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 mediaSource = playbackInfo.mediaSources?.first;
final mediaStreamsWithUrls = MediaStreamsModel.fromMediaStreamsList( final mediaStreamsWithUrls = MediaStreamsModel.fromMediaStreamsList(playbackInfo.mediaSources, ref).copyWith(
playbackInfo.mediaSources?.firstOrNull, playbackInfo.mediaSources?.firstOrNull?.mediaStreams ?? [], ref)
.copyWith(
defaultAudioStreamIndex: streamModel?.defaultAudioStreamIndex, defaultAudioStreamIndex: streamModel?.defaultAudioStreamIndex,
defaultSubStreamIndex: streamModel?.defaultSubStreamIndex, defaultSubStreamIndex: streamModel?.defaultSubStreamIndex,
); );
@ -352,9 +350,7 @@ class PlaybackModelHelper {
final mediaSource = playbackInfo.mediaSources?.first; final mediaSource = playbackInfo.mediaSources?.first;
final mediaStreamsWithUrls = MediaStreamsModel.fromMediaStreamsList( final mediaStreamsWithUrls = MediaStreamsModel.fromMediaStreamsList(playbackInfo.mediaSources, ref).copyWith(
playbackInfo.mediaSources?.firstOrNull, playbackInfo.mediaSources?.firstOrNull?.mediaStreams ?? [], ref)
.copyWith(
defaultAudioStreamIndex: audioIndex, defaultAudioStreamIndex: audioIndex,
defaultSubStreamIndex: subIndex, defaultSubStreamIndex: subIndex,
); );

View file

@ -199,8 +199,7 @@ class VideoStream {
playbackUrl: playbackUrl, playbackUrl: playbackUrl,
playbackType: playType, playbackType: playType,
playSessionId: info.playSessionId ?? "", playSessionId: info.playSessionId ?? "",
mediaStreamsModel: MediaStreamsModel.fromMediaStreamsList( mediaStreamsModel: MediaStreamsModel.fromMediaStreamsList(info.mediaSources, ref),
info.mediaSources?.firstOrNull, info.mediaSources?.firstOrNull?.mediaStreams ?? [], ref),
); );
} }
} }

View file

@ -106,4 +106,12 @@ class EpisodeDetailsProvider extends StateNotifier<EpisodeDetailModel> {
defaultAudioStreamIndex: index, defaultAudioStreamIndex: index,
))); )));
} }
void setVersionIndex(int index) {
state = state.copyWith(
episode: state.episode?.copyWith(
mediaStreams: state.episode?.mediaStreams.copyWith(
versionStreamIndex: index,
)));
}
} }

View file

@ -1,11 +1,12 @@
import 'package:chopper/chopper.dart'; import 'package:chopper/chopper.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:fladder/models/item_base_model.dart'; import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/models/items/movie_model.dart'; import 'package:fladder/models/items/movie_model.dart';
import 'package:fladder/providers/api_provider.dart'; import 'package:fladder/providers/api_provider.dart';
import 'package:fladder/providers/related_provider.dart'; import 'package:fladder/providers/related_provider.dart';
import 'package:fladder/providers/service_provider.dart'; import 'package:fladder/providers/service_provider.dart';
import 'package:fladder/providers/sync_provider.dart'; import 'package:fladder/providers/sync_provider.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'movies_details_provider.g.dart'; part 'movies_details_provider.g.dart';
@ -49,4 +50,8 @@ class MovieDetails extends _$MovieDetails {
void setAudioIndex(int index) { void setAudioIndex(int index) {
state = state?.copyWith(mediaStreams: state?.mediaStreams.copyWith(defaultAudioStreamIndex: index)); state = state?.copyWith(mediaStreams: state?.mediaStreams.copyWith(defaultAudioStreamIndex: index));
} }
void setVersionIndex(int index) {
state = state?.copyWith(mediaStreams: state?.mediaStreams.copyWith(versionStreamIndex: index));
}
} }

View file

@ -8,10 +8,16 @@ import 'package:fladder/util/localization_helper.dart';
class MediaStreamInformation extends ConsumerWidget { class MediaStreamInformation extends ConsumerWidget {
final MediaStreamsModel mediaStream; final MediaStreamsModel mediaStream;
final Function(int index)? onVersionIndexChanged;
final Function(int index)? onAudioIndexChanged; final Function(int index)? onAudioIndexChanged;
final Function(int index)? onSubIndexChanged; final Function(int index)? onSubIndexChanged;
const MediaStreamInformation( const MediaStreamInformation({
{required this.mediaStream, this.onAudioIndexChanged, this.onSubIndexChanged, super.key}); required this.mediaStream,
required this.onVersionIndexChanged,
this.onAudioIndexChanged,
this.onSubIndexChanged,
super.key,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
@ -19,6 +25,23 @@ class MediaStreamInformation extends ConsumerWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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) if (mediaStream.videoStreams.isNotEmpty)
_StreamOptionSelect( _StreamOptionSelect(
label: Text(context.localized.video), label: Text(context.localized.video),
@ -112,6 +135,7 @@ class _StreamOptionSelect<T> extends StatelessWidget {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
enabled: itemList.length > 1, enabled: itemList.length > 1,
itemBuilder: itemBuilder, itemBuilder: itemBuilder,
enableFeedback: false,
menuPadding: const EdgeInsets.symmetric(vertical: 16), menuPadding: const EdgeInsets.symmetric(vertical: 16),
padding: padding, padding: padding,
child: Padding( child: Padding(

View file

@ -131,6 +131,9 @@ class _ItemDetailScreenState extends ConsumerState<EpisodeDetailScreen> {
padding: padding, padding: padding,
child: MediaStreamInformation( child: MediaStreamInformation(
mediaStream: details.episode!.mediaStreams, mediaStream: details.episode!.mediaStreams,
onVersionIndexChanged: (index) {
ref.read(providerInstance.notifier).setVersionIndex(index);
},
onSubIndexChanged: (index) { onSubIndexChanged: (index) {
ref.read(providerInstance.notifier).setSubIndex(index); ref.read(providerInstance.notifier).setSubIndex(index);
}, },

View file

@ -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:flutter/material.dart';
import 'package:auto_route/auto_route.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:flutter_riverpod/flutter_riverpod.dart';
import 'package:fladder/models/item_base_model.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/items/movies_details_provider.dart';
import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/user_provider.dart';
import 'package:fladder/screens/details_screens/components/media_stream_information.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/external_urls.dart';
import 'package:fladder/screens/shared/media/people_row.dart'; import 'package:fladder/screens/shared/media/people_row.dart';
import 'package:fladder/screens/shared/media/poster_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/item_base_model_extensions.dart';
import 'package:fladder/util/item_base_model/play_item_helpers.dart'; import 'package:fladder/util/item_base_model/play_item_helpers.dart';
import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/list_padding.dart';
@ -126,6 +126,9 @@ class _ItemDetailScreenState extends ConsumerState<MovieDetailScreen> {
).padding(padding), ).padding(padding),
if (details.mediaStreams.isNotEmpty) if (details.mediaStreams.isNotEmpty)
MediaStreamInformation( MediaStreamInformation(
onVersionIndexChanged: (index) {
ref.read(providerInstance.notifier).setVersionIndex(index);
},
onSubIndexChanged: (index) { onSubIndexChanged: (index) {
ref.read(providerInstance.notifier).setSubIndex(index); ref.read(providerInstance.notifier).setSubIndex(index);
}, },

View file

@ -86,6 +86,9 @@ class NextUpEpisode extends ConsumerWidget {
children: [ children: [
MediaStreamInformation( MediaStreamInformation(
mediaStream: nextEpisode.mediaStreams, mediaStream: nextEpisode.mediaStreams,
onVersionIndexChanged: (index) => onChanged?.call(nextEpisode.copyWith(
mediaStreams: nextEpisode.mediaStreams.copyWith(versionStreamIndex: index),
)),
onAudioIndexChanged: (index) => onChanged?.call(nextEpisode.copyWith( onAudioIndexChanged: (index) => onChanged?.call(nextEpisode.copyWith(
mediaStreams: nextEpisode.mediaStreams.copyWith(defaultAudioStreamIndex: index))), mediaStreams: nextEpisode.mediaStreams.copyWith(defaultAudioStreamIndex: index))),
onSubIndexChanged: (index) => onChanged?.call(nextEpisode.copyWith( onSubIndexChanged: (index) => onChanged?.call(nextEpisode.copyWith(