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

View File

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

View File

@ -1,58 +1,64 @@
<?xml version="1.0" encoding="UTF-8"?> <?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"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Sptube</string> <string>Sptube</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>spotube</string> <string>spotube</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string> <string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string> <string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true />
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>
<string>LaunchScreen</string> <string>LaunchScreen</string>
<key>UIMainStoryboardFile</key> <key>UIMainStoryboardFile</key>
<string>Main</string> <string>Main</string>
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations~ipad</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string> <string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string> <string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string> <string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UIViewControllerBasedStatusBarAppearance</key> <key>UIViewControllerBasedStatusBarAppearance</key>
<true/> <true />
<key>NSAppTransportSecurity</key> <key>NSAppTransportSecurity</key>
<dict> <dict>
<key>NSAllowsArbitraryLoads</key> <key>NSAllowsArbitraryLoads</key>
<true/> <true />
<key>NSAllowsArbitraryLoadsForMedia</key> <key>NSAllowsArbitraryLoadsForMedia</key>
<true/> <true />
</dict> </dict>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true />
<key>UIStatusBarHidden</key> <key>UIStatusBarHidden</key>
<false/> <false />
</dict> <key>NSPhotoLibraryUsageDescription</key>
</plist> <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 noWifi = FeatherIcons.wifiOff;
static const wifi = FeatherIcons.wifi; static const wifi = FeatherIcons.wifi;
static const window = Icons.window_rounded; 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 { class UserLocalTracks extends HookConsumerWidget {
const UserLocalTracks({Key? key}) : super(key: key); const UserLocalTracks({Key? key}) : super(key: key);
void playLocalTracks( Future<void> playLocalTracks(
WidgetRef ref, WidgetRef ref,
List<LocalTrack> tracks, { List<LocalTrack> tracks, {
LocalTrack? currentTrack, LocalTrack? currentTrack,
@ -203,10 +203,10 @@ class UserLocalTracks extends HookConsumerWidget {
const SizedBox(width: 10), const SizedBox(width: 10),
FilledButton( FilledButton(
onPressed: trackSnapshot.value != null onPressed: trackSnapshot.value != null
? () { ? () async {
if (trackSnapshot.value?.isNotEmpty == true) { if (trackSnapshot.value?.isNotEmpty == true) {
if (!isPlaylistPlaying) { if (!isPlaylistPlaying) {
playLocalTracks( await playLocalTracks(
ref, ref,
trackSnapshot.value!, trackSnapshot.value!,
); );
@ -295,8 +295,8 @@ class UserLocalTracks extends HookConsumerWidget {
index: index, index: index,
track: track, track: track,
userPlaylist: false, userPlaylist: false,
onTap: () { onTap: () async {
playLocalTracks( await playLocalTracks(
ref, ref,
sortedTracks, sortedTracks,
currentTrack: track, 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/spotube_icons.dart';
import 'package:spotube/collections/intents.dart'; import 'package:spotube/collections/intents.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart';
import 'package:spotube/hooks/use_progress.dart'; import 'package:spotube/hooks/use_progress.dart';
import 'package:spotube/models/logger.dart'; import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.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/audio_player.dart';
import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:spotube/services/audio_player/loop_mode.dart';
import 'package:spotube/utils/primitive_utils.dart';
class PlayerControls extends HookConsumerWidget { class PlayerControls extends HookConsumerWidget {
final PaletteGenerator? palette; final PaletteGenerator? palette;
@ -113,19 +113,6 @@ class PlayerControls extends HookConsumerWidget {
:progressStatic :progressStatic
) = useProgress(ref); ) = 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>( final progress = useState<num>(
useMemoized(() => progressStatic, []), useMemoized(() => progressStatic, []),
); );
@ -173,8 +160,8 @@ class PlayerControls extends HookConsumerWidget {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text("$currentMinutes:$currentSeconds"), Text(position.toHumanReadableString()),
Text("$totalMinutes:$totalSeconds"), Text(duration.toHumanReadableString()),
], ],
), ),
), ),

View File

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

View File

@ -1,16 +1,21 @@
import 'dart:ui'; import 'dart:ui';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:scroll_to_index/scroll_to_index.dart'; import 'package:scroll_to_index/scroll_to_index.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/fallbacks/not_found.dart';
import 'package:spotube/components/shared/track_table/track_tile.dart'; import 'package:spotube/components/shared/track_table/track_tile.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/use_auto_scroll_controller.dart'; import 'package:spotube/hooks/use_auto_scroll_controller.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlayerQueue extends HookConsumerWidget { class PlayerQueue extends HookConsumerWidget {
final bool floating; final bool floating;
@ -24,12 +29,11 @@ class PlayerQueue extends HookConsumerWidget {
final playlist = ref.watch(ProxyPlaylistNotifier.provider); final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final controller = useAutoScrollController(); final controller = useAutoScrollController();
final searchText = useState('');
final isSearching = useState(false);
final tracks = playlist.tracks; final tracks = playlist.tracks;
if (tracks.isEmpty) {
return const NotFound(vertical: true);
}
final borderRadius = floating final borderRadius = floating
? BorderRadius.circular(10) ? BorderRadius.circular(10)
: const BorderRadius.only( : const BorderRadius.only(
@ -39,6 +43,27 @@ class PlayerQueue extends HookConsumerWidget {
final theme = Theme.of(context); final theme = Theme.of(context);
final headlineColor = theme.textTheme.headlineSmall?.color; 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(() { useEffect(() {
if (playlist.active == null) return null; if (playlist.active == null) return null;
@ -50,6 +75,10 @@ class PlayerQueue extends HookConsumerWidget {
return null; return null;
}, []); }, []);
if (tracks.isEmpty) {
return const NotFound(vertical: true);
}
return BackdropFilter( return BackdropFilter(
filter: ImageFilter.blur( filter: ImageFilter.blur(
sigmaX: 12.0, sigmaX: 12.0,
@ -64,89 +93,172 @@ class PlayerQueue extends HookConsumerWidget {
color: theme.scaffoldBackgroundColor.withOpacity(0.5), color: theme.scaffoldBackgroundColor.withOpacity(0.5),
borderRadius: borderRadius, borderRadius: borderRadius,
), ),
child: Column( child: CallbackShortcuts(
children: [ bindings: {
Container( LogicalKeySet(LogicalKeyboardKey.escape): () {
height: 5, if (!isSearching.value) {
width: 100, Navigator.of(context).pop();
margin: const EdgeInsets.only(bottom: 5, top: 2), }
decoration: BoxDecoration( isSearching.value = false;
color: headlineColor, searchText.value = '';
borderRadius: BorderRadius.circular(20), }
), },
), child: LayoutBuilder(builder: (context, constraints) {
Row( return Column(
children: [ children: [
const SizedBox(width: 10), Container(
Text( height: 5,
context.l10n.tracks_in_queue(tracks.length), width: 100,
style: TextStyle( margin: const EdgeInsets.only(bottom: 5, top: 2),
decoration: BoxDecoration(
color: headlineColor, color: headlineColor,
fontWeight: FontWeight.bold, borderRadius: BorderRadius.circular(20),
fontSize: 18,
), ),
), ),
const Spacer(), Row(
FilledButton( crossAxisAlignment: CrossAxisAlignment.center,
style: FilledButton.styleFrom( mainAxisAlignment: MainAxisAlignment.center,
backgroundColor: children: [
theme.scaffoldBackgroundColor.withOpacity(0.5), if (constraints.mdAndUp || !isSearching.value) ...[
foregroundColor: theme.textTheme.headlineSmall?.color, const SizedBox(width: 10),
), Text(
child: Row( context.l10n.tracks_in_queue(tracks.length),
children: [ style: TextStyle(
const Icon(SpotubeIcons.playlistRemove), color: headlineColor,
const SizedBox(width: 5), fontWeight: FontWeight.bold,
Text(context.l10n.clear_all), fontSize: 18,
],
),
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),
),
],
), ),
), ),
); 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/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart';
import 'package:spotube/hooks/use_debounce.dart'; import 'package:spotube/hooks/use_debounce.dart';
import 'package:spotube/models/matched_track.dart'; import 'package:spotube/models/matched_track.dart';
import 'package:spotube/models/spotube_track.dart'; import 'package:spotube/models/spotube_track.dart';
@ -99,9 +100,7 @@ class SiblingTracksSheet extends HookConsumerWidget {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
), ),
trailing: Text( trailing: Text(video.duration.toHumanReadableString()),
PrimitiveUtils.toReadableDuration(video.duration),
),
subtitle: Text(video.channelName), subtitle: Text(video.channelName),
enabled: playlist.isFetching != true, enabled: playlist.isFetching != true,
selected: playlist.isFetching != true && selected: playlist.isFetching != true &&

View File

@ -32,6 +32,7 @@ class PlaylistCard extends HookConsumerWidget {
final updating = useState(false); final updating = useState(false);
final spotify = ref.watch(spotifyProvider); final spotify = ref.watch(spotifyProvider);
final me = useQueries.user.me(ref);
return PlaybuttonCard( return PlaybuttonCard(
margin: const EdgeInsets.symmetric(horizontal: 10), margin: const EdgeInsets.symmetric(horizontal: 10),
@ -44,6 +45,7 @@ class PlaylistCard extends HookConsumerWidget {
isPlaying: isPlaylistPlaying, isPlaying: isPlaylistPlaying,
isLoading: isLoading:
(isPlaylistPlaying && playlistQueue.isFetching) || updating.value, (isPlaylistPlaying && playlistQueue.isFetching) || updating.value,
isOwner: playlist.owner?.id == me.data?.id && me.data?.id != null,
onTap: () { onTap: () {
ServiceUtils.push( ServiceUtils.push(
context, context,
@ -60,11 +62,18 @@ class PlaylistCard extends HookConsumerWidget {
return audioPlayer.resume(); return audioPlayer.resume();
} }
List<Track> fetchedTracks = await queryBowl.fetchQuery( List<Track> fetchedTracks = playlist.id == 'user-liked-tracks'
"playlist-tracks/${playlist.id}", ? await queryBowl.fetchQuery(
() => useQueries.playlist.tracksOf(playlist.id!, spotify), "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; if (fetchedTracks.isEmpty) return;
@ -83,7 +92,7 @@ class PlaylistCard extends HookConsumerWidget {
if (isPlaylistPlaying) return; if (isPlaylistPlaying) return;
List<Track> fetchedTracks = await queryBowl.fetchQuery( List<Track> fetchedTracks = await queryBowl.fetchQuery(
"playlist-tracks/${playlist.id}", "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/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:form_validator/form_validator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:spotify/spotify.dart'; import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/spotify_provider.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 { class PlaylistCreateDialog extends HookConsumerWidget {
/// Track ids to add to the playlist /// Track ids to add to the playlist
final List<String> trackIds; final List<String> trackIds;
const PlaylistCreateDialog({ final String? playlistId;
PlaylistCreateDialog({
Key? key, Key? key,
this.trackIds = const [], this.trackIds = const [],
this.playlistId,
}) : super(key: key); }) : super(key: key);
final formKey = GlobalKey<FormState>();
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final spotify = ref.watch(spotifyProvider); return ScaffoldMessenger(
final playlistName = useTextEditingController(); child: Scaffold(
final description = useTextEditingController(); backgroundColor: Colors.transparent,
final public = useState(false); body: HookBuilder(builder: (context) {
final collaborative = useState(false); final userPlaylists = useQueries.playlist.ofMine(ref);
final client = useQueryClient(); final updatingPlaylist = useMemoized(
final navigator = Navigator.of(context); () => userPlaylists.pages
.expand((p) => p.items ?? <PlaylistSimple>[])
.firstWhereOrNull((playlist) => playlist.id == playlistId),
[
userPlaylists.pages,
playlistId,
],
);
Future<void> onCreate() async { final playlistName = useTextEditingController(
if (playlistName.text.isEmpty) return; text: updatingPlaylist?.name,
final me = await spotify.me.get(); );
final playlist = await spotify.playlists.createPlaylist( final description = useTextEditingController(
me.id!, text: updatingPlaylist?.description,
playlistName.text, );
collaborative: collaborative.value, final public = useState(
public: public.value, updatingPlaylist?.public ?? false,
description: description.text, );
); final collaborative = useState(
if (trackIds.isNotEmpty) { updatingPlaylist?.collaborative ?? false,
await spotify.playlists.addTracks( );
trackIds.map((id) => "spotify:track:$id").toList(), final image = useState<XFile?>(null);
playlist.id!,
);
}
await client
.getQuery(
"current-user-playlists",
)
?.refresh();
navigator.pop(playlist);
}
return AlertDialog( final isUpdatingPlaylist = playlistId != null;
title: Text(context.l10n.create_a_playlist),
actions: [ final l10n = context.l10n;
OutlinedButton( final theme = Theme.of(context);
child: Text(context.l10n.cancel), final scaffold = ScaffoldMessenger.of(context);
onPressed: () {
Navigator.pop(context); final onError = useCallback((error) {
}, if (error is SpotifyError || error is SpotifyException) {
), scaffold.showSnackBar(
FilledButton( SnackBar(
onPressed: onCreate, content: Text(
child: Text(context.l10n.create), l10n.error(error.message ?? "Epic failure!"),
), style: theme.textTheme.bodyMedium!.copyWith(
], color: theme.colorScheme.onError,
content: Container( ),
width: MediaQuery.of(context).size.width, ),
constraints: const BoxConstraints(maxWidth: 500), backgroundColor: theme.colorScheme.error,
child: ListView( ),
shrinkWrap: true, );
children: [ }
TextField( }, [scaffold, l10n, theme]);
controller: playlistName,
decoration: InputDecoration( final playlistCreateMutation = useMutations.playlist.create(
hintText: context.l10n.name_of_playlist, ref,
labelText: context.l10n.name_of_playlist, 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) { showPlaylistDialog(BuildContext context, SpotifyApi spotify) {
showDialog( showDialog(
context: context, context: context,
builder: (context) => const PlaylistCreateDialog(), builder: (context) => PlaylistCreateDialog(),
); );
} }
@ -132,11 +302,12 @@ class PlaylistCreateDialogButton extends HookConsumerWidget {
} }
return FilledButton.tonalIcon( return FilledButton.tonalIcon(
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.primary, foregroundColor: Theme.of(context).colorScheme.primary,
), ),
icon: const Icon(SpotubeIcons.addFilled), icon: const Icon(SpotubeIcons.addFilled),
label: Text(context.l10n.create_playlist), label: Text(context.l10n.create_playlist),
onPressed: () => showPlaylistDialog(context, spotify)); onPressed: () => showPlaylistDialog(context, spotify),
);
} }
} }

View File

@ -78,6 +78,31 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
'Either icon or child must be provided', '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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);

View File

@ -29,7 +29,7 @@ class HeartButton extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final auth = ref.watch(AuthenticationNotifier.provider); final auth = ref.watch(AuthenticationNotifier.provider);
if (auth == null) return Container(); if (auth == null) return const SizedBox.shrink();
return IconButton( return IconButton(
tooltip: tooltip, tooltip: tooltip,
@ -57,18 +57,21 @@ class HeartButton extends HookConsumerWidget {
} }
} }
({ typedef UseTrackToggleLike = ({
bool isLiked, bool isLiked,
Mutation<bool, dynamic, bool> toggleTrackLike, Mutation<bool, dynamic, bool> toggleTrackLike,
Query<User?, dynamic> me, Query<User?, dynamic> me,
}) useTrackToggleLike(Track track, WidgetRef ref) { });
UseTrackToggleLike useTrackToggleLike(Track track, WidgetRef ref) {
final me = useQueries.user.me(ref); final me = useQueries.user.me(ref);
final savedTracks = final savedTracks = useQueries.playlist.likedTracksQuery(ref);
useQueries.playlist.tracksOfQuery(ref, "user-liked-tracks");
final isLiked = final isLiked = useMemoized(
savedTracks.data?.any((element) => element.id == track.id) ?? false; () => savedTracks.data?.any((element) => element.id == track.id) ?? false,
[savedTracks.data, track.id],
);
final mounted = useIsMounted(); final mounted = useIsMounted();
@ -76,28 +79,48 @@ class HeartButton extends HookConsumerWidget {
ref, ref,
track.id!, track.id!,
onMutate: (isLiked) { onMutate: (isLiked) {
savedTracks.setData( print("Toggle Like onMutate: $isLiked");
[
if (isLiked == true) if (isLiked) {
...?savedTracks.data?.where((element) => element.id != track.id) savedTracks.setData(
else savedTracks.data
...?savedTracks.data?..add(track) ?.where((element) => element.id != track.id)
], .toList() ??
); [],
);
} else {
savedTracks.setData(
[
...?savedTracks.data,
track,
],
);
}
return isLiked; return isLiked;
}, },
onData: (data, recoveryData) async { onData: (data, recoveryData) async {
print("Toggle Like onData: $data");
await savedTracks.refresh(); await savedTracks.refresh();
}, },
onError: (payload, isLiked) { onError: (payload, isLiked) {
print("Toggle Like onError: $payload");
if (!mounted()) return; if (!mounted()) return;
savedTracks.setData([ if (isLiked != true) {
if (isLiked != true) savedTracks.setData(
...?savedTracks.data?.where((element) => element.id != track.id) savedTracks.data
else ?.where((element) => element.id != track.id)
...?savedTracks.data?..add(track), .toList() ??
]); [],
);
} else {
savedTracks.setData(
[
...?savedTracks.data,
track,
],
);
}
}, },
); );
@ -113,21 +136,21 @@ class TrackHeartButton extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final savedTracks = final savedTracks = useQueries.playlist.likedTracksQuery(ref);
useQueries.playlist.tracksOfQuery(ref, "user-liked-tracks"); final (:me, :isLiked, :toggleTrackLike) = useTrackToggleLike(track, ref);
final toggler = useTrackToggleLike(track, ref);
if (toggler.me.isLoading || !toggler.me.hasData) { if (me.isLoading || !me.hasData) {
return const CircularProgressIndicator(); return const CircularProgressIndicator();
} }
return HeartButton( return HeartButton(
tooltip: toggler.isLiked tooltip: isLiked
? context.l10n.remove_from_favorites ? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite, : context.l10n.save_as_favorite,
isLiked: toggler.isLiked, isLiked: isLiked,
onPressed: savedTracks.hasData onPressed: savedTracks.hasData
? () { ? () {
toggler.toggleTrackLike.mutate(toggler.isLiked); toggleTrackLike.mutate(isLiked);
} }
: null, : null,
); );
@ -136,10 +159,14 @@ class TrackHeartButton extends HookConsumerWidget {
class PlaylistHeartButton extends HookConsumerWidget { class PlaylistHeartButton extends HookConsumerWidget {
final PlaylistSimple playlist; final PlaylistSimple playlist;
final IconData? icon;
final ValueChanged<bool>? onData;
const PlaylistHeartButton({ const PlaylistHeartButton({
required this.playlist, required this.playlist,
Key? key, Key? key,
this.icon,
this.onData,
}) : super(key: key); }) : super(key: key);
@override @override
@ -158,6 +185,7 @@ class PlaylistHeartButton extends HookConsumerWidget {
refreshQueries: [ refreshQueries: [
isLikedQuery.key, isLikedQuery.key,
], ],
onData: onData,
); );
if (me.isLoading || !me.hasData) { if (me.isLoading || !me.hasData) {
@ -170,6 +198,7 @@ class PlaylistHeartButton extends HookConsumerWidget {
? context.l10n.remove_from_favorites ? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite, : context.l10n.save_as_favorite,
color: Colors.white, color: Colors.white,
icon: icon,
onPressed: isLikedQuery.hasData onPressed: isLikedQuery.hasData
? () { ? () {
togglePlaylistLike.mutate(isLikedQuery.data!); 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/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.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/components/shared/image/universal_image.dart';
import 'package:spotube/hooks/use_breakpoint_value.dart'; import 'package:spotube/hooks/use_breakpoint_value.dart';
import 'package:spotube/hooks/use_brightness_value.dart'; import 'package:spotube/hooks/use_brightness_value.dart';
@ -28,6 +29,7 @@ class PlaybuttonCard extends HookWidget {
final bool isPlaying; final bool isPlaying;
final bool isLoading; final bool isLoading;
final String title; final String title;
final bool isOwner;
const PlaybuttonCard({ const PlaybuttonCard({
required this.imageUrl, required this.imageUrl,
@ -39,6 +41,7 @@ class PlaybuttonCard extends HookWidget {
this.onPlaybuttonPressed, this.onPlaybuttonPressed,
this.onAddToQueuePressed, this.onAddToQueuePressed,
this.onTap, this.onTap,
this.isOwner = false,
Key? key, Key? key,
}) : super(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( AnimatedPositioned(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
right: end, right: end,

View File

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

View File

@ -1,9 +1,12 @@
import 'dart:async';
import 'package:fl_query/fl_query.dart'; import 'package:fl_query/fl_query.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.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/shimmers/shimmer_track_tile.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.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 Query<List<TrackSimple>, T> tracksSnapshot;
final String titleImage; final String titleImage;
final PlayButtonState playingState; 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([Track? currentTrack]) onShuffledPlay;
final void Function() onAddToQueue; final void Function() onAddToQueue;
final void Function() onShare; final void Function() onShare;
@ -71,6 +74,18 @@ class TrackCollectionView<T> extends HookConsumerWidget {
icon: const Icon(SpotubeIcons.share), icon: const Icon(SpotubeIcons.share),
onPressed: onShare, 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!, if (heartBtn != null && auth != null) heartBtn!,
IconButton( IconButton(
onPressed: playingState == PlayButtonState.playing 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/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/local_track.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/authentication_provider.dart';
import 'package:spotube/provider/blacklist_provider.dart'; import 'package:spotube/provider/blacklist_provider.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
@ -40,9 +39,11 @@ class TrackOptions extends HookConsumerWidget {
final Track track; final Track track;
final bool userPlaylist; final bool userPlaylist;
final String? playlistId; final String? playlistId;
final ObjectRef<ValueChanged<RelativeRect>?>? showMenuCbRef;
const TrackOptions({ const TrackOptions({
Key? key, Key? key,
required this.track, required this.track,
this.showMenuCbRef,
this.userPlaylist = false, this.userPlaylist = false,
this.playlistId, this.playlistId,
}) : super(key: key); }) : super(key: key);
@ -114,210 +115,216 @@ class TrackOptions extends HookConsumerWidget {
return downloadManager.getProgressNotifier(spotubeTrack); 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( return ListTileTheme(
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: AdaptivePopSheetList<TrackOptionValue>( child: adaptivePopSheetList,
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),
),
]
},
),
); );
} }
} }

View File

@ -1,3 +1,6 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
@ -20,7 +23,7 @@ class TrackTile extends HookConsumerWidget {
final Track track; final Track track;
final bool selected; final bool selected;
final ValueChanged<bool?>? onChanged; final ValueChanged<bool?>? onChanged;
final VoidCallback? onTap; final Future<void> Function()? onTap;
final VoidCallback? onLongPress; final VoidCallback? onLongPress;
final bool userPlaylist; final bool userPlaylist;
final String? playlistId; final String? playlistId;
@ -57,174 +60,203 @@ class TrackTile extends HookConsumerWidget {
[blacklist, track], [blacklist, track],
); );
final showOptionCbRef = useRef<ValueChanged<RelativeRect>?>(null);
final isPlaying = track.id == playlist.activeTrack?.id; final isPlaying = track.id == playlist.activeTrack?.id;
final isLoading = useState(false);
final isSelected = isPlaying || isLoading.value;
return LayoutBuilder(builder: (context, constrains) { return LayoutBuilder(builder: (context, constrains) {
return HoverBuilder( return Listener(
permanentState: isPlaying || constrains.smAndDown ? true : null, onPointerDown: (event) {
builder: (context, isHovering) { if (event.buttons != kSecondaryMouseButton) return;
return ListTile( showOptionCbRef.value?.call(
selected: isPlaying, RelativeRect.fromLTRB(
onTap: onTap, event.position.dx,
onLongPress: onLongPress, event.position.dy,
enabled: !isBlackListed, constrains.maxWidth - event.position.dx,
contentPadding: EdgeInsets.zero, constrains.maxHeight - event.position.dy,
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,
),
],
), ),
); );
}, },
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:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
@ -27,7 +29,7 @@ final trackCollectionSortState =
StateProvider.family<SortBy, String>((ref, _) => SortBy.none); StateProvider.family<SortBy, String>((ref, _) => SortBy.none);
class TracksTableView extends HookConsumerWidget { class TracksTableView extends HookConsumerWidget {
final void Function(Track currentTrack)? onTrackPlayButtonPressed; final Future<void> Function(Track currentTrack)? onTrackPlayButtonPressed;
final List<Track> tracks; final List<Track> tracks;
final bool userPlaylist; final bool userPlaylist;
final String? playlistId; final String? playlistId;
@ -58,8 +60,7 @@ class TracksTableView extends HookConsumerWidget {
final downloader = ref.watch(downloadManagerProvider.notifier); final downloader = ref.watch(downloadManagerProvider.notifier);
final apiType = final apiType =
ref.watch(userPreferencesProvider.select((s) => s.youtubeApiType)); ref.watch(userPreferencesProvider.select((s) => s.youtubeApiType));
final tableHeadStyle = const tableHeadStyle = TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
const TextStyle(fontWeight: FontWeight.bold, fontSize: 16);
final selected = useState<List<String>>([]); final selected = useState<List<String>>([]);
final showCheck = useState<bool>(false); final showCheck = useState<bool>(false);
@ -297,7 +298,7 @@ class TracksTableView extends HookConsumerWidget {
selected: selected.value.contains(track.id), selected: selected.value.contains(track.id),
userPlaylist: userPlaylist, userPlaylist: userPlaylist,
playlistId: playlistId, playlistId: playlistId,
onTap: () { onTap: () async {
if (showCheck.value) { if (showCheck.value) {
final alreadyChecked = selected.value.contains(track.id); final alreadyChecked = selected.value.contains(track.id);
if (alreadyChecked) { if (alreadyChecked) {
@ -314,9 +315,8 @@ class TracksTableView extends HookConsumerWidget {
), ),
), ),
); );
if (!isBlackListed) { if (isBlackListed) return;
onTrackPlayButtonPressed?.call(track); await onTrackPlayButtonPressed?.call(track);
}
} }
}, },
onLongPress: () { onLongPress: () {

View File

@ -1,10 +1,21 @@
import 'package:duration/locale.dart'; import 'package:duration/locale.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:duration/duration.dart'; import 'package:duration/duration.dart';
extension DurationToHumanReadableString on Duration { extension DurationToHumanReadableString on Duration {
String toHumanReadableString() => String toHumanReadableString({padZero = true}) {
"${inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(inSeconds.remainder(60))}"; 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({ String format({
DurationTersity tersity = DurationTersity.second, DurationTersity tersity = DurationTersity.second,

View File

@ -8,7 +8,6 @@
import 'package:audio_service_web/audio_service_web.dart'; import 'package:audio_service_web/audio_service_web.dart';
import 'package:audio_session/audio_session_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:shared_preferences_web/shared_preferences_web.dart';
import 'package:url_launcher_web/url_launcher_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) { void registerPlugins(Registrar registrar) {
AudioServiceWeb.registerWith(registrar); AudioServiceWeb.registerWith(registrar);
AudioSessionWeb.registerWith(registrar); AudioSessionWeb.registerWith(registrar);
FilePickerWeb.registerWith(registrar);
SharedPreferencesPlugin.registerWith(registrar); SharedPreferencesPlugin.registerWith(registrar);
UrlLauncherPlugin.registerWith(registrar); UrlLauncherPlugin.registerWith(registrar);
registrar.registerMessageHandler(); registrar.registerMessageHandler();

View File

@ -4,7 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotube/hooks/use_async_effect.dart'; import 'package:spotube/hooks/use_async_effect.dart';
bool _asked = false; bool _asked = false;
void useDisableBatterOptimizations() { void useDisableBatteryOptimizations() {
useAsyncEffect(() async { useAsyncEffect(() async {
if (!DesktopTools.platform.isAndroid || _asked) return; if (!DesktopTools.platform.isAndroid || _asked) return;
final localStorage = await SharedPreferences.getInstance(); 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 // audioPlayer.positionStream is fired every 200ms and only 1s delay is
// enough. Thus only update the position if the difference is more than 1s // enough. Thus only update the position if the difference is more than 1s
// Reduces CPU usage // Reduces CPU usage
var lastPosition = position.value;
final positionSubscription = audioPlayer.positionStream.listen((event) { final positionSubscription = audioPlayer.positionStream.listen((event) {
if (event.inMilliseconds > 1000 && final diff = event.inMilliseconds - lastPosition.inMilliseconds;
event.inMilliseconds - lastPosition.inMilliseconds < 1000) return; if (event.inMilliseconds > 1000 && diff < 1000 && diff > 0) return;
lastPosition = event; lastPosition = event;
position.value = event; position.value = event;

View File

@ -24,8 +24,10 @@
"liked_tracks_description": "All your liked tracks", "liked_tracks_description": "All your liked tracks",
"create_playlist": "Create Playlist", "create_playlist": "Create Playlist",
"create_a_playlist": "Create a playlist", "create_a_playlist": "Create a playlist",
"update_playlist": "Update playlist",
"create": "Create", "create": "Create",
"cancel": "Cancel", "cancel": "Cancel",
"update": "Update",
"playlist_name": "Playlist Name", "playlist_name": "Playlist Name",
"name_of_playlist": "Name of the playlist", "name_of_playlist": "Name of the playlist",
"description": "Description", "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", "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", "you_are_offline": "You are currently offline",
"connection_restored": "Your internet connection was restored", "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:catcher/catcher.dart';
import 'package:device_preview/device_preview.dart'; import 'package:device_preview/device_preview.dart';
import 'package:fl_query/fl_query.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/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
@ -14,7 +12,6 @@ import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:metadata_god/metadata_god.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:shared_preferences/shared_preferences.dart';
import 'package:spotube/collections/routes.dart'; import 'package:spotube/collections/routes.dart';
import 'package:spotube/collections/intents.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/palette_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.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/services/connectivity_adapter.dart';
import 'package:spotube/themes/theme.dart'; import 'package:spotube/themes/theme.dart';
import 'package:spotube/utils/persisted_state_notifier.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'; import 'package:flutter_displaymode/flutter_displaymode.dart';
Future<void> main(List<String> rawArgs) async { Future<void> main(List<String> rawArgs) async {
final parser = ArgParser(); final arguments = await startCLI(rawArgs);
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 widgetsBinding = WidgetsFlutterBinding.ensureInitialized(); final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
@ -215,7 +179,7 @@ class SpotubeState extends ConsumerState<Spotube> {
}; };
}, []); }, []);
useDisableBatterOptimizations(); useDisableBatteryOptimizations();
final lightTheme = useMemoized( final lightTheme = useMemoized(
() => theme(paletteColor ?? accentMaterialColor, Brightness.light), () => theme(paletteColor ?? accentMaterialColor, Brightness.light),

View File

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

View File

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

View File

@ -1,6 +1,8 @@
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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_heading.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.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 { class PlaylistView extends HookConsumerWidget {
final logger = getLogger(PlaylistView); final logger = getLogger(PlaylistView);
final PlaylistSimple playlist; final PlaylistSimple playlistSimple;
PlaylistView(this.playlist, {Key? key}) : super(key: key); PlaylistView(this.playlistSimple, {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);
}
}
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
@ -55,7 +31,16 @@ class PlaylistView extends HookConsumerWidget {
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final meSnapshot = useQueries.user.me(ref); 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( final isPlaylistPlaying = useMemoized(
() => proxyPlaylist.collections.contains(playlist.id!), () => proxyPlaylist.collections.contains(playlist.id!),
@ -78,6 +63,35 @@ class PlaylistView extends HookConsumerWidget {
[proxyPlaylist.activeTrack, tracksSnapshot.data], [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( return TrackCollectionView(
id: playlist.id!, id: playlist.id!,
playingState: isPlaylistPlaying && playlistTrackPlaying playingState: isPlaylistPlaying && playlistTrackPlaying
@ -89,24 +103,17 @@ class PlaylistView extends HookConsumerWidget {
titleImage: titleImage, titleImage: titleImage,
tracksSnapshot: tracksSnapshot, tracksSnapshot: tracksSnapshot,
description: playlist.description, description: playlist.description,
isOwned: playlist.owner?.id != null && isOwned: ownPlaylist,
playlist.owner!.id == meSnapshot.data?.id, onPlay: ([track]) async {
onPlay: ([track]) {
if (tracksSnapshot.hasData) { if (tracksSnapshot.hasData) {
if (!isPlaylistPlaying) { if (!isPlaylistPlaying || (isPlaylistPlaying && track != null)) {
playPlaylist( await playPlaylist(
tracksSnapshot.data!,
ref,
currentTrack: track,
);
} else if (isPlaylistPlaying && track != null) {
playPlaylist(
tracksSnapshot.data!, tracksSnapshot.data!,
ref, ref,
currentTrack: track, currentTrack: track,
); );
} else { } else {
playlistNotifier await playlistNotifier
.removeTracks(tracksSnapshot.data!.map((e) => e.id!)); .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]) { onShuffledPlay: ([track]) {
final tracks = [...?tracksSnapshot.data]..shuffle(); final tracks = [...?tracksSnapshot.data]..shuffle();

View File

@ -55,13 +55,295 @@ class SearchPage extends HookConsumerWidget {
Future<void> onSearch() async { Future<void> onSearch() async {
await Future.wait([ await Future.wait([
searchTrack.refreshAll(), searchTrack.reset(),
searchAlbum.refreshAll(), searchAlbum.reset(),
searchPlaylist.refreshAll(), searchPlaylist.reset(),
searchArtist.refreshAll(), 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( return SafeArea(
bottom: false, bottom: false,
child: Scaffold( child: Scaffold(
@ -77,7 +359,7 @@ class SearchPage extends HookConsumerWidget {
), ),
color: theme.scaffoldBackgroundColor, color: theme.scaffoldBackgroundColor,
child: TextField( child: TextField(
autofocus: true, autofocus: queries.none((s) => s.hasPageData),
decoration: InputDecoration( decoration: InputDecoration(
prefixIcon: const Icon(SpotubeIcons.search), prefixIcon: const Icon(SpotubeIcons.search),
hintText: "${context.l10n.search}...", hintText: "${context.l10n.search}...",
@ -93,283 +375,64 @@ class SearchPage extends HookConsumerWidget {
}, },
), ),
), ),
HookBuilder( Expanded(
builder: (context) { child: AnimatedSwitcher(
final playlist = duration: const Duration(milliseconds: 300),
ref.watch(ProxyPlaylistNotifier.provider); child: searchTerm.isEmpty
final playlistNotifier = ? Column(
ref.watch(ProxyPlaylistNotifier.notifier); children: [
List<AlbumSimple> albums = []; SizedBox(
List<Artist> artists = []; height: mediaQuery.size.height * 0.2,
List<Track> tracks = []; ),
List<PlaylistSimple> playlists = []; Icon(
final pages = [ SpotubeIcons.web,
...searchTrack.pages, size: 120,
...searchAlbum.pages, color: theme.colorScheme.onBackground
...searchPlaylist.pages, .withOpacity(0.7),
...searchArtist.pages, ),
].expand<Page>((page) => page).toList(); const SizedBox(height: 20),
for (MapEntry<int, Page> page in pages.asMap().entries) { Text(
for (var item in page.value.items ?? []) { context.l10n.search_to_get_results,
if (item is AlbumSimple) { style: theme.textTheme.titleLarge?.copyWith(
albums.add(item); fontWeight: FontWeight.w900,
} else if (item is PlaylistSimple) { color: theme.colorScheme.onBackground
playlists.add(item); .withOpacity(0.5),
} else if (item is Artist) { ),
artists.add(item); ),
} else if (item is Track) { ],
tracks.add(item); )
} : isFetching
} ? Container(
} constraints: BoxConstraints(
return Expanded( maxWidth: mediaQuery.lgAndUp
child: SingleChildScrollView( ? mediaQuery.size.width * 0.5
child: Padding( : mediaQuery.size.width,
padding: const EdgeInsets.symmetric( ),
vertical: 8, padding: const EdgeInsets.symmetric(
horizontal: 20, horizontal: 20,
), ),
child: SafeArea( child: Column(
child: Column( mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
children: [ CrossAxisAlignment.center,
if (tracks.isNotEmpty) children: [
Text( Text(
context.l10n.songs, context.l10n.crunching_results,
style: theme.textTheme.titleLarge!, style: TextStyle(
), fontSize: 20,
if (searchTrack.isLoadingPage) fontWeight: FontWeight.w900,
const CircularProgressIndicator() color: theme.colorScheme.onBackground
else if (searchTrack.hasPageError) .withOpacity(0.7),
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);
},
),
],
),
), ),
), ),
), const SizedBox(height: 20),
const LinearProgressIndicator(),
],
), ),
if (searchPlaylist.isLoadingPage) )
const CircularProgressIndicator(), : resultWidget,
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() ??
"",
),
],
),
),
),
),
);
},
)
], ],
), ),
), ),

View File

@ -1,6 +1,6 @@
import 'package:auto_size_text/auto_size_text.dart'; import 'package:auto_size_text/auto_size_text.dart';
import 'package:collection/collection.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/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.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:piped_client/piped_client.dart';
import 'package:spotube/collections/env.dart'; import 'package:spotube/collections/env.dart';
import 'package:spotube/collections/language_codes.dart'; import 'package:spotube/collections/language_codes.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/settings/color_scheme_picker_dialog.dart'; import 'package:spotube/components/settings/color_scheme_picker_dialog.dart';
import 'package:spotube/components/settings/section_card_with_heading.dart'; import 'package:spotube/components/settings/section_card_with_heading.dart';
@ -47,8 +46,8 @@ class SettingsPage extends HookConsumerWidget {
}, []); }, []);
final pickDownloadLocation = useCallback(() async { final pickDownloadLocation = useCallback(() async {
final dirStr = await FilePicker.platform.getDirectoryPath( final dirStr = await getDirectoryPath(
dialogTitle: context.l10n.download_location, initialDirectory: preferences.downloadLocation,
); );
if (dirStr == null) return; if (dirStr == null) return;
preferences.setDownloadLocation(dirStr); 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/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:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/hooks/use_spotify_mutation.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 { class PlaylistMutations {
const PlaylistMutations(); const PlaylistMutations();
@ -11,8 +21,8 @@ class PlaylistMutations {
String playlistId, { String playlistId, {
List<String>? refreshQueries, List<String>? refreshQueries,
List<String>? refreshInfiniteQueries, List<String>? refreshInfiniteQueries,
ValueChanged<bool>? onData,
}) { }) {
final queryClient = useQueryClient();
return useSpotifyMutation<bool, dynamic, bool, dynamic>( return useSpotifyMutation<bool, dynamic, bool, dynamic>(
"toggle-playlist-like/$playlistId", "toggle-playlist-like/$playlistId",
(isLiked, spotify) async { (isLiked, spotify) async {
@ -25,10 +35,12 @@ class PlaylistMutations {
}, },
ref: ref, ref: ref,
refreshQueries: refreshQueries, refreshQueries: refreshQueries,
refreshInfiniteQueries: refreshInfiniteQueries, refreshInfiniteQueries: [
onData: (data, recoveryData) async { ...?refreshInfiniteQueries,
await queryClient "current-user-playlists",
.refreshInfiniteQueryAllPages("current-user-playlists"); ],
onData: (data, recoveryData) {
onData?.call(data);
}, },
); );
} }
@ -47,4 +59,91 @@ class PlaylistMutations {
refreshQueries: ["playlist-tracks/$playlistId"], 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:catcher/catcher.dart';
import 'package:fl_query/fl_query.dart'; import 'package:fl_query/fl_query.dart';
import 'package:fl_query_hooks/fl_query_hooks.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_infinite_query.dart';
import 'package:spotube/hooks/use_spotify_query.dart'; import 'package:spotube/hooks/use_spotify_query.dart';
import 'package:spotube/pages/library/playlist_generate/playlist_generate.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/custom_spotify_endpoint_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences_provider.dart';
@ -142,14 +146,49 @@ class PlaylistQueries {
); );
} }
Future<List<Track>> tracksOf(String playlistId, SpotifyApi spotify) { Future<List<Track>> likedTracks(
if (playlistId == "user-liked-tracks") { SpotifyApi spotify,
return spotify.tracks.me.saved.all().then( WidgetRef ref,
(tracks) => tracks.map((e) => e.track!).toList(), ) 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( 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>( return useSpotifyQuery<List<Track>, dynamic>(
"playlist-tracks/$playlistId", "playlist-tracks/$playlistId",
(spotify) => tracksOf(playlistId, spotify), (spotify) => tracksOf(playlistId, spotify, ref),
jsonConfig: playlistId == "user-liked-tracks" ref: ref,
? JsonConfig( );
toJson: (tracks) => <String, dynamic>{ }
'tracks': tracks.map((e) => e.toJson()).toList()
}, Query<Playlist, dynamic> byId(WidgetRef ref, String id) {
fromJson: (json) => (json['tracks'] as List) return useSpotifyQuery<Playlist, dynamic>(
.map((e) => Track.fromJson( "playlist/$id",
(e as Map).castKeyDeep<String>(), (spotify) async {
)) return await spotify.playlists.get(id);
.toList(), },
)
: null,
ref: ref, ref: ref,
); );
} }

View File

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

View File

@ -51,6 +51,7 @@ ThemeData theme(Color seed, Brightness brightness) {
sliderTheme: SliderThemeData(overlayShape: SliderComponentShape.noOverlay), sliderTheme: SliderThemeData(overlayShape: SliderComponentShape.noOverlay),
searchBarTheme: SearchBarThemeData( searchBarTheme: SearchBarThemeData(
constraints: const BoxConstraints(maxWidth: double.infinity), constraints: const BoxConstraints(maxWidth: double.infinity),
padding: const MaterialStatePropertyAll(EdgeInsets.all(8)),
backgroundColor: MaterialStatePropertyAll( backgroundColor: MaterialStatePropertyAll(
Color.lerp( Color.lerp(
scheme.surfaceVariant, 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>( static Future<T> raceMultiple<T>(
Future<T> Function() inner, { Future<T> Function() inner, {
Duration timeout = const Duration(milliseconds: 2500), Duration timeout = const Duration(milliseconds: 2500),

View File

@ -7,6 +7,7 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <catcher/catcher_plugin.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 <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <local_notifier/local_notifier_plugin.h> #include <local_notifier/local_notifier_plugin.h>
#include <media_kit_libs_linux/media_kit_libs_linux_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 = g_autoptr(FlPluginRegistrar) catcher_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "CatcherPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "CatcherPlugin");
catcher_plugin_register_with_registrar(catcher_registrar); 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 = g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -338,6 +338,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.1" 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: crypto:
dependency: transitive dependency: transitive
description: description:
@ -498,14 +506,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.4" version: "6.1.4"
file_picker: file_selector:
dependency: "direct main" dependency: "direct main"
description: description:
name: file_picker name: file_selector
sha256: c7a8e25ca60e7f331b153b0cb3d405828f18d3e72a6fa1d9440c86556fffc877 sha256: "84eaf3e034d647859167d1f01cfe7b6352488f34c1b4932635012b202014c25b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted 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: fixnum:
dependency: transitive dependency: transitive
description: description:
@ -518,18 +582,26 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: fl_query name: fl_query
sha256: "64f482fc09eb1166adca232f68772b2b11c616d88bce3208b2753c940ebc9f71" sha256: "3d71cd1eeb3232efa5e32363a351d74fd9ff07c6eb80aeb672b1970962764945"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted 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: fl_query_hooks:
dependency: "direct main" dependency: "direct main"
description: description:
name: fl_query_hooks name: fl_query_hooks
sha256: b0ffc81fb047cbcedd9766776f9c72b95382730ce173226f0695c3f45774b0bc sha256: "7f0880696666714f77981777509a8aedb765857dcdbdde23e623da20a24c4ae0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0-alpha.3" version: "1.0.0-alpha.4+1"
fluentui_system_icons: fluentui_system_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@ -694,10 +766,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: flutter_riverpod name: flutter_riverpod
sha256: "0c997763ce06359ee4686553b74def84062e9d6929ac63f61fa02465c1f8e32c" sha256: "1bd39b04f1bcd217a969589777ca6bd642d116e3e5de65c3e6a8e8bdd8b178ec"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.4.0"
flutter_rust_bridge: flutter_rust_bridge:
dependency: transitive dependency: transitive
description: description:
@ -780,6 +852,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "8.1.3" 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: freezed_annotation:
dependency: transitive dependency: transitive
description: description:
@ -893,10 +973,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: hooks_riverpod name: hooks_riverpod
sha256: "71695b2e1dfc22a39f1f9c67b798f8f8f1521f2d0349817d13ccdd5c4cd7acba" sha256: ad7b877c3687e38764633d221a1f65491bc7a540e724101e9a404a84db2a4276
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.4.0"
html: html:
dependency: "direct main" dependency: "direct main"
description: description:
@ -937,6 +1017,70 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.0.17" 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: integration_test:
dependency: "direct dev" dependency: "direct dev"
description: flutter description: flutter
@ -998,6 +1142,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.6.2" 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: jwt_decode:
dependency: transitive dependency: transitive
description: description:
@ -1474,10 +1626,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: riverpod name: riverpod
sha256: "0f43c64f1f79c2112c843305a879a746587fb7c1e388f1d4717737796756e2c4" sha256: a600120d6f213a9922860eea1abc32597436edd5b2c4e73b91410f8c2af67d22
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.4.0"
rxdart: rxdart:
dependency: transitive dependency: transitive
description: description:
@ -1610,10 +1762,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: skeleton_text name: skeleton_text
sha256: "6e088723b97ddcccfcce45312ce5e385ed1e5139a57afdf574f753d51eaa77f1" sha256: bacd536bf0664efe1cae53bcbd78c3d4040a120f300f69dc85d83f358471cc6c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.0.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter

View File

@ -33,9 +33,9 @@ dependencies:
disable_battery_optimization: ^1.1.0+1 disable_battery_optimization: ^1.1.0+1
duration: ^3.0.12 duration: ^3.0.12
envied: ^0.3.0 envied: ^0.3.0
file_picker: ^5.2.2 fl_query: ^1.0.0-alpha.4
fl_query: ^1.0.0-alpha.3 fl_query_hooks: ^1.0.0-alpha.4+1
fl_query_hooks: ^1.0.0-alpha.3 fl_query_devtools: ^0.1.0-alpha.2
fluentui_system_icons: ^1.1.189 fluentui_system_icons: ^1.1.189
flutter: flutter:
sdk: flutter sdk: flutter
@ -54,6 +54,7 @@ dependencies:
flutter_riverpod: ^2.1.1 flutter_riverpod: ^2.1.1
flutter_secure_storage: ^8.0.0 flutter_secure_storage: ^8.0.0
flutter_svg: ^1.1.6 flutter_svg: ^1.1.6
form_validator: ^2.1.1
fuzzywuzzy: ^0.2.0 fuzzywuzzy: ^0.2.0
google_fonts: ^5.1.0 google_fonts: ^5.1.0
go_router: ^10.0.0 go_router: ^10.0.0
@ -81,7 +82,7 @@ dependencies:
scroll_to_index: ^3.0.1 scroll_to_index: ^3.0.1
shared_preferences: ^2.0.11 shared_preferences: ^2.0.11
sidebarx: ^0.15.0 sidebarx: ^0.15.0
skeleton_text: ^3.0.0 skeleton_text: ^3.0.1
smtc_windows: ^0.1.0 smtc_windows: ^0.1.0
spotify: ^0.11.0 spotify: ^0.11.0
supabase: ^1.9.9 supabase: ^1.9.9
@ -99,6 +100,8 @@ dependencies:
path: plugins/window_size path: plugins/window_size
youtube_explode_dart: ^2.0.1 youtube_explode_dart: ^2.0.1
stroke_text: ^0.0.2 stroke_text: ^0.0.2
image_picker: ^1.0.4
file_selector: ^1.0.1
dev_dependencies: dev_dependencies:
build_runner: ^2.3.2 build_runner: ^2.3.2
@ -118,7 +121,6 @@ dev_dependencies:
dependency_overrides: dependency_overrides:
http: ^1.1.0 http: ^1.1.0
flutter_hooks: ^0.20.0
flutter: flutter:
generate: true 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 "generated_plugin_registrant.h"
#include <catcher/catcher_plugin.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 <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <local_notifier/local_notifier_plugin.h> #include <local_notifier/local_notifier_plugin.h>
#include <media_kit_libs_windows_audio/media_kit_libs_windows_audio_plugin_c_api.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) { void RegisterPlugins(flutter::PluginRegistry* registry) {
CatcherPluginRegisterWithRegistrar( CatcherPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("CatcherPlugin")); registry->GetRegistrarForPlugin("CatcherPlugin"));
FileSelectorWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSelectorWindows"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar( FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
LocalNotifierPluginRegisterWithRegistrar( LocalNotifierPluginRegisterWithRegistrar(

View File

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