diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index e9360b3..2a55362 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -1073,5 +1073,11 @@ "off": "Off", "screenBrightness": "Screen brightness", "scale":"Scale", - "playBackSettings": "Playback Settings" + "playBackSettings": "Playback Settings", + "settingsAutoNextTitle": "Next-up preview", + "settingsAutoNextDesc": "Displays a next-up preview near the end if another item is queued", + "autoNextOffSmartTitle": "Smart", + "autoNextOffSmartDesc": "Shows the next-up screen when the credits start if no more then 10 seconds remain after the credits. Else it shows the next-up screen with 30 seconds of playtime remaining", + "autoNextOffStaticTitle": "Static", + "autoNextOffStaticDesc": "Show the next-up screen when 30 seconds of playtime remain" } diff --git a/lib/models/settings/video_player_settings.dart b/lib/models/settings/video_player_settings.dart index e03cc6c..0f924df 100644 --- a/lib/models/settings/video_player_settings.dart +++ b/lib/models/settings/video_player_settings.dart @@ -1,86 +1,35 @@ -// ignore_for_file: public_member_api_docs, sort_constructors_first -import 'dart:convert'; - -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; -class VideoPlayerSettingsModel { - final double? screenBrightness; - final BoxFit videoFit; - final bool fillScreen; - final bool hardwareAccel; - final bool useLibass; - final double internalVolume; - final String? audioDevice; - const VideoPlayerSettingsModel({ - this.screenBrightness, - this.videoFit = BoxFit.contain, - this.fillScreen = false, - this.hardwareAccel = true, - this.useLibass = false, - this.internalVolume = 100, - this.audioDevice, - }); +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'package:fladder/util/localization_helper.dart'; + +part 'video_player_settings.freezed.dart'; +part 'video_player_settings.g.dart'; + +@freezed +class VideoPlayerSettingsModel with _$VideoPlayerSettingsModel { + const VideoPlayerSettingsModel._(); + + factory VideoPlayerSettingsModel({ + double? screenBrightness, + @Default(BoxFit.contain) BoxFit videoFit, + @Default(false) bool fillScreen, + @Default(true) bool hardwareAccel, + @Default(false) bool useLibass, + @Default(100) double internalVolume, + @Default(AutoNextType.static) AutoNextType nextVideoType, + String? audioDevice, + }) = _VideoPlayerSettingsModel; double get volume => switch (defaultTargetPlatform) { TargetPlatform.android || TargetPlatform.iOS => 100, _ => internalVolume, }; - VideoPlayerSettingsModel copyWith({ - ValueGetter? screenBrightness, - BoxFit? videoFit, - bool? fillScreen, - bool? hardwareAccel, - bool? useLibass, - double? internalVolume, - ValueGetter? audioDevice, - }) { - return VideoPlayerSettingsModel( - screenBrightness: screenBrightness != null ? screenBrightness() : this.screenBrightness, - videoFit: videoFit ?? this.videoFit, - fillScreen: fillScreen ?? this.fillScreen, - hardwareAccel: hardwareAccel ?? this.hardwareAccel, - useLibass: useLibass ?? this.useLibass, - internalVolume: internalVolume ?? this.internalVolume, - audioDevice: audioDevice != null ? audioDevice() : this.audioDevice, - ); - } - - Map toMap() { - return { - 'screenBrightness': screenBrightness, - 'videoFit': videoFit.name, - 'fillScreen': fillScreen, - 'hardwareAccel': hardwareAccel, - 'useLibass': useLibass, - 'internalVolume': internalVolume, - 'audioDevice': audioDevice, - }; - } - - factory VideoPlayerSettingsModel.fromMap(Map map) { - return VideoPlayerSettingsModel( - screenBrightness: map['screenBrightness']?.toDouble(), - videoFit: BoxFit.values.firstWhereOrNull((element) => element.name == map['videoFit']) ?? BoxFit.contain, - fillScreen: map['fillScreen'] ?? false, - hardwareAccel: map['hardwareAccel'] ?? false, - useLibass: map['useLibass'] ?? false, - internalVolume: map['internalVolume']?.toDouble() ?? 0.0, - audioDevice: map['audioDevice'], - ); - } - - String toJson() => json.encode(toMap()); - - factory VideoPlayerSettingsModel.fromJson(String source) => VideoPlayerSettingsModel.fromMap(json.decode(source)); - - @override - String toString() { - return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, internalVolume: $internalVolume, audioDevice: $audioDevice)'; - } + factory VideoPlayerSettingsModel.fromJson(Map json) => _$VideoPlayerSettingsModelFromJson(json); bool playerSame(VideoPlayerSettingsModel other) { return other.hardwareAccel == hardwareAccel && other.useLibass == useLibass; @@ -111,3 +60,23 @@ class VideoPlayerSettingsModel { audioDevice.hashCode; } } + +enum AutoNextType { + off, + smart, + static; + + const AutoNextType(); + + String label(BuildContext context) => switch (this) { + AutoNextType.off => context.localized.off, + AutoNextType.smart => context.localized.autoNextOffSmartTitle, + AutoNextType.static => context.localized.autoNextOffStaticTitle, + }; + + String desc(BuildContext context) => switch (this) { + AutoNextType.off => context.localized.off, + AutoNextType.smart => context.localized.autoNextOffSmartDesc, + AutoNextType.static => context.localized.autoNextOffStaticDesc, + }; +} diff --git a/lib/models/settings/video_player_settings.freezed.dart b/lib/models/settings/video_player_settings.freezed.dart new file mode 100644 index 0000000..d9356d7 --- /dev/null +++ b/lib/models/settings/video_player_settings.freezed.dart @@ -0,0 +1,319 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'video_player_settings.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +VideoPlayerSettingsModel _$VideoPlayerSettingsModelFromJson( + Map json) { + return _VideoPlayerSettingsModel.fromJson(json); +} + +/// @nodoc +mixin _$VideoPlayerSettingsModel { + double? get screenBrightness => throw _privateConstructorUsedError; + BoxFit get videoFit => throw _privateConstructorUsedError; + bool get fillScreen => throw _privateConstructorUsedError; + bool get hardwareAccel => throw _privateConstructorUsedError; + bool get useLibass => throw _privateConstructorUsedError; + double get internalVolume => throw _privateConstructorUsedError; + AutoNextType get nextVideoType => throw _privateConstructorUsedError; + String? get audioDevice => throw _privateConstructorUsedError; + + /// Serializes this VideoPlayerSettingsModel to a JSON map. + Map toJson() => throw _privateConstructorUsedError; + + /// Create a copy of VideoPlayerSettingsModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $VideoPlayerSettingsModelCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $VideoPlayerSettingsModelCopyWith<$Res> { + factory $VideoPlayerSettingsModelCopyWith(VideoPlayerSettingsModel value, + $Res Function(VideoPlayerSettingsModel) then) = + _$VideoPlayerSettingsModelCopyWithImpl<$Res, VideoPlayerSettingsModel>; + @useResult + $Res call( + {double? screenBrightness, + BoxFit videoFit, + bool fillScreen, + bool hardwareAccel, + bool useLibass, + double internalVolume, + AutoNextType nextVideoType, + String? audioDevice}); +} + +/// @nodoc +class _$VideoPlayerSettingsModelCopyWithImpl<$Res, + $Val extends VideoPlayerSettingsModel> + implements $VideoPlayerSettingsModelCopyWith<$Res> { + _$VideoPlayerSettingsModelCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of VideoPlayerSettingsModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? screenBrightness = freezed, + Object? videoFit = null, + Object? fillScreen = null, + Object? hardwareAccel = null, + Object? useLibass = null, + Object? internalVolume = null, + Object? nextVideoType = null, + Object? audioDevice = freezed, + }) { + return _then(_value.copyWith( + screenBrightness: freezed == screenBrightness + ? _value.screenBrightness + : screenBrightness // ignore: cast_nullable_to_non_nullable + as double?, + videoFit: null == videoFit + ? _value.videoFit + : videoFit // ignore: cast_nullable_to_non_nullable + as BoxFit, + fillScreen: null == fillScreen + ? _value.fillScreen + : fillScreen // ignore: cast_nullable_to_non_nullable + as bool, + hardwareAccel: null == hardwareAccel + ? _value.hardwareAccel + : hardwareAccel // ignore: cast_nullable_to_non_nullable + as bool, + useLibass: null == useLibass + ? _value.useLibass + : useLibass // ignore: cast_nullable_to_non_nullable + as bool, + internalVolume: null == internalVolume + ? _value.internalVolume + : internalVolume // ignore: cast_nullable_to_non_nullable + as double, + nextVideoType: null == nextVideoType + ? _value.nextVideoType + : nextVideoType // ignore: cast_nullable_to_non_nullable + as AutoNextType, + audioDevice: freezed == audioDevice + ? _value.audioDevice + : audioDevice // ignore: cast_nullable_to_non_nullable + as String?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$VideoPlayerSettingsModelImplCopyWith<$Res> + implements $VideoPlayerSettingsModelCopyWith<$Res> { + factory _$$VideoPlayerSettingsModelImplCopyWith( + _$VideoPlayerSettingsModelImpl value, + $Res Function(_$VideoPlayerSettingsModelImpl) then) = + __$$VideoPlayerSettingsModelImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {double? screenBrightness, + BoxFit videoFit, + bool fillScreen, + bool hardwareAccel, + bool useLibass, + double internalVolume, + AutoNextType nextVideoType, + String? audioDevice}); +} + +/// @nodoc +class __$$VideoPlayerSettingsModelImplCopyWithImpl<$Res> + extends _$VideoPlayerSettingsModelCopyWithImpl<$Res, + _$VideoPlayerSettingsModelImpl> + implements _$$VideoPlayerSettingsModelImplCopyWith<$Res> { + __$$VideoPlayerSettingsModelImplCopyWithImpl( + _$VideoPlayerSettingsModelImpl _value, + $Res Function(_$VideoPlayerSettingsModelImpl) _then) + : super(_value, _then); + + /// Create a copy of VideoPlayerSettingsModel + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? screenBrightness = freezed, + Object? videoFit = null, + Object? fillScreen = null, + Object? hardwareAccel = null, + Object? useLibass = null, + Object? internalVolume = null, + Object? nextVideoType = null, + Object? audioDevice = freezed, + }) { + return _then(_$VideoPlayerSettingsModelImpl( + screenBrightness: freezed == screenBrightness + ? _value.screenBrightness + : screenBrightness // ignore: cast_nullable_to_non_nullable + as double?, + videoFit: null == videoFit + ? _value.videoFit + : videoFit // ignore: cast_nullable_to_non_nullable + as BoxFit, + fillScreen: null == fillScreen + ? _value.fillScreen + : fillScreen // ignore: cast_nullable_to_non_nullable + as bool, + hardwareAccel: null == hardwareAccel + ? _value.hardwareAccel + : hardwareAccel // ignore: cast_nullable_to_non_nullable + as bool, + useLibass: null == useLibass + ? _value.useLibass + : useLibass // ignore: cast_nullable_to_non_nullable + as bool, + internalVolume: null == internalVolume + ? _value.internalVolume + : internalVolume // ignore: cast_nullable_to_non_nullable + as double, + nextVideoType: null == nextVideoType + ? _value.nextVideoType + : nextVideoType // ignore: cast_nullable_to_non_nullable + as AutoNextType, + audioDevice: freezed == audioDevice + ? _value.audioDevice + : audioDevice // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$VideoPlayerSettingsModelImpl extends _VideoPlayerSettingsModel + with DiagnosticableTreeMixin { + _$VideoPlayerSettingsModelImpl( + {this.screenBrightness, + this.videoFit = BoxFit.contain, + this.fillScreen = false, + this.hardwareAccel = true, + this.useLibass = false, + this.internalVolume = 100, + this.nextVideoType = AutoNextType.static, + this.audioDevice}) + : super._(); + + factory _$VideoPlayerSettingsModelImpl.fromJson(Map json) => + _$$VideoPlayerSettingsModelImplFromJson(json); + + @override + final double? screenBrightness; + @override + @JsonKey() + final BoxFit videoFit; + @override + @JsonKey() + final bool fillScreen; + @override + @JsonKey() + final bool hardwareAccel; + @override + @JsonKey() + final bool useLibass; + @override + @JsonKey() + final double internalVolume; + @override + @JsonKey() + final AutoNextType nextVideoType; + @override + final String? audioDevice; + + @override + String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) { + return 'VideoPlayerSettingsModel(screenBrightness: $screenBrightness, videoFit: $videoFit, fillScreen: $fillScreen, hardwareAccel: $hardwareAccel, useLibass: $useLibass, internalVolume: $internalVolume, nextVideoType: $nextVideoType, audioDevice: $audioDevice)'; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties + ..add(DiagnosticsProperty('type', 'VideoPlayerSettingsModel')) + ..add(DiagnosticsProperty('screenBrightness', screenBrightness)) + ..add(DiagnosticsProperty('videoFit', videoFit)) + ..add(DiagnosticsProperty('fillScreen', fillScreen)) + ..add(DiagnosticsProperty('hardwareAccel', hardwareAccel)) + ..add(DiagnosticsProperty('useLibass', useLibass)) + ..add(DiagnosticsProperty('internalVolume', internalVolume)) + ..add(DiagnosticsProperty('nextVideoType', nextVideoType)) + ..add(DiagnosticsProperty('audioDevice', audioDevice)); + } + + /// Create a copy of VideoPlayerSettingsModel + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$VideoPlayerSettingsModelImplCopyWith<_$VideoPlayerSettingsModelImpl> + get copyWith => __$$VideoPlayerSettingsModelImplCopyWithImpl< + _$VideoPlayerSettingsModelImpl>(this, _$identity); + + @override + Map toJson() { + return _$$VideoPlayerSettingsModelImplToJson( + this, + ); + } +} + +abstract class _VideoPlayerSettingsModel extends VideoPlayerSettingsModel { + factory _VideoPlayerSettingsModel( + {final double? screenBrightness, + final BoxFit videoFit, + final bool fillScreen, + final bool hardwareAccel, + final bool useLibass, + final double internalVolume, + final AutoNextType nextVideoType, + final String? audioDevice}) = _$VideoPlayerSettingsModelImpl; + _VideoPlayerSettingsModel._() : super._(); + + factory _VideoPlayerSettingsModel.fromJson(Map json) = + _$VideoPlayerSettingsModelImpl.fromJson; + + @override + double? get screenBrightness; + @override + BoxFit get videoFit; + @override + bool get fillScreen; + @override + bool get hardwareAccel; + @override + bool get useLibass; + @override + double get internalVolume; + @override + AutoNextType get nextVideoType; + @override + String? get audioDevice; + + /// Create a copy of VideoPlayerSettingsModel + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$VideoPlayerSettingsModelImplCopyWith<_$VideoPlayerSettingsModelImpl> + get copyWith => throw _privateConstructorUsedError; +} diff --git a/lib/models/settings/video_player_settings.g.dart b/lib/models/settings/video_player_settings.g.dart new file mode 100644 index 0000000..879e8af --- /dev/null +++ b/lib/models/settings/video_player_settings.g.dart @@ -0,0 +1,52 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'video_player_settings.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_$VideoPlayerSettingsModelImpl _$$VideoPlayerSettingsModelImplFromJson( + Map json) => + _$VideoPlayerSettingsModelImpl( + screenBrightness: (json['screenBrightness'] as num?)?.toDouble(), + videoFit: $enumDecodeNullable(_$BoxFitEnumMap, json['videoFit']) ?? + BoxFit.contain, + fillScreen: json['fillScreen'] as bool? ?? false, + hardwareAccel: json['hardwareAccel'] as bool? ?? true, + useLibass: json['useLibass'] as bool? ?? false, + internalVolume: (json['internalVolume'] as num?)?.toDouble() ?? 100, + nextVideoType: + $enumDecodeNullable(_$AutoNextTypeEnumMap, json['nextVideoType']) ?? + AutoNextType.static, + audioDevice: json['audioDevice'] as String?, + ); + +Map _$$VideoPlayerSettingsModelImplToJson( + _$VideoPlayerSettingsModelImpl instance) => + { + 'screenBrightness': instance.screenBrightness, + 'videoFit': _$BoxFitEnumMap[instance.videoFit]!, + 'fillScreen': instance.fillScreen, + 'hardwareAccel': instance.hardwareAccel, + 'useLibass': instance.useLibass, + 'internalVolume': instance.internalVolume, + 'nextVideoType': _$AutoNextTypeEnumMap[instance.nextVideoType]!, + 'audioDevice': instance.audioDevice, + }; + +const _$BoxFitEnumMap = { + BoxFit.fill: 'fill', + BoxFit.contain: 'contain', + BoxFit.cover: 'cover', + BoxFit.fitWidth: 'fitWidth', + BoxFit.fitHeight: 'fitHeight', + BoxFit.none: 'none', + BoxFit.scaleDown: 'scaleDown', +}; + +const _$AutoNextTypeEnumMap = { + AutoNextType.off: 'off', + AutoNextType.smart: 'smart', + AutoNextType.static: 'static', +}; diff --git a/lib/providers/settings/video_player_settings_provider.dart b/lib/providers/settings/video_player_settings_provider.dart index 2c297f3..c69407a 100644 --- a/lib/providers/settings/video_player_settings_provider.dart +++ b/lib/providers/settings/video_player_settings_provider.dart @@ -13,7 +13,7 @@ final videoPlayerSettingsProvider = }); class VideoPlayerSettingsProviderNotifier extends StateNotifier { - VideoPlayerSettingsProviderNotifier(this.ref) : super(const VideoPlayerSettingsModel()); + VideoPlayerSettingsProviderNotifier(this.ref) : super(VideoPlayerSettingsModel()); final Ref ref; @@ -29,7 +29,7 @@ class VideoPlayerSettingsProviderNotifier extends StateNotifier value, + screenBrightness: value, ); if (state.screenBrightness != null) { ScreenBrightness().setScreenBrightness(state.screenBrightness!); @@ -45,13 +45,13 @@ class VideoPlayerSettingsProviderNotifier extends StateNotifier state = state.copyWith(hardwareAccel: value); - void setUseLibass(bool? value) => state = state.copyWith(useLibass: value); + void setHardwareAccel(bool? value) => state = state.copyWith(hardwareAccel: value ?? true); + void setUseLibass(bool? value) => state = state.copyWith(useLibass: value ?? false); - void setFitType(BoxFit? value) => state = state.copyWith(videoFit: value); + void setFitType(BoxFit? value) => state = state.copyWith(videoFit: value ?? BoxFit.contain); void setVolume(double value) { state = state.copyWith(internalVolume: value); diff --git a/lib/providers/shared_provider.dart b/lib/providers/shared_provider.dart index f53998a..ecc7a5d 100644 --- a/lib/providers/shared_provider.dart +++ b/lib/providers/shared_provider.dart @@ -170,15 +170,15 @@ class SharedUtility { VideoPlayerSettingsModel get videoPlayerSettings { try { - return VideoPlayerSettingsModel.fromJson(sharedPreferences.getString(_videoPlayerSettingsKey) ?? ""); + return VideoPlayerSettingsModel.fromJson(jsonDecode(sharedPreferences.getString(_videoPlayerSettingsKey) ?? "")); } catch (e) { log(e.toString()); - return const VideoPlayerSettingsModel(); + return VideoPlayerSettingsModel(); } } set videoPlayerSettings(VideoPlayerSettingsModel settings) { - sharedPreferences.setString(_videoPlayerSettingsKey, settings.toJson()); + sharedPreferences.setString(_videoPlayerSettingsKey, jsonEncode(settings.toJson())); } PhotoViewSettingsModel get photoViewSettings { diff --git a/lib/screens/photo_viewer/photo_viewer_controls.dart b/lib/screens/photo_viewer/photo_viewer_controls.dart index 620acb4..4fe5a76 100644 --- a/lib/screens/photo_viewer/photo_viewer_controls.dart +++ b/lib/screens/photo_viewer/photo_viewer_controls.dart @@ -55,8 +55,8 @@ class _PhotoViewerControllsState extends ConsumerState with double dragUpDelta = 0.0; final controller = TextEditingController(); - late final timerController = - RestarableTimerController(ref.read(photoViewSettingsProvider).timer, const Duration(milliseconds: 32), () { + late final timerController = RestarableTimerController( + ref.read(photoViewSettingsProvider).timer, const Duration(milliseconds: 32), onTimeout: () { if (widget.pageController.page == widget.itemCount - 1) { widget.pageController.animateToPage(0, duration: const Duration(milliseconds: 250), curve: Curves.easeInOut); } else { @@ -314,6 +314,13 @@ class _PhotoViewerControllsState extends ConsumerState with ), ProgressFloatingButton( controller: timerController, + onLongPress: (duration) { + if (duration != null) { + ref + .read(photoViewSettingsProvider.notifier) + .update((state) => state.copyWith(timer: duration)); + } + }, ), ].addPadding(const EdgeInsets.symmetric(horizontal: 8)), ), diff --git a/lib/screens/settings/player_settings_page.dart b/lib/screens/settings/player_settings_page.dart index 9b36117..65ce4a1 100644 --- a/lib/screens/settings/player_settings_page.dart +++ b/lib/screens/settings/player_settings_page.dart @@ -1,4 +1,12 @@ +import 'dart:io' show Platform; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + import 'package:auto_route/auto_route.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fladder/models/settings/video_player_settings.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/screens/settings/settings_list_tile.dart'; import 'package:fladder/screens/settings/settings_scaffold.dart'; @@ -10,10 +18,7 @@ import 'package:fladder/util/adaptive_layout.dart'; import 'package:fladder/util/box_fit_extension.dart'; import 'package:fladder/util/localization_helper.dart'; import 'package:fladder/util/option_dialogue.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'dart:io' show Platform; +import 'package:fladder/widgets/shared/enum_selection.dart'; @RoutePage() class PlayerSettingsPage extends ConsumerStatefulWidget { @@ -104,6 +109,34 @@ class _PlayerSettingsPageState extends ConsumerState { : Container(), ), ], + SettingsListTile( + label: Text(context.localized.settingsAutoNextTitle), + subLabel: Text(context.localized.settingsAutoNextDesc), + trailing: EnumBox( + current: ref.watch( + videoPlayerSettingsProvider.select( + (value) => value.nextVideoType.label(context), + ), + ), + itemBuilder: (context) => AutoNextType.values + .map( + (entry) => PopupMenuItem( + value: entry, + child: Text(entry.label(context)), + onTap: () => ref.read(videoPlayerSettingsProvider.notifier).state = + ref.read(videoPlayerSettingsProvider).copyWith(nextVideoType: entry), + ), + ) + .toList(), + ), + ), + AnimatedFadeSize( + child: switch (ref.watch(videoPlayerSettingsProvider.select((value) => value.nextVideoType))) { + AutoNextType.smart => SettingsMessageBox(AutoNextType.smart.desc(context)), + AutoNextType.static => SettingsMessageBox(AutoNextType.static.desc(context)), + _ => const SizedBox.shrink(), + }, + ), SettingsListTile( label: Text(context.localized.settingsPlayerCustomSubtitlesTitle), subLabel: Text(context.localized.settingsPlayerCustomSubtitlesDesc), diff --git a/lib/screens/settings/widgets/settings_message_box.dart b/lib/screens/settings/widgets/settings_message_box.dart index d8a722b..68f7906 100644 --- a/lib/screens/settings/widgets/settings_message_box.dart +++ b/lib/screens/settings/widgets/settings_message_box.dart @@ -1,21 +1,20 @@ import 'package:flutter/material.dart'; + +import 'package:ficonsax/ficonsax.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fladder/util/list_padding.dart'; + enum MessageType { info, warning, error; - Color color(BuildContext context) { - switch (this) { - case info: - return Theme.of(context).colorScheme.surface; - case warning: - return Theme.of(context).colorScheme.primaryContainer; - case error: - return Theme.of(context).colorScheme.errorContainer; - } - } + Color color(BuildContext context) => switch (this) { + MessageType.info => Theme.of(context).colorScheme.secondaryContainer, + MessageType.warning => Theme.of(context).colorScheme.primaryContainer, + MessageType.error => Theme.of(context).colorScheme.errorContainer, + }; } class SettingsMessageBox extends ConsumerWidget { @@ -38,7 +37,20 @@ class SettingsMessageBox extends ConsumerWidget { color: messageType.color(context), child: Padding( padding: const EdgeInsets.all(8.0), - child: Text(message), + child: Row( + children: [ + Icon( + switch (messageType) { + MessageType.info => IconsaxOutline.information, + MessageType.warning => IconsaxOutline.warning_2, + MessageType.error => IconsaxOutline.danger, + }, + ), + Flexible( + child: Text(message), + ), + ].addInBetween(const SizedBox(width: 12)), + ), ), ), ), diff --git a/lib/screens/video_player/components/video_player_next_wrapper.dart b/lib/screens/video_player/components/video_player_next_wrapper.dart new file mode 100644 index 0000000..6e0cdba --- /dev/null +++ b/lib/screens/video_player/components/video_player_next_wrapper.dart @@ -0,0 +1,428 @@ +import 'package:flutter/material.dart'; + +import 'package:ficonsax/ficonsax.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'package:fladder/models/item_base_model.dart'; +import 'package:fladder/models/items/movie_model.dart'; +import 'package:fladder/models/media_playback_model.dart'; +import 'package:fladder/models/playback/playback_model.dart'; +import 'package:fladder/models/settings/video_player_settings.dart'; +import 'package:fladder/providers/settings/video_player_settings_provider.dart'; +import 'package:fladder/providers/user_provider.dart'; +import 'package:fladder/providers/video_player_provider.dart'; +import 'package:fladder/screens/shared/animated_fade_size.dart'; +import 'package:fladder/screens/shared/default_titlebar.dart'; +import 'package:fladder/util/adaptive_layout.dart'; +import 'package:fladder/util/fladder_image.dart'; +import 'package:fladder/util/list_padding.dart'; +import 'package:fladder/util/localization_helper.dart'; +import 'package:fladder/widgets/navigation_scaffold/components/floating_player_bar.dart'; +import 'package:fladder/widgets/shared/progress_floating_button.dart'; + +class VideoPlayerNextWrapper extends ConsumerStatefulWidget { + final Widget video; + final Widget controls; + final List overlays; + const VideoPlayerNextWrapper({ + required this.video, + required this.controls, + this.overlays = const [], + super.key, + }); + + @override + ConsumerState createState() => _VideoPlayerNextWrapperState(); +} + +class _VideoPlayerNextWrapperState extends ConsumerState { + bool show = false; + bool showOverwrite = false; + late RestarableTimerController timerController = + RestarableTimerController(const Duration(seconds: 30), const Duration(milliseconds: 33), onTimeout: onTimeOut); + + void onTimeOut() { + timerController.cancel(); + if (showOverwrite == true) return; + final nextUp = ref.read(playBackModel.select((value) => value?.nextVideo)); + if (nextUp != null) { + ref.read(playbackModelHelper).loadNewVideo(nextUp); + } + hideNextUp(); + } + + void showNextScreen(MediaPlaybackModel model) { + final nextUp = ref.read(playBackModel.select((value) => value?.nextVideo)); + if (nextUp == null) return; + if (show) return; + if (showOverwrite) return; + if (!model.playing) return; + if (model.buffering) return; + + setState(() { + show = true; + timerController.reset(); + timerController.play(); + }); + } + + void determineShow(MediaPlaybackModel model) { + final playerState = ref.watch(mediaPlaybackProvider.select((value) => value.state)); + if (playerState != VideoPlayerState.fullScreen) { + showOverwrite = false; + show = false; + return; + } + + final nextType = ref.read(videoPlayerSettingsProvider.select((value) => value.nextVideoType)); + if (nextType == AutoNextType.off || model.duration < const Duration(seconds: 40)) { + showOverwrite = false; + show = false; + return; + } + + final credits = ref.read(playBackModel)?.introSkipModel?.credits; + + if (nextType == AutoNextType.static || credits == null) { + if ((model.duration - model.position).abs() < const Duration(seconds: 32)) { + showNextScreen(model); + return; + } + } else if (nextType == AutoNextType.smart) { + final maxTime = ref.read(userProvider.select((value) => value?.serverConfiguration?.maxResumePct ?? 90)); + final resumeDuration = model.duration * (maxTime / 100); + final timeLeft = model.duration - credits.end; + if (credits.end > resumeDuration && timeLeft < const Duration(seconds: 30)) { + if (model.position >= credits.start) { + showNextScreen(model); + return; + } + } else if ((model.duration - model.position).abs() < const Duration(seconds: 32)) { + showNextScreen(model); + return; + } + } + setState(() { + show = false; + showOverwrite = false; + timerController.cancel(); + }); + } + + void hideNextUp() { + timerController.cancel(); + setState(() { + show = false; + showOverwrite = true; + }); + } + + @override + void dispose() { + timerController.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + const animSpeed = Duration(milliseconds: 250); + final nextUp = ref.watch(playBackModel.select((value) => value?.nextVideo)); + final currentItem = ref.watch(playBackModel.select((value) => value?.item)); + final portraitMode = MediaQuery.sizeOf(context).width < MediaQuery.sizeOf(context).height; + + double padding = show ? 16 : 0; + + ref.listen(mediaPlaybackProvider, (previous, next) => determineShow(next)); + return Hero( + tag: videoPlayerHeroTag, + child: Stack( + children: [ + Container( + color: Theme.of(context).colorScheme.surfaceContainerLowest.withOpacity(0.2), + ), + if (nextUp != null) + AnimatedAlign( + duration: animSpeed, + alignment: portraitMode ? Alignment.bottomCenter : Alignment.centerRight, + child: AnimatedOpacity( + duration: animSpeed, + opacity: show ? 1 : 0, + child: Padding( + padding: MediaQuery.paddingOf(context).add(const EdgeInsets.all(32)), + child: FractionallySizedBox( + widthFactor: portraitMode ? null : 0.35, + heightFactor: portraitMode ? 0.5 : null, + child: Card( + elevation: 10, + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Text( + context.localized.nextUp, + softWrap: false, + overflow: TextOverflow.fade, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontWeight: FontWeight.bold, fontSize: 24.0), + ), + ), + SizedBox.square( + dimension: 45.0, + child: ProgressFloatingButton( + controller: timerController, + ), + ), + ].addInBetween( + const SizedBox( + height: 16, + width: 16, + ), + ), + ), + const Divider(), + Flexible( + child: SingleChildScrollView( + child: _NextUpInformation( + item: nextUp, + ), + ), + ), + ].addInBetween(const SizedBox( + height: 8, + width: 8, + )), + ), + ), + ), + ), + ), + ), + ), + AnimatedAlign( + duration: animSpeed, + alignment: portraitMode ? Alignment.topCenter : Alignment.centerLeft, + child: AnimatedPadding( + duration: animSpeed, + padding: EdgeInsets.all(padding).add(show ? MediaQuery.paddingOf(context) : EdgeInsets.zero), + child: AnimatedFractionallySizedBox( + duration: animSpeed, + heightFactor: show ? (portraitMode ? 0.40 : 0.9) : 1.0, + widthFactor: show ? (portraitMode ? 1 : 0.60) : 1.0, + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (currentItem != null) + AnimatedFadeSize( + duration: animSpeed, + child: show + ? Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Text(currentItem.title, + style: Theme.of(context).textTheme.displaySmall)), + if (currentItem.label(context) != null) + Flexible( + child: Text( + currentItem.label(context)!, + maxLines: 2, + overflow: TextOverflow.fade, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton.filledTonal( + onPressed: () => hideNextUp(), + tooltip: "Resume video", + icon: const Icon(IconsaxBold.maximize_4), + ), + ], + ), + ) + : const SizedBox.shrink(), + ), + Flexible( + child: Stack( + fit: StackFit.passthrough, + children: [ + AnimatedContainer( + duration: animSpeed, + decoration: BoxDecoration( + color: Colors.black, + borderRadius: BorderRadius.circular(show ? 16 : 0), + ), + child: widget.video, + ), + IgnorePointer( + ignoring: show, + child: AnimatedOpacity( + opacity: show ? 0 : 1, + duration: animSpeed, + child: widget.controls, + ), + ), + ], + ), + ), + AnimatedFadeSize( + duration: animSpeed, + child: show + ? Padding( + padding: const EdgeInsets.only(top: 16), + child: _SimpleControls( + skip: nextUp != null ? () => onTimeOut() : null, + ), + ) + : const SizedBox.shrink(), + ), + ], + ), + ), + ), + ), + if (AdaptiveLayout.of(context).isDesktop) + const Align( + alignment: Alignment.topRight, + child: DefaultTitleBar(), + ), + ], + ), + ); + } +} + +class _NextUpInformation extends StatelessWidget { + final ItemBaseModel item; + const _NextUpInformation({ + required this.item, + }); + + @override + Widget build(BuildContext context) { + return switch (item) { + MovieModel _ => Row( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 150), + child: AspectRatio( + aspectRatio: 0.67, + child: Card( + child: FladderImage( + image: item.images?.primary, + ), + ), + ), + ), + ), + Text( + item.title, + style: Theme.of(context).textTheme.titleLarge, + ), + ].addInBetween( + const SizedBox(height: 8), + ), + ), + Flexible( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.localized.overview, + style: Theme.of(context).textTheme.titleLarge, + ), + const Divider(), + Text(item.overview.summary), + ], + ), + ) + ].addInBetween( + const SizedBox(width: 16), + ), + ), + _ => Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: Theme.of(context).textTheme.titleLarge, + ), + if (item.label(context) != null) + Text( + item.label(context)!, + ), + Flexible( + child: AspectRatio( + aspectRatio: 2.1, + child: Card( + child: FladderImage( + image: item.images?.primary, + ), + ), + ), + ), + Text( + context.localized.overview, + style: Theme.of(context).textTheme.titleLarge, + ), + Text(item.overview.summary), + const SizedBox(height: 12) + ].addInBetween( + const SizedBox(height: 8), + ), + ) + }; + } +} + +class _SimpleControls extends ConsumerWidget { + final Function()? skip; + const _SimpleControls({ + this.skip, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final player = ref.watch(videoPlayerProvider.select((value) => value.controller?.player)); + final isPlaying = ref.watch(mediaPlaybackProvider.select((value) => value.playing)); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton.filledTonal( + onPressed: () => player?.playOrPause(), + icon: Icon(isPlaying ? IconsaxBold.pause : IconsaxBold.play), + ), + if (skip != null) + IconButton.filledTonal( + onPressed: skip, + tooltip: "Play next video", + icon: const Icon(IconsaxBold.next), + ) + ].addInBetween(const SizedBox(width: 4))); + } +} diff --git a/lib/screens/video_player/video_player.dart b/lib/screens/video_player/video_player.dart index d5592f7..951d674 100644 --- a/lib/screens/video_player/video_player.dart +++ b/lib/screens/video_player/video_player.dart @@ -9,6 +9,7 @@ import 'package:media_kit_video/media_kit_video.dart'; import 'package:fladder/models/media_playback_model.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; +import 'package:fladder/screens/video_player/components/video_player_next_wrapper.dart'; import 'package:fladder/screens/video_player/video_player_controls.dart'; import 'package:fladder/util/adaptive_layout.dart'; import 'package:fladder/util/themes_data.dart'; @@ -87,12 +88,9 @@ class _VideoPlayerState extends ConsumerState with WidgetsBindingOb } lastScale = 0.0; }, - child: Hero( - tag: "HeroPlayer", - child: Stack( - children: [ - if (playerController != null) - Padding( + child: VideoPlayerNextWrapper( + video: playerController != null + ? Padding( padding: fillScreen ? EdgeInsets.zero : EdgeInsets.only(left: padding.left, right: padding.right), child: OrientationBuilder(builder: (context, orientation) { return Video( @@ -107,11 +105,12 @@ class _VideoPlayerState extends ConsumerState with WidgetsBindingOb controls: NoVideoControls, ); }), - ), - const DesktopControls(), - if (errorPlaying) const _VideoErrorWidget(), - ], - ), + ) + : const SizedBox.shrink(), + controls: const DesktopControls(), + overlays: [ + if (errorPlaying) const _VideoErrorWidget(), + ], ), ), ), diff --git a/lib/widgets/navigation_scaffold/components/floating_player_bar.dart b/lib/widgets/navigation_scaffold/components/floating_player_bar.dart index 437cd3e..a39b063 100644 --- a/lib/widgets/navigation_scaffold/components/floating_player_bar.dart +++ b/lib/widgets/navigation_scaffold/components/floating_player_bar.dart @@ -1,4 +1,11 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + import 'package:ficonsax/ficonsax.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:media_kit_video/media_kit_video.dart'; +import 'package:window_manager/window_manager.dart'; + import 'package:fladder/models/media_playback_model.dart'; import 'package:fladder/providers/settings/video_player_settings_provider.dart'; import 'package:fladder/providers/video_player_provider.dart'; @@ -9,11 +16,8 @@ import 'package:fladder/util/adaptive_layout.dart'; import 'package:fladder/util/duration_extensions.dart'; import 'package:fladder/util/list_padding.dart'; import 'package:fladder/util/refresh_state.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:media_kit_video/media_kit_video.dart'; -import 'package:window_manager/window_manager.dart'; + +const videoPlayerHeroTag = "HeroPlayer"; class FloatingPlayerBar extends ConsumerStatefulWidget { const FloatingPlayerBar({super.key}); @@ -97,7 +101,7 @@ class _CurrentlyPlayingBarState extends ConsumerState { child: Stack( children: [ Hero( - tag: "HeroPlayer", + tag: videoPlayerHeroTag, child: Video( controller: player.controller!, fit: BoxFit.fitHeight, diff --git a/lib/widgets/shared/progress_floating_button.dart b/lib/widgets/shared/progress_floating_button.dart index 168899f..3284d30 100644 --- a/lib/widgets/shared/progress_floating_button.dart +++ b/lib/widgets/shared/progress_floating_button.dart @@ -1,19 +1,20 @@ import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:async/async.dart'; import 'package:ficonsax/ficonsax.dart'; -import 'package:fladder/providers/settings/photo_view_settings_provider.dart'; -import 'package:fladder/util/simple_duration_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:square_progress_indicator/square_progress_indicator.dart'; +import 'package:fladder/util/simple_duration_picker.dart'; + class RestarableTimerController { late Duration _steps = const Duration(milliseconds: 32); RestartableTimer? _timer; late Duration _duration = const Duration(seconds: 1); - late Function() _onTimeout; + late Function()? _onTimeout; late Duration _timeLeft = _duration; set setTimeLeft(Duration value) { @@ -24,7 +25,7 @@ class RestarableTimerController { final StreamController _timeLeftController = StreamController.broadcast(); final StreamController _isActiveController = StreamController.broadcast(); - RestarableTimerController(Duration duration, Duration steps, Function() onTimeout) { + RestarableTimerController(Duration duration, Duration steps, {Function()? onTimeout}) { _steps = steps; _duration = duration; _onTimeout = onTimeout; @@ -50,7 +51,7 @@ class RestarableTimerController { () { if (_timeLeft < _steps) { setTimeLeft = _duration; - _onTimeout.call(); + _onTimeout?.call(); } else { setTimeLeft = _timeLeft - _steps; } @@ -86,7 +87,8 @@ class RestarableTimerController { class ProgressFloatingButton extends ConsumerStatefulWidget { final RestarableTimerController? controller; final Function()? onTimeOut; - const ProgressFloatingButton({this.controller, this.onTimeOut, super.key}); + final Function(Duration? newDuration)? onLongPress; + const ProgressFloatingButton({this.controller, this.onTimeOut, this.onLongPress, super.key}); @override ConsumerState createState() => _ProgressFloatingButtonState(); @@ -106,7 +108,7 @@ class _ProgressFloatingButtonState extends ConsumerState RestarableTimerController( const Duration(seconds: 1), const Duration(milliseconds: 32), - widget.onTimeOut ?? () {}, + onTimeout: widget.onTimeOut ?? () {}, ); subscriptions.addAll([ timer.timeLeft.listen((event) => setState(() => timeLeft = event)), @@ -132,18 +134,21 @@ class _ProgressFloatingButtonState extends ConsumerState timer.reset(); }); }, - onLongPress: () async { - HapticFeedback.vibrate(); - final newTimer = - await showSimpleDurationPicker(context: context, initialValue: timer._duration, showNever: false); - if (newTimer != null) { - setState(() { - ref.read(photoViewSettingsProvider.notifier).update((state) => state.copyWith(timer: newTimer)); - timer.setDuration(newTimer); - }); - } - }, + onLongPress: widget.onLongPress != null + ? () async { + HapticFeedback.vibrate(); + final newTimer = + await showSimpleDurationPicker(context: context, initialValue: timer._duration, showNever: false); + widget.onLongPress?.call(newTimer); + if (newTimer != null) { + setState(() { + timer.setDuration(newTimer); + }); + } + } + : null, child: FloatingActionButton( + heroTag: null, onPressed: isActive ? timer.cancel : timer.play, child: Stack( fit: StackFit.expand,