Compare commits

...

22 Commits

Author SHA1 Message Date
a6b40e81a7 🐛 Bug fixes on looking up ips 2024-09-11 20:37:42 +08:00
1913a7e909 🐛 Bug fixes on cross source track fetching 2024-09-11 20:18:03 +08:00
d860936010 🚀 Launch 1.0.0+17 2024-09-10 23:45:50 +08:00
873ad1cf8c 🐛 Fix cross source swap siblings issue 2024-09-10 23:06:15 +08:00
e0c9edad78 💄 Downgrade kugou source 2024-09-08 22:24:10 +08:00
70ea02962f 📝 Update README.md 2024-09-08 01:27:44 +08:00
59783c48f7 🐛 Fix & optimize kugou audio source
🐛 Fix fallback source switch causing error
2024-09-08 01:20:46 +08:00
b099f63f61 🐛 Fix settings page issue 2024-09-07 19:57:26 +08:00
b69bee7e59 🚀 Launch 1.0.0+13 2024-09-07 18:58:33 +08:00
3d23152802 💄 Auto dismiss error 2024-09-07 18:54:01 +08:00
90dc3f43a7 Allow player keep original cache provider
 Support chain fallback
2024-09-07 18:49:05 +08:00
3df93e47d2 🚀 Launch 1.2.1+12 2024-09-06 22:44:39 +08:00
6d2a027d9b Kugou music source 2024-09-06 22:19:26 +08:00
222d50d80d 🐛 Bug fixes of querying backend 2024-09-06 18:10:12 +08:00
499bca5b1c Better netease music check 2024-09-06 16:37:49 +08:00
252e4619f7 🐛 Fix player view layout issue 2024-09-06 16:22:39 +08:00
463cb9870f 🐛 Fixes 2024-09-06 13:26:20 +08:00
2cee7ee958 🐛 Search siblings in netease won't show duration 2024-09-06 12:55:10 +08:00
e30e7a5c24 🐛 Fix source track details 2024-09-06 12:52:57 +08:00
6509cd2511 Netease cloud music login 2024-09-06 12:41:35 +08:00
6cdc025c40 🐛 Will throw track not found when has no privilege to play on netease cloud music 2024-09-05 21:53:42 +08:00
de3ad4b21e 🐛 Bug fixes 2024-09-05 13:14:02 +08:00
28 changed files with 1443 additions and 412 deletions

@ -5,19 +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). 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. 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 ## Roadmap
- [x] Playing music - [x] Playing music
- [x] Add netease music as source - [x] Add netease music as source
- [ ] Add bilibili as source - [ ] Add bilibili as source
- [ ] Add kuwo music as source - [ ] Add kuwo music as source
- [ ] Add kugo music as source - [x] Add kugou music as source
- [x] Optimize fallback strategy
- [x] Re-design user interface - [x] Re-design user interface
- [x] Simplified UI and UX - [x] Simplified UI and UX
- [x] Support for large screen device - [x] Support for large screen device
## License ## 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 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,59 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>ITSAppUsesNonExemptEncryption</key>
<true/> <false/>
<key>CFBundleDevelopmentRegion</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <true/>
<key>CFBundleDisplayName</key> <key>CFBundleDevelopmentRegion</key>
<string>Groovy Box</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key> <key>CFBundleDisplayName</key>
<string>$(EXECUTABLE_NAME)</string> <string>Groovy Box</string>
<key>CFBundleIdentifier</key> <key>CFBundleExecutable</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleIdentifier</key>
<string>6.0</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleName</key> <key>CFBundleInfoDictionaryVersion</key>
<string>Groovy Box</string> <string>6.0</string>
<key>CFBundlePackageType</key> <key>CFBundleName</key>
<string>APPL</string> <string>Groovy Box</string>
<key>CFBundleShortVersionString</key> <key>CFBundlePackageType</key>
<string>$(FLUTTER_BUILD_NAME)</string> <string>APPL</string>
<key>CFBundleSignature</key> <key>CFBundleShortVersionString</key>
<string>????</string> <string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key> <key>CFBundleSignature</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>????</string>
<key>LSApplicationCategoryType</key> <key>CFBundleVersion</key>
<string>public.app-category.music</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key> <key>LSApplicationCategoryType</key>
<true/> <string>public.app-category.music</string>
<key>NSMicrophoneUsageDescription</key> <key>LSRequiresIPhoneOS</key>
<string>To provide information for RhythmBox to normalize the output audio</string> <true/>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>NSMicrophoneUsageDescription</key>
<true/> <string>To provide information for RhythmBox to normalize the output audio</string>
<key>UIBackgroundModes</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<array> <true/>
<string>audio</string> <key>UIBackgroundModes</key>
</array> <array>
<key>UILaunchStoryboardName</key> <string>audio</string>
<string>LaunchScreen</string> </array>
<key>UIMainStoryboardFile</key> <key>UILaunchStoryboardName</key>
<string>Main</string> <string>LaunchScreen</string>
<key>UIStatusBarHidden</key> <key>UIMainStoryboardFile</key>
<false/> <string>Main</string>
<key>UISupportedInterfaceOrientations</key> <key>UIStatusBarHidden</key>
<array> <false/>
<string>UIInterfaceOrientationPortrait</string> <key>UISupportedInterfaceOrientations</key>
<string>UIInterfaceOrientationLandscapeLeft</string> <array>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationPortrait</string>
</array> <string>UIInterfaceOrientationLandscapeLeft</string>
<key>UISupportedInterfaceOrientations~ipad</key> <string>UIInterfaceOrientationLandscapeRight</string>
<array> </array>
<string>UIInterfaceOrientationPortrait</string> <key>UISupportedInterfaceOrientations~ipad</key>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <array>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationPortraitUpsideDown</string>
</array> <string>UIInterfaceOrientationLandscapeLeft</string>
</dict> <string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist> </plist>

@ -9,15 +9,23 @@ import 'package:rhythm_box/providers/database.dart';
import 'package:rhythm_box/services/database/database.dart'; import 'package:rhythm_box/services/database/database.dart';
extension ExpirationAuthenticationTableData on AuthenticationTableData { 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('; ') .split('; ')
.firstWhereOrNull((c) => c.trim().startsWith('$key=')) .firstWhereOrNull((c) => c.trim().startsWith('$key='))
?.trim() ?.trim()
.split('=') .split('=')
.last .last
.replaceAll(';', ''); .replaceAll(';', '');
String? getNeteaseCookie(String key) => neteaseCookie?.value
.split(';')
.firstWhereOrNull((c) => c.trim().startsWith('$key='))
?.trim()
.split('=')
.last
.replaceAll(';', '');
} }
class AuthenticationProvider extends GetxController { class AuthenticationProvider extends GetxController {
@ -55,18 +63,18 @@ class AuthenticationProvider extends GetxController {
void _setRefreshTimer() { void _setRefreshTimer() {
refreshTimer?.cancel(); refreshTimer?.cancel();
if (auth.value != null && auth.value!.isExpired) { if (auth.value != null && auth.value!.isExpired) {
refreshCredentials(); refreshSpotifyCredentials();
} }
refreshTimer = Timer( refreshTimer = Timer(
auth.value!.expiration.difference(DateTime.now()), auth.value!.spotifyExpiration.difference(DateTime.now()),
() => refreshCredentials(), () => refreshSpotifyCredentials(),
); );
} }
Future<void> refreshCredentials() async { Future<void> refreshSpotifyCredentials() async {
final database = Get.find<DatabaseProvider>().database; final database = Get.find<DatabaseProvider>().database;
final refreshedCredentials = final refreshedCredentials =
await credentialsFromCookie(auth.value!.cookie.value); await credentialsFromCookie(auth.value!.spotifyCookie.value);
await database await database
.update(database.authenticationTable) .update(database.authenticationTable)
@ -112,9 +120,10 @@ class AuthenticationProvider extends GetxController {
return AuthenticationTableCompanion.insert( return AuthenticationTableCompanion.insert(
id: const Value(0), id: const Value(0),
cookie: DecryptedText("${res.headers["set-cookie"]?.join(";")}; $spDc"), spotifyCookie:
accessToken: DecryptedText(body['accessToken']), DecryptedText("${res.headers["set-cookie"]?.join(";")}; $spDc"),
expiration: DateTime.fromMillisecondsSinceEpoch( spotifyAccessToken: DecryptedText(body['accessToken']),
spotifyExpiration: DateTime.fromMillisecondsSinceEpoch(
body['accessTokenExpirationTimestampMs']), body['accessTokenExpirationTimestampMs']),
); );
} catch (e) { } 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 { Future<void> logout() async {
auth.value = null; auth.value = null;
final database = Get.find<DatabaseProvider>().database; final database = Get.find<DatabaseProvider>().database;
@ -132,6 +156,21 @@ class AuthenticationProvider extends GetxController {
// Additional cleanup if necessary // 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 @override
void onClose() { void onClose() {
refreshTimer?.cancel(); refreshTimer?.cancel();

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -6,12 +7,16 @@ import 'package:get/get.dart';
class ErrorNotifier extends GetxController { class ErrorNotifier extends GetxController {
Rx<MaterialBanner?> showing = Rx(null); Rx<MaterialBanner?> showing = Rx(null);
Timer? _autoDismissTimer;
void logError(String msg, {StackTrace? trace}) { void logError(String msg, {StackTrace? trace}) {
log('$msg${trace != null ? '\nTrace:\n$trace' : ''}'); log('$msg${trace != null ? '\nTrace:\n$trace' : ''}');
showError(msg); showError(msg);
} }
void showError(String msg) { void showError(String msg) {
_autoDismissTimer?.cancel();
showing.value = MaterialBanner( showing.value = MaterialBanner(
dividerColor: Colors.transparent, dividerColor: Colors.transparent,
leading: const Icon(Icons.error), leading: const Icon(Icons.error),
@ -34,5 +39,9 @@ class ErrorNotifier extends GetxController {
), ),
], ],
); );
_autoDismissTimer = Timer(const Duration(seconds: 3), () {
showing.value = null;
});
} }
} }

@ -44,7 +44,7 @@ class SpotifyProvider extends GetxController {
log('[SpotifyApi] Using user credentials...'); log('[SpotifyApi] Using user credentials...');
final AuthenticationProvider authenticate = Get.find(); final AuthenticationProvider authenticate = Get.find();
return SpotifyApi.withAccessToken( return SpotifyApi.withAccessToken(
authenticate.auth.value!.accessToken.value); authenticate.auth.value!.spotifyAccessToken.value);
} }
@override @override

@ -178,4 +178,8 @@ class UserPreferencesProvider extends GetxController {
setData(PreferencesTableCompanion(playerWakelock: Value(wakelock))); setData(PreferencesTableCompanion(playerWakelock: Value(wakelock)));
WakelockPlus.toggle(enable: 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:go_router/go_router.dart';
import 'package:rhythm_box/screens/about.dart'; import 'package:rhythm_box/screens/about.dart';
import 'package:rhythm_box/screens/album/view.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/auth/mobile_login.dart';
import 'package:rhythm_box/screens/explore.dart'; import 'package:rhythm_box/screens/explore.dart';
import 'package:rhythm_box/screens/library/view.dart'; import 'package:rhythm_box/screens/library/view.dart';
@ -104,6 +105,11 @@ final router = GoRouter(routes: [
name: 'authMobileLogin', name: 'authMobileLogin',
builder: (context, state) => const MobileLogin(), builder: (context, state) => const MobileLogin(),
), ),
GoRoute(
path: '/auth/netease/login',
name: 'authMobileLoginNetease',
builder: (context, state) => const LoginNeteaseScreen(),
),
], ],
), ),
]); ]);

