🔀 Merge some services from spotube

This commit is contained in:
LittleSheep 2024-08-26 23:21:22 +08:00
parent 80771e84ce
commit 84d66fbc4b
28 changed files with 2517 additions and 4 deletions

3
lib/providers/piped.dart Normal file
View File

@ -0,0 +1,3 @@
import 'package:get/get.dart';
class PipedProvider extends GetxController {}

View File

@ -0,0 +1,163 @@
import 'dart:developer';
import 'dart:io';
import 'package:media_kit/media_kit.dart' hide Track;
import 'package:flutter/foundation.dart';
import 'package:rhythm_box/platform.dart';
import 'package:rhythm_box/services/local_track.dart';
import 'package:spotify/spotify.dart' hide Playlist;
import 'package:rhythm_box/services/audio_player/custom_player.dart';
import 'dart:async';
import 'package:media_kit/media_kit.dart' as mk;
import 'package:rhythm_box/services/audio_player/playback_state.dart';
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
part 'audio_players_streams_mixin.dart';
part 'audio_player_impl.dart';
class RhythmMedia extends mk.Media {
final Track track;
static int serverPort = 0;
RhythmMedia(
this.track, {
Map<String, dynamic>? extras,
super.httpHeaders,
}) : super(
track is LocalTrack
? track.path
: "http://${PlatformInfo.isWindows ? "localhost" : InternetAddress.anyIPv4.address}:$serverPort/stream/${track.id}",
extras: {
...?extras,
"track": switch (track) {
LocalTrack() => track.toJson(),
SourcedTrack() => track.toJson(),
_ => track.toJson(),
},
},
);
@override
String get uri {
return switch (track) {
/// [super.uri] must be used instead of [track.path] to prevent wrong
/// path format exceptions in Windows causing [extras] to be null
LocalTrack() => super.uri,
_ =>
"http://${PlatformInfo.isWindows ? "localhost" : InternetAddress.anyIPv4.address}:"
"$serverPort/stream/${track.id}",
};
}
factory RhythmMedia.fromMedia(mk.Media media) {
final track = media.uri.startsWith("http")
? Track.fromJson(media.extras?["track"])
: LocalTrack.fromJson(media.extras?["track"]);
return RhythmMedia(
track,
extras: media.extras,
httpHeaders: media.httpHeaders,
);
}
// @override
// operator ==(Object other) {
// if (other is! RhythmMedia) return false;
// final isLocal = track is LocalTrack && other.track is LocalTrack;
// return isLocal
// ? (other.track as LocalTrack).path == (track as LocalTrack).path
// : other.track.id == track.id;
// }
// @override
// int get hashCode => track is LocalTrack
// ? (track as LocalTrack).path.hashCode
// : track.id.hashCode;
}
abstract class AudioPlayerInterface {
final CustomPlayer _mkPlayer;
AudioPlayerInterface()
: _mkPlayer = CustomPlayer(
configuration: const mk.PlayerConfiguration(
title: "Rhythm",
logLevel: kDebugMode ? mk.MPVLogLevel.info : mk.MPVLogLevel.error,
),
) {
_mkPlayer.stream.error.listen((event) {
log("[Playback] Error: $event");
});
}
/// Whether the current platform supports the audioplayers plugin
static const bool _mkSupportedPlatform = true;
bool get mkSupportedPlatform => _mkSupportedPlatform;
Duration get duration {
return _mkPlayer.state.duration;
}
Playlist get playlist {
return _mkPlayer.state.playlist;
}
Duration get position {
return _mkPlayer.state.position;
}
Duration get bufferedPosition {
return _mkPlayer.state.buffer;
}
Future<mk.AudioDevice> get selectedDevice async {
return _mkPlayer.state.audioDevice;
}
Future<List<mk.AudioDevice>> get devices async {
return _mkPlayer.state.audioDevices;
}
bool get hasSource {
return _mkPlayer.state.playlist.medias.isNotEmpty;
}
// states
bool get isPlaying {
return _mkPlayer.state.playing;
}
bool get isPaused {
return !_mkPlayer.state.playing;
}
bool get isStopped {
return !hasSource;
}
Future<bool> get isCompleted async {
return _mkPlayer.state.completed;
}
bool get isShuffled {
return _mkPlayer.shuffled;
}
PlaylistMode get loopMode {
return _mkPlayer.state.playlistMode;
}
/// Returns the current volume of the player, between 0 and 1
double get volume {
return _mkPlayer.state.volume / 100;
}
bool get isBuffering {
return _mkPlayer.state.buffering;
}
}

View File

@ -0,0 +1,134 @@
part of 'audio_player.dart';
final audioPlayer = RhythmAudioPlayer();
class RhythmAudioPlayer extends AudioPlayerInterface
with RhythmAudioPlayersStreams {
Future<void> pause() async {
await _mkPlayer.pause();
}
Future<void> resume() async {
await _mkPlayer.play();
}
Future<void> stop() async {
await _mkPlayer.stop();
}
Future<void> seek(Duration position) async {
await _mkPlayer.seek(position);
}
/// Volume is between 0 and 1
Future<void> setVolume(double volume) async {
assert(volume >= 0 && volume <= 1);
await _mkPlayer.setVolume(volume * 100);
}
Future<void> setSpeed(double speed) async {
await _mkPlayer.setRate(speed);
}
Future<void> setAudioDevice(mk.AudioDevice device) async {
await _mkPlayer.setAudioDevice(device);
}
Future<void> dispose() async {
await _mkPlayer.dispose();
}
// Playlist related
Future<void> openPlaylist(
List<mk.Media> tracks, {
bool autoPlay = true,
int initialIndex = 0,
}) async {
assert(tracks.isNotEmpty);
assert(initialIndex <= tracks.length - 1);
await _mkPlayer.open(
mk.Playlist(tracks, index: initialIndex),
play: autoPlay,
);
}
List<String> get sources {
return _mkPlayer.state.playlist.medias.map((e) => e.uri).toList();
}
String? get currentSource {
if (_mkPlayer.state.playlist.index == -1) return null;
return _mkPlayer.state.playlist.medias
.elementAtOrNull(_mkPlayer.state.playlist.index)
?.uri;
}
String? get nextSource {
if (loopMode == PlaylistMode.loop &&
_mkPlayer.state.playlist.index ==
_mkPlayer.state.playlist.medias.length - 1) {
return sources.first;
}
return _mkPlayer.state.playlist.medias
.elementAtOrNull(_mkPlayer.state.playlist.index + 1)
?.uri;
}
String? get previousSource {
if (loopMode == PlaylistMode.loop && _mkPlayer.state.playlist.index == 0) {
return sources.last;
}
return _mkPlayer.state.playlist.medias
.elementAtOrNull(_mkPlayer.state.playlist.index - 1)
?.uri;
}
int get currentIndex => _mkPlayer.state.playlist.index;
Future<void> skipToNext() async {
await _mkPlayer.next();
}
Future<void> skipToPrevious() async {
await _mkPlayer.previous();
}
Future<void> jumpTo(int index) async {
await _mkPlayer.jump(index);
}
Future<void> addTrack(mk.Media media) async {
await _mkPlayer.add(media);
}
Future<void> addTrackAt(mk.Media media, int index) async {
await _mkPlayer.insert(index, media);
}
Future<void> removeTrack(int index) async {
await _mkPlayer.remove(index);
}
Future<void> moveTrack(int from, int to) async {
await _mkPlayer.move(from, to);
}
Future<void> clearPlaylist() async {
_mkPlayer.stop();
}
Future<void> setShuffle(bool shuffle) async {
await _mkPlayer.setShuffle(shuffle);
}
Future<void> setLoopMode(PlaylistMode loop) async {
await _mkPlayer.setPlaylistMode(loop);
}
Future<void> setAudioNormalization(bool normalize) async {
await _mkPlayer.setAudioNormalization(normalize);
}
}

View File

