#437 tv: back navigation

This commit is contained in:
Thibault Deckers 2022-12-17 11:34:13 +01:00
parent e8bb1a77f0
commit c0fd75777e
17 changed files with 377 additions and 202 deletions

View file

@ -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 = [

View file

@ -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,7 +47,8 @@ class AboutPage extends StatelessWidget {
if (device.isTelevision) {
return Scaffold(
body: Row(
body: TvPopScope(
child: Row(
children: [
TvRail(
controller: context.read<TvRailController>(),
@ -54,6 +56,7 @@ class AboutPage extends StatelessWidget {
Expanded(child: body),
],
),
),
);
} else {
return Scaffold(

View file

@ -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');
}

View file

@ -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,14 +98,12 @@ class _CollectionPageState extends State<CollectionPage> {
}
return SynchronousFuture(true);
},
child: DoubleBackPopScope(
child: const DoubleBackPopScope(
child: GestureAreaProtectorStack(
child: SafeArea(
top: false,
bottom: false,
child: ChangeNotifierProvider<CollectionLens>.value(
value: _collection,
child: const CollectionGrid(
child: CollectionGrid(
// key is expected by test driver
key: Key('collection-grid'),
settingsRouteKey: CollectionPage.routeName,
@ -114,11 +113,12 @@ class _CollectionPageState extends State<CollectionPage> {
),
),
),
),
);
Widget page;
if (device.isTelevision) {
return Scaffold(
page = TvPopScope(
child: Scaffold(
body: Row(
children: [
TvRail(
@ -130,9 +130,10 @@ class _CollectionPageState extends State<CollectionPage> {
),
resizeToAvoidBottomInset: false,
extendBody: true,
),
);
} else {
return Selector<Settings, bool>(
page = Selector<Settings, bool>(
selector: (context, s) => s.enableBottomNavigationBar,
builder: (context, enableBottomNavigationBar, child) {
final canNavigate = context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canNavigate);
@ -160,6 +161,11 @@ class _CollectionPageState extends State<CollectionPage> {
},
);
}
// this provider should be above `TvRail`
return ChangeNotifierProvider<CollectionLens>.value(
value: _collection,
child: page,
);
},
),
);

View file

@ -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<CollectionLens>().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<CollectionSource>(),
filters: null,
),
);
case HomePageSetting.albums:
return MaterialPageRoute(
settings: const RouteSettings(name: AlbumListPage.routeName),
builder: (context) => const AlbumListPage(),
);
}
}
}

View file

@ -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<MapThemeData, MapNavigationButton>((v) => v.navigationButton)) {
case MapNavigationButton.back:
if (!device.isTelevision) {
navigationButton = MapOverlayButton(
icon: const BackButtonIcon(),
onPressed: () => Navigator.pop(context),
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
);
}
break;
case MapNavigationButton.map:
if (openMapPage != null) {

View file

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

View file

@ -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,10 +147,14 @@ class _SearchPageState extends State<SearchPage> {
),
actions: widget.delegate.buildActions(context),
),
body: AnimatedSwitcher(
body: TvPopScope(
child: DoubleBackPopScope(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: body,
),
),
),
);
}
}

View file

@ -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,7 +41,8 @@ class _AppDebugPageState extends State<AppDebugPage> {
@override
Widget build(BuildContext context) {
return Directionality(
return TvPopScope(
child: Directionality(
textDirection: TextDirection.ltr,
child: Scaffold(
appBar: AppBar(
@ -84,6 +86,7 @@ class _AppDebugPageState extends State<AppDebugPage> {
),
),
),
),
);
}

View file

@ -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,6 +89,7 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
}
return SynchronousFuture(true);
},
child: TvPopScope(
child: DoubleBackPopScope(
child: GestureAreaProtectorStack(
child: SafeArea(
@ -122,6 +124,7 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
),
),
),
),
);
if (device.isTelevision) {

View file

@ -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<CollectionLens?>();
return SearchPageRoute(
delegate: CollectionSearchDelegate(
searchFieldLabel: context.l10n.searchCollectionFieldHint,
source: context.read<CollectionSource>(),
parentCollection: currentCollection?.copyWith(),
),
);
default:

View file

@ -192,16 +192,19 @@ class _TvRailState extends State<TvRail> {
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<void> _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) {

View file

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

View file

@ -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,7 +74,8 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
if (device.isTelevision) {
return Scaffold(
body: Row(
body: TvPopScope(
child: Row(
children: [
TvRail(
controller: context.read<TvRailController>(),
@ -130,6 +132,7 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
),
],
),
),
);
} else {
return Scaffold(

View file

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

View file

@ -185,14 +185,17 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
Widget? child;
Map<ShortcutActivator, Intent>? 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<ViewerVerticalPageView> {
autofocus: true,
shortcuts: shortcuts,
actions: {
ShowPreviousIntent: CallbackAction<Intent>(onInvoke: (intent) => _goToHorizontalPage(-1, animate: false)),
ShowNextIntent: CallbackAction<Intent>(onInvoke: (intent) => _goToHorizontalPage(1, animate: false)),
LeaveIntent: CallbackAction<Intent>(onInvoke: (intent) => Navigator.pop(context)),
ShowInfoIntent: CallbackAction<Intent>(onInvoke: (intent) => ShowInfoPageNotification().dispatch(context)),
_ShowPreviousIntent: CallbackAction<Intent>(onInvoke: (intent) => _goToHorizontalPage(-1, animate: false)),
_ShowNextIntent: CallbackAction<Intent>(onInvoke: (intent) => _goToHorizontalPage(1, animate: false)),
_LeaveIntent: CallbackAction<Intent>(onInvoke: (intent) => Navigator.pop(context)),
_ShowInfoIntent: CallbackAction<Intent>(onInvoke: (intent) => ShowInfoPageNotification().dispatch(context)),
TvShowLessInfoIntent: CallbackAction<Intent>(onInvoke: (intent) => TvShowLessInfoNotification().dispatch(context)),
TvShowMoreInfoIntent: CallbackAction<Intent>(onInvoke: (intent) => TvShowMoreInfoNotification().dispatch(context)),
_TvShowMoreInfoIntent: CallbackAction<Intent>(onInvoke: (intent) => TvShowMoreInfoNotification().dispatch(context)),
_PlayPauseIntent: CallbackAction<_PlayPauseIntent>(onInvoke: (intent) => _onPlayPauseIntent(intent, entry)),
ActivateIntent: CallbackAction<Intent>(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<VideoConductor>().getController(_entry);
if (controller != null) {
VideoActionNotification(controller: controller, action: EntryAction.videoTogglePlay).dispatch(context);
@ -330,30 +335,75 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
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<VideoConductor>().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,
}

View file

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