🔀 Merge some services from spotube
This commit is contained in:
parent
80771e84ce
commit
84d66fbc4b
3
lib/providers/piped.dart
Normal file
3
lib/providers/piped.dart
Normal file
@ -0,0 +1,3 @@
|
||||
import 'package:get/get.dart';
|
||||
|
||||
class PipedProvider extends GetxController {}
|
163
lib/services/audio_player/audio_player.dart
Executable file
163
lib/services/audio_player/audio_player.dart
Executable 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;
|
||||
}
|
||||
}
|
134
lib/services/audio_player/audio_player_impl.dart
Executable file
134
lib/services/audio_player/audio_player_impl.dart
Executable 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);
|
||||
}
|
||||
}
|
152
lib/services/audio_player/audio_players_streams_mixin.dart
Executable file
152
lib/services/audio_player/audio_players_streams_mixin.dart
Executable 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;
|
||||
});
|
||||
}
|
148
lib/services/audio_player/custom_player.dart
Executable file
148
lib/services/audio_player/custom_player.dart
Executable 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', '');
|
||||
}
|
||||
}
|
||||
}
|
28
lib/services/audio_player/playback_state.dart
Executable file
28
lib/services/audio_player/playback_state.dart
Executable 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;
|
||||
// }
|
||||
// }
|
||||
}
|
44
lib/services/local_track.dart
Normal file
44
lib/services/local_track.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
158
lib/services/rhythm_media.dart
Normal file
158
lib/services/rhythm_media.dart
Normal 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;
|
||||
}
|
||||
}
|
19
lib/services/song_link/model.dart
Executable file
19
lib/services/song_link/model.dart
Executable 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);
|
||||
}
|
51
lib/services/song_link/song_link.dart
Executable file
51
lib/services/song_link/song_link.dart
Executable 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>[];
|
||||
}
|
||||
}
|
||||
}
|
320
lib/services/song_link/song_link.freezed.dart
Executable file
320
lib/services/song_link/song_link.freezed.dart
Executable 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;
|
||||
}
|
32
lib/services/song_link/song_link.g.dart
Executable file
32
lib/services/song_link/song_link.g.dart
Executable 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
10
lib/services/sort.dart
Normal file
@ -0,0 +1,10 @@
|
||||
enum SortBy {
|
||||
none,
|
||||
ascending,
|
||||
descending,
|
||||
newest,
|
||||
oldest,
|
||||
duration,
|
||||
artist,
|
||||
album,
|
||||
}
|
18
lib/services/sourced_track/enums.dart
Executable file
18
lib/services/sourced_track/enums.dart
Executable 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});
|
12
lib/services/sourced_track/exceptions.dart
Executable file
12
lib/services/sourced_track/exceptions.dart
Executable 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(", ")}';
|
||||
}
|
||||
}
|
12
lib/services/sourced_track/models/search.dart
Normal file
12
lib/services/sourced_track/models/search.dart
Normal 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);
|
||||
}
|
||||
}
|
33
lib/services/sourced_track/models/source_info.dart
Executable file
33
lib/services/sourced_track/models/source_info.dart
Executable 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);
|
||||
}
|
30
lib/services/sourced_track/models/source_info.g.dart
Executable file
30
lib/services/sourced_track/models/source_info.g.dart
Executable 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,
|
||||
};
|
58
lib/services/sourced_track/models/source_map.dart
Executable file
58
lib/services/sourced_track/models/source_map.dart
Executable 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;
|
||||
}
|
||||
}
|
||||
}
|
36
lib/services/sourced_track/models/source_map.g.dart
Executable file
36
lib/services/sourced_track/models/source_map.g.dart
Executable 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(),
|
||||
};
|
115
lib/services/sourced_track/models/video_info.dart
Executable file
115
lib/services/sourced_track/models/video_info.dart
Executable 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,
|
||||
);
|
||||
}
|
||||
}
|
153
lib/services/sourced_track/sourced_track.dart
Executable file
153
lib/services/sourced_track/sourced_track.dart
Executable 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;
|
||||
}
|
||||
}
|
239
lib/services/sourced_track/sources/piped.dart
Executable file
239
lib/services/sourced_track/sources/piped.dart
Executable 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,
|
||||
);
|
||||
}
|
||||
}
|
273
lib/services/sourced_track/sources/youtube.dart
Executable file
273
lib/services/sourced_track/sources/youtube.dart
Executable 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
189
lib/services/utils.dart
Normal 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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -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"))
|
||||
|
80
pubspec.lock
80
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:
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user