@ -0,0 +1,152 @@
part of 'audio_player.dart';
mixin RhythmAudioPlayersStreams on AudioPlayerInterface {
// stream getters
Stream<Duration> get durationStream {
// if (mkSupportedPlatform) {
return _mkPlayer.stream.duration;
// } else {
// return _justAudio!.durationStream
// .where((event) => event != null)
// .map((event) => event!)
// ;
// }
}
Stream<Duration> get positionStream {
// if (mkSupportedPlatform) {
return _mkPlayer.stream.position;
// } else {
// return _justAudio!.positionStream;
// }
}
Stream<Duration> get bufferedPositionStream {
// if (mkSupportedPlatform) {
// audioplayers doesn't have the capability to get buffered position
return _mkPlayer.stream.buffer;
// } else {
// return _justAudio!.bufferedPositionStream;
// }
}
Stream<void> get completedStream {
// if (mkSupportedPlatform) {
return _mkPlayer.stream.completed;
// } else {
// return _justAudio!.playerStateStream
// .where(
// (event) => event.processingState == ja.ProcessingState.completed)
// ;
// }
}
/// Stream that emits when the player is almost (%) complete
Stream<int> percentCompletedStream(double percent) {
return positionStream
.asyncMap(
(position) async => duration == Duration.zero
? 0
: (position.inSeconds / duration.inSeconds * 100).toInt(),
)
.where((event) => event >= percent);
}
Stream<bool> get playingStream {
// if (mkSupportedPlatform) {
return _mkPlayer.stream.playing;
// } else {
// return _justAudio!.playingStream;
// }
}
Stream<bool> get shuffledStream {
// if (mkSupportedPlatform) {
return _mkPlayer.shuffleStream;
// } else {
// return _justAudio!.shuffleModeEnabledStream;
// }
}
Stream<PlaylistMode> get loopModeStream {
// if (mkSupportedPlatform) {
return _mkPlayer.stream.playlistMode;
// } else {
// return _justAudio!.loopModeStream
// .map(PlaylistMode.fromLoopMode)
// ;
// }
}
Stream<double> get volumeStream {
// if (mkSupportedPlatform) {
return _mkPlayer.stream.volume.map((event) => event / 100);
// } else {
// return _justAudio!.volumeStream;
// }
}
Stream<bool> get bufferingStream {
// if (mkSupportedPlatform) {
return Stream.value(false);
// } else {
// return _justAudio!.playerStateStream
// .map(
// (event) =>
// event.processingState == ja.ProcessingState.buffering ||
// event.processingState == ja.ProcessingState.loading,
// )
// ;
// }
}
Stream<AudioPlaybackState> get playerStateStream {
// if (mkSupportedPlatform) {
return _mkPlayer.playerStateStream;
// } else {
// return _justAudio!.playerStateStream
// .map(AudioPlaybackState.fromJaPlayerState)
// ;
// }
}
Stream<int> get currentIndexChangedStream {
// if (mkSupportedPlatform) {
return _mkPlayer.indexChangeStream;
// } else {
// return _justAudio!.sequenceStateStream
// .map((event) => event?.currentIndex ?? -1)
// ;
// }
}
Stream<String> get activeSourceChangedStream {
// if (mkSupportedPlatform) {
return _mkPlayer.indexChangeStream
.map((event) {
return _mkPlayer.state.playlist.medias.elementAtOrNull(event)?.uri;
})
.where((event) => event != null)
.cast<String>();
// } else {
// return _justAudio!.sequenceStateStream
// .map((event) {
// return (event?.currentSource as ja.UriAudioSource?)?.uri.toString();
// })
// .where((event) => event != null)
// .cast<String>();
// }
}
Stream<List<mk.AudioDevice>> get devicesStream =>
_mkPlayer.stream.audioDevices.asBroadcastStream();
Stream<mk.AudioDevice> get selectedDeviceStream =>
_mkPlayer.stream.audioDevice.asBroadcastStream();
Stream<String> get errorStream => _mkPlayer.stream.error;
Stream<mk.Playlist> get playlistStream => _mkPlayer.stream.playlist.map((s) {
return s;
});
}

View File

@ -0,0 +1,148 @@
import 'dart:async';
import 'dart:developer';
import 'package:media_kit/media_kit.dart';
import 'package:flutter_broadcasts/flutter_broadcasts.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:audio_session/audio_session.dart';
import 'package:rhythm_box/platform.dart';
// ignore: implementation_imports
import 'package:rhythm_box/services/audio_player/playback_state.dart';
/// MediaKit [Player] by default doesn't have a state stream.
/// This class adds a state stream to the [Player] class.
class CustomPlayer extends Player {
final StreamController<AudioPlaybackState> _playerStateStream;
final StreamController<bool> _shuffleStream;
late final List<StreamSubscription> _subscriptions;
bool _shuffled;
int _androidAudioSessionId = 0;
String _packageName = "";
AndroidAudioManager? _androidAudioManager;
CustomPlayer({super.configuration})
: _playerStateStream = StreamController.broadcast(),
_shuffleStream = StreamController.broadcast(),
_shuffled = false {
nativePlayer.setProperty("network-timeout", "120");
_subscriptions = [
stream.buffering.listen((event) {
_playerStateStream.add(AudioPlaybackState.buffering);
}),
stream.playing.listen((playing) {
if (playing) {
_playerStateStream.add(AudioPlaybackState.playing);
} else {
_playerStateStream.add(AudioPlaybackState.paused);
}
}),
stream.completed.listen((isCompleted) async {
if (!isCompleted) return;
_playerStateStream.add(AudioPlaybackState.completed);
}),
stream.playlist.listen((event) {
if (event.medias.isEmpty) {
_playerStateStream.add(AudioPlaybackState.stopped);
}
}),
stream.error.listen((event) {
log('[MediaKitError] $event');
}),
];
PackageInfo.fromPlatform().then((packageInfo) {
_packageName = packageInfo.packageName;
});
if (PlatformInfo.isAndroid) {
_androidAudioManager = AndroidAudioManager();
AudioSession.instance.then((s) async {
_androidAudioSessionId =
await _androidAudioManager!.generateAudioSessionId();
notifyAudioSessionUpdate(true);
await nativePlayer.setProperty(
"audiotrack-session-id",
_androidAudioSessionId.toString(),
);
await nativePlayer.setProperty("ao", "audiotrack,opensles,");
});
}
}
Future<void> notifyAudioSessionUpdate(bool active) async {
if (PlatformInfo.isAndroid) {
sendBroadcast(
BroadcastMessage(
name: active
? "android.media.action.OPEN_AUDIO_EFFECT_CONTROL_SESSION"
: "android.media.action.CLOSE_AUDIO_EFFECT_CONTROL_SESSION",
data: {
"android.media.extra.AUDIO_SESSION": _androidAudioSessionId,
"android.media.extra.PACKAGE_NAME": _packageName
},
),
);
}
}
bool get shuffled => _shuffled;
Stream<AudioPlaybackState> get playerStateStream => _playerStateStream.stream;
Stream<bool> get shuffleStream => _shuffleStream.stream;
Stream<int> get indexChangeStream {
int oldIndex = state.playlist.index;
return stream.playlist.map((event) => event.index).where((newIndex) {
if (newIndex != oldIndex) {
oldIndex = newIndex;
return true;
}
return false;
});
}
@override
Future<void> setShuffle(bool shuffle) async {
_shuffled = shuffle;
await super.setShuffle(shuffle);
_shuffleStream.add(shuffle);
await Future.delayed(const Duration(milliseconds: 100));
if (shuffle) {
await move(state.playlist.index, 0);
}
}
@override
Future<void> stop() async {
await super.stop();
_shuffled = false;
_playerStateStream.add(AudioPlaybackState.stopped);
_shuffleStream.add(false);
}
@override
Future<void> dispose() async {
for (var element in _subscriptions) {
element.cancel();
}
await notifyAudioSessionUpdate(false);
return super.dispose();
}
NativePlayer get nativePlayer => platform as NativePlayer;
Future<void> insert(int index, Media media) async {
await add(media);
await move(state.playlist.medias.length, index);
}
Future<void> setAudioNormalization(bool normalize) async {
if (normalize) {
await nativePlayer.setProperty('af', 'dynaudnorm=g=5:f=250:r=0.9:p=0.5');
} else {
await nativePlayer.setProperty('af', '');
}
}
}

View File

@ -0,0 +1,28 @@
// import 'package:just_audio/just_audio.dart';
/// An unified playback state enum
enum AudioPlaybackState {
playing,
paused,
completed,
buffering,
stopped;
// static AudioPlaybackState fromJaPlayerState(PlayerState state) {
// if (state.playing) {
// return AudioPlaybackState.playing;
// }
// switch (state.processingState) {
// case ProcessingState.idle:
// return AudioPlaybackState.stopped;
// case ProcessingState.ready:
// return AudioPlaybackState.paused;
// case ProcessingState.completed:
// return AudioPlaybackState.completed;
// case ProcessingState.loading:
// case ProcessingState.buffering:
// return AudioPlaybackState.buffering;
// }
// }
}

View File

