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