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

View File

@ -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).
Their original app is good enough. But I just want to redesign the user interface and make it ready add to more features and more backend support.
## Highlight
Compare to original spotube. This project added more audio source e.g. netease cloud music, kugou and provide the ability to use in China Mainland.
At the same time, this project also focus on playing experience of VOCALOID songs.
We improve the search and rank algorithm to make the querying will less pick the cover version instead of original ones.
Due to the end service of jiosaavn in Asian region (maybe other regions also affected). We removed the jiosaavn audio source.
## Roadmap
- [x] Playing music
- [x] Add netease music as source
- [ ] Add bilibili as source
- [ ] Add kuwo music as source
- [ ] Add kugo music as source
- [x] Add netease music as source
- [ ] Add bilibili as source
- [ ] Add kuwo music as source
- [x] Add kugou music as source
- [x] Optimize fallback strategy
- [x] Re-design user interface
- [x] Simplified UI and UX
- [x] Support for large screen device
- [x] Simplified UI and UX
- [x] Support for large screen device
## License
This project is open-sourced under APGLv3 license. The original spotube project is open-sourced under license BSD-Clause4 and copyright by Kingkor Roy Tirtho.
This project is all rights reversed by LittleSheep and Solsynth LLC.
This project is all rights reversed by LittleSheep and Solsynth LLC.

View File

@ -1,59 +1,61 @@
<?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">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Groovy Box</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Groovy Box</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.music</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>To provide information for RhythmBox to normalize the output audio</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
<dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Groovy Box</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Groovy Box</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.music</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>To provide information for RhythmBox to normalize the output audio</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
</array>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UIStatusBarHidden</key>
<false/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>

View File

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

View File

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

View File

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

View File

@ -178,4 +178,8 @@ class UserPreferencesProvider extends GetxController {
setData(PreferencesTableCompanion(playerWakelock: Value(wakelock)));
WakelockPlus.toggle(enable: wakelock);
}
void setOverrideCacheProvider(bool override) {
setData(PreferencesTableCompanion(overrideCacheProvider: Value(override)));
}
}

View File

@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:rhythm_box/screens/about.dart';
import 'package:rhythm_box/screens/album/view.dart';
import 'package:rhythm_box/screens/auth/login_netease.dart';
import 'package:rhythm_box/screens/auth/mobile_login.dart';
import 'package:rhythm_box/screens/explore.dart';
import 'package:rhythm_box/screens/library/view.dart';
@ -104,6 +105,11 @@ final router = GoRouter(routes: [
name: 'authMobileLogin',
builder: (context, state) => const MobileLogin(),
),
GoRoute(
path: '/auth/netease/login',
name: 'authMobileLoginNetease',
builder: (context, state) => const LoginNeteaseScreen(),
),
],
),
]);

View File

@ -0,0 +1,156 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:get/get.dart';
import 'package:go_router/go_router.dart';
import 'package:rhythm_box/providers/auth.dart';
import 'package:rhythm_box/providers/error_notifier.dart';
import 'package:rhythm_box/services/sourced_track/sources/netease.dart';
import 'package:rhythm_box/widgets/sized_container.dart';
class LoginNeteaseScreen extends StatefulWidget {
const LoginNeteaseScreen({super.key});
@override
State<LoginNeteaseScreen> createState() => _LoginNeteaseScreenState();
}
class _LoginNeteaseScreenState extends State<LoginNeteaseScreen> {
final TextEditingController _phoneController = TextEditingController();
final TextEditingController _phoneRegionController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
late final AuthenticationProvider _auth = Get.find();
bool _isLogging = false;
Future<void> _sentCaptcha() async {
setState(() => _isLogging = true);
final phone = _phoneController.text;
var region = _phoneRegionController.text;
if (region.isEmpty) region = '86';
final client = NeteaseSourcedTrack.getClient();
final resp = await client.get(
'/captcha/sent?phone=$phone&ctcode=$region&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'),
)
],
),
),
);
}
}

View File

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

View File

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

View File

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

View File