@ -0,0 +1,44 @@
import 'package:spotify/spotify.dart';
class LocalTrack extends Track {
final String path;
LocalTrack.fromTrack({
required Track track,
required this.path,
}) : super() {
album = track.album;
artists = track.artists;
availableMarkets = track.availableMarkets;
discNumber = track.discNumber;
durationMs = track.durationMs;
explicit = track.explicit;
externalIds = track.externalIds;
externalUrls = track.externalUrls;
href = track.href;
id = track.id;
isPlayable = track.isPlayable;
linkedFrom = track.linkedFrom;
name = track.name;
popularity = track.popularity;
previewUrl = track.previewUrl;
trackNumber = track.trackNumber;
type = track.type;
uri = track.uri;
}
factory LocalTrack.fromJson(Map<String, dynamic> json) {
return LocalTrack.fromTrack(
track: Track.fromJson(json),
path: json['path'],
);
}
@override
Map<String, dynamic> toJson() {
return {
...super.toJson(),
'path': path,
};
}
}

View File

@ -0,0 +1,158 @@
import 'dart:developer';
import 'dart:io';
import 'package:media_kit/media_kit.dart' hide Track;
import 'package:flutter/foundation.dart';
import 'package:rhythm_box/platform.dart';
import 'package:rhythm_box/services/audio_player/custom_player.dart';
import 'package:rhythm_box/services/local_track.dart';
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
import 'package:spotify/spotify.dart' hide Playlist;
import 'dart:async';
import 'package:media_kit/media_kit.dart' as mk;
class RhythmMedia extends mk.Media {
final Track track;
static int serverPort = 0;
RhythmMedia(
this.track, {
Map<String, dynamic>? extras,
super.httpHeaders,
}) : super(
track is LocalTrack
? track.path
: "http://${PlatformInfo.isWindows ? "localhost" : InternetAddress.anyIPv4.address}:$serverPort/stream/${track.id}",
extras: {
...?extras,
"track": switch (track) {
LocalTrack() => track.toJson(),
SourcedTrack() => track.toJson(),
_ => track.toJson(),
},
},
);
@override
String get uri {
return switch (track) {
/// [super.uri] must be used instead of [track.path] to prevent wrong
/// path format exceptions in Windows causing [extras] to be null
LocalTrack() => super.uri,
_ =>
"http://${PlatformInfo.isWindows ? "localhost" : InternetAddress.anyIPv4.address}:"
"$serverPort/stream/${track.id}",
};
}
factory RhythmMedia.fromMedia(mk.Media media) {
final track = media.uri.startsWith("http")
? Track.fromJson(media.extras?["track"])
: LocalTrack.fromJson(media.extras?["track"]);
return RhythmMedia(
track,
extras: media.extras,
httpHeaders: media.httpHeaders,
);
}
// @override
// operator ==(Object other) {
// if (other is! rhythm_boxMedia) return false;
// final isLocal = track is LocalTrack && other.track is LocalTrack;
// return isLocal
// ? (other.track as LocalTrack).path == (track as LocalTrack).path
// : other.track.id == track.id;
// }
// @override
// int get hashCode => track is LocalTrack
// ? (track as LocalTrack).path.hashCode
// : track.id.hashCode;
}
abstract class AudioPlayerInterface {
final CustomPlayer _mkPlayer;
AudioPlayerInterface()
: _mkPlayer = CustomPlayer(
configuration: const mk.PlayerConfiguration(
title: "Rhythm",
logLevel: kDebugMode ? mk.MPVLogLevel.info : mk.MPVLogLevel.error,
),
) {
_mkPlayer.stream.error.listen((event) {
log("[Playback] Error: $event");
});
}
/// Whether the current platform supports the audioplayers plugin
static const bool _mkSupportedPlatform = true;
bool get mkSupportedPlatform => _mkSupportedPlatform;
Duration get duration {
return _mkPlayer.state.duration;
}
Playlist get playlist {
return _mkPlayer.state.playlist;
}
Duration get position {
return _mkPlayer.state.position;
}
Duration get bufferedPosition {
return _mkPlayer.state.buffer;
}
Future<mk.AudioDevice> get selectedDevice async {
return _mkPlayer.state.audioDevice;
}
Future<List<mk.AudioDevice>> get devices async {
return _mkPlayer.state.audioDevices;
}
bool get hasSource {
return _mkPlayer.state.playlist.medias.isNotEmpty;
}
// states
bool get isPlaying {
return _mkPlayer.state.playing;
}
bool get isPaused {
return !_mkPlayer.state.playing;
}
bool get isStopped {
return !hasSource;
}
Future<bool> get isCompleted async {
return _mkPlayer.state.completed;
}
bool get isShuffled {
return _mkPlayer.shuffled;
}
PlaylistMode get loopMode {
return _mkPlayer.state.playlistMode;
}
/// Returns the current volume of the player, between 0 and 1
double get volume {
return _mkPlayer.state.volume / 100;
}
bool get isBuffering {
return _mkPlayer.state.buffering;
}
}

View File

@ -0,0 +1,19 @@
part of './song_link.dart';
@freezed
class SongLink with _$SongLink {
const factory SongLink({
required String displayName,
required String linkId,
required String platform,
required bool show,
required String? uniqueId,
required String? country,
required String? url,
required String? nativeAppUriMobile,
required String? nativeAppUriDesktop,
}) = _SongLink;
factory SongLink.fromJson(Map<String, dynamic> json) =>
_$SongLinkFromJson(json);
}

View File

@ -0,0 +1,51 @@
library song_link;
import 'dart:convert';
import 'dart:developer';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:get/get.dart';
import 'package:html/parser.dart';
part 'model.dart';
part 'song_link.freezed.dart';
part 'song_link.g.dart';
abstract class SongLinkService {
static Future<List<SongLink>> links(String spotifyId) async {
try {
final client = GetConnect();
final res = await client.get(
"https://song.link/s/$spotifyId",
headers: {
"Accept":
"text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8",
"User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
},
);
final document = parse(res.body);
final script = document.getElementById("__NEXT_DATA__")?.text;
if (script == null) {
return <SongLink>[];
}
final pageProps = jsonDecode(script) as Map<String, dynamic>;
final songLinks = pageProps["props"]?["pageProps"]?["pageData"]
?["sections"]
?.firstWhere(
(section) => section?["sectionId"] == "section|auto|links|listen",
)?["links"] as List?;
return songLinks?.map((link) => SongLink.fromJson(link)).toList() ??
<SongLink>[];
} catch (e) {
log('[SongLink] Unable get song link: $e');
return <SongLink>[];
}
}
}

View File

