mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-09-13 16:05:18 +00:00
Merge pull request #399 from KRTirtho/feat-server
Playback Manager Rewrite and Custom Server
This commit is contained in:
commit
0104362b3d
4
.github/workflows/spotube-nightly.yml
vendored
4
.github/workflows/spotube-nightly.yml
vendored
@ -28,6 +28,7 @@ jobs:
|
|||||||
curl -sS https://webi.sh/yq | sh
|
curl -sS https://webi.sh/yq | sh
|
||||||
yq -i '.version |= sub("\+\d+", "-nightly-")' pubspec.yaml
|
yq -i '.version |= sub("\+\d+", "-nightly-")' pubspec.yaml
|
||||||
yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
|
yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
|
||||||
|
echo '${{ secrets.DOTENV_NIGHTLY }}' > .env
|
||||||
flutter config --enable-linux-desktop
|
flutter config --enable-linux-desktop
|
||||||
flutter pub get
|
flutter pub get
|
||||||
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
||||||
@ -63,6 +64,7 @@ jobs:
|
|||||||
curl -sS https://webi.sh/yq | sh
|
curl -sS https://webi.sh/yq | sh
|
||||||
yq -i '.version |= sub("\+\d+", "-nightly-")' pubspec.yaml
|
yq -i '.version |= sub("\+\d+", "-nightly-")' pubspec.yaml
|
||||||
yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
|
yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
|
||||||
|
echo '${{ secrets.DOTENV_NIGHTLY }}' > .env
|
||||||
flutter pub get
|
flutter pub get
|
||||||
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
||||||
echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks
|
echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks
|
||||||
@ -93,6 +95,7 @@ jobs:
|
|||||||
yq -i '.version |= sub("\+\d+", "-nightly-")' pubspec.yaml
|
yq -i '.version |= sub("\+\d+", "-nightly-")' pubspec.yaml
|
||||||
yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
|
yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
|
||||||
sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.GITHUB_RUN_NUMBER }}/" windows/runner/Runner.rc
|
sed -i "s/%{{SPOTUBE_VERSION}}%/${{ env.GITHUB_RUN_NUMBER }}/" windows/runner/Runner.rc
|
||||||
|
echo '${{ secrets.DOTENV_NIGHTLY }}' > .env
|
||||||
flutter config --enable-windows-desktop
|
flutter config --enable-windows-desktop
|
||||||
flutter pub get
|
flutter pub get
|
||||||
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
||||||
@ -120,6 +123,7 @@ jobs:
|
|||||||
- run: brew install yq
|
- run: brew install yq
|
||||||
- run: yq -i '.version |= sub("\+\d+", "-nightly-")' pubspec.yaml
|
- run: yq -i '.version |= sub("\+\d+", "-nightly-")' pubspec.yaml
|
||||||
- run: yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
|
- run: yq -i '.version += strenv(GITHUB_RUN_NUMBER)' pubspec.yaml
|
||||||
|
- run: echo '${{ secrets.DOTENV_NIGHTLY }}' > .env
|
||||||
- run: flutter config --enable-macos-desktop
|
- run: flutter config --enable-macos-desktop
|
||||||
- run: flutter pub get
|
- run: flutter pub get
|
||||||
- run: dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
- run: dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
||||||
|
4
.github/workflows/spotube-release.yml
vendored
4
.github/workflows/spotube-release.yml
vendored
@ -31,6 +31,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
cache: true
|
cache: true
|
||||||
- run: |
|
- run: |
|
||||||
|
echo '${{ secrets.DOTENV_RELEASE }}' > .env
|
||||||
flutter config --enable-windows-desktop
|
flutter config --enable-windows-desktop
|
||||||
flutter pub get
|
flutter pub get
|
||||||
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
||||||
@ -72,6 +73,7 @@ jobs:
|
|||||||
- uses: subosito/flutter-action@v2.8.0
|
- uses: subosito/flutter-action@v2.8.0
|
||||||
with:
|
with:
|
||||||
cache: true
|
cache: true
|
||||||
|
- run: echo '${{ secrets.DOTENV_RELEASE }}' > .env
|
||||||
- run: flutter config --enable-macos-desktop
|
- run: flutter config --enable-macos-desktop
|
||||||
- run: flutter pub get
|
- run: flutter pub get
|
||||||
- run: dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
- run: dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
||||||
@ -112,6 +114,7 @@ jobs:
|
|||||||
# replacing & adding new release version with older version
|
# replacing & adding new release version with older version
|
||||||
- run: |
|
- run: |
|
||||||
sed -i 's|%{{APPDATA_RELEASE}}%|<release version="${{ steps.tag.outputs.tag }}" date="${{ steps.date.outputs.date }}" />|' linux/com.github.KRTirtho.Spotube.appdata.xml
|
sed -i 's|%{{APPDATA_RELEASE}}%|<release version="${{ steps.tag.outputs.tag }}" date="${{ steps.date.outputs.date }}" />|' linux/com.github.KRTirtho.Spotube.appdata.xml
|
||||||
|
echo '${{ secrets.DOTENV_RELEASE }}' > .env
|
||||||
|
|
||||||
- run: |
|
- run: |
|
||||||
flutter config --enable-linux-desktop
|
flutter config --enable-linux-desktop
|
||||||
@ -146,6 +149,7 @@ jobs:
|
|||||||
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse
|
sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev make python3-pip python3-setuptools patchelf desktop-file-utils libgdk-pixbuf2.0-dev fakeroot strace fuse
|
||||||
|
|
||||||
- run: |
|
- run: |
|
||||||
|
echo '${{ secrets.DOTENV_RELEASE }}' > .env
|
||||||
flutter pub get
|
flutter pub get
|
||||||
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
dart bin/create-secrets.dart '${{ secrets.LYRICS_SECRET }}' '${{ secrets.SPOTIFY_SECRET }}'
|
||||||
echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks
|
echo '${{ secrets.KEYSTORE }}' | base64 --decode > android/app/upload-keystore.jks
|
||||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -75,3 +75,5 @@ appimage-build
|
|||||||
|
|
||||||
android/key.properties
|
android/key.properties
|
||||||
.fvm/flutter_sdk
|
.fvm/flutter_sdk
|
||||||
|
|
||||||
|
**/pb_data
|
@ -77,6 +77,6 @@ flutter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||||
implementation 'com.android.support:multidex:1.0.3'
|
implementation 'com.android.support:multidex:2.0.1'
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.6.10'
|
ext.kotlin_version = '1.7.21'
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip
|
||||||
|
15
lib/collections/env.dart
Normal file
15
lib/collections/env.dart
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -2,11 +2,10 @@ import 'package:flutter/cupertino.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
|
||||||
import 'package:spotube/components/player/player_controls.dart';
|
import 'package:spotube/components/player/player_controls.dart';
|
||||||
import 'package:spotube/collections/routes.dart';
|
import 'package:spotube/collections/routes.dart';
|
||||||
import 'package:spotube/models/logger.dart';
|
import 'package:spotube/models/logger.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/utils/platform.dart';
|
import 'package:spotube/utils/platform.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
@ -23,23 +22,22 @@ class PlayPauseAction extends Action<PlayPauseIntent> {
|
|||||||
if (PlayerControls.focusNode.canRequestFocus) {
|
if (PlayerControls.focusNode.canRequestFocus) {
|
||||||
PlayerControls.focusNode.requestFocus();
|
PlayerControls.focusNode.requestFocus();
|
||||||
}
|
}
|
||||||
final playback = intent.ref.read(playbackProvider);
|
final playlist = intent.ref.read(PlaylistQueueNotifier.provider);
|
||||||
if (playback.track == null) {
|
final playlistNotifier = intent.ref.read(PlaylistQueueNotifier.notifier);
|
||||||
|
if (playlist == null) {
|
||||||
return null;
|
return null;
|
||||||
} else if (playback.track != null &&
|
} else if (!PlaylistQueueNotifier.isPlaying) {
|
||||||
playback.currentDuration == Duration.zero &&
|
// if (playlist.activeTrack is SpotubeTrack &&
|
||||||
await playback.player.getCurrentPosition() == Duration.zero) {
|
// (playlist.activeTrack as SpotubeTrack).ytUri.startsWith("http")) {
|
||||||
if (playback.track!.ytUri.startsWith("http")) {
|
// final track =
|
||||||
final track = Track.fromJson(playback.track!.toJson());
|
// Track.fromJson((playlist.activeTrack as SpotubeTrack).toJson());
|
||||||
playback.track = null;
|
|
||||||
await playback.play(track);
|
// await playlistNotifier.play(track);
|
||||||
|
// } else {
|
||||||
|
// }
|
||||||
|
await playlistNotifier.play();
|
||||||
} else {
|
} else {
|
||||||
final track = playback.track;
|
await playlistNotifier.pause();
|
||||||
playback.track = null;
|
|
||||||
await playback.play(track!);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await playback.togglePlayPause();
|
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -102,9 +100,9 @@ class SeekIntent extends Intent {
|
|||||||
class SeekAction extends Action<SeekIntent> {
|
class SeekAction extends Action<SeekIntent> {
|
||||||
@override
|
@override
|
||||||
invoke(intent) async {
|
invoke(intent) async {
|
||||||
final playback = intent.ref.read(playbackProvider);
|
final playlist = intent.ref.read(PlaylistQueueNotifier.provider);
|
||||||
if ((playback.playlist == null && playback.track == null) ||
|
final playlistNotifier = intent.ref.read(PlaylistQueueNotifier.notifier);
|
||||||
playback.status == PlaybackStatus.loading) {
|
if (playlist == null || playlist.isLoading) {
|
||||||
DirectionalFocusAction().invoke(
|
DirectionalFocusAction().invoke(
|
||||||
DirectionalFocusIntent(
|
DirectionalFocusIntent(
|
||||||
intent.forward ? TraversalDirection.right : TraversalDirection.left,
|
intent.forward ? TraversalDirection.right : TraversalDirection.left,
|
||||||
@ -113,8 +111,8 @@ class SeekAction extends Action<SeekIntent> {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
final position =
|
final position =
|
||||||
(await playback.player.getCurrentPosition() ?? Duration.zero).inSeconds;
|
(await audioPlayer.getCurrentPosition() ?? Duration.zero).inSeconds;
|
||||||
await playback.seekPosition(
|
await playlistNotifier.seek(
|
||||||
Duration(
|
Duration(
|
||||||
seconds: intent.forward ? position + 5 : position - 5,
|
seconds: intent.forward ? position + 5 : position - 5,
|
||||||
),
|
),
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/shared/playbutton_card.dart';
|
import 'package:spotube/components/shared/playbutton_card.dart';
|
||||||
import 'package:spotube/hooks/use_breakpoint_value.dart';
|
import 'package:spotube/hooks/use_breakpoint_value.dart';
|
||||||
import 'package:spotube/models/current_playlist.dart';
|
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
|
||||||
import 'package:spotube/provider/spotify_provider.dart';
|
import 'package:spotube/provider/spotify_provider.dart';
|
||||||
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
|
|
||||||
@ -20,9 +20,10 @@ class AlbumCard extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
Playback playback = ref.watch(playbackProvider);
|
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||||
bool isPlaylistPlaying =
|
final playing = useStream(PlaylistQueueNotifier.playing).data ?? false;
|
||||||
playback.playlist != null && playback.playlist!.id == album.id;
|
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||||
|
bool isPlaylistPlaying = playlistNotifier.isPlayingPlaylist(album.tracks!);
|
||||||
final int marginH =
|
final int marginH =
|
||||||
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
|
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
|
||||||
return PlaybuttonCard(
|
return PlaybuttonCard(
|
||||||
@ -32,9 +33,8 @@ class AlbumCard extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
viewType: viewType,
|
viewType: viewType,
|
||||||
margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()),
|
margin: EdgeInsets.symmetric(horizontal: marginH.toDouble()),
|
||||||
isPlaying: isPlaylistPlaying && playback.isPlaying,
|
isPlaying: isPlaylistPlaying && playing,
|
||||||
isLoading: playback.status == PlaybackStatus.loading &&
|
isLoading: isPlaylistPlaying && playlist?.isLoading == true,
|
||||||
playback.playlist?.id == album.id,
|
|
||||||
title: album.name!,
|
title: album.name!,
|
||||||
description:
|
description:
|
||||||
"Album • ${TypeConversionUtils.artists_X_String<ArtistSimple>(album.artists ?? [])}",
|
"Album • ${TypeConversionUtils.artists_X_String<ArtistSimple>(album.artists ?? [])}",
|
||||||
@ -43,10 +43,10 @@ class AlbumCard extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
onPlaybuttonPressed: () async {
|
onPlaybuttonPressed: () async {
|
||||||
SpotifyApi spotify = ref.read(spotifyProvider);
|
SpotifyApi spotify = ref.read(spotifyProvider);
|
||||||
if (isPlaylistPlaying && playback.isPlaying) {
|
if (isPlaylistPlaying && playing) {
|
||||||
return playback.pause();
|
return playlistNotifier.pause();
|
||||||
} else if (isPlaylistPlaying && !playback.isPlaying) {
|
} else if (isPlaylistPlaying && !playing) {
|
||||||
return playback.resume();
|
return playlistNotifier.resume();
|
||||||
}
|
}
|
||||||
List<Track> tracks = (await spotify.albums.getTracks(album.id!).all())
|
List<Track> tracks = (await spotify.albums.getTracks(album.id!).all())
|
||||||
.map((track) =>
|
.map((track) =>
|
||||||
@ -54,12 +54,7 @@ class AlbumCard extends HookConsumerWidget {
|
|||||||
.toList();
|
.toList();
|
||||||
if (tracks.isEmpty) return;
|
if (tracks.isEmpty) return;
|
||||||
|
|
||||||
await playback.playPlaylist(CurrentPlaylist(
|
await playlistNotifier.loadAndPlay(tracks);
|
||||||
tracks: tracks,
|
|
||||||
id: album.id!,
|
|
||||||
name: album.name!,
|
|
||||||
thumbnail: album.images!.first.url!,
|
|
||||||
));
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -21,9 +21,8 @@ import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
|
|||||||
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
||||||
import 'package:spotube/hooks/use_async_effect.dart';
|
import 'package:spotube/hooks/use_async_effect.dart';
|
||||||
import 'package:spotube/hooks/use_breakpoints.dart';
|
import 'package:spotube/hooks/use_breakpoints.dart';
|
||||||
import 'package:spotube/models/current_playlist.dart';
|
import 'package:spotube/models/local_track.dart';
|
||||||
import 'package:spotube/models/logger.dart';
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
|
||||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||||
import 'package:spotube/utils/platform.dart';
|
import 'package:spotube/utils/platform.dart';
|
||||||
import 'package:spotube/utils/primitive_utils.dart';
|
import 'package:spotube/utils/primitive_utils.dart';
|
||||||
@ -58,7 +57,7 @@ enum SortBy {
|
|||||||
dateAdded,
|
dateAdded,
|
||||||
}
|
}
|
||||||
|
|
||||||
final localTracksProvider = FutureProvider<List<Track>>((ref) async {
|
final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {
|
||||||
try {
|
try {
|
||||||
if (kIsWeb) return [];
|
if (kIsWeb) return [];
|
||||||
final downloadLocation = ref.watch(
|
final downloadLocation = ref.watch(
|
||||||
@ -97,9 +96,8 @@ final localTracksProvider = FutureProvider<List<Track>>((ref) async {
|
|||||||
|
|
||||||
return {"metadata": metadata, "file": f, "art": imageFile.path};
|
return {"metadata": metadata, "file": f, "art": imageFile.path};
|
||||||
} on FfiException catch (e) {
|
} on FfiException catch (e) {
|
||||||
if (e.message == "NoTag: reader does not contain an id3 tag") {
|
if (e.message != "NoTag: reader does not contain an id3 tag") {
|
||||||
getLogger(FutureProvider<List<Track>>)
|
rethrow;
|
||||||
.v("[Fetching metadata]", e.message);
|
|
||||||
}
|
}
|
||||||
return {};
|
return {};
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
@ -114,11 +112,14 @@ final localTracksProvider = FutureProvider<List<Track>>((ref) async {
|
|||||||
|
|
||||||
final tracks = filesWithMetadata
|
final tracks = filesWithMetadata
|
||||||
.map(
|
.map(
|
||||||
(fileWithMetadata) => TypeConversionUtils.localTrack_X_Track(
|
(fileWithMetadata) => LocalTrack.fromTrack(
|
||||||
|
track: TypeConversionUtils.localTrack_X_Track(
|
||||||
fileWithMetadata["file"],
|
fileWithMetadata["file"],
|
||||||
metadata: fileWithMetadata["metadata"],
|
metadata: fileWithMetadata["metadata"],
|
||||||
art: fileWithMetadata["art"],
|
art: fileWithMetadata["art"],
|
||||||
),
|
),
|
||||||
|
path: fileWithMetadata["file"].path,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
@ -132,37 +133,34 @@ final localTracksProvider = FutureProvider<List<Track>>((ref) async {
|
|||||||
class UserLocalTracks extends HookConsumerWidget {
|
class UserLocalTracks extends HookConsumerWidget {
|
||||||
const UserLocalTracks({Key? key}) : super(key: key);
|
const UserLocalTracks({Key? key}) : super(key: key);
|
||||||
|
|
||||||
void playLocalTracks(Playback playback, List<Track> tracks,
|
void playLocalTracks(
|
||||||
{Track? currentTrack}) async {
|
PlaylistQueueNotifier playback,
|
||||||
|
List<LocalTrack> tracks, {
|
||||||
|
LocalTrack? currentTrack,
|
||||||
|
}) async {
|
||||||
currentTrack ??= tracks.first;
|
currentTrack ??= tracks.first;
|
||||||
final isPlaylistPlaying = playback.playlist?.id == "local";
|
final isPlaylistPlaying = playback.isPlayingPlaylist(tracks);
|
||||||
if (!isPlaylistPlaying) {
|
if (!isPlaylistPlaying) {
|
||||||
await playback.playPlaylist(
|
await playback.loadAndPlay(
|
||||||
CurrentPlaylist(
|
tracks,
|
||||||
tracks: tracks,
|
active: tracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||||
id: "local",
|
|
||||||
name: "Local Tracks",
|
|
||||||
thumbnail: TypeConversionUtils.image_X_UrlString(
|
|
||||||
null,
|
|
||||||
placeholder: ImagePlaceholder.collection,
|
|
||||||
),
|
|
||||||
isLocal: true,
|
|
||||||
),
|
|
||||||
tracks.indexWhere((s) => s.id == currentTrack?.id),
|
|
||||||
);
|
);
|
||||||
} else if (isPlaylistPlaying &&
|
} else if (isPlaylistPlaying &&
|
||||||
currentTrack.id != null &&
|
currentTrack.id != null &&
|
||||||
currentTrack.id != playback.track?.id) {
|
currentTrack.id != playback.state?.activeTrack.id) {
|
||||||
await playback.play(currentTrack);
|
await playback.playTrack(currentTrack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final sortBy = useState<SortBy>(SortBy.none);
|
final sortBy = useState<SortBy>(SortBy.none);
|
||||||
final playback = ref.watch(playbackProvider);
|
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||||
final isPlaylistPlaying = playback.playlist?.id == "local";
|
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||||
final trackSnapshot = ref.watch(localTracksProvider);
|
final trackSnapshot = ref.watch(localTracksProvider);
|
||||||
|
final isPlaylistPlaying = playlistNotifier.isPlayingPlaylist(
|
||||||
|
trackSnapshot.value ?? [],
|
||||||
|
);
|
||||||
final isMounted = useIsMounted();
|
final isMounted = useIsMounted();
|
||||||
final breakpoint = useBreakpoints();
|
final breakpoint = useBreakpoints();
|
||||||
|
|
||||||
@ -198,9 +196,10 @@ class UserLocalTracks extends HookConsumerWidget {
|
|||||||
? () {
|
? () {
|
||||||
if (trackSnapshot.value?.isNotEmpty == true) {
|
if (trackSnapshot.value?.isNotEmpty == true) {
|
||||||
if (!isPlaylistPlaying) {
|
if (!isPlaylistPlaying) {
|
||||||
playLocalTracks(playback, trackSnapshot.value!);
|
playLocalTracks(
|
||||||
|
playlistNotifier, trackSnapshot.value!);
|
||||||
} else {
|
} else {
|
||||||
playback.stop();
|
playlistNotifier.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -267,17 +266,17 @@ class UserLocalTracks extends HookConsumerWidget {
|
|||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final track = filteredTracks[index];
|
final track = filteredTracks[index];
|
||||||
return TrackTile(
|
return TrackTile(
|
||||||
playback,
|
playlist,
|
||||||
duration:
|
duration:
|
||||||
"${track.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.duration?.inSeconds.remainder(60) ?? 0)}",
|
"${track.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.duration?.inSeconds.remainder(60) ?? 0)}",
|
||||||
track: MapEntry(index, track),
|
track: MapEntry(index, track),
|
||||||
isActive: playback.track?.id == track.id,
|
isActive: playlist?.activeTrack.id == track.id,
|
||||||
isChecked: false,
|
isChecked: false,
|
||||||
showCheck: false,
|
showCheck: false,
|
||||||
isLocal: true,
|
isLocal: true,
|
||||||
onTrackPlayButtonPressed: (currentTrack) {
|
onTrackPlayButtonPressed: (currentTrack) {
|
||||||
return playLocalTracks(
|
return playLocalTracks(
|
||||||
playback,
|
playlistNotifier,
|
||||||
sortedTracks,
|
sortedTracks,
|
||||||
currentTrack: track,
|
currentTrack: track,
|
||||||
);
|
);
|
||||||
|
@ -5,14 +5,14 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:platform_ui/platform_ui.dart';
|
import 'package:platform_ui/platform_ui.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/components/library/user_local_tracks.dart';
|
|
||||||
import 'package:spotube/components/player/player_queue.dart';
|
import 'package:spotube/components/player/player_queue.dart';
|
||||||
import 'package:spotube/components/player/sibling_tracks_sheet.dart';
|
import 'package:spotube/components/player/sibling_tracks_sheet.dart';
|
||||||
import 'package:spotube/components/shared/heart_button.dart';
|
import 'package:spotube/components/shared/heart_button.dart';
|
||||||
|
import 'package:spotube/models/local_track.dart';
|
||||||
import 'package:spotube/models/logger.dart';
|
import 'package:spotube/models/logger.dart';
|
||||||
import 'package:spotube/provider/auth_provider.dart';
|
import 'package:spotube/provider/auth_provider.dart';
|
||||||
import 'package:spotube/provider/downloader_provider.dart';
|
import 'package:spotube/provider/downloader_provider.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
|
|
||||||
class PlayerActions extends HookConsumerWidget {
|
class PlayerActions extends HookConsumerWidget {
|
||||||
@ -29,26 +29,27 @@ class PlayerActions extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final Playback playback = ref.watch(playbackProvider);
|
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||||
final isLocalTrack = playback.playlist?.isLocal == true;
|
final playlistNotifier = ref.watch(PlaylistQueueNotifier.provider.notifier);
|
||||||
|
final isLocalTrack = playlist?.activeTrack is LocalTrack;
|
||||||
final downloader = ref.watch(downloaderProvider);
|
final downloader = ref.watch(downloaderProvider);
|
||||||
final isInQueue =
|
final isInQueue = downloader.inQueue
|
||||||
downloader.inQueue.any((element) => element.id == playback.track?.id);
|
.any((element) => element.id == playlist?.activeTrack.id);
|
||||||
final localTracks = ref.watch(localTracksProvider).value;
|
final localTracks = [] /* ref.watch(localTracksProvider).value */;
|
||||||
final auth = ref.watch(authProvider);
|
final auth = ref.watch(authProvider);
|
||||||
|
|
||||||
final isDownloaded = useMemoized(() {
|
final isDownloaded = useMemoized(() {
|
||||||
return localTracks?.any(
|
return localTracks.any(
|
||||||
(element) =>
|
(element) =>
|
||||||
element.name == playback.track?.name &&
|
element.name == playlist?.activeTrack.name &&
|
||||||
element.album?.name == playback.track?.album?.name &&
|
element.album?.name == playlist?.activeTrack.album?.name &&
|
||||||
TypeConversionUtils.artists_X_String<Artist>(
|
TypeConversionUtils.artists_X_String<Artist>(
|
||||||
element.artists ?? []) ==
|
element.artists ?? []) ==
|
||||||
TypeConversionUtils.artists_X_String<Artist>(
|
TypeConversionUtils.artists_X_String<Artist>(
|
||||||
playback.track?.artists ?? []),
|
playlist?.activeTrack.artists ?? []),
|
||||||
) ==
|
) ==
|
||||||
true;
|
true;
|
||||||
}, [localTracks, playback.track]);
|
}, [localTracks, playlist?.activeTrack]);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: mainAxisAlignment,
|
mainAxisAlignment: mainAxisAlignment,
|
||||||
@ -56,7 +57,7 @@ class PlayerActions extends HookConsumerWidget {
|
|||||||
PlatformIconButton(
|
PlatformIconButton(
|
||||||
icon: const Icon(SpotubeIcons.queue),
|
icon: const Icon(SpotubeIcons.queue),
|
||||||
tooltip: 'Queue',
|
tooltip: 'Queue',
|
||||||
onPressed: playback.playlist != null
|
onPressed: playlist != null
|
||||||
? () {
|
? () {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@ -82,7 +83,7 @@ class PlayerActions extends HookConsumerWidget {
|
|||||||
PlatformIconButton(
|
PlatformIconButton(
|
||||||
icon: const Icon(SpotubeIcons.alternativeRoute),
|
icon: const Icon(SpotubeIcons.alternativeRoute),
|
||||||
tooltip: "Alternative Track Sources",
|
tooltip: "Alternative Track Sources",
|
||||||
onPressed: playback.track != null
|
onPressed: playlist?.activeTrack != null
|
||||||
? () {
|
? () {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
@ -119,12 +120,12 @@ class PlayerActions extends HookConsumerWidget {
|
|||||||
icon: Icon(
|
icon: Icon(
|
||||||
isDownloaded ? SpotubeIcons.done : SpotubeIcons.download,
|
isDownloaded ? SpotubeIcons.done : SpotubeIcons.download,
|
||||||
),
|
),
|
||||||
onPressed: playback.track != null
|
onPressed: playlist?.activeTrack != null
|
||||||
? () => downloader.addToQueue(playback.track!)
|
? () => downloader.addToQueue(playlist!.activeTrack)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
if (playback.track != null && !isLocalTrack && auth.isLoggedIn)
|
if (playlist?.activeTrack != null && !isLocalTrack && auth.isLoggedIn)
|
||||||
TrackHeartButton(track: playback.track!),
|
TrackHeartButton(track: playlist!.activeTrack),
|
||||||
...(extraActions ?? [])
|
...(extraActions ?? [])
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
@ -4,10 +4,9 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:platform_ui/platform_ui.dart';
|
import 'package:platform_ui/platform_ui.dart';
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/hooks/playback_hooks.dart';
|
|
||||||
import 'package:spotube/collections/intents.dart';
|
import 'package:spotube/collections/intents.dart';
|
||||||
import 'package:spotube/models/logger.dart';
|
import 'package:spotube/models/logger.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/utils/primitive_utils.dart';
|
import 'package:spotube/utils/primitive_utils.dart';
|
||||||
|
|
||||||
class PlayerControls extends HookConsumerWidget {
|
class PlayerControls extends HookConsumerWidget {
|
||||||
@ -37,12 +36,9 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
SeekIntent: SeekAction(),
|
SeekIntent: SeekAction(),
|
||||||
},
|
},
|
||||||
[]);
|
[]);
|
||||||
final Playback playback = ref.watch(playbackProvider);
|
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||||
|
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||||
final onNext = useNextTrack(ref);
|
final playing = useStream(PlaylistQueueNotifier.playing).data ?? false;
|
||||||
final onPrevious = usePreviousTrack(ref);
|
|
||||||
|
|
||||||
final duration = playback.currentDuration;
|
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
@ -59,27 +55,26 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
constraints: const BoxConstraints(maxWidth: 600),
|
constraints: const BoxConstraints(maxWidth: 600),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
StreamBuilder<Duration>(
|
HookBuilder(
|
||||||
stream: playback.player.onPositionChanged,
|
builder: (context) {
|
||||||
builder: (context, snapshot) {
|
final duration =
|
||||||
|
useStream(PlaylistQueueNotifier.duration).data ??
|
||||||
|
Duration.zero;
|
||||||
|
final positionSnapshot =
|
||||||
|
useStream(PlaylistQueueNotifier.position);
|
||||||
|
final position = positionSnapshot.data ?? Duration.zero;
|
||||||
final totalMinutes = PrimitiveUtils.zeroPadNumStr(
|
final totalMinutes = PrimitiveUtils.zeroPadNumStr(
|
||||||
duration.inMinutes.remainder(60));
|
duration.inMinutes.remainder(60));
|
||||||
final totalSeconds = PrimitiveUtils.zeroPadNumStr(
|
final totalSeconds = PrimitiveUtils.zeroPadNumStr(
|
||||||
duration.inSeconds.remainder(60));
|
duration.inSeconds.remainder(60));
|
||||||
final currentMinutes = snapshot.hasData
|
final currentMinutes = PrimitiveUtils.zeroPadNumStr(
|
||||||
? PrimitiveUtils.zeroPadNumStr(
|
position.inMinutes.remainder(60));
|
||||||
snapshot.data!.inMinutes.remainder(60))
|
final currentSeconds = PrimitiveUtils.zeroPadNumStr(
|
||||||
: "00";
|
position.inSeconds.remainder(60));
|
||||||
final currentSeconds = snapshot.hasData
|
|
||||||
? PrimitiveUtils.zeroPadNumStr(
|
|
||||||
snapshot.data!.inSeconds.remainder(60))
|
|
||||||
: "00";
|
|
||||||
|
|
||||||
final sliderMax = duration.inSeconds;
|
final sliderMax = duration.inSeconds;
|
||||||
final sliderValue = snapshot.data?.inSeconds ?? 0;
|
final sliderValue = position.inSeconds;
|
||||||
|
|
||||||
return HookBuilder(
|
|
||||||
builder: (context) {
|
|
||||||
final progressStatic =
|
final progressStatic =
|
||||||
(sliderMax == 0 || sliderValue > sliderMax)
|
(sliderMax == 0 || sliderValue > sliderMax)
|
||||||
? 0
|
? 0
|
||||||
@ -94,6 +89,19 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
return null;
|
return null;
|
||||||
}, [progressStatic]);
|
}, [progressStatic]);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||||
|
if (positionSnapshot.hasData &&
|
||||||
|
duration == Duration.zero) {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 200));
|
||||||
|
await playlistNotifier.pause();
|
||||||
|
await Future.delayed(const Duration(milliseconds: 400));
|
||||||
|
await playlistNotifier.resume();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}, [positionSnapshot.hasData, duration]);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
PlatformTooltip(
|
PlatformTooltip(
|
||||||
@ -107,7 +115,7 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
progress.value = v;
|
progress.value = v;
|
||||||
},
|
},
|
||||||
onChangeEnd: (value) async {
|
onChangeEnd: (value) async {
|
||||||
await playback.seekPosition(
|
await playlistNotifier.seek(
|
||||||
Duration(
|
Duration(
|
||||||
seconds: (value * sliderMax).toInt(),
|
seconds: (value * sliderMax).toInt(),
|
||||||
),
|
),
|
||||||
@ -133,26 +141,28 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
children: [
|
children: [
|
||||||
PlatformIconButton(
|
PlatformIconButton(
|
||||||
tooltip: playback.isShuffled
|
tooltip: playlist?.isShuffled == true
|
||||||
? "Unshuffle playlist"
|
? "Unshuffle playlist"
|
||||||
: "Shuffle playlist",
|
: "Shuffle playlist",
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
SpotubeIcons.shuffle,
|
SpotubeIcons.shuffle,
|
||||||
color: playback.isShuffled
|
color: playlist?.isShuffled == true
|
||||||
? PlatformTheme.of(context).primaryColor
|
? PlatformTheme.of(context).primaryColor
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
onPressed: playback.playlist == null
|
onPressed: playlist == null
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
playback.setIsShuffled(!playback.isShuffled);
|
if (playlist.isShuffled == true) {
|
||||||
|
playlistNotifier.unshuffle();
|
||||||
|
} else {
|
||||||
|
playlistNotifier.shuffle();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
PlatformIconButton(
|
PlatformIconButton(
|
||||||
@ -161,23 +171,18 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
SpotubeIcons.skipBack,
|
SpotubeIcons.skipBack,
|
||||||
color: iconColor,
|
color: iconColor,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: playlistNotifier.previous,
|
||||||
onPrevious();
|
),
|
||||||
}),
|
|
||||||
PlatformIconButton(
|
PlatformIconButton(
|
||||||
tooltip: playback.isPlaying
|
tooltip: playing ? "Pause playback" : "Resume playback",
|
||||||
? "Pause playback"
|
icon: playlist?.isLoading == true
|
||||||
: "Resume playback",
|
|
||||||
icon: playback.status == PlaybackStatus.loading
|
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 20,
|
height: 20,
|
||||||
width: 20,
|
width: 20,
|
||||||
child: PlatformCircularProgressIndicator(),
|
child: PlatformCircularProgressIndicator(),
|
||||||
)
|
)
|
||||||
: Icon(
|
: Icon(
|
||||||
playback.isPlaying
|
playing ? SpotubeIcons.pause : SpotubeIcons.play,
|
||||||
? SpotubeIcons.pause
|
|
||||||
: SpotubeIcons.play,
|
|
||||||
color: iconColor,
|
color: iconColor,
|
||||||
),
|
),
|
||||||
onPressed: Actions.handler<PlayPauseIntent>(
|
onPressed: Actions.handler<PlayPauseIntent>(
|
||||||
@ -191,7 +196,7 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
SpotubeIcons.skipForward,
|
SpotubeIcons.skipForward,
|
||||||
color: iconColor,
|
color: iconColor,
|
||||||
),
|
),
|
||||||
onPressed: () => onNext(),
|
onPressed: playlistNotifier.next,
|
||||||
),
|
),
|
||||||
PlatformIconButton(
|
PlatformIconButton(
|
||||||
tooltip: "Stop playback",
|
tooltip: "Stop playback",
|
||||||
@ -199,21 +204,28 @@ class PlayerControls extends HookConsumerWidget {
|
|||||||
SpotubeIcons.stop,
|
SpotubeIcons.stop,
|
||||||
color: iconColor,
|
color: iconColor,
|
||||||
),
|
),
|
||||||
onPressed: playback.track != null ? playback.stop : null,
|
onPressed: playlist != null ? playlistNotifier.stop : null,
|
||||||
),
|
),
|
||||||
PlatformIconButton(
|
PlatformIconButton(
|
||||||
tooltip:
|
tooltip: playlist?.isLooping != true
|
||||||
!playback.isLoop ? "Loop Track" : "Repeat playlist",
|
? "Loop Track"
|
||||||
|
: "Repeat playlist",
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
playback.isLoop
|
playlist?.isLooping == true
|
||||||
? SpotubeIcons.repeatOne
|
? SpotubeIcons.repeatOne
|
||||||
: SpotubeIcons.repeat,
|
: SpotubeIcons.repeat,
|
||||||
|
color: playlist?.isLooping == true
|
||||||
|
? PlatformTheme.of(context).primaryColor
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
onPressed:
|
onPressed: playlist == null || playlist.isLoading
|
||||||
playback.track == null || playback.playlist == null
|
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
playback.setIsLoop(!playback.isLoop);
|
if (playlist.isLooping == true) {
|
||||||
|
playlistNotifier.unloop();
|
||||||
|
} else {
|
||||||
|
playlistNotifier.loop();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -1,15 +1,15 @@
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:platform_ui/platform_ui.dart';
|
import 'package:platform_ui/platform_ui.dart';
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/components/player/player_track_details.dart';
|
import 'package:spotube/components/player/player_track_details.dart';
|
||||||
import 'package:spotube/hooks/playback_hooks.dart';
|
|
||||||
import 'package:spotube/hooks/use_palette_color.dart';
|
import 'package:spotube/hooks/use_palette_color.dart';
|
||||||
import 'package:spotube/collections/intents.dart';
|
import 'package:spotube/collections/intents.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
|
|
||||||
class PlayerOverlay extends HookConsumerWidget {
|
class PlayerOverlay extends HookConsumerWidget {
|
||||||
@ -24,16 +24,10 @@ class PlayerOverlay extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final paletteColor = usePaletteColor(albumArt, ref);
|
final paletteColor = usePaletteColor(albumArt, ref);
|
||||||
final canShow = ref.watch(
|
final canShow = ref.watch(
|
||||||
playbackProvider.select(
|
PlaylistQueueNotifier.provider.select((s) => s != null),
|
||||||
(s) =>
|
|
||||||
s.track != null ||
|
|
||||||
s.isPlaying ||
|
|
||||||
s.status == PlaybackStatus.loading,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||||
final onNext = useNextTrack(ref);
|
final playing = useStream(PlaylistQueueNotifier.playing).data ?? false;
|
||||||
final onPrevious = usePreviousTrack(ref);
|
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onVerticalDragEnd: (details) {
|
onVerticalDragEnd: (details) {
|
||||||
@ -87,14 +81,13 @@ class PlayerOverlay extends HookConsumerWidget {
|
|||||||
SpotubeIcons.skipBack,
|
SpotubeIcons.skipBack,
|
||||||
color: paletteColor.bodyTextColor,
|
color: paletteColor.bodyTextColor,
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: playlistNotifier.previous,
|
||||||
onPrevious();
|
),
|
||||||
}),
|
|
||||||
Consumer(
|
Consumer(
|
||||||
builder: (context, ref, _) {
|
builder: (context, ref, _) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
ref.read(playbackProvider).isPlaying
|
playing
|
||||||
? SpotubeIcons.pause
|
? SpotubeIcons.pause
|
||||||
: SpotubeIcons.play,
|
: SpotubeIcons.play,
|
||||||
color: paletteColor.bodyTextColor,
|
color: paletteColor.bodyTextColor,
|
||||||
@ -111,7 +104,7 @@ class PlayerOverlay extends HookConsumerWidget {
|
|||||||
SpotubeIcons.skipForward,
|
SpotubeIcons.skipForward,
|
||||||
color: paletteColor.bodyTextColor,
|
color: paletteColor.bodyTextColor,
|
||||||
),
|
),
|
||||||
onPressed: () => onNext(),
|
onPressed: playlistNotifier.next,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
@ -8,7 +8,7 @@ import 'package:scroll_to_index/scroll_to_index.dart';
|
|||||||
import 'package:spotube/components/shared/fallbacks/not_found.dart';
|
import 'package:spotube/components/shared/fallbacks/not_found.dart';
|
||||||
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
||||||
import 'package:spotube/hooks/use_auto_scroll_controller.dart';
|
import 'package:spotube/hooks/use_auto_scroll_controller.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/utils/primitive_utils.dart';
|
import 'package:spotube/utils/primitive_utils.dart';
|
||||||
|
|
||||||
class PlayerQueue extends HookConsumerWidget {
|
class PlayerQueue extends HookConsumerWidget {
|
||||||
@ -20,9 +20,10 @@ class PlayerQueue extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final playback = ref.watch(playbackProvider);
|
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||||
|
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||||
final controller = useAutoScrollController();
|
final controller = useAutoScrollController();
|
||||||
final tracks = playback.playlist?.tracks ?? [];
|
final tracks = playlist?.tracks ?? {};
|
||||||
|
|
||||||
if (tracks.isEmpty) {
|
if (tracks.isEmpty) {
|
||||||
return const NotFound(vertical: true);
|
return const NotFound(vertical: true);
|
||||||
@ -38,9 +39,8 @@ class PlayerQueue extends HookConsumerWidget {
|
|||||||
PlatformTheme.of(context).textTheme?.subheading?.color;
|
PlatformTheme.of(context).textTheme?.subheading?.color;
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
if (playback.track == null || playback.playlist == null) return null;
|
if (playlist == null) return null;
|
||||||
final index = playback.playlist!.tracks
|
final index = playlist.active;
|
||||||
.indexWhere((track) => track.id == playback.track!.id);
|
|
||||||
if (index < 0) return;
|
if (index < 0) return;
|
||||||
controller.scrollToIndex(
|
controller.scrollToIndex(
|
||||||
index,
|
index,
|
||||||
@ -77,14 +77,6 @@ class PlayerQueue extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
PlatformText.subheading("Queue"),
|
PlatformText.subheading("Queue"),
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
|
||||||
child: PlatformText(
|
|
||||||
playback.playlist?.name ?? "",
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: PlatformTextTheme.of(context).body,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
Flexible(
|
Flexible(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
@ -92,7 +84,7 @@ class PlayerQueue extends HookConsumerWidget {
|
|||||||
itemCount: tracks.length,
|
itemCount: tracks.length,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
itemBuilder: (context, i) {
|
itemBuilder: (context, i) {
|
||||||
final track = tracks.asMap().entries.elementAt(i);
|
final track = tracks.toList().asMap().entries.elementAt(i);
|
||||||
String duration =
|
String duration =
|
||||||
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
|
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
|
||||||
return AutoScrollTag(
|
return AutoScrollTag(
|
||||||
@ -102,13 +94,15 @@ class PlayerQueue extends HookConsumerWidget {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
child: TrackTile(
|
child: TrackTile(
|
||||||
playback,
|
playlist,
|
||||||
track: track,
|
track: track,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
isActive: playback.track?.id == track.value.id,
|
isActive: playlist?.activeTrack.id == track.value.id,
|
||||||
onTrackPlayButtonPressed: (currentTrack) async {
|
onTrackPlayButtonPressed: (currentTrack) async {
|
||||||
if (playback.track?.id == track.value.id) return;
|
if (playlist?.activeTrack.id == track.value.id) {
|
||||||
await playback.setPlaylistPosition(i);
|
return;
|
||||||
|
}
|
||||||
|
await playlistNotifier.playTrack(currentTrack);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -4,7 +4,7 @@ import 'package:platform_ui/platform_ui.dart';
|
|||||||
import 'package:spotube/collections/assets.gen.dart';
|
import 'package:spotube/collections/assets.gen.dart';
|
||||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||||
import 'package:spotube/hooks/use_breakpoints.dart';
|
import 'package:spotube/hooks/use_breakpoints.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
|
|
||||||
class PlayerTrackDetails extends HookConsumerWidget {
|
class PlayerTrackDetails extends HookConsumerWidget {
|
||||||
@ -16,7 +16,7 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final breakpoint = useBreakpoints();
|
final breakpoint = useBreakpoints();
|
||||||
final playback = ref.watch(playbackProvider);
|
final playback = ref.watch(PlaylistQueueNotifier.provider);
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
@ -38,7 +38,7 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
|||||||
if (breakpoint.isLessThanOrEqualTo(Breakpoints.md))
|
if (breakpoint.isLessThanOrEqualTo(Breakpoints.md))
|
||||||
Flexible(
|
Flexible(
|
||||||
child: PlatformText(
|
child: PlatformText(
|
||||||
playback.track?.name ?? "Not playing",
|
playback?.activeTrack.name ?? "Not playing",
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, color: color),
|
style: TextStyle(fontWeight: FontWeight.bold, color: color),
|
||||||
),
|
),
|
||||||
@ -51,12 +51,12 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
PlatformText(
|
PlatformText(
|
||||||
playback.track?.name ?? "Not playing",
|
playback?.activeTrack.name ?? "Not playing",
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, color: color),
|
style: TextStyle(fontWeight: FontWeight.bold, color: color),
|
||||||
),
|
),
|
||||||
TypeConversionUtils.artists_X_ClickableArtists(
|
TypeConversionUtils.artists_X_ClickableArtists(
|
||||||
playback.track?.artists ?? [],
|
playback?.activeTrack.artists ?? [],
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
@ -5,8 +5,10 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:platform_ui/platform_ui.dart';
|
import 'package:platform_ui/platform_ui.dart';
|
||||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
import 'package:spotube/models/spotube_track.dart';
|
||||||
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/utils/primitive_utils.dart';
|
import 'package:spotube/utils/primitive_utils.dart';
|
||||||
|
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||||
|
|
||||||
class SiblingTracksSheet extends HookConsumerWidget {
|
class SiblingTracksSheet extends HookConsumerWidget {
|
||||||
final bool floating;
|
final bool floating;
|
||||||
@ -17,7 +19,13 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final playback = ref.watch(playbackProvider);
|
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||||
|
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||||
|
|
||||||
|
final siblings = playlist?.isLoading == false
|
||||||
|
? (playlist!.activeTrack as SpotubeTrack).siblings
|
||||||
|
: <Video>[];
|
||||||
|
|
||||||
final borderRadius = floating
|
final borderRadius = floating
|
||||||
? BorderRadius.circular(10)
|
? BorderRadius.circular(10)
|
||||||
: const BorderRadius.only(
|
: const BorderRadius.only(
|
||||||
@ -26,11 +34,12 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
if (playback.siblingYtVideos.isEmpty) {
|
if (playlist?.activeTrack is SpotubeTrack &&
|
||||||
playback.toSpotubeTrack(playback.track!, ignoreCache: true);
|
(playlist?.activeTrack as SpotubeTrack).siblings.isEmpty) {
|
||||||
|
playlistNotifier.populateSibling();
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [playback.siblingYtVideos]);
|
}, [playlist?.activeTrack]);
|
||||||
|
|
||||||
return BackdropFilter(
|
return BackdropFilter(
|
||||||
filter: ImageFilter.blur(
|
filter: ImageFilter.blur(
|
||||||
@ -59,9 +68,9 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
|||||||
body: Container(
|
body: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
padding: const EdgeInsets.symmetric(horizontal: 8.0),
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: playback.siblingYtVideos.length,
|
itemCount: siblings.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final video = playback.siblingYtVideos[index];
|
final video = siblings[index];
|
||||||
return PlatformListTile(
|
return PlatformListTile(
|
||||||
title: PlatformText(video.title),
|
title: PlatformText(video.title),
|
||||||
leading: Padding(
|
leading: Padding(
|
||||||
@ -81,12 +90,22 @@ class SiblingTracksSheet extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
subtitle: PlatformText(video.author),
|
subtitle: PlatformText(video.author),
|
||||||
enabled: playback.status != PlaybackStatus.loading,
|
enabled: playlist?.isLoading != true,
|
||||||
selected: video.id == playback.track!.ytTrack.id,
|
selected: playlist?.isLoading != true &&
|
||||||
|
video.id.value ==
|
||||||
|
(playlist?.activeTrack as SpotubeTrack)
|
||||||
|
.ytTrack
|
||||||
|
.id
|
||||||
|
.value,
|
||||||
selectedTileColor: Theme.of(context).popupMenuTheme.color,
|
selectedTileColor: Theme.of(context).popupMenuTheme.color,
|
||||||
onTap: () {
|
onTap: () async {
|
||||||
if (video.id != playback.track!.ytTrack.id) {
|
if (playlist?.isLoading == false &&
|
||||||
playback.changeToSiblingVideo(video, playback.track!);
|
video.id.value !=
|
||||||
|
(playlist?.activeTrack as SpotubeTrack)
|
||||||
|
.ytTrack
|
||||||
|
.id
|
||||||
|
.value) {
|
||||||
|
await playlistNotifier.swapSibling(video);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
|
import 'package:fl_query/fl_query.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/shared/playbutton_card.dart';
|
import 'package:spotube/components/shared/playbutton_card.dart';
|
||||||
import 'package:spotube/hooks/use_breakpoint_value.dart';
|
import 'package:spotube/hooks/use_breakpoint_value.dart';
|
||||||
import 'package:spotube/models/current_playlist.dart';
|
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
|
||||||
import 'package:spotube/provider/spotify_provider.dart';
|
import 'package:spotube/provider/spotify_provider.dart';
|
||||||
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
|
|
||||||
@ -19,9 +21,15 @@ class PlaylistCard extends HookConsumerWidget {
|
|||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
Playback playback = ref.watch(playbackProvider);
|
final playlistQueue = ref.watch(PlaylistQueueNotifier.provider);
|
||||||
bool isPlaylistPlaying =
|
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||||
playback.playlist != null && playback.playlist!.id == playlist.id;
|
final playing = useStream(PlaylistQueueNotifier.playing).data ?? false;
|
||||||
|
final tracks = QueryBowl.of(context)
|
||||||
|
.getQuery<List<Track>, SpotifyApi>(
|
||||||
|
Queries.playlist.tracksOf(playlist.id!).queryKey)
|
||||||
|
?.data ??
|
||||||
|
[];
|
||||||
|
bool isPlaylistPlaying = playlistNotifier.isPlayingPlaylist(tracks);
|
||||||
|
|
||||||
final int marginH =
|
final int marginH =
|
||||||
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
|
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
|
||||||
@ -33,8 +41,8 @@ class PlaylistCard extends HookConsumerWidget {
|
|||||||
playlist.images,
|
playlist.images,
|
||||||
placeholder: ImagePlaceholder.collection,
|
placeholder: ImagePlaceholder.collection,
|
||||||
),
|
),
|
||||||
isPlaying: isPlaylistPlaying && playback.isPlaying,
|
isPlaying: isPlaylistPlaying && playing,
|
||||||
isLoading: playback.status == PlaybackStatus.loading && isPlaylistPlaying,
|
isLoading: isPlaylistPlaying && playlistQueue?.isLoading == true,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ServiceUtils.navigate(
|
ServiceUtils.navigate(
|
||||||
context,
|
context,
|
||||||
@ -43,10 +51,10 @@ class PlaylistCard extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
onPlaybuttonPressed: () async {
|
onPlaybuttonPressed: () async {
|
||||||
if (isPlaylistPlaying && playback.isPlaying) {
|
if (isPlaylistPlaying && playing) {
|
||||||
return playback.pause();
|
return playlistNotifier.pause();
|
||||||
} else if (isPlaylistPlaying && !playback.isPlaying) {
|
} else if (isPlaylistPlaying && !playing) {
|
||||||
return playback.resume();
|
return playlistNotifier.resume();
|
||||||
}
|
}
|
||||||
SpotifyApi spotifyApi = ref.read(spotifyProvider);
|
SpotifyApi spotifyApi = ref.read(spotifyProvider);
|
||||||
|
|
||||||
@ -61,17 +69,7 @@ class PlaylistCard extends HookConsumerWidget {
|
|||||||
|
|
||||||
if (tracks.isEmpty) return;
|
if (tracks.isEmpty) return;
|
||||||
|
|
||||||
await playback.playPlaylist(
|
await playlistNotifier.loadAndPlay(tracks);
|
||||||
CurrentPlaylist(
|
|
||||||
tracks: tracks,
|
|
||||||
id: playlist.id!,
|
|
||||||
name: playlist.name!,
|
|
||||||
thumbnail: TypeConversionUtils.image_X_UrlString(
|
|
||||||
playlist.images,
|
|
||||||
placeholder: ImagePlaceholder.collection,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -13,8 +13,8 @@ import 'package:spotube/components/player/player_controls.dart';
|
|||||||
import 'package:spotube/hooks/use_breakpoints.dart';
|
import 'package:spotube/hooks/use_breakpoints.dart';
|
||||||
import 'package:spotube/hooks/use_platform_property.dart';
|
import 'package:spotube/hooks/use_platform_property.dart';
|
||||||
import 'package:spotube/models/logger.dart';
|
import 'package:spotube/models/logger.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
|
|
||||||
@ -24,21 +24,21 @@ class BottomPlayer extends HookConsumerWidget {
|
|||||||
final logger = getLogger(BottomPlayer);
|
final logger = getLogger(BottomPlayer);
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
Playback playback = ref.watch(playbackProvider);
|
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||||
final layoutMode =
|
final layoutMode =
|
||||||
ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
|
ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
|
||||||
|
|
||||||
final breakpoint = useBreakpoints();
|
final breakpoint = useBreakpoints();
|
||||||
|
|
||||||
String albumArt = useMemoized(
|
String albumArt = useMemoized(
|
||||||
() => playback.track?.album?.images?.isNotEmpty == true
|
() => playlist?.activeTrack.album?.images?.isNotEmpty == true
|
||||||
? TypeConversionUtils.image_X_UrlString(
|
? TypeConversionUtils.image_X_UrlString(
|
||||||
playback.track?.album?.images,
|
playlist?.activeTrack.album?.images,
|
||||||
index: (playback.track?.album?.images?.length ?? 1) - 1,
|
index: (playlist?.activeTrack.album?.images?.length ?? 1) - 1,
|
||||||
placeholder: ImagePlaceholder.albumArt,
|
placeholder: ImagePlaceholder.albumArt,
|
||||||
)
|
)
|
||||||
: Assets.albumPlaceholder.path,
|
: Assets.albumPlaceholder.path,
|
||||||
[playback.track?.album?.images],
|
[playlist?.activeTrack.album?.images],
|
||||||
);
|
);
|
||||||
|
|
||||||
// returning an empty non spacious Container as the overlay will take
|
// returning an empty non spacious Container as the overlay will take
|
||||||
@ -117,23 +117,27 @@ class BottomPlayer extends HookConsumerWidget {
|
|||||||
height: 20,
|
height: 20,
|
||||||
constraints: const BoxConstraints(maxWidth: 200),
|
constraints: const BoxConstraints(maxWidth: 200),
|
||||||
child: HookBuilder(builder: (context) {
|
child: HookBuilder(builder: (context) {
|
||||||
final volume = useState(playback.volume);
|
final volumeState = ref.watch(VolumeProvider.provider);
|
||||||
|
final volumeNotifier =
|
||||||
|
ref.watch(VolumeProvider.provider.notifier);
|
||||||
|
final volume = useState(volumeState);
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
if (volume.value != playback.volume) {
|
if (volume.value != volumeState) {
|
||||||
volume.value = playback.volume;
|
volume.value = volumeState;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [playback.volume]);
|
}, [volumeState]);
|
||||||
|
|
||||||
return Listener(
|
return Listener(
|
||||||
onPointerSignal: (event) async {
|
onPointerSignal: (event) async {
|
||||||
if (event is PointerScrollEvent) {
|
if (event is PointerScrollEvent) {
|
||||||
if (event.scrollDelta.dy > 0) {
|
if (event.scrollDelta.dy > 0) {
|
||||||
final value = volume.value - .2;
|
final value = volume.value - .2;
|
||||||
playback.setVolume(value < 0 ? 0 : value);
|
volumeNotifier.setVolume(value < 0 ? 0 : value);
|
||||||
} else {
|
} else {
|
||||||
final value = volume.value + .2;
|
final value = volume.value + .2;
|
||||||
playback.setVolume(value > 1 ? 1 : value);
|
volumeNotifier.setVolume(value > 1 ? 1 : value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -144,12 +148,7 @@ class BottomPlayer extends HookConsumerWidget {
|
|||||||
onChanged: (v) {
|
onChanged: (v) {
|
||||||
volume.value = v;
|
volume.value = v;
|
||||||
},
|
},
|
||||||
onChangeEnd: (value) async {
|
onChangeEnd: volumeNotifier.setVolume,
|
||||||
// You don't really need to know why but this
|
|
||||||
// way it works only
|
|
||||||
await playback.setVolume(value);
|
|
||||||
await playback.setVolume(value);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
@ -35,6 +35,8 @@ class UniversalImage extends HookWidget {
|
|||||||
cacheKey: path,
|
cacheKey: path,
|
||||||
scale: scale,
|
scale: scale,
|
||||||
);
|
);
|
||||||
|
} else if (path.startsWith("assets/")) {
|
||||||
|
return AssetImage(path);
|
||||||
} else if (Uri.tryParse(path) != null) {
|
} else if (Uri.tryParse(path) != null) {
|
||||||
return FileImage(File(path), scale: scale);
|
return FileImage(File(path), scale: scale);
|
||||||
}
|
}
|
||||||
|
@ -18,8 +18,8 @@ import 'package:spotube/hooks/use_breakpoints.dart';
|
|||||||
import 'package:spotube/models/logger.dart';
|
import 'package:spotube/models/logger.dart';
|
||||||
import 'package:spotube/provider/auth_provider.dart';
|
import 'package:spotube/provider/auth_provider.dart';
|
||||||
import 'package:spotube/provider/blacklist_provider.dart';
|
import 'package:spotube/provider/blacklist_provider.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
|
||||||
import 'package:spotube/provider/spotify_provider.dart';
|
import 'package:spotube/provider/spotify_provider.dart';
|
||||||
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/services/mutations/mutations.dart';
|
import 'package:spotube/services/mutations/mutations.dart';
|
||||||
import 'package:spotube/services/queries/queries.dart';
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
|
|
||||||
@ -27,7 +27,7 @@ import 'package:spotube/utils/type_conversion_utils.dart';
|
|||||||
import 'package:tuple/tuple.dart';
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
class TrackTile extends HookConsumerWidget {
|
class TrackTile extends HookConsumerWidget {
|
||||||
final Playback playback;
|
final PlaylistQueue? playlist;
|
||||||
final MapEntry<int, Track> track;
|
final MapEntry<int, Track> track;
|
||||||
final String duration;
|
final String duration;
|
||||||
final void Function(Track currentTrack)? onTrackPlayButtonPressed;
|
final void Function(Track currentTrack)? onTrackPlayButtonPressed;
|
||||||
@ -47,7 +47,7 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
final void Function(bool?)? onCheckChange;
|
final void Function(bool?)? onCheckChange;
|
||||||
|
|
||||||
TrackTile(
|
TrackTile(
|
||||||
this.playback, {
|
this.playlist, {
|
||||||
required this.track,
|
required this.track,
|
||||||
required this.duration,
|
required this.duration,
|
||||||
required this.isActive,
|
required this.isActive,
|
||||||
@ -240,8 +240,7 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: PlatformIconButton(
|
child: PlatformIconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
playback.track?.id != null &&
|
playlist?.activeTrack.id == track.value.id
|
||||||
playback.track?.id == track.value.id
|
|
||||||
? SpotubeIcons.pause
|
? SpotubeIcons.pause
|
||||||
: SpotubeIcons.play,
|
: SpotubeIcons.play,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
@ -13,7 +13,7 @@ import 'package:spotube/components/library/user_local_tracks.dart';
|
|||||||
import 'package:spotube/hooks/use_breakpoints.dart';
|
import 'package:spotube/hooks/use_breakpoints.dart';
|
||||||
import 'package:spotube/provider/blacklist_provider.dart';
|
import 'package:spotube/provider/blacklist_provider.dart';
|
||||||
import 'package:spotube/provider/downloader_provider.dart';
|
import 'package:spotube/provider/downloader_provider.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/utils/primitive_utils.dart';
|
import 'package:spotube/utils/primitive_utils.dart';
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
|
|
||||||
@ -40,7 +40,7 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(context, ref) {
|
Widget build(context, ref) {
|
||||||
Playback playback = ref.watch(playbackProvider);
|
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||||
final downloader = ref.watch(downloaderProvider);
|
final downloader = ref.watch(downloaderProvider);
|
||||||
TextStyle tableHeadStyle =
|
TextStyle tableHeadStyle =
|
||||||
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
|
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
|
||||||
@ -235,12 +235,12 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: TrackTile(
|
child: TrackTile(
|
||||||
playback,
|
playlist,
|
||||||
playlistId: playlistId,
|
playlistId: playlistId,
|
||||||
track: track,
|
track: track,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
userPlaylist: userPlaylist,
|
userPlaylist: userPlaylist,
|
||||||
isActive: playback.track?.id == track.value.id,
|
isActive: playlist?.activeTrack.id == track.value.id,
|
||||||
onTrackPlayButtonPressed: onTrackPlayButtonPressed,
|
onTrackPlayButtonPressed: onTrackPlayButtonPressed,
|
||||||
isChecked: selected.value.contains(track.value.id),
|
isChecked: selected.value.contains(track.value.id),
|
||||||
showCheck: showCheck.value,
|
showCheck: showCheck.value,
|
||||||
|
@ -1,4 +1,11 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:catcher/catcher.dart';
|
||||||
|
import 'package:http/http.dart';
|
||||||
import 'package:spotube/entities/cache_track.dart';
|
import 'package:spotube/entities/cache_track.dart';
|
||||||
|
import 'package:spotube/models/logger.dart';
|
||||||
|
import 'package:spotube/models/track.dart';
|
||||||
|
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||||
import 'package:spotube/utils/duration.dart';
|
import 'package:spotube/utils/duration.dart';
|
||||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||||
|
|
||||||
@ -30,6 +37,11 @@ extension VideoFromCacheTrackExtension on Video {
|
|||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<Video> fromBackendTrack(
|
||||||
|
BackendTrack track, YoutubeExplode youtube) {
|
||||||
|
return youtube.videos.get(VideoId.fromString(track.youtubeId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ThumbnailSetJson on ThumbnailSet {
|
extension ThumbnailSetJson on ThumbnailSet {
|
||||||
@ -100,3 +112,48 @@ extension VideoToJson on Video {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension GetSkipSegments on Video {
|
||||||
|
Future<List<Map<String, int>>> getSkipSegments(
|
||||||
|
UserPreferences preferences) async {
|
||||||
|
if (!preferences.skipSponsorSegments) return [];
|
||||||
|
try {
|
||||||
|
final res = await get(Uri(
|
||||||
|
scheme: "https",
|
||||||
|
host: "sponsor.ajay.app",
|
||||||
|
path: "/api/skipSegments",
|
||||||
|
queryParameters: {
|
||||||
|
"videoID": id.value,
|
||||||
|
"category": [
|
||||||
|
'sponsor',
|
||||||
|
'selfpromo',
|
||||||
|
'interaction',
|
||||||
|
'intro',
|
||||||
|
'outro',
|
||||||
|
'music_offtopic'
|
||||||
|
],
|
||||||
|
"actionType": 'skip'
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
if (res.body == "Not Found") {
|
||||||
|
return List.castFrom<dynamic, Map<String, int>>([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = jsonDecode(res.body);
|
||||||
|
final segments = data.map((obj) {
|
||||||
|
return Map.castFrom<String, dynamic, String, int>({
|
||||||
|
"start": obj["segment"].first.toInt(),
|
||||||
|
"end": obj["segment"].last.toInt(),
|
||||||
|
});
|
||||||
|
}).toList();
|
||||||
|
getLogger(Video).v(
|
||||||
|
"[SponsorBlock] successfully fetched skip segments for $title | ${id.value}",
|
||||||
|
);
|
||||||
|
return List.castFrom<dynamic, Map<String, int>>(segments);
|
||||||
|
} catch (e, stack) {
|
||||||
|
Catcher.reportCheckedError(e, stack);
|
||||||
|
return List.castFrom<dynamic, Map<String, int>>([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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();
|
|
||||||
};
|
|
||||||
}
|
|
@ -1,16 +1,13 @@
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
|
|
||||||
int useSyncedLyrics(
|
int useSyncedLyrics(
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
Map<int, String> lyricsMap,
|
Map<int, String> lyricsMap,
|
||||||
Duration delay,
|
Duration delay,
|
||||||
) {
|
) {
|
||||||
final player = ref.watch(playbackProvider.select(
|
final stream = PlaylistQueueNotifier.position;
|
||||||
(value) => (value.player),
|
|
||||||
));
|
|
||||||
final stream = player.onPositionChanged;
|
|
||||||
|
|
||||||
final currentTime = useState(0);
|
final currentTime = useState(0);
|
||||||
|
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:audio_service/audio_service.dart';
|
|
||||||
import 'package:catcher/catcher.dart';
|
import 'package:catcher/catcher.dart';
|
||||||
import 'package:fl_query/fl_query.dart';
|
import 'package:fl_query/fl_query.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
@ -12,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:platform_ui/platform_ui.dart';
|
import 'package:platform_ui/platform_ui.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:spotube/collections/cache_keys.dart';
|
import 'package:spotube/collections/cache_keys.dart';
|
||||||
|
import 'package:spotube/collections/env.dart';
|
||||||
import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart';
|
import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart';
|
||||||
import 'package:spotube/entities/cache_track.dart';
|
import 'package:spotube/entities/cache_track.dart';
|
||||||
import 'package:spotube/collections/routes.dart';
|
import 'package:spotube/collections/routes.dart';
|
||||||
@ -19,10 +19,9 @@ import 'package:spotube/collections/intents.dart';
|
|||||||
import 'package:spotube/models/logger.dart';
|
import 'package:spotube/models/logger.dart';
|
||||||
import 'package:spotube/provider/audio_player_provider.dart';
|
import 'package:spotube/provider/audio_player_provider.dart';
|
||||||
import 'package:spotube/provider/downloader_provider.dart';
|
import 'package:spotube/provider/downloader_provider.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
|
||||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||||
import 'package:spotube/provider/youtube_provider.dart';
|
import 'package:spotube/provider/youtube_provider.dart';
|
||||||
import 'package:spotube/services/mobile_audio_service.dart';
|
import 'package:spotube/services/pocketbase.dart';
|
||||||
import 'package:spotube/themes/dark_theme.dart';
|
import 'package:spotube/themes/dark_theme.dart';
|
||||||
import 'package:spotube/themes/light_theme.dart';
|
import 'package:spotube/themes/light_theme.dart';
|
||||||
import 'package:spotube/utils/platform.dart';
|
import 'package:spotube/utils/platform.dart';
|
||||||
@ -36,6 +35,8 @@ void main() async {
|
|||||||
Hive.registerAdapter(CacheTrackAdapter());
|
Hive.registerAdapter(CacheTrackAdapter());
|
||||||
Hive.registerAdapter(CacheTrackEngagementAdapter());
|
Hive.registerAdapter(CacheTrackEngagementAdapter());
|
||||||
Hive.registerAdapter(CacheTrackSkipSegmentAdapter());
|
Hive.registerAdapter(CacheTrackSkipSegmentAdapter());
|
||||||
|
await Env.configure();
|
||||||
|
|
||||||
if (kIsDesktop) {
|
if (kIsDesktop) {
|
||||||
await windowManager.ensureInitialized();
|
await windowManager.ensureInitialized();
|
||||||
WindowOptions windowOptions = const WindowOptions(
|
WindowOptions windowOptions = const WindowOptions(
|
||||||
@ -61,7 +62,6 @@ void main() async {
|
|||||||
await windowManager.show();
|
await windowManager.show();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
MobileAudioService? audioServiceHandler;
|
|
||||||
|
|
||||||
Catcher(
|
Catcher(
|
||||||
debugConfig: CatcherOptions(
|
debugConfig: CatcherOptions(
|
||||||
@ -72,7 +72,17 @@ void main() async {
|
|||||||
enableApplicationParameters: false,
|
enableApplicationParameters: false,
|
||||||
),
|
),
|
||||||
FileHandler(await getLogsPath(), printLogs: false),
|
FileHandler(await getLogsPath(), printLogs: false),
|
||||||
SnackbarHandler(const Duration(seconds: 5)),
|
SnackbarHandler(
|
||||||
|
const Duration(seconds: 5),
|
||||||
|
action: SnackBarAction(
|
||||||
|
label: "Dismiss",
|
||||||
|
onPressed: () {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
Catcher.navigatorKey!.currentContext!,
|
||||||
|
).hideCurrentSnackBar();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
releaseConfig: CatcherOptions(SilentReportMode(), [
|
releaseConfig: CatcherOptions(SilentReportMode(), [
|
||||||
@ -84,36 +94,6 @@ void main() async {
|
|||||||
builder: (context) {
|
builder: (context) {
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
overrides: [
|
overrides: [
|
||||||
playbackProvider.overrideWith(
|
|
||||||
(ref) {
|
|
||||||
final youtube = ref.watch(youtubeProvider);
|
|
||||||
final player = ref.watch(audioPlayerProvider);
|
|
||||||
|
|
||||||
final playback = Playback(
|
|
||||||
player: player,
|
|
||||||
youtube: youtube,
|
|
||||||
ref: ref,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (audioServiceHandler == null) {
|
|
||||||
AudioService.init(
|
|
||||||
builder: () => MobileAudioService(playback),
|
|
||||||
config: const AudioServiceConfig(
|
|
||||||
androidNotificationChannelId: 'com.krtirtho.Spotube',
|
|
||||||
androidNotificationChannelName: 'Spotube',
|
|
||||||
androidNotificationOngoing: true,
|
|
||||||
),
|
|
||||||
).then(
|
|
||||||
(value) {
|
|
||||||
playback.mobileAudioService = value;
|
|
||||||
audioServiceHandler = value;
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return playback;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
downloaderProvider.overrideWith(
|
downloaderProvider.overrideWith(
|
||||||
(ref) {
|
(ref) {
|
||||||
return Downloader(
|
return Downloader(
|
||||||
@ -155,6 +135,7 @@ void main() async {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
await initializePocketBase();
|
||||||
}
|
}
|
||||||
|
|
||||||
class Spotube extends StatefulHookConsumerWidget {
|
class Spotube extends StatefulHookConsumerWidget {
|
||||||
|
60
lib/models/local_track.dart
Normal file
60
lib/models/local_track.dart
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,20 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/extensions/video.dart';
|
import 'package:spotube/extensions/video.dart';
|
||||||
import 'package:spotube/extensions/album_simple.dart';
|
import 'package:spotube/extensions/album_simple.dart';
|
||||||
import 'package:spotube/extensions/artist_simple.dart';
|
import 'package:spotube/extensions/artist_simple.dart';
|
||||||
|
import 'package:spotube/models/track.dart';
|
||||||
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
|
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||||
|
import 'package:spotube/services/pocketbase.dart';
|
||||||
|
import 'package:spotube/utils/platform.dart';
|
||||||
|
import 'package:spotube/utils/primitive_utils.dart';
|
||||||
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
import 'package:youtube_explode_dart/youtube_explode_dart.dart';
|
||||||
|
import 'package:collection/collection.dart';
|
||||||
|
|
||||||
enum SpotubeTrackMatchAlgorithm {
|
enum SpotubeTrackMatchAlgorithm {
|
||||||
// selects the first result returned from YouTube
|
// selects the first result returned from YouTube
|
||||||
@ -14,14 +26,16 @@ enum SpotubeTrackMatchAlgorithm {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SpotubeTrack extends Track {
|
class SpotubeTrack extends Track {
|
||||||
Video ytTrack;
|
final Video ytTrack;
|
||||||
String ytUri;
|
final String ytUri;
|
||||||
List<Map<String, int>> skipSegments;
|
final List<Map<String, int>> skipSegments;
|
||||||
|
final List<Video> siblings;
|
||||||
|
|
||||||
SpotubeTrack(
|
SpotubeTrack(
|
||||||
this.ytTrack,
|
this.ytTrack,
|
||||||
this.ytUri,
|
this.ytUri,
|
||||||
this.skipSegments,
|
this.skipSegments,
|
||||||
|
this.siblings,
|
||||||
) : super();
|
) : super();
|
||||||
|
|
||||||
SpotubeTrack.fromTrack({
|
SpotubeTrack.fromTrack({
|
||||||
@ -29,6 +43,7 @@ class SpotubeTrack extends Track {
|
|||||||
required this.ytTrack,
|
required this.ytTrack,
|
||||||
required this.ytUri,
|
required this.ytUri,
|
||||||
required this.skipSegments,
|
required this.skipSegments,
|
||||||
|
required this.siblings,
|
||||||
}) : super() {
|
}) : super() {
|
||||||
album = track.album;
|
album = track.album;
|
||||||
artists = track.artists;
|
artists = track.artists;
|
||||||
@ -50,6 +65,219 @@ class SpotubeTrack extends Track {
|
|||||||
uri = track.uri;
|
uri = track.uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<SpotubeTrack> fromFetchTrack(
|
||||||
|
Track track, UserPreferences preferences) async {
|
||||||
|
final artists = (track.artists ?? [])
|
||||||
|
.map((ar) => ar.name)
|
||||||
|
.toList()
|
||||||
|
.whereNotNull()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final title = ServiceUtils.getTitle(
|
||||||
|
track.name!,
|
||||||
|
artists: artists,
|
||||||
|
onlyCleanArtist: true,
|
||||||
|
).trim();
|
||||||
|
|
||||||
|
final cachedTracks = await pb.collection(BackendTrack.collection).getList(
|
||||||
|
filter: "spotify_id = '${track.id}'",
|
||||||
|
sort: "-votes",
|
||||||
|
page: 0,
|
||||||
|
perPage: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
final cachedTrack = cachedTracks.items.isNotEmpty
|
||||||
|
? BackendTrack.fromRecord(cachedTracks.items.first)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
Video ytVideo;
|
||||||
|
List<Video> siblings = [];
|
||||||
|
if (cachedTrack != null) {
|
||||||
|
ytVideo = await VideoFromCacheTrackExtension.fromBackendTrack(
|
||||||
|
cachedTrack,
|
||||||
|
youtube,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
VideoSearchList videos = await PrimitiveUtils.raceMultiple(
|
||||||
|
() => youtube.search.search("${artists.join(", ")} - $title"),
|
||||||
|
);
|
||||||
|
siblings = videos.where((video) => !video.isLive).take(10).toList();
|
||||||
|
ytVideo = siblings.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
StreamManifest trackManifest = await PrimitiveUtils.raceMultiple(
|
||||||
|
() => youtube.videos.streams.getManifest(ytVideo.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
final audioManifest = trackManifest.audioOnly.where((info) {
|
||||||
|
final isMp4a = info.codec.mimeType == "audio/mp4";
|
||||||
|
if (kIsLinux) {
|
||||||
|
return !isMp4a;
|
||||||
|
} else if (kIsMacOS || kIsIOS) {
|
||||||
|
return isMp4a;
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final chosenStreamInfo = preferences.audioQuality == AudioQuality.high
|
||||||
|
? audioManifest.withHighestBitrate()
|
||||||
|
: audioManifest.sortByBitrate().last;
|
||||||
|
|
||||||
|
final ytUri = chosenStreamInfo.url.toString();
|
||||||
|
|
||||||
|
if (cachedTrack == null) {
|
||||||
|
await pb.collection(BackendTrack.collection).create(
|
||||||
|
body: BackendTrack(
|
||||||
|
spotifyId: track.id!,
|
||||||
|
youtubeId: ytVideo.id.value,
|
||||||
|
votes: 0,
|
||||||
|
).toJson(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferences.predownload &&
|
||||||
|
ytVideo.duration! < const Duration(minutes: 15)) {
|
||||||
|
await DefaultCacheManager().getFileFromCache(track.id!).then(
|
||||||
|
(file) async {
|
||||||
|
if (file != null) return file.file;
|
||||||
|
final List<int> bytesStore = [];
|
||||||
|
final bytesFuture = Completer<Uint8List>();
|
||||||
|
|
||||||
|
youtube.videos.streams.get(chosenStreamInfo).listen(
|
||||||
|
(data) {
|
||||||
|
bytesStore.addAll(data);
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
bytesFuture.complete(Uint8List.fromList(bytesStore));
|
||||||
|
},
|
||||||
|
onError: (e) {
|
||||||
|
bytesFuture.completeError(e);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final cached = await DefaultCacheManager().putFile(
|
||||||
|
track.id!,
|
||||||
|
await bytesFuture.future,
|
||||||
|
fileExtension: chosenStreamInfo.codec.mimeType.split("/").last,
|
||||||
|
);
|
||||||
|
|
||||||
|
return cached;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SpotubeTrack.fromTrack(
|
||||||
|
track: track,
|
||||||
|
ytTrack: ytVideo,
|
||||||
|
ytUri: ytUri,
|
||||||
|
skipSegments: preferences.skipSponsorSegments
|
||||||
|
? await ytVideo.getSkipSegments(preferences)
|
||||||
|
: [],
|
||||||
|
siblings: siblings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SpotubeTrack?> swappedCopy(
|
||||||
|
Video video,
|
||||||
|
UserPreferences preferences,
|
||||||
|
) async {
|
||||||
|
if (siblings.none((element) => element.id == video.id)) return null;
|
||||||
|
|
||||||
|
StreamManifest trackManifest = await PrimitiveUtils.raceMultiple(
|
||||||
|
() => youtube.videos.streams.getManifest(video.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
final audioManifest = trackManifest.audioOnly.where((info) {
|
||||||
|
final isMp4a = info.codec.mimeType == "audio/mp4";
|
||||||
|
if (kIsLinux) {
|
||||||
|
return !isMp4a;
|
||||||
|
} else if (kIsMacOS || kIsIOS) {
|
||||||
|
return isMp4a;
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final chosenStreamInfo = preferences.audioQuality == AudioQuality.high
|
||||||
|
? audioManifest.withHighestBitrate()
|
||||||
|
: audioManifest.sortByBitrate().last;
|
||||||
|
|
||||||
|
final ytUri = chosenStreamInfo.url.toString();
|
||||||
|
|
||||||
|
final cachedTracks = await pb.collection(BackendTrack.collection).getList(
|
||||||
|
filter: "spotify_id = '$id' && youtube_id = '${video.id.value}'",
|
||||||
|
sort: "-votes",
|
||||||
|
page: 0,
|
||||||
|
perPage: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
final cachedTrack = cachedTracks.items.isNotEmpty
|
||||||
|
? BackendTrack.fromRecord(cachedTracks.items.first)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (cachedTrack == null) {
|
||||||
|
await pb.collection(BackendTrack.collection).create(
|
||||||
|
body: BackendTrack(
|
||||||
|
spotifyId: id!,
|
||||||
|
youtubeId: video.id.value,
|
||||||
|
votes: 1,
|
||||||
|
).toJson(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await pb.collection(BackendTrack.collection).update(
|
||||||
|
cachedTrack.id,
|
||||||
|
body: {
|
||||||
|
"votes": cachedTrack.votes + 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preferences.predownload &&
|
||||||
|
video.duration! < const Duration(minutes: 15)) {
|
||||||
|
await DefaultCacheManager().getFileFromCache(id!).then(
|
||||||
|
(file) async {
|
||||||
|
if (file != null) return file.file;
|
||||||
|
final List<int> bytesStore = [];
|
||||||
|
final bytesFuture = Completer<Uint8List>();
|
||||||
|
|
||||||
|
youtube.videos.streams.get(chosenStreamInfo).listen(
|
||||||
|
(data) {
|
||||||
|
bytesStore.addAll(data);
|
||||||
|
},
|
||||||
|
onDone: () {
|
||||||
|
bytesFuture.complete(Uint8List.fromList(bytesStore));
|
||||||
|
},
|
||||||
|
onError: (e) {
|
||||||
|
bytesFuture.completeError(e);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final cached = await DefaultCacheManager().putFile(
|
||||||
|
id!,
|
||||||
|
await bytesFuture.future,
|
||||||
|
fileExtension: chosenStreamInfo.codec.mimeType.split("/").last,
|
||||||
|
);
|
||||||
|
|
||||||
|
return cached;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SpotubeTrack.fromTrack(
|
||||||
|
track: this,
|
||||||
|
ytTrack: video,
|
||||||
|
ytUri: ytUri,
|
||||||
|
skipSegments: preferences.skipSponsorSegments
|
||||||
|
? await video.getSkipSegments(preferences)
|
||||||
|
: [],
|
||||||
|
siblings: [
|
||||||
|
video,
|
||||||
|
...siblings.where((element) => element.id != video.id),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static SpotubeTrack fromJson(Map<String, dynamic> map) {
|
static SpotubeTrack fromJson(Map<String, dynamic> map) {
|
||||||
return SpotubeTrack.fromTrack(
|
return SpotubeTrack.fromTrack(
|
||||||
track: Track.fromJson(map),
|
track: Track.fromJson(map),
|
||||||
@ -57,6 +285,37 @@ class SpotubeTrack extends Track {
|
|||||||
ytUri: map["ytUri"],
|
ytUri: map["ytUri"],
|
||||||
skipSegments:
|
skipSegments:
|
||||||
List.castFrom<dynamic, Map<String, int>>(map["skipSegments"]),
|
List.castFrom<dynamic, Map<String, int>>(map["skipSegments"]),
|
||||||
|
siblings: List.castFrom<dynamic, Map<String, dynamic>>(map["siblings"])
|
||||||
|
.map((sibling) => VideoToJson.fromJson(sibling))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SpotubeTrack> populatedCopy() async {
|
||||||
|
if (this.siblings.isNotEmpty) return this;
|
||||||
|
final artists = (this.artists ?? [])
|
||||||
|
.map((ar) => ar.name)
|
||||||
|
.toList()
|
||||||
|
.whereNotNull()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final title = ServiceUtils.getTitle(
|
||||||
|
name!,
|
||||||
|
artists: artists,
|
||||||
|
onlyCleanArtist: true,
|
||||||
|
).trim();
|
||||||
|
VideoSearchList videos = await PrimitiveUtils.raceMultiple(
|
||||||
|
() => youtube.search.search("${artists.join(", ")} - $title"),
|
||||||
|
);
|
||||||
|
|
||||||
|
final siblings = videos.where((video) => !video.isLive).take(10).toList();
|
||||||
|
|
||||||
|
return SpotubeTrack.fromTrack(
|
||||||
|
track: this,
|
||||||
|
ytTrack: ytTrack,
|
||||||
|
ytUri: ytUri,
|
||||||
|
skipSegments: skipSegments,
|
||||||
|
siblings: siblings,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,7 +339,8 @@ class SpotubeTrack extends Track {
|
|||||||
"uri": uri,
|
"uri": uri,
|
||||||
"ytTrack": ytTrack.toJson(),
|
"ytTrack": ytTrack.toJson(),
|
||||||
"ytUri": ytUri,
|
"ytUri": ytUri,
|
||||||
"skipSegments": skipSegments
|
"skipSegments": skipSegments,
|
||||||
|
"siblings": siblings.map((sibling) => sibling.toJson()).toList(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
29
lib/models/track.dart
Normal file
29
lib/models/track.dart
Normal 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
30
lib/models/track.g.dart
Normal 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,
|
||||||
|
};
|
@ -8,11 +8,10 @@ import 'package:spotube/components/shared/heart_button.dart';
|
|||||||
import 'package:spotube/components/shared/track_table/track_collection_view.dart';
|
import 'package:spotube/components/shared/track_table/track_collection_view.dart';
|
||||||
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
|
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
|
||||||
import 'package:spotube/hooks/use_breakpoints.dart';
|
import 'package:spotube/hooks/use_breakpoints.dart';
|
||||||
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/services/queries/queries.dart';
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
import 'package:spotube/models/current_playlist.dart';
|
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
|
||||||
import 'package:spotube/provider/spotify_provider.dart';
|
import 'package:spotube/provider/spotify_provider.dart';
|
||||||
|
|
||||||
class AlbumPage extends HookConsumerWidget {
|
class AlbumPage extends HookConsumerWidget {
|
||||||
@ -20,38 +19,33 @@ class AlbumPage extends HookConsumerWidget {
|
|||||||
const AlbumPage(this.album, {Key? key}) : super(key: key);
|
const AlbumPage(this.album, {Key? key}) : super(key: key);
|
||||||
|
|
||||||
Future<void> playPlaylist(
|
Future<void> playPlaylist(
|
||||||
Playback playback,
|
PlaylistQueueNotifier playback,
|
||||||
List<Track> tracks,
|
List<Track> tracks,
|
||||||
WidgetRef ref, {
|
WidgetRef ref, {
|
||||||
Track? currentTrack,
|
Track? currentTrack,
|
||||||
}) async {
|
}) async {
|
||||||
|
final playlist = ref.read(PlaylistQueueNotifier.provider);
|
||||||
final sortBy = ref.read(trackCollectionSortState(album.id!));
|
final sortBy = ref.read(trackCollectionSortState(album.id!));
|
||||||
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
|
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
|
||||||
currentTrack ??= sortedTracks.first;
|
currentTrack ??= sortedTracks.first;
|
||||||
final isPlaylistPlaying = playback.playlist?.id == album.id;
|
final isPlaylistPlaying = playback.isPlayingPlaylist(tracks);
|
||||||
if (!isPlaylistPlaying) {
|
if (!isPlaylistPlaying) {
|
||||||
await playback.playPlaylist(
|
playback.load(
|
||||||
CurrentPlaylist(
|
sortedTracks,
|
||||||
tracks: sortedTracks,
|
active: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||||
id: album.id!,
|
|
||||||
name: album.name!,
|
|
||||||
thumbnail: TypeConversionUtils.image_X_UrlString(
|
|
||||||
album.images,
|
|
||||||
placeholder: ImagePlaceholder.collection,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
|
|
||||||
);
|
);
|
||||||
|
await playback.play();
|
||||||
} else if (isPlaylistPlaying &&
|
} else if (isPlaylistPlaying &&
|
||||||
currentTrack.id != null &&
|
currentTrack.id != null &&
|
||||||
currentTrack.id != playback.track?.id) {
|
currentTrack.id != playlist?.activeTrack.id) {
|
||||||
await playback.play(currentTrack);
|
await playback.playTrack(currentTrack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
Playback playback = ref.watch(playbackProvider);
|
ref.watch(PlaylistQueueNotifier.provider);
|
||||||
|
final playback = ref.watch(PlaylistQueueNotifier.notifier);
|
||||||
|
|
||||||
final SpotifyApi spotify = ref.watch(spotifyProvider);
|
final SpotifyApi spotify = ref.watch(spotifyProvider);
|
||||||
|
|
||||||
@ -69,8 +63,10 @@ class AlbumPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
final breakpoint = useBreakpoints();
|
final breakpoint = useBreakpoints();
|
||||||
|
|
||||||
final isAlbumPlaying =
|
final isAlbumPlaying = useMemoized(
|
||||||
playback.playlist?.id != null && playback.playlist?.id == album.id;
|
() => playback.isPlayingPlaylist(tracksSnapshot.data ?? []),
|
||||||
|
[tracksSnapshot.data],
|
||||||
|
);
|
||||||
return TrackCollectionView(
|
return TrackCollectionView(
|
||||||
id: album.id!,
|
id: album.id!,
|
||||||
isPlaying: isAlbumPlaying,
|
isPlaying: isAlbumPlaying,
|
||||||
|
@ -15,12 +15,11 @@ import 'package:spotube/components/artist/artist_album_list.dart';
|
|||||||
import 'package:spotube/components/artist/artist_card.dart';
|
import 'package:spotube/components/artist/artist_card.dart';
|
||||||
import 'package:spotube/hooks/use_breakpoint_value.dart';
|
import 'package:spotube/hooks/use_breakpoint_value.dart';
|
||||||
import 'package:spotube/hooks/use_breakpoints.dart';
|
import 'package:spotube/hooks/use_breakpoints.dart';
|
||||||
import 'package:spotube/models/current_playlist.dart';
|
|
||||||
import 'package:spotube/models/logger.dart';
|
import 'package:spotube/models/logger.dart';
|
||||||
import 'package:spotube/provider/auth_provider.dart';
|
import 'package:spotube/provider/auth_provider.dart';
|
||||||
import 'package:spotube/provider/blacklist_provider.dart';
|
import 'package:spotube/provider/blacklist_provider.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
|
||||||
import 'package:spotube/provider/spotify_provider.dart';
|
import 'package:spotube/provider/spotify_provider.dart';
|
||||||
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/services/queries/queries.dart';
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
|
|
||||||
import 'package:spotube/utils/primitive_utils.dart';
|
import 'package:spotube/utils/primitive_utils.dart';
|
||||||
@ -54,7 +53,8 @@ class ArtistPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
final breakpoint = useBreakpoints();
|
final breakpoint = useBreakpoints();
|
||||||
|
|
||||||
final Playback playback = ref.watch(playbackProvider);
|
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||||
|
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||||
|
|
||||||
final auth = ref.watch(authProvider);
|
final auth = ref.watch(authProvider);
|
||||||
|
|
||||||
@ -296,28 +296,20 @@ class ArtistPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
final topTracks = topTracksQuery.data!;
|
final topTracks = topTracksQuery.data!;
|
||||||
|
|
||||||
final isPlaylistPlaying =
|
final isPlaylistPlaying = useMemoized(() {
|
||||||
playback.playlist?.id == data.id;
|
return playlistNotifier.isPlayingPlaylist(topTracks);
|
||||||
|
}, [topTracks]);
|
||||||
playPlaylist(List<Track> tracks,
|
playPlaylist(List<Track> tracks,
|
||||||
{Track? currentTrack}) async {
|
{Track? currentTrack}) async {
|
||||||
currentTrack ??= tracks.first;
|
currentTrack ??= tracks.first;
|
||||||
if (!isPlaylistPlaying) {
|
if (!isPlaylistPlaying) {
|
||||||
await playback.playPlaylist(
|
playlistNotifier.loadAndPlay(tracks,
|
||||||
CurrentPlaylist(
|
active: tracks
|
||||||
tracks: tracks,
|
.indexWhere((s) => s.id == currentTrack?.id));
|
||||||
id: data.id!,
|
|
||||||
name: "${data.name!} To Tracks",
|
|
||||||
thumbnail: TypeConversionUtils.image_X_UrlString(
|
|
||||||
data.images,
|
|
||||||
placeholder: ImagePlaceholder.artist,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
tracks.indexWhere((s) => s.id == currentTrack?.id),
|
|
||||||
);
|
|
||||||
} else if (isPlaylistPlaying &&
|
} else if (isPlaylistPlaying &&
|
||||||
currentTrack.id != null &&
|
currentTrack.id != null &&
|
||||||
currentTrack.id != playback.track?.id) {
|
currentTrack.id != playlist?.activeTrack.id) {
|
||||||
await playback.play(currentTrack);
|
await playlistNotifier.playTrack(currentTrack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -352,10 +344,11 @@ class ArtistPage extends HookConsumerWidget {
|
|||||||
String duration =
|
String duration =
|
||||||
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
|
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
|
||||||
return TrackTile(
|
return TrackTile(
|
||||||
playback,
|
playlist,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
track: track,
|
track: track,
|
||||||
isActive: playback.track?.id == track.value.id,
|
isActive:
|
||||||
|
playlist?.activeTrack.id == track.value.id,
|
||||||
onTrackPlayButtonPressed: (currentTrack) =>
|
onTrackPlayButtonPressed: (currentTrack) =>
|
||||||
playPlaylist(
|
playPlaylist(
|
||||||
topTracks.toList(),
|
topTracks.toList(),
|
||||||
|
@ -2,11 +2,11 @@ import 'package:flutter/material.dart' hide Image;
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:platform_ui/platform_ui.dart';
|
import 'package:platform_ui/platform_ui.dart';
|
||||||
|
import 'package:spotube/components/library/user_local_tracks.dart';
|
||||||
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||||
import 'package:spotube/components/library/user_albums.dart';
|
import 'package:spotube/components/library/user_albums.dart';
|
||||||
import 'package:spotube/components/library/user_artists.dart';
|
import 'package:spotube/components/library/user_artists.dart';
|
||||||
import 'package:spotube/components/library/user_downloads.dart';
|
import 'package:spotube/components/library/user_downloads.dart';
|
||||||
import 'package:spotube/components/library/user_local_tracks.dart';
|
|
||||||
import 'package:spotube/components/library/user_playlists.dart';
|
import 'package:spotube/components/library/user_playlists.dart';
|
||||||
|
|
||||||
class LibraryPage extends HookConsumerWidget {
|
class LibraryPage extends HookConsumerWidget {
|
||||||
|
@ -5,7 +5,7 @@ import 'package:palette_generator/palette_generator.dart';
|
|||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart';
|
import 'package:spotube/components/shared/shimmers/shimmer_lyrics.dart';
|
||||||
import 'package:spotube/hooks/use_breakpoints.dart';
|
import 'package:spotube/hooks/use_breakpoints.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
|
|
||||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||||
import 'package:spotube/services/queries/queries.dart';
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
@ -23,11 +23,11 @@ class GeniusLyrics extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
Playback playback = ref.watch(playbackProvider);
|
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||||
final geniusLyricsQuery = useQuery(
|
final geniusLyricsQuery = useQuery(
|
||||||
job: Queries.lyrics.static(playback.track?.id ?? ""),
|
job: Queries.lyrics.static(playlist?.activeTrack.id ?? ""),
|
||||||
externalData: Tuple2(
|
externalData: Tuple2(
|
||||||
playback.track,
|
playlist?.activeTrack,
|
||||||
ref.watch(userPreferencesProvider).geniusAccessToken,
|
ref.watch(userPreferencesProvider).geniusAccessToken,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -40,7 +40,7 @@ class GeniusLyrics extends HookConsumerWidget {
|
|||||||
if (isModal != true) ...[
|
if (isModal != true) ...[
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
playback.track?.name ?? "",
|
playlist?.activeTrack.name ?? "",
|
||||||
style: breakpoint >= Breakpoints.md
|
style: breakpoint >= Breakpoints.md
|
||||||
? textTheme.headline3
|
? textTheme.headline3
|
||||||
: textTheme.headline4?.copyWith(
|
: textTheme.headline4?.copyWith(
|
||||||
@ -52,7 +52,7 @@ class GeniusLyrics extends HookConsumerWidget {
|
|||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
TypeConversionUtils.artists_X_String<Artist>(
|
TypeConversionUtils.artists_X_String<Artist>(
|
||||||
playback.track?.artists ?? []),
|
playlist?.activeTrack.artists ?? []),
|
||||||
style: (breakpoint >= Breakpoints.md
|
style: (breakpoint >= Breakpoints.md
|
||||||
? textTheme.headline5
|
? textTheme.headline5
|
||||||
: textTheme.headline6)
|
: textTheme.headline6)
|
||||||
@ -72,7 +72,7 @@ class GeniusLyrics extends HookConsumerWidget {
|
|||||||
return const ShimmerLyrics();
|
return const ShimmerLyrics();
|
||||||
} else if (geniusLyricsQuery.hasError) {
|
} else if (geniusLyricsQuery.hasError) {
|
||||||
return Text(
|
return Text(
|
||||||
"Sorry, no Lyrics were found for `${playback.track?.name}` :'(\n${geniusLyricsQuery.error.toString()}",
|
"Sorry, no Lyrics were found for `${playlist?.activeTrack.name}` :'(\n${geniusLyricsQuery.error.toString()}",
|
||||||
style: textTheme.bodyText1?.copyWith(
|
style: textTheme.bodyText1?.copyWith(
|
||||||
color: palette.bodyTextColor,
|
color: palette.bodyTextColor,
|
||||||
),
|
),
|
||||||
@ -82,12 +82,11 @@ class GeniusLyrics extends HookConsumerWidget {
|
|||||||
final lyrics = geniusLyricsQuery.data;
|
final lyrics = geniusLyricsQuery.data;
|
||||||
|
|
||||||
return Text(
|
return Text(
|
||||||
lyrics == null && playback.track == null
|
lyrics == null && playlist?.activeTrack == null
|
||||||
? "No Track being played currently"
|
? "No Track being played currently"
|
||||||
: lyrics ?? "",
|
: lyrics ?? "",
|
||||||
style: textTheme.headline6?.copyWith(
|
style:
|
||||||
color: palette.bodyTextColor,
|
TextStyle(color: palette.bodyTextColor, fontSize: 18),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -11,7 +11,7 @@ import 'package:spotube/hooks/use_custom_status_bar_color.dart';
|
|||||||
import 'package:spotube/hooks/use_palette_color.dart';
|
import 'package:spotube/hooks/use_palette_color.dart';
|
||||||
import 'package:spotube/pages/lyrics/genius_lyrics.dart';
|
import 'package:spotube/pages/lyrics/genius_lyrics.dart';
|
||||||
import 'package:spotube/pages/lyrics/synced_lyrics.dart';
|
import 'package:spotube/pages/lyrics/synced_lyrics.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/utils/platform.dart';
|
import 'package:spotube/utils/platform.dart';
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
|
|
||||||
@ -21,14 +21,14 @@ class LyricsPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
Playback playback = ref.watch(playbackProvider);
|
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||||
String albumArt = useMemoized(
|
String albumArt = useMemoized(
|
||||||
() => TypeConversionUtils.image_X_UrlString(
|
() => TypeConversionUtils.image_X_UrlString(
|
||||||
playback.track?.album?.images,
|
playlist?.activeTrack.album?.images,
|
||||||
index: (playback.track?.album?.images?.length ?? 1) - 1,
|
index: (playlist?.activeTrack.album?.images?.length ?? 1) - 1,
|
||||||
placeholder: ImagePlaceholder.albumArt,
|
placeholder: ImagePlaceholder.albumArt,
|
||||||
),
|
),
|
||||||
[playback.track?.album?.images],
|
[playlist?.activeTrack.album?.images],
|
||||||
);
|
);
|
||||||
final palette = usePaletteColor(albumArt, ref);
|
final palette = usePaletteColor(albumArt, ref);
|
||||||
final index = useState(0);
|
final index = useState(0);
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import 'package:fl_query_hooks/fl_query_hooks.dart';
|
import 'package:fl_query/fl_query.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
@ -12,8 +12,10 @@ import 'package:spotube/components/lyrics/lyric_delay_adjust_dialog.dart';
|
|||||||
import 'package:spotube/hooks/use_auto_scroll_controller.dart';
|
import 'package:spotube/hooks/use_auto_scroll_controller.dart';
|
||||||
import 'package:spotube/hooks/use_breakpoints.dart';
|
import 'package:spotube/hooks/use_breakpoints.dart';
|
||||||
import 'package:spotube/hooks/use_synced_lyrics.dart';
|
import 'package:spotube/hooks/use_synced_lyrics.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
import 'package:spotube/models/lyrics.dart';
|
||||||
|
import 'package:spotube/models/spotube_track.dart';
|
||||||
import 'package:scroll_to_index/scroll_to_index.dart';
|
import 'package:scroll_to_index/scroll_to_index.dart';
|
||||||
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/services/queries/queries.dart';
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
|
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
@ -36,15 +38,34 @@ class SyncedLyrics extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
Playback playback = ref.watch(playbackProvider);
|
final playlist = ref.watch(PlaylistQueueNotifier.provider);
|
||||||
final timedLyricsQuery = useQuery(
|
|
||||||
job: Queries.lyrics.synced(playback.track?.id ?? ""),
|
|
||||||
externalData: playback.track,
|
|
||||||
);
|
|
||||||
final lyricDelay = ref.watch(lyricDelayState);
|
final lyricDelay = ref.watch(lyricDelayState);
|
||||||
|
|
||||||
final breakpoint = useBreakpoints();
|
final breakpoint = useBreakpoints();
|
||||||
final controller = useAutoScrollController();
|
final controller = useAutoScrollController();
|
||||||
|
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
controller.scrollToIndex(0);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
ref.read(lyricDelayState.notifier).state = Duration.zero;
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}, [playlist?.activeTrack]);
|
||||||
|
|
||||||
|
final headlineTextStyle = (breakpoint >= Breakpoints.md
|
||||||
|
? textTheme.headline3
|
||||||
|
: textTheme.headline4?.copyWith(fontSize: 25))
|
||||||
|
?.copyWith(color: palette.titleTextColor);
|
||||||
|
|
||||||
|
return QueryBuilder<SubtitleSimple, SpotubeTrack?>(
|
||||||
|
job: Queries.lyrics.synced(playlist?.activeTrack.id ?? ""),
|
||||||
|
externalData: playlist?.isLoading == true
|
||||||
|
? playlist?.activeTrack as SpotubeTrack
|
||||||
|
: null,
|
||||||
|
builder: (context, timedLyricsQuery) {
|
||||||
|
return HookBuilder(builder: (context) {
|
||||||
final lyricValue = timedLyricsQuery.data;
|
final lyricValue = timedLyricsQuery.data;
|
||||||
final lyricsMap = useMemoized(
|
final lyricsMap = useMemoized(
|
||||||
() =>
|
() =>
|
||||||
@ -55,24 +76,7 @@ class SyncedLyrics extends HookConsumerWidget {
|
|||||||
{},
|
{},
|
||||||
[lyricValue],
|
[lyricValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
final currentTime = useSyncedLyrics(ref, lyricsMap, lyricDelay);
|
final currentTime = useSyncedLyrics(ref, lyricsMap, lyricDelay);
|
||||||
|
|
||||||
final textTheme = Theme.of(context).textTheme;
|
|
||||||
|
|
||||||
useEffect(() {
|
|
||||||
controller.scrollToIndex(0);
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
ref.read(lyricDelayState.notifier).state = Duration.zero;
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}, [playback.track]);
|
|
||||||
|
|
||||||
final headlineTextStyle = (breakpoint >= Breakpoints.md
|
|
||||||
? textTheme.headline3
|
|
||||||
: textTheme.headline4?.copyWith(fontSize: 25))
|
|
||||||
?.copyWith(color: palette.titleTextColor);
|
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Column(
|
||||||
@ -80,7 +84,7 @@ class SyncedLyrics extends HookConsumerWidget {
|
|||||||
if (isModal != true)
|
if (isModal != true)
|
||||||
Center(
|
Center(
|
||||||
child: SpotubeMarqueeText(
|
child: SpotubeMarqueeText(
|
||||||
text: playback.track?.name ?? "Not Playing",
|
text: playlist?.activeTrack.name ?? "Not Playing",
|
||||||
style: headlineTextStyle,
|
style: headlineTextStyle,
|
||||||
isHovering: true,
|
isHovering: true,
|
||||||
),
|
),
|
||||||
@ -89,7 +93,7 @@ class SyncedLyrics extends HookConsumerWidget {
|
|||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
TypeConversionUtils.artists_X_String<Artist>(
|
TypeConversionUtils.artists_X_String<Artist>(
|
||||||
playback.track?.artists ?? []),
|
playlist?.activeTrack.artists ?? []),
|
||||||
style: breakpoint >= Breakpoints.md
|
style: breakpoint >= Breakpoints.md
|
||||||
? textTheme.headline5
|
? textTheme.headline5
|
||||||
: textTheme.headline6,
|
: textTheme.headline6,
|
||||||
@ -102,7 +106,8 @@ class SyncedLyrics extends HookConsumerWidget {
|
|||||||
itemCount: lyricValue.lyrics.length,
|
itemCount: lyricValue.lyrics.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final lyricSlice = lyricValue.lyrics[index];
|
final lyricSlice = lyricValue.lyrics[index];
|
||||||
final isActive = lyricSlice.time.inSeconds == currentTime;
|
final isActive =
|
||||||
|
lyricSlice.time.inSeconds == currentTime;
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
controller.scrollToIndex(
|
controller.scrollToIndex(
|
||||||
@ -120,7 +125,8 @@ class SyncedLyrics extends HookConsumerWidget {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: AnimatedDefaultTextStyle(
|
child: AnimatedDefaultTextStyle(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration:
|
||||||
|
const Duration(milliseconds: 250),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isActive
|
color: isActive
|
||||||
? Colors.white
|
? Colors.white
|
||||||
@ -142,8 +148,9 @@ class SyncedLyrics extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (playback.track != null &&
|
if (playlist?.activeTrack != null &&
|
||||||
(lyricValue == null || lyricValue.lyrics.isEmpty == true))
|
(lyricValue == null ||
|
||||||
|
lyricValue.lyrics.isEmpty == true))
|
||||||
const Expanded(child: ShimmerLyrics()),
|
const Expanded(child: ShimmerLyrics()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -171,5 +178,7 @@ class SyncedLyrics extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,8 +16,9 @@ import 'package:spotube/components/shared/image/universal_image.dart';
|
|||||||
import 'package:spotube/hooks/use_breakpoints.dart';
|
import 'package:spotube/hooks/use_breakpoints.dart';
|
||||||
import 'package:spotube/hooks/use_custom_status_bar_color.dart';
|
import 'package:spotube/hooks/use_custom_status_bar_color.dart';
|
||||||
import 'package:spotube/hooks/use_palette_color.dart';
|
import 'package:spotube/hooks/use_palette_color.dart';
|
||||||
|
import 'package:spotube/models/local_track.dart';
|
||||||
import 'package:spotube/pages/lyrics/lyrics.dart';
|
import 'package:spotube/pages/lyrics/lyrics.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
|
|
||||||
@ -28,11 +29,11 @@ class PlayerView extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
final currentTrack = ref.watch(playbackProvider.select(
|
final currentTrack = ref.watch(PlaylistQueueNotifier.provider.select(
|
||||||
(value) => value.track,
|
(value) => value?.activeTrack,
|
||||||
));
|
));
|
||||||
final isLocalTrack = ref.watch(playbackProvider.select(
|
final isLocalTrack = ref.watch(PlaylistQueueNotifier.provider.select(
|
||||||
(value) => value.playlist?.isLocal == true,
|
(value) => value?.activeTrack is LocalTrack,
|
||||||
));
|
));
|
||||||
final breakpoint = useBreakpoints();
|
final breakpoint = useBreakpoints();
|
||||||
final canRotate = ref.watch(
|
final canRotate = ref.watch(
|
||||||
|
@ -6,12 +6,11 @@ import 'package:spotube/components/shared/heart_button.dart';
|
|||||||
import 'package:spotube/components/shared/track_table/track_collection_view.dart';
|
import 'package:spotube/components/shared/track_table/track_collection_view.dart';
|
||||||
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
|
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
|
||||||
import 'package:spotube/hooks/use_breakpoints.dart';
|
import 'package:spotube/hooks/use_breakpoints.dart';
|
||||||
import 'package:spotube/models/current_playlist.dart';
|
|
||||||
import 'package:spotube/models/logger.dart';
|
import 'package:spotube/models/logger.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/provider/spotify_provider.dart';
|
import 'package:spotube/provider/spotify_provider.dart';
|
||||||
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/services/queries/queries.dart';
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
|
|
||||||
import 'package:spotube/utils/service_utils.dart';
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
@ -23,7 +22,7 @@ class PlaylistView extends HookConsumerWidget {
|
|||||||
PlaylistView(this.playlist, {Key? key}) : super(key: key);
|
PlaylistView(this.playlist, {Key? key}) : super(key: key);
|
||||||
|
|
||||||
Future<void> playPlaylist(
|
Future<void> playPlaylist(
|
||||||
Playback playback,
|
PlaylistQueueNotifier playlistNotifier,
|
||||||
List<Track> tracks,
|
List<Track> tracks,
|
||||||
WidgetRef ref, {
|
WidgetRef ref, {
|
||||||
Track? currentTrack,
|
Track? currentTrack,
|
||||||
@ -31,34 +30,24 @@ class PlaylistView extends HookConsumerWidget {
|
|||||||
final sortBy = ref.read(trackCollectionSortState(playlist.id!));
|
final sortBy = ref.read(trackCollectionSortState(playlist.id!));
|
||||||
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
|
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
|
||||||
currentTrack ??= sortedTracks.first;
|
currentTrack ??= sortedTracks.first;
|
||||||
final isPlaylistPlaying =
|
final isPlaylistPlaying = playlistNotifier.isPlayingPlaylist(tracks);
|
||||||
playback.playlist?.id != null && playback.playlist?.id == playlist.id;
|
|
||||||
if (!isPlaylistPlaying) {
|
if (!isPlaylistPlaying) {
|
||||||
await playback.playPlaylist(
|
await playlistNotifier.loadAndPlay(
|
||||||
CurrentPlaylist(
|
sortedTracks,
|
||||||
tracks: sortedTracks,
|
active: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||||
id: playlist.id!,
|
|
||||||
name: playlist.name!,
|
|
||||||
thumbnail: TypeConversionUtils.image_X_UrlString(
|
|
||||||
playlist.images,
|
|
||||||
placeholder: ImagePlaceholder.collection,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
|
|
||||||
);
|
);
|
||||||
} else if (isPlaylistPlaying &&
|
} else if (isPlaylistPlaying &&
|
||||||
currentTrack.id != null &&
|
currentTrack.id != null &&
|
||||||
currentTrack.id != playback.track?.id) {
|
currentTrack.id != playlistNotifier.state?.activeTrack.id) {
|
||||||
await playback.play(currentTrack);
|
await playlistNotifier.playTrack(currentTrack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
Playback playback = ref.watch(playbackProvider);
|
final playlistQueue = ref.watch(PlaylistQueueNotifier.provider);
|
||||||
|
final playlistNotifier = ref.watch(PlaylistQueueNotifier.notifier);
|
||||||
SpotifyApi spotify = ref.watch(spotifyProvider);
|
SpotifyApi spotify = ref.watch(spotifyProvider);
|
||||||
final isPlaylistPlaying =
|
|
||||||
playback.playlist?.id != null && playback.playlist?.id == playlist.id;
|
|
||||||
|
|
||||||
final breakpoint = useBreakpoints();
|
final breakpoint = useBreakpoints();
|
||||||
|
|
||||||
@ -68,6 +57,9 @@ class PlaylistView extends HookConsumerWidget {
|
|||||||
externalData: spotify,
|
externalData: spotify,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final isPlaylistPlaying =
|
||||||
|
playlistNotifier.isPlayingPlaylist(tracksSnapshot.data ?? []);
|
||||||
|
|
||||||
final titleImage = useMemoized(
|
final titleImage = useMemoized(
|
||||||
() => TypeConversionUtils.image_X_UrlString(
|
() => TypeConversionUtils.image_X_UrlString(
|
||||||
playlist.images,
|
playlist.images,
|
||||||
@ -88,20 +80,20 @@ class PlaylistView extends HookConsumerWidget {
|
|||||||
if (tracksSnapshot.hasData) {
|
if (tracksSnapshot.hasData) {
|
||||||
if (!isPlaylistPlaying) {
|
if (!isPlaylistPlaying) {
|
||||||
playPlaylist(
|
playPlaylist(
|
||||||
playback,
|
playlistNotifier,
|
||||||
tracksSnapshot.data!,
|
tracksSnapshot.data!,
|
||||||
ref,
|
ref,
|
||||||
currentTrack: track,
|
currentTrack: track,
|
||||||
);
|
);
|
||||||
} else if (isPlaylistPlaying && track != null) {
|
} else if (isPlaylistPlaying && track != null) {
|
||||||
playPlaylist(
|
playPlaylist(
|
||||||
playback,
|
playlistNotifier,
|
||||||
tracksSnapshot.data!,
|
tracksSnapshot.data!,
|
||||||
ref,
|
ref,
|
||||||
currentTrack: track,
|
currentTrack: track,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
playback.stop();
|
playlistNotifier.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -132,20 +124,20 @@ class PlaylistView extends HookConsumerWidget {
|
|||||||
if (tracksSnapshot.hasData) {
|
if (tracksSnapshot.hasData) {
|
||||||
if (!isPlaylistPlaying) {
|
if (!isPlaylistPlaying) {
|
||||||
playPlaylist(
|
playPlaylist(
|
||||||
playback,
|
playlistNotifier,
|
||||||
tracks,
|
tracks,
|
||||||
ref,
|
ref,
|
||||||
currentTrack: track,
|
currentTrack: track,
|
||||||
);
|
);
|
||||||
} else if (isPlaylistPlaying && track != null) {
|
} else if (isPlaylistPlaying && track != null) {
|
||||||
playPlaylist(
|
playPlaylist(
|
||||||
playback,
|
playlistNotifier,
|
||||||
tracks,
|
tracks,
|
||||||
ref,
|
ref,
|
||||||
currentTrack: track,
|
currentTrack: track,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
playback.stop();
|
playlistNotifier.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -15,10 +15,9 @@ import 'package:spotube/components/shared/waypoint.dart';
|
|||||||
import 'package:spotube/components/artist/artist_card.dart';
|
import 'package:spotube/components/artist/artist_card.dart';
|
||||||
import 'package:spotube/components/playlist/playlist_card.dart';
|
import 'package:spotube/components/playlist/playlist_card.dart';
|
||||||
import 'package:spotube/hooks/use_breakpoints.dart';
|
import 'package:spotube/hooks/use_breakpoints.dart';
|
||||||
import 'package:spotube/models/current_playlist.dart';
|
|
||||||
import 'package:spotube/provider/auth_provider.dart';
|
import 'package:spotube/provider/auth_provider.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
|
||||||
import 'package:spotube/provider/spotify_provider.dart';
|
import 'package:spotube/provider/spotify_provider.dart';
|
||||||
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/services/queries/queries.dart';
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
|
|
||||||
import 'package:spotube/utils/platform.dart';
|
import 'package:spotube/utils/platform.dart';
|
||||||
@ -107,7 +106,10 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
HookBuilder(
|
HookBuilder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
Playback playback = ref.watch(playbackProvider);
|
final playlist =
|
||||||
|
ref.watch(PlaylistQueueNotifier.provider);
|
||||||
|
final playlistNotifier =
|
||||||
|
ref.watch(PlaylistQueueNotifier.notifier);
|
||||||
List<AlbumSimple> albums = [];
|
List<AlbumSimple> albums = [];
|
||||||
List<Artist> artists = [];
|
List<Artist> artists = [];
|
||||||
List<Track> tracks = [];
|
List<Track> tracks = [];
|
||||||
@ -154,36 +156,19 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
String duration =
|
String duration =
|
||||||
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
|
"${track.value.duration?.inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(track.value.duration?.inSeconds.remainder(60) ?? 0)}";
|
||||||
return TrackTile(
|
return TrackTile(
|
||||||
playback,
|
playlist,
|
||||||
track: track,
|
track: track,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
isActive:
|
isActive: playlist?.activeTrack.id ==
|
||||||
playback.track?.id == track.value.id,
|
track.value.id,
|
||||||
onTrackPlayButtonPressed:
|
onTrackPlayButtonPressed:
|
||||||
(currentTrack) async {
|
(currentTrack) async {
|
||||||
var isPlaylistPlaying =
|
final isTrackPlaying =
|
||||||
playback.playlist?.id != null &&
|
playlist?.activeTrack.id !=
|
||||||
playback.playlist?.id ==
|
|
||||||
currentTrack.id;
|
currentTrack.id;
|
||||||
if (!isPlaylistPlaying) {
|
if (!isTrackPlaying) {
|
||||||
playback.playPlaylist(
|
await playlistNotifier
|
||||||
CurrentPlaylist(
|
.loadAndPlay([currentTrack]);
|
||||||
tracks: [currentTrack],
|
|
||||||
id: currentTrack.id!,
|
|
||||||
name: currentTrack.name!,
|
|
||||||
thumbnail: TypeConversionUtils
|
|
||||||
.image_X_UrlString(
|
|
||||||
currentTrack.album?.images,
|
|
||||||
placeholder:
|
|
||||||
ImagePlaceholder.albumArt,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (isPlaylistPlaying &&
|
|
||||||
currentTrack.id != null &&
|
|
||||||
currentTrack.id !=
|
|
||||||
playback.track?.id) {
|
|
||||||
playback.play(currentTrack);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -14,9 +14,7 @@ import 'package:spotube/main.dart';
|
|||||||
import 'package:spotube/collections/spotify_markets.dart';
|
import 'package:spotube/collections/spotify_markets.dart';
|
||||||
import 'package:spotube/models/spotube_track.dart';
|
import 'package:spotube/models/spotube_track.dart';
|
||||||
import 'package:spotube/provider/auth_provider.dart';
|
import 'package:spotube/provider/auth_provider.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
|
||||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||||
import 'package:spotube/utils/platform.dart';
|
|
||||||
import 'package:url_launcher/url_launcher_string.dart';
|
import 'package:url_launcher/url_launcher_string.dart';
|
||||||
|
|
||||||
class SettingsPage extends HookConsumerWidget {
|
class SettingsPage extends HookConsumerWidget {
|
||||||
@ -309,7 +307,6 @@ class SettingsPage extends HookConsumerWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (kIsMobile)
|
|
||||||
PlatformListTile(
|
PlatformListTile(
|
||||||
leading: const Icon(SpotubeIcons.download),
|
leading: const Icon(SpotubeIcons.download),
|
||||||
title: const PlatformText(
|
title: const PlatformText(
|
||||||
@ -319,9 +316,9 @@ class SettingsPage extends HookConsumerWidget {
|
|||||||
"Instead of streaming audio, download bytes and play instead (Recommended for higher bandwidth users)",
|
"Instead of streaming audio, download bytes and play instead (Recommended for higher bandwidth users)",
|
||||||
),
|
),
|
||||||
trailing: PlatformSwitch(
|
trailing: PlatformSwitch(
|
||||||
value: preferences.androidBytesPlay,
|
value: preferences.predownload,
|
||||||
onChanged: (state) {
|
onChanged: (state) {
|
||||||
preferences.setAndroidBytesPlay(state);
|
preferences.setPredownload(state);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/models/current_playlist.dart';
|
import 'package:spotube/models/current_playlist.dart';
|
||||||
import 'package:spotube/utils/persisted_state_notifier.dart';
|
import 'package:spotube/utils/persisted_state_notifier.dart';
|
||||||
|
|
||||||
@ -39,7 +40,7 @@ class BlacklistedElement {
|
|||||||
|
|
||||||
class BlackListNotifier
|
class BlackListNotifier
|
||||||
extends PersistedStateNotifier<Set<BlacklistedElement>> {
|
extends PersistedStateNotifier<Set<BlacklistedElement>> {
|
||||||
BlackListNotifier() : super({});
|
BlackListNotifier() : super({}, "blacklist");
|
||||||
|
|
||||||
static final provider =
|
static final provider =
|
||||||
StateNotifierProvider<BlackListNotifier, Set<BlacklistedElement>>(
|
StateNotifierProvider<BlackListNotifier, Set<BlacklistedElement>>(
|
||||||
@ -54,6 +55,20 @@ class BlackListNotifier
|
|||||||
state = state.difference({element});
|
state = state.difference({element});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Iterable<TrackSimple> filter(Iterable<TrackSimple> tracks) {
|
||||||
|
return tracks.where(
|
||||||
|
(track) {
|
||||||
|
return !state
|
||||||
|
.contains(BlacklistedElement.track(track.id!, track.name!)) &&
|
||||||
|
!(track.artists ?? []).any(
|
||||||
|
(artist) => state.contains(
|
||||||
|
BlacklistedElement.artist(artist.id!, artist.name!),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
).toList();
|
||||||
|
}
|
||||||
|
|
||||||
CurrentPlaylist filterPlaylist(CurrentPlaylist playlist) {
|
CurrentPlaylist filterPlaylist(CurrentPlaylist playlist) {
|
||||||
return CurrentPlaylist(
|
return CurrentPlaylist(
|
||||||
id: playlist.id,
|
id: playlist.id,
|
||||||
|
@ -12,7 +12,6 @@ import 'package:spotube/components/shared/dialogs/replace_downloaded_dialog.dart
|
|||||||
|
|
||||||
import 'package:spotube/models/logger.dart';
|
import 'package:spotube/models/logger.dart';
|
||||||
import 'package:spotube/models/spotube_track.dart';
|
import 'package:spotube/models/spotube_track.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
|
||||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||||
import 'package:spotube/provider/youtube_provider.dart';
|
import 'package:spotube/provider/youtube_provider.dart';
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
@ -41,7 +40,7 @@ class Downloader with ChangeNotifier {
|
|||||||
|
|
||||||
final logger = getLogger(Downloader);
|
final logger = getLogger(Downloader);
|
||||||
|
|
||||||
Playback get _playback => ref.read(playbackProvider);
|
// Playback get _playback => ref.read(playbackProvider);
|
||||||
|
|
||||||
void addToQueue(Track baseTrack) async {
|
void addToQueue(Track baseTrack) async {
|
||||||
if (kIsWeb) return;
|
if (kIsWeb) return;
|
||||||
@ -51,13 +50,13 @@ class Downloader with ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
// Using android Audio Focus to keep the app run in background
|
// Using android Audio Focus to keep the app run in background
|
||||||
_playback.mobileAudioService?.session?.setActive(true);
|
// _playback.mobileAudioService?.session?.setActive(true);
|
||||||
grabberQueue.add(() async {
|
grabberQueue.add(() async {
|
||||||
final track = (await ref.read(playbackProvider).toSpotubeTrack(
|
final track = await SpotubeTrack.fromFetchTrack(
|
||||||
baseTrack,
|
baseTrack,
|
||||||
noSponsorBlock: true,
|
ref.read(userPreferencesProvider),
|
||||||
))
|
);
|
||||||
.item1;
|
|
||||||
_queue.add(() async {
|
_queue.add(() async {
|
||||||
final cleanTitle = track.ytTrack.title.replaceAll(
|
final cleanTitle = track.ytTrack.title.replaceAll(
|
||||||
RegExp(r'[/\\?%*:|"<>]'),
|
RegExp(r'[/\\?%*:|"<>]'),
|
||||||
@ -140,9 +139,9 @@ class Downloader with ChangeNotifier {
|
|||||||
} finally {
|
} finally {
|
||||||
currentlyRunning--;
|
currentlyRunning--;
|
||||||
inQueue.removeWhere((t) => t.id == track.id);
|
inQueue.removeWhere((t) => t.id == track.id);
|
||||||
if (currentlyRunning == 0 && !_playback.isPlaying) {
|
// if (currentlyRunning == 0 && !PlaylistProvider.isPlaying) {
|
||||||
_playback.mobileAudioService?.session?.setActive(false);
|
// _playback.mobileAudioService?.session?.setActive(false);
|
||||||
}
|
// }
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -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,
|
|
||||||
);
|
|
||||||
});
|
|
506
lib/provider/playlist_queue_provider.dart
Normal file
506
lib/provider/playlist_queue_provider.dart
Normal 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};
|
||||||
|
}
|
||||||
|
}
|
@ -6,7 +6,6 @@ import 'package:path_provider/path_provider.dart';
|
|||||||
import 'package:spotube/components/settings/color_scheme_picker_dialog.dart';
|
import 'package:spotube/components/settings/color_scheme_picker_dialog.dart';
|
||||||
import 'package:spotube/models/spotube_track.dart';
|
import 'package:spotube/models/spotube_track.dart';
|
||||||
import 'package:spotube/models/generated_secrets.dart';
|
import 'package:spotube/models/generated_secrets.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
|
||||||
import 'package:spotube/utils/persisted_change_notifier.dart';
|
import 'package:spotube/utils/persisted_change_notifier.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:spotube/utils/platform.dart';
|
import 'package:spotube/utils/platform.dart';
|
||||||
@ -19,6 +18,11 @@ enum LayoutMode {
|
|||||||
adaptive,
|
adaptive,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AudioQuality {
|
||||||
|
high,
|
||||||
|
low,
|
||||||
|
}
|
||||||
|
|
||||||
class UserPreferences extends PersistedChangeNotifier {
|
class UserPreferences extends PersistedChangeNotifier {
|
||||||
ThemeMode themeMode;
|
ThemeMode themeMode;
|
||||||
String ytSearchFormat;
|
String ytSearchFormat;
|
||||||
@ -38,7 +42,7 @@ class UserPreferences extends PersistedChangeNotifier {
|
|||||||
LayoutMode layoutMode;
|
LayoutMode layoutMode;
|
||||||
bool rotatingAlbumArt;
|
bool rotatingAlbumArt;
|
||||||
|
|
||||||
bool androidBytesPlay;
|
bool predownload;
|
||||||
|
|
||||||
UserPreferences({
|
UserPreferences({
|
||||||
required this.geniusAccessToken,
|
required this.geniusAccessToken,
|
||||||
@ -46,7 +50,7 @@ class UserPreferences extends PersistedChangeNotifier {
|
|||||||
required this.themeMode,
|
required this.themeMode,
|
||||||
required this.ytSearchFormat,
|
required this.ytSearchFormat,
|
||||||
required this.layoutMode,
|
required this.layoutMode,
|
||||||
this.androidBytesPlay = true,
|
required this.predownload,
|
||||||
this.saveTrackLyrics = false,
|
this.saveTrackLyrics = false,
|
||||||
this.accentColorScheme = Colors.green,
|
this.accentColorScheme = Colors.green,
|
||||||
this.backgroundColorScheme = Colors.grey,
|
this.backgroundColorScheme = Colors.grey,
|
||||||
@ -66,9 +70,10 @@ class UserPreferences extends PersistedChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void setAndroidBytesPlay(bool value) {
|
void setPredownload(bool value) {
|
||||||
androidBytesPlay = value;
|
predownload = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
updatePersistence();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setThemeMode(ThemeMode mode) {
|
void setThemeMode(ThemeMode mode) {
|
||||||
@ -199,7 +204,7 @@ class UserPreferences extends PersistedChangeNotifier {
|
|||||||
orElse: () => kIsDesktop ? LayoutMode.extended : LayoutMode.compact,
|
orElse: () => kIsDesktop ? LayoutMode.extended : LayoutMode.compact,
|
||||||
);
|
);
|
||||||
rotatingAlbumArt = map["rotatingAlbumArt"] ?? rotatingAlbumArt;
|
rotatingAlbumArt = map["rotatingAlbumArt"] ?? rotatingAlbumArt;
|
||||||
androidBytesPlay = map["androidBytesPlay"] ?? androidBytesPlay;
|
predownload = map["predownload"] ?? predownload;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -219,7 +224,7 @@ class UserPreferences extends PersistedChangeNotifier {
|
|||||||
"downloadLocation": downloadLocation,
|
"downloadLocation": downloadLocation,
|
||||||
"layoutMode": layoutMode.name,
|
"layoutMode": layoutMode.name,
|
||||||
"rotatingAlbumArt": rotatingAlbumArt,
|
"rotatingAlbumArt": rotatingAlbumArt,
|
||||||
"androidBytesPlay": androidBytesPlay,
|
"predownload": predownload,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -231,5 +236,6 @@ final userPreferencesProvider = ChangeNotifierProvider(
|
|||||||
themeMode: ThemeMode.system,
|
themeMode: ThemeMode.system,
|
||||||
ytSearchFormat: "\$MAIN_ARTIST - \$TITLE \$FEATURED_ARTISTS",
|
ytSearchFormat: "\$MAIN_ARTIST - \$TITLE \$FEATURED_ARTISTS",
|
||||||
layoutMode: kIsMobile ? LayoutMode.compact : LayoutMode.adaptive,
|
layoutMode: kIsMobile ? LayoutMode.compact : LayoutMode.adaptive,
|
||||||
|
predownload: kIsMobile,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:dbus/dbus.dart';
|
import 'package:dbus/dbus.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import 'package:spotube/provider/dbus_provider.dart';
|
import 'package:spotube/provider/dbus_provider.dart';
|
||||||
import 'package:spotube/models/spotube_track.dart';
|
import 'package:spotube/models/spotube_track.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
import 'package:window_manager/window_manager.dart';
|
import 'package:window_manager/window_manager.dart';
|
||||||
|
|
||||||
@ -217,12 +218,12 @@ class _MprisMediaPlayer2 extends DBusObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MprisMediaPlayer2Player extends DBusObject {
|
class _MprisMediaPlayer2Player extends DBusObject {
|
||||||
Playback playback;
|
final Ref ref;
|
||||||
|
final PlaylistQueueNotifier playlistNotifier;
|
||||||
|
|
||||||
/// Creates a new object to expose on [path].
|
/// Creates a new object to expose on [path].
|
||||||
_MprisMediaPlayer2Player({
|
_MprisMediaPlayer2Player(this.ref, this.playlistNotifier)
|
||||||
required this.playback,
|
: super(DBusObjectPath("/org/mpris/MediaPlayer2")) {
|
||||||
}) : super(DBusObjectPath("/org/mpris/MediaPlayer2")) {
|
|
||||||
(() async {
|
(() async {
|
||||||
final nameStatus =
|
final nameStatus =
|
||||||
await dbus.requestName("org.mpris.MediaPlayer2.spotube");
|
await dbus.requestName("org.mpris.MediaPlayer2.spotube");
|
||||||
@ -233,30 +234,39 @@ class _MprisMediaPlayer2Player extends DBusObject {
|
|||||||
}());
|
}());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
PlaylistQueue? get playlist => playlistNotifier.state;
|
||||||
|
double get volume => ref.read(VolumeProvider.provider);
|
||||||
|
VolumeProvider get volumeNotifier =>
|
||||||
|
ref.read(VolumeProvider.provider.notifier);
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
dbus.unregisterObject(this);
|
dbus.unregisterObject(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Player.PlaybackStatus
|
/// Gets value of property org.mpris.MediaPlayer2.Player.PlaybackStatus
|
||||||
Future<DBusMethodResponse> getPlaybackStatus() async {
|
Future<DBusMethodResponse> getPlaybackStatus() async {
|
||||||
final status = playback.isPlaying
|
final status = PlaylistQueueNotifier.isPlaying
|
||||||
? "Playing"
|
? "Playing"
|
||||||
: playback.playlist == null
|
: playlist == null
|
||||||
? "Stopped"
|
? "Stopped"
|
||||||
: "Paused";
|
: "Paused";
|
||||||
return DBusMethodSuccessResponse([DBusString(status)]);
|
return DBusMethodSuccessResponse([DBusString(status)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Implement Track Loop
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Player.LoopStatus
|
/// Gets value of property org.mpris.MediaPlayer2.Player.LoopStatus
|
||||||
Future<DBusMethodResponse> getLoopStatus() async {
|
Future<DBusMethodResponse> getLoopStatus() async {
|
||||||
return DBusMethodSuccessResponse([
|
return DBusMethodSuccessResponse([
|
||||||
playback.isLoop ? const DBusString("Track") : const DBusString("None"),
|
/* playlistNotifier.isLoop */ false
|
||||||
|
? const DBusString("Track")
|
||||||
|
: const DBusString("None"),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets property org.mpris.MediaPlayer2.Player.LoopStatus
|
/// Sets property org.mpris.MediaPlayer2.Player.LoopStatus
|
||||||
Future<DBusMethodResponse> setLoopStatus(String value) async {
|
Future<DBusMethodResponse> setLoopStatus(String value) async {
|
||||||
playback.setIsLoop(value == "Track");
|
// playlistNotifier.setIsLoop(value == "Track");
|
||||||
return DBusMethodSuccessResponse();
|
return DBusMethodSuccessResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -272,46 +282,47 @@ class _MprisMediaPlayer2Player extends DBusObject {
|
|||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Player.Shuffle
|
/// Gets value of property org.mpris.MediaPlayer2.Player.Shuffle
|
||||||
Future<DBusMethodResponse> getShuffle() async {
|
Future<DBusMethodResponse> getShuffle() async {
|
||||||
return DBusMethodSuccessResponse([DBusBoolean(playback.isShuffled)]);
|
return DBusMethodSuccessResponse(
|
||||||
|
[DBusBoolean(playlist?.isShuffled ?? false)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets property org.mpris.MediaPlayer2.Player.Shuffle
|
/// Sets property org.mpris.MediaPlayer2.Player.Shuffle
|
||||||
Future<DBusMethodResponse> setShuffle(bool value) async {
|
Future<DBusMethodResponse> setShuffle(bool value) async {
|
||||||
playback.setIsShuffled(value);
|
if (value) {
|
||||||
|
playlistNotifier.shuffle();
|
||||||
|
} else {
|
||||||
|
playlistNotifier.unshuffle();
|
||||||
|
}
|
||||||
return DBusMethodSuccessResponse();
|
return DBusMethodSuccessResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Player.Metadata
|
/// Gets value of property org.mpris.MediaPlayer2.Player.Metadata
|
||||||
Future<DBusMethodResponse> getMetadata() async {
|
Future<DBusMethodResponse> getMetadata() async {
|
||||||
if (playback.track == null) {
|
if (playlist == null || playlist!.isLoading) {
|
||||||
return DBusMethodSuccessResponse([DBusDict.stringVariant({})]);
|
return DBusMethodSuccessResponse([DBusDict.stringVariant({})]);
|
||||||
}
|
}
|
||||||
final id = (playback.playlist != null
|
final id = playlist!.active;
|
||||||
? playback.playlist!.tracks.indexWhere(
|
|
||||||
(track) => playback.track!.id == track.id!,
|
|
||||||
)
|
|
||||||
: 0)
|
|
||||||
.abs();
|
|
||||||
|
|
||||||
return DBusMethodSuccessResponse([
|
return DBusMethodSuccessResponse([
|
||||||
DBusDict.stringVariant({
|
DBusDict.stringVariant({
|
||||||
"mpris:trackid": DBusString("${path.value}/Track/$id"),
|
"mpris:trackid": DBusString("${path.value}/Track/$id"),
|
||||||
"mpris:length": DBusInt32(playback.currentDuration.inMicroseconds),
|
"mpris:length":
|
||||||
|
DBusInt32((await audioPlayer.getDuration())?.inMicroseconds ?? 0),
|
||||||
"mpris:artUrl": DBusString(
|
"mpris:artUrl": DBusString(
|
||||||
TypeConversionUtils.image_X_UrlString(
|
TypeConversionUtils.image_X_UrlString(
|
||||||
playback.track?.album?.images,
|
playlist?.activeTrack.album?.images,
|
||||||
placeholder: ImagePlaceholder.albumArt,
|
placeholder: ImagePlaceholder.albumArt,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
"xesam:album": DBusString(playback.track!.album!.name!),
|
"xesam:album": DBusString(playlist!.activeTrack.album!.name!),
|
||||||
"xesam:artist": DBusArray.string(
|
"xesam:artist": DBusArray.string(
|
||||||
playback.track!.artists!.map((artist) => artist.name!),
|
playlist!.activeTrack.artists!.map((artist) => artist.name!),
|
||||||
),
|
),
|
||||||
"xesam:title": DBusString(playback.track!.name!),
|
"xesam:title": DBusString(playlist!.activeTrack.name!),
|
||||||
"xesam:url": DBusString(
|
"xesam:url": DBusString(
|
||||||
playback.track is SpotubeTrack
|
playlist!.activeTrack is SpotubeTrack
|
||||||
? (playback.track as SpotubeTrack).ytUri
|
? (playlist!.activeTrack as SpotubeTrack).ytUri
|
||||||
: playback.track!.previewUrl!,
|
: playlist!.activeTrack.previewUrl!,
|
||||||
),
|
),
|
||||||
"xesam:genre": const DBusString("Unknown"),
|
"xesam:genre": const DBusString("Unknown"),
|
||||||
}),
|
}),
|
||||||
@ -320,19 +331,19 @@ class _MprisMediaPlayer2Player extends DBusObject {
|
|||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Player.Volume
|
/// Gets value of property org.mpris.MediaPlayer2.Player.Volume
|
||||||
Future<DBusMethodResponse> getVolume() async {
|
Future<DBusMethodResponse> getVolume() async {
|
||||||
return DBusMethodSuccessResponse([DBusDouble(playback.volume)]);
|
return DBusMethodSuccessResponse([DBusDouble(volume)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sets property org.mpris.MediaPlayer2.Player.Volume
|
/// Sets property org.mpris.MediaPlayer2.Player.Volume
|
||||||
Future<DBusMethodResponse> setVolume(double value) async {
|
Future<DBusMethodResponse> setVolume(double value) async {
|
||||||
playback.setVolume(value);
|
await volumeNotifier.setVolume(value);
|
||||||
return DBusMethodSuccessResponse();
|
return DBusMethodSuccessResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets value of property org.mpris.MediaPlayer2.Player.Position
|
/// Gets value of property org.mpris.MediaPlayer2.Player.Position
|
||||||
Future<DBusMethodResponse> getPosition() async {
|
Future<DBusMethodResponse> getPosition() async {
|
||||||
return DBusMethodSuccessResponse([
|
return DBusMethodSuccessResponse([
|
||||||
DBusInt64((await playback.player.getDuration())?.inMicroseconds ?? 0),
|
DBusInt64((await audioPlayer.getDuration())?.inMicroseconds ?? 0),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -350,7 +361,7 @@ class _MprisMediaPlayer2Player extends DBusObject {
|
|||||||
Future<DBusMethodResponse> getCanGoNext() async {
|
Future<DBusMethodResponse> getCanGoNext() async {
|
||||||
return DBusMethodSuccessResponse([
|
return DBusMethodSuccessResponse([
|
||||||
DBusBoolean(
|
DBusBoolean(
|
||||||
playback.playlist?.tracks.isNotEmpty == true,
|
(playlist?.tracks.length ?? 0) > 1,
|
||||||
)
|
)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -359,7 +370,7 @@ class _MprisMediaPlayer2Player extends DBusObject {
|
|||||||
Future<DBusMethodResponse> getCanGoPrevious() async {
|
Future<DBusMethodResponse> getCanGoPrevious() async {
|
||||||
return DBusMethodSuccessResponse([
|
return DBusMethodSuccessResponse([
|
||||||
DBusBoolean(
|
DBusBoolean(
|
||||||
playback.playlist?.tracks.isNotEmpty == true,
|
(playlist?.tracks.length ?? 0) > 1,
|
||||||
)
|
)
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@ -386,43 +397,45 @@ class _MprisMediaPlayer2Player extends DBusObject {
|
|||||||
|
|
||||||
/// Implementation of org.mpris.MediaPlayer2.Player.Next()
|
/// Implementation of org.mpris.MediaPlayer2.Player.Next()
|
||||||
Future<DBusMethodResponse> doNext() async {
|
Future<DBusMethodResponse> doNext() async {
|
||||||
playback.seekForward();
|
await playlistNotifier.next();
|
||||||
return DBusMethodSuccessResponse();
|
return DBusMethodSuccessResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Implementation of org.mpris.MediaPlayer2.Player.Previous()
|
/// Implementation of org.mpris.MediaPlayer2.Player.Previous()
|
||||||
Future<DBusMethodResponse> doPrevious() async {
|
Future<DBusMethodResponse> doPrevious() async {
|
||||||
playback.seekBackward();
|
await playlistNotifier.previous();
|
||||||
return DBusMethodSuccessResponse();
|
return DBusMethodSuccessResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Implementation of org.mpris.MediaPlayer2.Player.Pause()
|
/// Implementation of org.mpris.MediaPlayer2.Player.Pause()
|
||||||
Future<DBusMethodResponse> doPause() async {
|
Future<DBusMethodResponse> doPause() async {
|
||||||
playback.pause();
|
playlistNotifier.pause();
|
||||||
return DBusMethodSuccessResponse();
|
return DBusMethodSuccessResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Implementation of org.mpris.MediaPlayer2.Player.PlayPause()
|
/// Implementation of org.mpris.MediaPlayer2.Player.PlayPause()
|
||||||
Future<DBusMethodResponse> doPlayPause() async {
|
Future<DBusMethodResponse> doPlayPause() async {
|
||||||
playback.isPlaying ? playback.pause() : playback.resume();
|
PlaylistQueueNotifier.isPlaying
|
||||||
|
? await playlistNotifier.pause()
|
||||||
|
: await playlistNotifier.resume();
|
||||||
return DBusMethodSuccessResponse();
|
return DBusMethodSuccessResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Implementation of org.mpris.MediaPlayer2.Player.Stop()
|
/// Implementation of org.mpris.MediaPlayer2.Player.Stop()
|
||||||
Future<DBusMethodResponse> doStop() async {
|
Future<DBusMethodResponse> doStop() async {
|
||||||
playback.stop();
|
playlistNotifier.stop();
|
||||||
return DBusMethodSuccessResponse();
|
return DBusMethodSuccessResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Implementation of org.mpris.MediaPlayer2.Player.Play()
|
/// Implementation of org.mpris.MediaPlayer2.Player.Play()
|
||||||
Future<DBusMethodResponse> doPlay() async {
|
Future<DBusMethodResponse> doPlay() async {
|
||||||
playback.resume();
|
playlistNotifier.resume();
|
||||||
return DBusMethodSuccessResponse();
|
return DBusMethodSuccessResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Implementation of org.mpris.MediaPlayer2.Player.Seek()
|
/// Implementation of org.mpris.MediaPlayer2.Player.Seek()
|
||||||
Future<DBusMethodResponse> doSeek(int offset) async {
|
Future<DBusMethodResponse> doSeek(int offset) async {
|
||||||
playback.seekPosition(Duration(microseconds: offset));
|
await playlistNotifier.seek(Duration(microseconds: offset));
|
||||||
return DBusMethodSuccessResponse();
|
return DBusMethodSuccessResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -445,8 +458,7 @@ class _MprisMediaPlayer2Player extends DBusObject {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateProperties(Playback playback) async {
|
Future<void> updateProperties() async {
|
||||||
this.playback = playback;
|
|
||||||
return emitPropertiesChanged(
|
return emitPropertiesChanged(
|
||||||
"org.mpris.MediaPlayer2.Player",
|
"org.mpris.MediaPlayer2.Player",
|
||||||
changedProperties: {
|
changedProperties: {
|
||||||
@ -714,9 +726,9 @@ class LinuxAudioService {
|
|||||||
_MprisMediaPlayer2 mp2;
|
_MprisMediaPlayer2 mp2;
|
||||||
_MprisMediaPlayer2Player player;
|
_MprisMediaPlayer2Player player;
|
||||||
|
|
||||||
LinuxAudioService(Playback playback)
|
LinuxAudioService(Ref ref, PlaylistQueueNotifier playlistNotifier)
|
||||||
: mp2 = _MprisMediaPlayer2(),
|
: mp2 = _MprisMediaPlayer2(),
|
||||||
player = _MprisMediaPlayer2Player(playback: playback);
|
player = _MprisMediaPlayer2Player(ref, playlistNotifier);
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
mp2.dispose();
|
mp2.dispose();
|
||||||
|
@ -3,34 +3,36 @@ import 'dart:async';
|
|||||||
import 'package:audio_service/audio_service.dart';
|
import 'package:audio_service/audio_service.dart';
|
||||||
import 'package:audio_session/audio_session.dart';
|
import 'package:audio_session/audio_session.dart';
|
||||||
import 'package:audioplayers/audioplayers.dart';
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
import 'package:spotube/provider/playback_provider.dart';
|
import 'package:spotube/provider/playlist_queue_provider.dart';
|
||||||
|
|
||||||
class MobileAudioService extends BaseAudioHandler {
|
class MobileAudioService extends BaseAudioHandler {
|
||||||
final Playback playback;
|
|
||||||
AudioSession? session;
|
AudioSession? session;
|
||||||
|
final PlaylistQueueNotifier playlistNotifier;
|
||||||
|
|
||||||
MobileAudioService(this.playback) {
|
|
||||||
|
PlaylistQueue? get playlist => playlistNotifier.state;
|
||||||
|
|
||||||
|
MobileAudioService(this.playlistNotifier) {
|
||||||
AudioSession.instance.then((s) {
|
AudioSession.instance.then((s) {
|
||||||
session = s;
|
session = s;
|
||||||
s.interruptionEventStream.listen((event) {
|
s.interruptionEventStream.listen((event) async {
|
||||||
if (event.type != AudioInterruptionType.duck) {
|
if (event.type != AudioInterruptionType.duck) {
|
||||||
playback.pause();
|
await playlistNotifier.pause();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
final player = playback.player;
|
audioPlayer.onPlayerStateChanged.listen((state) async {
|
||||||
player.onPlayerStateChanged.listen((state) async {
|
|
||||||
if (state != PlayerState.completed) {
|
if (state != PlayerState.completed) {
|
||||||
playbackState.add(await _transformEvent());
|
playbackState.add(await _transformEvent());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
player.onPositionChanged.listen((pos) async {
|
audioPlayer.onPositionChanged.listen((pos) async {
|
||||||
playbackState.add(await _transformEvent());
|
playbackState.add(await _transformEvent());
|
||||||
});
|
});
|
||||||
|
|
||||||
player.onPlayerComplete.listen((_) {
|
audioPlayer.onPlayerComplete.listen((_) {
|
||||||
if (playback.playlist == null && playback.track == null) {
|
if (playlist == null) {
|
||||||
playbackState.add(
|
playbackState.add(
|
||||||
PlaybackState(
|
PlaybackState(
|
||||||
processingState: AudioProcessingState.completed,
|
processingState: AudioProcessingState.completed,
|
||||||
@ -46,34 +48,35 @@ class MobileAudioService extends BaseAudioHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> play() => playback.resume();
|
Future<void> play() => playlistNotifier.resume();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> pause() => playback.pause();
|
Future<void> pause() => playlistNotifier.pause();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> seek(Duration position) => playback.seekPosition(position);
|
Future<void> seek(Duration position) => playlistNotifier.seek(position);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
await playback.stop();
|
await playlistNotifier.stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> skipToNext() async {
|
Future<void> skipToNext() async {
|
||||||
playback.seekForward();
|
await playlistNotifier.next();
|
||||||
await super.skipToNext();
|
await super.skipToNext();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> skipToPrevious() async {
|
Future<void> skipToPrevious() async {
|
||||||
playback.seekBackward();
|
await playlistNotifier.previous();
|
||||||
await super.skipToPrevious();
|
await super.skipToPrevious();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> onTaskRemoved() {
|
Future<void> onTaskRemoved() async {
|
||||||
playback.destroy();
|
await playlistNotifier.stop();
|
||||||
|
await audioPlayer.release();
|
||||||
return super.onTaskRemoved();
|
return super.onTaskRemoved();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,7 +84,7 @@ class MobileAudioService extends BaseAudioHandler {
|
|||||||
return PlaybackState(
|
return PlaybackState(
|
||||||
controls: [
|
controls: [
|
||||||
MediaControl.skipToPrevious,
|
MediaControl.skipToPrevious,
|
||||||
playback.player.state == PlayerState.playing
|
audioPlayer.state == PlayerState.playing
|
||||||
? MediaControl.pause
|
? MediaControl.pause
|
||||||
: MediaControl.play,
|
: MediaControl.play,
|
||||||
MediaControl.skipToNext,
|
MediaControl.skipToNext,
|
||||||
@ -91,12 +94,11 @@ class MobileAudioService extends BaseAudioHandler {
|
|||||||
MediaAction.seek,
|
MediaAction.seek,
|
||||||
},
|
},
|
||||||
androidCompactActionIndices: const [0, 1, 2],
|
androidCompactActionIndices: const [0, 1, 2],
|
||||||
playing: playback.player.state == PlayerState.playing,
|
playing: audioPlayer.state == PlayerState.playing,
|
||||||
updatePosition:
|
updatePosition: (await audioPlayer.getCurrentPosition()) ?? Duration.zero,
|
||||||
(await playback.player.getCurrentPosition()) ?? Duration.zero,
|
processingState: audioPlayer.state == PlayerState.paused
|
||||||
processingState: playback.player.state == PlayerState.paused
|
|
||||||
? AudioProcessingState.buffering
|
? AudioProcessingState.buffering
|
||||||
: playback.player.state == PlayerState.playing
|
: audioPlayer.state == PlayerState.playing
|
||||||
? AudioProcessingState.ready
|
? AudioProcessingState.ready
|
||||||
: AudioProcessingState.idle,
|
: AudioProcessingState.idle,
|
||||||
);
|
);
|
||||||
|
14
lib/services/pocketbase.dart
Normal file
14
lib/services/pocketbase.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -1,48 +1,58 @@
|
|||||||
import 'dart:convert';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:hive/hive.dart';
|
||||||
|
|
||||||
abstract class PersistedStateNotifier<T> extends StateNotifier<T> {
|
abstract class PersistedStateNotifier<T> extends StateNotifier<T> {
|
||||||
get cacheKey => state.runtimeType.toString();
|
final String cacheKey;
|
||||||
|
|
||||||
SharedPreferences? localStorage;
|
PersistedStateNotifier(super.state, this.cacheKey) {
|
||||||
|
_load();
|
||||||
PersistedStateNotifier(super.state) : super() {
|
|
||||||
SharedPreferences.getInstance().then(
|
|
||||||
(localStorage) {
|
|
||||||
this.localStorage = localStorage;
|
|
||||||
final rawState = localStorage.getString(cacheKey);
|
|
||||||
|
|
||||||
if (rawState != null) {
|
|
||||||
state = fromJson(jsonDecode(rawState));
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
Future<void> _load() async {
|
||||||
|
final box = await Hive.openLazyBox("spotube_cache");
|
||||||
|
final json = await box.get(cacheKey);
|
||||||
|
|
||||||
|
if (json != null) {
|
||||||
|
state = await fromJson(castNestedJson(json));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> castNestedJson(Map map) {
|
||||||
|
return Map.castFrom<dynamic, dynamic, String, dynamic>(
|
||||||
|
map.map((key, value) {
|
||||||
|
if (value is Map) {
|
||||||
|
return MapEntry(
|
||||||
|
key,
|
||||||
|
castNestedJson(value),
|
||||||
|
);
|
||||||
|
} else if (value is Iterable) {
|
||||||
|
return MapEntry(
|
||||||
|
key,
|
||||||
|
value.map((e) {
|
||||||
|
if (e is Map) return castNestedJson(e);
|
||||||
|
return e;
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return MapEntry(key, value);
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
T fromJson(Map<String, dynamic> json);
|
void save() async {
|
||||||
|
final box = await Hive.openLazyBox("spotube_cache");
|
||||||
|
box.put(cacheKey, toJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
FutureOr<T> fromJson(Map<String, dynamic> json);
|
||||||
Map<String, dynamic> toJson();
|
Map<String, dynamic> toJson();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
set state(T value) {
|
set state(T value) {
|
||||||
if (state == value) return;
|
if (state == value) return;
|
||||||
super.state = value;
|
super.state = value;
|
||||||
if (localStorage == null) {
|
save();
|
||||||
SharedPreferences.getInstance().then(
|
|
||||||
(localStorage) {
|
|
||||||
this.localStorage = localStorage;
|
|
||||||
localStorage.setString(
|
|
||||||
cacheKey,
|
|
||||||
jsonEncode(toJson()),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
localStorage?.setString(
|
|
||||||
cacheKey,
|
|
||||||
jsonEncode(toJson()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -41,4 +41,20 @@ abstract class PrimitiveUtils {
|
|||||||
final seconds = duration.inSeconds % 60;
|
final seconds = duration.inSeconds % 60;
|
||||||
return "${hours > 0 ? "${zeroPadNumStr(hours)}:" : ""}${zeroPadNumStr(minutes)}:${zeroPadNumStr(seconds)}";
|
return "${hours > 0 ? "${zeroPadNumStr(hours)}:" : ""}${zeroPadNumStr(minutes)}:${zeroPadNumStr(seconds)}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Future<T> raceMultiple<T>(
|
||||||
|
Future<T> Function() inner, {
|
||||||
|
Duration timeout = const Duration(milliseconds: 2500),
|
||||||
|
int retryCount = 4,
|
||||||
|
}) async {
|
||||||
|
return Future.any(
|
||||||
|
List.generate(retryCount, (i) {
|
||||||
|
if (i == 0) return inner();
|
||||||
|
return Future.delayed(
|
||||||
|
Duration(milliseconds: timeout.inMilliseconds * i),
|
||||||
|
inner,
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -146,6 +146,7 @@ abstract class TypeConversionUtils {
|
|||||||
),
|
),
|
||||||
file.path,
|
file.path,
|
||||||
[],
|
[],
|
||||||
|
[],
|
||||||
);
|
);
|
||||||
track.album = Album()
|
track.album = Album()
|
||||||
..name = metadata?.album ?? "Spotube"
|
..name = metadata?.album ?? "Spotube"
|
||||||
|
27
pubspec.lock
27
pubspec.lock
@ -93,7 +93,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.2"
|
version: "2.3.2"
|
||||||
async:
|
async:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: async
|
name: async
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
@ -562,7 +562,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.0"
|
version: "0.7.0"
|
||||||
flutter_cache_manager:
|
flutter_cache_manager:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_cache_manager
|
name: flutter_cache_manager
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
@ -575,6 +575,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.0.2"
|
version: "0.0.2"
|
||||||
|
flutter_dotenv:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_dotenv
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "5.0.2"
|
||||||
flutter_feather_icons:
|
flutter_feather_icons:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -822,12 +829,19 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.4"
|
version: "0.6.4"
|
||||||
json_annotation:
|
json_annotation:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: json_annotation
|
name: json_annotation
|
||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.8.0"
|
version: "4.8.0"
|
||||||
|
json_serializable:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: json_serializable
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "6.6.0"
|
||||||
libadwaita:
|
libadwaita:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -1103,6 +1117,13 @@ packages:
|
|||||||
url: "https://pub.dartlang.org"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.3"
|
version: "2.1.3"
|
||||||
|
pocketbase:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: pocketbase
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.1+1"
|
||||||
pointycastle:
|
pointycastle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
@ -12,6 +12,7 @@ environment:
|
|||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
adwaita: ^0.5.2
|
adwaita: ^0.5.2
|
||||||
|
async: ^2.9.0
|
||||||
audio_service: ^0.18.9
|
audio_service: ^0.18.9
|
||||||
audio_session: ^0.1.13
|
audio_session: ^0.1.13
|
||||||
audioplayers: ^3.0.1
|
audioplayers: ^3.0.1
|
||||||
@ -31,6 +32,8 @@ dependencies:
|
|||||||
fluentui_system_icons: ^1.1.189
|
fluentui_system_icons: ^1.1.189
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
|
flutter_cache_manager: ^3.3.0
|
||||||
|
flutter_dotenv: ^5.0.2
|
||||||
flutter_feather_icons: ^2.0.0+1
|
flutter_feather_icons: ^2.0.0+1
|
||||||
flutter_hooks: ^0.18.2+1
|
flutter_hooks: ^0.18.2+1
|
||||||
flutter_inappwebview: ^5.7.2+3
|
flutter_inappwebview: ^5.7.2+3
|
||||||
@ -44,6 +47,8 @@ dependencies:
|
|||||||
html: ^0.15.1
|
html: ^0.15.1
|
||||||
http: ^0.13.5
|
http: ^0.13.5
|
||||||
introduction_screen: ^3.0.2
|
introduction_screen: ^3.0.2
|
||||||
|
json_annotation: ^4.8.0
|
||||||
|
json_serializable: ^6.6.0
|
||||||
libadwaita: ^1.2.5
|
libadwaita: ^1.2.5
|
||||||
logger: ^1.1.0
|
logger: ^1.1.0
|
||||||
macos_ui: ^1.7.5
|
macos_ui: ^1.7.5
|
||||||
@ -59,6 +64,7 @@ dependencies:
|
|||||||
git:
|
git:
|
||||||
url: https://github.com/KRTirtho/platform_ui.git
|
url: https://github.com/KRTirtho/platform_ui.git
|
||||||
ref: 073cefb9c419fcb01cbdfd6ca2f9714eec23c83b
|
ref: 073cefb9c419fcb01cbdfd6ca2f9714eec23c83b
|
||||||
|
pocketbase: ^0.7.1+1
|
||||||
popover: ^0.2.6+3
|
popover: ^0.2.6+3
|
||||||
queue: ^3.1.0+1
|
queue: ^3.1.0+1
|
||||||
scroll_to_index: ^3.0.1
|
scroll_to_index: ^3.0.1
|
||||||
@ -96,6 +102,7 @@ flutter:
|
|||||||
assets:
|
assets:
|
||||||
- assets/
|
- assets/
|
||||||
- assets/tutorial/
|
- assets/tutorial/
|
||||||
|
- .env
|
||||||
|
|
||||||
flutter_icons:
|
flutter_icons:
|
||||||
android: true
|
android: true
|
||||||
|
1
server/.pocketbase
Normal file
1
server/.pocketbase
Normal file
@ -0,0 +1 @@
|
|||||||
|
version=0.12.1
|
63
server/pb_migrations/1675256468_created_tracks.js
Normal file
63
server/pb_migrations/1675256468_created_tracks.js
Normal 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);
|
||||||
|
})
|
17
server/pb_migrations/1675256557_updated_tracks.js
Normal file
17
server/pb_migrations/1675256557_updated_tracks.js
Normal 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)
|
||||||
|
})
|
19
server/pb_migrations/1675256593_updated_users.js
Normal file
19
server/pb_migrations/1675256593_updated_users.js
Normal 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)
|
||||||
|
})
|
17
server/pb_migrations/1675256678_updated_tracks.js
Normal file
17
server/pb_migrations/1675256678_updated_tracks.js
Normal 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)
|
||||||
|
})
|
17
server/pb_migrations/1675257121_updated_tracks.js
Normal file
17
server/pb_migrations/1675257121_updated_tracks.js
Normal 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)
|
||||||
|
})
|
39
server/pb_migrations/1675257148_updated_tracks.js
Normal file
39
server/pb_migrations/1675257148_updated_tracks.js
Normal 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)
|
||||||
|
})
|
Loading…
Reference in New Issue
Block a user