diff --git a/DEVELOPEMENT.md b/DEVELOPEMENT.md index ecb35bb..8dab172 100644 --- a/DEVELOPEMENT.md +++ b/DEVELOPEMENT.md @@ -55,6 +55,10 @@ flutter pub run build_runner build ```bash flutter pub run build_runner watch ``` +Update localization definitions: +```bash +flutter gen-l10n +``` ## 🌐 Using a demo Server You can use a fake server from Jellyfin. diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 9fe40f7..194f281 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1201,5 +1201,15 @@ } }, "maxConcurrentDownloadsTitle": "Max concurrent downloads", - "maxConcurrentDownloadsDesc": "Sets the maximum number of downloads that can run at the same time. Set to 0 to disable the limit." + "maxConcurrentDownloadsDesc": "Sets the maximum number of downloads that can run at the same time. Set to 0 to disable the limit.", + "playbackTrackSelection": "Playback track selection", + "@playbackTrackSelection": {}, + "rememberSubtitleSelections": "Set subtitle track based on previous item", + "@rememberSubtitleSelections": {}, + "rememberAudioSelections": "Set audio track based on previous item", + "@rememberAudioSelections": {}, + "rememberSubtitleSelectionsDesc": "Try to set the subtitle track to the closest match to the last video.", + "@rememberSubtitleSelectionsDesc": {}, + "rememberAudioSelectionsDesc": "Try to set the audio track to the closest match to the last video.", + "@rememberAudioSelectionsDesc": {} } \ No newline at end of file diff --git a/lib/models/account_model.dart b/lib/models/account_model.dart index b5070d2..e0fdb30 100644 --- a/lib/models/account_model.dart +++ b/lib/models/account_model.dart @@ -34,6 +34,7 @@ class AccountModel with _$AccountModel { @Default([]) List savedFilters, @JsonKey(includeFromJson: false, includeToJson: false) UserPolicy? policy, @JsonKey(includeFromJson: false, includeToJson: false) ServerConfiguration? serverConfiguration, + @JsonKey(includeFromJson: false, includeToJson: false) UserConfiguration? userConfiguration, }) = _AccountModel; factory AccountModel.fromJson(Map json) => _$AccountModelFromJson(json); diff --git a/lib/models/account_model.freezed.dart b/lib/models/account_model.freezed.dart index 568621b..d370519 100644 --- a/lib/models/account_model.freezed.dart +++ b/lib/models/account_model.freezed.dart @@ -37,6 +37,9 @@ mixin _$AccountModel { @JsonKey(includeFromJson: false, includeToJson: false) ServerConfiguration? get serverConfiguration => throw _privateConstructorUsedError; + @JsonKey(includeFromJson: false, includeToJson: false) + UserConfiguration? get userConfiguration => + throw _privateConstructorUsedError; /// Serializes this AccountModel to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -68,7 +71,9 @@ abstract class $AccountModelCopyWith<$Res> { List savedFilters, @JsonKey(includeFromJson: false, includeToJson: false) UserPolicy? policy, @JsonKey(includeFromJson: false, includeToJson: false) - ServerConfiguration? serverConfiguration}); + ServerConfiguration? serverConfiguration, + @JsonKey(includeFromJson: false, includeToJson: false) + UserConfiguration? userConfiguration}); } /// @nodoc @@ -99,6 +104,7 @@ class _$AccountModelCopyWithImpl<$Res, $Val extends AccountModel> Object? savedFilters = null, Object? policy = freezed, Object? serverConfiguration = freezed, + Object? userConfiguration = freezed, }) { return _then(_value.copyWith( name: null == name @@ -153,6 +159,10 @@ class _$AccountModelCopyWithImpl<$Res, $Val extends AccountModel> ? _value.serverConfiguration : serverConfiguration // ignore: cast_nullable_to_non_nullable as ServerConfiguration?, + userConfiguration: freezed == userConfiguration + ? _value.userConfiguration + : userConfiguration // ignore: cast_nullable_to_non_nullable + as UserConfiguration?, ) as $Val); } } @@ -179,7 +189,9 @@ abstract class _$$AccountModelImplCopyWith<$Res> List savedFilters, @JsonKey(includeFromJson: false, includeToJson: false) UserPolicy? policy, @JsonKey(includeFromJson: false, includeToJson: false) - ServerConfiguration? serverConfiguration}); + ServerConfiguration? serverConfiguration, + @JsonKey(includeFromJson: false, includeToJson: false) + UserConfiguration? userConfiguration}); } /// @nodoc @@ -208,6 +220,7 @@ class __$$AccountModelImplCopyWithImpl<$Res> Object? savedFilters = null, Object? policy = freezed, Object? serverConfiguration = freezed, + Object? userConfiguration = freezed, }) { return _then(_$AccountModelImpl( name: null == name @@ -262,6 +275,10 @@ class __$$AccountModelImplCopyWithImpl<$Res> ? _value.serverConfiguration : serverConfiguration // ignore: cast_nullable_to_non_nullable as ServerConfiguration?, + userConfiguration: freezed == userConfiguration + ? _value.userConfiguration + : userConfiguration // ignore: cast_nullable_to_non_nullable + as UserConfiguration?, )); } } @@ -283,7 +300,9 @@ class _$AccountModelImpl extends _AccountModel with DiagnosticableTreeMixin { final List savedFilters = const [], @JsonKey(includeFromJson: false, includeToJson: false) this.policy, @JsonKey(includeFromJson: false, includeToJson: false) - this.serverConfiguration}) + this.serverConfiguration, + @JsonKey(includeFromJson: false, includeToJson: false) + this.userConfiguration}) : _latestItemsExcludes = latestItemsExcludes, _searchQueryHistory = searchQueryHistory, _savedFilters = savedFilters, @@ -346,10 +365,13 @@ class _$AccountModelImpl extends _AccountModel with DiagnosticableTreeMixin { @override @JsonKey(includeFromJson: false, includeToJson: false) final ServerConfiguration? serverConfiguration; + @override + @JsonKey(includeFromJson: false, includeToJson: false) + final UserConfiguration? userConfiguration; @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'AccountModel(name: $name, id: $id, avatar: $avatar, lastUsed: $lastUsed, authMethod: $authMethod, localPin: $localPin, credentials: $credentials, latestItemsExcludes: $latestItemsExcludes, searchQueryHistory: $searchQueryHistory, quickConnectState: $quickConnectState, savedFilters: $savedFilters, policy: $policy, serverConfiguration: $serverConfiguration)'; + return 'AccountModel(name: $name, id: $id, avatar: $avatar, lastUsed: $lastUsed, authMethod: $authMethod, localPin: $localPin, credentials: $credentials, latestItemsExcludes: $latestItemsExcludes, searchQueryHistory: $searchQueryHistory, quickConnectState: $quickConnectState, savedFilters: $savedFilters, policy: $policy, serverConfiguration: $serverConfiguration, userConfiguration: $userConfiguration)'; } @override @@ -369,7 +391,8 @@ class _$AccountModelImpl extends _AccountModel with DiagnosticableTreeMixin { ..add(DiagnosticsProperty('quickConnectState', quickConnectState)) ..add(DiagnosticsProperty('savedFilters', savedFilters)) ..add(DiagnosticsProperty('policy', policy)) - ..add(DiagnosticsProperty('serverConfiguration', serverConfiguration)); + ..add(DiagnosticsProperty('serverConfiguration', serverConfiguration)) + ..add(DiagnosticsProperty('userConfiguration', userConfiguration)); } @override @@ -398,7 +421,9 @@ class _$AccountModelImpl extends _AccountModel with DiagnosticableTreeMixin { .equals(other._savedFilters, _savedFilters) && (identical(other.policy, policy) || other.policy == policy) && (identical(other.serverConfiguration, serverConfiguration) || - other.serverConfiguration == serverConfiguration)); + other.serverConfiguration == serverConfiguration) && + (identical(other.userConfiguration, userConfiguration) || + other.userConfiguration == userConfiguration)); } @JsonKey(includeFromJson: false, includeToJson: false) @@ -417,7 +442,8 @@ class _$AccountModelImpl extends _AccountModel with DiagnosticableTreeMixin { quickConnectState, const DeepCollectionEquality().hash(_savedFilters), policy, - serverConfiguration); + serverConfiguration, + userConfiguration); /// Create a copy of AccountModel /// with the given fields replaced by the non-null parameter values. @@ -451,7 +477,9 @@ abstract class _AccountModel extends AccountModel { @JsonKey(includeFromJson: false, includeToJson: false) final UserPolicy? policy, @JsonKey(includeFromJson: false, includeToJson: false) - final ServerConfiguration? serverConfiguration}) = _$AccountModelImpl; + final ServerConfiguration? serverConfiguration, + @JsonKey(includeFromJson: false, includeToJson: false) + final UserConfiguration? userConfiguration}) = _$AccountModelImpl; const _AccountModel._() : super._(); factory _AccountModel.fromJson(Map json) = @@ -485,6 +513,9 @@ abstract class _AccountModel extends AccountModel { @override @JsonKey(includeFromJson: false, includeToJson: false) ServerConfiguration? get serverConfiguration; + @override + @JsonKey(includeFromJson: false, includeToJson: false) + UserConfiguration? get userConfiguration; /// Create a copy of AccountModel /// with the given fields replaced by the non-null parameter values. diff --git a/lib/models/items/media_streams_model.dart b/lib/models/items/media_streams_model.dart index 711fd62..547c912 100644 --- a/lib/models/items/media_streams_model.dart +++ b/lib/models/items/media_streams_model.dart @@ -175,6 +175,20 @@ class StreamModel { }); } +class AudioAndSubStreamModel extends StreamModel { + final String language; + final String displayTitle; + AudioAndSubStreamModel({ + required this.displayTitle, + required super.name, + required super.codec, + required super.isDefault, + required super.isExternal, + required super.index, + required this.language, + }); +} + class VersionStreamModel { final String name; final int index; @@ -250,19 +264,17 @@ extension SortByExternalExtension on Iterable { } } -class AudioStreamModel extends StreamModel { - final String displayTitle; - final String language; +class AudioStreamModel extends AudioAndSubStreamModel { final String channelLayout; AudioStreamModel({ - required this.displayTitle, + required super.displayTitle, required super.name, required super.codec, required super.isDefault, required super.isExternal, required super.index, - required this.language, + required super.language, required this.channelLayout, }); @@ -292,8 +304,8 @@ class AudioStreamModel extends StreamModel { AudioStreamModel.no({ super.name = 'Off', - this.displayTitle = 'Off', - this.language = '', + super.displayTitle = 'Off', + super.language = '', super.codec = '', this.channelLayout = '', super.isDefault = false, @@ -302,19 +314,17 @@ class AudioStreamModel extends StreamModel { }); } -class SubStreamModel extends StreamModel { +class SubStreamModel extends AudioAndSubStreamModel { String id; String title; - String displayTitle; - String language; String? url; bool supportsExternalStream; SubStreamModel({ required super.name, required this.id, required this.title, - required this.displayTitle, - required this.language, + required super.displayTitle, + required super.language, this.url, required super.codec, required super.isDefault, @@ -327,8 +337,8 @@ class SubStreamModel extends StreamModel { super.name = 'Off', this.id = 'Off', this.title = 'Off', - this.displayTitle = 'Off', - this.language = '', + super.displayTitle = 'Off', + super.language = '', this.url = '', super.codec = '', super.isDefault = false, diff --git a/lib/models/playback/playback_model.dart b/lib/models/playback/playback_model.dart index 902afe7..8eb8fcb 100644 --- a/lib/models/playback/playback_model.dart +++ b/lib/models/playback/playback_model.dart @@ -31,6 +31,7 @@ import 'package:fladder/util/bitrate_helper.dart'; import 'package:fladder/util/duration_extensions.dart'; import 'package:fladder/util/list_extensions.dart'; import 'package:fladder/util/map_bool_helper.dart'; +import 'package:fladder/util/streams_selection.dart'; import 'package:fladder/wrappers/media_control_wrapper.dart'; class Media { @@ -196,13 +197,25 @@ class PlaybackModelHelper { ); final streamModel = firstItemToPlay.streamModel; + final audioStreamIndex = selectAudioStream( + ref.read(userProvider.select((value) => value?.userConfiguration?.rememberAudioSelections ?? true)), + oldModel?.mediaStreams?.currentAudioStream, + streamModel?.audioStreams, + streamModel?.defaultAudioStreamIndex + ); + final subStreamIndex = selectSubStream( + ref.read(userProvider.select((value) => value?.userConfiguration?.rememberSubtitleSelections ?? true)), + oldModel?.mediaStreams?.currentSubStream, + streamModel?.subStreams, + streamModel?.defaultSubStreamIndex + ); final Response response = await api.itemsItemIdPlaybackInfoPost( itemId: firstItemToPlay.id, body: PlaybackInfoDto( startTimeTicks: startPosition?.toRuntimeTicks, - audioStreamIndex: streamModel?.defaultAudioStreamIndex, - subtitleStreamIndex: streamModel?.defaultSubStreamIndex, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subStreamIndex, enableTranscoding: true, autoOpenLiveStream: true, deviceProfile: ref.read(videoProfileProvider), @@ -223,8 +236,8 @@ class PlaybackModelHelper { if (mediaSource == null) return null; final mediaStreamsWithUrls = MediaStreamsModel.fromMediaStreamsList(playbackInfo.mediaSources, ref).copyWith( - defaultAudioStreamIndex: streamModel?.defaultAudioStreamIndex, - defaultSubStreamIndex: streamModel?.defaultSubStreamIndex, + defaultAudioStreamIndex: audioStreamIndex, + defaultSubStreamIndex: subStreamIndex, ); final mediaSegments = await api.mediaSegmentsGet(id: item.id); @@ -328,8 +341,18 @@ class PlaybackModelHelper { final currentPosition = ref.read(mediaPlaybackProvider.select((value) => value.position)); - final audioIndex = playbackModel.mediaStreams?.defaultAudioStreamIndex; - final subIndex = playbackModel.mediaStreams?.defaultSubStreamIndex; + final audioIndex = selectAudioStream( + ref.read(userProvider.select((value) => value?.userConfiguration?.rememberAudioSelections ?? true)), + playbackModel.mediaStreams?.currentAudioStream, + playbackModel.audioStreams, + playbackModel.mediaStreams?.defaultAudioStreamIndex + ); + final subIndex = selectSubStream( + ref.read(userProvider.select((value) => value?.userConfiguration?.rememberSubtitleSelections ?? true)), + playbackModel.mediaStreams?.currentSubStream, + playbackModel.subStreams, + playbackModel.mediaStreams?.defaultSubStreamIndex + ); Response response = await api.itemsItemIdPlaybackInfoPost( itemId: item.id, diff --git a/lib/providers/service_provider.dart b/lib/providers/service_provider.dart index c395d9c..60636da 100644 --- a/lib/providers/service_provider.dart +++ b/lib/providers/service_provider.dart @@ -902,4 +902,38 @@ class JellyService { Future> quickConnectEnabled() async => api.quickConnectEnabledGet(); Future> deleteItem(String itemId) => api.itemsItemIdDelete(itemId: itemId); + +Future _updateUserConfiguration(UserConfiguration newUserConfiguration) async { + if (account?.id == null) return null; + + final response = await api.usersConfigurationPost( + userId: account!.id, + body: newUserConfiguration, + ); + + if (response.isSuccessful) { + return newUserConfiguration; + } + return null; +} + +Future updateRememberAudioSelections() { + final currentUserConfiguration = account?.userConfiguration; + if (currentUserConfiguration == null) return Future.value(null); + + final updated = currentUserConfiguration.copyWith( + rememberAudioSelections: !(currentUserConfiguration.rememberAudioSelections ?? false), + ); + return _updateUserConfiguration(updated); +} + +Future updateRememberSubtitleSelections() { + final current = account?.userConfiguration; + if (current == null) return Future.value(null); + + final updated = current.copyWith( + rememberSubtitleSelections: !(current.rememberSubtitleSelections ?? false), + ); + return _updateUserConfiguration(updated); +} } diff --git a/lib/providers/user_provider.dart b/lib/providers/user_provider.dart index 3e7f7d8..7f0ceb2 100644 --- a/lib/providers/user_provider.dart +++ b/lib/providers/user_provider.dart @@ -45,6 +45,7 @@ class User extends _$User { name: response.body?.name ?? state?.name ?? "", policy: response.body?.policy, serverConfiguration: systemConfiguration.body, + userConfiguration: response.body?.configuration, quickConnectState: quickConnectStatus.body ?? false, latestItemsExcludes: response.body?.configuration?.latestItemsExcludes ?? [], ); @@ -53,6 +54,20 @@ class User extends _$User { return null; } + void setRememberAudioSelections() async { + final newUserConfiguration = await api.updateRememberAudioSelections(); + if (newUserConfiguration != null) { + userState = state?.copyWith(userConfiguration: newUserConfiguration); + } + } + + void setRememberSubtitleSelections() async { + final newUserConfiguration = await api.updateRememberSubtitleSelections(); + if (newUserConfiguration != null) { + userState = state?.copyWith(userConfiguration: newUserConfiguration); + } + } + Future refreshMetaData( String itemId, { MetadataRefresh? metadataRefreshMode, diff --git a/lib/screens/settings/player_settings_page.dart b/lib/screens/settings/player_settings_page.dart index 2e5eb22..93748b7 100644 --- a/lib/screens/settings/player_settings_page.dart +++ b/lib/screens/settings/player_settings_page.dart @@ -10,6 +10,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fladder/models/items/media_segments_model.dart'; import 'package:fladder/models/settings/home_settings_model.dart'; import 'package:fladder/models/settings/video_player_settings.dart'; +import 'package:fladder/providers/user_provider.dart'; import 'package:fladder/providers/connectivity_provider.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/screens/settings/settings_list_tile.dart'; @@ -166,6 +167,29 @@ class _PlayerSettingsPageState extends ConsumerState { ), ), ), + SettingsLabelDivider(label: context.localized.playbackTrackSelection), + SettingsListTile( + label: Text(context.localized.rememberAudioSelections), + subLabel: Text(context.localized.rememberAudioSelectionsDesc), + onTap: () => ref.read(userProvider.notifier).setRememberAudioSelections(), + trailing: Switch( + value: ref.watch(userProvider.select( + (value) => value?.userConfiguration?.rememberAudioSelections ?? true, + )), + onChanged: (_) => ref.read(userProvider.notifier).setRememberAudioSelections(), + ), + ), + SettingsListTile( + label: Text(context.localized.rememberSubtitleSelections), + subLabel: Text(context.localized.rememberSubtitleSelectionsDesc), + onTap: () => ref.read(userProvider.notifier).setRememberSubtitleSelections(), + trailing: Switch( + value: ref.watch(userProvider.select( + (value) => value?.userConfiguration?.rememberSubtitleSelections ?? true, + )), + onChanged: (_) => ref.read(userProvider.notifier).setRememberSubtitleSelections(), + ), + ), const Divider(), SettingsLabelDivider(label: context.localized.advanced), if (PlayerOptions.available.length != 1) @@ -235,16 +259,16 @@ class _PlayerSettingsPageState extends ConsumerState { label: Text(context.localized.settingsPlayerBufferSizeTitle), subLabel: Text(context.localized.settingsPlayerBufferSizeDesc), trailing: SizedBox( - width: 70, - child: IntInputField( - suffix: 'MB', - controller: TextEditingController(text: videoSettings.bufferSize.toString()), - onSubmitted: (value) { - if (value != null) { - provider.setBufferSize(value); - } - }, - )), + width: 70, + child: IntInputField( + suffix: 'MB', + controller: TextEditingController(text: videoSettings.bufferSize.toString()), + onSubmitted: (value) { + if (value != null) { + provider.setBufferSize(value); + } + }, + )), ), SettingsListTile( label: Text(context.localized.settingsPlayerCustomSubtitlesTitle), diff --git a/lib/util/streams_selection.dart b/lib/util/streams_selection.dart new file mode 100644 index 0000000..ac66112 --- /dev/null +++ b/lib/util/streams_selection.dart @@ -0,0 +1,56 @@ + +import 'package:fladder/models/items/media_streams_model.dart'; + +int? selectAudioStream(bool rememberAudioSelection, AudioAndSubStreamModel? previousStream, List? currentStream, int? defaultStream) { + if (!rememberAudioSelection){ + return defaultStream; + } + return _selectStream(previousStream, currentStream, defaultStream); +} + +int? selectSubStream(bool rememberSubSelection, AudioAndSubStreamModel? previousStream, List? currentStream, int? defaultStream) { + if (!rememberSubSelection){ + return defaultStream; + } + return _selectStream(previousStream, currentStream, defaultStream); +} + +int? _selectStream(AudioAndSubStreamModel? previousStream, List? currentStream, int? defaultStream) { + if (currentStream == null || previousStream == null){ + return defaultStream; + } + + int? bestStreamIndex; + int bestStreamScore = 0; + + // Find the relative index of the previous stream + int prevRelIndex = 0; + for (var stream in currentStream) { + if (stream.index == previousStream.index) break; + prevRelIndex += 1; + } + + int newRelIndex = 0; + for (var stream in currentStream) { + int score = 0; + + if (previousStream.codec == stream.codec) score += 1; + if (prevRelIndex == newRelIndex) score += 1; + if (previousStream.displayTitle == stream.displayTitle) { + score += 2; + } + if (previousStream.language != 'und' && + previousStream.language == stream.language) { + score += 2; + } + + if (score > bestStreamScore && score >= 3) { + bestStreamScore = score; + bestStreamIndex = stream.index; + } + + newRelIndex += 1; + } + return bestStreamIndex ?? defaultStream; +} +