Compare commits
	
		
			8 Commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| a6b40e81a7 | |||
| 1913a7e909 | |||
| d860936010 | |||
| 873ad1cf8c | |||
| e0c9edad78 | |||
| 70ea02962f | |||
| 59783c48f7 | |||
| b099f63f61 | 
							
								
								
									
										25
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										25
									
								
								README.md
									
									
									
									
									
								
							| @@ -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. | ||||
|  | ||||
|   | ||||
| @@ -223,15 +223,40 @@ class _SettingsScreenState extends State<SettingsScreen> { | ||||
|               Obx( | ||||
|                 () => CheckboxListTile( | ||||
|                   contentPadding: const EdgeInsets.symmetric(horizontal: 24), | ||||
|                   secondary: const Icon(Icons.all_inclusive), | ||||
|                   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.endlessPlayback, | ||||
|                   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( | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import 'dart:convert'; | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:dio/dio.dart' hide Response; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| @@ -41,8 +42,17 @@ class ServerPlaybackRoutesProvider { | ||||
|         // Special processing for kugou to get real assets url | ||||
|         final resp = await GetConnect(timeout: const Duration(seconds: 30)) | ||||
|             .get(sourcedTrack.url); | ||||
|         final realUrl = | ||||
|             KugouSourcedTrack.unescapeUrl(jsonDecode(resp.body)['url'][0]); | ||||
|         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; | ||||
|       } | ||||
|  | ||||
|   | ||||
| @@ -5,6 +5,7 @@ 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'; | ||||
| @@ -81,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) | ||||
| @@ -160,7 +188,7 @@ abstract class SourcedTrack extends Track { | ||||
|  | ||||
|       return switch (audioSource) { | ||||
|         AudioSource.netease => | ||||
|           await fetchFromTrack(track: track, fallbackTo: AudioSource.kugou), | ||||
|           await fetchFromTrack(track: track, fallbackTo: AudioSource.youtube), | ||||
|         AudioSource.kugou => | ||||
|           await fetchFromTrack(track: track, fallbackTo: AudioSource.youtube), | ||||
|         _ => | ||||
|   | ||||
| @@ -51,7 +51,7 @@ class KugouSourcedTrack extends SourcedTrack { | ||||
|     return client; | ||||
|   } | ||||
|  | ||||
|   static Future<KugouSourcedTrack> fetchFromTrack({ | ||||
|   static Future<SourcedTrack> fetchFromTrack({ | ||||
|     required Track track, | ||||
|   }) async { | ||||
|     final DatabaseProvider db = Get.find(); | ||||
| @@ -77,6 +77,7 @@ class KugouSourcedTrack extends SourcedTrack { | ||||
|               sourceId: siblings.first.info.id, | ||||
|               sourceType: const Value(SourceType.kugou), | ||||
|             ), | ||||
|             mode: InsertMode.insertOrReplace, | ||||
|           ); | ||||
|  | ||||
|       return KugouSourcedTrack( | ||||
| @@ -85,6 +86,11 @@ 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( | ||||
| @@ -109,7 +115,9 @@ class KugouSourcedTrack extends SourcedTrack { | ||||
|  | ||||
|     final hash = manifest is SourceMatchTableData | ||||
|         ? manifest.sourceId | ||||
|         : manifest?['hash']; | ||||
|         : 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'; | ||||
| @@ -142,7 +150,8 @@ 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.map(toSiblingType).toList(); | ||||
|     final matchedResults = | ||||
|         results.where((x) => x['pay_type'] == 0).map(toSiblingType).toList(); | ||||
|  | ||||
|     return matchedResults.cast<SiblingType>(); | ||||
|   } | ||||
| @@ -166,7 +175,11 @@ class KugouSourcedTrack extends SourcedTrack { | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<KugouSourcedTrack?> swapWithSibling(SourceInfo sibling) async { | ||||
|   Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async { | ||||
|     if (sibling is! KugouSourceInfo) { | ||||
|       return reRoutineSwapSiblings(sibling); | ||||
|     } | ||||
|  | ||||
|     if (sibling.id == sourceInfo.id) { | ||||
|       return null; | ||||
|     } | ||||
| @@ -181,7 +194,7 @@ class KugouSourcedTrack extends SourcedTrack { | ||||
|       ..insert(0, sourceInfo); | ||||
|  | ||||
|     final info = newSourceInfo as KugouSourceInfo; | ||||
|     final source = toSourceMap(newSourceInfo.id); | ||||
|     final source = toSourceMap(newSourceInfo); | ||||
|  | ||||
|     final db = Get.find<DatabaseProvider>(); | ||||
|     await db.database.into(db.database.sourceMatchTable).insert( | ||||
|   | ||||
| @@ -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(); | ||||
| @@ -105,6 +105,7 @@ class NeteaseSourcedTrack extends SourcedTrack { | ||||
|               sourceId: siblings.first.info.id, | ||||
|               sourceType: const Value(SourceType.netease), | ||||
|             ), | ||||
|             mode: InsertMode.insertOrReplace, | ||||
|           ); | ||||
|  | ||||
|       return NeteaseSourcedTrack( | ||||
| @@ -113,10 +114,16 @@ 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); | ||||
| @@ -168,7 +175,7 @@ class NeteaseSourcedTrack extends SourcedTrack { | ||||
|  | ||||
|     final client = getClient(); | ||||
|     final resp = await client.get( | ||||
|       '/search?keywords=${Uri.encodeComponent(query)}&realIP=${NeteaseSourcedTrack.lookupRealIp()}', | ||||
|       '/search?keywords=${Uri.encodeComponent(query)}&realIP=${await NeteaseSourcedTrack.lookupRealIp()}', | ||||
|     ); | ||||
|     if (resp.body?['code'] == 405) throw TrackNotFoundError(track); | ||||
|     final results = resp.body['result']['songs']; | ||||
| @@ -200,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; | ||||
|     } | ||||
|   | ||||
| @@ -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; | ||||
|     } | ||||
|   | ||||
| @@ -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(); | ||||
| @@ -78,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); | ||||
| @@ -269,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; | ||||
|     } | ||||
|   | ||||
| @@ -1,4 +1,5 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| @@ -119,11 +120,32 @@ 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=${NeteaseSourcedTrack.lookupRealIp()}'); | ||||
|             '/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, | ||||
|         ); | ||||
|       } 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 | ||||
|   | ||||
| @@ -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+13 | ||||
| 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 | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user