1033 lines
35 KiB
Dart
1033 lines
35 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:cookie_jar/cookie_jar.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_cache_manager/flutter_cache_manager.dart';
|
|
import 'package:flutter_cache_manager_hive/flutter_cache_manager_hive.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/cookie_jar_isar_storage.dart';
|
|
import 'package:freezer/api/definitions.dart';
|
|
import 'package:freezer/api/paths.dart';
|
|
import 'package:freezer/icons.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/fancy_scaffold.dart';
|
|
import 'package:freezer/ui/importer_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:get_it/get_it.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/audio_handler.dart';
|
|
import 'settings.dart';
|
|
import 'ui/home_screen.dart';
|
|
import 'ui/player_bar.dart';
|
|
|
|
late Function logOut;
|
|
GlobalKey<NavigatorState> mainNavigatorKey = GlobalKey<NavigatorState>();
|
|
GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
|
late final CacheManager cacheManager;
|
|
|
|
void main() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
|
|
// Hive typeadapters
|
|
Hive
|
|
..registerAdapter(HomePageSectionAdapter())
|
|
..registerAdapter(HomePageItemAdapter())
|
|
..registerAdapter(HomePageItemTypeAdapter())
|
|
..registerAdapter(HomePageSectionLayoutAdapter())
|
|
..registerAdapter(SmartTrackListAdapter())
|
|
..registerAdapter(TrackAdapter())
|
|
..registerAdapter(DeezerImageDetailsAdapter())
|
|
..registerAdapter(LyricsAdapter())
|
|
..registerAdapter(LyricAdapter())
|
|
..registerAdapter(PlaylistAdapter())
|
|
..registerAdapter(ArtistAdapter())
|
|
..registerAdapter(AlbumAdapter())
|
|
..registerAdapter(UserAdapter())
|
|
..registerAdapter(AlbumTypeAdapter())
|
|
..registerAdapter(DeezerChannelAdapter())
|
|
..registerAdapter(ShowAdapter())
|
|
..registerAdapter(DurationAdapter())
|
|
..registerAdapter(SortingAdapter())
|
|
..registerAdapter(SortTypeAdapter())
|
|
..registerAdapter(SortSourceTypesAdapter())
|
|
..registerAdapter(CacheEntryAdapter())
|
|
..registerAdapter(SearchHistoryItemTypeAdapter())
|
|
..registerAdapter(CacheAdapter())
|
|
..registerAdapter(ColorAdapter())
|
|
..registerAdapter(DateTimeAdapter())
|
|
..registerAdapter(MediaItemAdapter())
|
|
..registerAdapter(AudioServiceRepeatModeAdapter())
|
|
..registerAdapter(SettingsAdapter())
|
|
..registerAdapter(SpotifyCredentialsSaveAdapter())
|
|
..registerAdapter(AudioQualityAdapter())
|
|
..registerAdapter(ThemesAdapter())
|
|
..registerAdapter(NavigatorRouteTypeAdapter())
|
|
..registerAdapter(UriAdapter())
|
|
..registerAdapter(QueueSourceAdapter())
|
|
..registerAdapter(HomePageAdapter())
|
|
..registerAdapter(NavigationRailAppearanceAdapter())
|
|
..registerAdapter(HiveCacheObjectAdapter(typeId: 35)); // not working?
|
|
|
|
final dataDir = await Paths.dataDirectory();
|
|
|
|
Hive.init(dataDir);
|
|
|
|
// photos
|
|
cacheManager = CacheManager(Config(
|
|
DefaultCacheManager.key,
|
|
// cache aggressively
|
|
stalePeriod: const Duration(days: 30),
|
|
maxNrOfCacheObjects: 5000,
|
|
));
|
|
|
|
//Initialize globals
|
|
try {
|
|
cache = await Cache.load();
|
|
settings = await Settings.load();
|
|
await settings.save();
|
|
} catch (e) {
|
|
// ignore: avoid_print
|
|
print(e);
|
|
// ignore: avoid_print
|
|
print(
|
|
'Cannot load cache or settings box. Maybe another instance of the app is running?');
|
|
exit(1);
|
|
}
|
|
downloadManager.init();
|
|
|
|
// cacheManager = HiveCacheManager(
|
|
// boxName: 'freezer-images', boxPath: await Paths.cacheDir());
|
|
// TODO: WA
|
|
final cookieJar =
|
|
GetIt.instance.registerSingleton<CookieJar>(await getCookieJar());
|
|
final deezerAPI =
|
|
GetIt.instance.registerSingleton<DeezerAPI>(DeezerAPI(cookieJar));
|
|
|
|
deezerAPI.deezerCountry = settings.deezerCountry;
|
|
deezerAPI.deezerLanguage = settings.deezerLanguage;
|
|
deezerAPI.favoritesPlaylistId = cache.favoritesPlaylistId;
|
|
|
|
Logger.root.onRecord.listen((record) {
|
|
// ignore: avoid_print
|
|
print(
|
|
'${record.level.name}: ${record.time}: [${record.loggerName}] ${record.message}');
|
|
});
|
|
|
|
if (kDebugMode) {
|
|
Logger.root.level = Level.ALL;
|
|
}
|
|
|
|
//Do on BG
|
|
await playerHelper.initAudioHandler();
|
|
|
|
runApp(const FreezerApp());
|
|
}
|
|
|
|
Future<PersistCookieJar> getCookieJar() async => PersistCookieJar(
|
|
storage: IsarStorage('cookies', await Paths.dataDirectory()));
|
|
|
|
class FreezerApp extends StatefulWidget {
|
|
const FreezerApp({super.key});
|
|
|
|
@override
|
|
State<FreezerApp> createState() => _FreezerAppState();
|
|
}
|
|
|
|
class _FreezerAppState extends State<FreezerApp> with WidgetsBindingObserver {
|
|
@override
|
|
void initState() {
|
|
WidgetsBinding.instance.addObserver(this);
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
switch (state) {
|
|
case AppLifecycleState.paused:
|
|
playerHelper.stop();
|
|
break;
|
|
case AppLifecycleState.resumed:
|
|
playerHelper.start();
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
super.dispose();
|
|
}
|
|
|
|
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 NotificationListener<UpdateThemeNotification>(
|
|
onNotification: (notification) {
|
|
setState(() => settings.themeData);
|
|
return true;
|
|
},
|
|
child: ScreenUtilInit(
|
|
designSize: const Size(1080, 720),
|
|
builder: (context, child) =>
|
|
DynamicColorBuilder(builder: (lightScheme, darkScheme) {
|
|
final lightTheme = settings.materialYouAccent
|
|
? ThemeData(
|
|
textTheme: settings.textTheme,
|
|
fontFamily: settings.fontFamily,
|
|
colorScheme: lightScheme,
|
|
useMaterial3: true,
|
|
appBarTheme: const AppBarTheme(
|
|
systemOverlayStyle: SystemUiOverlayStyle(
|
|
statusBarBrightness: Brightness.dark,
|
|
statusBarIconBrightness: Brightness.dark,
|
|
systemNavigationBarIconBrightness: Brightness.dark,
|
|
statusBarColor: Colors.transparent,
|
|
systemNavigationBarColor: Colors.transparent,
|
|
systemNavigationBarDividerColor: Colors.transparent,
|
|
)),
|
|
)
|
|
: settings.themeData;
|
|
final darkTheme = settings.materialYouAccent
|
|
? ThemeData(
|
|
textTheme: settings.textTheme,
|
|
fontFamily: settings.fontFamily,
|
|
colorScheme: darkScheme,
|
|
useMaterial3: true,
|
|
brightness: Brightness.dark,
|
|
appBarTheme: const AppBarTheme(
|
|
systemOverlayStyle: SystemUiOverlayStyle(
|
|
statusBarBrightness: Brightness.light,
|
|
statusBarIconBrightness: Brightness.light,
|
|
systemNavigationBarIconBrightness: Brightness.light,
|
|
statusBarColor: Colors.transparent,
|
|
systemNavigationBarColor: Colors.transparent,
|
|
systemNavigationBarDividerColor: Colors.transparent,
|
|
)),
|
|
)
|
|
: 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) {
|
|
final deezerAPI = GetIt.instance<DeezerAPI>();
|
|
//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 {
|
|
await GetIt.instance<DeezerAPI>().logout();
|
|
setState(() {
|
|
settings.arl = null;
|
|
settings.offlineMode = false;
|
|
});
|
|
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});
|
|
|
|
static MainScreenState of(BuildContext context) =>
|
|
context.findAncestorStateOfType<MainScreenState>()!;
|
|
|
|
@override
|
|
MainScreenState createState() => MainScreenState();
|
|
}
|
|
|
|
class MainScreenState extends State<MainScreen>
|
|
with SingleTickerProviderStateMixin, WidgetsBindingObserver {
|
|
final _logger = Logger('_MainScreenState');
|
|
final _selected = ValueNotifier<int>(0);
|
|
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>();
|
|
|
|
bool isDesktop = false;
|
|
|
|
@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.instance.authorize();
|
|
if (type == 'flow') {
|
|
await playerHelper.playFromSmartTrackList(SmartTrackList(id: 'flow'));
|
|
return;
|
|
}
|
|
if (type == 'favorites') {
|
|
Playlist p = await DeezerAPI.instance
|
|
.fullPlaylist(DeezerAPI.instance.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.instance.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;
|
|
_logger.fine('received link: $link');
|
|
if (!context.mounted) return;
|
|
openScreenByURL(context, link);
|
|
}, onError: (err) {});
|
|
//Get initial link on cold start
|
|
try {
|
|
String? link = await getInitialLink();
|
|
_logger.fine('initial link: $link');
|
|
|
|
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 handle 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();
|
|
}
|
|
|
|
final _destinationRoutes = <int, String>{
|
|
0: '/',
|
|
1: '/podcasts',
|
|
2: '/library',
|
|
3: '/search',
|
|
};
|
|
final _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),
|
|
];
|
|
|
|
final _navigationRailDestinationRoutes = <int, String>{
|
|
0: '/',
|
|
1: '/podcasts',
|
|
2: '/library/tracks',
|
|
3: '/library/albums',
|
|
4: '/library/artists',
|
|
5: '/library/playlists',
|
|
6: '/library/history',
|
|
7: '/downloads',
|
|
8: '/settings',
|
|
9: '/spotify-importer'
|
|
};
|
|
final _navigationRailDestinations = <NavigationRailDestination>[
|
|
NavigationRailDestination(
|
|
icon: const Icon(Icons.home_outlined),
|
|
selectedIcon: const Icon(Icons.home),
|
|
label: Text('Home'.i18n),
|
|
),
|
|
NavigationRailDestination(
|
|
icon: const Icon(Icons.podcasts),
|
|
label: Text('Podcasts'.i18n),
|
|
),
|
|
NavigationRailDestination(
|
|
icon: const Icon(Icons.audiotrack_outlined),
|
|
selectedIcon: const Icon(Icons.audiotrack),
|
|
label: Text('Tracks'.i18n),
|
|
),
|
|
NavigationRailDestination(
|
|
icon: const Icon(Icons.album_outlined),
|
|
selectedIcon: const Icon(Icons.album),
|
|
label: Text('Albums'.i18n),
|
|
),
|
|
NavigationRailDestination(
|
|
icon: const Icon(Icons.recent_actors_outlined),
|
|
selectedIcon: const Icon(Icons.recent_actors),
|
|
label: Text('Artists'.i18n),
|
|
),
|
|
NavigationRailDestination(
|
|
icon: const Icon(Icons.playlist_play),
|
|
label: Text('Playlists'.i18n),
|
|
),
|
|
NavigationRailDestination(
|
|
icon: const Icon(Icons.history),
|
|
label: Text('History'.i18n),
|
|
),
|
|
NavigationRailDestination(
|
|
icon: const Icon(Icons.download_outlined),
|
|
selectedIcon: const Icon(Icons.download),
|
|
label: Text('Downloads'.i18n),
|
|
),
|
|
NavigationRailDestination(
|
|
icon: const Icon(Icons.settings_outlined),
|
|
selectedIcon: const Icon(Icons.settings),
|
|
label: Text('Settings'.i18n),
|
|
),
|
|
NavigationRailDestination(
|
|
icon: const Icon(FreezerIcons.spotify),
|
|
label: Text('Importer'.i18n),
|
|
),
|
|
];
|
|
|
|
void _onDestinationSelected(int s,
|
|
{bool useNavigationRailDestinations = false}) {
|
|
//Pop all routes until home screen
|
|
navigatorKey.currentState!.popUntil((route) => route.isFirst);
|
|
navigatorKey.currentState!.pushReplacementNamed(
|
|
useNavigationRailDestinations
|
|
? _navigationRailDestinationRoutes[s]!
|
|
: _destinationRoutes[s]!,
|
|
arguments: true);
|
|
|
|
if (_selected.value != s) _selected.value = s;
|
|
}
|
|
|
|
Widget? buildBottomBar(bool isDesktop) {
|
|
if (isDesktop) return null;
|
|
if (_selected.value > _destinations.length - 1) _selected.value = 3;
|
|
return FocusScope(
|
|
node: navigationBarFocusNode,
|
|
child: ValueListenableBuilder<int>(
|
|
valueListenable: _selected,
|
|
builder: (context, value, _) {
|
|
return NavigationBar(
|
|
selectedIndex: value,
|
|
onDestinationSelected: _onDestinationSelected,
|
|
destinations: _destinations,
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
Widget? _buildNavigationRail(bool isDesktop) {
|
|
if (!isDesktop) return null;
|
|
|
|
return ValueListenableBuilder(
|
|
valueListenable: _selected,
|
|
builder: (context, selected, _) {
|
|
return ExtensibleNavigationRail(
|
|
destinations: _navigationRailDestinations,
|
|
selectedIndex: selected,
|
|
onDestinationSelected: (int s) =>
|
|
_onDestinationSelected(s, useNavigationRailDestinations: true),
|
|
);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return RawKeyboardListener(
|
|
focusNode: FocusNode(),
|
|
onKey: _handleKey,
|
|
child: LayoutBuilder(builder: (context, constraints) {
|
|
// check if we're able to display the desktop layout
|
|
final isLandscape = constraints.maxWidth > constraints.maxHeight;
|
|
isDesktop = isLandscape &&
|
|
constraints.maxWidth >= 1100 &&
|
|
constraints.maxHeight >= 600;
|
|
return FancyScaffold(
|
|
key: _fancyScaffoldKey,
|
|
navigationRail: _buildNavigationRail(isDesktop),
|
|
bottomNavigationBar: buildBottomBar(isDesktop),
|
|
bottomPanel: Builder(
|
|
builder: (context) => PlayerBar(
|
|
backgroundColor: Theme.of(context).cardColor,
|
|
focusNode: playerBarFocusNode,
|
|
onTap: FancyScaffold.of(context)!.openPanel,
|
|
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) => 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(),
|
|
'/spotify-importer': (context) =>
|
|
const SpotifyImporterV2(),
|
|
},
|
|
)));
|
|
}));
|
|
}
|
|
}
|
|
|
|
// hella simple null-safe reimplementation of custom_navigator, which is NOT null-safe
|
|
class _MainRouteNavigator extends StatefulWidget {
|
|
final Map<String, WidgetBuilder> routes;
|
|
final GlobalKey<NavigatorState> navigatorKey;
|
|
final List<NavigatorObserver> observers;
|
|
const _MainRouteNavigator({
|
|
Key? key,
|
|
required this.routes,
|
|
required this.navigatorKey,
|
|
this.observers = const <NavigatorObserver>[],
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
State<_MainRouteNavigator> createState() => _MainRouteNavigatorState();
|
|
}
|
|
|
|
class _MainRouteNavigatorState extends State<_MainRouteNavigator>
|
|
with WidgetsBindingObserver {
|
|
@override
|
|
void initState() {
|
|
WidgetsBinding.instance.addObserver(this);
|
|
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(
|
|
observers: widget.observers,
|
|
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 ExtensibleNavigationRail extends StatefulWidget {
|
|
final List<NavigationRailDestination> destinations;
|
|
final int selectedIndex;
|
|
final void Function(int)? onDestinationSelected;
|
|
const ExtensibleNavigationRail({
|
|
super.key,
|
|
required this.destinations,
|
|
required this.selectedIndex,
|
|
this.onDestinationSelected,
|
|
});
|
|
|
|
@override
|
|
State<ExtensibleNavigationRail> createState() =>
|
|
_ExtensibleNavigationRailState();
|
|
}
|
|
|
|
class _ExtensibleNavigationRailState extends State<ExtensibleNavigationRail> {
|
|
bool _extended = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final child = NavigationRail(
|
|
extended: _extended,
|
|
destinations: widget.destinations,
|
|
selectedIndex: widget.selectedIndex,
|
|
onDestinationSelected: widget.onDestinationSelected,
|
|
);
|
|
|
|
if (settings.navigationRailAppearance !=
|
|
NavigationRailAppearance.expand_on_hover) {
|
|
_extended = settings.navigationRailAppearance ==
|
|
NavigationRailAppearance.always_expanded;
|
|
return child;
|
|
}
|
|
|
|
return MouseRegion(
|
|
onEnter: (_) => setState(() => _extended = true),
|
|
onExit: (_) => setState(() => _extended = false),
|
|
child: child);
|
|
}
|
|
}
|
|
|
|
class UpdateThemeNotification extends Notification {}
|
|
|
|
void updateTheme(BuildContext context) {
|
|
UpdateThemeNotification().dispatch(context);
|
|
}
|
|
|
|
// class FreezerDrawer extends StatelessWidget {
|
|
// final double? width;
|
|
// const FreezerDrawer({super.key, this.width});
|
|
//
|
|
// @override
|
|
// Widget build(BuildContext context) {
|
|
// return NavigationDrawer(
|
|
// onDestinationSelected: print,
|
|
// children: [
|
|
// const DrawerHeader(child: FreezerTitle()),
|
|
// NavigationDrawerDestination(
|
|
// icon: const Icon(Icons.home_outlined),
|
|
// selectedIcon: const Icon(Icons.home),
|
|
// label: Text('Home'.i18n)),
|
|
// FreezerDrawerTile(
|
|
// title: 'Home'.i18n,
|
|
// icon: const Icon(Icons.home),
|
|
// route: Navigator.defaultRouteName),
|
|
// const Divider(),
|
|
// FreezerDrawerTile(
|
|
// title: 'Tracks'.i18n,
|
|
// icon: const Icon(Icons.audiotrack),
|
|
// route: '/library/tracks'),
|
|
// FreezerDrawerTile(
|
|
// title: 'Albums'.i18n,
|
|
// icon: const Icon(Icons.album),
|
|
// route: '/library/albums'),
|
|
// FreezerDrawerTile(
|
|
// title: 'Artists'.i18n,
|
|
// icon: const Icon(Icons.recent_actors),
|
|
// route: '/library/artists'),
|
|
// FreezerDrawerTile(
|
|
// title: 'Playlists'.i18n,
|
|
// icon: const Icon(Icons.playlist_play),
|
|
// route: '/library/playlists'),
|
|
// const Divider(),
|
|
// FreezerDrawerTile(
|
|
// title: 'Downloads'.i18n,
|
|
// icon: const Icon(Icons.download),
|
|
// route: '/downloads'),
|
|
// FreezerDrawerTile(
|
|
// title: 'History'.i18n,
|
|
// icon: const Icon(Icons.history),
|
|
// route: '/library/history'),
|
|
// FreezerDrawerTile(
|
|
// title: 'Settings'.i18n,
|
|
// icon: const Icon(Icons.settings),
|
|
// route: '/settings'),
|
|
// ],
|
|
// );
|
|
// }
|
|
// }
|
|
//
|
|
// class FreezerDrawerTile extends StatefulWidget {
|
|
// 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
|
|
// State<FreezerDrawerTile> createState() => _FreezerDrawerTileState();
|
|
// }
|
|
//
|
|
// class _FreezerDrawerTileState extends State<FreezerDrawerTile> with RouteAware {
|
|
// bool _isHighlighted = false;
|
|
// late final RouteObserver _routeObserver;
|
|
//
|
|
// @override
|
|
// void didChangeDependencies() {
|
|
// _routeObserver = MainScreen.of(context).routeObserver;
|
|
// _routeObserver.subscribe(this, ModalRoute.of(context)!);
|
|
// super.didChangeDependencies();
|
|
// }
|
|
//
|
|
// @override
|
|
// void dispose() {
|
|
// _routeObserver.unsubscribe(this);
|
|
// super.dispose();
|
|
// }
|
|
//
|
|
// @override
|
|
// void didPushNext() => _update();
|
|
//
|
|
// @override
|
|
// void didPopNext() => _update();
|
|
//
|
|
// void _update() {
|
|
// final highlighted = ModalRoute.of(context)?.settings.name == widget.route;
|
|
//
|
|
// if (highlighted != _isHighlighted) {
|
|
// setState(() => _isHighlighted = highlighted);
|
|
// }
|
|
// }
|
|
//
|
|
// @override
|
|
// Widget build(BuildContext context) {
|
|
// return Padding(
|
|
// padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
|
// child: Material(
|
|
// borderRadius: BorderRadius.circular(8.0),
|
|
// clipBehavior: Clip.antiAlias,
|
|
// color: _isHighlighted
|
|
// ? Theme.of(context).brightness == Brightness.dark
|
|
// ? Colors.white12
|
|
// : Colors.black12
|
|
// : null,
|
|
// child: ListTile(
|
|
// selected: _isHighlighted,
|
|
// leading: widget.icon,
|
|
// visualDensity: VisualDensity.compact,
|
|
// title: Text(widget.title),
|
|
// onTap: () {
|
|
// navigatorKey.currentState!.pushReplacementNamed(widget.route);
|
|
// }),
|
|
// ),
|
|
// );
|
|
// }
|
|
// }
|