Merge branch 'dev' into circleci-project-setup

This commit is contained in:
Kingkor Roy Tirtho 2023-09-13 13:09:59 +06:00
commit 73ba9f3a3d
48 changed files with 2082 additions and 1149 deletions

View File

@ -22,12 +22,12 @@ jobs:
runs-on: ubuntu-22.04
if: contains(inputs.jobs, 'flathub')
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
repository: KRTirtho/com.github.KRTirtho.Spotube
token: ${{ secrets.FLATHUB_TOKEN }}
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
path: spotube
@ -50,7 +50,7 @@ jobs:
runs-on: ubuntu-22.04
if: contains(inputs.jobs, 'aur')
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: dsaltares/fetch-gh-release-asset@master
with:

View File

@ -32,7 +32,7 @@ jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2.10.0
with:
cache: true
@ -102,7 +102,7 @@ jobs:
linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2.10.0
with:
cache: true
@ -191,7 +191,7 @@ jobs:
android:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2.10.0
with:
cache: true
@ -266,7 +266,7 @@ jobs:
macos:
runs-on: macos-12
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2.10.0
with:
cache: true

View File

@ -1,58 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Sptube</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>spotube</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsForMedia</key>
<true/>
</dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIStatusBarHidden</key>
<false/>
</dict>
</plist>
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Sptube</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>spotube</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true />
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true />
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true />
<key>NSAllowsArbitraryLoadsForMedia</key>
<true />
</dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>UIStatusBarHidden</key>
<false />
<key>NSPhotoLibraryUsageDescription</key>
<string>This app require access to the photo library</string>
<key>NSCameraUsageDescription</key>
<string>This app require access to the device camera</string>
<key>NSMicrophoneUsageDescription</key>
<string>This app does not require access to the device microphone</string>
</dict>
</plist>

View File

@ -94,4 +94,7 @@ abstract class SpotubeIcons {
static const noWifi = FeatherIcons.wifiOff;
static const wifi = FeatherIcons.wifi;
static const window = Icons.window_rounded;
static const user = FeatherIcons.user;
static const edit = FeatherIcons.edit;
static const web = FeatherIcons.globe;
}

View File

