Merge pull request #399 from KRTirtho/feat-server

Playback Manager Rewrite and Custom Server
This commit is contained in:
Kingkor Roy Tirtho 2023-02-03 17:31:34 +06:00 committed by GitHub
commit 0104362b3d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 1918 additions and 1411 deletions

View File

@ -28,6 +28,7 @@ jobs:
curl -sS https://webi.sh/yq | sh curl -sS https://webi.sh/yq | sh
yq -i '.version |= sub("\+\d+", "-nightly-")' pubspec.yaml yq -i '.version |= sub("\+\d+", "-nightly-")' pubspec.yaml
yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
echo '${{ secrets.DOTENV_NIGHTLY }}' > .env
flutter config --enable-linux-desktop flutter config --enable-linux-desktop
flutter pub get flutter pub get
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
@ -63,6 +64,7 @@ jobs:
curl -sS https://webi.sh/yq | sh curl -sS https://webi.sh/yq | sh
yq -i '.version |= sub("\+\d+", "-nightly-")' pubspec.yaml yq -i '.version |= sub("\+\d+", "-nightly-")' pubspec.yaml
yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
echo '${{ secrets.DOTENV_NIGHTLY }}' > .env
flutter pub get flutter pub get
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks
@ -93,6 +95,7 @@ jobs:
yq -i '.version |= sub("\+\d+", "-nightly-")' pubspec.yaml yq -i '.version |= sub("\+\d+", "-nightly-")' pubspec.yaml
yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.GITHUB_RUN_NUMBER }}/" windows/runner/Runner.rc sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.GITHUB_RUN_NUMBER }}/" windows/runner/Runner.rc
echo '${{ secrets.DOTENV_NIGHTLY }}' > .env
flutter config --enable-windows-desktop flutter config --enable-windows-desktop
flutter pub get flutter pub get
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
@ -120,6 +123,7 @@ jobs:
- run: brew install yq - run: brew install yq
- run: yq -i '.version |= sub("\+\d+", "-nightly-")' pubspec.yaml - run: yq -i '.version |= sub("\+\d+", "-nightly-")' pubspec.yaml
- run: yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml - run: yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
- run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env
- run: flutter config --enable-macos-desktop - run: flutter config --enable-macos-desktop
- run: flutter pub get - run: flutter pub get
- run: dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' - run: dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'

View File

@ -31,6 +31,7 @@ jobs:
with: with:
cache: true cache: true
- run: | - run: |
echo '${{ secrets.DOTENV_RELEASE }}' > .env
flutter config --enable-windows-desktop flutter config --enable-windows-desktop
flutter pub get flutter pub get
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
@ -72,6 +73,7 @@ jobs:
- uses: subosito/flutter-action@v2.8.0 - uses: subosito/flutter-action@v2.8.0
with: with:
cache: true cache: true
- run: echo '${{ secrets.DOTENV_RELEASE }}' > .env
- run: flutter config --enable-macos-desktop - run: flutter config --enable-macos-desktop
- run: flutter pub get - run: flutter pub get
- run: dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' - run: dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
@ -112,6 +114,7 @@ jobs:
# replacing & adding new release version with older version # replacing & adding new release version with older version
- run: | - run: |
sed -i 's|%{{APPDATA_RELEASE}}%|<release version="${{ steps.tag.outputs.tag }}" date="${{ steps.date.outputs.date }}" />|' linux/com.github.KRTirtho.Spotube.appdata.xml sed -i 's|%{{APPDATA_RELEASE}}%|<release version="${{ steps.tag.outputs.tag }}" date="${{ steps.date.outputs.date }}" />|' linux/com.github.KRTirtho.Spotube.appdata.xml
echo '${{ secrets.DOTENV_RELEASE }}' > .env
- run: | - run: |
flutter config --enable-linux-desktop flutter config --enable-linux-desktop
@ -146,6 +149,7 @@ jobs:
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse
- run: | - run: |
echo '${{ secrets.DOTENV_RELEASE }}' > .env
flutter pub get flutter pub get
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}' dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks

2
.gitignore vendored
View File

@ -75,3 +75,5 @@ appimage-build
android/key.properties android/key.properties
.fvm/flutter_sdk .fvm/flutter_sdk
**/pb_data

View File

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

View File

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

View File

@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists 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

15
lib/collections/env.dart Normal file
View File

@ -0,0 +1,15 @@
import 'package:flutter/foundation.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
abstract class Env {
static final String pocketbaseUrl =
dotenv.get('POCKETBASE_URL', fallback: 'http://localhost:8090');
static final String username = dotenv.get('USERNAME', fallback: 'root');
static final String password = dotenv.get('PASSWORD', fallback: '12345678');
static configure() async {
if (kReleaseMode) {
await dotenv.load(fileName: ".env");
}
}
}

View File

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

View File

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

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

View File

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

View File

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

View File

@ -1,15 +1,15 @@
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:platform_ui/platform_ui.dart'; import 'package:platform_ui/platform_ui.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/player/player_track_details.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/hooks/use_palette_color.dart';
import 'package:spotube/collections/intents.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'; import 'package:spotube/utils/service_utils.dart';
class PlayerOverlay extends HookConsumerWidget { class PlayerOverlay extends HookConsumerWidget {
@ -24,16 +24,10 @@ class PlayerOverlay extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final paletteColor = usePaletteColor(albumArt, ref); final paletteColor = usePaletteColor(albumArt, ref);
final canShow = ref.watch( final canShow = ref.watch(
playbackProvider.select( PlaylistQueueNotifier.provider.select((s) => s != null),
(s) =>
s.track != null ||
s.isPlaying ||
s.status == PlaybackStatus.loading,
),
); );
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
final onNext = useNextTrack(ref); final playing = useStream(PlaylistQueueNotifier.playing).data ?? false;
final onPrevious = usePreviousTrack(ref);
return GestureDetector( return GestureDetector(
onVerticalDragEnd: (details) { onVerticalDragEnd: (details) {
@ -87,14 +81,13 @@ class PlayerOverlay extends HookConsumerWidget {
SpotubeIcons.skipBack, SpotubeIcons.skipBack,
color: paletteColor.bodyTextColor, color: paletteColor.bodyTextColor,
), ),
onPressed: () { onPressed: playlistNotifier.previous,
onPrevious(); ),
}),
Consumer( Consumer(
builder: (context, ref, _) { builder: (context, ref, _) {
return IconButton( return IconButton(
icon: Icon( icon: Icon(
ref.read(playbackProvider).isPlaying playing
? SpotubeIcons.pause ? SpotubeIcons.pause
: SpotubeIcons.play, : SpotubeIcons.play,
color: paletteColor.bodyTextColor, color: paletteColor.bodyTextColor,
@ -111,7 +104,7 @@ class PlayerOverlay extends HookConsumerWidget {
SpotubeIcons.skipForward, SpotubeIcons.skipForward,
color: paletteColor.bodyTextColor, 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/fallbacks/not_found.dart';
import 'package:spotube/components/shared/track_table/track_tile.dart'; import 'package:spotube/components/shared/track_table/track_tile.dart';
import 'package:spotube/hooks/use_auto_scroll_controller.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'; import 'package:spotube/utils/primitive_utils.dart';
class PlayerQueue extends HookConsumerWidget { class PlayerQueue extends HookConsumerWidget {
@ -20,9 +20,10 @@ class PlayerQueue extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { 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 controller = useAutoScrollController();
final tracks = playback.playlist?.tracks ?? []; final tracks = playlist?.tracks ?? {};
if (tracks.isEmpty) { if (tracks.isEmpty) {
return const NotFound(vertical: true); return const NotFound(vertical: true);
@ -38,9 +39,8 @@ class PlayerQueue extends HookConsumerWidget {
PlatformTheme.of(context).textTheme?.subheading?.color; PlatformTheme.of(context).textTheme?.subheading?.color;
useEffect(() { useEffect(() {
if (playback.track == null || playback.playlist == null) return null; if (playlist == null) return null;
final index = playback.playlist!.tracks final index = playlist.active;
.indexWhere((track) => track.id == playback.track!.id);
if (index < 0) return; if (index < 0) return;
controller.scrollToIndex( controller.scrollToIndex(
index, index,
@ -77,14 +77,6 @@ class PlayerQueue extends HookConsumerWidget {
), ),
), ),
PlatformText.subheading("Queue"), 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), const SizedBox(height: 10),
Flexible( Flexible(
child: ListView.builder( child: ListView.builder(
@ -92,7 +84,7 @@ class PlayerQueue extends HookConsumerWidget {
itemCount: tracks.length, itemCount: tracks.length,
shrinkWrap: true, shrinkWrap: true,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final track = tracks.asMap().entries.elementAt(i); final track = tracks.toList().asMap().entries.elementAt(i);
String duration = String duration =
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; "${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
return AutoScrollTag( return AutoScrollTag(
@ -102,13 +94,15 @@ class PlayerQueue extends HookConsumerWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0), padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: TrackTile( child: TrackTile(
playback, playlist,
track: track, track: track,
duration: duration, duration: duration,
isActive: playback.track?.id == track.value.id, isActive: playlist?.activeTrack.id == track.value.id,
onTrackPlayButtonPressed: (currentTrack) async { onTrackPlayButtonPressed: (currentTrack) async {
if (playback.track?.id == track.value.id) return; if (playlist?.activeTrack.id == track.value.id) {
await playback.setPlaylistPosition(i); return;
}
await playlistNotifier.playTrack(currentTrack);
}, },
), ),
), ),

View File

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

View File

@ -5,8 +5,10 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:platform_ui/platform_ui.dart'; import 'package:platform_ui/platform_ui.dart';
import 'package:spotube/components/shared/image/universal_image.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:spotube/utils/primitive_utils.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
class SiblingTracksSheet extends HookConsumerWidget { class SiblingTracksSheet extends HookConsumerWidget {
final bool floating; final bool floating;
@ -17,7 +19,13 @@ class SiblingTracksSheet extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { 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 final borderRadius = floating
? BorderRadius.circular(10) ? BorderRadius.circular(10)
: const BorderRadius.only( : const BorderRadius.only(
@ -26,11 +34,12 @@ class SiblingTracksSheet extends HookConsumerWidget {
); );
useEffect(() { useEffect(() {
if (playback.siblingYtVideos.isEmpty) { if (playlist?.activeTrack is SpotubeTrack &&
playback.toSpotubeTrack(playback.track!, ignoreCache: true); (playlist?.activeTrack as SpotubeTrack).siblings.isEmpty) {
playlistNotifier.populateSibling();
} }
return null; return null;
}, [playback.siblingYtVideos]); }, [playlist?.activeTrack]);
return BackdropFilter( return BackdropFilter(
filter: ImageFilter.blur( filter: ImageFilter.blur(
@ -59,9 +68,9 @@ class SiblingTracksSheet extends HookConsumerWidget {
body: Container( body: Container(
padding: const EdgeInsets.symmetric(horizontal: 8.0), padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: ListView.builder( child: ListView.builder(
itemCount: playback.siblingYtVideos.length, itemCount: siblings.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final video = playback.siblingYtVideos[index]; final video = siblings[index];
return PlatformListTile( return PlatformListTile(
title: PlatformText(video.title), title: PlatformText(video.title),
leading: Padding( leading: Padding(
@ -81,12 +90,22 @@ class SiblingTracksSheet extends HookConsumerWidget {
), ),
), ),
subtitle: PlatformText(video.author), subtitle: PlatformText(video.author),
enabled: playback.status != PlaybackStatus.loading, enabled: playlist?.isLoading != true,
selected: video.id == playback.track!.ytTrack.id, selected: playlist?.isLoading != true &&
video.id.value ==
(playlist?.activeTrack as SpotubeTrack)
.ytTrack
.id
.value,
selectedTileColor: Theme.of(context).popupMenuTheme.color, selectedTileColor: Theme.of(context).popupMenuTheme.color,
onTap: () { onTap: () async {
if (video.id != playback.track!.ytTrack.id) { if (playlist?.isLoading == false &&
playback.changeToSiblingVideo(video, playback.track!); video.id.value !=
(playlist?.activeTrack as SpotubeTrack)
.ytTrack
.id
.value) {
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/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/components/shared/playbutton_card.dart';
import 'package:spotube/hooks/use_breakpoint_value.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/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/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
@ -19,9 +21,15 @@ class PlaylistCard extends HookConsumerWidget {
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider); final playlistQueue = ref.watch(PlaylistQueueNotifier.provider);
bool isPlaylistPlaying = final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
playback.playlist != null && playback.playlist!.id == playlist.id; 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 = final int marginH =
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20); useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
@ -33,8 +41,8 @@ class PlaylistCard extends HookConsumerWidget {
playlist.images, playlist.images,
placeholder: ImagePlaceholder.collection, placeholder: ImagePlaceholder.collection,
), ),
isPlaying: isPlaylistPlaying && playback.isPlaying, isPlaying: isPlaylistPlaying && playing,
isLoading: playback.status == PlaybackStatus.loading && isPlaylistPlaying, isLoading: isPlaylistPlaying && playlistQueue?.isLoading == true,
onTap: () { onTap: () {
ServiceUtils.navigate( ServiceUtils.navigate(
context, context,
@ -43,10 +51,10 @@ class PlaylistCard extends HookConsumerWidget {
); );
}, },
onPlaybuttonPressed: () async { onPlaybuttonPressed: () async {
if (isPlaylistPlaying && playback.isPlaying) { if (isPlaylistPlaying && playing) {
return playback.pause(); return playlistNotifier.pause();
} else if (isPlaylistPlaying && !playback.isPlaying) { } else if (isPlaylistPlaying && !playing) {
return playback.resume(); return playlistNotifier.resume();
} }
SpotifyApi spotifyApi = ref.read(spotifyProvider); SpotifyApi spotifyApi = ref.read(spotifyProvider);
@ -61,17 +69,7 @@ class PlaylistCard extends HookConsumerWidget {
if (tracks.isEmpty) return; if (tracks.isEmpty) return;
await playback.playPlaylist( await playlistNotifier.loadAndPlay(tracks);
CurrentPlaylist(
tracks: tracks,
id: playlist.id!,
name: playlist.name!,
thumbnail: TypeConversionUtils.image_X_UrlString(
playlist.images,
placeholder: ImagePlaceholder.collection,
),
),
);
}, },
); );
} }

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

View File

@ -35,6 +35,8 @@ class UniversalImage extends HookWidget {
cacheKey: path, cacheKey: path,
scale: scale, scale: scale,
); );
} else if (path.startsWith("assets/")) {
return AssetImage(path);
} else if (Uri.tryParse(path) != null) { } else if (Uri.tryParse(path) != null) {
return FileImage(File(path), scale: scale); return FileImage(File(path), scale: scale);
} }

View File

@ -18,8 +18,8 @@ import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/auth_provider.dart'; import 'package:spotube/provider/auth_provider.dart';
import 'package:spotube/provider/blacklist_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/spotify_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/services/mutations/mutations.dart'; import 'package:spotube/services/mutations/mutations.dart';
import 'package:spotube/services/queries/queries.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'; import 'package:tuple/tuple.dart';
class TrackTile extends HookConsumerWidget { class TrackTile extends HookConsumerWidget {
final Playback playback; final PlaylistQueue? playlist;
final MapEntry<int, Track> track; final MapEntry<int, Track> track;
final String duration; final String duration;
final void Function(Track currentTrack)? onTrackPlayButtonPressed; final void Function(Track currentTrack)? onTrackPlayButtonPressed;
@ -47,7 +47,7 @@ class TrackTile extends HookConsumerWidget {
final void Function(bool?)? onCheckChange; final void Function(bool?)? onCheckChange;
TrackTile( TrackTile(
this.playback, { this.playlist, {
required this.track, required this.track,
required this.duration, required this.duration,
required this.isActive, required this.isActive,
@ -240,8 +240,7 @@ class TrackTile extends HookConsumerWidget {
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: PlatformIconButton( child: PlatformIconButton(
icon: Icon( icon: Icon(
playback.track?.id != null && playlist?.activeTrack.id == track.value.id
playback.track?.id == track.value.id
? SpotubeIcons.pause ? SpotubeIcons.pause
: SpotubeIcons.play, : SpotubeIcons.play,
color: Colors.white, 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/hooks/use_breakpoints.dart';
import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/downloader_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/primitive_utils.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
@ -40,7 +40,7 @@ class TracksTableView extends HookConsumerWidget {
@override @override
Widget build(context, ref) { Widget build(context, ref) {
Playback playback = ref.watch(playbackProvider); final playlist = ref.watch(PlaylistQueueNotifier.provider);
final downloader = ref.watch(downloaderProvider); final downloader = ref.watch(downloaderProvider);
TextStyle tableHeadStyle = TextStyle tableHeadStyle =
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16); const TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
@ -235,12 +235,12 @@ class TracksTableView extends HookConsumerWidget {
} }
}, },
child: TrackTile( child: TrackTile(
playback, playlist,
playlistId: playlistId, playlistId: playlistId,
track: track, track: track,
duration: duration, duration: duration,
userPlaylist: userPlaylist, userPlaylist: userPlaylist,
isActive: playback.track?.id == track.value.id, isActive: playlist?.activeTrack.id == track.value.id,
onTrackPlayButtonPressed: onTrackPlayButtonPressed, onTrackPlayButtonPressed: onTrackPlayButtonPressed,
isChecked: selected.value.contains(track.value.id), isChecked: selected.value.contains(track.value.id),
showCheck: showCheck.value, showCheck: showCheck.value,

View File

@ -1,4 +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/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:spotube/utils/duration.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart'; import 'package:youtube_explode_dart/youtube_explode_dart.dart';
@ -30,6 +37,11 @@ extension VideoFromCacheTrackExtension on Video {
false, false,
); );
} }
static Future<Video> fromBackendTrack(
BackendTrack track, YoutubeExplode youtube) {
return youtube.videos.get(VideoId.fromString(track.youtubeId));
}
} }
extension ThumbnailSetJson on ThumbnailSet { extension ThumbnailSetJson on ThumbnailSet {
@ -100,3 +112,48 @@ 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'
},
));
if (res.body == "Not Found") {
return List.castFrom<dynamic, Map<String, int>>([]);
}
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_hooks/flutter_hooks.dart';
import 'package:flutter_riverpod/flutter_riverpod.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( int useSyncedLyrics(
WidgetRef ref, WidgetRef ref,
Map<int, String> lyricsMap, Map<int, String> lyricsMap,
Duration delay, Duration delay,
) { ) {
final player = ref.watch(playbackProvider.select( final stream = PlaylistQueueNotifier.position;
(value) => (value.player),
));
final stream = player.onPositionChanged;
final currentTime = useState(0); final currentTime = useState(0);

View File

@ -1,6 +1,5 @@
import 'dart:convert'; import 'dart:convert';
import 'package:audio_service/audio_service.dart';
import 'package:catcher/catcher.dart'; import 'package:catcher/catcher.dart';
import 'package:fl_query/fl_query.dart'; import 'package:fl_query/fl_query.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@ -12,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:platform_ui/platform_ui.dart'; import 'package:platform_ui/platform_ui.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/collections/cache_keys.dart'; import 'package:spotube/collections/cache_keys.dart';
import 'package:spotube/collections/env.dart';
import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart'; import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart';
import 'package:spotube/entities/cache_track.dart'; import 'package:spotube/entities/cache_track.dart';
import 'package:spotube/collections/routes.dart'; import 'package:spotube/collections/routes.dart';
@ -19,10 +19,9 @@ import 'package:spotube/collections/intents.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/audio_player_provider.dart'; import 'package:spotube/provider/audio_player_provider.dart';
import 'package:spotube/provider/downloader_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/user_preferences_provider.dart';
import 'package:spotube/provider/youtube_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/dark_theme.dart';
import 'package:spotube/themes/light_theme.dart'; import 'package:spotube/themes/light_theme.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
@ -36,6 +35,8 @@ void main() async {
Hive.registerAdapter(CacheTrackAdapter()); Hive.registerAdapter(CacheTrackAdapter());
Hive.registerAdapter(CacheTrackEngagementAdapter()); Hive.registerAdapter(CacheTrackEngagementAdapter());
Hive.registerAdapter(CacheTrackSkipSegmentAdapter()); Hive.registerAdapter(CacheTrackSkipSegmentAdapter());
await Env.configure();
if (kIsDesktop) { if (kIsDesktop) {
await windowManager.ensureInitialized(); await windowManager.ensureInitialized();
WindowOptions windowOptions = const WindowOptions( WindowOptions windowOptions = const WindowOptions(
@ -61,7 +62,6 @@ void main() async {
await windowManager.show(); await windowManager.show();
}); });
} }
MobileAudioService? audioServiceHandler;
Catcher( Catcher(
debugConfig: CatcherOptions( debugConfig: CatcherOptions(
@ -72,7 +72,17 @@ void main() async {
enableApplicationParameters: false, enableApplicationParameters: false,
), ),
FileHandler(await getLogsPath(), printLogs: false), FileHandler(await getLogsPath(), printLogs: false),
SnackbarHandler(const Duration(seconds: 5)), SnackbarHandler(
const Duration(seconds: 5),
action: SnackBarAction(
label: "Dismiss",
onPressed: () {
ScaffoldMessenger.of(
Catcher.navigatorKey!.currentContext!,
).hideCurrentSnackBar();
},
),
),
], ],
), ),
releaseConfig: CatcherOptions(SilentReportMode(), [ releaseConfig: CatcherOptions(SilentReportMode(), [
@ -84,36 +94,6 @@ void main() async {
builder: (context) { builder: (context) {
return ProviderScope( return ProviderScope(
overrides: [ 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( downloaderProvider.overrideWith(
(ref) { (ref) {
return Downloader( return Downloader(
@ -155,6 +135,7 @@ void main() async {
); );
}, },
); );
await initializePocketBase();
} }
class Spotube extends StatefulHookConsumerWidget { 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

@ -1,8 +1,20 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/video.dart'; import 'package:spotube/extensions/video.dart';
import 'package:spotube/extensions/album_simple.dart'; import 'package:spotube/extensions/album_simple.dart';
import 'package:spotube/extensions/artist_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:youtube_explode_dart/youtube_explode_dart.dart';
import 'package:collection/collection.dart';
enum SpotubeTrackMatchAlgorithm { enum SpotubeTrackMatchAlgorithm {
// selects the first result returned from YouTube // selects the first result returned from YouTube
@ -14,14 +26,16 @@ enum SpotubeTrackMatchAlgorithm {
} }
class SpotubeTrack extends Track { class SpotubeTrack extends Track {
Video ytTrack; final Video ytTrack;
String ytUri; final String ytUri;
List<Map<String, int>> skipSegments; final List<Map<String, int>> skipSegments;
final List<Video> siblings;
SpotubeTrack( SpotubeTrack(
this.ytTrack, this.ytTrack,
this.ytUri, this.ytUri,
this.skipSegments, this.skipSegments,
this.siblings,
) : super(); ) : super();
SpotubeTrack.fromTrack({ SpotubeTrack.fromTrack({
@ -29,6 +43,7 @@ class SpotubeTrack extends Track {
required this.ytTrack, required this.ytTrack,
required this.ytUri, required this.ytUri,
required this.skipSegments, required this.skipSegments,
required this.siblings,
}) : super() { }) : super() {
album = track.album; album = track.album;
artists = track.artists; artists = track.artists;
@ -50,6 +65,219 @@ class SpotubeTrack extends Track {
uri = track.uri; 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.where((video) => !video.isLive).take(10).toList();
ytVideo = siblings.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(),
);
}
if (preferences.predownload &&
ytVideo.duration! < const Duration(minutes: 15)) {
await DefaultCacheManager().getFileFromCache(track.id!).then(
(file) async {
if (file != null) return file.file;
final List<int> bytesStore = [];
final bytesFuture = Completer<Uint8List>();
youtube.videos.streams.get(chosenStreamInfo).listen(
(data) {
bytesStore.addAll(data);
},
onDone: () {
bytesFuture.complete(Uint8List.fromList(bytesStore));
},
onError: (e) {
bytesFuture.completeError(e);
},
);
final cached = await DefaultCacheManager().putFile(
track.id!,
await bytesFuture.future,
fileExtension: chosenStreamInfo.codec.mimeType.split("/").last,
);
return cached;
},
);
}
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,
},
);
}
if (preferences.predownload &&
video.duration! < const Duration(minutes: 15)) {
await DefaultCacheManager().getFileFromCache(id!).then(
(file) async {
if (file != null) return file.file;
final List<int> bytesStore = [];
final bytesFuture = Completer<Uint8List>();
youtube.videos.streams.get(chosenStreamInfo).listen(
(data) {
bytesStore.addAll(data);
},
onDone: () {
bytesFuture.complete(Uint8List.fromList(bytesStore));
},
onError: (e) {
bytesFuture.completeError(e);
},
);
final cached = await DefaultCacheManager().putFile(
id!,
await bytesFuture.future,
fileExtension: chosenStreamInfo.codec.mimeType.split("/").last,
);
return cached;
},
);
}
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) { static SpotubeTrack fromJson(Map<String, dynamic> map) {
return SpotubeTrack.fromTrack( return SpotubeTrack.fromTrack(
track: Track.fromJson(map), track: Track.fromJson(map),
@ -57,6 +285,37 @@ class SpotubeTrack extends Track {
ytUri: map["ytUri"], ytUri: map["ytUri"],
skipSegments: skipSegments:
List.castFrom<dynamic, Map<String, int>>(map["skipSegments"]), List.castFrom<dynamic, Map<String, int>>(map["skipSegments"]),
siblings: List.castFrom<dynamic, Map<String, dynamic>>(map["siblings"])
.map((sibling) => VideoToJson.fromJson(sibling))
.toList(),
);
}
Future<SpotubeTrack> populatedCopy() async {
if (this.siblings.isNotEmpty) return this;
final artists = (this.artists ?? [])
.map((ar) => ar.name)
.toList()
.whereNotNull()
.toList();
final title = ServiceUtils.getTitle(
name!,
artists: artists,
onlyCleanArtist: true,
).trim();
VideoSearchList videos = await PrimitiveUtils.raceMultiple(
() => youtube.search.search("${artists.join(", ")} - $title"),
);
final siblings = videos.where((video) => !video.isLive).take(10).toList();
return SpotubeTrack.fromTrack(
track: this,
ytTrack: ytTrack,
ytUri: ytUri,
skipSegments: skipSegments,
siblings: siblings,
); );
} }
@ -80,7 +339,8 @@ class SpotubeTrack extends Track {
"uri": uri, "uri": uri,
"ytTrack": ytTrack.toJson(), "ytTrack": ytTrack.toJson(),
"ytUri": ytUri, "ytUri": ytUri,
"skipSegments": skipSegments "skipSegments": skipSegments,
"siblings": siblings.map((sibling) => sibling.toJson()).toList(),
}; };
} }
} }

29
lib/models/track.dart Normal file
View File

@ -0,0 +1,29 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:pocketbase/pocketbase.dart';
part 'track.g.dart';
@JsonSerializable()
class BackendTrack extends RecordModel {
@JsonKey(name: "spotify_id")
final String spotifyId;
@JsonKey(name: "youtube_id")
final String youtubeId;
final int votes;
BackendTrack({
required this.spotifyId,
required this.youtubeId,
required this.votes,
});
factory BackendTrack.fromRecord(RecordModel record) =>
BackendTrack.fromJson(record.toJson());
factory BackendTrack.fromJson(Map<String, dynamic> json) =>
_$BackendTrackFromJson(json);
@override
Map<String, dynamic> toJson() => _$BackendTrackToJson(this);
static String collection = "tracks";
}

30
lib/models/track.g.dart Normal file
View File

@ -0,0 +1,30 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'track.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
BackendTrack _$BackendTrackFromJson(Map<String, dynamic> json) => BackendTrack(
spotifyId: json['spotify_id'] as String,
youtubeId: json['youtube_id'] as String,
votes: json['votes'] as int,
)
..id = json['id'] as String
..created = json['created'] as String
..updated = json['updated'] as String
..collectionId = json['collectionId'] as String
..collectionName = json['collectionName'] as String;
Map<String, dynamic> _$BackendTrackToJson(BackendTrack instance) =>
<String, dynamic>{
'id': instance.id,
'created': instance.created,
'updated': instance.updated,
'collectionId': instance.collectionId,
'collectionName': instance.collectionName,
'spotify_id': instance.spotifyId,
'youtube_id': instance.youtubeId,
'votes': instance.votes,
};

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/track_collection_view.dart';
import 'package:spotube/components/shared/track_table/tracks_table_view.dart'; import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
import 'package:spotube/hooks/use_breakpoints.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/services/queries/queries.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_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'; import 'package:spotube/provider/spotify_provider.dart';
class AlbumPage extends HookConsumerWidget { class AlbumPage extends HookConsumerWidget {
@ -20,38 +19,33 @@ class AlbumPage extends HookConsumerWidget {
const AlbumPage(this.album, {Key? key}) : super(key: key); const AlbumPage(this.album, {Key? key}) : super(key: key);
Future<void> playPlaylist( Future<void> playPlaylist(
Playback playback, PlaylistQueueNotifier playback,
List<Track> tracks, List<Track> tracks,
WidgetRef ref, { WidgetRef ref, {
Track? currentTrack, Track? currentTrack,
}) async { }) async {
final playlist = ref.read(PlaylistQueueNotifier.provider);
final sortBy = ref.read(trackCollectionSortState(album.id!)); final sortBy = ref.read(trackCollectionSortState(album.id!));
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy); final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
currentTrack ??= sortedTracks.first; currentTrack ??= sortedTracks.first;
final isPlaylistPlaying = playback.playlist?.id == album.id; final isPlaylistPlaying = playback.isPlayingPlaylist(tracks);
if (!isPlaylistPlaying) { if (!isPlaylistPlaying) {
await playback.playPlaylist( playback.load(
CurrentPlaylist( sortedTracks,
tracks: sortedTracks, active: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
id: album.id!,
name: album.name!,
thumbnail: TypeConversionUtils.image_X_UrlString(
album.images,
placeholder: ImagePlaceholder.collection,
),
),
sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
); );
await playback.play();
} else if (isPlaylistPlaying && } else if (isPlaylistPlaying &&
currentTrack.id != null && currentTrack.id != null &&
currentTrack.id != playback.track?.id) { currentTrack.id != playlist?.activeTrack.id) {
await playback.play(currentTrack); await playback.playTrack(currentTrack);
} }
} }
@override @override
Widget build(BuildContext context, ref) { 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); final SpotifyApi spotify = ref.watch(spotifyProvider);
@ -69,8 +63,10 @@ class AlbumPage extends HookConsumerWidget {
final breakpoint = useBreakpoints(); final breakpoint = useBreakpoints();
final isAlbumPlaying = final isAlbumPlaying = useMemoized(
playback.playlist?.id != null && playback.playlist?.id == album.id; () => playback.isPlayingPlaylist(tracksSnapshot.data ?? []),
[tracksSnapshot.data],
);
return TrackCollectionView( return TrackCollectionView(
id: album.id!, id: album.id!,
isPlaying: isAlbumPlaying, 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/components/artist/artist_card.dart';
import 'package:spotube/hooks/use_breakpoint_value.dart'; import 'package:spotube/hooks/use_breakpoint_value.dart';
import 'package:spotube/hooks/use_breakpoints.dart'; import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/models/current_playlist.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/auth_provider.dart'; import 'package:spotube/provider/auth_provider.dart';
import 'package:spotube/provider/blacklist_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/spotify_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/primitive_utils.dart'; import 'package:spotube/utils/primitive_utils.dart';
@ -54,7 +53,8 @@ class ArtistPage extends HookConsumerWidget {
final breakpoint = useBreakpoints(); 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); final auth = ref.watch(authProvider);
@ -296,28 +296,20 @@ class ArtistPage extends HookConsumerWidget {
final topTracks = topTracksQuery.data!; final topTracks = topTracksQuery.data!;
final isPlaylistPlaying = final isPlaylistPlaying = useMemoized(() {
playback.playlist?.id == data.id; return playlistNotifier.isPlayingPlaylist(topTracks);
}, [topTracks]);
playPlaylist(List<Track> tracks, playPlaylist(List<Track> tracks,
{Track? currentTrack}) async { {Track? currentTrack}) async {
currentTrack ??= tracks.first; currentTrack ??= tracks.first;
if (!isPlaylistPlaying) { if (!isPlaylistPlaying) {
await playback.playPlaylist( playlistNotifier.loadAndPlay(tracks,
CurrentPlaylist( active: tracks
tracks: tracks, .indexWhere((s) => s.id == currentTrack?.id));
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),
);
} else if (isPlaylistPlaying && } else if (isPlaylistPlaying &&
currentTrack.id != null && currentTrack.id != null &&
currentTrack.id != playback.track?.id) { currentTrack.id != playlist?.activeTrack.id) {
await playback.play(currentTrack); await playlistNotifier.playTrack(currentTrack);
} }
} }
@ -352,10 +344,11 @@ class ArtistPage extends HookConsumerWidget {
String duration = String duration =
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; "${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
return TrackTile( return TrackTile(
playback, playlist,
duration: duration, duration: duration,
track: track, track: track,
isActive: playback.track?.id == track.value.id, isActive:
playlist?.activeTrack.id == track.value.id,
onTrackPlayButtonPressed: (currentTrack) => onTrackPlayButtonPressed: (currentTrack) =>
playPlaylist( playPlaylist(
topTracks.toList(), topTracks.toList(),

View File

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

View File

@ -5,7 +5,7 @@ import 'package:palette_generator/palette_generator.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart'; import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart';
import 'package:spotube/hooks/use_breakpoints.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/provider/user_preferences_provider.dart';
import 'package:spotube/services/queries/queries.dart'; import 'package:spotube/services/queries/queries.dart';
@ -23,11 +23,11 @@ class GeniusLyrics extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider); final playlist = ref.watch(PlaylistQueueNotifier.provider);
final geniusLyricsQuery = useQuery( final geniusLyricsQuery = useQuery(
job: Queries.lyrics.static(playback.track?.id ?? ""), job: Queries.lyrics.static(playlist?.activeTrack.id ?? ""),
externalData: Tuple2( externalData: Tuple2(
playback.track, playlist?.activeTrack,
ref.watch(userPreferencesProvider).geniusAccessToken, ref.watch(userPreferencesProvider).geniusAccessToken,
), ),
); );
@ -40,7 +40,7 @@ class GeniusLyrics extends HookConsumerWidget {
if (isModal != true) ...[ if (isModal != true) ...[
Center( Center(
child: Text( child: Text(
playback.track?.name ?? "", playlist?.activeTrack.name ?? "",
style: breakpoint >= Breakpoints.md style: breakpoint >= Breakpoints.md
? textTheme.headline3 ? textTheme.headline3
: textTheme.headline4?.copyWith( : textTheme.headline4?.copyWith(
@ -52,7 +52,7 @@ class GeniusLyrics extends HookConsumerWidget {
Center( Center(
child: Text( child: Text(
TypeConversionUtils.artists_X_String<Artist>( TypeConversionUtils.artists_X_String<Artist>(
playback.track?.artists ?? []), playlist?.activeTrack.artists ?? []),
style: (breakpoint >= Breakpoints.md style: (breakpoint >= Breakpoints.md
? textTheme.headline5 ? textTheme.headline5
: textTheme.headline6) : textTheme.headline6)
@ -72,7 +72,7 @@ class GeniusLyrics extends HookConsumerWidget {
return const ShimmerLyrics(); return const ShimmerLyrics();
} else if (geniusLyricsQuery.hasError) { } else if (geniusLyricsQuery.hasError) {
return Text( 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( style: textTheme.bodyText1?.copyWith(
color: palette.bodyTextColor, color: palette.bodyTextColor,
), ),
@ -82,12 +82,11 @@ class GeniusLyrics extends HookConsumerWidget {
final lyrics = geniusLyricsQuery.data; final lyrics = geniusLyricsQuery.data;
return Text( return Text(
lyrics == null && playback.track == null lyrics == null && playlist?.activeTrack == null
? "No Track being played currently" ? "No Track being played currently"
: lyrics ?? "", : lyrics ?? "",
style: textTheme.headline6?.copyWith( style:
color: palette.bodyTextColor, 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/hooks/use_palette_color.dart';
import 'package:spotube/pages/lyrics/genius_lyrics.dart'; import 'package:spotube/pages/lyrics/genius_lyrics.dart';
import 'package:spotube/pages/lyrics/synced_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/platform.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
@ -21,14 +21,14 @@ class LyricsPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider); final playlist = ref.watch(PlaylistQueueNotifier.provider);
String albumArt = useMemoized( String albumArt = useMemoized(
() => TypeConversionUtils.image_X_UrlString( () => TypeConversionUtils.image_X_UrlString(
playback.track?.album?.images, playlist?.activeTrack.album?.images,
index: (playback.track?.album?.images?.length ?? 1) - 1, index: (playlist?.activeTrack.album?.images?.length ?? 1) - 1,
placeholder: ImagePlaceholder.albumArt, placeholder: ImagePlaceholder.albumArt,
), ),
[playback.track?.album?.images], [playlist?.activeTrack.album?.images],
); );
final palette = usePaletteColor(albumArt, ref); final palette = usePaletteColor(albumArt, ref);
final index = useState(0); 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/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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_auto_scroll_controller.dart';
import 'package:spotube/hooks/use_breakpoints.dart'; import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/hooks/use_synced_lyrics.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: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/services/queries/queries.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
@ -36,15 +38,34 @@ class SyncedLyrics extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider); final playlist = ref.watch(PlaylistQueueNotifier.provider);
final timedLyricsQuery = useQuery(
job: Queries.lyrics.synced(playback.track?.id ?? ""),
externalData: playback.track,
);
final lyricDelay = ref.watch(lyricDelayState); final lyricDelay = ref.watch(lyricDelayState);
final breakpoint = useBreakpoints(); final breakpoint = useBreakpoints();
final controller = useAutoScrollController(); 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 lyricValue = timedLyricsQuery.data;
final lyricsMap = useMemoized( final lyricsMap = useMemoized(
() => () =>
@ -55,24 +76,7 @@ class SyncedLyrics extends HookConsumerWidget {
{}, {},
[lyricValue], [lyricValue],
); );
final currentTime = useSyncedLyrics(ref, lyricsMap, lyricDelay); 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( return Stack(
children: [ children: [
Column( Column(
@ -80,7 +84,7 @@ class SyncedLyrics extends HookConsumerWidget {
if (isModal != true) if (isModal != true)
Center( Center(
child: SpotubeMarqueeText( child: SpotubeMarqueeText(
text: playback.track?.name ?? "Not Playing", text: playlist?.activeTrack.name ?? "Not Playing",
style: headlineTextStyle, style: headlineTextStyle,
isHovering: true, isHovering: true,
), ),
@ -89,7 +93,7 @@ class SyncedLyrics extends HookConsumerWidget {
Center( Center(
child: Text( child: Text(
TypeConversionUtils.artists_X_String<Artist>( TypeConversionUtils.artists_X_String<Artist>(
playback.track?.artists ?? []), playlist?.activeTrack.artists ?? []),
style: breakpoint >= Breakpoints.md style: breakpoint >= Breakpoints.md
? textTheme.headline5 ? textTheme.headline5
: textTheme.headline6, : textTheme.headline6,
@ -102,7 +106,8 @@ class SyncedLyrics extends HookConsumerWidget {
itemCount: lyricValue.lyrics.length, itemCount: lyricValue.lyrics.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final lyricSlice = lyricValue.lyrics[index]; final lyricSlice = lyricValue.lyrics[index];
final isActive = lyricSlice.time.inSeconds == currentTime; final isActive =
lyricSlice.time.inSeconds == currentTime;
if (isActive) { if (isActive) {
controller.scrollToIndex( controller.scrollToIndex(
@ -120,7 +125,8 @@ class SyncedLyrics extends HookConsumerWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: AnimatedDefaultTextStyle( child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 250), duration:
const Duration(milliseconds: 250),
style: TextStyle( style: TextStyle(
color: isActive color: isActive
? Colors.white ? Colors.white
@ -142,8 +148,9 @@ class SyncedLyrics extends HookConsumerWidget {
}, },
), ),
), ),
if (playback.track != null && if (playlist?.activeTrack != null &&
(lyricValue == null || lyricValue.lyrics.isEmpty == true)) (lyricValue == null ||
lyricValue.lyrics.isEmpty == true))
const Expanded(child: ShimmerLyrics()), 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_breakpoints.dart';
import 'package:spotube/hooks/use_custom_status_bar_color.dart'; import 'package:spotube/hooks/use_custom_status_bar_color.dart';
import 'package:spotube/hooks/use_palette_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/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/provider/user_preferences_provider.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
@ -28,11 +29,11 @@ class PlayerView extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final currentTrack = ref.watch(playbackProvider.select( final currentTrack = ref.watch(PlaylistQueueNotifier.provider.select(
(value) => value.track, (value) => value?.activeTrack,
)); ));
final isLocalTrack = ref.watch(playbackProvider.select( final isLocalTrack = ref.watch(PlaylistQueueNotifier.provider.select(
(value) => value.playlist?.isLocal == true, (value) => value?.activeTrack is LocalTrack,
)); ));
final breakpoint = useBreakpoints(); final breakpoint = useBreakpoints();
final canRotate = ref.watch( 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/track_collection_view.dart';
import 'package:spotube/components/shared/track_table/tracks_table_view.dart'; import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
import 'package:spotube/hooks/use_breakpoints.dart'; import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/models/current_playlist.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/provider/spotify_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/services/queries/queries.dart';
import 'package:spotube/utils/service_utils.dart'; import 'package:spotube/utils/service_utils.dart';
@ -23,7 +22,7 @@ class PlaylistView extends HookConsumerWidget {
PlaylistView(this.playlist, {Key? key}) : super(key: key); PlaylistView(this.playlist, {Key? key}) : super(key: key);
Future<void> playPlaylist( Future<void> playPlaylist(
Playback playback, PlaylistQueueNotifier playlistNotifier,
List<Track> tracks, List<Track> tracks,
WidgetRef ref, { WidgetRef ref, {
Track? currentTrack, Track? currentTrack,
@ -31,34 +30,24 @@ class PlaylistView extends HookConsumerWidget {
final sortBy = ref.read(trackCollectionSortState(playlist.id!)); final sortBy = ref.read(trackCollectionSortState(playlist.id!));
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy); final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
currentTrack ??= sortedTracks.first; currentTrack ??= sortedTracks.first;
final isPlaylistPlaying = final isPlaylistPlaying = playlistNotifier.isPlayingPlaylist(tracks);
playback.playlist?.id != null && playback.playlist?.id == playlist.id;
if (!isPlaylistPlaying) { if (!isPlaylistPlaying) {
await playback.playPlaylist( await playlistNotifier.loadAndPlay(
CurrentPlaylist( sortedTracks,
tracks: sortedTracks, active: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
id: playlist.id!,
name: playlist.name!,
thumbnail: TypeConversionUtils.image_X_UrlString(
playlist.images,
placeholder: ImagePlaceholder.collection,
),
),
sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
); );
} else if (isPlaylistPlaying && } else if (isPlaylistPlaying &&
currentTrack.id != null && currentTrack.id != null &&
currentTrack.id != playback.track?.id) { currentTrack.id != playlistNotifier.state?.activeTrack.id) {
await playback.play(currentTrack); await playlistNotifier.playTrack(currentTrack);
} }
} }
@override @override
Widget build(BuildContext context, ref) { 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); SpotifyApi spotify = ref.watch(spotifyProvider);
final isPlaylistPlaying =
playback.playlist?.id != null && playback.playlist?.id == playlist.id;
final breakpoint = useBreakpoints(); final breakpoint = useBreakpoints();
@ -68,6 +57,9 @@ class PlaylistView extends HookConsumerWidget {
externalData: spotify, externalData: spotify,
); );
final isPlaylistPlaying =
playlistNotifier.isPlayingPlaylist(tracksSnapshot.data ?? []);
final titleImage = useMemoized( final titleImage = useMemoized(
() => TypeConversionUtils.image_X_UrlString( () => TypeConversionUtils.image_X_UrlString(
playlist.images, playlist.images,
@ -88,20 +80,20 @@ class PlaylistView extends HookConsumerWidget {
if (tracksSnapshot.hasData) { if (tracksSnapshot.hasData) {
if (!isPlaylistPlaying) { if (!isPlaylistPlaying) {
playPlaylist( playPlaylist(
playback, playlistNotifier,
tracksSnapshot.data!, tracksSnapshot.data!,
ref, ref,
currentTrack: track, currentTrack: track,
); );
} else if (isPlaylistPlaying && track != null) { } else if (isPlaylistPlaying && track != null) {
playPlaylist( playPlaylist(
playback, playlistNotifier,
tracksSnapshot.data!, tracksSnapshot.data!,
ref, ref,
currentTrack: track, currentTrack: track,
); );
} else { } else {
playback.stop(); playlistNotifier.stop();
} }
} }
}, },
@ -132,20 +124,20 @@ class PlaylistView extends HookConsumerWidget {
if (tracksSnapshot.hasData) { if (tracksSnapshot.hasData) {
if (!isPlaylistPlaying) { if (!isPlaylistPlaying) {
playPlaylist( playPlaylist(
playback, playlistNotifier,
tracks, tracks,
ref, ref,
currentTrack: track, currentTrack: track,
); );
} else if (isPlaylistPlaying && track != null) { } else if (isPlaylistPlaying && track != null) {
playPlaylist( playPlaylist(
playback, playlistNotifier,
tracks, tracks,
ref, ref,
currentTrack: track, currentTrack: track,
); );
} else { } 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/artist/artist_card.dart';
import 'package:spotube/components/playlist/playlist_card.dart'; import 'package:spotube/components/playlist/playlist_card.dart';
import 'package:spotube/hooks/use_breakpoints.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/auth_provider.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/spotify_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/services/queries/queries.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
@ -107,7 +106,10 @@ class SearchPage extends HookConsumerWidget {
), ),
HookBuilder( HookBuilder(
builder: (context) { builder: (context) {
Playback playback = ref.watch(playbackProvider); final playlist =
ref.watch(PlaylistQueueNotifier.provider);
final playlistNotifier =
ref.watch(PlaylistQueueNotifier.notifier);
List<AlbumSimple> albums = []; List<AlbumSimple> albums = [];
List<Artist> artists = []; List<Artist> artists = [];
List<Track> tracks = []; List<Track> tracks = [];
@ -154,36 +156,19 @@ class SearchPage extends HookConsumerWidget {
String duration = String duration =
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}"; "${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
return TrackTile( return TrackTile(
playback, playlist,
track: track, track: track,
duration: duration, duration: duration,
isActive: isActive: playlist?.activeTrack.id ==
playback.track?.id == track.value.id, track.value.id,
onTrackPlayButtonPressed: onTrackPlayButtonPressed:
(currentTrack) async { (currentTrack) async {
var isPlaylistPlaying = final isTrackPlaying =
playback.playlist?.id != null && playlist?.activeTrack.id !=
playback.playlist?.id ==
currentTrack.id; currentTrack.id;
if (!isPlaylistPlaying) { if (!isTrackPlaying) {
playback.playPlaylist( await playlistNotifier
CurrentPlaylist( .loadAndPlay([currentTrack]);
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);
} }
}, },
); );

View File

@ -14,9 +14,7 @@ import 'package:spotube/main.dart';
import 'package:spotube/collections/spotify_markets.dart'; import 'package:spotube/collections/spotify_markets.dart';
import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/auth_provider.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/provider/user_preferences_provider.dart';
import 'package:spotube/utils/platform.dart';
import 'package:url_launcher/url_launcher_string.dart'; import 'package:url_launcher/url_launcher_string.dart';
class SettingsPage extends HookConsumerWidget { class SettingsPage extends HookConsumerWidget {
@ -309,7 +307,6 @@ class SettingsPage extends HookConsumerWidget {
}, },
), ),
), ),
if (kIsMobile)
PlatformListTile( PlatformListTile(
leading: const Icon(SpotubeIcons.download), leading: const Icon(SpotubeIcons.download),
title: const PlatformText( title: const PlatformText(
@ -319,9 +316,9 @@ class SettingsPage extends HookConsumerWidget {
"Instead of streaming audio, download bytes and play instead (Recommended for higher bandwidth users)", "Instead of streaming audio, download bytes and play instead (Recommended for higher bandwidth users)",
), ),
trailing: PlatformSwitch( trailing: PlatformSwitch(
value: preferences.androidBytesPlay, value: preferences.predownload,
onChanged: (state) { onChanged: (state) {
preferences.setAndroidBytesPlay(state); preferences.setPredownload(state);
}, },
), ),
), ),

View File

@ -1,4 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/models/current_playlist.dart'; import 'package:spotube/models/current_playlist.dart';
import 'package:spotube/utils/persisted_state_notifier.dart'; import 'package:spotube/utils/persisted_state_notifier.dart';
@ -39,7 +40,7 @@ class BlacklistedElement {
class BlackListNotifier class BlackListNotifier
extends PersistedStateNotifier<Set<BlacklistedElement>> { extends PersistedStateNotifier<Set<BlacklistedElement>> {
BlackListNotifier() : super({}); BlackListNotifier() : super({}, "blacklist");
static final provider = static final provider =
StateNotifierProvider<BlackListNotifier, Set<BlacklistedElement>>( StateNotifierProvider<BlackListNotifier, Set<BlacklistedElement>>(
@ -54,6 +55,20 @@ class BlackListNotifier
state = state.difference({element}); 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) { CurrentPlaylist filterPlaylist(CurrentPlaylist playlist) {
return CurrentPlaylist( return CurrentPlaylist(
id: playlist.id, 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/logger.dart';
import 'package:spotube/models/spotube_track.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/user_preferences_provider.dart';
import 'package:spotube/provider/youtube_provider.dart'; import 'package:spotube/provider/youtube_provider.dart';
import 'package:spotube/utils/type_conversion_utils.dart'; import 'package:spotube/utils/type_conversion_utils.dart';
@ -41,7 +40,7 @@ class Downloader with ChangeNotifier {
final logger = getLogger(Downloader); final logger = getLogger(Downloader);
Playback get _playback => ref.read(playbackProvider); // Playback get _playback => ref.read(playbackProvider);
void addToQueue(Track baseTrack) async { void addToQueue(Track baseTrack) async {
if (kIsWeb) return; if (kIsWeb) return;
@ -51,13 +50,13 @@ class Downloader with ChangeNotifier {
notifyListeners(); notifyListeners();
// Using android Audio Focus to keep the app run in background // Using android Audio Focus to keep the app run in background
_playback.mobileAudioService?.session?.setActive(true); // _playback.mobileAudioService?.session?.setActive(true);
grabberQueue.add(() async { grabberQueue.add(() async {
final track = (await ref.read(playbackProvider).toSpotubeTrack( final track = await SpotubeTrack.fromFetchTrack(
baseTrack, baseTrack,
noSponsorBlock: true, ref.read(userPreferencesProvider),
)) );
.item1;
_queue.add(() async { _queue.add(() async {
final cleanTitle = track.ytTrack.title.replaceAll( final cleanTitle = track.ytTrack.title.replaceAll(
RegExp(r'[/\\?%*:|"<>]'), RegExp(r'[/\\?%*:|"<>]'),
@ -140,9 +139,9 @@ class Downloader with ChangeNotifier {
} finally { } finally {
currentlyRunning--; currentlyRunning--;
inQueue.removeWhere((t) => t.id == track.id); inQueue.removeWhere((t) => t.id == track.id);
if (currentlyRunning == 0 && !_playback.isPlaying) { // if (currentlyRunning == 0 && !PlaylistProvider.isPlaying) {
_playback.mobileAudioService?.session?.setActive(false); // _playback.mobileAudioService?.session?.setActive(false);
} // }
notifyListeners(); notifyListeners();
} }
}); });

View File

@ -1,675 +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:hive/hive.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/entities/cache_track.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/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/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
late LazyBox<CacheTrack> cache;
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 {
cache = await Hive.openLazyBox<CacheTrack>("track-cache");
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 cachedTrack = await cache.get(track.id);
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 (overwriteCache ||
cachedTrack == null ||
cachedTrack.mode != preferences.trackMatchAlgorithm.name) {
await cache.put(
track.id!,
CacheTrack.fromVideo(
ytVideo,
preferences.trackMatchAlgorithm.name,
skipSegments: skipSegments,
),
);
}
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 cache.get(track.id);
if (cachedTrack != null &&
cachedTrack.mode == matchAlgorithm.name &&
!ignoreCache) {
_logger.v(
"[Playing track from cache] youtubeId: ${cachedTrack.id} mode: ${cachedTrack.mode}",
);
ytVideo = VideoFromCacheTrackExtension.fromCacheTrack(cachedTrack);
} else {
VideoSearchList videos =
await raceMultiple(() => youtube.search.search(queryString));
if (matchAlgorithm != SpotubeTrackMatchAlgorithm.youtube) {
List<Map> ratedRankedVideos = videos
.map((video) {
// the find should be lazy thus everything case insensitive
final ytTitle = video.title.toLowerCase();
final bool hasTitle = ytTitle.contains(title);
final bool hasAllArtists = track.artists?.every(
(artist) => ytTitle.contains(artist.name!.toLowerCase()),
) ??
false;
final bool authorIsArtist =
track.artists?.first.name?.toLowerCase() ==
video.author.toLowerCase();
final bool hasNoLiveInTitle =
!PrimitiveUtils.containsTextInBracket(ytTitle, "live");
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,506 @@
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: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;
import 'package:collection/collection.dart';
final audioPlayer = AudioPlayer();
final youtube = YoutubeExplode();
class PlaylistQueue {
final Set<Track> tracks;
final Set<Track> tempTracks;
final bool loop;
final int active;
Track get activeTrack => tracks.elementAt(active);
static Future<PlaylistQueue> fromJson(
Map<String, dynamic> json, UserPreferences preferences) async {
final List? tracks = json['tracks'];
final List? tempTracks = json['tempTracks'];
return PlaylistQueue(
Set.from(
await Future.wait(
tracks?.mapIndexed(
(i, e) async {
final jsonTrack =
Map.castFrom<dynamic, dynamic, String, dynamic>(e);
if (e["path"] != null) {
return LocalTrack.fromJson(jsonTrack);
} else if (i == json["active"] && !json.containsKey("path")) {
return await SpotubeTrack.fromFetchTrack(
Track.fromJson(jsonTrack),
preferences,
);
} else {
return Track.fromJson(jsonTrack);
}
},
) ??
[],
),
),
active: json['active'],
tempTracks: Set.from(
await Future.wait(
tempTracks?.mapIndexed(
(i, e) async {
final jsonTrack =
Map.castFrom<dynamic, dynamic, String, dynamic>(e);
if (e["path"] != null) {
return LocalTrack.fromJson(jsonTrack);
} else if (i == json["active"] && !json.containsKey("path")) {
return await SpotubeTrack.fromFetchTrack(
Track.fromJson(jsonTrack),
preferences,
);
} else {
return Track.fromJson(jsonTrack);
}
},
) ??
[],
),
),
);
}
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,
'tempTracks': tempTracks.map(
(e) {
if (e is SpotubeTrack) {
return e.toJson();
} else if (e is LocalTrack) {
return e.toJson();
} else {
return e.toJson();
}
},
).toList(),
};
}
bool get isLoading =>
activeTrack is LocalTrack ? false : activeTrack is! SpotubeTrack;
bool get isShuffled => tempTracks.isNotEmpty;
bool get isLooping => loop;
PlaylistQueue(
this.tracks, {
required this.tempTracks,
this.active = 0,
this.loop = false,
}) : assert(active < tracks.length && active >= 0, "Invalid active index");
PlaylistQueue copyWith({
Set<Track>? tracks,
Set<Track>? tempTracks,
int? active,
bool? loop,
}) {
return PlaylistQueue(
tracks ?? this.tracks,
active: active ?? this.active,
tempTracks: tempTracks ?? this.tempTracks,
loop: loop ?? this.loop,
);
}
}
class PlaylistQueueNotifier extends PersistedStateNotifier<PlaylistQueue?> {
final Ref ref;
MobileAudioService? mobileService;
LinuxAudioService? linuxService;
static final provider =
StateNotifierProvider<PlaylistQueueNotifier, PlaylistQueue?>(
(ref) => PlaylistQueueNotifier._(ref),
);
static final notifier = provider.notifier;
PlaylistQueueNotifier._(this.ref) : super(null, "playlist") {
configure();
}
void configure() async {
if (kIsMobile || kIsMacOS) {
mobileService = await AudioService.init(
builder: () => MobileAudioService(this),
config: const AudioServiceConfig(
androidNotificationChannelId: 'com.krtirtho.Spotube',
androidNotificationChannelName: 'Spotube',
androidNotificationOngoing: true,
),
);
}
if (kIsLinux) {
linuxService = LinuxAudioService(ref, this);
}
addListener((state) {
linuxService?.player.updateProperties();
});
audioPlayer.onPlayerStateChanged.listen((event) {
linuxService?.player.updateProperties();
});
audioPlayer.onPlayerComplete.listen((event) async {
if (!isLoaded) return;
if (state!.isLooping) {
await audioPlayer.seek(Duration.zero);
await audioPlayer.resume();
} else {
await 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
// getters
UserPreferences get preferences => ref.read(userPreferencesProvider);
BlackListNotifier get blacklist =>
ref.read(BlackListNotifier.provider.notifier);
bool get isLoaded => state != null;
// 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.asBroadcastStream();
static Stream<Duration> get position =>
audioPlayer.onPositionChanged.asBroadcastStream();
static Stream<bool> get playing => audioPlayer.onPlayerStateChanged
.map((event) => event == PlayerState.playing)
.asBroadcastStream();
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 (!isLoaded || state!.isShuffled) return;
state = state?.copyWith(
tempTracks: state!.tracks,
tracks: {
state!.activeTrack,
...state!.tracks.toList()
..removeAt(state!.active)
..shuffle()
},
active: 0,
);
}
void unshuffle() {
if (!isLoaded || !state!.isShuffled) return;
state = state?.copyWith(
tracks: state!.tempTracks,
active: state!.tempTracks
.toList()
.indexWhere((element) => element.id == state!.activeTrack.id),
tempTracks: {},
);
}
void loop() {
if (!isLoaded || state!.isLooping) return;
state = state?.copyWith(
loop: true,
);
}
void unloop() {
if (!isLoaded || !state!.isLooping) return;
state = state?.copyWith(
loop: false,
);
}
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> populateSibling() async {
if (!isLoaded || state!.isLoading) return;
final tracks = state!.tracks.toList();
final track = await (state!.activeTrack as SpotubeTrack).populatedCopy();
tracks[state!.active] = track;
state = state!.copyWith(tracks: Set.from(tracks));
}
Future<void> play() async {
if (!isLoaded) return;
await pause();
await mobileService?.session?.setActive(true);
final mediaItem = 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.duration,
);
mobileService?.addItem(mediaItem);
if (state!.activeTrack is LocalTrack) {
await audioPlayer.play(
DeviceFileSource((state!.activeTrack as LocalTrack).path),
mode: PlayerMode.mediaPlayer,
);
return;
}
if (state!.activeTrack is! SpotubeTrack) {
final tracks = state!.tracks.toList();
tracks[state!.active] = await SpotubeTrack.fromFetchTrack(
state!.activeTrack,
preferences,
);
final tempTracks = state!.tempTracks
.map((e) =>
e.id == tracks[state!.active].id ? tracks[state!.active] : e)
.toList();
state = state!.copyWith(
tracks: Set.from(tracks),
tempTracks: Set.from(tempTracks),
);
}
mobileService?.addItem(mediaItem.copyWith(
duration: (state!.activeTrack as SpotubeTrack).ytTrack.duration,
));
final cached =
await DefaultCacheManager().getFileFromCache(state!.activeTrack.id!);
if (preferences.predownload && cached != null) {
await audioPlayer.play(
DeviceFileSource(cached.file.path),
mode: PlayerMode.mediaPlayer,
);
} else {
await audioPlayer.play(
UrlSource((state!.activeTrack as SpotubeTrack).ytUri),
mode: PlayerMode.mediaPlayer,
);
}
}
Future<void> playTrack(Track track) async {
if (!isLoaded) return;
final active =
state!.tracks.toList().indexWhere((element) => element.id == track.id);
if (active == -1) return;
state = state!.copyWith(active: active);
return play();
}
void load(Iterable<Track> tracks, {int active = 0}) {
state = PlaylistQueue(
Set.from(blacklist.filter(tracks)),
tempTracks: {},
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 {
(mobileService)?.session?.setActive(false);
state = null;
return audioPlayer.stop();
}
Future<void> next() async {
if (!isLoaded) return;
if (state!.active == state!.tracks.length - 1) {
state = state!.copyWith(
active: 0,
);
} else {
state = state!.copyWith(
active: state!.active + 1,
);
}
return play();
}
Future<void> previous() async {
if (!isLoaded) return;
if (state!.active == 0) {
state = state!.copyWith(
active: state!.tracks.length - 1,
);
} else {
state = state!.copyWith(
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 (state!.isShuffled) {
final trackIds = state!.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
Future<PlaylistQueue>? fromJson(Map<String, dynamic> json) {
if (json.isEmpty) return null;
return PlaylistQueue.fromJson(json, preferences);
}
@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};
}
}

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/components/settings/color_scheme_picker_dialog.dart';
import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/models/generated_secrets.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:spotube/utils/persisted_change_notifier.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
@ -19,6 +18,11 @@ enum LayoutMode {
adaptive, adaptive,
} }
enum AudioQuality {
high,
low,
}
class UserPreferences extends PersistedChangeNotifier { class UserPreferences extends PersistedChangeNotifier {
ThemeMode themeMode; ThemeMode themeMode;
String ytSearchFormat; String ytSearchFormat;
@ -38,7 +42,7 @@ class UserPreferences extends PersistedChangeNotifier {
LayoutMode layoutMode; LayoutMode layoutMode;
bool rotatingAlbumArt; bool rotatingAlbumArt;
bool androidBytesPlay; bool predownload;
UserPreferences({ UserPreferences({
required this.geniusAccessToken, required this.geniusAccessToken,
@ -46,7 +50,7 @@ class UserPreferences extends PersistedChangeNotifier {
required this.themeMode, required this.themeMode,
required this.ytSearchFormat, required this.ytSearchFormat,
required this.layoutMode, required this.layoutMode,
this.androidBytesPlay = true, required this.predownload,
this.saveTrackLyrics = false, this.saveTrackLyrics = false,
this.accentColorScheme = Colors.green, this.accentColorScheme = Colors.green,
this.backgroundColorScheme = Colors.grey, this.backgroundColorScheme = Colors.grey,
@ -66,9 +70,10 @@ class UserPreferences extends PersistedChangeNotifier {
} }
} }
void setAndroidBytesPlay(bool value) { void setPredownload(bool value) {
androidBytesPlay = value; predownload = value;
notifyListeners(); notifyListeners();
updatePersistence();
} }
void setThemeMode(ThemeMode mode) { void setThemeMode(ThemeMode mode) {
@ -199,7 +204,7 @@ class UserPreferences extends PersistedChangeNotifier {
orElse: () => kIsDesktop ? LayoutMode.extended : LayoutMode.compact, orElse: () => kIsDesktop ? LayoutMode.extended : LayoutMode.compact,
); );
rotatingAlbumArt = map["rotatingAlbumArt"] ?? rotatingAlbumArt; rotatingAlbumArt = map["rotatingAlbumArt"] ?? rotatingAlbumArt;
androidBytesPlay = map["androidBytesPlay"] ?? androidBytesPlay; predownload = map["predownload"] ?? predownload;
} }
@override @override
@ -219,7 +224,7 @@ class UserPreferences extends PersistedChangeNotifier {
"downloadLocation": downloadLocation, "downloadLocation": downloadLocation,
"layoutMode": layoutMode.name, "layoutMode": layoutMode.name,
"rotatingAlbumArt": rotatingAlbumArt, "rotatingAlbumArt": rotatingAlbumArt,
"androidBytesPlay": androidBytesPlay, "predownload": predownload,
}; };
} }
} }
@ -231,5 +236,6 @@ final userPreferencesProvider = ChangeNotifierProvider(
themeMode: ThemeMode.system, themeMode: ThemeMode.system,
ytSearchFormat: "\$MAIN_ARTIST - \$TITLE \$FEATURED_ARTISTS", ytSearchFormat: "\$MAIN_ARTIST - \$TITLE \$FEATURED_ARTISTS",
layoutMode: kIsMobile ? LayoutMode.compact : LayoutMode.adaptive, layoutMode: kIsMobile ? LayoutMode.compact : LayoutMode.adaptive,
predownload: kIsMobile,
), ),
); );

View File

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

View File

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

View File

@ -0,0 +1,14 @@
import 'package:catcher/catcher.dart';
import 'package:pocketbase/pocketbase.dart';
import 'package:spotube/collections/env.dart';
final pb = PocketBase(Env.pocketbaseUrl);
bool isLoggedIn = false;
Future<void> initializePocketBase() async {
try {
await pb.collection("users").authWithPassword(Env.username, Env.password);
isLoggedIn = true;
} catch (e, stack) {
Catcher.reportCheckedError(e, stack);
}
}

View File

@ -1,48 +1,58 @@
import 'dart:convert'; import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart'; 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> { abstract class PersistedStateNotifier<T> extends StateNotifier<T> {
get cacheKey => state.runtimeType.toString(); final String cacheKey;
SharedPreferences? localStorage; PersistedStateNotifier(super.state, this.cacheKey) {
_load();
PersistedStateNotifier(super.state) : super() {
SharedPreferences.getInstance().then(
(localStorage) {
this.localStorage = localStorage;
final rawState = localStorage.getString(cacheKey);
if (rawState != null) {
state = fromJson(jsonDecode(rawState));
} }
},
Future<void> _load() async {
final box = await Hive.openLazyBox("spotube_cache");
final json = await box.get(cacheKey);
if (json != null) {
state = await 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);
}),
); );
} }
T fromJson(Map<String, dynamic> json); void save() async {
final box = await Hive.openLazyBox("spotube_cache");
box.put(cacheKey, toJson());
}
FutureOr<T> fromJson(Map<String, dynamic> json);
Map<String, dynamic> toJson(); Map<String, dynamic> toJson();
@override @override
set state(T value) { set state(T value) {
if (state == value) return; if (state == value) return;
super.state = value; super.state = value;
if (localStorage == null) { save();
SharedPreferences.getInstance().then(
(localStorage) {
this.localStorage = localStorage;
localStorage.setString(
cacheKey,
jsonEncode(toJson()),
);
},
);
} else {
localStorage?.setString(
cacheKey,
jsonEncode(toJson()),
);
}
} }
} }

View File

@ -41,4 +41,20 @@ abstract class PrimitiveUtils {
final seconds = duration.inSeconds % 60; final seconds = duration.inSeconds % 60;
return "${hours > 0 ? "${zeroPadNumStr(hours)}:" : ""}${zeroPadNumStr(minutes)}:${zeroPadNumStr(seconds)}"; 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, file.path,
[], [],
[],
); );
track.album = Album() track.album = Album()
..name = metadata?.album ?? "Spotube" ..name = metadata?.album ?? "Spotube"

View File

@ -93,7 +93,7 @@ packages:
source: hosted source: hosted
version: "2.3.2" version: "2.3.2"
async: async:
dependency: transitive dependency: "direct main"
description: description:
name: async name: async
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
@ -562,7 +562,7 @@ packages:
source: hosted source: hosted
version: "0.7.0" version: "0.7.0"
flutter_cache_manager: flutter_cache_manager:
dependency: transitive dependency: "direct main"
description: description:
name: flutter_cache_manager name: flutter_cache_manager
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
@ -575,6 +575,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "0.0.2" version: "0.0.2"
flutter_dotenv:
dependency: "direct main"
description:
name: flutter_dotenv
url: "https://pub.dartlang.org"
source: hosted
version: "5.0.2"
flutter_feather_icons: flutter_feather_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@ -822,12 +829,19 @@ packages:
source: hosted source: hosted
version: "0.6.4" version: "0.6.4"
json_annotation: json_annotation:
dependency: transitive dependency: "direct main"
description: description:
name: json_annotation name: json_annotation
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.8.0" version: "4.8.0"
json_serializable:
dependency: "direct main"
description:
name: json_serializable
url: "https://pub.dartlang.org"
source: hosted
version: "6.6.0"
libadwaita: libadwaita:
dependency: "direct main" dependency: "direct main"
description: description:
@ -1103,6 +1117,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "2.1.3" version: "2.1.3"
pocketbase:
dependency: "direct main"
description:
name: pocketbase
url: "https://pub.dartlang.org"
source: hosted
version: "0.7.1+1"
pointycastle: pointycastle:
dependency: transitive dependency: transitive
description: description:

View File

@ -12,6 +12,7 @@ environment:
dependencies: dependencies:
adwaita: ^0.5.2 adwaita: ^0.5.2
async: ^2.9.0
audio_service: ^0.18.9 audio_service: ^0.18.9
audio_session: ^0.1.13 audio_session: ^0.1.13
audioplayers: ^3.0.1 audioplayers: ^3.0.1
@ -31,6 +32,8 @@ dependencies:
fluentui_system_icons: ^1.1.189 fluentui_system_icons: ^1.1.189
flutter: flutter:
sdk: flutter sdk: flutter
flutter_cache_manager: ^3.3.0
flutter_dotenv: ^5.0.2
flutter_feather_icons: ^2.0.0+1 flutter_feather_icons: ^2.0.0+1
flutter_hooks: ^0.18.2+1 flutter_hooks: ^0.18.2+1
flutter_inappwebview: ^5.7.2+3 flutter_inappwebview: ^5.7.2+3
@ -44,6 +47,8 @@ dependencies:
html: ^0.15.1 html: ^0.15.1
http: ^0.13.5 http: ^0.13.5
introduction_screen: ^3.0.2 introduction_screen: ^3.0.2
json_annotation: ^4.8.0
json_serializable: ^6.6.0
libadwaita: ^1.2.5 libadwaita: ^1.2.5
logger: ^1.1.0 logger: ^1.1.0
macos_ui: ^1.7.5 macos_ui: ^1.7.5
@ -59,6 +64,7 @@ dependencies:
git: git:
url: https://github.com/KRTirtho/platform_ui.git url: https://github.com/KRTirtho/platform_ui.git
ref: 073cefb9c419fcb01cbdfd6ca2f9714eec23c83b ref: 073cefb9c419fcb01cbdfd6ca2f9714eec23c83b
pocketbase: ^0.7.1+1
popover: ^0.2.6+3 popover: ^0.2.6+3
queue: ^3.1.0+1 queue: ^3.1.0+1
scroll_to_index: ^3.0.1 scroll_to_index: ^3.0.1
@ -96,6 +102,7 @@ flutter:
assets: assets:
- assets/ - assets/
- assets/tutorial/ - assets/tutorial/
- .env
flutter_icons: flutter_icons:
android: true android: true

1
server/.pocketbase Normal file
View File

@ -0,0 +1 @@
version=0.12.1

View File

@ -0,0 +1,63 @@
migrate((db) => {
const collection = new Collection({
"id": "pevn93oxbnovw0s",
"created": "2023-02-01 13:01:08.893Z",
"updated": "2023-02-01 13:01:08.893Z",
"name": "tracks",
"type": "base",
"system": false,
"schema": [
{
"system": false,
"id": "ycnix0ai",
"name": "spotify_id",
"type": "text",
"required": true,
"unique": false,
"options": {
"min": 20,
"max": 22,
"pattern": ""
}
},
{
"system": false,
"id": "ih8fxzgh",
"name": "youtube_id",
"type": "text",
"required": true,
"unique": false,
"options": {
"min": 10,
"max": 11,
"pattern": ""
}
},
{
"system": false,
"id": "vzvqgsjf",
"name": "votes",
"type": "number",
"required": true,
"unique": false,
"options": {
"min": null,
"max": null
}
}
],
"listRule": null,
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"options": {}
});
return Dao(db).saveCollection(collection);
}, (db) => {
const dao = new Dao(db);
const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s");
return dao.deleteCollection(collection);
})

View File

@ -0,0 +1,17 @@
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s")
collection.listRule = ""
collection.viewRule = ""
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s")
collection.listRule = null
collection.viewRule = null
return dao.saveCollection(collection)
})

View File

@ -0,0 +1,19 @@
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
collection.createRule = null
collection.updateRule = null
collection.deleteRule = null
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("_pb_users_auth_")
collection.createRule = ""
collection.updateRule = "id = @request.auth.id"
collection.deleteRule = "id = @request.auth.id"
return dao.saveCollection(collection)
})

View File

@ -0,0 +1,17 @@
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s")
collection.createRule = "@request.auth.id != ''"
collection.updateRule = "@request.auth.id != ''"
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s")
collection.createRule = null
collection.updateRule = null
return dao.saveCollection(collection)
})

View File

@ -0,0 +1,17 @@
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s")
collection.createRule = "@request.auth.id != '' && ((spotify_id ?= @collection.tracks.spotify_id && youtube_id ?= @collection.tracks.youtube_id) || (spotify_id ?!= @collection.tracks.spotify_id && youtube_id ?!= @collection.tracks.youtube_id))"
collection.updateRule = "@request.auth.id != '' && ((spotify_id ?= @collection.tracks.spotify_id && youtube_id ?= @collection.tracks.youtube_id) || (spotify_id ?!= @collection.tracks.spotify_id && youtube_id ?!= @collection.tracks.youtube_id))"
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s")
collection.createRule = null
collection.updateRule = null
return dao.saveCollection(collection)
})

View File

@ -0,0 +1,39 @@
migrate((db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s")
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "vzvqgsjf",
"name": "votes",
"type": "number",
"required": false,
"unique": false,
"options": {
"min": null,
"max": null
}
}))
return dao.saveCollection(collection)
}, (db) => {
const dao = new Dao(db)
const collection = dao.findCollectionByNameOrId("pevn93oxbnovw0s")
// update
collection.schema.addField(new SchemaField({
"system": false,
"id": "vzvqgsjf",
"name": "votes",
"type": "number",
"required": true,
"unique": false,
"options": {
"min": null,
"max": null
}
}))
return dao.saveCollection(collection)
})