Compare commits

..

No commits in common. "master" and "1.0.0+13" have entirely different histories.

10 changed files with 25 additions and 166 deletions

View File

@ -5,30 +5,19 @@ 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
- [x] Add kugou music as source
- [x] Optimize fallback strategy
- [x] Add netease music as source
- [ ] Add bilibili as source
- [ ] Add kuwo music as source
- [ ] Add kugo music as source
- [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

@ -223,40 +223,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
Obx(
() => CheckboxListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 24),
secondary: const Icon(Icons.update),
secondary: const Icon(Icons.all_inclusive),
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,
value: _preferences.state.value.endlessPlayback,
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(

View File

@ -1,5 +1,4 @@
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart' hide Response;
import 'package:flutter/foundation.dart';
@ -42,17 +41,8 @@ class ServerPlaybackRoutesProvider {
// 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);
final realUrl =
KugouSourcedTrack.unescapeUrl(jsonDecode(resp.body)['url'][0]);
url = realUrl;
}

View File

@ -5,7 +5,6 @@ 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';
@ -82,33 +81,6 @@ 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)
@ -188,7 +160,7 @@ abstract class SourcedTrack extends Track {
return switch (audioSource) {
AudioSource.netease =>
await fetchFromTrack(track: track, fallbackTo: AudioSource.youtube),
await fetchFromTrack(track: track, fallbackTo: AudioSource.kugou),
AudioSource.kugou =>
await fetchFromTrack(track: track, fallbackTo: AudioSource.youtube),
_ =>

View File

@ -51,7 +51,7 @@ class KugouSourcedTrack extends SourcedTrack {
return client;
}
static Future<SourcedTrack> fetchFromTrack({
static Future<KugouSourcedTrack> fetchFromTrack({
required Track track,
}) async {
final DatabaseProvider db = Get.find();
@ -77,7 +77,6 @@ class KugouSourcedTrack extends SourcedTrack {
sourceId: siblings.first.info.id,
sourceType: const Value(SourceType.kugou),
),
mode: InsertMode.insertOrReplace,
);
return KugouSourcedTrack(
@ -86,11 +85,6 @@ class KugouSourcedTrack extends SourcedTrack {
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(
@ -115,9 +109,7 @@ class KugouSourcedTrack extends SourcedTrack {
final hash = manifest is SourceMatchTableData
? manifest.sourceId
: manifest is KugouSourceInfo
? manifest.id
: manifest?['hash'];
: 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';
@ -150,8 +142,7 @@ class KugouSourcedTrack extends SourcedTrack {
// 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();
final matchedResults = results.map(toSiblingType).toList();
return matchedResults.cast<SiblingType>();
}
@ -175,11 +166,7 @@ class KugouSourcedTrack extends SourcedTrack {
}
@override
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
if (sibling is! KugouSourceInfo) {
return reRoutineSwapSiblings(sibling);
}
Future<KugouSourcedTrack?> swapWithSibling(SourceInfo sibling) async {
if (sibling.id == sourceInfo.id) {
return null;
}
@ -194,7 +181,7 @@ class KugouSourcedTrack extends SourcedTrack {
..insert(0, sourceInfo);
final info = newSourceInfo as KugouSourceInfo;
final source = toSourceMap(newSourceInfo);
final source = toSourceMap(newSourceInfo.id);
final db = Get.find<DatabaseProvider>();
await db.database.into(db.database.sourceMatchTable).insert(

View File

@ -73,7 +73,7 @@ class NeteaseSourcedTrack extends SourcedTrack {
return _lookedUpRealIp!;
}
static Future<SourcedTrack> fetchFromTrack({
static Future<NeteaseSourcedTrack> fetchFromTrack({
required Track track,
}) async {
final DatabaseProvider db = Get.find();
@ -105,7 +105,6 @@ class NeteaseSourcedTrack extends SourcedTrack {
sourceId: siblings.first.info.id,
sourceType: const Value(SourceType.netease),
),
mode: InsertMode.insertOrReplace,
);
return NeteaseSourcedTrack(
@ -114,16 +113,10 @@ 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}');
if (resp.body?['songs'] == null) throw TrackNotFoundError(track);
final item = (resp.body['songs'] as List<dynamic>).firstOrNull;
if (item == null) throw TrackNotFoundError(track);
@ -175,7 +168,7 @@ class NeteaseSourcedTrack extends SourcedTrack {
final client = getClient();
final resp = await client.get(
'/search?keywords=${Uri.encodeComponent(query)}&realIP=${await NeteaseSourcedTrack.lookupRealIp()}',
'/search?keywords=${Uri.encodeComponent(query)}&realIP=${NeteaseSourcedTrack.lookupRealIp()}',
);
if (resp.body?['code'] == 405) throw TrackNotFoundError(track);
final results = resp.body['result']['songs'];
@ -207,11 +200,7 @@ class NeteaseSourcedTrack extends SourcedTrack {
}
@override
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
if (sibling is! NeteaseSourceInfo) {
return reRoutineSwapSiblings(sibling);
}
Future<NeteaseSourcedTrack?> swapWithSibling(SourceInfo sibling) async {
if (sibling.id == sourceInfo.id) {
return null;
}

View File

@ -57,14 +57,6 @@ 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) {
@ -81,7 +73,6 @@ class PipedSourcedTrack extends SourcedTrack {
: SourceType.youtubeMusic,
),
),
mode: InsertMode.insertOrReplace,
);
return PipedSourcedTrack(
@ -264,10 +255,6 @@ 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<SourcedTrack> fetchFromTrack({
static Future<YoutubeSourcedTrack> fetchFromTrack({
required Track track,
}) async {
final DatabaseProvider db = Get.find();
@ -78,11 +78,6 @@ 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);
@ -274,11 +269,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
}
@override
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
if (sibling is! YoutubeSourceInfo) {
return reRoutineSwapSiblings(sibling);
}
Future<YoutubeSourcedTrack?> swapWithSibling(SourceInfo sibling) async {
if (sibling.id == sourceInfo.id) {
return null;
}

View File

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:convert';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
@ -120,32 +119,11 @@ class _SiblingTracksState extends State<SiblingTracks> {
} else if (preferences.audioSource == AudioSource.netease) {
final client = NeteaseSourcedTrack.getClient();
final resp = await client.get(
'/search?keywords=${Uri.encodeComponent(searchTerm)}&realIP=${await NeteaseSourcedTrack.lookupRealIp()}');
'/search?keywords=${Uri.encodeComponent(searchTerm)}&realIP=${NeteaseSourcedTrack.lookupRealIp()}');
final searchResults = resp.body['result']['songs']
.map(NeteaseSourcedTrack.toSourceInfo)
.toList();
final activeSourceInfo = (_activeTrack! as SourcedTrack).sourceInfo;
_siblings = List.from(
searchResults
..removeWhere((element) => element.id == activeSourceInfo.id)
..insert(
0,
activeSourceInfo,
),
growable: true,
);
} else if (preferences.audioSource == AudioSource.kugou) {
final client = KugouSourcedTrack.getClient();
final resp = await client.get(
'/api/v3/search/song?keyword=${Uri.encodeComponent(searchTerm)}&page=1&pagesize=10',
);
final results = jsonDecode(resp.body)['data']['info'];
final searchResults = results
.where((x) => x['pay_type'] == 0)
.map(KugouSourcedTrack.toSourceInfo)
.toList();
final activeSourceInfo = (_activeTrack! as SourcedTrack).sourceInfo;
_siblings = List.from(
searchResults

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+18
version: 1.0.0+13
environment:
sdk: ^3.5.0
@ -180,3 +180,4 @@ flutter_native_splash:
color: "#fef8f5"
color_dark: "#18120d"
image: assets/icon-w-shadow.png