@ -0,0 +1,320 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'song_link.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
SongLink _$SongLinkFromJson(Map<String, dynamic> json) {
return _SongLink.fromJson(json);
}
/// @nodoc
mixin _$SongLink {
String get displayName => throw _privateConstructorUsedError;
String get linkId => throw _privateConstructorUsedError;
String get platform => throw _privateConstructorUsedError;
bool get show => throw _privateConstructorUsedError;
String? get uniqueId => throw _privateConstructorUsedError;
String? get country => throw _privateConstructorUsedError;
String? get url => throw _privateConstructorUsedError;
String? get nativeAppUriMobile => throw _privateConstructorUsedError;
String? get nativeAppUriDesktop => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$SongLinkCopyWith<SongLink> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $SongLinkCopyWith<$Res> {
factory $SongLinkCopyWith(SongLink value, $Res Function(SongLink) then) =
_$SongLinkCopyWithImpl<$Res, SongLink>;
@useResult
$Res call(
{String displayName,
String linkId,
String platform,
bool show,
String? uniqueId,
String? country,
String? url,
String? nativeAppUriMobile,
String? nativeAppUriDesktop});
}
/// @nodoc
class _$SongLinkCopyWithImpl<$Res, $Val extends SongLink>
implements $SongLinkCopyWith<$Res> {
_$SongLinkCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? displayName = null,
Object? linkId = null,
Object? platform = null,
Object? show = null,
Object? uniqueId = freezed,
Object? country = freezed,
Object? url = freezed,
Object? nativeAppUriMobile = freezed,
Object? nativeAppUriDesktop = freezed,
}) {
return _then(_value.copyWith(
displayName: null == displayName
? _value.displayName
: displayName // ignore: cast_nullable_to_non_nullable
as String,
linkId: null == linkId
? _value.linkId
: linkId // ignore: cast_nullable_to_non_nullable
as String,
platform: null == platform
? _value.platform
: platform // ignore: cast_nullable_to_non_nullable
as String,
show: null == show
? _value.show
: show // ignore: cast_nullable_to_non_nullable
as bool,
uniqueId: freezed == uniqueId
? _value.uniqueId
: uniqueId // ignore: cast_nullable_to_non_nullable
as String?,
country: freezed == country
? _value.country
: country // ignore: cast_nullable_to_non_nullable
as String?,
url: freezed == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String?,
nativeAppUriMobile: freezed == nativeAppUriMobile
? _value.nativeAppUriMobile
: nativeAppUriMobile // ignore: cast_nullable_to_non_nullable
as String?,
nativeAppUriDesktop: freezed == nativeAppUriDesktop
? _value.nativeAppUriDesktop
: nativeAppUriDesktop // ignore: cast_nullable_to_non_nullable
as String?,
) as $Val);
}
}
/// @nodoc
abstract class _$$SongLinkImplCopyWith<$Res>
implements $SongLinkCopyWith<$Res> {
factory _$$SongLinkImplCopyWith(
_$SongLinkImpl value, $Res Function(_$SongLinkImpl) then) =
__$$SongLinkImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String displayName,
String linkId,
String platform,
bool show,
String? uniqueId,
String? country,
String? url,
String? nativeAppUriMobile,
String? nativeAppUriDesktop});
}
/// @nodoc
class __$$SongLinkImplCopyWithImpl<$Res>
extends _$SongLinkCopyWithImpl<$Res, _$SongLinkImpl>
implements _$$SongLinkImplCopyWith<$Res> {
__$$SongLinkImplCopyWithImpl(
_$SongLinkImpl _value, $Res Function(_$SongLinkImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? displayName = null,
Object? linkId = null,
Object? platform = null,
Object? show = null,
Object? uniqueId = freezed,
Object? country = freezed,
Object? url = freezed,
Object? nativeAppUriMobile = freezed,
Object? nativeAppUriDesktop = freezed,
}) {
return _then(_$SongLinkImpl(
displayName: null == displayName
? _value.displayName
: displayName // ignore: cast_nullable_to_non_nullable
as String,
linkId: null == linkId
? _value.linkId
: linkId // ignore: cast_nullable_to_non_nullable
as String,
platform: null == platform
? _value.platform
: platform // ignore: cast_nullable_to_non_nullable
as String,
show: null == show
? _value.show
: show // ignore: cast_nullable_to_non_nullable
as bool,
uniqueId: freezed == uniqueId
? _value.uniqueId
: uniqueId // ignore: cast_nullable_to_non_nullable
as String?,
country: freezed == country
? _value.country
: country // ignore: cast_nullable_to_non_nullable
as String?,
url: freezed == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String?,
nativeAppUriMobile: freezed == nativeAppUriMobile
? _value.nativeAppUriMobile
: nativeAppUriMobile // ignore: cast_nullable_to_non_nullable
as String?,
nativeAppUriDesktop: freezed == nativeAppUriDesktop
? _value.nativeAppUriDesktop
: nativeAppUriDesktop // ignore: cast_nullable_to_non_nullable
as String?,
));
}
}
/// @nodoc
@JsonSerializable()
class _$SongLinkImpl implements _SongLink {
const _$SongLinkImpl(
{required this.displayName,
required this.linkId,
required this.platform,
required this.show,
required this.uniqueId,
required this.country,
required this.url,
required this.nativeAppUriMobile,
required this.nativeAppUriDesktop});
factory _$SongLinkImpl.fromJson(Map<String, dynamic> json) =>
_$$SongLinkImplFromJson(json);
@override
final String displayName;
@override
final String linkId;
@override
final String platform;
@override
final bool show;
@override
final String? uniqueId;
@override
final String? country;
@override
final String? url;
@override
final String? nativeAppUriMobile;
@override
final String? nativeAppUriDesktop;
@override
String toString() {
return 'SongLink(displayName: $displayName, linkId: $linkId, platform: $platform, show: $show, uniqueId: $uniqueId, country: $country, url: $url, nativeAppUriMobile: $nativeAppUriMobile, nativeAppUriDesktop: $nativeAppUriDesktop)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SongLinkImpl &&
(identical(other.displayName, displayName) ||
other.displayName == displayName) &&
(identical(other.linkId, linkId) || other.linkId == linkId) &&
(identical(other.platform, platform) ||
other.platform == platform) &&
(identical(other.show, show) || other.show == show) &&
(identical(other.uniqueId, uniqueId) ||
other.uniqueId == uniqueId) &&
(identical(other.country, country) || other.country == country) &&
(identical(other.url, url) || other.url == url) &&
(identical(other.nativeAppUriMobile, nativeAppUriMobile) ||
other.nativeAppUriMobile == nativeAppUriMobile) &&
(identical(other.nativeAppUriDesktop, nativeAppUriDesktop) ||
other.nativeAppUriDesktop == nativeAppUriDesktop));
}
@JsonKey(ignore: true)
@override
int get hashCode => Object.hash(runtimeType, displayName, linkId, platform,
show, uniqueId, country, url, nativeAppUriMobile, nativeAppUriDesktop);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith =>
__$$SongLinkImplCopyWithImpl<_$SongLinkImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$SongLinkImplToJson(
this,
);
}
}
abstract class _SongLink implements SongLink {
const factory _SongLink(
{required final String displayName,
required final String linkId,
required final String platform,
required final bool show,
required final String? uniqueId,
required final String? country,
required final String? url,
required final String? nativeAppUriMobile,
required final String? nativeAppUriDesktop}) = _$SongLinkImpl;
factory _SongLink.fromJson(Map<String, dynamic> json) =
_$SongLinkImpl.fromJson;
@override
String get displayName;
@override
String get linkId;
@override
String get platform;
@override
bool get show;
@override
String? get uniqueId;
@override
String? get country;
@override
String? get url;
@override
String? get nativeAppUriMobile;
@override
String? get nativeAppUriDesktop;
@override
@JsonKey(ignore: true)
_$$SongLinkImplCopyWith<_$SongLinkImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,32 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'song_link.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$SongLinkImpl _$$SongLinkImplFromJson(Map json) => _$SongLinkImpl(
displayName: json['displayName'] as String,
linkId: json['linkId'] as String,
platform: json['platform'] as String,
show: json['show'] as bool,
uniqueId: json['uniqueId'] as String?,
country: json['country'] as String?,
url: json['url'] as String?,
nativeAppUriMobile: json['nativeAppUriMobile'] as String?,
nativeAppUriDesktop: json['nativeAppUriDesktop'] as String?,
);
Map<String, dynamic> _$$SongLinkImplToJson(_$SongLinkImpl instance) =>
<String, dynamic>{
'displayName': instance.displayName,
'linkId': instance.linkId,
'platform': instance.platform,
'show': instance.show,
'uniqueId': instance.uniqueId,
'country': instance.country,
'url': instance.url,
'nativeAppUriMobile': instance.nativeAppUriMobile,
'nativeAppUriDesktop': instance.nativeAppUriDesktop,
};

10
lib/services/sort.dart Normal file
View File

@ -0,0 +1,10 @@
enum SortBy {
none,
ascending,
descending,
newest,
oldest,
duration,
artist,
album,
}

View File

@ -0,0 +1,18 @@
import 'package:rhythm_box/services/sourced_track/models/source_info.dart';
import 'package:rhythm_box/services/sourced_track/models/source_map.dart';
enum SourceCodecs {
m4a._("M4a (Best for downloaded music)"),
weba._("WebA (Best for streamed music)\nDoesn't support audio metadata");
final String label;
const SourceCodecs._(this.label);
}
enum SourceQualities {
high,
medium,
low,
}
typedef SiblingType<T extends SourceInfo> = ({T info, SourceMap? source});

View File

@ -0,0 +1,12 @@
import 'package:spotify/spotify.dart';
class TrackNotFoundError extends Error {
final Track track;
TrackNotFoundError(this.track);
@override
String toString() {
return '[TrackNotFoundError] ${track.name} - ${track.artists?.map((e) => e.name).join(", ")}';
}
}

View File

@ -0,0 +1,12 @@
enum SearchMode {
youtube._("YouTube"),
youtubeMusic._("YouTube Music");
final String label;
const SearchMode._(this.label);
factory SearchMode.fromString(String key) {
return SearchMode.values.firstWhere((e) => e.name == key);
}
}

View File

