diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 8133a73..233c345 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1179,5 +1179,9 @@ "qualityOptionsTitle": "Quality options", "qualityOptionsOriginal": "Original", "qualityOptionsAuto": "Auto", - "version": "Version" + "version": "Version", + "mediaSegmentActions": "Media segment actions", + "segmentActionNone": "None", + "segmentActionAskToSkip": "Ask to skip", + "segmentActionSkip": "Skip" } \ No newline at end of file diff --git a/lib/models/items/media_segments_model.dart b/lib/models/items/media_segments_model.dart index 4f0c728..971b883 100644 --- a/lib/models/items/media_segments_model.dart +++ b/lib/models/items/media_segments_model.dart @@ -42,6 +42,28 @@ class MediaSegment with _$MediaSegment { bool forceShow(Duration position) => (position - start).inSeconds < (end - start).inSeconds * 0.20; } +const Map defaultSegmentSkipValues = { + MediaSegmentType.commercial: SegmentSkip.askToSkip, + MediaSegmentType.preview: SegmentSkip.askToSkip, + MediaSegmentType.recap: SegmentSkip.askToSkip, + MediaSegmentType.outro: SegmentSkip.askToSkip, + MediaSegmentType.intro: SegmentSkip.askToSkip, +}; + +enum SegmentSkip { + none, + askToSkip, + skip; + + const SegmentSkip(); + + String label(BuildContext context) => switch (this) { + SegmentSkip.none => context.localized.segmentActionNone, + SegmentSkip.askToSkip => context.localized.segmentActionAskToSkip, + SegmentSkip.skip => context.localized.segmentActionSkip, + }; +} + enum MediaSegmentType { unknown, commercial, diff --git a/lib/models/settings/video_player_settings.dart b/lib/models/settings/video_player_settings.dart index 52bfe3d..dbc99fc 100644 --- a/lib/models/settings/video_player_settings.dart +++ b/lib/models/settings/video_player_settings.dart @@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:fladder/models/items/media_segments_model.dart'; import 'package:fladder/util/bitrate_helper.dart'; import 'package:fladder/util/localization_helper.dart'; @@ -28,6 +29,7 @@ class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel { @Default(Bitrate.original) Bitrate maxHomeBitrate, @Default(Bitrate.original) Bitrate maxInternetBitrate, String? audioDevice, + @Default(defaultSegmentSkipValues) Map segmentSkipSettings, }) = _VideoPlayerSettingsModel; double get volume => switch (defaultTargetPlatform) { diff --git a/lib/models/settings/video_player_settings.freezed.dart b/lib/models/settings/video_player_settings.freezed.dart index f23e2e9..b15464e 100644 --- a/lib/models/settings/video_player_settings.freezed.dart +++ b/lib/models/settings/video_player_settings.freezed.dart @@ -34,6 +34,8 @@ mixin _$VideoPlayerSettingsModel { Bitrate get maxHomeBitrate => throw _privateConstructorUsedError; Bitrate get maxInternetBitrate => throw _privateConstructorUsedError; String? get audioDevice => throw _privateConstructorUsedError; + Map get segmentSkipSettings => + throw _privateConstructorUsedError; /// Serializes this VideoPlayerSettingsModel to a JSON map. Map toJson() => throw _privateConstructorUsedError; @@ -63,7 +65,8 @@ abstract class $VideoPlayerSettingsModelCopyWith<$Res> { AutoNextType nextVideoType, Bitrate maxHomeBitrate, Bitrate maxInternetBitrate, - String? audioDevice}); + String? audioDevice, + Map segmentSkipSettings}); } /// @nodoc @@ -94,6 +97,7 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res, Object? maxHomeBitrate = null, Object? maxInternetBitrate = null, Object? audioDevice = freezed, + Object? segmentSkipSettings = null, }) { return _then(_value.copyWith( screenBrightness: freezed == screenBrightness @@ -144,6 +148,10 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res, ? _value.audioDevice : audioDevice // ignore: cast_nullable_to_non_nullable as String?, + segmentSkipSettings: null == segmentSkipSettings + ? _value.segmentSkipSettings + : segmentSkipSettings // ignore: cast_nullable_to_non_nullable + as Map, ) as $Val); } } @@ -169,7 +177,8 @@ abstract class _$$VideoPlayerSettingsModelImplCopyWith<$Res> AutoNextType nextVideoType, Bitrate maxHomeBitrate, Bitrate maxInternetBitrate, - String? audioDevice}); + String? audioDevice, + Map segmentSkipSettings}); } /// @nodoc @@ -199,6 +208,7 @@ class __$$VideoPlayerSettingsModelImplCopyWithImpl<$Res> Object? maxHomeBitrate = null, Object? maxInternetBitrate = null, Object? audioDevice = freezed, + Object? segmentSkipSettings = null, }) { return _then(_$VideoPlayerSettingsModelImpl( screenBrightness: freezed == screenBrightness @@ -249,6 +259,10 @@ class __$$VideoPlayerSettingsModelImplCopyWithImpl<$Res> ? _value.audioDevice : audioDevice // ignore: cast_nullable_to_non_nullable as String?, + segmentSkipSettings: null == segmentSkipSettings + ? _value._segmentSkipSettings + : segmentSkipSettings // ignore: cast_nullable_to_non_nullable + as Map, )); } } @@ -269,8 +283,11 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel this.nextVideoType = AutoNextType.smart, this.maxHomeBitrate = Bitrate.original, this.maxInternetBitrate = Bitrate.original, - this.audioDevice}) + this.audioDevice, + final Map segmentSkipSettings = + defaultSegmentSkipValues}) : _allowedOrientations = allowedOrientations, + _segmentSkipSettings = segmentSkipSettings, super._(); factory _$VideoPlayerSettingsModelImpl.fromJson(Map json) => @@ -317,10 +334,19 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel final Bitrate maxInternetBitrate; @override final String? audioDevice; + final Map _segmentSkipSettings; + @override + @JsonKey() + Map get segmentSkipSettings { + if (_segmentSkipSettings is EqualUnmodifiableMapView) + return _segmentSkipSettings; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_segmentSkipSettings); + } @override String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { - return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice)'; + return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, playerOptions: $playerOptions, internalVolume: $internalVolume, allowedOrientations: $allowedOrientations, nextVideoType: $nextVideoType, maxHomeBitrate: $maxHomeBitrate, maxInternetBitrate: $maxInternetBitrate, audioDevice: $audioDevice, segmentSkipSettings: $segmentSkipSettings)'; } @override @@ -339,7 +365,8 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel ..add(DiagnosticsProperty('nextVideoType', nextVideoType)) ..add(DiagnosticsProperty('maxHomeBitrate', maxHomeBitrate)) ..add(DiagnosticsProperty('maxInternetBitrate', maxInternetBitrate)) - ..add(DiagnosticsProperty('audioDevice', audioDevice)); + ..add(DiagnosticsProperty('audioDevice', audioDevice)) + ..add(DiagnosticsProperty('segmentSkipSettings', segmentSkipSettings)); } /// Create a copy of VideoPlayerSettingsModel @@ -361,18 +388,20 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel abstract class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel { factory _VideoPlayerSettingsModel( - {final double? screenBrightness, - final BoxFit videoFit, - final bool fillScreen, - final bool hardwareAccel, - final bool useLibass, - final PlayerOptions? playerOptions, - final double internalVolume, - final Set? allowedOrientations, - final AutoNextType nextVideoType, - final Bitrate maxHomeBitrate, - final Bitrate maxInternetBitrate, - final String? audioDevice}) = _$VideoPlayerSettingsModelImpl; + {final double? screenBrightness, + final BoxFit videoFit, + final bool fillScreen, + final bool hardwareAccel, + final bool useLibass, + final PlayerOptions? playerOptions, + final double internalVolume, + final Set? allowedOrientations, + final AutoNextType nextVideoType, + final Bitrate maxHomeBitrate, + final Bitrate maxInternetBitrate, + final String? audioDevice, + final Map segmentSkipSettings}) = + _$VideoPlayerSettingsModelImpl; _VideoPlayerSettingsModel._() : super._(); factory _VideoPlayerSettingsModel.fromJson(Map json) = @@ -402,6 +431,8 @@ abstract class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel { Bitrate get maxInternetBitrate; @override String? get audioDevice; + @override + Map get segmentSkipSettings; /// Create a copy of VideoPlayerSettingsModel /// with the given fields replaced by the non-null parameter values. diff --git a/lib/models/settings/video_player_settings.g.dart b/lib/models/settings/video_player_settings.g.dart index 63f0e01..6743497 100644 --- a/lib/models/settings/video_player_settings.g.dart +++ b/lib/models/settings/video_player_settings.g.dart @@ -31,6 +31,12 @@ _$VideoPlayerSettingsModelImpl _$$VideoPlayerSettingsModelImplFromJson( $enumDecodeNullable(_$BitrateEnumMap, json['maxInternetBitrate']) ?? Bitrate.original, audioDevice: json['audioDevice'] as String?, + segmentSkipSettings: + (json['segmentSkipSettings'] as Map?)?.map( + (k, e) => MapEntry($enumDecode(_$MediaSegmentTypeEnumMap, k), + $enumDecode(_$SegmentSkipEnumMap, e)), + ) ?? + defaultSegmentSkipValues, ); Map _$$VideoPlayerSettingsModelImplToJson( @@ -50,6 +56,8 @@ Map _$$VideoPlayerSettingsModelImplToJson( 'maxHomeBitrate': _$BitrateEnumMap[instance.maxHomeBitrate]!, 'maxInternetBitrate': _$BitrateEnumMap[instance.maxInternetBitrate]!, 'audioDevice': instance.audioDevice, + 'segmentSkipSettings': instance.segmentSkipSettings.map((k, e) => + MapEntry(_$MediaSegmentTypeEnumMap[k]!, _$SegmentSkipEnumMap[e]!)), }; const _$BoxFitEnumMap = { @@ -95,6 +103,21 @@ const _$BitrateEnumMap = { Bitrate.b4Mbps: 'b4Mbps', Bitrate.b3Mbps: 'b3Mbps', Bitrate.b1_5Mbps: 'b1_5Mbps', - Bitrate.b420Kbps: 'b420Kbps', Bitrate.b720Kbps: 'b720Kbps', + Bitrate.b420Kbps: 'b420Kbps', +}; + +const _$SegmentSkipEnumMap = { + SegmentSkip.none: 'none', + SegmentSkip.askToSkip: 'askToSkip', + SegmentSkip.skip: 'skip', +}; + +const _$MediaSegmentTypeEnumMap = { + MediaSegmentType.unknown: 'unknown', + MediaSegmentType.commercial: 'commercial', + MediaSegmentType.preview: 'preview', + MediaSegmentType.recap: 'recap', + MediaSegmentType.outro: 'outro', + MediaSegmentType.intro: 'intro', }; diff --git a/lib/providers/items/movies_details_provider.g.dart b/lib/providers/items/movies_details_provider.g.dart index ab84be2..e852d19 100644 --- a/lib/providers/items/movies_details_provider.g.dart +++ b/lib/providers/items/movies_details_provider.g.dart @@ -6,7 +6,7 @@ part of 'movies_details_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$movieDetailsHash() => r'da07dcdb6e1955119df64f8a6a5634216435982c'; +String _$movieDetailsHash() => r'872ea61464ef8493c7e6c559c526377f1c8f6a6d'; /// Copied from Dart SDK class _SystemHash { diff --git a/lib/screens/settings/player_settings_page.dart b/lib/screens/settings/player_settings_page.dart index 08c65fe..3c45e57 100644 --- a/lib/screens/settings/player_settings_page.dart +++ b/lib/screens/settings/player_settings_page.dart @@ -4,8 +4,10 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:auto_route/auto_route.dart'; +import 'package:collection/collection.dart'; 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/connectivity_provider.dart'; @@ -130,6 +132,40 @@ class _PlayerSettingsPageState extends ConsumerState { ), ), const Divider(), + SettingsLabelDivider(label: context.localized.mediaSegmentActions), + ...videoSettings.segmentSkipSettings.entries.sorted((a, b) => b.key.index.compareTo(a.key.index)).map( + (entry) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: Text( + entry.key.label(context), + style: Theme.of(context).textTheme.titleLarge, + ), + ), + EnumBox( + current: entry.value.label(context), + itemBuilder: (context) => SegmentSkip.values + .map( + (value) => PopupMenuItem( + value: value, + child: Text(value.label(context)), + onTap: () { + final newEntries = videoSettings.segmentSkipSettings.map( + (key, currentValue) => MapEntry(key, key == entry.key ? value : currentValue)); + ref.read(videoPlayerSettingsProvider.notifier).state = + ref.read(videoPlayerSettingsProvider).copyWith(segmentSkipSettings: newEntries); + }, + ), + ) + .toList(), + ) + ], + ), + ), + ), + const Divider(), SettingsLabelDivider(label: context.localized.advanced), if (PlayerOptions.available.length != 1) SettingsListTile( diff --git a/lib/screens/video_player/components/video_player_controls_extras.dart b/lib/screens/video_player/components/video_player_controls_extras.dart index 903b704..32f0ad2 100644 --- a/lib/screens/video_player/components/video_player_controls_extras.dart +++ b/lib/screens/video_player/components/video_player_controls_extras.dart @@ -65,16 +65,22 @@ class OpenQueueButton extends ConsumerWidget { class SkipSegmentButton extends ConsumerWidget { final MediaSegment? segment; + final SegmentSkip? skipType; final bool isOverlayVisible; final Function() pressedSkip; - const SkipSegmentButton( - {required this.segment, required this.isOverlayVisible, required this.pressedSkip, super.key}); + const SkipSegmentButton({ + required this.segment, + this.skipType, + required this.isOverlayVisible, + required this.pressedSkip, + super.key, + }); @override Widget build(BuildContext context, WidgetRef ref) { return AnimatedFadeSize( - child: segment != null + child: segment != null && skipType != SegmentSkip.none ? AnimatedOpacity( opacity: isOverlayVisible ? 1 : 0.15, duration: const Duration(milliseconds: 500), diff --git a/lib/screens/video_player/video_player_controls.dart b/lib/screens/video_player/video_player_controls.dart index f0a42cc..6d46afa 100644 --- a/lib/screens/video_player/video_player_controls.dart +++ b/lib/screens/video_player/video_player_controls.dart @@ -166,6 +166,13 @@ class _DesktopControlsState extends ConsumerState { final position = ref.watch(mediaPlaybackProvider.select((value) => value.position)); MediaSegment? segment = mediaSegments?.atPosition(position); bool forceShow = segment?.forceShow(position) ?? false; + final segmentSkipType = ref + .watch(videoPlayerSettingsProvider.select((value) => value.segmentSkipSettings[segment?.type])); + final autoSkip = + forceShow == true && segmentSkipType == SegmentSkip.skip && player.lastState?.buffering == false; + if (autoSkip) { + skipToSegmentEnd(segment); + } return Stack( children: [ Align( @@ -174,6 +181,7 @@ class _DesktopControlsState extends ConsumerState { padding: const EdgeInsets.all(32), child: SkipSegmentButton( segment: segment, + skipType: segmentSkipType, isOverlayVisible: forceShow ? true : showOverlay, pressedSkip: () => skipToSegmentEnd(segment), ),