feature: Add actions for media segments (#236)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2025-02-23 16:56:49 +01:00 committed by GitHub
parent f0414439f3
commit e4b8a050c7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 155 additions and 23 deletions

View file

@ -1179,5 +1179,9 @@
"qualityOptionsTitle": "Quality options", "qualityOptionsTitle": "Quality options",
"qualityOptionsOriginal": "Original", "qualityOptionsOriginal": "Original",
"qualityOptionsAuto": "Auto", "qualityOptionsAuto": "Auto",
"version": "Version" "version": "Version",
"mediaSegmentActions": "Media segment actions",
"segmentActionNone": "None",
"segmentActionAskToSkip": "Ask to skip",
"segmentActionSkip": "Skip"
} }

View file

@ -42,6 +42,28 @@ class MediaSegment with _$MediaSegment {
bool forceShow(Duration position) => (position - start).inSeconds < (end - start).inSeconds * 0.20; bool forceShow(Duration position) => (position - start).inSeconds < (end - start).inSeconds * 0.20;
} }
const Map<MediaSegmentType, SegmentSkip> 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 { enum MediaSegmentType {
unknown, unknown,
commercial, commercial,

View file

@ -5,6 +5,7 @@ import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.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/bitrate_helper.dart';
import 'package:fladder/util/localization_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 maxHomeBitrate,
@Default(Bitrate.original) Bitrate maxInternetBitrate, @Default(Bitrate.original) Bitrate maxInternetBitrate,
String? audioDevice, String? audioDevice,
@Default(defaultSegmentSkipValues) Map<MediaSegmentType, SegmentSkip> segmentSkipSettings,
}) = _VideoPlayerSettingsModel; }) = _VideoPlayerSettingsModel;
double get volume => switch (defaultTargetPlatform) { double get volume => switch (defaultTargetPlatform) {

View file

@ -34,6 +34,8 @@ mixin _$VideoPlayerSettingsModel {
Bitrate get maxHomeBitrate => throw _privateConstructorUsedError; Bitrate get maxHomeBitrate => throw _privateConstructorUsedError;
Bitrate get maxInternetBitrate => throw _privateConstructorUsedError; Bitrate get maxInternetBitrate => throw _privateConstructorUsedError;
String? get audioDevice => throw _privateConstructorUsedError; String? get audioDevice => throw _privateConstructorUsedError;
Map<MediaSegmentType, SegmentSkip> get segmentSkipSettings =>
throw _privateConstructorUsedError;
/// Serializes this VideoPlayerSettingsModel to a JSON map. /// Serializes this VideoPlayerSettingsModel to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError; Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@ -63,7 +65,8 @@ abstract class $VideoPlayerSettingsModelCopyWith<$Res> {
AutoNextType nextVideoType, AutoNextType nextVideoType,
Bitrate maxHomeBitrate, Bitrate maxHomeBitrate,
Bitrate maxInternetBitrate, Bitrate maxInternetBitrate,
String? audioDevice}); String? audioDevice,
Map<MediaSegmentType, SegmentSkip> segmentSkipSettings});
} }
/// @nodoc /// @nodoc
@ -94,6 +97,7 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res,
Object? maxHomeBitrate = null, Object? maxHomeBitrate = null,
Object? maxInternetBitrate = null, Object? maxInternetBitrate = null,
Object? audioDevice = freezed, Object? audioDevice = freezed,
Object? segmentSkipSettings = null,
}) { }) {
return _then(_value.copyWith( return _then(_value.copyWith(
screenBrightness: freezed == screenBrightness screenBrightness: freezed == screenBrightness
@ -144,6 +148,10 @@ class _$VideoPlayerSettingsModelCopyWithImpl<$Res,
? _value.audioDevice ? _value.audioDevice
: audioDevice // ignore: cast_nullable_to_non_nullable : audioDevice // ignore: cast_nullable_to_non_nullable
as String?, as String?,
segmentSkipSettings: null == segmentSkipSettings
? _value.segmentSkipSettings
: segmentSkipSettings // ignore: cast_nullable_to_non_nullable
as Map<MediaSegmentType, SegmentSkip>,
) as $Val); ) as $Val);
} }
} }
@ -169,7 +177,8 @@ abstract class _$$VideoPlayerSettingsModelImplCopyWith<$Res>
AutoNextType nextVideoType, AutoNextType nextVideoType,
Bitrate maxHomeBitrate, Bitrate maxHomeBitrate,
Bitrate maxInternetBitrate, Bitrate maxInternetBitrate,
String? audioDevice}); String? audioDevice,
Map<MediaSegmentType, SegmentSkip> segmentSkipSettings});
} }
/// @nodoc /// @nodoc
@ -199,6 +208,7 @@ class __$$VideoPlayerSettingsModelImplCopyWithImpl<$Res>
Object? maxHomeBitrate = null, Object? maxHomeBitrate = null,
Object? maxInternetBitrate = null, Object? maxInternetBitrate = null,
Object? audioDevice = freezed, Object? audioDevice = freezed,
Object? segmentSkipSettings = null,
}) { }) {
return _then(_$VideoPlayerSettingsModelImpl( return _then(_$VideoPlayerSettingsModelImpl(
screenBrightness: freezed == screenBrightness screenBrightness: freezed == screenBrightness
@ -249,6 +259,10 @@ class __$$VideoPlayerSettingsModelImplCopyWithImpl<$Res>
? _value.audioDevice ? _value.audioDevice
: audioDevice // ignore: cast_nullable_to_non_nullable : audioDevice // ignore: cast_nullable_to_non_nullable
as String?, as String?,
segmentSkipSettings: null == segmentSkipSettings
? _value._segmentSkipSettings
: segmentSkipSettings // ignore: cast_nullable_to_non_nullable
as Map<MediaSegmentType, SegmentSkip>,
)); ));
} }
} }
@ -269,8 +283,11 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel
this.nextVideoType = AutoNextType.smart, this.nextVideoType = AutoNextType.smart,
this.maxHomeBitrate = Bitrate.original, this.maxHomeBitrate = Bitrate.original,
this.maxInternetBitrate = Bitrate.original, this.maxInternetBitrate = Bitrate.original,
this.audioDevice}) this.audioDevice,
final Map<MediaSegmentType, SegmentSkip> segmentSkipSettings =
defaultSegmentSkipValues})
: _allowedOrientations = allowedOrientations, : _allowedOrientations = allowedOrientations,
_segmentSkipSettings = segmentSkipSettings,
super._(); super._();
factory _$VideoPlayerSettingsModelImpl.fromJson(Map<String, dynamic> json) => factory _$VideoPlayerSettingsModelImpl.fromJson(Map<String, dynamic> json) =>
@ -317,10 +334,19 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel
final Bitrate maxInternetBitrate; final Bitrate maxInternetBitrate;
@override @override
final String? audioDevice; final String? audioDevice;
final Map<MediaSegmentType, SegmentSkip> _segmentSkipSettings;
@override
@JsonKey()
Map<MediaSegmentType, SegmentSkip> get segmentSkipSettings {
if (_segmentSkipSettings is EqualUnmodifiableMapView)
return _segmentSkipSettings;
// ignore: implicit_dynamic_type
return EqualUnmodifiableMapView(_segmentSkipSettings);
}
@override @override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { 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 @override
@ -339,7 +365,8 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel
..add(DiagnosticsProperty('nextVideoType', nextVideoType)) ..add(DiagnosticsProperty('nextVideoType', nextVideoType))
..add(DiagnosticsProperty('maxHomeBitrate', maxHomeBitrate)) ..add(DiagnosticsProperty('maxHomeBitrate', maxHomeBitrate))
..add(DiagnosticsProperty('maxInternetBitrate', maxInternetBitrate)) ..add(DiagnosticsProperty('maxInternetBitrate', maxInternetBitrate))
..add(DiagnosticsProperty('audioDevice', audioDevice)); ..add(DiagnosticsProperty('audioDevice', audioDevice))
..add(DiagnosticsProperty('segmentSkipSettings', segmentSkipSettings));
} }
/// Create a copy of VideoPlayerSettingsModel /// Create a copy of VideoPlayerSettingsModel
@ -361,18 +388,20 @@ class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel
abstract class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel { abstract class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel {
factory _VideoPlayerSettingsModel( factory _VideoPlayerSettingsModel(
{final double? screenBrightness, {final double? screenBrightness,
final BoxFit videoFit, final BoxFit videoFit,
final bool fillScreen, final bool fillScreen,
final bool hardwareAccel, final bool hardwareAccel,
final bool useLibass, final bool useLibass,
final PlayerOptions? playerOptions, final PlayerOptions? playerOptions,
final double internalVolume, final double internalVolume,
final Set<DeviceOrientation>? allowedOrientations, final Set<DeviceOrientation>? allowedOrientations,
final AutoNextType nextVideoType, final AutoNextType nextVideoType,
final Bitrate maxHomeBitrate, final Bitrate maxHomeBitrate,
final Bitrate maxInternetBitrate, final Bitrate maxInternetBitrate,
final String? audioDevice}) = _$VideoPlayerSettingsModelImpl; final String? audioDevice,
final Map<MediaSegmentType, SegmentSkip> segmentSkipSettings}) =
_$VideoPlayerSettingsModelImpl;
_VideoPlayerSettingsModel._() : super._(); _VideoPlayerSettingsModel._() : super._();
factory _VideoPlayerSettingsModel.fromJson(Map<String, dynamic> json) = factory _VideoPlayerSettingsModel.fromJson(Map<String, dynamic> json) =
@ -402,6 +431,8 @@ abstract class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel {
Bitrate get maxInternetBitrate; Bitrate get maxInternetBitrate;
@override @override
String? get audioDevice; String? get audioDevice;
@override
Map<MediaSegmentType, SegmentSkip> get segmentSkipSettings;
/// Create a copy of VideoPlayerSettingsModel /// Create a copy of VideoPlayerSettingsModel
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.

View file

@ -31,6 +31,12 @@ _$VideoPlayerSettingsModelImpl _$$VideoPlayerSettingsModelImplFromJson(
$enumDecodeNullable(_$BitrateEnumMap, json['maxInternetBitrate']) ?? $enumDecodeNullable(_$BitrateEnumMap, json['maxInternetBitrate']) ??
Bitrate.original, Bitrate.original,
audioDevice: json['audioDevice'] as String?, audioDevice: json['audioDevice'] as String?,
segmentSkipSettings:
(json['segmentSkipSettings'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry($enumDecode(_$MediaSegmentTypeEnumMap, k),
$enumDecode(_$SegmentSkipEnumMap, e)),
) ??
defaultSegmentSkipValues,
); );
Map<String, dynamic> _$$VideoPlayerSettingsModelImplToJson( Map<String, dynamic> _$$VideoPlayerSettingsModelImplToJson(
@ -50,6 +56,8 @@ Map<String, dynamic> _$$VideoPlayerSettingsModelImplToJson(
'maxHomeBitrate': _$BitrateEnumMap[instance.maxHomeBitrate]!, 'maxHomeBitrate': _$BitrateEnumMap[instance.maxHomeBitrate]!,
'maxInternetBitrate': _$BitrateEnumMap[instance.maxInternetBitrate]!, 'maxInternetBitrate': _$BitrateEnumMap[instance.maxInternetBitrate]!,
'audioDevice': instance.audioDevice, 'audioDevice': instance.audioDevice,
'segmentSkipSettings': instance.segmentSkipSettings.map((k, e) =>
MapEntry(_$MediaSegmentTypeEnumMap[k]!, _$SegmentSkipEnumMap[e]!)),
}; };
const _$BoxFitEnumMap = { const _$BoxFitEnumMap = {
@ -95,6 +103,21 @@ const _$BitrateEnumMap = {
Bitrate.b4Mbps: 'b4Mbps', Bitrate.b4Mbps: 'b4Mbps',
Bitrate.b3Mbps: 'b3Mbps', Bitrate.b3Mbps: 'b3Mbps',
Bitrate.b1_5Mbps: 'b1_5Mbps', Bitrate.b1_5Mbps: 'b1_5Mbps',
Bitrate.b420Kbps: 'b420Kbps',
Bitrate.b720Kbps: 'b720Kbps', 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',
}; };

View file

@ -6,7 +6,7 @@ part of 'movies_details_provider.dart';
// RiverpodGenerator // RiverpodGenerator
// ************************************************************************** // **************************************************************************
String _$movieDetailsHash() => r'da07dcdb6e1955119df64f8a6a5634216435982c'; String _$movieDetailsHash() => r'872ea61464ef8493c7e6c559c526377f1c8f6a6d';
/// Copied from Dart SDK /// Copied from Dart SDK
class _SystemHash { class _SystemHash {

View file

@ -4,8 +4,10 @@ import 'package:flutter/foundation.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';
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/home_settings_model.dart';
import 'package:fladder/models/settings/video_player_settings.dart'; import 'package:fladder/models/settings/video_player_settings.dart';
import 'package:fladder/providers/connectivity_provider.dart'; import 'package:fladder/providers/connectivity_provider.dart';
@ -130,6 +132,40 @@ class _PlayerSettingsPageState extends ConsumerState<PlayerSettingsPage> {
), ),
), ),
const Divider(), 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), SettingsLabelDivider(label: context.localized.advanced),
if (PlayerOptions.available.length != 1) if (PlayerOptions.available.length != 1)
SettingsListTile( SettingsListTile(

View file

@ -65,16 +65,22 @@ class OpenQueueButton extends ConsumerWidget {
class SkipSegmentButton extends ConsumerWidget { class SkipSegmentButton extends ConsumerWidget {
final MediaSegment? segment; final MediaSegment? segment;
final SegmentSkip? skipType;
final bool isOverlayVisible; final bool isOverlayVisible;
final Function() pressedSkip; final Function() pressedSkip;
const SkipSegmentButton( const SkipSegmentButton({
{required this.segment, required this.isOverlayVisible, required this.pressedSkip, super.key}); required this.segment,
this.skipType,
required this.isOverlayVisible,
required this.pressedSkip,
super.key,
});
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return AnimatedFadeSize( return AnimatedFadeSize(
child: segment != null child: segment != null && skipType != SegmentSkip.none
? AnimatedOpacity( ? AnimatedOpacity(
opacity: isOverlayVisible ? 1 : 0.15, opacity: isOverlayVisible ? 1 : 0.15,
duration: const Duration(milliseconds: 500), duration: const Duration(milliseconds: 500),

View file

@ -166,6 +166,13 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
final position = ref.watch(mediaPlaybackProvider.select((value) => value.position)); final position = ref.watch(mediaPlaybackProvider.select((value) => value.position));
MediaSegment? segment = mediaSegments?.atPosition(position); MediaSegment? segment = mediaSegments?.atPosition(position);
bool forceShow = segment?.forceShow(position) ?? false; 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( return Stack(
children: [ children: [
Align( Align(
@ -174,6 +181,7 @@ class _DesktopControlsState extends ConsumerState<DesktopControls> {
padding: const EdgeInsets.all(32), padding: const EdgeInsets.all(32),
child: SkipSegmentButton( child: SkipSegmentButton(
segment: segment, segment: segment,
skipType: segmentSkipType,
isOverlayVisible: forceShow ? true : showOverlay, isOverlayVisible: forceShow ? true : showOverlay,
pressedSkip: () => skipToSegmentEnd(segment), pressedSkip: () => skipToSegmentEnd(segment),
), ),