@ -0,0 +1,33 @@
import 'package:json_annotation/json_annotation.dart';
part 'source_info.g.dart';
@JsonSerializable()
class SourceInfo {
final String id;
final String title;
final String artist;
final String artistUrl;
final String? album;
final String thumbnail;
final String pageUrl;
final Duration duration;
SourceInfo({
required this.id,
required this.title,
required this.artist,
required this.thumbnail,
required this.pageUrl,
required this.duration,
required this.artistUrl,
this.album,
});
factory SourceInfo.fromJson(Map<String, dynamic> json) =>
_$SourceInfoFromJson(json);
Map<String, dynamic> toJson() => _$SourceInfoToJson(this);
}

View File

@ -0,0 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'source_info.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SourceInfo _$SourceInfoFromJson(Map json) => SourceInfo(
id: json['id'] as String,
title: json['title'] as String,
artist: json['artist'] as String,
thumbnail: json['thumbnail'] as String,
pageUrl: json['pageUrl'] as String,
duration: Duration(microseconds: json['duration'] as int),
artistUrl: json['artistUrl'] as String,
album: json['album'] as String?,
);
Map<String, dynamic> _$SourceInfoToJson(SourceInfo instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'artist': instance.artist,
'artistUrl': instance.artistUrl,
'album': instance.album,
'thumbnail': instance.thumbnail,
'pageUrl': instance.pageUrl,
'duration': instance.duration.inMicroseconds,
};

View File

@ -0,0 +1,58 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:rhythm_box/services/sourced_track/enums.dart';
part 'source_map.g.dart';
@JsonSerializable()
class SourceQualityMap {
final String high;
final String medium;
final String low;
const SourceQualityMap({
required this.high,
required this.medium,
required this.low,
});
factory SourceQualityMap.fromJson(Map<String, dynamic> json) =>
_$SourceQualityMapFromJson(json);
Map<String, dynamic> toJson() => _$SourceQualityMapToJson(this);
operator [](SourceQualities key) {
switch (key) {
case SourceQualities.high:
return high;
case SourceQualities.medium:
return medium;
case SourceQualities.low:
return low;
}
}
}
@JsonSerializable()
class SourceMap {
final SourceQualityMap? weba;
final SourceQualityMap? m4a;
const SourceMap({
this.weba,
this.m4a,
});
factory SourceMap.fromJson(Map<String, dynamic> json) =>
_$SourceMapFromJson(json);
Map<String, dynamic> toJson() => _$SourceMapToJson(this);
operator [](SourceCodecs key) {
switch (key) {
case SourceCodecs.weba:
return weba;
case SourceCodecs.m4a:
return m4a;
}
}
}

View File

@ -0,0 +1,36 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'source_map.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
SourceQualityMap _$SourceQualityMapFromJson(Map json) => SourceQualityMap(
high: json['high'] as String,
medium: json['medium'] as String,
low: json['low'] as String,
);
Map<String, dynamic> _$SourceQualityMapToJson(SourceQualityMap instance) =>
<String, dynamic>{
'high': instance.high,
'medium': instance.medium,
'low': instance.low,
};
SourceMap _$SourceMapFromJson(Map json) => SourceMap(
weba: json['weba'] == null
? null
: SourceQualityMap.fromJson(
Map<String, dynamic>.from(json['weba'] as Map)),
m4a: json['m4a'] == null
? null
: SourceQualityMap.fromJson(
Map<String, dynamic>.from(json['m4a'] as Map)),
);
Map<String, dynamic> _$SourceMapToJson(SourceMap instance) => <String, dynamic>{
'weba': instance.weba?.toJson(),
'm4a': instance.m4a?.toJson(),
};

View File

@ -0,0 +1,115 @@
import 'package:piped_client/piped_client.dart';
import 'package:rhythm_box/services/sourced_track/models/search.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
class YoutubeVideoInfo {
final SearchMode searchMode;
final String title;
final Duration duration;
final String thumbnailUrl;
final String id;
final int likes;
final int dislikes;
final int views;
final String channelName;
final String channelId;
final DateTime publishedAt;
YoutubeVideoInfo({
required this.searchMode,
required this.title,
required this.duration,
required this.thumbnailUrl,
required this.id,
required this.likes,
required this.dislikes,
required this.views,
required this.channelName,
required this.publishedAt,
required this.channelId,
});
YoutubeVideoInfo.fromJson(Map<String, dynamic> json)
: title = json['title'],
searchMode = SearchMode.fromString(json['searchMode']),
duration = Duration(seconds: json['duration']),
thumbnailUrl = json['thumbnailUrl'],
id = json['id'],
likes = json['likes'],
dislikes = json['dislikes'],
views = json['views'],
channelName = json['channelName'],
channelId = json['channelId'],
publishedAt = DateTime.tryParse(json['publishedAt']) ?? DateTime.now();
Map<String, dynamic> toJson() => {
'title': title,
'duration': duration.inSeconds,
'thumbnailUrl': thumbnailUrl,
'id': id,
'likes': likes,
'dislikes': dislikes,
'views': views,
'channelName': channelName,
'channelId': channelId,
'publishedAt': publishedAt.toIso8601String(),
'searchMode': searchMode.name,
};
factory YoutubeVideoInfo.fromVideo(Video video) {
return YoutubeVideoInfo(
searchMode: SearchMode.youtube,
title: video.title,
duration: video.duration ?? Duration.zero,
thumbnailUrl: video.thumbnails.mediumResUrl,
id: video.id.value,
likes: video.engagement.likeCount ?? 0,
dislikes: video.engagement.dislikeCount ?? 0,
views: video.engagement.viewCount,
channelName: video.author,
channelId: '/c/${video.channelId.value}',
publishedAt: video.uploadDate ?? DateTime(2003, 9, 9),
);
}
factory YoutubeVideoInfo.fromSearchItemStream(
PipedSearchItemStream searchItem,
SearchMode searchMode,
) {
return YoutubeVideoInfo(
searchMode: searchMode,
title: searchItem.title,
duration: searchItem.duration,
thumbnailUrl: searchItem.thumbnail,
id: searchItem.id,
likes: 0,
dislikes: 0,
views: searchItem.views,
channelName: searchItem.uploaderName,
channelId: searchItem.uploaderUrl ?? "",
publishedAt: searchItem.uploadedDate != null
? DateTime.tryParse(searchItem.uploadedDate!) ?? DateTime(2003, 9, 9)
: DateTime(2003, 9, 9),
);
}
factory YoutubeVideoInfo.fromStreamResponse(
PipedStreamResponse stream, SearchMode searchMode) {
return YoutubeVideoInfo(
searchMode: searchMode,
title: stream.title,
duration: stream.duration,
thumbnailUrl: stream.thumbnailUrl,
id: stream.id,
likes: stream.likes,
dislikes: stream.dislikes,
views: stream.views,
channelName: stream.uploader,
publishedAt: stream.uploadedDate != null
? DateTime.tryParse(stream.uploadedDate!) ?? DateTime(2003, 9, 9)
: DateTime(2003, 9, 9),
channelId: stream.uploaderUrl,
);
}
}

View File

