495 lines
16 KiB
Dart
495 lines
16 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/rendering.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_displaymode/flutter_displaymode.dart';
|
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
|
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
|
import 'package:freezer/api/cache.dart';
|
|
import 'package:freezer/api/definitions.dart';
|
|
import 'package:freezer/ui/library.dart';
|
|
import 'package:freezer/ui/login_screen.dart';
|
|
import 'package:freezer/ui/search.dart';
|
|
import 'package:freezer/ui/updater.dart';
|
|
import 'package:i18n_extension/i18n_widget.dart';
|
|
import 'package:move_to_background/move_to_background.dart';
|
|
import 'package:freezer/translations.i18n.dart';
|
|
import 'package:quick_actions/quick_actions.dart';
|
|
import 'package:uni_links/uni_links.dart';
|
|
|
|
import 'api/deezer.dart';
|
|
import 'api/download.dart';
|
|
import 'api/player.dart';
|
|
import 'settings.dart';
|
|
import 'ui/home_screen.dart';
|
|
import 'ui/player_bar.dart';
|
|
|
|
late Function updateTheme;
|
|
late Function logOut;
|
|
GlobalKey<NavigatorState> mainNavigatorKey = GlobalKey<NavigatorState>();
|
|
GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
|
|
|
void main() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
|
|
//Initialize globals
|
|
settings = await Settings().loadSettings();
|
|
await downloadManager.init();
|
|
cache = await Cache.load();
|
|
|
|
//Do on BG
|
|
playerHelper.authorizeLastFM();
|
|
await playerHelper.initAudioHandler();
|
|
|
|
runApp(FreezerApp());
|
|
}
|
|
|
|
class FreezerApp extends StatefulWidget {
|
|
@override
|
|
_FreezerAppState createState() => _FreezerAppState();
|
|
}
|
|
|
|
class _FreezerAppState extends State<FreezerApp> {
|
|
late StreamSubscription _playbackStateSub;
|
|
|
|
@override
|
|
void initState() {
|
|
_initStateAsync();
|
|
//Make update theme global
|
|
updateTheme = _updateTheme;
|
|
super.initState();
|
|
}
|
|
|
|
Future<void> _initStateAsync() async {
|
|
_playbackStateChanged(audioHandler.playbackState.value);
|
|
_playbackStateSub =
|
|
audioHandler.playbackState.listen(_playbackStateChanged);
|
|
}
|
|
|
|
Future<void> _playbackStateChanged(PlaybackState playbackState) async {
|
|
if (playbackState.processingState == AudioProcessingState.idle ||
|
|
playbackState.processingState == AudioProcessingState.error) {
|
|
// reconnect maybe?
|
|
return;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_playbackStateSub.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
void _updateTheme() {
|
|
setState(() {
|
|
settings.themeData;
|
|
});
|
|
}
|
|
|
|
Locale? _locale() {
|
|
if (settings.language == null || settings.language!.split('_').length < 2)
|
|
return null;
|
|
return Locale(
|
|
settings.language!.split('_')[0], settings.language!.split('_')[1]);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ScreenUtilInit(
|
|
designSize: Size(1080, 720),
|
|
builder: () => MaterialApp(
|
|
title: 'Freezer',
|
|
shortcuts: <ShortcutActivator, Intent>{
|
|
...WidgetsApp.defaultShortcuts,
|
|
LogicalKeySet(LogicalKeyboardKey.select):
|
|
const ActivateIntent(), // DPAD center key, for remote controls
|
|
},
|
|
theme: settings.themeData,
|
|
localizationsDelegates: [
|
|
GlobalMaterialLocalizations.delegate,
|
|
GlobalWidgetsLocalizations.delegate,
|
|
GlobalCupertinoLocalizations.delegate,
|
|
],
|
|
supportedLocales: supportedLocales,
|
|
home: WillPopScope(
|
|
onWillPop: () async {
|
|
if (navigatorKey.currentState!.canPop()) {
|
|
await navigatorKey.currentState!.maybePop();
|
|
return false;
|
|
}
|
|
await MoveToBackground.moveTaskToBack();
|
|
return false;
|
|
},
|
|
child: I18n(
|
|
initialLocale: _locale(),
|
|
child: LoginMainWrapper(),
|
|
),
|
|
),
|
|
navigatorKey: mainNavigatorKey,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
//Wrapper for login and main screen.
|
|
class LoginMainWrapper extends StatefulWidget {
|
|
@override
|
|
_LoginMainWrapperState createState() => _LoginMainWrapperState();
|
|
}
|
|
|
|
class _LoginMainWrapperState extends State<LoginMainWrapper> {
|
|
@override
|
|
void initState() {
|
|
if (settings.arl != null) {
|
|
playerHelper.start();
|
|
//Load token on background
|
|
deezerAPI.arl = settings.arl;
|
|
settings.offlineMode = true;
|
|
deezerAPI.authorize().then((b) async {
|
|
if (b) setState(() => settings.offlineMode = false);
|
|
});
|
|
}
|
|
//Global logOut function
|
|
logOut = _logOut;
|
|
|
|
super.initState();
|
|
}
|
|
|
|
Future _logOut() async {
|
|
setState(() {
|
|
settings.arl = null;
|
|
settings.offlineMode = false;
|
|
deezerAPI = new DeezerAPI();
|
|
});
|
|
await settings.save();
|
|
await Cache.wipe();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (settings.arl == null)
|
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
|
value: (Theme.of(context).brightness == Brightness.dark
|
|
? SystemUiOverlayStyle.dark
|
|
: SystemUiOverlayStyle.light)
|
|
.copyWith(
|
|
statusBarColor: Colors.transparent,
|
|
systemNavigationBarColor: Theme.of(context).scaffoldBackgroundColor,
|
|
statusBarIconBrightness: Brightness.light,
|
|
),
|
|
child: LoginWidget(
|
|
callback: () => setState(() => {}),
|
|
),
|
|
);
|
|
return MainScreen();
|
|
}
|
|
}
|
|
|
|
class MainScreen extends StatefulWidget {
|
|
@override
|
|
_MainScreenState createState() => _MainScreenState();
|
|
}
|
|
|
|
class _MainScreenState extends State<MainScreen>
|
|
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
|
List<Widget> _screens = [HomeScreen(), SearchScreen(), LibraryScreen()];
|
|
final _selected = ValueNotifier<int>(0);
|
|
StreamSubscription? _urlLinkStream;
|
|
int _keyPressed = 0;
|
|
bool textFieldVisited = false;
|
|
final _slideTween = Tween<Offset>(
|
|
begin: const Offset(0.0, 0.025), end: const Offset(0.0, 0.0));
|
|
final _scaleTween = Tween(begin: 0.975, end: 1.0);
|
|
|
|
@override
|
|
void initState() {
|
|
//Set display mode
|
|
if (settings.displayMode != null && settings.displayMode! >= 0) {
|
|
FlutterDisplayMode.supported.then((modes) async {
|
|
if (modes.length - 1 >= settings.displayMode!)
|
|
FlutterDisplayMode.setPreferredMode(modes[settings.displayMode!]);
|
|
});
|
|
}
|
|
|
|
_startStreamingServer();
|
|
|
|
//Start with parameters
|
|
_setupUniLinks();
|
|
_loadPreloadInfo();
|
|
_prepareQuickActions();
|
|
|
|
//Check for updates on background
|
|
Future.delayed(Duration(seconds: 5), () {
|
|
FreezerVersions.checkUpdate();
|
|
});
|
|
|
|
super.initState();
|
|
WidgetsBinding.instance!.addObserver(this);
|
|
}
|
|
|
|
void _startStreamingServer() async {
|
|
await DownloadManager.platform
|
|
.invokeMethod("startServer", {"arl": settings.arl});
|
|
}
|
|
|
|
void _prepareQuickActions() {
|
|
final QuickActions quickActions = QuickActions();
|
|
quickActions.initialize((type) {
|
|
_startPreload(type);
|
|
});
|
|
|
|
//Actions
|
|
quickActions.setShortcutItems([
|
|
ShortcutItem(
|
|
type: 'favorites',
|
|
localizedTitle: 'Favorites'.i18n,
|
|
icon: 'ic_favorites'),
|
|
ShortcutItem(type: 'flow', localizedTitle: 'Flow'.i18n, icon: 'ic_flow'),
|
|
]);
|
|
}
|
|
|
|
void _startPreload(String type) async {
|
|
await deezerAPI.authorize();
|
|
if (type == 'flow') {
|
|
await playerHelper.playFromSmartTrackList(SmartTrackList(id: 'flow'));
|
|
return;
|
|
}
|
|
if (type == 'favorites') {
|
|
Playlist p = await deezerAPI.fullPlaylist(deezerAPI.favoritesPlaylistId);
|
|
playerHelper.playFromPlaylist(p, p.tracks![0]!.id);
|
|
}
|
|
}
|
|
|
|
void _loadPreloadInfo() async {
|
|
String? info =
|
|
await DownloadManager.platform.invokeMethod('getPreloadInfo');
|
|
if (info != null) {
|
|
//Used if started from android auto
|
|
await deezerAPI.authorize();
|
|
_startPreload(info);
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_urlLinkStream?.cancel();
|
|
WidgetsBinding.instance!.removeObserver(this);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (state == AppLifecycleState.resumed) {
|
|
setState(() {
|
|
textFieldVisited = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _setupUniLinks() async {
|
|
//Listen to URLs
|
|
_urlLinkStream = linkStream.listen((String? link) {
|
|
if (link == null) return;
|
|
openScreenByURL(context, link);
|
|
}, onError: (err) {});
|
|
//Get initial link on cold start
|
|
try {
|
|
String? link = await getInitialLink();
|
|
if (link != null && link.length > 4) openScreenByURL(context, link);
|
|
} 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 as Map<LogicalKeySet, Intent>;
|
|
final BuildContext? primaryContext = primaryFocus.context;
|
|
Intent? intent = shortcuts[LogicalKeySet(event.logicalKey)];
|
|
if (intent != null) {
|
|
Actions.invoke(primaryContext!, intent);
|
|
}
|
|
// 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
|
|
Widget build(BuildContext context) {
|
|
FocusScopeNode navigationBarFocusNode =
|
|
FocusScopeNode(); // for bottom navigation bar
|
|
FocusNode screenFocusNode = FocusNode(); // for CustomNavigator
|
|
|
|
return RawKeyboardListener(
|
|
focusNode: FocusNode(),
|
|
onKey: _handleKey(navigationBarFocusNode, screenFocusNode),
|
|
child: Scaffold(
|
|
bottomNavigationBar: FocusScope(
|
|
node: navigationBarFocusNode,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: <Widget>[
|
|
PlayerBar(),
|
|
ValueListenableBuilder<int>(
|
|
valueListenable: _selected,
|
|
builder: (context, value, _) {
|
|
return BottomNavigationBar(
|
|
backgroundColor: Theme.of(context).bottomAppBarColor,
|
|
currentIndex: value,
|
|
onTap: (int s) async {
|
|
//Pop all routes until home screen
|
|
while (navigatorKey.currentState!.canPop()) {
|
|
await navigatorKey.currentState!.maybePop();
|
|
}
|
|
|
|
await navigatorKey.currentState!.maybePop();
|
|
if (_selected.value != s) _selected.value = s;
|
|
},
|
|
selectedItemColor: Theme.of(context).primaryColor,
|
|
items: <BottomNavigationBarItem>[
|
|
BottomNavigationBarItem(
|
|
icon: Icon(Icons.home), label: 'Home'.i18n),
|
|
BottomNavigationBarItem(
|
|
icon: Icon(Icons.search),
|
|
label: 'Search'.i18n,
|
|
),
|
|
BottomNavigationBarItem(
|
|
icon: Icon(Icons.library_music),
|
|
label: 'Library'.i18n)
|
|
],
|
|
);
|
|
})
|
|
],
|
|
)),
|
|
body: _MainRouteNavigator(
|
|
navigatorKey: navigatorKey,
|
|
home: Focus(
|
|
focusNode: screenFocusNode,
|
|
skipTraversal: true,
|
|
canRequestFocus: false,
|
|
child: ValueListenableBuilder<int>(
|
|
valueListenable: _selected,
|
|
builder: (context, value, _) => AnimatedSwitcher(
|
|
duration: Duration(milliseconds: 250),
|
|
transitionBuilder: (child, animation) =>
|
|
SlideTransition(
|
|
position: _slideTween.animate(animation),
|
|
child: ScaleTransition(
|
|
scale: _scaleTween.animate(animation),
|
|
child: FadeTransition(
|
|
opacity: animation,
|
|
child: child,
|
|
),
|
|
)),
|
|
layoutBuilder: (currentChild, previousChildren) =>
|
|
currentChild!,
|
|
child: _screens[value],
|
|
)))),
|
|
));
|
|
}
|
|
}
|
|
|
|
// hella simple null-safe reimplementation of custom_navigator, which is NOT null-safe
|
|
class _MainRouteNavigator extends StatelessWidget with WidgetsBindingObserver {
|
|
final Widget home;
|
|
final GlobalKey<NavigatorState> navigatorKey;
|
|
const _MainRouteNavigator(
|
|
{Key? key, required this.home, required this.navigatorKey})
|
|
: super(key: key);
|
|
|
|
// A system method that get invoked when user press back button on Android or back slide on iOS
|
|
@override
|
|
Future<bool> didPopRoute() async {
|
|
final NavigatorState? navigator = navigatorKey.currentState;
|
|
if (navigator == null) return false;
|
|
return await navigator.maybePop();
|
|
}
|
|
|
|
@override
|
|
Future<bool> didPushRoute(String route) async {
|
|
final NavigatorState? navigator = navigatorKey.currentState;
|
|
if (navigator == null) return false;
|
|
navigator.pushNamed(route);
|
|
return true;
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Navigator(
|
|
key: navigatorKey,
|
|
initialRoute: Navigator.defaultRouteName,
|
|
onGenerateRoute: _onGenerateRoute,
|
|
);
|
|
}
|
|
|
|
Route<dynamic>? _onGenerateRoute(RouteSettings settings) {
|
|
if (settings.name == Navigator.defaultRouteName) {
|
|
return MaterialPageRoute(builder: (context) => home, settings: settings);
|
|
}
|
|
return null;
|
|
}
|
|
}
|