@ -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&timestamp=${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&timestamp=${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'),
)
],
),
),
);
}
}

@ -72,8 +72,8 @@ class _ExploreScreenState extends State<ExploreScreen> {
} }
if (_auth.auth.value != null) { if (_auth.auth.value != null) {
final customEndpoint = final customEndpoint = CustomSpotifyEndpoints(
CustomSpotifyEndpoints(_auth.auth.value?.accessToken.value ?? ''); _auth.auth.value?.spotifyAccessToken.value ?? '');
final forYouView = await customEndpoint.getView( final forYouView = await customEndpoint.getView(
'made-for-x-hub', 'made-for-x-hub',
market: market, market: market,

@ -1,15 +1,19 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:rhythm_box/services/server/active_sourced_track.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'; import 'package:rhythm_box/widgets/player/track_source_details.dart';
class SourceDetailsPopup extends StatelessWidget { class SourceDetailsPopup extends StatelessWidget {
const SourceDetailsPopup({super.key}); const SourceDetailsPopup({super.key});
Future<SourcedTrack?> _pullActiveTrack() async {
final ActiveSourcedTrackProvider activeSourcedTrack = Get.find();
return activeSourcedTrack.state.value;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final ActiveSourcedTrackProvider activeTrack = Get.find();
return SizedBox( return SizedBox(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -20,8 +24,19 @@ class SourceDetailsPopup extends StatelessWidget {
).paddingOnly(left: 24, right: 24, top: 32, bottom: 16), ).paddingOnly(left: 24, right: 24, top: 32, bottom: 16),
Expanded( Expanded(
child: Obx( child: Obx(
() => TrackSourceDetails( () => FutureBuilder(
track: activeTrack.state.value!, future: _pullActiveTrack(),
builder: (context, snapshot) {
if (snapshot.hasData) {
return TrackSourceDetails(
track: snapshot.data!,
);
}
return const Center(
child: CircularProgressIndicator(),
);
},
).paddingSymmetric(horizontal: 24), ).paddingSymmetric(horizontal: 24),
), ),
) )

@ -82,34 +82,38 @@ class _PlayerScreenState extends State<PlayerScreen> {
padding: const EdgeInsets.symmetric(vertical: 24), padding: const EdgeInsets.symmetric(vertical: 24),
children: [ children: [
Obx( Obx(
() => LimitedBox( () => Center(
maxHeight: maxAlbumSize, child: LimitedBox(
maxWidth: maxAlbumSize, maxHeight: maxAlbumSize,
child: Hero( maxWidth: maxAlbumSize,
tag: const Key('current-active-track-album-art'), child: Hero(
child: AspectRatio( tag: const Key('current-active-track-album-art'),
aspectRatio: 1, child: AspectRatio(
child: ClipRRect( aspectRatio: 1,
borderRadius: child: ClipRRect(
const BorderRadius.all(Radius.circular(16)), borderRadius: const BorderRadius.all(
child: _albumArt != null Radius.circular(16),
? AutoCacheImage( ),
_albumArt!, child: _albumArt != null
width: albumSize, ? AutoCacheImage(
height: albumSize, _albumArt!,
) width: albumSize,
: Container( height: albumSize,
color: Theme.of(context) fit: BoxFit.cover,
.colorScheme )
.surfaceContainerHigh, : Container(
width: 64, color: Theme.of(context)
height: 64, .colorScheme
child: const Center( .surfaceContainerHigh,
child: Icon(Icons.image), width: 64,
height: 64,
child: const Center(
child: Icon(Icons.image),
),
), ),
), ),
), ).marginSymmetric(horizontal: 24),
).marginSymmetric(horizontal: 24), ),
), ),
), ),
), ),
@ -309,86 +313,89 @@ class _PlayerScreenState extends State<PlayerScreen> {
], ],
), ),
const Gap(20), const Gap(20),
SizedBox( Center(
height: 40, child: SizedBox(
child: ListView( height: 40,
scrollDirection: Axis.horizontal, child: ListView(
children: [ scrollDirection: Axis.horizontal,
TextButton.icon( shrinkWrap: true,
icon: const Icon(Icons.queue_music), children: [
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( TextButton.icon(
icon: const Icon(Icons.lyrics), icon: const Icon(Icons.queue_music),
label: const Text( label: const Text(
'Lyrics', 'Queue',
maxLines: 1, maxLines: 1,
overflow: TextOverflow.fade, overflow: TextOverflow.fade,
), ),
onPressed: () { onPressed: () {
GoRouter.of(context) showModalBottomSheet(
.pushNamed('playerLyrics'); useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: (context) =>
const PlayerQueuePopup(),
).then((_) {
if (mounted) {
setState(() {});
}
});
}, },
), ),
const Gap(4), if (!isLargeScreen) const Gap(4),
TextButton.icon( if (!isLargeScreen)
icon: const Icon(Icons.merge), TextButton.icon(
label: const Text( icon: const Icon(Icons.lyrics),
'Sources', label: const Text(
maxLines: 1, 'Lyrics',
overflow: TextOverflow.fade, 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(() {});
}
});
},
), ),
onPressed: () { const Gap(4),
showModalBottomSheet( TextButton.icon(
useRootNavigator: true, label: const Text('Info'),
isScrollControlled: true, icon: const Icon(Icons.info),
context: context, onPressed: () {
builder: (context) => showModalBottomSheet(
const SiblingTracksPopup(), useRootNavigator: true,
).then((_) { context: context,
if (mounted) { builder: (context) =>
setState(() {}); const SourceDetailsPopup(),
} ).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(() {});
}
});
},
),
],
), ),
), ),
], ],

@ -7,6 +7,7 @@ import 'package:rhythm_box/providers/spotify.dart';
import 'package:rhythm_box/providers/user_preferences.dart'; import 'package:rhythm_box/providers/user_preferences.dart';
import 'package:rhythm_box/screens/auth/login.dart'; import 'package:rhythm_box/screens/auth/login.dart';
import 'package:rhythm_box/services/database/database.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/auto_cache_image.dart';
import 'package:rhythm_box/widgets/sized_container.dart'; import 'package:rhythm_box/widgets/sized_container.dart';
@ -86,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(() { Obx(() {
if (_authenticate.auth.value == null) { if (_authenticate.auth.value == null) {
return const SizedBox(); return const SizedBox();
@ -95,7 +165,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.logout), leading: const Icon(Icons.logout),
title: const Text('Log out'), 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), trailing: const Icon(Icons.chevron_right),
onTap: () async { onTap: () async {
_authenticate.logout(); _authenticate.logout();
@ -150,6 +220,44 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
), ),
const Divider(thickness: 0.3, height: 1), 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( Obx(
() => SwitchListTile( () => SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),

@ -65,7 +65,10 @@ class AppDatabase extends _$AppDatabase {
}, },
onUpgrade: (Migrator m, int from, int to) async { onUpgrade: (Migrator m, int from, int to) async {
if (from < 2) { if (from < 2) {
await m.addColumn(preferencesTable, preferencesTable.playerWakelock); await m.addColumn(
preferencesTable,
preferencesTable.overrideCacheProvider,
);
} }
}, },
); );

@ -18,29 +18,54 @@ class $AuthenticationTableTable extends AuthenticationTable
requiredDuringInsert: false, requiredDuringInsert: false,
defaultConstraints: defaultConstraints:
GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT')); GeneratedColumn.constraintIsAlways('PRIMARY KEY AUTOINCREMENT'));
static const VerificationMeta _cookieMeta = const VerificationMeta('cookie'); static const VerificationMeta _spotifyCookieMeta =
@override const VerificationMeta('spotifyCookie');
late final GeneratedColumnWithTypeConverter<DecryptedText, String> cookie =
GeneratedColumn<String>('cookie', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true)
.withConverter<DecryptedText>(
$AuthenticationTableTable.$convertercookie);
static const VerificationMeta _accessTokenMeta =
const VerificationMeta('accessToken');
@override @override
late final GeneratedColumnWithTypeConverter<DecryptedText, String> late final GeneratedColumnWithTypeConverter<DecryptedText, String>
accessToken = GeneratedColumn<String>('access_token', aliasedName, false, spotifyCookie = GeneratedColumn<String>(
'spotify_cookie', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true) type: DriftSqlType.string, requiredDuringInsert: true)
.withConverter<DecryptedText>( .withConverter<DecryptedText>(
$AuthenticationTableTable.$converteraccessToken); $AuthenticationTableTable.$converterspotifyCookie);
static const VerificationMeta _expirationMeta = static const VerificationMeta _spotifyAccessTokenMeta =
const VerificationMeta('expiration'); const VerificationMeta('spotifyAccessToken');
@override @override
late final GeneratedColumn<DateTime> expiration = GeneratedColumn<DateTime>( late final GeneratedColumnWithTypeConverter<DecryptedText, String>
'expiration', aliasedName, false, spotifyAccessToken = GeneratedColumn<String>(
type: DriftSqlType.dateTime, requiredDuringInsert: true); 'spotify_access_token', aliasedName, false,
type: DriftSqlType.string, requiredDuringInsert: true)
.withConverter<DecryptedText>(
$AuthenticationTableTable.$converterspotifyAccessToken);
static const VerificationMeta _spotifyExpirationMeta =
const VerificationMeta('spotifyExpiration');
@override @override
List<GeneratedColumn> get $columns => [id, cookie, accessToken, expiration]; late final GeneratedColumn<DateTime> spotifyExpiration =
GeneratedColumn<DateTime>('spotify_expiration', aliasedName, false,
type: DriftSqlType.dateTime, requiredDuringInsert: true);
static const VerificationMeta _neteaseCookieMeta =
const VerificationMeta('neteaseCookie');
@override
late final GeneratedColumnWithTypeConverter<DecryptedText?, String>
neteaseCookie = GeneratedColumn<String>(
'netease_cookie', aliasedName, true,
type: DriftSqlType.string, requiredDuringInsert: false)
.withConverter<DecryptedText?>(
$AuthenticationTableTable.$converterneteaseCookien);
static const VerificationMeta _neteaseExpirationMeta =
const VerificationMeta('neteaseExpiration');
@override
late final GeneratedColumn<DateTime> neteaseExpiration =
GeneratedColumn<DateTime>('netease_expiration', aliasedName, true,
type: DriftSqlType.dateTime, requiredDuringInsert: false);
@override
List<GeneratedColumn> get $columns => [
id,
spotifyCookie,
spotifyAccessToken,
spotifyExpiration,
neteaseCookie,
neteaseExpiration
];
@override @override
String get aliasedName => _alias ?? actualTableName; String get aliasedName => _alias ?? actualTableName;
@override @override
@ -55,15 +80,22 @@ class $AuthenticationTableTable extends AuthenticationTable
if (data.containsKey('id')) { if (data.containsKey('id')) {
context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta));
} }
context.handle(_cookieMeta, const VerificationResult.success()); context.handle(_spotifyCookieMeta, const VerificationResult.success());
context.handle(_accessTokenMeta, const VerificationResult.success()); context.handle(_spotifyAccessTokenMeta, const VerificationResult.success());
if (data.containsKey('expiration')) { if (data.containsKey('spotify_expiration')) {
context.handle( context.handle(
_expirationMeta, _spotifyExpirationMeta,
expiration.isAcceptableOrUnknown( spotifyExpiration.isAcceptableOrUnknown(
data['expiration']!, _expirationMeta)); data['spotify_expiration']!, _spotifyExpirationMeta));
} else if (isInserting) { } else if (isInserting) {
context.missing(_expirationMeta); context.missing(_spotifyExpirationMeta);
}
context.handle(_neteaseCookieMeta, const VerificationResult.success());
if (data.containsKey('netease_expiration')) {
context.handle(
_neteaseExpirationMeta,
neteaseExpiration.isAcceptableOrUnknown(
data['netease_expiration']!, _neteaseExpirationMeta));
} }
return context; return context;
} }
@ -77,14 +109,19 @@ class $AuthenticationTableTable extends AuthenticationTable
return AuthenticationTableData( return AuthenticationTableData(
id: attachedDatabase.typeMapping id: attachedDatabase.typeMapping
.read(DriftSqlType.int, data['${effectivePrefix}id'])!, .read(DriftSqlType.int, data['${effectivePrefix}id'])!,
cookie: $AuthenticationTableTable.$convertercookie.fromSql( spotifyCookie: $AuthenticationTableTable.$converterspotifyCookie.fromSql(
attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}cookie'])!),
accessToken: $AuthenticationTableTable.$converteraccessToken.fromSql(
attachedDatabase.typeMapping.read( attachedDatabase.typeMapping.read(
DriftSqlType.string, data['${effectivePrefix}access_token'])!), DriftSqlType.string, data['${effectivePrefix}spotify_cookie'])!),
expiration: attachedDatabase.typeMapping spotifyAccessToken: $AuthenticationTableTable.$converterspotifyAccessToken
.read(DriftSqlType.dateTime, data['${effectivePrefix}expiration'])!, .fromSql(attachedDatabase.typeMapping.read(DriftSqlType.string,
data['${effectivePrefix}spotify_access_token'])!),
spotifyExpiration: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime, data['${effectivePrefix}spotify_expiration'])!,
neteaseCookie: $AuthenticationTableTable.$converterneteaseCookien.fromSql(
attachedDatabase.typeMapping.read(
DriftSqlType.string, data['${effectivePrefix}netease_cookie'])),
neteaseExpiration: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime, data['${effectivePrefix}netease_expiration']),
); );
} }
@ -93,45 +130,69 @@ class $AuthenticationTableTable extends AuthenticationTable
return $AuthenticationTableTable(attachedDatabase, alias); return $AuthenticationTableTable(attachedDatabase, alias);
} }
static TypeConverter<DecryptedText, String> $convertercookie = static TypeConverter<DecryptedText, String> $converterspotifyCookie =
EncryptedTextConverter(); EncryptedTextConverter();
static TypeConverter<DecryptedText, String> $converteraccessToken = static TypeConverter<DecryptedText, String> $converterspotifyAccessToken =
EncryptedTextConverter(); EncryptedTextConverter();
static TypeConverter<DecryptedText, String> $converterneteaseCookie =
EncryptedTextConverter();
static TypeConverter<DecryptedText?, String?> $converterneteaseCookien =
NullAwareTypeConverter.wrap($converterneteaseCookie);
} }
class AuthenticationTableData extends DataClass class AuthenticationTableData extends DataClass
implements Insertable<AuthenticationTableData> { implements Insertable<AuthenticationTableData> {
final int id; final int id;
final DecryptedText cookie; final DecryptedText spotifyCookie;
final DecryptedText accessToken; final DecryptedText spotifyAccessToken;
final DateTime expiration; final DateTime spotifyExpiration;
final DecryptedText? neteaseCookie;
final DateTime? neteaseExpiration;
const AuthenticationTableData( const AuthenticationTableData(
{required this.id, {required this.id,
required this.cookie, required this.spotifyCookie,
required this.accessToken, required this.spotifyAccessToken,
required this.expiration}); required this.spotifyExpiration,
this.neteaseCookie,
this.neteaseExpiration});
@override @override
Map<String, Expression> toColumns(bool nullToAbsent) { Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{}; final map = <String, Expression>{};
map['id'] = Variable<int>(id); map['id'] = Variable<int>(id);
{ {
map['cookie'] = Variable<String>( map['spotify_cookie'] = Variable<String>($AuthenticationTableTable
$AuthenticationTableTable.$convertercookie.toSql(cookie)); .$converterspotifyCookie
.toSql(spotifyCookie));
} }
{ {
map['access_token'] = Variable<String>( map['spotify_access_token'] = Variable<String>($AuthenticationTableTable
$AuthenticationTableTable.$converteraccessToken.toSql(accessToken)); .$converterspotifyAccessToken
.toSql(spotifyAccessToken));
}
map['spotify_expiration'] = Variable<DateTime>(spotifyExpiration);
if (!nullToAbsent || neteaseCookie != null) {
map['netease_cookie'] = Variable<String>($AuthenticationTableTable
.$converterneteaseCookien
.toSql(neteaseCookie));
}
if (!nullToAbsent || neteaseExpiration != null) {
map['netease_expiration'] = Variable<DateTime>(neteaseExpiration);
} }
map['expiration'] = Variable<DateTime>(expiration);
return map; return map;
} }
AuthenticationTableCompanion toCompanion(bool nullToAbsent) { AuthenticationTableCompanion toCompanion(bool nullToAbsent) {
return AuthenticationTableCompanion( return AuthenticationTableCompanion(
id: Value(id), id: Value(id),
cookie: Value(cookie), spotifyCookie: Value(spotifyCookie),
accessToken: Value(accessToken), spotifyAccessToken: Value(spotifyAccessToken),
expiration: Value(expiration), spotifyExpiration: Value(spotifyExpiration),
neteaseCookie: neteaseCookie == null && nullToAbsent
? const Value.absent()
: Value(neteaseCookie),
neteaseExpiration: neteaseExpiration == null && nullToAbsent
? const Value.absent()
: Value(neteaseExpiration),
); );
} }
@ -140,9 +201,14 @@ class AuthenticationTableData extends DataClass
serializer ??= driftRuntimeOptions.defaultSerializer; serializer ??= driftRuntimeOptions.defaultSerializer;
return AuthenticationTableData( return AuthenticationTableData(
id: serializer.fromJson<int>(json['id']), id: serializer.fromJson<int>(json['id']),
cookie: serializer.fromJson<DecryptedText>(json['cookie']), spotifyCookie: serializer.fromJson<DecryptedText>(json['spotifyCookie']),
accessToken: serializer.fromJson<DecryptedText>(json['accessToken']), spotifyAccessToken:
expiration: serializer.fromJson<DateTime>(json['expiration']), serializer.fromJson<DecryptedText>(json['spotifyAccessToken']),
spotifyExpiration:
serializer.fromJson<DateTime>(json['spotifyExpiration']),
neteaseCookie: serializer.fromJson<DecryptedText?>(json['neteaseCookie']),
neteaseExpiration:
serializer.fromJson<DateTime?>(json['neteaseExpiration']),
); );
} }
@override @override
@ -150,31 +216,51 @@ class AuthenticationTableData extends DataClass
serializer ??= driftRuntimeOptions.defaultSerializer; serializer ??= driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{ return <String, dynamic>{
'id': serializer.toJson<int>(id), 'id': serializer.toJson<int>(id),
'cookie': serializer.toJson<DecryptedText>(cookie), 'spotifyCookie': serializer.toJson<DecryptedText>(spotifyCookie),
'accessToken': serializer.toJson<DecryptedText>(accessToken), 'spotifyAccessToken':
'expiration': serializer.toJson<DateTime>(expiration), serializer.toJson<DecryptedText>(spotifyAccessToken),
'spotifyExpiration': serializer.toJson<DateTime>(spotifyExpiration),
'neteaseCookie': serializer.toJson<DecryptedText?>(neteaseCookie),
'neteaseExpiration': serializer.toJson<DateTime?>(neteaseExpiration),
}; };
} }
AuthenticationTableData copyWith( AuthenticationTableData copyWith(
{int? id, {int? id,
DecryptedText? cookie, DecryptedText? spotifyCookie,
DecryptedText? accessToken, DecryptedText? spotifyAccessToken,
DateTime? expiration}) => DateTime? spotifyExpiration,
Value<DecryptedText?> neteaseCookie = const Value.absent(),
Value<DateTime?> neteaseExpiration = const Value.absent()}) =>
AuthenticationTableData( AuthenticationTableData(
id: id ?? this.id, id: id ?? this.id,
cookie: cookie ?? this.cookie, spotifyCookie: spotifyCookie ?? this.spotifyCookie,
accessToken: accessToken ?? this.accessToken, spotifyAccessToken: spotifyAccessToken ?? this.spotifyAccessToken,
expiration: expiration ?? this.expiration, spotifyExpiration: spotifyExpiration ?? this.spotifyExpiration,
neteaseCookie:
neteaseCookie.present ? neteaseCookie.value : this.neteaseCookie,
neteaseExpiration: neteaseExpiration.present
? neteaseExpiration.value
: this.neteaseExpiration,
); );
AuthenticationTableData copyWithCompanion(AuthenticationTableCompanion data) { AuthenticationTableData copyWithCompanion(AuthenticationTableCompanion data) {
return AuthenticationTableData( return AuthenticationTableData(
id: data.id.present ? data.id.value : this.id, id: data.id.present ? data.id.value : this.id,
cookie: data.cookie.present ? data.cookie.value : this.cookie, spotifyCookie: data.spotifyCookie.present
accessToken: ? data.spotifyCookie.value
data.accessToken.present ? data.accessToken.value : this.accessToken, : this.spotifyCookie,
expiration: spotifyAccessToken: data.spotifyAccessToken.present
data.expiration.present ? data.expiration.value : this.expiration, ? data.spotifyAccessToken.value
: this.spotifyAccessToken,
spotifyExpiration: data.spotifyExpiration.present
? data.spotifyExpiration.value
: this.spotifyExpiration,
neteaseCookie: data.neteaseCookie.present
? data.neteaseCookie.value
: this.neteaseCookie,
neteaseExpiration: data.neteaseExpiration.present
? data.neteaseExpiration.value
: this.neteaseExpiration,
); );
} }
@ -182,69 +268,89 @@ class AuthenticationTableData extends DataClass
String toString() { String toString() {
return (StringBuffer('AuthenticationTableData(') return (StringBuffer('AuthenticationTableData(')
..write('id: $id, ') ..write('id: $id, ')
..write('cookie: $cookie, ') ..write('spotifyCookie: $spotifyCookie, ')
..write('accessToken: $accessToken, ') ..write('spotifyAccessToken: $spotifyAccessToken, ')
..write('expiration: $expiration') ..write('spotifyExpiration: $spotifyExpiration, ')
..write('neteaseCookie: $neteaseCookie, ')
..write('neteaseExpiration: $neteaseExpiration')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@override @override
int get hashCode => Object.hash(id, cookie, accessToken, expiration); int get hashCode => Object.hash(id, spotifyCookie, spotifyAccessToken,
spotifyExpiration, neteaseCookie, neteaseExpiration);
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
identical(this, other) || identical(this, other) ||
(other is AuthenticationTableData && (other is AuthenticationTableData &&
other.id == this.id && other.id == this.id &&
other.cookie == this.cookie && other.spotifyCookie == this.spotifyCookie &&
other.accessToken == this.accessToken && other.spotifyAccessToken == this.spotifyAccessToken &&
other.expiration == this.expiration); other.spotifyExpiration == this.spotifyExpiration &&
other.neteaseCookie == this.neteaseCookie &&
other.neteaseExpiration == this.neteaseExpiration);
} }
class AuthenticationTableCompanion class AuthenticationTableCompanion
extends UpdateCompanion<AuthenticationTableData> { extends UpdateCompanion<AuthenticationTableData> {
final Value<int> id; final Value<int> id;
final Value<DecryptedText> cookie; final Value<DecryptedText> spotifyCookie;
final Value<DecryptedText> accessToken; final Value<DecryptedText> spotifyAccessToken;
final Value<DateTime> expiration; final Value<DateTime> spotifyExpiration;
final Value<DecryptedText?> neteaseCookie;
final Value<DateTime?> neteaseExpiration;
const AuthenticationTableCompanion({ const AuthenticationTableCompanion({
this.id = const Value.absent(), this.id = const Value.absent(),
this.cookie = const Value.absent(), this.spotifyCookie = const Value.absent(),
this.accessToken = const Value.absent(), this.spotifyAccessToken = const Value.absent(),
this.expiration = const Value.absent(), this.spotifyExpiration = const Value.absent(),
this.neteaseCookie = const Value.absent(),
this.neteaseExpiration = const Value.absent(),
}); });
AuthenticationTableCompanion.insert({ AuthenticationTableCompanion.insert({
this.id = const Value.absent(), this.id = const Value.absent(),
required DecryptedText cookie, required DecryptedText spotifyCookie,
required DecryptedText accessToken, required DecryptedText spotifyAccessToken,
required DateTime expiration, required DateTime spotifyExpiration,
}) : cookie = Value(cookie), this.neteaseCookie = const Value.absent(),
accessToken = Value(accessToken), this.neteaseExpiration = const Value.absent(),
expiration = Value(expiration); }) : spotifyCookie = Value(spotifyCookie),
spotifyAccessToken = Value(spotifyAccessToken),
spotifyExpiration = Value(spotifyExpiration);
static Insertable<AuthenticationTableData> custom({ static Insertable<AuthenticationTableData> custom({
Expression<int>? id, Expression<int>? id,
Expression<String>? cookie, Expression<String>? spotifyCookie,
Expression<String>? accessToken, Expression<String>? spotifyAccessToken,
Expression<DateTime>? expiration, Expression<DateTime>? spotifyExpiration,
Expression<String>? neteaseCookie,
Expression<DateTime>? neteaseExpiration,
}) { }) {
return RawValuesInsertable({ return RawValuesInsertable({
if (id != null) 'id': id, if (id != null) 'id': id,
if (cookie != null) 'cookie': cookie, if (spotifyCookie != null) 'spotify_cookie': spotifyCookie,
if (accessToken != null) 'access_token': accessToken, if (spotifyAccessToken != null)
if (expiration != null) 'expiration': expiration, 'spotify_access_token': spotifyAccessToken,
if (spotifyExpiration != null) 'spotify_expiration': spotifyExpiration,
if (neteaseCookie != null) 'netease_cookie': neteaseCookie,
if (neteaseExpiration != null) 'netease_expiration': neteaseExpiration,
}); });
} }
AuthenticationTableCompanion copyWith( AuthenticationTableCompanion copyWith(
{Value<int>? id, {Value<int>? id,
Value<DecryptedText>? cookie, Value<DecryptedText>? spotifyCookie,
Value<DecryptedText>? accessToken, Value<DecryptedText>? spotifyAccessToken,
Value<DateTime>? expiration}) { Value<DateTime>? spotifyExpiration,
Value<DecryptedText?>? neteaseCookie,
Value<DateTime?>? neteaseExpiration}) {
return AuthenticationTableCompanion( return AuthenticationTableCompanion(
id: id ?? this.id, id: id ?? this.id,
cookie: cookie ?? this.cookie, spotifyCookie: spotifyCookie ?? this.spotifyCookie,
accessToken: accessToken ?? this.accessToken, spotifyAccessToken: spotifyAccessToken ?? this.spotifyAccessToken,
expiration: expiration ?? this.expiration, spotifyExpiration: spotifyExpiration ?? this.spotifyExpiration,
neteaseCookie: neteaseCookie ?? this.neteaseCookie,
neteaseExpiration: neteaseExpiration ?? this.neteaseExpiration,
); );
} }
@ -254,17 +360,26 @@ class AuthenticationTableCompanion
if (id.present) { if (id.present) {
map['id'] = Variable<int>(id.value); map['id'] = Variable<int>(id.value);
} }
if (cookie.present) { if (spotifyCookie.present) {
map['cookie'] = Variable<String>( map['spotify_cookie'] = Variable<String>($AuthenticationTableTable
$AuthenticationTableTable.$convertercookie.toSql(cookie.value)); .$converterspotifyCookie
.toSql(spotifyCookie.value));
} }
if (accessToken.present) { if (spotifyAccessToken.present) {
map['access_token'] = Variable<String>($AuthenticationTableTable map['spotify_access_token'] = Variable<String>($AuthenticationTableTable
.$converteraccessToken .$converterspotifyAccessToken
.toSql(accessToken.value)); .toSql(spotifyAccessToken.value));
} }
if (expiration.present) { if (spotifyExpiration.present) {
map['expiration'] = Variable<DateTime>(expiration.value); map['spotify_expiration'] = Variable<DateTime>(spotifyExpiration.value);
}
if (neteaseCookie.present) {
map['netease_cookie'] = Variable<String>($AuthenticationTableTable
.$converterneteaseCookien
.toSql(neteaseCookie.value));
}
if (neteaseExpiration.present) {
map['netease_expiration'] = Variable<DateTime>(neteaseExpiration.value);
} }
return map; return map;
} }
@ -273,9 +388,11 @@ class AuthenticationTableCompanion
String toString() { String toString() {
return (StringBuffer('AuthenticationTableCompanion(') return (StringBuffer('AuthenticationTableCompanion(')
..write('id: $id, ') ..write('id: $id, ')
..write('cookie: $cookie, ') ..write('spotifyCookie: $spotifyCookie, ')
..write('accessToken: $accessToken, ') ..write('spotifyAccessToken: $spotifyAccessToken, ')
..write('expiration: $expiration') ..write('spotifyExpiration: $spotifyExpiration, ')
..write('neteaseCookie: $neteaseCookie, ')
..write('neteaseExpiration: $neteaseExpiration')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@ -523,6 +640,16 @@ class $PreferencesTableTable extends PreferencesTable
defaultConstraints: GeneratedColumn.constraintIsAlways( defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("player_wakelock" IN (0, 1))'), 'CHECK ("player_wakelock" IN (0, 1))'),
defaultValue: const Constant(true)); defaultValue: const Constant(true));
static const VerificationMeta _overrideCacheProviderMeta =
const VerificationMeta('overrideCacheProvider');
@override
late final GeneratedColumn<bool> overrideCacheProvider =
GeneratedColumn<bool>('override_cache_provider', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("override_cache_provider" IN (0, 1))'),
defaultValue: const Constant(true));
@override @override
List<GeneratedColumn> get $columns => [ List<GeneratedColumn> get $columns => [
id, id,
@ -548,7 +675,8 @@ class $PreferencesTableTable extends PreferencesTable
streamMusicCodec, streamMusicCodec,
downloadMusicCodec, downloadMusicCodec,
endlessPlayback, endlessPlayback,
playerWakelock playerWakelock,
overrideCacheProvider
]; ];
@override @override
String get aliasedName => _alias ?? actualTableName; String get aliasedName => _alias ?? actualTableName;
@ -643,6 +771,12 @@ class $PreferencesTableTable extends PreferencesTable
playerWakelock.isAcceptableOrUnknown( playerWakelock.isAcceptableOrUnknown(
data['player_wakelock']!, _playerWakelockMeta)); data['player_wakelock']!, _playerWakelockMeta));
} }
if (data.containsKey('override_cache_provider')) {
context.handle(
_overrideCacheProviderMeta,
overrideCacheProvider.isAcceptableOrUnknown(
data['override_cache_provider']!, _overrideCacheProviderMeta));
}
return context; return context;
} }
@ -713,6 +847,9 @@ class $PreferencesTableTable extends PreferencesTable
.read(DriftSqlType.bool, data['${effectivePrefix}endless_playback'])!, .read(DriftSqlType.bool, data['${effectivePrefix}endless_playback'])!,
playerWakelock: attachedDatabase.typeMapping playerWakelock: attachedDatabase.typeMapping
.read(DriftSqlType.bool, data['${effectivePrefix}player_wakelock'])!, .read(DriftSqlType.bool, data['${effectivePrefix}player_wakelock'])!,
overrideCacheProvider: attachedDatabase.typeMapping.read(
DriftSqlType.bool,
data['${effectivePrefix}override_cache_provider'])!,
); );
} }
@ -777,6 +914,7 @@ class PreferencesTableData extends DataClass
final SourceCodecs downloadMusicCodec; final SourceCodecs downloadMusicCodec;
final bool endlessPlayback; final bool endlessPlayback;
final bool playerWakelock; final bool playerWakelock;
final bool overrideCacheProvider;
const PreferencesTableData( const PreferencesTableData(
{required this.id, {required this.id,
required this.audioQuality, required this.audioQuality,
@ -801,7 +939,8 @@ class PreferencesTableData extends DataClass
required this.streamMusicCodec, required this.streamMusicCodec,
required this.downloadMusicCodec, required this.downloadMusicCodec,
required this.endlessPlayback, required this.endlessPlayback,
required this.playerWakelock}); required this.playerWakelock,
required this.overrideCacheProvider});
@override @override
Map<String, Expression> toColumns(bool nullToAbsent) { Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{}; final map = <String, Expression>{};
@ -869,6 +1008,7 @@ class PreferencesTableData extends DataClass
} }
map['endless_playback'] = Variable<bool>(endlessPlayback); map['endless_playback'] = Variable<bool>(endlessPlayback);
map['player_wakelock'] = Variable<bool>(playerWakelock); map['player_wakelock'] = Variable<bool>(playerWakelock);
map['override_cache_provider'] = Variable<bool>(overrideCacheProvider);
return map; return map;
} }
@ -898,6 +1038,7 @@ class PreferencesTableData extends DataClass
downloadMusicCodec: Value(downloadMusicCodec), downloadMusicCodec: Value(downloadMusicCodec),
endlessPlayback: Value(endlessPlayback), endlessPlayback: Value(endlessPlayback),
playerWakelock: Value(playerWakelock), playerWakelock: Value(playerWakelock),
overrideCacheProvider: Value(overrideCacheProvider),
); );
} }
@ -941,6 +1082,8 @@ class PreferencesTableData extends DataClass
.fromJson(serializer.fromJson<String>(json['downloadMusicCodec'])), .fromJson(serializer.fromJson<String>(json['downloadMusicCodec'])),
endlessPlayback: serializer.fromJson<bool>(json['endlessPlayback']), endlessPlayback: serializer.fromJson<bool>(json['endlessPlayback']),
playerWakelock: serializer.fromJson<bool>(json['playerWakelock']), playerWakelock: serializer.fromJson<bool>(json['playerWakelock']),
overrideCacheProvider:
serializer.fromJson<bool>(json['overrideCacheProvider']),
); );
} }
@override @override
@ -983,6 +1126,7 @@ class PreferencesTableData extends DataClass
.toJson(downloadMusicCodec)), .toJson(downloadMusicCodec)),
'endlessPlayback': serializer.toJson<bool>(endlessPlayback), 'endlessPlayback': serializer.toJson<bool>(endlessPlayback),
'playerWakelock': serializer.toJson<bool>(playerWakelock), 'playerWakelock': serializer.toJson<bool>(playerWakelock),
'overrideCacheProvider': serializer.toJson<bool>(overrideCacheProvider),
}; };
} }
@ -1010,7 +1154,8 @@ class PreferencesTableData extends DataClass
SourceCodecs? streamMusicCodec, SourceCodecs? streamMusicCodec,
SourceCodecs? downloadMusicCodec, SourceCodecs? downloadMusicCodec,
bool? endlessPlayback, bool? endlessPlayback,
bool? playerWakelock}) => bool? playerWakelock,
bool? overrideCacheProvider}) =>
PreferencesTableData( PreferencesTableData(
id: id ?? this.id, id: id ?? this.id,
audioQuality: audioQuality ?? this.audioQuality, audioQuality: audioQuality ?? this.audioQuality,
@ -1036,6 +1181,8 @@ class PreferencesTableData extends DataClass
downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec,
endlessPlayback: endlessPlayback ?? this.endlessPlayback, endlessPlayback: endlessPlayback ?? this.endlessPlayback,
playerWakelock: playerWakelock ?? this.playerWakelock, playerWakelock: playerWakelock ?? this.playerWakelock,
overrideCacheProvider:
overrideCacheProvider ?? this.overrideCacheProvider,
); );
PreferencesTableData copyWithCompanion(PreferencesTableCompanion data) { PreferencesTableData copyWithCompanion(PreferencesTableCompanion data) {
return PreferencesTableData( return PreferencesTableData(
@ -1099,6 +1246,9 @@ class PreferencesTableData extends DataClass
playerWakelock: data.playerWakelock.present playerWakelock: data.playerWakelock.present
? data.playerWakelock.value ? data.playerWakelock.value
: this.playerWakelock, : this.playerWakelock,
overrideCacheProvider: data.overrideCacheProvider.present
? data.overrideCacheProvider.value
: this.overrideCacheProvider,
); );
} }
@ -1128,7 +1278,8 @@ class PreferencesTableData extends DataClass
..write('streamMusicCodec: $streamMusicCodec, ') ..write('streamMusicCodec: $streamMusicCodec, ')
..write('downloadMusicCodec: $downloadMusicCodec, ') ..write('downloadMusicCodec: $downloadMusicCodec, ')
..write('endlessPlayback: $endlessPlayback, ') ..write('endlessPlayback: $endlessPlayback, ')
..write('playerWakelock: $playerWakelock') ..write('playerWakelock: $playerWakelock, ')
..write('overrideCacheProvider: $overrideCacheProvider')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@ -1158,7 +1309,8 @@ class PreferencesTableData extends DataClass
streamMusicCodec, streamMusicCodec,
downloadMusicCodec, downloadMusicCodec,
endlessPlayback, endlessPlayback,
playerWakelock playerWakelock,
overrideCacheProvider
]); ]);
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@ -1187,7 +1339,8 @@ class PreferencesTableData extends DataClass
other.streamMusicCodec == this.streamMusicCodec && other.streamMusicCodec == this.streamMusicCodec &&
other.downloadMusicCodec == this.downloadMusicCodec && other.downloadMusicCodec == this.downloadMusicCodec &&
other.endlessPlayback == this.endlessPlayback && other.endlessPlayback == this.endlessPlayback &&
other.playerWakelock == this.playerWakelock); other.playerWakelock == this.playerWakelock &&
other.overrideCacheProvider == this.overrideCacheProvider);
} }
class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> { class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
@ -1215,6 +1368,7 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
final Value<SourceCodecs> downloadMusicCodec; final Value<SourceCodecs> downloadMusicCodec;
final Value<bool> endlessPlayback; final Value<bool> endlessPlayback;
final Value<bool> playerWakelock; final Value<bool> playerWakelock;
final Value<bool> overrideCacheProvider;
const PreferencesTableCompanion({ const PreferencesTableCompanion({
this.id = const Value.absent(), this.id = const Value.absent(),
this.audioQuality = const Value.absent(), this.audioQuality = const Value.absent(),
@ -1240,6 +1394,7 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
this.downloadMusicCodec = const Value.absent(), this.downloadMusicCodec = const Value.absent(),
this.endlessPlayback = const Value.absent(), this.endlessPlayback = const Value.absent(),
this.playerWakelock = const Value.absent(), this.playerWakelock = const Value.absent(),
this.overrideCacheProvider = const Value.absent(),
}); });
PreferencesTableCompanion.insert({ PreferencesTableCompanion.insert({
this.id = const Value.absent(), this.id = const Value.absent(),
@ -1266,6 +1421,7 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
this.downloadMusicCodec = const Value.absent(), this.downloadMusicCodec = const Value.absent(),
this.endlessPlayback = const Value.absent(), this.endlessPlayback = const Value.absent(),
this.playerWakelock = const Value.absent(), this.playerWakelock = const Value.absent(),
this.overrideCacheProvider = const Value.absent(),
}); });
static Insertable<PreferencesTableData> custom({ static Insertable<PreferencesTableData> custom({
Expression<int>? id, Expression<int>? id,
@ -1292,6 +1448,7 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
Expression<String>? downloadMusicCodec, Expression<String>? downloadMusicCodec,
Expression<bool>? endlessPlayback, Expression<bool>? endlessPlayback,
Expression<bool>? playerWakelock, Expression<bool>? playerWakelock,
Expression<bool>? overrideCacheProvider,
}) { }) {
return RawValuesInsertable({ return RawValuesInsertable({
if (id != null) 'id': id, if (id != null) 'id': id,
@ -1322,6 +1479,8 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
'download_music_codec': downloadMusicCodec, 'download_music_codec': downloadMusicCodec,
if (endlessPlayback != null) 'endless_playback': endlessPlayback, if (endlessPlayback != null) 'endless_playback': endlessPlayback,
if (playerWakelock != null) 'player_wakelock': playerWakelock, if (playerWakelock != null) 'player_wakelock': playerWakelock,
if (overrideCacheProvider != null)
'override_cache_provider': overrideCacheProvider,
}); });
} }
@ -1349,7 +1508,8 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
Value<SourceCodecs>? streamMusicCodec, Value<SourceCodecs>? streamMusicCodec,
Value<SourceCodecs>? downloadMusicCodec, Value<SourceCodecs>? downloadMusicCodec,
Value<bool>? endlessPlayback, Value<bool>? endlessPlayback,
Value<bool>? playerWakelock}) { Value<bool>? playerWakelock,
Value<bool>? overrideCacheProvider}) {
return PreferencesTableCompanion( return PreferencesTableCompanion(
id: id ?? this.id, id: id ?? this.id,
audioQuality: audioQuality ?? this.audioQuality, audioQuality: audioQuality ?? this.audioQuality,
@ -1375,6 +1535,8 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec,
endlessPlayback: endlessPlayback ?? this.endlessPlayback, endlessPlayback: endlessPlayback ?? this.endlessPlayback,
playerWakelock: playerWakelock ?? this.playerWakelock, playerWakelock: playerWakelock ?? this.playerWakelock,
overrideCacheProvider:
overrideCacheProvider ?? this.overrideCacheProvider,
); );
} }
@ -1472,6 +1634,10 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
if (playerWakelock.present) { if (playerWakelock.present) {
map['player_wakelock'] = Variable<bool>(playerWakelock.value); map['player_wakelock'] = Variable<bool>(playerWakelock.value);
} }
if (overrideCacheProvider.present) {
map['override_cache_provider'] =
Variable<bool>(overrideCacheProvider.value);
}
return map; return map;
} }
@ -1501,7 +1667,8 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
..write('streamMusicCodec: $streamMusicCodec, ') ..write('streamMusicCodec: $streamMusicCodec, ')
..write('downloadMusicCodec: $downloadMusicCodec, ') ..write('downloadMusicCodec: $downloadMusicCodec, ')
..write('endlessPlayback: $endlessPlayback, ') ..write('endlessPlayback: $endlessPlayback, ')
..write('playerWakelock: $playerWakelock') ..write('playerWakelock: $playerWakelock, ')
..write('overrideCacheProvider: $overrideCacheProvider')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@ -3824,16 +3991,20 @@ abstract class _$AppDatabase extends GeneratedDatabase {
typedef $$AuthenticationTableTableCreateCompanionBuilder typedef $$AuthenticationTableTableCreateCompanionBuilder
= AuthenticationTableCompanion Function({ = AuthenticationTableCompanion Function({
Value<int> id, Value<int> id,
required DecryptedText cookie, required DecryptedText spotifyCookie,
required DecryptedText accessToken, required DecryptedText spotifyAccessToken,
required DateTime expiration, required DateTime spotifyExpiration,
Value<DecryptedText?> neteaseCookie,
Value<DateTime?> neteaseExpiration,
}); });
typedef $$AuthenticationTableTableUpdateCompanionBuilder typedef $$AuthenticationTableTableUpdateCompanionBuilder
= AuthenticationTableCompanion Function({ = AuthenticationTableCompanion Function({
Value<int> id, Value<int> id,
Value<DecryptedText> cookie, Value<DecryptedText> spotifyCookie,
Value<DecryptedText> accessToken, Value<DecryptedText> spotifyAccessToken,
Value<DateTime> expiration, Value<DateTime> spotifyExpiration,
Value<DecryptedText?> neteaseCookie,
Value<DateTime?> neteaseExpiration,
}); });
class $$AuthenticationTableTableTableManager extends RootTableManager< class $$AuthenticationTableTableTableManager extends RootTableManager<
@ -3855,27 +4026,35 @@ class $$AuthenticationTableTableTableManager extends RootTableManager<
ComposerState(db, table)), ComposerState(db, table)),
updateCompanionCallback: ({ updateCompanionCallback: ({
Value<int> id = const Value.absent(), Value<int> id = const Value.absent(),
Value<DecryptedText> cookie = const Value.absent(), Value<DecryptedText> spotifyCookie = const Value.absent(),
Value<DecryptedText> accessToken = const Value.absent(), Value<DecryptedText> spotifyAccessToken = const Value.absent(),
Value<DateTime> expiration = const Value.absent(), Value<DateTime> spotifyExpiration = const Value.absent(),
Value<DecryptedText?> neteaseCookie = const Value.absent(),
Value<DateTime?> neteaseExpiration = const Value.absent(),
}) => }) =>
AuthenticationTableCompanion( AuthenticationTableCompanion(
id: id, id: id,
cookie: cookie, spotifyCookie: spotifyCookie,
accessToken: accessToken, spotifyAccessToken: spotifyAccessToken,
expiration: expiration, spotifyExpiration: spotifyExpiration,
neteaseCookie: neteaseCookie,
neteaseExpiration: neteaseExpiration,
), ),
createCompanionCallback: ({ createCompanionCallback: ({
Value<int> id = const Value.absent(), Value<int> id = const Value.absent(),
required DecryptedText cookie, required DecryptedText spotifyCookie,
required DecryptedText accessToken, required DecryptedText spotifyAccessToken,
required DateTime expiration, required DateTime spotifyExpiration,
Value<DecryptedText?> neteaseCookie = const Value.absent(),
Value<DateTime?> neteaseExpiration = const Value.absent(),
}) => }) =>
AuthenticationTableCompanion.insert( AuthenticationTableCompanion.insert(
id: id, id: id,
cookie: cookie, spotifyCookie: spotifyCookie,
accessToken: accessToken, spotifyAccessToken: spotifyAccessToken,
expiration: expiration, spotifyExpiration: spotifyExpiration,
neteaseCookie: neteaseCookie,
neteaseExpiration: neteaseExpiration,
), ),
)); ));
} }
@ -3889,21 +4068,33 @@ class $$AuthenticationTableTableFilterComposer
ColumnFilters(column, joinBuilders: joinBuilders)); ColumnFilters(column, joinBuilders: joinBuilders));
ColumnWithTypeConverterFilters<DecryptedText, DecryptedText, String> ColumnWithTypeConverterFilters<DecryptedText, DecryptedText, String>
get cookie => $state.composableBuilder( get spotifyCookie => $state.composableBuilder(
column: $state.table.cookie, column: $state.table.spotifyCookie,
builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( builder: (column, joinBuilders) => ColumnWithTypeConverterFilters(
column, column,
joinBuilders: joinBuilders)); joinBuilders: joinBuilders));
ColumnWithTypeConverterFilters<DecryptedText, DecryptedText, String> ColumnWithTypeConverterFilters<DecryptedText, DecryptedText, String>
get accessToken => $state.composableBuilder( get spotifyAccessToken => $state.composableBuilder(
column: $state.table.accessToken, column: $state.table.spotifyAccessToken,
builder: (column, joinBuilders) => ColumnWithTypeConverterFilters( builder: (column, joinBuilders) => ColumnWithTypeConverterFilters(
column, column,
joinBuilders: joinBuilders)); joinBuilders: joinBuilders));
ColumnFilters<DateTime> get expiration => $state.composableBuilder( ColumnFilters<DateTime> get spotifyExpiration => $state.composableBuilder(
column: $state.table.expiration, column: $state.table.spotifyExpiration,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
ColumnWithTypeConverterFilters<DecryptedText?, DecryptedText, String>
get neteaseCookie => $state.composableBuilder(
column: $state.table.neteaseCookie,
builder: (column, joinBuilders) => ColumnWithTypeConverterFilters(
column,
joinBuilders: joinBuilders));
ColumnFilters<DateTime> get neteaseExpiration => $state.composableBuilder(
column: $state.table.neteaseExpiration,
builder: (column, joinBuilders) => builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders)); ColumnFilters(column, joinBuilders: joinBuilders));
} }
@ -3916,18 +4107,28 @@ class $$AuthenticationTableTableOrderingComposer
builder: (column, joinBuilders) => builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders)); ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<String> get cookie => $state.composableBuilder( ColumnOrderings<String> get spotifyCookie => $state.composableBuilder(
column: $state.table.cookie, column: $state.table.spotifyCookie,
builder: (column, joinBuilders) => builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders)); ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<String> get accessToken => $state.composableBuilder( ColumnOrderings<String> get spotifyAccessToken => $state.composableBuilder(
column: $state.table.accessToken, column: $state.table.spotifyAccessToken,
builder: (column, joinBuilders) => builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders)); ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<DateTime> get expiration => $state.composableBuilder( ColumnOrderings<DateTime> get spotifyExpiration => $state.composableBuilder(
column: $state.table.expiration, column: $state.table.spotifyExpiration,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<String> get neteaseCookie => $state.composableBuilder(
column: $state.table.neteaseCookie,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<DateTime> get neteaseExpiration => $state.composableBuilder(
column: $state.table.neteaseExpiration,
builder: (column, joinBuilders) => builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders)); ColumnOrderings(column, joinBuilders: joinBuilders));
} }
@ -3958,6 +4159,7 @@ typedef $$PreferencesTableTableCreateCompanionBuilder
Value<SourceCodecs> downloadMusicCodec, Value<SourceCodecs> downloadMusicCodec,
Value<bool> endlessPlayback, Value<bool> endlessPlayback,
Value<bool> playerWakelock, Value<bool> playerWakelock,
Value<bool> overrideCacheProvider,
}); });
typedef $$PreferencesTableTableUpdateCompanionBuilder typedef $$PreferencesTableTableUpdateCompanionBuilder
= PreferencesTableCompanion Function({ = PreferencesTableCompanion Function({
@ -3985,6 +4187,7 @@ typedef $$PreferencesTableTableUpdateCompanionBuilder
Value<SourceCodecs> downloadMusicCodec, Value<SourceCodecs> downloadMusicCodec,
Value<bool> endlessPlayback, Value<bool> endlessPlayback,
Value<bool> playerWakelock, Value<bool> playerWakelock,
Value<bool> overrideCacheProvider,
}); });
class $$PreferencesTableTableTableManager extends RootTableManager< class $$PreferencesTableTableTableManager extends RootTableManager<
@ -4029,6 +4232,7 @@ class $$PreferencesTableTableTableManager extends RootTableManager<
Value<SourceCodecs> downloadMusicCodec = const Value.absent(), Value<SourceCodecs> downloadMusicCodec = const Value.absent(),
Value<bool> endlessPlayback = const Value.absent(), Value<bool> endlessPlayback = const Value.absent(),
Value<bool> playerWakelock = const Value.absent(), Value<bool> playerWakelock = const Value.absent(),
Value<bool> overrideCacheProvider = const Value.absent(),
}) => }) =>
PreferencesTableCompanion( PreferencesTableCompanion(
id: id, id: id,
@ -4055,6 +4259,7 @@ class $$PreferencesTableTableTableManager extends RootTableManager<
downloadMusicCodec: downloadMusicCodec, downloadMusicCodec: downloadMusicCodec,
endlessPlayback: endlessPlayback, endlessPlayback: endlessPlayback,
playerWakelock: playerWakelock, playerWakelock: playerWakelock,
overrideCacheProvider: overrideCacheProvider,
), ),
createCompanionCallback: ({ createCompanionCallback: ({
Value<int> id = const Value.absent(), Value<int> id = const Value.absent(),
@ -4081,6 +4286,7 @@ class $$PreferencesTableTableTableManager extends RootTableManager<
Value<SourceCodecs> downloadMusicCodec = const Value.absent(), Value<SourceCodecs> downloadMusicCodec = const Value.absent(),
Value<bool> endlessPlayback = const Value.absent(), Value<bool> endlessPlayback = const Value.absent(),
Value<bool> playerWakelock = const Value.absent(), Value<bool> playerWakelock = const Value.absent(),
Value<bool> overrideCacheProvider = const Value.absent(),
}) => }) =>
PreferencesTableCompanion.insert( PreferencesTableCompanion.insert(
id: id, id: id,
@ -4107,6 +4313,7 @@ class $$PreferencesTableTableTableManager extends RootTableManager<
downloadMusicCodec: downloadMusicCodec, downloadMusicCodec: downloadMusicCodec,
endlessPlayback: endlessPlayback, endlessPlayback: endlessPlayback,
playerWakelock: playerWakelock, playerWakelock: playerWakelock,
overrideCacheProvider: overrideCacheProvider,
), ),
)); ));
} }
@ -4257,6 +4464,11 @@ class $$PreferencesTableTableFilterComposer
column: $state.table.playerWakelock, column: $state.table.playerWakelock,
builder: (column, joinBuilders) => builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders)); ColumnFilters(column, joinBuilders: joinBuilders));
ColumnFilters<bool> get overrideCacheProvider => $state.composableBuilder(
column: $state.table.overrideCacheProvider,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
} }
class $$PreferencesTableTableOrderingComposer class $$PreferencesTableTableOrderingComposer
@ -4381,6 +4593,11 @@ class $$PreferencesTableTableOrderingComposer
column: $state.table.playerWakelock, column: $state.table.playerWakelock,
builder: (column, joinBuilders) => builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders)); ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<bool> get overrideCacheProvider => $state.composableBuilder(
column: $state.table.overrideCacheProvider,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
} }
typedef $$ScrobblerTableTableCreateCompanionBuilder = ScrobblerTableCompanion typedef $$ScrobblerTableTableCreateCompanionBuilder = ScrobblerTableCompanion

