Compare commits
36 Commits
Author | SHA1 | Date | |
---|---|---|---|
a6b40e81a7 | |||
1913a7e909 | |||
d860936010 | |||
873ad1cf8c | |||
e0c9edad78 | |||
70ea02962f | |||
59783c48f7 | |||
b099f63f61 | |||
b69bee7e59 | |||
3d23152802 | |||
90dc3f43a7 | |||
3df93e47d2 | |||
6d2a027d9b | |||
222d50d80d | |||
499bca5b1c | |||
252e4619f7 | |||
463cb9870f | |||
2cee7ee958 | |||
e30e7a5c24 | |||
6509cd2511 | |||
6cdc025c40 | |||
de3ad4b21e | |||
ad1c188982 | |||
43fae51462 | |||
9012f560b5 | |||
19a7fd82df | |||
010ee6286f | |||
3c3447a9ee | |||
ee2633db52 | |||
ddeda2ce23 | |||
a5f39321eb | |||
da2a3508d1 | |||
ed7b69f7b3 | |||
710ab755fc | |||
4fd9447591 | |||
c97a7ae859 |
22
README.md
22
README.md
@ -5,16 +5,30 @@ Yet another spotify third-party client. Support multi-platform because built wit
|
||||
This project is inspired by and taken supported by [spotube](https://spotube.krtirtho.dev).
|
||||
Their original app is good enough. But I just want to redesign the user interface and make it ready add to more features and more backend support.
|
||||
|
||||
## Highlight
|
||||
|
||||
Compare to original spotube. This project added more audio source e.g. netease cloud music, kugou and provide the ability to use in China Mainland.
|
||||
|
||||
At the same time, this project also focus on playing experience of VOCALOID songs.
|
||||
We improve the search and rank algorithm to make the querying will less pick the cover version instead of original ones.
|
||||
|
||||
Due to the end service of jiosaavn in Asian region (maybe other regions also affected). We removed the jiosaavn audio source.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] Playing music
|
||||
- [ ] Add netease music as source
|
||||
- [x] Add netease music as source
|
||||
- [ ] Add bilibili as source
|
||||
- [ ] Add kuwo music as source
|
||||
- [x] Add kugou music as source
|
||||
- [x] Optimize fallback strategy
|
||||
- [x] Re-design user interface
|
||||
- [x] Simplified UI and UX
|
||||
- [ ] Support for large screen device
|
||||
- [x] Simplified UI and UX
|
||||
- [x] Support for large screen device
|
||||
|
||||
## License
|
||||
|
||||
This project is open-sourced under APGLv3 license. The original spotube project is open-sourced under license BSD-Clause4 and copyright by Kingkor Roy Tirtho.
|
||||
|
||||
This project is all rights reversed by LittleSheep and Solsynth LLC.
|
||||
This project is all rights reversed by LittleSheep and Solsynth LLC.
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
@ -57,6 +57,8 @@ PODS:
|
||||
- sqlite3/rtree
|
||||
- url_launcher_ios (0.0.1):
|
||||
- Flutter
|
||||
- wakelock_plus (0.0.1):
|
||||
- Flutter
|
||||
|
||||
DEPENDENCIES:
|
||||
- audio_service (from `.symlinks/plugins/audio_service/ios`)
|
||||
@ -76,6 +78,7 @@ DEPENDENCIES:
|
||||
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
|
||||
- sqlite3_flutter_libs (from `.symlinks/plugins/sqlite3_flutter_libs/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
|
||||
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
@ -117,6 +120,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/sqlite3_flutter_libs/ios"
|
||||
url_launcher_ios:
|
||||
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||
wakelock_plus:
|
||||
:path: ".symlinks/plugins/wakelock_plus/ios"
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
audio_service: f509d65da41b9521a61f1c404dd58651f265a567
|
||||
@ -138,6 +143,7 @@ SPEC CHECKSUMS:
|
||||
sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb
|
||||
sqlite3_flutter_libs: c00457ebd31e59fa6bb830380ddba24d44fbcd3b
|
||||
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
|
||||
|
||||
PODFILE CHECKSUM: 819463e6a0290f5a72f145ba7cde16e8b6ef0796
|
||||
|
||||
|
@ -2,6 +2,10 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
@ -22,12 +26,24 @@
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.music</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>To provide information for RhythmBox to normalize the output audio</string>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
@ -41,15 +57,5 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.music</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>To provide information for RhythmBox to normalize the output audio</string>
|
||||
<key>UIStatusBarHidden</key>
|
||||
<false/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@ -8,6 +8,7 @@ import 'package:rhythm_box/providers/audio_player_stream.dart';
|
||||
import 'package:rhythm_box/providers/auth.dart';
|
||||
import 'package:rhythm_box/providers/database.dart';
|
||||
import 'package:rhythm_box/providers/endless_playback.dart';
|
||||
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||
import 'package:rhythm_box/providers/history.dart';
|
||||
import 'package:rhythm_box/providers/palette.dart';
|
||||
import 'package:rhythm_box/providers/recent_played.dart';
|
||||
@ -24,11 +25,11 @@ import 'package:rhythm_box/services/server/active_sourced_track.dart';
|
||||
import 'package:rhythm_box/services/server/routes/playback.dart';
|
||||
import 'package:rhythm_box/services/server/server.dart';
|
||||
import 'package:rhythm_box/services/server/sourced_track.dart';
|
||||
import 'package:rhythm_box/services/wm_tools.dart';
|
||||
import 'package:rhythm_box/shells/system_shell.dart';
|
||||
import 'package:rhythm_box/translations.dart';
|
||||
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
||||
import 'package:smtc_windows/smtc_windows.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
Future<void> main(List<String> rawArgs) async {
|
||||
if (rawArgs.contains('web_view_title_bar')) {
|
||||
@ -42,13 +43,7 @@ Future<void> main(List<String> rawArgs) async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
if (PlatformInfo.isDesktop) {
|
||||
await windowManager.ensureInitialized();
|
||||
await windowManager.setPreventClose(true);
|
||||
if (PlatformInfo.isMacOS) {
|
||||
await windowManager.setTitleBarStyle(TitleBarStyle.hidden);
|
||||
} else {
|
||||
await windowManager.setTitleBarStyle(TitleBarStyle.normal);
|
||||
}
|
||||
await WindowManagerTools.initialize();
|
||||
}
|
||||
if (PlatformInfo.isWindows) {
|
||||
await SMTCWindows.initialize();
|
||||
@ -66,7 +61,7 @@ class RhythmApp extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GetMaterialApp.router(
|
||||
title: 'DietaryGuard',
|
||||
title: 'RhythmBox',
|
||||
routerDelegate: router.routerDelegate,
|
||||
routeInformationParser: router.routeInformationParser,
|
||||
routeInformationProvider: router.routeInformationProvider,
|
||||
@ -90,8 +85,8 @@ class RhythmApp extends StatelessWidget {
|
||||
translations: AppTranslations(),
|
||||
onInit: () => _initializeProviders(context),
|
||||
builder: (context, child) {
|
||||
return SystemShell(
|
||||
child: ScaffoldMessenger(
|
||||
return ScaffoldMessenger(
|
||||
child: SystemShell(
|
||||
child: child ?? const SizedBox(),
|
||||
),
|
||||
);
|
||||
@ -103,6 +98,8 @@ class RhythmApp extends StatelessWidget {
|
||||
Get.lazyPut(() => SpotifyProvider());
|
||||
Get.lazyPut(() => SyncedLyricsProvider());
|
||||
|
||||
Get.put(ErrorNotifier());
|
||||
|
||||
Get.put(DatabaseProvider());
|
||||
Get.put(AuthenticationProvider());
|
||||
|
||||
|
@ -3,6 +3,7 @@ import 'dart:developer';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:palette_generator/palette_generator.dart';
|
||||
import 'package:rhythm_box/providers/audio_player.dart';
|
||||
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||
import 'package:rhythm_box/providers/history.dart';
|
||||
import 'package:rhythm_box/providers/palette.dart';
|
||||
import 'package:rhythm_box/providers/scrobbler.dart';
|
||||
@ -126,7 +127,8 @@ class AudioPlayerStreamProvider extends GetxController {
|
||||
.addTrack(playback.state.value.activeTrack!);
|
||||
lastScrobbled = uid;
|
||||
} catch (e, stack) {
|
||||
log('[Scrobbler] Error: $e; Trace:\n$stack');
|
||||
Get.find<ErrorNotifier>()
|
||||
.logError('[Scrobbler] Error: $e', trace: stack);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -9,15 +9,23 @@ import 'package:rhythm_box/providers/database.dart';
|
||||
import 'package:rhythm_box/services/database/database.dart';
|
||||
|
||||
extension ExpirationAuthenticationTableData on AuthenticationTableData {
|
||||
bool get isExpired => DateTime.now().isAfter(expiration);
|
||||
bool get isExpired => DateTime.now().isAfter(spotifyExpiration);
|
||||
|
||||
String? getCookie(String key) => cookie.value
|
||||
String? getCookie(String key) => spotifyCookie.value
|
||||
.split('; ')
|
||||
.firstWhereOrNull((c) => c.trim().startsWith('$key='))
|
||||
?.trim()
|
||||
.split('=')
|
||||
.last
|
||||
.replaceAll(';', '');
|
||||
|
||||
String? getNeteaseCookie(String key) => neteaseCookie?.value
|
||||
.split(';')
|
||||
.firstWhereOrNull((c) => c.trim().startsWith('$key='))
|
||||
?.trim()
|
||||
.split('=')
|
||||
.last
|
||||
.replaceAll(';', '');
|
||||
}
|
||||
|
||||
class AuthenticationProvider extends GetxController {
|
||||
@ -55,18 +63,18 @@ class AuthenticationProvider extends GetxController {
|
||||
void _setRefreshTimer() {
|
||||
refreshTimer?.cancel();
|
||||
if (auth.value != null && auth.value!.isExpired) {
|
||||
refreshCredentials();
|
||||
refreshSpotifyCredentials();
|
||||
}
|
||||
refreshTimer = Timer(
|
||||
auth.value!.expiration.difference(DateTime.now()),
|
||||
() => refreshCredentials(),
|
||||
auth.value!.spotifyExpiration.difference(DateTime.now()),
|
||||
() => refreshSpotifyCredentials(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> refreshCredentials() async {
|
||||
Future<void> refreshSpotifyCredentials() async {
|
||||
final database = Get.find<DatabaseProvider>().database;
|
||||
final refreshedCredentials =
|
||||
await credentialsFromCookie(auth.value!.cookie.value);
|
||||
await credentialsFromCookie(auth.value!.spotifyCookie.value);
|
||||
|
||||
await database
|
||||
.update(database.authenticationTable)
|
||||
@ -112,9 +120,10 @@ class AuthenticationProvider extends GetxController {
|
||||
|
||||
return AuthenticationTableCompanion.insert(
|
||||
id: const Value(0),
|
||||
cookie: DecryptedText("${res.headers["set-cookie"]?.join(";")}; $spDc"),
|
||||
accessToken: DecryptedText(body['accessToken']),
|
||||
expiration: DateTime.fromMillisecondsSinceEpoch(
|
||||
spotifyCookie:
|
||||
DecryptedText("${res.headers["set-cookie"]?.join(";")}; $spDc"),
|
||||
spotifyAccessToken: DecryptedText(body['accessToken']),
|
||||
spotifyExpiration: DateTime.fromMillisecondsSinceEpoch(
|
||||
body['accessTokenExpirationTimestampMs']),
|
||||
);
|
||||
} catch (e) {
|
||||
@ -123,6 +132,21 @@ class AuthenticationProvider extends GetxController {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setNeteaseCredentials(String cookie) async {
|
||||
final database = Get.find<DatabaseProvider>().database;
|
||||
await database.update(database.authenticationTable).replace(
|
||||
AuthenticationTableCompanion.insert(
|
||||
id: const Value(0),
|
||||
spotifyCookie: auth.value!.spotifyCookie,
|
||||
spotifyAccessToken: auth.value!.spotifyAccessToken,
|
||||
spotifyExpiration: auth.value!.spotifyExpiration,
|
||||
neteaseCookie: Value(DecryptedText(cookie)),
|
||||
neteaseExpiration: const Value(null),
|
||||
),
|
||||
);
|
||||
await loadAuthenticationData();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
auth.value = null;
|
||||
final database = Get.find<DatabaseProvider>().database;
|
||||
@ -132,6 +156,21 @@ class AuthenticationProvider extends GetxController {
|
||||
// Additional cleanup if necessary
|
||||
}
|
||||
|
||||
Future<void> logoutNetease() async {
|
||||
final database = Get.find<DatabaseProvider>().database;
|
||||
await database.update(database.authenticationTable).replace(
|
||||
AuthenticationTableCompanion.insert(
|
||||
id: const Value(0),
|
||||
spotifyCookie: auth.value!.spotifyCookie,
|
||||
spotifyAccessToken: auth.value!.spotifyAccessToken,
|
||||
spotifyExpiration: auth.value!.spotifyExpiration,
|
||||
neteaseCookie: const Value(null),
|
||||
neteaseExpiration: const Value(null),
|
||||
),
|
||||
);
|
||||
await loadAuthenticationData();
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
refreshTimer?.cancel();
|
||||
|
@ -1,5 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/providers/audio_player.dart';
|
||||
@ -9,6 +8,8 @@ import 'package:rhythm_box/providers/user_preferences.dart';
|
||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
import 'error_notifier.dart';
|
||||
|
||||
class EndlessPlaybackProvider extends GetxController {
|
||||
late final _auth = Get.find<AuthenticationProvider>();
|
||||
late final _playback = Get.find<AudioPlayerProvider>();
|
||||
@ -88,7 +89,8 @@ class EndlessPlaybackProvider extends GetxController {
|
||||
}),
|
||||
);
|
||||
} catch (e, stack) {
|
||||
log('[EndlessPlayback] Error: $e; Trace:\n$stack');
|
||||
Get.find<ErrorNotifier>()
|
||||
.logError('[EndlessPlayback] Error: $e', trace: stack);
|
||||
}
|
||||
}
|
||||
|
||||
|
47
lib/providers/error_notifier.dart
Normal file
47
lib/providers/error_notifier.dart
Normal file
@ -0,0 +1,47 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class ErrorNotifier extends GetxController {
|
||||
Rx<MaterialBanner?> showing = Rx(null);
|
||||
|
||||
Timer? _autoDismissTimer;
|
||||
|
||||
void logError(String msg, {StackTrace? trace}) {
|
||||
log('$msg${trace != null ? '\nTrace:\n$trace' : ''}');
|
||||
showError(msg);
|
||||
}
|
||||
|
||||
void showError(String msg) {
|
||||
_autoDismissTimer?.cancel();
|
||||
|
||||
showing.value = MaterialBanner(
|
||||
dividerColor: Colors.transparent,
|
||||
leading: const Icon(Icons.error),
|
||||
content: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Something went wrong...',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
Text(msg),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
showing.value = null;
|
||||
},
|
||||
child: const Text('Dismiss'),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
_autoDismissTimer = Timer(const Duration(seconds: 3), () {
|
||||
showing.value = null;
|
||||
});
|
||||
}
|
||||
}
|
@ -1,8 +1,8 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:get/get.dart' hide Value;
|
||||
import 'package:rhythm_box/providers/database.dart';
|
||||
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||
import 'package:rhythm_box/services/artist.dart';
|
||||
import 'package:rhythm_box/services/database/database.dart';
|
||||
import 'package:scrobblenaut/scrobblenaut.dart';
|
||||
@ -44,7 +44,8 @@ class ScrobblerProvider extends GetxController {
|
||||
),
|
||||
);
|
||||
} catch (e, stack) {
|
||||
log('[Scrobble] Error: $e; Trace:\n$stack');
|
||||
Get.find<ErrorNotifier>()
|
||||
.logError('[Scrobbler] Error: $e', trace: stack);
|
||||
scrobbler.value = null;
|
||||
}
|
||||
} else {
|
||||
@ -63,8 +64,9 @@ class ScrobblerProvider extends GetxController {
|
||||
timestamp: DateTime.now().toUtc(),
|
||||
trackNumber: track.trackNumber,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
log('[Scrobble] Error: $e; Trace:\n$stackTrace');
|
||||
} catch (e, stack) {
|
||||
Get.find<ErrorNotifier>()
|
||||
.logError('[Scrobbler] Error: $e', trace: stack);
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -1,8 +1,7 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/providers/database.dart';
|
||||
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||
import 'package:rhythm_box/providers/user_preferences.dart';
|
||||
import 'package:rhythm_box/services/database/database.dart';
|
||||
import 'package:rhythm_box/services/server/active_sourced_track.dart';
|
||||
@ -72,7 +71,7 @@ Future<List<SkipSegmentTableData>> getAndCacheSkipSegments(String id) async {
|
||||
..where((s) => s.trackId.equals(id)))
|
||||
.get();
|
||||
} catch (e, stack) {
|
||||
log('[SkipSegment] Error: $e; Trace:\n$stack');
|
||||
Get.find<ErrorNotifier>().logError('[SkipSegment] Error: $e', trace: stack);
|
||||
return List.castFrom<dynamic, SkipSegmentTableData>([]);
|
||||
}
|
||||
}
|
||||
|
@ -44,7 +44,7 @@ class SpotifyProvider extends GetxController {
|
||||
log('[SpotifyApi] Using user credentials...');
|
||||
final AuthenticationProvider authenticate = Get.find();
|
||||
return SpotifyApi.withAccessToken(
|
||||
authenticate.auth.value!.accessToken.value);
|
||||
authenticate.auth.value!.spotifyAccessToken.value);
|
||||
}
|
||||
|
||||
@override
|
||||
|
@ -9,10 +9,10 @@ import 'package:rhythm_box/services/color.dart';
|
||||
import 'package:rhythm_box/services/database/database.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/enums.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
typedef UserPreferences = PreferencesTableData;
|
||||
|
||||
@ -49,13 +49,7 @@ class UserPreferencesProvider extends GetxController {
|
||||
.listen((event) async {
|
||||
state.value = event;
|
||||
|
||||
if (PlatformInfo.isDesktop) {
|
||||
await windowManager.setTitleBarStyle(
|
||||
state.value.systemTitleBar
|
||||
? TitleBarStyle.normal
|
||||
: TitleBarStyle.hidden,
|
||||
);
|
||||
}
|
||||
await WakelockPlus.toggle(enable: state.value.playerWakelock);
|
||||
|
||||
await audioPlayer.setAudioNormalization(state.value.normalizeAudio);
|
||||
});
|
||||
@ -147,6 +141,10 @@ class UserPreferencesProvider extends GetxController {
|
||||
setData(PreferencesTableCompanion(locale: Value(locale)));
|
||||
}
|
||||
|
||||
void setNeteaseApiInstance(String instance) {
|
||||
setData(PreferencesTableCompanion(neteaseApiInstance: Value(instance)));
|
||||
}
|
||||
|
||||
void setPipedInstance(String instance) {
|
||||
setData(PreferencesTableCompanion(pipedInstance: Value(instance)));
|
||||
}
|
||||
@ -167,14 +165,6 @@ class UserPreferencesProvider extends GetxController {
|
||||
setData(PreferencesTableCompanion(systemTitleBar: Value(isSystemTitleBar)));
|
||||
}
|
||||
|
||||
void setDiscordPresence(bool discordPresence) {
|
||||
setData(PreferencesTableCompanion(discordPresence: Value(discordPresence)));
|
||||
}
|
||||
|
||||
void setAmoledDarkTheme(bool isAmoled) {
|
||||
setData(PreferencesTableCompanion(amoledDarkTheme: Value(isAmoled)));
|
||||
}
|
||||
|
||||
void setNormalizeAudio(bool normalize) {
|
||||
setData(PreferencesTableCompanion(normalizeAudio: Value(normalize)));
|
||||
audioPlayer.setAudioNormalization(normalize);
|
||||
@ -184,7 +174,12 @@ class UserPreferencesProvider extends GetxController {
|
||||
setData(PreferencesTableCompanion(endlessPlayback: Value(endless)));
|
||||
}
|
||||
|
||||
void setEnableConnect(bool enable) {
|
||||
setData(PreferencesTableCompanion(enableConnect: Value(enable)));
|
||||
void setPlayerWakelock(bool wakelock) {
|
||||
setData(PreferencesTableCompanion(playerWakelock: Value(wakelock)));
|
||||
WakelockPlus.toggle(enable: wakelock);
|
||||
}
|
||||
|
||||
void setOverrideCacheProvider(bool override) {
|
||||
setData(PreferencesTableCompanion(overrideCacheProvider: Value(override)));
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:rhythm_box/screens/about.dart';
|
||||
import 'package:rhythm_box/screens/album/view.dart';
|
||||
import 'package:rhythm_box/screens/auth/login_netease.dart';
|
||||
import 'package:rhythm_box/screens/auth/mobile_login.dart';
|
||||
import 'package:rhythm_box/screens/explore.dart';
|
||||
import 'package:rhythm_box/screens/library/view.dart';
|
||||
@ -104,6 +105,11 @@ final router = GoRouter(routes: [
|
||||
name: 'authMobileLogin',
|
||||
builder: (context, state) => const MobileLogin(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/auth/netease/login',
|
||||
name: 'authMobileLoginNetease',
|
||||
builder: (context, state) => const LoginNeteaseScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
]);
|
||||
|
156
lib/screens/auth/login_netease.dart
Normal file
156
lib/screens/auth/login_netease.dart
Normal file
@ -0,0 +1,156 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:rhythm_box/providers/auth.dart';
|
||||
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sources/netease.dart';
|
||||
import 'package:rhythm_box/widgets/sized_container.dart';
|
||||
|
||||
class LoginNeteaseScreen extends StatefulWidget {
|
||||
const LoginNeteaseScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LoginNeteaseScreen> createState() => _LoginNeteaseScreenState();
|
||||
}
|
||||
|
||||
class _LoginNeteaseScreenState extends State<LoginNeteaseScreen> {
|
||||
final TextEditingController _phoneController = TextEditingController();
|
||||
final TextEditingController _phoneRegionController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
|
||||
late final AuthenticationProvider _auth = Get.find();
|
||||
|
||||
bool _isLogging = false;
|
||||
|
||||
Future<void> _sentCaptcha() async {
|
||||
setState(() => _isLogging = true);
|
||||
|
||||
final phone = _phoneController.text;
|
||||
var region = _phoneRegionController.text;
|
||||
if (region.isEmpty) region = '86';
|
||||
final client = NeteaseSourcedTrack.getClient();
|
||||
final resp = await client.get(
|
||||
'/captcha/sent?phone=$phone&ctcode=$region×tamp=${DateTime.now().millisecondsSinceEpoch}',
|
||||
);
|
||||
|
||||
if (resp.statusCode != 200 || resp.body?['code'] != 200) {
|
||||
Get.find<ErrorNotifier>().showError(
|
||||
resp.bodyString ?? resp.status.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
setState(() => _isLogging = false);
|
||||
}
|
||||
|
||||
Future<void> _performLogin() async {
|
||||
setState(() => _isLogging = true);
|
||||
|
||||
final phone = _phoneController.text;
|
||||
final password = _passwordController.text;
|
||||
var region = _phoneRegionController.text;
|
||||
if (region.isEmpty) region = '86';
|
||||
final client = NeteaseSourcedTrack.getClient();
|
||||
final resp = await client.get(
|
||||
'/login/cellphone?phone=$phone&captcha=$password&countrycode=$region×tamp=${DateTime.now().millisecondsSinceEpoch}',
|
||||
);
|
||||
|
||||
if (resp.statusCode != 200 || resp.body?['code'] != 200) {
|
||||
Get.find<ErrorNotifier>().showError(
|
||||
resp.bodyString ?? resp.status.toString(),
|
||||
);
|
||||
setState(() => _isLogging = false);
|
||||
return;
|
||||
}
|
||||
|
||||
await _auth.setNeteaseCredentials(resp.body['cookie']);
|
||||
|
||||
setState(() => _isLogging = false);
|
||||
|
||||
GoRouter.of(context).goNamed('settings');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
_phoneRegionController.dispose();
|
||||
_passwordController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Connect with Netease Cloud Music'),
|
||||
),
|
||||
body: CenteredContainer(
|
||||
maxWidth: 320,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 64,
|
||||
child: TextField(
|
||||
controller: _phoneRegionController,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
hintText: '86',
|
||||
isDense: true,
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _phoneController,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
label: Text('Phone Number'),
|
||||
isDense: true,
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _passwordController,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
border: OutlineInputBorder(),
|
||||
label: Text('Captcha Code'),
|
||||
isDense: true,
|
||||
),
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
IconButton(
|
||||
onPressed: _isLogging ? null : () => _sentCaptcha(),
|
||||
icon: const Icon(Icons.sms),
|
||||
tooltip: 'Get Captcha',
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(8),
|
||||
TextButton(
|
||||
onPressed: _isLogging ? null : () => _performLogin(),
|
||||
child: const Text('Login'),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -42,30 +42,50 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
||||
|
||||
_featuredPlaylist =
|
||||
(await _spotify.api.playlists.featured.getPage(20)).items!.toList();
|
||||
setState(() => _isLoading['featured'] = false);
|
||||
if (mounted) {
|
||||
setState(() => _isLoading['featured'] = false);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
final idxList = Set();
|
||||
_recentlyPlaylist = (await _history.fetch())
|
||||
.where((x) => x.playlist != null)
|
||||
.map((x) => x.playlist!)
|
||||
.toList();
|
||||
setState(() => _isLoading['recently'] = false);
|
||||
.toList()
|
||||
..retainWhere((x) => idxList.add(x.id!));
|
||||
if (mounted) {
|
||||
setState(() => _isLoading['recently'] = false);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
_newReleasesPlaylist =
|
||||
(await _spotify.api.browse.newReleases(country: market).getPage(20))
|
||||
.items
|
||||
?.map((album) => album.toAlbum())
|
||||
.toList();
|
||||
setState(() => _isLoading['newReleases'] = false);
|
||||
if (mounted) {
|
||||
setState(() => _isLoading['newReleases'] = false);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
final customEndpoint =
|
||||
CustomSpotifyEndpoints(_auth.auth.value?.accessToken.value ?? '');
|
||||
final forYouView = await customEndpoint.getView(
|
||||
'made-for-x-hub',
|
||||
market: market,
|
||||
locale: Intl.canonicalizedLocale(locale.toString()),
|
||||
);
|
||||
_forYouView = forYouView['content']?['items'];
|
||||
setState(() => _isLoading['forYou'] = false);
|
||||
if (_auth.auth.value != null) {
|
||||
final customEndpoint = CustomSpotifyEndpoints(
|
||||
_auth.auth.value?.spotifyAccessToken.value ?? '');
|
||||
final forYouView = await customEndpoint.getView(
|
||||
'made-for-x-hub',
|
||||
market: market,
|
||||
locale: Intl.canonicalizedLocale(locale.toString()),
|
||||
);
|
||||
_forYouView = forYouView['content']?['items'];
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() => _isLoading['forYou'] = false);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@ -85,15 +105,6 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
||||
),
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
if (_newReleasesPlaylist?.isNotEmpty ?? false)
|
||||
SliverToBoxAdapter(
|
||||
child: PlaylistSection(
|
||||
isLoading: _isLoading['newReleases']!,
|
||||
title: 'New Releases',
|
||||
list: _newReleasesPlaylist,
|
||||
),
|
||||
),
|
||||
if (_newReleasesPlaylist?.isNotEmpty ?? false) const SliverGap(16),
|
||||
if (_recentlyPlaylist?.isNotEmpty ?? false)
|
||||
SliverToBoxAdapter(
|
||||
child: PlaylistSection(
|
||||
@ -103,6 +114,15 @@ class _ExploreScreenState extends State<ExploreScreen> {
|
||||
),
|
||||
),
|
||||
if (_recentlyPlaylist?.isNotEmpty ?? false) const SliverGap(16),
|
||||
if (_newReleasesPlaylist?.isNotEmpty ?? false)
|
||||
SliverToBoxAdapter(
|
||||
child: PlaylistSection(
|
||||
isLoading: _isLoading['newReleases']!,
|
||||
title: 'New Releases',
|
||||
list: _newReleasesPlaylist,
|
||||
),
|
||||
),
|
||||
if (_newReleasesPlaylist?.isNotEmpty ?? false) const SliverGap(16),
|
||||
SliverList.builder(
|
||||
itemCount: _forYouView?.length ?? 0,
|
||||
itemBuilder: (context, idx) {
|
||||
|
@ -5,9 +5,14 @@ import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/widgets/lyrics/synced_lyrics.dart';
|
||||
import 'package:rhythm_box/widgets/player/bottom_player.dart';
|
||||
|
||||
class LyricsScreen extends StatelessWidget {
|
||||
class LyricsScreen extends StatefulWidget {
|
||||
const LyricsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<LyricsScreen> createState() => _LyricsScreenState();
|
||||
}
|
||||
|
||||
class _LyricsScreenState extends State<LyricsScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
|
@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:rhythm_box/platform.dart';
|
||||
import 'package:rhythm_box/screens/player/queue.dart';
|
||||
import 'package:rhythm_box/screens/player/siblings.dart';
|
||||
import 'package:rhythm_box/widgets/lyrics/synced_lyrics.dart';
|
||||
import 'package:rhythm_box/widgets/player/bottom_player.dart';
|
||||
import 'package:rhythm_box/widgets/player/devices.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class MiniPlayerScreen extends StatefulWidget {
|
||||
@ -94,6 +97,46 @@ class _MiniPlayerScreenState extends State<MiniPlayerScreen> {
|
||||
onPressed: () => _exitMiniPlayer(),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.speaker, size: 18),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) => const PlayerDevicePopup(),
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.merge),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => const SiblingTracksPopup(),
|
||||
).then((_) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.queue_music),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => const PlayerQueuePopup(),
|
||||
).then((_) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: _isHoverMode
|
||||
? const Icon(Icons.touch_app)
|
||||
|
47
lib/screens/player/source_details.dart
Normal file
47
lib/screens/player/source_details.dart
Normal file
@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/services/server/active_sourced_track.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||
import 'package:rhythm_box/widgets/player/track_source_details.dart';
|
||||
|
||||
class SourceDetailsPopup extends StatelessWidget {
|
||||
const SourceDetailsPopup({super.key});
|
||||
|
||||
Future<SourcedTrack?> _pullActiveTrack() async {
|
||||
final ActiveSourcedTrackProvider activeSourcedTrack = Get.find();
|
||||
return activeSourcedTrack.state.value;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Source Details',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
|
||||
Expanded(
|
||||
child: Obx(
|
||||
() => FutureBuilder(
|
||||
future: _pullActiveTrack(),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return TrackSourceDetails(
|
||||
track: snapshot.data!,
|
||||
);
|
||||
}
|
||||
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
);
|
||||
},
|
||||
).paddingSymmetric(horizontal: 24),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@ import 'package:rhythm_box/providers/audio_player.dart';
|
||||
import 'package:rhythm_box/providers/auth.dart';
|
||||
import 'package:rhythm_box/screens/player/queue.dart';
|
||||
import 'package:rhythm_box/screens/player/siblings.dart';
|
||||
import 'package:rhythm_box/screens/player/source_details.dart';
|
||||
import 'package:rhythm_box/services/artist.dart';
|
||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||
import 'package:rhythm_box/services/duration.dart';
|
||||
@ -69,294 +70,344 @@ class _PlayerScreenState extends State<PlayerScreen> {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
direction: DismissiblePageDismissDirection.down,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: SafeArea(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
children: [
|
||||
Obx(
|
||||
() => LimitedBox(
|
||||
maxHeight: maxAlbumSize,
|
||||
maxWidth: maxAlbumSize,
|
||||
child: Hero(
|
||||
tag: const Key('current-active-track-album-art'),
|
||||
child: ClipRRect(
|
||||
borderRadius:
|
||||
const BorderRadius.all(Radius.circular(16)),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: _albumArt != null
|
||||
? AutoCacheImage(
|
||||
_albumArt!,
|
||||
width: albumSize,
|
||||
height: albumSize,
|
||||
)
|
||||
: Container(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHigh,
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: const Center(
|
||||
child: Icon(Icons.image)),
|
||||
),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(vertical: 24),
|
||||
children: [
|
||||
Obx(
|
||||
() => Center(
|
||||
child: LimitedBox(
|
||||
maxHeight: maxAlbumSize,
|
||||
maxWidth: maxAlbumSize,
|
||||
child: Hero(
|
||||
tag: const Key('current-active-track-album-art'),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(
|
||||
Radius.circular(16),
|
||||
),
|
||||
child: _albumArt != null
|
||||
? AutoCacheImage(
|
||||
_albumArt!,
|
||||
width: albumSize,
|
||||
height: albumSize,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
: Container(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHigh,
|
||||
width: 64,
|
||||
height: 64,
|
||||
child: const Center(
|
||||
child: Icon(Icons.image),
|
||||
),
|
||||
),
|
||||
),
|
||||
).marginSymmetric(horizontal: 24),
|
||||
),
|
||||
).marginSymmetric(horizontal: 24),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(24),
|
||||
Obx(
|
||||
() => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
const Gap(24),
|
||||
Obx(
|
||||
() => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_playback.state.value.activeTrack?.name ??
|
||||
'Not playing',
|
||||
style:
|
||||
Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
Text(
|
||||
_playback.state.value.activeTrack?.artists
|
||||
?.asString() ??
|
||||
'No author',
|
||||
style:
|
||||
Theme.of(context).textTheme.bodyMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.left,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_playback.state.value.activeTrack != null &&
|
||||
_auth.auth.value != null)
|
||||
TrackHeartButton(
|
||||
key: ValueKey(
|
||||
_playback.state.value.activeTrack!.id!,
|
||||
),
|
||||
trackId: _playback.state.value.activeTrack!.id!,
|
||||
),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 32),
|
||||
),
|
||||
const Gap(24),
|
||||
Obx(
|
||||
() => Column(
|
||||
children: [
|
||||
SliderTheme(
|
||||
data: SliderThemeData(
|
||||
trackHeight: 2,
|
||||
trackShape: _PlayerProgressTrackShape(),
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 8,
|
||||
),
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
),
|
||||
child: Slider(
|
||||
secondaryTrackValue: _playback
|
||||
.durationBuffered.value.inMilliseconds
|
||||
.abs()
|
||||
.toDouble(),
|
||||
value: _draggingValue?.abs() ??
|
||||
_playback
|
||||
.durationCurrent.value.inMilliseconds
|
||||
.toDouble()
|
||||
.abs(),
|
||||
min: 0,
|
||||
max: max(
|
||||
_playback.durationCurrent.value.inMilliseconds
|
||||
.abs(),
|
||||
_playback.durationTotal.value.inMilliseconds
|
||||
.abs(),
|
||||
).toDouble(),
|
||||
onChanged: (value) {
|
||||
setState(() => _draggingValue = value);
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
audioPlayer.seek(
|
||||
Duration(milliseconds: value.toInt()),
|
||||
);
|
||||
setState(() => _draggingValue = null);
|
||||
},
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_playback.state.value.activeTrack?.name ??
|
||||
'Not playing',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
textAlign: TextAlign.left,
|
||||
_playback.durationCurrent.value
|
||||
.toHumanReadableString(),
|
||||
style: GoogleFonts.robotoMono(fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
_playback.state.value.activeTrack?.artists
|
||||
?.asString() ??
|
||||
'No author',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.left,
|
||||
_playback.durationTotal.value
|
||||
.toHumanReadableString(),
|
||||
style: GoogleFonts.robotoMono(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_playback.state.value.activeTrack != null &&
|
||||
_auth.auth.value != null)
|
||||
TrackHeartButton(
|
||||
trackId: _playback.state.value.activeTrack!.id!,
|
||||
),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 32),
|
||||
),
|
||||
const Gap(24),
|
||||
Obx(
|
||||
() => Column(
|
||||
).paddingSymmetric(horizontal: 8, vertical: 4),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 24),
|
||||
),
|
||||
const Gap(24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SliderTheme(
|
||||
data: SliderThemeData(
|
||||
trackHeight: 2,
|
||||
trackShape: _PlayerProgressTrackShape(),
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 8,
|
||||
),
|
||||
overlayShape: SliderComponentShape.noOverlay,
|
||||
),
|
||||
child: Slider(
|
||||
secondaryTrackValue: _playback
|
||||
.durationBuffered.value.inMilliseconds
|
||||
.abs()
|
||||
.toDouble(),
|
||||
value: _draggingValue?.abs() ??
|
||||
_playback.durationCurrent.value.inMilliseconds
|
||||
.toDouble()
|
||||
.abs(),
|
||||
min: 0,
|
||||
max: max(
|
||||
_playback.durationCurrent.value.inMilliseconds
|
||||
.abs(),
|
||||
_playback.durationTotal.value.inMilliseconds
|
||||
.abs(),
|
||||
).toDouble(),
|
||||
onChanged: (value) {
|
||||
setState(() => _draggingValue = value);
|
||||
},
|
||||
onChangeEnd: (value) {
|
||||
audioPlayer.seek(
|
||||
Duration(milliseconds: value.toInt()));
|
||||
},
|
||||
StreamBuilder<bool>(
|
||||
stream: audioPlayer.shuffledStream,
|
||||
builder: (context, snapshot) {
|
||||
final shuffled = snapshot.data ?? false;
|
||||
return Obx(
|
||||
() => IconButton(
|
||||
icon: Icon(
|
||||
shuffled
|
||||
? Icons.shuffle_on_outlined
|
||||
: Icons.shuffle,
|
||||
),
|
||||
onPressed: _isFetchingActiveTrack
|
||||
? null
|
||||
: () {
|
||||
if (shuffled) {
|
||||
audioPlayer.setShuffle(false);
|
||||
} else {
|
||||
audioPlayer.setShuffle(true);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Obx(
|
||||
() => IconButton(
|
||||
icon: const Icon(Icons.skip_previous),
|
||||
onPressed: _isFetchingActiveTrack
|
||||
? null
|
||||
: audioPlayer.skipToPrevious,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_playback.durationCurrent.value
|
||||
.toHumanReadableString(),
|
||||
style: GoogleFonts.robotoMono(fontSize: 12),
|
||||
const Gap(8),
|
||||
Obx(
|
||||
() => SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: IconButton.filled(
|
||||
icon: _isFetchingActiveTrack
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
!_isPlaying
|
||||
? Icons.play_arrow
|
||||
: Icons.pause,
|
||||
size: 28,
|
||||
),
|
||||
onPressed: _togglePlayState,
|
||||
),
|
||||
Text(
|
||||
_playback.durationTotal.value
|
||||
.toHumanReadableString(),
|
||||
style: GoogleFonts.robotoMono(fontSize: 12),
|
||||
),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 8, vertical: 4),
|
||||
],
|
||||
).paddingSymmetric(horizontal: 24),
|
||||
),
|
||||
const Gap(24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
StreamBuilder<bool>(
|
||||
stream: audioPlayer.shuffledStream,
|
||||
builder: (context, snapshot) {
|
||||
final shuffled = snapshot.data ?? false;
|
||||
return IconButton(
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Obx(
|
||||
() => IconButton(
|
||||
icon: const Icon(Icons.skip_next),
|
||||
onPressed: _isFetchingActiveTrack
|
||||
? null
|
||||
: audioPlayer.skipToNext,
|
||||
),
|
||||
),
|
||||
Obx(
|
||||
() => IconButton(
|
||||
icon: Icon(
|
||||
shuffled
|
||||
? Icons.shuffle_on_outlined
|
||||
: Icons.shuffle,
|
||||
_loopMode == PlaylistMode.none
|
||||
? Icons.repeat
|
||||
: _loopMode == PlaylistMode.loop
|
||||
? Icons.repeat_on_outlined
|
||||
: Icons.repeat_one_on_outlined,
|
||||
),
|
||||
onPressed: _isFetchingActiveTrack
|
||||
? null
|
||||
: () {
|
||||
if (shuffled) {
|
||||
audioPlayer.setShuffle(false);
|
||||
} else {
|
||||
audioPlayer.setShuffle(true);
|
||||
}
|
||||
: () async {
|
||||
await audioPlayer.setLoopMode(
|
||||
switch (_loopMode) {
|
||||
PlaylistMode.loop =>
|
||||
PlaylistMode.single,
|
||||
PlaylistMode.single =>
|
||||
PlaylistMode.none,
|
||||
PlaylistMode.none =>
|
||||
PlaylistMode.loop,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
Obx(
|
||||
() => IconButton(
|
||||
icon: const Icon(Icons.skip_previous),
|
||||
onPressed: _isFetchingActiveTrack
|
||||
? null
|
||||
: audioPlayer.skipToPrevious,
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Obx(
|
||||
() => SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: IconButton.filled(
|
||||
icon: (_isFetchingActiveTrack && _isPlaying)
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2.5,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
!_isPlaying
|
||||
? Icons.play_arrow
|
||||
: Icons.pause,
|
||||
size: 28,
|
||||
),
|
||||
onPressed: _togglePlayState,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(8),
|
||||
Obx(
|
||||
() => IconButton(
|
||||
icon: const Icon(Icons.skip_next),
|
||||
onPressed: _isFetchingActiveTrack
|
||||
? null
|
||||
: audioPlayer.skipToNext,
|
||||
),
|
||||
),
|
||||
Obx(
|
||||
() => IconButton(
|
||||
icon: Icon(
|
||||
_loopMode == PlaylistMode.none
|
||||
? Icons.repeat
|
||||
: _loopMode == PlaylistMode.loop
|
||||
? Icons.repeat_on_outlined
|
||||
: Icons.repeat_one_on_outlined,
|
||||
),
|
||||
onPressed: _isFetchingActiveTrack
|
||||
? null
|
||||
: () async {
|
||||
await audioPlayer.setLoopMode(
|
||||
switch (_loopMode) {
|
||||
PlaylistMode.loop =>
|
||||
PlaylistMode.single,
|
||||
PlaylistMode.single =>
|
||||
PlaylistMode.none,
|
||||
PlaylistMode.none => PlaylistMode.loop,
|
||||
},
|
||||
);
|
||||
],
|
||||
),
|
||||
const Gap(20),
|
||||
Center(
|
||||
child: SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.queue_music),
|
||||
label: const Text(
|
||||
'Queue',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
const PlayerQueuePopup(),
|
||||
).then((_) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
if (!isLargeScreen) const Gap(4),
|
||||
if (!isLargeScreen)
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.lyrics),
|
||||
label: const Text(
|
||||
'Lyrics',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
onPressed: () {
|
||||
GoRouter.of(context)
|
||||
.pushNamed('playerLyrics');
|
||||
},
|
||||
),
|
||||
const Gap(4),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.merge),
|
||||
label: const Text(
|
||||
'Sources',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.fade,
|
||||
),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
const SiblingTracksPopup(),
|
||||
).then((_) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
const Gap(4),
|
||||
TextButton.icon(
|
||||
label: const Text('Info'),
|
||||
icon: const Icon(Icons.info),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
const SourceDetailsPopup(),
|
||||
).then((_) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Gap(20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(Icons.queue_music),
|
||||
label: const Text('Queue'),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) => const PlayerQueuePopup(),
|
||||
).then((_) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
if (!isLargeScreen) const Gap(4),
|
||||
if (!isLargeScreen)
|
||||
Expanded(
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(Icons.lyrics),
|
||||
label: const Text('Lyrics'),
|
||||
onPressed: () {
|
||||
GoRouter.of(context).pushNamed('playerLyrics');
|
||||
},
|
||||
),
|
||||
),
|
||||
const Gap(4),
|
||||
Expanded(
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(Icons.merge),
|
||||
label: const Text('Sources'),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
useRootNavigator: true,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
const SiblingTracksPopup(),
|
||||
).then((_) {
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isLargeScreen) const Gap(24),
|
||||
if (isLargeScreen)
|
||||
const Expanded(
|
||||
child: SyncedLyrics(defaultTextZoom: 67),
|
||||
)
|
||||
],
|
||||
if (isLargeScreen) const Gap(24),
|
||||
if (isLargeScreen)
|
||||
const Expanded(
|
||||
child: SyncedLyrics(defaultTextZoom: 67),
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
).marginSymmetric(horizontal: 24),
|
||||
),
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'package:dropdown_button2/dropdown_button2.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
@ -5,6 +6,8 @@ import 'package:rhythm_box/providers/auth.dart';
|
||||
import 'package:rhythm_box/providers/spotify.dart';
|
||||
import 'package:rhythm_box/providers/user_preferences.dart';
|
||||
import 'package:rhythm_box/screens/auth/login.dart';
|
||||
import 'package:rhythm_box/services/database/database.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sources/netease.dart';
|
||||
import 'package:rhythm_box/widgets/auto_cache_image.dart';
|
||||
import 'package:rhythm_box/widgets/sized_container.dart';
|
||||
|
||||
@ -32,7 +35,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
centerTitle: MediaQuery.of(context).size.width >= 720,
|
||||
),
|
||||
body: CenteredContainer(
|
||||
child: Column(
|
||||
child: ListView(
|
||||
children: [
|
||||
Obx(() {
|
||||
if (_authenticate.auth.value == null) {
|
||||
@ -84,6 +87,75 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
},
|
||||
);
|
||||
}),
|
||||
Obx(() {
|
||||
if (_authenticate.auth.value == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
if (_authenticate.auth.value?.neteaseCookie == null) {
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Icons.cloud_outlined),
|
||||
title: const Text('Connect with Netease Cloud Music'),
|
||||
subtitle: const Text(
|
||||
'Make us able to play more music from Netease Cloud Music'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
enabled: !_isLoggingIn,
|
||||
onTap: () async {
|
||||
setState(() => _isLoggingIn = true);
|
||||
await GoRouter.of(context)
|
||||
.pushNamed('authMobileLoginNetease');
|
||||
setState(() => _isLoggingIn = false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return FutureBuilder(
|
||||
future: NeteaseSourcedTrack.getClient().get('/user/account'),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
),
|
||||
),
|
||||
title: const Text('Loading...'),
|
||||
trailing: IconButton(
|
||||
onPressed: () {
|
||||
_authenticate.logoutNetease();
|
||||
},
|
||||
icon: const Icon(Icons.link_off),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
leading:
|
||||
snapshot.data!.body['profile']['avatarUrl'] != null
|
||||
? CircleAvatar(
|
||||
backgroundImage: AutoCacheImage.provider(
|
||||
snapshot.data!.body['profile']['avatarUrl'],
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.account_circle),
|
||||
title: Text(snapshot.data!.body['profile']['nickname']),
|
||||
subtitle: const Text(
|
||||
'Connected with your Netease Cloud Music account',
|
||||
),
|
||||
trailing: IconButton(
|
||||
onPressed: () {
|
||||
_authenticate.logoutNetease();
|
||||
},
|
||||
icon: const Icon(Icons.link_off),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
Obx(() {
|
||||
if (_authenticate.auth.value == null) {
|
||||
return const SizedBox();
|
||||
@ -93,7 +165,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Icons.logout),
|
||||
title: const Text('Log out'),
|
||||
subtitle: const Text('Disconnect with this Spotify account'),
|
||||
subtitle: const Text('Disconnect with every account'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () async {
|
||||
_authenticate.logout();
|
||||
@ -101,6 +173,91 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}),
|
||||
const Divider(thickness: 0.3, height: 1),
|
||||
Obx(
|
||||
() => ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: const Icon(Icons.audio_file),
|
||||
title: const Text('Audio Source'),
|
||||
subtitle:
|
||||
const Text('Choose who to provide the songs you played.'),
|
||||
trailing: DropdownButtonHideUnderline(
|
||||
child: DropdownButton2<AudioSource>(
|
||||
isExpanded: true,
|
||||
hint: Text(
|
||||
'Select Item',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
items: AudioSource.values
|
||||
.map((AudioSource item) =>
|
||||
DropdownMenuItem<AudioSource>(
|
||||
value: item,
|
||||
child: Text(
|
||||
item.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
))
|
||||
.toList(),
|
||||
value: _preferences.state.value.audioSource,
|
||||
onChanged: (AudioSource? value) {
|
||||
_preferences
|
||||
.setAudioSource(value ?? AudioSource.youtube);
|
||||
},
|
||||
buttonStyleData: const ButtonStyleData(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
height: 40,
|
||||
width: 140,
|
||||
),
|
||||
menuItemStyleData: const MenuItemStyleData(
|
||||
height: 40,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(thickness: 0.3, height: 1),
|
||||
Obx(
|
||||
() => CheckboxListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
secondary: const Icon(Icons.update),
|
||||
title: const Text('Override Cache Provider'),
|
||||
subtitle: const Text(
|
||||
'Decide whether use original cached source or query a new one from current audio provider'),
|
||||
value: _preferences.state.value.overrideCacheProvider,
|
||||
onChanged: (value) =>
|
||||
_preferences.setOverrideCacheProvider(value ?? false),
|
||||
),
|
||||
),
|
||||
Obx(
|
||||
() => Column(
|
||||
children: [
|
||||
const ListTile(
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 24),
|
||||
leading: Icon(Icons.cloud),
|
||||
title: Text('Netease Cloud Music API'),
|
||||
subtitle: Text(
|
||||
'Use your own endpoint to prevent IP throttling and more'),
|
||||
),
|
||||
TextFormField(
|
||||
initialValue: _preferences.state.value.neteaseApiInstance,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Endpoint URL',
|
||||
isDense: true,
|
||||
),
|
||||
onChanged: (value) {
|
||||
_preferences.setNeteaseApiInstance(value);
|
||||
},
|
||||
onTapOutside: (_) =>
|
||||
FocusManager.instance.primaryFocus?.unfocus(),
|
||||
).paddingOnly(left: 24, right: 24, bottom: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(thickness: 0.3, height: 1),
|
||||
Obx(
|
||||
() => SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
@ -123,6 +280,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
onChanged: _preferences.setNormalizeAudio,
|
||||
),
|
||||
),
|
||||
Obx(
|
||||
() => SwitchListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
secondary: const Icon(Icons.screen_lock_portrait),
|
||||
title: const Text('Player Wakelock'),
|
||||
subtitle: const Text(
|
||||
'Keep your screen doesn\'t lock in player screen'),
|
||||
value: _preferences.state.value.playerWakelock,
|
||||
onChanged: _preferences.setPlayerWakelock,
|
||||
),
|
||||
),
|
||||
const Divider(thickness: 0.3, height: 1),
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
|
@ -1,10 +1,11 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:media_kit/media_kit.dart' hide Track;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:rhythm_box/platform.dart';
|
||||
import 'package:rhythm_box/providers/audio_player.dart';
|
||||
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||
import 'package:rhythm_box/services/local_track.dart';
|
||||
import 'package:rhythm_box/services/server/server.dart';
|
||||
import 'package:rhythm_box/widgets/tracks/querying_track_info.dart';
|
||||
@ -93,7 +94,7 @@ abstract class AudioPlayerInterface {
|
||||
),
|
||||
) {
|
||||
_mkPlayer.stream.error.listen((event) {
|
||||
log('[Playback] Error: $event');
|
||||
Get.find<ErrorNotifier>().logError('[Playback][Player] Error: $event');
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -90,16 +90,28 @@ class RhythmAudioPlayer extends AudioPlayerInterface
|
||||
|
||||
Future<void> skipToNext() async {
|
||||
Get.find<QueryingTrackInfoProvider>().isQueryingTrackInfo.value = true;
|
||||
Get.find<AudioPlayerProvider>().durationBuffered.value =
|
||||
const Duration(seconds: 0);
|
||||
Get.find<AudioPlayerProvider>().durationCurrent.value =
|
||||
const Duration(seconds: 0);
|
||||
await _mkPlayer.next();
|
||||
}
|
||||
|
||||
Future<void> skipToPrevious() async {
|
||||
Get.find<QueryingTrackInfoProvider>().isQueryingTrackInfo.value = true;
|
||||
Get.find<AudioPlayerProvider>().durationBuffered.value =
|
||||
const Duration(seconds: 0);
|
||||
Get.find<AudioPlayerProvider>().durationCurrent.value =
|
||||
const Duration(seconds: 0);
|
||||
await _mkPlayer.previous();
|
||||
}
|
||||
|
||||
Future<void> jumpTo(int index) async {
|
||||
Get.find<QueryingTrackInfoProvider>().isQueryingTrackInfo.value = true;
|
||||
Get.find<AudioPlayerProvider>().durationBuffered.value =
|
||||
const Duration(seconds: 0);
|
||||
Get.find<AudioPlayerProvider>().durationCurrent.value =
|
||||
const Duration(seconds: 0);
|
||||
await _mkPlayer.jump(index);
|
||||
}
|
||||
|
||||
|
@ -1,10 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:flutter_broadcasts/flutter_broadcasts.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:audio_session/audio_session.dart';
|
||||
import 'package:rhythm_box/platform.dart';
|
||||
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||
|
||||
// ignore: implementation_imports
|
||||
import 'package:rhythm_box/services/audio_player/playback_state.dart';
|
||||
@ -49,7 +50,8 @@ class CustomPlayer extends Player {
|
||||
}
|
||||
}),
|
||||
stream.error.listen((event) {
|
||||
log('[MediaKitError] $event');
|
||||
Get.find<ErrorNotifier>()
|
||||
.logError('[Playback][CustomLayer] Error: $event');
|
||||
}),
|
||||
];
|
||||
PackageInfo.fromPlatform().then((packageInfo) {
|
||||
|
@ -55,7 +55,24 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase() : super(_openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 1;
|
||||
int get schemaVersion => 2;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
return MigrationStrategy(
|
||||
onCreate: (Migrator m) async {
|
||||
await m.createAll();
|
||||
},
|
||||
onUpgrade: (Migrator m, int from, int to) async {
|
||||
if (from < 2) {
|
||||
await m.addColumn(
|
||||
preferencesTable,
|
||||
preferencesTable.overrideCacheProvider,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
LazyDatabase _openConnection() {
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -2,7 +2,10 @@ part of '../database.dart';
|
||||
|
||||
class AuthenticationTable extends Table {
|
||||
IntColumn get id => integer().autoIncrement()();
|
||||
TextColumn get cookie => text().map(EncryptedTextConverter())();
|
||||
TextColumn get accessToken => text().map(EncryptedTextConverter())();
|
||||
DateTimeColumn get expiration => dateTime()();
|
||||
TextColumn get spotifyCookie => text().map(EncryptedTextConverter())();
|
||||
TextColumn get spotifyAccessToken => text().map(EncryptedTextConverter())();
|
||||
DateTimeColumn get spotifyExpiration => dateTime()();
|
||||
TextColumn get neteaseCookie =>
|
||||
text().map(EncryptedTextConverter()).nullable()();
|
||||
DateTimeColumn get neteaseExpiration => dateTime().nullable()();
|
||||
}
|
||||
|
@ -13,7 +13,9 @@ enum CloseBehavior {
|
||||
|
||||
enum AudioSource {
|
||||
youtube,
|
||||
piped;
|
||||
piped,
|
||||
netease,
|
||||
kugou;
|
||||
|
||||
String get label => name[0].toUpperCase() + name.substring(1);
|
||||
}
|
||||
@ -45,8 +47,6 @@ class PreferencesTable extends Table {
|
||||
.withDefault(Constant(SourceQualities.high.name))();
|
||||
BoolColumn get albumColorSync =>
|
||||
boolean().withDefault(const Constant(true))();
|
||||
BoolColumn get amoledDarkTheme =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
BoolColumn get checkUpdate => boolean().withDefault(const Constant(true))();
|
||||
BoolColumn get normalizeAudio =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
@ -76,6 +76,8 @@ class PreferencesTable extends Table {
|
||||
text().withDefault(const Constant('')).map(const StringListConverter())();
|
||||
TextColumn get pipedInstance =>
|
||||
text().withDefault(const Constant('https://pipedapi.kavin.rocks'))();
|
||||
TextColumn get neteaseApiInstance => text().withDefault(
|
||||
const Constant('https://rhythmbox-netease-music-api.vercel.app'))();
|
||||
TextColumn get themeMode =>
|
||||
textEnum<ThemeMode>().withDefault(Constant(ThemeMode.system.name))();
|
||||
TextColumn get audioSource =>
|
||||
@ -84,12 +86,12 @@ class PreferencesTable extends Table {
|
||||
textEnum<SourceCodecs>().withDefault(Constant(SourceCodecs.weba.name))();
|
||||
TextColumn get downloadMusicCodec =>
|
||||
textEnum<SourceCodecs>().withDefault(Constant(SourceCodecs.m4a.name))();
|
||||
BoolColumn get discordPresence =>
|
||||
boolean().withDefault(const Constant(true))();
|
||||
BoolColumn get endlessPlayback =>
|
||||
boolean().withDefault(const Constant(true))();
|
||||
BoolColumn get enableConnect =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
BoolColumn get playerWakelock =>
|
||||
boolean().withDefault(const Constant(true))();
|
||||
BoolColumn get overrideCacheProvider =>
|
||||
boolean().withDefault(const Constant(true))();
|
||||
|
||||
// Default values as PreferencesTableData
|
||||
static PreferencesTableData defaults() {
|
||||
@ -97,7 +99,6 @@ class PreferencesTable extends Table {
|
||||
id: 0,
|
||||
audioQuality: SourceQualities.high,
|
||||
albumColorSync: true,
|
||||
amoledDarkTheme: false,
|
||||
checkUpdate: true,
|
||||
normalizeAudio: false,
|
||||
showSystemTrayIcon: false,
|
||||
@ -111,14 +112,15 @@ class PreferencesTable extends Table {
|
||||
searchMode: SearchMode.youtube,
|
||||
downloadLocation: '',
|
||||
localLibraryLocation: [],
|
||||
neteaseApiInstance: 'https://rhythmbox-netease-music-api.vercel.app',
|
||||
pipedInstance: 'https://pipedapi.kavin.rocks',
|
||||
themeMode: ThemeMode.system,
|
||||
audioSource: AudioSource.youtube,
|
||||
streamMusicCodec: SourceCodecs.weba,
|
||||
downloadMusicCodec: SourceCodecs.m4a,
|
||||
discordPresence: true,
|
||||
endlessPlayback: true,
|
||||
enableConnect: false,
|
||||
playerWakelock: true,
|
||||
overrideCacheProvider: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,9 @@ part of '../database.dart';
|
||||
|
||||
enum SourceType {
|
||||
youtube._('YouTube'),
|
||||
youtubeMusic._('YouTube Music');
|
||||
youtubeMusic._('YouTube Music'),
|
||||
netease._('Netease Music'),
|
||||
kugou._('Kugou Music');
|
||||
|
||||
final String label;
|
||||
|
||||
|
@ -1,11 +1,10 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:lrc/lrc.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:rhythm_box/providers/database.dart';
|
||||
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||
import 'package:rhythm_box/providers/spotify.dart';
|
||||
import 'package:rhythm_box/services/database/database.dart';
|
||||
import 'package:rhythm_box/services/lyrics/model.dart';
|
||||
@ -164,8 +163,8 @@ class SyncedLyricsProvider extends GetxController {
|
||||
}
|
||||
|
||||
return lyrics;
|
||||
} catch (e, stackTrace) {
|
||||
log('[Lyrics] Error: $e; Trace:\n$stackTrace');
|
||||
} catch (e, stack) {
|
||||
Get.find<ErrorNotifier>().logError('[Lyrics] Error: $e', trace: stack);
|
||||
return SubtitleSimple(
|
||||
uri: Uri.parse('https://example.com/not-found'),
|
||||
name: 'Lyrics Not Found',
|
||||
|
@ -1,9 +1,10 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:media_kit/media_kit.dart' hide Track;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:rhythm_box/platform.dart';
|
||||
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||
import 'package:rhythm_box/services/audio_player/custom_player.dart';
|
||||
import 'package:rhythm_box/services/local_track.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||
@ -85,7 +86,7 @@ abstract class AudioPlayerInterface {
|
||||
),
|
||||
) {
|
||||
_mkPlayer.stream.error.listen((event) {
|
||||
log('[Playback] Error: $event');
|
||||
Get.find<ErrorNotifier>().logError('[Playback][Media] Error: $event');
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,6 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/providers/audio_player.dart';
|
||||
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/models/source_info.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||
@ -41,7 +40,9 @@ class ActiveSourcedTrackProvider extends GetxController {
|
||||
await audioPlayer.removeTrack(oldActiveIndex);
|
||||
await playback.jumpToTrack(newTrack);
|
||||
} catch (e, stack) {
|
||||
log('[Playback] Failed to swap with siblings. Error: $e; Trace:\n$stack');
|
||||
Get.find<ErrorNotifier>().logError(
|
||||
'[Playback] Failed to swap with siblings. Error: $e',
|
||||
trace: stack);
|
||||
} finally {
|
||||
query.isQueryingTrackInfo.value = false;
|
||||
await audioPlayer.resume();
|
||||
|
@ -1,12 +1,16 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart' hide Response;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:get/get.dart' hide Response;
|
||||
import 'package:rhythm_box/providers/audio_player.dart';
|
||||
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||
import 'package:rhythm_box/services/audio_player/audio_player.dart';
|
||||
import 'package:rhythm_box/services/server/active_sourced_track.dart';
|
||||
import 'package:rhythm_box/services/server/sourced_track.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sources/kugou.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sources/netease.dart';
|
||||
import 'package:shelf/shelf.dart';
|
||||
|
||||
class ServerPlaybackRoutesProvider {
|
||||
@ -25,14 +29,41 @@ class ServerPlaybackRoutesProvider {
|
||||
|
||||
activeSourcedTrack.updateTrack(sourcedTrack);
|
||||
|
||||
var url = sourcedTrack!.url;
|
||||
|
||||
if (sourcedTrack is NeteaseSourcedTrack) {
|
||||
// Special processing for netease to get real assets url
|
||||
final resp = await GetConnect(timeout: const Duration(seconds: 30)).get(
|
||||
'${sourcedTrack.url}&realIP=${await NeteaseSourcedTrack.lookupRealIp()}',
|
||||
);
|
||||
final realUrl = resp.body['data'][0]['url'];
|
||||
url = realUrl;
|
||||
} else if (sourcedTrack is KugouSourcedTrack) {
|
||||
// Special processing for kugou to get real assets url
|
||||
final resp = await GetConnect(timeout: const Duration(seconds: 30))
|
||||
.get(sourcedTrack.url);
|
||||
final urls = jsonDecode(resp.body)['url'];
|
||||
if (urls?.isEmpty ?? true) {
|
||||
Get.find<ErrorNotifier>().showError(
|
||||
'[PlaybackServer] Unable get audio source via Kugou, probably cause by paid needed resources.',
|
||||
);
|
||||
return Response(
|
||||
HttpStatus.notFound,
|
||||
body: 'Unable get audio source via Kugou',
|
||||
);
|
||||
}
|
||||
final realUrl = KugouSourcedTrack.unescapeUrl(urls.first);
|
||||
url = realUrl;
|
||||
}
|
||||
|
||||
final res = await Dio().get(
|
||||
sourcedTrack!.url,
|
||||
url,
|
||||
options: Options(
|
||||
headers: {
|
||||
...request.headers,
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36',
|
||||
'host': Uri.parse(sourcedTrack.url).host,
|
||||
'host': Uri.parse(url).host,
|
||||
'Cache-Control': 'max-age=0',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
@ -57,8 +88,9 @@ class ServerPlaybackRoutesProvider {
|
||||
},
|
||||
headers: res.headers.map,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
log('[PlaybackSever] Error: $e; Trace:\n $stackTrace');
|
||||
} catch (e, stack) {
|
||||
Get.find<ErrorNotifier>()
|
||||
.logError('[PlaybackSever] Error: $e', trace: stack);
|
||||
return Response.internalServerError();
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,13 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/providers/database.dart';
|
||||
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||
import 'package:rhythm_box/providers/user_preferences.dart';
|
||||
import 'package:rhythm_box/services/database/database.dart';
|
||||
import 'package:rhythm_box/services/server/active_sourced_track.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sources/kugou.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sources/netease.dart';
|
||||
import 'package:rhythm_box/services/utils.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
@ -55,6 +61,12 @@ abstract class SourcedTrack extends Track {
|
||||
.cast<SourceInfo>();
|
||||
|
||||
return switch (audioSource) {
|
||||
AudioSource.netease => NeteaseSourcedTrack(
|
||||
source: source,
|
||||
siblings: siblings,
|
||||
sourceInfo: sourceInfo,
|
||||
track: track,
|
||||
),
|
||||
AudioSource.piped => PipedSourcedTrack(
|
||||
source: source,
|
||||
siblings: siblings,
|
||||
@ -70,6 +82,33 @@ abstract class SourcedTrack extends Track {
|
||||
};
|
||||
}
|
||||
|
||||
static Future<SourcedTrack?> reRoutineFetchFromTrack(
|
||||
Track track, SourceMatchTableData cachedSource) {
|
||||
final preferences = Get.find<UserPreferencesProvider>().state.value;
|
||||
final ytOrPiped = preferences.audioSource == AudioSource.piped
|
||||
? PipedSourcedTrack.fetchFromTrack
|
||||
: YoutubeSourcedTrack.fetchFromTrack;
|
||||
final sourceInfoTrackMap = {
|
||||
SourceType.youtube: ytOrPiped,
|
||||
SourceType.youtubeMusic: ytOrPiped,
|
||||
SourceType.netease: NeteaseSourcedTrack.fetchFromTrack,
|
||||
SourceType.kugou: KugouSourcedTrack.fetchFromTrack,
|
||||
};
|
||||
return sourceInfoTrackMap[cachedSource.sourceType]!(track: track);
|
||||
}
|
||||
|
||||
Future<SourcedTrack?> reRoutineSwapSiblings(SourceInfo info) {
|
||||
final sourceInfoTrackMap = {
|
||||
YoutubeSourceInfo: YoutubeSourcedTrack.fetchFromTrack,
|
||||
PipedSourceInfo: PipedSourcedTrack.fetchFromTrack,
|
||||
NeteaseSourceInfo: NeteaseSourcedTrack.fetchFromTrack,
|
||||
KugouSourceInfo: KugouSourcedTrack.fetchFromTrack,
|
||||
};
|
||||
return sourceInfoTrackMap[info.runtimeType]!(
|
||||
track: Get.find<ActiveSourcedTrackProvider>().state.value!,
|
||||
);
|
||||
}
|
||||
|
||||
static String getSearchTerm(Track track) {
|
||||
final artists = (track.artists ?? [])
|
||||
.map((ar) => ar.name)
|
||||
@ -88,20 +127,73 @@ abstract class SourcedTrack extends Track {
|
||||
|
||||
static Future<SourcedTrack> fetchFromTrack({
|
||||
required Track track,
|
||||
AudioSource? fallbackTo,
|
||||
}) async {
|
||||
final preferences = Get.find<UserPreferencesProvider>().state.value;
|
||||
final audioSource = preferences.audioSource;
|
||||
var audioSource = preferences.audioSource;
|
||||
|
||||
if (!preferences.overrideCacheProvider && fallbackTo == null) {
|
||||
final DatabaseProvider db = Get.find();
|
||||
final cachedSource =
|
||||
await (db.database.select(db.database.sourceMatchTable)
|
||||
..where((s) => s.trackId.equals(track.id!))
|
||||
..limit(1)
|
||||
..orderBy([
|
||||
(s) => OrderingTerm(
|
||||
expression: s.createdAt, mode: OrderingMode.desc),
|
||||
]))
|
||||
.get()
|
||||
.then((s) => s.firstOrNull);
|
||||
|
||||
final ytOrPiped = preferences.audioSource == AudioSource.youtube
|
||||
? AudioSource.youtube
|
||||
: AudioSource.piped;
|
||||
final sourceTypeTrackMap = {
|
||||
SourceType.youtube: ytOrPiped,
|
||||
SourceType.youtubeMusic: ytOrPiped,
|
||||
SourceType.netease: AudioSource.netease,
|
||||
SourceType.kugou: AudioSource.kugou,
|
||||
};
|
||||
|
||||
if (cachedSource != null) {
|
||||
final cachedAudioSource = sourceTypeTrackMap[cachedSource.sourceType]!;
|
||||
audioSource = cachedAudioSource;
|
||||
}
|
||||
}
|
||||
|
||||
if (fallbackTo != null) {
|
||||
audioSource = fallbackTo;
|
||||
}
|
||||
|
||||
try {
|
||||
return switch (audioSource) {
|
||||
AudioSource.netease =>
|
||||
await NeteaseSourcedTrack.fetchFromTrack(track: track),
|
||||
AudioSource.kugou =>
|
||||
await KugouSourcedTrack.fetchFromTrack(track: track),
|
||||
AudioSource.piped =>
|
||||
await PipedSourcedTrack.fetchFromTrack(track: track),
|
||||
_ => await YoutubeSourcedTrack.fetchFromTrack(track: track),
|
||||
};
|
||||
} on TrackNotFoundError catch (_) {
|
||||
// TODO Try to look it up in other source
|
||||
// But the youtube and piped.video are the same, and there is no extra sources, so i ignored this for temporary
|
||||
rethrow;
|
||||
} on TrackNotFoundError catch (err) {
|
||||
Get.find<ErrorNotifier>().showError(
|
||||
'${err.toString()} via ${preferences.audioSource.label}, querying in fallback sources...',
|
||||
);
|
||||
|
||||
if (fallbackTo != null) {
|
||||
// Prevent infinite fallback
|
||||
if (audioSource == AudioSource.youtube ||
|
||||
audioSource == AudioSource.piped) rethrow;
|
||||
}
|
||||
|
||||
return switch (audioSource) {
|
||||
AudioSource.netease =>
|
||||
await fetchFromTrack(track: track, fallbackTo: AudioSource.youtube),
|
||||
AudioSource.kugou =>
|
||||
await fetchFromTrack(track: track, fallbackTo: AudioSource.youtube),
|
||||
_ =>
|
||||
await fetchFromTrack(track: track, fallbackTo: AudioSource.netease),
|
||||
};
|
||||
} on HttpClientClosedException catch (_) {
|
||||
return await PipedSourcedTrack.fetchFromTrack(track: track);
|
||||
} on VideoUnplayableException catch (_) {
|
||||
|
242
lib/services/sourced_track/sources/kugou.dart
Normal file
242
lib/services/sourced_track/sources/kugou.dart
Normal file
@ -0,0 +1,242 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:get/get.dart' hide Value;
|
||||
import 'package:rhythm_box/providers/database.dart';
|
||||
import 'package:rhythm_box/services/database/database.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/enums.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/exceptions.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/models/source_info.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/models/source_map.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||
|
||||
class KugouSourceInfo extends SourceInfo {
|
||||
KugouSourceInfo({
|
||||
required super.id,
|
||||
required super.title,
|
||||
required super.artist,
|
||||
required super.thumbnail,
|
||||
required super.pageUrl,
|
||||
required super.duration,
|
||||
required super.artistUrl,
|
||||
required super.album,
|
||||
});
|
||||
}
|
||||
|
||||
class KugouSourcedTrack extends SourcedTrack {
|
||||
KugouSourcedTrack({
|
||||
required super.source,
|
||||
required super.siblings,
|
||||
required super.sourceInfo,
|
||||
required super.track,
|
||||
});
|
||||
|
||||
static String unescapeUrl(String src) {
|
||||
return src.replaceAll('\\/', '/');
|
||||
}
|
||||
|
||||
static String getBaseUrl() {
|
||||
return 'http://mobilecdn.kugou.com';
|
||||
}
|
||||
|
||||
static GetConnect getClient() {
|
||||
final client = GetConnect(
|
||||
withCredentials: true,
|
||||
timeout: const Duration(seconds: 30),
|
||||
);
|
||||
client.baseUrl = getBaseUrl();
|
||||
return client;
|
||||
}
|
||||
|
||||
static Future<SourcedTrack> fetchFromTrack({
|
||||
required Track track,
|
||||
}) async {
|
||||
final DatabaseProvider db = Get.find();
|
||||
final cachedSource = await (db.database.select(db.database.sourceMatchTable)
|
||||
..where((s) => s.trackId.equals(track.id!))
|
||||
..limit(1)
|
||||
..orderBy([
|
||||
(s) =>
|
||||
OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc),
|
||||
]))
|
||||
.get()
|
||||
.then((s) => s.firstOrNull);
|
||||
|
||||
if (cachedSource == null || cachedSource.sourceType != SourceType.kugou) {
|
||||
final siblings = await fetchSiblings(track: track);
|
||||
if (siblings.isEmpty) {
|
||||
throw TrackNotFoundError(track);
|
||||
}
|
||||
|
||||
await db.database.into(db.database.sourceMatchTable).insert(
|
||||
SourceMatchTableCompanion.insert(
|
||||
trackId: track.id!,
|
||||
sourceId: siblings.first.info.id,
|
||||
sourceType: const Value(SourceType.kugou),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
|
||||
return KugouSourcedTrack(
|
||||
siblings: siblings.map((s) => s.info).skip(1).toList(),
|
||||
source: siblings.first.source as SourceMap,
|
||||
sourceInfo: siblings.first.info,
|
||||
track: track,
|
||||
);
|
||||
} else if (cachedSource.sourceType != SourceType.kugou) {
|
||||
final out =
|
||||
await SourcedTrack.reRoutineFetchFromTrack(track, cachedSource);
|
||||
if (out == null) throw TrackNotFoundError(track);
|
||||
return out;
|
||||
}
|
||||
|
||||
return KugouSourcedTrack(
|
||||
siblings: [],
|
||||
source: toSourceMap(cachedSource),
|
||||
sourceInfo: KugouSourceInfo(
|
||||
id: cachedSource.sourceId,
|
||||
artist: 'unknown',
|
||||
artistUrl: '#',
|
||||
pageUrl: '#',
|
||||
thumbnail: '#',
|
||||
title: 'unknown',
|
||||
duration: Duration.zero,
|
||||
album: 'unknown',
|
||||
),
|
||||
track: track,
|
||||
);
|
||||
}
|
||||
|
||||
static SourceMap toSourceMap(dynamic manifest) {
|
||||
const baseUrl = 'http://trackercdn.kugou.com/i/v2';
|
||||
|
||||
final hash = manifest is SourceMatchTableData
|
||||
? manifest.sourceId
|
||||
: manifest is KugouSourceInfo
|
||||
? manifest.id
|
||||
: manifest?['hash'];
|
||||
final key = md5.convert(utf8.encode('${hash}kgcloudv2')).toString();
|
||||
final url =
|
||||
'$baseUrl/song/url?key=$key&hash=$hash&appid=1005&pid=2&cmd=25&behavior=play';
|
||||
|
||||
return SourceMap(
|
||||
m4a: SourceQualityMap(
|
||||
high: url,
|
||||
medium: url,
|
||||
low: url,
|
||||
),
|
||||
weba: SourceQualityMap(
|
||||
high: url,
|
||||
medium: url,
|
||||
low: url,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<List<SiblingType>> fetchSiblings({
|
||||
required Track track,
|
||||
}) async {
|
||||
final query = SourcedTrack.getSearchTerm(track);
|
||||
|
||||
final client = getClient();
|
||||
final resp = await client.get(
|
||||
'/api/v3/search/song?keyword=${Uri.encodeComponent(query)}&page=1&pagesize=10',
|
||||
);
|
||||
final results = jsonDecode(resp.body)['data']['info'];
|
||||
|
||||
// We can just trust kugou music for now
|
||||
// If we need to check is the result correct, refer to this code
|
||||
// https://github.com/KRTirtho/spotube/blob/9b024120601c0d381edeab4460cb22f87149d0f8/lib/services/sourced_track/sources/jiosaavn.dart#L129
|
||||
final matchedResults =
|
||||
results.where((x) => x['pay_type'] == 0).map(toSiblingType).toList();
|
||||
|
||||
return matchedResults.cast<SiblingType>();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<KugouSourcedTrack> copyWithSibling() async {
|
||||
if (siblings.isNotEmpty) {
|
||||
return this;
|
||||
}
|
||||
final fetchedSiblings = await fetchSiblings(track: this);
|
||||
|
||||
return KugouSourcedTrack(
|
||||
siblings: fetchedSiblings
|
||||
.where((s) => s.info.id != sourceInfo.id)
|
||||
.map((s) => s.info)
|
||||
.toList(),
|
||||
source: source,
|
||||
sourceInfo: sourceInfo,
|
||||
track: this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
|
||||
if (sibling is! KugouSourceInfo) {
|
||||
return reRoutineSwapSiblings(sibling);
|
||||
}
|
||||
|
||||
if (sibling.id == sourceInfo.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// a sibling source that was fetched from the search results
|
||||
final isStepSibling = siblings.none((s) => s.id == sibling.id);
|
||||
|
||||
final newSourceInfo = isStepSibling
|
||||
? sibling
|
||||
: siblings.firstWhere((s) => s.id == sibling.id);
|
||||
final newSiblings = siblings.where((s) => s.id != sibling.id).toList()
|
||||
..insert(0, sourceInfo);
|
||||
|
||||
final info = newSourceInfo as KugouSourceInfo;
|
||||
final source = toSourceMap(newSourceInfo);
|
||||
|
||||
final db = Get.find<DatabaseProvider>();
|
||||
await db.database.into(db.database.sourceMatchTable).insert(
|
||||
SourceMatchTableCompanion.insert(
|
||||
trackId: id!,
|
||||
sourceId: info.id,
|
||||
sourceType: const Value(SourceType.kugou),
|
||||
// Because we're sorting by createdAt in the query
|
||||
// we have to update it to indicate priority
|
||||
createdAt: Value(DateTime.now()),
|
||||
),
|
||||
mode: InsertMode.replace,
|
||||
);
|
||||
|
||||
return KugouSourcedTrack(
|
||||
siblings: newSiblings,
|
||||
source: source,
|
||||
sourceInfo: info,
|
||||
track: this,
|
||||
);
|
||||
}
|
||||
|
||||
static KugouSourceInfo toSourceInfo(dynamic item) {
|
||||
return KugouSourceInfo(
|
||||
id: item['hash'],
|
||||
artist: item['singername'],
|
||||
artistUrl: '#',
|
||||
pageUrl: '#',
|
||||
thumbnail: unescapeUrl(item['trans_param']['union_cover'])
|
||||
.replaceFirst('/{size}', ''),
|
||||
title: item['songname'],
|
||||
duration: Duration(seconds: item['duration']),
|
||||
album: item['album_name'],
|
||||
);
|
||||
}
|
||||
|
||||
static SiblingType toSiblingType(dynamic item) {
|
||||
final SiblingType sibling = (
|
||||
info: toSourceInfo(item),
|
||||
source: toSourceMap(item),
|
||||
);
|
||||
|
||||
return sibling;
|
||||
}
|
||||
}
|
283
lib/services/sourced_track/sources/netease.dart
Executable file
283
lib/services/sourced_track/sources/netease.dart
Executable file
@ -0,0 +1,283 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:get/get.dart' hide Value;
|
||||
import 'package:get/get_connect/http/src/request/request.dart';
|
||||
import 'package:rhythm_box/providers/auth.dart';
|
||||
import 'package:rhythm_box/providers/database.dart';
|
||||
import 'package:rhythm_box/providers/user_preferences.dart';
|
||||
import 'package:rhythm_box/services/database/database.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/enums.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/exceptions.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/models/source_info.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/models/source_map.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||
|
||||
class NeteaseSourceInfo extends SourceInfo {
|
||||
NeteaseSourceInfo({
|
||||
required super.id,
|
||||
required super.title,
|
||||
required super.artist,
|
||||
required super.thumbnail,
|
||||
required super.pageUrl,
|
||||
required super.duration,
|
||||
required super.artistUrl,
|
||||
required super.album,
|
||||
});
|
||||
}
|
||||
|
||||
class NeteaseSourcedTrack extends SourcedTrack {
|
||||
NeteaseSourcedTrack({
|
||||
required super.source,
|
||||
required super.siblings,
|
||||
required super.sourceInfo,
|
||||
required super.track,
|
||||
});
|
||||
|
||||
static String getBaseUrl() {
|
||||
final preferences = Get.find<UserPreferencesProvider>().state.value;
|
||||
return preferences.neteaseApiInstance;
|
||||
}
|
||||
|
||||
static GetConnect getClient() {
|
||||
final client = GetConnect(
|
||||
withCredentials: true,
|
||||
timeout: const Duration(seconds: 30),
|
||||
);
|
||||
client.baseUrl = getBaseUrl();
|
||||
client.httpClient.addRequestModifier((Request request) async {
|
||||
final AuthenticationProvider auth = Get.find();
|
||||
if (auth.auth.value?.neteaseCookie != null) {
|
||||
final cookie =
|
||||
'MUSIC_U=${auth.auth.value!.getNeteaseCookie('MUSIC_U')}';
|
||||
if (request.headers['Cookie'] == null) {
|
||||
request.headers['Cookie'] = cookie;
|
||||
} else {
|
||||
request.headers['Cookie'] = request.headers['Cookie']! + cookie;
|
||||
}
|
||||
}
|
||||
|
||||
return request;
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
static String? _lookedUpRealIp;
|
||||
|
||||
static Future<String> lookupRealIp() async {
|
||||
if (_lookedUpRealIp != null) return _lookedUpRealIp!;
|
||||
const ipCheckUrl = 'https://api.ipify.org';
|
||||
final client = GetConnect(timeout: const Duration(seconds: 30));
|
||||
final resp = await client.get(ipCheckUrl);
|
||||
_lookedUpRealIp = resp.body;
|
||||
return _lookedUpRealIp!;
|
||||
}
|
||||
|
||||
static Future<SourcedTrack> fetchFromTrack({
|
||||
required Track track,
|
||||
}) async {
|
||||
final DatabaseProvider db = Get.find();
|
||||
final cachedSource = await (db.database.select(db.database.sourceMatchTable)
|
||||
..where((s) => s.trackId.equals(track.id!))
|
||||
..limit(1)
|
||||
..orderBy([
|
||||
(s) =>
|
||||
OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc),
|
||||
]))
|
||||
.get()
|
||||
.then((s) => s.firstOrNull);
|
||||
|
||||
if (cachedSource == null || cachedSource.sourceType != SourceType.netease) {
|
||||
final siblings = await fetchSiblings(track: track);
|
||||
if (siblings.isEmpty) {
|
||||
throw TrackNotFoundError(track);
|
||||
}
|
||||
|
||||
final client = getClient();
|
||||
final checkResp = await client.get(
|
||||
'/check/music?id=${siblings.first.info.id}&realIP=${await lookupRealIp()}',
|
||||
);
|
||||
if (checkResp.body['success'] != true) throw TrackNotFoundError(track);
|
||||
|
||||
await db.database.into(db.database.sourceMatchTable).insert(
|
||||
SourceMatchTableCompanion.insert(
|
||||
trackId: track.id!,
|
||||
sourceId: siblings.first.info.id,
|
||||
sourceType: const Value(SourceType.netease),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
|
||||
return NeteaseSourcedTrack(
|
||||
siblings: siblings.map((s) => s.info).skip(1).toList(),
|
||||
source: siblings.first.source as SourceMap,
|
||||
sourceInfo: siblings.first.info,
|
||||
track: track,
|
||||
);
|
||||
} else if (cachedSource.sourceType != SourceType.netease) {
|
||||
final out =
|
||||
await SourcedTrack.reRoutineFetchFromTrack(track, cachedSource);
|
||||
if (out == null) throw TrackNotFoundError(track);
|
||||
return out;
|
||||
}
|
||||
|
||||
final client = getClient();
|
||||
final resp = await client.get('/song/detail?ids=${cachedSource.sourceId}');
|
||||
if (resp.body?['songs'] == null) throw TrackNotFoundError(track);
|
||||
final item = (resp.body['songs'] as List<dynamic>).firstOrNull;
|
||||
|
||||
if (item == null) throw TrackNotFoundError(track);
|
||||
|
||||
final checkResp = await client.get(
|
||||
'/check/music?id=${item['id']}&realIP=${await lookupRealIp()}',
|
||||
);
|
||||
if (checkResp.body['success'] != true) throw TrackNotFoundError(track);
|
||||
|
||||
return NeteaseSourcedTrack(
|
||||
siblings: [],
|
||||
source: toSourceMap(item),
|
||||
sourceInfo: NeteaseSourceInfo(
|
||||
id: item['id'].toString(),
|
||||
artist: item['ar'].map((x) => x['name']).join(','),
|
||||
artistUrl: 'https://music.163.com/#/artist?id=${item['ar'][0]['id']}',
|
||||
pageUrl: 'https://music.163.com/#/song?id=${item['id']}',
|
||||
thumbnail: item['al']['picUrl'],
|
||||
title: item['name'],
|
||||
duration: Duration(milliseconds: item['dt']),
|
||||
album: item['al']['name'],
|
||||
),
|
||||
track: track,
|
||||
);
|
||||
}
|
||||
|
||||
static SourceMap toSourceMap(dynamic manifest) {
|
||||
final baseUrl = getBaseUrl();
|
||||
|
||||
// Due to netease may provide m4a, mp3 and others, we cannot decide this so mock this data.
|
||||
return SourceMap(
|
||||
m4a: SourceQualityMap(
|
||||
high: '$baseUrl/song/url?id=${manifest['id']}',
|
||||
medium: '$baseUrl/song/url?id=${manifest['id']}&br=192000',
|
||||
low: '$baseUrl/song/url?id=${manifest['id']}&br=128000',
|
||||
),
|
||||
weba: SourceQualityMap(
|
||||
high: '$baseUrl/song/url?id=${manifest['id']}',
|
||||
medium: '$baseUrl/song/url?id=${manifest['id']}&br=192000',
|
||||
low: '$baseUrl/song/url?id=${manifest['id']}&br=128000',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<List<SiblingType>> fetchSiblings({
|
||||
required Track track,
|
||||
}) async {
|
||||
final query = SourcedTrack.getSearchTerm(track);
|
||||
|
||||
final client = getClient();
|
||||
final resp = await client.get(
|
||||
'/search?keywords=${Uri.encodeComponent(query)}&realIP=${await NeteaseSourcedTrack.lookupRealIp()}',
|
||||
);
|
||||
if (resp.body?['code'] == 405) throw TrackNotFoundError(track);
|
||||
final results = resp.body['result']['songs'];
|
||||
|
||||
// We can just trust netease music for now
|
||||
// If we need to check is the result correct, refer to this code
|
||||
// https://github.com/KRTirtho/spotube/blob/9b024120601c0d381edeab4460cb22f87149d0f8/lib/services/sourced_track/sources/jiosaavn.dart#L129
|
||||
final matchedResults = results.map(toSiblingType).toList();
|
||||
|
||||
return matchedResults.cast<SiblingType>();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<NeteaseSourcedTrack> copyWithSibling() async {
|
||||
if (siblings.isNotEmpty) {
|
||||
return this;
|
||||
}
|
||||
final fetchedSiblings = await fetchSiblings(track: this);
|
||||
|
||||
return NeteaseSourcedTrack(
|
||||
siblings: fetchedSiblings
|
||||
.where((s) => s.info.id != sourceInfo.id)
|
||||
.map((s) => s.info)
|
||||
.toList(),
|
||||
source: source,
|
||||
sourceInfo: sourceInfo,
|
||||
track: this,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
|
||||
if (sibling is! NeteaseSourceInfo) {
|
||||
return reRoutineSwapSiblings(sibling);
|
||||
}
|
||||
|
||||
if (sibling.id == sourceInfo.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// a sibling source that was fetched from the search results
|
||||
final isStepSibling = siblings.none((s) => s.id == sibling.id);
|
||||
|
||||
final newSourceInfo = isStepSibling
|
||||
? sibling
|
||||
: siblings.firstWhere((s) => s.id == sibling.id);
|
||||
final newSiblings = siblings.where((s) => s.id != sibling.id).toList()
|
||||
..insert(0, sourceInfo);
|
||||
|
||||
final client = getClient();
|
||||
final resp = await client.get('/song/detail?ids=${newSourceInfo.id}');
|
||||
final item = (resp.body['songs'] as List<dynamic>).first;
|
||||
|
||||
final (:info, :source) = toSiblingType(item);
|
||||
|
||||
final db = Get.find<DatabaseProvider>();
|
||||
await db.database.into(db.database.sourceMatchTable).insert(
|
||||
SourceMatchTableCompanion.insert(
|
||||
trackId: id!,
|
||||
sourceId: info.id,
|
||||
sourceType: const Value(SourceType.netease),
|
||||
// Because we're sorting by createdAt in the query
|
||||
// we have to update it to indicate priority
|
||||
createdAt: Value(DateTime.now()),
|
||||
),
|
||||
mode: InsertMode.replace,
|
||||
);
|
||||
|
||||
return NeteaseSourcedTrack(
|
||||
siblings: newSiblings,
|
||||
source: source!,
|
||||
sourceInfo: info,
|
||||
track: this,
|
||||
);
|
||||
}
|
||||
|
||||
static NeteaseSourceInfo toSourceInfo(dynamic item) {
|
||||
final firstArtist = item['ar'] != null ? item['ar'][0] : item['artists'][0];
|
||||
|
||||
return NeteaseSourceInfo(
|
||||
id: item['id'].toString(),
|
||||
artist: item['ar'] != null
|
||||
? item['ar'].map((x) => x['name']).join(',')
|
||||
: item['artists'].map((x) => x['name']).toString(),
|
||||
artistUrl: 'https://music.163.com/#/artist?id=${firstArtist['id']}',
|
||||
pageUrl: 'https://music.163.com/#/song?id=${item['id']}',
|
||||
thumbnail: item['al']?['picUrl'] ??
|
||||
'https://p1.music.126.net/6y-UleORITEDbvrOLV0Q8A==/5639395138885805.jpg',
|
||||
title: item['name'],
|
||||
duration: item['dt'] != null
|
||||
? Duration(milliseconds: item['dt'])
|
||||
: Duration(milliseconds: item['duration']),
|
||||
album: item['al']?['name'],
|
||||
);
|
||||
}
|
||||
|
||||
static SiblingType toSiblingType(dynamic item) {
|
||||
final SiblingType sibling = (
|
||||
info: toSourceInfo(item),
|
||||
source: toSourceMap(item),
|
||||
);
|
||||
|
||||
return sibling;
|
||||
}
|
||||
}
|
@ -57,6 +57,14 @@ class PipedSourcedTrack extends SourcedTrack {
|
||||
|
||||
final preferences = Get.find<UserPreferencesProvider>().state.value;
|
||||
|
||||
if (cachedSource?.sourceType != SourceType.youtube &&
|
||||
cachedSource?.sourceType != SourceType.youtubeMusic) {
|
||||
final out =
|
||||
await SourcedTrack.reRoutineFetchFromTrack(track, cachedSource!);
|
||||
if (out == null) throw TrackNotFoundError(track);
|
||||
return out;
|
||||
}
|
||||
|
||||
if (cachedSource == null) {
|
||||
final siblings = await fetchSiblings(track: track);
|
||||
if (siblings.isEmpty) {
|
||||
@ -73,6 +81,7 @@ class PipedSourcedTrack extends SourcedTrack {
|
||||
: SourceType.youtubeMusic,
|
||||
),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
|
||||
return PipedSourcedTrack(
|
||||
@ -255,6 +264,10 @@ class PipedSourcedTrack extends SourcedTrack {
|
||||
|
||||
@override
|
||||
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
|
||||
if (sibling is! PipedSourceInfo) {
|
||||
return reRoutineSwapSiblings(sibling);
|
||||
}
|
||||
|
||||
if (sibling.id == sourceInfo.id) {
|
||||
return null;
|
||||
}
|
||||
|
@ -1,10 +1,9 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:get/get.dart' hide Value;
|
||||
import 'package:http/http.dart';
|
||||
import 'package:rhythm_box/providers/database.dart';
|
||||
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||
import 'package:rhythm_box/services/database/database.dart';
|
||||
import 'package:rhythm_box/services/utils.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
@ -44,7 +43,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
required super.track,
|
||||
});
|
||||
|
||||
static Future<YoutubeSourcedTrack> fetchFromTrack({
|
||||
static Future<SourcedTrack> fetchFromTrack({
|
||||
required Track track,
|
||||
}) async {
|
||||
final DatabaseProvider db = Get.find();
|
||||
@ -70,6 +69,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
sourceId: siblings.first.info.id,
|
||||
sourceType: const Value(SourceType.youtube),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
|
||||
return YoutubeSourcedTrack(
|
||||
@ -78,6 +78,11 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
sourceInfo: siblings.first.info,
|
||||
track: track,
|
||||
);
|
||||
} else if (cachedSource.sourceType != SourceType.youtube) {
|
||||
final out =
|
||||
await SourcedTrack.reRoutineFetchFromTrack(track, cachedSource);
|
||||
if (out == null) throw TrackNotFoundError(track);
|
||||
return out;
|
||||
}
|
||||
|
||||
final item = await youtubeClient.videos.get(cachedSource.sourceId);
|
||||
@ -86,7 +91,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
cachedSource.sourceId,
|
||||
)
|
||||
.timeout(
|
||||
const Duration(seconds: 5),
|
||||
const Duration(seconds: 30),
|
||||
onTimeout: () => throw ClientException('Timeout'),
|
||||
);
|
||||
return YoutubeSourcedTrack(
|
||||
@ -141,7 +146,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
if (index == 0) {
|
||||
final manifest =
|
||||
await youtubeClient.videos.streamsClient.getManifest(item.id).timeout(
|
||||
const Duration(seconds: 5),
|
||||
const Duration(seconds: 30),
|
||||
onTimeout: () => throw ClientException('Timeout'),
|
||||
);
|
||||
sourceMap = toSourceMap(manifest);
|
||||
@ -242,7 +247,8 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
];
|
||||
} on VideoUnplayableException catch (e) {
|
||||
// Ignore this error and continue with the search
|
||||
log('[Source][YoutubeMusic] Unable to search data: $e');
|
||||
Get.find<ErrorNotifier>().logError(
|
||||
'[Source][YoutubeMusic] Unable to play stream on youtube: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@ -250,7 +256,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
|
||||
final searchResults = await youtubeClient.search.search(
|
||||
query,
|
||||
filter: const SearchFilter('CAMSAhAB'),
|
||||
filter: TypeFilters.video,
|
||||
);
|
||||
|
||||
if (ServiceUtils.onlyContainsEnglish(query)) {
|
||||
@ -268,7 +274,11 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<YoutubeSourcedTrack?> swapWithSibling(SourceInfo sibling) async {
|
||||
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
|
||||
if (sibling is! YoutubeSourceInfo) {
|
||||
return reRoutineSwapSiblings(sibling);
|
||||
}
|
||||
|
||||
if (sibling.id == sourceInfo.id) {
|
||||
return null;
|
||||
}
|
||||
@ -285,7 +295,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
|
||||
final manifest = await youtubeClient.videos.streamsClient
|
||||
.getManifest(newSourceInfo.id)
|
||||
.timeout(
|
||||
const Duration(seconds: 5),
|
||||
const Duration(seconds: 30),
|
||||
onTimeout: () => throw ClientException('Timeout'),
|
||||
);
|
||||
|
||||
|
@ -39,11 +39,12 @@ class WindowManagerTools with WidgetsBindingObserver {
|
||||
WidgetsBinding.instance.addObserver(instance);
|
||||
|
||||
await windowManager.waitUntilReadyToShow(
|
||||
const WindowOptions(
|
||||
WindowOptions(
|
||||
title: 'RhythmBox',
|
||||
backgroundColor: Colors.transparent,
|
||||
minimumSize: Size(300, 700),
|
||||
titleBarStyle: TitleBarStyle.hidden,
|
||||
minimumSize: const Size(300, 700),
|
||||
titleBarStyle:
|
||||
PlatformInfo.isMacOS ? TitleBarStyle.hidden : TitleBarStyle.normal,
|
||||
center: true,
|
||||
),
|
||||
() async {
|
||||
|
@ -1,12 +1,43 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:rhythm_box/platform.dart';
|
||||
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
|
||||
class SystemShell extends StatelessWidget {
|
||||
class SystemShell extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const SystemShell({super.key, required this.child});
|
||||
|
||||
@override
|
||||
State<SystemShell> createState() => _SystemShellState();
|
||||
}
|
||||
|
||||
class _SystemShellState extends State<SystemShell> {
|
||||
late final ErrorNotifier _errorNotifier = Get.find();
|
||||
|
||||
StreamSubscription? _subscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_subscription = _errorNotifier.showing.listen((value) {
|
||||
if (value == null) {
|
||||
ScaffoldMessenger.of(context).clearMaterialBanners();
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showMaterialBanner(value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_subscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (PlatformInfo.isMacOS) {
|
||||
@ -21,12 +52,12 @@ class SystemShell extends StatelessWidget {
|
||||
thickness: 0.3,
|
||||
height: 0.3,
|
||||
),
|
||||
Expanded(child: child),
|
||||
Expanded(child: widget.child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return child;
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
@ -5,8 +5,10 @@ import 'package:rhythm_box/platform.dart';
|
||||
class AutoCacheImage extends StatelessWidget {
|
||||
final String url;
|
||||
final double? width, height;
|
||||
final BoxFit? fit;
|
||||
|
||||
const AutoCacheImage(this.url, {super.key, this.width, this.height});
|
||||
const AutoCacheImage(this.url,
|
||||
{super.key, this.width, this.height, this.fit});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -15,12 +17,14 @@ class AutoCacheImage extends StatelessWidget {
|
||||
imageUrl: url,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
);
|
||||
}
|
||||
return Image.network(
|
||||
url,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -50,25 +50,27 @@ class _SyncedLyricsState extends State<SyncedLyrics> {
|
||||
Theme.of(context).colorScheme.onSurface.withOpacity(0.5);
|
||||
|
||||
void _syncLyricsProgress() {
|
||||
for (var idx = 0; idx < _lyric!.lyrics.length; idx++) {
|
||||
final lyricSlice = _lyric!.lyrics[idx];
|
||||
final lyricNextSlice =
|
||||
idx + 1 < _lyric!.lyrics.length ? _lyric!.lyrics[idx + 1] : null;
|
||||
final isActive = _playback.durationCurrent.value.inSeconds >=
|
||||
lyricSlice.time.inSeconds &&
|
||||
(lyricNextSlice == null ||
|
||||
lyricNextSlice.time.inSeconds >
|
||||
_playback.durationCurrent.value.inSeconds);
|
||||
if (isActive) {
|
||||
_autoScrollController.scrollToIndex(
|
||||
idx,
|
||||
preferPosition: AutoScrollPosition.middle,
|
||||
);
|
||||
return;
|
||||
if (_isLyricSynced) {
|
||||
for (var idx = 0; idx < _lyric!.lyrics.length; idx++) {
|
||||
final lyricSlice = _lyric!.lyrics[idx];
|
||||
final lyricNextSlice =
|
||||
idx + 1 < _lyric!.lyrics.length ? _lyric!.lyrics[idx + 1] : null;
|
||||
final isActive = _playback.durationCurrent.value.inSeconds >=
|
||||
lyricSlice.time.inSeconds &&
|
||||
(lyricNextSlice == null ||
|
||||
lyricNextSlice.time.inSeconds >
|
||||
_playback.durationCurrent.value.inSeconds);
|
||||
if (isActive) {
|
||||
_autoScrollController.scrollToIndex(
|
||||
idx,
|
||||
preferPosition: AutoScrollPosition.middle,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_lyric!.lyrics.isNotEmpty) {
|
||||
if (_lyric!.lyrics.isNotEmpty || !_isLyricSynced) {
|
||||
_autoScrollController.scrollToIndex(
|
||||
0,
|
||||
preferPosition: AutoScrollPosition.begin,
|
||||
@ -120,6 +122,18 @@ class _SyncedLyricsState extends State<SyncedLyrics> {
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
if (_lyric != null && _lyric!.lyrics.isNotEmpty && !_isLyricSynced)
|
||||
SliverToBoxAdapter(
|
||||
child: Text(
|
||||
'Lyrics isn\'t synced',
|
||||
textAlign: MediaQuery.of(context).size.width >= 720
|
||||
? TextAlign.center
|
||||
: TextAlign.left,
|
||||
).paddingSymmetric(
|
||||
horizontal: 24,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
if (_lyric != null && _lyric!.lyrics.isNotEmpty)
|
||||
SliverList.builder(
|
||||
itemCount: _lyric!.lyrics.length,
|
||||
@ -132,7 +146,8 @@ class _SyncedLyricsState extends State<SyncedLyrics> {
|
||||
lyricSlice.time.inSeconds &&
|
||||
(lyricNextSlice == null ||
|
||||
lyricNextSlice.time.inSeconds >
|
||||
_playback.durationCurrent.value.inSeconds);
|
||||
_playback.durationCurrent.value.inSeconds) &&
|
||||
_isLyricSynced;
|
||||
|
||||
if (_playback.durationCurrent.value.inSeconds ==
|
||||
lyricSlice.time.inSeconds &&
|
||||
@ -142,6 +157,7 @@ class _SyncedLyricsState extends State<SyncedLyrics> {
|
||||
preferPosition: AutoScrollPosition.middle,
|
||||
);
|
||||
}
|
||||
|
||||
return AutoScrollTag(
|
||||
key: ValueKey(idx),
|
||||
index: idx,
|
||||
@ -215,6 +231,7 @@ class _SyncedLyricsState extends State<SyncedLyrics> {
|
||||
children: [
|
||||
Text(
|
||||
'Lyrics Not Found',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
const Text(
|
||||
|
@ -46,6 +46,8 @@ class _BottomPlayerState extends State<BottomPlayer>
|
||||
late final AudioPlayerProvider _playback = Get.find();
|
||||
late final QueryingTrackInfoProvider _query = Get.find();
|
||||
|
||||
bool get _isFetchingActiveTrack => _query.isQueryingTrackInfo.value;
|
||||
|
||||
String? get _albumArt =>
|
||||
(_playback.state.value.activeTrack?.album?.images).asUrlString(
|
||||
index:
|
||||
@ -102,19 +104,18 @@ class _BottomPlayerState extends State<BottomPlayer>
|
||||
behavior: HitTestBehavior.translucent,
|
||||
child: Column(
|
||||
children: [
|
||||
if (_playback.durationCurrent.value != Duration.zero)
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(
|
||||
begin: 0,
|
||||
end: _playback.durationCurrent.value.inMilliseconds /
|
||||
max(_playback.durationTotal.value.inMilliseconds, 1),
|
||||
),
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
minHeight: 3,
|
||||
value: value,
|
||||
),
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(
|
||||
begin: 0,
|
||||
end: _playback.durationCurrent.value.inMilliseconds /
|
||||
max(_playback.durationTotal.value.inMilliseconds, 1),
|
||||
),
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
builder: (context, value, _) => LinearProgressIndicator(
|
||||
minHeight: 3,
|
||||
value: _isFetchingActiveTrack ? null : value,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
|
@ -1,10 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:rhythm_box/providers/audio_player.dart';
|
||||
import 'package:rhythm_box/providers/error_notifier.dart';
|
||||
import 'package:rhythm_box/providers/user_preferences.dart';
|
||||
import 'package:rhythm_box/services/database/database.dart';
|
||||
import 'package:rhythm_box/services/duration.dart';
|
||||
@ -12,6 +14,8 @@ import 'package:rhythm_box/services/server/active_sourced_track.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/models/source_info.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/models/video_info.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sources/kugou.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sources/netease.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sources/piped.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sources/youtube.dart';
|
||||
import 'package:rhythm_box/services/artist.dart';
|
||||
@ -42,6 +46,8 @@ class _SiblingTracksState extends State<SiblingTracks> {
|
||||
final sourceInfoToLabelMap = {
|
||||
YoutubeSourceInfo: 'YouTube',
|
||||
PipedSourceInfo: 'Piped',
|
||||
NeteaseSourceInfo: 'Netease',
|
||||
KugouSourceInfo: 'Kugou',
|
||||
};
|
||||
|
||||
List<StreamSubscription>? _subscriptions;
|
||||
@ -87,29 +93,75 @@ class _SiblingTracksState extends State<SiblingTracks> {
|
||||
final preferences = Get.find<UserPreferencesProvider>().state.value;
|
||||
final searchTerm = _searchTermController.text.trim();
|
||||
|
||||
if (preferences.audioSource == AudioSource.youtube ||
|
||||
preferences.audioSource == AudioSource.piped) {
|
||||
final resultsYt = await youtubeClient.search.search(searchTerm.trim());
|
||||
try {
|
||||
if (preferences.audioSource == AudioSource.youtube ||
|
||||
preferences.audioSource == AudioSource.piped) {
|
||||
final resultsYt = await youtubeClient.search.search(searchTerm.trim());
|
||||
|
||||
final searchResults = await Future.wait(
|
||||
resultsYt.map(YoutubeVideoInfo.fromVideo).mapIndexed((i, video) async {
|
||||
final siblingType = await YoutubeSourcedTrack.toSiblingType(i, video);
|
||||
return siblingType.info;
|
||||
}),
|
||||
);
|
||||
final activeSourceInfo = (_activeTrack! as SourcedTrack).sourceInfo;
|
||||
_siblings = List.from(
|
||||
searchResults
|
||||
..removeWhere((element) => element.id == activeSourceInfo.id)
|
||||
..insert(
|
||||
0,
|
||||
activeSourceInfo,
|
||||
),
|
||||
growable: true,
|
||||
);
|
||||
final searchResults = await Future.wait(
|
||||
resultsYt
|
||||
.map(YoutubeVideoInfo.fromVideo)
|
||||
.mapIndexed((i, video) async {
|
||||
final siblingType =
|
||||
await YoutubeSourcedTrack.toSiblingType(i, video);
|
||||
return siblingType.info;
|
||||
}),
|
||||
);
|
||||
final activeSourceInfo = (_activeTrack! as SourcedTrack).sourceInfo;
|
||||
_siblings = List.from(
|
||||
searchResults
|
||||
..removeWhere((element) => element.id == activeSourceInfo.id)
|
||||
..insert(
|
||||
0,
|
||||
activeSourceInfo,
|
||||
),
|
||||
growable: true,
|
||||
);
|
||||
} else if (preferences.audioSource == AudioSource.netease) {
|
||||
final client = NeteaseSourcedTrack.getClient();
|
||||
final resp = await client.get(
|
||||
'/search?keywords=${Uri.encodeComponent(searchTerm)}&realIP=${await NeteaseSourcedTrack.lookupRealIp()}');
|
||||
final searchResults = resp.body['result']['songs']
|
||||
.map(NeteaseSourcedTrack.toSourceInfo)
|
||||
.toList();
|
||||
|
||||
final activeSourceInfo = (_activeTrack! as SourcedTrack).sourceInfo;
|
||||
_siblings = List.from(
|
||||
searchResults
|
||||
..removeWhere((element) => element.id == activeSourceInfo.id)
|
||||
..insert(
|
||||
0,
|
||||
activeSourceInfo,
|
||||
),
|
||||
growable: true,
|
||||
);
|
||||
} else if (preferences.audioSource == AudioSource.kugou) {
|
||||
final client = KugouSourcedTrack.getClient();
|
||||
final resp = await client.get(
|
||||
'/api/v3/search/song?keyword=${Uri.encodeComponent(searchTerm)}&page=1&pagesize=10',
|
||||
);
|
||||
final results = jsonDecode(resp.body)['data']['info'];
|
||||
final searchResults = results
|
||||
.where((x) => x['pay_type'] == 0)
|
||||
.map(KugouSourcedTrack.toSourceInfo)
|
||||
.toList();
|
||||
|
||||
final activeSourceInfo = (_activeTrack! as SourcedTrack).sourceInfo;
|
||||
_siblings = List.from(
|
||||
searchResults
|
||||
..removeWhere((element) => element.id == activeSourceInfo.id)
|
||||
..insert(
|
||||
0,
|
||||
activeSourceInfo,
|
||||
),
|
||||
growable: true,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
Get.find<ErrorNotifier>().showError(err.toString());
|
||||
} finally {
|
||||
setState(() => _isSearching = false);
|
||||
}
|
||||
|
||||
setState(() => _isSearching = false);
|
||||
}
|
||||
|
||||
@override
|
||||
|
72
lib/widgets/player/track_source_details.dart
Normal file
72
lib/widgets/player/track_source_details.dart
Normal file
@ -0,0 +1,72 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:rhythm_box/services/duration.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sources/kugou.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sources/netease.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sources/piped.dart';
|
||||
import 'package:rhythm_box/services/sourced_track/sources/youtube.dart';
|
||||
|
||||
class TrackSourceDetails extends StatelessWidget {
|
||||
final SourcedTrack track;
|
||||
|
||||
const TrackSourceDetails({super.key, required this.track});
|
||||
|
||||
static final sourceInfoToLabelMap = {
|
||||
YoutubeSourceInfo: 'YouTube',
|
||||
PipedSourceInfo: 'Piped',
|
||||
NeteaseSourceInfo: 'Netease',
|
||||
KugouSourceInfo: 'Kugou',
|
||||
};
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final detailsMap = {
|
||||
'Title': track.name!,
|
||||
'Artist': track.artists?.map((x) => x.name).join(', '),
|
||||
'Album': track.album!.name!,
|
||||
'Duration': track.sourceInfo.duration.toHumanReadableString(),
|
||||
if (track.album!.releaseDate != null)
|
||||
'Released': track.album!.releaseDate,
|
||||
'Popularity': track.popularity?.toString() ?? '0',
|
||||
'Provider': sourceInfoToLabelMap[track.sourceInfo.runtimeType],
|
||||
};
|
||||
|
||||
return Table(
|
||||
columnWidths: const {
|
||||
0: FixedColumnWidth(95),
|
||||
1: FixedColumnWidth(10),
|
||||
2: FlexColumnWidth(1),
|
||||
},
|
||||
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
|
||||
children: [
|
||||
for (final entry in detailsMap.entries)
|
||||
TableRow(
|
||||
children: [
|
||||
TableCell(
|
||||
verticalAlignment: TableCellVerticalAlignment.top,
|
||||
child: Text(
|
||||
entry.key,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
const TableCell(
|
||||
verticalAlignment: TableCellVerticalAlignment.top,
|
||||
child: Text(':'),
|
||||
),
|
||||
if (entry.value is Widget)
|
||||
entry.value as Widget
|
||||
else if (entry.value is String)
|
||||
Text(
|
||||
entry.value as String,
|
||||
style: theme.textTheme.bodyMedium,
|
||||
)
|
||||
else
|
||||
const Text('Unknown'),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -19,6 +19,7 @@ import shared_preferences_foundation
|
||||
import sqflite
|
||||
import sqlite3_flutter_libs
|
||||
import url_launcher_macos
|
||||
import wakelock_plus
|
||||
import window_manager
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
@ -36,5 +37,6 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||
Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin"))
|
||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||
WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
|
||||
WindowManagerPlugin.register(with: registry.registrar(forPlugin: "WindowManagerPlugin"))
|
||||
}
|
||||
|
@ -53,6 +53,8 @@ PODS:
|
||||
- sqlite3/rtree
|
||||
- url_launcher_macos (0.0.1):
|
||||
- FlutterMacOS
|
||||
- wakelock_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- window_manager (0.2.0):
|
||||
- FlutterMacOS
|
||||
|
||||
@ -74,6 +76,7 @@ DEPENDENCIES:
|
||||
- sqflite (from `Flutter/ephemeral/.symlinks/plugins/sqflite/darwin`)
|
||||
- sqlite3_flutter_libs (from `Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos`)
|
||||
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
|
||||
- wakelock_plus (from `Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos`)
|
||||
- window_manager (from `Flutter/ephemeral/.symlinks/plugins/window_manager/macos`)
|
||||
|
||||
SPEC REPOS:
|
||||
@ -116,6 +119,8 @@ EXTERNAL SOURCES:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/sqlite3_flutter_libs/macos
|
||||
url_launcher_macos:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
|
||||
wakelock_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/wakelock_plus/macos
|
||||
window_manager:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
|
||||
|
||||
@ -139,6 +144,7 @@ SPEC CHECKSUMS:
|
||||
sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb
|
||||
sqlite3_flutter_libs: 5ca46c1a04eddfbeeb5b16566164aa7ad1616e7b
|
||||
url_launcher_macos: 5f437abeda8c85500ceb03f5c1938a8c5a705399
|
||||
wakelock_plus: 4783562c9a43d209c458cb9b30692134af456269
|
||||
window_manager: 3a1844359a6295ab1e47659b1a777e36773cd6e8
|
||||
|
||||
PODFILE CHECKSUM: 236401fc2c932af29a9fcf0e97baeeb2d750d367
|
||||
|
@ -269,7 +269,6 @@
|
||||
33CC10EC2044A3C60003C045 = {
|
||||
CreatedOnToolsVersion = 9.2;
|
||||
LastSwiftMigration = 1100;
|
||||
ProvisioningStyle = Automatic;
|
||||
SystemCapabilities = {
|
||||
com.apple.Sandbox = {
|
||||
enabled = 1;
|
||||
@ -477,6 +476,7 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@ -492,6 +492,7 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@ -507,6 +508,7 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox.RunnerTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@ -571,7 +573,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||
@ -582,6 +584,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
@ -591,6 +594,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
};
|
||||
name = Profile;
|
||||
@ -707,7 +711,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements;
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||
@ -718,6 +722,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 5.0;
|
||||
@ -731,7 +736,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements;
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
DEVELOPMENT_TEAM = W7HPZ53V6B;
|
||||
@ -742,6 +747,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
@ -751,6 +757,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Manual;
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
};
|
||||
name = Debug;
|
||||
@ -759,6 +766,7 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
};
|
||||
name = Release;
|
||||
|
@ -15,7 +15,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "rhythm_box.app"
|
||||
BuildableName = "GroovyBox.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
@ -31,7 +31,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "rhythm_box.app"
|
||||
BuildableName = "GroovyBox.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
@ -65,7 +65,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "rhythm_box.app"
|
||||
BuildableName = "GroovyBox.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
@ -82,7 +82,7 @@
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "33CC10EC2044A3C60003C045"
|
||||
BuildableName = "rhythm_box.app"
|
||||
BuildableName = "GroovyBox.app"
|
||||
BlueprintName = "Runner"
|
||||
ReferencedContainer = "container:Runner.xcodeproj">
|
||||
</BuildableReference>
|
||||
|
@ -5,7 +5,7 @@
|
||||
// 'flutter create' template.
|
||||
|
||||
// The application's name. By default this is also the title of the Flutter window.
|
||||
PRODUCT_NAME = rhythm_box
|
||||
PRODUCT_NAME = GroovyBox
|
||||
|
||||
// The application's bundle identifier
|
||||
PRODUCT_BUNDLE_IDENTIFIER = dev.solsynth.rhythmBox
|
||||
|
@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
|
@ -28,6 +28,8 @@
|
||||
<string>MainMenu</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.music</string>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>NSPrincipalClass</key>
|
||||
<string>NSApplication</string>
|
||||
</dict>
|
||||
|
@ -2,6 +2,8 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.developer.aps-environment</key>
|
||||
<string>development</string>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
|
40
pubspec.lock
40
pubspec.lock
@ -310,6 +310,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.6"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.10"
|
||||
desktop_webview_window:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -339,10 +347,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: "0dfb6b6a1979dac1c1245e17cef824d7b452ea29bd33d3467269f9bef3715fb0"
|
||||
sha256: "5598aa796bbf4699afd5c67c0f5f6e2ed542afc956884b9cd58c306966efc260"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.6.0"
|
||||
version: "5.7.0"
|
||||
dio_web_adapter:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -375,6 +383,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.19.1"
|
||||
dropdown_button2:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dropdown_button2
|
||||
sha256: b0fe8d49a030315e9eef6c7ac84ca964250155a6224d491c1365061bc974a9e1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.9"
|
||||
duration:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1506,10 +1522,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: uuid
|
||||
sha256: "83d37c7ad7aaf9aa8e275490669535c8080377cfa7a7004c24dfac53afffaa90"
|
||||
sha256: f33d6bb662f0e4f79dcd7ada2e6170f3b3a2530c28fc41f49a411ddedd576a77
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.4.2"
|
||||
version: "4.5.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1526,6 +1542,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.2.5"
|
||||
wakelock_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: wakelock_plus
|
||||
sha256: bf4ee6f17a2fa373ed3753ad0e602b7603f8c75af006d5b9bdade263928c0484
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.8"
|
||||
wakelock_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wakelock_plus_platform_interface
|
||||
sha256: "422d1cdbb448079a8a62a5a770b69baa489f8f7ca21aef47800c726d404f9d16"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
watcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
12
pubspec.yaml
12
pubspec.yaml
@ -1,8 +1,8 @@
|
||||
name: rhythm_box
|
||||
description: "A new Flutter project."
|
||||
description: Yet another Spotify third-party client.
|
||||
# The following line prevents the package from being accidentally published to
|
||||
# pub.dev using `flutter pub publish`. This is preferred for private packages.
|
||||
publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
||||
|
||||
# The following defines the version and build number for your application.
|
||||
# A version number is three numbers separated by dots, like 1.2.43
|
||||
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+3
|
||||
version: 1.0.0+18
|
||||
|
||||
environment:
|
||||
sdk: ^3.5.0
|
||||
@ -31,7 +31,6 @@ dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
|
||||
# The following adds the Cupertino Icons font to your application.
|
||||
# Use with the CupertinoIcons class for iOS style icons.
|
||||
cupertino_icons: ^1.0.8
|
||||
@ -101,6 +100,8 @@ dependencies:
|
||||
flutter_inappwebview: ^6.0.0
|
||||
timezone: ^0.9.4
|
||||
url_launcher: ^6.3.0
|
||||
wakelock_plus: ^1.2.8
|
||||
dropdown_button2: ^2.3.9
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
@ -124,7 +125,6 @@ dev_dependencies:
|
||||
|
||||
# The following section is specific to Flutter packages.
|
||||
flutter:
|
||||
|
||||
# The following line ensures that the Material Icons font is
|
||||
# included with your application, so that you can use the icons in
|
||||
# the material Icons class.
|
||||
@ -179,4 +179,4 @@ flutter_launcher_icons:
|
||||
flutter_native_splash:
|
||||
color: "#fef8f5"
|
||||
color_dark: "#18120d"
|
||||
image: assets/icon-w-shadow.png
|
||||
image: assets/icon-w-shadow.png
|
||||
|
@ -29,6 +29,9 @@
|
||||
|
||||
<title>RhythmBox</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
|
||||
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
|
||||
<style id="splash-screen-style">
|
||||
html {
|
||||
height: 100%
|
||||
@ -100,7 +103,6 @@
|
||||
document.body.style.background = "transparent";
|
||||
}
|
||||
</script>
|
||||
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
|
||||
</head>
|
||||
<body>
|
||||
<picture id="splash">
|
||||
@ -108,6 +110,7 @@
|
||||
<source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x" media="(prefers-color-scheme: dark)">
|
||||
<img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt="">
|
||||
</picture>
|
||||
|
||||
<script src="flutter_bootstrap.js" async=""></script>
|
||||
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user