refactor: move from Track to SpotubeTrackObject and use TrackSources object for providers

This commit is contained in:
Kingkor Roy Tirtho 2025-06-18 21:30:32 +06:00
parent b979a6ede9
commit 4e6db8b9e1
56 changed files with 3054 additions and 2680 deletions

View File

@ -73,8 +73,8 @@ extension IterableTrackSimpleExtensions on Iterable<TrackSimple> {
Future<List<Track>> asTracks(AlbumSimple album, ref) async {
try {
final spotify = ref.read(spotifyProvider);
final tracks = await spotify.invoke(
(api) => api.tracks.list(map((trackSimple) => trackSimple.id!).toList()));
final tracks = await spotify.invoke((api) =>
api.tracks.list(map((trackSimple) => trackSimple.id!).toList()));
return tracks.toList();
} catch (e, stack) {
// Ignore errors and create the track locally
@ -105,9 +105,3 @@ extension IterableTrackSimpleExtensions on Iterable<TrackSimple> {
}
}
}
extension TracksToMediaExtension on Iterable<Track> {
List<SpotubeMedia> asMediaList() {
return map((track) => SpotubeMedia(track)).toList();
}
}

View File

@ -1,3 +1,4 @@
import 'package:spotube/provider/metadata_plugin/tracks/playlist.dart';
import 'package:spotube/services/logger/logger.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -8,83 +9,85 @@ import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
// TODO: Implement endless playback functionality
void useEndlessPlayback(WidgetRef ref) {
final auth = ref.watch(authenticationProvider);
final playback = ref.watch(audioPlayerProvider.notifier);
final playlist = ref.watch(audioPlayerProvider.select((s) => s.playlist));
final spotify = ref.watch(spotifyProvider);
final endlessPlayback =
ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback));
// final auth = ref.watch(authenticationProvider);
// final playback = ref.watch(audioPlayerProvider.notifier);
// final audioPlayerState = ref.watch(audioPlayerProvider);
// final spotify = ref.watch(spotifyProvider);
// final endlessPlayback =
// ref.watch(userPreferencesProvider.select((s) => s.endlessPlayback));
useEffect(
() {
if (!endlessPlayback || auth.asData?.value == null) return null;
// useEffect(
// () {
// if (!endlessPlayback || auth.asData?.value == null) return null;
void listener(int index) async {
try {
final playlist = ref.read(audioPlayerProvider);
if (index != playlist.tracks.length - 1) return;
// void listener(int index) async {
// try {
// final playlist = ref.read(audioPlayerProvider);
// if (index != playlist.tracks.length - 1) return;
final track = playlist.tracks.last;
// final track = playlist.tracks.last;
final query = "${track.name} Radio";
final pages = await spotify.invoke((api) =>
api.search.get(query, types: [SearchType.playlist]).first());
// final query = "${track.name} Radio";
// final pages = await spotify.invoke((api) =>
// api.search.get(query, types: [SearchType.playlist]).first());
final radios = pages
.expand((e) => e.items?.toList() ?? <PlaylistSimple>[])
.toList()
.cast<PlaylistSimple>();
// final radios = pages
// .expand((e) => e.items?.toList() ?? <PlaylistSimple>[])
// .toList()
// .cast<PlaylistSimple>();
final artists = track.artists!.map((e) => e.name);
// final artists = track.artists.map((e) => e.name);
final radio = radios.firstWhere(
(e) {
final validPlaylists =
artists.where((a) => e.description!.contains(a!));
return e.name == "${track.name} Radio" &&
(validPlaylists.length >= 2 ||
validPlaylists.length == artists.length) &&
e.owner?.displayName != "Spotify";
},
orElse: () => radios.first,
);
// final radio = radios.firstWhere(
// (e) {
// final validPlaylists =
// artists.where((a) => e.description!.contains(a));
// return e.name == "${track.name} Radio" &&
// (validPlaylists.length >= 2 ||
// validPlaylists.length == artists.length) &&
// e.owner?.displayName != "Spotify";
// },
// orElse: () => radios.first,
// );
final tracks = await spotify.invoke(
(api) => api.playlists.getTracksByPlaylistId(radio.id!).all());
// final tracks =
// ref.read(metadataPluginPlaylistTracksProvider(radio.id!));
await playback.addTracks(
tracks.toList()
..removeWhere((e) {
final playlist = ref.read(audioPlayerProvider);
final isDuplicate = playlist.tracks.any((t) => t.id == e.id);
return e.id == track.id || isDuplicate;
}),
);
} catch (e, stack) {
AppLogger.reportError(e, stack);
}
}
// await playback.addTracks(
// tracks.toList()
// ..removeWhere((e) {
// final playlist = ref.read(audioPlayerProvider);
// final isDuplicate = playlist.tracks.any((t) => t.id == e.id);
// return e.id == track.id || isDuplicate;
// }),
// );
// } catch (e, stack) {
// AppLogger.reportError(e, stack);
// }
// }
// Sometimes user can change settings for which the currentIndexChanged
// might not be called. So we need to check if the current track is the
// last track and if it is then we need to call the listener manually.
if (playlist.index == playlist.medias.length - 1 &&
audioPlayer.isPlaying) {
listener(playlist.index);
}
// // Sometimes user can change settings for which the currentIndexChanged
// // might not be called. So we need to check if the current track is the
// // last track and if it is then we need to call the listener manually.
// if (audioPlayerState.currentIndex == audioPlayerState.tracks.length - 1 &&
// audioPlayer.isPlaying) {
// listener(audioPlayerState.currentIndex);
// }
final subscription =
audioPlayer.currentIndexChangedStream.listen(listener);
// final subscription =
// audioPlayer.currentIndexChangedStream.listen(listener);
return subscription.cancel;
},
[
spotify,
playback,
playlist.medias,
endlessPlayback,
auth,
],
);
// return subscription.cancel;
// },
// [
// spotify,
// playback,
// audioPlayerState.tracks,
// audioPlayerState.currentIndex,
// endlessPlayback,
// auth,
// ],
// );
}

View File

@ -5,7 +5,7 @@ import 'dart:convert';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:media_kit/media_kit.dart' hide Track;
import 'package:spotify/spotify.dart' hide Playlist;
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/audio_player/state.dart';
part 'connect.freezed.dart';

View File

