mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 07:55:18 +00:00

Downloaded tracks are saved with metadata. Only MP3 file metadata support is available in local track player for now
566 lines
17 KiB
Dart
566 lines
17 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:audio_service/audio_service.dart';
|
|
import 'package:audioplayers/audioplayers.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:hive/hive.dart';
|
|
import 'package:spotify/spotify.dart';
|
|
import 'package:spotube/entities/CacheTrack.dart';
|
|
import 'package:spotube/extensions/yt-video-from-cache-track.dart';
|
|
import 'package:spotube/models/CurrentPlaylist.dart';
|
|
import 'package:spotube/models/Logger.dart';
|
|
import 'package:spotube/models/SpotubeTrack.dart';
|
|
import 'package:spotube/provider/AudioPlayer.dart';
|
|
import 'package:spotube/provider/UserPreferences.dart';
|
|
import 'package:spotube/provider/YouTube.dart';
|
|
import 'package:spotube/services/LinuxAudioService.dart';
|
|
import 'package:spotube/services/MobileAudioService.dart';
|
|
import 'package:spotube/utils/PersistedChangeNotifier.dart';
|
|
import 'package:spotube/utils/platform.dart';
|
|
import 'package:spotube/utils/primitive_utils.dart';
|
|
import 'package:spotube/utils/service_utils.dart';
|
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
|
import 'package:youtube_explode_dart/youtube_explode_dart.dart' hide Playlist;
|
|
import 'package:collection/collection.dart';
|
|
import 'package:spotube/extensions/list-sort-multiple.dart';
|
|
import 'package:http/http.dart' as http;
|
|
|
|
enum PlaybackStatus {
|
|
playing,
|
|
loading,
|
|
idle,
|
|
}
|
|
|
|
enum AudioQuality {
|
|
high,
|
|
low,
|
|
}
|
|
|
|
class Playback extends PersistedChangeNotifier {
|
|
// player properties
|
|
bool isShuffled;
|
|
bool isPlaying;
|
|
Duration currentDuration;
|
|
double volume;
|
|
|
|
// class dependencies
|
|
LinuxAudioService? _linuxAudioService;
|
|
MobileAudioService? mobileAudioService;
|
|
|
|
// foreign/passed properties
|
|
AudioPlayer player;
|
|
YoutubeExplode youtube;
|
|
Ref ref;
|
|
UserPreferences get preferences => ref.read(userPreferencesProvider);
|
|
|
|
// playlist & track list properties
|
|
late LazyBox<CacheTrack> cache;
|
|
CurrentPlaylist? playlist;
|
|
SpotubeTrack? track;
|
|
|
|
// internal stuff
|
|
final List<StreamSubscription> _subscriptions;
|
|
final _logger = getLogger(Playback);
|
|
// state of preSearch used inside [onPositionChanged]
|
|
bool _isPreSearching = false;
|
|
|
|
PlaybackStatus status;
|
|
|
|
Playback({
|
|
required this.player,
|
|
required this.youtube,
|
|
required this.ref,
|
|
this.mobileAudioService,
|
|
}) : volume = 0,
|
|
isShuffled = false,
|
|
isPlaying = false,
|
|
currentDuration = Duration.zero,
|
|
_subscriptions = [],
|
|
status = PlaybackStatus.idle,
|
|
super() {
|
|
if (kIsLinux) {
|
|
_linuxAudioService = LinuxAudioService(this);
|
|
}
|
|
|
|
(() async {
|
|
cache = await Hive.openLazyBox<CacheTrack>("track-cache");
|
|
|
|
if (kIsAndroid) {
|
|
await player.setVolume(1);
|
|
volume = 1;
|
|
} else {
|
|
await player.setVolume(volume);
|
|
}
|
|
|
|
_subscriptions.addAll([
|
|
player.onPlayerStateChanged.listen(
|
|
(state) async {
|
|
isPlaying = state == PlayerState.playing;
|
|
notifyListeners();
|
|
},
|
|
),
|
|
player.onPlayerComplete.listen((_) {
|
|
if (track?.id != null) {
|
|
seekForward();
|
|
} else {
|
|
isPlaying = false;
|
|
status = PlaybackStatus.idle;
|
|
currentDuration = Duration.zero;
|
|
notifyListeners();
|
|
}
|
|
}),
|
|
player.onDurationChanged.listen((event) {
|
|
if (event != currentDuration) {
|
|
currentDuration = event;
|
|
notifyListeners();
|
|
}
|
|
}),
|
|
player.onPositionChanged.listen((pos) async {
|
|
if (pos > Duration.zero && currentDuration == Duration.zero) {
|
|
currentDuration = await player.getDuration() ?? Duration.zero;
|
|
notifyListeners();
|
|
}
|
|
|
|
final currentTrackIndex =
|
|
playlist?.tracks.indexWhere((t) => t.id == track?.id);
|
|
|
|
// when the track progress is above 80%, track isn't the last
|
|
// and is not already fetched and nothing is fetching currently
|
|
if (pos.inSeconds > currentDuration.inSeconds * .8 &&
|
|
playlist != null &&
|
|
currentTrackIndex != playlist!.tracks.length - 1 &&
|
|
playlist!.tracks.elementAt(currentTrackIndex! + 1)
|
|
is! SpotubeTrack &&
|
|
!_isPreSearching) {
|
|
_isPreSearching = true;
|
|
playlist!.tracks[currentTrackIndex + 1] = await toSpotubeTrack(
|
|
playlist!.tracks[currentTrackIndex + 1],
|
|
).then((v) {
|
|
_isPreSearching = false;
|
|
return v;
|
|
});
|
|
}
|
|
if (track != null && preferences.skipSponsorSegments) {
|
|
for (final segment in track!.skipSegments) {
|
|
if (pos.inSeconds == segment["start"] ||
|
|
(pos.inSeconds > segment["start"]! &&
|
|
pos.inSeconds < segment["end"]!)) {
|
|
seekPosition(Duration(seconds: segment["end"]!));
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
]);
|
|
}());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_linuxAudioService?.dispose();
|
|
for (var subscription in _subscriptions) {
|
|
subscription.cancel();
|
|
}
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> playPlaylist(CurrentPlaylist playlist, [int index = 0]) async {
|
|
try {
|
|
if (index < 0 || index > playlist.tracks.length - 1) return;
|
|
if (isPlaying || status == PlaybackStatus.playing) await stop();
|
|
this.playlist = playlist;
|
|
final played = this.playlist!.tracks[index];
|
|
status = PlaybackStatus.loading;
|
|
notifyListeners();
|
|
await play(played).then((_) {
|
|
int i = this
|
|
.playlist!
|
|
.tracks
|
|
.indexWhere((element) => element.id == played.id);
|
|
if (index == -1) return;
|
|
this.playlist!.tracks[i] = track!;
|
|
});
|
|
} catch (e) {
|
|
_logger.e("[playPlaylist] $e");
|
|
}
|
|
}
|
|
|
|
// player methods
|
|
Future<void> play(Track track) async {
|
|
_logger.v("[Track Playing] ${track.name} - ${track.id}");
|
|
try {
|
|
// the track is already playing so no need to change that
|
|
if (track.id == this.track?.id) return;
|
|
if (status != PlaybackStatus.loading) {
|
|
status = PlaybackStatus.loading;
|
|
notifyListeners();
|
|
}
|
|
|
|
// the track is not a SpotubeTrack so turning it to one
|
|
if (track is! SpotubeTrack) {
|
|
track = await toSpotubeTrack(track);
|
|
}
|
|
|
|
final tag = MediaItem(
|
|
id: track.id!,
|
|
title: track.name!,
|
|
album: track.album?.name,
|
|
artist: TypeConversionUtils.artists_X_String(
|
|
track.artists ?? <ArtistSimple>[]),
|
|
artUri: Uri.parse(
|
|
TypeConversionUtils.image_X_UrlString(
|
|
track.album?.images,
|
|
),
|
|
),
|
|
duration: track.ytTrack.duration,
|
|
);
|
|
mobileAudioService?.addItem(tag);
|
|
_logger.v("[Track Direct Source] - ${(track).ytUri}");
|
|
this.track = track;
|
|
notifyListeners();
|
|
updatePersistence();
|
|
await player.play(
|
|
track.ytUri.startsWith("http")
|
|
? UrlSource(track.ytUri)
|
|
: DeviceFileSource(track.ytUri),
|
|
);
|
|
status = PlaybackStatus.playing;
|
|
notifyListeners();
|
|
} catch (e, stack) {
|
|
_logger.e("play", e, stack);
|
|
}
|
|
}
|
|
|
|
Future<void> resume() async {
|
|
if (isPlaying || (playlist == null && track == null)) return;
|
|
await player.resume();
|
|
isPlaying = true;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> pause() async {
|
|
if (!isPlaying || (playlist == null && track == null)) return;
|
|
await player.pause();
|
|
isPlaying = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> togglePlayPause() async {
|
|
isPlaying ? await pause() : await resume();
|
|
}
|
|
|
|
toggleShuffle() {
|
|
final result = isShuffled ? playlist?.unshuffle() : playlist?.shuffle();
|
|
if (result == true) {
|
|
isShuffled = !isShuffled;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
Future<void> seekPosition(Duration position) {
|
|
return player.seek(position);
|
|
}
|
|
|
|
Future<void> setVolume(double newVolume) async {
|
|
await player.setVolume(volume);
|
|
volume = newVolume;
|
|
notifyListeners();
|
|
updatePersistence();
|
|
}
|
|
|
|
Future<void> stop() async {
|
|
await player.stop();
|
|
await player.release();
|
|
isPlaying = false;
|
|
isShuffled = false;
|
|
playlist = null;
|
|
track = null;
|
|
status = PlaybackStatus.idle;
|
|
currentDuration = Duration.zero;
|
|
notifyListeners();
|
|
updatePersistence(clearNullEntries: true);
|
|
}
|
|
|
|
void destroy() {
|
|
stop();
|
|
player.dispose();
|
|
}
|
|
|
|
Future<T> raceMultiple<T>(
|
|
Future<T> Function() inner, {
|
|
Duration timeout = const Duration(milliseconds: 2500),
|
|
int retryCount = 4,
|
|
}) async {
|
|
return Future.any(
|
|
List.generate(retryCount, (i) {
|
|
if (i == 0) return inner();
|
|
return Future.delayed(
|
|
Duration(milliseconds: timeout.inMilliseconds * i),
|
|
inner,
|
|
);
|
|
}),
|
|
);
|
|
}
|
|
|
|
Future<List<Map<String, int>>> getSkipSegments(String id) async {
|
|
if (!preferences.skipSponsorSegments) return [];
|
|
try {
|
|
final res = await http.get(Uri(
|
|
scheme: "https",
|
|
host: "sponsor.ajay.app",
|
|
path: "/api/skipSegments",
|
|
queryParameters: {
|
|
"videoID": id,
|
|
"category": [
|
|
'sponsor',
|
|
'selfpromo',
|
|
'interaction',
|
|
'intro',
|
|
'outro',
|
|
'music_offtopic'
|
|
],
|
|
"actionType": 'skip'
|
|
},
|
|
));
|
|
|
|
final data = jsonDecode(res.body);
|
|
final segments = data.map((obj) {
|
|
return Map.castFrom<String, dynamic, String, int>({
|
|
"start": obj["segment"].first.toInt(),
|
|
"end": obj["segment"].last.toInt(),
|
|
});
|
|
}).toList();
|
|
_logger.v(
|
|
"[SponsorBlock] successfully fetched skip segments for ${track?.name} | ${track?.ytTrack.id.value}",
|
|
);
|
|
return List.castFrom<dynamic, Map<String, int>>(segments);
|
|
} catch (e, stack) {
|
|
_logger.e("[getSkipSegments]", e, stack);
|
|
return List.castFrom<dynamic, Map<String, int>>([]);
|
|
}
|
|
}
|
|
|
|
// playlist & track list methods
|
|
Future<SpotubeTrack> toSpotubeTrack(
|
|
Track track, {
|
|
bool noSponsorBlock = false,
|
|
}) async {
|
|
try {
|
|
final format = preferences.ytSearchFormat;
|
|
final matchAlgorithm = preferences.trackMatchAlgorithm;
|
|
final artistsName = track.artists
|
|
?.map((ar) => ar.name)
|
|
.toList()
|
|
.whereNotNull()
|
|
.toList() ??
|
|
[];
|
|
final audioQuality = preferences.audioQuality;
|
|
_logger.v("[Track Search Artists] $artistsName");
|
|
final mainArtist = artistsName.first;
|
|
final featuredArtists = artistsName.length > 1
|
|
? "feat. " + artistsName.sublist(1).join(" ")
|
|
: "";
|
|
final title = ServiceUtils.getTitle(
|
|
track.name!,
|
|
artists: artistsName,
|
|
onlyCleanArtist: true,
|
|
).trim();
|
|
_logger.v("[Track Search Title] $title");
|
|
final queryString = format
|
|
.replaceAll("\$MAIN_ARTIST", mainArtist)
|
|
.replaceAll("\$TITLE", title)
|
|
.replaceAll("\$FEATURED_ARTISTS", featuredArtists);
|
|
_logger.v("[Youtube Search Term] $queryString");
|
|
|
|
Video ytVideo;
|
|
final cachedTrack = await cache.get(track.id);
|
|
if (cachedTrack != null && cachedTrack.mode == matchAlgorithm.name) {
|
|
_logger.v(
|
|
"[Playing track from cache] youtubeId: ${cachedTrack.id} mode: ${cachedTrack.mode}",
|
|
);
|
|
ytVideo = VideoFromCacheTrackExtension.fromCacheTrack(cachedTrack);
|
|
} else {
|
|
VideoSearchList videos =
|
|
await raceMultiple(() => youtube.search.search(queryString));
|
|
if (matchAlgorithm != SpotubeTrackMatchAlgorithm.youtube) {
|
|
List<Map> ratedRankedVideos = videos
|
|
.map((video) {
|
|
// the find should be lazy thus everything case insensitive
|
|
final ytTitle = video.title.toLowerCase();
|
|
final bool hasTitle = ytTitle.contains(title);
|
|
final bool hasAllArtists = track.artists?.every(
|
|
(artist) => ytTitle.contains(artist.name!.toLowerCase()),
|
|
) ??
|
|
false;
|
|
final bool authorIsArtist =
|
|
track.artists?.first.name?.toLowerCase() ==
|
|
video.author.toLowerCase();
|
|
|
|
final bool hasNoLiveInTitle =
|
|
!PrimitiveUtils.containsTextInBracket(ytTitle, "live");
|
|
|
|
int rate = 0;
|
|
for (final el in [
|
|
hasTitle,
|
|
hasAllArtists,
|
|
if (matchAlgorithm ==
|
|
SpotubeTrackMatchAlgorithm.authenticPopular)
|
|
authorIsArtist,
|
|
hasNoLiveInTitle,
|
|
!video.isLive,
|
|
]) {
|
|
if (el) rate++;
|
|
}
|
|
// can't let pass any non title matching track
|
|
if (!hasTitle) rate = rate - 2;
|
|
return {
|
|
"video": video,
|
|
"points": rate,
|
|
"views": video.engagement.viewCount,
|
|
};
|
|
})
|
|
.toList()
|
|
.sortByProperties(
|
|
[false, false],
|
|
["points", "views"],
|
|
);
|
|
|
|
ytVideo = ratedRankedVideos.first["video"] as Video;
|
|
} else {
|
|
ytVideo = videos.where((video) => !video.isLive).first;
|
|
}
|
|
}
|
|
|
|
StreamManifest trackManifest = await raceMultiple(
|
|
() => youtube.videos.streams.getManifest(ytVideo.id),
|
|
);
|
|
|
|
_logger.v(
|
|
"[YouTube Matched Track] ${ytVideo.title} | ${ytVideo.author} - ${ytVideo.url}",
|
|
);
|
|
|
|
final audioManifest = trackManifest.audioOnly.where((info) {
|
|
final isMp4a = info.codec.mimeType == "audio/mp4";
|
|
if (kIsLinux) {
|
|
return !isMp4a;
|
|
} else if (kIsMacOS || kIsIOS) {
|
|
return isMp4a;
|
|
} else {
|
|
return true;
|
|
}
|
|
});
|
|
|
|
final ytUri = (audioQuality == AudioQuality.high
|
|
? audioManifest.withHighestBitrate()
|
|
: audioManifest.sortByBitrate().last)
|
|
.url
|
|
.toString();
|
|
|
|
final skipSegments = cachedTrack?.skipSegments != null &&
|
|
cachedTrack!.skipSegments!.isNotEmpty
|
|
? cachedTrack.skipSegments!
|
|
.map(
|
|
(segment) => segment.toJson(),
|
|
)
|
|
.toList()
|
|
: noSponsorBlock
|
|
? List.castFrom<dynamic, Map<String, int>>([])
|
|
: await getSkipSegments(ytVideo.id.value);
|
|
|
|
// only save when the track isn't available in the cache with same
|
|
// matchAlgorithm
|
|
if (cachedTrack == null || cachedTrack.mode != matchAlgorithm.name) {
|
|
await cache.put(
|
|
track.id!,
|
|
CacheTrack.fromVideo(
|
|
ytVideo,
|
|
matchAlgorithm.name,
|
|
skipSegments: skipSegments,
|
|
),
|
|
);
|
|
}
|
|
|
|
return SpotubeTrack.fromTrack(
|
|
track: track,
|
|
ytTrack: ytVideo,
|
|
// Since Mac OS's & IOS's CodeAudio doesn't support WebMedia
|
|
// ('audio/webm', 'video/webm' & 'image/webp') thus using 'audio/mpeg'
|
|
// codec/mimetype for those Platforms
|
|
ytUri: ytUri,
|
|
skipSegments: skipSegments,
|
|
);
|
|
} catch (e, stack) {
|
|
_logger.e("topSpotubeTrack", e, stack);
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> setPlaylistPosition(int position) async {
|
|
if (playlist == null) return;
|
|
await playPlaylist(playlist!, position);
|
|
}
|
|
|
|
Future<void> seekForward() async {
|
|
if (playlist == null || track == null) return;
|
|
final int nextTrackIndex =
|
|
(playlist!.trackIds.indexOf(track!.id!) + 1).toInt();
|
|
// checking if there's any track available forward
|
|
if (nextTrackIndex > (playlist?.tracks.length ?? 0) - 1) return;
|
|
await play(playlist!.tracks.elementAt(nextTrackIndex)).then((_) {
|
|
playlist!.tracks[nextTrackIndex] = track!;
|
|
});
|
|
}
|
|
|
|
Future<void> seekBackward() async {
|
|
if (playlist == null || track == null) return;
|
|
final int prevTrackIndex =
|
|
(playlist!.trackIds.indexOf(track!.id!) - 1).toInt();
|
|
// checking if there's any track available behind
|
|
if (prevTrackIndex < 0) return;
|
|
await play(playlist!.tracks.elementAt(prevTrackIndex)).then((_) {
|
|
playlist!.tracks[prevTrackIndex] = track!;
|
|
});
|
|
}
|
|
|
|
@override
|
|
FutureOr<void> loadFromLocal(Map<String, dynamic> map) async {
|
|
try {
|
|
if (map["playlist"] != null) {
|
|
playlist = CurrentPlaylist.fromJson(jsonDecode(map["playlist"]));
|
|
}
|
|
if (map["track"] != null) {
|
|
final Map<String, dynamic> trackMap = jsonDecode(map["track"]);
|
|
// for backwards compatibility
|
|
if (!trackMap.containsKey("skipSegments")) {
|
|
trackMap["skipSegments"] = await getSkipSegments(
|
|
trackMap["id"],
|
|
);
|
|
}
|
|
track = SpotubeTrack.fromJson(trackMap);
|
|
}
|
|
volume = map["volume"] ?? volume;
|
|
} catch (e) {
|
|
_logger.e("loadFromLocal", e);
|
|
}
|
|
}
|
|
|
|
@override
|
|
FutureOr<Map<String, dynamic>> toMap() {
|
|
return {
|
|
"playlist": playlist != null ? jsonEncode(playlist?.toJson()) : null,
|
|
"track": track != null ? jsonEncode(track?.toJson()) : null,
|
|
"volume": volume,
|
|
};
|
|
}
|
|
}
|
|
|
|
final playbackProvider = ChangeNotifierProvider<Playback>((ref) {
|
|
final youtube = ref.watch(youtubeProvider);
|
|
final player = ref.watch(audioPlayerProvider);
|
|
return Playback(
|
|
player: player,
|
|
youtube: youtube,
|
|
ref: ref,
|
|
);
|
|
});
|