@ -0,0 +1,153 @@
import 'package:collection/collection.dart';
import 'package:rhythm_box/services/utils.dart';
import 'package:spotify/spotify.dart';
import 'package:rhythm_box/services/sourced_track/enums.dart';
import 'package:rhythm_box/services/sourced_track/exceptions.dart';
import 'package:rhythm_box/services/sourced_track/models/source_info.dart';
import 'package:rhythm_box/services/sourced_track/models/source_map.dart';
import 'package:rhythm_box/services/sourced_track/sources/piped.dart';
import 'package:rhythm_box/services/sourced_track/sources/youtube.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
abstract class SourcedTrack extends Track {
final SourceMap source;
final List<SourceInfo> siblings;
final SourceInfo sourceInfo;
SourcedTrack({
required this.source,
required this.siblings,
required this.sourceInfo,
required Track track,
}) {
id = track.id;
name = track.name;
artists = track.artists;
album = track.album;
durationMs = track.durationMs;
discNumber = track.discNumber;
explicit = track.explicit;
externalIds = track.externalIds;
href = track.href;
isPlayable = track.isPlayable;
linkedFrom = track.linkedFrom;
popularity = track.popularity;
previewUrl = track.previewUrl;
trackNumber = track.trackNumber;
type = track.type;
uri = track.uri;
}
static SourcedTrack fromJson(Map<String, dynamic> json) {
// TODO Follow user preferences
const audioSource = "youtube";
final sourceInfo = SourceInfo.fromJson(json);
final source = SourceMap.fromJson(json);
final track = Track.fromJson(json);
final siblings = (json["siblings"] as List)
.map((sibling) => SourceInfo.fromJson(sibling))
.toList()
.cast<SourceInfo>();
return switch (audioSource) {
"piped" => PipedSourcedTrack(
source: source,
siblings: siblings,
sourceInfo: sourceInfo,
track: track,
),
_ => YoutubeSourcedTrack(
source: source,
siblings: siblings,
sourceInfo: sourceInfo,
track: track,
),
};
}
static String getSearchTerm(Track track) {
final artists = (track.artists ?? [])
.map((ar) => ar.name)
.toList()
.whereNotNull()
.toList();
final title = ServiceUtils.getTitle(
track.name!,
artists: artists,
onlyCleanArtist: true,
).trim();
return "$title - ${artists.join(", ")}";
}
static Future<SourcedTrack> fetchFromTrack({
required Track track,
}) async {
// TODO Follow user preferences
const audioSource = "youtube";
try {
return switch (audioSource) {
"piped" => await PipedSourcedTrack.fetchFromTrack(track: track),
_ => await YoutubeSourcedTrack.fetchFromTrack(track: track),
};
} on TrackNotFoundError catch (_) {
// TODO Try to look it up in other source
// But the youtube and piped.video are the same, and there is no extra sources, so i ignored this for temporary
rethrow;
} on HttpClientClosedException catch (_) {
return await PipedSourcedTrack.fetchFromTrack(track: track);
} on VideoUnplayableException catch (_) {
return await PipedSourcedTrack.fetchFromTrack(track: track);
} catch (e) {
rethrow;
}
}
static Future<List<SiblingType>> fetchSiblings({
required Track track,
}) {
// TODO Follow user preferences
const audioSource = "youtube";
return switch (audioSource) {
"piped" => PipedSourcedTrack.fetchSiblings(track: track),
_ => YoutubeSourcedTrack.fetchSiblings(track: track),
};
}
Future<SourcedTrack> copyWithSibling();
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling);
Future<SourcedTrack?> swapWithSiblingOfIndex(int index) {
return swapWithSibling(siblings[index]);
}
String get url {
// TODO Follow user preferences
const streamMusicCodec = SourceCodecs.weba;
return getUrlOfCodec(streamMusicCodec);
}
String getUrlOfCodec(SourceCodecs codec) {
// TODO Follow user preferences
const audioQuality = SourceQualities.high;
return source[codec]?[audioQuality] ??
// this will ensure playback doesn't break
source[codec == SourceCodecs.m4a ? SourceCodecs.weba : SourceCodecs.m4a]
[audioQuality];
}
SourceCodecs get codec {
// TODO Follow user preferences
const streamMusicCodec = SourceCodecs.weba;
return streamMusicCodec;
}
}

View File

@ -0,0 +1,239 @@
import 'package:collection/collection.dart';
import 'package:piped_client/piped_client.dart';
import 'package:rhythm_box/services/sourced_track/models/search.dart';
import 'package:rhythm_box/services/utils.dart';
import 'package:spotify/spotify.dart';
import 'package:rhythm_box/services/sourced_track/enums.dart';
import 'package:rhythm_box/services/sourced_track/exceptions.dart';
import 'package:rhythm_box/services/sourced_track/models/source_info.dart';
import 'package:rhythm_box/services/sourced_track/models/source_map.dart';
import 'package:rhythm_box/services/sourced_track/models/video_info.dart';
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
import 'package:rhythm_box/services/sourced_track/sources/youtube.dart';
class PipedSourceInfo extends SourceInfo {
PipedSourceInfo({
required super.id,
required super.title,
required super.artist,
required super.thumbnail,
required super.pageUrl,
required super.duration,
required super.artistUrl,
required super.album,
});
}
class PipedSourcedTrack extends SourcedTrack {
PipedSourcedTrack({
required super.source,
required super.siblings,
required super.sourceInfo,
required super.track,
});
static PipedClient _getClient() {
// TODO Allow user define their own piped.video instance
return PipedClient();
}
static Future<SourcedTrack> fetchFromTrack({
required Track track,
}) async {
// TODO Add cache query here
final siblings = await fetchSiblings(track: track);
if (siblings.isEmpty) {
throw TrackNotFoundError(track);
}
// TODO Insert to cache here
return PipedSourcedTrack(
siblings: siblings.map((s) => s.info).skip(1).toList(),
source: siblings.first.source as SourceMap,
sourceInfo: siblings.first.info,
track: track,
);
}
static SourceMap toSourceMap(PipedStreamResponse manifest) {
final m4a = manifest.audioStreams
.where((audio) => audio.format == PipedAudioStreamFormat.m4a)
.sorted((a, b) => a.bitrate.compareTo(b.bitrate));
final weba = manifest.audioStreams
.where((audio) => audio.format == PipedAudioStreamFormat.webm)
.sorted((a, b) => a.bitrate.compareTo(b.bitrate));
return SourceMap(
m4a: SourceQualityMap(
high: m4a.first.url.toString(),
medium: (m4a.elementAtOrNull(m4a.length ~/ 2) ?? m4a[1]).url.toString(),
low: m4a.last.url.toString(),
),
weba: SourceQualityMap(
high: weba.first.url.toString(),
medium:
(weba.elementAtOrNull(weba.length ~/ 2) ?? weba[1]).url.toString(),
low: weba.last.url.toString(),
),
);
}
static Future<SiblingType> toSiblingType(
int index,
YoutubeVideoInfo item,
PipedClient pipedClient,
) async {
SourceMap? sourceMap;
if (index == 0) {
final manifest = await pipedClient.streams(item.id);
sourceMap = toSourceMap(manifest);
}
final SiblingType sibling = (
info: PipedSourceInfo(
id: item.id,
artist: item.channelName,
artistUrl: "https://www.youtube.com/${item.channelId}",
pageUrl: "https://www.youtube.com/watch?v=${item.id}",
thumbnail: item.thumbnailUrl,
title: item.title,
duration: item.duration,
album: null,
),
source: sourceMap,
);
return sibling;
}
static Future<List<SiblingType>> fetchSiblings({
required Track track,
}) async {
final pipedClient = _getClient();
// TODO Allow user search with normal youtube video (`youtube`)
const searchMode = SearchMode.youtubeMusic;
// TODO Follow user preferences
const audioSource = "youtube";
final query = SourcedTrack.getSearchTerm(track);
final PipedSearchResult(items: searchResults) = await pipedClient.search(
query,
searchMode == SearchMode.youtube
? PipedFilter.video
: PipedFilter.musicSongs,
);
// when falling back to piped API make sure to use the YouTube mode
const isYouTubeMusic =
audioSource != "piped" ? false : searchMode == SearchMode.youtubeMusic;
if (isYouTubeMusic) {
final artists = (track.artists ?? [])
.map((ar) => ar.name)
.toList()
.whereNotNull()
.toList();
return await Future.wait(
searchResults
.map(
(result) => YoutubeVideoInfo.fromSearchItemStream(
result as PipedSearchItemStream,
searchMode,
),
)
.sorted((a, b) => b.views.compareTo(a.views))
.where(
(item) => artists.any(
(artist) =>
artist.toLowerCase() == item.channelName.toLowerCase(),
),
)
.mapIndexed((i, r) => toSiblingType(i, r, pipedClient)),
);
}
if (ServiceUtils.onlyContainsEnglish(query)) {
return await Future.wait(
searchResults
.whereType<PipedSearchItemStream>()
.map(
(result) => YoutubeVideoInfo.fromSearchItemStream(
result,
searchMode,
),
)
.mapIndexed((i, r) => toSiblingType(i, r, pipedClient)),
);
}
final rankedSiblings = YoutubeSourcedTrack.rankResults(
searchResults
.map(
(result) => YoutubeVideoInfo.fromSearchItemStream(
result as PipedSearchItemStream,
searchMode,
),
)
.toList(),
track,
);
return await Future.wait(
rankedSiblings.mapIndexed((i, r) => toSiblingType(i, r, pipedClient)),
);
}
@override
Future<SourcedTrack> copyWithSibling() async {
if (siblings.isNotEmpty) {
return this;
}
final fetchedSiblings = await fetchSiblings(track: this);
return PipedSourcedTrack(
siblings: fetchedSiblings
.where((s) => s.info.id != sourceInfo.id)
.map((s) => s.info)
.toList(),
source: source,
sourceInfo: sourceInfo,
track: this,
);
}
@override
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
if (sibling.id == sourceInfo.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, sourceInfo);
final pipedClient = _getClient();
final manifest = await pipedClient.streams(newSourceInfo.id);
// TODO Save to cache here
return PipedSourcedTrack(
siblings: newSiblings,
source: toSourceMap(manifest),
sourceInfo: newSourceInfo,
track: this,
);
}
}