@ -33,49 +33,36 @@ WebSocketLoadEventData _$WebSocketLoadEventDataFromJson(
/// @nodoc
mixin _$WebSocketLoadEventData {
@JsonKey(name: 'tracks', toJson: _tracksJson)
List<Track> get tracks => throw _privateConstructorUsedError;
List<dynamic> get tracks => throw _privateConstructorUsedError;
Object? get collection => throw _privateConstructorUsedError;
int? get initialIndex => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
int? initialIndex)
required TResult Function(List<SpotubeFullTrackObject> tracks,
SpotubeSimplePlaylistObject? collection, int? initialIndex)
playlist,
required TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex)
required TResult Function(List<SpotubeFullTrackObject> tracks,
SpotubeSimpleAlbumObject? collection, int? initialIndex)
album,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
int? initialIndex)?
TResult? Function(List<SpotubeFullTrackObject> tracks,
SpotubeSimplePlaylistObject? collection, int? initialIndex)?
playlist,
TResult? Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex)?
TResult? Function(List<SpotubeFullTrackObject> tracks,
SpotubeSimpleAlbumObject? collection, int? initialIndex)?
album,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
int? initialIndex)?
TResult Function(List<SpotubeFullTrackObject> tracks,
SpotubeSimplePlaylistObject? collection, int? initialIndex)?
playlist,
TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex)?
TResult Function(List<SpotubeFullTrackObject> tracks,
SpotubeSimpleAlbumObject? collection, int? initialIndex)?
album,
required TResult orElse(),
}) =>
@ -116,9 +103,7 @@ abstract class $WebSocketLoadEventDataCopyWith<$Res> {
$Res Function(WebSocketLoadEventData) then) =
_$WebSocketLoadEventDataCopyWithImpl<$Res, WebSocketLoadEventData>;
@useResult
$Res call(
{@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
int? initialIndex});
$Res call({List<dynamic> tracks, int? initialIndex});
}
/// @nodoc
@ -144,7 +129,7 @@ class _$WebSocketLoadEventDataCopyWithImpl<$Res,
tracks: null == tracks
? _value.tracks
: tracks // ignore: cast_nullable_to_non_nullable
as List<Track>,
as List<dynamic>,
initialIndex: freezed == initialIndex
? _value.initialIndex
: initialIndex // ignore: cast_nullable_to_non_nullable
@ -163,9 +148,11 @@ abstract class _$$WebSocketLoadEventDataPlaylistImplCopyWith<$Res>
@override
@useResult
$Res call(
{@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
{List<SpotubeFullTrackObject> tracks,
SpotubeSimplePlaylistObject? collection,
int? initialIndex});
$SpotubeSimplePlaylistObjectCopyWith<$Res>? get collection;
}
/// @nodoc
@ -191,17 +178,32 @@ class __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<$Res>
tracks: null == tracks
? _value._tracks
: tracks // ignore: cast_nullable_to_non_nullable
as List<Track>,
as List<SpotubeFullTrackObject>,
collection: freezed == collection
? _value.collection
: collection // ignore: cast_nullable_to_non_nullable
as PlaylistSimple?,
as SpotubeSimplePlaylistObject?,
initialIndex: freezed == initialIndex
? _value.initialIndex
: initialIndex // ignore: cast_nullable_to_non_nullable
as int?,
));
}
/// Create a copy of WebSocketLoadEventData
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SpotubeSimplePlaylistObjectCopyWith<$Res>? get collection {
if (_value.collection == null) {
return null;
}
return $SpotubeSimplePlaylistObjectCopyWith<$Res>(_value.collection!,
(value) {
return _then(_value.copyWith(collection: value));
});
}
}
/// @nodoc
@ -209,8 +211,7 @@ class __$$WebSocketLoadEventDataPlaylistImplCopyWithImpl<$Res>
class _$WebSocketLoadEventDataPlaylistImpl
extends WebSocketLoadEventDataPlaylist {
_$WebSocketLoadEventDataPlaylistImpl(
{@JsonKey(name: 'tracks', toJson: _tracksJson)
required final List<Track> tracks,
{required final List<SpotubeFullTrackObject> tracks,
this.collection,
this.initialIndex,
final String? $type})
@ -222,17 +223,16 @@ class _$WebSocketLoadEventDataPlaylistImpl
Map<String, dynamic> json) =>
_$$WebSocketLoadEventDataPlaylistImplFromJson(json);
final List<Track> _tracks;
final List<SpotubeFullTrackObject> _tracks;
@override
@JsonKey(name: 'tracks', toJson: _tracksJson)
List<Track> get tracks {
List<SpotubeFullTrackObject> get tracks {
if (_tracks is EqualUnmodifiableListView) return _tracks;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_tracks);
}
@override
final PlaylistSimple? collection;
final SpotubeSimplePlaylistObject? collection;
@override
final int? initialIndex;
@ -274,15 +274,11 @@ class _$WebSocketLoadEventDataPlaylistImpl
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
int? initialIndex)
required TResult Function(List<SpotubeFullTrackObject> tracks,
SpotubeSimplePlaylistObject? collection, int? initialIndex)
playlist,
required TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex)
required TResult Function(List<SpotubeFullTrackObject> tracks,
SpotubeSimpleAlbumObject? collection, int? initialIndex)
album,
}) {
return playlist(tracks, collection, initialIndex);
@ -291,15 +287,11 @@ class _$WebSocketLoadEventDataPlaylistImpl
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
int? initialIndex)?
TResult? Function(List<SpotubeFullTrackObject> tracks,
SpotubeSimplePlaylistObject? collection, int? initialIndex)?
playlist,
TResult? Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex)?
TResult? Function(List<SpotubeFullTrackObject> tracks,
SpotubeSimpleAlbumObject? collection, int? initialIndex)?
album,
}) {
return playlist?.call(tracks, collection, initialIndex);
@ -308,15 +300,11 @@ class _$WebSocketLoadEventDataPlaylistImpl
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
int? initialIndex)?
TResult Function(List<SpotubeFullTrackObject> tracks,
SpotubeSimplePlaylistObject? collection, int? initialIndex)?
playlist,
TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex)?
TResult Function(List<SpotubeFullTrackObject> tracks,
SpotubeSimpleAlbumObject? collection, int? initialIndex)?
album,
required TResult orElse(),
}) {
@ -367,9 +355,8 @@ class _$WebSocketLoadEventDataPlaylistImpl
abstract class WebSocketLoadEventDataPlaylist extends WebSocketLoadEventData {
factory WebSocketLoadEventDataPlaylist(
{@JsonKey(name: 'tracks', toJson: _tracksJson)
required final List<Track> tracks,
final PlaylistSimple? collection,
{required final List<SpotubeFullTrackObject> tracks,
final SpotubeSimplePlaylistObject? collection,
final int? initialIndex}) = _$WebSocketLoadEventDataPlaylistImpl;
WebSocketLoadEventDataPlaylist._() : super._();
@ -377,10 +364,9 @@ abstract class WebSocketLoadEventDataPlaylist extends WebSocketLoadEventData {
_$WebSocketLoadEventDataPlaylistImpl.fromJson;
@override
@JsonKey(name: 'tracks', toJson: _tracksJson)
List<Track> get tracks;
List<SpotubeFullTrackObject> get tracks;
@override
PlaylistSimple? get collection;
SpotubeSimplePlaylistObject? get collection;
@override
int? get initialIndex;
@ -403,9 +389,11 @@ abstract class _$$WebSocketLoadEventDataAlbumImplCopyWith<$Res>
@override
@useResult
$Res call(
{@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
{List<SpotubeFullTrackObject> tracks,
SpotubeSimpleAlbumObject? collection,
int? initialIndex});
$SpotubeSimpleAlbumObjectCopyWith<$Res>? get collection;
}
/// @nodoc
@ -431,25 +419,38 @@ class __$$WebSocketLoadEventDataAlbumImplCopyWithImpl<$Res>
tracks: null == tracks
? _value._tracks
: tracks // ignore: cast_nullable_to_non_nullable
as List<Track>,
as List<SpotubeFullTrackObject>,
collection: freezed == collection
? _value.collection
: collection // ignore: cast_nullable_to_non_nullable
as AlbumSimple?,
as SpotubeSimpleAlbumObject?,
initialIndex: freezed == initialIndex
? _value.initialIndex
: initialIndex // ignore: cast_nullable_to_non_nullable
as int?,
));
}
/// Create a copy of WebSocketLoadEventData
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SpotubeSimpleAlbumObjectCopyWith<$Res>? get collection {
if (_value.collection == null) {
return null;
}
return $SpotubeSimpleAlbumObjectCopyWith<$Res>(_value.collection!, (value) {
return _then(_value.copyWith(collection: value));
});
}
}
/// @nodoc
@JsonSerializable()
class _$WebSocketLoadEventDataAlbumImpl extends WebSocketLoadEventDataAlbum {
_$WebSocketLoadEventDataAlbumImpl(
{@JsonKey(name: 'tracks', toJson: _tracksJson)
required final List<Track> tracks,
{required final List<SpotubeFullTrackObject> tracks,
this.collection,
this.initialIndex,
final String? $type})
@ -461,17 +462,16 @@ class _$WebSocketLoadEventDataAlbumImpl extends WebSocketLoadEventDataAlbum {
Map<String, dynamic> json) =>
_$$WebSocketLoadEventDataAlbumImplFromJson(json);
final List<Track> _tracks;
final List<SpotubeFullTrackObject> _tracks;
@override
@JsonKey(name: 'tracks', toJson: _tracksJson)
List<Track> get tracks {
List<SpotubeFullTrackObject> get tracks {
if (_tracks is EqualUnmodifiableListView) return _tracks;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_tracks);
}
@override
final AlbumSimple? collection;
final SpotubeSimpleAlbumObject? collection;
@override
final int? initialIndex;
@ -512,15 +512,11 @@ class _$WebSocketLoadEventDataAlbumImpl extends WebSocketLoadEventDataAlbum {
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
int? initialIndex)
required TResult Function(List<SpotubeFullTrackObject> tracks,
SpotubeSimplePlaylistObject? collection, int? initialIndex)
playlist,
required TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex)
required TResult Function(List<SpotubeFullTrackObject> tracks,
SpotubeSimpleAlbumObject? collection, int? initialIndex)
album,
}) {
return album(tracks, collection, initialIndex);
@ -529,15 +525,11 @@ class _$WebSocketLoadEventDataAlbumImpl extends WebSocketLoadEventDataAlbum {
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
int? initialIndex)?
TResult? Function(List<SpotubeFullTrackObject> tracks,
SpotubeSimplePlaylistObject? collection, int? initialIndex)?
playlist,
TResult? Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex)?
TResult? Function(List<SpotubeFullTrackObject> tracks,
SpotubeSimpleAlbumObject? collection, int? initialIndex)?
album,
}) {
return album?.call(tracks, collection, initialIndex);
@ -546,15 +538,11 @@ class _$WebSocketLoadEventDataAlbumImpl extends WebSocketLoadEventDataAlbum {
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
PlaylistSimple? collection,
int? initialIndex)?
TResult Function(List<SpotubeFullTrackObject> tracks,
SpotubeSimplePlaylistObject? collection, int? initialIndex)?
playlist,
TResult Function(
@JsonKey(name: 'tracks', toJson: _tracksJson) List<Track> tracks,
AlbumSimple? collection,
int? initialIndex)?
TResult Function(List<SpotubeFullTrackObject> tracks,
SpotubeSimpleAlbumObject? collection, int? initialIndex)?
album,
required TResult orElse(),
}) {
@ -605,9 +593,8 @@ class _$WebSocketLoadEventDataAlbumImpl extends WebSocketLoadEventDataAlbum {
abstract class WebSocketLoadEventDataAlbum extends WebSocketLoadEventData {
factory WebSocketLoadEventDataAlbum(
{@JsonKey(name: 'tracks', toJson: _tracksJson)
required final List<Track> tracks,
final AlbumSimple? collection,
{required final List<SpotubeFullTrackObject> tracks,
final SpotubeSimpleAlbumObject? collection,
final int? initialIndex}) = _$WebSocketLoadEventDataAlbumImpl;
WebSocketLoadEventDataAlbum._() : super._();
@ -615,10 +602,9 @@ abstract class WebSocketLoadEventDataAlbum extends WebSocketLoadEventData {
_$WebSocketLoadEventDataAlbumImpl.fromJson;
@override
@JsonKey(name: 'tracks', toJson: _tracksJson)
List<Track> get tracks;
List<SpotubeFullTrackObject> get tracks;
@override
AlbumSimple? get collection;
SpotubeSimpleAlbumObject? get collection;
@override
int? get initialIndex;

View File

@ -10,11 +10,12 @@ _$WebSocketLoadEventDataPlaylistImpl
_$$WebSocketLoadEventDataPlaylistImplFromJson(Map json) =>
_$WebSocketLoadEventDataPlaylistImpl(
tracks: (json['tracks'] as List<dynamic>)
.map((e) => Track.fromJson(Map<String, dynamic>.from(e as Map)))
.map((e) => SpotubeFullTrackObject.fromJson(
Map<String, dynamic>.from(e as Map)))
.toList(),
collection: json['collection'] == null
? null
: PlaylistSimple.fromJson(
: SpotubeSimplePlaylistObject.fromJson(
Map<String, dynamic>.from(json['collection'] as Map)),
initialIndex: (json['initialIndex'] as num?)?.toInt(),
$type: json['runtimeType'] as String?,
@ -23,7 +24,7 @@ _$WebSocketLoadEventDataPlaylistImpl
Map<String, dynamic> _$$WebSocketLoadEventDataPlaylistImplToJson(
_$WebSocketLoadEventDataPlaylistImpl instance) =>
<String, dynamic>{
'tracks': _tracksJson(instance.tracks),
'tracks': instance.tracks.map((e) => e.toJson()).toList(),
'collection': instance.collection?.toJson(),
'initialIndex': instance.initialIndex,
'runtimeType': instance.$type,
@ -33,11 +34,12 @@ _$WebSocketLoadEventDataAlbumImpl _$$WebSocketLoadEventDataAlbumImplFromJson(
Map json) =>
_$WebSocketLoadEventDataAlbumImpl(
tracks: (json['tracks'] as List<dynamic>)
.map((e) => Track.fromJson(Map<String, dynamic>.from(e as Map)))
.map((e) => SpotubeFullTrackObject.fromJson(
Map<String, dynamic>.from(e as Map)))
.toList(),
collection: json['collection'] == null
? null
: AlbumSimple.fromJson(
: SpotubeSimpleAlbumObject.fromJson(
Map<String, dynamic>.from(json['collection'] as Map)),
initialIndex: (json['initialIndex'] as num?)?.toInt(),
$type: json['runtimeType'] as String?,
@ -46,7 +48,7 @@ _$WebSocketLoadEventDataAlbumImpl _$$WebSocketLoadEventDataAlbumImplFromJson(
Map<String, dynamic> _$$WebSocketLoadEventDataAlbumImplToJson(
_$WebSocketLoadEventDataAlbumImpl instance) =>
<String, dynamic>{
'tracks': _tracksJson(instance.tracks),
'tracks': instance.tracks.map((e) => e.toJson()).toList(),
'collection': instance.collection?.toJson(),
'initialIndex': instance.initialIndex,
'runtimeType': instance.$type,

View File

@ -1,22 +1,18 @@
part of 'connect.dart';
List<Map<String, dynamic>> _tracksJson(List<Track> tracks) {
return tracks.map((e) => e.toJson()).toList();
}
@freezed
class WebSocketLoadEventData with _$WebSocketLoadEventData {
const WebSocketLoadEventData._();
factory WebSocketLoadEventData.playlist({
@JsonKey(name: 'tracks', toJson: _tracksJson) required List<Track> tracks,
PlaylistSimple? collection,
required List<SpotubeFullTrackObject> tracks,
SpotubeSimplePlaylistObject? collection,
int? initialIndex,
}) = WebSocketLoadEventDataPlaylist;
factory WebSocketLoadEventData.album({
@JsonKey(name: 'tracks', toJson: _tracksJson) required List<Track> tracks,
AlbumSimple? collection,
required List<SpotubeFullTrackObject> tracks,
SpotubeSimpleAlbumObject? collection,
int? initialIndex,
}) = WebSocketLoadEventDataAlbum;

View File

@ -338,13 +338,16 @@ class WebSocketRemoveTrackEvent extends WebSocketEvent<String> {
WebSocketRemoveTrackEvent(String data) : super(WsEvent.removeTrack, data);
}
class WebSocketAddTrackEvent extends WebSocketEvent<Track> {
WebSocketAddTrackEvent(Track data) : super(WsEvent.addTrack, data);
class WebSocketAddTrackEvent extends WebSocketEvent<SpotubeFullTrackObject> {
WebSocketAddTrackEvent(SpotubeFullTrackObject data)
: super(WsEvent.addTrack, data);
WebSocketAddTrackEvent.fromJson(Map<String, dynamic> json)
: super(
WsEvent.addTrack,
Track.fromJson(json["data"] as Map<String, dynamic>),
SpotubeFullTrackObject.fromJson(
json["data"] as Map<String, dynamic>,
),
);
}

View File

@ -1,75 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
class CurrentPlaylist {
List<Track>? _tempTrack;
List<Track> tracks;
String id;
String name;
String thumbnail;
bool isLocal;
CurrentPlaylist({
required this.tracks,
required this.id,
required this.name,
required this.thumbnail,
this.isLocal = false,
});
static CurrentPlaylist fromJson(Map<String, dynamic> map, Ref ref) {
return CurrentPlaylist(
id: map["id"],
tracks: List.castFrom<dynamic, Track>(map["tracks"]
.map(
(track) => map["isLocal"] == true
? SourcedTrack.fromJson(track, ref: ref)
: Track.fromJson(track),
)
.toList()),
name: map["name"],
thumbnail: map["thumbnail"],
isLocal: map["isLocal"],
);
}
List<String> get trackIds => tracks.map((e) => e.id!).toList();
bool shuffle(Track? topTrack) {
// won't shuffle if already shuffled
if (_tempTrack == null) {
_tempTrack = [...tracks];
tracks = List.from(tracks)..shuffle();
if (topTrack != null) {
tracks.remove(topTrack);
tracks.insert(0, topTrack);
}
return true;
}
return false;
}
bool unshuffle() {
// without _tempTracks unshuffling can't be done
if (_tempTrack != null) {
tracks = [..._tempTrack!];
_tempTrack = null;
return true;
}
return false;
}
Map<String, dynamic> toJson() {
return {
"id": id,
"name": name,
"tracks": tracks
.map((track) =>
track is SourcedTrack ? track.toJson() : track.toJson())
.toList(),
"thumbnail": thumbnail,
"isLocal": isLocal,
};
}
}

View File

@ -13,6 +13,7 @@ import 'package:shadcn_flutter/shadcn_flutter.dart' show ThemeMode, Colors;
import 'package:spotify/spotify.dart' hide Playlist;
import 'package:spotube/models/database/database.steps.dart';
import 'package:spotube/models/lyrics.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/services/kv_store/encrypted_kv_store.dart';
import 'package:spotube/services/kv_store/kv_store.dart';
import 'package:spotube/services/sourced_track/enums.dart';
@ -43,6 +44,7 @@ part 'typeconverters/locale.dart';
part 'typeconverters/string_list.dart';
part 'typeconverters/encrypted_text.dart';
part 'typeconverters/map.dart';
part 'typeconverters/map_list.dart';
part 'typeconverters/subtitle.dart';
@DriftDatabase(
@ -54,8 +56,6 @@ part 'typeconverters/subtitle.dart';
SkipSegmentTable,
SourceMatchTable,
AudioPlayerStateTable,
PlaylistTable,
PlaylistMediaTable,
HistoryTable,
LyricsTable,
MetadataPluginsTable,

File diff suppressed because it is too large Load Diff

View File

@ -6,22 +6,30 @@ class AudioPlayerStateTable extends Table {
TextColumn get loopMode => textEnum<PlaylistMode>()();
BoolColumn get shuffled => boolean()();
TextColumn get collections => text().map(const StringListConverter())();
TextColumn get tracks =>
text().map(const SpotubeTrackObjectListConverter())();
IntColumn get currentIndex => integer()();
}
class PlaylistTable extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get audioPlayerStateId =>
integer().references(AudioPlayerStateTable, #id)();
IntColumn get index => integer()();
}
class SpotubeTrackObjectListConverter
extends TypeConverter<List<SpotubeTrackObject>, String> {
const SpotubeTrackObjectListConverter();
class PlaylistMediaTable extends Table {
IntColumn get id => integer().autoIncrement()();
IntColumn get playlistId => integer().references(PlaylistTable, #id)();
@override
List<SpotubeTrackObject> fromSql(String fromDb) {
return fromDb
.split(",")
.where((e) => e.isNotEmpty)
.map(
(e) => SpotubeTrackObject.fromJson(
json.decode(e) as Map<String, dynamic>,
),
)
.toList();
}
TextColumn get uri => text()();
TextColumn get extras =>
text().nullable().map(const MapTypeConverter<String, dynamic>())();
TextColumn get httpHeaders =>
text().nullable().map(const MapTypeConverter<String, String>())();
@override
String toSql(List<SpotubeTrackObject> value) {
return value.map((e) => json.encode(e)).join(",");
}
}

View File

@ -0,0 +1,20 @@
part of '../database.dart';
class MapListConverter
extends TypeConverter<List<Map<String, dynamic>>, String> {
const MapListConverter();
@override
List<Map<String, dynamic>> fromSql(String fromDb) {
return fromDb
.split(",")
.where((e) => e.isNotEmpty)
.map((e) => json.decode(e) as Map<String, dynamic>)
.toList();
}
@override
String toSql(List<Map<String, dynamic>> value) {
return value.map((e) => json.encode(e)).join(",");
}
}

View File

@ -1,8 +1,13 @@
library metadata_objects;
import 'dart:typed_data';
import 'package:collection/collection.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:media_kit/media_kit.dart';
import 'package:metadata_god/metadata_god.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/utils/primitive_utils.dart';
part 'metadata.g.dart';

View File

@ -2822,6 +2822,8 @@ abstract class _SpotubeSearchResponseObject
SpotubeTrackObject _$SpotubeTrackObjectFromJson(Map<String, dynamic> json) {
switch (json['runtimeType']) {
case 'local':
return SpotubeLocalTrackObject.fromJson(json);
case 'full':
return SpotubeFullTrackObject.fromJson(json);
case 'simple':
@ -2842,9 +2844,17 @@ mixin _$SpotubeTrackObject {
throw _privateConstructorUsedError;
SpotubeSimpleAlbumObject? get album => throw _privateConstructorUsedError;
int get durationMs => throw _privateConstructorUsedError;
bool get explicit => throw _privateConstructorUsedError;
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(
String id,
String name,
String externalUri,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject album,
int durationMs,
String path)
local,
required TResult Function(
String id,
String name,
@ -2868,6 +2878,15 @@ mixin _$SpotubeTrackObject {
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(
String id,
String name,
String externalUri,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject album,
int durationMs,
String path)?
local,
TResult? Function(
String id,
String name,
@ -2891,6 +2910,15 @@ mixin _$SpotubeTrackObject {
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(
String id,
String name,
String externalUri,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject album,
int durationMs,
String path)?
local,
TResult Function(
String id,
String name,
@ -2915,18 +2943,21 @@ mixin _$SpotubeTrackObject {
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(SpotubeLocalTrackObject value) local,
required TResult Function(SpotubeFullTrackObject value) full,
required TResult Function(SpotubeSimpleTrackObject value) simple,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(SpotubeLocalTrackObject value)? local,
TResult? Function(SpotubeFullTrackObject value)? full,
TResult? Function(SpotubeSimpleTrackObject value)? simple,
}) =>
throw _privateConstructorUsedError;
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(SpotubeLocalTrackObject value)? local,
TResult Function(SpotubeFullTrackObject value)? full,
TResult Function(SpotubeSimpleTrackObject value)? simple,
required TResult orElse(),
@ -2955,8 +2986,7 @@ abstract class $SpotubeTrackObjectCopyWith<$Res> {
String externalUri,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject album,
int durationMs,
bool explicit});
int durationMs});
$SpotubeSimpleAlbumObjectCopyWith<$Res>? get album;
}
@ -2982,7 +3012,6 @@ class _$SpotubeTrackObjectCopyWithImpl<$Res, $Val extends SpotubeTrackObject>
Object? artists = null,
Object? album = null,
Object? durationMs = null,
Object? explicit = null,
}) {
return _then(_value.copyWith(
id: null == id
@ -3009,10 +3038,6 @@ class _$SpotubeTrackObjectCopyWithImpl<$Res, $Val extends SpotubeTrackObject>
? _value.durationMs
: durationMs // ignore: cast_nullable_to_non_nullable
as int,
explicit: null == explicit
? _value.explicit
: explicit // ignore: cast_nullable_to_non_nullable
as bool,
) as $Val);
}
@ -3031,6 +3056,358 @@ class _$SpotubeTrackObjectCopyWithImpl<$Res, $Val extends SpotubeTrackObject>
}
}
/// @nodoc
abstract class _$$SpotubeLocalTrackObjectImplCopyWith<$Res>
implements $SpotubeTrackObjectCopyWith<$Res> {
factory _$$SpotubeLocalTrackObjectImplCopyWith(
_$SpotubeLocalTrackObjectImpl value,
$Res Function(_$SpotubeLocalTrackObjectImpl) then) =
__$$SpotubeLocalTrackObjectImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String id,
String name,
String externalUri,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject album,
int durationMs,
String path});
@override
$SpotubeSimpleAlbumObjectCopyWith<$Res> get album;
}
/// @nodoc
class __$$SpotubeLocalTrackObjectImplCopyWithImpl<$Res>
extends _$SpotubeTrackObjectCopyWithImpl<$Res,
_$SpotubeLocalTrackObjectImpl>
implements _$$SpotubeLocalTrackObjectImplCopyWith<$Res> {
__$$SpotubeLocalTrackObjectImplCopyWithImpl(
_$SpotubeLocalTrackObjectImpl _value,
$Res Function(_$SpotubeLocalTrackObjectImpl) _then)
: super(_value, _then);
/// Create a copy of SpotubeTrackObject
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? name = null,
Object? externalUri = null,
Object? artists = null,
Object? album = null,
Object? durationMs = null,
Object? path = null,
}) {
return _then(_$SpotubeLocalTrackObjectImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
name: null == name
? _value.name
: name // ignore: cast_nullable_to_non_nullable
as String,
externalUri: null == externalUri
? _value.externalUri
: externalUri // ignore: cast_nullable_to_non_nullable
as String,
artists: null == artists
? _value._artists
: artists // ignore: cast_nullable_to_non_nullable
as List<SpotubeSimpleArtistObject>,
album: null == album
? _value.album
: album // ignore: cast_nullable_to_non_nullable
as SpotubeSimpleAlbumObject,
durationMs: null == durationMs
? _value.durationMs
: durationMs // ignore: cast_nullable_to_non_nullable
as int,
path: null == path
? _value.path
: path // ignore: cast_nullable_to_non_nullable
as String,
));
}
/// Create a copy of SpotubeTrackObject
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$SpotubeSimpleAlbumObjectCopyWith<$Res> get album {
return $SpotubeSimpleAlbumObjectCopyWith<$Res>(_value.album, (value) {
return _then(_value.copyWith(album: value));
});
}
}
/// @nodoc
@JsonSerializable()
class _$SpotubeLocalTrackObjectImpl implements SpotubeLocalTrackObject {
_$SpotubeLocalTrackObjectImpl(
{required this.id,
required this.name,
required this.externalUri,
final List<SpotubeSimpleArtistObject> artists = const [],
required this.album,
required this.durationMs,
required this.path,
final String? $type})
: _artists = artists,
$type = $type ?? 'local';
factory _$SpotubeLocalTrackObjectImpl.fromJson(Map<String, dynamic> json) =>
_$$SpotubeLocalTrackObjectImplFromJson(json);
@override
final String id;
@override
final String name;
@override
final String externalUri;
final List<SpotubeSimpleArtistObject> _artists;
@override
@JsonKey()
List<SpotubeSimpleArtistObject> get artists {
if (_artists is EqualUnmodifiableListView) return _artists;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_artists);
}
@override
final SpotubeSimpleAlbumObject album;
@override
final int durationMs;
@override
final String path;
@JsonKey(name: 'runtimeType')
final String $type;
@override
String toString() {
return 'SpotubeTrackObject.local(id: $id, name: $name, externalUri: $externalUri, artists: $artists, album: $album, durationMs: $durationMs, path: $path)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$SpotubeLocalTrackObjectImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.name, name) || other.name == name) &&
(identical(other.externalUri, externalUri) ||
other.externalUri == externalUri) &&
const DeepCollectionEquality().equals(other._artists, _artists) &&
(identical(other.album, album) || other.album == album) &&
(identical(other.durationMs, durationMs) ||
other.durationMs == durationMs) &&
(identical(other.path, path) || other.path == path));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, id, name, externalUri,
const DeepCollectionEquality().hash(_artists), album, durationMs, path);
/// Create a copy of SpotubeTrackObject
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$SpotubeLocalTrackObjectImplCopyWith<_$SpotubeLocalTrackObjectImpl>
get copyWith => __$$SpotubeLocalTrackObjectImplCopyWithImpl<
_$SpotubeLocalTrackObjectImpl>(this, _$identity);
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(
String id,
String name,
String externalUri,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject album,
int durationMs,
String path)
local,
required TResult Function(
String id,
String name,
String externalUri,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject album,
int durationMs,
String isrc,
bool explicit)
full,
required TResult Function(
String id,
String name,
String externalUri,
int durationMs,
bool explicit,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject? album)
simple,
}) {
return local(id, name, externalUri, artists, album, durationMs, path);
}
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(
String id,
String name,
String externalUri,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject album,
int durationMs,
String path)?
local,
TResult? Function(
String id,
String name,
String externalUri,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject album,
int durationMs,
String isrc,
bool explicit)?
full,
TResult? Function(
String id,
String name,
String externalUri,
int durationMs,
bool explicit,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject? album)?
simple,
}) {
return local?.call(id, name, externalUri, artists, album, durationMs, path);
}
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(
String id,
String name,
String externalUri,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject album,
int durationMs,
String path)?
local,
TResult Function(
String id,
String name,
String externalUri,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject album,
int durationMs,
String isrc,
bool explicit)?
full,
TResult Function(
String id,
String name,
String externalUri,
int durationMs,
bool explicit,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject? album)?
simple,
required TResult orElse(),
}) {
if (local != null) {
return local(id, name, externalUri, artists, album, durationMs, path);
}
return orElse();
}
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(SpotubeLocalTrackObject value) local,
required TResult Function(SpotubeFullTrackObject value) full,
required TResult Function(SpotubeSimpleTrackObject value) simple,
}) {
return local(this);
}
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(SpotubeLocalTrackObject value)? local,
TResult? Function(SpotubeFullTrackObject value)? full,
TResult? Function(SpotubeSimpleTrackObject value)? simple,
}) {
return local?.call(this);
}
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(SpotubeLocalTrackObject value)? local,
TResult Function(SpotubeFullTrackObject value)? full,
TResult Function(SpotubeSimpleTrackObject value)? simple,
required TResult orElse(),
}) {
if (local != null) {
return local(this);
}
return orElse();
}
@override
Map<String, dynamic> toJson() {
return _$$SpotubeLocalTrackObjectImplToJson(
this,
);
}
}
abstract class SpotubeLocalTrackObject implements SpotubeTrackObject {
factory SpotubeLocalTrackObject(
{required final String id,
required final String name,
required final String externalUri,
final List<SpotubeSimpleArtistObject> artists,
required final SpotubeSimpleAlbumObject album,
required final int durationMs,
required final String path}) = _$SpotubeLocalTrackObjectImpl;
factory SpotubeLocalTrackObject.fromJson(Map<String, dynamic> json) =
_$SpotubeLocalTrackObjectImpl.fromJson;
@override
String get id;
@override
String get name;
@override
String get externalUri;
@override
List<SpotubeSimpleArtistObject> get artists;
@override
SpotubeSimpleAlbumObject get album;
@override
int get durationMs;
String get path;
/// Create a copy of SpotubeTrackObject
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$SpotubeLocalTrackObjectImplCopyWith<_$SpotubeLocalTrackObjectImpl>
get copyWith => throw _privateConstructorUsedError;
}
/// @nodoc
abstract class _$$SpotubeFullTrackObjectImplCopyWith<$Res>
implements $SpotubeTrackObjectCopyWith<$Res> {
@ -3218,6 +3595,15 @@ class _$SpotubeFullTrackObjectImpl implements SpotubeFullTrackObject {
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(
String id,
String name,
String externalUri,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject album,
int durationMs,
String path)
local,
required TResult Function(
String id,
String name,
@ -3245,6 +3631,15 @@ class _$SpotubeFullTrackObjectImpl implements SpotubeFullTrackObject {
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(
String id,
String name,
String externalUri,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject album,
int durationMs,
String path)?
local,
TResult? Function(
String id,
String name,
@ -3272,6 +3667,15 @@ class _$SpotubeFullTrackObjectImpl implements SpotubeFullTrackObject {
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(
String id,
String name,
String externalUri,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject album,
int durationMs,
String path)?
local,
TResult Function(
String id,
String name,
@ -3303,6 +3707,7 @@ class _$SpotubeFullTrackObjectImpl implements SpotubeFullTrackObject {
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(SpotubeLocalTrackObject value) local,
required TResult Function(SpotubeFullTrackObject value) full,
required TResult Function(SpotubeSimpleTrackObject value) simple,
}) {
@ -3312,6 +3717,7 @@ class _$SpotubeFullTrackObjectImpl implements SpotubeFullTrackObject {
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(SpotubeLocalTrackObject value)? local,
TResult? Function(SpotubeFullTrackObject value)? full,
TResult? Function(SpotubeSimpleTrackObject value)? simple,
}) {
@ -3321,6 +3727,7 @@ class _$SpotubeFullTrackObjectImpl implements SpotubeFullTrackObject {
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(SpotubeLocalTrackObject value)? local,
TResult Function(SpotubeFullTrackObject value)? full,
TResult Function(SpotubeSimpleTrackObject value)? simple,
required TResult orElse(),
@ -3366,7 +3773,6 @@ abstract class SpotubeFullTrackObject implements SpotubeTrackObject {
@override
int get durationMs;
String get isrc;
@override
bool get explicit;
/// Create a copy of SpotubeTrackObject
@ -3544,6 +3950,15 @@ class _$SpotubeSimpleTrackObjectImpl implements SpotubeSimpleTrackObject {
@override
@optionalTypeArgs
TResult when<TResult extends Object?>({
required TResult Function(
String id,
String name,
String externalUri,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject album,
int durationMs,
String path)
local,
required TResult Function(
String id,
String name,
@ -3570,6 +3985,15 @@ class _$SpotubeSimpleTrackObjectImpl implements SpotubeSimpleTrackObject {
@override
@optionalTypeArgs
TResult? whenOrNull<TResult extends Object?>({
TResult? Function(
String id,
String name,
String externalUri,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject album,
int durationMs,
String path)?
local,
TResult? Function(
String id,
String name,
@ -3597,6 +4021,15 @@ class _$SpotubeSimpleTrackObjectImpl implements SpotubeSimpleTrackObject {
@override
@optionalTypeArgs
TResult maybeWhen<TResult extends Object?>({
TResult Function(
String id,
String name,
String externalUri,
List<SpotubeSimpleArtistObject> artists,
SpotubeSimpleAlbumObject album,
int durationMs,
String path)?
local,
TResult Function(
String id,
String name,
@ -3628,6 +4061,7 @@ class _$SpotubeSimpleTrackObjectImpl implements SpotubeSimpleTrackObject {
@override
@optionalTypeArgs
TResult map<TResult extends Object?>({
required TResult Function(SpotubeLocalTrackObject value) local,
required TResult Function(SpotubeFullTrackObject value) full,
required TResult Function(SpotubeSimpleTrackObject value) simple,
}) {
@ -3637,6 +4071,7 @@ class _$SpotubeSimpleTrackObjectImpl implements SpotubeSimpleTrackObject {
@override
@optionalTypeArgs
TResult? mapOrNull<TResult extends Object?>({
TResult? Function(SpotubeLocalTrackObject value)? local,
TResult? Function(SpotubeFullTrackObject value)? full,
TResult? Function(SpotubeSimpleTrackObject value)? simple,
}) {
@ -3646,6 +4081,7 @@ class _$SpotubeSimpleTrackObjectImpl implements SpotubeSimpleTrackObject {
@override
@optionalTypeArgs
TResult maybeMap<TResult extends Object?>({
TResult Function(SpotubeLocalTrackObject value)? local,
TResult Function(SpotubeFullTrackObject value)? full,
TResult Function(SpotubeSimpleTrackObject value)? simple,
required TResult orElse(),
@ -3685,7 +4121,6 @@ abstract class SpotubeSimpleTrackObject implements SpotubeTrackObject {
String get externalUri;
@override
int get durationMs;
@override
bool get explicit;
@override
List<SpotubeSimpleArtistObject> get artists;

View File

@ -287,6 +287,37 @@ Map<String, dynamic> _$$SpotubeSearchResponseObjectImplToJson(
'tracks': instance.tracks.map((e) => e.toJson()).toList(),
};
_$SpotubeLocalTrackObjectImpl _$$SpotubeLocalTrackObjectImplFromJson(
Map json) =>
_$SpotubeLocalTrackObjectImpl(
id: json['id'] as String,
name: json['name'] as String,
externalUri: json['externalUri'] as String,
artists: (json['artists'] as List<dynamic>?)
?.map((e) => SpotubeSimpleArtistObject.fromJson(
Map<String, dynamic>.from(e as Map)))
.toList() ??
const [],
album: SpotubeSimpleAlbumObject.fromJson(
Map<String, dynamic>.from(json['album'] as Map)),
durationMs: (json['durationMs'] as num).toInt(),
path: json['path'] as String,
$type: json['runtimeType'] as String?,
);
Map<String, dynamic> _$$SpotubeLocalTrackObjectImplToJson(
_$SpotubeLocalTrackObjectImpl instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'externalUri': instance.externalUri,
'artists': instance.artists.map((e) => e.toJson()).toList(),
'album': instance.album.toJson(),
'durationMs': instance.durationMs,
'path': instance.path,
'runtimeType': instance.$type,
};
_$SpotubeFullTrackObjectImpl _$$SpotubeFullTrackObjectImplFromJson(Map json) =>
_$SpotubeFullTrackObjectImpl(
id: json['id'] as String,

View File

@ -2,6 +2,16 @@ part of 'metadata.dart';
@freezed
class SpotubeTrackObject with _$SpotubeTrackObject {
factory SpotubeTrackObject.local({
required String id,
required String name,
required String externalUri,
@Default([]) List<SpotubeSimpleArtistObject> artists,
required SpotubeSimpleAlbumObject album,
required int durationMs,
required String path,
}) = SpotubeLocalTrackObject;
factory SpotubeTrackObject.full({
required String id,
required String name,
@ -27,6 +37,40 @@ class SpotubeTrackObject with _$SpotubeTrackObject {
_$SpotubeTrackObjectFromJson(
json.containsKey("isrc")
? {...json, "runtimeType": "full"}
: json.containsKey("path")
? {...json, "runtimeType": "local"}
: {...json, "runtimeType": "simple"},
);
}
extension AsMediaListSpotubeTrackObject on Iterable<SpotubeTrackObject> {
List<SpotubeMedia> asMediaList() {
return map((track) => SpotubeMedia(track)).toList();
}
}
extension ToMetadataSpotubeFullTrackObject on SpotubeFullTrackObject {
Metadata toMetadata({
required int fileLength,
Uint8List? imageBytes,
}) {
return Metadata(
title: name,
artist: artists.map((a) => a.name).join(", "),
album: album.name,
albumArtist: artists.map((a) => a.name).join(", "),
year: album.releaseDate == null
? 1970
: DateTime.parse(album.releaseDate!).year,
durationMs: durationMs.toDouble(),
fileSize: BigInt.from(fileLength),
picture: imageBytes != null
? Picture(
data: imageBytes,
// Spotify images are always JPEGs
mimeType: 'image/jpeg',
)
: null,
);
}
}

View File

@ -0,0 +1,94 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/sourced_track/enums.dart';
part 'track_sources.freezed.dart';
part 'track_sources.g.dart';
@freezed
class TrackSourceQuery with _$TrackSourceQuery {
factory TrackSourceQuery({
required String id,
required String title,
required List<String> artists,
required String album,
required int durationMs,
required String isrc,
required bool explicit,
}) = _TrackSourceQuery;
factory TrackSourceQuery.fromJson(Map<String, dynamic> json) =>
_$TrackSourceQueryFromJson(json);
factory TrackSourceQuery.fromTrack(SpotubeFullTrackObject track) {
return TrackSourceQuery(
id: track.id,
title: track.name,
artists: track.artists.map((e) => e.name).toList(),
album: track.album.name,
durationMs: track.durationMs,
isrc: track.isrc,
explicit: track.explicit,
);
}
/// Parses [SpotubeMedia]'s [uri] property to create a [TrackSourceQuery].
factory TrackSourceQuery.parseUri(String url) {
final uri = Uri.parse(url);
return TrackSourceQuery.fromJson({
"id": uri.pathSegments.last,
...uri.queryParameters,
});
}
}
@freezed
class TrackSourceInfo with _$TrackSourceInfo {
factory TrackSourceInfo({
required String id,
required String title,
required String artists,
required String thumbnail,
required String pageUrl,
required int durationMs,
}) = _TrackSourceInfo;
factory TrackSourceInfo.fromJson(Map<String, dynamic> json) =>
_$TrackSourceInfoFromJson(json);
}
@freezed
class TrackSource with _$TrackSource {
factory TrackSource({
required String url,
required SourceQualities quality,
required SourceCodecs codec,
required String bitrate,
}) = _TrackSource;
factory TrackSource.fromJson(Map<String, dynamic> json) =>
_$TrackSourceFromJson(json);
}
@JsonSerializable()
abstract class BasicSourcedTrack {
final TrackSourceQuery query;
final AudioSource source;
final TrackSourceInfo info;
final List<TrackSource> sources;
@JsonKey(defaultValue: [])
final List<TrackSourceInfo> siblings;
BasicSourcedTrack({
required this.query,
required this.source,
required this.info,
required this.sources,
this.siblings = const [],
});
factory BasicSourcedTrack.fromJson(Map<String, dynamic> json) =>
_$BasicSourcedTrackFromJson(json);
Map<String, dynamic> toJson() => _$BasicSourcedTrackToJson(this);
}

View File

@ -0,0 +1,776 @@
// 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 'track_sources.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');
TrackSourceQuery _$TrackSourceQueryFromJson(Map<String, dynamic> json) {
return _TrackSourceQuery.fromJson(json);
}
/// @nodoc
mixin _$TrackSourceQuery {
String get id => throw _privateConstructorUsedError;
String get title => throw _privateConstructorUsedError;
List<String> get artists => throw _privateConstructorUsedError;
String get album => throw _privateConstructorUsedError;
int get durationMs => throw _privateConstructorUsedError;
String get isrc => throw _privateConstructorUsedError;
bool get explicit => throw _privateConstructorUsedError;
/// Serializes this TrackSourceQuery to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of TrackSourceQuery
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$TrackSourceQueryCopyWith<TrackSourceQuery> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $TrackSourceQueryCopyWith<$Res> {
factory $TrackSourceQueryCopyWith(
TrackSourceQuery value, $Res Function(TrackSourceQuery) then) =
_$TrackSourceQueryCopyWithImpl<$Res, TrackSourceQuery>;
@useResult
$Res call(
{String id,
String title,
List<String> artists,
String album,
int durationMs,
String isrc,
bool explicit});
}
/// @nodoc
class _$TrackSourceQueryCopyWithImpl<$Res, $Val extends TrackSourceQuery>
implements $TrackSourceQueryCopyWith<$Res> {
_$TrackSourceQueryCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of TrackSourceQuery
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? title = null,
Object? artists = null,
Object? album = null,
Object? durationMs = null,
Object? isrc = null,
Object? explicit = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
title: null == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String,
artists: null == artists
? _value.artists
: artists // ignore: cast_nullable_to_non_nullable
as List<String>,
album: null == album
? _value.album
: album // ignore: cast_nullable_to_non_nullable
as String,
durationMs: null == durationMs
? _value.durationMs
: durationMs // ignore: cast_nullable_to_non_nullable
as int,
isrc: null == isrc
? _value.isrc
: isrc // ignore: cast_nullable_to_non_nullable
as String,
explicit: null == explicit
? _value.explicit
: explicit // ignore: cast_nullable_to_non_nullable
as bool,
) as $Val);
}
}
/// @nodoc
abstract class _$$TrackSourceQueryImplCopyWith<$Res>
implements $TrackSourceQueryCopyWith<$Res> {
factory _$$TrackSourceQueryImplCopyWith(_$TrackSourceQueryImpl value,
$Res Function(_$TrackSourceQueryImpl) then) =
__$$TrackSourceQueryImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String id,
String title,
List<String> artists,
String album,
int durationMs,
String isrc,
bool explicit});
}
/// @nodoc
class __$$TrackSourceQueryImplCopyWithImpl<$Res>
extends _$TrackSourceQueryCopyWithImpl<$Res, _$TrackSourceQueryImpl>
implements _$$TrackSourceQueryImplCopyWith<$Res> {
__$$TrackSourceQueryImplCopyWithImpl(_$TrackSourceQueryImpl _value,
$Res Function(_$TrackSourceQueryImpl) _then)
: super(_value, _then);
/// Create a copy of TrackSourceQuery
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? title = null,
Object? artists = null,
Object? album = null,
Object? durationMs = null,
Object? isrc = null,
Object? explicit = null,
}) {
return _then(_$TrackSourceQueryImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
title: null == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String,
artists: null == artists
? _value._artists
: artists // ignore: cast_nullable_to_non_nullable
as List<String>,
album: null == album
? _value.album
: album // ignore: cast_nullable_to_non_nullable
as String,
durationMs: null == durationMs
? _value.durationMs
: durationMs // ignore: cast_nullable_to_non_nullable
as int,
isrc: null == isrc
? _value.isrc
: isrc // ignore: cast_nullable_to_non_nullable
as String,
explicit: null == explicit
? _value.explicit
: explicit // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// @nodoc
@JsonSerializable()
class _$TrackSourceQueryImpl implements _TrackSourceQuery {
_$TrackSourceQueryImpl(
{required this.id,
required this.title,
required final List<String> artists,
required this.album,
required this.durationMs,
required this.isrc,
required this.explicit})
: _artists = artists;
factory _$TrackSourceQueryImpl.fromJson(Map<String, dynamic> json) =>
_$$TrackSourceQueryImplFromJson(json);
@override
final String id;
@override
final String title;
final List<String> _artists;
@override
List<String> get artists {
if (_artists is EqualUnmodifiableListView) return _artists;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_artists);
}
@override
final String album;
@override
final int durationMs;
@override
final String isrc;
@override
final bool explicit;
@override
String toString() {
return 'TrackSourceQuery(id: $id, title: $title, artists: $artists, album: $album, durationMs: $durationMs, isrc: $isrc, explicit: $explicit)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$TrackSourceQueryImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.title, title) || other.title == title) &&
const DeepCollectionEquality().equals(other._artists, _artists) &&
(identical(other.album, album) || other.album == album) &&
(identical(other.durationMs, durationMs) ||
other.durationMs == durationMs) &&
(identical(other.isrc, isrc) || other.isrc == isrc) &&
(identical(other.explicit, explicit) ||
other.explicit == explicit));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
id,
title,
const DeepCollectionEquality().hash(_artists),
album,
durationMs,
isrc,
explicit);
/// Create a copy of TrackSourceQuery
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$TrackSourceQueryImplCopyWith<_$TrackSourceQueryImpl> get copyWith =>
__$$TrackSourceQueryImplCopyWithImpl<_$TrackSourceQueryImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$TrackSourceQueryImplToJson(
this,
);
}
}
abstract class _TrackSourceQuery implements TrackSourceQuery {
factory _TrackSourceQuery(
{required final String id,
required final String title,
required final List<String> artists,
required final String album,
required final int durationMs,
required final String isrc,
required final bool explicit}) = _$TrackSourceQueryImpl;
factory _TrackSourceQuery.fromJson(Map<String, dynamic> json) =
_$TrackSourceQueryImpl.fromJson;
@override
String get id;
@override
String get title;
@override
List<String> get artists;
@override
String get album;
@override
int get durationMs;
@override
String get isrc;
@override
bool get explicit;
/// Create a copy of TrackSourceQuery
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$TrackSourceQueryImplCopyWith<_$TrackSourceQueryImpl> get copyWith =>
throw _privateConstructorUsedError;
}
TrackSourceInfo _$TrackSourceInfoFromJson(Map<String, dynamic> json) {
return _TrackSourceInfo.fromJson(json);
}
/// @nodoc
mixin _$TrackSourceInfo {
String get id => throw _privateConstructorUsedError;
String get title => throw _privateConstructorUsedError;
String get artists => throw _privateConstructorUsedError;
String get thumbnail => throw _privateConstructorUsedError;
String get pageUrl => throw _privateConstructorUsedError;
int get durationMs => throw _privateConstructorUsedError;
/// Serializes this TrackSourceInfo to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of TrackSourceInfo
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$TrackSourceInfoCopyWith<TrackSourceInfo> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $TrackSourceInfoCopyWith<$Res> {
factory $TrackSourceInfoCopyWith(
TrackSourceInfo value, $Res Function(TrackSourceInfo) then) =
_$TrackSourceInfoCopyWithImpl<$Res, TrackSourceInfo>;
@useResult
$Res call(
{String id,
String title,
String artists,
String thumbnail,
String pageUrl,
int durationMs});
}
/// @nodoc
class _$TrackSourceInfoCopyWithImpl<$Res, $Val extends TrackSourceInfo>
implements $TrackSourceInfoCopyWith<$Res> {
_$TrackSourceInfoCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of TrackSourceInfo
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? title = null,
Object? artists = null,
Object? thumbnail = null,
Object? pageUrl = null,
Object? durationMs = null,
}) {
return _then(_value.copyWith(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
title: null == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String,
artists: null == artists
? _value.artists
: artists // ignore: cast_nullable_to_non_nullable
as String,
thumbnail: null == thumbnail
? _value.thumbnail
: thumbnail // ignore: cast_nullable_to_non_nullable
as String,
pageUrl: null == pageUrl
? _value.pageUrl
: pageUrl // ignore: cast_nullable_to_non_nullable
as String,
durationMs: null == durationMs
? _value.durationMs
: durationMs // ignore: cast_nullable_to_non_nullable
as int,
) as $Val);
}
}
/// @nodoc
abstract class _$$TrackSourceInfoImplCopyWith<$Res>
implements $TrackSourceInfoCopyWith<$Res> {
factory _$$TrackSourceInfoImplCopyWith(_$TrackSourceInfoImpl value,
$Res Function(_$TrackSourceInfoImpl) then) =
__$$TrackSourceInfoImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String id,
String title,
String artists,
String thumbnail,
String pageUrl,
int durationMs});
}
/// @nodoc
class __$$TrackSourceInfoImplCopyWithImpl<$Res>
extends _$TrackSourceInfoCopyWithImpl<$Res, _$TrackSourceInfoImpl>
implements _$$TrackSourceInfoImplCopyWith<$Res> {
__$$TrackSourceInfoImplCopyWithImpl(
_$TrackSourceInfoImpl _value, $Res Function(_$TrackSourceInfoImpl) _then)
: super(_value, _then);
/// Create a copy of TrackSourceInfo
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? id = null,
Object? title = null,
Object? artists = null,
Object? thumbnail = null,
Object? pageUrl = null,
Object? durationMs = null,
}) {
return _then(_$TrackSourceInfoImpl(
id: null == id
? _value.id
: id // ignore: cast_nullable_to_non_nullable
as String,
title: null == title
? _value.title
: title // ignore: cast_nullable_to_non_nullable
as String,
artists: null == artists
? _value.artists
: artists // ignore: cast_nullable_to_non_nullable
as String,
thumbnail: null == thumbnail
? _value.thumbnail
: thumbnail // ignore: cast_nullable_to_non_nullable
as String,
pageUrl: null == pageUrl
? _value.pageUrl
: pageUrl // ignore: cast_nullable_to_non_nullable
as String,
durationMs: null == durationMs
? _value.durationMs
: durationMs // ignore: cast_nullable_to_non_nullable
as int,
));
}
}
/// @nodoc
@JsonSerializable()
class _$TrackSourceInfoImpl implements _TrackSourceInfo {
_$TrackSourceInfoImpl(
{required this.id,
required this.title,
required this.artists,
required this.thumbnail,
required this.pageUrl,
required this.durationMs});
factory _$TrackSourceInfoImpl.fromJson(Map<String, dynamic> json) =>
_$$TrackSourceInfoImplFromJson(json);
@override
final String id;
@override
final String title;
@override
final String artists;
@override
final String thumbnail;
@override
final String pageUrl;
@override
final int durationMs;
@override
String toString() {
return 'TrackSourceInfo(id: $id, title: $title, artists: $artists, thumbnail: $thumbnail, pageUrl: $pageUrl, durationMs: $durationMs)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$TrackSourceInfoImpl &&
(identical(other.id, id) || other.id == id) &&
(identical(other.title, title) || other.title == title) &&
(identical(other.artists, artists) || other.artists == artists) &&
(identical(other.thumbnail, thumbnail) ||
other.thumbnail == thumbnail) &&
(identical(other.pageUrl, pageUrl) || other.pageUrl == pageUrl) &&
(identical(other.durationMs, durationMs) ||
other.durationMs == durationMs));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType, id, title, artists, thumbnail, pageUrl, durationMs);
/// Create a copy of TrackSourceInfo
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$TrackSourceInfoImplCopyWith<_$TrackSourceInfoImpl> get copyWith =>
__$$TrackSourceInfoImplCopyWithImpl<_$TrackSourceInfoImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$TrackSourceInfoImplToJson(
this,
);
}
}
abstract class _TrackSourceInfo implements TrackSourceInfo {
factory _TrackSourceInfo(
{required final String id,
required final String title,
required final String artists,
required final String thumbnail,
required final String pageUrl,
required final int durationMs}) = _$TrackSourceInfoImpl;
factory _TrackSourceInfo.fromJson(Map<String, dynamic> json) =
_$TrackSourceInfoImpl.fromJson;
@override
String get id;
@override
String get title;
@override
String get artists;
@override
String get thumbnail;
@override
String get pageUrl;
@override
int get durationMs;
/// Create a copy of TrackSourceInfo
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$TrackSourceInfoImplCopyWith<_$TrackSourceInfoImpl> get copyWith =>
throw _privateConstructorUsedError;
}
TrackSource _$TrackSourceFromJson(Map<String, dynamic> json) {
return _TrackSource.fromJson(json);
}
/// @nodoc
mixin _$TrackSource {
String get url => throw _privateConstructorUsedError;
SourceQualities get quality => throw _privateConstructorUsedError;
SourceCodecs get codec => throw _privateConstructorUsedError;
String get bitrate => throw _privateConstructorUsedError;
/// Serializes this TrackSource to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of TrackSource
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$TrackSourceCopyWith<TrackSource> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $TrackSourceCopyWith<$Res> {
factory $TrackSourceCopyWith(
TrackSource value, $Res Function(TrackSource) then) =
_$TrackSourceCopyWithImpl<$Res, TrackSource>;
@useResult
$Res call(
{String url,
SourceQualities quality,
SourceCodecs codec,
String bitrate});
}
/// @nodoc
class _$TrackSourceCopyWithImpl<$Res, $Val extends TrackSource>
implements $TrackSourceCopyWith<$Res> {
_$TrackSourceCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of TrackSource
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? url = null,
Object? quality = null,
Object? codec = null,
Object? bitrate = null,
}) {
return _then(_value.copyWith(
url: null == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String,
quality: null == quality
? _value.quality
: quality // ignore: cast_nullable_to_non_nullable
as SourceQualities,
codec: null == codec
? _value.codec
: codec // ignore: cast_nullable_to_non_nullable
as SourceCodecs,
bitrate: null == bitrate
? _value.bitrate
: bitrate // ignore: cast_nullable_to_non_nullable
as String,
) as $Val);
}
}
/// @nodoc
abstract class _$$TrackSourceImplCopyWith<$Res>
implements $TrackSourceCopyWith<$Res> {
factory _$$TrackSourceImplCopyWith(
_$TrackSourceImpl value, $Res Function(_$TrackSourceImpl) then) =
__$$TrackSourceImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{String url,
SourceQualities quality,
SourceCodecs codec,
String bitrate});
}
/// @nodoc
class __$$TrackSourceImplCopyWithImpl<$Res>
extends _$TrackSourceCopyWithImpl<$Res, _$TrackSourceImpl>
implements _$$TrackSourceImplCopyWith<$Res> {
__$$TrackSourceImplCopyWithImpl(
_$TrackSourceImpl _value, $Res Function(_$TrackSourceImpl) _then)
: super(_value, _then);
/// Create a copy of TrackSource
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? url = null,
Object? quality = null,
Object? codec = null,
Object? bitrate = null,
}) {
return _then(_$TrackSourceImpl(
url: null == url
? _value.url
: url // ignore: cast_nullable_to_non_nullable
as String,
quality: null == quality
? _value.quality
: quality // ignore: cast_nullable_to_non_nullable
as SourceQualities,
codec: null == codec
? _value.codec
: codec // ignore: cast_nullable_to_non_nullable
as SourceCodecs,
bitrate: null == bitrate
? _value.bitrate
: bitrate // ignore: cast_nullable_to_non_nullable
as String,
));
}
}
/// @nodoc
@JsonSerializable()
class _$TrackSourceImpl implements _TrackSource {
_$TrackSourceImpl(
{required this.url,
required this.quality,
required this.codec,
required this.bitrate});
factory _$TrackSourceImpl.fromJson(Map<String, dynamic> json) =>
_$$TrackSourceImplFromJson(json);
@override
final String url;
@override
final SourceQualities quality;
@override
final SourceCodecs codec;
@override
final String bitrate;
@override
String toString() {
return 'TrackSource(url: $url, quality: $quality, codec: $codec, bitrate: $bitrate)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$TrackSourceImpl &&
(identical(other.url, url) || other.url == url) &&
(identical(other.quality, quality) || other.quality == quality) &&
(identical(other.codec, codec) || other.codec == codec) &&
(identical(other.bitrate, bitrate) || other.bitrate == bitrate));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType, url, quality, codec, bitrate);
/// Create a copy of TrackSource
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$TrackSourceImplCopyWith<_$TrackSourceImpl> get copyWith =>
__$$TrackSourceImplCopyWithImpl<_$TrackSourceImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$TrackSourceImplToJson(
this,
);
}
}
abstract class _TrackSource implements TrackSource {
factory _TrackSource(
{required final String url,
required final SourceQualities quality,
required final SourceCodecs codec,
required final String bitrate}) = _$TrackSourceImpl;
factory _TrackSource.fromJson(Map<String, dynamic> json) =
_$TrackSourceImpl.fromJson;
@override
String get url;
@override
SourceQualities get quality;
@override
SourceCodecs get codec;
@override
String get bitrate;
/// Create a copy of TrackSource
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$TrackSourceImplCopyWith<_$TrackSourceImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,110 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'track_sources.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
BasicSourcedTrack _$BasicSourcedTrackFromJson(Map json) => BasicSourcedTrack(
query: TrackSourceQuery.fromJson(
Map<String, dynamic>.from(json['query'] as Map)),
source: $enumDecode(_$AudioSourceEnumMap, json['source']),
info: TrackSourceInfo.fromJson(
Map<String, dynamic>.from(json['info'] as Map)),
sources: (json['sources'] as List<dynamic>)
.map((e) => TrackSource.fromJson(Map<String, dynamic>.from(e as Map)))
.toList(),
siblings: (json['siblings'] as List<dynamic>?)
?.map((e) =>
TrackSourceInfo.fromJson(Map<String, dynamic>.from(e as Map)))
.toList() ??
[],
);
Map<String, dynamic> _$BasicSourcedTrackToJson(BasicSourcedTrack instance) =>
<String, dynamic>{
'query': instance.query.toJson(),
'source': _$AudioSourceEnumMap[instance.source]!,
'info': instance.info.toJson(),
'sources': instance.sources.map((e) => e.toJson()).toList(),
'siblings': instance.siblings.map((e) => e.toJson()).toList(),
};
const _$AudioSourceEnumMap = {
AudioSource.youtube: 'youtube',
AudioSource.piped: 'piped',
AudioSource.jiosaavn: 'jiosaavn',
AudioSource.invidious: 'invidious',
};
_$TrackSourceQueryImpl _$$TrackSourceQueryImplFromJson(Map json) =>
_$TrackSourceQueryImpl(
id: json['id'] as String,
title: json['title'] as String,
artists:
(json['artists'] as List<dynamic>).map((e) => e as String).toList(),
album: json['album'] as String,
durationMs: (json['durationMs'] as num).toInt(),
isrc: json['isrc'] as String,
explicit: json['explicit'] as bool,
);
Map<String, dynamic> _$$TrackSourceQueryImplToJson(
_$TrackSourceQueryImpl instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'artists': instance.artists,
'album': instance.album,
'durationMs': instance.durationMs,
'isrc': instance.isrc,
'explicit': instance.explicit,
};
_$TrackSourceInfoImpl _$$TrackSourceInfoImplFromJson(Map json) =>
_$TrackSourceInfoImpl(
id: json['id'] as String,
title: json['title'] as String,
artists: json['artists'] as String,
thumbnail: json['thumbnail'] as String,
pageUrl: json['pageUrl'] as String,
durationMs: (json['durationMs'] as num).toInt(),
);
Map<String, dynamic> _$$TrackSourceInfoImplToJson(
_$TrackSourceInfoImpl instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'artists': instance.artists,
'thumbnail': instance.thumbnail,
'pageUrl': instance.pageUrl,
'durationMs': instance.durationMs,
};
_$TrackSourceImpl _$$TrackSourceImplFromJson(Map json) => _$TrackSourceImpl(
url: json['url'] as String,
quality: $enumDecode(_$SourceQualitiesEnumMap, json['quality']),
codec: $enumDecode(_$SourceCodecsEnumMap, json['codec']),
bitrate: json['bitrate'] as String,
);
Map<String, dynamic> _$$TrackSourceImplToJson(_$TrackSourceImpl instance) =>
<String, dynamic>{
'url': instance.url,
'quality': _$SourceQualitiesEnumMap[instance.quality]!,
'codec': _$SourceCodecsEnumMap[instance.codec]!,
'bitrate': instance.bitrate,
};
const _$SourceQualitiesEnumMap = {
SourceQualities.high: 'high',
SourceQualities.medium: 'medium',
SourceQualities.low: 'low',
};
const _$SourceCodecsEnumMap = {
SourceCodecs.m4a: 'm4a',
SourceCodecs.weba: 'weba',
};

View File

@ -24,7 +24,7 @@ import 'package:spotube/models/local_track.dart';
import 'package:spotube/modules/root/spotube_navigation_bar.dart';
import 'package:spotube/provider/authentication/authentication.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/server/active_sourced_track.dart';
import 'package:spotube/provider/server/active_track_sources.dart';
import 'package:spotube/provider/volume_provider.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart';
import 'package:spotube/utils/platform.dart';
@ -44,7 +44,7 @@ class PlayerView extends HookConsumerWidget {
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
final auth = ref.watch(authenticationProvider);
final sourcedCurrentTrack = ref.watch(activeSourcedTrackProvider);
final sourcedCurrentTrack = ref.watch(activeTrackSourcesProvider);
final currentActiveTrack =
ref.watch(audioPlayerProvider.select((s) => s.activeTrack));
final currentTrack = sourcedCurrentTrack ?? currentActiveTrack;

View File

@ -19,7 +19,7 @@ import 'package:spotube/hooks/utils/use_debounce.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/audio_player/querying_track_info.dart';
import 'package:spotube/provider/server/active_sourced_track.dart';
import 'package:spotube/provider/server/active_track_sources.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/youtube_engine/youtube_engine.dart';
import 'package:spotube/services/sourced_track/models/source_info.dart';
@ -75,9 +75,9 @@ class SiblingTracksSheet extends HookConsumerWidget {
final isSearching = useState(false);
final searchMode = useState(preferences.searchMode);
final activeTrackNotifier = ref.watch(activeSourcedTrackProvider.notifier);
final activeTrackNotifier = ref.watch(activeTrackSourcesProvider.notifier);
final activeTrack =
ref.watch(activeSourcedTrackProvider) ?? playlist.activeTrack;
ref.watch(activeTrackSourcesProvider) ?? playlist.activeTrack;
final title = ServiceUtils.getTitle(
activeTrack?.name ?? "",

View File

@ -3,22 +3,38 @@ import 'dart:math';
import 'package:drift/drift.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:media_kit/media_kit.dart' hide Track;
import 'package:spotify/spotify.dart' hide Playlist;
import 'package:spotube/extensions/list.dart';
import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/audio_player/state.dart';
import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/discord_provider.dart';
import 'package:spotube/provider/server/sourced_track.dart';
import 'package:spotube/provider/server/track_sources.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/logger/logger.dart';
class AudioPlayerNotifier extends Notifier<AudioPlayerState> {
BlackListNotifier get _blacklist => ref.read(blacklistProvider.notifier);
void _assertAllowedTracks(Iterable<SpotubeTrackObject> tracks) {
assert(
tracks.every(
(track) =>
track is SpotubeFullTrackObject || track is SpotubeLocalTrackObject,
),
'All tracks must be either SpotubeFullTrackObject or SpotubeLocalTrackObject',
);
}
void _assertAllowedTrack(SpotubeTrackObject tracks) {
assert(
tracks is SpotubeFullTrackObject || tracks is SpotubeLocalTrackObject,
'Track must be either SpotubeFullTrackObject or SpotubeLocalTrackObject',
);
}
Future<void> _syncSavedState() async {
final database = ref.read(databaseProvider);
@ -32,6 +48,8 @@ class AudioPlayerNotifier extends Notifier<AudioPlayerState> {
loopMode: audioPlayer.loopMode,
shuffled: audioPlayer.isShuffled,
collections: <String>[],
tracks: <SpotubeTrackObject>[],
currentIndex: 0,
id: const Value(0),
),
);
@ -43,51 +61,20 @@ class AudioPlayerNotifier extends Notifier<AudioPlayerState> {
await audioPlayer.setShuffle(playerState.shuffled);
}
var playlist =
await database.select(database.playlistTable).getSingleOrNull();
final medias = await database.select(database.playlistMediaTable).get();
final tracks = playerState.tracks;
final currentIndex = playerState.currentIndex;
if (playlist == null) {
await database.into(database.playlistTable).insert(
PlaylistTableCompanion.insert(
audioPlayerStateId: 0,
index: audioPlayer.playlist.index,
id: const Value(0),
if (tracks.isEmpty && state.tracks.isNotEmpty) {
await _updatePlayerState(
AudioPlayerStateTableCompanion(
tracks: Value(state.tracks),
currentIndex: Value(currentIndex),
),
);
playlist = await database.select(database.playlistTable).getSingle();
}
if (medias.isEmpty && audioPlayer.playlist.medias.isNotEmpty) {
await database.batch((batch) {
batch.insertAll(
database.playlistMediaTable,
[
for (final media in audioPlayer.playlist.medias)
PlaylistMediaTableCompanion.insert(
playlistId: playlist!.id,
uri: media.uri,
extras: Value(media.extras),
httpHeaders: Value(media.httpHeaders),
),
],
);
});
} else if (medias.isNotEmpty) {
} else if (tracks.isNotEmpty) {
await audioPlayer.openPlaylist(
medias
.map(
(media) => SpotubeMedia.fromMedia(
Media(
media.uri,
extras: media.extras,
httpHeaders: media.httpHeaders,
),
),
)
.toList(),
initialIndex: playlist.index,
tracks.asMediaList(),
initialIndex: currentIndex,
autoPlay: false,
);
}
@ -109,36 +96,6 @@ class AudioPlayerNotifier extends Notifier<AudioPlayerState> {
.write(companion);
}
Future<void> _updatePlaylist(
Playlist playlist,
) async {
final database = ref.read(databaseProvider);
await database.batch((batch) {
batch.update(
database.playlistTable,
PlaylistTableCompanion(index: Value(playlist.index)),
where: (tb) => tb.id.equals(0),
);
batch.deleteAll(database.playlistMediaTable);
if (playlist.medias.isEmpty) return;
batch.insertAll(
database.playlistMediaTable,
[
for (final media in playlist.medias)
PlaylistMediaTableCompanion.insert(
playlistId: 0,
uri: media.uri,
extras: Value(media.extras),
httpHeaders: Value(media.httpHeaders),
),
],
);
});
}
@override
build() {
final subscriptions = [
@ -183,9 +140,25 @@ class AudioPlayerNotifier extends Notifier<AudioPlayerState> {
}),
audioPlayer.playlistStream.listen((playlist) async {
try {
state = state.copyWith(playlist: playlist);
final queries = playlist.medias
.map((media) => TrackSourceQuery.parseUri(media.uri))
.toList();
final tracks = queries
.map((query) => state.tracks.firstWhere(
(element) => element.id == query.id,
))
.toList();
state = state.copyWith(
tracks: tracks,
currentIndex: playlist.index,
);
await _updatePlaylist(playlist);
await _updatePlayerState(
AudioPlayerStateTableCompanion(
currentIndex: Value(state.currentIndex),
tracks: Value(state.tracks),
),
);
} catch (e, stack) {
AppLogger.reportError(e, stack);
}
@ -203,8 +176,8 @@ class AudioPlayerNotifier extends Notifier<AudioPlayerState> {
return AudioPlayerState(
loopMode: audioPlayer.loopMode,
playing: audioPlayer.isPlaying,
playlist: audioPlayer.playlist,
shuffled: audioPlayer.isShuffled,
tracks: [],
collections: [],
);
}
@ -245,17 +218,16 @@ class AudioPlayerNotifier extends Notifier<AudioPlayerState> {
await removeCollections([collectionId]);
}
// Tracks related methods
Future<void> addTracksAtFirst(
Iterable<Track> tracks, {
Iterable<SpotubeTrackObject> tracks, {
bool allowDuplicates = false,
}) async {
_assertAllowedTracks(tracks);
if (state.tracks.length == 1) {
return addTracks(tracks);
}
tracks = _blacklist.filter(tracks).toList() as List<Track>;
tracks = _blacklist.filter(tracks).toList();
for (int i = 0; i < tracks.length; i++) {
final track = tracks.elementAt(i);
@ -267,19 +239,23 @@ class AudioPlayerNotifier extends Notifier<AudioPlayerState> {
await audioPlayer.addTrackAt(
SpotubeMedia(track),
max(state.playlist.index, 0) + i + 1,
max(state.currentIndex, 0) + i + 1,
);
}
}
Future<void> addTrack(Track track) async {
Future<void> addTrack(SpotubeTrackObject track) async {
_assertAllowedTrack(track);
if (_blacklist.contains(track)) return;
if (state.tracks.any((element) => _compareTracks(element, track))) return;
await audioPlayer.addTrack(SpotubeMedia(track));
}
Future<void> addTracks(Iterable<Track> tracks) async {
tracks = _blacklist.filter(tracks).toList() as List<Track>;
Future<void> addTracks(Iterable<SpotubeTrackObject> tracks) async {
_assertAllowedTracks(tracks);
tracks = _blacklist.filter(tracks).toList();
for (final track in tracks) {
await audioPlayer.addTrack(SpotubeMedia(track));
}
@ -299,31 +275,40 @@ class AudioPlayerNotifier extends Notifier<AudioPlayerState> {
}
}
bool _compareTracks(Track a, Track b) {
if ((a is LocalTrack && b is! LocalTrack) ||
(a is! LocalTrack && b is LocalTrack)) {
bool _compareTracks(SpotubeTrackObject a, SpotubeTrackObject b) {
if ((a is SpotubeLocalTrackObject && b is! SpotubeLocalTrackObject) ||
(a is! SpotubeLocalTrackObject && b is SpotubeLocalTrackObject)) {
return false;
}
return a is LocalTrack && b is LocalTrack
return a is SpotubeLocalTrackObject && b is SpotubeLocalTrackObject
? (a).path == (b).path
: a.id == b.id;
}
Future<void> load(
List<Track> tracks, {
List<SpotubeTrackObject> tracks, {
int initialIndex = 0,
bool autoPlay = false,
}) async {
final medias = (_blacklist.filter(tracks).toList() as List<Track>)
_assertAllowedTracks(tracks);
final medias = _blacklist
.filter(tracks)
.toList()
.asMediaList()
.unique((a, b) => _compareTracks(a.track, b.track));
.unique((a, b) => a.uri == b.uri);
// Giving the initial track a boost so MediaKit won't skip
// because of timeout
final intendedActiveTrack = medias.elementAt(initialIndex);
if (intendedActiveTrack.track is! LocalTrack) {
await ref.read(sourcedTrackProvider(intendedActiveTrack).future);
if (intendedActiveTrack.track is! SpotubeLocalTrackObject) {
await ref.read(
trackSourcesProvider(
TrackSourceQuery.fromTrack(
intendedActiveTrack.track as SpotubeFullTrackObject),
).future,
);
}
if (medias.isEmpty) return;
@ -337,7 +322,7 @@ class AudioPlayerNotifier extends Notifier<AudioPlayerState> {
);
}
Future<void> jumpToTrack(Track track) async {
Future<void> jumpToTrack(SpotubeTrackObject track) async {
final index =
state.tracks.toList().indexWhere((element) => element.id == track.id);
if (index == -1) return;

View File

@ -2,16 +2,17 @@ import 'dart:async';
import 'dart:math';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/audio_player/state.dart';
import 'package:spotube/provider/discord_provider.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/metadata_plugin/metadata_plugin_provider.dart';
import 'package:spotube/provider/server/track_sources.dart';
import 'package:spotube/provider/skip_segments/skip_segments.dart';
import 'package:spotube/provider/scrobbler/scrobbler.dart';
import 'package:spotube/provider/server/sourced_track.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/audio_services/audio_services.dart';
@ -101,16 +102,18 @@ class AudioPlayerStreamListeners {
/// The [Track] from Playlist.getTracks doesn't contain artist images
/// so we need to fetch them from the API
final activeTrack =
Track.fromJson(audioPlayerState.activeTrack!.toJson());
if (audioPlayerState.activeTrack!.artists
?.any((a) => a.images == null) ??
false) {
activeTrack.artists =
await ref.read(spotifyProvider).api.artists.list([
for (final artist in audioPlayerState.activeTrack!.artists!)
artist.id!,
]).then((value) => value.toList());
var activeTrack = audioPlayerState.activeTrack!;
if (activeTrack.artists.any((a) => a.images == null)) {
final metadataPlugin = await ref.read(metadataPluginProvider.future);
final artists = await Future.wait(
activeTrack.artists
.map((artist) => metadataPlugin!.artist.getArtist(artist.id)),
);
activeTrack = activeTrack.copyWith(
artists: artists
.map((e) => SpotubeSimpleArtistObject.fromJson(e.toJson()))
.toList(),
);
}
await history.addTrack(activeTrack);
@ -127,24 +130,26 @@ class AudioPlayerStreamListeners {
(event.inSeconds / max(audioPlayer.duration.inSeconds, 1)) * 100;
try {
if (percentProgress < 80 ||
audioPlayerState.playlist.index == -1 ||
audioPlayerState.playlist.index ==
audioPlayerState.currentIndex == -1 ||
audioPlayerState.currentIndex ==
audioPlayerState.tracks.length - 1) {
return;
}
final nextTrack = SpotubeMedia.fromMedia(
audioPlayerState.playlist.medias
.elementAt(audioPlayerState.playlist.index + 1),
);
final nextTrack = audioPlayerState.tracks
.elementAt(audioPlayerState.currentIndex + 1);
if (lastTrack == nextTrack.track.id || nextTrack.track is LocalTrack) {
if (lastTrack == nextTrack.id || nextTrack is SpotubeLocalTrackObject) {
return;
}
try {
await ref.read(sourcedTrackProvider(nextTrack).future);
await ref.read(
trackSourcesProvider(
TrackSourceQuery.fromTrack(nextTrack as SpotubeFullTrackObject),
).future,
);
} finally {
lastTrack = nextTrack.track.id!;
lastTrack = nextTrack.id;
}
} catch (e, stack) {
AppLogger.reportError(e, stack);

View File

@ -1,24 +1,20 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/server/sourced_track.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/provider/server/track_sources.dart';
final queryingTrackInfoProvider = Provider<bool>((ref) {
final media = audioPlayer.playlist.index == -1 ||
audioPlayer.playlist.medias.isEmpty
? null
: audioPlayer.playlist.medias.elementAtOrNull(audioPlayer.playlist.index);
final audioPlayerActiveTrack =
media == null ? null : SpotubeMedia.fromMedia(media);
final audioPlayer = ref.watch(audioPlayerProvider);
final activeMedia = ref.watch(audioPlayerProvider.select(
(s) => s.activeMedia == null
? null
: SpotubeMedia.fromMedia(s.activeMedia!),
)) ??
audioPlayerActiveTrack;
if (audioPlayer.activeTrack == null) {
return false;
}
if (activeMedia == null) return false;
return ref.watch(sourcedTrackProvider(activeMedia)).isLoading;
return ref
.watch(trackSourcesProvider(
TrackSourceQuery.fromTrack(
audioPlayer.activeTrack! as SpotubeFullTrackObject),
))
.isLoading;
});

View File

@ -1,104 +1,60 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:media_kit/media_kit.dart' hide Track;
import 'package:spotify/spotify.dart' hide Playlist;
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/models/metadata/metadata.dart';
class AudioPlayerState {
final bool playing;
final PlaylistMode loopMode;
final bool shuffled;
final Playlist playlist;
part 'state.freezed.dart';
part 'state.g.dart';
final List<Track> tracks;
final List<String> collections;
@freezed
class AudioPlayerState with _$AudioPlayerState {
const AudioPlayerState._();
AudioPlayerState({
required this.playing,
required this.loopMode,
required this.shuffled,
required this.playlist,
required this.collections,
List<Track>? tracks,
}) : tracks = tracks ??
playlist.medias
.map((media) => SpotubeMedia.fromMedia(media).track)
.toList();
factory AudioPlayerState._inner({
required bool playing,
required PlaylistMode loopMode,
required bool shuffled,
required List<String> collections,
@Default(0) int currentIndex,
@Default([]) List<SpotubeTrackObject> tracks,
}) = _AudioPlayerState;
factory AudioPlayerState.fromJson(Map<String, dynamic> json) {
return AudioPlayerState(
playing: json['playing'],
loopMode: PlaylistMode.values.firstWhere(
(e) => e.name == json['loopMode'],
orElse: () => audioPlayer.loopMode,
),
shuffled: json['shuffled'],
playlist: Playlist(
json['playlist']['medias']
.map(
(media) => SpotubeMedia.fromMedia(Media(
media['uri'],
extras: media['extras'],
httpHeaders: media['httpHeaders'],
)),
)
.cast<Media>()
.toList(),
index: json['playlist']['index'],
),
collections: List<String>.from(json['collections']),
);
}
Map<String, dynamic> toJson() {
return {
'playing': playing,
'loopMode': loopMode.name,
'shuffled': shuffled,
'playlist': {
'medias': playlist.medias
.map((media) => {
'uri': media.uri,
'extras': media.extras,
'httpHeaders': media.httpHeaders,
})
.toList(),
'index': playlist.index,
},
'collections': collections,
};
}
AudioPlayerState copyWith({
bool? playing,
PlaylistMode? loopMode,
bool? shuffled,
Playlist? playlist,
List<String>? collections,
factory AudioPlayerState({
required bool playing,
required PlaylistMode loopMode,
required bool shuffled,
required List<String> collections,
int currentIndex = 0,
List<SpotubeTrackObject> tracks = const [],
}) {
return AudioPlayerState(
playing: playing ?? this.playing,
loopMode: loopMode ?? this.loopMode,
shuffled: shuffled ?? this.shuffled,
playlist: playlist ?? this.playlist,
collections: collections ?? this.collections,
tracks: playlist == null ? tracks : null,
assert(
tracks.every((track) =>
track is SpotubeFullTrackObject || track is SpotubeLocalTrackObject),
'All tracks must be either SpotubeFullTrackObject or SpotubeLocalTrackObject',
);
return AudioPlayerState._inner(
playing: playing,
loopMode: loopMode,
shuffled: shuffled,
currentIndex: currentIndex,
tracks: tracks,
collections: collections,
);
}
Track? get activeTrack {
if (playlist.index == -1) return null;
return tracks.elementAtOrNull(playlist.index);
factory AudioPlayerState.fromJson(Map<String, dynamic> json) =>
_$AudioPlayerStateFromJson(json);
SpotubeTrackObject? get activeTrack {
if (currentIndex < 0 || currentIndex >= tracks.length) return null;
return tracks[currentIndex];
}
Media? get activeMedia {
if (playlist.index == -1 || playlist.medias.isEmpty) return null;
return playlist.medias.elementAt(playlist.index);
}
bool containsTrack(Track track) {
bool containsTrack(SpotubeTrackObject track) {
return tracks.any((t) => t.id == track.id);
}
bool containsTracks(List<Track> tracks) {
bool containsTracks(List<SpotubeTrackObject> tracks) {
return tracks.every(containsTrack);
}

View File

@ -0,0 +1,297 @@
// 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 'state.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');
AudioPlayerState _$AudioPlayerStateFromJson(Map<String, dynamic> json) {
return _AudioPlayerState.fromJson(json);
}
/// @nodoc
mixin _$AudioPlayerState {
bool get playing => throw _privateConstructorUsedError;
PlaylistMode get loopMode => throw _privateConstructorUsedError;
bool get shuffled => throw _privateConstructorUsedError;
List<String> get collections => throw _privateConstructorUsedError;
int get currentIndex => throw _privateConstructorUsedError;
List<SpotubeTrackObject> get tracks => throw _privateConstructorUsedError;
/// Serializes this AudioPlayerState to a JSON map.
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
/// Create a copy of AudioPlayerState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
$AudioPlayerStateCopyWith<AudioPlayerState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $AudioPlayerStateCopyWith<$Res> {
factory $AudioPlayerStateCopyWith(
AudioPlayerState value, $Res Function(AudioPlayerState) then) =
_$AudioPlayerStateCopyWithImpl<$Res, AudioPlayerState>;
@useResult
$Res call(
{bool playing,
PlaylistMode loopMode,
bool shuffled,
List<String> collections,
int currentIndex,
List<SpotubeTrackObject> tracks});
}
/// @nodoc
class _$AudioPlayerStateCopyWithImpl<$Res, $Val extends AudioPlayerState>
implements $AudioPlayerStateCopyWith<$Res> {
_$AudioPlayerStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
/// Create a copy of AudioPlayerState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? playing = null,
Object? loopMode = null,
Object? shuffled = null,
Object? collections = null,
Object? currentIndex = null,
Object? tracks = null,
}) {
return _then(_value.copyWith(
playing: null == playing
? _value.playing
: playing // ignore: cast_nullable_to_non_nullable
as bool,
loopMode: null == loopMode
? _value.loopMode
: loopMode // ignore: cast_nullable_to_non_nullable
as PlaylistMode,
shuffled: null == shuffled
? _value.shuffled
: shuffled // ignore: cast_nullable_to_non_nullable
as bool,
collections: null == collections
? _value.collections
: collections // ignore: cast_nullable_to_non_nullable
as List<String>,
currentIndex: null == currentIndex
? _value.currentIndex
: currentIndex // ignore: cast_nullable_to_non_nullable
as int,
tracks: null == tracks
? _value.tracks
: tracks // ignore: cast_nullable_to_non_nullable
as List<SpotubeTrackObject>,
) as $Val);
}
}
/// @nodoc
abstract class _$$AudioPlayerStateImplCopyWith<$Res>
implements $AudioPlayerStateCopyWith<$Res> {
factory _$$AudioPlayerStateImplCopyWith(_$AudioPlayerStateImpl value,
$Res Function(_$AudioPlayerStateImpl) then) =
__$$AudioPlayerStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{bool playing,
PlaylistMode loopMode,
bool shuffled,
List<String> collections,
int currentIndex,
List<SpotubeTrackObject> tracks});
}
/// @nodoc
class __$$AudioPlayerStateImplCopyWithImpl<$Res>
extends _$AudioPlayerStateCopyWithImpl<$Res, _$AudioPlayerStateImpl>
implements _$$AudioPlayerStateImplCopyWith<$Res> {
__$$AudioPlayerStateImplCopyWithImpl(_$AudioPlayerStateImpl _value,
$Res Function(_$AudioPlayerStateImpl) _then)
: super(_value, _then);
/// Create a copy of AudioPlayerState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline')
@override
$Res call({
Object? playing = null,
Object? loopMode = null,
Object? shuffled = null,
Object? collections = null,
Object? currentIndex = null,
Object? tracks = null,
}) {
return _then(_$AudioPlayerStateImpl(
playing: null == playing
? _value.playing
: playing // ignore: cast_nullable_to_non_nullable
as bool,
loopMode: null == loopMode
? _value.loopMode
: loopMode // ignore: cast_nullable_to_non_nullable
as PlaylistMode,
shuffled: null == shuffled
? _value.shuffled
: shuffled // ignore: cast_nullable_to_non_nullable
as bool,
collections: null == collections
? _value._collections
: collections // ignore: cast_nullable_to_non_nullable
as List<String>,
currentIndex: null == currentIndex
? _value.currentIndex
: currentIndex // ignore: cast_nullable_to_non_nullable
as int,
tracks: null == tracks
? _value._tracks
: tracks // ignore: cast_nullable_to_non_nullable
as List<SpotubeTrackObject>,
));
}
}
/// @nodoc
@JsonSerializable()
class _$AudioPlayerStateImpl extends _AudioPlayerState {
_$AudioPlayerStateImpl(
{required this.playing,
required this.loopMode,
required this.shuffled,
required final List<String> collections,
this.currentIndex = 0,
final List<SpotubeTrackObject> tracks = const []})
: _collections = collections,
_tracks = tracks,
super._();
factory _$AudioPlayerStateImpl.fromJson(Map<String, dynamic> json) =>
_$$AudioPlayerStateImplFromJson(json);
@override
final bool playing;
@override
final PlaylistMode loopMode;
@override
final bool shuffled;
final List<String> _collections;
@override
List<String> get collections {
if (_collections is EqualUnmodifiableListView) return _collections;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_collections);
}
@override
@JsonKey()
final int currentIndex;
final List<SpotubeTrackObject> _tracks;
@override
@JsonKey()
List<SpotubeTrackObject> get tracks {
if (_tracks is EqualUnmodifiableListView) return _tracks;
// ignore: implicit_dynamic_type
return EqualUnmodifiableListView(_tracks);
}
@override
String toString() {
return 'AudioPlayerState._inner(playing: $playing, loopMode: $loopMode, shuffled: $shuffled, collections: $collections, currentIndex: $currentIndex, tracks: $tracks)';
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$AudioPlayerStateImpl &&
(identical(other.playing, playing) || other.playing == playing) &&
(identical(other.loopMode, loopMode) ||
other.loopMode == loopMode) &&
(identical(other.shuffled, shuffled) ||
other.shuffled == shuffled) &&
const DeepCollectionEquality()
.equals(other._collections, _collections) &&
(identical(other.currentIndex, currentIndex) ||
other.currentIndex == currentIndex) &&
const DeepCollectionEquality().equals(other._tracks, _tracks));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(
runtimeType,
playing,
loopMode,
shuffled,
const DeepCollectionEquality().hash(_collections),
currentIndex,
const DeepCollectionEquality().hash(_tracks));
/// Create a copy of AudioPlayerState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@override
@pragma('vm:prefer-inline')
_$$AudioPlayerStateImplCopyWith<_$AudioPlayerStateImpl> get copyWith =>
__$$AudioPlayerStateImplCopyWithImpl<_$AudioPlayerStateImpl>(
this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$AudioPlayerStateImplToJson(
this,
);
}
}
abstract class _AudioPlayerState extends AudioPlayerState {
factory _AudioPlayerState(
{required final bool playing,
required final PlaylistMode loopMode,
required final bool shuffled,
required final List<String> collections,
final int currentIndex,
final List<SpotubeTrackObject> tracks}) = _$AudioPlayerStateImpl;
_AudioPlayerState._() : super._();
factory _AudioPlayerState.fromJson(Map<String, dynamic> json) =
_$AudioPlayerStateImpl.fromJson;
@override
bool get playing;
@override
PlaylistMode get loopMode;
@override
bool get shuffled;
@override
List<String> get collections;
@override
int get currentIndex;
@override
List<SpotubeTrackObject> get tracks;
/// Create a copy of AudioPlayerState
/// with the given fields replaced by the non-null parameter values.
@override
@JsonKey(includeFromJson: false, includeToJson: false)
_$$AudioPlayerStateImplCopyWith<_$AudioPlayerStateImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,40 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'state.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$AudioPlayerStateImpl _$$AudioPlayerStateImplFromJson(Map json) =>
_$AudioPlayerStateImpl(
playing: json['playing'] as bool,
loopMode: $enumDecode(_$PlaylistModeEnumMap, json['loopMode']),
shuffled: json['shuffled'] as bool,
collections: (json['collections'] as List<dynamic>)
.map((e) => e as String)
.toList(),
currentIndex: (json['currentIndex'] as num?)?.toInt() ?? 0,
tracks: (json['tracks'] as List<dynamic>?)
?.map((e) => SpotubeTrackObject.fromJson(
Map<String, dynamic>.from(e as Map)))
.toList() ??
const [],
);
Map<String, dynamic> _$$AudioPlayerStateImplToJson(
_$AudioPlayerStateImpl instance) =>
<String, dynamic>{
'playing': instance.playing,
'loopMode': _$PlaylistModeEnumMap[instance.loopMode]!,
'shuffled': instance.shuffled,
'collections': instance.collections,
'currentIndex': instance.currentIndex,
'tracks': instance.tracks.map((e) => e.toJson()).toList(),
};
const _$PlaylistModeEnumMap = {
PlaylistMode.none: 'none',
PlaylistMode.single: 'single',
PlaylistMode.loop: 'loop',
};

View File

@ -1,8 +1,8 @@
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/current_playlist.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/database/database.dart';
class BlackListNotifier extends AsyncNotifier<List<BlacklistTableData>> {
@ -34,17 +34,15 @@ class BlackListNotifier extends AsyncNotifier<List<BlacklistTableData>> {
.go();
}
bool contains(TrackSimple track) {
bool contains(SpotubeTrackObject track) {
final containsTrack =
state.asData?.value.any((element) => element.elementId == track.id) ??
false;
final containsTrackArtists = track.artists?.any(
final containsTrackArtists = track.artists.any(
(artist) =>
state.asData?.value.any((el) => el.elementId == artist.id) ??
false,
) ??
false;
state.asData?.value.any((el) => el.elementId == artist.id) ?? false,
);
return containsTrack || containsTrackArtists;
}
@ -56,18 +54,9 @@ class BlackListNotifier extends AsyncNotifier<List<BlacklistTableData>> {
}
/// Filters the non blacklisted tracks from the given [tracks]
Iterable<TrackSimple> filter(Iterable<TrackSimple> tracks) {
Iterable<SpotubeTrackObject> filter(Iterable<SpotubeTrackObject> tracks) {
return tracks.whereNot(contains).toList();
}
CurrentPlaylist filterPlaylist(CurrentPlaylist playlist) {
return CurrentPlaylist(
id: playlist.id,
name: playlist.name,
thumbnail: playlist.thumbnail,
tracks: playlist.tracks.where((track) => !contains(track)).toList(),
);
}
}
final blacklistProvider =

View File

@ -5,11 +5,11 @@ import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/audio_player/state.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/logger/logger.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart' hide Playlist;
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/connect/clients.dart';
@ -41,7 +41,8 @@ final queueProvider = StateProvider<AudioPlayerState>(
playing: audioPlayer.isPlaying,
loopMode: audioPlayer.loopMode,
shuffled: audioPlayer.isShuffled,
playlist: audioPlayer.playlist,
tracks: [],
currentIndex: 0,
collections: [],
),
);
@ -207,7 +208,7 @@ class ConnectNotifier extends AsyncNotifier<ConnectState?> {
emit(WebSocketLoopEvent(value));
}
Future<void> addTrack(Track data) async {
Future<void> addTrack(SpotubeFullTrackObject data) async {
emit(WebSocketAddTrackEvent(data));
}

View File

@ -2,8 +2,7 @@ import 'dart:async';
import 'package:flutter_discord_rpc/flutter_discord_rpc.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
@ -74,20 +73,20 @@ class DiscordNotifier extends AsyncNotifier<void> {
}
}
Future<void> updatePresence(Track track) async {
Future<void> updatePresence(SpotubeTrackObject track) async {
if (!kIsDesktop) return;
if (FlutterDiscordRPC.instance.isConnected == false) return;
final artistNames = track.artists?.asString();
final artistNames = track.artists.asString();
final isPlaying = audioPlayer.isPlaying;
final position = audioPlayer.position;
await FlutterDiscordRPC.instance.setActivity(
activity: RPCActivity(
details: track.name,
state: artistNames != null ? "by $artistNames" : null,
state: artistNames,
assets: RPCAssets(
largeImage:
track.album?.images?.first.url ?? "spotube-logo-foreground",
track.album?.images.first.url ?? "spotube-logo-foreground",
largeText: track.album?.name ?? "Unknown album",
smallImage: "spotube-logo-foreground",
smallText: "Spotube",
@ -95,8 +94,7 @@ class DiscordNotifier extends AsyncNotifier<void> {
buttons: [
RPCButton(
label: "Listen on Spotify",
url: track.externalUrls?.spotify ??
"https://open.spotify.com/tracks/${track.id}",
url: track.externalUri,
),
],
timestamps: RPCTimestamps(

View File

@ -1,16 +1,15 @@
import 'dart:async';
import 'dart:io';
import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/server/track_sources.dart';
import 'package:spotube/services/logger/logger.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:metadata_god/metadata_god.dart';
import 'package:path/path.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/download_manager/download_manager.dart';
import 'package:spotube/services/sourced_track/enums.dart';
@ -21,18 +20,22 @@ import 'package:spotube/utils/service_utils.dart';
class DownloadManagerProvider extends ChangeNotifier {
DownloadManagerProvider({required this.ref})
: $history = <SourcedTrack>{},
$backHistory = <Track>{},
$backHistory = <SpotubeFullTrackObject>{},
dl = DownloadManager() {
dl.statusStream.listen((event) async {
try {
final (:request, :status) = event;
final track = $history.firstWhereOrNull(
final sourcedTrack = $history.firstWhereOrNull(
(element) => element.getUrlOfCodec(downloadCodec) == request.url,
);
if (sourcedTrack == null) return;
final track = $backHistory.firstWhereOrNull(
(element) => element.id == sourcedTrack.query.id,
);
if (track == null) return;
final savePath = getTrackFileUrl(track);
final savePath = getTrackFileUrl(sourcedTrack);
// related to onFileExists
final oldFile = File("$savePath.old");
@ -57,7 +60,7 @@ class DownloadManagerProvider extends ChangeNotifier {
}
final imageBytes = await ServiceUtils.downloadImage(
(track.album?.images).asUrlString(
(track.album.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,
index: 1,
),
@ -78,7 +81,8 @@ class DownloadManagerProvider extends ChangeNotifier {
});
}
Future<bool> Function(Track track) onFileExists = (Track track) async => true;
Future<bool> Function(SpotubeFullTrackObject track) onFileExists =
(SpotubeFullTrackObject track) async => true;
final Ref<DownloadManagerProvider> ref;
@ -99,21 +103,19 @@ class DownloadManagerProvider extends ChangeNotifier {
final Set<SourcedTrack> $history;
// these are the tracks which metadata hasn't been fetched yet
final Set<Track> $backHistory;
final Set<SpotubeFullTrackObject> $backHistory;
final DownloadManager dl;
String getTrackFileUrl(Track track) {
String getTrackFileUrl(SourcedTrack track) {
final name =
"${track.name} - ${track.artists?.asString() ?? ""}.${downloadCodec.name}";
"${track.query.title} - ${track.query.artists.join(", ")}.${downloadCodec.name}";
return join(downloadDirectory, PrimitiveUtils.toSafeFileName(name));
}
bool isActive(Track track) {
Future<bool> isActive(SpotubeFullTrackObject track) async {
if ($backHistory.contains(track)) return true;
final sourcedTrack = mapToSourcedTrack(track);
if (sourcedTrack == null) return false;
final sourcedTrack = await mapToSourcedTrack(track);
return dl
.getAllDownloads()
@ -128,8 +130,12 @@ class DownloadManagerProvider extends ChangeNotifier {
}
/// For singular downloads
Future<void> addToQueue(Track track) async {
final savePath = getTrackFileUrl(track);
Future<void> addToQueue(SpotubeFullTrackObject track) async {
final sourcedTrack = await ref.read(
trackSourcesProvider(TrackSourceQuery.fromTrack(track)).future,
);
final savePath = getTrackFileUrl(sourcedTrack);
final oldFile = File(savePath);
if (await oldFile.exists() && !await onFileExists(track)) {
@ -140,18 +146,21 @@ class DownloadManagerProvider extends ChangeNotifier {
await oldFile.rename("$savePath.old");
}
if (track is SourcedTrack && track.codec == downloadCodec) {
final downloadTask =
await dl.addDownload(track.getUrlOfCodec(downloadCodec), savePath);
if (sourcedTrack.codec == downloadCodec) {
final downloadTask = await dl.addDownload(
sourcedTrack.getUrlOfCodec(downloadCodec), savePath);
if (downloadTask != null) {
$history.add(track);
$history.add(sourcedTrack);
}
} else {
$backHistory.add(track);
final sourcedTrack = await SourcedTrack.fetchFromTrack(
ref: ref,
track: track,
).then((d) {
final sourcedTrack = await ref
.read(
trackSourcesProvider(
TrackSourceQuery.fromTrack(track),
).future,
)
.then((d) {
$backHistory.remove(track);
return d;
});
@ -167,10 +176,8 @@ class DownloadManagerProvider extends ChangeNotifier {
notifyListeners();
}
Future<void> batchAddToQueue(List<Track> tracks) async {
$backHistory.addAll(
tracks.where((element) => element is! SourcedTrack),
);
Future<void> batchAddToQueue(List<SpotubeFullTrackObject> tracks) async {
$backHistory.addAll(tracks);
notifyListeners();
for (final track in tracks) {
try {
@ -194,20 +201,23 @@ class DownloadManagerProvider extends ChangeNotifier {
$history.remove(track);
}
Future<void> pause(SourcedTrack track) {
return dl.pauseDownload(track.getUrlOfCodec(downloadCodec));
Future<void> pause(SpotubeFullTrackObject track) async {
final sourcedTrack = await mapToSourcedTrack(track);
return dl.pauseDownload(sourcedTrack.getUrlOfCodec(downloadCodec));
}
Future<void> resume(SourcedTrack track) {
return dl.resumeDownload(track.getUrlOfCodec(downloadCodec));
Future<void> resume(SpotubeFullTrackObject track) async {
final sourcedTrack = await mapToSourcedTrack(track);
return dl.resumeDownload(sourcedTrack.getUrlOfCodec(downloadCodec));
}
Future<void> retry(SourcedTrack track) {
Future<void> retry(SpotubeFullTrackObject track) {
return addToQueue(track);
}
void cancel(SourcedTrack track) {
dl.cancelDownload(track.getUrlOfCodec(downloadCodec));
void cancel(SpotubeFullTrackObject track) async {
final sourcedTrack = await mapToSourcedTrack(track);
return dl.cancelDownload(sourcedTrack.getUrlOfCodec(downloadCodec));
}
void cancelAll() {
@ -217,12 +227,19 @@ class DownloadManagerProvider extends ChangeNotifier {
}
}
SourcedTrack? mapToSourcedTrack(Track track) {
if (track is SourcedTrack) {
return track;
} else {
return $history.firstWhereOrNull((element) => element.id == track.id);
Future<SourcedTrack> mapToSourcedTrack(SpotubeFullTrackObject track) async {
final historicTrack =
$history.firstWhereOrNull((element) => element.query.id == track.id);
if (historicTrack != null) {
return historicTrack;
}
final sourcedTrack = await ref.read(
trackSourcesProvider(TrackSourceQuery.fromTrack(track)).future,
);
return sourcedTrack;
}
ValueNotifier<DownloadStatus>? getStatusNotifier(SourcedTrack track) {

View File

@ -5,7 +5,7 @@ import 'package:home_widget/home_widget.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
import 'package:logger/logger.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/server/server.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
@ -71,7 +71,7 @@ Future<void> _updateWidget() async {
}
}
Future<void> _sendActiveTrack(Track? track) async {
Future<void> _sendActiveTrack(SpotubeTrackObject? track) async {
if (track == null) {
await _saveWidgetData("activeTrack", null);
await _updateWidget();
@ -80,8 +80,8 @@ Future<void> _sendActiveTrack(Track? track) async {
final jsonTrack = track.toJson();
final image = track.album?.images?.first;
final cachedImage = await DefaultCacheManager().getSingleFile(image!.url!);
final image = track.album?.images.first;
final cachedImage = await DefaultCacheManager().getSingleFile(image!.url);
final data = {
...jsonTrack,
"album": {

View File

@ -1,6 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/database/database.dart';
class PlaybackHistoryActions {
@ -16,31 +16,31 @@ class PlaybackHistoryActions {
});
}
Future<void> addPlaylists(List<PlaylistSimple> playlists) async {
Future<void> addPlaylists(List<SpotubeSimplePlaylistObject> playlists) async {
await _batchInsertHistoryEntries([
for (final playlist in playlists)
HistoryTableCompanion.insert(
type: HistoryEntryType.playlist,
itemId: playlist.id!,
itemId: playlist.id,
data: playlist.toJson(),
),
]);
}
Future<void> addAlbums(List<AlbumSimple> albums) async {
Future<void> addAlbums(List<SpotubeSimpleAlbumObject> albums) async {
await _batchInsertHistoryEntries([
for (final albums in albums)
HistoryTableCompanion.insert(
type: HistoryEntryType.album,
itemId: albums.id!,
itemId: albums.id,
data: albums.toJson(),
),
]);
}
Future<void> addTracks(List<Track> tracks) async {
Future<void> addTracks(List<SpotubeTrackObject> tracks) async {
assert(
tracks.every((t) => t.artists?.every((a) => a.images != null) ?? false),
tracks.every((t) => t.artists.every((a) => a.images != null)),
'Track artists must have images',
);
@ -48,22 +48,22 @@ class PlaybackHistoryActions {
for (final track in tracks)
HistoryTableCompanion.insert(
type: HistoryEntryType.track,
itemId: track.id!,
itemId: track.id,
data: track.toJson(),
),
]);
}
Future<void> addTrack(Track track) async {
Future<void> addTrack(SpotubeTrackObject track) async {
assert(
track.artists?.every((a) => a.images != null) ?? false,
track.artists.every((a) => a.images != null),
'Track artists must have images',
);
await _db.into(_db.historyTable).insert(
HistoryTableCompanion.insert(
type: HistoryEntryType.track,
itemId: track.id!,
itemId: track.id,
data: track.toJson(),
),
);

View File

@ -3,16 +3,15 @@ import 'dart:async';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:scrobblenaut/scrobblenaut.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/env.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/services/logger/logger.dart';
class ScrobblerNotifier extends AsyncNotifier<Scrobblenaut?> {
final StreamController<Track> _scrobbleController =
StreamController<Track>.broadcast();
final StreamController<SpotubeTrackObject> _scrobbleController =
StreamController<SpotubeTrackObject>.broadcast();
@override
build() async {
final database = ref.watch(databaseProvider);
@ -47,13 +46,12 @@ class ScrobblerNotifier extends AsyncNotifier<Scrobblenaut?> {
_scrobbleController.stream.listen((track) async {
try {
await state.asData?.value?.track.scrobble(
artist: track.artists!.first.name!,
track: track.name!,
album: track.album!.name!,
artist: track.artists.first.name,
track: track.name,
album: track.album!.name,
chosenByUser: true,
duration: track.duration,
duration: Duration(milliseconds: track.durationMs),
timestamp: DateTime.now().toUtc(),
trackNumber: track.trackNumber,
);
} catch (e, stackTrace) {
AppLogger.reportError(e, stackTrace);
@ -109,21 +107,21 @@ class ScrobblerNotifier extends AsyncNotifier<Scrobblenaut?> {
await database.delete(database.scrobblerTable).go();
}
void scrobble(Track track) {
void scrobble(SpotubeTrackObject track) {
_scrobbleController.add(track);
}
Future<void> love(Track track) async {
Future<void> love(SpotubeTrackObject track) async {
await state.asData?.value?.track.love(
artist: track.artists!.asString(),
track: track.name!,
artist: track.artists.asString(),
track: track.name,
);
}
Future<void> unlove(Track track) async {
Future<void> unlove(SpotubeTrackObject track) async {
await state.asData?.value?.track.unLove(
artist: track.artists!.asString(),
track: track.name!,
artist: track.artists.asString(),
track: track.name,
);
}
}

View File

@ -1,47 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/sourced_track/models/source_info.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
class ActiveSourcedTrackNotifier extends Notifier<SourcedTrack?> {
@override
build() {
return null;
}
void update(SourcedTrack? sourcedTrack) {
state = sourcedTrack;
}
Future<void> populateSibling() async {
if (state == null) return;
state = await state!.copyWithSibling();
}
Future<void> swapSibling(SourceInfo sibling) async {
if (state == null) return;
await populateSibling();
final newTrack = await state!.swapWithSibling(sibling);
if (newTrack == null) return;
state = newTrack;
await audioPlayer.pause();
final playbackNotifier = ref.read(audioPlayerProvider.notifier);
final oldActiveIndex = audioPlayer.currentIndex;
await playbackNotifier.addTracksAtFirst([newTrack], allowDuplicates: true);
await Future.delayed(const Duration(milliseconds: 50));
await playbackNotifier.jumpToTrack(newTrack);
await audioPlayer.removeTrack(oldActiveIndex);
await audioPlayer.resume();
}
}
final activeSourcedTrackProvider =
NotifierProvider<ActiveSourcedTrackNotifier, SourcedTrack?>(
() => ActiveSourcedTrackNotifier(),
);

View File

@ -0,0 +1,42 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/server/track_sources.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
final activeTrackSourcesProvider = FutureProvider<
({
SourcedTrack? source,
TrackSourcesNotifier? notifier,
SpotubeTrackObject track,
})?>((ref) async {
final audioPlayerState = ref.watch(audioPlayerProvider);
if (audioPlayerState.activeTrack == null) {
return null;
}
if (audioPlayerState.activeTrack is SpotubeLocalTrackObject) {
return (
source: null,
notifier: null,
track: audioPlayerState.activeTrack!,
);
}
final trackQuery = TrackSourceQuery.fromTrack(
audioPlayerState.activeTrack! as SpotubeFullTrackObject,
);
final sourcedTrack = await ref.watch(trackSourcesProvider(trackQuery).future);
final sourcedTrackNotifier = ref.watch(
trackSourcesProvider(trackQuery).notifier,
);
return (
source: sourcedTrack,
track: audioPlayerState.activeTrack!,
notifier: sourcedTrackNotifier,
);
});

View File

@ -6,10 +6,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shelf/shelf.dart';
import 'package:shelf_web_socket/shelf_web_socket.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
@ -161,19 +161,19 @@ class ServerConnectRoutes {
event.onLoad((event) async {
await audioPlayerNotifier.load(
event.data.tracks,
event.data.tracks as List<SpotubeFullTrackObject>,
autoPlay: true,
initialIndex: event.data.initialIndex ?? 0,
);
if (event.data.collectionId == null) return;
audioPlayerNotifier.addCollection(event.data.collectionId!);
if (event.data.collection is AlbumSimple) {
historyNotifier
.addAlbums([event.data.collection as AlbumSimple]);
if (event.data.collection is SpotubeSimpleAlbumObject) {
historyNotifier.addAlbums(
[event.data.collection as SpotubeSimpleAlbumObject]);
} else {
historyNotifier.addPlaylists(
[event.data.collection as PlaylistSimple]);
[event.data.collection as SpotubeSimplePlaylistObject]);
}
});

View File

@ -1,6 +1,7 @@
import 'dart:io';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:dio/dio.dart' hide Response;
import 'package:dio/dio.dart' as dio_lib;
import 'package:flutter/foundation.dart';
@ -8,15 +9,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:metadata_god/metadata_god.dart';
import 'package:path/path.dart';
import 'package:shelf/shelf.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/extensions/track.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/parser/range_headers.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/audio_player/state.dart';
import 'package:spotube/provider/server/active_sourced_track.dart';
import 'package:spotube/provider/server/sourced_track.dart';
import 'package:spotube/provider/server/active_track_sources.dart';
import 'package:spotube/provider/server/track_sources.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/logger/logger.dart';
@ -55,7 +55,7 @@ class ServerPlaybackRoutes {
join(
await UserPreferencesNotifier.getMusicCacheDir(),
ServiceUtils.sanitizeFilename(
'${track.name} - ${track.artists?.asString()} (${track.sourceInfo.id}).${track.codec.name}',
'${track.query.title} - ${track.query.artists.join(",")} (${track.info.id}).${track.codec.name}',
),
),
);
@ -127,16 +127,16 @@ class ServerPlaybackRoutes {
.catchError((e, stack) async {
AppLogger.reportError(e, stack);
final sourcedTrack = await ref
.read(sourcedTrackProvider(SpotubeMedia(track)).notifier)
.read(trackSourcesProvider(track.query).notifier)
.refreshStreamingUrl();
if (playlist.activeTrack?.id == sourcedTrack?.id &&
sourcedTrack != null) {
ref.read(activeSourcedTrackProvider.notifier).update(sourcedTrack);
}
// It gets updated by itself.
// if (playlist.activeTrack?.id == sourcedTrack.query.id) {
// ref.read(activeTrackSourcesProvider.notifier).update(sourcedTrack);
// }
return await dio.get<Uint8List>(
sourcedTrack!.url,
sourcedTrack.url,
options: options.copyWith(headers: {
...?options.headers,
"user-agent": _randomUserAgent,
@ -174,8 +174,18 @@ class ServerPlaybackRoutes {
}
if (contentRange.total == fileLength && track.codec != SourceCodecs.weba) {
final playlistTrack = playlist.tracks.firstWhereOrNull(
(element) => element.id == track.query.id,
);
if (playlistTrack == null) {
AppLogger.log.e(
"Track ${track.query.id} not found in playlist, cannot write metadata.",
);
return (response: res, bytes: bytes);
}
final imageBytes = await ServiceUtils.downloadImage(
(track.album?.images).asUrlString(
(playlistTrack.album?.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,
index: 1,
),
@ -183,9 +193,9 @@ class ServerPlaybackRoutes {
await MetadataGod.writeMetadata(
file: trackCacheFile.path,
metadata: track.toMetadata(
fileLength: fileLength,
metadata: (playlistTrack as SpotubeFullTrackObject).toMetadata(
imageBytes: imageBytes,
fileLength: fileLength,
),
);
}
@ -199,15 +209,21 @@ class ServerPlaybackRoutes {
final track =
playlist.tracks.firstWhere((element) => element.id == trackId);
final activeSourcedTrack = ref.read(activeSourcedTrackProvider);
final sourcedTrack = activeSourcedTrack?.id == track.id
? activeSourcedTrack
: await ref.read(sourcedTrackProvider(SpotubeMedia(track)).future);
final activeSourcedTrack =
await ref.read(activeTrackSourcesProvider.future);
final sourcedTrack = activeSourcedTrack?.track.id == track.id
? activeSourcedTrack?.source
: await ref.read(
trackSourcesProvider(
TrackSourceQuery.parseUri(request.url.toString()),
).future,
);
if (playlist.activeTrack?.id == sourcedTrack?.id &&
sourcedTrack != null) {
ref.read(activeSourcedTrackProvider.notifier).update(sourcedTrack);
}
// This will be automatically updated by the notifier.
// if (playlist.activeTrack?.id == sourcedTrack?.query.id &&
// sourcedTrack != null) {
// ref.read(activeTrackSourcesProvider.notifier).update(sourcedTrack);
// }
final (bytes: audioBytes, response: res) =
await streamTrack(sourcedTrack!, request.headers);

View File

@ -1,49 +0,0 @@
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
class SourcedTrackNotifier
extends FamilyAsyncNotifier<SourcedTrack?, SpotubeMedia?> {
@override
build(media) async {
final track = media?.track;
if (track == null || track is LocalTrack) {
return null;
}
ref.listen(
audioPlayerProvider.select((value) => value.tracks),
(old, next) {
if (next.isEmpty || next.none((element) => element.id == track.id)) {
ref.invalidateSelf();
}
},
);
final sourcedTrack =
await SourcedTrack.fetchFromTrack(track: track, ref: ref);
return sourcedTrack;
}
Future<SourcedTrack?> refreshStreamingUrl() async {
if (arg == null) {
return null;
}
return await update((prev) async {
return await SourcedTrack.fetchFromTrack(
track: state.value!,
ref: ref,
);
});
}
}
final sourcedTrackProvider = AsyncNotifierProviderFamily<SourcedTrackNotifier,
SourcedTrack?, SpotubeMedia?>(
() => SourcedTrackNotifier(),
);

View File

@ -0,0 +1,45 @@
import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
class TrackSourcesNotifier
extends FamilyAsyncNotifier<SourcedTrack, TrackSourceQuery> {
@override
FutureOr<SourcedTrack> build(query) {
ref.watch(userPreferencesProvider.select((p) => p.audioQuality));
ref.watch(userPreferencesProvider.select((p) => p.audioSource));
ref.watch(userPreferencesProvider.select((p) => p.streamMusicCodec));
ref.watch(userPreferencesProvider.select((p) => p.downloadMusicCodec));
return SourcedTrack.fetchFromQuery(query: query, ref: ref);
}
Future<SourcedTrack> refreshStreamingUrl() async {
return await update((prev) async {
return await prev.refreshStream();
});
}
Future<SourcedTrack> copyWithSibling(
TrackSourceInfo info,
TrackSourceQuery query,
) async {
return await update((prev) async {
return prev.copyWithSibling();
});
}
Future<SourcedTrack> swapWithSibling(TrackSourceInfo sibling) async {
return await update((prev) async {
return await prev.swapWithSibling(sibling) ?? prev;
});
}
}
final trackSourcesProvider = AsyncNotifierProviderFamily<TrackSourcesNotifier,
SourcedTrack, TrackSourceQuery>(
() => TrackSourcesNotifier(),
);

View File

@ -1,9 +1,10 @@
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/services/logger/logger.dart';
import 'package:dio/dio.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/provider/server/active_sourced_track.dart';
import 'package:spotube/provider/server/active_track_sources.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/dio/dio.dart';
@ -81,8 +82,11 @@ Future<List<SkipSegmentTableData>> getAndCacheSkipSegments(
final segmentProvider = FutureProvider<SourcedSegments?>(
(ref) async {
final track = ref.watch(activeSourcedTrackProvider);
if (track == null) return null;
final snapshot = await ref.watch(activeTrackSourcesProvider.future);
if (snapshot == null) return null;
final (:track, :source, :notifier) = snapshot;
if (track is SpotubeLocalTrackObject) return null;
if (source!.source case AudioSource.jiosaavn) return null;
final skipNonMusic = ref.watch(
userPreferencesProvider.select(
@ -96,16 +100,13 @@ final segmentProvider = FutureProvider<SourcedSegments?>(
);
if (!skipNonMusic) {
return SourcedSegments(
segments: [],
source: track.sourceInfo.id,
);
return SourcedSegments(segments: [], source: source.info.id);
}
final segments = await getAndCacheSkipSegments(track.sourceInfo.id, ref);
final segments = await getAndCacheSkipSegments(source.info.id, ref);
return SourcedSegments(
source: track.sourceInfo.id,
source: source.info.id,
segments: segments,
);
},

View File

@ -1,9 +1,10 @@
import 'dart:io';
import 'package:media_kit/media_kit.dart' hide Track;
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/services/logger/logger.dart';
import 'package:flutter/foundation.dart';
import 'package:spotify/spotify.dart' hide Playlist;
import 'package:spotube/models/local_track.dart';
import 'package:spotube/services/audio_player/custom_player.dart';
import 'dart:async';
@ -11,33 +12,41 @@ import 'dart:async';
import 'package:media_kit/media_kit.dart' as mk;
import 'package:spotube/services/audio_player/playback_state.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/platform.dart';
part 'audio_players_streams_mixin.dart';
part 'audio_player_impl.dart';
class SpotubeMedia extends mk.Media {
final Track track;
static int serverPort = 0;
final SpotubeTrackObject track;
static String get _host =>
kIsWindows ? "localhost" : InternetAddress.anyIPv4.address;
static String _queries(SpotubeFullTrackObject track) {
final params = TrackSourceQuery.fromTrack(track).toJson();
return params.entries
.map((e) =>
"${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}")
.join("&");
}
SpotubeMedia(
this.track, {
Map<String, dynamic>? extras,
super.httpHeaders,
}) : super(
track is LocalTrack
}) : assert(
track is SpotubeLocalTrackObject || track is SpotubeFullTrackObject,
"Track must be a either a local track or a full track object with ISRC",
),
// If the track is a local track, use its path, otherwise use the server URL
super(
track is SpotubeLocalTrackObject
? track.path
: "http://${kIsWindows ? "localhost" : InternetAddress.anyIPv4.address}:$serverPort/stream/${track.id}",
extras: {
...?extras,
"track": switch (track) {
LocalTrack() => track.toJson(),
SourcedTrack() => track.toJson(),
_ => track.toJson(),
},
},
: "http://$_host:$serverPort/stream/${track.id}?${_queries(track as SpotubeFullTrackObject)}",
);
@override
@ -46,23 +55,11 @@ class SpotubeMedia extends mk.Media {
/// [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://${kIsWindows ? "localhost" : InternetAddress.anyIPv4.address}:"
_ => "http://$_host:"
"$serverPort/stream/${track.id}",
};
}
factory SpotubeMedia.fromMedia(mk.Media media) {
final track = media.uri.startsWith("http")
? Track.fromJson(media.extras?["track"])
: LocalTrack.fromJson(media.extras?["track"]);
return SpotubeMedia(
track,
extras: media.extras,
httpHeaders: media.httpHeaders,
);
}
// @override
// operator ==(Object other) {
// if (other is! SpotubeMedia) return false;

View File

@ -1,10 +1,8 @@
import 'package:audio_service/audio_service.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/env.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/audio_services/mobile_audio_service.dart';
@ -49,16 +47,14 @@ class AudioServices with WidgetsBindingObserver {
return AudioServices(mobile, smtc);
}
Future<void> addTrack(Track track) async {
Future<void> addTrack(SpotubeTrackObject track) async {
await smtc?.addTrack(track);
mobile?.addItem(MediaItem(
id: track.id!,
id: track.id,
album: track.album?.name ?? "",
title: track.name!,
artist: (track.artists)?.asString() ?? "",
duration: track is SourcedTrack
? track.sourceInfo.duration
: Duration(milliseconds: track.durationMs ?? 0),
title: track.name,
artist: track.artists.asString(),
duration: Duration(milliseconds: track.durationMs),
artUri: Uri.parse(
(track.album?.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,

View File

@ -2,9 +2,7 @@ import 'dart:async';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:smtc_windows/smtc_windows.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/audio_player/playback_state.dart';
@ -77,15 +75,15 @@ class WindowsAudioService {
]);
}
Future<void> addTrack(Track track) async {
Future<void> addTrack(SpotubeTrackObject track) async {
if (!smtc.enabled) {
await smtc.enableSmtc();
}
await smtc.updateMetadata(
MusicMetadata(
title: track.name!,
albumArtist: track.artists?.firstOrNull?.name ?? "Unknown",
artist: track.artists?.asString() ?? "Unknown",
title: track.name,
albumArtist: track.artists.firstOrNull?.name ?? "Unknown",
artist: track.artists.asString(),
album: track.album?.name ?? "Unknown",
thumbnail: (track.album?.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,

View File

@ -1,5 +1,4 @@
import 'package:spotube/services/sourced_track/models/source_info.dart';
import 'package:spotube/services/sourced_track/models/source_map.dart';
import 'package:spotube/models/playback/track_sources.dart';
enum SourceCodecs {
m4a._("M4a (Best for downloaded music)"),
@ -15,4 +14,7 @@ enum SourceQualities {
low,
}
typedef SiblingType<T extends SourceInfo> = ({T info, SourceMap? source});
typedef SiblingType<T extends TrackSourceInfo> = ({
T info,
List<TrackSource>? source
});

View File

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

View File

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

View File

@ -1,30 +0,0 @@
// 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 num).toInt()),
artistUrl: json['artistUrl'] as String,
album: json['album'] as String?,
);
Map<String, dynamic> _$SourceInfoToJson(SourceInfo instance) =>
<String, dynamic>{
'id': instance.id,
'title': instance.title,
'artist': instance.artist,
'artistUrl': instance.artistUrl,
'album': instance.album,
'thumbnail': instance.thumbnail,
'pageUrl': instance.pageUrl,
'duration': instance.duration.inMicroseconds,
};

View File

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

View File

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

View File

@ -1,47 +1,27 @@
import 'package:collection/collection.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/sourced_track/models/source_info.dart';
import 'package:spotube/services/sourced_track/models/source_map.dart';
import 'package:spotube/services/sourced_track/sources/invidious.dart';
import 'package:spotube/services/sourced_track/sources/jiosaavn.dart';
import 'package:spotube/services/sourced_track/sources/piped.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart';
import 'package:spotube/utils/service_utils.dart';
abstract class SourcedTrack extends Track {
final SourceMap source;
final List<SourceInfo> siblings;
final SourceInfo sourceInfo;
abstract class SourcedTrack extends BasicSourcedTrack {
final Ref ref;
SourcedTrack({
required this.ref,
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;
}
required super.info,
required super.query,
required super.source,
required super.siblings,
required super.sources,
});
static SourcedTrack fromJson(
Map<String, dynamic> json, {
@ -49,110 +29,116 @@ abstract class SourcedTrack extends Track {
}) {
final preferences = ref.read(userPreferencesProvider);
final sourceInfo = SourceInfo.fromJson(json);
final source = SourceMap.fromJson(json);
final track = Track.fromJson(json);
final info = TrackSourceInfo.fromJson(json["info"]);
final query = TrackSourceQuery.fromJson(json["query"]);
final source = AudioSource.values.firstWhereOrNull(
(source) => source.name == json["source"],
) ??
preferences.audioSource;
final siblings = (json["siblings"] as List)
.map((sibling) => SourceInfo.fromJson(sibling))
.toList()
.cast<SourceInfo>();
.map((s) => TrackSourceInfo.fromJson(s))
.toList();
final sources =
(json["sources"] as List).map((s) => TrackSource.fromJson(s)).toList();
return switch (preferences.audioSource) {
AudioSource.youtube => YoutubeSourcedTrack(
ref: ref,
source: source,
siblings: siblings,
sourceInfo: sourceInfo,
track: track,
info: info,
query: query,
sources: sources,
),
AudioSource.piped => PipedSourcedTrack(
ref: ref,
source: source,
siblings: siblings,
sourceInfo: sourceInfo,
track: track,
info: info,
query: query,
sources: sources,
),
AudioSource.jiosaavn => JioSaavnSourcedTrack(
ref: ref,
source: source,
siblings: siblings,
sourceInfo: sourceInfo,
track: track,
info: info,
query: query,
sources: sources,
),
AudioSource.invidious => InvidiousSourcedTrack(
ref: ref,
source: source,
siblings: siblings,
sourceInfo: sourceInfo,
track: track,
info: info,
query: query,
sources: sources,
),
};
}
static String getSearchTerm(Track track) {
final artists =
(track.artists ?? []).map((ar) => ar.name).toList().nonNulls.toList();
static String getSearchTerm(TrackSourceQuery track) {
final title = ServiceUtils.getTitle(
track.name!,
artists: artists,
track.title,
artists: track.artists,
onlyCleanArtist: true,
).trim();
return "$title - ${artists.join(", ")}";
return "$title - ${track.artists.join(", ")}";
}
static Future<SourcedTrack> fetchFromTrack({
required Track track,
static Future<SourcedTrack> fetchFromQuery({
required TrackSourceQuery query,
required Ref ref,
}) async {
final preferences = ref.read(userPreferencesProvider);
try {
return switch (preferences.audioSource) {
AudioSource.youtube =>
await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref),
await YoutubeSourcedTrack.fetchFromTrack(query: query, ref: ref),
AudioSource.piped =>
await PipedSourcedTrack.fetchFromTrack(track: track, ref: ref),
await PipedSourcedTrack.fetchFromTrack(query: query, ref: ref),
AudioSource.invidious =>
await InvidiousSourcedTrack.fetchFromTrack(track: track, ref: ref),
await InvidiousSourcedTrack.fetchFromTrack(query: query, ref: ref),
AudioSource.jiosaavn =>
await JioSaavnSourcedTrack.fetchFromTrack(track: track, ref: ref),
await JioSaavnSourcedTrack.fetchFromTrack(query: query, ref: ref),
};
} catch (e) {
if (preferences.audioSource == AudioSource.youtube) {
rethrow;
}
return await YoutubeSourcedTrack.fetchFromTrack(track: track, ref: ref);
return await YoutubeSourcedTrack.fetchFromTrack(query: query, ref: ref);
}
}
static Future<List<SiblingType>> fetchSiblings({
required Track track,
required TrackSourceQuery query,
required Ref ref,
}) {
final preferences = ref.read(userPreferencesProvider);
return switch (preferences.audioSource) {
AudioSource.piped =>
PipedSourcedTrack.fetchSiblings(track: track, ref: ref),
PipedSourcedTrack.fetchSiblings(query: query, ref: ref),
AudioSource.youtube =>
YoutubeSourcedTrack.fetchSiblings(track: track, ref: ref),
YoutubeSourcedTrack.fetchSiblings(query: query, ref: ref),
AudioSource.jiosaavn =>
JioSaavnSourcedTrack.fetchSiblings(track: track, ref: ref),
JioSaavnSourcedTrack.fetchSiblings(query: query, ref: ref),
AudioSource.invidious =>
InvidiousSourcedTrack.fetchSiblings(track: track, ref: ref),
InvidiousSourcedTrack.fetchSiblings(query: query, ref: ref),
};
}
Future<SourcedTrack> copyWithSibling();
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling);
Future<SourcedTrack?> swapWithSibling(TrackSourceInfo sibling);
Future<SourcedTrack?> swapWithSiblingOfIndex(int index) {
return swapWithSibling(siblings[index]);
}
Future<SourcedTrack> refreshStream();
String get url {
final preferences = ref.read(userPreferencesProvider);
@ -166,10 +152,22 @@ abstract class SourcedTrack extends Track {
String getUrlOfCodec(SourceCodecs codec) {
final preferences = ref.read(userPreferencesProvider);
return source[codec]?[preferences.audioQuality] ??
// this will ensure playback doesn't break
source[codec == SourceCodecs.m4a ? SourceCodecs.weba : SourceCodecs.m4a]
[preferences.audioQuality];
return sources
.firstWhereOrNull(
(source) =>
source.codec == codec &&
source.quality == preferences.audioQuality,
)
?.url ??
// fallback to the first available source of the same codec
sources.firstWhereOrNull((source) => source.codec == codec)?.url ??
// fallback to the first available source of any codec
sources
.firstWhereOrNull(
(source) => source.quality == preferences.audioQuality)
?.url ??
// fallback to the first available source
sources.first.url;
}
SourceCodecs get codec {
@ -179,4 +177,12 @@ abstract class SourcedTrack extends Track {
? SourceCodecs.m4a
: preferences.streamMusicCodec;
}
TrackSource get activeTrackSource {
final audioQuality = ref.read(userPreferencesProvider).audioQuality;
return sources.firstWhereOrNull(
(source) => source.codec == codec && source.quality == audioQuality,
) ??
sources.first;
}
}

View File

@ -1,14 +1,12 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/sourced_track/exceptions.dart';
import 'package:spotube/services/sourced_track/models/source_info.dart';
import 'package:spotube/services/sourced_track/models/source_map.dart';
import 'package:spotube/services/sourced_track/models/video_info.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:invidious/invidious.dart';
@ -24,51 +22,24 @@ final invidiousProvider = Provider<InvidiousClient>(
},
);
class InvidiousSourceInfo extends SourceInfo {
InvidiousSourceInfo({
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 InvidiousSourcedTrack extends SourcedTrack {
InvidiousSourcedTrack({
required super.ref,
required super.source,
required super.siblings,
required super.sourceInfo,
required super.track,
required super.info,
required super.query,
required super.sources,
});
static Future<SourcedTrack> fetchFromTrack({
required Track track,
required TrackSourceQuery query,
required Ref ref,
}) async {
// Indicates a stream url refresh
if (track is InvidiousSourcedTrack) {
final manifest = await ref
.read(invidiousProvider)
.videos
.get(track.sourceInfo.id, local: true);
return InvidiousSourcedTrack(
ref: ref,
siblings: track.siblings,
source: toSourceMap(manifest),
sourceInfo: track.sourceInfo,
track: track,
);
}
final audioSource = ref.read(userPreferencesProvider).audioSource;
final database = ref.read(databaseProvider);
final cachedSource = await (database.select(database.sourceMatchTable)
..where((s) => s.trackId.equals(track.id!))
..where((s) => s.trackId.equals(query.id))
..limit(1)
..orderBy([
(s) =>
@ -78,14 +49,14 @@ class InvidiousSourcedTrack extends SourcedTrack {
final invidiousClient = ref.read(invidiousProvider);
if (cachedSource == null) {
final siblings = await fetchSiblings(ref: ref, track: track);
final siblings = await fetchSiblings(ref: ref, query: query);
if (siblings.isEmpty) {
throw TrackNotFoundError(track);
throw TrackNotFoundError(query);
}
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: track.id!,
trackId: query.id,
sourceId: siblings.first.info.id,
sourceType: const Value(SourceType.youtube),
),
@ -94,9 +65,10 @@ class InvidiousSourcedTrack extends SourcedTrack {
return InvidiousSourcedTrack(
ref: ref,
siblings: siblings.map((s) => s.info).skip(1).toList(),
source: siblings.first.source as SourceMap,
sourceInfo: siblings.first.info,
track: track,
sources: siblings.first.source as List<TrackSource>,
info: siblings.first.info,
query: query,
source: audioSource,
);
} else {
final manifest =
@ -105,44 +77,36 @@ class InvidiousSourcedTrack extends SourcedTrack {
return InvidiousSourcedTrack(
ref: ref,
siblings: [],
source: toSourceMap(manifest),
sourceInfo: InvidiousSourceInfo(
sources: toSources(manifest),
info: TrackSourceInfo(
id: manifest.videoId,
artist: manifest.author,
artistUrl: manifest.authorUrl,
artists: manifest.author,
pageUrl: "https://www.youtube.com/watch?v=${manifest.videoId}",
thumbnail: manifest.videoThumbnails.first.url,
title: manifest.title,
duration: Duration(seconds: manifest.lengthSeconds),
album: null,
durationMs: Duration(seconds: manifest.lengthSeconds).inMilliseconds,
),
track: track,
query: query,
source: audioSource,
);
}
}
static SourceMap toSourceMap(InvidiousVideoResponse manifest) {
final m4a = manifest.adaptiveFormats
.where((audio) => audio.type.contains("audio/mp4"))
.sorted((a, b) => int.parse(a.bitrate).compareTo(int.parse(b.bitrate)));
final weba = manifest.adaptiveFormats
.where((audio) => audio.type.contains("audio/webm"))
.sorted((a, b) => int.parse(a.bitrate).compareTo(int.parse(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 List<TrackSource> toSources(InvidiousVideoResponse manifest) {
return manifest.adaptiveFormats.map((stream) {
return TrackSource(
url: stream.url.toString(),
quality: switch (stream.qualityLabel) {
"high" => SourceQualities.high,
"medium" => SourceQualities.medium,
_ => SourceQualities.low,
},
codec: stream.type.contains("audio/webm")
? SourceCodecs.weba
: SourceCodecs.m4a,
bitrate: stream.bitrate,
);
}).toList();
}
static Future<SiblingType> toSiblingType(
@ -150,22 +114,20 @@ class InvidiousSourcedTrack extends SourcedTrack {
YoutubeVideoInfo item,
InvidiousClient invidiousClient,
) async {
SourceMap? sourceMap;
List<TrackSource>? sourceMap;
if (index == 0) {
final manifest = await invidiousClient.videos.get(item.id, local: true);
sourceMap = toSourceMap(manifest);
sourceMap = toSources(manifest);
}
final SiblingType sibling = (
info: InvidiousSourceInfo(
info: TrackSourceInfo(
id: item.id,
artist: item.channelName,
artistUrl: "https://www.youtube.com/${item.channelId}",
artists: item.channelName,
pageUrl: "https://www.youtube.com/watch?v=${item.id}",
thumbnail: item.thumbnailUrl,
title: item.title,
duration: item.duration,
album: null,
durationMs: item.duration.inMilliseconds,
),
source: sourceMap,
);
@ -174,20 +136,20 @@ class InvidiousSourcedTrack extends SourcedTrack {
}
static Future<List<SiblingType>> fetchSiblings({
required Track track,
required TrackSourceQuery query,
required Ref ref,
}) async {
final invidiousClient = ref.read(invidiousProvider);
final preference = ref.read(userPreferencesProvider);
final query = SourcedTrack.getSearchTerm(track);
final searchQuery = SourcedTrack.getSearchTerm(query);
final searchResults = await invidiousClient.search.list(
query,
searchQuery,
type: InvidiousSearchType.video,
);
if (ServiceUtils.onlyContainsEnglish(query)) {
if (ServiceUtils.onlyContainsEnglish(searchQuery)) {
return await Future.wait(
searchResults
.whereType<InvidiousSearchResponseVideo>()
@ -211,7 +173,7 @@ class InvidiousSourcedTrack extends SourcedTrack {
),
)
.toList(),
track,
query,
);
return await Future.wait(
@ -224,23 +186,24 @@ class InvidiousSourcedTrack extends SourcedTrack {
if (siblings.isNotEmpty) {
return this;
}
final fetchedSiblings = await fetchSiblings(ref: ref, track: this);
final fetchedSiblings = await fetchSiblings(ref: ref, query: query);
return InvidiousSourcedTrack(
ref: ref,
siblings: fetchedSiblings
.where((s) => s.info.id != sourceInfo.id)
.where((s) => s.info.id != info.id)
.map((s) => s.info)
.toList(),
source: source,
sourceInfo: sourceInfo,
track: this,
info: info,
query: query,
sources: sources,
);
}
@override
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
if (sibling.id == sourceInfo.id) {
Future<SourcedTrack?> swapWithSibling(TrackSourceInfo sibling) async {
if (sibling.id == info.id) {
return null;
}
@ -251,7 +214,7 @@ class InvidiousSourcedTrack extends SourcedTrack {
? sibling
: siblings.firstWhere((s) => s.id == sibling.id);
final newSiblings = siblings.where((s) => s.id != sibling.id).toList()
..insert(0, sourceInfo);
..insert(0, info);
final pipedClient = ref.read(invidiousProvider);
@ -261,7 +224,7 @@ class InvidiousSourcedTrack extends SourcedTrack {
final database = ref.read(databaseProvider);
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: id!,
trackId: query.id,
sourceId: newSourceInfo.id,
sourceType: const Value(SourceType.youtube),
// Because we're sorting by createdAt in the query
@ -274,9 +237,25 @@ class InvidiousSourcedTrack extends SourcedTrack {
return InvidiousSourcedTrack(
ref: ref,
siblings: newSiblings,
source: toSourceMap(manifest),
sourceInfo: newSourceInfo,
track: this,
sources: toSources(manifest),
info: newSourceInfo,
query: query,
source: source,
);
}
@override
Future<SourcedTrack> refreshStream() async {
final manifest =
await ref.read(invidiousProvider).videos.get(info.id, local: true);
return InvidiousSourcedTrack(
ref: ref,
siblings: siblings,
sources: toSources(manifest),
info: info,
query: query,
source: source,
);
}
}

View File

@ -1,49 +1,35 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/sourced_track/exceptions.dart';
import 'package:spotube/services/sourced_track/models/source_info.dart';
import 'package:spotube/services/sourced_track/models/source_map.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:jiosaavn/jiosaavn.dart';
import 'package:spotube/extensions/string.dart';
final jiosaavnClient = JioSaavnClient();
class JioSaavnSourceInfo extends SourceInfo {
JioSaavnSourceInfo({
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 JioSaavnSourcedTrack extends SourcedTrack {
JioSaavnSourcedTrack({
required super.ref,
required super.source,
required super.siblings,
required super.sourceInfo,
required super.track,
required super.info,
required super.query,
required super.sources,
});
static Future<SourcedTrack> fetchFromTrack({
required Track track,
required TrackSourceQuery query,
required Ref ref,
bool weakMatch = false,
}) async {
final database = ref.read(databaseProvider);
final cachedSource = await (database.select(database.sourceMatchTable)
..where((s) => s.trackId.equals(track.id!))
..where((s) => s.trackId.equals(query.id))
..limit(1)
..orderBy([
(s) =>
@ -54,15 +40,15 @@ class JioSaavnSourcedTrack extends SourcedTrack {
if (cachedSource == null ||
cachedSource.sourceType != SourceType.jiosaavn) {
final siblings =
await fetchSiblings(ref: ref, track: track, weakMatch: weakMatch);
await fetchSiblings(ref: ref, query: query, weakMatch: weakMatch);
if (siblings.isEmpty) {
throw TrackNotFoundError(track);
throw TrackNotFoundError(query);
}
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: track.id!,
trackId: query.id,
sourceId: siblings.first.info.id,
sourceType: const Value(SourceType.jiosaavn),
),
@ -71,9 +57,10 @@ class JioSaavnSourcedTrack extends SourcedTrack {
return JioSaavnSourcedTrack(
ref: ref,
siblings: siblings.map((s) => s.info).skip(1).toList(),
source: siblings.first.source!,
sourceInfo: siblings.first.info,
track: track,
sources: siblings.first.source!,
info: siblings.first.info,
query: query,
source: AudioSource.jiosaavn,
);
}
@ -85,80 +72,77 @@ class JioSaavnSourcedTrack extends SourcedTrack {
return JioSaavnSourcedTrack(
ref: ref,
siblings: [],
source: source!,
sourceInfo: info,
track: track,
sources: source!,
query: query,
info: info,
source: AudioSource.jiosaavn,
);
}
static SiblingType toSiblingType(SongResponse result) {
final SiblingType sibling = (
info: JioSaavnSourceInfo(
artist: [
info: TrackSourceInfo(
artists: [
result.primaryArtists,
if (result.featuredArtists.isNotEmpty) ", ",
result.featuredArtists
].join("").unescapeHtml(),
artistUrl:
"https://www.jiosaavn.com/artist/${result.primaryArtistsId.split(",").firstOrNull ?? ""}",
duration: Duration(seconds: int.parse(result.duration)),
durationMs:
Duration(seconds: int.parse(result.duration)).inMilliseconds,
id: result.id,
pageUrl: result.url,
thumbnail: result.image?.last.link ?? "",
title: result.name!.unescapeHtml(),
album: result.album.name,
),
source: SourceMap(
m4a: SourceQualityMap(
high: result.downloadUrl!
.firstWhere((element) => element.quality == "320kbps")
.link,
medium: result.downloadUrl!
.firstWhere((element) => element.quality == "160kbps")
.link,
low: result.downloadUrl!
.firstWhere((element) => element.quality == "96kbps")
.link,
),
),
source: result.downloadUrl!.map((link) {
return TrackSource(
url: link.link,
quality: link.quality == "320kbps"
? SourceQualities.high
: link.quality == "160kbps"
? SourceQualities.medium
: SourceQualities.low,
codec: SourceCodecs.m4a,
bitrate: link.quality,
);
}).toList()
);
return sibling;
}
static Future<List<SiblingType>> fetchSiblings({
required Track track,
required TrackSourceQuery query,
required Ref ref,
bool weakMatch = false,
}) async {
final query = SourcedTrack.getSearchTerm(track);
final searchQuery = SourcedTrack.getSearchTerm(query);
final SongSearchResponse(:results) =
await jiosaavnClient.search.songs(query, limit: 20);
await jiosaavnClient.search.songs(searchQuery, limit: 20);
final trackArtistNames = track.artists?.map((ar) => ar.name).toList();
final trackArtistNames = query.artists;
final matchedResults = results
.where(
(s) {
s.name?.unescapeHtml().contains(track.name!) ?? false;
s.name?.unescapeHtml().contains(query.title) ?? false;
final sameName = s.name?.unescapeHtml() == track.name;
final sameName = s.name?.unescapeHtml() == query.title;
final artistNames = [
s.primaryArtists,
if (s.featuredArtists.isNotEmpty) ", ",
s.featuredArtists
].join("").unescapeHtml();
final sameArtists = artistNames.split(", ").any(
(artist) =>
trackArtistNames?.any((ar) => artist == ar) ?? false,
(artist) => trackArtistNames.any((ar) => artist == ar),
);
if (weakMatch) {
final containsName =
s.name?.unescapeHtml().contains(track.name!) ?? false;
s.name?.unescapeHtml().contains(query.title) ?? false;
final containsPrimaryArtist = s.primaryArtists
.unescapeHtml()
.contains(trackArtistNames?.first ?? "");
.contains(trackArtistNames.first);
return containsName && containsPrimaryArtist;
}
@ -181,23 +165,24 @@ class JioSaavnSourcedTrack extends SourcedTrack {
if (siblings.isNotEmpty) {
return this;
}
final fetchedSiblings = await fetchSiblings(ref: ref, track: this);
final fetchedSiblings = await fetchSiblings(ref: ref, query: query);
return JioSaavnSourcedTrack(
ref: ref,
siblings: fetchedSiblings
.where((s) => s.info.id != sourceInfo.id)
.where((s) => s.info.id != info.id)
.map((s) => s.info)
.toList(),
source: source,
sourceInfo: sourceInfo,
track: this,
info: info,
query: query,
sources: sources,
);
}
@override
Future<JioSaavnSourcedTrack?> swapWithSibling(SourceInfo sibling) async {
if (sibling.id == sourceInfo.id) {
Future<JioSaavnSourcedTrack?> swapWithSibling(TrackSourceInfo sibling) async {
if (sibling.id == this.info.id) {
return null;
}
@ -208,7 +193,7 @@ class JioSaavnSourcedTrack extends SourcedTrack {
? sibling
: siblings.firstWhere((s) => s.id == sibling.id);
final newSiblings = siblings.where((s) => s.id != sibling.id).toList()
..insert(0, sourceInfo);
..insert(0, this.info);
final [item] = await jiosaavnClient.songs.detailsById([newSourceInfo.id]);
@ -217,7 +202,7 @@ class JioSaavnSourcedTrack extends SourcedTrack {
final database = ref.read(databaseProvider);
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: id!,
trackId: query.id,
sourceId: info.id,
sourceType: const Value(SourceType.jiosaavn),
// Because we're sorting by createdAt in the query
@ -230,9 +215,16 @@ class JioSaavnSourcedTrack extends SourcedTrack {
return JioSaavnSourcedTrack(
ref: ref,
siblings: newSiblings,
source: source!,
sourceInfo: info,
track: this,
sources: source!,
info: info,
query: query,
source: AudioSource.jiosaavn,
);
}
@override
Future<SourcedTrack> refreshStream() async {
// There's no need to refresh the stream for JioSaavnSourcedTrack
return this;
}
}

View File

@ -2,15 +2,13 @@ import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:piped_client/piped_client.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/sourced_track/exceptions.dart';
import 'package:spotube/services/sourced_track/models/source_info.dart';
import 'package:spotube/services/sourced_track/models/source_map.dart';
import 'package:spotube/services/sourced_track/models/video_info.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/services/sourced_track/sources/youtube.dart';
@ -24,48 +22,24 @@ final pipedProvider = Provider<PipedClient>(
},
);
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.ref,
required super.source,
required super.siblings,
required super.sourceInfo,
required super.track,
required super.info,
required super.query,
required super.sources,
});
static Future<SourcedTrack> fetchFromTrack({
required Track track,
required TrackSourceQuery query,
required Ref ref,
}) async {
// Means it wants a refresh of the stream
if (track is PipedSourcedTrack) {
final manifest =
await ref.read(pipedProvider).streams(track.sourceInfo.id);
return PipedSourcedTrack(
ref: ref,
siblings: track.siblings,
sourceInfo: track.sourceInfo,
source: toSourceMap(manifest),
track: track,
);
}
final audioSource = ref.read(userPreferencesProvider).audioSource;
final database = ref.read(databaseProvider);
final cachedSource = await (database.select(database.sourceMatchTable)
..where((s) => s.trackId.equals(track.id!))
..where((s) => s.trackId.equals(query.id))
..limit(1)
..orderBy([
(s) =>
@ -76,14 +50,14 @@ class PipedSourcedTrack extends SourcedTrack {
final pipedClient = ref.read(pipedProvider);
if (cachedSource == null) {
final siblings = await fetchSiblings(ref: ref, track: track);
final siblings = await fetchSiblings(ref: ref, query: query);
if (siblings.isEmpty) {
throw TrackNotFoundError(track);
throw TrackNotFoundError(query);
}
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: track.id!,
trackId: query.id,
sourceId: siblings.first.info.id,
sourceType: Value(
preferences.searchMode == SearchMode.youtube
@ -96,9 +70,10 @@ class PipedSourcedTrack extends SourcedTrack {
return PipedSourcedTrack(
ref: ref,
siblings: siblings.map((s) => s.info).skip(1).toList(),
source: siblings.first.source as SourceMap,
sourceInfo: siblings.first.info,
track: track,
source: audioSource,
info: siblings.first.info,
query: query,
sources: siblings.first.source!,
);
} else {
final manifest = await pipedClient.streams(cachedSource.sourceId);
@ -106,44 +81,36 @@ class PipedSourcedTrack extends SourcedTrack {
return PipedSourcedTrack(
ref: ref,
siblings: [],
source: toSourceMap(manifest),
sourceInfo: PipedSourceInfo(
sources: toSources(manifest),
info: TrackSourceInfo(
id: manifest.id,
artist: manifest.uploader,
artistUrl: manifest.uploaderUrl,
artists: manifest.uploader,
pageUrl: "https://www.youtube.com/watch?v=${manifest.id}",
thumbnail: manifest.thumbnailUrl,
title: manifest.title,
duration: manifest.duration,
album: null,
durationMs: manifest.duration.inMilliseconds,
),
track: track,
query: query,
source: audioSource,
);
}
}
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 List<TrackSource> toSources(PipedStreamResponse manifest) {
return manifest.audioStreams.map((audio) {
return TrackSource(
url: audio.url.toString(),
quality: switch (audio.quality) {
"high" => SourceQualities.high,
"medium" => SourceQualities.medium,
_ => SourceQualities.low,
},
codec: audio.format == PipedAudioStreamFormat.m4a
? SourceCodecs.m4a
: SourceCodecs.weba,
bitrate: audio.bitrate.toString(),
);
}).toList();
}
static Future<SiblingType> toSiblingType(
@ -151,40 +118,38 @@ class PipedSourcedTrack extends SourcedTrack {
YoutubeVideoInfo item,
PipedClient pipedClient,
) async {
SourceMap? sourceMap;
List<TrackSource>? sources;
if (index == 0) {
final manifest = await pipedClient.streams(item.id);
sourceMap = toSourceMap(manifest);
sources = toSources(manifest);
}
final SiblingType sibling = (
info: PipedSourceInfo(
info: TrackSourceInfo(
id: item.id,
artist: item.channelName,
artistUrl: "https://www.youtube.com/${item.channelId}",
artists: item.channelName,
pageUrl: "https://www.youtube.com/watch?v=${item.id}",
thumbnail: item.thumbnailUrl,
title: item.title,
duration: item.duration,
album: null,
durationMs: item.duration.inMilliseconds,
),
source: sourceMap,
source: sources,
);
return sibling;
}
static Future<List<SiblingType>> fetchSiblings({
required Track track,
required TrackSourceQuery query,
required Ref ref,
}) async {
final pipedClient = ref.read(pipedProvider);
final preference = ref.read(userPreferencesProvider);
final query = SourcedTrack.getSearchTerm(track);
final searchQuery = SourcedTrack.getSearchTerm(query);
final PipedSearchResult(items: searchResults) = await pipedClient.search(
query,
searchQuery,
preference.searchMode == SearchMode.youtube
? PipedFilter.videos
: PipedFilter.musicSongs,
@ -196,8 +161,7 @@ class PipedSourcedTrack extends SourcedTrack {
: preference.searchMode == SearchMode.youtubeMusic;
if (isYouTubeMusic) {
final artists =
(track.artists ?? []).map((ar) => ar.name).toList().nonNulls.toList();
final artists = query.artists;
return await Future.wait(
searchResults
@ -218,7 +182,7 @@ class PipedSourcedTrack extends SourcedTrack {
);
}
if (ServiceUtils.onlyContainsEnglish(query)) {
if (ServiceUtils.onlyContainsEnglish(searchQuery)) {
return await Future.wait(
searchResults
.whereType<PipedSearchItemStream>()
@ -241,7 +205,7 @@ class PipedSourcedTrack extends SourcedTrack {
),
)
.toList(),
track,
query,
);
return await Future.wait(
@ -254,23 +218,24 @@ class PipedSourcedTrack extends SourcedTrack {
if (siblings.isNotEmpty) {
return this;
}
final fetchedSiblings = await fetchSiblings(ref: ref, track: this);
final fetchedSiblings = await fetchSiblings(ref: ref, query: query);
return PipedSourcedTrack(
ref: ref,
siblings: fetchedSiblings
.where((s) => s.info.id != sourceInfo.id)
.where((s) => s.info.id != info.id)
.map((s) => s.info)
.toList(),
source: source,
sourceInfo: sourceInfo,
track: this,
info: info,
query: query,
sources: sources,
);
}
@override
Future<SourcedTrack?> swapWithSibling(SourceInfo sibling) async {
if (sibling.id == sourceInfo.id) {
Future<SourcedTrack?> swapWithSibling(TrackSourceInfo sibling) async {
if (sibling.id == info.id) {
return null;
}
@ -281,7 +246,7 @@ class PipedSourcedTrack extends SourcedTrack {
? sibling
: siblings.firstWhere((s) => s.id == sibling.id);
final newSiblings = siblings.where((s) => s.id != sibling.id).toList()
..insert(0, sourceInfo);
..insert(0, info);
final pipedClient = ref.read(pipedProvider);
@ -290,7 +255,7 @@ class PipedSourcedTrack extends SourcedTrack {
final database = ref.read(databaseProvider);
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: id!,
trackId: query.id,
sourceId: newSourceInfo.id,
sourceType: const Value(SourceType.youtube),
// Because we're sorting by createdAt in the query
@ -303,9 +268,23 @@ class PipedSourcedTrack extends SourcedTrack {
return PipedSourcedTrack(
ref: ref,
siblings: newSiblings,
source: toSourceMap(manifest),
sourceInfo: newSourceInfo,
track: this,
sources: toSources(manifest),
info: info,
query: query,
source: source,
);
}
@override
Future<SourcedTrack> refreshStream() async {
final manifest = await ref.read(pipedProvider).streams(info.id);
return PipedSourcedTrack(
ref: ref,
siblings: siblings,
info: info,
source: source,
query: query,
sources: toSources(manifest),
);
}
}

View File

@ -1,16 +1,15 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/models/playback/track_sources.dart';
import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/provider/youtube_engine/youtube_engine.dart';
import 'package:spotube/services/logger/logger.dart';
import 'package:spotube/services/song_link/song_link.dart';
import 'package:spotube/services/sourced_track/enums.dart';
import 'package:spotube/services/sourced_track/exceptions.dart';
import 'package:spotube/services/sourced_track/models/source_info.dart';
import 'package:spotube/services/sourced_track/models/source_map.dart';
import 'package:spotube/services/sourced_track/models/video_info.dart';
import 'package:spotube/services/sourced_track/sourced_track.dart';
import 'package:spotube/utils/service_utils.dart';
@ -21,54 +20,24 @@ final officialMusicRegex = RegExp(
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,
required super.info,
required super.query,
required super.sources,
required super.ref,
});
static Future<YoutubeSourcedTrack> fetchFromTrack({
required Track track,
required TrackSourceQuery query,
required Ref ref,
}) async {
// Indicates the track is requesting a stream refresh
if (track is YoutubeSourcedTrack) {
final manifest = await ref
.read(youtubeEngineProvider)
.getStreamManifest(track.sourceInfo.id);
final sourcedTrack = YoutubeSourcedTrack(
ref: ref,
siblings: track.siblings,
source: toSourceMap(manifest),
sourceInfo: track.sourceInfo,
track: track,
);
AppLogger.log.i("Refreshing ${track.name}: ${sourcedTrack.url}");
return sourcedTrack;
}
final audioSource = ref.read(userPreferencesProvider).audioSource;
final database = ref.read(databaseProvider);
final cachedSource = await (database.select(database.sourceMatchTable)
..where((s) => s.trackId.equals(track.id!))
..where((s) => s.trackId.equals(query.id))
..limit(1)
..orderBy([
(s) =>
@ -78,14 +47,14 @@ class YoutubeSourcedTrack extends SourcedTrack {
.then((s) => s.firstOrNull);
if (cachedSource == null || cachedSource.sourceType != SourceType.youtube) {
final siblings = await fetchSiblings(ref: ref, track: track);
final siblings = await fetchSiblings(ref: ref, query: query);
if (siblings.isEmpty) {
throw TrackNotFoundError(track);
throw TrackNotFoundError(query);
}
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: track.id!,
trackId: query.id,
sourceId: siblings.first.info.id,
sourceType: const Value(SourceType.youtube),
),
@ -94,9 +63,10 @@ class YoutubeSourcedTrack extends SourcedTrack {
return YoutubeSourcedTrack(
ref: ref,
siblings: siblings.map((s) => s.info).skip(1).toList(),
source: siblings.first.source as SourceMap,
sourceInfo: siblings.first.info,
track: track,
info: siblings.first.info,
source: audioSource,
sources: siblings.first.source ?? [],
query: query,
);
}
final (item, manifest) = await ref
@ -106,26 +76,25 @@ class YoutubeSourcedTrack extends SourcedTrack {
final sourcedTrack = YoutubeSourcedTrack(
ref: ref,
siblings: [],
source: toSourceMap(manifest),
sourceInfo: YoutubeSourceInfo(
sources: toTrackSources(manifest),
info: TrackSourceInfo(
id: item.id.value,
artist: item.author,
artistUrl: "https://www.youtube.com/channel/${item.channelId}",
artists: item.author,
pageUrl: item.url,
thumbnail: item.thumbnails.highResUrl,
title: item.title,
duration: item.duration ?? Duration.zero,
album: null,
durationMs: item.duration?.inMilliseconds ?? 0,
),
track: track,
query: query,
source: audioSource,
);
AppLogger.log.i("${track.name}: ${sourcedTrack.url}");
AppLogger.log.i("${query.title}: ${sourcedTrack.url}");
return sourcedTrack;
}
static SourceMap toSourceMap(StreamManifest manifest) {
static List<TrackSource> toTrackSources(StreamManifest manifest) {
var m4a = manifest.audioOnly
.where((audio) => audio.codec.mimeType == "audio/mp4")
.sortByBitrate();
@ -137,19 +106,20 @@ class YoutubeSourcedTrack extends SourcedTrack {
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(),
),
return manifest.audioOnly.map((streamInfo) {
return TrackSource(
url: streamInfo.url.toString(),
quality: streamInfo.qualityLabel == "AUDIO_QUALITY_HIGH"
? SourceQualities.high
: streamInfo.qualityLabel == "AUDIO_QUALITY_MEDIUM"
? SourceQualities.medium
: SourceQualities.low,
codec: streamInfo.codec.mimeType == "audio/mp4"
? SourceCodecs.m4a
: SourceCodecs.weba,
bitrate: streamInfo.bitrate.bitsPerSecond.toString(),
);
}).toList();
}
static Future<SiblingType> toSiblingType(
@ -158,23 +128,21 @@ class YoutubeSourcedTrack extends SourcedTrack {
dynamic ref,
) async {
assert(ref is WidgetRef || ref is Ref, "Invalid ref type");
SourceMap? sourceMap;
List<TrackSource>? sourceMap;
if (index == 0) {
final manifest =
await ref.read(youtubeEngineProvider).getStreamManifest(item.id);
sourceMap = toSourceMap(manifest);
sourceMap = toTrackSources(manifest);
}
final SiblingType sibling = (
info: YoutubeSourceInfo(
info: TrackSourceInfo(
id: item.id,
artist: item.channelName,
artistUrl: "https://www.youtube.com/channel/${item.channelId}",
artists: item.channelName,
pageUrl: "https://www.youtube.com/watch?v=${item.id}",
thumbnail: item.thumbnailUrl,
title: item.title,
duration: item.duration,
album: null,
durationMs: item.duration.inMilliseconds,
),
source: sourceMap,
);
@ -183,16 +151,13 @@ class YoutubeSourcedTrack extends SourcedTrack {
}
static List<YoutubeVideoInfo> rankResults(
List<YoutubeVideoInfo> results, Track track) {
final artists =
(track.artists ?? []).map((ar) => ar.name).toList().nonNulls.toList();
List<YoutubeVideoInfo> results, TrackSourceQuery track) {
return results
.sorted((a, b) => b.views.compareTo(a.views))
.map((sibling) {
int score = 0;
for (final artist in artists) {
for (final artist in track.artists) {
final isSameChannelArtist =
sibling.channelName.toLowerCase() == artist.toLowerCase();
final channelContainsArtist = sibling.channelName
@ -212,7 +177,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
}
final titleContainsTrackName =
sibling.title.toLowerCase().contains(track.name!.toLowerCase());
sibling.title.toLowerCase().contains(track.title.toLowerCase());
final hasOfficialFlag =
officialMusicRegex.hasMatch(sibling.title.toLowerCase());
@ -237,12 +202,12 @@ class YoutubeSourcedTrack extends SourcedTrack {
}
static Future<List<YoutubeVideoInfo>> fetchFromIsrc({
required Track track,
required TrackSourceQuery track,
required Ref ref,
}) async {
final isrcResults = <YoutubeVideoInfo>[];
final isrc = track.externalIds?.isrc;
if (isrc != null && isrc.isNotEmpty) {
final isrc = track.isrc;
if (isrc.isNotEmpty) {
final searchedVideos =
await ref.read(youtubeEngineProvider).searchVideos(isrc.toString());
if (searchedVideos.isNotEmpty) {
@ -254,15 +219,18 @@ class YoutubeSourcedTrack extends SourcedTrack {
.replaceAll(RegExp(r'[^\p{L}\p{N}\p{Z}]+', unicode: true), '')
.split(RegExp(r'\p{Z}+', unicode: true))
.where((item) => item.isNotEmpty);
final spWords = track.name!
final spWords = track.title
.toLowerCase()
.replaceAll(RegExp(r'[^\p{L}\p{N}\p{Z}]+', unicode: true), '')
.split(RegExp(r'\p{Z}+', unicode: true))
.where((item) => item.isNotEmpty);
// Single word and duration match with 3 second tolerance
if (ytWords.any((word) => spWords.contains(word)) &&
(videoInfo.duration - track.duration!)
.abs().inMilliseconds <= 3000) {
(videoInfo.duration -
Duration(milliseconds: track.durationMs))
.abs()
.inMilliseconds <=
3000) {
return videoInfo;
}
return null;
@ -275,21 +243,21 @@ class YoutubeSourcedTrack extends SourcedTrack {
}
static Future<List<SiblingType>> fetchSiblings({
required Track track,
required TrackSourceQuery query,
required Ref ref,
}) async {
final videoResults = <YoutubeVideoInfo>[];
if (track is! SourcedTrack) {
if (query is! SourcedTrack) {
final isrcResults = await fetchFromIsrc(
track: track,
track: query,
ref: ref,
);
videoResults.addAll(isrcResults);
if (isrcResults.isEmpty) {
final links = await SongLinkService.links(track.id!);
final links = await SongLinkService.links(query.id);
final ytLink = links.firstWhereOrNull(
(link) => link.platform == "youtube",
);
@ -308,18 +276,18 @@ class YoutubeSourcedTrack extends SourcedTrack {
}
}
final query = SourcedTrack.getSearchTerm(track);
final searchQuery = SourcedTrack.getSearchTerm(query);
final searchResults =
await ref.read(youtubeEngineProvider).searchVideos(query);
await ref.read(youtubeEngineProvider).searchVideos(searchQuery);
if (ServiceUtils.onlyContainsEnglish(query)) {
if (ServiceUtils.onlyContainsEnglish(searchQuery)) {
videoResults
.addAll(searchResults.map(YoutubeVideoInfo.fromVideo).toList());
} else {
videoResults.addAll(rankResults(
searchResults.map(YoutubeVideoInfo.fromVideo).toList(),
track,
query,
));
}
@ -338,8 +306,8 @@ class YoutubeSourcedTrack extends SourcedTrack {
}
@override
Future<YoutubeSourcedTrack?> swapWithSibling(SourceInfo sibling) async {
if (sibling.id == sourceInfo.id) {
Future<YoutubeSourcedTrack?> swapWithSibling(TrackSourceInfo sibling) async {
if (sibling.id == info.id) {
return null;
}
@ -350,7 +318,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
? sibling
: siblings.firstWhere((s) => s.id == sibling.id);
final newSiblings = siblings.where((s) => s.id != sibling.id).toList()
..insert(0, sourceInfo);
..insert(0, info);
final manifest = await ref
.read(youtubeEngineProvider)
@ -360,7 +328,7 @@ class YoutubeSourcedTrack extends SourcedTrack {
await database.into(database.sourceMatchTable).insert(
SourceMatchTableCompanion.insert(
trackId: id!,
trackId: query.id,
sourceId: newSourceInfo.id,
sourceType: const Value(SourceType.youtube),
// Because we're sorting by createdAt in the query
@ -372,10 +340,11 @@ class YoutubeSourcedTrack extends SourcedTrack {
return YoutubeSourcedTrack(
ref: ref,
source: source,
siblings: newSiblings,
source: toSourceMap(manifest),
sourceInfo: newSourceInfo,
track: this,
sources: toTrackSources(manifest),
info: info,
query: query,
);
}
@ -384,17 +353,37 @@ class YoutubeSourcedTrack extends SourcedTrack {
if (siblings.isNotEmpty) {
return this;
}
final fetchedSiblings = await fetchSiblings(ref: ref, track: this);
final fetchedSiblings = await fetchSiblings(ref: ref, query: query);
return YoutubeSourcedTrack(
ref: ref,
siblings: fetchedSiblings
.where((s) => s.info.id != sourceInfo.id)
.where((s) => s.info.id != info.id)
.map((s) => s.info)
.toList(),
source: source,
sourceInfo: sourceInfo,
track: this,
sources: sources,
info: info,
query: query,
);
}
@override
Future<SourcedTrack> refreshStream() async {
final manifest =
await ref.read(youtubeEngineProvider).getStreamManifest(info.id);
final sourcedTrack = YoutubeSourcedTrack(
ref: ref,
siblings: siblings,
source: source,
sources: toTrackSources(manifest),
info: info,
query: query,
);
AppLogger.log.i("Refreshing ${query.title}: ${sourcedTrack.url}");
return sourcedTrack;
}
}

View File

@ -195,10 +195,9 @@ abstract class ServiceUtils {
@Deprecated("In favor spotify lyrics api, this isn't needed anymore")
static Future<SubtitleSimple?> getTimedLyrics(SourcedTrack track) async {
final artistNames =
track.artists?.map((artist) => artist.name!).toList() ?? [];
final artistNames = track.query.artists;
final query = getTitle(
track.name!,
track.query.title,
artists: artistNames,
);
@ -217,13 +216,11 @@ abstract class ServiceUtils {
final rateSortedResults = results.map((result) {
final title = result.text.trim().toLowerCase();
int points = 0;
final hasAllArtists = track.artists
?.map((artist) => artist.name!)
.every((artist) => title.contains(artist.toLowerCase())) ??
false;
final hasTrackName = title.contains(track.name!.toLowerCase());
final hasAllArtists = track.query.artists
.every((artist) => title.contains(artist.toLowerCase()));
final hasTrackName = title.contains(track.query.title.toLowerCase());
final isNotLive = !PrimitiveUtils.containsTextInBracket(title, "live");
final exactYtMatch = title == track.sourceInfo.title.toLowerCase();
final exactYtMatch = title == track.info.title.toLowerCase();
if (exactYtMatch) points = 7;
for (final criteria in [hasTrackName, hasAllArtists, isNotLive]) {
if (criteria) points++;