#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) { if (device.isTelevision) {
themeBrightness = AvesThemeBrightness.dark; themeBrightness = AvesThemeBrightness.dark;
mustBackTwiceToExit = false; mustBackTwiceToExit = false;
// address `TV-BU` / `TV-BY` requirements from https://developer.android.com/docs/quality-guidelines/tv-app-quality
keepScreenOn = KeepScreenOn.videoPlayback; keepScreenOn = KeepScreenOn.videoPlayback;
enableBottomNavigationBar = false; enableBottomNavigationBar = false;
drawerTypeBookmarks = [ 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/licenses.dart';
import 'package:aves/widgets/about/translators.dart'; import 'package:aves/widgets/about/translators.dart';
import 'package:aves/widgets/common/basic/insets.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/common/extensions/build_context.dart';
import 'package:aves/widgets/navigation/tv_rail.dart'; import 'package:aves/widgets/navigation/tv_rail.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -46,13 +47,15 @@ class AboutPage extends StatelessWidget {
if (device.isTelevision) { if (device.isTelevision) {
return Scaffold( return Scaffold(
body: Row( body: TvPopScope(
children: [ child: Row(
TvRail( children: [
controller: context.read<TvRailController>(), TvRail(
), controller: context.read<TvRailController>(),
Expanded(child: body), ),
], Expanded(child: body),
],
),
), ),
); );
} else { } else {

View file

@ -112,8 +112,10 @@ class AvesApp extends StatefulWidget {
if (urlString != null) { if (urlString != null) {
final url = Uri.parse(urlString); final url = Uri.parse(urlString);
if (await ul.canLaunchUrl(url)) { 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 { try {
await ul.launchUrl(url, mode: device.isTelevision ? ul.LaunchMode.inAppWebView : ul.LaunchMode.externalApplication); await ul.launchUrl(url, mode: mode);
} catch (error, stack) { } catch (error, stack) {
debugPrint('failed to open url=$urlString with error=$error\n$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/draggable_scrollbar.dart';
import 'package:aves/widgets/common/basic/insets.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/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/build_context.dart';
import 'package:aves/widgets/common/identity/aves_fab.dart'; import 'package:aves/widgets/common/identity/aves_fab.dart';
import 'package:aves/widgets/common/providers/query_provider.dart'; import 'package:aves/widgets/common/providers/query_provider.dart';
@ -97,18 +98,15 @@ class _CollectionPageState extends State<CollectionPage> {
} }
return SynchronousFuture(true); return SynchronousFuture(true);
}, },
child: DoubleBackPopScope( child: const DoubleBackPopScope(
child: GestureAreaProtectorStack( child: GestureAreaProtectorStack(
child: SafeArea( child: SafeArea(
top: false, top: false,
bottom: false, bottom: false,
child: ChangeNotifierProvider<CollectionLens>.value( child: CollectionGrid(
value: _collection, // key is expected by test driver
child: const CollectionGrid( key: Key('collection-grid'),
// key is expected by test driver settingsRouteKey: CollectionPage.routeName,
key: Key('collection-grid'),
settingsRouteKey: CollectionPage.routeName,
),
), ),
), ),
), ),
@ -117,22 +115,25 @@ class _CollectionPageState extends State<CollectionPage> {
), ),
); );
Widget page;
if (device.isTelevision) { if (device.isTelevision) {
return Scaffold( page = TvPopScope(
body: Row( child: Scaffold(
children: [ body: Row(
TvRail( children: [
controller: context.read<TvRailController>(), TvRail(
currentCollection: _collection, controller: context.read<TvRailController>(),
), currentCollection: _collection,
Expanded(child: body), ),
], Expanded(child: body),
],
),
resizeToAvoidBottomInset: false,
extendBody: true,
), ),
resizeToAvoidBottomInset: false,
extendBody: true,
); );
} else { } else {
return Selector<Settings, bool>( page = Selector<Settings, bool>(
selector: (context, s) => s.enableBottomNavigationBar, selector: (context, s) => s.enableBottomNavigationBar,
builder: (context, enableBottomNavigationBar, child) { builder: (context, enableBottomNavigationBar, child) {
final canNavigate = context.select<ValueNotifier<AppMode>, bool>((v) => v.value.canNavigate); 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/enums/map_style.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
@ -35,11 +36,13 @@ class MapButtonPanel extends StatelessWidget {
Widget? navigationButton; Widget? navigationButton;
switch (context.select<MapThemeData, MapNavigationButton>((v) => v.navigationButton)) { switch (context.select<MapThemeData, MapNavigationButton>((v) => v.navigationButton)) {
case MapNavigationButton.back: case MapNavigationButton.back:
navigationButton = MapOverlayButton( if (!device.isTelevision) {
icon: const BackButtonIcon(), navigationButton = MapOverlayButton(
onPressed: () => Navigator.pop(context), icon: const BackButtonIcon(),
tooltip: MaterialLocalizations.of(context).backButtonTooltip, onPressed: () => Navigator.pop(context),
); tooltip: MaterialLocalizations.of(context).backButtonTooltip,
);
}
break; break;
case MapNavigationButton.map: case MapNavigationButton.map:
if (openMapPage != null) { if (openMapPage != null) {

View file

@ -1,3 +1,4 @@
import 'package:aves/model/device.dart';
import 'package:aves/theme/icons.dart'; import 'package:aves/theme/icons.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/search/route.dart'; import 'package:aves/widgets/common/search/route.dart';
@ -19,6 +20,10 @@ abstract class AvesSearchDelegate extends SearchDelegate {
@override @override
Widget? buildLeading(BuildContext context) { Widget? buildLeading(BuildContext context) {
if (device.isTelevision) {
return const Icon(AIcons.search);
}
// use a property instead of checking `Navigator.canPop(context)` // use a property instead of checking `Navigator.canPop(context)`
// because the navigator state changes as soon as we press back // because the navigator state changes as soon as we press back
// so the leading may mistakenly switch to the close button // 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/theme/durations.dart';
import 'package:aves/utils/debouncer.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/identity/aves_app_bar.dart';
import 'package:aves/widgets/common/search/delegate.dart'; import 'package:aves/widgets/common/search/delegate.dart';
import 'package:aves/widgets/common/search/route.dart'; import 'package:aves/widgets/common/search/route.dart';
@ -145,9 +147,13 @@ class _SearchPageState extends State<SearchPage> {
), ),
actions: widget.delegate.buildActions(context), actions: widget.delegate.buildActions(context),
), ),
body: AnimatedSwitcher( body: TvPopScope(
duration: const Duration(milliseconds: 300), child: DoubleBackPopScope(
child: body, 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/theme/durations.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/common/basic/menu.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/common/identity/aves_expansion_tile.dart';
import 'package:aves/widgets/debug/android_apps.dart'; import 'package:aves/widgets/debug/android_apps.dart';
import 'package:aves/widgets/debug/android_codecs.dart'; import 'package:aves/widgets/debug/android_codecs.dart';
@ -40,48 +41,50 @@ class _AppDebugPageState extends State<AppDebugPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Directionality( return TvPopScope(
textDirection: TextDirection.ltr, child: Directionality(
child: Scaffold( textDirection: TextDirection.ltr,
appBar: AppBar( child: Scaffold(
title: const Text('Debug'), appBar: AppBar(
actions: [ title: const Text('Debug'),
MenuIconTheme( actions: [
child: PopupMenuButton<AppDebugAction>( MenuIconTheme(
// key is expected by test driver child: PopupMenuButton<AppDebugAction>(
key: const Key('appbar-menu-button'), // key is expected by test driver
itemBuilder: (context) => AppDebugAction.values key: const Key('appbar-menu-button'),
.map((v) => PopupMenuItem( itemBuilder: (context) => AppDebugAction.values
// key is expected by test driver .map((v) => PopupMenuItem(
key: Key('menu-${v.name}'), // key is expected by test driver
value: v, key: Key('menu-${v.name}'),
child: MenuRow(text: v.name), value: v,
)) child: MenuRow(text: v.name),
.toList(), ))
onSelected: (action) async { .toList(),
// wait for the popup menu to hide before proceeding with the action onSelected: (action) async {
await Future.delayed(Durations.popupMenuAnimation * timeDilation); // wait for the popup menu to hide before proceeding with the action
unawaited(_onActionSelected(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(),
],
),
),
), ),
), ),
); );

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/draggable_scrollbar.dart';
import 'package:aves/widgets/common/basic/insets.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/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/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/item_tracker.dart'; import 'package:aves/widgets/common/grid/item_tracker.dart';
@ -88,35 +89,37 @@ class FilterGridPage<T extends CollectionFilter> extends StatelessWidget {
} }
return SynchronousFuture(true); return SynchronousFuture(true);
}, },
child: DoubleBackPopScope( child: TvPopScope(
child: GestureAreaProtectorStack( child: DoubleBackPopScope(
child: SafeArea( child: GestureAreaProtectorStack(
top: false, child: SafeArea(
bottom: false, top: false,
child: Selector<MediaQueryData, double>( bottom: false,
selector: (context, mq) => mq.padding.top, child: Selector<MediaQueryData, double>(
builder: (context, mqPaddingTop, child) { selector: (context, mq) => mq.padding.top,
return ValueListenableBuilder<double>( builder: (context, mqPaddingTop, child) {
valueListenable: appBarHeightNotifier, return ValueListenableBuilder<double>(
builder: (context, appBarHeight, child) { valueListenable: appBarHeightNotifier,
return FilterGrid<T>( builder: (context, appBarHeight, child) {
// key is expected by test driver return FilterGrid<T>(
key: const Key('filter-grid'), // key is expected by test driver
settingsRouteKey: settingsRouteKey, key: const Key('filter-grid'),
appBar: appBar, settingsRouteKey: settingsRouteKey,
appBarHeight: mqPaddingTop + appBarHeight, appBar: appBar,
sections: sections, appBarHeight: mqPaddingTop + appBarHeight,
newFilters: newFilters, sections: sections,
sortFactor: sortFactor, newFilters: newFilters,
showHeaders: showHeaders, sortFactor: sortFactor,
selectable: selectable, showHeaders: showHeaders,
applyQuery: applyQuery, selectable: selectable,
emptyBuilder: emptyBuilder, applyQuery: applyQuery,
heroType: heroType, emptyBuilder: emptyBuilder,
); heroType: heroType,
}, );
); },
}, );
},
),
), ),
), ),
), ),

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/model/source/collection_source.dart';
import 'package:aves/widgets/about/about_page.dart'; import 'package:aves/widgets/about/about_page.dart';
import 'package:aves/widgets/common/extensions/build_context.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) { static Route routeBuilder(BuildContext context, String routeName) {
switch (routeName) { switch (routeName) {
case SearchPage.routeName: case SearchPage.routeName:
final currentCollection = context.read<CollectionLens?>();
return SearchPageRoute( return SearchPageRoute(
delegate: CollectionSearchDelegate( delegate: CollectionSearchDelegate(
searchFieldLabel: context.l10n.searchCollectionFieldHint, searchFieldLabel: context.l10n.searchCollectionFieldHint,
source: context.read<CollectionSource>(), source: context.read<CollectionSource>(),
parentCollection: currentCollection?.copyWith(),
), ),
); );
default: default:

View file

@ -192,16 +192,19 @@ class _TvRailState extends State<TvRail> {
return pageBookmarks.map(_routeNavEntry).toList(); return pageBookmarks.map(_routeNavEntry).toList();
} }
_NavEntry _routeNavEntry(String route) => _NavEntry( _NavEntry _routeNavEntry(String routeName) => _NavEntry(
icon: DrawerPageIcon(route: route), icon: DrawerPageIcon(route: routeName),
label: DrawerPageTitle(route: route), label: DrawerPageTitle(route: routeName),
isSelected: context.currentRouteName == route, isSelected: context.currentRouteName == routeName,
onSelection: () => _goTo(route), onSelection: () => _goTo(routeName),
); );
Future<void> _goTo(String routeName) async { void _goTo(String routeName) {
// TODO TLAD [tv] check `topLevel` / `Navigator.pushAndRemoveUntil` Navigator.pushAndRemoveUntil(
await Navigator.push(context, PageNavTile.routeBuilder(context, routeName)); context,
PageNavTile.routeBuilder(context, routeName),
(route) => false,
);
} }
void _goToCollection(BuildContext context, CollectionFilter? filter) { void _goToCollection(BuildContext context, CollectionFilter? filter) {

View file

@ -279,21 +279,25 @@ class CollectionSearchDelegate extends AvesSearchDelegate {
if (parentCollection != null) { if (parentCollection != null) {
_applyToParentCollectionPage(context, filter); _applyToParentCollectionPage(context, filter);
} else { } else {
_jumpToCollectionPage(context, filter); _jumpToCollectionPage(context, {filter});
} }
} }
void _applyToParentCollectionPage(BuildContext context, CollectionFilter filter) { void _applyToParentCollectionPage(BuildContext context, CollectionFilter filter) {
parentCollection!.addFilter(filter); parentCollection!.addFilter(filter);
// We delay closing the current page after applying the filter selection if (Navigator.canPop(context)) {
// so that hero animation target is ready in the `FilterBar`, // We delay closing the current page after applying the filter selection
// even when the target is a child of an `AnimatedList`. // so that hero animation target is ready in the `FilterBar`,
// Do not use `WidgetsBinding.instance.addPostFrameCallback`, // even when the target is a child of an `AnimatedList`.
// as it may not trigger if there is no subsequent build. // Do not use `WidgetsBinding.instance.addPostFrameCallback`,
Future.delayed(const Duration(milliseconds: 100), () => goBack(context)); // 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(); clean();
Navigator.pushAndRemoveUntil( Navigator.pushAndRemoveUntil(
context, context,
@ -301,7 +305,7 @@ class CollectionSearchDelegate extends AvesSearchDelegate {
settings: const RouteSettings(name: CollectionPage.routeName), settings: const RouteSettings(name: CollectionPage.routeName),
builder: (context) => CollectionPage( builder: (context) => CollectionPage(
source: source, source: source,
filters: {filter}, filters: filters,
), ),
), ),
(route) => false, (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/app_bar/app_bar_title.dart';
import 'package:aves/widgets/common/basic/insets.dart'; import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/basic/menu.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/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/search/route.dart'; import 'package:aves/widgets/common/search/route.dart';
@ -73,62 +74,64 @@ class _SettingsPageState extends State<SettingsPage> with FeedbackMixin {
if (device.isTelevision) { if (device.isTelevision) {
return Scaffold( return Scaffold(
body: Row( body: TvPopScope(
children: [ child: Row(
TvRail( children: [
controller: context.read<TvRailController>(), TvRail(
), controller: context.read<TvRailController>(),
Expanded(
child: Column(
children: [
const SizedBox(height: 8),
AppBar(
automaticallyImplyLeading: false,
title: appBarTitle,
elevation: 0,
),
Expanded(
child: ValueListenableBuilder<int>(
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)),
),
),
],
);
},
);
},
),
),
],
), ),
), Expanded(
], child: Column(
children: [
const SizedBox(height: 8),
AppBar(
automaticallyImplyLeading: false,
title: appBarTitle,
elevation: 0,
),
Expanded(
child: ValueListenableBuilder<int>(
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 { } else {

View file

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:aves/model/device.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart'; import 'package:aves/model/filters/album.dart';
import 'package:aves/model/filters/filters.dart'; import 'package:aves/model/filters/filters.dart';
@ -225,6 +226,7 @@ class _StatsPageState extends State<StatsPage> {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: !device.isTelevision,
title: Text(l10n.statsPageTitle), title: Text(l10n.statsPageTitle),
), ),
body: GestureAreaProtectorStack( body: GestureAreaProtectorStack(
@ -354,6 +356,7 @@ class StatsTopPage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
automaticallyImplyLeading: !device.isTelevision,
title: Text(title), title: Text(title),
), ),
body: GestureAreaProtectorStack( body: GestureAreaProtectorStack(

View file

@ -185,14 +185,17 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
Widget? child; Widget? child;
Map<ShortcutActivator, Intent>? shortcuts = { Map<ShortcutActivator, Intent>? shortcuts = {
const SingleActivator(LogicalKeyboardKey.arrowUp): isTelevision ? const TvShowLessInfoIntent() : const LeaveIntent(), const SingleActivator(LogicalKeyboardKey.arrowUp): isTelevision ? const TvShowLessInfoIntent() : const _LeaveIntent(),
const SingleActivator(LogicalKeyboardKey.arrowDown): isTelevision ? const TvShowMoreInfoIntent() : const ShowInfoIntent(), 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) { if (hasCollection) {
shortcuts.addAll(const { shortcuts.addAll(const {
SingleActivator(LogicalKeyboardKey.arrowLeft): ShowPreviousIntent(), SingleActivator(LogicalKeyboardKey.arrowLeft): _ShowPreviousIntent(),
SingleActivator(LogicalKeyboardKey.arrowRight): ShowNextIntent(), SingleActivator(LogicalKeyboardKey.arrowRight): _ShowNextIntent(),
}); });
child = MultiEntryScroller( child = MultiEntryScroller(
collection: collection!, collection: collection!,
@ -227,16 +230,18 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
autofocus: true, autofocus: true,
shortcuts: shortcuts, shortcuts: shortcuts,
actions: { actions: {
ShowPreviousIntent: CallbackAction<Intent>(onInvoke: (intent) => _goToHorizontalPage(-1, animate: false)), _ShowPreviousIntent: CallbackAction<Intent>(onInvoke: (intent) => _goToHorizontalPage(-1, animate: false)),
ShowNextIntent: 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)), _LeaveIntent: CallbackAction<Intent>(onInvoke: (intent) => Navigator.pop(context)),
ShowInfoIntent: CallbackAction<Intent>(onInvoke: (intent) => ShowInfoPageNotification().dispatch(context)), _ShowInfoIntent: CallbackAction<Intent>(onInvoke: (intent) => ShowInfoPageNotification().dispatch(context)),
TvShowLessInfoIntent: CallbackAction<Intent>(onInvoke: (intent) => TvShowLessInfoNotification().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) { ActivateIntent: CallbackAction<Intent>(onInvoke: (intent) {
if (isTelevision) { if (isTelevision) {
final _entry = entry; final _entry = entry;
if (_entry != null && _entry.isVideo) { 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); final controller = context.read<VideoConductor>().getController(_entry);
if (controller != null) { if (controller != null) {
VideoActionNotification(controller: controller, action: EntryAction.videoTogglePlay).dispatch(context); VideoActionNotification(controller: controller, action: EntryAction.videoTogglePlay).dispatch(context);
@ -330,30 +335,75 @@ class _ViewerVerticalPageViewState extends State<ViewerVerticalPageView> {
setState(() {}); 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 // keyboard shortcut intents
class ShowPreviousIntent extends Intent { class _ShowPreviousIntent extends Intent {
const ShowPreviousIntent(); const _ShowPreviousIntent();
} }
class ShowNextIntent extends Intent { class _ShowNextIntent extends Intent {
const ShowNextIntent(); const _ShowNextIntent();
} }
class LeaveIntent extends Intent { class _LeaveIntent extends Intent {
const LeaveIntent(); const _LeaveIntent();
} }
class ShowInfoIntent extends Intent { class _ShowInfoIntent extends Intent {
const ShowInfoIntent(); const _ShowInfoIntent();
} }
class TvShowLessInfoIntent extends Intent { class TvShowLessInfoIntent extends Intent {
const TvShowLessInfoIntent(); const TvShowLessInfoIntent();
} }
class TvShowMoreInfoIntent extends Intent { class _TvShowMoreInfoIntent extends Intent {
const TvShowMoreInfoIntent(); 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/actions/entry_actions.dart';
import 'package:aves/model/device.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/theme/durations.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)); final formatSpecificActions = EntryActions.formatSpecificMetadataActions.where((v) => actionDelegate.isVisible(entry, v));
return SliverAppBar( return SliverAppBar(
leading: IconButton( leading: device.isTelevision
// key is expected by test driver ? null
key: const Key('back-button'), : IconButton(
icon: const Icon(AIcons.goUp), // key is expected by test driver
onPressed: onBackPressed, key: const Key('back-button'),
tooltip: context.l10n.viewerInfoBackToViewerTooltip, icon: const Icon(AIcons.goUp),
), onPressed: onBackPressed,
tooltip: context.l10n.viewerInfoBackToViewerTooltip,
),
automaticallyImplyLeading: false,
title: SliverAppBarTitleWrapper( title: SliverAppBarTitleWrapper(
child: InteractiveAppBarTitle( child: InteractiveAppBarTitle(
onTap: () => _goToSearch(context), onTap: () => _goToSearch(context),