Pato05
6816bdc112
change package name to deezer.android.app for the same reason (will probably be changed back if we can get freezer to work even without this "hack") implement topResult in search screen
1015 lines
34 KiB
Dart
1015 lines
34 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_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/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: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?
|
|
|
|
Hive.init(await Paths.dataDirectory());
|
|
|
|
//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();
|
|
// photos
|
|
cacheManager = CacheManager(Config(
|
|
DefaultCacheManager.key,
|
|
// cache aggressively
|
|
stalePeriod: const Duration(days: 30),
|
|
maxNrOfCacheObjects: 5000,
|
|
));
|
|
// cacheManager = HiveCacheManager(
|
|
// boxName: 'freezer-images', boxPath: await Paths.cacheDir());
|
|
// TODO: WA
|
|
deezerAPI.favoritesPlaylistId = cache.favoritesPlaylistId;
|
|
|
|
Logger.root.onRecord.listen((record) {
|
|
// ignore: avoid_print
|
|
print(
|
|
'${record.level.name}: ${record.time}: [${record.loggerName}] ${record.message}');
|
|
});
|
|
|
|
if (kDebugMode || true) {
|
|
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> 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);
|
|
playerHelper.stop();
|
|
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) {
|
|
//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 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.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;
|
|
_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);
|
|
// }),
|
|
// ),
|
|
// );
|
|
// }
|
|
// }
|