fix: Long press "play" button with dpad navigation

This commit is contained in:
PartyDonut 2025-10-03 22:37:37 +02:00
parent fd35ffb004
commit 3ce0ed6dbc
9 changed files with 188 additions and 121 deletions

View file

@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:fladder/models/video_stream_model.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/util/localization_helper.dart';
Future<PlaybackType?> showPlaybackTypeSelection({
@ -12,14 +15,12 @@ Future<PlaybackType?> showPlaybackTypeSelection({
await showDialog(
context: context,
useSafeArea: false,
builder: (context) => Dialog(
child: PlaybackDialogue(
options: options,
onClose: (type) {
playbackType = type;
Navigator.of(context).pop();
},
),
builder: (context) => PlaybackDialogue(
options: options,
onClose: (type) {
playbackType = type;
Navigator.of(context).pop();
},
),
);
return playbackType;
@ -32,26 +33,46 @@ class PlaybackDialogue extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16).add(const EdgeInsets.only(top: 16, bottom: 8)),
child: Text(
context.localized.playbackType,
style: Theme.of(context).textTheme.titleLarge,
return Dialog(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16).add(const EdgeInsets.only(top: 16, bottom: 8)),
child: Text(
context.localized.playbackType,
style: Theme.of(context).textTheme.titleLarge,
),
),
),
const Divider(),
...options.map((type) => ListTile(
title: Text(type.name(context)),
leading: Icon(type.icon),
onTap: () {
onClose(type);
},
))
],
const Divider(),
...options.mapIndexed(
(index, type) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: FocusButton(
autoFocus: index == 0,
onTap: () {
onClose(type);
},
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
spacing: 8,
mainAxisSize: MainAxisSize.max,
children: [
Icon(type.icon),
Text(type.name(context)),
],
),
),
),
),
),
const SizedBox(
height: 16,
)
],
),
);
}
}

View file

@ -81,7 +81,12 @@ class _BookDetailScreenState extends ConsumerState<BookDetailScreen> {
//Wrapped so the correct context is used for refreshing the pages
return MediaPlayButton(
item: details.nextUp!,
onPressed: () async => details.nextUp.play(context, ref, provider: provider),
onPressed: (restart) async => details.nextUp.play(
context,
ref,
provider: provider,
currentPage: restart ? 0 : null,
),
);
},
),

View file

