feature: Auto next-up preview, skip to next in queue. (#96)

Co-authored-by: PartyDonut <PartyDonut@users.noreply.github.com>
This commit is contained in:
PartyDonut 2024-11-01 15:52:54 +01:00 committed by GitHub
parent f72ae9e3ca
commit 66f2b6cd4e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 971 additions and 137 deletions

View file

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

View file

@ -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<double?>? screenBrightness,
BoxFit? videoFit,
bool? fillScreen,
bool? hardwareAccel,
bool? useLibass,
double? internalVolume,
ValueGetter<String?>? 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<String, dynamic> toMap() {
return {
'screenBrightness': screenBrightness,
'videoFit': videoFit.name,
'fillScreen': fillScreen,
'hardwareAccel': hardwareAccel,
'useLibass': useLibass,
'internalVolume': internalVolume,
'audioDevice': audioDevice,
};
}
factory VideoPlayerSettingsModel.fromMap(Map<String, dynamic> 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<String, dynamic> 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,
};
}

View file

@ -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>(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<String, dynamic> 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<String, dynamic> 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<VideoPlayerSettingsModel> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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;
}

View file

@ -0,0 +1,52 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'video_player_settings.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$VideoPlayerSettingsModelImpl _$$VideoPlayerSettingsModelImplFromJson(
Map<String, dynamic> 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<String, dynamic> _$$VideoPlayerSettingsModelImplToJson(
_$VideoPlayerSettingsModelImpl instance) =>
<String, dynamic>{
'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',
};

View file

@ -13,7 +13,7 @@ final videoPlayerSettingsProvider =
});
class VideoPlayerSettingsProviderNotifier extends StateNotifier<VideoPlayerSettingsModel> {
VideoPlayerSettingsProviderNotifier(this.ref) : super(const VideoPlayerSettingsModel());
VideoPlayerSettingsProviderNotifier(this.ref) : super(VideoPlayerSettingsModel());
final Ref ref;
@ -29,7 +29,7 @@ class VideoPlayerSettingsProviderNotifier extends StateNotifier<VideoPlayerSetti
void setScreenBrightness(double? value) async {
state = state.copyWith(
screenBrightness: () => value,
screenBrightness: value,
);
if (state.screenBrightness != null) {
ScreenBrightness().setScreenBrightness(state.screenBrightness!);
@ -45,13 +45,13 @@ class VideoPlayerSettingsProviderNotifier extends StateNotifier<VideoPlayerSetti
}
void setFillScreen(bool? value, {BuildContext? context}) {
state = state.copyWith(fillScreen: value);
state = state.copyWith(fillScreen: value ?? false);
}
void setHardwareAccel(bool? value) => 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);

View file

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

View file

@ -55,8 +55,8 @@ class _PhotoViewerControllsState extends ConsumerState<PhotoViewerControls> 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<PhotoViewerControls> 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)),
),

View file

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

View file

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

View file

@ -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<Widget> overlays;
const VideoPlayerNextWrapper({
required this.video,
required this.controls,
this.overlays = const [],
super.key,
});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _VideoPlayerNextWrapperState();
}
class _VideoPlayerNextWrapperState extends ConsumerState<VideoPlayerNextWrapper> {
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)));
}
}

View file

@ -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<VideoPlayer> 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<VideoPlayer> with WidgetsBindingOb
controls: NoVideoControls,
);
}),
),
const DesktopControls(),
if (errorPlaying) const _VideoErrorWidget(),
],
),
)
: const SizedBox.shrink(),
controls: const DesktopControls(),
overlays: [
if (errorPlaying) const _VideoErrorWidget(),
],
),
),
),

View file

@ -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<FloatingPlayerBar> {
child: Stack(
children: [
Hero(
tag: "HeroPlayer",
tag: videoPlayerHeroTag,
child: Video(
controller: player.controller!,
fit: BoxFit.fitHeight,

View file

@ -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<Duration> _timeLeftController = StreamController<Duration>.broadcast();
final StreamController<bool> _isActiveController = StreamController<bool>.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<ConsumerStatefulWidget> createState() => _ProgressFloatingButtonState();
@ -106,7 +108,7 @@ class _ProgressFloatingButtonState extends ConsumerState<ProgressFloatingButton>
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<ProgressFloatingButton>
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,