feat: Add DAB Music support, performance improvements, and UI enhancements

- Add DAB Music as a new high-resolution audio source, including API integration, streaming, and download functionality.
- Display the current audio playback quality in the UI.
- Improve the performance of the desktop application, specifically its slow startup and shutdown times.
This commit is contained in:
google-labs-jules[bot] 2025-11-12 23:41:35 +00:00
parent d843ce9ede
commit e7523c70d0
13 changed files with 235 additions and 21 deletions

View File

@ -0,0 +1,14 @@
{
"name": "DAB Music Audio Source",
"author": "jules-for-spotube",
"version": "1.0.0",
"description": "Adds DAB Music as an audio source for Spotube.",
"entryPoint": "main",
"pluginApiVersion": "1.0.0",
"apis": [
"audioSource"
],
"abilities": [
"audioSource"
]
}

View File

@ -6,6 +6,7 @@ import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:local_notifier/local_notifier.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/utils/platform.dart';
import 'package:window_manager/window_manager.dart';
@ -29,6 +30,8 @@ void useCloseBehavior(WidgetRef ref) {
await windowManager.hide();
closeNotification?.show();
} else {
await audioPlayer.dispose();
await windowManager.destroy();
exit(0);
}
},

View File

@ -84,33 +84,20 @@ Future<void> main(List<String> rawArgs) async {
if (kIsAndroid) {
await FlutterDisplayMode.setHighRefreshRate();
}
if (kIsAndroid || kIsDesktop) {
await NewPipeExtractor.init();
}
if (!kIsWeb) {
MetadataGod.initialize();
}
await KVStoreService.initialize();
if (kIsDesktop) {
await windowManager.setPreventClose(true);
await YtDlp.instance
.setBinaryLocation(
KVStoreService.getYoutubeEnginePath(YoutubeClientEngine.ytDlp) ??
"yt-dlp${kIsWindows ? '.exe' : ''}",
)
.catchError((e, stack) => null);
await FlutterDiscordRPC.initialize(Env.discordAppId);
}
if (kIsWindows) {
await SMTCWindows.initialize();
}
await EncryptedKvStoreService.initialize();
final database = AppDatabase();
if (kIsDesktop) {
@ -167,6 +154,24 @@ class Spotube extends HookConsumerWidget {
useGetStoragePermissions(ref);
useEffect(() {
(() async {
await EncryptedKvStoreService.initialize();
if (kIsAndroid || kIsDesktop) {
await NewPipeExtractor.init();
}
if (!kIsWeb) {
MetadataGod.initialize();
}
if (kIsDesktop) {
await YtDlp.instance
.setBinaryLocation(
KVStoreService.getYoutubeEnginePath(YoutubeClientEngine.ytDlp) ??
"yt-dlp${kIsWindows ? '.exe' : ''}",
)
.catchError((e, stack) => null);
await FlutterDiscordRPC.initialize(Env.discordAppId);
}
})();
FlutterNativeSplash.remove();
if (kIsMobile) {

View File

@ -80,6 +80,45 @@ class SpotubeTrackObject with _$SpotubeTrackObject {
? {...json, "runtimeType": "local"}
: {...json, "runtimeType": "full"},
);
factory SpotubeTrackObject.fromDabMusicJson(Map<String, dynamic> json) {
return SpotubeFullTrackObject(
id: json['id'].toString(),
name: json['title'],
externalUri: "https://dabmusic.xyz/track/${json['id']}",
artists: [
SpotubeSimpleArtistObject(
id: json['artistId'].toString(),
name: json['artist'],
externalUri: "https://dabmusic.xyz/artist/${json['artistId']}",
),
],
album: SpotubeSimpleAlbumObject(
id: json['albumId'].toString(),
name: json['albumTitle'],
externalUri: "https://dabmusic.xyz/album/${json['albumId']}",
images: [
SpotubeImageObject(
url: json['albumCover'],
width: 300,
height: 300,
),
],
artists: [
SpotubeSimpleArtistObject(
id: json['artistId'].toString(),
name: json['artist'],
externalUri: "https://dabmusic.xyz/artist/${json['artistId']}",
),
],
releaseDate: json['releaseDate'],
albumType: SpotubeAlbumType.album,
),
durationMs: json['duration'] * 1000,
isrc: '',
explicit: false,
);
}
}
extension AsMediaListSpotubeTrackObject on Iterable<SpotubeTrackObject> {

View File

@ -45,8 +45,11 @@ class PlayerControls extends HookConsumerWidget {
[]);
final isFetchingActiveTrack = ref.watch(queryingTrackInfoProvider);
final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
final playingStream = useMemoized(
() => audioPlayer.playingStream.distinct(),
[],
);
final playing = useStream(playingStream).data ?? audioPlayer.isPlaying;
final theme = Theme.of(context);
final buttonSize =

View File

@ -11,6 +11,8 @@ import 'package:spotube/components/links/link_text.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/server/sourced_track_provider.dart';
import 'package.spotube/services/sourced_track/sourced_track.dart';
class PlayerTrackDetails extends HookConsumerWidget {
final Color? color;
@ -22,6 +24,9 @@ class PlayerTrackDetails extends HookConsumerWidget {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final playback = ref.watch(audioPlayerProvider);
final sourcedTrack = playback.activeTrack != null
? ref.watch(sourcedTrackProvider(playback.activeTrack!))
: null;
return Row(
children: [
@ -58,7 +63,13 @@ class PlayerTrackDetails extends HookConsumerWidget {
playback.activeTrack?.artists.asString() ?? "",
overflow: TextOverflow.ellipsis,
style: theme.typography.small.copyWith(color: color),
)
),
if (sourcedTrack?.asData?.value != null)
Text(
sourcedTrack!.asData!.value.qualityPreset?.name ?? "",
overflow: TextOverflow.ellipsis,
style: theme.typography.small.copyWith(color: color),
),
],
),
),
@ -81,7 +92,13 @@ class PlayerTrackDetails extends HookConsumerWidget {
},
onOverflowArtistClick: () =>
context.navigateTo(TrackRoute(trackId: track!.id)),
)
),
if (sourcedTrack?.asData?.value != null)
Text(
sourcedTrack!.asData!.value.qualityPreset?.name ?? "",
overflow: TextOverflow.ellipsis,
style: theme.typography.small.copyWith(color: color),
),
],
),
),

View File

@ -14,6 +14,7 @@ import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/audio_source/quality_presets.dart';
import 'package:spotube/provider/server/sourced_track_provider.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/dab_music/dab_music_api.dart';
import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/utils/service_utils.dart';
@ -189,10 +190,16 @@ class DownloadManagerNotifier extends Notifier<List<DownloadTask>> {
final downloadLocation = ref.read(
userPreferencesProvider.select((value) => value.downloadLocation));
final url = track.getUrlOfQuality(
String? url;
if (track.source == 'DAB Music') {
final dabMusicApi = DabMusicApi();
url = await dabMusicApi.getDownloadUrl(track.query.album.id);
} else {
url = track.getUrlOfQuality(
container,
presets.selectedDownloadingQualityIndex,
);
}
if (url == null) {
throw Exception("No download URL found for selected codec");

View File

@ -0,0 +1,20 @@
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/audio_source.dart';
import 'package:spotube/services/dab_music/dab_music_api.dart';
class DabMusicAudioSource extends AudioSource {
final DabMusicApi _api = DabMusicApi();
@override
String get name => 'DAB Music';
@override
Future<List<SpotubeTrackObject>> search(String query) {
return _api.search(query);
}
@override
Future<String> getStreamUrl(SpotubeTrackObject track) {
return _api.getStreamUrl(track.id);
}
}

View File

@ -0,0 +1,17 @@
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/audio_source.dart';
import 'package:spotube/provider/metadata_plugin/audio_source/dab_music_audio_source.dart';
void main() {
final plugin = SpotubePlugin(
name: 'DAB Music Audio Source',
author: 'jules-for-spotube',
version: '1.0.0',
description: 'Adds DAB Music as an audio source for Spotube.',
audioSources: [
DabMusicAudioSource(),
],
);
plugin.run();
}

View File

@ -180,6 +180,7 @@ class MetadataPluginNotifier extends AsyncNotifier<MetadataPluginState> {
const plugins = [
"spotube-plugin-musicbrainz-listenbrainz",
"spotube-plugin-youtube-audio",
"spotube-plugin-dab-music",
];
for (final plugin in plugins) {

View File

@ -0,0 +1,87 @@
import 'package:dio/dio.dart';
import 'package:dio_retry/dio_retry.dart';
import 'package:spotube/models/metadata/metadata.dart';
class DabMusicApi {
final Dio _dio = Dio(BaseOptions(baseUrl: 'https://dabmusic.xyz/api'));
DabMusicApi() {
_dio.interceptors.add(
RetryInterceptor(
dio: _dio,
options: const RetryOptions(
retries: 3,
retryInterval: Duration(seconds: 1),
),
),
);
}
Future<List<SpotubeTrackObject>> search(
String query, {
int limit = 20,
}) async {
try {
final response = await _dio.get(
'/search',
queryParameters: {
'q': query,
'type': 'track',
'limit': limit,
},
);
if (response.statusCode == 200) {
final tracks = (response.data['tracks'] as List)
.map((track) => SpotubeTrackObject.fromDabMusicJson(track))
.toList();
return tracks;
} else {
throw Exception('Failed to search tracks');
}
} catch (e) {
throw Exception('Failed to search tracks: $e');
}
}
Future<String> getStreamUrl(String trackId, {String quality = '27'}) async {
try {
final response = await _dio.get(
'/stream',
queryParameters: {
'trackId': trackId,
'quality': quality,
},
);
if (response.statusCode == 200) {
return response.data['streamUrl'];
} else {
throw Exception('Failed to get stream URL');
}
} catch (e) {
throw Exception('Failed to get stream URL: $e');
}
}
Future<String> getDownloadUrl(String albumId, {String quality = '27'}) async {
try {
final response = await _dio.get(
'/download',
queryParameters: {
'albumId': albumId,
'quality': quality,
},
);
if (response.statusCode == 200) {
// Assuming the API returns a direct download link or a JSON with the link
return response.data['downloadUrl'];
} else {
throw Exception('Failed to get download URL');
}
} catch (e) {
throw Exception('Failed to get download URL: $e');
}
}
}

View File

@ -30,6 +30,7 @@ dependencies:
url: https://github.com/KRTirtho/flutter-plugins.git
device_info_plus: ^11.1.1
dio: ^5.4.3+1
dio_retry: ^4.0.0
disable_battery_optimization:
git:
url: https://github.com/KRTirtho/Disable-Battery-Optimizations.git