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

View File

@ -31,6 +31,7 @@ jobs:
with:
cache: true
- run: |
echo '${{ secrets.DOTENV_RELEASE }}' > .env
flutter config --enable-windows-desktop
flutter pub get
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
@ -72,6 +73,7 @@ jobs:
- uses: subosito/flutter-action@v2.8.0
with:
cache: true
- run: echo '${{ secrets.DOTENV_RELEASE }}' > .env
- run: flutter config --enable-macos-desktop
- run: flutter pub get
- 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
- run: |
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: |
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
- run: |
echo '${{ secrets.DOTENV_RELEASE }}' > .env
flutter pub get
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks

2
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -35,6 +35,8 @@ class UniversalImage extends HookWidget {
cacheKey: path,
scale: scale,
);
} else if (path.startsWith("assets/")) {
return AssetImage(path);
} else if (Uri.tryParse(path) != null) {
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/provider/auth_provider.dart';
import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/services/mutations/mutations.dart';
import 'package:spotube/services/queries/queries.dart';
@ -27,7 +27,7 @@ import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:tuple/tuple.dart';
class TrackTile extends HookConsumerWidget {
final Playback playback;
final PlaylistQueue? playlist;
final MapEntry<int, Track> track;
final String duration;
final void Function(Track currentTrack)? onTrackPlayButtonPressed;
@ -47,7 +47,7 @@ class TrackTile extends HookConsumerWidget {
final void Function(bool?)? onCheckChange;
TrackTile(
this.playback, {
this.playlist, {
required this.track,
required this.duration,
required this.isActive,
@ -240,8 +240,7 @@ class TrackTile extends HookConsumerWidget {
padding: const EdgeInsets.all(8.0),
child: PlatformIconButton(
icon: Icon(
playback.track?.id != null &&
playback.track?.id == track.value.id
playlist?.activeTrack.id == track.value.id
? SpotubeIcons.pause
: SpotubeIcons.play,
color: Colors.white,

View File

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

View File

@ -1,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/models/logger.dart';
import 'package:spotube/models/track.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/utils/duration.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
@ -30,6 +37,11 @@ extension VideoFromCacheTrackExtension on Video {
false,
);
}
static Future<Video> fromBackendTrack(
BackendTrack track, YoutubeExplode youtube) {
return youtube.videos.get(VideoId.fromString(track.youtubeId));
}
}
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_riverpod/flutter_riverpod.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
int useSyncedLyrics(
WidgetRef ref,
Map<int, String> lyricsMap,
Duration delay,
) {
final player = ref.watch(playbackProvider.select(
(value) => (value.player),
));
final stream = player.onPositionChanged;
final stream = PlaylistQueueNotifier.position;
final currentTime = useState(0);

View File

@ -1,6 +1,5 @@
import 'dart:convert';
import 'package:audio_service/audio_service.dart';
import 'package:catcher/catcher.dart';
import 'package:fl_query/fl_query.dart';
import 'package:flutter/foundation.dart';
@ -12,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:platform_ui/platform_ui.dart';
import 'package:shared_preferences/shared_preferences.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/entities/cache_track.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/provider/audio_player_provider.dart';
import 'package:spotube/provider/downloader_provider.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/provider/youtube_provider.dart';
import 'package:spotube/services/mobile_audio_service.dart';
import 'package:spotube/services/pocketbase.dart';
import 'package:spotube/themes/dark_theme.dart';
import 'package:spotube/themes/light_theme.dart';
import 'package:spotube/utils/platform.dart';
@ -36,6 +35,8 @@ void main() async {
Hive.registerAdapter(CacheTrackAdapter());
Hive.registerAdapter(CacheTrackEngagementAdapter());
Hive.registerAdapter(CacheTrackSkipSegmentAdapter());
await Env.configure();
if (kIsDesktop) {
await windowManager.ensureInitialized();
WindowOptions windowOptions = const WindowOptions(
@ -61,7 +62,6 @@ void main() async {
await windowManager.show();
});
}
MobileAudioService? audioServiceHandler;
Catcher(
debugConfig: CatcherOptions(
@ -72,7 +72,17 @@ void main() async {
enableApplicationParameters: 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(), [
@ -84,36 +94,6 @@ void main() async {
builder: (context) {
return ProviderScope(
overrides: [
playbackProvider.overrideWith(
(ref) {
final youtube = ref.watch(youtubeProvider);
final player = ref.watch(audioPlayerProvider);
final playback = Playback(
player: player,
youtube: youtube,
ref: ref,
);
if (audioServiceHandler == null) {
AudioService.init(
builder: () => MobileAudioService(playback),
config: const AudioServiceConfig(
androidNotificationChannelId: 'com.krtirtho.Spotube',
androidNotificationChannelName: 'Spotube',
androidNotificationOngoing: true,
),
).then(
(value) {
playback.mobileAudioService = value;
audioServiceHandler = value;
},
);
}
return playback;
},
),
downloaderProvider.overrideWith(
(ref) {
return Downloader(
@ -155,6 +135,7 @@ void main() async {
);
},
);
await initializePocketBase();
}
class Spotube extends StatefulHookConsumerWidget {

View File

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

View File

@ -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:spotube/extensions/video.dart';
import 'package:spotube/extensions/album_simple.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/models/track.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/services/pocketbase.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
import 'package:collection/collection.dart';
enum SpotubeTrackMatchAlgorithm {
// selects the first result returned from YouTube
@ -14,14 +26,16 @@ enum SpotubeTrackMatchAlgorithm {
}
class SpotubeTrack extends Track {
Video ytTrack;
String ytUri;
List<Map<String, int>> skipSegments;
final Video ytTrack;
final String ytUri;
final List<Map<String, int>> skipSegments;
final List<Video> siblings;
SpotubeTrack(
this.ytTrack,
this.ytUri,
this.skipSegments,
this.siblings,
) : super();
SpotubeTrack.fromTrack({
@ -29,6 +43,7 @@ class SpotubeTrack extends Track {
required this.ytTrack,
required this.ytUri,
required this.skipSegments,
required this.siblings,
}) : super() {
album = track.album;
artists = track.artists;
@ -50,6 +65,219 @@ class SpotubeTrack extends Track {
uri = track.uri;
}
static Future<SpotubeTrack> fromFetchTrack(
Track track, UserPreferences preferences) async {
final artists = (track.artists ?? [])
.map((ar) => ar.name)
.toList()
.whereNotNull()
.toList();
final title = ServiceUtils.getTitle(
track.name!,
artists: artists,
onlyCleanArtist: true,
).trim();
final cachedTracks = await pb.collection(BackendTrack.collection).getList(
filter: "spotify_id = '${track.id}'",
sort: "-votes",
page: 0,
perPage: 1,
);
final cachedTrack = cachedTracks.items.isNotEmpty
? BackendTrack.fromRecord(cachedTracks.items.first)
: null;
Video ytVideo;
List<Video> siblings = [];
if (cachedTrack != null) {
ytVideo = await VideoFromCacheTrackExtension.fromBackendTrack(
cachedTrack,
youtube,
);
} else {
VideoSearchList videos = await PrimitiveUtils.raceMultiple(
() => youtube.search.search("${artists.join(", ")} - $title"),
);
siblings = videos.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) {
return SpotubeTrack.fromTrack(
track: Track.fromJson(map),
@ -57,6 +285,37 @@ class SpotubeTrack extends Track {
ytUri: map["ytUri"],
skipSegments:
List.castFrom<dynamic, Map<String, int>>(map["skipSegments"]),
siblings: List.castFrom<dynamic, Map<String, dynamic>>(map["siblings"])
.map((sibling) => VideoToJson.fromJson(sibling))
.toList(),
);
}
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,
"ytTrack": ytTrack.toJson(),
"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/tracks_table_view.dart';
import 'package:spotube/hooks/use_breakpoints.dart';
import 'package:spotube/provider/playlist_queue_provider.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:spotube/models/current_playlist.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/spotify_provider.dart';
class AlbumPage extends HookConsumerWidget {
@ -20,38 +19,33 @@ class AlbumPage extends HookConsumerWidget {
const AlbumPage(this.album, {Key? key}) : super(key: key);
Future<void> playPlaylist(
Playback playback,
PlaylistQueueNotifier playback,
List<Track> tracks,
WidgetRef ref, {
Track? currentTrack,
}) async {
final playlist = ref.read(PlaylistQueueNotifier.provider);
final sortBy = ref.read(trackCollectionSortState(album.id!));
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
currentTrack ??= sortedTracks.first;
final isPlaylistPlaying = playback.playlist?.id == album.id;
final isPlaylistPlaying = playback.isPlayingPlaylist(tracks);
if (!isPlaylistPlaying) {
await playback.playPlaylist(
CurrentPlaylist(
tracks: sortedTracks,
id: album.id!,
name: album.name!,
thumbnail: TypeConversionUtils.image_X_UrlString(
album.images,
placeholder: ImagePlaceholder.collection,
),
),
sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
playback.load(
sortedTracks,
active: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
);
await playback.play();
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != playback.track?.id) {
await playback.play(currentTrack);
currentTrack.id != playlist?.activeTrack.id) {
await playback.playTrack(currentTrack);
}
}
@override
Widget build(BuildContext context, ref) {
Playback playback = ref.watch(playbackProvider);
ref.watch(PlaylistQueueNotifier.provider);
final playback = ref.watch(PlaylistQueueNotifier.notifier);
final SpotifyApi spotify = ref.watch(spotifyProvider);
@ -69,8 +63,10 @@ class AlbumPage extends HookConsumerWidget {
final breakpoint = useBreakpoints();
final isAlbumPlaying =
playback.playlist?.id != null && playback.playlist?.id == album.id;
final isAlbumPlaying = useMemoized(
() => playback.isPlayingPlaylist(tracksSnapshot.data ?? []),
[tracksSnapshot.data],
);
return TrackCollectionView(
id: album.id!,
isPlaying: isAlbumPlaying,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,9 +14,7 @@ import 'package:spotube/main.dart';
import 'package:spotube/collections/spotify_markets.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/auth_provider.dart';
import 'package:spotube/provider/playback_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/utils/platform.dart';
import 'package:url_launcher/url_launcher_string.dart';
class SettingsPage extends HookConsumerWidget {
@ -309,7 +307,6 @@ class SettingsPage extends HookConsumerWidget {
},
),
),
if (kIsMobile)
PlatformListTile(
leading: const Icon(SpotubeIcons.download),
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)",
),
trailing: PlatformSwitch(
value: preferences.androidBytesPlay,
value: preferences.predownload,
onChanged: (state) {
preferences.setAndroidBytesPlay(state);
preferences.setPredownload(state);
},
),
),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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:shared_preferences/shared_preferences.dart';
import 'package:hive/hive.dart';
abstract class PersistedStateNotifier<T> extends StateNotifier<T> {
get cacheKey => state.runtimeType.toString();
final String cacheKey;
SharedPreferences? localStorage;
PersistedStateNotifier(super.state) : super() {
SharedPreferences.getInstance().then(
(localStorage) {
this.localStorage = localStorage;
final rawState = localStorage.getString(cacheKey);
if (rawState != null) {
state = fromJson(jsonDecode(rawState));
PersistedStateNotifier(super.state, this.cacheKey) {
_load();
}
},
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();
@override
set state(T value) {
if (state == value) return;
super.state = value;
if (localStorage == null) {
SharedPreferences.getInstance().then(
(localStorage) {
this.localStorage = localStorage;
localStorage.setString(
cacheKey,
jsonEncode(toJson()),
);
},
);
} else {
localStorage?.setString(
cacheKey,
jsonEncode(toJson()),
);
}
save();
}
}

View File

@ -41,4 +41,20 @@ abstract class PrimitiveUtils {
final seconds = duration.inSeconds % 60;
return "${hours > 0 ? "${zeroPadNumStr(hours)}:" : ""}${zeroPadNumStr(minutes)}:${zeroPadNumStr(seconds)}";
}
static Future<T> raceMultiple<T>(
Future<T> Function() inner, {
Duration timeout = const Duration(milliseconds: 2500),
int retryCount = 4,
}) async {
return Future.any(
List.generate(retryCount, (i) {
if (i == 0) return inner();
return Future.delayed(
Duration(milliseconds: timeout.inMilliseconds * i),
inner,
);
}),
);
}
}

View File

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

View File

@ -93,7 +93,7 @@ packages:
source: hosted
version: "2.3.2"
async:
dependency: transitive
dependency: "direct main"
description:
name: async
url: "https://pub.dartlang.org"
@ -562,7 +562,7 @@ packages:
source: hosted
version: "0.7.0"
flutter_cache_manager:
dependency: transitive
dependency: "direct main"
description:
name: flutter_cache_manager
url: "https://pub.dartlang.org"
@ -575,6 +575,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
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:
dependency: "direct main"
description:
@ -822,12 +829,19 @@ packages:
source: hosted
version: "0.6.4"
json_annotation:
dependency: transitive
dependency: "direct main"
description:
name: json_annotation
url: "https://pub.dartlang.org"
source: hosted
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:
dependency: "direct main"
description:
@ -1103,6 +1117,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "2.1.3"
pocketbase:
dependency: "direct main"
description:
name: pocketbase
url: "https://pub.dartlang.org"
source: hosted
version: "0.7.1+1"
pointycastle:
dependency: transitive
description:

View File

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