feat: Integrate DAB Music, add quality display, and optimize performance

This commit introduces several new features and improvements to Spotube:

- **DAB Music Integration:** Adds DAB Music as a new high-quality audio source, with support for searching, streaming, and downloading tracks.
- **Playback Quality Display:** Adds a UI element to the player to display the actual audio quality of the currently playing stream.
- **Performance Optimization:** Improves the startup and shutdown performance of the desktop application.
- **Dependency Fix:** Resolves a dependency conflict with `dio_retry` by implementing a custom retry interceptor.
This commit is contained in:
google-labs-jules[bot] 2025-11-14 03:10:35 +00:00
parent e7523c70d0
commit f3e3159ca9
14 changed files with 219 additions and 51 deletions

View File

@ -156,21 +156,9 @@ class Spotube extends HookConsumerWidget {
useEffect(() { useEffect(() {
(() async { (() async {
await EncryptedKvStoreService.initialize(); await EncryptedKvStoreService.initialize();
if (kIsAndroid || kIsDesktop) {
await NewPipeExtractor.init();
}
if (!kIsWeb) { if (!kIsWeb) {
MetadataGod.initialize(); 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(); FlutterNativeSplash.remove();
@ -178,6 +166,21 @@ class Spotube extends HookConsumerWidget {
HomeWidget.registerInteractivityCallback(glanceBackgroundCallback); HomeWidget.registerInteractivityCallback(glanceBackgroundCallback);
} }
Future.delayed(const Duration(seconds: 5), () {
if (kIsAndroid || kIsDesktop) {
NewPipeExtractor.init();
}
if (kIsDesktop) {
YtDlp.instance
.setBinaryLocation(
KVStoreService.getYoutubeEnginePath(YoutubeClientEngine.ytDlp) ??
"yt-dlp${kIsWindows ? '.exe' : ''}",
)
.catchError((e, stack) => null);
FlutterDiscordRPC.initialize(Env.discordAppId);
}
});
return () { return () {
/// For enabling hot reload for audio player /// For enabling hot reload for audio player
if (!kDebugMode) return; if (!kDebugMode) return;

View File

@ -0,0 +1,27 @@
enum AudioQuality {
low,
high,
lossless;
String toDabMusicQuality() {
switch (this) {
case AudioQuality.low:
return '12';
case AudioQuality.high:
return '27';
case AudioQuality.lossless:
return '28';
}
}
String toShortString() {
switch (this) {
case AudioQuality.low:
return 'Low';
case AudioQuality.high:
return 'High';
case AudioQuality.lossless:
return 'Lossless';
}
}
}

View File

@ -66,7 +66,7 @@ class AppDatabase extends _$AppDatabase {
AppDatabase() : super(_openConnection()); AppDatabase() : super(_openConnection());
@override @override
int get schemaVersion => 10; int get schemaVersion => 11;
@override @override
MigrationStrategy get migration { MigrationStrategy get migration {
@ -237,6 +237,12 @@ class AppDatabase extends _$AppDatabase {
.dropColumn(schema.sourceMatchTable, "source_id") .dropColumn(schema.sourceMatchTable, "source_id")
.catchError((e, stack) => AppLogger.reportError(e, stack)); .catchError((e, stack) => AppLogger.reportError(e, stack));
}, },
from10To11: (m, schema) async {
await m.addColumn(
schema.preferencesTable,
schema.preferencesTable.audioQuality,
);
},
), ),
); );
} }

View File

@ -1,5 +1,7 @@
part of '../database.dart'; part of '../database.dart';
import 'package:spotube/models/audio_quality.dart';
enum LayoutMode { enum LayoutMode {
compact, compact,
extended, extended,
@ -79,6 +81,8 @@ class PreferencesTable extends Table {
TextColumn get themeMode => TextColumn get themeMode =>
textEnum<ThemeMode>().withDefault(Constant(ThemeMode.system.name))(); textEnum<ThemeMode>().withDefault(Constant(ThemeMode.system.name))();
TextColumn get audioSourceId => text().nullable()(); TextColumn get audioSourceId => text().nullable()();
TextColumn get audioQuality =>
textEnum<AudioQuality>().withDefault(Constant(AudioQuality.high.name))();
TextColumn get youtubeClientEngine => textEnum<YoutubeClientEngine>() TextColumn get youtubeClientEngine => textEnum<YoutubeClientEngine>()
.withDefault(Constant(YoutubeClientEngine.youtubeExplode.name))(); .withDefault(Constant(YoutubeClientEngine.youtubeExplode.name))();
BoolColumn get discordPresence => BoolColumn get discordPresence =>
@ -119,6 +123,7 @@ class PreferencesTable extends Table {
enableConnect: false, enableConnect: false,
cacheMusic: true, cacheMusic: true,
connectPort: -1, connectPort: -1,
audioQuality: AudioQuality.high,
); );
} }
} }

View File

@ -21,7 +21,7 @@ import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/modules/root/spotube_navigation_bar.dart'; import 'package:spotube/modules/root/spotube_navigation_bar.dart';
import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/metadata_plugin/audio_source/quality_label.dart'; import 'package:spotube/provider/player/playback_quality_provider.dart';
import 'package:spotube/provider/server/active_track_sources.dart'; import 'package:spotube/provider/server/active_track_sources.dart';
import 'package:spotube/provider/volume_provider.dart'; import 'package:spotube/provider/volume_provider.dart';
@ -43,7 +43,7 @@ class PlayerView extends HookConsumerWidget {
final currentActiveTrackSource = sourcedCurrentTrack.asData?.value?.source; final currentActiveTrackSource = sourcedCurrentTrack.asData?.value?.source;
final isLocalTrack = currentActiveTrack is SpotubeLocalTrackObject; final isLocalTrack = currentActiveTrack is SpotubeLocalTrackObject;
final mediaQuery = MediaQuery.sizeOf(context); final mediaQuery = MediaQuery.sizeOf(context);
final qualityLabel = ref.watch(audioSourceQualityLabelProvider); final quality = ref.watch(playbackQualityProvider);
final shouldHide = useState(true); final shouldHide = useState(true);
@ -262,7 +262,7 @@ class PlayerView extends HookConsumerWidget {
}, },
), ),
leading: const Icon(SpotubeIcons.lightningOutlined), leading: const Icon(SpotubeIcons.lightningOutlined),
child: Text(qualityLabel), child: Text(quality.toShortString()),
) )
], ],
), ),

