From 84d66fbc4be6081842a277636f465cd1e76177e9 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Mon, 26 Aug 2024 23:21:22 +0800 Subject: [PATCH] :twisted_rightwards_arrows: Merge some services from spotube --- lib/providers/piped.dart | 3 + lib/services/audio_player/audio_player.dart | 163 +++++++++ .../audio_player/audio_player_impl.dart | 134 ++++++++ .../audio_players_streams_mixin.dart | 152 +++++++++ lib/services/audio_player/custom_player.dart | 148 ++++++++ lib/services/audio_player/playback_state.dart | 28 ++ lib/services/local_track.dart | 44 +++ lib/services/rhythm_media.dart | 158 +++++++++ lib/services/song_link/model.dart | 19 ++ lib/services/song_link/song_link.dart | 51 +++ lib/services/song_link/song_link.freezed.dart | 320 ++++++++++++++++++ lib/services/song_link/song_link.g.dart | 32 ++ lib/services/sort.dart | 10 + lib/services/sourced_track/enums.dart | 18 + lib/services/sourced_track/exceptions.dart | 12 + lib/services/sourced_track/models/search.dart | 12 + .../sourced_track/models/source_info.dart | 33 ++ .../sourced_track/models/source_info.g.dart | 30 ++ .../sourced_track/models/source_map.dart | 58 ++++ .../sourced_track/models/source_map.g.dart | 36 ++ .../sourced_track/models/video_info.dart | 115 +++++++ lib/services/sourced_track/sourced_track.dart | 153 +++++++++ lib/services/sourced_track/sources/piped.dart | 239 +++++++++++++ .../sourced_track/sources/youtube.dart | 273 +++++++++++++++ lib/services/utils.dart | 189 +++++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 80 ++++- pubspec.yaml | 9 + 28 files changed, 2517 insertions(+), 4 deletions(-) create mode 100644 lib/providers/piped.dart create mode 100755 lib/services/audio_player/audio_player.dart create mode 100755 lib/services/audio_player/audio_player_impl.dart create mode 100755 lib/services/audio_player/audio_players_streams_mixin.dart create mode 100755 lib/services/audio_player/custom_player.dart create mode 100755 lib/services/audio_player/playback_state.dart create mode 100644 lib/services/local_track.dart create mode 100644 lib/services/rhythm_media.dart create mode 100755 lib/services/song_link/model.dart create mode 100755 lib/services/song_link/song_link.dart create mode 100755 lib/services/song_link/song_link.freezed.dart create mode 100755 lib/services/song_link/song_link.g.dart create mode 100644 lib/services/sort.dart create mode 100755 lib/services/sourced_track/enums.dart create mode 100755 lib/services/sourced_track/exceptions.dart create mode 100644 lib/services/sourced_track/models/search.dart create mode 100755 lib/services/sourced_track/models/source_info.dart create mode 100755 lib/services/sourced_track/models/source_info.g.dart create mode 100755 lib/services/sourced_track/models/source_map.dart create mode 100755 lib/services/sourced_track/models/source_map.g.dart create mode 100755 lib/services/sourced_track/models/video_info.dart create mode 100755 lib/services/sourced_track/sourced_track.dart create mode 100755 lib/services/sourced_track/sources/piped.dart create mode 100755 lib/services/sourced_track/sources/youtube.dart create mode 100644 lib/services/utils.dart diff --git a/lib/providers/piped.dart b/lib/providers/piped.dart new file mode 100644 index 0000000..3005b0f --- /dev/null +++ b/lib/providers/piped.dart @@ -0,0 +1,3 @@ +import 'package:get/get.dart'; + +class PipedProvider extends GetxController {} diff --git a/lib/services/audio_player/audio_player.dart b/lib/services/audio_player/audio_player.dart new file mode 100755 index 0000000..7ddda6a --- /dev/null +++ b/lib/services/audio_player/audio_player.dart @@ -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? 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 get selectedDevice async { + return _mkPlayer.state.audioDevice; + } + + Future> 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 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; + } +} diff --git a/lib/services/audio_player/audio_player_impl.dart b/lib/services/audio_player/audio_player_impl.dart new file mode 100755 index 0000000..963182f --- /dev/null +++ b/lib/services/audio_player/audio_player_impl.dart @@ -0,0 +1,134 @@ +part of 'audio_player.dart'; + +final audioPlayer = RhythmAudioPlayer(); + +class RhythmAudioPlayer extends AudioPlayerInterface + with RhythmAudioPlayersStreams { + Future pause() async { + await _mkPlayer.pause(); + } + + Future resume() async { + await _mkPlayer.play(); + } + + Future stop() async { + await _mkPlayer.stop(); + } + + Future seek(Duration position) async { + await _mkPlayer.seek(position); + } + + /// Volume is between 0 and 1 + Future setVolume(double volume) async { + assert(volume >= 0 && volume <= 1); + await _mkPlayer.setVolume(volume * 100); + } + + Future setSpeed(double speed) async { + await _mkPlayer.setRate(speed); + } + + Future setAudioDevice(mk.AudioDevice device) async { + await _mkPlayer.setAudioDevice(device); + } + + Future dispose() async { + await _mkPlayer.dispose(); + } + + // Playlist related + + Future openPlaylist( + List 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 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 skipToNext() async { + await _mkPlayer.next(); + } + + Future skipToPrevious() async { + await _mkPlayer.previous(); + } + + Future jumpTo(int index) async { + await _mkPlayer.jump(index); + } + + Future addTrack(mk.Media media) async { + await _mkPlayer.add(media); + } + + Future addTrackAt(mk.Media media, int index) async { + await _mkPlayer.insert(index, media); + } + + Future removeTrack(int index) async { + await _mkPlayer.remove(index); + } + + Future moveTrack(int from, int to) async { + await _mkPlayer.move(from, to); + } + + Future clearPlaylist() async { + _mkPlayer.stop(); + } + + Future setShuffle(bool shuffle) async { + await _mkPlayer.setShuffle(shuffle); + } + + Future setLoopMode(PlaylistMode loop) async { + await _mkPlayer.setPlaylistMode(loop); + } + + Future setAudioNormalization(bool normalize) async { + await _mkPlayer.setAudioNormalization(normalize); + } +} diff --git a/lib/services/audio_player/audio_players_streams_mixin.dart b/lib/services/audio_player/audio_players_streams_mixin.dart new file mode 100755 index 0000000..f3d3bd6 --- /dev/null +++ b/lib/services/audio_player/audio_players_streams_mixin.dart @@ -0,0 +1,152 @@ +part of 'audio_player.dart'; + +mixin RhythmAudioPlayersStreams on AudioPlayerInterface { + // stream getters + Stream get durationStream { + // if (mkSupportedPlatform) { + return _mkPlayer.stream.duration; + // } else { + // return _justAudio!.durationStream + // .where((event) => event != null) + // .map((event) => event!) + // ; + // } + } + + Stream get positionStream { + // if (mkSupportedPlatform) { + return _mkPlayer.stream.position; + // } else { + // return _justAudio!.positionStream; + // } + } + + Stream get bufferedPositionStream { + // if (mkSupportedPlatform) { + // audioplayers doesn't have the capability to get buffered position + return _mkPlayer.stream.buffer; + // } else { + // return _justAudio!.bufferedPositionStream; + // } + } + + Stream 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 percentCompletedStream(double percent) { + return positionStream + .asyncMap( + (position) async => duration == Duration.zero + ? 0 + : (position.inSeconds / duration.inSeconds * 100).toInt(), + ) + .where((event) => event >= percent); + } + + Stream get playingStream { + // if (mkSupportedPlatform) { + return _mkPlayer.stream.playing; + // } else { + // return _justAudio!.playingStream; + // } + } + + Stream get shuffledStream { + // if (mkSupportedPlatform) { + return _mkPlayer.shuffleStream; + // } else { + // return _justAudio!.shuffleModeEnabledStream; + // } + } + + Stream get loopModeStream { + // if (mkSupportedPlatform) { + return _mkPlayer.stream.playlistMode; + // } else { + // return _justAudio!.loopModeStream + // .map(PlaylistMode.fromLoopMode) + // ; + // } + } + + Stream get volumeStream { + // if (mkSupportedPlatform) { + return _mkPlayer.stream.volume.map((event) => event / 100); + // } else { + // return _justAudio!.volumeStream; + // } + } + + Stream 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 get playerStateStream { + // if (mkSupportedPlatform) { + return _mkPlayer.playerStateStream; + // } else { + // return _justAudio!.playerStateStream + // .map(AudioPlaybackState.fromJaPlayerState) + // ; + // } + } + + Stream get currentIndexChangedStream { + // if (mkSupportedPlatform) { + return _mkPlayer.indexChangeStream; + // } else { + // return _justAudio!.sequenceStateStream + // .map((event) => event?.currentIndex ?? -1) + // ; + // } + } + + Stream get activeSourceChangedStream { + // if (mkSupportedPlatform) { + return _mkPlayer.indexChangeStream + .map((event) { + return _mkPlayer.state.playlist.medias.elementAtOrNull(event)?.uri; + }) + .where((event) => event != null) + .cast(); + // } else { + // return _justAudio!.sequenceStateStream + // .map((event) { + // return (event?.currentSource as ja.UriAudioSource?)?.uri.toString(); + // }) + // .where((event) => event != null) + // .cast(); + // } + } + + Stream> get devicesStream => + _mkPlayer.stream.audioDevices.asBroadcastStream(); + + Stream get selectedDeviceStream => + _mkPlayer.stream.audioDevice.asBroadcastStream(); + + Stream get errorStream => _mkPlayer.stream.error; + + Stream get playlistStream => _mkPlayer.stream.playlist.map((s) { + return s; + }); +} diff --git a/lib/services/audio_player/custom_player.dart b/lib/services/audio_player/custom_player.dart new file mode 100755 index 0000000..a3ed210 --- /dev/null +++ b/lib/services/audio_player/custom_player.dart @@ -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 _playerStateStream; + final StreamController _shuffleStream; + + late final List _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 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 get playerStateStream => _playerStateStream.stream; + Stream get shuffleStream => _shuffleStream.stream; + Stream 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 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 stop() async { + await super.stop(); + + _shuffled = false; + _playerStateStream.add(AudioPlaybackState.stopped); + _shuffleStream.add(false); + } + + @override + Future dispose() async { + for (var element in _subscriptions) { + element.cancel(); + } + await notifyAudioSessionUpdate(false); + return super.dispose(); + } + + NativePlayer get nativePlayer => platform as NativePlayer; + + Future insert(int index, Media media) async { + await add(media); + await move(state.playlist.medias.length, index); + } + + Future 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', ''); + } + } +} diff --git a/lib/services/audio_player/playback_state.dart b/lib/services/audio_player/playback_state.dart new file mode 100755 index 0000000..a4743a4 --- /dev/null +++ b/lib/services/audio_player/playback_state.dart @@ -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; + // } + // } +} diff --git a/lib/services/local_track.dart b/lib/services/local_track.dart new file mode 100644 index 0000000..def3b64 --- /dev/null +++ b/lib/services/local_track.dart @@ -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 json) { + return LocalTrack.fromTrack( + track: Track.fromJson(json), + path: json['path'], + ); + } + + @override + Map toJson() { + return { + ...super.toJson(), + 'path': path, + }; + } +} diff --git a/lib/services/rhythm_media.dart b/lib/services/rhythm_media.dart new file mode 100644 index 0000000..0037d1e --- /dev/null +++ b/lib/services/rhythm_media.dart @@ -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? 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 get selectedDevice async { + return _mkPlayer.state.audioDevice; + } + + Future> 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 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; + } +} diff --git a/lib/services/song_link/model.dart b/lib/services/song_link/model.dart new file mode 100755 index 0000000..ae9d383 --- /dev/null +++ b/lib/services/song_link/model.dart @@ -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 json) => + _$SongLinkFromJson(json); +} diff --git a/lib/services/song_link/song_link.dart b/lib/services/song_link/song_link.dart new file mode 100755 index 0000000..a7e81f1 --- /dev/null +++ b/lib/services/song_link/song_link.dart @@ -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> 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 []; + } + + final pageProps = jsonDecode(script) as Map; + 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() ?? + []; + } catch (e) { + log('[SongLink] Unable get song link: $e'); + return []; + } + } +} diff --git a/lib/services/song_link/song_link.freezed.dart b/lib/services/song_link/song_link.freezed.dart new file mode 100755 index 0000000..0a1af8a --- /dev/null +++ b/lib/services/song_link/song_link.freezed.dart @@ -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 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 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 toJson() => throw _privateConstructorUsedError; + @JsonKey(ignore: true) + $SongLinkCopyWith 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 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 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 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; +} diff --git a/lib/services/song_link/song_link.g.dart b/lib/services/song_link/song_link.g.dart new file mode 100755 index 0000000..7658a74 --- /dev/null +++ b/lib/services/song_link/song_link.g.dart @@ -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 _$$SongLinkImplToJson(_$SongLinkImpl instance) => + { + '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, + }; diff --git a/lib/services/sort.dart b/lib/services/sort.dart new file mode 100644 index 0000000..d030bfa --- /dev/null +++ b/lib/services/sort.dart @@ -0,0 +1,10 @@ +enum SortBy { + none, + ascending, + descending, + newest, + oldest, + duration, + artist, + album, +} diff --git a/lib/services/sourced_track/enums.dart b/lib/services/sourced_track/enums.dart new file mode 100755 index 0000000..e4803d0 --- /dev/null +++ b/lib/services/sourced_track/enums.dart @@ -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 info, SourceMap? source}); diff --git a/lib/services/sourced_track/exceptions.dart b/lib/services/sourced_track/exceptions.dart new file mode 100755 index 0000000..85bc5b2 --- /dev/null +++ b/lib/services/sourced_track/exceptions.dart @@ -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(", ")}'; + } +} diff --git a/lib/services/sourced_track/models/search.dart b/lib/services/sourced_track/models/search.dart new file mode 100644 index 0000000..b62c975 --- /dev/null +++ b/lib/services/sourced_track/models/search.dart @@ -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); + } +} diff --git a/lib/services/sourced_track/models/source_info.dart b/lib/services/sourced_track/models/source_info.dart new file mode 100755 index 0000000..4ba9035 --- /dev/null +++ b/lib/services/sourced_track/models/source_info.dart @@ -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 json) => + _$SourceInfoFromJson(json); + + Map toJson() => _$SourceInfoToJson(this); +} diff --git a/lib/services/sourced_track/models/source_info.g.dart b/lib/services/sourced_track/models/source_info.g.dart new file mode 100755 index 0000000..5fe136c --- /dev/null +++ b/lib/services/sourced_track/models/source_info.g.dart @@ -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 _$SourceInfoToJson(SourceInfo instance) => + { + '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, + }; diff --git a/lib/services/sourced_track/models/source_map.dart b/lib/services/sourced_track/models/source_map.dart new file mode 100755 index 0000000..1b68333 --- /dev/null +++ b/lib/services/sourced_track/models/source_map.dart @@ -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 json) => + _$SourceQualityMapFromJson(json); + + Map 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 json) => + _$SourceMapFromJson(json); + + Map toJson() => _$SourceMapToJson(this); + + operator [](SourceCodecs key) { + switch (key) { + case SourceCodecs.weba: + return weba; + case SourceCodecs.m4a: + return m4a; + } + } +} diff --git a/lib/services/sourced_track/models/source_map.g.dart b/lib/services/sourced_track/models/source_map.g.dart new file mode 100755 index 0000000..a581cc6 --- /dev/null +++ b/lib/services/sourced_track/models/source_map.g.dart @@ -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 _$SourceQualityMapToJson(SourceQualityMap instance) => + { + 'high': instance.high, + 'medium': instance.medium, + 'low': instance.low, + }; + +SourceMap _$SourceMapFromJson(Map json) => SourceMap( + weba: json['weba'] == null + ? null + : SourceQualityMap.fromJson( + Map.from(json['weba'] as Map)), + m4a: json['m4a'] == null + ? null + : SourceQualityMap.fromJson( + Map.from(json['m4a'] as Map)), + ); + +Map _$SourceMapToJson(SourceMap instance) => { + 'weba': instance.weba?.toJson(), + 'm4a': instance.m4a?.toJson(), + }; diff --git a/lib/services/sourced_track/models/video_info.dart b/lib/services/sourced_track/models/video_info.dart new file mode 100755 index 0000000..38a9759 --- /dev/null +++ b/lib/services/sourced_track/models/video_info.dart @@ -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 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 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, + ); + } +} diff --git a/lib/services/sourced_track/sourced_track.dart b/lib/services/sourced_track/sourced_track.dart new file mode 100755 index 0000000..9e2dfaa --- /dev/null +++ b/lib/services/sourced_track/sourced_track.dart @@ -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 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 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(); + + 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 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> fetchSiblings({ + required Track track, + }) { + // TODO Follow user preferences + const audioSource = "youtube"; + + return switch (audioSource) { + "piped" => PipedSourcedTrack.fetchSiblings(track: track), + _ => YoutubeSourcedTrack.fetchSiblings(track: track), + }; + } + + Future copyWithSibling(); + + Future swapWithSibling(SourceInfo sibling); + + Future 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; + } +} diff --git a/lib/services/sourced_track/sources/piped.dart b/lib/services/sourced_track/sources/piped.dart new file mode 100755 index 0000000..182c029 --- /dev/null +++ b/lib/services/sourced_track/sources/piped.dart @@ -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 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 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> 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() + .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 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 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, + ); + } +} diff --git a/lib/services/sourced_track/sources/youtube.dart b/lib/services/sourced_track/sources/youtube.dart new file mode 100755 index 0000000..938c71d --- /dev/null +++ b/lib/services/sourced_track/sources/youtube.dart @@ -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 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 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 rankResults( + List 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> 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 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 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, + ); + } +} diff --git a/lib/services/utils.dart b/lib/services/utils.dart new file mode 100644 index 0000000..5f824db --- /dev/null +++ b/lib/services/utils.dart @@ -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 artists) { + return title + .replaceAll(RegExp(artists.join("|"), caseSensitive: false), "") + .trim(); + } + + static String getTitle( + String title, { + List 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 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("
", "\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? pathParameters, + Map? 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 pathParameters = const {}, + Map 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 sortTracks(List tracks, SortBy sortBy) { + if (sortBy == SortBy.none) return tracks; + return List.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; + } + }); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 630ce90..ec27ef7 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,14 @@ import FlutterMacOS import Foundation +import audio_session import media_kit_libs_macos_audio import package_info_plus import path_provider_foundation import sqflite func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) diff --git a/pubspec.lock b/pubspec.lock index f901cac..5a55c52 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -66,13 +74,21 @@ packages: source: hosted version: "1.1.1" collection: - dependency: transitive + dependency: "direct main" description: name: collection sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a url: "https://pub.dev" source: hosted version: "1.18.0" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" crypto: dependency: transitive description: @@ -97,6 +113,30 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -134,6 +174,14 @@ packages: description: flutter source: sdk 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: dependency: transitive description: @@ -160,8 +208,16 @@ packages: description: flutter source: sdk 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: - dependency: transitive + dependency: "direct main" description: name: freezed_annotation sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 @@ -201,7 +257,7 @@ packages: source: hosted version: "6.2.1" html: - dependency: transitive + dependency: "direct main" description: name: html sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" @@ -209,7 +265,7 @@ packages: source: hosted version: "0.15.4" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 @@ -480,6 +536,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -565,6 +629,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2c1ae17..87b983d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,15 @@ dependencies: google_fonts: ^6.2.1 media_kit: ^1.1.11 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: flutter_test: