mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-12-06 07:29:42 +00:00
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:
parent
e7523c70d0
commit
f3e3159ca9
@ -156,21 +156,9 @@ class Spotube extends HookConsumerWidget {
|
||||
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();
|
||||
|
||||
@ -178,6 +166,21 @@ class Spotube extends HookConsumerWidget {
|
||||
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 () {
|
||||
/// For enabling hot reload for audio player
|
||||
if (!kDebugMode) return;
|
||||
|
||||
27
lib/models/audio_quality.dart
Normal file
27
lib/models/audio_quality.dart
Normal 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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -66,7 +66,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
AppDatabase() : super(_openConnection());
|
||||
|
||||
@override
|
||||
int get schemaVersion => 10;
|
||||
int get schemaVersion => 11;
|
||||
|
||||
@override
|
||||
MigrationStrategy get migration {
|
||||
@ -237,6 +237,12 @@ class AppDatabase extends _$AppDatabase {
|
||||
.dropColumn(schema.sourceMatchTable, "source_id")
|
||||
.catchError((e, stack) => AppLogger.reportError(e, stack));
|
||||
},
|
||||
from10To11: (m, schema) async {
|
||||
await m.addColumn(
|
||||
schema.preferencesTable,
|
||||
schema.preferencesTable.audioQuality,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
part of '../database.dart';
|
||||
|
||||
import 'package:spotube/models/audio_quality.dart';
|
||||
|
||||
enum LayoutMode {
|
||||
compact,
|
||||
extended,
|
||||
@ -79,6 +81,8 @@ class PreferencesTable extends Table {
|
||||
TextColumn get themeMode =>
|
||||
textEnum<ThemeMode>().withDefault(Constant(ThemeMode.system.name))();
|
||||
TextColumn get audioSourceId => text().nullable()();
|
||||
TextColumn get audioQuality =>
|
||||
textEnum<AudioQuality>().withDefault(Constant(AudioQuality.high.name))();
|
||||
TextColumn get youtubeClientEngine => textEnum<YoutubeClientEngine>()
|
||||
.withDefault(Constant(YoutubeClientEngine.youtubeExplode.name))();
|
||||
BoolColumn get discordPresence =>
|
||||
@ -119,6 +123,7 @@ class PreferencesTable extends Table {
|
||||
enableConnect: false,
|
||||
cacheMusic: true,
|
||||
connectPort: -1,
|
||||
audioQuality: AudioQuality.high,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,7 @@ import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/modules/root/spotube_navigation_bar.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/volume_provider.dart';
|
||||
|
||||
@ -43,7 +43,7 @@ class PlayerView extends HookConsumerWidget {
|
||||
final currentActiveTrackSource = sourcedCurrentTrack.asData?.value?.source;
|
||||
final isLocalTrack = currentActiveTrack is SpotubeLocalTrackObject;
|
||||
final mediaQuery = MediaQuery.sizeOf(context);
|
||||
final qualityLabel = ref.watch(audioSourceQualityLabelProvider);
|
||||
final quality = ref.watch(playbackQualityProvider);
|
||||
|
||||
final shouldHide = useState(true);
|
||||
|
||||
@ -262,7 +262,7 @@ class PlayerView extends HookConsumerWidget {
|
||||
},
|
||||
),
|
||||
leading: const Icon(SpotubeIcons.lightningOutlined),
|
||||
child: Text(qualityLabel),
|
||||
child: Text(quality.toShortString()),
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
@ -11,8 +11,9 @@ 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/player/playback_quality_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 {
|
||||
final Color? color;
|
||||
@ -24,9 +25,7 @@ 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;
|
||||
final quality = ref.watch(playbackQualityProvider);
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
@ -64,12 +63,11 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
||||
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),
|
||||
),
|
||||
Text(
|
||||
quality.toShortString(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.typography.small.copyWith(color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -93,12 +91,11 @@ 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),
|
||||
),
|
||||
Text(
|
||||
quality.toShortString(),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.typography.small.copyWith(color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -193,7 +193,7 @@ class DownloadManagerNotifier extends Notifier<List<DownloadTask>> {
|
||||
String? url;
|
||||
if (track.source == 'DAB Music') {
|
||||
final dabMusicApi = DabMusicApi();
|
||||
url = await dabMusicApi.getDownloadUrl(track.query.album.id);
|
||||
url = await dabMusicApi.getDownloadUrl(track.query.id);
|
||||
} else {
|
||||
url = track.getUrlOfQuality(
|
||||
container,
|
||||
|
||||
@ -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/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';
|
||||
|
||||
class DabMusicAudioSource extends AudioSource {
|
||||
final DabMusicApi _api = DabMusicApi();
|
||||
final Ref? ref;
|
||||
|
||||
DabMusicAudioSource([this.ref]);
|
||||
|
||||
@override
|
||||
String get name => 'DAB Music';
|
||||
@ -15,6 +21,7 @@ class DabMusicAudioSource extends AudioSource {
|
||||
|
||||
@override
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
58
lib/provider/player/playback_quality_provider.dart
Normal file
58
lib/provider/player/playback_quality_provider.dart
Normal 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),
|
||||
);
|
||||
@ -10,6 +10,7 @@ import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart';
|
||||
import 'package:spotube/provider/database/database.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/services/logger/logger.dart';
|
||||
import 'package:spotube/models/audio_quality.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'package:open_file/open_file.dart';
|
||||
@ -226,6 +227,10 @@ class UserPreferencesNotifier extends Notifier<PreferencesTableData> {
|
||||
void setCacheMusic(bool cache) {
|
||||
setData(PreferencesTableCompanion(cacheMusic: Value(cache)));
|
||||
}
|
||||
|
||||
void setAudioQuality(AudioQuality quality) {
|
||||
setData(PreferencesTableCompanion(audioQuality: Value(quality)));
|
||||
}
|
||||
}
|
||||
|
||||
final userPreferencesProvider =
|
||||
|
||||
@ -1,18 +1,23 @@
|
||||
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/services/dio/retry_interceptor.dart';
|
||||
|
||||
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() {
|
||||
_dio.interceptors.add(
|
||||
RetryInterceptor(
|
||||
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 {
|
||||
final response = await _dio.get(
|
||||
'/stream',
|
||||
queryParameters: {
|
||||
'trackId': trackId,
|
||||
'quality': quality,
|
||||
'quality': quality.toDabMusicQuality(),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return response.data['streamUrl'];
|
||||
if (response.statusCode == 302) {
|
||||
return response.headers.value('location')!;
|
||||
} else {
|
||||
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 {
|
||||
final response = await _dio.get(
|
||||
'/download',
|
||||
queryParameters: {
|
||||
'albumId': albumId,
|
||||
'quality': quality,
|
||||
'trackId': trackId,
|
||||
'quality': quality.toDabMusicQuality(),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// Assuming the API returns a direct download link or a JSON with the link
|
||||
return response.data['downloadUrl'];
|
||||
if (response.statusCode == 302) {
|
||||
return response.headers.value('location')!;
|
||||
} else {
|
||||
throw Exception('Failed to get download URL');
|
||||
}
|
||||
|
||||
51
lib/services/dio/retry_interceptor.dart
Normal file
51
lib/services/dio/retry_interceptor.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -30,7 +30,6 @@ 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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user