@ -2,7 +2,10 @@ part of '../database.dart';
class AuthenticationTable extends Table { class AuthenticationTable extends Table {
IntColumn get id => integer().autoIncrement()(); IntColumn get id => integer().autoIncrement()();
TextColumn get cookie => text().map(EncryptedTextConverter())(); TextColumn get spotifyCookie => text().map(EncryptedTextConverter())();
TextColumn get accessToken => text().map(EncryptedTextConverter())(); TextColumn get spotifyAccessToken => text().map(EncryptedTextConverter())();
DateTimeColumn get expiration => dateTime()(); DateTimeColumn get spotifyExpiration => dateTime()();
TextColumn get neteaseCookie =>
text().map(EncryptedTextConverter()).nullable()();
DateTimeColumn get neteaseExpiration => dateTime().nullable()();
} }

@ -14,7 +14,8 @@ enum CloseBehavior {
enum AudioSource { enum AudioSource {
youtube, youtube,
piped, piped,
netease; netease,
kugou;
String get label => name[0].toUpperCase() + name.substring(1); String get label => name[0].toUpperCase() + name.substring(1);
} }
@ -89,6 +90,8 @@ class PreferencesTable extends Table {
boolean().withDefault(const Constant(true))(); boolean().withDefault(const Constant(true))();
BoolColumn get playerWakelock => BoolColumn get playerWakelock =>
boolean().withDefault(const Constant(true))(); boolean().withDefault(const Constant(true))();
BoolColumn get overrideCacheProvider =>
boolean().withDefault(const Constant(true))();
// Default values as PreferencesTableData // Default values as PreferencesTableData
static PreferencesTableData defaults() { static PreferencesTableData defaults() {
@ -117,6 +120,7 @@ class PreferencesTable extends Table {
downloadMusicCodec: SourceCodecs.m4a, downloadMusicCodec: SourceCodecs.m4a,
endlessPlayback: true, endlessPlayback: true,
playerWakelock: true, playerWakelock: true,
overrideCacheProvider: true,
); );
} }
} }

