From 3ce0ed6dbca42d95c143bdaba383ce5d1465ff67 Mon Sep 17 00:00:00 2001 From: PartyDonut Date: Fri, 3 Oct 2025 22:37:37 +0200 Subject: [PATCH] fix: Long press "play" button with dpad navigation --- .../playback/playback_options_dialogue.dart | 75 ++++++---- .../details_screens/book_detail_screen.dart | 7 +- .../episode_detail_screen.dart | 17 ++- .../details_screens/movie_detail_screen.dart | 6 +- .../details_screens/series_detail_screen.dart | 33 +++-- .../media/components/media_play_button.dart | 138 ++++++++++-------- lib/screens/shared/media/episode_posters.dart | 29 ++-- lib/theme.dart | 2 +- lib/util/focus_provider.dart | 2 +- 9 files changed, 188 insertions(+), 121 deletions(-) diff --git a/lib/models/playback/playback_options_dialogue.dart b/lib/models/playback/playback_options_dialogue.dart index c016dfe..d276cb7 100644 --- a/lib/models/playback/playback_options_dialogue.dart +++ b/lib/models/playback/playback_options_dialogue.dart @@ -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 showPlaybackTypeSelection({ @@ -12,14 +15,12 @@ Future 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, + ) + ], + ), ); } } diff --git a/lib/screens/details_screens/book_detail_screen.dart b/lib/screens/details_screens/book_detail_screen.dart index 7e08a52..a63a709 100644 --- a/lib/screens/details_screens/book_detail_screen.dart +++ b/lib/screens/details_screens/book_detail_screen.dart @@ -81,7 +81,12 @@ class _BookDetailScreenState extends ConsumerState { //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, + ), ); }, ), diff --git a/lib/screens/details_screens/episode_detail_screen.dart b/lib/screens/details_screens/episode_detail_screen.dart index 009ac93..77e6246 100644 --- a/lib/screens/details_screens/episode_detail_screen.dart +++ b/lib/screens/details_screens/episode_detail_screen.dart @@ -82,12 +82,21 @@ class _ItemDetailScreenState extends ConsumerState { 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); }, ) diff --git a/lib/screens/details_screens/movie_detail_screen.dart b/lib/screens/details_screens/movie_detail_screen.dart index 26ce688..e1068dc 100644 --- a/lib/screens/details_screens/movie_detail_screen.dart +++ b/lib/screens/details_screens/movie_detail_screen.dart @@ -75,18 +75,20 @@ class _ItemDetailScreenState extends ConsumerState { 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); }, diff --git a/lib/screens/details_screens/series_detail_screen.dart b/lib/screens/details_screens/series_detail_screen.dart index 91f2275..62f3df8 100644 --- a/lib/screens/details_screens/series_detail_screen.dart +++ b/lib/screens/details_screens/series_detail_screen.dart @@ -76,21 +76,28 @@ class _SeriesDetailScreenState extends ConsumerState { 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, diff --git a/lib/screens/shared/media/components/media_play_button.dart b/lib/screens/shared/media/components/media_play_button.dart index 37d1859..7d98674 100644 --- a/lib/screens/shared/media/components/media_play_button.dart +++ b/lib/screens/shared/media/components/media_play_button.dart @@ -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, ), ), ), - ], - ), - ), + ), + ], ), ); } diff --git a/lib/screens/shared/media/episode_posters.dart b/lib/screens/shared/media/episode_posters.dart index b98e345..626e6c6 100644 --- a/lib/screens/shared/media/episode_posters.dart +++ b/lib/screens/shared/media/episode_posters.dart @@ -87,20 +87,22 @@ class _EpisodePosterState extends ConsumerState { 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 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) diff --git a/lib/theme.dart b/lib/theme.dart index 9fefb65..1a4fcbc 100644 --- a/lib/theme.dart +++ b/lib/theme.dart @@ -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, ); diff --git a/lib/util/focus_provider.dart b/lib/util/focus_provider.dart index 4e7200a..7201e7e 100644 --- a/lib/util/focus_provider.dart +++ b/lib/util/focus_provider.dart @@ -173,7 +173,7 @@ class FocusButtonState extends State { .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, ), ),