@ -131,7 +131,7 @@ final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {
class UserLocalTracks extends HookConsumerWidget {
const UserLocalTracks({Key? key}) : super(key: key);
void playLocalTracks(
Future<void> playLocalTracks(
WidgetRef ref,
List<LocalTrack> tracks, {
LocalTrack? currentTrack,
@ -203,10 +203,10 @@ class UserLocalTracks extends HookConsumerWidget {
const SizedBox(width: 10),
FilledButton(
onPressed: trackSnapshot.value != null
? () {
? () async {
if (trackSnapshot.value?.isNotEmpty == true) {
if (!isPlaylistPlaying) {
playLocalTracks(
await playLocalTracks(
ref,
trackSnapshot.value!,
);
@ -295,8 +295,8 @@ class UserLocalTracks extends HookConsumerWidget {
index: index,
track: track,
userPlaylist: false,
onTap: () {
playLocalTracks(
onTap: () async {
await playLocalTracks(
ref,
sortedTracks,
currentTrack: track,

View File

@ -7,12 +7,12 @@ import 'package:palette_generator/palette_generator.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/collections/intents.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart';
import 'package:spotube/hooks/use_progress.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/audio_player/loop_mode.dart';
import 'package:spotube/utils/primitive_utils.dart';
class PlayerControls extends HookConsumerWidget {
final PaletteGenerator? palette;
@ -113,19 +113,6 @@ class PlayerControls extends HookConsumerWidget {
:progressStatic
) = useProgress(ref);
final totalMinutes = PrimitiveUtils.zeroPadNumStr(
duration.inMinutes.remainder(60),
);
final totalSeconds = PrimitiveUtils.zeroPadNumStr(
duration.inSeconds.remainder(60),
);
final currentMinutes = PrimitiveUtils.zeroPadNumStr(
position.inMinutes.remainder(60),
);
final currentSeconds = PrimitiveUtils.zeroPadNumStr(
position.inSeconds.remainder(60),
);
final progress = useState<num>(
useMemoized(() => progressStatic, []),
);
@ -173,8 +160,8 @@ class PlayerControls extends HookConsumerWidget {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("$currentMinutes:$currentSeconds"),
Text("$totalMinutes:$totalSeconds"),
Text(position.toHumanReadableString()),
Text(duration.toHumanReadableString()),
],
),
),

View File

@ -100,9 +100,13 @@ class PlayerOverlay extends HookConsumerWidget {
child: GestureDetector(
onTap: () =>
GoRouter.of(context).push("/player"),
child: PlayerTrackDetails(
albumArt: albumArt,
color: textColor,
child: Container(
width: double.infinity,
color: Colors.transparent,
child: PlayerTrackDetails(
albumArt: albumArt,
color: textColor,
),
),
),
),
@ -114,7 +118,9 @@ class PlayerOverlay extends HookConsumerWidget {
SpotubeIcons.skipBack,
color: textColor,
),
onPressed: playlistNotifier.previous,
onPressed: playlist.isFetching
? null
: playlistNotifier.previous,
),
Consumer(
builder: (context, ref, _) {
@ -143,7 +149,9 @@ class PlayerOverlay extends HookConsumerWidget {
SpotubeIcons.skipForward,
color: textColor,
),
onPressed: playlistNotifier.next,
onPressed: playlist.isFetching
? null
: playlistNotifier.next,
),
],
),

View File

@ -1,16 +1,21 @@
import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/fallbacks/not_found.dart';
import 'package:spotube/components/shared/track_table/track_tile.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/use_auto_scroll_controller.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlayerQueue extends HookConsumerWidget {
final bool floating;
@ -24,12 +29,11 @@ class PlayerQueue extends HookConsumerWidget {
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final controller = useAutoScrollController();
final searchText = useState('');
final isSearching = useState(false);
final tracks = playlist.tracks;
if (tracks.isEmpty) {
return const NotFound(vertical: true);
}
final borderRadius = floating
? BorderRadius.circular(10)
: const BorderRadius.only(
@ -39,6 +43,27 @@ class PlayerQueue extends HookConsumerWidget {
final theme = Theme.of(context);
final headlineColor = theme.textTheme.headlineSmall?.color;
final filteredTracks = useMemoized(
() {
if (searchText.value.isEmpty) {
return tracks;
}
return tracks
.map((e) => (
weightedRatio(
'${e.name!} - ${TypeConversionUtils.artists_X_String(e.artists!)}',
searchText.value,
),
e
))
.sorted((a, b) => b.$1.compareTo(a.$1))
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList();
},
[tracks, searchText.value],
);
useEffect(() {
if (playlist.active == null) return null;
@ -50,6 +75,10 @@ class PlayerQueue extends HookConsumerWidget {
return null;
}, []);
if (tracks.isEmpty) {
return const NotFound(vertical: true);
}
return BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 12.0,
@ -64,89 +93,172 @@ class PlayerQueue extends HookConsumerWidget {
color: theme.scaffoldBackgroundColor.withOpacity(0.5),
borderRadius: borderRadius,
),
child: Column(
children: [
Container(
height: 5,
width: 100,
margin: const EdgeInsets.only(bottom: 5, top: 2),
decoration: BoxDecoration(
color: headlineColor,
borderRadius: BorderRadius.circular(20),
),
),
Row(
child: CallbackShortcuts(
bindings: {
LogicalKeySet(LogicalKeyboardKey.escape): () {
if (!isSearching.value) {
Navigator.of(context).pop();
}
isSearching.value = false;
searchText.value = '';
}
},
child: LayoutBuilder(builder: (context, constraints) {
return Column(
children: [
const SizedBox(width: 10),
Text(
context.l10n.tracks_in_queue(tracks.length),
style: TextStyle(
Container(
height: 5,
width: 100,
margin: const EdgeInsets.only(bottom: 5, top: 2),
decoration: BoxDecoration(
color: headlineColor,
fontWeight: FontWeight.bold,
fontSize: 18,
borderRadius: BorderRadius.circular(20),
),
),
const Spacer(),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor:
theme.scaffoldBackgroundColor.withOpacity(0.5),
foregroundColor: theme.textTheme.headlineSmall?.color,
),
child: Row(
children: [
const Icon(SpotubeIcons.playlistRemove),
const SizedBox(width: 5),
Text(context.l10n.clear_all),
],
),
onPressed: () {
playlistNotifier.stop();
Navigator.of(context).pop();
},
),
const SizedBox(width: 10),
],
),
const SizedBox(height: 10),
Flexible(
child: ReorderableListView.builder(
onReorder: (oldIndex, newIndex) {
playlistNotifier.moveTrack(oldIndex, newIndex);
},
scrollController: controller,
itemCount: tracks.length,
shrinkWrap: true,
buildDefaultDragHandles: false,
itemBuilder: (context, i) {
final track = tracks.elementAt(i);
return AutoScrollTag(
key: ValueKey(i),
controller: controller,
index: i,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: TrackTile(
index: i,
track: track,
onTap: () async {
if (playlist.activeTrack?.id == track.id) {
return;
}
await playlistNotifier.jumpToTrack(track);
},
leadingActions: [
ReorderableDragStartListener(
index: i,
child: const Icon(SpotubeIcons.dragHandle),
),
],
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (constraints.mdAndUp || !isSearching.value) ...[
const SizedBox(width: 10),
Text(
context.l10n.tracks_in_queue(tracks.length),
style: TextStyle(
color: headlineColor,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
);
}),
),
],
const Spacer(),
],
if (constraints.mdAndUp || isSearching.value)
TextField(
onChanged: (value) {
searchText.value = value;
},
decoration: InputDecoration(
hintText: context.l10n.search,
isDense: true,
prefixIcon: constraints.smAndDown
? IconButton(
icon: const Icon(
Icons.arrow_back_ios_new_outlined,
),
onPressed: () {
isSearching.value = false;
searchText.value = '';
},
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size.square(20),
),
)
: const Icon(SpotubeIcons.filter),
constraints: BoxConstraints(
maxHeight: 40,
maxWidth: constraints.smAndDown
? constraints.maxWidth - 20
: 300,
),
),
)
else
IconButton.filledTonal(
icon: const Icon(SpotubeIcons.filter),
onPressed: () {
isSearching.value = !isSearching.value;
},
),
if (constraints.mdAndUp || !isSearching.value) ...[
const SizedBox(width: 10),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor:
theme.scaffoldBackgroundColor.withOpacity(0.5),
foregroundColor: theme.textTheme.headlineSmall?.color,
),
child: Row(
children: [
const Icon(SpotubeIcons.playlistRemove),
const SizedBox(width: 5),
Text(context.l10n.clear_all),
],
),
onPressed: () {
playlistNotifier.stop();
Navigator.of(context).pop();
},
),
const SizedBox(width: 10),
],
],
),
const SizedBox(height: 10),
if (!isSearching.value && searchText.value.isEmpty)
Flexible(
child: ReorderableListView.builder(
onReorder: (oldIndex, newIndex) {
playlistNotifier.moveTrack(oldIndex, newIndex);
},
scrollController: controller,
itemCount: tracks.length,
shrinkWrap: true,
buildDefaultDragHandles: false,
itemBuilder: (context, i) {
final track = tracks.elementAt(i);
return AutoScrollTag(
key: ValueKey(i),
controller: controller,
index: i,
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8.0),
child: TrackTile(
index: i,
track: track,
onTap: () async {
if (playlist.activeTrack?.id == track.id) {
return;
}
await playlistNotifier.jumpToTrack(track);
},
leadingActions: [
ReorderableDragStartListener(
index: i,
child: const Icon(SpotubeIcons.dragHandle),
),
],
),
),
);
},
),
)
else
Flexible(
child: ListView.builder(
itemCount: filteredTracks.length,
itemBuilder: (context, i) {
final track = filteredTracks.elementAt(i);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: TrackTile(
index: i,
track: track,
onTap: () async {
if (playlist.activeTrack?.id == track.id) {
return;
}
await playlistNotifier.jumpToTrack(track);
},
),
);
},
),
),
],
);
}),
),
),
);

View File

@ -9,6 +9,7 @@ import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart';
import 'package:spotube/hooks/use_debounce.dart';
import 'package:spotube/models/matched_track.dart';
import 'package:spotube/models/spotube_track.dart';
@ -99,9 +100,7 @@ class SiblingTracksSheet extends HookConsumerWidget {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5),
),
trailing: Text(
PrimitiveUtils.toReadableDuration(video.duration),
),
trailing: Text(video.duration.toHumanReadableString()),
subtitle: Text(video.channelName),
enabled: playlist.isFetching != true,
selected: playlist.isFetching != true &&

View File

@ -32,6 +32,7 @@ class PlaylistCard extends HookConsumerWidget {
final updating = useState(false);
final spotify = ref.watch(spotifyProvider);
final me = useQueries.user.me(ref);
return PlaybuttonCard(
margin: const EdgeInsets.symmetric(horizontal: 10),
@ -44,6 +45,7 @@ class PlaylistCard extends HookConsumerWidget {
isPlaying: isPlaylistPlaying,
isLoading:
(isPlaylistPlaying && playlistQueue.isFetching) || updating.value,
isOwner: playlist.owner?.id == me.data?.id && me.data?.id != null,
onTap: () {
ServiceUtils.push(
context,
@ -60,11 +62,18 @@ class PlaylistCard extends HookConsumerWidget {
return audioPlayer.resume();
}
List<Track> fetchedTracks = await queryBowl.fetchQuery(
"playlist-tracks/${playlist.id}",
() => useQueries.playlist.tracksOf(playlist.id!, spotify),
) ??
[];
List<Track> fetchedTracks = playlist.id == 'user-liked-tracks'
? await queryBowl.fetchQuery(
"user-liked-tracks",
() => useQueries.playlist.likedTracks(spotify, ref),
) ??
[]
: await queryBowl.fetchQuery(
"playlist-tracks/${playlist.id}",
() => useQueries.playlist
.tracksOf(playlist.id!, spotify, ref),
) ??
[];
if (fetchedTracks.isEmpty) return;
@ -83,7 +92,7 @@ class PlaylistCard extends HookConsumerWidget {
if (isPlaylistPlaying) return;
List<Track> fetchedTracks = await queryBowl.fetchQuery(
"playlist-tracks/${playlist.id}",
() => useQueries.playlist.tracksOf(playlist.id!, spotify),
() => useQueries.playlist.tracksOf(playlist.id!, spotify, ref),
) ??
[];

View File

@ -1,106 +1,276 @@
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'dart:convert';
import 'dart:io';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:form_validator/form_validator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/spotify_provider.dart';
import 'package:spotube/services/mutations/mutations.dart';
import 'package:spotube/services/mutations/playlist.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlaylistCreateDialog extends HookConsumerWidget {
/// Track ids to add to the playlist
final List<String> trackIds;
const PlaylistCreateDialog({
final String? playlistId;
PlaylistCreateDialog({
Key? key,
this.trackIds = const [],
this.playlistId,
}) : super(key: key);
final formKey = GlobalKey<FormState>();
@override
Widget build(BuildContext context, ref) {
final spotify = ref.watch(spotifyProvider);
final playlistName = useTextEditingController();
final description = useTextEditingController();
final public = useState(false);
final collaborative = useState(false);
final client = useQueryClient();
final navigator = Navigator.of(context);
return ScaffoldMessenger(
child: Scaffold(
backgroundColor: Colors.transparent,
body: HookBuilder(builder: (context) {
final userPlaylists = useQueries.playlist.ofMine(ref);
final updatingPlaylist = useMemoized(
() => userPlaylists.pages
.expand((p) => p.items ?? <PlaylistSimple>[])
.firstWhereOrNull((playlist) => playlist.id == playlistId),
[
userPlaylists.pages,
playlistId,
],
);
Future<void> onCreate() async {
if (playlistName.text.isEmpty) return;
final me = await spotify.me.get();
final playlist = await spotify.playlists.createPlaylist(
me.id!,
playlistName.text,
collaborative: collaborative.value,
public: public.value,
description: description.text,
);
if (trackIds.isNotEmpty) {
await spotify.playlists.addTracks(
trackIds.map((id) => "spotify:track:$id").toList(),
playlist.id!,
);
}
await client
.getQuery(
"current-user-playlists",
)
?.refresh();
navigator.pop(playlist);
}
final playlistName = useTextEditingController(
text: updatingPlaylist?.name,
);
final description = useTextEditingController(
text: updatingPlaylist?.description,
);
final public = useState(
updatingPlaylist?.public ?? false,
);
final collaborative = useState(
updatingPlaylist?.collaborative ?? false,
);
final image = useState<XFile?>(null);
return AlertDialog(
title: Text(context.l10n.create_a_playlist),
actions: [
OutlinedButton(
child: Text(context.l10n.cancel),
onPressed: () {
Navigator.pop(context);
},
),
FilledButton(
onPressed: onCreate,
child: Text(context.l10n.create),
),
],
content: Container(
width: MediaQuery.of(context).size.width,
constraints: const BoxConstraints(maxWidth: 500),
child: ListView(
shrinkWrap: true,
children: [
TextField(
controller: playlistName,
decoration: InputDecoration(
hintText: context.l10n.name_of_playlist,
labelText: context.l10n.name_of_playlist,
final isUpdatingPlaylist = playlistId != null;
final l10n = context.l10n;
final theme = Theme.of(context);
final scaffold = ScaffoldMessenger.of(context);
final onError = useCallback((error) {
if (error is SpotifyError || error is SpotifyException) {
scaffold.showSnackBar(
SnackBar(
content: Text(
l10n.error(error.message ?? "Epic failure!"),
style: theme.textTheme.bodyMedium!.copyWith(
color: theme.colorScheme.onError,
),
),
backgroundColor: theme.colorScheme.error,
),
);
}
}, [scaffold, l10n, theme]);
final playlistCreateMutation = useMutations.playlist.create(
ref,
trackIds: trackIds,
onData: (value) {
Navigator.pop(context);
},
onError: onError,
);
final playlistUpdateMutation = useMutations.playlist.update(
ref,
playlistId: playlistId,
onData: (value) {
Navigator.pop(context);
},
onError: onError,
);
Future<void> onCreate() async {
if (!formKey.currentState!.validate()) return;
final PlaylistCRUDVariables payload = (
playlistName: playlistName.text,
collaborative: collaborative.value,
public: public.value,
description: description.text,
base64Image: image.value?.path != null
? await image.value!
.readAsBytes()
.then((bytes) => base64Encode(bytes))
: null,
);
if (isUpdatingPlaylist) {
await playlistUpdateMutation.mutate(payload);
} else {
await playlistCreateMutation.mutate(payload);
}
}
return AlertDialog(
title: Text(
isUpdatingPlaylist
? context.l10n.update_playlist
: context.l10n.create_a_playlist,
),
actions: [
OutlinedButton(
child: Text(context.l10n.cancel),
onPressed: () {
Navigator.pop(context);
},
),
FilledButton(
onPressed: onCreate,
child: Text(
isUpdatingPlaylist
? context.l10n.update
: context.l10n.create,
),
),
],
insetPadding: const EdgeInsets.all(8),
content: Container(
width: MediaQuery.of(context).size.width,
constraints: const BoxConstraints(maxWidth: 500),
child: Form(
key: formKey,
child: ListView(
shrinkWrap: true,
children: [
FormField<XFile?>(
initialValue: image.value,
onSaved: (newValue) {
image.value = newValue;
},
validator: (value) {
if (value == null) return null;
final file = File(value.path);
if (file.lengthSync() > 256000) {
return "Image size should be less than 256kb";
}
return null;
},
builder: (field) {
return Center(
child: Stack(
children: [
UniversalImage(
path: field.value?.path ??
TypeConversionUtils.image_X_UrlString(
updatingPlaylist?.images,
placeholder:
ImagePlaceholder.collection,
),
height: 200,
),
Positioned(
bottom: 20,
right: 20,
child: IconButton.filled(
icon: const Icon(SpotubeIcons.edit),
style: IconButton.styleFrom(
backgroundColor:
theme.colorScheme.surface,
foregroundColor:
theme.colorScheme.primary,
elevation: 2,
shadowColor: theme.colorScheme.onSurface,
),
onPressed: () async {
final imageFile = await ImagePicker()
.pickImage(
source: ImageSource.gallery);
if (imageFile != null) {
field.didChange(imageFile);
field.validate();
field.save();
}
},
),
),
if (field.hasError)
Positioned(
bottom: 20,
left: 20,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: theme.colorScheme.error,
borderRadius: BorderRadius.circular(4),
),
child: Text(
field.errorText ?? "",
style: theme.textTheme.bodyMedium!
.copyWith(
color: theme.colorScheme.onError,
),
),
),
),
],
),
);
}),
const SizedBox(height: 10),
TextFormField(
controller: playlistName,
decoration: InputDecoration(
hintText: context.l10n.name_of_playlist,
labelText: context.l10n.name_of_playlist,
),
validator: ValidationBuilder().required().build(),
),
const SizedBox(height: 10),
TextFormField(
controller: description,
decoration: InputDecoration(
hintText: context.l10n.description,
),
keyboardType: TextInputType.multiline,
validator: ValidationBuilder().required().build(),
maxLines: 5,
),
const SizedBox(height: 10),
CheckboxListTile(
title: Text(context.l10n.public),
value: public.value,
onChanged: (val) => public.value = val ?? false,
),
const SizedBox(height: 10),
CheckboxListTile(
title: Text(context.l10n.collaborative),
value: collaborative.value,
onChanged: (val) => collaborative.value = val ?? false,
),
],
),
),
),
const SizedBox(height: 10),
TextField(
controller: description,
decoration: InputDecoration(
hintText: context.l10n.description,
),
keyboardType: TextInputType.multiline,
maxLines: 5,
),
const SizedBox(height: 10),
CheckboxListTile(
title: Text(context.l10n.public),
value: public.value,
onChanged: (val) => public.value = val ?? false,
),
const SizedBox(height: 10),
CheckboxListTile(
title: Text(context.l10n.collaborative),
value: collaborative.value,
onChanged: (val) => collaborative.value = val ?? false,
),
],
),
);
}),
),
);
}
@ -112,7 +282,7 @@ class PlaylistCreateDialogButton extends HookConsumerWidget {
showPlaylistDialog(BuildContext context, SpotifyApi spotify) {
showDialog(
context: context,
builder: (context) => const PlaylistCreateDialog(),
builder: (context) => PlaylistCreateDialog(),
);
}
@ -132,11 +302,12 @@ class PlaylistCreateDialogButton extends HookConsumerWidget {
}
return FilledButton.tonalIcon(
style: FilledButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.primary,
),
icon: const Icon(SpotubeIcons.addFilled),
label: Text(context.l10n.create_playlist),
onPressed: () => showPlaylistDialog(context, spotify));
style: FilledButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.primary,
),
icon: const Icon(SpotubeIcons.addFilled),
label: Text(context.l10n.create_playlist),
onPressed: () => showPlaylistDialog(context, spotify),
);
}
}

View File

@ -78,6 +78,31 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
'Either icon or child must be provided',
);
Future<T?> showPopupMenu(BuildContext context, RelativeRect position) {
final mediaQuery = MediaQuery.of(context);
return showMenu<T>(
context: context,
useRootNavigator: useRootNavigator,
constraints: BoxConstraints(
maxHeight: mediaQuery.size.height * 0.6,
),
position: position,
items: children
.map(
(item) => PopupMenuItem<T>(
padding: EdgeInsets.zero,
enabled: false,
child: _AdaptivePopSheetListItem<T>(
item: item,
onSelected: onSelected,
),
),
)
.toList(),
);
}
@override
Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context);

View File

@ -29,7 +29,7 @@ class HeartButton extends HookConsumerWidget {
Widget build(BuildContext context, ref) {
final auth = ref.watch(AuthenticationNotifier.provider);
if (auth == null) return Container();
if (auth == null) return const SizedBox.shrink();
return IconButton(
tooltip: tooltip,
@ -57,18 +57,21 @@ class HeartButton extends HookConsumerWidget {
}
}
({
typedef UseTrackToggleLike = ({
bool isLiked,
Mutation<bool, dynamic, bool> toggleTrackLike,
Query<User?, dynamic> me,
}) useTrackToggleLike(Track track, WidgetRef ref) {
});
UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) {
final me = useQueries.user.me(ref);
final savedTracks =
useQueries.playlist.tracksOfQuery(ref, "user-liked-tracks");
final savedTracks = useQueries.playlist.likedTracksQuery(ref);
final isLiked =
savedTracks.data?.any((element) => element.id == track.id) ?? false;
final isLiked = useMemoized(
() => savedTracks.data?.any((element) => element.id == track.id) ?? false,
[savedTracks.data, track.id],
);
final mounted = useIsMounted();
@ -76,28 +79,48 @@ class HeartButton extends HookConsumerWidget {
ref,
track.id!,
onMutate: (isLiked) {
savedTracks.setData(
[
if (isLiked == true)
...?savedTracks.data?.where((element) => element.id != track.id)
else
...?savedTracks.data?..add(track)
],
);
print("Toggle Like onMutate: $isLiked");
if (isLiked) {
savedTracks.setData(
savedTracks.data
?.where((element) => element.id != track.id)
.toList() ??
[],
);
} else {
savedTracks.setData(
[
...?savedTracks.data,
track,
],
);
}
return isLiked;
},
onData: (data, recoveryData) async {
print("Toggle Like onData: $data");
await savedTracks.refresh();
},
onError: (payload, isLiked) {
print("Toggle Like onError: $payload");
if (!mounted()) return;
savedTracks.setData([
if (isLiked != true)
...?savedTracks.data?.where((element) => element.id != track.id)
else
...?savedTracks.data?..add(track),
]);
if (isLiked != true) {
savedTracks.setData(
savedTracks.data
?.where((element) => element.id != track.id)
.toList() ??
[],
);
} else {
savedTracks.setData(
[
...?savedTracks.data,
track,
],
);
}
},
);
@ -113,21 +136,21 @@ class TrackHeartButton extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final savedTracks =
useQueries.playlist.tracksOfQuery(ref, "user-liked-tracks");
final toggler = useTrackToggleLike(track, ref);
if (toggler.me.isLoading || !toggler.me.hasData) {
final savedTracks = useQueries.playlist.likedTracksQuery(ref);
final (:me, :isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref);
if (me.isLoading || !me.hasData) {
return const CircularProgressIndicator();
}
return HeartButton(
tooltip: toggler.isLiked
tooltip: isLiked
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
isLiked: toggler.isLiked,
isLiked: isLiked,
onPressed: savedTracks.hasData
? () {
toggler.toggleTrackLike.mutate(toggler.isLiked);
toggleTrackLike.mutate(isLiked);
}
: null,
);
@ -136,10 +159,14 @@ class TrackHeartButton extends HookConsumerWidget {
class PlaylistHeartButton extends HookConsumerWidget {
final PlaylistSimple playlist;
final IconData? icon;
final ValueChanged<bool>? onData;
const PlaylistHeartButton({
required this.playlist,
Key? key,
this.icon,
this.onData,
}) : super(key: key);
@override
@ -158,6 +185,7 @@ class PlaylistHeartButton extends HookConsumerWidget {
refreshQueries: [
isLikedQuery.key,
],
onData: onData,
);
if (me.isLoading || !me.hasData) {
@ -170,6 +198,7 @@ class PlaylistHeartButton extends HookConsumerWidget {
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
color: Colors.white,
icon: icon,
onPressed: isLikedQuery.hasData
? () {
togglePlaylistLike.mutate(isLikedQuery.data!);

View File

@ -4,6 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/hover_builder.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/hooks/use_breakpoint_value.dart';
import 'package:spotube/hooks/use_brightness_value.dart';
@ -28,6 +29,7 @@ class PlaybuttonCard extends HookWidget {
final bool isPlaying;
final bool isLoading;
final String title;
final bool isOwner;
const PlaybuttonCard({
required this.imageUrl,
@ -39,6 +41,7 @@ class PlaybuttonCard extends HookWidget {
this.onPlaybuttonPressed,
this.onAddToQueuePressed,
this.onTap,
this.isOwner = false,
Key? key,
}) : super(key: key);
@ -153,6 +156,42 @@ class PlaybuttonCard extends HookWidget {
),
),
),
if (isOwner)
Positioned(
top: 15,
left: 25,
child: AnimatedSize(
duration: const Duration(milliseconds: 150),
alignment: Alignment.centerLeft,
curve: Curves.easeInExpo,
child: HoverBuilder(builder: (context, isHovered) {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: Colors.blueAccent,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
SpotubeIcons.user,
color: Colors.white,
size: 16,
),
if (isHovered)
Text(
"Owned by you",
style: theme.textTheme.bodySmall?.copyWith(
color: Colors.white,
),
),
],
),
);
}),
),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
right: end,

View File

@ -1,5 +1,6 @@
import 'dart:ui';
import 'package:auto_size_text/auto_size_text.dart';
import 'package:fl_query/fl_query.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -103,11 +104,19 @@ class TrackCollectionHeading<T> extends HookConsumerWidget {
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
Text(
title,
style: theme.textTheme.titleLarge!.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constrains.mdAndDown ? 400 : 300,
),
child: AutoSizeText(
title,
style: theme.textTheme.titleLarge!.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
maxLines: 2,
minFontSize: 16,
overflow: TextOverflow.ellipsis,
),
),
if (album != null)
@ -125,11 +134,12 @@ class TrackCollectionHeading<T> extends HookConsumerWidget {
constraints: BoxConstraints(
maxWidth: constrains.mdAndDown ? 400 : 300,
),
child: Text(
child: AutoSizeText(
cleanDescription,
style: const TextStyle(color: Colors.white),
maxLines: 2,
overflow: TextOverflow.fade,
minFontSize: 14,
),
),
const SizedBox(height: 10),

View File

@ -1,9 +1,12 @@
import 'dart:async';
import 'package:fl_query/fl_query.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart';
@ -26,7 +29,7 @@ class TrackCollectionView<T> extends HookConsumerWidget {
final Query<List<TrackSimple>, T> tracksSnapshot;
final String titleImage;
final PlayButtonState playingState;
final void Function([Track? currentTrack]) onPlay;
final Future<void> Function([Track? currentTrack]) onPlay;
final void Function([Track? currentTrack]) onShuffledPlay;
final void Function() onAddToQueue;
final void Function() onShare;
@ -71,6 +74,18 @@ class TrackCollectionView<T> extends HookConsumerWidget {
icon: const Icon(SpotubeIcons.share),
onPressed: onShare,
),
if (isOwned)
IconButton(
icon: const Icon(SpotubeIcons.edit),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return PlaylistCreateDialog(playlistId: id);
},
);
},
),
if (heartBtn != null && auth != null) heartBtn!,
IconButton(
onPressed: playingState == PlayButtonState.playing

View File

@ -14,7 +14,6 @@ import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/download_manager_provider.dart';
@ -40,9 +39,11 @@ class TrackOptions extends HookConsumerWidget {
final Track track;
final bool userPlaylist;
final String? playlistId;
final ObjectRef<ValueChanged<RelativeRect>?>? showMenuCbRef;
const TrackOptions({
Key? key,
required this.track,
this.showMenuCbRef,
this.userPlaylist = false,
this.playlistId,
}) : super(key: key);
@ -114,210 +115,216 @@ class TrackOptions extends HookConsumerWidget {
return downloadManager.getProgressNotifier(spotubeTrack);
});
final adaptivePopSheetList = AdaptivePopSheetList<TrackOptionValue>(
onSelected: (value) async {
switch (value) {
case TrackOptionValue.delete:
await File((track as LocalTrack).path).delete();
ref.refresh(localTracksProvider);
break;
case TrackOptionValue.addToQueue:
await playback.addTrack(track);
if (context.mounted) {
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
context.l10n.added_track_to_queue(track.name!),
),
),
);
}
break;
case TrackOptionValue.playNext:
playback.addTracksAtFirst([track]);
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
context.l10n.track_will_play_next(track.name!),
),
),
);
break;
case TrackOptionValue.removeFromQueue:
playback.removeTrack(track.id!);
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
context.l10n.removed_track_from_queue(
track.name!,
),
),
),
);
break;
case TrackOptionValue.favorite:
favorites.toggleTrackLike.mutate(favorites.isLiked);
break;
case TrackOptionValue.addToPlaylist:
actionAddToPlaylist(context, track);
break;
case TrackOptionValue.removeFromPlaylist:
removingTrack.value = track.uri;
removeTrack.mutate(track.uri!);
break;
case TrackOptionValue.blacklist:
if (isBlackListed) {
ref.read(BlackListNotifier.provider.notifier).remove(
BlacklistedElement.track(track.id!, track.name!),
);
} else {
ref.read(BlackListNotifier.provider.notifier).add(
BlacklistedElement.track(track.id!, track.name!),
);
}
break;
case TrackOptionValue.share:
actionShare(context, track);
break;
case TrackOptionValue.details:
showDialog(
context: context,
builder: (context) => TrackDetailsDialog(track: track),
);
break;
case TrackOptionValue.download:
await downloadManager.addToQueue(track);
break;
}
},
icon: const Icon(SpotubeIcons.moreHorizontal),
headings: [
ListTile(
dense: true,
leading: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: UniversalImage(
path: TypeConversionUtils.image_X_UrlString(track.album!.images,
placeholder: ImagePlaceholder.albumArt),
fit: BoxFit.cover,
),
),
),
title: Text(
track.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
subtitle: Align(
alignment: Alignment.centerLeft,
child: TypeConversionUtils.artists_X_ClickableArtists(
track.artists!,
),
),
),
],
children: switch (track.runtimeType) {
LocalTrack => [
PopSheetEntry(
value: TrackOptionValue.delete,
leading: const Icon(SpotubeIcons.trash),
title: Text(context.l10n.delete),
)
],
_ => [
if (!playlist.containsTrack(track)) ...[
PopSheetEntry(
value: TrackOptionValue.addToQueue,
leading: const Icon(SpotubeIcons.queueAdd),
title: Text(context.l10n.add_to_queue),
),
PopSheetEntry(
value: TrackOptionValue.playNext,
leading: const Icon(SpotubeIcons.lightning),
title: Text(context.l10n.play_next),
),
] else
PopSheetEntry(
value: TrackOptionValue.removeFromQueue,
enabled: playlist.activeTrack?.id != track.id,
leading: const Icon(SpotubeIcons.queueRemove),
title: Text(context.l10n.remove_from_queue),
),
if (favorites.me.hasData)
PopSheetEntry(
value: TrackOptionValue.favorite,
leading: favorites.isLiked
? const Icon(
SpotubeIcons.heartFilled,
color: Colors.pink,
)
: const Icon(SpotubeIcons.heart),
title: Text(
favorites.isLiked
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
),
),
if (auth != null)
PopSheetEntry(
value: TrackOptionValue.addToPlaylist,
leading: const Icon(SpotubeIcons.playlistAdd),
title: Text(context.l10n.add_to_playlist),
),
if (userPlaylist && auth != null)
PopSheetEntry(
value: TrackOptionValue.removeFromPlaylist,
leading: (removeTrack.isMutating || !removeTrack.hasData) &&
removingTrack.value == track.uri
? const CircularProgressIndicator()
: const Icon(SpotubeIcons.removeFilled),
title: Text(context.l10n.remove_from_playlist),
),
PopSheetEntry(
value: TrackOptionValue.download,
enabled: !isInQueue,
leading: isInQueue
? HookBuilder(builder: (context) {
final progress = useListenable(progressNotifier!);
return CircularProgressIndicator(
value: progress.value,
);
})
: const Icon(SpotubeIcons.download),
title: Text(context.l10n.download_track),
),
PopSheetEntry(
value: TrackOptionValue.blacklist,
leading: const Icon(SpotubeIcons.playlistRemove),
iconColor: !isBlackListed ? Colors.red[400] : null,
textColor: !isBlackListed ? Colors.red[400] : null,
title: Text(
isBlackListed
? context.l10n.remove_from_blacklist
: context.l10n.add_to_blacklist,
),
),
PopSheetEntry(
value: TrackOptionValue.share,
leading: const Icon(SpotubeIcons.share),
title: Text(context.l10n.share),
),
PopSheetEntry(
value: TrackOptionValue.details,
leading: const Icon(SpotubeIcons.info),
title: Text(context.l10n.details),
),
]
},
);
//! This is the most ANTI pattern I've ever done, but it works
showMenuCbRef?.value = (relativeRect) {
adaptivePopSheetList.showPopupMenu(context, relativeRect);
};
return ListTileTheme(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
child: AdaptivePopSheetList<TrackOptionValue>(
onSelected: (value) async {
switch (value) {
case TrackOptionValue.delete:
await File((track as LocalTrack).path).delete();
ref.refresh(localTracksProvider);
break;
case TrackOptionValue.addToQueue:
await playback.addTrack(track);
if (context.mounted) {
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
context.l10n.added_track_to_queue(track.name!),
),
),
);
}
break;
case TrackOptionValue.playNext:
playback.addTracksAtFirst([track]);
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
context.l10n.track_will_play_next(track.name!),
),
),
);
break;
case TrackOptionValue.removeFromQueue:
playback.removeTrack(track.id!);
scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
context.l10n.removed_track_from_queue(
track.name!,
),
),
),
);
break;
case TrackOptionValue.favorite:
favorites.toggleTrackLike.mutate(favorites.isLiked);
break;
case TrackOptionValue.addToPlaylist:
actionAddToPlaylist(context, track);
break;
case TrackOptionValue.removeFromPlaylist:
removingTrack.value = track.uri;
removeTrack.mutate(track.uri!);
break;
case TrackOptionValue.blacklist:
if (isBlackListed) {
ref.read(BlackListNotifier.provider.notifier).remove(
BlacklistedElement.track(track.id!, track.name!),
);
} else {
ref.read(BlackListNotifier.provider.notifier).add(
BlacklistedElement.track(track.id!, track.name!),
);
}
break;
case TrackOptionValue.share:
actionShare(context, track);
break;
case TrackOptionValue.details:
showDialog(
context: context,
builder: (context) => TrackDetailsDialog(track: track),
);
break;
case TrackOptionValue.download:
await downloadManager.addToQueue(track);
break;
}
},
icon: const Icon(SpotubeIcons.moreHorizontal),
headings: [
ListTile(
dense: true,
leading: AspectRatio(
aspectRatio: 1,
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: UniversalImage(
path: TypeConversionUtils.image_X_UrlString(
track.album!.images,
placeholder: ImagePlaceholder.albumArt),
fit: BoxFit.cover,
),
),
),
title: Text(
track.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium,
),
subtitle: Align(
alignment: Alignment.centerLeft,
child: TypeConversionUtils.artists_X_ClickableArtists(
track.artists!,
),
),
),
],
children: switch (track.runtimeType) {
LocalTrack => [
PopSheetEntry(
value: TrackOptionValue.delete,
leading: const Icon(SpotubeIcons.trash),
title: Text(context.l10n.delete),
)
],
_ => [
if (!playlist.containsTrack(track)) ...[
PopSheetEntry(
value: TrackOptionValue.addToQueue,
leading: const Icon(SpotubeIcons.queueAdd),
title: Text(context.l10n.add_to_queue),
),
PopSheetEntry(
value: TrackOptionValue.playNext,
leading: const Icon(SpotubeIcons.lightning),
title: Text(context.l10n.play_next),
),
] else
PopSheetEntry(
value: TrackOptionValue.removeFromQueue,
enabled: playlist.activeTrack?.id != track.id,
leading: const Icon(SpotubeIcons.queueRemove),
title: Text(context.l10n.remove_from_queue),
),
if (favorites.me.hasData)
PopSheetEntry(
value: TrackOptionValue.favorite,
leading: favorites.isLiked
? const Icon(
SpotubeIcons.heartFilled,
color: Colors.pink,
)
: const Icon(SpotubeIcons.heart),
title: Text(
favorites.isLiked
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
),
),
if (auth != null)
PopSheetEntry(
value: TrackOptionValue.addToPlaylist,
leading: const Icon(SpotubeIcons.playlistAdd),
title: Text(context.l10n.add_to_playlist),
),
if (userPlaylist && auth != null)
PopSheetEntry(
value: TrackOptionValue.removeFromPlaylist,
leading: (removeTrack.isMutating || !removeTrack.hasData) &&
removingTrack.value == track.uri
? const CircularProgressIndicator()
: const Icon(SpotubeIcons.removeFilled),
title: Text(context.l10n.remove_from_playlist),
),
PopSheetEntry(
value: TrackOptionValue.download,
enabled: !isInQueue,
leading: isInQueue
? HookBuilder(builder: (context) {
final progress = useListenable(progressNotifier!);
return CircularProgressIndicator(
value: progress.value,
);
})
: const Icon(SpotubeIcons.download),
title: Text(context.l10n.download_track),
),
PopSheetEntry(
value: TrackOptionValue.blacklist,
leading: const Icon(SpotubeIcons.playlistRemove),
iconColor: !isBlackListed ? Colors.red[400] : null,
textColor: !isBlackListed ? Colors.red[400] : null,
title: Text(
isBlackListed
? context.l10n.remove_from_blacklist
: context.l10n.add_to_blacklist,
),
),
PopSheetEntry(
value: TrackOptionValue.share,
leading: const Icon(SpotubeIcons.share),
title: Text(context.l10n.share),
),
PopSheetEntry(
value: TrackOptionValue.details,
leading: const Icon(SpotubeIcons.info),
title: Text(context.l10n.details),
),
]
},
),
child: adaptivePopSheetList,
);
}
}

