feat: Videoplayer remember subtitle and audio selection(#339)

This commit is contained in:
Julien9969 2025-05-22 13:24:42 -04:00 committed by GitHub
parent 93a38a0b6b
commit b1491b0ada
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 247 additions and 39 deletions

View file

@ -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.

View file

@ -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": {}
}

View file

@ -34,6 +34,7 @@ class AccountModel with _$AccountModel {
@Default([]) List<LibraryFiltersModel> 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<String, dynamic> json) => _$AccountModelFromJson(json);

View file

@ -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<String, dynamic> toJson() => throw _privateConstructorUsedError;
@ -68,7 +71,9 @@ abstract class $AccountModelCopyWith<$Res> {
List<LibraryFiltersModel> 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<LibraryFiltersModel> 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<LibraryFiltersModel> 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<String, dynamic> 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.

View file

@ -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<T extends StreamModel> on Iterable<T> {
}
}
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,

View file

@ -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<PlaybackInfoResponse> 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<PlaybackInfoResponse> response = await api.itemsItemIdPlaybackInfoPost(
itemId: item.id,

View file

@ -902,4 +902,38 @@ class JellyService {
Future<Response<bool>> quickConnectEnabled() async => api.quickConnectEnabledGet();
Future<Response<dynamic>> deleteItem(String itemId) => api.itemsItemIdDelete(itemId: itemId);
Future<UserConfiguration?> _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<UserConfiguration?> updateRememberAudioSelections() {
final currentUserConfiguration = account?.userConfiguration;
if (currentUserConfiguration == null) return Future.value(null);
final updated = currentUserConfiguration.copyWith(
rememberAudioSelections: !(currentUserConfiguration.rememberAudioSelections ?? false),
);
return _updateUserConfiguration(updated);
}
Future<UserConfiguration?> updateRememberSubtitleSelections() {
final current = account?.userConfiguration;
if (current == null) return Future.value(null);
final updated = current.copyWith(
rememberSubtitleSelections: !(current.rememberSubtitleSelections ?? false),
);
return _updateUserConfiguration(updated);
}
}

View file

@ -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<Response> refreshMetaData(
String itemId, {
MetadataRefresh? metadataRefreshMode,

View file

@ -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<PlayerSettingsPage> {
),
),
),
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<PlayerSettingsPage> {
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),

View file

@ -0,0 +1,56 @@
import 'package:fladder/models/items/media_streams_model.dart';
int? selectAudioStream(bool rememberAudioSelection, AudioAndSubStreamModel? previousStream, List<AudioAndSubStreamModel>? currentStream, int? defaultStream) {
if (!rememberAudioSelection){
return defaultStream;
}
return _selectStream(previousStream, currentStream, defaultStream);
}
int? selectSubStream(bool rememberSubSelection, AudioAndSubStreamModel? previousStream, List<AudioAndSubStreamModel>? currentStream, int? defaultStream) {
if (!rememberSubSelection){
return defaultStream;
}
return _selectStream(previousStream, currentStream, defaultStream);
}
int? _selectStream(AudioAndSubStreamModel? previousStream, List<AudioAndSubStreamModel>? 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;
}