View File

@ -0,0 +1,273 @@
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:http/http.dart';
import 'package:rhythm_box/services/utils.dart';
import 'package:spotify/spotify.dart';
import 'package:rhythm_box/services/song_link/song_link.dart';
import 'package:rhythm_box/services/sourced_track/enums.dart';
import 'package:rhythm_box/services/sourced_track/exceptions.dart';
import 'package:rhythm_box/services/sourced_track/models/source_info.dart';
import 'package:rhythm_box/services/sourced_track/models/source_map.dart';
import 'package:rhythm_box/services/sourced_track/models/video_info.dart';
import 'package:rhythm_box/services/sourced_track/sourced_track.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
final youtubeClient = YoutubeExplode();
final officialMusicRegex = RegExp(
r"official\s(video|audio|music\svideo|lyric\svideo|visualizer)",
caseSensitive: false,
);
class YoutubeSourceInfo extends SourceInfo {
YoutubeSourceInfo({
required super.id,
required super.title,
required super.artist,
required super.thumbnail,
required super.pageUrl,
required super.duration,
required super.artistUrl,
required super.album,
});
}
class YoutubeSourcedTrack extends SourcedTrack {
YoutubeSourcedTrack({
required super.source,
required super.siblings,
required super.sourceInfo,
required super.track,
});
static Future<YoutubeSourcedTrack> fetchFromTrack({
required Track track,
}) async {
// TODO Add cache query here
final siblings = await fetchSiblings(track: track);
if (siblings.isEmpty) {
throw TrackNotFoundError(track);
}
// TODO Save to cache here
return YoutubeSourcedTrack(
siblings: siblings.map((s) => s.info).skip(1).toList(),
source: siblings.first.source as SourceMap,
sourceInfo: siblings.first.info,
track: track,
);
}
static SourceMap toSourceMap(StreamManifest manifest) {
var m4a = manifest.audioOnly
.where((audio) => audio.codec.mimeType == "audio/mp4")
.sortByBitrate();
var weba = manifest.audioOnly
.where((audio) => audio.codec.mimeType == "audio/webm")
.sortByBitrate();
m4a = m4a.isEmpty ? weba.toList() : m4a;
weba = weba.isEmpty ? m4a.toList() : weba;
return SourceMap(
m4a: SourceQualityMap(
high: m4a.first.url.toString(),
medium: (m4a.elementAtOrNull(m4a.length ~/ 2) ?? m4a[1]).url.toString(),
low: m4a.last.url.toString(),
),
weba: SourceQualityMap(
high: weba.first.url.toString(),
medium:
(weba.elementAtOrNull(weba.length ~/ 2) ?? weba[1]).url.toString(),
low: weba.last.url.toString(),
),
);
}
static Future<SiblingType> toSiblingType(
int index,
YoutubeVideoInfo item,
) async {
SourceMap? sourceMap;
if (index == 0) {
final manifest =
await youtubeClient.videos.streamsClient.getManifest(item.id).timeout(
const Duration(seconds: 5),
onTimeout: () => throw ClientException("Timeout"),
);
sourceMap = toSourceMap(manifest);
}
final SiblingType sibling = (
info: YoutubeSourceInfo(
id: item.id,
artist: item.channelName,
artistUrl: "https://www.youtube.com/channel/${item.channelId}",
pageUrl: "https://www.youtube.com/watch?v=${item.id}",
thumbnail: item.thumbnailUrl,
title: item.title,
duration: item.duration,
album: null,
),
source: sourceMap,
);
return sibling;
}
static List<YoutubeVideoInfo> rankResults(
List<YoutubeVideoInfo> results, Track track) {
final artists = (track.artists ?? [])
.map((ar) => ar.name)
.toList()
.whereNotNull()
.toList();
return results
.sorted((a, b) => b.views.compareTo(a.views))
.map((sibling) {
int score = 0;
for (final artist in artists) {
final isSameChannelArtist =
sibling.channelName.toLowerCase() == artist.toLowerCase();
final channelContainsArtist = sibling.channelName
.toLowerCase()
.contains(artist.toLowerCase());
if (isSameChannelArtist || channelContainsArtist) {
score += 1;
}
final titleContainsArtist =
sibling.title.toLowerCase().contains(artist.toLowerCase());
if (titleContainsArtist) {
score += 1;
}
}
final titleContainsTrackName =
sibling.title.toLowerCase().contains(track.name!.toLowerCase());
final hasOfficialFlag =
officialMusicRegex.hasMatch(sibling.title.toLowerCase());
if (titleContainsTrackName) {
score += 3;
}
if (hasOfficialFlag) {
score += 1;
}
if (hasOfficialFlag && titleContainsTrackName) {
score += 2;
}
return (sibling: sibling, score: score);
})
.sorted((a, b) => b.score.compareTo(a.score))
.map((e) => e.sibling)
.toList();
}
static Future<List<SiblingType>> fetchSiblings({
required Track track,
}) async {
final links = await SongLinkService.links(track.id!);
final ytLink = links.firstWhereOrNull((link) => link.platform == "youtube");
if (ytLink?.url != null
// allows to fetch siblings more results for already sourced track
&&
track is! SourcedTrack) {
try {
return [
await toSiblingType(
0,
YoutubeVideoInfo.fromVideo(
await youtubeClient.videos.get(ytLink!.url!),
),
)
];
} on VideoUnplayableException catch (e) {
// Ignore this error and continue with the search
log('[Source][YoutubeMusic] Unable to search data: $e');
}
}
final query = SourcedTrack.getSearchTerm(track);
final searchResults = await youtubeClient.search.search(
"$query - Topic",
filter: TypeFilters.video,
);
if (ServiceUtils.onlyContainsEnglish(query)) {
return await Future.wait(searchResults
.map(YoutubeVideoInfo.fromVideo)
.mapIndexed(toSiblingType));
}
final rankedSiblings = rankResults(
searchResults.map(YoutubeVideoInfo.fromVideo).toList(),
track,
);
return await Future.wait(rankedSiblings.mapIndexed(toSiblingType));
}
@override
Future<YoutubeSourcedTrack?> swapWithSibling(SourceInfo sibling) async {
if (sibling.id == sourceInfo.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, sourceInfo);
final manifest = await youtubeClient.videos.streamsClient
.getManifest(newSourceInfo.id)
.timeout(
const Duration(seconds: 5),
onTimeout: () => throw ClientException("Timeout"),
);
// TODO Save to cache here
return YoutubeSourcedTrack(
siblings: newSiblings,
source: toSourceMap(manifest),
sourceInfo: newSourceInfo,
track: this,
);
}
@override
Future<YoutubeSourcedTrack> copyWithSibling() async {
if (siblings.isNotEmpty) {
return this;
}
final fetchedSiblings = await fetchSiblings(track: this);
return YoutubeSourcedTrack(
siblings: fetchedSiblings
.where((s) => s.info.id != sourceInfo.id)
.map((s) => s.info)
.toList(),
source: source,
sourceInfo: sourceInfo,
track: this,
);
}
}

189
lib/services/utils.dart Normal file
View File