View File

@ -1,3 +1,6 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -20,7 +23,7 @@ class TrackTile extends HookConsumerWidget {
final Track track;
final bool selected;
final ValueChanged<bool?>? onChanged;
final VoidCallback? onTap;
final Future<void> Function()? onTap;
final VoidCallback? onLongPress;
final bool userPlaylist;
final String? playlistId;
@ -57,174 +60,203 @@ class TrackTile extends HookConsumerWidget {
[blacklist, track],
);
final showOptionCbRef = useRef<ValueChanged<RelativeRect>?>(null);
final isPlaying = track.id == playlist.activeTrack?.id;
final isLoading = useState(false);
final isSelected = isPlaying || isLoading.value;
return LayoutBuilder(builder: (context, constrains) {
return HoverBuilder(
permanentState: isPlaying || constrains.smAndDown ? true : null,
builder: (context, isHovering) {
return ListTile(
selected: isPlaying,
onTap: onTap,
onLongPress: onLongPress,
enabled: !isBlackListed,
contentPadding: EdgeInsets.zero,
tileColor: isBlackListed ? theme.colorScheme.errorContainer : null,
horizontalTitleGap: 12,
leadingAndTrailingTextStyle: theme.textTheme.bodyMedium,
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
...?leadingActions,
if (index != null && onChanged == null && constrains.mdAndUp)
SizedBox(
width: 34,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
'$index',
maxLines: 1,
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
),
)
else if (constrains.smAndDown)
const SizedBox(width: 16),
if (onChanged != null)
Checkbox(
value: selected,
onChanged: onChanged,
),
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: AspectRatio(
aspectRatio: 1,
child: UniversalImage(
path: TypeConversionUtils.image_X_UrlString(
track.album?.images,
placeholder: ImagePlaceholder.albumArt,
),
fit: BoxFit.cover,
),
),
),
Positioned.fill(
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: isHovering
? Colors.black.withOpacity(0.4)
: Colors.transparent,
),
),
),
Positioned.fill(
child: Center(
child: IconTheme(
data: theme.iconTheme
.copyWith(size: 26, color: Colors.white),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: !isHovering
? const SizedBox.shrink()
: isPlaying && playlist.isFetching
? const SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: Colors.white,
),
)
: isPlaying
? Icon(
SpotubeIcons.pause,
color: theme.colorScheme.primary,
)
: const Icon(SpotubeIcons.play),
),
),
),
),
],
),
],
),
title: Row(
children: [
Expanded(
flex: 6,
child: Text(
track.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (constrains.mdAndUp) ...[
const SizedBox(width: 8),
Expanded(
flex: 4,
child: switch (track.runtimeType) {
LocalTrack => Text(
track.album!.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
_ => Align(
alignment: Alignment.centerLeft,
child: LinkText(
track.album!.name!,
"/album/${track.album?.id}",
extra: track.album,
push: true,
overflow: TextOverflow.ellipsis,
),
)
},
),
],
],
),
subtitle: Align(
alignment: Alignment.centerLeft,
child: track is LocalTrack
? Text(
TypeConversionUtils.artists_X_String<Artist>(
track.artists ?? [],
),
)
: ClipRect(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 40),
child: TypeConversionUtils.artists_X_ClickableArtists(
track.artists ?? [],
),
),
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 8),
Text(
Duration(milliseconds: track.durationMs ?? 0)
.toHumanReadableString(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
TrackOptions(
track: track,
playlistId: playlistId,
userPlaylist: userPlaylist,
),
],
return Listener(
onPointerDown: (event) {
if (event.buttons != kSecondaryMouseButton) return;
showOptionCbRef.value?.call(
RelativeRect.fromLTRB(
event.position.dx,
event.position.dy,
constrains.maxWidth - event.position.dx,
constrains.maxHeight - event.position.dy,
),
);
},
child: HoverBuilder(
permanentState: isSelected || constrains.smAndDown ? true : null,
builder: (context, isHovering) {
return ListTile(
selected: isSelected,
onTap: () async {
try {
isLoading.value = true;
await onTap?.call();
} finally {
isLoading.value = false;
}
},
onLongPress: onLongPress,
enabled: !isBlackListed,
contentPadding: EdgeInsets.zero,
tileColor:
isBlackListed ? theme.colorScheme.errorContainer : null,
horizontalTitleGap: 12,
leadingAndTrailingTextStyle: theme.textTheme.bodyMedium,
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
...?leadingActions,
if (index != null && onChanged == null && constrains.mdAndUp)
SizedBox(
width: 34,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
'$index',
maxLines: 1,
style: theme.textTheme.bodySmall,
textAlign: TextAlign.center,
),
),
)
else if (constrains.smAndDown)
const SizedBox(width: 16),
if (onChanged != null)
Checkbox(
value: selected,
onChanged: onChanged,
),
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: AspectRatio(
aspectRatio: 1,
child: UniversalImage(
path: TypeConversionUtils.image_X_UrlString(
track.album?.images,
placeholder: ImagePlaceholder.albumArt,
),
fit: BoxFit.cover,
),
),
),
Positioned.fill(
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
color: isHovering
? Colors.black.withOpacity(0.4)
: Colors.transparent,
),
),
),
Positioned.fill(
child: Center(
child: IconTheme(
data: theme.iconTheme
.copyWith(size: 26, color: Colors.white),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: (isPlaying && playlist.isFetching) ||
isLoading.value
? const SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: Colors.white,
),
)
: isPlaying
? Icon(
SpotubeIcons.pause,
color: theme.colorScheme.primary,
)
: !isHovering
? const SizedBox.shrink()
: const Icon(SpotubeIcons.play),
),
),
),
),
],
),
],
),
title: Row(
children: [
Expanded(
flex: 6,
child: Text(
track.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (constrains.mdAndUp) ...[
const SizedBox(width: 8),
Expanded(
flex: 4,
child: switch (track.runtimeType) {
LocalTrack => Text(
track.album!.name!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
_ => Align(
alignment: Alignment.centerLeft,
child: LinkText(
track.album!.name!,
"/album/${track.album?.id}",
extra: track.album,
push: true,
overflow: TextOverflow.ellipsis,
),
)
},
),
],
],
),
subtitle: Align(
alignment: Alignment.centerLeft,
child: track is LocalTrack
? Text(
TypeConversionUtils.artists_X_String<Artist>(
track.artists ?? [],
),
)
: ClipRect(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 40),
child: TypeConversionUtils.artists_X_ClickableArtists(
track.artists ?? [],
),
),
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(width: 8),
Text(
Duration(milliseconds: track.durationMs ?? 0)
.toHumanReadableString(padZero: false),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
TrackOptions(
track: track,
playlistId: playlistId,
userPlaylist: userPlaylist,
showMenuCbRef: showOptionCbRef,
),
],
),
);
},
),
);
});
}

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -27,7 +29,7 @@ final trackCollectionSortState =
StateProvider.family<SortBy, String>((ref, _) => SortBy.none);
class TracksTableView extends HookConsumerWidget {
final void Function(Track currentTrack)? onTrackPlayButtonPressed;
final Future<void> Function(Track currentTrack)? onTrackPlayButtonPressed;
final List<Track> tracks;
final bool userPlaylist;
final String? playlistId;
@ -58,8 +60,7 @@ class TracksTableView extends HookConsumerWidget {
final downloader = ref.watch(downloadManagerProvider.notifier);
final apiType =
ref.watch(userPreferencesProvider.select((s) => s.youtubeApiType));
final tableHeadStyle =
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
const tableHeadStyle = TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
final selected = useState<List<String>>([]);
final showCheck = useState<bool>(false);
@ -297,7 +298,7 @@ class TracksTableView extends HookConsumerWidget {
selected: selected.value.contains(track.id),
userPlaylist: userPlaylist,
playlistId: playlistId,
onTap: () {
onTap: () async {
if (showCheck.value) {
final alreadyChecked = selected.value.contains(track.id);
if (alreadyChecked) {
@ -314,9 +315,8 @@ class TracksTableView extends HookConsumerWidget {
),
),
);
if (!isBlackListed) {
onTrackPlayButtonPressed?.call(track);
}
if (isBlackListed) return;
await onTrackPlayButtonPressed?.call(track);
}
},
onLongPress: () {

View File

@ -1,10 +1,21 @@
import 'package:duration/locale.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:duration/duration.dart';
extension DurationToHumanReadableString on Duration {
String toHumanReadableString() =>
"${inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(inSeconds.remainder(60))}";
String toHumanReadableString({padZero = true}) {
final mm = inMinutes
.remainder(60)
.toString()
.padLeft(2, !padZero && inHours == 0 ? '' : "0");
final ss = inSeconds.remainder(60).toString().padLeft(2, "0");
if (inHours > 0) {
final hh = inHours.toString().padLeft(2, !padZero ? '' : "0");
return "$hh:$mm:$ss";
}
return "$mm:$ss";
}
String format({
DurationTersity tersity = DurationTersity.second,

View File

@ -8,7 +8,6 @@
import 'package:audio_service_web/audio_service_web.dart';
import 'package:audio_session/audio_session_web.dart';
import 'package:file_picker/_internal/file_picker_web.dart';
import 'package:shared_preferences_web/shared_preferences_web.dart';
import 'package:url_launcher_web/url_launcher_web.dart';
@ -18,7 +17,6 @@ import 'package:flutter_web_plugins/flutter_web_plugins.dart';
void registerPlugins(Registrar registrar) {
AudioServiceWeb.registerWith(registrar);
AudioSessionWeb.registerWith(registrar);
FilePickerWeb.registerWith(registrar);
SharedPreferencesPlugin.registerWith(registrar);
UrlLauncherPlugin.registerWith(registrar);
registrar.registerMessageHandler();

View File

@ -4,7 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/hooks/use_async_effect.dart';
bool _asked = false;
void useDisableBatterOptimizations() {
void useDisableBatteryOptimizations() {
useAsyncEffect(() async {
if (!DesktopTools.platform.isAndroid || _asked) return;
final localStorage = await SharedPreferences.getInstance();

View File

@ -40,14 +40,14 @@ import 'package:spotube/services/audio_player/audio_player.dart';
}
});
var lastPosition = position.value;
// audioPlayer.positionStream is fired every 200ms and only 1s delay is
// enough. Thus only update the position if the difference is more than 1s
// Reduces CPU usage
var lastPosition = position.value;
final positionSubscription = audioPlayer.positionStream.listen((event) {
if (event.inMilliseconds > 1000 &&
event.inMilliseconds - lastPosition.inMilliseconds < 1000) return;
final diff = event.inMilliseconds - lastPosition.inMilliseconds;
if (event.inMilliseconds > 1000 && diff < 1000 && diff > 0) return;
lastPosition = event;
position.value = event;

View File

@ -24,8 +24,10 @@
"liked_tracks_description": "All your liked tracks",
"create_playlist": "Create Playlist",
"create_a_playlist": "Create a playlist",
"update_playlist": "Update playlist",
"create": "Create",
"cancel": "Cancel",
"update": "Update",
"playlist_name": "Playlist Name",
"name_of_playlist": "Name of the playlist",
"description": "Description",
@ -259,5 +261,7 @@
"piped_down_error_instructions": "The Piped instance {pipedInstance} is currently down\n\nEither change the instance or change the 'API type' to official YouTube API\n\nMake sure to restart the app after change",
"you_are_offline": "You are currently offline",
"connection_restored": "Your internet connection was restored",
"use_system_title_bar": "Use system title bar"
"use_system_title_bar": "Use system title bar",
"crunching_results": "Crunching results...",
"search_to_get_results": "Search to get results"
}

View File

@ -1,9 +1,7 @@
import 'dart:io';
import 'package:args/args.dart';
import 'package:catcher/catcher.dart';
import 'package:device_preview/device_preview.dart';
import 'package:fl_query/fl_query.dart';
import 'package:fl_query_devtools/fl_query_devtools.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@ -14,7 +12,6 @@ import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:media_kit/media_kit.dart';
import 'package:metadata_god/metadata_god.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/collections/routes.dart';
import 'package:spotube/collections/intents.dart';
@ -26,6 +23,7 @@ import 'package:spotube/models/skip_segment.dart';
import 'package:spotube/provider/palette_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/services/cli/cli.dart';
import 'package:spotube/services/connectivity_adapter.dart';
import 'package:spotube/themes/theme.dart';
import 'package:spotube/utils/persisted_state_notifier.dart';
@ -37,41 +35,7 @@ import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:flutter_displaymode/flutter_displaymode.dart';
Future<void> main(List<String> rawArgs) async {
final parser = ArgParser();
parser.addFlag(
'verbose',
abbr: 'v',
help: 'Verbose mode',
defaultsTo: !kReleaseMode,
callback: (verbose) {
if (verbose) {
logEnv['VERBOSE'] = 'true';
logEnv['DEBUG'] = 'true';
logEnv['ERROR'] = 'true';
}
},
);
parser.addFlag(
"version",
help: "Print version and exit",
negatable: false,
);
parser.addFlag("help", abbr: "h", negatable: false);
final arguments = parser.parse(rawArgs);
if (arguments["help"] == true) {
print(parser.usage);
exit(0);
}
if (arguments["version"] == true) {
final package = await PackageInfo.fromPlatform();
print("Spotube v${package.version}");
exit(0);
}
final arguments = await startCLI(rawArgs);
final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
@ -215,7 +179,7 @@ class SpotubeState extends ConsumerState<Spotube> {
};
}, []);
useDisableBatterOptimizations();
useDisableBatteryOptimizations();
final lightTheme = useMemoized(
() => theme(paletteColor ?? accentMaterialColor, Brightness.light),

View File

@ -85,10 +85,10 @@ class AlbumPage extends HookConsumerWidget {
album: album,
routePath: "/album/${album.id}",
bottomSpace: mediaQuery.mdAndDown,
onPlay: ([track]) {
onPlay: ([track]) async {
if (tracksSnapshot.hasData) {
if (!isAlbumPlaying) {
playPlaylist(
await playPlaylist(
tracksSnapshot.data!
.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album))
@ -96,7 +96,7 @@ class AlbumPage extends HookConsumerWidget {
ref,
);
} else if (isAlbumPlaying && track != null) {
playPlaylist(
await playPlaylist(
tracksSnapshot.data!
.map((track) =>
TypeConversionUtils.simpleTrack_X_Track(track, album))
@ -105,7 +105,7 @@ class AlbumPage extends HookConsumerWidget {
ref,
);
} else {
playback
await playback
.removeTracks(tracksSnapshot.data!.map((track) => track.id!));
}
}

View File

@ -390,7 +390,7 @@ class ArtistPage extends HookConsumerWidget {
return TrackTile(
index: i,
track: track,
onTap: () {
onTap: () async {
playPlaylist(
topTracks.toList(),
currentTrack: track,

View File

@ -1,6 +1,8 @@
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart';
@ -18,34 +20,8 @@ import 'package:spotube/utils/type_conversion_utils.dart';
class PlaylistView extends HookConsumerWidget {
final logger = getLogger(PlaylistView);
final PlaylistSimple playlist;
PlaylistView(this.playlist, {Key? key}) : super(key: key);
Future<void> playPlaylist(
List<Track> tracks,
WidgetRef ref, {
Track? currentTrack,
}) async {
final proxyPlaylist = ref.read(ProxyPlaylistNotifier.provider);
final playback = ref.read(ProxyPlaylistNotifier.notifier);
final sortBy = ref.read(trackCollectionSortState(playlist.id!));
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
currentTrack ??= sortedTracks.first;
final isPlaylistPlaying = proxyPlaylist.containsTracks(tracks);
if (!isPlaylistPlaying) {
playback.addCollection(playlist.id!); // for enabling loading indicator
await playback.load(
sortedTracks,
initialIndex: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
autoPlay: true,
);
playback.addCollection(playlist.id!);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != proxyPlaylist.activeTrack?.id) {
await playback.jumpToTrack(currentTrack);
}
}
final PlaylistSimple playlistSimple;
PlaylistView(this.playlistSimple, {Key? key}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
@ -55,7 +31,16 @@ class PlaylistView extends HookConsumerWidget {
final mediaQuery = MediaQuery.of(context);
final meSnapshot = useQueries.user.me(ref);
final tracksSnapshot = useQueries.playlist.tracksOfQuery(ref, playlist.id!);
final playlistQuery = useQueries.playlist.byId(ref, playlistSimple.id!);
final playlist = playlistQuery.data ?? playlistSimple;
final playlistTrackSnapshot =
useQueries.playlist.tracksOfQuery(ref, playlist.id!);
final likedTracksSnapshot = useQueries.playlist.likedTracksQuery(ref);
final tracksSnapshot = playlist.id! == "user-liked-tracks"
? likedTracksSnapshot
: playlistTrackSnapshot;
final isPlaylistPlaying = useMemoized(
() => proxyPlaylist.collections.contains(playlist.id!),
@ -78,6 +63,35 @@ class PlaylistView extends HookConsumerWidget {
[proxyPlaylist.activeTrack, tracksSnapshot.data],
);
final playPlaylist = useCallback((
List<Track> tracks,
WidgetRef ref, {
Track? currentTrack,
}) async {
final playback = ref.read(ProxyPlaylistNotifier.notifier);
final sortBy = ref.read(trackCollectionSortState(playlist.id!));
final sortedTracks = ServiceUtils.sortTracks(tracks, sortBy);
currentTrack ??= sortedTracks.first;
final isPlaylistPlaying = proxyPlaylist.containsTracks(tracks);
if (!isPlaylistPlaying) {
playback.addCollection(playlist.id!); // for enabling loading indicator
await playback.load(
sortedTracks,
initialIndex:
sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
autoPlay: true,
);
playback.addCollection(playlist.id!);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != proxyPlaylist.activeTrack?.id) {
await playback.jumpToTrack(currentTrack);
}
}, [proxyPlaylist, playlist]);
final ownPlaylist =
playlist.owner?.id != null && playlist.owner?.id == meSnapshot.data?.id;
return TrackCollectionView(
id: playlist.id!,
playingState: isPlaylistPlaying && playlistTrackPlaying
@ -89,24 +103,17 @@ class PlaylistView extends HookConsumerWidget {
titleImage: titleImage,
tracksSnapshot: tracksSnapshot,
description: playlist.description,
isOwned: playlist.owner?.id != null &&
playlist.owner!.id == meSnapshot.data?.id,
onPlay: ([track]) {
isOwned: ownPlaylist,
onPlay: ([track]) async {
if (tracksSnapshot.hasData) {
if (!isPlaylistPlaying) {
playPlaylist(
tracksSnapshot.data!,
ref,
currentTrack: track,
);
} else if (isPlaylistPlaying && track != null) {
playPlaylist(
if (!isPlaylistPlaying || (isPlaylistPlaying && track != null)) {
await playPlaylist(
tracksSnapshot.data!,
ref,
currentTrack: track,
);
} else {
playlistNotifier
await playlistNotifier
.removeTracks(tracksSnapshot.data!.map((e) => e.id!));
}
}
@ -137,7 +144,13 @@ class PlaylistView extends HookConsumerWidget {
);
});
},
heartBtn: PlaylistHeartButton(playlist: playlist),
heartBtn: PlaylistHeartButton(
playlist: playlist,
icon: ownPlaylist ? SpotubeIcons.trash : null,
onData: (data) {
GoRouter.of(context).pop();
},
),
onShuffledPlay: ([track]) {
final tracks = [...?tracksSnapshot.data]..shuffle();

View File

@ -55,13 +55,295 @@ class SearchPage extends HookConsumerWidget {
Future<void> onSearch() async {
await Future.wait([
searchTrack.refreshAll(),
searchAlbum.refreshAll(),
searchPlaylist.refreshAll(),
searchArtist.refreshAll(),
]);
searchTrack.reset(),
searchAlbum.reset(),
searchPlaylist.reset(),
searchArtist.reset(),
]).then((_) {
return Future.wait([
searchTrack.refreshAll(),
searchAlbum.refreshAll(),
searchPlaylist.refreshAll(),
searchArtist.refreshAll(),
]);
});
}
final queries = [searchTrack, searchAlbum, searchPlaylist, searchArtist];
final isFetching = queries.every(
(s) => s.isLoadingPage || s.isRefreshingPage || !s.hasPageData,
) &&
searchTerm.isNotEmpty;
final resultWidget = HookBuilder(
builder: (context) {
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
List<AlbumSimple> albums = [];
List<Artist> artists = [];
List<Track> tracks = [];
List<PlaylistSimple> playlists = [];
final pages = [
...searchTrack.pages,
...searchAlbum.pages,
...searchPlaylist.pages,
...searchArtist.pages,
].expand<Page>((page) => page).toList();
for (MapEntry<int, Page> page in pages.asMap().entries) {
for (var item in page.value.items ?? []) {
if (item is AlbumSimple) {
albums.add(item);
} else if (item is PlaylistSimple) {
playlists.add(item);
} else if (item is Artist) {
artists.add(item);
} else if (item is Track) {
tracks.add(item);
}
}
}
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (tracks.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
context.l10n.songs,
style: theme.textTheme.titleLarge!,
),
),
if (searchTrack.isLoadingPage)
const CircularProgressIndicator()
else if (searchTrack.hasPageError)
Text(
searchTrack.errors.lastOrNull?.toString() ?? "",
)
else
...tracks.mapIndexed((i, track) {
return TrackTile(
index: i,
track: track,
onTap: () async {
final isTrackPlaying =
playlist.activeTrack?.id == track.id;
if (!isTrackPlaying && context.mounted) {
final shouldPlay = (playlist.tracks.length) > 20
? await showPromptDialog(
context: context,
title: context.l10n.playing_track(
track.name!,
),
message: context.l10n.queue_clear_alert(
playlist.tracks.length,
),
)
: true;
if (shouldPlay) {
await playlistNotifier.load(
[track],
autoPlay: true,
);
}
}
},
);
}),
if (searchTrack.hasNextPage && tracks.isNotEmpty)
Center(
child: TextButton(
onPressed: searchTrack.isRefreshingPage
? null
: () => searchTrack.fetchNext(),
child: searchTrack.isRefreshingPage
? const CircularProgressIndicator()
: Text(context.l10n.load_more),
),
),
if (playlists.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
context.l10n.playlists,
style: theme.textTheme.titleLarge!,
),
),
const SizedBox(height: 10),
ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
},
),
child: Scrollbar(
scrollbarOrientation: mediaQuery.lgAndUp
? ScrollbarOrientation.bottom
: ScrollbarOrientation.top,
controller: playlistController,
child: Waypoint(
onTouchEdge: () {
searchPlaylist.fetchNext();
},
controller: playlistController,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: playlistController,
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
...playlists.mapIndexed(
(i, playlist) {
if (i == playlists.length - 1 &&
searchPlaylist.hasNextPage) {
return const ShimmerPlaybuttonCard(
count: 1);
}
return PlaylistCard(playlist);
},
),
],
),
),
),
),
),
if (searchPlaylist.isLoadingPage)
const CircularProgressIndicator(),
if (searchPlaylist.hasPageError)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
searchPlaylist.errors.lastOrNull?.toString() ?? "",
),
),
const SizedBox(height: 20),
if (artists.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
context.l10n.artists,
style: theme.textTheme.titleLarge!,
),
),
const SizedBox(height: 10),
ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
},
),
child: Scrollbar(
controller: artistController,
child: Waypoint(
controller: artistController,
onTouchEdge: () {
searchArtist.fetchNext();
},
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: artistController,
child: Row(
children: [
...artists.mapIndexed(
(i, artist) {
if (i == artists.length - 1 &&
searchArtist.hasNextPage) {
return const ShimmerPlaybuttonCard(
count: 1);
}
return Container(
margin: const EdgeInsets.symmetric(
horizontal: 15),
child: ArtistCard(artist),
);
},
),
],
),
),
),
),
),
if (searchArtist.isLoadingPage)
const CircularProgressIndicator(),
if (searchArtist.hasPageError)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
searchArtist.errors.lastOrNull?.toString() ?? "",
),
),
const SizedBox(height: 20),
if (albums.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
context.l10n.albums,
style: theme.textTheme.titleLarge!,
),
),
const SizedBox(height: 10),
ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
},
),
child: Scrollbar(
controller: albumController,
child: Waypoint(
controller: albumController,
onTouchEdge: () {
searchAlbum.fetchNext();
},
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: albumController,
child: Row(
children: [
...albums.mapIndexed((i, album) {
if (i == albums.length - 1 &&
searchAlbum.hasNextPage) {
return const ShimmerPlaybuttonCard(count: 1);
}
return AlbumCard(
TypeConversionUtils.simpleAlbum_X_Album(
album,
),
);
}),
],
),
),
),
),
),
if (searchAlbum.isLoadingPage)
const CircularProgressIndicator(),
if (searchAlbum.hasPageError)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Text(
searchAlbum.errors.lastOrNull?.toString() ?? "",
),
),
],
),
),
),
);
},
);
return SafeArea(
bottom: false,
child: Scaffold(
@ -77,7 +359,7 @@ class SearchPage extends HookConsumerWidget {
),
color: theme.scaffoldBackgroundColor,
child: TextField(
autofocus: true,
autofocus: queries.none((s) => s.hasPageData),
decoration: InputDecoration(
prefixIcon: const Icon(SpotubeIcons.search),
hintText: "${context.l10n.search}...",
@ -93,283 +375,64 @@ class SearchPage extends HookConsumerWidget {
},
),
),
HookBuilder(
builder: (context) {
final playlist =
ref.watch(ProxyPlaylistNotifier.provider);
final playlistNotifier =
ref.watch(ProxyPlaylistNotifier.notifier);
List<AlbumSimple> albums = [];
List<Artist> artists = [];
List<Track> tracks = [];
List<PlaylistSimple> playlists = [];
final pages = [
...searchTrack.pages,
...searchAlbum.pages,
...searchPlaylist.pages,
...searchArtist.pages,
].expand<Page>((page) => page).toList();
for (MapEntry<int, Page> page in pages.asMap().entries) {
for (var item in page.value.items ?? []) {
if (item is AlbumSimple) {
albums.add(item);
} else if (item is PlaylistSimple) {
playlists.add(item);
} else if (item is Artist) {
artists.add(item);
} else if (item is Track) {
tracks.add(item);
}
}
}
return Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 8,
horizontal: 20,
),
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (tracks.isNotEmpty)
Text(
context.l10n.songs,
style: theme.textTheme.titleLarge!,
),
if (searchTrack.isLoadingPage)
const CircularProgressIndicator()
else if (searchTrack.hasPageError)
Text(
searchTrack.errors.lastOrNull
?.toString() ??
"",
)
else
...tracks.mapIndexed((i, track) {
return TrackTile(
index: i,
track: track,
onTap: () async {
final isTrackPlaying =
playlist.activeTrack?.id ==
track.id;
if (!isTrackPlaying &&
context.mounted) {
final shouldPlay =
(playlist.tracks.length) > 20
? await showPromptDialog(
context: context,
title: context.l10n
.playing_track(
track.name!,
),
message: context.l10n
.queue_clear_alert(
playlist
.tracks.length,
),
)
: true;
if (shouldPlay) {
await playlistNotifier.load(
[track],
autoPlay: true,
);
}
}
},
);
}),
if (searchTrack.hasNextPage &&
tracks.isNotEmpty)
Center(
child: TextButton(
onPressed: searchTrack.isRefreshingPage
? null
: () => searchTrack.fetchNext(),
child: searchTrack.isRefreshingPage
? const CircularProgressIndicator()
: Text(context.l10n.load_more),
),
),
if (playlists.isNotEmpty)
Text(
context.l10n.playlists,
style: theme.textTheme.titleLarge!,
),
const SizedBox(height: 10),
ScrollConfiguration(
behavior: ScrollConfiguration.of(context)
.copyWith(
dragDevices: {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
},
),
child: Scrollbar(
scrollbarOrientation: mediaQuery.lgAndUp
? ScrollbarOrientation.bottom
: ScrollbarOrientation.top,
controller: playlistController,
child: Waypoint(
onTouchEdge: () {
searchPlaylist.fetchNext();
},
controller: playlistController,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: playlistController,
child: Row(
children: [
...playlists.mapIndexed(
(i, playlist) {
if (i ==
playlists.length -
1 &&
searchPlaylist
.hasNextPage) {
return const ShimmerPlaybuttonCard(
count: 1);
}
return PlaylistCard(playlist);
},
),
],
),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: searchTerm.isEmpty
? Column(
children: [
SizedBox(
height: mediaQuery.size.height * 0.2,
),
Icon(
SpotubeIcons.web,
size: 120,
color: theme.colorScheme.onBackground
.withOpacity(0.7),
),
const SizedBox(height: 20),
Text(
context.l10n.search_to_get_results,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
color: theme.colorScheme.onBackground
.withOpacity(0.5),
),
),
],
)
: isFetching
? Container(
constraints: BoxConstraints(
maxWidth: mediaQuery.lgAndUp
? mediaQuery.size.width * 0.5
: mediaQuery.size.width,
),
padding: const EdgeInsets.symmetric(
horizontal: 20,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
Text(
context.l10n.crunching_results,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w900,
color: theme.colorScheme.onBackground
.withOpacity(0.7),
),
),
),
const SizedBox(height: 20),
const LinearProgressIndicator(),
],
),
if (searchPlaylist.isLoadingPage)
const CircularProgressIndicator(),
if (searchPlaylist.hasPageError)
Text(
searchPlaylist.errors.lastOrNull
?.toString() ??
"",
),
const SizedBox(height: 20),
if (artists.isNotEmpty)
Text(
context.l10n.artists,
style: theme.textTheme.titleLarge!,
),
const SizedBox(height: 10),
ScrollConfiguration(
behavior: ScrollConfiguration.of(context)
.copyWith(
dragDevices: {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
},
),
child: Scrollbar(
controller: artistController,
child: Waypoint(
controller: artistController,
onTouchEdge: () {
searchArtist.fetchNext();
},
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: artistController,
child: Row(
children: [
...artists.mapIndexed(
(i, artist) {
if (i == artists.length - 1 &&
searchArtist
.hasNextPage) {
return const ShimmerPlaybuttonCard(
count: 1);
}
return Container(
margin: const EdgeInsets
.symmetric(
horizontal: 15),
child: ArtistCard(artist),
);
},
),
],
),
),
),
),
),
if (searchArtist.isLoadingPage)
const CircularProgressIndicator(),
if (searchArtist.hasPageError)
Text(
searchArtist.errors.lastOrNull
?.toString() ??
"",
),
const SizedBox(height: 20),
if (albums.isNotEmpty)
Text(
context.l10n.albums,
style: theme.textTheme.titleMedium!,
),
const SizedBox(height: 10),
ScrollConfiguration(
behavior: ScrollConfiguration.of(context)
.copyWith(
dragDevices: {
PointerDeviceKind.touch,
PointerDeviceKind.mouse,
},
),
child: Scrollbar(
controller: albumController,
child: Waypoint(
controller: albumController,
onTouchEdge: () {
searchAlbum.fetchNext();
},
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: albumController,
child: Row(
children: [
...albums.mapIndexed((i, album) {
if (i == albums.length - 1 &&
searchAlbum.hasNextPage) {
return const ShimmerPlaybuttonCard(
count: 1);
}
return AlbumCard(
TypeConversionUtils
.simpleAlbum_X_Album(
album,
),
);
}),
],
),
),
),
),
),
if (searchAlbum.isLoadingPage)
const CircularProgressIndicator(),
if (searchAlbum.hasPageError)
Text(
searchAlbum.errors.lastOrNull
?.toString() ??
"",
),
],
),
),
),
),
);
},
)
)
: resultWidget,
),
),
],
),
),

