diff --git a/lib/model/settings/settings.dart b/lib/model/settings/settings.dart index f49a55362..693742ccc 100644 --- a/lib/model/settings/settings.dart +++ b/lib/model/settings/settings.dart @@ -243,6 +243,7 @@ class Settings extends ChangeNotifier { if (device.isTelevision) { themeBrightness = AvesThemeBrightness.dark; mustBackTwiceToExit = false; + // address `TV-BU` / `TV-BY` requirements from https://developer.android.com/docs/quality-guidelines/tv-app-quality keepScreenOn = KeepScreenOn.videoPlayback; enableBottomNavigationBar = false; drawerTypeBookmarks = [ diff --git a/lib/widgets/about/about_page.dart b/lib/widgets/about/about_page.dart index 92066f7f2..8f97fdde3 100644 --- a/lib/widgets/about/about_page.dart +++ b/lib/widgets/about/about_page.dart @@ -5,6 +5,7 @@ import 'package:aves/widgets/about/credits.dart'; import 'package:aves/widgets/about/licenses.dart'; import 'package:aves/widgets/about/translators.dart'; import 'package:aves/widgets/common/basic/insets.dart'; +import 'package:aves/widgets/common/behaviour/tv_pop.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/navigation/tv_rail.dart'; import 'package:flutter/material.dart'; @@ -46,13 +47,15 @@ class AboutPage extends StatelessWidget { if (device.isTelevision) { return Scaffold( - body: Row( - children: [ - TvRail( - controller: context.read(), - ), - Expanded(child: body), - ], + body: TvPopScope( + child: Row( + children: [ + TvRail( + controller: context.read(), + ), + Expanded(child: body), + ], + ), ), ); } else { diff --git a/lib/widgets/aves_app.dart b/lib/widgets/aves_app.dart index aeed6f40d..f0cceed28 100644 --- a/lib/widgets/aves_app.dart +++ b/lib/widgets/aves_app.dart @@ -112,8 +112,10 @@ class AvesApp extends StatefulWidget { if (urlString != null) { final url = Uri.parse(urlString); if (await ul.canLaunchUrl(url)) { + // address `TV-WB` requirement from https://developer.android.com/docs/quality-guidelines/tv-app-quality + final mode = device.isTelevision ? ul.LaunchMode.inAppWebView : ul.LaunchMode.externalApplication; try { - await ul.launchUrl(url, mode: device.isTelevision ? ul.LaunchMode.inAppWebView : ul.LaunchMode.externalApplication); + await ul.launchUrl(url, mode: mode); } catch (error, stack) { debugPrint('failed to open url=$urlString with error=$error\n$stack'); } diff --git a/lib/widgets/collection/collection_page.dart b/lib/widgets/collection/collection_page.dart index b1fd6e2a4..3b5cd6808 100644 --- a/lib/widgets/collection/collection_page.dart +++ b/lib/widgets/collection/collection_page.dart @@ -17,6 +17,7 @@ import 'package:aves/widgets/collection/collection_grid.dart'; import 'package:aves/widgets/common/basic/draggable_scrollbar.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/behaviour/double_back_pop.dart'; +import 'package:aves/widgets/common/behaviour/tv_pop.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/aves_fab.dart'; import 'package:aves/widgets/common/providers/query_provider.dart'; @@ -97,18 +98,15 @@ class _CollectionPageState extends State { } return SynchronousFuture(true); }, - child: DoubleBackPopScope( + child: const DoubleBackPopScope( child: GestureAreaProtectorStack( child: SafeArea( top: false, bottom: false, - child: ChangeNotifierProvider.value( - value: _collection, - child: const CollectionGrid( - // key is expected by test driver - key: Key('collection-grid'), - settingsRouteKey: CollectionPage.routeName, - ), + child: CollectionGrid( + // key is expected by test driver + key: Key('collection-grid'), + settingsRouteKey: CollectionPage.routeName, ), ), ), @@ -117,22 +115,25 @@ class _CollectionPageState extends State { ), ); + Widget page; if (device.isTelevision) { - return Scaffold( - body: Row( - children: [ - TvRail( - controller: context.read(), - currentCollection: _collection, - ), - Expanded(child: body), - ], + page = TvPopScope( + child: Scaffold( + body: Row( + children: [ + TvRail( + controller: context.read(), + currentCollection: _collection, + ), + Expanded(child: body), + ], + ), + resizeToAvoidBottomInset: false, + extendBody: true, ), - resizeToAvoidBottomInset: false, - extendBody: true, ); } else { - return Selector( + page = Selector( selector: (context, s) => s.enableBottomNavigationBar, builder: (context, enableBottomNavigationBar, child) { final canNavigate = context.select, bool>((v) => v.value.canNavigate); @@ -160,6 +161,11 @@ class _CollectionPageState extends State { }, ); } + // this provider should be above `TvRail` + return ChangeNotifierProvider.value( + value: _collection, + child: page, + ); }, ), ); diff --git a/lib/widgets/common/behaviour/tv_pop.dart b/lib/widgets/common/behaviour/tv_pop.dart new file mode 100644 index 000000000..b669be85e --- /dev/null +++ b/lib/widgets/common/behaviour/tv_pop.dart @@ -0,0 +1,73 @@ +import 'package:aves/model/device.dart'; +import 'package:aves/model/settings/enums/enums.dart'; +import 'package:aves/model/settings/enums/home_page.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/model/source/collection_source.dart'; +import 'package:aves/widgets/collection/collection_page.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/filter_grids/albums_page.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +// address `TV-DB` requirement from https://developer.android.com/docs/quality-guidelines/tv-app-quality +class TvPopScope extends StatelessWidget { + final Widget child; + + const TvPopScope({ + super.key, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return WillPopScope( + onWillPop: () { + if (!device.isTelevision || _isHome(context)) { + return SynchronousFuture(true); + } + + Navigator.pushAndRemoveUntil( + context, + _getHomeRoute(), + (route) => false, + ); + return SynchronousFuture(false); + }, + child: child, + ); + } + + bool _isHome(BuildContext context) { + final homePage = settings.homePage; + final currentRoute = context.currentRouteName; + + if (currentRoute != homePage.routeName) return false; + + switch (homePage) { + case HomePageSetting.collection: + return context.read().filters.isEmpty; + case HomePageSetting.albums: + return true; + } + } + + Route _getHomeRoute() { + switch (settings.homePage) { + case HomePageSetting.collection: + return MaterialPageRoute( + settings: const RouteSettings(name: CollectionPage.routeName), + builder: (context) => CollectionPage( + source: context.read(), + filters: null, + ), + ); + case HomePageSetting.albums: + return MaterialPageRoute( + settings: const RouteSettings(name: AlbumListPage.routeName), + builder: (context) => const AlbumListPage(), + ); + } + } +} diff --git a/lib/widgets/common/map/buttons/panel.dart b/lib/widgets/common/map/buttons/panel.dart index 58df61ba1..fb1b8b0e7 100644 --- a/lib/widgets/common/map/buttons/panel.dart +++ b/lib/widgets/common/map/buttons/panel.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/device.dart'; import 'package:aves/model/settings/enums/map_style.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/services/common/services.dart'; @@ -35,11 +36,13 @@ class MapButtonPanel extends StatelessWidget { Widget? navigationButton; switch (context.select((v) => v.navigationButton)) { case MapNavigationButton.back: - navigationButton = MapOverlayButton( - icon: const BackButtonIcon(), - onPressed: () => Navigator.pop(context), - tooltip: MaterialLocalizations.of(context).backButtonTooltip, - ); + if (!device.isTelevision) { + navigationButton = MapOverlayButton( + icon: const BackButtonIcon(), + onPressed: () => Navigator.pop(context), + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + ); + } break; case MapNavigationButton.map: if (openMapPage != null) { diff --git a/lib/widgets/common/search/delegate.dart b/lib/widgets/common/search/delegate.dart index 092bb25ad..54e9d3859 100644 --- a/lib/widgets/common/search/delegate.dart +++ b/lib/widgets/common/search/delegate.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/device.dart'; import 'package:aves/theme/icons.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/search/route.dart'; @@ -19,6 +20,10 @@ abstract class AvesSearchDelegate extends SearchDelegate { @override Widget? buildLeading(BuildContext context) { + if (device.isTelevision) { + return const Icon(AIcons.search); + } + // use a property instead of checking `Navigator.canPop(context)` // because the navigator state changes as soon as we press back // so the leading may mistakenly switch to the close button diff --git a/lib/widgets/common/search/page.dart b/lib/widgets/common/search/page.dart index edb1d304f..76e7a7501 100644 --- a/lib/widgets/common/search/page.dart +++ b/lib/widgets/common/search/page.dart @@ -2,6 +2,8 @@ import 'dart:ui'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/debouncer.dart'; +import 'package:aves/widgets/common/behaviour/double_back_pop.dart'; +import 'package:aves/widgets/common/behaviour/tv_pop.dart'; import 'package:aves/widgets/common/identity/aves_app_bar.dart'; import 'package:aves/widgets/common/search/delegate.dart'; import 'package:aves/widgets/common/search/route.dart'; @@ -145,9 +147,13 @@ class _SearchPageState extends State { ), actions: widget.delegate.buildActions(context), ), - body: AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: body, + body: TvPopScope( + child: DoubleBackPopScope( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: body, + ), + ), ), ); } diff --git a/lib/widgets/debug/app_debug_page.dart b/lib/widgets/debug/app_debug_page.dart index 4f1e6a348..92eb20caf 100644 --- a/lib/widgets/debug/app_debug_page.dart +++ b/lib/widgets/debug/app_debug_page.dart @@ -9,6 +9,7 @@ import 'package:aves/services/analysis_service.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/widgets/common/basic/menu.dart'; +import 'package:aves/widgets/common/behaviour/tv_pop.dart'; import 'package:aves/widgets/common/identity/aves_expansion_tile.dart'; import 'package:aves/widgets/debug/android_apps.dart'; import 'package:aves/widgets/debug/android_codecs.dart'; @@ -40,48 +41,50 @@ class _AppDebugPageState extends State { @override Widget build(BuildContext context) { - return Directionality( - textDirection: TextDirection.ltr, - child: Scaffold( - appBar: AppBar( - title: const Text('Debug'), - actions: [ - MenuIconTheme( - child: PopupMenuButton( - // key is expected by test driver - key: const Key('appbar-menu-button'), - itemBuilder: (context) => AppDebugAction.values - .map((v) => PopupMenuItem( - // key is expected by test driver - key: Key('menu-${v.name}'), - value: v, - child: MenuRow(text: v.name), - )) - .toList(), - onSelected: (action) async { - // wait for the popup menu to hide before proceeding with the action - await Future.delayed(Durations.popupMenuAnimation * timeDilation); - unawaited(_onActionSelected(action)); - }, + return TvPopScope( + child: Directionality( + textDirection: TextDirection.ltr, + child: Scaffold( + appBar: AppBar( + title: const Text('Debug'), + actions: [ + MenuIconTheme( + child: PopupMenuButton( + // key is expected by test driver + key: const Key('appbar-menu-button'), + itemBuilder: (context) => AppDebugAction.values + .map((v) => PopupMenuItem( + // key is expected by test driver + key: Key('menu-${v.name}'), + value: v, + child: MenuRow(text: v.name), + )) + .toList(), + onSelected: (action) async { + // wait for the popup menu to hide before proceeding with the action + await Future.delayed(Durations.popupMenuAnimation * timeDilation); + unawaited(_onActionSelected(action)); + }, + ), ), - ), - ], - ), - body: SafeArea( - child: ListView( - padding: const EdgeInsets.all(8), - children: [ - _buildGeneralTabView(), - const DebugAndroidAppSection(), - const DebugAndroidCodecSection(), - const DebugAndroidDirSection(), - const DebugCacheSection(), - const DebugAppDatabaseSection(), - const DebugErrorReportingSection(), - const DebugSettingsSection(), - const DebugStorageSection(), ], ), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(8), + children: [ + _buildGeneralTabView(), + const DebugAndroidAppSection(), + const DebugAndroidCodecSection(), + const DebugAndroidDirSection(), + const DebugCacheSection(), + const DebugAppDatabaseSection(), + const DebugErrorReportingSection(), + const DebugSettingsSection(), + const DebugStorageSection(), + ], + ), + ), ), ), ); diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index bc89bff49..dc4f2dce3 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -14,6 +14,7 @@ import 'package:aves/theme/durations.dart'; import 'package:aves/widgets/common/basic/draggable_scrollbar.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/behaviour/double_back_pop.dart'; +import 'package:aves/widgets/common/behaviour/tv_pop.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/grid/item_tracker.dart'; @@ -88,35 +89,37 @@ class FilterGridPage extends StatelessWidget { } return SynchronousFuture(true); }, - child: DoubleBackPopScope( - child: GestureAreaProtectorStack( - child: SafeArea( - top: false, - bottom: false, - child: Selector( - selector: (context, mq) => mq.padding.top, - builder: (context, mqPaddingTop, child) { - return ValueListenableBuilder( - valueListenable: appBarHeightNotifier, - builder: (context, appBarHeight, child) { - return FilterGrid( - // key is expected by test driver - key: const Key('filter-grid'), - settingsRouteKey: settingsRouteKey, - appBar: appBar, - appBarHeight: mqPaddingTop + appBarHeight, - sections: sections, - newFilters: newFilters, - sortFactor: sortFactor, - showHeaders: showHeaders, - selectable: selectable, - applyQuery: applyQuery, - emptyBuilder: emptyBuilder, - heroType: heroType, - ); - }, - ); - }, + child: TvPopScope( + child: DoubleBackPopScope( + child: GestureAreaProtectorStack( + child: SafeArea( + top: false, + bottom: false, + child: Selector( + selector: (context, mq) => mq.padding.top, + builder: (context, mqPaddingTop, child) { + return ValueListenableBuilder( + valueListenable: appBarHeightNotifier, + builder: (context, appBarHeight, child) { + return FilterGrid( + // key is expected by test driver + key: const Key('filter-grid'), + settingsRouteKey: settingsRouteKey, + appBar: appBar, + appBarHeight: mqPaddingTop + appBarHeight, + sections: sections, + newFilters: newFilters, + sortFactor: sortFactor, + showHeaders: showHeaders, + selectable: selectable, + applyQuery: applyQuery, + emptyBuilder: emptyBuilder, + heroType: heroType, + ); + }, + ); + }, + ), ), ), ), diff --git a/lib/widgets/navigation/drawer/page_nav_tile.dart b/lib/widgets/navigation/drawer/page_nav_tile.dart index bb69ee34a..5d334770b 100644 --- a/lib/widgets/navigation/drawer/page_nav_tile.dart +++ b/lib/widgets/navigation/drawer/page_nav_tile.dart @@ -1,3 +1,4 @@ +import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_source.dart'; import 'package:aves/widgets/about/about_page.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; @@ -66,10 +67,12 @@ class PageNavTile extends StatelessWidget { static Route routeBuilder(BuildContext context, String routeName) { switch (routeName) { case SearchPage.routeName: + final currentCollection = context.read(); return SearchPageRoute( delegate: CollectionSearchDelegate( searchFieldLabel: context.l10n.searchCollectionFieldHint, source: context.read(), + parentCollection: currentCollection?.copyWith(), ), ); default: diff --git a/lib/widgets/navigation/tv_rail.dart b/lib/widgets/navigation/tv_rail.dart index eeecd3840..6fae76a28 100644 --- a/lib/widgets/navigation/tv_rail.dart +++ b/lib/widgets/navigation/tv_rail.dart @@ -192,16 +192,19 @@ class _TvRailState extends State { return pageBookmarks.map(_routeNavEntry).toList(); } - _NavEntry _routeNavEntry(String route) => _NavEntry( - icon: DrawerPageIcon(route: route), - label: DrawerPageTitle(route: route), - isSelected: context.currentRouteName == route, - onSelection: () => _goTo(route), + _NavEntry _routeNavEntry(String routeName) => _NavEntry( + icon: DrawerPageIcon(route: routeName), + label: DrawerPageTitle(route: routeName), + isSelected: context.currentRouteName == routeName, + onSelection: () => _goTo(routeName), ); - Future _goTo(String routeName) async { - // TODO TLAD [tv] check `topLevel` / `Navigator.pushAndRemoveUntil` - await Navigator.push(context, PageNavTile.routeBuilder(context, routeName)); + void _goTo(String routeName) { + Navigator.pushAndRemoveUntil( + context, + PageNavTile.routeBuilder(context, routeName), + (route) => false, + ); } void _goToCollection(BuildContext context, CollectionFilter? filter) { diff --git a/lib/widgets/search/search_delegate.dart b/lib/widgets/search/search_delegate.dart index 8cb400725..9ce560a16 100644 --- a/lib/widgets/search/search_delegate.dart +++ b/lib/widgets/search/search_delegate.dart @@ -279,21 +279,25 @@ class CollectionSearchDelegate extends AvesSearchDelegate { if (parentCollection != null) { _applyToParentCollectionPage(context, filter); } else { - _jumpToCollectionPage(context, filter); + _jumpToCollectionPage(context, {filter}); } } void _applyToParentCollectionPage(BuildContext context, CollectionFilter filter) { parentCollection!.addFilter(filter); - // We delay closing the current page after applying the filter selection - // so that hero animation target is ready in the `FilterBar`, - // even when the target is a child of an `AnimatedList`. - // Do not use `WidgetsBinding.instance.addPostFrameCallback`, - // as it may not trigger if there is no subsequent build. - Future.delayed(const Duration(milliseconds: 100), () => goBack(context)); + if (Navigator.canPop(context)) { + // We delay closing the current page after applying the filter selection + // so that hero animation target is ready in the `FilterBar`, + // even when the target is a child of an `AnimatedList`. + // Do not use `WidgetsBinding.instance.addPostFrameCallback`, + // as it may not trigger if there is no subsequent build. + Future.delayed(const Duration(milliseconds: 100), () => goBack(context)); + } else { + _jumpToCollectionPage(context, parentCollection!.filters); + } } - void _jumpToCollectionPage(BuildContext context, CollectionFilter filter) { + void _jumpToCollectionPage(BuildContext context, Set filters) { clean(); Navigator.pushAndRemoveUntil( context, @@ -301,7 +305,7 @@ class CollectionSearchDelegate extends AvesSearchDelegate { settings: const RouteSettings(name: CollectionPage.routeName), builder: (context) => CollectionPage( source: source, - filters: {filter}, + filters: filters, ), ), (route) => false, diff --git a/lib/widgets/settings/settings_page.dart b/lib/widgets/settings/settings_page.dart index d16bb526f..84f4059d1 100644 --- a/lib/widgets/settings/settings_page.dart +++ b/lib/widgets/settings/settings_page.dart @@ -13,6 +13,7 @@ import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/app_bar/app_bar_title.dart'; import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/basic/menu.dart'; +import 'package:aves/widgets/common/behaviour/tv_pop.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/search/route.dart'; @@ -73,62 +74,64 @@ class _SettingsPageState extends State with FeedbackMixin { if (device.isTelevision) { return Scaffold( - body: Row( - children: [ - TvRail( - controller: context.read(), - ), - Expanded( - child: Column( - children: [ - const SizedBox(height: 8), - AppBar( - automaticallyImplyLeading: false, - title: appBarTitle, - elevation: 0, - ), - Expanded( - child: ValueListenableBuilder( - valueListenable: _tvSelectedIndexNotifier, - builder: (context, selectedIndex, child) { - final rail = NavigationRail( - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - extended: true, - destinations: sections - .map((section) => NavigationRailDestination( - icon: section.icon(context), - label: Text(section.title(context)), - )) - .toList(), - selectedIndex: selectedIndex, - onDestinationSelected: (index) => _tvSelectedIndexNotifier.value = index, - ); - return LayoutBuilder( - builder: (context, constraints) { - return Row( - children: [ - SingleChildScrollView( - child: ConstrainedBox( - constraints: BoxConstraints(minHeight: constraints.maxHeight), - child: IntrinsicHeight(child: rail), - ), - ), - Expanded( - child: _SettingsSectionBody( - loader: Future.value(sections[selectedIndex].tiles(context)), - ), - ), - ], - ); - }, - ); - }, - ), - ), - ], + body: TvPopScope( + child: Row( + children: [ + TvRail( + controller: context.read(), ), - ), - ], + Expanded( + child: Column( + children: [ + const SizedBox(height: 8), + AppBar( + automaticallyImplyLeading: false, + title: appBarTitle, + elevation: 0, + ), + Expanded( + child: ValueListenableBuilder( + valueListenable: _tvSelectedIndexNotifier, + builder: (context, selectedIndex, child) { + final rail = NavigationRail( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + extended: true, + destinations: sections + .map((section) => NavigationRailDestination( + icon: section.icon(context), + label: Text(section.title(context)), + )) + .toList(), + selectedIndex: selectedIndex, + onDestinationSelected: (index) => _tvSelectedIndexNotifier.value = index, + ); + return LayoutBuilder( + builder: (context, constraints) { + return Row( + children: [ + SingleChildScrollView( + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight), + child: IntrinsicHeight(child: rail), + ), + ), + Expanded( + child: _SettingsSectionBody( + loader: Future.value(sections[selectedIndex].tiles(context)), + ), + ), + ], + ); + }, + ); + }, + ), + ), + ], + ), + ), + ], + ), ), ); } else { diff --git a/lib/widgets/stats/stats_page.dart b/lib/widgets/stats/stats_page.dart index c1d2ea247..f22187cee 100644 --- a/lib/widgets/stats/stats_page.dart +++ b/lib/widgets/stats/stats_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/filters.dart'; @@ -225,6 +226,7 @@ class _StatsPageState extends State { return Scaffold( appBar: AppBar( + automaticallyImplyLeading: !device.isTelevision, title: Text(l10n.statsPageTitle), ), body: GestureAreaProtectorStack( @@ -354,6 +356,7 @@ class StatsTopPage extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: AppBar( + automaticallyImplyLeading: !device.isTelevision, title: Text(title), ), body: GestureAreaProtectorStack( diff --git a/lib/widgets/viewer/entry_vertical_pager.dart b/lib/widgets/viewer/entry_vertical_pager.dart index 381b3e226..7a15adf5c 100644 --- a/lib/widgets/viewer/entry_vertical_pager.dart +++ b/lib/widgets/viewer/entry_vertical_pager.dart @@ -185,14 +185,17 @@ class _ViewerVerticalPageViewState extends State { Widget? child; Map? shortcuts = { - const SingleActivator(LogicalKeyboardKey.arrowUp): isTelevision ? const TvShowLessInfoIntent() : const LeaveIntent(), - const SingleActivator(LogicalKeyboardKey.arrowDown): isTelevision ? const TvShowMoreInfoIntent() : const ShowInfoIntent(), + const SingleActivator(LogicalKeyboardKey.arrowUp): isTelevision ? const TvShowLessInfoIntent() : const _LeaveIntent(), + const SingleActivator(LogicalKeyboardKey.arrowDown): isTelevision ? const _TvShowMoreInfoIntent() : const _ShowInfoIntent(), + const SingleActivator(LogicalKeyboardKey.mediaPause): const _PlayPauseIntent.pause(), + const SingleActivator(LogicalKeyboardKey.mediaPlay): const _PlayPauseIntent.play(), + const SingleActivator(LogicalKeyboardKey.mediaPlayPause): const _PlayPauseIntent.toggle(), }; if (hasCollection) { shortcuts.addAll(const { - SingleActivator(LogicalKeyboardKey.arrowLeft): ShowPreviousIntent(), - SingleActivator(LogicalKeyboardKey.arrowRight): ShowNextIntent(), + SingleActivator(LogicalKeyboardKey.arrowLeft): _ShowPreviousIntent(), + SingleActivator(LogicalKeyboardKey.arrowRight): _ShowNextIntent(), }); child = MultiEntryScroller( collection: collection!, @@ -227,16 +230,18 @@ class _ViewerVerticalPageViewState extends State { autofocus: true, shortcuts: shortcuts, actions: { - ShowPreviousIntent: CallbackAction(onInvoke: (intent) => _goToHorizontalPage(-1, animate: false)), - ShowNextIntent: CallbackAction(onInvoke: (intent) => _goToHorizontalPage(1, animate: false)), - LeaveIntent: CallbackAction(onInvoke: (intent) => Navigator.pop(context)), - ShowInfoIntent: CallbackAction(onInvoke: (intent) => ShowInfoPageNotification().dispatch(context)), + _ShowPreviousIntent: CallbackAction(onInvoke: (intent) => _goToHorizontalPage(-1, animate: false)), + _ShowNextIntent: CallbackAction(onInvoke: (intent) => _goToHorizontalPage(1, animate: false)), + _LeaveIntent: CallbackAction(onInvoke: (intent) => Navigator.pop(context)), + _ShowInfoIntent: CallbackAction(onInvoke: (intent) => ShowInfoPageNotification().dispatch(context)), TvShowLessInfoIntent: CallbackAction(onInvoke: (intent) => TvShowLessInfoNotification().dispatch(context)), - TvShowMoreInfoIntent: CallbackAction(onInvoke: (intent) => TvShowMoreInfoNotification().dispatch(context)), + _TvShowMoreInfoIntent: CallbackAction(onInvoke: (intent) => TvShowMoreInfoNotification().dispatch(context)), + _PlayPauseIntent: CallbackAction<_PlayPauseIntent>(onInvoke: (intent) => _onPlayPauseIntent(intent, entry)), ActivateIntent: CallbackAction(onInvoke: (intent) { if (isTelevision) { final _entry = entry; if (_entry != null && _entry.isVideo) { + // address `TV-PC` requirement from https://developer.android.com/docs/quality-guidelines/tv-app-quality final controller = context.read().getController(_entry); if (controller != null) { VideoActionNotification(controller: controller, action: EntryAction.videoTogglePlay).dispatch(context); @@ -330,30 +335,75 @@ class _ViewerVerticalPageViewState extends State { setState(() {}); } } + + void _onPlayPauseIntent(_PlayPauseIntent intent, entry) { + // address `TV-PP` requirement from https://developer.android.com/docs/quality-guidelines/tv-app-quality + final _entry = entry; + if (_entry != null && _entry.isVideo) { + final controller = context.read().getController(_entry); + if (controller != null) { + bool toggle; + switch (intent.type) { + case _TvPlayPauseType.play: + toggle = !controller.isPlaying; + break; + case _TvPlayPauseType.pause: + toggle = controller.isPlaying; + break; + case _TvPlayPauseType.toggle: + toggle = true; + break; + } + if (toggle) { + VideoActionNotification(controller: controller, action: EntryAction.videoTogglePlay).dispatch(context); + } + } + } + } } // keyboard shortcut intents -class ShowPreviousIntent extends Intent { - const ShowPreviousIntent(); +class _ShowPreviousIntent extends Intent { + const _ShowPreviousIntent(); } -class ShowNextIntent extends Intent { - const ShowNextIntent(); +class _ShowNextIntent extends Intent { + const _ShowNextIntent(); } -class LeaveIntent extends Intent { - const LeaveIntent(); +class _LeaveIntent extends Intent { + const _LeaveIntent(); } -class ShowInfoIntent extends Intent { - const ShowInfoIntent(); +class _ShowInfoIntent extends Intent { + const _ShowInfoIntent(); } class TvShowLessInfoIntent extends Intent { const TvShowLessInfoIntent(); } -class TvShowMoreInfoIntent extends Intent { - const TvShowMoreInfoIntent(); +class _TvShowMoreInfoIntent extends Intent { + const _TvShowMoreInfoIntent(); +} + +class _PlayPauseIntent extends Intent { + const _PlayPauseIntent({ + required this.type, + }); + + const _PlayPauseIntent.play() : type = _TvPlayPauseType.play; + + const _PlayPauseIntent.pause() : type = _TvPlayPauseType.pause; + + const _PlayPauseIntent.toggle() : type = _TvPlayPauseType.toggle; + + final _TvPlayPauseType type; +} + +enum _TvPlayPauseType { + play, + pause, + toggle, } diff --git a/lib/widgets/viewer/info/info_app_bar.dart b/lib/widgets/viewer/info/info_app_bar.dart index c7dfee805..ac1be650e 100644 --- a/lib/widgets/viewer/info/info_app_bar.dart +++ b/lib/widgets/viewer/info/info_app_bar.dart @@ -1,4 +1,5 @@ import 'package:aves/model/actions/entry_actions.dart'; +import 'package:aves/model/device.dart'; import 'package:aves/model/entry.dart'; import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/theme/durations.dart'; @@ -36,13 +37,16 @@ class InfoAppBar extends StatelessWidget { final formatSpecificActions = EntryActions.formatSpecificMetadataActions.where((v) => actionDelegate.isVisible(entry, v)); return SliverAppBar( - leading: IconButton( - // key is expected by test driver - key: const Key('back-button'), - icon: const Icon(AIcons.goUp), - onPressed: onBackPressed, - tooltip: context.l10n.viewerInfoBackToViewerTooltip, - ), + leading: device.isTelevision + ? null + : IconButton( + // key is expected by test driver + key: const Key('back-button'), + icon: const Icon(AIcons.goUp), + onPressed: onBackPressed, + tooltip: context.l10n.viewerInfoBackToViewerTooltip, + ), + automaticallyImplyLeading: false, title: SliverAppBarTitleWrapper( child: InteractiveAppBarTitle( onTap: () => _goToSearch(context),