View File

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

View File

@ -193,7 +193,7 @@ class DownloadManagerNotifier extends Notifier<List<DownloadTask>> {
String? url; String? url;
if (track.source == 'DAB Music') { if (track.source == 'DAB Music') {
final dabMusicApi = DabMusicApi(); final dabMusicApi = DabMusicApi();
url = await dabMusicApi.getDownloadUrl(track.query.album.id); url = await dabMusicApi.getDownloadUrl(track.query.id);
} else { } else {
url = track.getUrlOfQuality( url = track.getUrlOfQuality(
container, container,

View File

@ -1,9 +1,15 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/audio_quality.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/metadata_plugin/audio_source.dart'; import 'package:spotube/provider/metadata_plugin/audio_source.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/dab_music/dab_music_api.dart';
class DabMusicAudioSource extends AudioSource { class DabMusicAudioSource extends AudioSource {
final DabMusicApi _api = DabMusicApi(); final DabMusicApi _api = DabMusicApi();
final Ref? ref;
DabMusicAudioSource([this.ref]);
@override @override
String get name => 'DAB Music'; String get name => 'DAB Music';
@ -15,6 +21,7 @@ class DabMusicAudioSource extends AudioSource {
@override @override
Future<String> getStreamUrl(SpotubeTrackObject track) { Future<String> getStreamUrl(SpotubeTrackObject track) {
return _api.getStreamUrl(track.id); final quality = ref?.read(userPreferencesProvider).audioQuality ?? AudioQuality.high;
return _api.getStreamUrl(track.id, quality: quality);
} }
} }

View File

@ -0,0 +1,58 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/audio_quality.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
class PlaybackQualityState {
final AudioQuality selectedQuality;
final double? bitrate;
PlaybackQualityState({
required this.selectedQuality,
this.bitrate,
});
PlaybackQualityState copyWith({
AudioQuality? selectedQuality,
double? bitrate,
}) {
return PlaybackQualityState(
selectedQuality: selectedQuality ?? this.selectedQuality,
bitrate: bitrate ?? this.bitrate,
);
}
String toShortString() {
if (bitrate != null) {
return '${(bitrate! / 1000).round()} kbps';
}
return selectedQuality.toShortString();
}
}
class PlaybackQualityNotifier extends StateNotifier<PlaybackQualityState> {
final Ref ref;
PlaybackQualityNotifier(this.ref)
: super(
PlaybackQualityState(
selectedQuality:
ref.read(userPreferencesProvider).audioQuality,
),
) {
ref.listen(userPreferencesProvider.select((s) => s.audioQuality),
(previous, next) {
state = state.copyWith(selectedQuality: next);
});
audioPlayer.audioBitrateStream.listen((bitrate) {
state = state.copyWith(bitrate: bitrate);
});
}
}
final playbackQualityProvider =
StateNotifierProvider<PlaybackQualityNotifier, PlaybackQualityState>(
(ref) => PlaybackQualityNotifier(ref),
);

View File

@ -10,6 +10,7 @@ import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart';
import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/database/database.dart';
import 'package:spotube/services/audio_player/audio_player.dart'; import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/models/audio_quality.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'package:open_file/open_file.dart'; import 'package:open_file/open_file.dart';
@ -226,6 +227,10 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
void setCacheMusic(bool cache) { void setCacheMusic(bool cache) {
setData(PreferencesTableCompanion(cacheMusic: Value(cache))); setData(PreferencesTableCompanion(cacheMusic: Value(cache)));
} }
void setAudioQuality(AudioQuality quality) {
setData(PreferencesTableCompanion(audioQuality: Value(quality)));
}
} }
final userPreferencesProvider = final userPreferencesProvider =

View File

@ -1,18 +1,23 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:dio_retry/dio_retry.dart'; import 'package:spotube/models/audio_quality.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/services/dio/retry_interceptor.dart';
class DabMusicApi { class DabMusicApi {
final Dio _dio = Dio(BaseOptions(baseUrl: 'https://dabmusic.xyz/api')); final Dio _dio = Dio(
BaseOptions(
baseUrl: 'https://dabmusic.xyz/api',
followRedirects: false,
validateStatus: (status) {
return status != null && status < 500;
},
),
);
DabMusicApi() { DabMusicApi() {
_dio.interceptors.add( _dio.interceptors.add(
RetryInterceptor( RetryInterceptor(
dio: _dio, dio: _dio,
options: const RetryOptions(
retries: 3,
retryInterval: Duration(seconds: 1),
),
), ),
); );
} }
@ -44,18 +49,21 @@ class DabMusicApi {
} }
} }
Future<String> getStreamUrl(String trackId, {String quality = '27'}) async { Future<String> getStreamUrl(
String trackId, {
AudioQuality quality = AudioQuality.high,
}) async {
try { try {
final response = await _dio.get( final response = await _dio.get(
'/stream', '/stream',
queryParameters: { queryParameters: {
'trackId': trackId, 'trackId': trackId,
'quality': quality, 'quality': quality.toDabMusicQuality(),
}, },
); );
if (response.statusCode == 200) { if (response.statusCode == 302) {
return response.data['streamUrl']; return response.headers.value('location')!;
} else { } else {
throw Exception('Failed to get stream URL'); throw Exception('Failed to get stream URL');
} }
@ -64,19 +72,21 @@ class DabMusicApi {
} }
} }
Future<String> getDownloadUrl(String albumId, {String quality = '27'}) async { Future<String> getDownloadUrl(
String trackId, {
AudioQuality quality = AudioQuality.high,
}) async {
try { try {
final response = await _dio.get( final response = await _dio.get(
'/download', '/download',
queryParameters: { queryParameters: {
'albumId': albumId, 'trackId': trackId,
'quality': quality, 'quality': quality.toDabMusicQuality(),
}, },
); );
if (response.statusCode == 200) { if (response.statusCode == 302) {
// Assuming the API returns a direct download link or a JSON with the link return response.headers.value('location')!;
return response.data['downloadUrl'];
} else { } else {
throw Exception('Failed to get download URL'); throw Exception('Failed to get download URL');
} }

View File

@ -0,0 +1,51 @@
import 'dart:async';
import 'package:dio/dio.dart';
class RetryInterceptor extends Interceptor {
final Dio dio;
final int retries;
final Duration retryInterval;
RetryInterceptor({
required this.dio,
this.retries = 3,
this.retryInterval = const Duration(seconds: 1),
});
@override
Future onError(DioError err, ErrorInterceptorHandler handler) async {
int retryCount = err.requestOptions.extra['retry_count'] ?? 0;
if (retryCount < retries && _shouldRetry(err)) {
retryCount++;
err.requestOptions.extra['retry_count'] = retryCount;
try {
await Future.delayed(retryInterval);
final response = await dio.request(
err.requestOptions.path,
cancelToken: err.requestOptions.cancelToken,
data: err.requestOptions.data,
onReceiveProgress: err.requestOptions.onReceiveProgress,
onSendProgress: err.requestOptions.onSendProgress,
queryParameters: err.requestOptions.queryParameters,
options: Options(
method: err.requestOptions.method,
headers: err.requestOptions.headers,
responseType: err.requestOptions.responseType,
extra: err.requestOptions.extra,
),
);
return handler.resolve(response);
} catch (e) {
return super.onError(err, handler);
}
}
return super.onError(err, handler);
}
bool _shouldRetry(DioError err) {
return err.type == DioErrorType.other ||
err.type == DioErrorType.connectTimeout ||
err.response?.statusCode == 429;
}
}

View File

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