View File

@ -1,6 +1,6 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:collection/collection.dart';
import 'package:file_picker/file_picker.dart';
import 'package:file_selector/file_selector.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
@ -11,7 +11,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:piped_client/piped_client.dart';
import 'package:spotube/collections/env.dart';
import 'package:spotube/collections/language_codes.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/settings/color_scheme_picker_dialog.dart';
import 'package:spotube/components/settings/section_card_with_heading.dart';
@ -47,8 +46,8 @@ class SettingsPage extends HookConsumerWidget {
}, []);
final pickDownloadLocation = useCallback(() async {
final dirStr = await FilePicker.platform.getDirectoryPath(
dialogTitle: context.l10n.download_location,
final dirStr = await getDirectoryPath(
initialDirectory: preferences.downloadLocation,
);
if (dirStr == null) return;
preferences.setDownloadLocation(dirStr);

46
lib/services/cli/cli.dart Normal file
View File

@ -0,0 +1,46 @@
import 'dart:io';
import 'package:args/args.dart';
import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:spotube/models/logger.dart';
Future<ArgResults> startCLI(List<String> args) async {
final parser = ArgParser();
parser.addFlag(
'verbose',
abbr: 'v',
help: 'Verbose mode',
defaultsTo: !kReleaseMode,
callback: (verbose) {
if (verbose) {
logEnv['VERBOSE'] = 'true';
logEnv['DEBUG'] = 'true';
logEnv['ERROR'] = 'true';
}
},
);
parser.addFlag(
"version",
help: "Print version and exit",
negatable: false,
);
parser.addFlag("help", abbr: "h", negatable: false);
final arguments = parser.parse(args);
if (arguments["help"] == true) {
print(parser.usage);
exit(0);
}
if (arguments["version"] == true) {
final package = await PackageInfo.fromPlatform();
print("Spotube v${package.version}");
exit(0);
}
return arguments;
}

View File

@ -1,7 +1,17 @@
import 'package:fl_query/fl_query.dart';
import 'package:fl_query_hooks/fl_query_hooks.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/hooks/use_spotify_mutation.dart';
import 'package:spotube/services/queries/queries.dart';
typedef PlaylistCRUDVariables = ({
String playlistName,
bool? public,
bool? collaborative,
String? description,
String? base64Image,
});
class PlaylistMutations {
const PlaylistMutations();
@ -11,8 +21,8 @@ class PlaylistMutations {
String playlistId, {
List<String>? refreshQueries,
List<String>? refreshInfiniteQueries,
ValueChanged<bool>? onData,
}) {
final queryClient = useQueryClient();
return useSpotifyMutation<bool, dynamic, bool, dynamic>(
"toggle-playlist-like/$playlistId",
(isLiked, spotify) async {
@ -25,10 +35,12 @@ class PlaylistMutations {
},
ref: ref,
refreshQueries: refreshQueries,
refreshInfiniteQueries: refreshInfiniteQueries,
onData: (data, recoveryData) async {
await queryClient
.refreshInfiniteQueryAllPages("current-user-playlists");
refreshInfiniteQueries: [
...?refreshInfiniteQueries,
"current-user-playlists",
],
onData: (data, recoveryData) {
onData?.call(data);
},
);
}
@ -47,4 +59,91 @@ class PlaylistMutations {
refreshQueries: ["playlist-tracks/$playlistId"],
);
}
Mutation<Playlist, dynamic, PlaylistCRUDVariables> create(
WidgetRef ref, {
List<String>? trackIds,
ValueChanged<dynamic>? onError,
ValueChanged<Playlist>? onData,
}) {
final me = useQueries.user.me(ref);
return useSpotifyMutation<Playlist, dynamic, PlaylistCRUDVariables, void>(
"create-playlist",
(variable, spotify) async {
final playlist = await spotify.playlists.createPlaylist(
me.data!.id!,
variable.playlistName,
collaborative: variable.collaborative,
description: variable.description,
public: variable.public,
);
if (variable.base64Image != null) {
await spotify.playlists.updatePlaylistImage(
playlist.id!,
variable.base64Image!,
);
}
if (trackIds != null && trackIds.isNotEmpty) {
await spotify.playlists.addTracks(
trackIds.map((id) => "spotify:track:$id").toList(),
playlist.id!,
);
}
return playlist;
},
refreshInfiniteQueries: [
"current-user-playlists",
],
ref: ref,
onError: (error, recoveryData) {
onError?.call(error);
},
onData: (data, recoveryData) {
onData?.call(data);
},
);
}
Mutation<void, dynamic, PlaylistCRUDVariables> update(
WidgetRef ref, {
String? playlistId,
ValueChanged<dynamic>? onError,
ValueChanged<void>? onData,
}) {
return useSpotifyMutation<void, dynamic, PlaylistCRUDVariables, void>(
"update-playlist/$playlistId",
(variable, spotify) async {
if (playlistId == null) return;
await spotify.playlists.updatePlaylist(
playlistId,
variable.playlistName,
collaborative: variable.collaborative,
description: variable.description,
public: variable.public,
);
if (variable.base64Image != null) {
await spotify.playlists.updatePlaylistImage(
playlistId,
variable.base64Image!,
);
}
},
refreshQueries: [
"playlist/$playlistId",
],
refreshInfiniteQueries: [
"current-user-playlists",
],
ref: ref,
onError: (error, recoveryData) {
onError?.call(error);
},
onData: (data, recoveryData) {
onData?.call(data);
},
);
}
}

View File

@ -1,3 +1,6 @@
import 'dart:io';
import 'dart:math';
import 'package:catcher/catcher.dart';
import 'package:fl_query/fl_query.dart';
import 'package:fl_query_hooks/fl_query_hooks.dart';
@ -10,6 +13,7 @@ import 'package:spotube/extensions/track.dart';
import 'package:spotube/hooks/use_spotify_infinite_query.dart';
import 'package:spotube/hooks/use_spotify_query.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/custom_spotify_endpoint_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
@ -142,14 +146,49 @@ class PlaylistQueries {
);
}
Future<List<Track>> tracksOf(String playlistId, SpotifyApi spotify) {
if (playlistId == "user-liked-tracks") {
return spotify.tracks.me.saved.all().then(
(tracks) => tracks.map((e) => e.track!).toList(),
);
}
Future<List<Track>> likedTracks(
SpotifyApi spotify,
WidgetRef ref,
) async {
final tracks = await spotify.tracks.me.saved.all();
return tracks.map((e) => e.track!).toList();
}
Query<List<Track>, dynamic> likedTracksQuery(WidgetRef ref) {
final query = useCallback((spotify) => likedTracks(spotify, ref), []);
final context = useContext();
return useSpotifyQuery<List<Track>, dynamic>(
"user-liked-tracks",
query,
jsonConfig: JsonConfig(
toJson: (tracks) => <String, dynamic>{
'tracks': tracks.map((e) => e.toJson()).toList(),
},
fromJson: (json) => (json['tracks'] as List)
.map(
(e) => Track.fromJson((e as Map).castKeyDeep<String>()),
)
.toList(),
),
refreshConfig: RefreshConfig.withDefaults(
context,
// will never make it stale
staleDuration: const Duration(days: 60),
),
ref: ref,
);
}
Future<List<Track>> tracksOf(
String playlistId,
SpotifyApi spotify,
WidgetRef ref,
) async {
if (playlistId == "user-liked-tracks") return <Track>[];
return spotify.playlists.getTracksByPlaylistId(playlistId).all().then(
(value) => value.toList(),
(value) => value.where((track) => track.id != null).toList(),
);
}
@ -159,19 +198,17 @@ class PlaylistQueries {
) {
return useSpotifyQuery<List<Track>, dynamic>(
"playlist-tracks/$playlistId",
(spotify) => tracksOf(playlistId, spotify),
jsonConfig: playlistId == "user-liked-tracks"
? JsonConfig(
toJson: (tracks) => <String, dynamic>{
'tracks': tracks.map((e) => e.toJson()).toList()
},
fromJson: (json) => (json['tracks'] as List)
.map((e) => Track.fromJson(
(e as Map).castKeyDeep<String>(),
))
.toList(),
)
: null,
(spotify) => tracksOf(playlistId, spotify, ref),
ref: ref,
);
}
Query<Playlist, dynamic> byId(WidgetRef ref, String id) {
return useSpotifyQuery<Playlist, dynamic>(
"playlist/$id",
(spotify) async {
return await spotify.playlists.get(id);
},
ref: ref,
);
}

View File

@ -1,4 +1,5 @@
import 'package:fl_query/fl_query.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/hooks/use_spotify_query.dart';
@ -8,6 +9,8 @@ import 'package:spotube/utils/type_conversion_utils.dart';
class UserQueries {
const UserQueries();
Query<User?, dynamic> me(WidgetRef ref) {
final context = useContext();
return useSpotifyQuery<User, dynamic>(
"current-user",
(spotify) async {
@ -26,6 +29,11 @@ class UserQueries {
}
return me;
},
refreshConfig: RefreshConfig.withDefaults(
context,
// will never make it stale
staleDuration: const Duration(days: 60),
),
ref: ref,
);
}

View File

@ -51,6 +51,7 @@ ThemeData theme(Color seed, Brightness brightness) {
sliderTheme: SliderThemeData(overlayShape: SliderComponentShape.noOverlay),
searchBarTheme: SearchBarThemeData(
constraints: const BoxConstraints(maxWidth: double.infinity),
padding: const MaterialStatePropertyAll(EdgeInsets.all(8)),
backgroundColor: MaterialStatePropertyAll(
Color.lerp(
scheme.surfaceVariant,

View File

@ -31,17 +31,6 @@ abstract class PrimitiveUtils {
}
}
static String zeroPadNumStr(int input) {
return input < 10 ? "0$input" : input.toString();
}
static String toReadableDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes % 60;
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),

View File

@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <catcher/catcher_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <local_notifier/local_notifier_plugin.h>
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
@ -21,6 +22,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) catcher_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "CatcherPlugin");
catcher_plugin_register_with_registrar(catcher_registrar);
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
catcher
file_selector_linux
flutter_secure_storage_linux
local_notifier
media_kit_libs_linux

View File

@ -16,6 +16,7 @@ dependencies:
- libsecret-1-0
- libnotify-bin
- libjsoncpp25
- libmpv2
essential: false
icon: assets/spotube-logo.png

View File

@ -9,6 +9,7 @@ import audio_service
import audio_session
import catcher
import device_info_plus
import file_selector_macos
import flutter_secure_storage_macos
import local_notifier
import media_kit_libs_macos_audio
@ -28,6 +29,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
CatcherPlugin.register(with: registry.registrar(forPlugin: "CatcherPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
LocalNotifierPlugin.register(with: registry.registrar(forPlugin: "LocalNotifierPlugin"))
MediaKitLibsMacosAudioPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosAudioPlugin"))

View File

@ -19,5 +19,8 @@
<!-- Requires Certification -->
<!-- <key>keychain-access-groups</key>
<array /> -->
<!-- FilePicker -->
<key>com.apple.security.files.user-selected.read-write</key>
<true />
</dict>
</plist>

View File

@ -1,39 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>$(PRODUCT_COPYRIGHT)</string>
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoadsForMedia</key>
<true/>
</dict>
</dict>
</plist>
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconFile</key>
<string></string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSMinimumSystemVersion</key>
<string>$(MACOSX_DEPLOYMENT_TARGET)</string>
<key>NSHumanReadableCopyright</key>
<string>$(PRODUCT_COPYRIGHT)</string>
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true />
<key>NSAllowsArbitraryLoadsForMedia</key>
<true />
</dict>
</dict>
</plist>

View File

@ -17,5 +17,8 @@
<!-- Requires Certification -->
<!-- <key>keychain-access-groups</key>
<array /> -->
<!-- FilePicker -->
<key>com.apple.security.files.user-selected.read-write</key>
<true />
</dict>
</plist>

View File

@ -338,6 +338,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.1"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: fd832b5384d0d6da4f6df60b854d33accaaeb63aa9e10e736a87381f08dee2cb
url: "https://pub.dev"
source: hosted
version: "0.3.3+5"
crypto:
dependency: transitive
description:
@ -498,14 +506,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.4"
file_picker:
file_selector:
dependency: "direct main"
description:
name: file_picker
sha256: c7a8e25ca60e7f331b153b0cb3d405828f18d3e72a6fa1d9440c86556fffc877
name: file_selector
sha256: "84eaf3e034d647859167d1f01cfe7b6352488f34c1b4932635012b202014c25b"
url: "https://pub.dev"
source: hosted
version: "5.3.0"
version: "1.0.1"
file_selector_android:
dependency: transitive
description:
name: file_selector_android
sha256: d41e165d6f798ca941d536e5dc93494d50e78c571c28ad60cfe0b0fefeb9f1e7
url: "https://pub.dev"
source: hosted
version: "0.5.0+3"
file_selector_ios:
dependency: transitive
description:
name: file_selector_ios
sha256: b3fbdda64aa2e335df6e111f6b0f1bb968402ed81d2dd1fa4274267999aa32c2
url: "https://pub.dev"
source: hosted
version: "0.5.1+6"
file_selector_linux:
dependency: transitive
description:
name: file_selector_linux
sha256: "045d372bf19b02aeb69cacf8b4009555fb5f6f0b7ad8016e5f46dd1387ddd492"
url: "https://pub.dev"
source: hosted
version: "0.9.2+1"
file_selector_macos:
dependency: transitive
description:
name: file_selector_macos
sha256: "182c3f8350cee659f7b115e956047ee3dc672a96665883a545e81581b9a82c72"
url: "https://pub.dev"
source: hosted
version: "0.9.3+2"
file_selector_platform_interface:
dependency: transitive
description:
name: file_selector_platform_interface
sha256: "0aa47a725c346825a2bd396343ce63ac00bda6eff2fbc43eabe99737dede8262"
url: "https://pub.dev"
source: hosted
version: "2.6.1"
file_selector_web:
dependency: transitive
description:
name: file_selector_web
sha256: dc6622c4d66cb1bee623ddcc029036603c6cc45c85e4a775bb06008d61c809c1
url: "https://pub.dev"
source: hosted
version: "0.9.2+1"
file_selector_windows:
dependency: transitive
description:
name: file_selector_windows
sha256: d3547240c20cabf205c7c7f01a50ecdbc413755814d6677f3cb366f04abcead0
url: "https://pub.dev"
source: hosted
version: "0.9.3+1"
fixnum:
dependency: transitive
description:
@ -518,18 +582,26 @@ packages:
dependency: "direct main"
description:
name: fl_query
sha256: "64f482fc09eb1166adca232f68772b2b11c616d88bce3208b2753c940ebc9f71"
sha256: "3d71cd1eeb3232efa5e32363a351d74fd9ff07c6eb80aeb672b1970962764945"
url: "https://pub.dev"
source: hosted
version: "1.0.0-alpha.3"
version: "1.0.0-alpha.4"
fl_query_devtools:
dependency: "direct main"
description:
name: fl_query_devtools
sha256: "72fac45293902b9f99c726609cd5416573566cce0b7c6e27311efde7fdf1b8b1"
url: "https://pub.dev"
source: hosted
version: "0.1.0-alpha.2"
fl_query_hooks:
dependency: "direct main"
description:
name: fl_query_hooks
sha256: b0ffc81fb047cbcedd9766776f9c72b95382730ce173226f0695c3f45774b0bc
sha256: "7f0880696666714f77981777509a8aedb765857dcdbdde23e623da20a24c4ae0"
url: "https://pub.dev"
source: hosted
version: "1.0.0-alpha.3"
version: "1.0.0-alpha.4+1"
fluentui_system_icons:
dependency: "direct main"
description:
@ -694,10 +766,10 @@ packages:
dependency: "direct main"
description:
name: flutter_riverpod
sha256: "0c997763ce06359ee4686553b74def84062e9d6929ac63f61fa02465c1f8e32c"
sha256: "1bd39b04f1bcd217a969589777ca6bd642d116e3e5de65c3e6a8e8bdd8b178ec"
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.4.0"
flutter_rust_bridge:
dependency: transitive
description:
@ -780,6 +852,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "8.1.3"
form_validator:
dependency: "direct main"
description:
name: form_validator
sha256: "8cbe91b7d5260870d6fb9e23acd55d5d1d1fdf2397f0279a4931ac3c0c7bf8fb"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
freezed_annotation:
dependency: transitive
description:
@ -893,10 +973,10 @@ packages:
dependency: "direct main"
description:
name: hooks_riverpod
sha256: "71695b2e1dfc22a39f1f9c67b798f8f8f1521f2d0349817d13ccdd5c4cd7acba"
sha256: ad7b877c3687e38764633d221a1f65491bc7a540e724101e9a404a84db2a4276
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.4.0"
html:
dependency: "direct main"
description:
@ -937,6 +1017,70 @@ packages:
url: "https://pub.dev"
source: hosted
version: "4.0.17"
image_picker:
dependency: "direct main"
description:
name: image_picker
sha256: "7d7f2768df2a8b0a3cefa5ef4f84636121987d403130e70b17ef7e2cf650ba84"
url: "https://pub.dev"
source: hosted
version: "1.0.4"
image_picker_android:
dependency: transitive
description:
name: image_picker_android
sha256: d32a997bcc4ee135aebca8e272b7c517927aa65a74b9c60a81a2764ef1a0462d
url: "https://pub.dev"
source: hosted
version: "0.8.7+5"
image_picker_for_web:
dependency: transitive
description:
name: image_picker_for_web
sha256: "50bc9ae6a77eea3a8b11af5eb6c661eeb858fdd2f734c2a4fd17086922347ef7"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
image_picker_ios:
dependency: transitive
description:
name: image_picker_ios
sha256: c5538cacefacac733c724be7484377923b476216ad1ead35a0d2eadcdc0fc497
url: "https://pub.dev"
source: hosted
version: "0.8.8+2"
image_picker_linux:
dependency: transitive
description:
name: image_picker_linux
sha256: "4ed1d9bb36f7cd60aa6e6cd479779cc56a4cb4e4de8f49d487b1aaad831300fa"
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
image_picker_macos:
dependency: transitive
description:
name: image_picker_macos
sha256: "3f5ad1e8112a9a6111c46d0b57a7be2286a9a07fc6e1976fdf5be2bd31d4ff62"
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
image_picker_platform_interface:
dependency: transitive
description:
name: image_picker_platform_interface
sha256: ed9b00e63977c93b0d2d2b343685bed9c324534ba5abafbb3dfbd6a780b1b514
url: "https://pub.dev"
source: hosted
version: "2.9.1"
image_picker_windows:
dependency: transitive
description:
name: image_picker_windows
sha256: "6ad07afc4eb1bc25f3a01084d28520496c4a3bb0cb13685435838167c9dcedeb"
url: "https://pub.dev"
source: hosted
version: "0.2.1+1"
integration_test:
dependency: "direct dev"
description: flutter
@ -998,6 +1142,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.6.2"
json_view:
dependency: transitive
description:
name: json_view
sha256: "905c69f9e69d1eab5406b87ab6c10c3706c04c70c6a4959621bd2b43c2d27374"
url: "https://pub.dev"
source: hosted
version: "0.4.2"
jwt_decode:
dependency: transitive
description:
@ -1474,10 +1626,10 @@ packages:
dependency: transitive
description:
name: riverpod
sha256: "0f43c64f1f79c2112c843305a879a746587fb7c1e388f1d4717737796756e2c4"
sha256: a600120d6f213a9922860eea1abc32597436edd5b2c4e73b91410f8c2af67d22
url: "https://pub.dev"
source: hosted
version: "2.1.3"
version: "2.4.0"
rxdart:
dependency: transitive
description:
@ -1610,10 +1762,10 @@ packages:
dependency: "direct main"
description:
name: skeleton_text
sha256: "6e088723b97ddcccfcce45312ce5e385ed1e5139a57afdf574f753d51eaa77f1"
sha256: bacd536bf0664efe1cae53bcbd78c3d4040a120f300f69dc85d83f358471cc6c
url: "https://pub.dev"
source: hosted
version: "3.0.0"
version: "3.0.1"
sky_engine:
dependency: transitive
description: flutter

View File

@ -33,9 +33,9 @@ dependencies:
disable_battery_optimization: ^1.1.0+1
duration: ^3.0.12
envied: ^0.3.0
file_picker: ^5.2.2
fl_query: ^1.0.0-alpha.3
fl_query_hooks: ^1.0.0-alpha.3
fl_query: ^1.0.0-alpha.4
fl_query_hooks: ^1.0.0-alpha.4+1
fl_query_devtools: ^0.1.0-alpha.2
fluentui_system_icons: ^1.1.189
flutter:
sdk: flutter
@ -54,6 +54,7 @@ dependencies:
flutter_riverpod: ^2.1.1
flutter_secure_storage: ^8.0.0
flutter_svg: ^1.1.6
form_validator: ^2.1.1
fuzzywuzzy: ^0.2.0
google_fonts: ^5.1.0
go_router: ^10.0.0
@ -81,7 +82,7 @@ dependencies:
scroll_to_index: ^3.0.1
shared_preferences: ^2.0.11
sidebarx: ^0.15.0
skeleton_text: ^3.0.0
skeleton_text: ^3.0.1
smtc_windows: ^0.1.0
spotify: ^0.11.0
supabase: ^1.9.9
@ -99,6 +100,8 @@ dependencies:
path: plugins/window_size
youtube_explode_dart: ^2.0.1
stroke_text: ^0.0.2
image_picker: ^1.0.4
file_selector: ^1.0.1
dev_dependencies:
build_runner: ^2.3.2
@ -118,7 +121,6 @@ dev_dependencies:
dependency_overrides:
http: ^1.1.0
flutter_hooks: ^0.20.0
flutter:
generate: true

View File

@ -1 +1,78 @@
{}
{
"bn": [
"update_playlist",
"update",
"crunching_results",
"search_to_get_results"
],
"ca": [
"update_playlist",
"update",
"crunching_results",
"search_to_get_results"
],
"de": [
"update_playlist",
"update",
"crunching_results",
"search_to_get_results"
],
"es": [
"update_playlist",
"update",
"crunching_results",
"search_to_get_results"
],
"fr": [
"update_playlist",
"update",
"crunching_results",
"search_to_get_results"
],
"hi": [
"update_playlist",
"update",
"crunching_results",
"search_to_get_results"
],
"ja": [
"update_playlist",
"update",
"crunching_results",
"search_to_get_results"
],
"pl": [
"update_playlist",
"update",
"crunching_results",
"search_to_get_results"
],
"pt": [
"update_playlist",
"update",
"crunching_results",
"search_to_get_results"
],
"ru": [
"update_playlist",
"update",
"crunching_results",
"search_to_get_results"
],
"zh": [
"update_playlist",
"update",
"crunching_results",
"search_to_get_results"
]
}

View File

@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h"
#include <catcher/catcher_plugin.h>
#include <file_selector_windows/file_selector_windows.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <local_notifier/local_notifier_plugin.h>
#include <media_kit_libs_windows_audio/media_kit_libs_windows_audio_plugin_c_api.h>
@ -21,6 +22,8 @@
void RegisterPlugins(flutter::PluginRegistry* registry) {
CatcherPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("CatcherPlugin"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
LocalNotifierPluginRegisterWithRegistrar(

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
catcher
file_selector_windows
flutter_secure_storage_windows
local_notifier
media_kit_libs_windows_audio