Merge branch 'feature/atv' into 'master'

Android TV support

See merge request freezer/freezer!2
This commit is contained in:
exttex 2020-11-30 23:29:12 +03:00
commit ef9ae6e2ad
9 changed files with 290 additions and 153 deletions

2
.gitignore vendored
View file

@ -37,7 +37,7 @@ android/local.properties
.pub-cache/ .pub-cache/
.pub/ .pub/
/build/ /build/
.gradle .gradle/
# Web related # Web related
lib/generated_plugin_registrant.dart lib/generated_plugin_registrant.dart

View file

@ -14,6 +14,7 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-feature android:name="android.software.LEANBACK" android:required="true"/>
<application <application
android:name="io.flutter.app.FlutterApplication" android:name="io.flutter.app.FlutterApplication"
@ -35,6 +36,9 @@
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:launchMode="singleTop" android:launchMode="singleTop"
android:theme="@style/LaunchTheme" android:theme="@style/LaunchTheme"
android:banner="@mipmap/ic_launcher"
android:icon="@mipmap/ic_launcher"
android:logo="@mipmap/ic_launcher"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- <!--
@ -90,6 +94,11 @@
android:scheme="https" android:scheme="https"
android:host="deezer.page.link" /> android:host="deezer.page.link" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity> </activity>
<!-- <!--
Don't delete the meta-data below. Don't delete the meta-data below.

View file