@ -0,0 +1,189 @@
import 'package:get/get.dart';
import 'package:go_router/go_router.dart';
import 'package:html/dom.dart' hide Text;
import 'package:rhythm_box/services/sort.dart';
import 'package:spotify/spotify.dart';
import 'package:html/parser.dart' as parser;
import 'dart:async';
import 'package:flutter/material.dart' hide Element;
abstract class ServiceUtils {
static final _englishMatcherRegex = RegExp(
"^[a-zA-Z0-9\\s!\"#\$%&\\'()*+,-.\\/:;<=>?@\\[\\]^_`{|}~]*\$",
);
static bool onlyContainsEnglish(String text) {
return _englishMatcherRegex.hasMatch(text);
}
static String clearArtistsOfTitle(String title, List<String> artists) {
return title
.replaceAll(RegExp(artists.join("|"), caseSensitive: false), "")
.trim();
}
static String getTitle(
String title, {
List<String> artists = const [],
bool onlyCleanArtist = false,
}) {
final match = RegExp(r"(?<=\().+?(?=\))").firstMatch(title)?.group(0);
final artistInBracket =
artists.any((artist) => match?.contains(artist) ?? false);
if (artistInBracket) {
title = title.replaceAll(
RegExp(" *\\([^)]*\\) *"),
'',
);
}
title = clearArtistsOfTitle(title, artists);
if (onlyCleanArtist) {
artists = [];
}
return "$title ${artists.map((e) => e.replaceAll(",", " ")).join(", ")}"
.toLowerCase()
.replaceAll(RegExp(r"\s*\[[^\]]*]"), ' ')
.replaceAll(RegExp(r"\sfeat\.|\sft\."), ' ')
.replaceAll(RegExp(r"\s+"), ' ')
.trim();
}
static Future<String?> extractLyrics(Uri url) async {
final client = GetConnect();
final response = await client.get(url.toString());
Document document = parser.parse(response.body);
String? lyrics = document.querySelector('div.lyrics')?.text.trim();
if (lyrics == null) {
lyrics = "";
document
.querySelectorAll("div[class^=\"Lyrics__Container\"]")
.forEach((element) {
if (element.text.trim().isNotEmpty) {
final snippet = element.innerHtml.replaceAll("<br>", "\n").replaceAll(
RegExp("<(?!\\s*br\\s*\\/?)[^>]+>", caseSensitive: false),
"",
);
final el = document.createElement("textarea");
el.innerHtml = snippet;
lyrics = "$lyrics${el.text.trim()}\n\n";
}
});
}
return lyrics;
}
static void navigate(BuildContext context, String location, {Object? extra}) {
if (GoRouterState.of(context).matchedLocation == location) return;
GoRouter.of(context).go(location, extra: extra);
}
static void navigateNamed(
BuildContext context,
String name, {
Object? extra,
Map<String, String>? pathParameters,
Map<String, dynamic>? queryParameters,
}) {
if (GoRouterState.of(context).matchedLocation == name) return;
GoRouter.of(context).goNamed(
name,
pathParameters: pathParameters ?? const {},
queryParameters: queryParameters ?? const {},
extra: extra,
);
}
static void push(BuildContext context, String location, {Object? extra}) {
final router = GoRouter.of(context);
final routerState = GoRouterState.of(context);
final routerStack = router.routerDelegate.currentConfiguration.matches
.map((e) => e.matchedLocation);
if (routerState.matchedLocation == location ||
routerStack.contains(location)) return;
router.push(location, extra: extra);
}
static void pushNamed(
BuildContext context,
String name, {
Object? extra,
Map<String, String> pathParameters = const {},
Map<String, String> queryParameters = const {},
}) {
final router = GoRouter.of(context);
final routerState = GoRouterState.of(context);
final routerStack = router.routerDelegate.currentConfiguration.matches
.map((e) => e.matchedLocation);
final nameLocation = routerState.namedLocation(
name,
pathParameters: pathParameters,
queryParameters: queryParameters,
);
if (routerState.matchedLocation == nameLocation ||
routerStack.contains(nameLocation)) {
return;
}
router.pushNamed(
name,
pathParameters: pathParameters,
queryParameters: queryParameters,
extra: extra,
);
}
static DateTime parseSpotifyAlbumDate(AlbumSimple? album) {
if (album == null || album.releaseDate == null) {
return DateTime.parse("1975-01-01");
}
switch (album.releaseDatePrecision ?? DatePrecision.year) {
case DatePrecision.day:
return DateTime.parse(album.releaseDate!);
case DatePrecision.month:
return DateTime.parse("${album.releaseDate}-01");
case DatePrecision.year:
return DateTime.parse("${album.releaseDate}-01-01");
}
}
static List<T> sortTracks<T extends Track>(List<T> tracks, SortBy sortBy) {
if (sortBy == SortBy.none) return tracks;
return List<T>.from(tracks)
..sort((a, b) {
switch (sortBy) {
case SortBy.ascending:
return a.name?.compareTo(b.name ?? "") ?? 0;
case SortBy.descending:
return b.name?.compareTo(a.name ?? "") ?? 0;
case SortBy.newest:
final aDate = parseSpotifyAlbumDate(a.album);
final bDate = parseSpotifyAlbumDate(b.album);
return bDate.compareTo(aDate);
case SortBy.oldest:
final aDate = parseSpotifyAlbumDate(a.album);
final bDate = parseSpotifyAlbumDate(b.album);
return aDate.compareTo(bDate);
case SortBy.duration:
return a.durationMs?.compareTo(b.durationMs ?? 0) ?? 0;
case SortBy.artist:
return a.artists?.first.name
?.compareTo(b.artists?.first.name ?? "") ??
0;
case SortBy.album:
return a.album?.name?.compareTo(b.album?.name ?? "") ?? 0;
default:
return 0;
}
});
}
}

View File

@ -5,12 +5,14 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import audio_session
import media_kit_libs_macos_audio import media_kit_libs_macos_audio
import package_info_plus import package_info_plus
import path_provider_foundation import path_provider_foundation
import sqflite import sqflite
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin")) MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))

View File

@ -17,6 +17,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.11.0" version: "2.11.0"
audio_session:
dependency: "direct main"
description:
name: audio_session
sha256: "343e83bc7809fbda2591a49e525d6b63213ade10c76f15813be9aed6657b3261"
url: "https://pub.dev"
source: hosted
version: "0.1.21"
boolean_selector: boolean_selector:
dependency: transitive dependency: transitive
description: description:
@ -66,13 +74,21 @@ packages:
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
collection: collection:
dependency: transitive dependency: "direct main"
description: description:
name: collection name: collection
sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.18.0" version: "1.18.0"
convert:
dependency: transitive
description:
name: convert
sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@ -97,6 +113,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.0.8"
dio:
dependency: transitive
description:
name: dio
sha256: "0dfb6b6a1979dac1c1245e17cef824d7b452ea29bd33d3467269f9bef3715fb0"
url: "https://pub.dev"
source: hosted
version: "5.6.0"
dio_web_adapter:
dependency: transitive
description:
name: dio_web_adapter
sha256: "33259a9276d6cea88774a0000cfae0d861003497755969c92faa223108620dc8"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
drift:
dependency: "direct main"
description:
name: drift
sha256: "15b51e0ee1970455c0c3f7e560f3ac02ecb9c04711a9657586e470b234659dba"
url: "https://pub.dev"
source: hosted
version: "2.20.0"
fake_async: fake_async:
dependency: transitive dependency: transitive
description: description:
@ -134,6 +174,14 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
flutter_broadcasts:
dependency: "direct main"
description:
name: flutter_broadcasts
sha256: "9e76eeeda4a9faef63e3b08af5664c79219a2eabffc8ce95296858ea70423b1e"
url: "https://pub.dev"
source: hosted
version: "0.4.0"
flutter_cache_manager: flutter_cache_manager:
dependency: transitive dependency: transitive
description: description:
@ -160,8 +208,16 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
freeze:
dependency: "direct main"
description:
name: freeze
sha256: "0d1e4e45ff60000288c0d5d3f565ec0dccfbf1052997617f3a3ad1a9a79ea617"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
freezed_annotation: freezed_annotation:
dependency: transitive dependency: "direct main"
description: description:
name: freezed_annotation name: freezed_annotation
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
@ -201,7 +257,7 @@ packages:
source: hosted source: hosted
version: "6.2.1" version: "6.2.1"
html: html:
dependency: transitive dependency: "direct main"
description: description:
name: html name: html
sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a"
@ -209,7 +265,7 @@ packages:
source: hosted source: hosted
version: "0.15.4" version: "0.15.4"
http: http:
dependency: transitive dependency: "direct main"
description: description:
name: http name: http
sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010
@ -480,6 +536,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.2" version: "6.0.2"
piped_client:
dependency: "direct main"
description:
name: piped_client
sha256: "87b04b2ebf4e008cfbb0ac85e9920ab3741f5aa697be2dd44919658a3297a4bc"
url: "https://pub.dev"
source: hosted
version: "0.1.1"
platform: platform:
dependency: transitive dependency: transitive
description: description:
@ -565,6 +629,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.4+2" version: "2.5.4+2"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "45f168ae2213201b54e09429ed0c593dc2c88c924a1488d6f9c523a255d567cb"
url: "https://pub.dev"
source: hosted
version: "2.4.6"
stack_trace: stack_trace:
dependency: transitive dependency: transitive
description: description:

View File

@ -47,6 +47,15 @@ dependencies:
google_fonts: ^6.2.1 google_fonts: ^6.2.1
media_kit: ^1.1.11 media_kit: ^1.1.11
media_kit_libs_audio: ^1.0.5 media_kit_libs_audio: ^1.0.5
html: ^0.15.4
freeze: ^1.0.0
freezed_annotation: ^2.4.4
http: ^1.2.2
drift: ^2.20.0
collection: ^1.18.0
piped_client: ^0.1.1
flutter_broadcasts: ^0.4.0
audio_session: ^0.1.21
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: