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

View File

@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>

View File

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

View File

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

View File

@@ -82,7 +82,8 @@ class _PlayerScreenState extends State<PlayerScreen> {
padding: const EdgeInsets.symmetric(vertical: 24), padding: const EdgeInsets.symmetric(vertical: 24),
children: [ children: [
Obx( Obx(
() => LimitedBox( () => Center(
child: LimitedBox(
maxHeight: maxAlbumSize, maxHeight: maxAlbumSize,
maxWidth: maxAlbumSize, maxWidth: maxAlbumSize,
child: Hero( child: Hero(
@@ -90,13 +91,15 @@ class _PlayerScreenState extends State<PlayerScreen> {
child: AspectRatio( child: AspectRatio(
aspectRatio: 1, aspectRatio: 1,
child: ClipRRect( child: ClipRRect(
borderRadius: borderRadius: const BorderRadius.all(
const BorderRadius.all(Radius.circular(16)), Radius.circular(16),
),
child: _albumArt != null child: _albumArt != null
? AutoCacheImage( ? AutoCacheImage(
_albumArt!, _albumArt!,
width: albumSize, width: albumSize,
height: albumSize, height: albumSize,
fit: BoxFit.cover,
) )
: Container( : Container(
color: Theme.of(context) color: Theme.of(context)
@@ -113,6 +116,7 @@ class _PlayerScreenState extends State<PlayerScreen> {
), ),
), ),
), ),
),
const Gap(24), const Gap(24),
Obx( Obx(
() => Row( () => Row(
@@ -309,7 +313,8 @@ class _PlayerScreenState extends State<PlayerScreen> {
], ],
), ),
const Gap(20), const Gap(20),
SizedBox( Center(
child: SizedBox(
height: 40, height: 40,
child: ListView( child: ListView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
@@ -392,6 +397,7 @@ class _PlayerScreenState extends State<PlayerScreen> {
], ],
), ),
), ),
),
], ],
), ),
), ),

View File

@@ -220,6 +220,44 @@ class _SettingsScreenState extends State<SettingsScreen> {
), ),
), ),
const Divider(thickness: 0.3, height: 1), const Divider(thickness: 0.3, height: 1),
Obx(
() => CheckboxListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
secondary: const Icon(Icons.update),
title: const Text('Override Cache Provider'),
subtitle: const Text(
'Decide whether use original cached source or query a new one from current audio provider'),
value: _preferences.state.value.overrideCacheProvider,
onChanged: (value) =>
_preferences.setOverrideCacheProvider(value ?? false),
),
),
Obx(
() => Column(
children: [
const ListTile(
contentPadding: EdgeInsets.symmetric(horizontal: 24),
leading: Icon(Icons.cloud),
title: Text('Netease Cloud Music API'),
subtitle: Text(
'Use your own endpoint to prevent IP throttling and more'),
),
TextFormField(
initialValue: _preferences.state.value.neteaseApiInstance,
decoration: const InputDecoration(
hintText: 'Endpoint URL',
isDense: true,
),
onChanged: (value) {
_preferences.setNeteaseApiInstance(value);
},
onTapOutside: (_) =>
FocusManager.instance.primaryFocus?.unfocus(),
).paddingOnly(left: 24, right: 24, bottom: 12),
],
),
),
const Divider(thickness: 0.3, height: 1),
Obx( Obx(
() => SwitchListTile( () => SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24), contentPadding: const EdgeInsets.symmetric(horizontal: 24),

View File

@@ -55,7 +55,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection()); AppDatabase() : super(_openConnection());
@override @override
int get schemaVersion => 1; int get schemaVersion => 2;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@@ -63,7 +63,14 @@ class AppDatabase extends _$AppDatabase {
onCreate: (Migrator m) async { onCreate: (Migrator m) async {
await m.createAll(); 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( defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("player_wakelock" IN (0, 1))'), 'CHECK ("player_wakelock" IN (0, 1))'),
defaultValue: const Constant(true)); defaultValue: const Constant(true));
static const VerificationMeta _overrideCacheProviderMeta =
const VerificationMeta('overrideCacheProvider');
@override
late final GeneratedColumn<bool> overrideCacheProvider =
GeneratedColumn<bool>('override_cache_provider', aliasedName, false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("override_cache_provider" IN (0, 1))'),
defaultValue: const Constant(true));
@override @override
List<GeneratedColumn> get $columns => [ List<GeneratedColumn> get $columns => [
id, id,
@@ -665,7 +675,8 @@ class $PreferencesTableTable extends PreferencesTable
streamMusicCodec, streamMusicCodec,
downloadMusicCodec, downloadMusicCodec,
endlessPlayback, endlessPlayback,
playerWakelock playerWakelock,
overrideCacheProvider
]; ];
@override @override
String get aliasedName => _alias ?? actualTableName; String get aliasedName => _alias ?? actualTableName;
@@ -760,6 +771,12 @@ class $PreferencesTableTable extends PreferencesTable
playerWakelock.isAcceptableOrUnknown( playerWakelock.isAcceptableOrUnknown(
data['player_wakelock']!, _playerWakelockMeta)); data['player_wakelock']!, _playerWakelockMeta));
} }
if (data.containsKey('override_cache_provider')) {
context.handle(
_overrideCacheProviderMeta,
overrideCacheProvider.isAcceptableOrUnknown(
data['override_cache_provider']!, _overrideCacheProviderMeta));
}
return context; return context;
} }
@@ -830,6 +847,9 @@ class $PreferencesTableTable extends PreferencesTable
.read(DriftSqlType.bool, data['${effectivePrefix}endless_playback'])!, .read(DriftSqlType.bool, data['${effectivePrefix}endless_playback'])!,
playerWakelock: attachedDatabase.typeMapping playerWakelock: attachedDatabase.typeMapping
.read(DriftSqlType.bool, data['${effectivePrefix}player_wakelock'])!, .read(DriftSqlType.bool, data['${effectivePrefix}player_wakelock'])!,
overrideCacheProvider: attachedDatabase.typeMapping.read(
DriftSqlType.bool,
data['${effectivePrefix}override_cache_provider'])!,
); );
} }
@@ -894,6 +914,7 @@ class PreferencesTableData extends DataClass
final SourceCodecs downloadMusicCodec; final SourceCodecs downloadMusicCodec;
final bool endlessPlayback; final bool endlessPlayback;
final bool playerWakelock; final bool playerWakelock;
final bool overrideCacheProvider;
const PreferencesTableData( const PreferencesTableData(
{required this.id, {required this.id,
required this.audioQuality, required this.audioQuality,
@@ -918,7 +939,8 @@ class PreferencesTableData extends DataClass
required this.streamMusicCodec, required this.streamMusicCodec,
required this.downloadMusicCodec, required this.downloadMusicCodec,
required this.endlessPlayback, required this.endlessPlayback,
required this.playerWakelock}); required this.playerWakelock,
required this.overrideCacheProvider});
@override @override
Map<String, Expression> toColumns(bool nullToAbsent) { Map<String, Expression> toColumns(bool nullToAbsent) {
final map = <String, Expression>{}; final map = <String, Expression>{};
@@ -986,6 +1008,7 @@ class PreferencesTableData extends DataClass
} }
map['endless_playback'] = Variable<bool>(endlessPlayback); map['endless_playback'] = Variable<bool>(endlessPlayback);
map['player_wakelock'] = Variable<bool>(playerWakelock); map['player_wakelock'] = Variable<bool>(playerWakelock);
map['override_cache_provider'] = Variable<bool>(overrideCacheProvider);
return map; return map;
} }
@@ -1015,6 +1038,7 @@ class PreferencesTableData extends DataClass
downloadMusicCodec: Value(downloadMusicCodec), downloadMusicCodec: Value(downloadMusicCodec),
endlessPlayback: Value(endlessPlayback), endlessPlayback: Value(endlessPlayback),
playerWakelock: Value(playerWakelock), playerWakelock: Value(playerWakelock),
overrideCacheProvider: Value(overrideCacheProvider),
); );
} }
@@ -1058,6 +1082,8 @@ class PreferencesTableData extends DataClass
.fromJson(serializer.fromJson<String>(json['downloadMusicCodec'])), .fromJson(serializer.fromJson<String>(json['downloadMusicCodec'])),
endlessPlayback: serializer.fromJson<bool>(json['endlessPlayback']), endlessPlayback: serializer.fromJson<bool>(json['endlessPlayback']),
playerWakelock: serializer.fromJson<bool>(json['playerWakelock']), playerWakelock: serializer.fromJson<bool>(json['playerWakelock']),
overrideCacheProvider:
serializer.fromJson<bool>(json['overrideCacheProvider']),
); );
} }
@override @override
@@ -1100,6 +1126,7 @@ class PreferencesTableData extends DataClass
.toJson(downloadMusicCodec)), .toJson(downloadMusicCodec)),
'endlessPlayback': serializer.toJson<bool>(endlessPlayback), 'endlessPlayback': serializer.toJson<bool>(endlessPlayback),
'playerWakelock': serializer.toJson<bool>(playerWakelock), 'playerWakelock': serializer.toJson<bool>(playerWakelock),
'overrideCacheProvider': serializer.toJson<bool>(overrideCacheProvider),
}; };
} }
@@ -1127,7 +1154,8 @@ class PreferencesTableData extends DataClass
SourceCodecs? streamMusicCodec, SourceCodecs? streamMusicCodec,
SourceCodecs? downloadMusicCodec, SourceCodecs? downloadMusicCodec,
bool? endlessPlayback, bool? endlessPlayback,
bool? playerWakelock}) => bool? playerWakelock,
bool? overrideCacheProvider}) =>
PreferencesTableData( PreferencesTableData(
id: id ?? this.id, id: id ?? this.id,
audioQuality: audioQuality ?? this.audioQuality, audioQuality: audioQuality ?? this.audioQuality,
@@ -1153,6 +1181,8 @@ class PreferencesTableData extends DataClass
downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec,
endlessPlayback: endlessPlayback ?? this.endlessPlayback, endlessPlayback: endlessPlayback ?? this.endlessPlayback,
playerWakelock: playerWakelock ?? this.playerWakelock, playerWakelock: playerWakelock ?? this.playerWakelock,
overrideCacheProvider:
overrideCacheProvider ?? this.overrideCacheProvider,
); );
PreferencesTableData copyWithCompanion(PreferencesTableCompanion data) { PreferencesTableData copyWithCompanion(PreferencesTableCompanion data) {
return PreferencesTableData( return PreferencesTableData(
@@ -1216,6 +1246,9 @@ class PreferencesTableData extends DataClass
playerWakelock: data.playerWakelock.present playerWakelock: data.playerWakelock.present
? data.playerWakelock.value ? data.playerWakelock.value
: this.playerWakelock, : this.playerWakelock,
overrideCacheProvider: data.overrideCacheProvider.present
? data.overrideCacheProvider.value
: this.overrideCacheProvider,
); );
} }
@@ -1245,7 +1278,8 @@ class PreferencesTableData extends DataClass
..write('streamMusicCodec: $streamMusicCodec, ') ..write('streamMusicCodec: $streamMusicCodec, ')
..write('downloadMusicCodec: $downloadMusicCodec, ') ..write('downloadMusicCodec: $downloadMusicCodec, ')
..write('endlessPlayback: $endlessPlayback, ') ..write('endlessPlayback: $endlessPlayback, ')
..write('playerWakelock: $playerWakelock') ..write('playerWakelock: $playerWakelock, ')
..write('overrideCacheProvider: $overrideCacheProvider')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@@ -1275,7 +1309,8 @@ class PreferencesTableData extends DataClass
streamMusicCodec, streamMusicCodec,
downloadMusicCodec, downloadMusicCodec,
endlessPlayback, endlessPlayback,
playerWakelock playerWakelock,
overrideCacheProvider
]); ]);
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -1304,7 +1339,8 @@ class PreferencesTableData extends DataClass
other.streamMusicCodec == this.streamMusicCodec && other.streamMusicCodec == this.streamMusicCodec &&
other.downloadMusicCodec == this.downloadMusicCodec && other.downloadMusicCodec == this.downloadMusicCodec &&
other.endlessPlayback == this.endlessPlayback && other.endlessPlayback == this.endlessPlayback &&
other.playerWakelock == this.playerWakelock); other.playerWakelock == this.playerWakelock &&
other.overrideCacheProvider == this.overrideCacheProvider);
} }
class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> { class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
@@ -1332,6 +1368,7 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
final Value<SourceCodecs> downloadMusicCodec; final Value<SourceCodecs> downloadMusicCodec;
final Value<bool> endlessPlayback; final Value<bool> endlessPlayback;
final Value<bool> playerWakelock; final Value<bool> playerWakelock;
final Value<bool> overrideCacheProvider;
const PreferencesTableCompanion({ const PreferencesTableCompanion({
this.id = const Value.absent(), this.id = const Value.absent(),
this.audioQuality = const Value.absent(), this.audioQuality = const Value.absent(),
@@ -1357,6 +1394,7 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
this.downloadMusicCodec = const Value.absent(), this.downloadMusicCodec = const Value.absent(),
this.endlessPlayback = const Value.absent(), this.endlessPlayback = const Value.absent(),
this.playerWakelock = const Value.absent(), this.playerWakelock = const Value.absent(),
this.overrideCacheProvider = const Value.absent(),
}); });
PreferencesTableCompanion.insert({ PreferencesTableCompanion.insert({
this.id = const Value.absent(), this.id = const Value.absent(),
@@ -1383,6 +1421,7 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
this.downloadMusicCodec = const Value.absent(), this.downloadMusicCodec = const Value.absent(),
this.endlessPlayback = const Value.absent(), this.endlessPlayback = const Value.absent(),
this.playerWakelock = const Value.absent(), this.playerWakelock = const Value.absent(),
this.overrideCacheProvider = const Value.absent(),
}); });
static Insertable<PreferencesTableData> custom({ static Insertable<PreferencesTableData> custom({
Expression<int>? id, Expression<int>? id,
@@ -1409,6 +1448,7 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
Expression<String>? downloadMusicCodec, Expression<String>? downloadMusicCodec,
Expression<bool>? endlessPlayback, Expression<bool>? endlessPlayback,
Expression<bool>? playerWakelock, Expression<bool>? playerWakelock,
Expression<bool>? overrideCacheProvider,
}) { }) {
return RawValuesInsertable({ return RawValuesInsertable({
if (id != null) 'id': id, if (id != null) 'id': id,
@@ -1439,6 +1479,8 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
'download_music_codec': downloadMusicCodec, 'download_music_codec': downloadMusicCodec,
if (endlessPlayback != null) 'endless_playback': endlessPlayback, if (endlessPlayback != null) 'endless_playback': endlessPlayback,
if (playerWakelock != null) 'player_wakelock': playerWakelock, if (playerWakelock != null) 'player_wakelock': playerWakelock,
if (overrideCacheProvider != null)
'override_cache_provider': overrideCacheProvider,
}); });
} }
@@ -1466,7 +1508,8 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
Value<SourceCodecs>? streamMusicCodec, Value<SourceCodecs>? streamMusicCodec,
Value<SourceCodecs>? downloadMusicCodec, Value<SourceCodecs>? downloadMusicCodec,
Value<bool>? endlessPlayback, Value<bool>? endlessPlayback,
Value<bool>? playerWakelock}) { Value<bool>? playerWakelock,
Value<bool>? overrideCacheProvider}) {
return PreferencesTableCompanion( return PreferencesTableCompanion(
id: id ?? this.id, id: id ?? this.id,
audioQuality: audioQuality ?? this.audioQuality, audioQuality: audioQuality ?? this.audioQuality,
@@ -1492,6 +1535,8 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec, downloadMusicCodec: downloadMusicCodec ?? this.downloadMusicCodec,
endlessPlayback: endlessPlayback ?? this.endlessPlayback, endlessPlayback: endlessPlayback ?? this.endlessPlayback,
playerWakelock: playerWakelock ?? this.playerWakelock, playerWakelock: playerWakelock ?? this.playerWakelock,
overrideCacheProvider:
overrideCacheProvider ?? this.overrideCacheProvider,
); );
} }
@@ -1589,6 +1634,10 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
if (playerWakelock.present) { if (playerWakelock.present) {
map['player_wakelock'] = Variable<bool>(playerWakelock.value); map['player_wakelock'] = Variable<bool>(playerWakelock.value);
} }
if (overrideCacheProvider.present) {
map['override_cache_provider'] =
Variable<bool>(overrideCacheProvider.value);
}
return map; return map;
} }
@@ -1618,7 +1667,8 @@ class PreferencesTableCompanion extends UpdateCompanion<PreferencesTableData> {
..write('streamMusicCodec: $streamMusicCodec, ') ..write('streamMusicCodec: $streamMusicCodec, ')
..write('downloadMusicCodec: $downloadMusicCodec, ') ..write('downloadMusicCodec: $downloadMusicCodec, ')
..write('endlessPlayback: $endlessPlayback, ') ..write('endlessPlayback: $endlessPlayback, ')
..write('playerWakelock: $playerWakelock') ..write('playerWakelock: $playerWakelock, ')
..write('overrideCacheProvider: $overrideCacheProvider')
..write(')')) ..write(')'))
.toString(); .toString();
} }
@@ -4109,6 +4159,7 @@ typedef $$PreferencesTableTableCreateCompanionBuilder
Value<SourceCodecs> downloadMusicCodec, Value<SourceCodecs> downloadMusicCodec,
Value<bool> endlessPlayback, Value<bool> endlessPlayback,
Value<bool> playerWakelock, Value<bool> playerWakelock,
Value<bool> overrideCacheProvider,
}); });
typedef $$PreferencesTableTableUpdateCompanionBuilder typedef $$PreferencesTableTableUpdateCompanionBuilder
= PreferencesTableCompanion Function({ = PreferencesTableCompanion Function({
@@ -4136,6 +4187,7 @@ typedef $$PreferencesTableTableUpdateCompanionBuilder
Value<SourceCodecs> downloadMusicCodec, Value<SourceCodecs> downloadMusicCodec,
Value<bool> endlessPlayback, Value<bool> endlessPlayback,
Value<bool> playerWakelock, Value<bool> playerWakelock,
Value<bool> overrideCacheProvider,
}); });
class $$PreferencesTableTableTableManager extends RootTableManager< class $$PreferencesTableTableTableManager extends RootTableManager<
@@ -4180,6 +4232,7 @@ class $$PreferencesTableTableTableManager extends RootTableManager<
Value<SourceCodecs> downloadMusicCodec = const Value.absent(), Value<SourceCodecs> downloadMusicCodec = const Value.absent(),
Value<bool> endlessPlayback = const Value.absent(), Value<bool> endlessPlayback = const Value.absent(),
Value<bool> playerWakelock = const Value.absent(), Value<bool> playerWakelock = const Value.absent(),
Value<bool> overrideCacheProvider = const Value.absent(),
}) => }) =>
PreferencesTableCompanion( PreferencesTableCompanion(
id: id, id: id,
@@ -4206,6 +4259,7 @@ class $$PreferencesTableTableTableManager extends RootTableManager<
downloadMusicCodec: downloadMusicCodec, downloadMusicCodec: downloadMusicCodec,
endlessPlayback: endlessPlayback, endlessPlayback: endlessPlayback,
playerWakelock: playerWakelock, playerWakelock: playerWakelock,
overrideCacheProvider: overrideCacheProvider,
), ),
createCompanionCallback: ({ createCompanionCallback: ({
Value<int> id = const Value.absent(), Value<int> id = const Value.absent(),
@@ -4232,6 +4286,7 @@ class $$PreferencesTableTableTableManager extends RootTableManager<
Value<SourceCodecs> downloadMusicCodec = const Value.absent(), Value<SourceCodecs> downloadMusicCodec = const Value.absent(),
Value<bool> endlessPlayback = const Value.absent(), Value<bool> endlessPlayback = const Value.absent(),
Value<bool> playerWakelock = const Value.absent(), Value<bool> playerWakelock = const Value.absent(),
Value<bool> overrideCacheProvider = const Value.absent(),
}) => }) =>
PreferencesTableCompanion.insert( PreferencesTableCompanion.insert(
id: id, id: id,
@@ -4258,6 +4313,7 @@ class $$PreferencesTableTableTableManager extends RootTableManager<
downloadMusicCodec: downloadMusicCodec, downloadMusicCodec: downloadMusicCodec,
endlessPlayback: endlessPlayback, endlessPlayback: endlessPlayback,
playerWakelock: playerWakelock, playerWakelock: playerWakelock,
overrideCacheProvider: overrideCacheProvider,
), ),
)); ));
} }
@@ -4408,6 +4464,11 @@ class $$PreferencesTableTableFilterComposer
column: $state.table.playerWakelock, column: $state.table.playerWakelock,
builder: (column, joinBuilders) => builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders)); ColumnFilters(column, joinBuilders: joinBuilders));
ColumnFilters<bool> get overrideCacheProvider => $state.composableBuilder(
column: $state.table.overrideCacheProvider,
builder: (column, joinBuilders) =>
ColumnFilters(column, joinBuilders: joinBuilders));
} }
class $$PreferencesTableTableOrderingComposer class $$PreferencesTableTableOrderingComposer
@@ -4532,6 +4593,11 @@ class $$PreferencesTableTableOrderingComposer
column: $state.table.playerWakelock, column: $state.table.playerWakelock,
builder: (column, joinBuilders) => builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders)); ColumnOrderings(column, joinBuilders: joinBuilders));
ColumnOrderings<bool> get overrideCacheProvider => $state.composableBuilder(
column: $state.table.overrideCacheProvider,
builder: (column, joinBuilders) =>
ColumnOrderings(column, joinBuilders: joinBuilders));
} }
typedef $$ScrobblerTableTableCreateCompanionBuilder = ScrobblerTableCompanion typedef $$ScrobblerTableTableCreateCompanionBuilder = ScrobblerTableCompanion

