refactor(playback): new immutable queue based playback manager

Dropping support for search format, track match algorithm in favor of server track cache and alternative track source
This commit is contained in:
Kingkor Roy Tirtho 2023-02-02 18:43:12 +06:00
parent ad90c11ab0
commit 312f7fbe77
44 changed files with 1398 additions and 1416 deletions

View File

@ -77,6 +77,6 @@ flutter {
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation 'com.android.support:multidex:1.0.3'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation 'com.android.support:multidex:2.0.1'
}

View File

@ -1,5 +1,5 @@
buildscript {
ext.kotlin_version = '1.6.10'
ext.kotlin_version = '1.7.21'
repositories {
google()
mavenCentral()

View File

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip

View File

@ -2,11 +2,10 @@ import 'package:flutter/cupertino.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/player/player_controls.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/utils/platform.dart';
import 'package:window_manager/window_manager.dart';
@ -23,23 +22,22 @@ class PlayPauseAction extends Action<PlayPauseIntent> {
if (PlayerControls.focusNode.canRequestFocus) {
PlayerControls.focusNode.requestFocus();
}
final playback = intent.ref.read(playbackProvider);
if (playback.track == null) {
final playlist = intent.ref.read(PlaylistQueueNotifier.provider);
final playlistNotifier = intent.ref.read(PlaylistQueueNotifier.notifier);
if (playlist == null) {
return null;
} else if (playback.track != null &&
playback.currentDuration == Duration.zero &&
await playback.player.getCurrentPosition() == Duration.zero) {
if (playback.track!.ytUri.startsWith("http")) {
final track = Track.fromJson(playback.track!.toJson());
playback.track = null;
await playback.play(track);
} else if (!PlaylistQueueNotifier.isPlaying) {
// if (playlist.activeTrack is SpotubeTrack &&
// (playlist.activeTrack as SpotubeTrack).ytUri.startsWith("http")) {
// final track =
// Track.fromJson((playlist.activeTrack as SpotubeTrack).toJson());
// await playlistNotifier.play(track);
// } else {
// }
await playlistNotifier.play();
} else {
final track = playback.track;
playback.track = null;
await playback.play(track!);
}
} else {
await playback.togglePlayPause();
await playlistNotifier.pause();
}
return null;
}
@ -102,9 +100,9 @@ class SeekIntent extends Intent {
class SeekAction extends Action<SeekIntent> {
@override
invoke(intent) async {
final playback = intent.ref.read(playbackProvider);
if ((playback.playlist == null && playback.track == null) ||
playback.status == PlaybackStatus.loading) {
final playlist = intent.ref.read(PlaylistQueueNotifier.provider);
final playlistNotifier = intent.ref.read(PlaylistQueueNotifier.notifier);
if (playlist == null || playlist.isLoading) {
DirectionalFocusAction().invoke(
DirectionalFocusIntent(
intent.forward ? TraversalDirection.right : TraversalDirection.left,
@ -113,8 +111,8 @@ class SeekAction extends Action<SeekIntent> {
return null;
}
final position =
(await playback.player.getCurrentPosition() ?? Duration.zero).inSeconds;
await playback.seekPosition(
(await audioPlayer.getCurrentPosition() ?? Duration.zero).inSeconds;
await playlistNotifier.seek(
Duration(
seconds: intent.forward ? position + 5 : position - 5,
),

View File

@ -1,11 +1,11 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/hooks/use_breakpoint_value.dart';
import 'package:spotube/models/current_playlist.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
@ -20,9 +20,10 @@ class AlbumCard extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider);
bool isPlaylistPlaying =
playback.playlist != null && playback.playlist!.id == album.id;
final playlist = ref.watch(PlaylistQueueNotifier.provider);
final playing = useStream(PlaylistQueueNotifier.playing).data ?? false;
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
bool isPlaylistPlaying = playlistNotifier.isPlayingPlaylist(album.tracks!);
final int marginH =
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
return PlaybuttonCard(
@ -32,9 +33,8 @@ class AlbumCard extends HookConsumerWidget {
),
viewType: viewType,
margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()),
isPlaying: isPlaylistPlaying && playback.isPlaying,
isLoading: playback.status == PlaybackStatus.loading &&
playback.playlist?.id == album.id,
isPlaying: isPlaylistPlaying && playing,
isLoading: isPlaylistPlaying && playlist?.isLoading == true,
title: album.name!,
description:
"Album • ${TypeConversionUtils.artists_X_String<ArtistSimple>(album.artists ?? [])}",
@ -43,10 +43,10 @@ class AlbumCard extends HookConsumerWidget {
},
onPlaybuttonPressed: () async {
SpotifyApi spotify = ref.read(spotifyProvider);
if (isPlaylistPlaying && playback.isPlaying) {
return playback.pause();
} else if (isPlaylistPlaying && !playback.isPlaying) {
return playback.resume();
if (isPlaylistPlaying && playing) {
return playlistNotifier.pause();
} else if (isPlaylistPlaying && !playing) {
return playlistNotifier.resume();
}
List<Track> tracks = (await spotify.albums.getTracks(album.id!).all())
.map((track) =>
@ -54,12 +54,7 @@ class AlbumCard extends HookConsumerWidget {
.toList();
if (tracks.isEmpty) return;
await playback.playPlaylist(CurrentPlaylist(
tracks: tracks,
id: album.id!,
name: album.name!,
thumbnail: album.images!.first.url!,
));
await playlistNotifier.loadAndPlay(tracks);
},
);
}

View File

@ -21,9 +21,8 @@ import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
import 'package:spotube/components/shared/track_table/track_tile.dart';
import 'package:spotube/hooks/use_async_effect.dart';
import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/models/current_playlist.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/primitive_utils.dart';
@ -58,7 +57,7 @@ enum SortBy {
dateAdded,
}
final localTracksProvider = FutureProvider<List<Track>>((ref) async {
final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {
try {
if (kIsWeb) return [];
final downloadLocation = ref.watch(
@ -97,9 +96,8 @@ final localTracksProvider = FutureProvider<List<Track>>((ref) async {
return {"metadata": metadata, "file": f, "art": imageFile.path};
} on FfiException catch (e) {
if (e.message == "NoTag: reader does not contain an id3 tag") {
getLogger(FutureProvider<List<Track>>)
.v("[Fetching metadata]", e.message);
if (e.message != "NoTag: reader does not contain an id3 tag") {
rethrow;
}
return {};
} catch (e, stack) {
@ -114,11 +112,14 @@ final localTracksProvider = FutureProvider<List<Track>>((ref) async {
final tracks = filesWithMetadata
.map(
(fileWithMetadata) => TypeConversionUtils.localTrack_X_Track(
(fileWithMetadata) => LocalTrack.fromTrack(
track: TypeConversionUtils.localTrack_X_Track(
fileWithMetadata["file"],
metadata: fileWithMetadata["metadata"],
art: fileWithMetadata["art"],
),
path: fileWithMetadata["file"].path,
),
)
.toList();
@ -132,37 +133,36 @@ final localTracksProvider = FutureProvider<List<Track>>((ref) async {
class UserLocalTracks extends HookConsumerWidget {
const UserLocalTracks({Key? key}) : super(key: key);
void playLocalTracks(Playback playback, List<Track> tracks,
{Track? currentTrack}) async {
void playLocalTracks(
PlaylistQueueNotifier playback,
List<LocalTrack> tracks, {
LocalTrack? currentTrack,
}) async {
currentTrack ??= tracks.first;
final isPlaylistPlaying = playback.playlist?.id == "local";
final isPlaylistPlaying = playback.isPlayingPlaylist(tracks);
if (!isPlaylistPlaying) {
await playback.playPlaylist(
CurrentPlaylist(
tracks: tracks,
id: "local",
name: "Local Tracks",
thumbnail: TypeConversionUtils.image_X_UrlString(
null,
placeholder: ImagePlaceholder.collection,
),
isLocal: true,
),
tracks.indexWhere((s) => s.id == currentTrack?.id),
await playback.loadAndPlay(
tracks,
active: tracks.indexWhere((s) => s.id == currentTrack?.id),
);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != playback.track?.id) {
await playback.play(currentTrack);
currentTrack.id != playback.state?.activeTrack.id) {
await playback.playAt(
tracks.indexWhere((s) => s.id == currentTrack?.id),
);
}
}
@override
Widget build(BuildContext context, ref) {
final sortBy = useState<SortBy>(SortBy.none);
final playback = ref.watch(playbackProvider);
final isPlaylistPlaying = playback.playlist?.id == "local";
final playlist = ref.watch(PlaylistQueueNotifier.provider);
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
final trackSnapshot = ref.watch(localTracksProvider);
final isPlaylistPlaying = playlistNotifier.isPlayingPlaylist(
trackSnapshot.value ?? [],
);
final isMounted = useIsMounted();
final breakpoint = useBreakpoints();
@ -198,9 +198,10 @@ class UserLocalTracks extends HookConsumerWidget {
? () {
if (trackSnapshot.value?.isNotEmpty == true) {
if (!isPlaylistPlaying) {
playLocalTracks(playback, trackSnapshot.value!);
playLocalTracks(
playlistNotifier, trackSnapshot.value!);
} else {
playback.stop();
playlistNotifier.stop();
}
}
}
@ -267,17 +268,17 @@ class UserLocalTracks extends HookConsumerWidget {
itemBuilder: (context, index) {
final track = filteredTracks[index];
return TrackTile(
playback,
playlist,
duration:
"${track.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.duration?.inSeconds.remainder(60) ?? 0)}",
track: MapEntry(index, track),
isActive: playback.track?.id == track.id,
isActive: playlist?.activeTrack.id == track.id,
isChecked: false,
showCheck: false,
isLocal: true,
onTrackPlayButtonPressed: (currentTrack) {
return playLocalTracks(
playback,
playlistNotifier,
sortedTracks,
currentTrack: track,
);

View File

@ -5,14 +5,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:platform_ui/platform_ui.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/library/user_local_tracks.dart';
import 'package:spotube/components/player/player_queue.dart';
import 'package:spotube/components/player/sibling_tracks_sheet.dart';
import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/auth_provider.dart';
import 'package:spotube/provider/downloader_provider.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlayerActions extends HookConsumerWidget {
@ -29,26 +29,26 @@ class PlayerActions extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final Playback playback = ref.watch(playbackProvider);
final isLocalTrack = playback.playlist?.isLocal == true;
final playlist = ref.watch(PlaylistQueueNotifier.provider);
final isLocalTrack = playlist?.activeTrack is LocalTrack;
final downloader = ref.watch(downloaderProvider);
final isInQueue =
downloader.inQueue.any((element) => element.id == playback.track?.id);
final localTracks = ref.watch(localTracksProvider).value;
final isInQueue = downloader.inQueue
.any((element) => element.id == playlist?.activeTrack.id);
final localTracks = [] /* ref.watch(localTracksProvider).value */;
final auth = ref.watch(authProvider);
final isDownloaded = useMemoized(() {
return localTracks?.any(
return localTracks.any(
(element) =>
element.name == playback.track?.name &&
element.album?.name == playback.track?.album?.name &&
element.name == playlist?.activeTrack.name &&
element.album?.name == playlist?.activeTrack.album?.name &&
TypeConversionUtils.artists_X_String<Artist>(
element.artists ?? []) ==
TypeConversionUtils.artists_X_String<Artist>(
playback.track?.artists ?? []),
playlist?.activeTrack.artists ?? []),
) ==
true;
}, [localTracks, playback.track]);
}, [localTracks, playlist?.activeTrack]);
return Row(
mainAxisAlignment: mainAxisAlignment,
@ -56,7 +56,7 @@ class PlayerActions extends HookConsumerWidget {
PlatformIconButton(
icon: const Icon(SpotubeIcons.queue),
tooltip: 'Queue',
onPressed: playback.playlist != null
onPressed: playlist != null
? () {
showModalBottomSheet(
context: context,
@ -82,7 +82,7 @@ class PlayerActions extends HookConsumerWidget {
PlatformIconButton(
icon: const Icon(SpotubeIcons.alternativeRoute),
tooltip: "Alternative Track Sources",
onPressed: playback.track != null
onPressed: playlist?.activeTrack != null
? () {
showModalBottomSheet(
context: context,
@ -119,12 +119,12 @@ class PlayerActions extends HookConsumerWidget {
icon: Icon(
isDownloaded ? SpotubeIcons.done : SpotubeIcons.download,
),
onPressed: playback.track != null
? () => downloader.addToQueue(playback.track!)
onPressed: playlist?.activeTrack != null
? () => downloader.addToQueue(playlist!.activeTrack)
: null,
),
if (playback.track != null && !isLocalTrack && auth.isLoggedIn)
TrackHeartButton(track: playback.track!),
if (playlist?.activeTrack != null && !isLocalTrack && auth.isLoggedIn)
TrackHeartButton(track: playlist!.activeTrack),
...(extraActions ?? [])
],
);

View File

@ -4,10 +4,9 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:platform_ui/platform_ui.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/hooks/playback_hooks.dart';
import 'package:spotube/collections/intents.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/utils/primitive_utils.dart';
class PlayerControls extends HookConsumerWidget {
@ -37,12 +36,9 @@ class PlayerControls extends HookConsumerWidget {
SeekIntent: SeekAction(),
},
[]);
final Playback playback = ref.watch(playbackProvider);
final onNext = useNextTrack(ref);
final onPrevious = usePreviousTrack(ref);
final duration = playback.currentDuration;
final playlist = ref.watch(PlaylistQueueNotifier.provider);
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
final playing = useStream(PlaylistQueueNotifier.playing).data ?? false;
return GestureDetector(
behavior: HitTestBehavior.translucent,
@ -59,27 +55,26 @@ class PlayerControls extends HookConsumerWidget {
constraints: const BoxConstraints(maxWidth: 600),
child: Column(
children: [
StreamBuilder<Duration>(
stream: playback.player.onPositionChanged,
builder: (context, snapshot) {
HookBuilder(
builder: (context) {
final duration =
useStream(PlaylistQueueNotifier.duration).data ??
Duration.zero;
final positionSnapshot =
useStream(PlaylistQueueNotifier.position);
final position = positionSnapshot.data ?? Duration.zero;
final totalMinutes = PrimitiveUtils.zeroPadNumStr(
duration.inMinutes.remainder(60));
final totalSeconds = PrimitiveUtils.zeroPadNumStr(
duration.inSeconds.remainder(60));
final currentMinutes = snapshot.hasData
? PrimitiveUtils.zeroPadNumStr(
snapshot.data!.inMinutes.remainder(60))
: "00";
final currentSeconds = snapshot.hasData
? PrimitiveUtils.zeroPadNumStr(
snapshot.data!.inSeconds.remainder(60))
: "00";
final currentMinutes = PrimitiveUtils.zeroPadNumStr(
position.inMinutes.remainder(60));
final currentSeconds = PrimitiveUtils.zeroPadNumStr(
position.inSeconds.remainder(60));
final sliderMax = duration.inSeconds;
final sliderValue = snapshot.data?.inSeconds ?? 0;
final sliderValue = position.inSeconds;
return HookBuilder(
builder: (context) {
final progressStatic =
(sliderMax == 0 || sliderValue > sliderMax)
? 0
@ -94,6 +89,19 @@ class PlayerControls extends HookConsumerWidget {
return null;
}, [progressStatic]);
useEffect(() {
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (positionSnapshot.hasData &&
duration == Duration.zero) {
await Future.delayed(const Duration(milliseconds: 200));
await playlistNotifier.pause();
await Future.delayed(const Duration(milliseconds: 400));
await playlistNotifier.resume();
}
});
return null;
}, [positionSnapshot.hasData, duration]);
return Column(
children: [
PlatformTooltip(
@ -107,7 +115,7 @@ class PlayerControls extends HookConsumerWidget {
progress.value = v;
},
onChangeEnd: (value) async {
await playback.seekPosition(
await playlistNotifier.seek(
Duration(
seconds: (value * sliderMax).toInt(),
),
@ -133,26 +141,28 @@ class PlayerControls extends HookConsumerWidget {
],
);
},
);
},
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
PlatformIconButton(
tooltip: playback.isShuffled
tooltip: playlistNotifier.isShuffled
? "Unshuffle playlist"
: "Shuffle playlist",
icon: Icon(
SpotubeIcons.shuffle,
color: playback.isShuffled
color: playlistNotifier.isShuffled
? PlatformTheme.of(context).primaryColor
: null,
),
onPressed: playback.playlist == null
onPressed: playlist == null
? null
: () {
playback.setIsShuffled(!playback.isShuffled);
if (playlistNotifier.isShuffled) {
playlistNotifier.unshuffle();
} else {
playlistNotifier.shuffle();
}
},
),
PlatformIconButton(
@ -161,23 +171,18 @@ class PlayerControls extends HookConsumerWidget {
SpotubeIcons.skipBack,
color: iconColor,
),
onPressed: () {
onPrevious();
}),
onPressed: playlistNotifier.previous,
),
PlatformIconButton(
tooltip: playback.isPlaying
? "Pause playback"
: "Resume playback",
icon: playback.status == PlaybackStatus.loading
tooltip: playing ? "Pause playback" : "Resume playback",
icon: playlist?.isLoading == true
? const SizedBox(
height: 20,
width: 20,
child: PlatformCircularProgressIndicator(),
)
: Icon(
playback.isPlaying
? SpotubeIcons.pause
: SpotubeIcons.play,
playing ? SpotubeIcons.pause : SpotubeIcons.play,
color: iconColor,
),
onPressed: Actions.handler<PlayPauseIntent>(
@ -191,7 +196,7 @@ class PlayerControls extends HookConsumerWidget {
SpotubeIcons.skipForward,
color: iconColor,
),
onPressed: () => onNext(),
onPressed: playlistNotifier.next,
),
PlatformIconButton(
tooltip: "Stop playback",
@ -199,23 +204,23 @@ class PlayerControls extends HookConsumerWidget {
SpotubeIcons.stop,
color: iconColor,
),
onPressed: playback.track != null ? playback.stop : null,
),
PlatformIconButton(
tooltip:
!playback.isLoop ? "Loop Track" : "Repeat playlist",
icon: Icon(
playback.isLoop
? SpotubeIcons.repeatOne
: SpotubeIcons.repeat,
),
onPressed:
playback.track == null || playback.playlist == null
? null
: () {
playback.setIsLoop(!playback.isLoop);
},
onPressed: playlist != null ? playlistNotifier.stop : null,
),
// PlatformIconButton(
// tooltip:
// !playlist.isLoop ? "Loop Track" : "Repeat playlist",
// icon: Icon(
// playlist.isLoop
// ? SpotubeIcons.repeatOne
// : SpotubeIcons.repeat,
// ),
// onPressed:
// playlist.track == null || playlist.playlist == null
// ? null
// : () {
// playlist.setIsLoop(!playlist.isLoop);
// },
// ),
],
),
const SizedBox(height: 5)

View File

@ -1,15 +1,15 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:platform_ui/platform_ui.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/player/player_track_details.dart';
import 'package:spotube/hooks/playback_hooks.dart';
import 'package:spotube/hooks/use_palette_color.dart';
import 'package:spotube/collections/intents.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/utils/service_utils.dart';
class PlayerOverlay extends HookConsumerWidget {
@ -24,16 +24,10 @@ class PlayerOverlay extends HookConsumerWidget {
Widget build(BuildContext context, ref) {
final paletteColor = usePaletteColor(albumArt, ref);
final canShow = ref.watch(
playbackProvider.select(
(s) =>
s.track != null ||
s.isPlaying ||
s.status == PlaybackStatus.loading,
),
PlaylistQueueNotifier.provider.select((s) => s != null),
);
final onNext = useNextTrack(ref);
final onPrevious = usePreviousTrack(ref);
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
final playing = useStream(PlaylistQueueNotifier.playing).data ?? false;
return GestureDetector(
onVerticalDragEnd: (details) {
@ -87,14 +81,13 @@ class PlayerOverlay extends HookConsumerWidget {
SpotubeIcons.skipBack,
color: paletteColor.bodyTextColor,
),
onPressed: () {
onPrevious();
}),
onPressed: playlistNotifier.previous,
),
Consumer(
builder: (context, ref, _) {
return IconButton(
icon: Icon(
ref.read(playbackProvider).isPlaying
playing
? SpotubeIcons.pause
: SpotubeIcons.play,
color: paletteColor.bodyTextColor,
@ -111,7 +104,7 @@ class PlayerOverlay extends HookConsumerWidget {
SpotubeIcons.skipForward,
color: paletteColor.bodyTextColor,
),
onPressed: () => onNext(),
onPressed: playlistNotifier.next,
),
],
),

View File

@ -8,7 +8,7 @@ import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:spotube/components/shared/fallbacks/not_found.dart';
import 'package:spotube/components/shared/track_table/track_tile.dart';
import 'package:spotube/hooks/use_auto_scroll_controller.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/utils/primitive_utils.dart';
class PlayerQueue extends HookConsumerWidget {
@ -20,9 +20,10 @@ class PlayerQueue extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final playback = ref.watch(playbackProvider);
final playlist = ref.watch(PlaylistQueueNotifier.provider);
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
final controller = useAutoScrollController();
final tracks = playback.playlist?.tracks ?? [];
final tracks = playlist?.tracks ?? {};
if (tracks.isEmpty) {
return const NotFound(vertical: true);
@ -38,9 +39,8 @@ class PlayerQueue extends HookConsumerWidget {
PlatformTheme.of(context).textTheme?.subheading?.color;
useEffect(() {
if (playback.track == null || playback.playlist == null) return null;
final index = playback.playlist!.tracks
.indexWhere((track) => track.id == playback.track!.id);
if (playlist == null) return null;
final index = playlist.active;
if (index < 0) return;
controller.scrollToIndex(
index,
@ -77,14 +77,6 @@ class PlayerQueue extends HookConsumerWidget {
),
),
PlatformText.subheading("Queue"),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: PlatformText(
playback.playlist?.name ?? "",
overflow: TextOverflow.ellipsis,
style: PlatformTextTheme.of(context).body,
),
),
const SizedBox(height: 10),
Flexible(
child: ListView.builder(
@ -92,7 +84,7 @@ class PlayerQueue extends HookConsumerWidget {
itemCount: tracks.length,
shrinkWrap: true,
itemBuilder: (context, i) {
final track = tracks.asMap().entries.elementAt(i);
final track = tracks.toList().asMap().entries.elementAt(i);
String duration =
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
return AutoScrollTag(
@ -102,13 +94,15 @@ class PlayerQueue extends HookConsumerWidget {
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: TrackTile(
playback,
playlist,
track: track,
duration: duration,
isActive: playback.track?.id == track.value.id,
isActive: playlist?.activeTrack.id == track.value.id,
onTrackPlayButtonPressed: (currentTrack) async {
if (playback.track?.id == track.value.id) return;
await playback.setPlaylistPosition(i);
if (playlist?.activeTrack.id == track.value.id) {
return;
}
await playlistNotifier.playAt(i);
},
),
),

View File

@ -4,7 +4,7 @@ import 'package:platform_ui/platform_ui.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlayerTrackDetails extends HookConsumerWidget {
@ -16,7 +16,7 @@ class PlayerTrackDetails extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final breakpoint = useBreakpoints();
final playback = ref.watch(playbackProvider);
final playback = ref.watch(PlaylistQueueNotifier.provider);
return Row(
children: [
@ -38,7 +38,7 @@ class PlayerTrackDetails extends HookConsumerWidget {
if (breakpoint.isLessThanOrEqualTo(Breakpoints.md))
Flexible(
child: PlatformText(
playback.track?.name ?? "Not playing",
playback?.activeTrack.name ?? "Not playing",
overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.bold, color: color),
),
@ -51,12 +51,12 @@ class PlayerTrackDetails extends HookConsumerWidget {
child: Column(
children: [
PlatformText(
playback.track?.name ?? "Not playing",
playback?.activeTrack.name ?? "Not playing",
overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.bold, color: color),
),
TypeConversionUtils.artists_X_ClickableArtists(
playback.track?.artists ?? [],
playback?.activeTrack.artists ?? [],
)
],
),

View File

@ -1,12 +1,13 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:platform_ui/platform_ui.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
class SiblingTracksSheet extends HookConsumerWidget {
final bool floating;
@ -17,7 +18,13 @@ class SiblingTracksSheet extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final playback = ref.watch(playbackProvider);
final playlist = ref.watch(PlaylistQueueNotifier.provider);
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
final siblings = playlist?.isLoading == false
? (playlist!.activeTrack as SpotubeTrack).siblings
: <Video>[];
final borderRadius = floating
? BorderRadius.circular(10)
: const BorderRadius.only(
@ -25,13 +32,6 @@ class SiblingTracksSheet extends HookConsumerWidget {
topRight: Radius.circular(10),
);
useEffect(() {
if (playback.siblingYtVideos.isEmpty) {
playback.toSpotubeTrack(playback.track!, ignoreCache: true);
}
return null;
}, [playback.siblingYtVideos]);
return BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 12.0,
@ -59,9 +59,9 @@ class SiblingTracksSheet extends HookConsumerWidget {
body: Container(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: ListView.builder(
itemCount: playback.siblingYtVideos.length,
itemCount: siblings.length,
itemBuilder: (context, index) {
final video = playback.siblingYtVideos[index];
final video = siblings[index];
return PlatformListTile(
title: PlatformText(video.title),
leading: Padding(
@ -81,12 +81,18 @@ class SiblingTracksSheet extends HookConsumerWidget {
),
),
subtitle: PlatformText(video.author),
enabled: playback.status != PlaybackStatus.loading,
selected: video.id == playback.track!.ytTrack.id,
enabled: playlist?.isLoading != true,
selected: playlist?.isLoading != true &&
video.id ==
(playlist?.activeTrack as SpotubeTrack).ytTrack.id,
selectedTileColor: Theme.of(context).popupMenuTheme.color,
onTap: () {
if (video.id != playback.track!.ytTrack.id) {
playback.changeToSiblingVideo(video, playback.track!);
onTap: () async {
if (playlist?.isLoading == false &&
video.id !=
(playlist?.activeTrack as SpotubeTrack)
.ytTrack
.id) {
await playlistNotifier.swapSibling(video);
}
},
);

View File

@ -1,11 +1,13 @@
import 'package:fl_query/fl_query.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/hooks/use_breakpoint_value.dart';
import 'package:spotube/models/current_playlist.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
@ -19,9 +21,15 @@ class PlaylistCard extends HookConsumerWidget {
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider);
bool isPlaylistPlaying =
playback.playlist != null && playback.playlist!.id == playlist.id;
final playlistQueue = ref.watch(PlaylistQueueNotifier.provider);
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
final playing = useStream(PlaylistQueueNotifier.playing).data ?? false;
final tracks = QueryBowl.of(context)
.getQuery<List<Track>, SpotifyApi>(
Queries.playlist.tracksOf(playlist.id!).queryKey)
?.data ??
[];
bool isPlaylistPlaying = playlistNotifier.isPlayingPlaylist(tracks);
final int marginH =
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
@ -33,8 +41,8 @@ class PlaylistCard extends HookConsumerWidget {
playlist.images,
placeholder: ImagePlaceholder.collection,
),
isPlaying: isPlaylistPlaying && playback.isPlaying,
isLoading: playback.status == PlaybackStatus.loading && isPlaylistPlaying,
isPlaying: isPlaylistPlaying && playing,
isLoading: isPlaylistPlaying && playlistQueue?.isLoading == true,
onTap: () {
ServiceUtils.navigate(
context,
@ -43,10 +51,10 @@ class PlaylistCard extends HookConsumerWidget {
);
},
onPlaybuttonPressed: () async {
if (isPlaylistPlaying && playback.isPlaying) {
return playback.pause();
} else if (isPlaylistPlaying && !playback.isPlaying) {
return playback.resume();
if (isPlaylistPlaying && playing) {
return playlistNotifier.pause();
} else if (isPlaylistPlaying && !playing) {
return playlistNotifier.resume();
}
SpotifyApi spotifyApi = ref.read(spotifyProvider);
@ -61,17 +69,7 @@ class PlaylistCard extends HookConsumerWidget {
if (tracks.isEmpty) return;
await playback.playPlaylist(
CurrentPlaylist(
tracks: tracks,
id: playlist.id!,
name: playlist.name!,
thumbnail: TypeConversionUtils.image_X_UrlString(
playlist.images,
placeholder: ImagePlaceholder.collection,
),
),
);
await playlistNotifier.loadAndPlay(tracks);
},
);
}

View File

@ -13,8 +13,8 @@ import 'package:spotube/components/player/player_controls.dart';
import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/hooks/use_platform_property.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:flutter/material.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
@ -24,21 +24,21 @@ class BottomPlayer extends HookConsumerWidget {
final logger = getLogger(BottomPlayer);
@override
Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider);
final playlist = ref.watch(PlaylistQueueNotifier.provider);
final layoutMode =
ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
final breakpoint = useBreakpoints();
String albumArt = useMemoized(
() => playback.track?.album?.images?.isNotEmpty == true
() => playlist?.activeTrack.album?.images?.isNotEmpty == true
? TypeConversionUtils.image_X_UrlString(
playback.track?.album?.images,
index: (playback.track?.album?.images?.length ?? 1) - 1,
playlist?.activeTrack.album?.images,
index: (playlist?.activeTrack.album?.images?.length ?? 1) - 1,
placeholder: ImagePlaceholder.albumArt,
)
: Assets.albumPlaceholder.path,
[playback.track?.album?.images],
[playlist?.activeTrack.album?.images],
);
// returning an empty non spacious Container as the overlay will take
@ -117,23 +117,26 @@ class BottomPlayer extends HookConsumerWidget {
height: 20,
constraints: const BoxConstraints(maxWidth: 200),
child: HookBuilder(builder: (context) {
final volume = useState(playback.volume);
final volumeState = ref.watch(VolumeProvider.provider);
final volumeNotifier =
ref.watch(VolumeProvider.provider.notifier);
final volume = useState(volumeState);
useEffect(() {
if (volume.value != playback.volume) {
volume.value = playback.volume;
if (volume.value != volumeState) {
volumeNotifier.setVolume(volume.value);
}
return null;
}, [playback.volume]);
}, [volumeState]);
return Listener(
onPointerSignal: (event) async {
if (event is PointerScrollEvent) {
if (event.scrollDelta.dy > 0) {
final value = volume.value - .2;
playback.setVolume(value < 0 ? 0 : value);
volumeNotifier.setVolume(value < 0 ? 0 : value);
} else {
final value = volume.value + .2;
playback.setVolume(value > 1 ? 1 : value);
volumeNotifier.setVolume(value > 1 ? 1 : value);
}
}
},
@ -147,8 +150,8 @@ class BottomPlayer extends HookConsumerWidget {
onChangeEnd: (value) async {
// You don't really need to know why but this
// way it works only
await playback.setVolume(value);
await playback.setVolume(value);
await volumeNotifier.setVolume(value);
await volumeNotifier.setVolume(value);
},
),
);

View File

@ -18,8 +18,8 @@ import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/auth_provider.dart';
import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/services/mutations/mutations.dart';
import 'package:spotube/services/queries/queries.dart';
@ -27,7 +27,7 @@ import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:tuple/tuple.dart';
class TrackTile extends HookConsumerWidget {
final Playback playback;
final PlaylistQueue? playlist;
final MapEntry<int, Track> track;
final String duration;
final void Function(Track currentTrack)? onTrackPlayButtonPressed;
@ -47,7 +47,7 @@ class TrackTile extends HookConsumerWidget {
final void Function(bool?)? onCheckChange;
TrackTile(
this.playback, {
this.playlist, {
required this.track,
required this.duration,
required this.isActive,
@ -240,8 +240,7 @@ class TrackTile extends HookConsumerWidget {
padding: const EdgeInsets.all(8.0),
child: PlatformIconButton(
icon: Icon(
playback.track?.id != null &&
playback.track?.id == track.value.id
playlist?.activeTrack.id == track.value.id
? SpotubeIcons.pause
: SpotubeIcons.play,
color: Colors.white,

View File

@ -13,7 +13,7 @@ import 'package:spotube/components/library/user_local_tracks.dart';
import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/downloader_provider.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:spotube/utils/service_utils.dart';
@ -40,7 +40,7 @@ class TracksTableView extends HookConsumerWidget {
@override
Widget build(context, ref) {
Playback playback = ref.watch(playbackProvider);
final playlist = ref.watch(PlaylistQueueNotifier.provider);
final downloader = ref.watch(downloaderProvider);
TextStyle tableHeadStyle =
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
@ -235,12 +235,12 @@ class TracksTableView extends HookConsumerWidget {
}
},
child: TrackTile(
playback,
playlist,
playlistId: playlistId,
track: track,
duration: duration,
userPlaylist: userPlaylist,
isActive: playback.track?.id == track.value.id,
isActive: playlist?.activeTrack.id == track.value.id,
onTrackPlayButtonPressed: onTrackPlayButtonPressed,
isChecked: selected.value.contains(track.value.id),
showCheck: showCheck.value,

View File

@ -1,5 +1,11 @@
import 'dart:convert';
import 'package:catcher/catcher.dart';
import 'package:http/http.dart';
import 'package:spotube/entities/cache_track.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/models/track.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/utils/duration.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
@ -106,3 +112,44 @@ extension VideoToJson on Video {
};
}
}
extension GetSkipSegments on Video {
Future<List<Map<String, int>>> getSkipSegments(
UserPreferences preferences) async {
if (!preferences.skipSponsorSegments) return [];
try {
final res = await get(Uri(
scheme: "https",
host: "sponsor.ajay.app",
path: "/api/skipSegments",
queryParameters: {
"videoID": id.value,
"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();
getLogger(Video).v(
"[SponsorBlock] successfully fetched skip segments for $title | ${id.value}",
);
return List.castFrom<dynamic, Map<String, int>>(segments);
} catch (e, stack) {
Catcher.reportCheckedError(e, stack);
return List.castFrom<dynamic, Map<String, int>>([]);
}
}
}

View File

@ -1,20 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/provider/playback_provider.dart';
Future<void> Function() useNextTrack(WidgetRef ref) {
return () async {
final playback = ref.read(playbackProvider);
await playback.player.pause();
await playback.player.seek(Duration.zero);
playback.seekForward();
};
}
Future<void> Function() usePreviousTrack(WidgetRef ref) {
return () async {
final playback = ref.read(playbackProvider);
await playback.player.pause();
await playback.player.seek(Duration.zero);
playback.seekBackward();
};
}

View File

@ -1,16 +1,13 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
int useSyncedLyrics(
WidgetRef ref,
Map<int, String> lyricsMap,
Duration delay,
) {
final player = ref.watch(playbackProvider.select(
(value) => (value.player),
));
final stream = player.onPositionChanged;
final stream = PlaylistQueueNotifier.position;
final currentTime = useState(0);

View File

@ -1,6 +1,5 @@
import 'dart:convert';
import 'package:audio_service/audio_service.dart';
import 'package:catcher/catcher.dart';
import 'package:fl_query/fl_query.dart';
import 'package:flutter/foundation.dart';
@ -20,10 +19,8 @@ import 'package:spotube/collections/intents.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/audio_player_provider.dart';
import 'package:spotube/provider/downloader_provider.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/provider/youtube_provider.dart';
import 'package:spotube/services/mobile_audio_service.dart';
import 'package:spotube/services/pocketbase.dart';
import 'package:spotube/themes/dark_theme.dart';
import 'package:spotube/themes/light_theme.dart';
@ -39,7 +36,7 @@ void main() async {
Hive.registerAdapter(CacheTrackEngagementAdapter());
Hive.registerAdapter(CacheTrackSkipSegmentAdapter());
await Env.configure();
await initializePocketBase();
if (kIsDesktop) {
await windowManager.ensureInitialized();
WindowOptions windowOptions = const WindowOptions(
@ -65,7 +62,6 @@ void main() async {
await windowManager.show();
});
}
MobileAudioService? audioServiceHandler;
Catcher(
debugConfig: CatcherOptions(
@ -98,36 +94,6 @@ void main() async {
builder: (context) {
return ProviderScope(
overrides: [
playbackProvider.overrideWith(
(ref) {
final youtube = ref.watch(youtubeProvider);
final player = ref.watch(audioPlayerProvider);
final playback = Playback(
player: player,
youtube: youtube,
ref: ref,
);
if (audioServiceHandler == null) {
AudioService.init(
builder: () => MobileAudioService(playback),
config: const AudioServiceConfig(
androidNotificationChannelId: 'com.krtirtho.Spotube',
androidNotificationChannelName: 'Spotube',
androidNotificationOngoing: true,
),
).then(
(value) {
playback.mobileAudioService = value;
audioServiceHandler = value;
},
);
}
return playback;
},
),
downloaderProvider.overrideWith(
(ref) {
return Downloader(
@ -169,6 +135,7 @@ void main() async {
);
},
);
await initializePocketBase();
}
class Spotube extends StatefulHookConsumerWidget {

View File

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

View File

@ -2,7 +2,15 @@ import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/video.dart';
import 'package:spotube/extensions/album_simple.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/models/track.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/services/pocketbase.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
import 'package:collection/collection.dart';
enum SpotubeTrackMatchAlgorithm {
// selects the first result returned from YouTube
@ -14,14 +22,16 @@ enum SpotubeTrackMatchAlgorithm {
}
class SpotubeTrack extends Track {
Video ytTrack;
String ytUri;
List<Map<String, int>> skipSegments;
final Video ytTrack;
final String ytUri;
final List<Map<String, int>> skipSegments;
final List<Video> siblings;
SpotubeTrack(
this.ytTrack,
this.ytUri,
this.skipSegments,
this.siblings,
) : super();
SpotubeTrack.fromTrack({
@ -29,6 +39,7 @@ class SpotubeTrack extends Track {
required this.ytTrack,
required this.ytUri,
required this.skipSegments,
required this.siblings,
}) : super() {
album = track.album;
artists = track.artists;
@ -50,6 +61,156 @@ class SpotubeTrack extends Track {
uri = track.uri;
}
static Future<SpotubeTrack> fromFetchTrack(
Track track, UserPreferences preferences) async {
final artists = (track.artists ?? [])
.map((ar) => ar.name)
.toList()
.whereNotNull()
.toList();
final title = ServiceUtils.getTitle(
track.name!,
artists: artists,
onlyCleanArtist: true,
).trim();
final cachedTracks = await pb.collection(BackendTrack.collection).getList(
filter: "spotify_id = '${track.id}'",
sort: "-votes",
page: 0,
perPage: 1,
);
final cachedTrack = cachedTracks.items.isNotEmpty
? BackendTrack.fromRecord(cachedTracks.items.first)
: null;
Video ytVideo;
List<Video> siblings = [];
if (cachedTrack != null) {
ytVideo = await VideoFromCacheTrackExtension.fromBackendTrack(
cachedTrack,
youtube,
);
} else {
VideoSearchList videos = await PrimitiveUtils.raceMultiple(
() => youtube.search.search("${artists.join(", ")} - $title"),
);
siblings = videos.take(10).toList();
ytVideo = videos.where((video) => !video.isLive).first;
}
StreamManifest trackManifest = await PrimitiveUtils.raceMultiple(
() => youtube.videos.streams.getManifest(ytVideo.id),
);
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 chosenStreamInfo = preferences.audioQuality == AudioQuality.high
? audioManifest.withHighestBitrate()
: audioManifest.sortByBitrate().last;
final ytUri = chosenStreamInfo.url.toString();
if (cachedTrack == null) {
await pb.collection(BackendTrack.collection).create(
body: BackendTrack(
spotifyId: track.id!,
youtubeId: ytVideo.id.value,
votes: 0,
).toJson(),
);
}
return SpotubeTrack.fromTrack(
track: track,
ytTrack: ytVideo,
ytUri: ytUri,
skipSegments: preferences.skipSponsorSegments
? await ytVideo.getSkipSegments(preferences)
: [],
siblings: siblings,
);
}
Future<SpotubeTrack?> swappedCopy(
Video video,
UserPreferences preferences,
) async {
if (siblings.none((element) => element.id == video.id)) return null;
StreamManifest trackManifest = await PrimitiveUtils.raceMultiple(
() => youtube.videos.streams.getManifest(video.id),
);
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 chosenStreamInfo = preferences.audioQuality == AudioQuality.high
? audioManifest.withHighestBitrate()
: audioManifest.sortByBitrate().last;
final ytUri = chosenStreamInfo.url.toString();
final cachedTracks = await pb.collection(BackendTrack.collection).getList(
filter: "spotify_id = '$id' && youtube_id = '${video.id.value}'",
sort: "-votes",
page: 0,
perPage: 1,
);
final cachedTrack = cachedTracks.items.isNotEmpty
? BackendTrack.fromRecord(cachedTracks.items.first)
: null;
if (cachedTrack == null) {
await pb.collection(BackendTrack.collection).create(
body: BackendTrack(
spotifyId: id!,
youtubeId: video.id.value,
votes: 1,
).toJson(),
);
} else {
await pb.collection(BackendTrack.collection).update(
cachedTrack.id,
body: {
"votes": cachedTrack.votes + 1,
},
);
}
return SpotubeTrack.fromTrack(
track: this,
ytTrack: video,
ytUri: ytUri,
skipSegments: preferences.skipSponsorSegments
? await video.getSkipSegments(preferences)
: [],
siblings: [
video,
...siblings.where((element) => element.id != video.id),
],
);
}
static SpotubeTrack fromJson(Map<String, dynamic> map) {
return SpotubeTrack.fromTrack(
track: Track.fromJson(map),
@ -57,6 +218,9 @@ class SpotubeTrack extends Track {
ytUri: map["ytUri"],
skipSegments:
List.castFrom<dynamic, Map<String, int>>(map["skipSegments"]),
siblings: List.castFrom<dynamic, Map<String, dynamic>>(map["siblings"])
.map((sibling) => VideoToJson.fromJson(sibling))
.toList(),
);
}
@ -80,7 +244,8 @@ class SpotubeTrack extends Track {
"uri": uri,
"ytTrack": ytTrack.toJson(),
"ytUri": ytUri,
"skipSegments": skipSegments
"skipSegments": skipSegments,
"siblings": siblings.map((sibling) => sibling.toJson()).toList(),
};
}
}

View File

@ -8,11 +8,10 @@ import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/track_table/track_collection_view.dart';
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:spotube/models/current_playlist.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/spotify_provider.dart';
class AlbumPage extends HookConsumerWidget {
@ -20,38 +19,35 @@ class AlbumPage extends HookConsumerWidget {
const AlbumPage(this.album, {Key? key}) : super(key: key);
Future<void> playPlaylist(
Playback playback,
PlaylistQueueNotifier playback,
List<Track> tracks,
WidgetRef ref, {
Track? currentTrack,
}) async {
final playlist = ref.read(PlaylistQueueNotifier.provider);
final sortBy = ref.read(trackCollectionSortState(album.id!));
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
currentTrack ??= sortedTracks.first;
final isPlaylistPlaying = playback.playlist?.id == album.id;
final isPlaylistPlaying = playback.isPlayingPlaylist(tracks);
if (!isPlaylistPlaying) {
await playback.playPlaylist(
CurrentPlaylist(
tracks: sortedTracks,
id: album.id!,
name: album.name!,
thumbnail: TypeConversionUtils.image_X_UrlString(
album.images,
placeholder: ImagePlaceholder.collection,
),
),
sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
playback.load(
sortedTracks,
active: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
);
await playback.play();
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != playback.track?.id) {
await playback.play(currentTrack);
currentTrack.id != playlist?.activeTrack.id) {
await playback.playAt(
sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
);
}
}
@override
Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider);
ref.watch(PlaylistQueueNotifier.provider);
final playback = ref.watch(PlaylistQueueNotifier.notifier);
final SpotifyApi spotify = ref.watch(spotifyProvider);
@ -69,8 +65,10 @@ class AlbumPage extends HookConsumerWidget {
final breakpoint = useBreakpoints();
final isAlbumPlaying =
playback.playlist?.id != null && playback.playlist?.id == album.id;
final isAlbumPlaying = useMemoized(
() => playback.isPlayingPlaylist(tracksSnapshot.data ?? []),
[tracksSnapshot.data],
);
return TrackCollectionView(
id: album.id!,
isPlaying: isAlbumPlaying,

View File

@ -15,12 +15,11 @@ import 'package:spotube/components/artist/artist_album_list.dart';
import 'package:spotube/components/artist/artist_card.dart';
import 'package:spotube/hooks/use_breakpoint_value.dart';
import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/models/current_playlist.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/auth_provider.dart';
import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/primitive_utils.dart';
@ -54,7 +53,8 @@ class ArtistPage extends HookConsumerWidget {
final breakpoint = useBreakpoints();
final Playback playback = ref.watch(playbackProvider);
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
final playlist = ref.watch(PlaylistQueueNotifier.provider);
final auth = ref.watch(authProvider);
@ -296,28 +296,22 @@ class ArtistPage extends HookConsumerWidget {
final topTracks = topTracksQuery.data!;
final isPlaylistPlaying =
playback.playlist?.id == data.id;
final isPlaylistPlaying = useMemoized(() {
return playlistNotifier.isPlayingPlaylist(topTracks);
}, [topTracks]);
playPlaylist(List<Track> tracks,
{Track? currentTrack}) async {
currentTrack ??= tracks.first;
if (!isPlaylistPlaying) {
await playback.playPlaylist(
CurrentPlaylist(
tracks: tracks,
id: data.id!,
name: "${data.name!} To Tracks",
thumbnail: TypeConversionUtils.image_X_UrlString(
data.images,
placeholder: ImagePlaceholder.artist,
),
),
tracks.indexWhere((s) => s.id == currentTrack?.id),
);
playlistNotifier.loadAndPlay(tracks,
active: tracks
.indexWhere((s) => s.id == currentTrack?.id));
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != playback.track?.id) {
await playback.play(currentTrack);
currentTrack.id != playlist?.activeTrack.id) {
await playlistNotifier.playAt(
tracks.indexWhere((s) => s.id == currentTrack?.id),
);
}
}
@ -352,10 +346,11 @@ class ArtistPage extends HookConsumerWidget {
String duration =
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
return TrackTile(
playback,
playlist,
duration: duration,
track: track,
isActive: playback.track?.id == track.value.id,
isActive:
playlist?.activeTrack.id == track.value.id,
onTrackPlayButtonPressed: (currentTrack) =>
playPlaylist(
topTracks.toList(),

View File

@ -2,11 +2,11 @@ import 'package:flutter/material.dart' hide Image;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:platform_ui/platform_ui.dart';
import 'package:spotube/components/library/user_local_tracks.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/library/user_albums.dart';
import 'package:spotube/components/library/user_artists.dart';
import 'package:spotube/components/library/user_downloads.dart';
import 'package:spotube/components/library/user_local_tracks.dart';
import 'package:spotube/components/library/user_playlists.dart';
class LibraryPage extends HookConsumerWidget {

View File

@ -5,7 +5,7 @@ import 'package:palette_generator/palette_generator.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart';
import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/services/queries/queries.dart';
@ -23,11 +23,11 @@ class GeniusLyrics extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider);
final playlist = ref.watch(PlaylistQueueNotifier.provider);
final geniusLyricsQuery = useQuery(
job: Queries.lyrics.static(playback.track?.id ?? ""),
job: Queries.lyrics.static(playlist?.activeTrack.id ?? ""),
externalData: Tuple2(
playback.track,
playlist?.activeTrack,
ref.watch(userPreferencesProvider).geniusAccessToken,
),
);
@ -40,7 +40,7 @@ class GeniusLyrics extends HookConsumerWidget {
if (isModal != true) ...[
Center(
child: Text(
playback.track?.name ?? "",
playlist?.activeTrack.name ?? "",
style: breakpoint >= Breakpoints.md
? textTheme.headline3
: textTheme.headline4?.copyWith(
@ -52,7 +52,7 @@ class GeniusLyrics extends HookConsumerWidget {
Center(
child: Text(
TypeConversionUtils.artists_X_String<Artist>(
playback.track?.artists ?? []),
playlist?.activeTrack.artists ?? []),
style: (breakpoint >= Breakpoints.md
? textTheme.headline5
: textTheme.headline6)
@ -72,7 +72,7 @@ class GeniusLyrics extends HookConsumerWidget {
return const ShimmerLyrics();
} else if (geniusLyricsQuery.hasError) {
return Text(
"Sorry, no Lyrics were found for `${playback.track?.name}` :'(\n${geniusLyricsQuery.error.toString()}",
"Sorry, no Lyrics were found for `${playlist?.activeTrack.name}` :'(\n${geniusLyricsQuery.error.toString()}",
style: textTheme.bodyText1?.copyWith(
color: palette.bodyTextColor,
),
@ -82,12 +82,11 @@ class GeniusLyrics extends HookConsumerWidget {
final lyrics = geniusLyricsQuery.data;
return Text(
lyrics == null && playback.track == null
lyrics == null && playlist?.activeTrack == null
? "No Track being played currently"
: lyrics ?? "",
style: textTheme.headline6?.copyWith(
color: palette.bodyTextColor,
),
style:
TextStyle(color: palette.bodyTextColor, fontSize: 18),
);
},
),

View File

@ -11,7 +11,7 @@ import 'package:spotube/hooks/use_custom_status_bar_color.dart';
import 'package:spotube/hooks/use_palette_color.dart';
import 'package:spotube/pages/lyrics/genius_lyrics.dart';
import 'package:spotube/pages/lyrics/synced_lyrics.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
@ -21,14 +21,14 @@ class LyricsPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider);
final playlist = ref.watch(PlaylistQueueNotifier.provider);
String albumArt = useMemoized(
() => TypeConversionUtils.image_X_UrlString(
playback.track?.album?.images,
index: (playback.track?.album?.images?.length ?? 1) - 1,
playlist?.activeTrack.album?.images,
index: (playlist?.activeTrack.album?.images?.length ?? 1) - 1,
placeholder: ImagePlaceholder.albumArt,
),
[playback.track?.album?.images],
[playlist?.activeTrack.album?.images],
);
final palette = usePaletteColor(albumArt, ref);
final index = useState(0);

View File

@ -1,4 +1,4 @@
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:fl_query/fl_query.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -12,8 +12,10 @@ import 'package:spotube/components/lyrics/lyric_delay_adjust_dialog.dart';
import 'package:spotube/hooks/use_auto_scroll_controller.dart';
import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/hooks/use_synced_lyrics.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/models/lyrics.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
@ -36,15 +38,34 @@ class SyncedLyrics extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider);
final timedLyricsQuery = useQuery(
job: Queries.lyrics.synced(playback.track?.id ?? ""),
externalData: playback.track,
);
final playlist = ref.watch(PlaylistQueueNotifier.provider);
final lyricDelay = ref.watch(lyricDelayState);
final breakpoint = useBreakpoints();
final controller = useAutoScrollController();
final textTheme = Theme.of(context).textTheme;
useEffect(() {
controller.scrollToIndex(0);
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(lyricDelayState.notifier).state = Duration.zero;
});
return null;
}, [playlist?.activeTrack]);
final headlineTextStyle = (breakpoint >= Breakpoints.md
? textTheme.headline3
: textTheme.headline4?.copyWith(fontSize: 25))
?.copyWith(color: palette.titleTextColor);
return QueryBuilder<SubtitleSimple, SpotubeTrack?>(
job: Queries.lyrics.synced(playlist?.activeTrack.id ?? ""),
externalData: playlist?.isLoading == true
? playlist?.activeTrack as SpotubeTrack
: null,
builder: (context, timedLyricsQuery) {
return HookBuilder(builder: (context) {
final lyricValue = timedLyricsQuery.data;
final lyricsMap = useMemoized(
() =>
@ -55,24 +76,7 @@ class SyncedLyrics extends HookConsumerWidget {
{},
[lyricValue],
);
final currentTime = useSyncedLyrics(ref, lyricsMap, lyricDelay);
final textTheme = Theme.of(context).textTheme;
useEffect(() {
controller.scrollToIndex(0);
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(lyricDelayState.notifier).state = Duration.zero;
});
return null;
}, [playback.track]);
final headlineTextStyle = (breakpoint >= Breakpoints.md
? textTheme.headline3
: textTheme.headline4?.copyWith(fontSize: 25))
?.copyWith(color: palette.titleTextColor);
return Stack(
children: [
Column(
@ -80,7 +84,7 @@ class SyncedLyrics extends HookConsumerWidget {
if (isModal != true)
Center(
child: SpotubeMarqueeText(
text: playback.track?.name ?? "Not Playing",
text: playlist?.activeTrack.name ?? "Not Playing",
style: headlineTextStyle,
isHovering: true,
),
@ -89,7 +93,7 @@ class SyncedLyrics extends HookConsumerWidget {
Center(
child: Text(
TypeConversionUtils.artists_X_String<Artist>(
playback.track?.artists ?? []),
playlist?.activeTrack.artists ?? []),
style: breakpoint >= Breakpoints.md
? textTheme.headline5
: textTheme.headline6,
@ -102,7 +106,8 @@ class SyncedLyrics extends HookConsumerWidget {
itemCount: lyricValue.lyrics.length,
itemBuilder: (context, index) {
final lyricSlice = lyricValue.lyrics[index];
final isActive = lyricSlice.time.inSeconds == currentTime;
final isActive =
lyricSlice.time.inSeconds == currentTime;
if (isActive) {
controller.scrollToIndex(
@ -120,7 +125,8 @@ class SyncedLyrics extends HookConsumerWidget {
child: Padding(
padding: const EdgeInsets.all(8.0),
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 250),
duration:
const Duration(milliseconds: 250),
style: TextStyle(
color: isActive
? Colors.white
@ -142,8 +148,9 @@ class SyncedLyrics extends HookConsumerWidget {
},
),
),
if (playback.track != null &&
(lyricValue == null || lyricValue.lyrics.isEmpty == true))
if (playlist?.activeTrack != null &&
(lyricValue == null ||
lyricValue.lyrics.isEmpty == true))
const Expanded(child: ShimmerLyrics()),
],
),
@ -171,5 +178,7 @@ class SyncedLyrics extends HookConsumerWidget {
),
],
);
});
});
}
}

View File

@ -16,8 +16,9 @@ import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/hooks/use_custom_status_bar_color.dart';
import 'package:spotube/hooks/use_palette_color.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/pages/lyrics/lyrics.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
@ -28,11 +29,11 @@ class PlayerView extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final currentTrack = ref.watch(playbackProvider.select(
(value) => value.track,
final currentTrack = ref.watch(PlaylistQueueNotifier.provider.select(
(value) => value?.activeTrack,
));
final isLocalTrack = ref.watch(playbackProvider.select(
(value) => value.playlist?.isLocal == true,
final isLocalTrack = ref.watch(PlaylistQueueNotifier.provider.select(
(value) => value?.activeTrack is LocalTrack,
));
final breakpoint = useBreakpoints();
final canRotate = ref.watch(

View File

@ -6,12 +6,11 @@ import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/track_table/track_collection_view.dart';
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/models/current_playlist.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/service_utils.dart';
@ -23,7 +22,7 @@ class PlaylistView extends HookConsumerWidget {
PlaylistView(this.playlist, {Key? key}) : super(key: key);
Future<void> playPlaylist(
Playback playback,
PlaylistQueueNotifier playlistNotifier,
List<Track> tracks,
WidgetRef ref, {
Track? currentTrack,
@ -31,34 +30,27 @@ class PlaylistView extends HookConsumerWidget {
final sortBy = ref.read(trackCollectionSortState(playlist.id!));
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
currentTrack ??= sortedTracks.first;
final isPlaylistPlaying =
playback.playlist?.id != null && playback.playlist?.id == playlist.id;
final isPlaylistPlaying = playlistNotifier.isPlayingPlaylist(tracks);
if (!isPlaylistPlaying) {
await playback.playPlaylist(
CurrentPlaylist(
tracks: sortedTracks,
id: playlist.id!,
name: playlist.name!,
thumbnail: TypeConversionUtils.image_X_UrlString(
playlist.images,
placeholder: ImagePlaceholder.collection,
),
),
sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
await playlistNotifier.loadAndPlay(
sortedTracks,
active: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != playback.track?.id) {
await playback.play(currentTrack);
currentTrack.id != playlistNotifier.state?.activeTrack.id) {
await playlistNotifier.loadAndPlay(
sortedTracks,
active: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
);
}
}
@override
Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider);
final playlistQueue = ref.watch(PlaylistQueueNotifier.provider);
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
SpotifyApi spotify = ref.watch(spotifyProvider);
final isPlaylistPlaying =
playback.playlist?.id != null && playback.playlist?.id == playlist.id;
final breakpoint = useBreakpoints();
@ -68,6 +60,9 @@ class PlaylistView extends HookConsumerWidget {
externalData: spotify,
);
final isPlaylistPlaying =
playlistNotifier.isPlayingPlaylist(tracksSnapshot.data ?? []);
final titleImage = useMemoized(
() => TypeConversionUtils.image_X_UrlString(
playlist.images,
@ -88,20 +83,20 @@ class PlaylistView extends HookConsumerWidget {
if (tracksSnapshot.hasData) {
if (!isPlaylistPlaying) {
playPlaylist(
playback,
playlistNotifier,
tracksSnapshot.data!,
ref,
currentTrack: track,
);
} else if (isPlaylistPlaying && track != null) {
playPlaylist(
playback,
playlistNotifier,
tracksSnapshot.data!,
ref,
currentTrack: track,
);
} else {
playback.stop();
playlistNotifier.stop();
}
}
},
@ -132,20 +127,20 @@ class PlaylistView extends HookConsumerWidget {
if (tracksSnapshot.hasData) {
if (!isPlaylistPlaying) {
playPlaylist(
playback,
playlistNotifier,
tracks,
ref,
currentTrack: track,
);
} else if (isPlaylistPlaying && track != null) {
playPlaylist(
playback,
playlistNotifier,
tracks,
ref,
currentTrack: track,
);
} else {
playback.stop();
playlistNotifier.stop();
}
}
},

View File

@ -15,10 +15,9 @@ import 'package:spotube/components/shared/waypoint.dart';
import 'package:spotube/components/artist/artist_card.dart';
import 'package:spotube/components/playlist/playlist_card.dart';
import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/models/current_playlist.dart';
import 'package:spotube/provider/auth_provider.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/platform.dart';
@ -107,7 +106,10 @@ class SearchPage extends HookConsumerWidget {
),
HookBuilder(
builder: (context) {
Playback playback = ref.watch(playbackProvider);
final playlist =
ref.watch(PlaylistQueueNotifier.provider);
final playlistNotifier =
ref.watch(PlaylistQueueNotifier.notifier);
List<AlbumSimple> albums = [];
List<Artist> artists = [];
List<Track> tracks = [];
@ -154,36 +156,19 @@ class SearchPage extends HookConsumerWidget {
String duration =
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
return TrackTile(
playback,
playlist,
track: track,
duration: duration,
isActive:
playback.track?.id == track.value.id,
isActive: playlist?.activeTrack.id ==
track.value.id,
onTrackPlayButtonPressed:
(currentTrack) async {
var isPlaylistPlaying =
playback.playlist?.id != null &&
playback.playlist?.id ==
final isTrackPlaying =
playlist?.activeTrack.id !=
currentTrack.id;
if (!isPlaylistPlaying) {
playback.playPlaylist(
CurrentPlaylist(
tracks: [currentTrack],
id: currentTrack.id!,
name: currentTrack.name!,
thumbnail: TypeConversionUtils
.image_X_UrlString(
currentTrack.album?.images,
placeholder:
ImagePlaceholder.albumArt,
),
),
);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id !=
playback.track?.id) {
playback.play(currentTrack);
if (!isTrackPlaying) {
await playlistNotifier
.loadAndPlay([currentTrack]);
}
},
);

View File

@ -14,7 +14,6 @@ import 'package:spotube/main.dart';
import 'package:spotube/collections/spotify_markets.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/auth_provider.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/utils/platform.dart';
import 'package:url_launcher/url_launcher_string.dart';

View File

@ -1,4 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/current_playlist.dart';
import 'package:spotube/utils/persisted_state_notifier.dart';
@ -39,7 +40,7 @@ class BlacklistedElement {
class BlackListNotifier
extends PersistedStateNotifier<Set<BlacklistedElement>> {
BlackListNotifier() : super({});
BlackListNotifier() : super({}, "blacklist");
static final provider =
StateNotifierProvider<BlackListNotifier, Set<BlacklistedElement>>(
@ -54,6 +55,20 @@ class BlackListNotifier
state = state.difference({element});
}
Iterable<TrackSimple> filter(Iterable<TrackSimple> tracks) {
return tracks.where(
(track) {
return !state
.contains(BlacklistedElement.track(track.id!, track.name!)) &&
!(track.artists ?? []).any(
(artist) => state.contains(
BlacklistedElement.artist(artist.id!, artist.name!),
),
);
},
).toList();
}
CurrentPlaylist filterPlaylist(CurrentPlaylist playlist) {
return CurrentPlaylist(
id: playlist.id,

View File

@ -12,7 +12,6 @@ import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart
import 'package:spotube/models/logger.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/provider/youtube_provider.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
@ -41,7 +40,7 @@ class Downloader with ChangeNotifier {
final logger = getLogger(Downloader);
Playback get _playback => ref.read(playbackProvider);
// Playback get _playback => ref.read(playbackProvider);
void addToQueue(Track baseTrack) async {
if (kIsWeb) return;
@ -51,13 +50,13 @@ class Downloader with ChangeNotifier {
notifyListeners();
// Using android Audio Focus to keep the app run in background
_playback.mobileAudioService?.session?.setActive(true);
// _playback.mobileAudioService?.session?.setActive(true);
grabberQueue.add(() async {
final track = (await ref.read(playbackProvider).toSpotubeTrack(
final track = await SpotubeTrack.fromFetchTrack(
baseTrack,
noSponsorBlock: true,
))
.item1;
ref.read(userPreferencesProvider),
);
_queue.add(() async {
final cleanTitle = track.ytTrack.title.replaceAll(
RegExp(r'[/\\?%*:|"<>]'),
@ -140,9 +139,9 @@ class Downloader with ChangeNotifier {
} finally {
currentlyRunning--;
inQueue.removeWhere((t) => t.id == track.id);
if (currentlyRunning == 0 && !_playback.isPlaying) {
_playback.mobileAudioService?.session?.setActive(false);
}
// if (currentlyRunning == 0 && !PlaylistProvider.isPlaying) {
// _playback.mobileAudioService?.session?.setActive(false);
// }
notifyListeners();
}
});

View File

@ -1,696 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:audio_service/audio_service.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:catcher/catcher.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/video.dart';
import 'package:spotube/models/current_playlist.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/models/track.dart';
import 'package:spotube/provider/audio_player_provider.dart';
import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/provider/youtube_provider.dart';
import 'package:spotube/services/linux_audio_service.dart';
import 'package:spotube/services/mobile_audio_service.dart';
import 'package:spotube/services/pocketbase.dart';
import 'package:spotube/utils/persisted_change_notifier.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:tuple/tuple.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart' hide Playlist;
import 'package:collection/collection.dart';
import 'package:spotube/extensions/list.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 isLoop;
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);
Set<BlacklistedElement> get blacklist => ref.read(BlackListNotifier.provider);
BlackListNotifier get blacklistNotifier =>
ref.read(BlackListNotifier.provider.notifier);
// playlist & track list properties
CurrentPlaylist? playlist;
SpotubeTrack? track;
List<Video> _siblingYtVideos = [];
// 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 = 1,
isPlaying = false,
isShuffled = false,
isLoop = false,
currentDuration = Duration.zero,
_subscriptions = [],
status = PlaybackStatus.idle,
super() {
if (kIsLinux) {
_linuxAudioService = LinuxAudioService(this);
}
(() async {
if (kIsAndroid) {
await player.setVolume(1);
volume = 1;
} else {
await player.setVolume(volume);
}
addListener(() {
_linuxAudioService?.player.updateProperties(this);
});
_subscriptions.addAll([
player.onPlayerStateChanged.listen(
(state) async {
isPlaying = state == PlayerState.playing;
notifyListeners();
},
),
player.onPlayerComplete.listen((_) {
if (track?.id != null) {
if (isLoop) {
final prevTrack = track;
track = null;
play(prevTrack!);
} else if (playlist != 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.item1;
});
}
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> changeToSiblingVideo(Video ytVideo, Track track) async {
pause();
final siblingYtVideos = _siblingYtVideos;
final spotubeTrack = await ytVideoToSpotubeTrack(
ytVideo,
track,
overwriteCache: true,
);
this.track = null;
await play(spotubeTrack.item1, manifest: spotubeTrack.item2);
_siblingYtVideos = siblingYtVideos;
notifyListeners();
}
Future<void> playPlaylist(CurrentPlaylist playlist, [int index = 0]) async {
if (index < 0 || index > playlist.tracks.length - 1) return;
if (isPlaying || status == PlaybackStatus.playing) await stop();
this.playlist = blacklistNotifier.filterPlaylist(playlist);
mobileAudioService?.session?.setActive(true);
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!;
});
}
// player methods
Future<void> play(Track track, {AudioOnlyStreamInfo? manifest}) async {
_logger.v("[Track Playing] ${track.name} - ${track.id}");
// 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();
}
_siblingYtVideos = [];
// the track is not a SpotubeTrack so turning it to one
if (track is! SpotubeTrack) {
final s = await toSpotubeTrack(track);
track = s.item1;
manifest = s.item2;
}
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,
placeholder: ImagePlaceholder.online,
),
),
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")
? await getAppropriateSource(track, manifest)
: DeviceFileSource(track.ytUri),
);
status = PlaybackStatus.playing;
notifyListeners();
}
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();
}
void setIsShuffled(bool shuffle) {
isShuffled = shuffle;
if (isShuffled) {
playlist?.shuffle(track);
} else {
playlist?.unshuffle();
}
notifyListeners();
}
void setIsLoop(bool loop) {
isLoop = loop;
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 {
mobileAudioService?.session?.setActive(false);
await player.stop();
await player.release();
isPlaying = false;
isShuffled = false;
isLoop = 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) {
Catcher.reportCheckedError(e, stack);
return List.castFrom<dynamic, Map<String, int>>([]);
}
}
Future<Tuple2<SpotubeTrack, AudioOnlyStreamInfo>> ytVideoToSpotubeTrack(
Video ytVideo,
Track track, {
bool noSponsorBlock = false,
bool overwriteCache = false,
}) async {
final cachedTracks = await pb
.collection(BackendTrack.collection)
.getFullList(filter: "spotify_id = '${track.id}'", sort: "-votes");
final cachedTrack = cachedTracks.isNotEmpty
? BackendTrack.fromRecord(cachedTracks.first)
: null;
final altTrack = cachedTracks.firstWhereOrNull(
(record) => record.data["youtube_id"] == ytVideo.id.value,
);
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 chosenStreamInfo = preferences.audioQuality == AudioQuality.high
? audioManifest.withHighestBitrate()
: audioManifest.sortByBitrate().last;
final ytUri = chosenStreamInfo.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 && altTrack == null) {
await pb.collection(BackendTrack.collection).create(
body: BackendTrack(
spotifyId: track.id!,
youtubeId: ytVideo.id.value,
votes: 0,
).toJson(),
);
} else if (cachedTrack != null && altTrack != null && overwriteCache) {
await pb.collection(BackendTrack.collection).update(
altTrack.id,
body: {
"votes": altTrack.data["votes"] + 1,
},
);
} else if (cachedTrack != null && altTrack == null && overwriteCache) {
await pb.collection(BackendTrack.collection).create(
body: BackendTrack(
spotifyId: track.id!,
youtubeId: ytVideo.id.value,
votes: 1,
).toJson(),
);
}
return Tuple2(
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 */ [],
),
chosenStreamInfo,
);
}
// playlist & track list methods
Future<Tuple2<SpotubeTrack, AudioOnlyStreamInfo>> toSpotubeTrack(
Track track, {
bool noSponsorBlock = false,
bool ignoreCache = false,
}) async {
final format = preferences.ytSearchFormat;
final matchAlgorithm = preferences.trackMatchAlgorithm;
final artistsName =
track.artists?.map((ar) => ar.name).toList().whereNotNull().toList() ??
[];
_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 pb
.collection(BackendTrack.collection)
.getFullList(filter: "spotify_id = '${track.id}'", sort: "-votes")
.then((l) => l.isNotEmpty ? BackendTrack.fromRecord(l.first) : null);
if (cachedTrack != null && !ignoreCache) {
_logger.v(
"[Playing track from cache] youtubeId: ${cachedTrack.youtubeId}",
);
ytVideo = await VideoFromCacheTrackExtension.fromBackendTrack(
cachedTrack, youtube);
} 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");
final bool hasCloseDuration =
(track.duration!.inSeconds - video.duration!.inSeconds)
.abs() <=
10; //Duration matching threshold
int rate = 0;
for (final el in [
hasTitle,
hasAllArtists,
if (matchAlgorithm ==
SpotubeTrackMatchAlgorithm.authenticPopular)
authorIsArtist,
hasNoLiveInTitle,
hasCloseDuration,
!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;
_siblingYtVideos =
ratedRankedVideos.map((e) => e["video"] as Video).toList();
notifyListeners();
} else {
ytVideo = videos.where((video) => !video.isLive).first;
_siblingYtVideos = videos.take(10).toList();
notifyListeners();
}
}
return ytVideoToSpotubeTrack(
ytVideo,
track,
noSponsorBlock: noSponsorBlock,
);
}
Future<Source> getAppropriateSource(
SpotubeTrack track, [
AudioOnlyStreamInfo? manifest,
]) async {
if (!kIsMobile || !preferences.androidBytesPlay) {
return UrlSource(track.ytUri);
}
final List<int> bytesStore = [];
final bytesFuture = Completer<Uint8List>();
if (manifest == null) {
StreamManifest trackManifest = await raceMultiple(
() => youtube.videos.streams.getManifest(track.ytTrack.id),
);
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;
}
});
manifest ??= audioManifest.sortByBitrate().last;
}
youtube.videos.streamsClient.get(manifest).listen(
(data) {
bytesStore.addAll(data);
},
onDone: () {
bytesFuture.complete(Uint8List.fromList(bytesStore));
},
onError: (e) {
_logger.e("toByteTrack", e);
bytesFuture.completeError(e);
},
);
final bytes = await bytesFuture.future;
return bytes.isNotEmpty ? BytesSource(bytes) : UrlSource(track.ytUri);
}
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 pause();
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 pause();
await play(playlist!.tracks.elementAt(prevTrackIndex)).then((_) {
playlist!.tracks[prevTrackIndex] = track!;
});
}
@override
FutureOr<void> loadFromLocal(Map<String, dynamic> map) async {
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;
}
@override
FutureOr<Map<String, dynamic>> toMap() {
return {
"playlist": playlist != null ? jsonEncode(playlist?.toJson()) : null,
"track": track != null ? jsonEncode(track?.toJson()) : null,
"volume": volume,
};
}
UnmodifiableListView<Video> get siblingYtVideos =>
UnmodifiableListView(_siblingYtVideos);
}
final playbackProvider = ChangeNotifierProvider<Playback>((ref) {
final youtube = ref.watch(youtubeProvider);
final player = ref.watch(audioPlayerProvider);
return Playback(
player: player,
youtube: youtube,
ref: ref,
);
});

View File

@ -0,0 +1,427 @@
import 'package:audio_service/audio_service.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:path/path.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/extensions/track.dart';
import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/services/linux_audio_service.dart';
import 'package:spotube/services/mobile_audio_service.dart';
import 'package:spotube/utils/persisted_state_notifier.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart' hide Playlist;
final audioPlayer = AudioPlayer();
final youtube = YoutubeExplode();
class PlaylistQueue {
final Set<Track> tracks;
final int active;
Track get activeTrack => tracks.elementAt(active);
factory PlaylistQueue.fromJson(Map<String, dynamic> json) {
return PlaylistQueue(
Set.from(json['tracks'].map(
(e) {
final json = Map.castFrom<dynamic, dynamic, String, dynamic>(e);
if (e["ytTrack"] != null) {
return SpotubeTrack.fromJson(json);
} else if (e["path"] != null) {
return LocalTrack.fromJson(json);
} else {
return Track.fromJson(json);
}
},
)),
active: json['active'],
);
}
Map<String, dynamic> toJson() {
return {
'tracks': tracks.map(
(e) {
if (e is SpotubeTrack) {
return e.toJson();
} else if (e is LocalTrack) {
return e.toJson();
} else {
return e.toJson();
}
},
).toList(),
'active': active,
};
}
bool get isLoading =>
activeTrack is LocalTrack ? false : activeTrack is! SpotubeTrack;
PlaylistQueue(
this.tracks, {
this.active = 0,
});
PlaylistQueue copyWith({
Set<Track>? tracks,
int? active,
}) {
return PlaylistQueue(
tracks ?? this.tracks,
active: active ?? this.active,
);
}
}
class PlaylistQueueNotifier extends PersistedStateNotifier<PlaylistQueue?> {
final Ref ref;
static final provider =
StateNotifierProvider<PlaylistQueueNotifier, PlaylistQueue?>(
(ref) => PlaylistQueueNotifier._(ref),
);
static final notifier = provider.notifier;
PlaylistQueueNotifier._(this.ref) : super(null, "playlist") {
configure();
}
void configure() async {
addListener((state) {
linuxService?.player.updateProperties();
});
audioPlayer.onPlayerStateChanged.listen((event) {
linuxService?.player.updateProperties();
});
audioPlayer.onPlayerComplete.listen((event) => next());
bool isPreSearching = false;
audioPlayer.onPositionChanged.listen((pos) async {
if (!isLoaded) return;
await linuxService?.player.updateProperties();
final currentDuration = await audioPlayer.getDuration() ?? Duration.zero;
// 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 &&
state!.active != state!.tracks.length - 1 &&
state!.tracks.elementAt(state!.active + 1) is! SpotubeTrack &&
!isPreSearching) {
isPreSearching = true;
final tracks = state!.tracks.toList();
tracks[state!.active + 1] = await SpotubeTrack.fromFetchTrack(
state!.tracks.elementAt(state!.active + 1),
preferences,
);
state = state!.copyWith(tracks: Set.from(tracks));
isPreSearching = false;
}
});
}
// properties
Set<Track> _tempTracks = {};
// getters
UserPreferences get preferences => ref.read(userPreferencesProvider);
BlackListNotifier get blacklist =>
ref.read(BlackListNotifier.provider.notifier);
LinuxAudioService? get linuxService => ref.read(linuxAudioServiceProvider);
Future<MobileAudioService?> get mobileService =>
ref.read(mobileAudioServiceProvider);
bool get isLoaded => state != null;
bool get isShuffled => _tempTracks.isNotEmpty;
// redirectors
static bool get isPlaying => audioPlayer.state == PlayerState.playing;
static bool get isPaused => audioPlayer.state == PlayerState.paused;
static bool get isStopped => audioPlayer.state == PlayerState.stopped;
static Stream<Duration> get duration => audioPlayer.onDurationChanged;
static Stream<Duration> get position => audioPlayer.onPositionChanged;
static Stream<bool> get playing => audioPlayer.onPlayerStateChanged
.map((event) => event == PlayerState.playing);
List<Video> get siblings => state?.isLoading == false
? (state!.activeTrack as SpotubeTrack).siblings
: [];
// modifiers
void add(Track track) {
state = state?.copyWith(
tracks: state!.tracks..add(track),
);
}
void remove(Track track) {
state = state?.copyWith(
tracks: state!.tracks..remove(track),
);
}
void shuffle() {
if (isShuffled || !isLoaded) return;
_tempTracks = state?.tracks ?? _tempTracks;
state = state?.copyWith(
tracks: {
state!.activeTrack,
..._tempTracks.toList()
..removeAt(state!.active)
..shuffle()
},
);
}
void unshuffle() {
if (!isShuffled || !isLoaded) return;
state = state?.copyWith(
tracks: _tempTracks,
);
_tempTracks = {};
}
Future<void> swapSibling(Video video) async {
if (!isLoaded || state!.isLoading) return;
await pause();
final tracks = state!.tracks.toList();
final track = await (state!.activeTrack as SpotubeTrack)
.swappedCopy(video, preferences);
if (track == null) return;
tracks[state!.active] = track;
state = state!.copyWith(tracks: Set.from(tracks));
await play();
}
Future<void> play() async {
if (!isLoaded) return;
pause();
(await mobileService)?.session?.setActive(true);
if (state!.activeTrack is LocalTrack) {
await audioPlayer.play(
DeviceFileSource((state!.activeTrack as LocalTrack).path),
mode: PlayerMode.lowLatency,
);
return;
}
if (state!.activeTrack is! SpotubeTrack) {
final tracks = state!.tracks.toList();
tracks[state!.active] = await SpotubeTrack.fromFetchTrack(
state!.activeTrack,
preferences,
);
state = state!.copyWith(tracks: Set.from(tracks));
}
(await mobileService)?.addItem(
MediaItem(
id: state!.activeTrack.id!,
title: state!.activeTrack.name!,
album: state!.activeTrack.album?.name,
artist: TypeConversionUtils.artists_X_String(
state!.activeTrack.artists ?? <ArtistSimple>[]),
artUri: Uri.parse(
TypeConversionUtils.image_X_UrlString(
state!.activeTrack.album?.images,
placeholder: ImagePlaceholder.online,
),
),
duration: (state!.activeTrack as SpotubeTrack).ytTrack.duration,
),
);
if (preferences.androidBytesPlay) {
final cached = await DefaultCacheManager()
.getFileFromCache(state!.activeTrack.id!)
.then(
(file) async {
if (file != null) return file.file;
final downloaded = await DefaultCacheManager()
.downloadFile((state!.activeTrack as SpotubeTrack).ytUri);
final cached = await DefaultCacheManager().putFile(
state!.activeTrack.id!,
await downloaded.file.readAsBytes(),
fileExtension: extension(downloaded.file.path).replaceAll('.', ''),
);
await DefaultCacheManager().removeFile(downloaded.originalUrl);
return cached;
},
);
await audioPlayer.play(
DeviceFileSource(cached.path),
mode: PlayerMode.lowLatency,
);
} else {
await audioPlayer.play(
UrlSource((state!.activeTrack as SpotubeTrack).ytUri),
mode: PlayerMode.lowLatency,
);
}
}
Future<void> playAt(int index) async {
if (!isLoaded) return;
state = PlaylistQueue(
state!.tracks,
active: index,
);
return play();
}
void load(Iterable<Track> tracks, {int active = 0}) {
state = PlaylistQueue(
Set.from(blacklist.filter(tracks)),
active: active,
);
}
Future<void> loadAndPlay(Iterable<Track> tracks, {int active = 0}) async {
load(tracks, active: active);
await play();
}
Future<void> pause() {
return audioPlayer.pause();
}
Future<void> resume() {
return audioPlayer.resume();
}
Future<void> stop() async {
(await mobileService)?.session?.setActive(false);
state = null;
_tempTracks = {};
return audioPlayer.stop();
}
Future<void> next() async {
if (!isLoaded) return;
if (state!.active == state!.tracks.length - 1) {
state = PlaylistQueue(
state!.tracks,
active: 0,
);
} else {
state = PlaylistQueue(
state!.tracks,
active: state!.active + 1,
);
}
return play();
}
Future<void> previous() async {
if (!isLoaded) return;
if (state!.active == 0) {
state = PlaylistQueue(
state!.tracks,
active: state!.tracks.length - 1,
);
} else {
state = PlaylistQueue(
state!.tracks,
active: state!.active - 1,
);
}
return play();
}
Future<void> seek(Duration position) async {
if (!isLoaded) return;
await audioPlayer.seek(position);
await resume();
}
// utility
bool isPlayingPlaylist(Iterable<TrackSimple> playlist) {
if (!isLoaded || playlist.isEmpty) return false;
if (isShuffled) {
final trackIds = _tempTracks.map((track) => track.id!);
return blacklist
.filter(playlist)
.every((track) => trackIds.contains(track.id!));
}
final trackIds = state!.tracks.map((track) => track.id!);
return blacklist
.filter(playlist)
.every((track) => trackIds.contains(track.id!));
}
@override
PlaylistQueue? fromJson(Map<String, dynamic> json) {
if (json.isEmpty) return null;
return PlaylistQueue.fromJson(json);
}
@override
Map<String, dynamic> toJson() {
return state?.toJson() ?? {};
}
}
class VolumeProvider extends PersistedStateNotifier<double> {
VolumeProvider() : super(1, 'volume');
static final provider = StateNotifierProvider<VolumeProvider, double>((ref) {
return VolumeProvider();
});
Future<void> setVolume(double volume) async {
if (volume > 1) {
state = 1;
} else if (volume < 0) {
state = 0;
} else {
state = volume;
}
await audioPlayer.setVolume(state);
await audioPlayer.setVolume(state);
}
void increaseVolume() {
setVolume(state + 0.1);
}
void decreaseVolume() {
setVolume(state - 0.1);
}
@override
double fromJson(Map<String, dynamic> json) {
return json['volume'] as double;
}
@override
Map<String, dynamic> toJson() {
return {'volume': state};
}
}
final linuxAudioServiceProvider = Provider<LinuxAudioService?>((ref) {
if (!kIsLinux) return null;
return LinuxAudioService(ref);
});
final mobileAudioServiceProvider =
Provider<Future<MobileAudioService?>>((ref) async {
if (!kIsMobile) return null;
return AudioService.init(
builder: () => MobileAudioService(ref),
config: const AudioServiceConfig(
androidNotificationChannelId: 'com.krtirtho.Spotube',
androidNotificationChannelName: 'Spotube',
androidNotificationOngoing: true,
),
);
});

View File

@ -6,7 +6,6 @@ import 'package:path_provider/path_provider.dart';
import 'package:spotube/components/settings/color_scheme_picker_dialog.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/models/generated_secrets.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/utils/persisted_change_notifier.dart';
import 'package:collection/collection.dart';
import 'package:spotube/utils/platform.dart';
@ -19,6 +18,11 @@ enum LayoutMode {
adaptive,
}
enum AudioQuality {
high,
low,
}
class UserPreferences extends PersistedChangeNotifier {
ThemeMode themeMode;
String ytSearchFormat;

View File

@ -1,10 +1,11 @@
import 'dart:io';
import 'package:dbus/dbus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/provider/dbus_provider.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:window_manager/window_manager.dart';
@ -217,12 +218,11 @@ class _MprisMediaPlayer2 extends DBusObject {
}
class _MprisMediaPlayer2Player extends DBusObject {
Playback playback;
Ref ref;
/// Creates a new object to expose on [path].
_MprisMediaPlayer2Player({
required this.playback,
}) : super(DBusObjectPath("/org/mpris/MediaPlayer2")) {
_MprisMediaPlayer2Player(this.ref)
: super(DBusObjectPath("/org/mpris/MediaPlayer2")) {
(() async {
final nameStatus =
await dbus.requestName("org.mpris.MediaPlayer2.spotube");
@ -233,30 +233,41 @@ class _MprisMediaPlayer2Player extends DBusObject {
}());
}
PlaylistQueue? get playlist => ref.read(PlaylistQueueNotifier.provider);
PlaylistQueueNotifier get playlistNotifier =>
ref.read(PlaylistQueueNotifier.notifier);
double get volume => ref.read(VolumeProvider.provider);
VolumeProvider get volumeNotifier =>
ref.read(VolumeProvider.provider.notifier);
void dispose() {
dbus.unregisterObject(this);
}
/// Gets value of property org.mpris.MediaPlayer2.Player.PlaybackStatus
Future<DBusMethodResponse> getPlaybackStatus() async {
final status = playback.isPlaying
final status = PlaylistQueueNotifier.isPlaying
? "Playing"
: playback.playlist == null
: playlist == null
? "Stopped"
: "Paused";
return DBusMethodSuccessResponse([DBusString(status)]);
}
// TODO: Implement Track Loop
/// Gets value of property org.mpris.MediaPlayer2.Player.LoopStatus
Future<DBusMethodResponse> getLoopStatus() async {
return DBusMethodSuccessResponse([
playback.isLoop ? const DBusString("Track") : const DBusString("None"),
/* playlistNotifier.isLoop */ false
? const DBusString("Track")
: const DBusString("None"),
]);
}
/// Sets property org.mpris.MediaPlayer2.Player.LoopStatus
Future<DBusMethodResponse> setLoopStatus(String value) async {
playback.setIsLoop(value == "Track");
// playlistNotifier.setIsLoop(value == "Track");
return DBusMethodSuccessResponse();
}
@ -272,46 +283,47 @@ class _MprisMediaPlayer2Player extends DBusObject {
/// Gets value of property org.mpris.MediaPlayer2.Player.Shuffle
Future<DBusMethodResponse> getShuffle() async {
return DBusMethodSuccessResponse([DBusBoolean(playback.isShuffled)]);
return DBusMethodSuccessResponse(
[DBusBoolean(playlistNotifier.isShuffled)]);
}
/// Sets property org.mpris.MediaPlayer2.Player.Shuffle
Future<DBusMethodResponse> setShuffle(bool value) async {
playback.setIsShuffled(value);
if (value) {
playlistNotifier.shuffle();
} else {
playlistNotifier.unshuffle();
}
return DBusMethodSuccessResponse();
}
/// Gets value of property org.mpris.MediaPlayer2.Player.Metadata
Future<DBusMethodResponse> getMetadata() async {
if (playback.track == null) {
if (playlist == null || playlist!.isLoading) {
return DBusMethodSuccessResponse([DBusDict.stringVariant({})]);
}
final id = (playback.playlist != null
? playback.playlist!.tracks.indexWhere(
(track) => playback.track!.id == track.id!,
)
: 0)
.abs();
final id = playlist!.active;
return DBusMethodSuccessResponse([
DBusDict.stringVariant({
"mpris:trackid": DBusString("${path.value}/Track/$id"),
"mpris:length": DBusInt32(playback.currentDuration.inMicroseconds),
"mpris:length":
DBusInt32((await audioPlayer.getDuration())?.inMicroseconds ?? 0),
"mpris:artUrl": DBusString(
TypeConversionUtils.image_X_UrlString(
playback.track?.album?.images,
playlist?.activeTrack.album?.images,
placeholder: ImagePlaceholder.albumArt,
),
),
"xesam:album": DBusString(playback.track!.album!.name!),
"xesam:album": DBusString(playlist!.activeTrack.album!.name!),
"xesam:artist": DBusArray.string(
playback.track!.artists!.map((artist) => artist.name!),
playlist!.activeTrack.artists!.map((artist) => artist.name!),
),
"xesam:title": DBusString(playback.track!.name!),
"xesam:title": DBusString(playlist!.activeTrack.name!),
"xesam:url": DBusString(
playback.track is SpotubeTrack
? (playback.track as SpotubeTrack).ytUri
: playback.track!.previewUrl!,
playlist!.activeTrack is SpotubeTrack
? (playlist!.activeTrack as SpotubeTrack).ytUri
: playlist!.activeTrack.previewUrl!,
),
"xesam:genre": const DBusString("Unknown"),
}),
@ -320,19 +332,19 @@ class _MprisMediaPlayer2Player extends DBusObject {
/// Gets value of property org.mpris.MediaPlayer2.Player.Volume
Future<DBusMethodResponse> getVolume() async {
return DBusMethodSuccessResponse([DBusDouble(playback.volume)]);
return DBusMethodSuccessResponse([DBusDouble(volume)]);
}
/// Sets property org.mpris.MediaPlayer2.Player.Volume
Future<DBusMethodResponse> setVolume(double value) async {
playback.setVolume(value);
await volumeNotifier.setVolume(value);
return DBusMethodSuccessResponse();
}
/// Gets value of property org.mpris.MediaPlayer2.Player.Position
Future<DBusMethodResponse> getPosition() async {
return DBusMethodSuccessResponse([
DBusInt64((await playback.player.getDuration())?.inMicroseconds ?? 0),
DBusInt64((await audioPlayer.getDuration())?.inMicroseconds ?? 0),
]);
}
@ -350,7 +362,7 @@ class _MprisMediaPlayer2Player extends DBusObject {
Future<DBusMethodResponse> getCanGoNext() async {
return DBusMethodSuccessResponse([
DBusBoolean(
playback.playlist?.tracks.isNotEmpty == true,
(playlist?.tracks.length ?? 0) > 1,
)
]);
}
@ -359,7 +371,7 @@ class _MprisMediaPlayer2Player extends DBusObject {
Future<DBusMethodResponse> getCanGoPrevious() async {
return DBusMethodSuccessResponse([
DBusBoolean(
playback.playlist?.tracks.isNotEmpty == true,
(playlist?.tracks.length ?? 0) > 1,
)
]);
}
@ -386,43 +398,45 @@ class _MprisMediaPlayer2Player extends DBusObject {
/// Implementation of org.mpris.MediaPlayer2.Player.Next()
Future<DBusMethodResponse> doNext() async {
playback.seekForward();
await playlistNotifier.next();
return DBusMethodSuccessResponse();
}
/// Implementation of org.mpris.MediaPlayer2.Player.Previous()
Future<DBusMethodResponse> doPrevious() async {
playback.seekBackward();
await playlistNotifier.previous();
return DBusMethodSuccessResponse();
}
/// Implementation of org.mpris.MediaPlayer2.Player.Pause()
Future<DBusMethodResponse> doPause() async {
playback.pause();
playlistNotifier.pause();
return DBusMethodSuccessResponse();
}
/// Implementation of org.mpris.MediaPlayer2.Player.PlayPause()
Future<DBusMethodResponse> doPlayPause() async {
playback.isPlaying ? playback.pause() : playback.resume();
PlaylistQueueNotifier.isPlaying
? await playlistNotifier.pause()
: await playlistNotifier.resume();
return DBusMethodSuccessResponse();
}
/// Implementation of org.mpris.MediaPlayer2.Player.Stop()
Future<DBusMethodResponse> doStop() async {
playback.stop();
playlistNotifier.stop();
return DBusMethodSuccessResponse();
}
/// Implementation of org.mpris.MediaPlayer2.Player.Play()
Future<DBusMethodResponse> doPlay() async {
playback.resume();
playlistNotifier.resume();
return DBusMethodSuccessResponse();
}
/// Implementation of org.mpris.MediaPlayer2.Player.Seek()
Future<DBusMethodResponse> doSeek(int offset) async {
playback.seekPosition(Duration(microseconds: offset));
await playlistNotifier.seek(Duration(microseconds: offset));
return DBusMethodSuccessResponse();
}
@ -445,8 +459,7 @@ class _MprisMediaPlayer2Player extends DBusObject {
);
}
Future<void> updateProperties(Playback playback) async {
this.playback = playback;
Future<void> updateProperties() async {
return emitPropertiesChanged(
"org.mpris.MediaPlayer2.Player",
changedProperties: {
@ -714,9 +727,9 @@ class LinuxAudioService {
_MprisMediaPlayer2 mp2;
_MprisMediaPlayer2Player player;
LinuxAudioService(Playback playback)
LinuxAudioService(Ref ref)
: mp2 = _MprisMediaPlayer2(),
player = _MprisMediaPlayer2Player(playback: playback);
player = _MprisMediaPlayer2Player(ref);
void dispose() {
mp2.dispose();

View File

@ -3,34 +3,38 @@ import 'dart:async';
import 'package:audio_service/audio_service.dart';
import 'package:audio_session/audio_session.dart';
import 'package:audioplayers/audioplayers.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
class MobileAudioService extends BaseAudioHandler {
final Playback playback;
final Ref ref;
AudioSession? session;
MobileAudioService(this.playback) {
PlaylistQueue? get playlist => ref.watch(PlaylistQueueNotifier.provider);
PlaylistQueueNotifier get playlistNotifier =>
ref.watch(PlaylistQueueNotifier.notifier);
MobileAudioService(this.ref) {
AudioSession.instance.then((s) {
session = s;
s.interruptionEventStream.listen((event) {
s.interruptionEventStream.listen((event) async {
if (event.type != AudioInterruptionType.duck) {
playback.pause();
await playlistNotifier.pause();
}
});
});
final player = playback.player;
player.onPlayerStateChanged.listen((state) async {
audioPlayer.onPlayerStateChanged.listen((state) async {
if (state != PlayerState.completed) {
playbackState.add(await _transformEvent());
}
});
player.onPositionChanged.listen((pos) async {
audioPlayer.onPositionChanged.listen((pos) async {
playbackState.add(await _transformEvent());
});
player.onPlayerComplete.listen((_) {
if (playback.playlist == null && playback.track == null) {
audioPlayer.onPlayerComplete.listen((_) {
if (playlist == null) {
playbackState.add(
PlaybackState(
processingState: AudioProcessingState.completed,
@ -46,34 +50,35 @@ class MobileAudioService extends BaseAudioHandler {
}
@override
Future<void> play() => playback.resume();
Future<void> play() => playlistNotifier.resume();
@override
Future<void> pause() => playback.pause();
Future<void> pause() => playlistNotifier.pause();
@override
Future<void> seek(Duration position) => playback.seekPosition(position);
Future<void> seek(Duration position) => playlistNotifier.seek(position);
@override
Future<void> stop() async {
await playback.stop();
await playlistNotifier.stop();
}
@override
Future<void> skipToNext() async {
playback.seekForward();
await playlistNotifier.next();
await super.skipToNext();
}
@override
Future<void> skipToPrevious() async {
playback.seekBackward();
await playlistNotifier.previous();
await super.skipToPrevious();
}
@override
Future<void> onTaskRemoved() {
playback.destroy();
Future<void> onTaskRemoved() async {
await playlistNotifier.stop();
await audioPlayer.release();
return super.onTaskRemoved();
}
@ -81,7 +86,7 @@ class MobileAudioService extends BaseAudioHandler {
return PlaybackState(
controls: [
MediaControl.skipToPrevious,
playback.player.state == PlayerState.playing
audioPlayer.state == PlayerState.playing
? MediaControl.pause
: MediaControl.play,
MediaControl.skipToNext,
@ -91,12 +96,11 @@ class MobileAudioService extends BaseAudioHandler {
MediaAction.seek,
},
androidCompactActionIndices: const [0, 1, 2],
playing: playback.player.state == PlayerState.playing,
updatePosition:
(await playback.player.getCurrentPosition()) ?? Duration.zero,
processingState: playback.player.state == PlayerState.paused
playing: audioPlayer.state == PlayerState.playing,
updatePosition: (await audioPlayer.getCurrentPosition()) ?? Duration.zero,
processingState: audioPlayer.state == PlayerState.paused
? AudioProcessingState.buffering
: playback.player.state == PlayerState.playing
: audioPlayer.state == PlayerState.playing
? AudioProcessingState.ready
: AudioProcessingState.idle,
);

View File

@ -1,24 +1,47 @@
import 'dart:convert';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:hive/hive.dart';
abstract class PersistedStateNotifier<T> extends StateNotifier<T> {
get cacheKey => state.runtimeType.toString();
final String cacheKey;
SharedPreferences? localStorage;
PersistedStateNotifier(super.state) : super() {
SharedPreferences.getInstance().then(
(localStorage) {
this.localStorage = localStorage;
final rawState = localStorage.getString(cacheKey);
if (rawState != null) {
state = fromJson(jsonDecode(rawState));
PersistedStateNotifier(super.state, this.cacheKey) : super() {
_load();
}
},
Future<void> _load() async {
final box = await Hive.openLazyBox("spotube_cache");
final json = await box.get(cacheKey);
if (json != null) {
state = fromJson(castNestedJson(json));
}
}
Map<String, dynamic> castNestedJson(Map map) {
return Map.castFrom<dynamic, dynamic, String, dynamic>(
map.map((key, value) {
if (value is Map) {
return MapEntry(
key,
castNestedJson(value),
);
} else if (value is Iterable) {
return MapEntry(
key,
value.map((e) {
if (e is Map) return castNestedJson(e);
return e;
}).toList(),
);
}
return MapEntry(key, value);
}),
);
}
void save() async {
final box = await Hive.openLazyBox("spotube_cache");
box.put(cacheKey, toJson());
}
T fromJson(Map<String, dynamic> json);
@ -28,21 +51,6 @@ abstract class PersistedStateNotifier<T> extends StateNotifier<T> {
set state(T value) {
if (state == value) return;
super.state = value;
if (localStorage == null) {
SharedPreferences.getInstance().then(
(localStorage) {
this.localStorage = localStorage;
localStorage.setString(
cacheKey,
jsonEncode(toJson()),
);
},
);
} else {
localStorage?.setString(
cacheKey,
jsonEncode(toJson()),
);
}
save();
}
}

View File

@ -41,4 +41,20 @@ abstract class PrimitiveUtils {
final seconds = duration.inSeconds % 60;
return "${hours > 0 ? "${zeroPadNumStr(hours)}:" : ""}${zeroPadNumStr(minutes)}:${zeroPadNumStr(seconds)}";
}
static 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,
);
}),
);
}
}

View File

@ -146,6 +146,7 @@ abstract class TypeConversionUtils {
),
file.path,
[],
[],
);
track.album = Album()
..name = metadata?.album ?? "Spotube"

View File

@ -93,7 +93,7 @@ packages:
source: hosted
version: "2.3.2"
async:
dependency: transitive
dependency: "direct main"
description:
name: async
url: "https://pub.dartlang.org"
@ -562,7 +562,7 @@ packages:
source: hosted
version: "0.7.0"
flutter_cache_manager:
dependency: transitive
dependency: "direct main"
description:
name: flutter_cache_manager
url: "https://pub.dartlang.org"

View File

@ -12,6 +12,7 @@ environment:
dependencies:
adwaita: ^0.5.2
async: ^2.9.0
audio_service: ^0.18.9
audio_session: ^0.1.13
audioplayers: ^3.0.1
@ -31,6 +32,7 @@ dependencies:
fluentui_system_icons: ^1.1.189
flutter:
sdk: flutter
flutter_cache_manager: ^3.3.0
flutter_dotenv: ^5.0.2
flutter_feather_icons: ^2.0.0+1
flutter_hooks: ^0.18.2+1