@ -3,7 +3,8 @@ part of '../database.dart';
enum SourceType { enum SourceType {
youtube._('YouTube'), youtube._('YouTube'),
youtubeMusic._('YouTube Music'), youtubeMusic._('YouTube Music'),
netease._('Netease Music'); netease._('Netease Music'),
kugou._('Kugou Music');
final String label; final String label;

@ -1,3 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart' hide Response; import 'package:dio/dio.dart' hide Response;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:get/get.dart' hide Response; import 'package:get/get.dart' hide Response;
@ -6,6 +9,7 @@ import 'package:rhythm_box/providers/error_notifier.dart';
import 'package:rhythm_box/services/audio_player/audio_player.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/active_sourced_track.dart';
import 'package:rhythm_box/services/server/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:rhythm_box/services/sourced_track/sources/netease.dart';
import 'package:shelf/shelf.dart'; import 'package:shelf/shelf.dart';
@ -34,6 +38,22 @@ class ServerPlaybackRoutesProvider {
); );
final realUrl = resp.body['data'][0]['url']; final realUrl = resp.body['data'][0]['url'];
url = realUrl; 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( final res = await Dio().get(

@ -1,7 +1,12 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:get/get.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/providers/user_preferences.dart';
import 'package:rhythm_box/services/database/database.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/sourced_track/sources/netease.dart';
import 'package:rhythm_box/services/utils.dart'; import 'package:rhythm_box/services/utils.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
@ -77,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) { static String getSearchTerm(Track track) {
final artists = (track.artists ?? []) final artists = (track.artists ?? [])
.map((ar) => ar.name) .map((ar) => ar.name)
@ -95,25 +127,72 @@ abstract class SourcedTrack extends Track {
static Future<SourcedTrack> fetchFromTrack({ static Future<SourcedTrack> fetchFromTrack({
required Track track, required Track track,
AudioSource? fallbackTo,
}) async { }) async {
final preferences = Get.find<UserPreferencesProvider>().state.value; 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 { try {
return switch (audioSource) { return switch (audioSource) {
AudioSource.netease => AudioSource.netease =>
await NeteaseSourcedTrack.fetchFromTrack(track: track), await NeteaseSourcedTrack.fetchFromTrack(track: track),
AudioSource.kugou =>
await KugouSourcedTrack.fetchFromTrack(track: track),
AudioSource.piped => AudioSource.piped =>
await PipedSourcedTrack.fetchFromTrack(track: track), await PipedSourcedTrack.fetchFromTrack(track: track),
_ => await YoutubeSourcedTrack.fetchFromTrack(track: track), _ => await YoutubeSourcedTrack.fetchFromTrack(track: track),
}; };
} on TrackNotFoundError catch (_) { } on TrackNotFoundError catch (err) {
return switch (preferences.audioSource) { Get.find<ErrorNotifier>().showError(
AudioSource.piped || '${err.toString()} via ${preferences.audioSource.label}, querying in fallback sources...',
AudioSource.youtube => );
await NeteaseSourcedTrack.fetchFromTrack(track: track),
if (fallbackTo != null) {
// Prevent infinite fallback
if (audioSource == AudioSource.youtube ||
audioSource == AudioSource.piped) rethrow;
}
return switch (audioSource) {
AudioSource.netease => AudioSource.netease =>
await YoutubeSourcedTrack.fetchFromTrack(track: track), 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 (_) { } on HttpClientClosedException catch (_) {
return await PipedSourcedTrack.fetchFromTrack(track: track); return await PipedSourcedTrack.fetchFromTrack(track: track);

@ -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;
}
}

@ -1,6 +1,8 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:get/get.dart' hide Value; 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/database.dart';
import 'package:rhythm_box/providers/user_preferences.dart'; import 'package:rhythm_box/providers/user_preferences.dart';
import 'package:rhythm_box/services/database/database.dart'; import 'package:rhythm_box/services/database/database.dart';
@ -38,9 +40,25 @@ class NeteaseSourcedTrack extends SourcedTrack {
} }
static GetConnect getClient() { static GetConnect getClient() {
final client = GetConnect(); final client = GetConnect(
withCredentials: true,
timeout: const Duration(seconds: 30),
);
client.baseUrl = getBaseUrl(); client.baseUrl = getBaseUrl();
client.timeout = const Duration(seconds: 30); 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; return client;
} }
@ -55,7 +73,7 @@ class NeteaseSourcedTrack extends SourcedTrack {
return _lookedUpRealIp!; return _lookedUpRealIp!;
} }
static Future<NeteaseSourcedTrack> fetchFromTrack({ static Future<SourcedTrack> fetchFromTrack({
required Track track, required Track track,
}) async { }) async {
final DatabaseProvider db = Get.find(); final DatabaseProvider db = Get.find();
@ -69,18 +87,25 @@ class NeteaseSourcedTrack extends SourcedTrack {
.get() .get()
.then((s) => s.firstOrNull); .then((s) => s.firstOrNull);
if (cachedSource == null) { if (cachedSource == null || cachedSource.sourceType != SourceType.netease) {
final siblings = await fetchSiblings(track: track); final siblings = await fetchSiblings(track: track);
if (siblings.isEmpty) { if (siblings.isEmpty) {
throw TrackNotFoundError(track); 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( await db.database.into(db.database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert( SourceMatchTableCompanion.insert(
trackId: track.id!, trackId: track.id!,
sourceId: siblings.first.info.id, sourceId: siblings.first.info.id,
sourceType: const Value(SourceType.netease), sourceType: const Value(SourceType.netease),
), ),
mode: InsertMode.insertOrReplace,
); );
return NeteaseSourcedTrack( return NeteaseSourcedTrack(
@ -89,11 +114,24 @@ class NeteaseSourcedTrack extends SourcedTrack {
sourceInfo: siblings.first.info, sourceInfo: siblings.first.info,
track: track, 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 client = getClient();
final resp = await client.get('/song/detail?ids=${cachedSource.sourceId}'); final resp = await client.get('/song/detail?ids=${cachedSource.sourceId}');
final item = resp.body['songs'][0]; 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( return NeteaseSourcedTrack(
siblings: [], siblings: [],
@ -136,8 +174,10 @@ class NeteaseSourcedTrack extends SourcedTrack {
final query = SourcedTrack.getSearchTerm(track); final query = SourcedTrack.getSearchTerm(track);
final client = getClient(); final client = getClient();
final resp = final resp = await client.get(
await client.get('/search?keywords=${Uri.encodeComponent(query)}'); '/search?keywords=${Uri.encodeComponent(query)}&realIP=${await NeteaseSourcedTrack.lookupRealIp()}',
);
if (resp.body?['code'] == 405) throw TrackNotFoundError(track);
final results = resp.body['result']['songs']; final results = resp.body['result']['songs'];
// We can just trust netease music for now // We can just trust netease music for now
@ -167,7 +207,11 @@ class NeteaseSourcedTrack extends SourcedTrack {
} }
@override @override
Future<NeteaseSourcedTrack?> swapWithSibling(SourceInfo sibling) async { Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
if (sibling is! NeteaseSourceInfo) {
return reRoutineSwapSiblings(sibling);
}
if (sibling.id == sourceInfo.id) { if (sibling.id == sourceInfo.id) {
return null; return null;
} }
@ -183,7 +227,7 @@ class NeteaseSourcedTrack extends SourcedTrack {
final client = getClient(); final client = getClient();
final resp = await client.get('/song/detail?ids=${newSourceInfo.id}'); final resp = await client.get('/song/detail?ids=${newSourceInfo.id}');
final item = resp.body['songs'][0]; final item = (resp.body['songs'] as List<dynamic>).first;
final (:info, :source) = toSiblingType(item); final (:info, :source) = toSiblingType(item);
@ -208,25 +252,29 @@ class NeteaseSourcedTrack extends SourcedTrack {
); );
} }
static SiblingType toSiblingType(dynamic item) { static NeteaseSourceInfo toSourceInfo(dynamic item) {
final firstArtist = item['ar'] != null ? item['ar'][0] : item['artists'][0]; 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 = ( final SiblingType sibling = (
info: NeteaseSourceInfo( info: toSourceInfo(item),
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.zero,
album: item['al']?['name'],
),
source: toSourceMap(item), source: toSourceMap(item),
); );

@ -57,6 +57,14 @@ class PipedSourcedTrack extends SourcedTrack {
final preferences = Get.find<UserPreferencesProvider>().state.value; 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) { if (cachedSource == null) {
final siblings = await fetchSiblings(track: track); final siblings = await fetchSiblings(track: track);
if (siblings.isEmpty) { if (siblings.isEmpty) {
@ -73,6 +81,7 @@ class PipedSourcedTrack extends SourcedTrack {
: SourceType.youtubeMusic, : SourceType.youtubeMusic,
), ),
), ),
mode: InsertMode.insertOrReplace,
); );
return PipedSourcedTrack( return PipedSourcedTrack(
@ -255,6 +264,10 @@ class PipedSourcedTrack extends SourcedTrack {
@override @override
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async { Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
if (sibling is! PipedSourceInfo) {
return reRoutineSwapSiblings(sibling);
}
if (sibling.id == sourceInfo.id) { if (sibling.id == sourceInfo.id) {
return null; return null;
} }

@ -43,7 +43,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
required super.track, required super.track,
}); });
static Future<YoutubeSourcedTrack> fetchFromTrack({ static Future<SourcedTrack> fetchFromTrack({
required Track track, required Track track,
}) async { }) async {
final DatabaseProvider db = Get.find(); final DatabaseProvider db = Get.find();
@ -69,6 +69,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
sourceId: siblings.first.info.id, sourceId: siblings.first.info.id,
sourceType: const Value(SourceType.youtube), sourceType: const Value(SourceType.youtube),
), ),
mode: InsertMode.insertOrReplace,
); );
return YoutubeSourcedTrack( return YoutubeSourcedTrack(
@ -77,6 +78,11 @@ class YoutubeSourcedTrack extends SourcedTrack {
sourceInfo: siblings.first.info, sourceInfo: siblings.first.info,
track: track, 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); final item = await youtubeClient.videos.get(cachedSource.sourceId);
@ -268,7 +274,11 @@ class YoutubeSourcedTrack extends SourcedTrack {
} }
@override @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) { if (sibling.id == sourceInfo.id) {
return null; return null;
} }

@ -5,8 +5,10 @@ import 'package:rhythm_box/platform.dart';
class AutoCacheImage extends StatelessWidget { class AutoCacheImage extends StatelessWidget {
final String url; final String url;
final double? width, height; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -15,12 +17,14 @@ class AutoCacheImage extends StatelessWidget {
imageUrl: url, imageUrl: url,
width: width, width: width,
height: height, height: height,
fit: fit,
); );
} }
return Image.network( return Image.network(
url, url,
width: width, width: width,
height: height, height: height,
fit: fit,
); );
} }

@ -1,10 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:rhythm_box/providers/audio_player.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/providers/user_preferences.dart';
import 'package:rhythm_box/services/database/database.dart'; import 'package:rhythm_box/services/database/database.dart';
import 'package:rhythm_box/services/duration.dart'; import 'package:rhythm_box/services/duration.dart';
@ -12,6 +14,7 @@ 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/source_info.dart';
import 'package:rhythm_box/services/sourced_track/models/video_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/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/netease.dart';
import 'package:rhythm_box/services/sourced_track/sources/piped.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/sourced_track/sources/youtube.dart';
@ -44,6 +47,7 @@ class _SiblingTracksState extends State<SiblingTracks> {
YoutubeSourceInfo: 'YouTube', YoutubeSourceInfo: 'YouTube',
PipedSourceInfo: 'Piped', PipedSourceInfo: 'Piped',
NeteaseSourceInfo: 'Netease', NeteaseSourceInfo: 'Netease',
KugouSourceInfo: 'Kugou',
}; };
List<StreamSubscription>? _subscriptions; List<StreamSubscription>? _subscriptions;
@ -89,48 +93,75 @@ class _SiblingTracksState extends State<SiblingTracks> {
final preferences = Get.find<UserPreferencesProvider>().state.value; final preferences = Get.find<UserPreferencesProvider>().state.value;
final searchTerm = _searchTermController.text.trim(); final searchTerm = _searchTermController.text.trim();
if (preferences.audioSource == AudioSource.youtube || try {
preferences.audioSource == AudioSource.piped) { if (preferences.audioSource == AudioSource.youtube ||
final resultsYt = await youtubeClient.search.search(searchTerm.trim()); preferences.audioSource == AudioSource.piped) {
final resultsYt = await youtubeClient.search.search(searchTerm.trim());
final searchResults = await Future.wait( final searchResults = await Future.wait(
resultsYt.map(YoutubeVideoInfo.fromVideo).mapIndexed((i, video) async { resultsYt
final siblingType = await YoutubeSourcedTrack.toSiblingType(i, video); .map(YoutubeVideoInfo.fromVideo)
return siblingType.info; .mapIndexed((i, video) async {
}), final siblingType =
); await YoutubeSourcedTrack.toSiblingType(i, video);
final activeSourceInfo = (_activeTrack! as SourcedTrack).sourceInfo; return siblingType.info;
_siblings = List.from( }),
searchResults );
..removeWhere((element) => element.id == activeSourceInfo.id) final activeSourceInfo = (_activeTrack! as SourcedTrack).sourceInfo;
..insert( _siblings = List.from(
0, searchResults
activeSourceInfo, ..removeWhere((element) => element.id == activeSourceInfo.id)
), ..insert(
growable: true, 0,
); activeSourceInfo,
} else if (preferences.audioSource == AudioSource.netease) { ),
final client = NeteaseSourcedTrack.getClient(); growable: true,
final resp = await client );
.get('/search?keywords=${Uri.encodeComponent(searchTerm)}'); } else if (preferences.audioSource == AudioSource.netease) {
final searchResults = resp.body['result']['songs'] final client = NeteaseSourcedTrack.getClient();
.map(NeteaseSourcedTrack.toSiblingType) final resp = await client.get(
.map((x) => x.info) '/search?keywords=${Uri.encodeComponent(searchTerm)}&realIP=${await NeteaseSourcedTrack.lookupRealIp()}');
.toList(); final searchResults = resp.body['result']['songs']
.map(NeteaseSourcedTrack.toSourceInfo)
.toList();
final activeSourceInfo = (_activeTrack! as SourcedTrack).sourceInfo; final activeSourceInfo = (_activeTrack! as SourcedTrack).sourceInfo;
_siblings = List.from( _siblings = List.from(
searchResults searchResults
..removeWhere((element) => element.id == activeSourceInfo.id) ..removeWhere((element) => element.id == activeSourceInfo.id)
..insert( ..insert(
0, 0,
activeSourceInfo, activeSourceInfo,
), ),
growable: true, 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 @override

@ -1,13 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:rhythm_box/services/duration.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/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/netease.dart';
import 'package:rhythm_box/services/sourced_track/sources/piped.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/sourced_track/sources/youtube.dart';
import 'package:spotify/spotify.dart';
class TrackSourceDetails extends StatelessWidget { class TrackSourceDetails extends StatelessWidget {
final Track track; final SourcedTrack track;
const TrackSourceDetails({super.key, required this.track}); const TrackSourceDetails({super.key, required this.track});
@ -15,6 +15,7 @@ class TrackSourceDetails extends StatelessWidget {
YoutubeSourceInfo: 'YouTube', YoutubeSourceInfo: 'YouTube',
PipedSourceInfo: 'Piped', PipedSourceInfo: 'Piped',
NeteaseSourceInfo: 'Netease', NeteaseSourceInfo: 'Netease',
KugouSourceInfo: 'Kugou',
}; };
@override @override
@ -25,14 +26,11 @@ class TrackSourceDetails extends StatelessWidget {
'Title': track.name!, 'Title': track.name!,
'Artist': track.artists?.map((x) => x.name).join(', '), 'Artist': track.artists?.map((x) => x.name).join(', '),
'Album': track.album!.name!, 'Album': track.album!.name!,
'Duration': (track is SourcedTrack 'Duration': track.sourceInfo.duration.toHumanReadableString(),
? (track as SourcedTrack).sourceInfo.duration
: track.duration!)
.toHumanReadableString(),
if (track.album!.releaseDate != null) if (track.album!.releaseDate != null)
'Released': track.album!.releaseDate, 'Released': track.album!.releaseDate,
'Popularity': track.popularity?.toString() ?? '0', 'Popularity': track.popularity?.toString() ?? '0',
'Provider': sourceInfoToLabelMap[track.runtimeType], 'Provider': sourceInfoToLabelMap[track.sourceInfo.runtimeType],
}; };
return Table( return Table(

@ -28,6 +28,8 @@
<string>MainMenu</string> <string>MainMenu</string>
<key>LSApplicationCategoryType</key> <key>LSApplicationCategoryType</key>
<string>public.app-category.music</string> <string>public.app-category.music</string>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSPrincipalClass</key> <key>NSPrincipalClass</key>
<string>NSApplication</string> <string>NSApplication</string>
</dict> </dict>

@ -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 # 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 # 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. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+8 version: 1.0.0+18
environment: environment:
sdk: ^3.5.0 sdk: ^3.5.0
@ -180,4 +180,3 @@ flutter_native_splash:
color: "#fef8f5" color: "#fef8f5"
color_dark: "#18120d" color_dark: "#18120d"
image: assets/icon-w-shadow.png image: assets/icon-w-shadow.png