freezer/lib/main.dart

769 lines
26 KiB
Dart

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/foundation.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<NavigatorState> mainNavigatorKey = GlobalKey<NavigatorState>();
GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
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) {
if (kDebugMode) {
print('${record.level.name}: ${record.time}: ${record.message}');
}
});
if (kDebugMode) {
Logger.root.level = Level.ALL;
}
//Do on BG
await playerHelper.initAudioHandler();
runApp(const FreezerApp());
}
class FreezerApp extends StatefulWidget {
const FreezerApp({super.key});
@override
State<FreezerApp> 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) {
// 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: const 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: <ShortcutActivator, Intent>{
...WidgetsApp.defaultShortcuts,
LogicalKeySet(LogicalKeyboardKey.select):
const ActivateIntent(), // DPAD center key, for remote controls
},
theme: lightTheme,
darkTheme: darkTheme,
localizationsDelegates: const [
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: const LoginMainWrapper(),
),
),
navigatorKey: mainNavigatorKey,
);
}),
);
}
}
//Wrapper for login and main screen.
class LoginMainWrapper extends StatefulWidget {
const LoginMainWrapper({super.key});
@override
State<LoginMainWrapper> createState() => _LoginMainWrapperState();
}
class _LoginMainWrapperState extends State<LoginMainWrapper> {
@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);
if (cache.libraryTracks.isEmpty) {
// load and cache library tracks.
final favorites =
await deezerAPI.fullPlaylist(deezerAPI.favoritesPlaylistId!);
cache.libraryTracks = favorites.tracks!.map((e) => e.id).toList();
}
});
}
//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<SystemUiOverlayStyle>(
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 const 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 {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen>
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
final _logger = Logger('_MainScreenState');
final _selected = ValueNotifier<int>(0);
final _destinations = <int, String>{
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
final playerScreenFocusNode = FocusScopeNode();
final playerBarFocusNode = FocusNode();
final _fancyScaffoldKey = GlobalKey<FancyScaffoldState>();
@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;
const 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();
screenFocusNode.dispose();
navigationBarFocusNode.dispose();
playerBarFocusNode.dispose();
playerScreenFocusNode.dispose();
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) {
setState(() {
textFieldVisited = false;
});
}
}
void _setupUniLinks() async {
// supported only on android for now
if (!Platform.isAndroid) return;
//Listen to URLs
_urlLinkStream = linkStream.listen((String? link) {
if (link == null) return;
if (!context.mounted) return;
openScreenByURL(context, link);
}, onError: (err) {});
//Get initial link on cold start
try {
String? link = await getInitialLink();
if (!context.mounted) return;
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') {
textFieldVisited = true;
}
// Movement to navigation bar and back
if ((event is RawKeyUpEvent && textFieldVisited) ||
event is RawKeyDownEvent) {
// only handl if we're running on android
if (event.data is! RawKeyEventDataAndroid) return;
int keyCode = (event.data as RawKeyEventDataAndroid).keyCode;
_logger.fine('KEY PRESSED: $keyCode');
switch (keyCode) {
case 82: // Menu on FireTV
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(
const 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 (playerBarFocusNode.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
}
if (navigationBarFocusNode.hasFocus) {
playerBarFocusNode.requestFocus();
}
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<ShortcutActivator, Intent> 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() {
if (playerBarFocusNode.hasFocus) {
_logger.fine('Focusing to navBar');
return navigationBarFocusNode.requestFocus();
}
_logger.fine('Focusing to playerBar');
return playerBarFocusNode.requestFocus();
}
@override
Widget build(BuildContext context) {
return RawKeyboardListener(
focusNode: FocusNode(),
onKey: _handleKey,
child: FancyScaffold(
key: _fancyScaffoldKey,
bottomNavigationBar: FocusScope(
node: navigationBarFocusNode,
child: ValueListenableBuilder<int>(
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]!,
arguments: true);
if (_selected.value != s) _selected.value = s;
},
destinations: <NavigationDestination>[
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(
focusNode: playerBarFocusNode,
onTap: () =>
_fancyScaffoldKey.currentState!.dragController.fling(),
shouldHaveHero: false,
),
bottomPanelHeight: 68.0,
expandedPanel: FocusScope(
node: playerScreenFocusNode,
skipTraversal: true,
canRequestFocus: true,
child: const PlayerScreen(),
),
onAnimationStatusChange: (status) {
if (status == AnimationStatus.dismissed) {
return playerBarFocusNode.requestFocus();
}
_logger.fine('requesting focus to playerScreen');
playerScreenFocusNode.requestFocus();
},
body: Focus(
focusNode: screenFocusNode,
skipTraversal: true,
canRequestFocus: false,
child: _MainRouteNavigator(
navigatorKey: navigatorKey,
routes: {
Navigator.defaultRouteName: (context) => const HomeScreen(),
'/podcasts': (context) => HomePageScreen(
cacheable: true,
channel:
const 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 StatefulWidget {
final Map<String, WidgetBuilder> routes;
final Map<String, RouteFactory>? customRoutes;
final GlobalKey<NavigatorState> navigatorKey;
const _MainRouteNavigator({
Key? key,
required this.routes,
this.customRoutes,
required this.navigatorKey,
}) : super(key: key);
@override
State<_MainRouteNavigator> createState() => _MainRouteNavigatorState();
}
class _MainRouteNavigatorState extends State<_MainRouteNavigator>
with WidgetsBindingObserver {
@override
void initState() {
super.initState();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
// 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 = widget.navigatorKey.currentState;
if (navigator == null) return false;
return await navigator.maybePop();
}
@override
Future<bool> didPushRoute(String route) async {
final NavigatorState? navigator = widget.navigatorKey.currentState;
if (navigator == null) return false;
navigator.pushNamed(route);
return true;
}
@override
Widget build(BuildContext context) {
return Navigator(
key: widget.navigatorKey,
initialRoute: Navigator.defaultRouteName,
onGenerateRoute: _onGenerateRoute,
);
}
Route<dynamic>? _onGenerateRoute<T>(RouteSettings s) {
final routeBuilder = widget.routes[s.name];
if (routeBuilder == null) return null;
if (s.arguments == true) {
return ScaleFadePageRoute(builder: routeBuilder, settings: s);
}
switch (settings.navigatorRouteType) {
case NavigatorRouteType.blur_slide:
return BlurSlidePageRoute<T>(builder: routeBuilder, settings: s);
case NavigatorRouteType.material:
return MaterialPageRoute<T>(builder: routeBuilder, settings: s);
case NavigatorRouteType.cupertino:
return CupertinoPageRoute<T>(builder: routeBuilder, settings: s);
case NavigatorRouteType.fade:
return FadePageRoute<T>(builder: routeBuilder, settings: s);
case NavigatorRouteType.fade_blur:
return FadePageRoute<T>(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);
// }),
// ),
// );
// }
// }