mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-12-05 23:19:42 +00:00
feat(playback): add dab music source
This commit is contained in:
parent
ca6924f5a9
commit
e8a54d3209
@ -12,12 +12,14 @@ enum CloseBehavior {
|
||||
}
|
||||
|
||||
enum AudioSource {
|
||||
youtube,
|
||||
piped,
|
||||
jiosaavn,
|
||||
invidious;
|
||||
youtube("YouTube"),
|
||||
piped("Piped"),
|
||||
jiosaavn("JioSaavn"),
|
||||
invidious("Invidious"),
|
||||
dabMusic("DAB Music");
|
||||
|
||||
String get label => name[0].toUpperCase() + name.substring(1);
|
||||
final String label;
|
||||
const AudioSource(this.label);
|
||||
}
|
||||
|
||||
enum YoutubeClientEngine {
|
||||
|
||||
@ -3,7 +3,8 @@ part of '../database.dart';
|
||||
enum SourceType {
|
||||
youtube._("YouTube"),
|
||||
youtubeMusic._("YouTube Music"),
|
||||
jiosaavn._("JioSaavn");
|
||||
jiosaavn._("JioSaavn"),
|
||||
dabMusic._("DAB Music");
|
||||
|
||||
final String label;
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@ import 'package:flutter/material.dart' show ListTile;
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:piped_client/piped_client.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart' hide Consumer;
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:spotube/collections/routes.gr.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/models/database/database.dart';
|
||||
@ -396,7 +396,9 @@ class SettingsPlaybackSection extends HookConsumerWidget {
|
||||
onChanged: preferencesNotifier.setNormalizeAudio,
|
||||
),
|
||||
),
|
||||
if (preferences.audioSource != AudioSource.jiosaavn) ...[
|
||||
if (const [AudioSource.jiosaavn, AudioSource.dabMusic]
|
||||
.contains(preferences.audioSource) ==
|
||||
false) ...[
|
||||
AdaptiveSelectTile<SourceCodecs>(
|
||||
popupConstraints: const BoxConstraints(maxWidth: 300),
|
||||
secondary: const Icon(SpotubeIcons.stream),
|
||||
|
||||
@ -135,23 +135,6 @@ class ServerPlaybackRoutes {
|
||||
);
|
||||
}
|
||||
|
||||
final contentLength = contentLengthRes?.headers.value("content-length");
|
||||
|
||||
/// Forcing partial content range as mpv sometimes greedily wants
|
||||
/// everything at one go. Slows down overall streaming.
|
||||
final range = RangeHeader.parse(headers["range"] ?? "");
|
||||
final contentPartialLength = int.tryParse(contentLength ?? "");
|
||||
if ((range.end == null) &&
|
||||
contentPartialLength != null &&
|
||||
range.start == 0) {
|
||||
options = options.copyWith(
|
||||
headers: {
|
||||
...?options.headers,
|
||||
"range": "$range${(contentPartialLength * 0.3).ceil()}",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final res = await dio.get<Uint8List>(url, options: options);
|
||||
|
||||
final bytes = res.data;
|
||||
@ -183,7 +166,7 @@ class ServerPlaybackRoutes {
|
||||
await trackPartialCacheFile.rename(trackCacheFile.path);
|
||||
}
|
||||
|
||||
if (contentRange.total == fileLength && track.codec != SourceCodecs.weba) {
|
||||
if (contentRange.total == fileLength && track.codec == SourceCodecs.m4a) {
|
||||
final playlistTrack = playlist.tracks.firstWhereOrNull(
|
||||
(element) => element.id == track.query.id,
|
||||
);
|
||||
|
||||
@ -56,6 +56,7 @@ abstract class AudioPlayerInterface {
|
||||
configuration: const mk.PlayerConfiguration(
|
||||
title: "Spotube",
|
||||
logLevel: kDebugMode ? mk.MPVLogLevel.info : mk.MPVLogLevel.error,
|
||||
bufferSize: 4 * 1024 * 1024, // 4MB buffer
|
||||
),
|
||||
) {
|
||||
_mkPlayer.stream.error.listen((event) {
|
||||
|
||||
@ -2,7 +2,8 @@ import 'package:spotube/models/playback/track_sources.dart';
|
||||
|
||||
enum SourceCodecs {
|
||||
m4a._("M4a (Best for downloaded music)"),
|
||||
weba._("WebA (Best for streamed music)\nDoesn't support audio metadata");
|
||||
weba._("WebA (Best for streamed music)\nDoesn't support audio metadata"),
|
||||
mp3._("MP3 (Widely supported audio format)");
|
||||
|
||||
final String label;
|
||||
const SourceCodecs._(this.label);
|
||||
|
||||
@ -5,6 +5,7 @@ import 'package:spotube/models/playback/track_sources.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
|
||||
import 'package:spotube/services/sourced_track/enums.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/dab_music.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/invidious.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/jiosaavn.dart';
|
||||
import 'package:spotube/services/sourced_track/sources/piped.dart';
|
||||
@ -74,6 +75,14 @@ abstract class SourcedTrack extends BasicSourcedTrack {
|
||||
query: query,
|
||||
sources: sources,
|
||||
),
|
||||
AudioSource.dabMusic => DABMusicSourcedTrack(
|
||||
ref: ref,
|
||||
source: source,
|
||||
siblings: siblings,
|
||||
info: info,
|
||||
query: query,
|
||||
sources: sources,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -104,6 +113,8 @@ abstract class SourcedTrack extends BasicSourcedTrack {
|
||||
await InvidiousSourcedTrack.fetchFromTrack(query: query, ref: ref),
|
||||
AudioSource.jiosaavn =>
|
||||
await JioSaavnSourcedTrack.fetchFromTrack(query: query, ref: ref),
|
||||
AudioSource.dabMusic =>
|
||||
await DABMusicSourcedTrack.fetchFromTrack(query: query, ref: ref),
|
||||
};
|
||||
} catch (e) {
|
||||
if (preferences.audioSource == AudioSource.youtube) {
|
||||
@ -129,6 +140,8 @@ abstract class SourcedTrack extends BasicSourcedTrack {
|
||||
JioSaavnSourcedTrack.fetchSiblings(query: query, ref: ref),
|
||||
AudioSource.invidious =>
|
||||
InvidiousSourcedTrack.fetchSiblings(query: query, ref: ref),
|
||||
AudioSource.dabMusic =>
|
||||
DABMusicSourcedTrack.fetchSiblings(query: query, ref: ref),
|
||||
};
|
||||
}
|
||||
|
||||
@ -198,9 +211,11 @@ abstract class SourcedTrack extends BasicSourcedTrack {
|
||||
SourceCodecs get codec {
|
||||
final preferences = ref.read(userPreferencesProvider);
|
||||
|
||||
return preferences.audioSource == AudioSource.jiosaavn
|
||||
? SourceCodecs.m4a
|
||||
: preferences.streamMusicCodec;
|
||||
return switch (preferences.audioSource) {
|
||||
AudioSource.dabMusic => SourceCodecs.mp3,
|
||||
AudioSource.jiosaavn => SourceCodecs.m4a,
|
||||
_ => preferences.streamMusicCodec
|
||||
};
|
||||
}
|
||||
|
||||
TrackSource get activeTrackSource {
|
||||
|
||||
203
lib/services/sourced_track/sources/dab_music.dart
Normal file
203
lib/services/sourced_track/sources/dab_music.dart
Normal file
@ -0,0 +1,203 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotube/models/database/database.dart';
|
||||
import 'package:spotube/models/playback/track_sources.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/services/logger/logger.dart';
|
||||
import 'package:spotube/services/sourced_track/enums.dart';
|
||||
import 'package:spotube/services/sourced_track/exceptions.dart';
|
||||
import 'package:spotube/services/sourced_track/sourced_track.dart';
|
||||
import 'package:dab_music_api/dab_music_api.dart';
|
||||
|
||||
final dabMusicApiClient = DabMusicApiClient(
|
||||
Dio(),
|
||||
baseUrl: "https://dab.yeet.su/api",
|
||||
);
|
||||
|
||||
/// Only Music source that can't support database caching due to having no endpoint.
|
||||
/// But ISRC search is 100% reliable so caching is actually not necessary.
|
||||
class DABMusicSourcedTrack extends SourcedTrack {
|
||||
DABMusicSourcedTrack({
|
||||
required super.ref,
|
||||
required super.source,
|
||||
required super.siblings,
|
||||
required super.info,
|
||||
required super.query,
|
||||
required super.sources,
|
||||
});
|
||||
|
||||
static Future<SourcedTrack> fetchFromTrack({
|
||||
required TrackSourceQuery query,
|
||||
required Ref ref,
|
||||
}) async {
|
||||
try {
|
||||
final siblings = await fetchSiblings(ref: ref, query: query);
|
||||
|
||||
if (siblings.isEmpty) {
|
||||
throw TrackNotFoundError(query);
|
||||
}
|
||||
return DABMusicSourcedTrack(
|
||||
ref: ref,
|
||||
siblings: siblings.map((s) => s.info).skip(1).toList(),
|
||||
sources: siblings.first.source!,
|
||||
info: siblings.first.info,
|
||||
query: query,
|
||||
source: AudioSource.dabMusic,
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.reportError(e, stackTrace);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<TrackSource>> fetchSources(
|
||||
String id,
|
||||
SourceQualities quality,
|
||||
) async {
|
||||
try {
|
||||
final streamResponse = await dabMusicApiClient.music.getStream(
|
||||
trackId: id,
|
||||
quality: "5", // mp3 320kbps (best available)
|
||||
);
|
||||
if (streamResponse.url == null) {
|
||||
throw Exception("No stream URL found for track ID: $id");
|
||||
}
|
||||
return [
|
||||
TrackSource(
|
||||
url: streamResponse.url!,
|
||||
quality: SourceQualities.high,
|
||||
bitrate: "320kbps",
|
||||
codec: SourceCodecs.mp3,
|
||||
),
|
||||
];
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.reportError(e, stackTrace);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<SiblingType> toSiblingType(
|
||||
Ref ref,
|
||||
int index,
|
||||
Track result,
|
||||
) async {
|
||||
try {
|
||||
List<TrackSource>? source;
|
||||
if (index == 0) {
|
||||
source = await fetchSources(
|
||||
result.id.toString(),
|
||||
ref.read(userPreferencesProvider).audioQuality,
|
||||
);
|
||||
}
|
||||
|
||||
final SiblingType sibling = (
|
||||
info: TrackSourceInfo(
|
||||
artists: result.artist!,
|
||||
durationMs: Duration(seconds: result.duration!).inMilliseconds,
|
||||
id: result.id.toString(),
|
||||
pageUrl: "https://dab.yeet.su/music/${result.id}",
|
||||
thumbnail: result.albumCover!,
|
||||
title: result.title!,
|
||||
),
|
||||
source: source,
|
||||
);
|
||||
|
||||
return sibling;
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.reportError(e, stackTrace);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<List<SiblingType>> fetchSiblings({
|
||||
required TrackSourceQuery query,
|
||||
required Ref ref,
|
||||
}) async {
|
||||
try {
|
||||
List<Track> results = [];
|
||||
|
||||
if (query.isrc.isNotEmpty) {
|
||||
final res =
|
||||
await dabMusicApiClient.music.getSearch(q: query.isrc, limit: 1);
|
||||
results = res.tracks ?? <Track>[];
|
||||
}
|
||||
|
||||
if (results.isEmpty) {
|
||||
final res = await dabMusicApiClient.music.getSearch(
|
||||
q: SourcedTrack.getSearchTerm(query),
|
||||
limit: 20,
|
||||
);
|
||||
results = res.tracks ?? <Track>[];
|
||||
}
|
||||
|
||||
if (results.isEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
final matchedResults =
|
||||
results.mapIndexed((index, d) => toSiblingType(ref, index, d));
|
||||
|
||||
return Future.wait(matchedResults);
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.reportError(e, stackTrace);
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DABMusicSourcedTrack> copyWithSibling() async {
|
||||
if (siblings.isNotEmpty) {
|
||||
return this;
|
||||
}
|
||||
final fetchedSiblings = await fetchSiblings(ref: ref, query: query);
|
||||
|
||||
return DABMusicSourcedTrack(
|
||||
ref: ref,
|
||||
siblings: fetchedSiblings
|
||||
.where((s) => s.info.id != info.id)
|
||||
.map((s) => s.info)
|
||||
.toList(),
|
||||
source: source,
|
||||
info: info,
|
||||
query: query,
|
||||
sources: sources,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<DABMusicSourcedTrack?> swapWithSibling(TrackSourceInfo sibling) async {
|
||||
if (sibling.id == this.info.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// a sibling source that was fetched from the search results
|
||||
final isStepSibling = siblings.none((s) => s.id == sibling.id);
|
||||
|
||||
final newSourceInfo = isStepSibling
|
||||
? sibling
|
||||
: siblings.firstWhere((s) => s.id == sibling.id);
|
||||
final newSiblings = siblings.where((s) => s.id != sibling.id).toList()
|
||||
..insert(0, this.info);
|
||||
|
||||
final source = await fetchSources(
|
||||
sibling.id,
|
||||
ref.read(userPreferencesProvider).audioQuality,
|
||||
);
|
||||
|
||||
return DABMusicSourcedTrack(
|
||||
ref: ref,
|
||||
siblings: newSiblings,
|
||||
sources: source,
|
||||
info: newSourceInfo,
|
||||
query: query,
|
||||
source: AudioSource.dabMusic,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<SourcedTrack> refreshStream() async {
|
||||
// There's no need to refresh the stream for DABMusicSourcedTrack
|
||||
return this;
|
||||
}
|
||||
}
|
||||
17
pubspec.lock
17
pubspec.lock
@ -458,6 +458,15 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
dab_music_api:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: main
|
||||
resolved-ref: "55f96368b7465eec2e5e81774f9f2a7b18acc4ab"
|
||||
url: "https://github.com/KRTirtho/dab_music_api.git"
|
||||
source: git
|
||||
version: "0.1.0"
|
||||
dart_des:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -2046,6 +2055,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.0"
|
||||
retrofit:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: retrofit
|
||||
sha256: "699cf44ec6c7fc7d248740932eca75d334e36bdafe0a8b3e9ff93100591c8a25"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.7.2"
|
||||
riverpod:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@ -24,6 +24,10 @@ dependencies:
|
||||
bonsoir: ^5.1.10
|
||||
cached_network_image: ^3.3.1
|
||||
connectivity_plus: ^6.1.2
|
||||
dab_music_api:
|
||||
git:
|
||||
url: https://github.com/KRTirtho/dab_music_api.git
|
||||
ref: main
|
||||
desktop_webview_window:
|
||||
git:
|
||||
path: packages/desktop_webview_window
|
||||
|
||||
Loading…
Reference in New Issue
Block a user