@ -7,6 +7,7 @@ import 'package:rhythm_box/providers/spotify.dart';
import 'package:rhythm_box/providers/user_preferences.dart';
import 'package:rhythm_box/screens/auth/login.dart';
import 'package:rhythm_box/services/database/database.dart';
import 'package:rhythm_box/services/sourced_track/sources/netease.dart';
import 'package:rhythm_box/widgets/auto_cache_image.dart';
import 'package:rhythm_box/widgets/sized_container.dart';
@ -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(() {
if (_authenticate.auth.value == null) {
return const SizedBox();
@ -95,7 +165,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
leading: const Icon(Icons.logout),
title: const Text('Log out'),
subtitle: const Text('Disconnect with this Spotify account'),
subtitle: const Text('Disconnect with every account'),
trailing: const Icon(Icons.chevron_right),
onTap: () async {
_authenticate.logout();
@ -150,6 +220,44 @@ class _SettingsScreenState extends State<SettingsScreen> {
),
),
const Divider(thickness: 0.3, height: 1),
Obx(
() => CheckboxListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
secondary: const Icon(Icons.update),
title: const Text('Override Cache Provider'),
subtitle: const Text(
'Decide whether use original cached source or query a new one from current audio provider'),
value: _preferences.state.value.overrideCacheProvider,
onChanged: (value) =>
_preferences.setOverrideCacheProvider(value ?? false),
),
),
Obx(
() => Column(
children: [
const ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Icons.cloud),
title: Text('Netease Cloud Music API'),
subtitle: Text(
'Use your own endpoint to prevent IP throttling and more'),
),
TextFormField(
initialValue: _preferences.state.value.neteaseApiInstance,
decoration: const InputDecoration(
hintText: 'Endpoint URL',
isDense: true,
),
onChanged: (value) {
_preferences.setNeteaseApiInstance(value);
},
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).paddingOnly(left: 24, right: 24, bottom: 12),
],
),
),
const Divider(thickness: 0.3, height: 1),
Obx(
() => SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,3 +1,6 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart' hide Response;
import 'package:flutter/foundation.dart';
import 'package:get/get.dart' hide Response;
@ -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/server/active_sourced_track.dart';
import 'package:rhythm_box/services/server/sourced_track.dart';
import 'package:rhythm_box/services/sourced_track/sources/kugou.dart';
import 'package:rhythm_box/services/sourced_track/sources/netease.dart';
import 'package:shelf/shelf.dart';
@ -34,6 +38,22 @@ class ServerPlaybackRoutesProvider {
);
final realUrl = resp.body['data'][0]['url'];
url = realUrl;
} else if (sourcedTrack is KugouSourcedTrack) {
// Special processing for kugou to get real assets url
final resp = await GetConnect(timeout: const Duration(seconds: 30))
.get(sourcedTrack.url);
final urls = jsonDecode(resp.body)['url'];
if (urls?.isEmpty ?? true) {
Get.find<ErrorNotifier>().showError(
'[PlaybackServer] Unable get audio source via Kugou, probably cause by paid needed resources.',
);
return Response(
HttpStatus.notFound,
body: 'Unable get audio source via Kugou',
);
}
final realUrl = KugouSourcedTrack.unescapeUrl(urls.first);
url = realUrl;
}
final res = await Dio().get(

View File

@ -1,7 +1,12 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:get/get.dart';
import 'package:rhythm_box/providers/database.dart';
import 'package:rhythm_box/providers/error_notifier.dart';
import 'package:rhythm_box/providers/user_preferences.dart';
import 'package:rhythm_box/services/database/database.dart';
import 'package:rhythm_box/services/server/active_sourced_track.dart';
import 'package:rhythm_box/services/sourced_track/sources/kugou.dart';
import 'package:rhythm_box/services/sourced_track/sources/netease.dart';
import 'package:rhythm_box/services/utils.dart';
import 'package:spotify/spotify.dart';
@ -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) {
final artists = (track.artists ?? [])
.map((ar) => ar.name)
@ -95,25 +127,72 @@ abstract class SourcedTrack extends Track {
static Future<SourcedTrack> fetchFromTrack({
required Track track,
AudioSource? fallbackTo,
}) async {
final preferences = Get.find<UserPreferencesProvider>().state.value;
final audioSource = preferences.audioSource;
var audioSource = preferences.audioSource;
if (!preferences.overrideCacheProvider && fallbackTo == null) {
final DatabaseProvider db = Get.find();
final cachedSource =
await (db.database.select(db.database.sourceMatchTable)
..where((s) => s.trackId.equals(track.id!))
..limit(1)
..orderBy([
(s) => OrderingTerm(
expression: s.createdAt, mode: OrderingMode.desc),
]))
.get()
.then((s) => s.firstOrNull);
final ytOrPiped = preferences.audioSource == AudioSource.youtube
? AudioSource.youtube
: AudioSource.piped;
final sourceTypeTrackMap = {
SourceType.youtube: ytOrPiped,
SourceType.youtubeMusic: ytOrPiped,
SourceType.netease: AudioSource.netease,
SourceType.kugou: AudioSource.kugou,
};
if (cachedSource != null) {
final cachedAudioSource = sourceTypeTrackMap[cachedSource.sourceType]!;
audioSource = cachedAudioSource;
}
}
if (fallbackTo != null) {
audioSource = fallbackTo;
}
try {
return switch (audioSource) {
AudioSource.netease =>
await NeteaseSourcedTrack.fetchFromTrack(track: track),
AudioSource.kugou =>
await KugouSourcedTrack.fetchFromTrack(track: track),
AudioSource.piped =>
await PipedSourcedTrack.fetchFromTrack(track: track),
_ => await YoutubeSourcedTrack.fetchFromTrack(track: track),
};
} on TrackNotFoundError catch (_) {
return switch (preferences.audioSource) {
AudioSource.piped ||
AudioSource.youtube =>
await NeteaseSourcedTrack.fetchFromTrack(track: track),
} on TrackNotFoundError catch (err) {
Get.find<ErrorNotifier>().showError(
'${err.toString()} via ${preferences.audioSource.label}, querying in fallback sources...',
);
if (fallbackTo != null) {
// Prevent infinite fallback
if (audioSource == AudioSource.youtube ||
audioSource == AudioSource.piped) rethrow;
}
return switch (audioSource) {
AudioSource.netease =>
await 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 (_) {
return await PipedSourcedTrack.fetchFromTrack(track: track);

View File

@ -0,0 +1,242 @@
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:crypto/crypto.dart';
import 'package:get/get.dart' hide Value;
import 'package:rhythm_box/providers/database.dart';
import 'package:rhythm_box/services/database/database.dart';
import 'package:spotify/spotify.dart';
import 'package:rhythm_box/services/sourced_track/enums.dart';
import 'package:rhythm_box/services/sourced_track/exceptions.dart';
import 'package:rhythm_box/services/sourced_track/models/source_info.dart';
import 'package:rhythm_box/services/sourced_track/models/source_map.dart';
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
class KugouSourceInfo extends SourceInfo {
KugouSourceInfo({
required super.id,
required super.title,
required super.artist,
required super.thumbnail,
required super.pageUrl,
required super.duration,
required super.artistUrl,
required super.album,
});
}
class KugouSourcedTrack extends SourcedTrack {
KugouSourcedTrack({
required super.source,
required super.siblings,
required super.sourceInfo,
required super.track,
});
static String unescapeUrl(String src) {
return src.replaceAll('\\/', '/');
}
static String getBaseUrl() {
return 'http://mobilecdn.kugou.com';
}
static GetConnect getClient() {
final client = GetConnect(
withCredentials: true,
timeout: const Duration(seconds: 30),
);
client.baseUrl = getBaseUrl();
return client;
}
static Future<SourcedTrack> fetchFromTrack({
required Track track,
}) async {
final DatabaseProvider db = Get.find();
final cachedSource = await (db.database.select(db.database.sourceMatchTable)
..where((s) => s.trackId.equals(track.id!))
..limit(1)
..orderBy([
(s) =>
OrderingTerm(expression: s.createdAt, mode: OrderingMode.desc),
]))
.get()
.then((s) => s.firstOrNull);
if (cachedSource == null || cachedSource.sourceType != SourceType.kugou) {
final siblings = await fetchSiblings(track: track);
if (siblings.isEmpty) {
throw TrackNotFoundError(track);
}
await db.database.into(db.database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: track.id!,
sourceId: siblings.first.info.id,
sourceType: const Value(SourceType.kugou),
),
mode: InsertMode.insertOrReplace,
);
return KugouSourcedTrack(
siblings: siblings.map((s) => s.info).skip(1).toList(),
source: siblings.first.source as SourceMap,
sourceInfo: siblings.first.info,
track: track,
);
} else if (cachedSource.sourceType != SourceType.kugou) {
final out =
await SourcedTrack.reRoutineFetchFromTrack(track, cachedSource);
if (out == null) throw TrackNotFoundError(track);
return out;
}
return KugouSourcedTrack(
siblings: [],
source: toSourceMap(cachedSource),
sourceInfo: KugouSourceInfo(
id: cachedSource.sourceId,
artist: 'unknown',
artistUrl: '#',
pageUrl: '#',
thumbnail: '#',
title: 'unknown',
duration: Duration.zero,
album: 'unknown',
),
track: track,
);
}
static SourceMap toSourceMap(dynamic manifest) {
const baseUrl = 'http://trackercdn.kugou.com/i/v2';
final hash = manifest is SourceMatchTableData
? manifest.sourceId
: manifest is KugouSourceInfo
? manifest.id
: manifest?['hash'];
final key = md5.convert(utf8.encode('${hash}kgcloudv2')).toString();
final url =
'$baseUrl/song/url?key=$key&hash=$hash&appid=1005&pid=2&cmd=25&behavior=play';
return SourceMap(
m4a: SourceQualityMap(
high: url,
medium: url,
low: url,
),
weba: SourceQualityMap(
high: url,
medium: url,
low: url,
),
);
}
static Future<List<SiblingType>> fetchSiblings({
required Track track,
}) async {
final query = SourcedTrack.getSearchTerm(track);
final client = getClient();
final resp = await client.get(
'/api/v3/search/song?keyword=${Uri.encodeComponent(query)}&page=1&pagesize=10',
);
final results = jsonDecode(resp.body)['data']['info'];
// We can just trust kugou music for now
// If we need to check is the result correct, refer to this code
// https://github.com/KRTirtho/spotube/blob/9b024120601c0d381edeab4460cb22f87149d0f8/lib/services/sourced_track/sources/jiosaavn.dart#L129
final matchedResults =
results.where((x) => x['pay_type'] == 0).map(toSiblingType).toList();
return matchedResults.cast<SiblingType>();
}
@override
Future<KugouSourcedTrack> copyWithSibling() async {
if (siblings.isNotEmpty) {
return this;
}
final fetchedSiblings = await fetchSiblings(track: this);
return KugouSourcedTrack(
siblings: fetchedSiblings
.where((s) => s.info.id != sourceInfo.id)
.map((s) => s.info)
.toList(),
source: source,
sourceInfo: sourceInfo,
track: this,
);
}
@override
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
if (sibling is! KugouSourceInfo) {
return reRoutineSwapSiblings(sibling);
}
if (sibling.id == sourceInfo.id) {
return null;
}
// a sibling source that was fetched from the search results
final isStepSibling = siblings.none((s) => s.id == sibling.id);
final newSourceInfo = isStepSibling
? sibling
: siblings.firstWhere((s) => s.id == sibling.id);
final newSiblings = siblings.where((s) => s.id != sibling.id).toList()
..insert(0, sourceInfo);
final info = newSourceInfo as KugouSourceInfo;
final source = toSourceMap(newSourceInfo);
final db = Get.find<DatabaseProvider>();
await db.database.into(db.database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: id!,
sourceId: info.id,
sourceType: const Value(SourceType.kugou),
// Because we're sorting by createdAt in the query
// we have to update it to indicate priority
createdAt: Value(DateTime.now()),
),
mode: InsertMode.replace,
);
return KugouSourcedTrack(
siblings: newSiblings,
source: source,
sourceInfo: info,
track: this,
);
}
static KugouSourceInfo toSourceInfo(dynamic item) {
return KugouSourceInfo(
id: item['hash'],
artist: item['singername'],
artistUrl: '#',
pageUrl: '#',
thumbnail: unescapeUrl(item['trans_param']['union_cover'])
.replaceFirst('/{size}', ''),
title: item['songname'],
duration: Duration(seconds: item['duration']),
album: item['album_name'],
);
}
static SiblingType toSiblingType(dynamic item) {
final SiblingType sibling = (
info: toSourceInfo(item),
source: toSourceMap(item),
);
return sibling;
}
}

View File

@ -1,6 +1,8 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:get/get.dart' hide Value;
import 'package:get/get_connect/http/src/request/request.dart';
import 'package:rhythm_box/providers/auth.dart';
import 'package:rhythm_box/providers/database.dart';
import 'package:rhythm_box/providers/user_preferences.dart';
import 'package:rhythm_box/services/database/database.dart';
@ -38,9 +40,25 @@ class NeteaseSourcedTrack extends SourcedTrack {
}
static GetConnect getClient() {
final client = GetConnect();
final client = GetConnect(
withCredentials: true,
timeout: const Duration(seconds: 30),
);
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;
}
@ -55,7 +73,7 @@ class NeteaseSourcedTrack extends SourcedTrack {
return _lookedUpRealIp!;
}
static Future<NeteaseSourcedTrack> fetchFromTrack({
static Future<SourcedTrack> fetchFromTrack({
required Track track,
}) async {
final DatabaseProvider db = Get.find();
@ -69,18 +87,25 @@ class NeteaseSourcedTrack extends SourcedTrack {
.get()
.then((s) => s.firstOrNull);
if (cachedSource == null) {
if (cachedSource == null || cachedSource.sourceType != SourceType.netease) {
final siblings = await fetchSiblings(track: track);
if (siblings.isEmpty) {
throw TrackNotFoundError(track);
}
final client = getClient();
final checkResp = await client.get(
'/check/music?id=${siblings.first.info.id}&realIP=${await lookupRealIp()}',
);
if (checkResp.body['success'] != true) throw TrackNotFoundError(track);
await db.database.into(db.database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: track.id!,
sourceId: siblings.first.info.id,
sourceType: const Value(SourceType.netease),
),
mode: InsertMode.insertOrReplace,
);
return NeteaseSourcedTrack(
@ -89,11 +114,24 @@ class NeteaseSourcedTrack extends SourcedTrack {
sourceInfo: siblings.first.info,
track: track,
);
} else if (cachedSource.sourceType != SourceType.netease) {
final out =
await SourcedTrack.reRoutineFetchFromTrack(track, cachedSource);
if (out == null) throw TrackNotFoundError(track);
return out;
}
final client = getClient();
final resp = await client.get('/song/detail?ids=${cachedSource.sourceId}');
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(
siblings: [],
@ -136,8 +174,10 @@ class NeteaseSourcedTrack extends SourcedTrack {
final query = SourcedTrack.getSearchTerm(track);
final client = getClient();
final resp =
await client.get('/search?keywords=${Uri.encodeComponent(query)}');
final resp = await client.get(
'/search?keywords=${Uri.encodeComponent(query)}&realIP=${await NeteaseSourcedTrack.lookupRealIp()}',
);
if (resp.body?['code'] == 405) throw TrackNotFoundError(track);
final results = resp.body['result']['songs'];
// We can just trust netease music for now
@ -167,7 +207,11 @@ class NeteaseSourcedTrack extends SourcedTrack {
}
@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) {
return null;
}
@ -183,7 +227,7 @@ class NeteaseSourcedTrack extends SourcedTrack {
final client = getClient();
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);
@ -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];
return NeteaseSourceInfo(
id: item['id'].toString(),
artist: item['ar'] != null
? item['ar'].map((x) => x['name']).join(',')
: item['artists'].map((x) => x['name']).toString(),
artistUrl: 'https://music.163.com/#/artist?id=${firstArtist['id']}',
pageUrl: 'https://music.163.com/#/song?id=${item['id']}',
thumbnail: item['al']?['picUrl'] ??
'https://p1.music.126.net/6y-UleORITEDbvrOLV0Q8A==/5639395138885805.jpg',
title: item['name'],
duration: item['dt'] != null
? Duration(milliseconds: item['dt'])
: Duration(milliseconds: item['duration']),
album: item['al']?['name'],
);
}
static SiblingType toSiblingType(dynamic item) {
final SiblingType sibling = (
info: 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.zero,
album: item['al']?['name'],
),
info: toSourceInfo(item),
source: toSourceMap(item),
);

View File

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

View File

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

View File

@ -5,8 +5,10 @@ import 'package:rhythm_box/platform.dart';
class AutoCacheImage extends StatelessWidget {
final String url;
final double? width, height;
final BoxFit? fit;
const AutoCacheImage(this.url, {super.key, this.width, this.height});
const AutoCacheImage(this.url,
{super.key, this.width, this.height, this.fit});
@override
Widget build(BuildContext context) {
@ -15,12 +17,14 @@ class AutoCacheImage extends StatelessWidget {
imageUrl: url,
width: width,
height: height,
fit: fit,
);
}
return Image.network(
url,
width: width,
height: height,
fit: fit,
);
}

View File

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

View File

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

View File

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

View File

@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+8
version: 1.0.0+18
environment:
sdk: ^3.5.0
@ -180,4 +180,3 @@ flutter_native_splash:
color: "#fef8f5"
color_dark: "#18120d"
image: assets/icon-w-shadow.png