mirror of
https://github.com/DJDoubleD/refreezer.git
synced 2026-01-15 16:32:54 -03:00
527 lines
17 KiB
Dart
527 lines
17 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:app_links/app_links.dart';
|
|
import 'package:custom_navigator/custom_navigator.dart';
|
|
import 'package:refreezer/fonts/deezer_icons.dart';
|
|
import 'package:refreezer/ui/restartable.dart';
|
|
import 'package:refreezer/ui/settings_screen.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:get_it/get_it.dart';
|
|
import 'package:i18n_extension/i18n_extension.dart';
|
|
import 'package:logging/logging.dart';
|
|
import 'package:move_to_background/move_to_background.dart';
|
|
import 'package:permission_handler/permission_handler.dart';
|
|
import 'package:quick_actions/quick_actions.dart';
|
|
//import 'package:restart_app/restart_app.dart';
|
|
|
|
import 'api/cache.dart';
|
|
import 'api/deezer.dart';
|
|
import 'api/definitions.dart';
|
|
import 'api/download.dart';
|
|
import 'service/audio_service.dart';
|
|
import 'service/service_locator.dart';
|
|
import 'settings.dart';
|
|
import 'translations.i18n.dart';
|
|
import 'ui/home_screen.dart';
|
|
import 'ui/library.dart';
|
|
import 'ui/login_screen.dart';
|
|
import 'ui/player_bar.dart';
|
|
import 'ui/updater.dart';
|
|
import 'ui/search.dart';
|
|
import 'utils/logging.dart';
|
|
import 'utils/navigator_keys.dart';
|
|
|
|
late Function updateTheme;
|
|
late Function logOut;
|
|
|
|
void main() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
|
|
await Permission.notification.isDenied.then((value) {
|
|
if (value) {
|
|
Permission.notification.request();
|
|
}
|
|
});
|
|
|
|
await prepareRun();
|
|
|
|
runApp(const Restartable(child: ReFreezerApp()));
|
|
}
|
|
|
|
Future<void> prepareRun() async {
|
|
await initializeLogging();
|
|
Logger.root.info('Starting ReFreezer App...');
|
|
settings = await Settings().loadSettings();
|
|
cache = await Cache.load();
|
|
}
|
|
|
|
class ReFreezerApp extends StatefulWidget {
|
|
const ReFreezerApp({super.key});
|
|
|
|
@override
|
|
_ReFreezerAppState createState() => _ReFreezerAppState();
|
|
}
|
|
|
|
class _ReFreezerAppState extends State<ReFreezerApp> {
|
|
@override
|
|
void initState() {
|
|
//Make update theme global
|
|
updateTheme = _updateTheme;
|
|
_updateTheme();
|
|
super.initState();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
super.dispose();
|
|
}
|
|
|
|
void _updateTheme() {
|
|
setState(() {
|
|
settings.themeData;
|
|
});
|
|
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
|
|
systemNavigationBarColor: settings.themeData.bottomAppBarTheme.color,
|
|
systemNavigationBarIconBrightness:
|
|
settings.isDark ? Brightness.light : Brightness.dark,
|
|
));
|
|
}
|
|
|
|
Locale? _locale() {
|
|
if ((settings.language?.split('_').length ?? 0) < 2) return null;
|
|
return Locale(
|
|
settings.language!.split('_')[0], settings.language!.split('_')[1]);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
title: 'Deezer',
|
|
shortcuts: <ShortcutActivator, Intent>{
|
|
...WidgetsApp.defaultShortcuts,
|
|
LogicalKeySet(LogicalKeyboardKey.select):
|
|
const ActivateIntent(), // DPAD center key, for remote controls
|
|
},
|
|
theme: settings.themeData,
|
|
localizationsDelegates: const [
|
|
GlobalMaterialLocalizations.delegate,
|
|
GlobalWidgetsLocalizations.delegate,
|
|
GlobalCupertinoLocalizations.delegate,
|
|
],
|
|
supportedLocales: supportedLocales,
|
|
home: PopScope(
|
|
canPop: false, // Prevent full app exit
|
|
onPopInvokedWithResult: (bool didPop, Object? result) async {
|
|
// When at least 1 layer inside a custom navigator screen,
|
|
// let the back button move back down the custom navigator stack
|
|
if (customNavigatorKey.currentState!.canPop()) {
|
|
await customNavigatorKey.currentState!.maybePop();
|
|
return;
|
|
}
|
|
|
|
// When on a root screen of the custom navigator, move app to background with back button
|
|
await MoveToBackground.moveTaskToBack();
|
|
return;
|
|
},
|
|
child: I18n(
|
|
initialLocale: _locale(),
|
|
child: const LoginMainWrapper(),
|
|
),
|
|
),
|
|
navigatorKey: mainNavigatorKey,
|
|
);
|
|
}
|
|
}
|
|
|
|
//Wrapper for login and main screen.
|
|
class LoginMainWrapper extends StatefulWidget {
|
|
const LoginMainWrapper({super.key});
|
|
|
|
@override
|
|
_LoginMainWrapperState createState() => _LoginMainWrapperState();
|
|
}
|
|
|
|
class _LoginMainWrapperState extends State<LoginMainWrapper> {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
//GetIt.I<AudioPlayerHandler>().start();
|
|
//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;
|
|
}
|
|
|
|
Future _logOut() async {
|
|
try {
|
|
GetIt.I<AudioPlayerHandler>().stop();
|
|
GetIt.I<AudioPlayerHandler>().updateQueue([]);
|
|
GetIt.I<AudioPlayerHandler>().removeSavedQueueFile();
|
|
} catch (e, st) {
|
|
Logger.root.severe(
|
|
'Error stopping and clearing audio service before logout', e, st);
|
|
}
|
|
await downloadManager.stop();
|
|
await DownloadManager.platform.invokeMethod('kill');
|
|
setState(() {
|
|
settings.arl = null;
|
|
settings.offlineMode = false;
|
|
deezerAPI = DeezerAPI();
|
|
});
|
|
await settings.save();
|
|
await Cache.wipe();
|
|
Restartable.restart();
|
|
//Restart.restartApp();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (settings.arl == null) {
|
|
return LoginWidget(
|
|
callback: () => setState(() => {}),
|
|
);
|
|
}
|
|
return const MainScreen();
|
|
}
|
|
}
|
|
|
|
class MainScreen extends StatefulWidget {
|
|
const MainScreen({super.key});
|
|
|
|
@override
|
|
_MainScreenState createState() => _MainScreenState();
|
|
}
|
|
|
|
class _MainScreenState extends State<MainScreen>
|
|
with SingleTickerProviderStateMixin {
|
|
late final AppLifecycleListener _lifeCycleListener;
|
|
final List<Widget> _screens = [
|
|
const HomeScreen(),
|
|
const LibraryScreen(),
|
|
const LibraryPlaylists(),
|
|
const SearchScreen(),
|
|
const SettingsScreen()
|
|
];
|
|
Future<void>? _initialization;
|
|
int _selected = 0;
|
|
StreamSubscription? _urlLinkStream;
|
|
int _keyPressed = 0;
|
|
|
|
@override
|
|
void initState() {
|
|
_lifeCycleListener =
|
|
AppLifecycleListener(onStateChange: _onLifeCycleChanged);
|
|
_initialization = _init();
|
|
super.initState();
|
|
}
|
|
|
|
Future<void> _init() async {
|
|
//Set display mode
|
|
if ((settings.displayMode ?? -1) >= 0) {
|
|
FlutterDisplayMode.supported.then((modes) async {
|
|
if (modes.length - 1 >= settings.displayMode!.toInt()) {
|
|
FlutterDisplayMode.setPreferredMode(
|
|
modes[settings.displayMode!.toInt()]);
|
|
}
|
|
});
|
|
}
|
|
|
|
_preloadFavoriteTracksToCache();
|
|
_initDownloadManager();
|
|
_startStreamingServer();
|
|
await _setupServiceLocator();
|
|
|
|
//Do on BG
|
|
GetIt.I<AudioPlayerHandler>().authorizeLastFM();
|
|
|
|
//Start with parameters
|
|
_setupDeepLinks();
|
|
_loadPreloadInfo();
|
|
_prepareQuickActions();
|
|
|
|
//Check for updates on background
|
|
Future.delayed(const Duration(seconds: 5), () {
|
|
ReFreezerLatest.checkUpdate();
|
|
});
|
|
|
|
//Restore saved queue
|
|
_loadSavedQueue();
|
|
}
|
|
|
|
void _preloadFavoriteTracksToCache() async {
|
|
try {
|
|
cache.libraryTracks = await deezerAPI.getFavoriteTrackIds();
|
|
Logger.root
|
|
.info('Cached favorite trackIds: ${cache.libraryTracks?.length}');
|
|
} catch (e, st) {
|
|
Logger.root.severe('Error loading favorite trackIds!', e, st);
|
|
}
|
|
}
|
|
|
|
void _initDownloadManager() async {
|
|
await downloadManager.init();
|
|
}
|
|
|
|
void _startStreamingServer() async {
|
|
await DownloadManager.platform
|
|
.invokeMethod('startServer', {'arl': settings.arl});
|
|
}
|
|
|
|
Future<void> _setupServiceLocator() async {
|
|
await setupServiceLocator();
|
|
// Wait for the player to be initialized
|
|
await GetIt.I<AudioPlayerHandler>().waitForPlayerInitialization();
|
|
}
|
|
|
|
void _prepareQuickActions() {
|
|
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 GetIt.I<AudioPlayerHandler>()
|
|
.playFromSmartTrackList(SmartTrackList(id: 'flow'));
|
|
return;
|
|
}
|
|
if (type == 'favorites') {
|
|
Playlist p = await deezerAPI
|
|
.fullPlaylist(deezerAPI.favoritesPlaylistId.toString());
|
|
GetIt.I<AudioPlayerHandler>().playFromPlaylist(p, p.tracks?[0].id ?? '');
|
|
}
|
|
}
|
|
|
|
void _loadPreloadInfo() async {
|
|
String info =
|
|
await DownloadManager.platform.invokeMethod('getPreloadInfo') ?? '';
|
|
if (info.isEmpty) return;
|
|
_startPreload(info);
|
|
}
|
|
|
|
Future<void> _loadSavedQueue() async {
|
|
GetIt.I<AudioPlayerHandler>().loadQueueFromFile();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_urlLinkStream?.cancel();
|
|
_lifeCycleListener.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _onLifeCycleChanged(AppLifecycleState state) {
|
|
switch (state) {
|
|
case AppLifecycleState.detached:
|
|
Logger.root.info('App detached.');
|
|
GetIt.I<AudioPlayerHandler>().dispose();
|
|
downloadManager.stop();
|
|
case AppLifecycleState.resumed:
|
|
case AppLifecycleState.inactive:
|
|
case AppLifecycleState.hidden:
|
|
case AppLifecycleState.paused:
|
|
}
|
|
}
|
|
|
|
void _setupDeepLinks() async {
|
|
AppLinks deepLinks = AppLinks();
|
|
|
|
// Check initial link if app was in cold state (terminated)
|
|
final deepLink = await deepLinks.getInitialLinkString();
|
|
if (deepLink != null && deepLink.length > 4) {
|
|
Logger.root.info('Opening app from deeplink: $deepLink');
|
|
openScreenByURL(deepLink);
|
|
}
|
|
|
|
//Listen to URLs when app is in warm state (front or background)
|
|
_urlLinkStream = deepLinks.stringLinkStream.listen((deeplink) {
|
|
Logger.root.info('Opening deeplink: $deeplink');
|
|
openScreenByURL(deeplink);
|
|
}, onError: (e) {
|
|
Logger.root.severe('Error handling app link: $e');
|
|
});
|
|
}
|
|
|
|
void _handleKey(KeyEvent event, FocusScopeNode navigationBarFocusNode,
|
|
FocusNode screenFocusNode) {
|
|
FocusNode? primaryFocus = FocusManager.instance.primaryFocus;
|
|
|
|
// Movement to navigation bar and back
|
|
if (event is KeyDownEvent) {
|
|
final logicalKey = event.logicalKey;
|
|
final keyCode = logicalKey.keyId;
|
|
|
|
if (logicalKey == LogicalKeyboardKey.tvContentsMenu) {
|
|
// Menu key on Android TV
|
|
focusToNavbar(navigationBarFocusNode);
|
|
} else if (keyCode == 0x100070000127) {
|
|
// EPG key on Hisense TV (example, you need to find correct LogicalKeyboardKey or define it)
|
|
focusToNavbar(navigationBarFocusNode);
|
|
} else if (logicalKey == LogicalKeyboardKey.arrowLeft ||
|
|
logicalKey == LogicalKeyboardKey.arrowRight) {
|
|
if ((_keyPressed == LogicalKeyboardKey.arrowLeft.keyId &&
|
|
logicalKey == LogicalKeyboardKey.arrowRight) ||
|
|
(_keyPressed == LogicalKeyboardKey.arrowRight.keyId &&
|
|
logicalKey == LogicalKeyboardKey.arrowLeft)) {
|
|
// LEFT + RIGHT
|
|
focusToNavbar(navigationBarFocusNode);
|
|
}
|
|
_keyPressed = logicalKey.keyId;
|
|
Future.delayed(const Duration(milliseconds: 100), () {
|
|
_keyPressed = 0;
|
|
});
|
|
} else if (logicalKey == LogicalKeyboardKey.arrowDown) {
|
|
// 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(navigationBarFocusNode);
|
|
}
|
|
}
|
|
} else if (logicalKey == LogicalKeyboardKey.arrowUp) {
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
void focusToNavbar(FocusScopeNode navigatorFocusNode) {
|
|
navigatorFocusNode.requestFocus();
|
|
navigatorFocusNode.focusInDirection(TraversalDirection
|
|
.down); // If player bar is hidden, focus won't be visible, so go down once more
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
FocusScopeNode navigationBarFocusNode =
|
|
FocusScopeNode(); // for bottom navigation bar
|
|
FocusNode screenFocusNode = FocusNode(); // for CustomNavigator
|
|
screenFocusNode.requestFocus();
|
|
|
|
return FutureBuilder(
|
|
future: _initialization,
|
|
builder: (context, snapshot) {
|
|
// Check _initialization status
|
|
if (snapshot.connectionState == ConnectionState.done) {
|
|
// When _initialization is done, render app
|
|
return KeyboardListener(
|
|
focusNode: FocusNode(),
|
|
onKeyEvent: (event) =>
|
|
_handleKey(event, navigationBarFocusNode, screenFocusNode),
|
|
child: Scaffold(
|
|
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
|
floatingActionButtonLocation:
|
|
FloatingActionButtonLocation.centerFloat,
|
|
floatingActionButton: Container(
|
|
margin: EdgeInsets.fromLTRB(6, 0, 6, 0),
|
|
decoration: BoxDecoration(
|
|
color: Colors.transparent,
|
|
),
|
|
child: const PlayerBar(),
|
|
),
|
|
bottomNavigationBar: FocusScope(
|
|
node: navigationBarFocusNode,
|
|
child: Theme(
|
|
data: Theme.of(context).copyWith(
|
|
canvasColor:
|
|
Theme.of(context).scaffoldBackgroundColor),
|
|
child: BottomNavigationBar(
|
|
type: BottomNavigationBarType.fixed,
|
|
unselectedItemColor:
|
|
Theme.of(context).unselectedWidgetColor,
|
|
currentIndex: _selected,
|
|
onTap: (int index) async {
|
|
//Pop all routes until home screen
|
|
while (customNavigatorKey.currentState!.canPop()) {
|
|
await customNavigatorKey.currentState!.maybePop();
|
|
}
|
|
|
|
await customNavigatorKey.currentState!.maybePop();
|
|
|
|
setState(() {
|
|
_selected = index;
|
|
});
|
|
//Fix statusbar
|
|
SystemChrome.setSystemUIOverlayStyle(
|
|
const SystemUiOverlayStyle(
|
|
statusBarColor: Colors.transparent,
|
|
));
|
|
},
|
|
selectedItemColor:
|
|
settings.primaryColor.withOpacity(0.8),
|
|
showUnselectedLabels: true,
|
|
selectedLabelStyle:
|
|
TextStyle(color: settings.primaryColor),
|
|
unselectedLabelStyle:
|
|
TextStyle(color: Settings.secondaryText),
|
|
items: <BottomNavigationBarItem>[
|
|
BottomNavigationBarItem(
|
|
activeIcon: const Icon(DeezerIcons.house_fill),
|
|
icon: const Icon(DeezerIcons.house),
|
|
label: 'Home'.i18n),
|
|
BottomNavigationBarItem(
|
|
activeIcon:
|
|
const Icon(DeezerIcons.compass_fill),
|
|
icon: const Icon(DeezerIcons.compass),
|
|
label: 'Explore'.i18n),
|
|
BottomNavigationBarItem(
|
|
activeIcon: const Icon(DeezerIcons.heart_fill),
|
|
icon: const Icon(DeezerIcons.heart),
|
|
label: 'Favorites'.i18n),
|
|
BottomNavigationBarItem(
|
|
activeIcon: const Icon(DeezerIcons.search_fill),
|
|
icon: const Icon(DeezerIcons.search),
|
|
label: 'Search'.i18n,
|
|
),
|
|
BottomNavigationBarItem(
|
|
activeIcon: const Icon(DeezerIcons.settings),
|
|
icon: const Icon(DeezerIcons.settings),
|
|
label: 'Settings'.i18n)
|
|
],
|
|
))),
|
|
body: CustomNavigator(
|
|
navigatorKey: customNavigatorKey,
|
|
home: Focus(
|
|
focusNode: screenFocusNode,
|
|
skipTraversal: true,
|
|
canRequestFocus: false,
|
|
child: _screens[_selected]),
|
|
pageRoute: PageRoutes.materialPageRoute),
|
|
));
|
|
} else {
|
|
// While audio_service is initializing
|
|
return Scaffold(
|
|
body: Center(
|
|
child: CircularProgressIndicator(
|
|
color: Theme.of(context).primaryColor,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|