@ -84,6 +84,10 @@ class _FreezerAppState extends State<FreezerApp> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'Freezer', title: 'Freezer',
shortcuts: <LogicalKeySet, Intent>{
...WidgetsApp.defaultShortcuts,
LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(), // DPAD center key, for remote controls
},
theme: settings.themeData, theme: settings.themeData,
localizationsDelegates: [ localizationsDelegates: [
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
@ -159,10 +163,12 @@ class MainScreen extends StatefulWidget {
_MainScreenState createState() => _MainScreenState(); _MainScreenState createState() => _MainScreenState();
} }
class _MainScreenState extends State<MainScreen> with SingleTickerProviderStateMixin{ class _MainScreenState extends State<MainScreen> with SingleTickerProviderStateMixin, WidgetsBindingObserver {
List<Widget> _screens = [HomeScreen(), SearchScreen(), LibraryScreen()]; List<Widget> _screens = [HomeScreen(), SearchScreen(), LibraryScreen()];
int _selected = 0; int _selected = 0;
StreamSubscription _urlLinkStream; StreamSubscription _urlLinkStream;
int _keyPressed = 0;
bool textFieldVisited = false;
@override @override
void initState() { void initState() {
@ -181,6 +187,7 @@ class _MainScreenState extends State<MainScreen> with SingleTickerProviderStateM
}); });
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this);
} }
void _startStreamingServer() async { void _startStreamingServer() async {
@ -227,9 +234,19 @@ class _MainScreenState extends State<MainScreen> with SingleTickerProviderStateM
void dispose() { void dispose() {
if (_urlLinkStream != null) if (_urlLinkStream != null)
_urlLinkStream.cancel(); _urlLinkStream.cancel();
WidgetsBinding.instance.removeObserver(this);
super.dispose(); super.dispose();
} }
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
setState(() {
textFieldVisited = false;
});
}
}
void _setupUniLinks() async { void _setupUniLinks() async {
//Listen to URLs //Listen to URLs
_urlLinkStream = getUriLinksStream().listen((Uri uri) { _urlLinkStream = getUriLinksStream().listen((Uri uri) {
@ -243,49 +260,133 @@ class _MainScreenState extends State<MainScreen> with SingleTickerProviderStateM
} catch (e) {} } catch (e) {}
} }
ValueChanged<RawKeyEvent> _handleKey(FocusScopeNode navigationBarFocusNode, FocusNode screenFocusNode){
return (event) {
FocusNode primaryFocus = FocusManager.instance.primaryFocus;
// After visiting text field, something goes wrong and KeyDown events are not sent, only KeyUp-s.
// So, set this flag to indicate a transition to other "mode"
if (primaryFocus.context.widget.runtimeType.toString() == 'EditableText') {
setState(() {
textFieldVisited = true;
});
}
// Movement to navigation bar and back
if (event.runtimeType.toString() == (textFieldVisited ? 'RawKeyUpEvent' : 'RawKeyDownEvent')) {
int keyCode = (event.data as RawKeyEventDataAndroid).keyCode;
switch (keyCode) {
case 127: // Menu on Android TV
case 327: // EPG on Hisense TV
focusToNavbar(navigationBarFocusNode);
break;
case 22: // LEFT + RIGHT
case 21:
if (_keyPressed == 21 && keyCode == 22 || _keyPressed == 22 && keyCode == 21) {
focusToNavbar(navigationBarFocusNode);
}
_keyPressed = keyCode;
Future.delayed(Duration(milliseconds: 100), () => {
_keyPressed = 0
});
break;
case 20: // DOWN
// If it's bottom row, go to navigation bar
var row = primaryFocus.parent;
if (row != null) {
var column = row.parent;
if (column.children.last == row) {
focusToNavbar(navigationBarFocusNode);
}
}
break;
case 19: // UP
if (navigationBarFocusNode.hasFocus) {
screenFocusNode.parent.parent.children.last // children.last is used for handling "playlists" screen in library. Under CustomNavigator 2 screens appears.
.nextFocus(); // nextFocus is used instead of requestFocus because it focuses on last, bottom, non-visible tile of main page
}
break;
}
}
// After visiting text field, something goes wrong and KeyDown events are not sent, only KeyUp-s.
// Focus moving works only on KeyDown events, so here we simulate keys handling as it's done in Flutter
if (textFieldVisited && event.runtimeType.toString() == 'RawKeyUpEvent') {
Map<LogicalKeySet, Intent> shortcuts = Shortcuts.of(context).shortcuts;
final BuildContext primaryContext = primaryFocus?.context;
Intent intent = shortcuts[LogicalKeySet(event.logicalKey)];
if (intent != null) {
Actions.invoke(primaryContext, intent, nullOk: true);
}
// WA for "Search field -> navigator -> UP -> DOWN" case. Prevents focus hanging.
FocusNode newFocus = FocusManager.instance.primaryFocus;
if (newFocus is FocusScopeNode) {
navigationBarFocusNode.requestFocus();
}
}
};
}
void focusToNavbar(FocusScopeNode navigatorFocusNode) {
navigatorFocusNode.requestFocus();
navigatorFocusNode.focusInDirection(TraversalDirection.down); // If player bar is hidden, focus won't be visible, so go down once more
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( FocusScopeNode navigationBarFocusNode = FocusScopeNode(); // for bottom navigation bar
bottomNavigationBar: Column( FocusNode screenFocusNode = FocusNode(); // for CustomNavigator
mainAxisSize: MainAxisSize.min,
children: <Widget>[ return RawKeyboardListener(
PlayerBar(), focusNode: FocusNode(),
BottomNavigationBar( onKey: _handleKey(navigationBarFocusNode, screenFocusNode),
backgroundColor: Theme.of(context).bottomAppBarColor, child: Scaffold(
currentIndex: _selected, bottomNavigationBar:
onTap: (int s) async { FocusScope(
node: navigationBarFocusNode,
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
PlayerBar(),
BottomNavigationBar(
backgroundColor: Theme.of(context).bottomAppBarColor,
currentIndex: _selected,
onTap: (int s) async {
//Pop all routes until home screen
while (navigatorKey.currentState.canPop()) {
await navigatorKey.currentState.maybePop();
}
//Pop all routes until home screen await navigatorKey.currentState.maybePop();
while (navigatorKey.currentState.canPop()) { setState(() {
await navigatorKey.currentState.maybePop(); _selected = s;
} });
},
await navigatorKey.currentState.maybePop(); selectedItemColor: Theme.of(context).primaryColor,
setState(() { items: <BottomNavigationBarItem>[
_selected = s; BottomNavigationBarItem(
}); icon: Icon(Icons.home),
}, title: Text('Home'.i18n)),
selectedItemColor: Theme.of(context).primaryColor, BottomNavigationBarItem(
items: <BottomNavigationBarItem>[ icon: Icon(Icons.search),
BottomNavigationBarItem( title: Text('Search'.i18n),
icon: Icon(Icons.home), title: Text('Home'.i18n)), ),
BottomNavigationBarItem( BottomNavigationBarItem(
icon: Icon(Icons.search), icon: Icon(Icons.library_music),
title: Text('Search'.i18n), title: Text('Library'.i18n))
],
)
],
)),
body: AudioServiceWidget(
child: CustomNavigator(
navigatorKey: navigatorKey,
home: Focus(
focusNode: screenFocusNode,
skipTraversal: true,
canRequestFocus: false,
child: _screens[_selected]
),
pageRoute: PageRoutes.materialPageRoute
), ),
BottomNavigationBarItem( )));
icon: Icon(Icons.library_music), title: Text('Library'.i18n))
],
)
],
),
body: AudioServiceWidget(
child: CustomNavigator(
navigatorKey: navigatorKey,
home: _screens[_selected],
pageRoute: PageRoutes.materialPageRoute,
),
));
} }
} }

