freezer/lib/main.dart

687 lines
24 KiB
Dart
Raw Normal View History

2023-07-29 02:17:26 +00:00
import 'dart:async';
import 'dart:io';
2023-07-29 02:17:26 +00:00
import 'package:audio_service/audio_service.dart';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/cupertino.dart';
2023-07-29 02:17:26 +00:00
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';
2023-07-29 02:17:26 +00:00
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';
2023-07-29 02:17:26 +00:00
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';
2023-07-29 02:17:26 +00:00
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())
2023-07-29 02:17:26 +00:00
..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());
2023-07-29 02:17:26 +00:00
await Hive.initFlutter();
//Initialize globals
settings = await Settings.load();
settings.save();
downloadManager.init();
2023-07-29 02:17:26 +00:00
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;
}());
2023-07-29 02:17:26 +00:00
//Do on BG
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) {
// TODO: reconnect maybe?
2023-07-29 02:17:26 +00:00
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: <ShortcutActivator, Intent>{
...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;
2023-07-29 02:17:26 +00:00
},
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) {
//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;
2023-07-29 02:17:26 +00:00
});
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,
2023-07-29 02:17:26 +00:00
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();
// }
// }
2023-07-29 02:17:26 +00:00
class MainScreen extends StatefulWidget {
@override
_MainScreenState createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen>
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
final _selected = ValueNotifier<int>(0);
final _destinations = <int, String>{
0: '/',
1: '/podcasts',
2: '/library',
3: '/search',
2023-07-29 02:17:26 +00:00
};
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();
2023-07-29 02:17:26 +00:00
//Start with parameters
_setupUniLinks();
_loadPreloadInfo();
_prepareQuickActions();
unawaited(playerHelper.start());
2023-07-29 02:17:26 +00:00
//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});
// }
2023-07-29 02:17:26 +00:00
void _prepareQuickActions() {
if (!Platform.isAndroid) return;
2023-07-29 02:17:26 +00:00
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;
2023-07-29 02:17:26 +00:00
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<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() {
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<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]!);
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(
shouldHandleClicks: false,
shouldHaveHero: false,
2023-07-29 02:17:26 +00:00
),
bottomPanelHeight: 68.0,
expandedPanel: PlayerScreen(),
2023-07-29 02:17:26 +00:00
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(),
2023-07-29 02:17:26 +00:00
},
))));
}
}
// hella simple null-safe reimplementation of custom_navigator, which is NOT null-safe
class _MainRouteNavigator extends StatelessWidget with WidgetsBindingObserver {
final Map<String, WidgetBuilder> routes;
final GlobalKey<NavigatorState> 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<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<T>? _onGenerateRoute<T>(RouteSettings s) {
final routeBuilder = routes[s.name];
2023-07-29 02:17:26 +00:00
if (routeBuilder == null) return null;
if (!navigatorKey.currentState!.canPop())
return ScaleFadePageRoute<T>(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);
}
2023-07-29 02:17:26 +00:00
}
}
// class FreezerDrawer extends StatelessWidget {
// const FreezerDrawer({Key? key}) : super(key: key);
//
2023-07-29 02:17:26 +00:00
// @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'),
// ]),
// );
// }
// }
//
2023-07-29 02:17:26 +00:00
// 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);
//
2023-07-29 02:17:26 +00:00
// @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);
// }),
// ),
// );
// }
// }