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 mainNavigatorKey = GlobalKey(); GlobalKey navigatorKey = GlobalKey(); 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 { late StreamSubscription _playbackStateSub; @override void initState() { _initStateAsync(); //Make update theme global updateTheme = _updateTheme; super.initState(); } Future _initStateAsync() async { _playbackStateChanged(audioHandler.playbackState.value); _playbackStateSub = audioHandler.playbackState.listen(_playbackStateChanged); } Future _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: { ...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 { @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( 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 with SingleTickerProviderStateMixin, WidgetsBindingObserver { List _screens = [HomeScreen(), SearchScreen(), LibraryScreen()]; final _selected = ValueNotifier(0); StreamSubscription? _urlLinkStream; int _keyPressed = 0; bool textFieldVisited = false; final _slideTween = Tween( 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 _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 shortcuts = Shortcuts.of(context).shortcuts as Map; 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: [ PlayerBar(), ValueListenableBuilder( 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( 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( 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 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 didPopRoute() async { final NavigatorState? navigator = navigatorKey.currentState; if (navigator == null) return false; return await navigator.maybePop(); } @override Future 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? _onGenerateRoute(RouteSettings settings) { if (settings.name == Navigator.defaultRouteName) { return MaterialPageRoute(builder: (context) => home, settings: settings); } return null; } }