import 'dart:async'; import 'dart:io'; import 'package:audio_service/audio_service.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.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/page_routes/blur_slide.dart'; import 'package:freezer/page_routes/fade.dart'; import 'package:freezer/page_routes/scale_fade.dart'; import 'package:freezer/type_adapters/uri.dart'; import 'package:freezer/ui/downloads_screen.dart'; import 'package:freezer/ui/library.dart'; import 'package:freezer/ui/login_screen.dart'; import 'package:freezer/ui/player_screen.dart'; import 'package:freezer/ui/search.dart'; import 'package:freezer/ui/settings_screen.dart'; import 'package:hive_flutter/adapters.dart'; import 'package:i18n_extension/i18n_widget.dart'; import 'package:logging/logging.dart'; import 'package:freezer/translations.i18n.dart'; import 'package:quick_actions/quick_actions.dart'; import 'package:uni_links/uni_links.dart'; import 'package:freezer/type_adapters/audioservicerepeatmode.dart'; import 'package:freezer/type_adapters/datetime.dart'; import 'package:freezer/type_adapters/duration.dart'; import 'package:freezer/type_adapters/mediaitem.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(); // Hive typeadapters Hive ..registerAdapter(HomePageSectionAdapter()) ..registerAdapter(HomePageItemAdapter()) ..registerAdapter(HomePageItemTypeAdapter()) ..registerAdapter(HomePageSectionLayoutAdapter()) ..registerAdapter(TrackAdapter()) ..registerAdapter(AlbumAdapter()) ..registerAdapter(ArtistAdapter()) ..registerAdapter(PlaylistAdapter()) ..registerAdapter(UserAdapter()) ..registerAdapter(DeezerImageDetailsAdapter()) ..registerAdapter(LyricsAdapter()) ..registerAdapter(LyricAdapter()) ..registerAdapter(SmartTrackListAdapter()) ..registerAdapter(DeezerChannelAdapter()) ..registerAdapter(ShowAdapter()) ..registerAdapter(AlbumTypeAdapter()) ..registerAdapter(ColorAdapter()) ..registerAdapter(DurationAdapter()) ..registerAdapter(SortingAdapter()) ..registerAdapter(SortTypeAdapter()) ..registerAdapter(SortSourceTypesAdapter()) ..registerAdapter(SearchHistoryItemAdapter()) ..registerAdapter(SearchHistoryItemTypeAdapter()) ..registerAdapter(CacheAdapter()) ..registerAdapter(DateTimeAdapter()) ..registerAdapter(MediaItemAdapter()) ..registerAdapter(AudioServiceRepeatModeAdapter()) ..registerAdapter(SettingsAdapter()) ..registerAdapter(SpotifyCredentialsSaveAdapter()) ..registerAdapter(AudioQualityAdapter()) ..registerAdapter(ThemesAdapter()) ..registerAdapter(NavigatorRouteTypeAdapter()) ..registerAdapter(UriAdapter()) ..registerAdapter(QueueSourceAdapter()) ..registerAdapter(HomePageAdapter()); await Hive.initFlutter(); //Initialize globals settings = await Settings.load(); settings.save(); downloadManager.init(); cache = await Cache.load(); // TODO: WA deezerAPI.favoritesPlaylistId = cache.favoritesPlaylistId; Logger.root.onRecord.listen((record) { print('${record.level.name}: ${record.time}: ${record.message}'); }); assert(() { Logger.root.level = Level.ALL; return true; }()); //Do on BG 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) { // TODO: 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: (context, child) => DynamicColorBuilder(builder: (lightScheme, darkScheme) { final lightTheme = settings.materialYouAccent ? ThemeData(colorScheme: lightScheme, useMaterial3: true) : settings.themeData; final darkTheme = settings.materialYouAccent ? ThemeData( colorScheme: darkScheme, useMaterial3: true, brightness: Brightness.dark) : null; return MaterialApp( title: 'Freezer', shortcuts: { ...WidgetsApp.defaultShortcuts, LogicalKeySet(LogicalKeyboardKey.select): const ActivateIntent(), // DPAD center key, for remote controls }, theme: lightTheme, darkTheme: darkTheme, 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 true; }, 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) { //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.arl = null; }); 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: Colors.transparent, // Theme.of(context).scaffoldBackgroundColor, statusBarIconBrightness: Brightness.light, ), child: LoginWidget( callback: () => setState(() => {}), ), ); return MainScreen(); } } // class _PlayerBarSliverDelegate extends SliverPersistentHeaderDelegate { // @override // // TODO: implement minExtent // double get minExtent => 59.0; // double get maxExtent => 59.0; // // @override // bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => // false; // // @override // Widget build( // BuildContext context, double shrinkOffset, bool overlapsContent) { // return PlayerBar(); // } // } class MainScreen extends StatefulWidget { @override _MainScreenState createState() => _MainScreenState(); } class _MainScreenState extends State with SingleTickerProviderStateMixin, WidgetsBindingObserver { final _selected = ValueNotifier(0); final _destinations = { 0: '/', 1: '/podcasts', 2: '/library', 3: '/search', }; StreamSubscription? _urlLinkStream; int _keyPressed = 0; bool textFieldVisited = false; final navigationBarFocusNode = FocusScopeNode(); // for bottom navigation bar final screenFocusNode = FocusNode(); // for CustomNavigator @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(); unawaited(playerHelper.start()); //Check for updates on background // Future.delayed(Duration(seconds: 5), () { // FreezerVersions.checkUpdate(); // }); super.initState(); WidgetsBinding.instance.addObserver(this); } /// not needed anymore, replaced by [DeezerAudioSource] // void _startStreamingServer() async { // await DownloadManager.platform // .invokeMethod("startServer", {"arl": settings.arl}); // } void _prepareQuickActions() { if (!Platform.isAndroid) return; 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 { if (!Platform.isAndroid) return; 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) {} } void _handleKey(RawKeyEvent 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(); break; case 22: // LEFT + RIGHT case 21: if (_keyPressed == 21 && keyCode == 22 || _keyPressed == 22 && keyCode == 21) { focusToNavbar(); } _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(); } } 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; } } // TODO: is this even necessary? // 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 is RawKeyUpEvent) { // Map shortcuts = // Shortcuts.of(context).shortcuts; // 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() { navigationBarFocusNode.requestFocus(); navigationBarFocusNode.focusInDirection(TraversalDirection .down); // If player bar is hidden, focus won't be visible, so go down once more } @override Widget build(BuildContext context) { return RawKeyboardListener( focusNode: FocusNode(), onKey: _handleKey, child: FancyScaffold( bottomNavigationBar: FocusScope( node: navigationBarFocusNode, child: ValueListenableBuilder( valueListenable: _selected, builder: (context, value, _) { return NavigationBar( selectedIndex: value, onDestinationSelected: (int s) async { //Pop all routes until home screen navigatorKey.currentState! .popUntil((route) => route.isFirst); navigatorKey.currentState! .pushReplacementNamed(_destinations[s]!); if (_selected.value != s) _selected.value = s; }, destinations: [ NavigationDestination( icon: const Icon(Icons.home_outlined), selectedIcon: const Icon(Icons.home), label: 'Home'.i18n), NavigationDestination( icon: const Icon(Icons.podcasts), label: 'Podcasts'.i18n), NavigationDestination( icon: const Icon(Icons.library_music_outlined), selectedIcon: const Icon(Icons.library_music), label: 'Library'.i18n), // NavigationDestination( // icon: const Icon(Icons.search), // label: 'Search'.i18n), ], ); }), ), bottomPanel: PlayerBar( shouldHandleClicks: false, shouldHaveHero: false, ), bottomPanelHeight: 68.0, expandedPanel: PlayerScreen(), body: Focus( focusNode: screenFocusNode, skipTraversal: true, canRequestFocus: false, child: _MainRouteNavigator( navigatorKey: navigatorKey, routes: { '/': (context) => const HomeScreen(), '/podcasts': (context) => HomePageScreen( cacheable: true, channel: DeezerChannel(target: 'channels/podcasts'), title: 'Podcasts'.i18n, ), '/library': (context) => const LibraryScreen(), '/library/tracks': (context) => const LibraryTracks(), '/library/albums': (context) => const LibraryAlbums(), '/library/artists': (context) => const LibraryArtists(), '/library/playlists': (context) => const LibraryPlaylists(), '/library/history': (context) => const HistoryScreen(), '/search': (context) => const SearchScreen(), '/settings': (context) => const SettingsScreen(), '/downloads': (context) => const DownloadsScreen(), }, )))); } } // hella simple null-safe reimplementation of custom_navigator, which is NOT null-safe class _MainRouteNavigator extends StatelessWidget with WidgetsBindingObserver { final Map routes; final GlobalKey navigatorKey; const _MainRouteNavigator( {Key? key, required this.routes, 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 s) { final routeBuilder = routes[s.name]; if (routeBuilder == null) return null; if (!navigatorKey.currentState!.canPop()) return ScaleFadePageRoute(builder: routeBuilder, settings: s); switch (settings.navigatorRouteType) { case NavigatorRouteType.blur_slide: return BlurSlidePageRoute(builder: routeBuilder, settings: s); case NavigatorRouteType.material: return MaterialPageRoute(builder: routeBuilder, settings: s); case NavigatorRouteType.cupertino: return CupertinoPageRoute(builder: routeBuilder, settings: s); case NavigatorRouteType.fade: return FadePageRoute(builder: routeBuilder, settings: s); case NavigatorRouteType.fade_blur: return FadePageRoute(builder: routeBuilder, settings: s, blur: true); } } } // class FreezerDrawer extends StatelessWidget { // const FreezerDrawer({Key? key}) : super(key: key); // // @override // Widget build(BuildContext context) { // return Drawer( // child: ListView(children: [ // const DrawerHeader(child: FreezerTitle()), // FreezerDrawerTile( // title: 'Home'.i18n, icon: Icon(Icons.home), route: '/'), // Divider(), // FreezerDrawerTile( // title: 'Tracks'.i18n, // icon: Icon(Icons.audiotrack), // route: '/library/tracks'), // FreezerDrawerTile( // title: 'Albums'.i18n, // icon: Icon(Icons.album), // route: '/library/albums'), // FreezerDrawerTile( // title: 'Artists'.i18n, // icon: Icon(Icons.recent_actors), // route: '/library/artists'), // FreezerDrawerTile( // title: 'Playlists'.i18n, // icon: Icon(Icons.playlist_play), // route: '/library/playlists'), // Divider(), // FreezerDrawerTile( // title: 'Downloads'.i18n, // icon: Icon(Icons.download), // route: '/downloads'), // FreezerDrawerTile( // title: 'History'.i18n, // icon: Icon(Icons.history), // route: '/library/history'), // FreezerDrawerTile( // title: 'Settings'.i18n, // icon: Icon(Icons.settings), // route: '/settings'), // ]), // ); // } // } // // class FreezerDrawerTile extends StatelessWidget { // final Widget? icon; // final String title; // final String route; // const FreezerDrawerTile( // {Key? key, this.icon, required this.title, required this.route}) // : super(key: key); // // @override // Widget build(BuildContext context) { // print(route); // return Padding( // padding: const EdgeInsets.symmetric(horizontal: 8.0), // child: Material( // borderRadius: BorderRadius.circular(8.0), // clipBehavior: Clip.antiAlias, // color: ModalRoute.of(context)?.settings.name == route // ? Theme.of(context).brightness == Brightness.dark // ? Colors.white12 // : Colors.black12 // : null, // child: ListTile( // selected: ModalRoute.of(context)?.settings.name == route, // leading: icon, // visualDensity: VisualDensity.compact, // title: Text(title), // onTap: () { // Navigator.of(context).pop(); // navigatorKey.currentState!.pushReplacementNamed(route); // }), // ), // ); // } // }