View File

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

View File

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

View File

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

View File

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

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!; return _lookedUpRealIp!;
} }
static Future<NeteaseSourcedTrack> fetchFromTrack({ static Future<SourcedTrack> fetchFromTrack({
required Track track, required Track track,
}) async { }) async {
final DatabaseProvider db = Get.find(); final DatabaseProvider db = Get.find();
@@ -93,12 +93,19 @@ class NeteaseSourcedTrack extends SourcedTrack {
throw TrackNotFoundError(track); throw TrackNotFoundError(track);
} }
final client = getClient();
final checkResp = await client.get(
'/check/music?id=${siblings.first.info.id}&realIP=${await lookupRealIp()}',
);
if (checkResp.body['success'] != true) throw TrackNotFoundError(track);
await db.database.into(db.database.sourceMatchTable).insert( await db.database.into(db.database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert( SourceMatchTableCompanion.insert(
trackId: track.id!, trackId: track.id!,
sourceId: siblings.first.info.id, sourceId: siblings.first.info.id,
sourceType: const Value(SourceType.netease), sourceType: const Value(SourceType.netease),
), ),
mode: InsertMode.insertOrReplace,
); );
return NeteaseSourcedTrack( return NeteaseSourcedTrack(
@@ -107,12 +114,24 @@ class NeteaseSourcedTrack extends SourcedTrack {
sourceInfo: siblings.first.info, sourceInfo: siblings.first.info,
track: track, track: track,
); );
} else if (cachedSource.sourceType != SourceType.netease) {
final out =
await SourcedTrack.reRoutineFetchFromTrack(track, cachedSource);
if (out == null) throw TrackNotFoundError(track);
return out;
} }
final client = getClient(); final client = getClient();
final resp = await client.get('/song/detail?ids=${cachedSource.sourceId}'); final resp = await client.get('/song/detail?ids=${cachedSource.sourceId}');
print(resp.body); if (resp.body?['songs'] == null) throw TrackNotFoundError(track);
final item = resp.body['songs'][0]; final item = (resp.body['songs'] as List<dynamic>).firstOrNull;
if (item == null) throw TrackNotFoundError(track);
final checkResp = await client.get(
'/check/music?id=${item['id']}&realIP=${await lookupRealIp()}',
);
if (checkResp.body['success'] != true) throw TrackNotFoundError(track);
return NeteaseSourcedTrack( return NeteaseSourcedTrack(
siblings: [], siblings: [],
@@ -155,8 +174,10 @@ class NeteaseSourcedTrack extends SourcedTrack {
final query = SourcedTrack.getSearchTerm(track); final query = SourcedTrack.getSearchTerm(track);
final client = getClient(); final client = getClient();
final resp = final resp = await client.get(
await client.get('/search?keywords=${Uri.encodeComponent(query)}'); '/search?keywords=${Uri.encodeComponent(query)}&realIP=${await NeteaseSourcedTrack.lookupRealIp()}',
);
if (resp.body?['code'] == 405) throw TrackNotFoundError(track);
final results = resp.body['result']['songs']; final results = resp.body['result']['songs'];
// We can just trust netease music for now // We can just trust netease music for now
@@ -186,7 +207,11 @@ class NeteaseSourcedTrack extends SourcedTrack {
} }
@override @override
Future<NeteaseSourcedTrack?> swapWithSibling(SourceInfo sibling) async { Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
if (sibling is! NeteaseSourceInfo) {
return reRoutineSwapSiblings(sibling);
}
if (sibling.id == sourceInfo.id) { if (sibling.id == sourceInfo.id) {
return null; return null;
} }
@@ -202,7 +227,7 @@ class NeteaseSourcedTrack extends SourcedTrack {
final client = getClient(); final client = getClient();
final resp = await client.get('/song/detail?ids=${newSourceInfo.id}'); final resp = await client.get('/song/detail?ids=${newSourceInfo.id}');
final item = resp.body['songs'][0]; final item = (resp.body['songs'] as List<dynamic>).first;
final (:info, :source) = toSiblingType(item); final (:info, :source) = toSiblingType(item);
@@ -227,11 +252,10 @@ class NeteaseSourcedTrack extends SourcedTrack {
); );
} }
static SiblingType toSiblingType(dynamic item) { static NeteaseSourceInfo toSourceInfo(dynamic item) {
final firstArtist = item['ar'] != null ? item['ar'][0] : item['artists'][0]; final firstArtist = item['ar'] != null ? item['ar'][0] : item['artists'][0];
final SiblingType sibling = ( return NeteaseSourceInfo(
info: NeteaseSourceInfo(
id: item['id'].toString(), id: item['id'].toString(),
artist: item['ar'] != null artist: item['ar'] != null
? item['ar'].map((x) => x['name']).join(',') ? item['ar'].map((x) => x['name']).join(',')
@@ -245,7 +269,12 @@ class NeteaseSourcedTrack extends SourcedTrack {
? Duration(milliseconds: item['dt']) ? Duration(milliseconds: item['dt'])
: Duration(milliseconds: item['duration']), : Duration(milliseconds: item['duration']),
album: item['al']?['name'], album: item['al']?['name'],
), );
}
static SiblingType toSiblingType(dynamic item) {
final SiblingType sibling = (
info: toSourceInfo(item),
source: toSourceMap(item), source: toSourceMap(item),
); );

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:rhythm_box/services/duration.dart'; import 'package:rhythm_box/services/duration.dart';
import 'package:rhythm_box/services/sourced_track/sourced_track.dart'; import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
import 'package:rhythm_box/services/sourced_track/sources/kugou.dart';
import 'package:rhythm_box/services/sourced_track/sources/netease.dart'; import 'package:rhythm_box/services/sourced_track/sources/netease.dart';
import 'package:rhythm_box/services/sourced_track/sources/piped.dart'; import 'package:rhythm_box/services/sourced_track/sources/piped.dart';
import 'package:rhythm_box/services/sourced_track/sources/youtube.dart'; import 'package:rhythm_box/services/sourced_track/sources/youtube.dart';
@@ -14,6 +15,7 @@ class TrackSourceDetails extends StatelessWidget {
YoutubeSourceInfo: 'YouTube', YoutubeSourceInfo: 'YouTube',
PipedSourceInfo: 'Piped', PipedSourceInfo: 'Piped',
NeteaseSourceInfo: 'Netease', NeteaseSourceInfo: 'Netease',
KugouSourceInfo: 'Kugou',
}; };
@override @override

View File

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

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 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+10 version: 1.0.0+18
environment: environment:
sdk: ^3.5.0 sdk: ^3.5.0
@@ -180,4 +180,3 @@ flutter_native_splash:
color: "#fef8f5" color: "#fef8f5"
color_dark: "#18120d" color_dark: "#18120d"
image: assets/icon-w-shadow.png image: assets/icon-w-shadow.png