@ -82,12 +82,21 @@ class _ItemDetailScreenState extends ConsumerState<EpisodeDetailScreen> {
playButton: episodeDetails.playAble
? MediaPlayButton(
item: episodeDetails,
onPressed: () async {
await details.episode.play(context, ref);
onPressed: (restart) async {
await details.episode.play(
context,
ref,
startPosition: restart ? Duration.zero : null,
);
ref.read(providerInstance.notifier).fetchDetails(widget.item);
},
onLongPressed: () async {
await details.episode.play(context, ref, showPlaybackOption: true);
onLongPressed: (restart) async {
await details.episode.play(
context,
ref,
showPlaybackOption: true,
startPosition: restart ? Duration.zero : null,
);
ref.read(providerInstance.notifier).fetchDetails(widget.item);
},
)

View file

@ -75,18 +75,20 @@ class _ItemDetailScreenState extends ConsumerState<MovieDetailScreen> {
padding: padding,
playButton: MediaPlayButton(
item: details,
onLongPressed: () async {
onLongPressed: (restart) async {
await details.play(
context,
ref,
showPlaybackOption: true,
startPosition: restart ? Duration.zero : null,
);
ref.read(providerInstance.notifier).fetchDetails(widget.item);
},
onPressed: () async {
onPressed: (restart) async {
await details.play(
context,
ref,
startPosition: restart ? Duration.zero : null,
);
ref.read(providerInstance.notifier).fetchDetails(widget.item);
},

View file

@ -76,21 +76,28 @@ class _SeriesDetailScreenState extends ConsumerState<SeriesDetailScreen> {
OverviewHeader(
name: details.name,
image: details.images,
playButton: MediaPlayButton(
item: details.nextUp,
onPressed: details.nextUp != null
? () async {
await details.nextUp.play(context, ref);
playButton: details.nextUp != null
? MediaPlayButton(
item: details.nextUp,
onPressed: (restart) async {
await details.nextUp.play(
context,
ref,
startPosition: restart ? Duration.zero : null,
);
ref.read(providerId.notifier).fetchDetails(widget.item);
}
: null,
onLongPressed: details.nextUp != null
? () async {
await details.nextUp.play(context, ref, showPlaybackOption: true);
},
onLongPressed: (restart) async {
await details.nextUp.play(
context,
ref,
showPlaybackOption: true,
startPosition: restart ? Duration.zero : null,
);
ref.read(providerId.notifier).fetchDetails(widget.item);
}
: null,
),
},
)
: null,
centerButtons: Wrap(
spacing: 8,
runSpacing: 8,

View file

@ -7,12 +7,13 @@ import 'package:fladder/models/item_base_model.dart';
import 'package:fladder/screens/shared/animated_fade_size.dart';
import 'package:fladder/theme.dart';
import 'package:fladder/util/adaptive_layout/adaptive_layout.dart';
import 'package:fladder/util/focus_provider.dart';
import 'package:fladder/widgets/shared/ensure_visible.dart';
class MediaPlayButton extends ConsumerWidget {
final ItemBaseModel? item;
final VoidCallback? onPressed;
final VoidCallback? onLongPressed;
final Function(bool restart)? onPressed;
final Function(bool restart)? onLongPressed;
const MediaPlayButton({
required this.item,
@ -26,17 +27,7 @@ class MediaPlayButton extends ConsumerWidget {
final progress = (item?.progress ?? 0) / 100.0;
final padding = 3.0;
final radius = FladderTheme.smallShape.borderRadius.subtract(BorderRadius.circular(padding));
final buttonState = WidgetStateProperty.resolveWith(
(states) {
return BorderSide(
width: 2,
color: Theme.of(context)
.colorScheme
.onPrimaryContainer
.withValues(alpha: states.contains(WidgetState.focused) ? 0.9 : 0.0),
);
},
);
final theme = Theme.of(context);
Widget buttonTitle(Color contentColor) {
return Padding(
@ -50,10 +41,10 @@ class MediaPlayButton extends ConsumerWidget {
item?.playButtonLabel(context) ?? "",
maxLines: 2,
overflow: TextOverflow.clip,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: contentColor,
),
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: contentColor,
),
),
),
const SizedBox(width: 4),
@ -70,54 +61,79 @@ class MediaPlayButton extends ConsumerWidget {
duration: const Duration(milliseconds: 250),
child: onPressed == null
? const SizedBox.shrink(key: ValueKey('empty'))
: TextButton(
onPressed: onPressed,
onLongPress: onLongPressed,
autofocus: AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad,
style: ButtonStyle(
side: buttonState,
padding: const WidgetStatePropertyAll(EdgeInsets.zero),
),
onFocusChange: (value) {
if (value) {
context.ensureVisible(
alignment: 1.0,
);
}
},
child: Padding(
padding: EdgeInsets.all(padding),
child: Stack(
alignment: Alignment.center,
children: [
// Progress background
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: radius,
),
),
),
// Button content
buttonTitle(Theme.of(context).colorScheme.onPrimaryContainer),
Positioned.fill(
child: ClipRect(
clipper: _ProgressClipper(
progress,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
borderRadius: radius,
: Row(
spacing: 2,
children: [
FocusButton(
onTap: () => onPressed?.call(false),
onLongPress: () => onLongPressed?.call(false),
autoFocus: AdaptiveLayout.inputDeviceOf(context) == InputDevice.dPad,
darkOverlay: false,
onFocusChanged: (value) {
if (value) {
context.ensureVisible(
alignment: 1.0,
);
}
},
child: Padding(
padding: EdgeInsets.all(padding),
child: Stack(
alignment: Alignment.center,
children: [
// Progress background
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: radius,
),
),
child: buttonTitle(Theme.of(context).colorScheme.onPrimary),
),
// Button content
buttonTitle(theme.colorScheme.onPrimaryContainer),
Positioned.fill(
child: ClipRect(
clipper: _ProgressClipper(
progress,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: theme.colorScheme.primary,
borderRadius: radius,
),
child: buttonTitle(theme.colorScheme.onPrimary),
),
),
),
],
),
),
),
if (progress != 0)
FocusButton(
onTap: () => onPressed?.call(true),
onLongPress: () => onLongPressed?.call(true),
onFocusChanged: (value) {
if (value) {
context.ensureVisible(
alignment: 1.0,
);
}
},
child: Card(
color: theme.colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Icon(
IconsaxPlusBold.refresh,
size: 29,
color: theme.colorScheme.onPrimaryContainer,
),
),
),
],
),
),
),
],
),
);
}

View file

@ -87,20 +87,22 @@ class _EpisodePosterState extends ConsumerState<EpisodePosters> {
itemBuilder: (context, index) {
final episode = episodes[index];
final isCurrentEpisode = index == indexOfCurrent;
final tag = UniqueKey();
return EpisodePoster(
episode: episode,
heroTag: tag,
blur: allPlayed ? false : indexOfCurrent < index,
onTap: widget.onEpisodeTap != null
? () {
widget.onEpisodeTap?.call(
() {
episode.navigateTo(context);
episode.navigateTo(context, tag: tag);
},
episode,
);
}
: () {
episode.navigateTo(context);
episode.navigateTo(context, tag: tag);
},
onLongPress: () async {
await showBottomSheetPill(
@ -134,6 +136,7 @@ class EpisodePoster extends ConsumerWidget {
final bool blur;
final List<ItemAction> actions;
final bool isCurrentEpisode;
final Object? heroTag;
const EpisodePoster({
super.key,
@ -144,6 +147,7 @@ class EpisodePoster extends ConsumerWidget {
this.blur = false,
required this.actions,
required this.isCurrentEpisode,
this.heroTag,
});
@override
@ -176,15 +180,18 @@ class EpisodePoster extends ConsumerWidget {
await showMenu(
context: context, position: position, items: actions.popupMenuItems(useIcons: true));
},
child: FladderImage(
image: !episodeAvailable ? episode.parentImages?.primary : episode.images?.primary,
placeHolder: placeHolder,
blurOnly: !episodeAvailable
? true
: ref.watch(clientSettingsProvider.select((value) => value.blurUpcomingEpisodes))
? blur
: false,
decodeHeight: 250,
child: Hero(
tag: heroTag ?? UniqueKey(),
child: FladderImage(
image: !episodeAvailable ? episode.parentImages?.primary : episode.images?.primary,
placeHolder: placeHolder,
blurOnly: !episodeAvailable
? true
: ref.watch(clientSettingsProvider.select((value) => value.blurUpcomingEpisodes))
? blur
: false,
decodeHeight: 250,
),
),
overlays: [
if (AdaptiveLayout.inputDeviceOf(context) == InputDevice.pointer && actions.isNotEmpty)

View file

@ -40,7 +40,7 @@ class FladderTheme {
final buttonState = WidgetStateProperty.resolveWith(
(states) {
return BorderSide(
width: 2,
width: 3,
color: scheme?.onPrimaryContainer.withValues(alpha: states.contains(WidgetState.focused) ? 0.9 : 0.0) ??
Colors.transparent,
);

View file

@ -173,7 +173,7 @@ class FocusButtonState extends State<FocusButton> {
.colorScheme
.primaryContainer
.withValues(alpha: widget.darkOverlay ? 0.1 : 0),
border: Border.all(width: 4, color: Theme.of(context).colorScheme.onPrimaryContainer),
border: Border.all(width: 3, color: Theme.of(context).colorScheme.onPrimaryContainer),
borderRadius: FladderTheme.smallShape.borderRadius,
),
),