View file

@ -143,14 +143,11 @@ class _HomePageScreenState extends State<HomePageScreen> {
)); ));
if (_error) if (_error)
return ErrorScreen(); return ErrorScreen();
return ListView.builder( return Column(
shrinkWrap: true, children: List.generate(_homePage.sections.length, (i) {
physics: NeverScrollableScrollPhysics(), return HomepageSectionWidget(_homePage.sections[i]);
itemCount: _homePage.sections.length, },
itemBuilder: (context, i) { ));
return HomepageSectionWidget(_homePage.sections[i]);
},
);
} }
} }
@ -161,62 +158,54 @@ class HomepageSectionWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return ListTile(
mainAxisSize: MainAxisSize.min, title: Text(
crossAxisAlignment: CrossAxisAlignment.start, section.title??'',
children: [
Padding(
child: Text(
section.title,
textAlign: TextAlign.left, textAlign: TextAlign.left,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 20.0, fontSize: 20.0,
fontWeight: FontWeight.w900 fontWeight: FontWeight.w900
), ),
), ),
padding: EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0) subtitle: SingleChildScrollView(
), scrollDirection: Axis.horizontal,
child: Row(
SingleChildScrollView( children: List.generate(section.items.length + 1, (j) {
scrollDirection: Axis.horizontal, //Has more items
child: Row( if (j == section.items.length) {
children: List.generate(section.items.length + 1, (i) { if (section.hasMore ?? false) {
//Has more items return FlatButton(
if (i == section.items.length) { child: Text(
if (section.hasMore??false) { 'Show more'.i18n,
return FlatButton( textAlign: TextAlign.center,
child: Text( style: TextStyle(
'Show more'.i18n, fontSize: 20.0
textAlign: TextAlign.center, ),
style: TextStyle(
fontSize: 20.0
),
),
onPressed: () => Navigator.of(context).push(MaterialPageRoute(
builder: (context) => Scaffold(
appBar: FreezerAppBar(section.title),
body: SingleChildScrollView(
child: HomePageScreen(
channel: DeezerChannel(target: section.pagePath)
)
), ),
), onPressed: () => Navigator.of(context).push(MaterialPageRoute(
)), builder: (context) => Scaffold(
); appBar: FreezerAppBar(section.title),
} body: SingleChildScrollView(
return Container(height: 0, width: 0); child: HomePageScreen(
} channel: DeezerChannel(target: section.pagePath)
//Show item )
HomePageItem item = section.items[i]; ),
return HomePageItemWidget(item); ),
}), )),
), );
), }
Container(height: 8.0), return Container(height: 0, width: 0);
], }
);
//Show item
HomePageItem item = section.items[j];
return HomePageItemWidget(item);
}),
),
)
);
} }
} }

View file

