16 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
21 changed files with 824 additions and 248 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

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

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

@@ -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,87 +313,89 @@ class _PlayerScreenState extends State<PlayerScreen> {
],
),
const Gap(20),
SizedBox(
height: 40,
child: ListView(
scrollDirection: Axis.horizontal,
shrinkWrap: true,
children: [
TextButton.icon(
icon: const Icon(Icons.queue_music),
label: const Text(
'Queue',
maxLines: 1,
overflow: TextOverflow.fade,
),
onPressed: () {
showModalBottomSheet(
useRootNavigator: true,
isScrollControlled: true,
context: context,
builder: (context) =>
const PlayerQueuePopup(),
).then((_) {
if (mounted) {
setState(() {});
}
});
},
),
if (!isLargeScreen) const Gap(4),
if (!isLargeScreen)
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

@@ -220,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

@@ -55,7 +55,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection());
@override
int get schemaVersion => 1;
int get schemaVersion => 2;
@override
MigrationStrategy get migration {
@@ -63,7 +63,14 @@ class AppDatabase extends _$AppDatabase {
onCreate: (Migrator m) async {
await m.createAll();
},
onUpgrade: (Migrator m, int from, int to) async {},
onUpgrade: (Migrator m, int from, int to) async {
if (from < 2) {
await m.addColumn(
preferencesTable,
preferencesTable.overrideCacheProvider,
);
}
},
);
}
}

View File

@@ -640,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,
@@ -665,7 +675,8 @@ class $PreferencesTableTable extends PreferencesTable
streamMusicCodec,
downloadMusicCodec,
endlessPlayback,
playerWakelock
playerWakelock,
overrideCacheProvider
];
@override
String get aliasedName => _alias ?? actualTableName;
@@ -760,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;
}
@@ -830,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'])!,
);
}
@@ -894,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,
@@ -918,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>{};
@@ -986,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;
}
@@ -1015,6 +1038,7 @@ class PreferencesTableData extends DataClass
downloadMusicCodec: Value(downloadMusicCodec),
endlessPlayback: Value(endlessPlayback),
playerWakelock: Value(playerWakelock),
overrideCacheProvider: Value(overrideCacheProvider),
);
}
@@ -1058,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
@@ -1100,6 +1126,7 @@ class PreferencesTableData extends DataClass
.toJson(downloadMusicCodec)),
'endlessPlayback': serializer.toJson<bool>(endlessPlayback),
'playerWakelock': serializer.toJson<bool>(playerWakelock),
'overrideCacheProvider': serializer.toJson<bool>(overrideCacheProvider),
};
}
@@ -1127,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,
@@ -1153,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(
@@ -1216,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,
);
}
@@ -1245,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();
}
@@ -1275,7 +1309,8 @@ class PreferencesTableData extends DataClass
streamMusicCodec,
downloadMusicCodec,
endlessPlayback,
playerWakelock
playerWakelock,
overrideCacheProvider
]);
@override
bool operator ==(Object other) =>
@@ -1304,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> {
@@ -1332,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(),
@@ -1357,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(),
@@ -1383,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,
@@ -1409,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,
@@ -1439,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,
});
}
@@ -1466,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,
@@ -1492,6 +1535,8 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec,
endlessPlayback: endlessPlayback ?? this.endlessPlayback,
playerWakelock: playerWakelock ?? this.playerWakelock,
overrideCacheProvider:
overrideCacheProvider ?? this.overrideCacheProvider,
);
}
@@ -1589,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;
}
@@ -1618,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();
}
@@ -4109,6 +4159,7 @@ typedef $$PreferencesTableTableCreateCompanionBuilder
Value<SourceCodecs> downloadMusicCodec,
Value<bool> endlessPlayback,
Value<bool> playerWakelock,
Value<bool> overrideCacheProvider,
});
typedef $$PreferencesTableTableUpdateCompanionBuilder
= PreferencesTableCompanion Function({
@@ -4136,6 +4187,7 @@ typedef $$PreferencesTableTableUpdateCompanionBuilder
Value<SourceCodecs> downloadMusicCodec,
Value<bool> endlessPlayback,
Value<bool> playerWakelock,
Value<bool> overrideCacheProvider,
});
class $$PreferencesTableTableTableManager extends RootTableManager<
@@ -4180,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,
@@ -4206,6 +4259,7 @@ class $$PreferencesTableTableTableManager extends RootTableManager<
downloadMusicCodec: downloadMusicCodec,
endlessPlayback: endlessPlayback,
playerWakelock: playerWakelock,
overrideCacheProvider: overrideCacheProvider,
),
createCompanionCallback: ({
Value<int> id = const Value.absent(),
@@ -4232,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,
@@ -4258,6 +4313,7 @@ class $$PreferencesTableTableTableManager extends RootTableManager<
downloadMusicCodec: downloadMusicCodec,
endlessPlayback: endlessPlayback,
playerWakelock: playerWakelock,
overrideCacheProvider: overrideCacheProvider,
),
));
}
@@ -4408,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
@@ -4532,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

@@ -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,8 +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';
@@ -78,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)
@@ -96,27 +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 (err) {
Get.find<ErrorNotifier>()
.showError('${err.toString()} via ${preferences.audioSource.label}');
return switch (preferences.audioSource) {
AudioSource.piped ||
AudioSource.youtube =>
await NeteaseSourcedTrack.fetchFromTrack(track: track),
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

@@ -73,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();
@@ -93,12 +93,19 @@ class NeteaseSourcedTrack extends SourcedTrack {
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(
@@ -107,12 +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}');
print(resp.body);
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: [],
@@ -155,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
@@ -186,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;
}
@@ -202,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);
@@ -227,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(milliseconds: item['duration']),
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,6 +1,7 @@
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';
@@ -14,6 +15,7 @@ class TrackSourceDetails extends StatelessWidget {
YoutubeSourceInfo: 'YouTube',
PipedSourceInfo: 'Piped',
NeteaseSourceInfo: 'Netease',
KugouSourceInfo: 'Kugou',
};
@override

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+10
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