@ -1,5 +1,6 @@
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:freezer/api/deezer.dart'; import 'package:freezer/api/deezer.dart';
import 'package:freezer/api/player.dart'; import 'package:freezer/api/player.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart';
@ -111,6 +112,17 @@ class _LoginWidgetState extends State<LoginWidget> {
_start(); _start();
} }
// ARL auth: called on "Save" click, Enter and DPAD_Center press
void goARL(FocusNode node, TextEditingController _controller) {
if (node != null) {
node.unfocus();
}
_controller.clear();
settings.arl = _arl.trim();
Navigator.of(context).pop();
_update();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -121,7 +133,14 @@ class _LoginWidgetState extends State<LoginWidget> {
child: CircularProgressIndicator(), child: CircularProgressIndicator(),
), ),
); );
TextEditingController _controller = new TextEditingController();
// For "DPAD center" key handling on remote controls
FocusNode focusNode = FocusNode(skipTraversal: true,descendantsAreFocusable: false,onKey: (node, event) {
if (event.logicalKey == LogicalKeyboardKey.select) {
goARL(node, _controller);
}
return true;
});
if (settings.arl == null) if (settings.arl == null)
return Scaffold( return Scaffold(
body: Padding( body: Padding(
@ -165,6 +184,7 @@ class _LoginWidgetState extends State<LoginWidget> {
showDialog( showDialog(
context: context, context: context,
builder: (context) { builder: (context) {
Future.delayed(Duration(seconds: 1), () => {focusNode.requestFocus()}); // autofocus doesn't work - it's replacement
return AlertDialog( return AlertDialog(
title: Text('Enter ARL'.i18n), title: Text('Enter ARL'.i18n),
content: Container( content: Container(
@ -173,16 +193,17 @@ class _LoginWidgetState extends State<LoginWidget> {
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Token (ARL)'.i18n labelText: 'Token (ARL)'.i18n
), ),
focusNode: focusNode,
controller: _controller,
onSubmitted: (String s) {
goARL(focusNode, _controller);
},
), ),
), ),
actions: <Widget>[ actions: <Widget>[
FlatButton( FlatButton(
child: Text('Save'.i18n), child: Text('Save'.i18n),
onPressed: () { onPressed: () => goARL(null, _controller),
settings.arl = _arl.trim();
Navigator.of(context).pop();
_update();
},
) )
], ],
); );

View file

@ -20,6 +20,7 @@ class PlayerBar extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var focusNode = FocusNode();
return GestureDetector( return GestureDetector(
onHorizontalDragUpdate: (details) async { onHorizontalDragUpdate: (details) async {
if (_gestureRegistered) return; if (_gestureRegistered) return;
@ -46,9 +47,11 @@ class PlayerBar extends StatelessWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: <Widget>[ children: <Widget>[
Container( Container(
color: Theme.of(context).bottomAppBarColor, // For Android TV: indicate focus by grey
color: focusNode.hasFocus ? Colors.black26 : Theme.of(context).bottomAppBarColor,
child: ListTile( child: ListTile(
dense: true, dense: true,
focusNode: focusNode,
contentPadding: EdgeInsets.symmetric(horizontal: 8.0), contentPadding: EdgeInsets.symmetric(horizontal: 8.0),
onTap: () { onTap: () {
Navigator.of(context).push(MaterialPageRoute( Navigator.of(context).push(MaterialPageRoute(

View file

@ -57,7 +57,7 @@ class _PlayerScreenState extends State<PlayerScreen> {
setState(() => _bgGradient = LinearGradient( setState(() => _bgGradient = LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [palette.dominantColor.color.withOpacity(0.5), Theme.of(context).bottomAppBarColor], colors: [palette.dominantColor.color.withOpacity(0.5), Color.fromARGB(0, 0, 0, 0)],
stops: [ stops: [
0.0, 0.0,
0.4 0.4
@ -687,6 +687,7 @@ class _SeekBarState extends State<SeekBar> {
Container( Container(
height: 32.0, height: 32.0,
child: Slider( child: Slider(
focusNode: FocusNode(canRequestFocus: false, skipTraversal: true), // Don't focus on Slider - it doesn't work (and not needed)
value: position, value: position,
max: duration, max: duration,
onChangeStart: (double d) { onChangeStart: (double d) {

View file

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:fluttericon/font_awesome5_icons.dart'; import 'package:fluttericon/font_awesome5_icons.dart';
import 'package:fluttericon/typicons_icons.dart'; import 'package:fluttericon/typicons_icons.dart';
import 'package:flutter/src/services/keyboard_key.dart';
import 'package:freezer/api/cache.dart'; import 'package:freezer/api/cache.dart';
import 'package:freezer/api/download.dart'; import 'package:freezer/api/download.dart';
import 'package:freezer/api/player.dart'; import 'package:freezer/api/player.dart';
@ -127,9 +128,11 @@ class _SearchScreenState extends State<SearchScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var textFielFocusNode = FocusNode();
return Scaffold( return Scaffold(
appBar: FreezerAppBar('Search'.i18n), appBar: FreezerAppBar('Search'.i18n),
body: ListView( body: FocusScope(
child: ListView(
children: <Widget>[ children: <Widget>[
Container(height: 4.0), Container(height: 4.0),
Padding( Padding(
@ -140,53 +143,66 @@ class _SearchScreenState extends State<SearchScreen> {
child: Stack( child: Stack(
alignment: Alignment(1.0, 0.0), alignment: Alignment(1.0, 0.0),
children: [ children: [
TextField( RawKeyboardListener(
onChanged: (String s) { focusNode: FocusNode(),
setState(() => _query = s); onKey: (event) { // For Android TV: quit search textfield
_loadSuggestions(); if (event.runtimeType.toString() == 'RawKeyUpEvent') {
}, LogicalKeyboardKey key = event.data.logicalKey;
onTap: () { if (key == LogicalKeyboardKey.arrowDown) {
setState(() => _showCards = false); textFielFocusNode.unfocus();
}, }
focusNode: _focus, }
decoration: InputDecoration( },
labelText: 'Search or paste URL'.i18n, child: TextField(
fillColor: Theme.of(context).bottomAppBarColor, onChanged: (String s) {
filled: true, setState(() => _query = s);
focusedBorder: OutlineInputBorder( _loadSuggestions();
borderSide: BorderSide(color: Colors.grey) },
), onTap: () {
enabledBorder: OutlineInputBorder( setState(() => _showCards = false);
borderSide: BorderSide(color: Colors.grey) },
), focusNode: textFielFocusNode,
), decoration: InputDecoration(
controller: _controller, labelText: 'Search or paste URL'.i18n,
onSubmitted: (String s) => _submit(context, query: s), fillColor: Theme.of(context).bottomAppBarColor,
), filled: true,
Row( focusedBorder: OutlineInputBorder(
mainAxisSize: MainAxisSize.min, borderSide: BorderSide(color: Colors.grey)
children: [ ),
Container( enabledBorder: OutlineInputBorder(
width: 40.0, borderSide: BorderSide(color: Colors.grey)
child: IconButton( ),
splashRadius: 20.0,
icon: Icon(Icons.clear),
onPressed: () {
setState(() {
_suggestions = [];
_query = '';
});
_controller.clear();
},
), ),
), controller: _controller,
], onSubmitted: (String s) => _submit(context, query: s),
)
),
Focus(
canRequestFocus: false, // Focus is moving to cross, and hangs out there,
descendantsAreFocusable: false, // so we disable focusing on it at all
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40.0,
child: IconButton(
splashRadius: 20.0,
icon: Icon(Icons.clear),
onPressed: () {
setState(() {
_suggestions = [];
_query = '';
});
_controller.clear();
},
),
),
],
)
) )
], ],
) )
), ),
], ],
), ),
), ),
@ -374,6 +390,7 @@ class _SearchScreenState extends State<SearchScreen> {
}, },
)) ))
], ],
)
), ),
); );
} }

View file

@ -135,7 +135,6 @@ class ArtistTile extends StatelessWidget {
return SizedBox( return SizedBox(
width: 150, width: 150,
child: Container( child: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
onLongPress: onHold, onLongPress: onHold,
@ -246,7 +245,6 @@ class PlaylistCardTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
color: Theme.of(context).scaffoldBackgroundColor,
height: 180.0, height: 180.0,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
@ -293,7 +291,6 @@ class SmartTrackListTile extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
height: 200.0, height: 200.0,
color: Theme.of(context).scaffoldBackgroundColor,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
onLongPress: onHold, onLongPress: onHold,
@ -365,7 +362,6 @@ class AlbumCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
onLongPress: onHold, onLongPress: onHold,