Merge branch 'dev' into patch-1

This commit is contained in:
Kingkor Roy Tirtho 2023-06-25 10:31:21 +06:00 committed by GitHub
commit 19ad11a5f1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
79 changed files with 2490 additions and 1305 deletions

View File

@ -79,6 +79,17 @@ flutter {
}
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
constraints {
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version") {
because("kotlin-stdlib-jdk7 is now a part of kotlin-stdlib")
}
implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version") {
because("kotlin-stdlib-jdk8 is now a part of kotlin-stdlib")
}
}
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
// other deps so just ignore
implementation 'com.android.support:multidex:2.0.1'
}

View File

@ -49,17 +49,21 @@
</activity>
<service android:name="com.ryanheise.audioservice.AudioService" android:exported="false">
<!-- AudioService Config -->
<service android:name="com.ryanheise.audioservice.AudioService"
android:foregroundServiceType="mediaPlayback"
android:exported="true">
<intent-filter>
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver" android:exported="false">
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter>
</receiver>
<!-- =================== -->
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->

View File

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

View File

@ -296,10 +296,10 @@ abstract class LanguageLocals {
// name: "Inuktitut",
// nativeName: "ᐃᓄᒃᑎᑐᑦ",
// ),
// "ja": const ISOLanguageName(
// name: "Japanese",
// nativeName: "日本語 (にほんご/にっぽんご)",
// ),
"ja": const ISOLanguageName(
name: "Japanese",
nativeName: "日本語 (にほんご/にっぽんご)",
),
// "jv": const ISOLanguageName(
// name: "Javanese",
// nativeName: "basa Jawa",

View File

@ -8,6 +8,7 @@ import 'package:spotube/pages/lyrics/mini_lyrics.dart';
import 'package:spotube/pages/search/search.dart';
import 'package:spotube/pages/settings/blacklist.dart';
import 'package:spotube/pages/settings/about.dart';
import 'package:spotube/pages/settings/logs.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/components/shared/spotube_page_route.dart';
import 'package:spotube/pages/album/album.dart';
@ -79,14 +80,20 @@ final router = GoRouter(
routes: [
GoRoute(
path: "blacklist",
pageBuilder: (context, state) => const SpotubePage(
child: BlackListPage(),
pageBuilder: (context, state) => SpotubeSlidePage(
child: const BlackListPage(),
),
),
GoRoute(
path: "logs",
pageBuilder: (context, state) => SpotubeSlidePage(
child: const LogsPage(),
),
),
GoRoute(
path: "about",
pageBuilder: (context, state) => const SpotubePage(
child: AboutSpotube(),
pageBuilder: (context, state) => SpotubeSlidePage(
child: const AboutSpotube(),
),
),
],

View File

@ -86,4 +86,7 @@ abstract class SpotubeIcons {
static const volumeMedium = FeatherIcons.volume1;
static const volumeLow = FeatherIcons.volume;
static const volumeMute = FeatherIcons.volumeX;
static const timer = FeatherIcons.clock;
static const logs = FeatherIcons.fileText;
static const clipboard = FeatherIcons.clipboard;
}

View File

@ -46,14 +46,12 @@ class AlbumCard extends HookConsumerWidget {
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
final queryClient = useQueryClient();
final query = queryClient
.getQuery<List<TrackSimple>, dynamic>("album-tracks/${album.id}");
bool isPlaylistPlaying = useMemoized(
() => playlist.containsTracks(query?.data ?? album.tracks ?? []),
[playlistNotifier, query?.data, album.tracks],
() => playlist.containsCollection(album.id!),
[playlist, album.id],
);
final int marginH =
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
useBreakpointValue(xs: 10, sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
final updating = useState(false);
final spotify = ref.watch(spotifyProvider);
@ -70,7 +68,7 @@ class AlbumCard extends HookConsumerWidget {
description:
"${AlbumType.from(album.albumType!).formatted}${TypeConversionUtils.artists_X_String<ArtistSimple>(album.artists ?? [])}",
onTap: () {
ServiceUtils.navigate(context, "/album/${album.id}", extra: album);
ServiceUtils.push(context, "/album/${album.id}", extra: album);
},
onPlaybuttonPressed: () async {
updating.value = true;
@ -89,6 +87,7 @@ class AlbumCard extends HookConsumerWidget {
[],
autoPlay: true,
);
playlistNotifier.addCollection(album.id!);
} finally {
updating.value = false;
}
@ -118,6 +117,7 @@ class AlbumCard extends HookConsumerWidget {
if (fetchedTracks == null || fetchedTracks.isEmpty) return;
playlistNotifier.addTracks(fetchedTracks);
playlistNotifier.addCollection(album.id!);
if (context.mounted) {
final snackbar = SnackBar(
content: Text("Added ${album.tracks?.length} tracks to queue"),

View File

@ -35,6 +35,7 @@ class ArtistCard extends HookConsumerWidget {
final radius = BorderRadius.circular(15);
final double size = useBreakpointValue<double>(
xs: 130,
sm: 130,
md: 150,
others: 170,
@ -62,7 +63,7 @@ class ArtistCard extends HookConsumerWidget {
),
child: InkWell(
onTap: () {
ServiceUtils.navigate(context, "/artist/${artist.id}");
ServiceUtils.push(context, "/artist/${artist.id}");
},
borderRadius: radius,
child: Padding(

View File

@ -188,7 +188,7 @@ class _MultiSelectDialog<T> extends HookWidget {
return AlertDialog(
scrollable: true,
title: dialogTitle ?? const Text('Select'),
contentPadding: mediaQuery.isSm ? const EdgeInsets.all(16) : null,
contentPadding: mediaQuery.mdAndUp ? null : const EdgeInsets.all(16),
insetPadding: const EdgeInsets.all(16),
actions: [
OutlinedButton(

View File

@ -94,7 +94,7 @@ class RecommendationAttributeDials extends HookWidget {
return Card(
child: ExpansionTile(
title: DefaultTextStyle(
style: Theme.of(context).textTheme.titleMedium!,
style: Theme.of(context).textTheme.titleSmall!,
child: title,
),
shape: const Border(),

View File

@ -93,7 +93,7 @@ class RecommendationAttributeFields extends HookWidget {
return Card(
child: ExpansionTile(
title: DefaultTextStyle(
style: Theme.of(context).textTheme.titleMedium!,
style: Theme.of(context).textTheme.titleSmall!,
child: title,
),
shape: const Border(),

View File

@ -24,6 +24,7 @@ class UserAlbums extends HookConsumerWidget {
final albumsQuery = useQueries.album.ofMine(ref);
final spacing = useBreakpointValue<double>(
xs: 0,
sm: 0,
others: 20,
);

View File

@ -1,4 +1,5 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:background_downloader/background_downloader.dart';
// import 'package:background_downloader/background_downloader.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -65,14 +66,11 @@ class UserDownloads extends HookConsumerWidget {
.where((element) => element.taskId == track.id),
);
final taskItSelf = useFuture(
Future.value(null),
// FileDownloader().database.recordForId(track.id!),
FileDownloader().database.recordForId(track.id!),
);
final hasFailed = failedTaskStream
.hasData /* ||
taskItSelf.data?.status == TaskStatus.failed */
;
final hasFailed = failedTaskStream.hasData ||
taskItSelf.data?.status == TaskStatus.failed;
return ListTile(
title: Text(track.name ?? ''),
@ -91,21 +89,18 @@ class UserDownloads extends HookConsumerWidget {
),
),
horizontalTitleGap: 10,
trailing: SizedBox(
width: 30,
height: 30,
child: downloadManager.activeItem?.id == track.id
? CircularProgressIndicator(
value: task.data?.progress ?? 0,
)
: hasFailed
? Icon(SpotubeIcons.error, color: Colors.red[400])
: IconButton(
icon: const Icon(SpotubeIcons.close),
onPressed: () {
downloadManager.cancel(track);
}),
),
trailing: downloadManager.activeItem?.id == track.id &&
!hasFailed
? CircularProgressIndicator(
value: task.data?.progress ?? 0,
)
: hasFailed
? Icon(SpotubeIcons.error, color: Colors.red[400])
: IconButton(
icon: const Icon(SpotubeIcons.close),
onPressed: () {
downloadManager.cancel(track);
}),
subtitle: TypeConversionUtils.artists_X_ClickableArtists(
track.artists ?? <Artist>[],
mainAxisAlignment: WrapAlignment.start,

View File

@ -15,7 +15,7 @@ import 'package:permission_handler/permission_handler.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/compact_search.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
import 'package:spotube/components/shared/track_table/track_tile.dart';
@ -52,7 +52,8 @@ enum SortBy {
descending,
artist,
album,
dateAdded,
newest,
oldest,
}
final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {
@ -95,7 +96,7 @@ final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {
return {"metadata": metadata, "file": f, "art": imageFile.path};
} catch (e, stack) {
if (e is FfiException) {
return {};
return {"file": f};
}
Catcher.reportCheckedError(e, stack);
return {};
@ -160,7 +161,10 @@ class UserLocalTracks extends HookConsumerWidget {
playlist.containsTracks(trackSnapshot.value ?? []);
final isMounted = useIsMounted();
final searchText = useState<String>("");
final searchController = useTextEditingController();
useValueListenable(searchController);
final searchFocus = useFocusNode();
final isFiltering = useState(false);
useAsyncEffect(
() async {
@ -175,11 +179,6 @@ class UserLocalTracks extends HookConsumerWidget {
[],
);
final searchbar = CompactSearch(
onChanged: (value) => searchText.value = value,
placeholder: context.l10n.search_local_tracks,
);
return Column(
children: [
Padding(
@ -213,7 +212,10 @@ class UserLocalTracks extends HookConsumerWidget {
),
),
const Spacer(),
searchbar,
ExpandableSearchButton(
isFiltering: isFiltering,
searchFocus: searchFocus,
),
const SizedBox(width: 10),
SortTracksDropdown(
value: sortBy.value,
@ -231,6 +233,11 @@ class UserLocalTracks extends HookConsumerWidget {
],
),
),
ExpandableSearchField(
searchController: searchController,
searchFocus: searchFocus,
isFiltering: isFiltering,
),
trackSnapshot.when(
data: (tracks) {
final sortedTracks = useMemoized(() {
@ -238,14 +245,14 @@ class UserLocalTracks extends HookConsumerWidget {
}, [sortBy.value, tracks]);
final filteredTracks = useMemoized(() {
if (searchText.value.isEmpty) {
if (searchController.text.isEmpty) {
return sortedTracks;
}
return sortedTracks
.map((e) => (
weightedRatio(
"${e.name} - ${TypeConversionUtils.artists_X_String<Artist>(e.artists ?? [])}",
searchText.value,
searchController.text,
),
e,
))
@ -257,7 +264,7 @@ class UserLocalTracks extends HookConsumerWidget {
.map((e) => e.$2)
.toList()
.toList();
}, [searchText.value, sortedTracks]);
}, [searchController.text, sortedTracks]);
return Expanded(
child: RefreshIndicator(

View File

@ -3,26 +3,31 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotify/spotify.dart' hide Offset;
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/player/player_queue.dart';
import 'package:spotube/components/player/sibling_tracks_sheet.dart';
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart';
import 'package:spotube/models/local_track.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/sleep_timer_provider.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlayerActions extends HookConsumerWidget {
final MainAxisAlignment mainAxisAlignment;
final bool floatingQueue;
final bool showQueue;
final List<Widget>? extraActions;
PlayerActions({
this.mainAxisAlignment = MainAxisAlignment.center,
this.floatingQueue = true,
this.showQueue = true,
this.extraActions,
Key? key,
}) : super(key: key);
@ -38,6 +43,8 @@ class PlayerActions extends HookConsumerWidget {
downloader.activeItem!.id == playlist.activeTrack?.id;
final localTracks = [] /* ref.watch(localTracksProvider).value */;
final auth = ref.watch(AuthenticationNotifier.provider);
final sleepTimer = ref.watch(SleepTimerNotifier.provider);
final sleepTimerNotifier = ref.watch(SleepTimerNotifier.notifier);
final isDownloaded = useMemoized(() {
return localTracks.any(
@ -52,34 +59,47 @@ class PlayerActions extends HookConsumerWidget {
true;
}, [localTracks, playlist.activeTrack]);
final sleepTimerEntries = useMemoized(
() => {
context.l10n.mins(15): const Duration(minutes: 15),
context.l10n.mins(30): const Duration(minutes: 30),
context.l10n.hour(1): const Duration(hours: 1),
context.l10n.hour(2): const Duration(hours: 2),
},
[context.l10n],
);
var customHoursEnabled =
sleepTimer == null || sleepTimerEntries.values.contains(sleepTimer);
return Row(
mainAxisAlignment: mainAxisAlignment,
children: [
IconButton(
icon: const Icon(SpotubeIcons.queue),
tooltip: context.l10n.queue,
onPressed: playlist.activeTrack != null
? () {
showModalBottomSheet(
context: context,
isDismissible: true,
enableDrag: true,
isScrollControlled: true,
backgroundColor: Colors.black12,
barrierColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * .7,
),
builder: (context) {
return PlayerQueue(floating: floatingQueue);
},
);
}
: null,
),
if (showQueue)
IconButton(
icon: const Icon(SpotubeIcons.queue),
tooltip: context.l10n.queue,
onPressed: playlist.activeTrack != null
? () {
showModalBottomSheet(
context: context,
isDismissible: true,
enableDrag: true,
isScrollControlled: true,
backgroundColor: Colors.black12,
barrierColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * .7,
),
builder: (context) {
return PlayerQueue(floating: floatingQueue);
},
);
}
: null,
),
if (!isLocalTrack)
IconButton(
icon: const Icon(SpotubeIcons.alternativeRoute),
@ -127,6 +147,62 @@ class PlayerActions extends HookConsumerWidget {
),
if (playlist.activeTrack != null && !isLocalTrack && auth != null)
TrackHeartButton(track: playlist.activeTrack!),
AdaptivePopSheetList(
offset: Offset(0, -50 * (sleepTimerEntries.values.length + 2)),
headings: [
Text(context.l10n.sleep_timer),
],
icon: Icon(
SpotubeIcons.timer,
color: sleepTimer != null ? Colors.red : null,
),
onSelected: (value) {
if (value == Duration.zero) {
sleepTimerNotifier.cancelSleepTimer();
} else {
sleepTimerNotifier.setSleepTimer(value);
}
},
children: [
for (final entry in sleepTimerEntries.entries)
PopSheetEntry(
value: entry.value,
enabled: sleepTimer != entry.value,
title: Text(entry.key),
),
PopSheetEntry(
title: Text(
customHoursEnabled
? context.l10n.custom_hours
: sleepTimer.format(abbreviated: true),
),
// only enabled when there's no preset timers selected
enabled: customHoursEnabled,
onTap: () async {
final currentTime = TimeOfDay.now();
final time = await showTimePicker(
context: context,
initialTime: currentTime,
);
if (time != null) {
sleepTimerNotifier.setSleepTimer(
Duration(
hours: (time.hour - currentTime.hour).abs(),
minutes: (time.minute - currentTime.minute).abs(),
),
);
}
},
),
PopSheetEntry(
value: Duration.zero,
enabled: sleepTimer != Duration.zero && sleepTimer != null,
textColor: Colors.green,
title: Text(context.l10n.cancel),
),
],
),
...(extraActions ?? [])
],
);

View File

@ -44,7 +44,7 @@ class PlayerOverlay extends HookConsumerWidget {
int sensitivity = 8;
if (details.primaryVelocity != null &&
details.primaryVelocity! < -sensitivity) {
ServiceUtils.navigate(context, "/player");
ServiceUtils.push(context, "/player");
}
},
child: ClipRRect(

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
@ -6,6 +7,7 @@ import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class PlayerTrackDetails extends HookConsumerWidget {
@ -37,7 +39,7 @@ class PlayerTrackDetails extends HookConsumerWidget {
),
),
),
if (mediaQuery.isSm || mediaQuery.isMd)
if (mediaQuery.mdAndDown)
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -72,6 +74,9 @@ class PlayerTrackDetails extends HookConsumerWidget {
),
TypeConversionUtils.artists_X_ClickableArtists(
playback.activeTrack?.artists ?? [],
onRouteChange: (route) {
ServiceUtils.push(context, route);
},
)
],
),

View File

@ -0,0 +1,66 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/provider/volume_provider.dart';
class VolumeSlider extends HookConsumerWidget {
final bool fullWidth;
const VolumeSlider({
Key? key,
this.fullWidth = false,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final volume = ref.watch(volumeProvider);
final volumeNotifier = ref.watch(volumeProvider.notifier);
var slider = Listener(
onPointerSignal: (event) async {
if (event is PointerScrollEvent) {
if (event.scrollDelta.dy > 0) {
final value = volume - .2;
volumeNotifier.setVolume(value < 0 ? 0 : value);
} else {
final value = volume + .2;
volumeNotifier.setVolume(value > 1 ? 1 : value);
}
}
},
child: Slider.adaptive(
min: 0,
max: 1,
value: volume,
onChanged: volumeNotifier.setVolume,
),
);
return Row(
mainAxisAlignment:
!fullWidth ? MainAxisAlignment.center : MainAxisAlignment.start,
children: [
IconButton(
icon: Icon(
volume == 0
? SpotubeIcons.volumeMute
: volume <= 0.2
? SpotubeIcons.volumeLow
: volume <= 0.6
? SpotubeIcons.volumeMedium
: SpotubeIcons.volumeHigh,
size: 16,
),
onPressed: () {
if (volume == 0) {
volumeNotifier.setVolume(1);
} else {
volumeNotifier.setVolume(0);
}
},
),
if (fullWidth) Expanded(child: slider) else slider,
],
);
}
}

View File

@ -24,13 +24,10 @@ class PlaylistCard extends HookConsumerWidget {
final playing =
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
final queryBowl = QueryClient.of(context);
final query = queryBowl.getQuery<List<Track>, dynamic>(
"playlist-tracks/${playlist.id}",
);
final tracks = useState<List<TrackSimple>?>(null);
bool isPlaylistPlaying = useMemoized(
() => playlistQueue.containsTracks(tracks.value ?? query?.data ?? []),
[playlistNotifier, tracks.value, query?.data],
() => playlistQueue.containsCollection(playlist.id!),
[playlistQueue, playlist.id],
);
final updating = useState(false);
@ -48,7 +45,7 @@ class PlaylistCard extends HookConsumerWidget {
isLoading:
(isPlaylistPlaying && playlistQueue.isFetching) || updating.value,
onTap: () {
ServiceUtils.navigate(
ServiceUtils.push(
context,
"/playlist/${playlist.id}",
extra: playlist,
@ -72,6 +69,7 @@ class PlaylistCard extends HookConsumerWidget {
if (fetchedTracks.isEmpty) return;
await playlistNotifier.load(fetchedTracks, autoPlay: true);
playlistNotifier.addCollection(playlist.id!);
tracks.value = fetchedTracks;
} finally {
updating.value = false;
@ -90,6 +88,7 @@ class PlaylistCard extends HookConsumerWidget {
if (fetchedTracks.isEmpty) return;
playlistNotifier.addTracks(fetchedTracks);
playlistNotifier.addCollection(playlist.id!);
tracks.value = fetchedTracks;
if (context.mounted) {
final snackbar = SnackBar(

View File

@ -121,7 +121,7 @@ class PlaylistCreateDialogButton extends HookConsumerWidget {
final mediaQuery = MediaQuery.of(context);
final spotify = ref.watch(spotifyProvider);
if (mediaQuery.isSm) {
if (mediaQuery.smAndDown) {
return ElevatedButton(
style: FilledButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.primary,

View File

@ -12,6 +12,7 @@ import 'package:spotube/components/player/player_actions.dart';
import 'package:spotube/components/player/player_overlay.dart';
import 'package:spotube/components/player/player_track_details.dart';
import 'package:spotube/components/player/player_controls.dart';
import 'package:spotube/components/player/volume_slider.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/use_brightness_value.dart';
@ -20,7 +21,6 @@ import 'package:flutter/material.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/provider/volume_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
@ -58,8 +58,7 @@ class BottomPlayer extends HookConsumerWidget {
// returning an empty non spacious Container as the overlay will take
// place in the global overlay stack aka [_entries]
if (layoutMode == LayoutMode.compact ||
((mediaQuery.isSm || mediaQuery.isMd) &&
layoutMode == LayoutMode.adaptive)) {
((mediaQuery.mdAndDown) && layoutMode == LayoutMode.adaptive)) {
return PlayerOverlay(albumArt: albumArt);
}
@ -116,57 +115,7 @@ class BottomPlayer extends HookConsumerWidget {
Container(
height: 40,
constraints: const BoxConstraints(maxWidth: 250),
child: HookBuilder(builder: (context) {
final volume = ref.watch(volumeProvider);
final volumeNotifier =
ref.watch(volumeProvider.notifier);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: Icon(
volume == 0
? SpotubeIcons.volumeMute
: volume <= 0.2
? SpotubeIcons.volumeLow
: volume <= 0.6
? SpotubeIcons.volumeMedium
: SpotubeIcons.volumeHigh,
size: 16,
),
onPressed: () {
if (volume == 0) {
volumeNotifier.setVolume(1);
} else {
volumeNotifier.setVolume(0);
}
},
),
Listener(
onPointerSignal: (event) async {
if (event is PointerScrollEvent) {
if (event.scrollDelta.dy > 0) {
final value = volume - .2;
volumeNotifier
.setVolume(value < 0 ? 0 : value);
} else {
final value = volume + .2;
volumeNotifier
.setVolume(value > 1 ? 1 : value);
}
}
},
child: Slider.adaptive(
min: 0,
max: 1,
value: volume,
onChanged: volumeNotifier.setVolume,
),
),
],
);
}),
child: const VolumeSlider(),
)
],
)

View File

@ -82,16 +82,17 @@ class Sidebar extends HookConsumerWidget {
}, [controller]);
useEffect(() {
if (!context.mounted) return;
if (mediaQuery.lgAndUp && !controller.extended) {
controller.setExtended(true);
} else if ((mediaQuery.isSm || mediaQuery.isMd) && controller.extended) {
} else if (mediaQuery.mdAndDown && controller.extended) {
controller.setExtended(false);
}
return null;
}, [mediaQuery, controller]);
if (layoutMode == LayoutMode.compact ||
(mediaQuery.isSm && layoutMode == LayoutMode.adaptive)) {
(mediaQuery.smAndDown && layoutMode == LayoutMode.adaptive)) {
return Scaffold(body: child);
}
@ -186,7 +187,7 @@ class SidebarHeader extends HookWidget {
final mediaQuery = MediaQuery.of(context);
final theme = Theme.of(context);
if (mediaQuery.isSm || mediaQuery.isMd) {
if (mediaQuery.mdAndDown) {
return Container(
height: 40,
width: 40,
@ -236,7 +237,7 @@ class SidebarFooter extends HookConsumerWidget {
final auth = ref.watch(AuthenticationNotifier.provider);
if (mediaQuery.isSm || mediaQuery.isMd) {
if (mediaQuery.mdAndDown) {
return IconButton(
icon: const Icon(SpotubeIcons.settings),
onPressed: () => Sidebar.goToSettings(context),

View File

@ -17,7 +17,7 @@ class AdaptiveListTile extends HookWidget {
this.title,
this.subtitle,
this.leading,
this.breakOn ,
this.breakOn,
});
@override
@ -27,10 +27,11 @@ class AdaptiveListTile extends HookWidget {
return ListTile(
title: title,
subtitle: subtitle,
trailing:
breakOn ?? mediaQuery.isSm ? null : trailing?.call(context, null),
trailing: breakOn ?? mediaQuery.smAndDown
? null
: trailing?.call(context, null),
leading: leading,
onTap: breakOn ?? mediaQuery.isSm
onTap: breakOn ?? mediaQuery.smAndDown
? () {
onTap?.call();
showDialog(

View File

@ -2,17 +2,47 @@ import 'package:flutter/material.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/constrains.dart';
class PopSheetEntry<T> {
final T? value;
final VoidCallback? onTap;
final Widget child;
final bool enabled;
_emptyCB() {}
class PopSheetEntry<T> extends ListTile {
final T? value;
const PopSheetEntry({
required this.child,
this.value,
this.onTap,
this.enabled = true,
super.key,
super.leading,
super.title,
super.subtitle,
super.trailing,
super.isThreeLine = false,
super.dense,
super.visualDensity,
super.shape,
super.style,
super.selectedColor,
super.iconColor,
super.textColor,
super.titleTextStyle,
super.subtitleTextStyle,
super.leadingAndTrailingTextStyle,
super.contentPadding,
super.enabled = true,
super.onTap = _emptyCB,
super.onLongPress,
super.onFocusChange,
super.mouseCursor,
super.selected = false,
super.focusColor,
super.hoverColor,
super.splashColor,
super.focusNode,
super.autofocus = false,
super.tileColor,
super.selectedTileColor,
super.enableFeedback,
super.horizontalTitleGap,
super.minVerticalPadding,
super.minLeadingWidth,
super.titleAlignment,
});
}
@ -30,6 +60,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
final ValueChanged<T>? onSelected;
final BorderRadius borderRadius;
final Offset offset;
const AdaptivePopSheetList({
super.key,
@ -41,6 +72,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
this.onSelected,
this.borderRadius = const BorderRadius.all(Radius.circular(999)),
this.tooltip,
this.offset = Offset.zero,
}) : assert(
!(icon != null && child != null),
'Either icon or child must be provided',
@ -55,11 +87,13 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
return PopupMenuButton(
icon: icon,
tooltip: tooltip,
offset: offset,
child: child == null ? null : IgnorePointer(child: child),
itemBuilder: (context) => children
.map(
(item) => PopupMenuItem(
padding: EdgeInsets.zero,
enabled: false,
child: _AdaptivePopSheetListItem(
item: item,
onSelected: onSelected,
@ -74,40 +108,38 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
showModalBottomSheet(
context: context,
useRootNavigator: useRootNavigator,
isScrollControlled: true,
showDragHandle: true,
constraints: BoxConstraints(
maxHeight: mediaQuery.size.height * 0.6,
),
builder: (context) {
return Padding(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.all(8.0).copyWith(top: 0),
child: DefaultTextStyle(
style: theme.textTheme.titleMedium!,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (headings != null) ...[
Container(
width: 180,
height: 6,
decoration: BoxDecoration(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (headings != null) ...[
...headings!,
const SizedBox(height: 8),
Divider(
color: theme.colorScheme.primary,
borderRadius: BorderRadius.circular(999),
thickness: 0.3,
endIndent: 16,
indent: 16,
),
),
const SizedBox(height: 8),
...headings!,
const SizedBox(height: 8),
Divider(
color: theme.colorScheme.primary,
thickness: 0.3,
endIndent: 16,
indent: 16,
),
],
...children.map(
(item) => _AdaptivePopSheetListItem(
item: item,
onSelected: onSelected,
),
)
],
...children.map(
(item) => _AdaptivePopSheetListItem(
item: item,
onSelected: onSelected,
),
)
],
),
),
),
);
@ -153,27 +185,23 @@ class _AdaptivePopSheetListItem<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
borderRadius: BorderRadius.circular(10),
borderRadius: (theme.listTileTheme.shape as RoundedRectangleBorder?)
?.borderRadius as BorderRadius? ??
const BorderRadius.all(Radius.circular(10)),
onTap: !item.enabled
? null
: () {
item.onTap?.call();
Navigator.pop(context);
if (item.value != null) {
Navigator.pop(context);
onSelected?.call(item.value as T);
}
},
child: DefaultTextStyle(
style: TextStyle(
color: item.enabled
? theme.textTheme.bodyMedium!.color
: theme.textTheme.bodyMedium!.color!.withOpacity(0.5),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: item.child,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: IgnorePointer(child: item),
),
);
}

View File

@ -0,0 +1,166 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/links/hyper_link.dart';
import 'package:spotube/components/shared/links/link_text.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/spotube_track.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
import 'package:spotube/extensions/duration.dart';
class TrackDetailsDialog extends HookWidget {
final Track track;
const TrackDetailsDialog({
Key? key,
required this.track,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final detailsMap = {
context.l10n.title: track.name!,
context.l10n.artist: TypeConversionUtils.artists_X_ClickableArtists(
track.artists ?? <Artist>[],
mainAxisAlignment: WrapAlignment.start,
textStyle: const TextStyle(color: Colors.blue),
),
context.l10n.album: LinkText(
track.album!.name!,
"/album/${track.album?.id}",
extra: track.album,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.blue),
),
context.l10n.duration: (track is SpotubeTrack
? (track as SpotubeTrack).ytTrack.duration
: track.duration!)
.toHumanReadableString(),
if (track.album!.releaseDate != null)
context.l10n.released: track.album!.releaseDate,
context.l10n.popularity: track.popularity?.toString() ?? "0",
};
final ytTrack =
track is SpotubeTrack ? (track as SpotubeTrack).ytTrack : null;
final ytTracksDetailsMap = ytTrack == null
? {}
: {
context.l10n.youtube: Hyperlink(
"https://piped.video/watch?v=${ytTrack.id}",
"https://piped.video/watch?v=${ytTrack.id}",
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
context.l10n.channel: Hyperlink(
ytTrack.uploader,
"https://youtube.com${ytTrack.uploaderUrl}",
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
context.l10n.likes:
PrimitiveUtils.toReadableNumber(ytTrack.likes.toDouble()),
context.l10n.dislikes:
PrimitiveUtils.toReadableNumber(ytTrack.dislikes.toDouble()),
context.l10n.views:
PrimitiveUtils.toReadableNumber(ytTrack.views.toDouble()),
context.l10n.streamUrl: Hyperlink(
(track as SpotubeTrack).ytUri,
(track as SpotubeTrack).ytUri,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
};
return AlertDialog(
contentPadding: const EdgeInsets.all(16),
insetPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 100),
scrollable: true,
title: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(SpotubeIcons.info),
const SizedBox(width: 8),
Text(
context.l10n.details,
style: theme.textTheme.titleMedium,
),
],
),
content: SizedBox(
width: mediaQuery.mdAndUp ? double.infinity : 700,
child: Table(
columnWidths: const {
0: FixedColumnWidth(95),
1: FixedColumnWidth(10),
2: FlexColumnWidth(1),
},
defaultVerticalAlignment: TableCellVerticalAlignment.middle,
children: [
for (final entry in detailsMap.entries)
TableRow(
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.top,
child: Text(
entry.key,
style: theme.textTheme.titleMedium,
),
),
const TableCell(
verticalAlignment: TableCellVerticalAlignment.top,
child: Text(":"),
),
if (entry.value is Widget)
entry.value as Widget
else if (entry.value is String)
Text(
entry.value as String,
style: theme.textTheme.bodyMedium,
),
],
),
const TableRow(
children: [
SizedBox(height: 16),
SizedBox(height: 16),
SizedBox(height: 16),
],
),
for (final entry in ytTracksDetailsMap.entries)
TableRow(
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.top,
child: Text(
entry.key,
style: theme.textTheme.titleMedium,
),
),
const TableCell(
verticalAlignment: TableCellVerticalAlignment.top,
child: Text(":"),
),
if (entry.value is Widget)
entry.value as Widget
else
Text(
entry.value,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodyMedium,
),
],
),
],
),
),
);
}
}

View File

@ -0,0 +1,91 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart';
class ExpandableSearchField extends StatelessWidget {
final ValueNotifier<bool> isFiltering;
final TextEditingController searchController;
final FocusNode searchFocus;
const ExpandableSearchField({
Key? key,
required this.isFiltering,
required this.searchController,
required this.searchFocus,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: isFiltering.value ? 1 : 0,
child: AnimatedSize(
duration: const Duration(milliseconds: 200),
child: SizedBox(
height: isFiltering.value ? 50 : 0,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: CallbackShortcuts(
bindings: {
LogicalKeySet(LogicalKeyboardKey.escape): () {
isFiltering.value = false;
searchController.clear();
searchFocus.unfocus();
}
},
child: TextField(
focusNode: searchFocus,
controller: searchController,
decoration: InputDecoration(
hintText: context.l10n.search_tracks,
isDense: true,
prefixIcon: const Icon(SpotubeIcons.search),
),
),
),
),
),
),
);
}
}
class ExpandableSearchButton extends StatelessWidget {
final ValueNotifier<bool> isFiltering;
final FocusNode searchFocus;
final Widget icon;
final ValueChanged<bool>? onPressed;
const ExpandableSearchButton({
Key? key,
required this.isFiltering,
required this.searchFocus,
this.icon = const Icon(SpotubeIcons.filter),
this.onPressed,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return IconButton(
icon: icon,
style: IconButton.styleFrom(
backgroundColor:
isFiltering.value ? theme.colorScheme.secondaryContainer : null,
foregroundColor: isFiltering.value ? theme.colorScheme.secondary : null,
minimumSize: const Size(25, 25),
),
onPressed: () {
isFiltering.value = !isFiltering.value;
if (isFiltering.value) {
searchFocus.requestFocus();
} else {
searchFocus.unfocus();
}
onPressed?.call(isFiltering.value);
},
);
}
}

View File

@ -24,7 +24,7 @@ class AnonymousFallback extends ConsumerWidget {
const SizedBox(height: 10),
FilledButton(
child: const Text("Login with Spotify"),
onPressed: () => ServiceUtils.navigate(context, "/settings"),
onPressed: () => ServiceUtils.push(context, "/settings"),
)
],
),

View File

@ -5,13 +5,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/use_palette_color.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/services/mutations/mutations.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class HeartButton extends HookConsumerWidget {
final bool isLiked;
final void Function()? onPressed;
@ -163,15 +160,6 @@ class PlaylistHeartButton extends HookConsumerWidget {
],
);
final titleImage = useMemoized(
() => TypeConversionUtils.image_X_UrlString(
playlist.images,
placeholder: ImagePlaceholder.collection,
),
[playlist.images]);
final color = usePaletteGenerator(titleImage).dominantColor;
if (me.isLoading || !me.hasData) {
return const CircularProgressIndicator();
}
@ -181,7 +169,7 @@ class PlaylistHeartButton extends HookConsumerWidget {
tooltip: isLikedQuery.data ?? false
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
color: color?.titleTextColor,
color: Colors.white,
onPressed: isLikedQuery.hasData
? () {
togglePlaylistLike.mutate(isLikedQuery.data!);
@ -224,6 +212,7 @@ class AlbumHeartButton extends HookConsumerWidget {
tooltip: isLiked
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
color: Colors.white,
onPressed: albumIsSaved.hasData
? () {
toggleAlbumLike.mutate(isLiked);

View File

@ -7,6 +7,7 @@ class AnchorButton<T> extends HookWidget {
final TextAlign? textAlign;
final TextOverflow? overflow;
final void Function()? onTap;
final int? maxLines;
const AnchorButton(
this.text, {
@ -14,6 +15,7 @@ class AnchorButton<T> extends HookWidget {
this.onTap,
this.textAlign,
this.overflow,
this.maxLines,
this.style = const TextStyle(),
}) : super(key: key);
@ -34,6 +36,7 @@ class AnchorButton<T> extends HookWidget {
decoration:
hover.value || tap.value ? TextDecoration.underline : null,
),
maxLines: maxLines,
textAlign: textAlign,
overflow: overflow,
),

View File

@ -8,6 +8,8 @@ class Hyperlink extends StatelessWidget {
final TextAlign? textAlign;
final TextOverflow? overflow;
final String url;
final int? maxLines;
const Hyperlink(
this.text,
this.url, {
@ -15,6 +17,7 @@ class Hyperlink extends StatelessWidget {
this.textAlign,
this.overflow,
this.style = const TextStyle(),
this.maxLines,
}) : super(key: key);
@override
@ -29,6 +32,7 @@ class Hyperlink extends StatelessWidget {
},
key: key,
overflow: overflow,
maxLines: maxLines,
style: style.copyWith(color: Colors.blue),
textAlign: textAlign,
);

View File

@ -9,6 +9,8 @@ class LinkText<T> extends StatelessWidget {
final TextOverflow? overflow;
final String route;
final T? extra;
final bool push;
const LinkText(
this.text,
this.route, {
@ -17,6 +19,7 @@ class LinkText<T> extends StatelessWidget {
this.extra,
this.overflow,
this.style = const TextStyle(),
this.push = false,
}) : super(key: key);
@override
@ -24,7 +27,11 @@ class LinkText<T> extends StatelessWidget {
return AnchorButton(
text,
onTap: () {
ServiceUtils.navigate(context, route, extra: extra);
if (push) {
ServiceUtils.push(context, route, extra: extra);
} else {
ServiceUtils.navigate(context, route, extra: extra);
}
},
key: key,
overflow: overflow,

View File

@ -40,6 +40,7 @@ class PlaybuttonCard extends HookWidget {
final radius = BorderRadius.circular(15);
final double size = useBreakpointValue<double>(
xs: 130,
sm: 130,
md: 150,
others: 170,
@ -47,6 +48,7 @@ class PlaybuttonCard extends HookWidget {
170;
final end = useBreakpointValue<double>(
xs: 15,
sm: 15,
others: 20,
) ??

View File

@ -21,6 +21,7 @@ class ShimmerArtistProfile extends HookWidget {
shimmerTheme.shimmerBackgroundColor ?? Colors.grey;
final avatarWidth = useBreakpointValue(
xs: MediaQuery.of(context).size.width * 0.80,
sm: MediaQuery.of(context).size.width * 0.80,
md: MediaQuery.of(context).size.width * 0.50,
lg: MediaQuery.of(context).size.width * 0.30,

View File

@ -18,6 +18,7 @@ class ShimmerCategories extends HookWidget {
shimmerTheme.shimmerBackgroundColor ?? Colors.grey;
final shimmerCount = useBreakpointValue(
xs: 2,
sm: 2,
md: 3,
lg: 3,

View File

@ -32,7 +32,7 @@ class ShimmerLyrics extends HookWidget {
if (mediaQuery.isMd) {
widthsCp.removeLast();
}
if (mediaQuery.isSm) {
if (mediaQuery.smAndDown) {
widthsCp.removeLast();
widthsCp.removeLast();
}

View File

@ -86,6 +86,7 @@ class ShimmerPlaybuttonCard extends HookWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
final Size size = useBreakpointValue<Size>(
xs: const Size(130, 200),
sm: const Size(130, 200),
md: const Size(150, 220),
others: const Size(170, 240),

View File

@ -16,6 +16,7 @@ class SortTracksDropdown extends StatelessWidget {
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return ListTileTheme(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
@ -25,50 +26,37 @@ class SortTracksDropdown extends StatelessWidget {
PopSheetEntry(
value: SortBy.none,
enabled: value != SortBy.none,
child: ListTile(
enabled: value != SortBy.none,
title: Text(context.l10n.none),
),
title: Text(context.l10n.none),
),
PopSheetEntry(
value: SortBy.ascending,
enabled: value != SortBy.ascending,
child: ListTile(
enabled: value != SortBy.ascending,
title: Text(context.l10n.sort_a_z),
),
title: Text(context.l10n.sort_a_z),
),
PopSheetEntry(
value: SortBy.descending,
enabled: value != SortBy.descending,
child: ListTile(
enabled: value != SortBy.descending,
title: Text(context.l10n.sort_z_a),
),
title: Text(context.l10n.sort_z_a),
),
PopSheetEntry(
value: SortBy.dateAdded,
enabled: value != SortBy.dateAdded,
child: ListTile(
enabled: value != SortBy.dateAdded,
title: Text(context.l10n.sort_date),
),
value: SortBy.newest,
enabled: value != SortBy.newest,
title: Text(context.l10n.sort_newest),
),
PopSheetEntry(
value: SortBy.oldest,
enabled: value != SortBy.oldest,
title: Text(context.l10n.sort_oldest),
),
PopSheetEntry(
value: SortBy.artist,
enabled: value != SortBy.artist,
child: ListTile(
enabled: value != SortBy.artist,
title: Text(context.l10n.sort_artist),
),
title: Text(context.l10n.sort_artist),
),
PopSheetEntry(
value: SortBy.album,
enabled: value != SortBy.album,
child: ListTile(
enabled: value != SortBy.album,
title: Text(context.l10n.sort_album),
),
title: Text(context.l10n.sort_album),
),
],
headings: [
@ -79,7 +67,7 @@ class SortTracksDropdown extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: DefaultTextStyle(
style: Theme.of(context).textTheme.titleSmall!,
style: theme.textTheme.titleSmall!,
child: Row(
children: [
const Icon(SpotubeIcons.sort),

View File

@ -1,5 +1,25 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class SpotubePage<T> extends MaterialPage<T> {
const SpotubePage({required super.child});
}
class SpotubeSlidePage extends CustomTransitionPage {
SpotubeSlidePage({
required super.child,
super.key,
}) : super(
reverseTransitionDuration: const Duration(milliseconds: 150),
transitionDuration: const Duration(milliseconds: 150),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(animation),
child: child,
);
},
);
}

View File

@ -18,6 +18,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
);
final breakpoint = useBreakpointValue(
xs: 85.0,
sm: 85.0,
md: 35.0,
others: 0.0,

View File

@ -1,349 +0,0 @@
import 'package:fl_query/fl_query.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:collection/collection.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/album/album_card.dart';
import 'package:spotube/components/shared/compact_search.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/image/universal_image.dart';
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/use_custom_status_bar_color.dart';
import 'package:spotube/hooks/use_palette_color.dart';
import 'package:spotube/models/logger.dart';
import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class TrackCollectionView<T> extends HookConsumerWidget {
final logger = getLogger(TrackCollectionView);
final String id;
final String title;
final String? description;
final Query<List<TrackSimple>, T> tracksSnapshot;
final String titleImage;
final bool isPlaying;
final void Function([Track? currentTrack]) onPlay;
final void Function() onAddToQueue;
final void Function([Track? currentTrack]) onShuffledPlay;
final void Function() onShare;
final Widget? heartBtn;
final AlbumSimple? album;
final bool showShare;
final bool isOwned;
final bool bottomSpace;
final String routePath;
TrackCollectionView({
required this.title,
required this.id,
required this.tracksSnapshot,
required this.titleImage,
required this.isPlaying,
required this.onPlay,
required this.onShuffledPlay,
required this.onAddToQueue,
required this.onShare,
required this.routePath,
this.heartBtn,
this.album,
this.description,
this.showShare = true,
this.isOwned = false,
this.bottomSpace = false,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
final auth = ref.watch(AuthenticationNotifier.provider);
final color = usePaletteGenerator(titleImage).dominantColor;
final List<Widget> buttons = [
if (showShare)
IconButton(
icon: Icon(
SpotubeIcons.share,
color: color?.titleTextColor,
),
onPressed: onShare,
),
if (heartBtn != null && auth != null) heartBtn!,
IconButton(
tooltip: context.l10n.shuffle,
icon: Icon(
SpotubeIcons.shuffle,
color: color?.titleTextColor,
),
onPressed: onShuffledPlay,
),
const SizedBox(width: 5),
// add to queue playlist
if (!isPlaying)
IconButton(
onPressed: tracksSnapshot.data != null ? onAddToQueue : null,
icon: Icon(
SpotubeIcons.queueAdd,
color: color?.titleTextColor,
),
),
// play playlist
ElevatedButton(
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
backgroundColor: theme.colorScheme.inversePrimary,
),
onPressed: tracksSnapshot.data != null ? onPlay : null,
child: Icon(isPlaying ? SpotubeIcons.stop : SpotubeIcons.play),
),
const SizedBox(width: 10),
];
final controller = useScrollController();
final collapsed = useState(false);
final searchText = useState("");
final searchController = useTextEditingController();
final filteredTracks = useMemoized(() {
if (searchText.value.isEmpty) {
return tracksSnapshot.data;
}
return tracksSnapshot.data
?.map((e) => (weightedRatio(e.name!, searchText.value), e))
.sorted((a, b) => b.$1.compareTo(a.$1))
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList();
}, [tracksSnapshot.data, searchText.value]);
useCustomStatusBarColor(
color?.color ?? theme.scaffoldBackgroundColor,
GoRouter.of(context).location == routePath,
);
useEffect(() {
listener() {
if (controller.position.pixels >= 390 && !collapsed.value) {
collapsed.value = true;
} else if (controller.position.pixels < 390 && collapsed.value) {
collapsed.value = false;
}
}
controller.addListener(listener);
return () => controller.removeListener(listener);
}, [collapsed.value]);
final searchbar = ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 300,
maxHeight: 50,
),
child: TextField(
controller: searchController,
onChanged: (value) => searchText.value = value,
style: TextStyle(color: color?.titleTextColor),
decoration: InputDecoration(
hintText: context.l10n.search_tracks,
hintStyle: TextStyle(color: color?.titleTextColor),
border: theme.inputDecorationTheme.border?.copyWith(
borderSide: BorderSide(
color: color?.titleTextColor ?? Colors.white,
),
),
isDense: true,
prefixIconColor: color?.titleTextColor,
prefixIcon: const Icon(SpotubeIcons.search),
),
),
);
return SafeArea(
bottom: false,
child: Scaffold(
appBar: kIsDesktop
? PageWindowTitleBar(
backgroundColor: color?.color,
foregroundColor: color?.titleTextColor,
leadingWidth: 400,
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
BackButton(color: color?.titleTextColor),
const SizedBox(width: 10),
searchbar,
],
),
)
: null,
body: RefreshIndicator(
onRefresh: () async {
await tracksSnapshot.refresh();
},
child: CustomScrollView(
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverAppBar(
actions: [
if (kIsMobile)
CompactSearch(
onChanged: (value) => searchText.value = value,
placeholder: context.l10n.search_tracks,
iconColor: color?.titleTextColor,
),
if (collapsed.value) ...buttons,
],
floating: false,
pinned: true,
expandedHeight: 400,
automaticallyImplyLeading: kIsMobile,
leading: kIsMobile
? BackButton(color: color?.titleTextColor)
: null,
iconTheme: IconThemeData(color: color?.titleTextColor),
primary: true,
backgroundColor: color?.color,
title: collapsed.value
? Text(
title,
style: theme.textTheme.titleLarge!.copyWith(
color: color?.titleTextColor,
fontWeight: FontWeight.w600,
),
)
: null,
centerTitle: true,
flexibleSpace: FlexibleSpaceBar(
background: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
color?.color ?? Colors.transparent,
theme.canvasColor,
],
begin: const FractionalOffset(0, 0),
end: const FractionalOffset(0, 1),
tileMode: TileMode.clamp,
),
),
child: Material(
type: MaterialType.transparency,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
),
child: Wrap(
spacing: 20,
runSpacing: 20,
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
children: [
Container(
constraints:
const BoxConstraints(maxHeight: 200),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: UniversalImage(
path: titleImage,
placeholder: Assets.albumPlaceholder.path,
),
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Text(
title,
style: theme.textTheme.titleLarge!.copyWith(
color: color?.titleTextColor,
fontWeight: FontWeight.w600,
),
),
if (album != null)
Text(
"${AlbumType.from(album?.albumType).formatted}${context.l10n.released}${DateTime.tryParse(
album?.releaseDate ?? "",
)?.year}",
style:
theme.textTheme.titleMedium!.copyWith(
color: color?.titleTextColor,
fontWeight: FontWeight.normal,
),
),
if (description != null)
Text(
description!,
style: TextStyle(
color: color?.bodyTextColor,
),
maxLines: 2,
overflow: TextOverflow.fade,
),
const SizedBox(height: 10),
Row(
mainAxisSize: MainAxisSize.min,
children: buttons,
),
],
)
],
),
),
),
),
),
),
HookBuilder(
builder: (context) {
if (tracksSnapshot.isLoading || !tracksSnapshot.hasData) {
return const ShimmerTrackTile();
} else if (tracksSnapshot.hasError) {
return SliverToBoxAdapter(
child: Text(
context.l10n.error(tracksSnapshot.error ?? ""),
),
);
}
return TracksTableView(
List.from(
(filteredTracks ?? []).map(
(e) {
if (e is Track) {
return e;
} else {
return TypeConversionUtils.simpleTrack_X_Track(
e, album!);
}
},
),
),
onTrackPlayButtonPressed: onPlay,
playlistId: id,
userPlaylist: isOwned,
);
},
)
],
),
)),
);
}
}

View File

@ -0,0 +1,198 @@
import 'dart:ui';
import 'package:fl_query/fl_query.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/album/album_card.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
class TrackCollectionHeading<T> extends HookConsumerWidget {
final String title;
final String? description;
final String titleImage;
final List<Widget> buttons;
final AlbumSimple? album;
final Query<List<TrackSimple>, T> tracksSnapshot;
final bool isPlaying;
final void Function([Track? currentTrack]) onPlay;
final void Function([Track? currentTrack]) onShuffledPlay;
final PaletteColor? color;
const TrackCollectionHeading({
Key? key,
required this.title,
required this.titleImage,
required this.buttons,
required this.tracksSnapshot,
required this.isPlaying,
required this.onPlay,
required this.onShuffledPlay,
required this.color,
this.description,
this.album,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
return LayoutBuilder(
builder: (context, constrains) {
return DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: UniversalImage.imageProvider(titleImage),
fit: BoxFit.cover,
),
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.black45,
theme.colorScheme.surface,
],
begin: const FractionalOffset(0, 0),
end: const FractionalOffset(0, 1),
tileMode: TileMode.clamp,
),
),
child: Material(
type: MaterialType.transparency,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
),
child: SafeArea(
child: Flex(
direction: constrains.mdAndDown
? Axis.vertical
: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.center,
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: UniversalImage(
path: titleImage,
placeholder: Assets.albumPlaceholder.path,
),
),
),
const SizedBox(width: 10, height: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
Text(
title,
style: theme.textTheme.titleLarge!.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
if (album != null)
Text(
"${AlbumType.from(album?.albumType).formatted}${context.l10n.released}${DateTime.tryParse(
album?.releaseDate ?? "",
)?.year}",
style: theme.textTheme.titleMedium!.copyWith(
color: Colors.white,
fontWeight: FontWeight.normal,
),
),
if (description != null)
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constrains.mdAndDown ? 400 : 300,
),
child: Text(
description!,
style: const TextStyle(color: Colors.white),
maxLines: 2,
overflow: TextOverflow.fade,
),
),
const SizedBox(height: 10),
IconTheme(
data: theme.iconTheme.copyWith(
color: Colors.white,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: buttons,
),
),
const SizedBox(height: 10),
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: constrains.mdAndDown ? 400 : 300,
),
child: Row(
mainAxisSize: constrains.smAndUp
? MainAxisSize.min
: MainAxisSize.min,
children: [
Expanded(
child: FilledButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
),
label: Text(context.l10n.shuffle),
icon: const Icon(SpotubeIcons.shuffle),
onPressed: tracksSnapshot.data == null ||
isPlaying
? null
: onShuffledPlay,
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: color?.color,
foregroundColor: color?.bodyTextColor,
),
onPressed: tracksSnapshot.data != null
? onPlay
: null,
icon: Icon(
isPlaying
? SpotubeIcons.stop
: SpotubeIcons.play,
),
label: Text(
isPlaying
? context.l10n.stop
: context.l10n.play,
),
),
),
],
),
),
],
)
],
),
),
),
),
),
),
);
},
);
}
}

View File

@ -0,0 +1,241 @@
import 'package:fl_query/fl_query.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_heading.dart';
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/use_custom_status_bar_color.dart';
import 'package:spotube/hooks/use_palette_color.dart';
import 'package:spotube/models/logger.dart';
import 'package:flutter/material.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/utils/platform.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
class TrackCollectionView<T> extends HookConsumerWidget {
final logger = getLogger(TrackCollectionView);
final String id;
final String title;
final String? description;
final Query<List<TrackSimple>, T> tracksSnapshot;
final String titleImage;
final bool isPlaying;
final void Function([Track? currentTrack]) onPlay;
final void Function([Track? currentTrack]) onShuffledPlay;
final void Function() onAddToQueue;
final void Function() onShare;
final Widget? heartBtn;
final AlbumSimple? album;
final bool showShare;
final bool isOwned;
final bool bottomSpace;
final String routePath;
TrackCollectionView({
required this.title,
required this.id,
required this.tracksSnapshot,
required this.titleImage,
required this.isPlaying,
required this.onPlay,
required this.onShuffledPlay,
required this.onAddToQueue,
required this.onShare,
required this.routePath,
this.heartBtn,
this.album,
this.description,
this.showShare = true,
this.isOwned = false,
this.bottomSpace = false,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
final auth = ref.watch(AuthenticationNotifier.provider);
final color = usePaletteGenerator(titleImage).dominantColor;
final List<Widget> buttons = [
if (showShare)
IconButton(
icon: const Icon(SpotubeIcons.share),
onPressed: onShare,
),
if (heartBtn != null && auth != null) heartBtn!,
IconButton(
onPressed: isPlaying
? null
: tracksSnapshot.data != null
? onAddToQueue
: null,
icon: const Icon(
SpotubeIcons.queueAdd,
),
),
];
final controller = useScrollController();
final collapsed = useState(false);
useCustomStatusBarColor(
Colors.transparent,
GoRouter.of(context).location == routePath,
);
useEffect(() {
listener() {
if (controller.position.pixels >= 390 && !collapsed.value) {
collapsed.value = true;
} else if (controller.position.pixels < 390 && collapsed.value) {
collapsed.value = false;
}
}
controller.addListener(listener);
return () => controller.removeListener(listener);
}, [collapsed.value]);
return Scaffold(
appBar: kIsDesktop
? const PageWindowTitleBar(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
leadingWidth: 400,
leading: Align(
alignment: Alignment.centerLeft,
child: BackButton(color: Colors.white),
),
)
: null,
extendBodyBehindAppBar: kIsDesktop,
body: RefreshIndicator(
onRefresh: () async {
await tracksSnapshot.refresh();
},
child: CustomScrollView(
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverAppBar(
actions: [
AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: collapsed.value ? 1 : 0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: buttons,
),
),
AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: collapsed.value ? 1 : 0,
child: IconButton(
tooltip: context.l10n.shuffle,
icon: const Icon(SpotubeIcons.shuffle),
onPressed: isPlaying ? null : onShuffledPlay,
),
),
AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: collapsed.value ? 1 : 0,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
shape: const CircleBorder(),
backgroundColor: theme.colorScheme.inversePrimary,
),
onPressed: tracksSnapshot.data != null ? onPlay : null,
child: Icon(
isPlaying ? SpotubeIcons.stop : SpotubeIcons.play),
),
),
],
floating: false,
pinned: true,
expandedHeight: 400,
automaticallyImplyLeading: kIsMobile,
leading:
kIsMobile ? const BackButton(color: Colors.white) : null,
iconTheme: IconThemeData(color: color?.titleTextColor),
primary: true,
backgroundColor: color?.color.withOpacity(.5),
title: collapsed.value
? Text(
title,
style: theme.textTheme.titleMedium!.copyWith(
color: color?.titleTextColor,
fontWeight: FontWeight.w600,
),
)
: null,
centerTitle: true,
flexibleSpace: FlexibleSpaceBar(
background: TrackCollectionHeading<T>(
color: color,
title: title,
description: description,
titleImage: titleImage,
isPlaying: isPlaying,
onPlay: onPlay,
onShuffledPlay: onShuffledPlay,
tracksSnapshot: tracksSnapshot,
buttons: buttons,
album: album,
),
),
),
HookBuilder(
builder: (context) {
if (tracksSnapshot.isLoading || !tracksSnapshot.hasData) {
return const ShimmerTrackTile();
} else if (tracksSnapshot.hasError) {
return SliverToBoxAdapter(
child: Text(
context.l10n.error(tracksSnapshot.error ?? ""),
),
);
}
return TracksTableView(
(tracksSnapshot.data ?? []).map(
(track) {
if (track is Track) {
return track;
} else {
return TypeConversionUtils.simpleTrack_X_Track(
track,
album!,
);
}
},
).toList(),
onTrackPlayButtonPressed: onPlay,
playlistId: id,
userPlaylist: isOwned,
onFiltering: () {
// scroll the flexible space
// to allow more space for search results
controller.animateTo(
330,
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
);
},
);
},
)
],
),
));
}
}

View File

@ -9,6 +9,7 @@ import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/library/user_local_tracks.dart';
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/shared/dialogs/track_details_dialog.dart';
import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/context.dart';
@ -29,6 +30,7 @@ enum TrackOptionValue {
delete,
playNext,
favorite,
details,
}
class TrackOptions extends HookConsumerWidget {
@ -163,6 +165,12 @@ class TrackOptions extends HookConsumerWidget {
case TrackOptionValue.share:
actionShare(context, track);
break;
case TrackOptionValue.details:
showDialog(
context: context,
builder: (context) => TrackDetailsDialog(track: track),
);
break;
}
},
icon: const Icon(SpotubeIcons.moreHorizontal),
@ -199,96 +207,82 @@ class TrackOptions extends HookConsumerWidget {
LocalTrack => [
PopSheetEntry(
value: TrackOptionValue.delete,
child: ListTile(
leading: const Icon(SpotubeIcons.trash),
title: Text(context.l10n.delete),
),
leading: const Icon(SpotubeIcons.trash),
title: Text(context.l10n.delete),
)
],
_ => [
if (!playlist.containsTrack(track)) ...[
PopSheetEntry(
value: TrackOptionValue.addToQueue,
child: ListTile(
leading: const Icon(SpotubeIcons.queueAdd),
title: Text(context.l10n.add_to_queue),
),
leading: const Icon(SpotubeIcons.queueAdd),
title: Text(context.l10n.add_to_queue),
),
PopSheetEntry(
value: TrackOptionValue.playNext,
child: ListTile(
leading: const Icon(SpotubeIcons.lightning),
title: Text(context.l10n.play_next),
),
leading: const Icon(SpotubeIcons.lightning),
title: Text(context.l10n.play_next),
),
] else
PopSheetEntry(
value: TrackOptionValue.removeFromQueue,
enabled: playlist.activeTrack?.id != track.id,
child: ListTile(
enabled: playlist.activeTrack?.id != track.id,
leading: const Icon(SpotubeIcons.queueRemove),
title: Text(context.l10n.remove_from_queue),
),
leading: const Icon(SpotubeIcons.queueRemove),
title: Text(context.l10n.remove_from_queue),
),
if (favorites.me.hasData)
PopSheetEntry(
value: TrackOptionValue.favorite,
child: ListTile(
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,
),
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,
child: ListTile(
leading: const Icon(SpotubeIcons.playlistAdd),
title: Text(context.l10n.add_to_playlist),
),
leading: const Icon(SpotubeIcons.playlistAdd),
title: Text(context.l10n.add_to_playlist),
),
if (userPlaylist && auth != null)
PopSheetEntry(
value: TrackOptionValue.removeFromPlaylist,
child: ListTile(
leading: (removeTrack.isMutating || !removeTrack.hasData) &&
removingTrack.value == track.uri
? const Center(
child: CircularProgressIndicator(),
)
: const Icon(SpotubeIcons.removeFilled),
title: Text(context.l10n.remove_from_playlist),
),
leading: (removeTrack.isMutating || !removeTrack.hasData) &&
removingTrack.value == track.uri
? const Center(
child: CircularProgressIndicator(),
)
: const Icon(SpotubeIcons.removeFilled),
title: Text(context.l10n.remove_from_playlist),
),
PopSheetEntry(
value: TrackOptionValue.blacklist,
child: ListTile(
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,
),
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,
child: ListTile(
leading: const Icon(SpotubeIcons.share),
title: Text(context.l10n.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

@ -61,7 +61,7 @@ class TrackTile extends HookConsumerWidget {
return LayoutBuilder(builder: (context, constrains) {
return HoverBuilder(
permanentState: isPlaying || constrains.isSm ? true : null,
permanentState: isPlaying || constrains.smAndDown ? true : null,
builder: (context, isHovering) {
return ListTile(
selected: isPlaying,
@ -89,7 +89,7 @@ class TrackTile extends HookConsumerWidget {
),
),
)
else if (constrains.isSm)
else if (constrains.smAndDown)
const SizedBox(width: 16),
if (onChanged != null)
Checkbox.adaptive(
@ -100,10 +100,14 @@ class TrackTile extends HookConsumerWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: UniversalImage(
path: TypeConversionUtils.image_X_UrlString(
track.album?.images,
placeholder: ImagePlaceholder.albumArt,
child: AspectRatio(
aspectRatio: 1,
child: UniversalImage(
path: TypeConversionUtils.image_X_UrlString(
track.album?.images,
placeholder: ImagePlaceholder.albumArt,
),
fit: BoxFit.cover,
),
),
),
@ -176,6 +180,7 @@ class TrackTile extends HookConsumerWidget {
track.album!.name!,
"/album/${track.album?.id}",
extra: track.album,
push: true,
overflow: TextOverflow.ellipsis,
),
)

View File

@ -1,6 +1,7 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
@ -8,6 +9,7 @@ import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart';
import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
import 'package:spotube/components/shared/fallbacks/not_found.dart';
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
import 'package:spotube/components/shared/track_table/track_tile.dart';
@ -31,10 +33,14 @@ class TracksTableView extends HookConsumerWidget {
final bool isSliver;
final Widget? heading;
final VoidCallback? onFiltering;
const TracksTableView(
this.tracks, {
Key? key,
this.onTrackPlayButtonPressed,
this.onFiltering,
this.userPlaylist = false,
this.playlistId,
this.heading,
@ -43,7 +49,9 @@ class TracksTableView extends HookConsumerWidget {
@override
Widget build(context, ref) {
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final mediaQuery = MediaQuery.of(context);
ref.watch(ProxyPlaylistNotifier.provider);
final playback = ref.watch(ProxyPlaylistNotifier.notifier);
ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier);
@ -54,11 +62,31 @@ class TracksTableView extends HookConsumerWidget {
final showCheck = useState<bool>(false);
final sortBy = ref.watch(trackCollectionSortState(playlistId ?? ''));
final isFiltering = useState<bool>(false);
final searchController = useTextEditingController();
final searchFocus = useFocusNode();
// this will trigger update on each change in searchController
useValueListenable(searchController);
final filteredTracks = useMemoized(() {
if (searchController.text.isEmpty) {
return tracks;
}
return tracks
.map((e) => (weightedRatio(e.name!, searchController.text), e))
.sorted((a, b) => b.$1.compareTo(a.$1))
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList();
}, [tracks, searchController.text]);
final sortedTracks = useMemoized(
() {
return ServiceUtils.sortTracks(tracks, sortBy);
return ServiceUtils.sortTracks(filteredTracks, sortBy);
},
[tracks, sortBy],
[filteredTracks, sortBy],
);
final selectedTracks = useMemoized(
@ -68,7 +96,7 @@ class TracksTableView extends HookConsumerWidget {
[sortedTracks],
);
final children = sortedTracks.isEmpty
final children = tracks.isEmpty
? [const NotFound(vertical: true)]
: [
if (heading != null) heading!,
@ -105,7 +133,7 @@ class TracksTableView extends HookConsumerWidget {
: const SizedBox(width: 16),
),
Expanded(
flex: 5,
flex: 7,
child: Row(
children: [
Text(
@ -139,6 +167,15 @@ class TracksTableView extends HookConsumerWidget {
.state = value;
},
),
ExpandableSearchButton(
isFiltering: isFiltering,
searchFocus: searchFocus,
onPressed: (value) {
if (isFiltering.value) {
onFiltering?.call();
}
},
),
AdaptivePopSheetList(
tooltip: context.l10n.more_actions,
headings: [
@ -147,55 +184,6 @@ class TracksTableView extends HookConsumerWidget {
style: tableHeadStyle,
),
],
children: [
PopSheetEntry(
enabled: selectedTracks.isNotEmpty,
value: "download",
child: ListTile(
leading: const Icon(SpotubeIcons.download),
enabled: selectedTracks.isNotEmpty,
title: Text(
context.l10n.download_count(selectedTracks.length),
),
),
),
if (!userPlaylist)
PopSheetEntry(
enabled: selectedTracks.isNotEmpty,
value: "add-to-playlist",
child: ListTile(
leading: const Icon(SpotubeIcons.playlistAdd),
enabled: selectedTracks.isNotEmpty,
title: Text(
context.l10n
.add_count_to_playlist(selectedTracks.length),
),
),
),
PopSheetEntry(
enabled: selectedTracks.isNotEmpty,
value: "add-to-queue",
child: ListTile(
leading: const Icon(SpotubeIcons.queueAdd),
enabled: selectedTracks.isNotEmpty,
title: Text(
context.l10n
.add_count_to_queue(selectedTracks.length),
),
),
),
PopSheetEntry(
enabled: selectedTracks.isNotEmpty,
value: "play-next",
child: ListTile(
leading: const Icon(SpotubeIcons.lightning),
enabled: selectedTracks.isNotEmpty,
title: Text(
context.l10n.play_count_next(selectedTracks.length),
),
),
),
],
onSelected: (action) async {
switch (action) {
case "download":
@ -230,6 +218,9 @@ class TracksTableView extends HookConsumerWidget {
case "play-next":
{
playback.addTracksAtFirst(selectedTracks);
if (playlistId != null) {
playback.addCollection(playlistId!);
}
selected.value = [];
showCheck.value = false;
break;
@ -237,6 +228,9 @@ class TracksTableView extends HookConsumerWidget {
case "add-to-queue":
{
playback.addTracks(selectedTracks);
if (playlistId != null) {
playback.addCollection(playlistId!);
}
selected.value = [];
showCheck.value = false;
break;
@ -245,11 +239,53 @@ class TracksTableView extends HookConsumerWidget {
}
},
icon: const Icon(SpotubeIcons.moreVertical),
children: [
PopSheetEntry(
value: "download",
leading: const Icon(SpotubeIcons.download),
enabled: selectedTracks.isNotEmpty,
title: Text(
context.l10n.download_count(selectedTracks.length),
),
),
if (!userPlaylist)
PopSheetEntry(
value: "add-to-playlist",
leading: const Icon(SpotubeIcons.playlistAdd),
enabled: selectedTracks.isNotEmpty,
title: Text(
context.l10n
.add_count_to_playlist(selectedTracks.length),
),
),
PopSheetEntry(
enabled: selectedTracks.isNotEmpty,
value: "add-to-queue",
leading: const Icon(SpotubeIcons.queueAdd),
title: Text(
context.l10n
.add_count_to_queue(selectedTracks.length),
),
),
PopSheetEntry(
enabled: selectedTracks.isNotEmpty,
value: "play-next",
leading: const Icon(SpotubeIcons.lightning),
title: Text(
context.l10n.play_count_next(selectedTracks.length),
),
),
],
),
const SizedBox(width: 10),
],
);
}),
ExpandableSearchField(
isFiltering: isFiltering,
searchController: searchController,
searchFocus: searchFocus,
),
...sortedTracks.mapIndexed((i, track) {
return TrackTile(
index: i,
@ -297,11 +333,17 @@ class TracksTableView extends HookConsumerWidget {
}
},
);
}).toList(),
}),
// extra space for mobile devices where keyboard takes half of the screen
if (isFiltering.value)
SizedBox(
height: mediaQuery.size.height * .75, //75% of the screen
),
];
if (isSliver) {
return SliverSafeArea(
top: false,
sliver: SliverList(delegate: SliverChildListDelegate(children)),
);
}

View File

@ -1,25 +1,39 @@
import 'package:flutter/widgets.dart';
extension ContainerBreakpoints on BoxConstraints {
bool get isSm => biggest.width <= 640;
bool get isXs => biggest.width <= 480;
bool get isSm => biggest.width > 480 && biggest.width <= 640;
bool get isMd => biggest.width > 640 && biggest.width <= 768;
bool get isLg => biggest.width > 768 && biggest.width <= 1024;
bool get isXl => biggest.width > 1024 && biggest.width <= 1280;
bool get is2Xl => biggest.width > 1280;
bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl;
bool get mdAndUp => isMd || isLg || isXl || is2Xl;
bool get lgAndUp => isLg || isXl || is2Xl;
bool get xlAndUp => isXl || is2Xl;
bool get smAndDown => isXs || isSm;
bool get mdAndDown => isXs || isSm || isMd;
bool get lgAndDown => isXs || isSm || isMd || isLg;
bool get xlAndDown => isXs || isSm || isMd || isLg || isXl;
}
extension ScreenBreakpoints on MediaQueryData {
bool get isSm => size.width <= 640;
bool get isXs => size.width <= 480;
bool get isSm => size.width > 480 && size.width <= 640;
bool get isMd => size.width > 640 && size.width <= 768;
bool get isLg => size.width > 768 && size.width <= 1024;
bool get isXl => size.width > 1024 && size.width <= 1280;
bool get is2Xl => size.width > 1280;
bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl;
bool get mdAndUp => isMd || isLg || isXl || is2Xl;
bool get lgAndUp => isLg || isXl || is2Xl;
bool get xlAndUp => isXl || is2Xl;
bool get smAndDown => isXs || isSm;
bool get mdAndDown => isXs || isSm || isMd;
bool get lgAndDown => isXs || isSm || isMd || isLg;
bool get xlAndDown => isXs || isSm || isMd || isLg || isXl;
}

View File

@ -1,6 +1,28 @@
import 'package:duration/locale.dart';
import 'package:spotube/utils/primitive_utils.dart';
import 'package:duration/duration.dart';
extension DurationToHumanReadableString on Duration {
toHumanReadableString() =>
String toHumanReadableString() =>
"${inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(inSeconds.remainder(60))}";
String format({
DurationTersity tersity = DurationTersity.second,
DurationTersity upperTersity = DurationTersity.week,
DurationLocale locale = const EnglishDurationLocale(),
String? spacer,
String? delimiter,
String? conjugation,
bool abbreviated = false,
}) =>
printDuration(
this,
tersity: tersity,
upperTersity: upperTersity,
locale: locale,
spacer: spacer,
delimiter: delimiter,
conjugation: conjugation,
abbreviated: abbreviated,
);
}

View File

@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotube/extensions/constrains.dart';
T useBreakpointValue<T>({
T? xs,
T? sm,
T? md,
T? lg,
@ -10,8 +11,12 @@ T useBreakpointValue<T>({
T? xxl,
T? others,
}) {
final isSomeNull =
sm == null || md == null || lg == null || xl == null || xxl == null;
final isSomeNull = xs == null ||
sm == null ||
md == null ||
lg == null ||
xl == null ||
xxl == null;
assert(
(isSomeNull && others != null) || (!isSomeNull && others == null),
'You must provide a value for all breakpoints or a default value for others',
@ -20,7 +25,9 @@ T useBreakpointValue<T>({
final mediaQuery = MediaQuery.of(context);
if (isSomeNull) {
if (mediaQuery.isSm) {
if (mediaQuery.isXs) {
return xs ?? others!;
} else if (mediaQuery.isSm) {
return sm ?? others!;
} else if (mediaQuery.isMd) {
return md ?? others!;
@ -32,7 +39,9 @@ T useBreakpointValue<T>({
return lg ?? others!;
}
} else {
if (mediaQuery.isSm) {
if (mediaQuery.isXs) {
return xs;
} else if (mediaQuery.isSm) {
return sm;
} else if (mediaQuery.isMd) {
return md;

View File

@ -37,7 +37,6 @@
"none": "কোনটিই না",
"sort_a_z": "A-Z ক্রমে সাজান",
"sort_z_a": "Z-A ক্রমে সাজান",
"sort_date": "তারিখের ক্রমে সাজান",
"sort_artist": "শিল্পীর ক্রমে সাজান",
"sort_album": "অ্যালবামের ক্রমে সাজান",
"sort_tracks": "গানের ক্রম",

View File

@ -37,7 +37,6 @@
"none": "None",
"sort_a_z": "Sort by A-Z",
"sort_z_a": "Sort by Z-A",
"sort_date": "Sort by date",
"sort_artist": "Sort by Artist",
"sort_album": "Sort by Album",
"sort_tracks": "Sort Tracks",
@ -230,5 +229,22 @@
"download_agreement_2": "I'll support the Artist wherever I can and I'm only doing this because I don't have money to buy their art",
"download_agreement_3": "I'm completely aware that my IP can get blocked on YouTube & I don't hold Spotube or his owners/contributors responsible for any accidents caused by my current action",
"decline": "Decline",
"accept": "Accept"
"accept": "Accept",
"details": "Details",
"youtube": "YouTube",
"channel": "Channel",
"likes": "Likes",
"dislikes": "Dislikes",
"views": "Views",
"streamUrl": "Stream URL",
"stop": "Stop",
"sort_newest": "Sort by newest added",
"sort_oldest": "Sort by oldest added",
"sleep_timer": "Sleep Timer",
"mins": "{minutes} Minutes",
"hours": "{hours} Hours",
"hour": "{hours} Hour",
"custom_hours": "Custom Hours",
"logs": "Logs",
"developers": "Developers"
}

View File

@ -37,7 +37,6 @@
"none": "Aucun",
"sort_a_z": "Trier par ordre alphabétique",
"sort_z_a": "Trier par ordre alphabétique inverse",
"sort_date": "Trier par date",
"sort_artist": "Trier par artiste",
"sort_album": "Trier par album",
"sort_tracks": "Trier les pistes",

View File

@ -37,7 +37,6 @@
"none": "कोई नहीं",
"sort_a_z": "A-Z सॉर्ट करें",
"sort_z_a": "Z-A सॉर्ट करें",
"sort_date": "तिथि के अनुसार सॉर्ट करें",
"sort_artist": "कलाकार के अनुसार सॉर्ट करें",
"sort_album": "एल्बम के अनुसार सॉर्ट करें",
"sort_tracks": "ट्रैक को सॉर्ट करें",

250
lib/l10n/app_ja.arb Normal file
View File

@ -0,0 +1,250 @@
{
"guest": "ゲスト",
"browse": "閲覧",
"search": "検索",
"library": "ライブラリ",
"lyrics": "歌詞",
"settings": "設定",
"genre_categories_filter": "カテゴリーやジャンルを絞り込み...",
"genre": "ジャンル",
"personalized": "あなたにおすすめ",
"featured": "注目",
"new_releases": "新着",
"songs": "曲",
"playing_track": "{track} を再生",
"queue_clear_alert": "現在のキューを消去します。{track_length} 曲を消去します。\n続行しますか",
"load_more": "もっと読み込む",
"playlists": "再生リスト",
"artists": "アーティスト",
"albums": "アルバム",
"tracks": "曲",
"downloads": "ダウンロード",
"filter_playlists": "あなたの再生リストを絞り込み...",
"liked_tracks": "いいねした曲",
"liked_tracks_description": "いいねしたすべての曲",
"create_playlist": "再生リストの作成",
"create_a_playlist": "再生リストの作成",
"create": "作成",
"cancel": "キャンセル",
"playlist_name": "再生リスト名",
"name_of_playlist": "再生リストの名前",
"description": "説明",
"public": "公開",
"collaborative": "コラボ",
"search_local_tracks": "端末内の曲を検索...",
"play": "再生",
"delete": "削除",
"none": "なし",
"sort_a_z": "A-Z 順に並び替え",
"sort_z_a": "Z-A 順に並び替え",
"sort_artist": "アーティスト順に並び替え",
"sort_album": "アルバム順に並び替え",
"sort_tracks": "曲の並び替え",
"currently_downloading": "いまダウンロード中 ({tracks_length}) 曲",
"cancel_all": "すべてキャンセル",
"filter_artist": "アーティストを絞り込み...",
"followers": "{followers} フォロワー",
"add_artist_to_blacklist": "このアーティストをブラックリストに追加",
"top_tracks": "人気の曲",
"fans_also_like": "ファンの間で人気",
"loading": "読み込み中...",
"artist": "アーティスト",
"blacklisted": "ブラックリスト",
"following": "フォロー中",
"follow": "フォローする",
"artist_url_copied": "アーティストの URL をクリップボードにコピーしました",
"added_to_queue": "{tracks} をキューに追加しました",
"filter_albums": "アルバムを絞り込み...",
"synced": "同期する",
"plain": "そのまま",
"shuffle": "シャッフル",
"search_tracks": "曲を検索...",
"released": "リリース日",
"error": "エラー {error}",
"title": "タイトル",
"time": "長さ",
"more_actions": "ほかの操作",
"download_count": "ダウンロード ({count}) 曲",
"add_count_to_playlist": "再生リストに ({count}) 曲を追加",
"add_count_to_queue": "キューに ({count}) 曲を追加",
"play_count_next": "次に ({count}) 曲を再生",
"album": "アルバム",
"copied_to_clipboard": "{data} をクリップボードにコピーしました",
"add_to_following_playlists": "{track} をこの再生リストに追加",
"add": "追加",
"added_track_to_queue": "キューに {track} を追加しました",
"add_to_queue": "キューに追加",
"track_will_play_next": "{track} を次に再生",
"play_next": "次に再生",
"removed_track_from_queue": "キューから {track} を除去しました",
"remove_from_queue": "キューから除去",
"remove_from_favorites": "お気に入りから除去",
"save_as_favorite": "お気に入りに保存",
"add_to_playlist": "再生リストに追加",
"remove_from_playlist": "再生リストから除去",
"add_to_blacklist": "ブラックリストに追加",
"remove_from_blacklist": "ブラックリストから除去",
"share": "共有",
"mini_player": "ミニプレイヤー",
"slide_to_seek": "前後にスライドしてシーク",
"shuffle_playlist": "再生リストをシャッフル",
"unshuffle_playlist": "再生リストのシャッフル解除",
"previous_track": "前の曲",
"next_track": "次の曲",
"pause_playback": "再生を停止",
"resume_playback": "再生を再開",
"loop_track": "曲をループ",
"repeat_playlist": "再生リストをリピート",
"queue": "再生キュー",
"alternative_track_sources": "この曲の別の提供元を選ぶ",
"download_track": "曲のダウンロード",
"tracks_in_queue": "{tracks}曲の再生キュー",
"clear_all": "すべて消去l",
"show_hide_ui_on_hover": "マウスを乗せてUIを表示/隠す",
"always_on_top": "常に手前に表示",
"exit_mini_player": "ミニプレイヤーを終了",
"download_location": "ダウンロード先",
"account": "アカウント",
"login_with_spotify": "Spotify アカウントでログイン",
"connect_with_spotify": "Spotify に接続",
"logout": "ログアウト",
"logout_of_this_account": "このアカウントからログアウト",
"language_region": "言語 & 地域",
"language": "言語",
"system_default": "システムの既定値",
"market_place_region": "市場の地域",
"recommendation_country": "推薦先の国",
"appearance": "外観",
"layout_mode": "レイアウトの種類",
"override_layout_settings": "レスポンシブなレイアウトの種類の設定を上書きする",
"adaptive": "適応的",
"compact": "コンパクト",
"extended": "幅広",
"theme": "テーマ",
"dark": "ダーク",
"light": "ライト",
"system": "システムに従う",
"accent_color": "アクセントカラー",
"sync_album_color": "アルバムの色に合わせる",
"sync_album_color_description": "アルバムアートの主張色をアクセントカラーとして使用",
"playback": "再生",
"audio_quality": "音声品質",
"high": "高",
"low": "低",
"pre_download_play": "事前ダウンロードと再生",
"pre_download_play_description": "音声をストリーミングする代わりに、データをバイト単位でダウンロードして再生 (回線速度が早いユーザーにおすすめ)",
"skip_non_music": "音楽でない部分をスキップ (SponsorBlock)",
"blacklist_description": "曲とアーティストのブラックリスト",
"wait_for_download_to_finish": "現在のダウンロードが完了するまでお待ちください",
"download_lyrics": "曲と共に歌詞もダウンロード",
"desktop": "デスクトップ",
"close_behavior": "閉じた時の動作",
"close": "閉じる",
"minimize_to_tray": "トレイに最小化",
"show_tray_icon": "システムトレイにアイコンを表示",
"about": "このアプリについて",
"u_love_spotube": "Spotube が好きだと知っていますよ",
"check_for_updates": "アップデートの確認",
"about_spotube": "Spotube について",
"blacklist": "ブラックリスト",
"please_sponsor": "出資/寄付もお待ちします",
"spotube_description": "Spotube は、軽量でクロスプラットフォームな、すべて無料の spotify クライアント",
"version": "バージョン",
"build_number": "ビルド番号",
"founder": "創始者",
"repository": "リポジトリ",
"bug_issues": "バグや問題",
"made_with": "❤️ を込めてバングラディシュ🇧🇩で開発",
"kingkor_roy_tirtho": "Kingkor Roy Tirtho",
"copyright": "© 2021-{current_year} Kingkor Roy Tirtho",
"license": "ライセンス",
"add_spotify_credentials": "Spotify のログイン情報を追加してはじめましょう",
"credentials_will_not_be_shared_disclaimer": "心配ありません。個人情報を収集したり、共有されることはありません",
"know_how_to_login": "やり方が分からないですか?",
"follow_step_by_step_guide": "やり方の説明を見る",
"spotify_cookie": "Spotify {name} Cookies",
"cookie_name_cookie": "{name} Cookies",
"fill_in_all_fields": "すべての欄に入力してください",
"submit": "送信",
"exit": "終了",
"previous": "前へ",
"next": "次へ",
"done": "完了",
"step_1": "ステップ 1",
"first_go_to": "最初にここを開き",
"login_if_not_logged_in": "、ログインしてないならログインまたは登録します",
"step_2": "ステップ 2",
"step_2_steps": "1. ログインしたら、F12を押すか、マウス右クリック 調査(検証)でブラウザの開発者ツール (devtools) を開きます。\n2. アプリケーション (Application) タブ (Chrome, Edge, Brave など) またはストレージタブ (Firefox, Palemoon など)\n3. Cookies 欄を選択し、https://accounts.spotify.com の枝を選びます",
"step_3": "ステップ 3",
"step_3_steps": "sp_dc と sp_key の値 (Value) をコピーします",
"success_emoji": "成功🥳",
"success_message": "アカウントへのログインに成功しました。よくできました!",
"step_4": "ステップ 4",
"step_4_steps": "コピーした sp_dc と sp_keyの値をそれぞれの入力欄に貼り付けます",
"something_went_wrong": "何か誤りがあります",
"piped_instance": "Piped サーバーのインスタンス",
"piped_description": "曲の matching (未訳)に使う Piped サーバーのインスタンス\nそれらの一部ではうまく動作しないこともあります。自己責任で使用してください",
"generate_playlist": "再生リストの生成",
"track_exists": "曲 {track} は既に存在します",
"replace_downloaded_tracks": "すべてのダウンロード済みの曲を置換",
"skip_download_tracks": "すべてのダウンロード済みの曲をスキップ",
"do_you_want_to_replace": "既存の曲と置換しますか?",
"replace": "置換する",
"skip": "スキップ",
"select_up_to_count_type": "{type}を最大{count} 個まで選択",
"select_genres": "ジャンルを選択",
"add_genres": "ジャンルを追加",
"country": "国",
"number_of_tracks_generate": "生成する曲数",
"acousticness": "アコースティック性",
"danceability": "ダンス性",
"energy": "エネルギー",
"instrumentalness": "楽器性",
"liveness": "ライブ性",
"loudness": "ラウドネス",
"speechiness": "会話性",
"valence": "多幸性",
"popularity": "人気度",
"key": "キー",
"duration": "長さ (秒)",
"tempo": "テンポ (BPM)",
"mode": "種類",
"time_signature": "拍子記号",
"short": "短",
"medium": "中",
"long": "長",
"min": "最小",
"max": "最大",
"target": "対象",
"moderate": "中",
"deselect_all": "すべて選択解除",
"select_all": "すべて選択",
"are_you_sure": "よろしいですか?",
"generating_playlist": "カスタム再生リストを生成中...",
"selected_count_tracks": "{count} 曲が選ばれました",
"download_warning": "全曲の一括ダウンロードは、明らかに音楽への海賊行為であり、音楽を生み出す共同体に損害を与えるでしょう。気づいてほしい。アーティストの多大な努力に敬意を払い、支援するようにしてください",
"download_ip_ban_warning": "また、通常よりも過剰なダウンロード要求があれば、YouTubeはあなたのIPをブロックします。つまり、そのIPの端末からは、少なくとも2-3か月の間、ログインしてもYouTubeを利用できないということです。そうなっても Spotube は一切の責任を負いません",
"by_clicking_accept_terms": "「同意する」のクリックにより、以下の条件への同意となります:",
"download_agreement_1": "ええ、音楽への海賊行為だ。私は悪い",
"download_agreement_2": "芸術作品を買うお金がないのでそうするしかないが、アーティストをできる限り支援する",
"download_agreement_3": "私のIPがYouTubeにブロックされることがあると完全に把握した。私のこの行動により起きたどんな事故も、Spotube やその所有者/貢献者に責任はありません。",
"decline": "同意しない",
"accept": "同意する",
"details": "詳細",
"youtube": "YouTube",
"channel": "チャンネル",
"likes": "高評価",
"dislikes": "低評価",
"views": "視聴回数",
"streamUrl": "動画の URL",
"stop": "中止",
"sort_newest": "追加日の新しい順に並び替え",
"sort_oldest": "追加日の古い順に並び替え",
"sleep_timer": "スリープタイマー",
"mins": "{minutes} 分",
"hours": "{hours} 時間",
"hour": "{hours} 時間",
"custom_hours": "時間指定",
"logs": "ログ",
"developers": "開発"
}

View File

@ -2,6 +2,7 @@
///
/// Kingkor Roy Tirtho => English, Bengali
/// ChatGPT (GPT 3.5) XD => Hindi, French
/// maboroshin@github => Japanese
import 'package:flutter/material.dart';
class L10n {
@ -11,5 +12,6 @@ class L10n {
const Locale('fr', 'FR'),
const Locale('hi', 'IN'),
const Locale ('ge', 'DE'),
const Locale('ja', 'JA'),
];
}

View File

@ -26,7 +26,6 @@ import 'package:spotube/provider/palette_provider.dart';
import 'package:spotube/provider/user_preferences_provider.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
import 'package:spotube/themes/theme.dart';
import 'package:spotube/utils/custom_toast_handler.dart';
import 'package:spotube/utils/persisted_state_notifier.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:system_theme/system_theme.dart';
@ -122,23 +121,18 @@ Future<void> main(List<String> rawArgs) async {
releaseConfig: CatcherOptions(
SilentReportMode(),
[
if (arguments["verbose"] ?? false)
ConsoleHandler(
enableDeviceParameters: false,
enableApplicationParameters: false,
),
if (arguments["verbose"] ?? false) ConsoleHandler(),
FileHandler(
await getLogsPath(),
printLogs: false,
),
CustomToastHandler(),
],
),
runAppFunction: () {
runApp(
DevicePreview(
availableLocales: L10n.all,
enabled: !kReleaseMode,
enabled: !kReleaseMode && DesktopTools.platform.isDesktop,
builder: (context) {
return ProviderScope(
child: QueryClientProvider(

View File

@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/track_table/track_collection_view.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart';
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
@ -32,6 +32,7 @@ class AlbumPage extends HookConsumerWidget {
sortedTracks,
initialIndex: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
);
playback.addCollection(album.id!);
} else if (isPlaylistPlaying &&
currentTrack.id != null &&
currentTrack.id != playlist.activeTrack?.id) {
@ -67,7 +68,7 @@ class AlbumPage extends HookConsumerWidget {
tracksSnapshot: tracksSnapshot,
album: album,
routePath: "/album/${album.id}",
bottomSpace: mediaQuery.isSm || mediaQuery.isMd,
bottomSpace: mediaQuery.mdAndDown,
onPlay: ([track]) {
if (tracksSnapshot.hasData) {
if (!isAlbumPlaying) {
@ -101,6 +102,7 @@ class AlbumPage extends HookConsumerWidget {
TypeConversionUtils.simpleTrack_X_Track(track, album))
.toList(),
);
playback.addCollection(album.id!);
}
},
onShare: () {

View File

@ -39,6 +39,7 @@ class ArtistPage extends HookConsumerWidget {
final scaffoldMessenger = ScaffoldMessenger.of(context);
final textTheme = theme.textTheme;
final chipTextVariant = useBreakpointValue(
xs: textTheme.bodySmall,
sm: textTheme.bodySmall,
md: textTheme.bodyMedium,
lg: textTheme.bodyLarge,
@ -49,6 +50,7 @@ class ArtistPage extends HookConsumerWidget {
final mediaQuery = MediaQuery.of(context);
final avatarWidth = useBreakpointValue(
xs: mediaQuery.size.width * 0.50,
sm: mediaQuery.size.width * 0.50,
md: mediaQuery.size.width * 0.40,
lg: mediaQuery.size.width * 0.18,
@ -155,7 +157,7 @@ class ArtistPage extends HookConsumerWidget {
),
Text(
data.name!,
style: mediaQuery.isSm
style: mediaQuery.smAndDown
? textTheme.headlineSmall
: textTheme.headlineMedium,
),
@ -166,8 +168,9 @@ class ArtistPage extends HookConsumerWidget {
),
),
style: textTheme.bodyMedium?.copyWith(
fontWeight:
mediaQuery.isSm ? null : FontWeight.bold,
fontWeight: mediaQuery.mdAndUp
? FontWeight.bold
: null,
),
),
const SizedBox(height: 20),

View File

@ -35,7 +35,7 @@ class DesktopLoginPage extends HookConsumerWidget {
children: [
Assets.spotubeLogoPng.image(
width: MediaQuery.of(context).size.width *
(mediaQuery.isSm || mediaQuery.isMd ? .5 : .3),
(mediaQuery.mdAndDown ? .5 : .3),
),
Text(
context.l10n.add_spotify_credentials,

View File

@ -54,7 +54,7 @@ class LoginTutorial extends ConsumerWidget {
overrideDone: FilledButton(
onPressed: authenticationNotifier.isLoggedIn
? () {
ServiceUtils.navigate(context, "/");
ServiceUtils.push(context, "/");
}
: null,
child: Center(child: Text(context.l10n.done)),

View File

@ -1,11 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:collection/collection.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/genre/category_card.dart';
import 'package:spotube/components/shared/compact_search.dart';
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
import 'package:spotube/components/shared/shimmers/shimmer_categories.dart';
import 'package:spotube/components/shared/waypoint.dart';
import 'package:spotube/extensions/context.dart';
@ -18,15 +21,21 @@ class GenrePage extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
final scrollController = useScrollController();
final recommendationMarket = ref.watch(
userPreferencesProvider.select((s) => s.recommendationMarket),
);
final categoriesQuery = useQueries.category.list(ref, recommendationMarket);
final isFiltering = useState(false);
final isMounted = useIsMounted();
final searchText = useState("");
final searchController = useTextEditingController();
final searchFocus = useFocusNode();
useValueListenable(searchController);
final categories = useMemoized(
() {
final categories = categoriesQuery.pages
@ -34,12 +43,12 @@ class GenrePage extends HookConsumerWidget {
(page) => page.items ?? const Iterable.empty(),
)
.toList();
if (searchText.value.isEmpty) {
if (searchController.text.isEmpty) {
return categories;
}
return categories
.map((e) => (
weightedRatio(e.name!, searchText.value),
weightedRatio(e.name!, searchController.text),
e,
))
.sorted((a, b) => b.$1.compareTo(a.$1))
@ -47,14 +56,7 @@ class GenrePage extends HookConsumerWidget {
.map((e) => e.$2)
.toList();
},
[categoriesQuery.pages, searchText.value],
);
final searchbar = CompactSearch(
onChanged: (value) {
searchText.value = value;
},
placeholder: context.l10n.genre_categories_filter,
[categoriesQuery.pages, searchController.text],
);
final list = RefreshIndicator(
@ -68,22 +70,32 @@ class GenrePage extends HookConsumerWidget {
}
},
controller: scrollController,
child: ListView.builder(
controller: scrollController,
itemCount: categories.length,
shrinkWrap: true,
itemBuilder: (context, index) {
return AnimatedCrossFade(
crossFadeState: searchText.value.isEmpty &&
index == categories.length - 1 &&
categoriesQuery.hasNextPage
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 300),
firstChild: const ShimmerCategories(),
secondChild: CategoryCard(categories[index]),
);
},
child: Column(
children: [
ExpandableSearchField(
isFiltering: isFiltering,
searchController: searchController,
searchFocus: searchFocus,
),
Expanded(
child: ListView.builder(
controller: scrollController,
itemCount: categories.length,
itemBuilder: (context, index) {
return AnimatedCrossFade(
crossFadeState: searchController.text.isEmpty &&
index == categories.length - 1 &&
categoriesQuery.hasNextPage
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 300),
firstChild: const ShimmerCategories(),
secondChild: CategoryCard(categories[index]),
);
},
),
),
],
),
),
);
@ -94,7 +106,20 @@ class GenrePage extends HookConsumerWidget {
Positioned(
top: 0,
right: 10,
child: searchbar,
child: ExpandableSearchButton(
isFiltering: isFiltering,
searchFocus: searchFocus,
icon: const Icon(SpotubeIcons.search),
onPressed: (value) {
if (isFiltering.value) {
scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
},
),
),
],
);

View File

@ -10,6 +10,7 @@ import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'
import 'package:spotube/components/shared/waypoint.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/logger.dart';
import 'package:spotube/provider/authentication_provider.dart';
import 'package:spotube/services/queries/queries.dart';
import 'package:spotube/utils/type_conversion_utils.dart';
@ -94,6 +95,7 @@ class PersonalizedPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final auth = ref.watch(AuthenticationNotifier.provider);
final featuredPlaylistsQuery = useQueries.playlist.featured(ref);
final playlists = useMemoized(
() => featuredPlaylistsQuery.pages
@ -132,12 +134,13 @@ class PersonalizedPage extends HookConsumerWidget {
hasNextPage: featuredPlaylistsQuery.hasNextPage,
onFetchMore: featuredPlaylistsQuery.fetchNext,
),
PersonalizedItemCard(
albums: albums,
title: context.l10n.new_releases,
hasNextPage: newReleases.hasNextPage,
onFetchMore: newReleases.fetchNext,
),
if (auth != null)
PersonalizedItemCard(
albums: albums,
title: context.l10n.new_releases,
hasNextPage: newReleases.hasNextPage,
onFetchMore: newReleases.fetchNext,
),
...?madeForUser.data?["content"]?["items"]?.map((item) {
final playlists = item["content"]?["items"]
?.where((itemL2) => itemL2["type"] == "playlist")

View File

@ -248,251 +248,256 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
title: Text(context.l10n.generate_playlist),
centerTitle: true,
),
body: SafeArea(
child: LayoutBuilder(builder: (context, constrains) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
ValueListenableBuilder(
valueListenable: limit,
builder: (context, value, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.number_of_tracks_generate,
style: textTheme.titleMedium,
),
Row(
children: [
Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
child: Text(
value.round().toString(),
style: textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.primaryContainer,
body: SliderTheme(
data: const SliderThemeData(
overlayShape: RoundSliderOverlayShape(),
),
child: SafeArea(
child: LayoutBuilder(builder: (context, constrains) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
ValueListenableBuilder(
valueListenable: limit,
builder: (context, value, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.number_of_tracks_generate,
style: textTheme.titleMedium,
),
Row(
children: [
Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: BoxDecoration(
color: theme.colorScheme.primary,
shape: BoxShape.circle,
),
child: Text(
value.round().toString(),
style: textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.primaryContainer,
),
),
),
),
Expanded(
child: Slider.adaptive(
value: value.toDouble(),
min: 10,
max: 100,
divisions: 9,
label: value.round().toString(),
onChanged: (value) {
limit.value = value.round();
},
),
)
],
)
],
);
},
),
const SizedBox(height: 16),
if (constrains.mdAndUp)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: countrySelector,
),
const SizedBox(width: 16),
Expanded(
child: genreSelector,
),
],
)
else ...[
countrySelector,
const SizedBox(height: 16),
genreSelector,
],
const SizedBox(height: 16),
if (constrains.mdAndUp)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: artistAutoComplete,
),
const SizedBox(width: 16),
Expanded(
child: tracksAutocomplete,
),
],
)
else ...[
artistAutoComplete,
const SizedBox(height: 16),
tracksAutocomplete,
],
const SizedBox(height: 16),
RecommendationAttributeDials(
title: Text(context.l10n.acousticness),
values: acousticness.value,
onChanged: (value) {
acousticness.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.danceability),
values: danceability.value,
onChanged: (value) {
danceability.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.energy),
values: energy.value,
onChanged: (value) {
energy.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.instrumentalness),
values: instrumentalness.value,
onChanged: (value) {
instrumentalness.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.liveness),
values: liveness.value,
onChanged: (value) {
liveness.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.loudness),
values: loudness.value,
onChanged: (value) {
loudness.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.speechiness),
values: speechiness.value,
onChanged: (value) {
speechiness.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.valence),
values: valence.value,
onChanged: (value) {
valence.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.popularity),
values: popularity.value,
base: 100,
onChanged: (value) {
popularity.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.key),
values: key.value,
base: 11,
onChanged: (value) {
key.value = value;
},
),
RecommendationAttributeFields(
title: Text(context.l10n.duration),
values: (
max: durationMs.value.max / 1000,
target: durationMs.value.target / 1000,
min: durationMs.value.min / 1000,
Expanded(
child: Slider.adaptive(
value: value.toDouble(),
min: 10,
max: 100,
divisions: 9,
label: value.round().toString(),
onChanged: (value) {
limit.value = value.round();
},
),
)
],
)
],
);
},
),
onChanged: (value) {
durationMs.value = (
max: value.max * 1000,
target: value.target * 1000,
min: value.min * 1000,
);
},
presets: {
context.l10n.short: (min: 50, target: 90, max: 120),
context.l10n.medium: (min: 120, target: 180, max: 200),
context.l10n.long: (min: 480, target: 560, max: 640)
},
),
RecommendationAttributeFields(
title: Text(context.l10n.tempo),
values: tempo.value,
onChanged: (value) {
tempo.value = value;
},
),
RecommendationAttributeFields(
title: Text(context.l10n.mode),
values: mode.value,
onChanged: (value) {
mode.value = value;
},
),
RecommendationAttributeFields(
title: Text(context.l10n.time_signature),
values: timeSignature.value,
onChanged: (value) {
timeSignature.value = value;
},
),
const SizedBox(height: 20),
FilledButton.icon(
icon: const Icon(SpotubeIcons.magic),
label: Text(context.l10n.generate_playlist),
onPressed: artists.value.isEmpty &&
tracks.value.isEmpty &&
genres.value.isEmpty
? null
: () {
final PlaylistGenerateResultRouteState routeState = (
seeds: (
artists: artists.value.map((a) => a.id!).toList(),
tracks: tracks.value.map((t) => t.id!).toList(),
genres: genres.value
),
market: market.value,
limit: limit.value,
parameters: (
acousticness: acousticness.value,
danceability: danceability.value,
energy: energy.value,
instrumentalness: instrumentalness.value,
liveness: liveness.value,
loudness: loudness.value,
speechiness: speechiness.value,
valence: valence.value,
popularity: popularity.value,
key: key.value,
duration_ms: durationMs.value,
tempo: tempo.value,
mode: mode.value,
time_signature: timeSignature.value,
)
);
GoRouter.of(context).push(
"/library/generate/result",
extra: routeState,
);
},
),
],
);
}),
const SizedBox(height: 16),
if (constrains.mdAndUp)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: countrySelector,
),
const SizedBox(width: 16),
Expanded(
child: genreSelector,
),
],
)
else ...[
countrySelector,
const SizedBox(height: 16),
genreSelector,
],
const SizedBox(height: 16),
if (constrains.mdAndUp)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: artistAutoComplete,
),
const SizedBox(width: 16),
Expanded(
child: tracksAutocomplete,
),
],
)
else ...[
artistAutoComplete,
const SizedBox(height: 16),
tracksAutocomplete,
],
const SizedBox(height: 16),
RecommendationAttributeDials(
title: Text(context.l10n.acousticness),
values: acousticness.value,
onChanged: (value) {
acousticness.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.danceability),
values: danceability.value,
onChanged: (value) {
danceability.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.energy),
values: energy.value,
onChanged: (value) {
energy.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.instrumentalness),
values: instrumentalness.value,
onChanged: (value) {
instrumentalness.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.liveness),
values: liveness.value,
onChanged: (value) {
liveness.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.loudness),
values: loudness.value,
onChanged: (value) {
loudness.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.speechiness),
values: speechiness.value,
onChanged: (value) {
speechiness.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.valence),
values: valence.value,
onChanged: (value) {
valence.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.popularity),
values: popularity.value,
base: 100,
onChanged: (value) {
popularity.value = value;
},
),
RecommendationAttributeDials(
title: Text(context.l10n.key),
values: key.value,
base: 11,
onChanged: (value) {
key.value = value;
},
),
RecommendationAttributeFields(
title: Text(context.l10n.duration),
values: (
max: durationMs.value.max / 1000,
target: durationMs.value.target / 1000,
min: durationMs.value.min / 1000,
),
onChanged: (value) {
durationMs.value = (
max: value.max * 1000,
target: value.target * 1000,
min: value.min * 1000,
);
},
presets: {
context.l10n.short: (min: 50, target: 90, max: 120),
context.l10n.medium: (min: 120, target: 180, max: 200),
context.l10n.long: (min: 480, target: 560, max: 640)
},
),
RecommendationAttributeFields(
title: Text(context.l10n.tempo),
values: tempo.value,
onChanged: (value) {
tempo.value = value;
},
),
RecommendationAttributeFields(
title: Text(context.l10n.mode),
values: mode.value,
onChanged: (value) {
mode.value = value;
},
),
RecommendationAttributeFields(
title: Text(context.l10n.time_signature),
values: timeSignature.value,
onChanged: (value) {
timeSignature.value = value;
},
),
const SizedBox(height: 20),
FilledButton.icon(
icon: const Icon(SpotubeIcons.magic),
label: Text(context.l10n.generate_playlist),
onPressed: artists.value.isEmpty &&
tracks.value.isEmpty &&
genres.value.isEmpty
? null
: () {
final PlaylistGenerateResultRouteState routeState = (
seeds: (
artists: artists.value.map((a) => a.id!).toList(),
tracks: tracks.value.map((t) => t.id!).toList(),
genres: genres.value
),
market: market.value,
limit: limit.value,
parameters: (
acousticness: acousticness.value,
danceability: danceability.value,
energy: energy.value,
instrumentalness: instrumentalness.value,
liveness: liveness.value,
loudness: loudness.value,
speechiness: speechiness.value,
valence: valence.value,
popularity: popularity.value,
key: key.value,
duration_ms: durationMs.value,
tempo: tempo.value,
mode: mode.value,
time_signature: timeSignature.value,
)
);
GoRouter.of(context).push(
"/library/generate/result",
extra: routeState,
);
},
),
],
);
}),
),
),
);
}

View File

@ -111,10 +111,22 @@ class SyncedLyrics extends HookConsumerWidget {
index: index,
controller: controller,
child: lyricSlice.text.isEmpty
? Container()
? Container(
padding: index == lyricValue.lyrics.length - 1
? EdgeInsets.only(
bottom:
MediaQuery.of(context).size.height /
2,
)
: null,
)
: Center(
child: Padding(
padding: const EdgeInsets.all(8.0),
padding: index == lyricValue.lyrics.length - 1
? const EdgeInsets.all(8.0).copyWith(
bottom: 100,
)
: const EdgeInsets.all(8.0),
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 250),
style: TextStyle(

View File

@ -9,10 +9,14 @@ import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/player/player_actions.dart';
import 'package:spotube/components/player/player_controls.dart';
import 'package:spotube/components/player/player_queue.dart';
import 'package:spotube/components/player/volume_slider.dart';
import 'package:spotube/components/shared/animated_gradient.dart';
import 'package:spotube/components/shared/dialogs/track_details_dialog.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/use_custom_status_bar_color.dart';
import 'package:spotube/hooks/use_palette_color.dart';
import 'package:spotube/models/local_track.dart';
@ -74,6 +78,24 @@ class PlayerView extends HookConsumerWidget {
foregroundColor: titleTextColor,
toolbarOpacity: 1,
leading: const BackButton(),
actions: [
IconButton(
icon: const Icon(SpotubeIcons.info, size: 18),
tooltip: context.l10n.details,
style: IconButton.styleFrom(foregroundColor: bodyTextColor),
onPressed: currentTrack == null
? null
: () {
showDialog(
context: context,
builder: (context) {
return TrackDetailsDialog(
track: currentTrack,
);
});
},
)
],
),
extendBodyBehindAppBar: true,
body: SizedBox(
@ -106,29 +128,27 @@ class PlayerView extends HookConsumerWidget {
padding: const EdgeInsets.all(8.0),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
constraints: const BoxConstraints(
maxHeight: 300, maxWidth: 300),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: const [
BoxShadow(
color: Colors.black26,
spreadRadius: 2,
blurRadius: 10,
offset: Offset(0, 0),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: UniversalImage(
path: albumArt,
placeholder: Assets.albumPlaceholder.path,
fit: BoxFit.cover,
Container(
margin: const EdgeInsets.all(8),
constraints: const BoxConstraints(
maxHeight: 300, maxWidth: 300),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
boxShadow: const [
BoxShadow(
color: Colors.black26,
spreadRadius: 2,
blurRadius: 10,
offset: Offset(0, 0),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: UniversalImage(
path: albumArt,
placeholder: Assets.albumPlaceholder.path,
fit: BoxFit.cover,
),
),
),
@ -182,39 +202,111 @@ class PlayerView extends HookConsumerWidget {
const SizedBox(height: 25),
PlayerActions(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
floatingQueue: false,
extraActions: [
showQueue: false,
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
const SizedBox(width: 10),
Expanded(
child: OutlinedButton.icon(
icon: const Icon(SpotubeIcons.queue),
label: Text(context.l10n.queue),
style: OutlinedButton.styleFrom(
foregroundColor: bodyTextColor,
side: BorderSide(
color: bodyTextColor ?? Colors.white,
),
),
onPressed: currentTrack != null
? () {
showModalBottomSheet(
context: context,
isDismissible: true,
enableDrag: true,
isScrollControlled: true,
backgroundColor: Colors.black12,
barrierColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(10),
),
constraints: BoxConstraints(
maxHeight:
MediaQuery.of(context)
.size
.height *
.7,
),
builder: (context) {
return PlayerQueue(
floating: false);
},
);
}
: null),
),
if (auth != null) const SizedBox(width: 10),
if (auth != null)
IconButton(
tooltip: "Open Lyrics",
icon: const Icon(SpotubeIcons.music),
onPressed: () {
showModalBottomSheet(
context: context,
isDismissible: true,
enableDrag: true,
isScrollControlled: true,
backgroundColor: Colors.black38,
barrierColor: Colors.black12,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
Expanded(
child: OutlinedButton.icon(
label: Text(context.l10n.lyrics),
icon: const Icon(SpotubeIcons.music),
style: OutlinedButton.styleFrom(
foregroundColor: bodyTextColor,
side: BorderSide(
color: bodyTextColor ?? Colors.white,
),
),
onPressed: () {
showModalBottomSheet(
context: context,
isDismissible: true,
enableDrag: true,
isScrollControlled: true,
backgroundColor: Colors.black38,
barrierColor: Colors.black12,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
),
constraints: BoxConstraints(
maxHeight:
MediaQuery.of(context).size.height *
0.8,
),
builder: (context) =>
const LyricsPage(isModal: true),
);
},
)
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context)
.size
.height *
0.8,
),
builder: (context) =>
const LyricsPage(isModal: true),
);
},
),
),
const SizedBox(width: 10),
],
),
const SizedBox(height: 25)
const SizedBox(height: 25),
SliderTheme(
data: theme.sliderTheme.copyWith(
activeTrackColor: titleTextColor,
inactiveTrackColor: bodyTextColor,
thumbColor: titleTextColor,
overlayColor: titleTextColor?.withOpacity(0.2),
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 8,
),
),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: VolumeSlider(
fullWidth: true,
),
),
),
],
),
),

View File

@ -2,7 +2,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/shared/heart_button.dart';
import 'package:spotube/components/shared/track_table/track_collection_view.dart';
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart';
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/models/logger.dart';
@ -36,6 +36,7 @@ class PlaylistView extends HookConsumerWidget {
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) {
@ -97,9 +98,10 @@ class PlaylistView extends HookConsumerWidget {
onAddToQueue: () {
if (tracksSnapshot.hasData && !isPlaylistPlaying) {
playlistNotifier.addTracks(tracksSnapshot.data!);
playlistNotifier.addCollection(playlist.id!);
}
},
bottomSpace: mediaQuery.isSm || mediaQuery.isMd,
bottomSpace: mediaQuery.mdAndDown,
showShare: playlist.id != "user-liked-tracks",
routePath: "/playlist/${playlist.id}",
onShare: () {

View File

@ -77,6 +77,7 @@ class SearchPage extends HookConsumerWidget {
),
color: theme.scaffoldBackgroundColor,
child: TextField(
autofocus: true,
decoration: InputDecoration(
prefixIcon: const Icon(SpotubeIcons.search),
hintText: "${context.l10n.search}...",

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/components/shared/image/universal_image.dart';
import 'package:spotube/components/shared/links/hyper_link.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/hooks/use_package_info.dart';
@ -24,6 +25,8 @@ class AboutSpotube extends HookConsumerWidget {
final license = ref.watch(_licenseProvider);
final theme = Theme.of(context);
final colon = Text(":");
return Scaffold(
appBar: PageWindowTitleBar(
leading: const BackButton(),
@ -40,77 +43,75 @@ class AboutSpotube extends HookConsumerWidget {
),
Center(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.spotube_description,
style: theme.textTheme.titleLarge,
),
const SizedBox(height: 20),
Row(
mainAxisSize: MainAxisSize.min,
Table(
columnWidths: const {
0: FixedColumnWidth(95),
1: FixedColumnWidth(10),
2: IntrinsicColumnWidth(),
},
children: [
Text(
"${context.l10n.founder}: ${context.l10n.kingkor_roy_tirtho}",
style: const TextStyle(
fontWeight: FontWeight.bold,
),
TableRow(
children: [
Text(context.l10n.founder),
colon,
Hyperlink(
context.l10n.kingkor_roy_tirtho,
"https://github.com/KRTirtho",
)
],
),
const SizedBox(width: 5),
CircleAvatar(
radius: 20,
child: ClipOval(
child: Image.network(
"https://avatars.githubusercontent.com/u/61944859?v=4",
TableRow(
children: [
Text(context.l10n.version),
colon,
Text("v${packageInfo.version}")
],
),
TableRow(
children: [
Text(context.l10n.build_number),
colon,
Text(packageInfo.buildNumber.replaceAll(".", " "))
],
),
TableRow(
children: [
Text(context.l10n.repository),
colon,
const Hyperlink(
"github.com/KRTirtho/spotube",
"https://github.com/KRTirtho/spotube",
),
),
],
),
TableRow(
children: [
Text(context.l10n.license),
colon,
const Hyperlink(
"BSD-4-Clause",
"https://raw.githubusercontent.com/KRTirtho/spotube/master/LICENSE",
),
],
),
TableRow(
children: [
Text(context.l10n.bug_issues),
colon,
const Hyperlink(
"github.com/KRTirtho/spotube/issues",
"https://github.com/KRTirtho/spotube/issues",
),
],
),
],
),
const SizedBox(height: 5),
Text(
"${context.l10n.version}: v${packageInfo.version}",
),
const SizedBox(height: 5),
Text(
"${context.l10n.build_number}: ${packageInfo.buildNumber.replaceAll(".", " ")}",
),
const SizedBox(height: 5),
InkWell(
onTap: () {
launchUrlString(
"https://github.com/KRTirtho/spotube",
mode: LaunchMode.externalApplication,
);
},
child: Text(
"${context.l10n.repository}: https://github.com/KRTirtho/spotube",
),
),
const SizedBox(height: 5),
InkWell(
onTap: () {
launchUrlString(
"https://raw.githubusercontent.com/KRTirtho/spotube/master/LICENSE",
mode: LaunchMode.externalApplication,
);
},
child: Text(
"${context.l10n.license}: BSD-4-Clause",
),
),
const SizedBox(height: 5),
InkWell(
onTap: () {
launchUrlString(
"https://github.com/KRTirtho/spotube/issues",
mode: LaunchMode.externalApplication,
);
},
child: Text(
"${context.l10n.bug_issues}: https://github.com/KRTirtho/spotube/issues",
),
),
],
),
),

View File

@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/blacklist_provider.dart';
class BlackListPage extends HookConsumerWidget {
@ -38,7 +39,7 @@ class BlackListPage extends HookConsumerWidget {
return Scaffold(
appBar: PageWindowTitleBar(
title: const Text("Blacklist"),
title: Text(context.l10n.blacklist),
centerTitle: true,
leading: const BackButton(),
),
@ -49,9 +50,9 @@ class BlackListPage extends HookConsumerWidget {
padding: const EdgeInsets.all(8.0),
child: TextField(
onChanged: (value) => searchText.value = value,
decoration: const InputDecoration(
hintText: "Search",
prefixIcon: Icon(SpotubeIcons.search),
decoration: InputDecoration(
hintText: context.l10n.search,
prefixIcon: const Icon(SpotubeIcons.search),
),
),
),

View File

@ -0,0 +1,139 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/settings/section_card_with_heading.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/logger.dart';
class LogsPage extends HookWidget {
const LogsPage({Key? key}) : super(key: key);
List<({DateTime? date, String body})> parseLogs(String raw) {
return raw
.split(
"======================================================================",
)
.map(
(line) {
DateTime? date;
line = line
.replaceAll(
"============================== CATCHER LOG ==============================",
"",
)
.split("\n")
.map((l) {
if (l.startsWith("Crash occurred on")) {
date = DateTime.parse(
l.split("Crash occurred on")[1].trim(),
);
return "";
}
return l;
})
.where((l) => l.replaceAll("\n", "").trim().isNotEmpty)
.join("\n");
return (
date: date,
body: line,
);
},
)
.where((e) => e.date != null && e.body.isNotEmpty)
.toList()
..sort((a, b) => b.date!.compareTo(a.date!));
}
@override
Widget build(BuildContext context) {
final logs = useState<List<({DateTime? date, String body})>>([]);
final rawLogs = useRef<String>("");
final path = useRef<File?>(null);
useEffect(() {
final timer = Timer.periodic(const Duration(seconds: 5), (t) async {
path.value ??= await getLogsPath();
final raw = await path.value!.readAsString();
final hasChanged = rawLogs.value != raw;
rawLogs.value = raw;
if (hasChanged) logs.value = parseLogs(rawLogs.value);
});
return () {
timer.cancel();
};
}, []);
return Scaffold(
appBar: PageWindowTitleBar(
title: Text(context.l10n.logs),
leading: const BackButton(),
actions: [
IconButton(
icon: const Icon(SpotubeIcons.clipboard),
iconSize: 16,
onPressed: () async {
await Clipboard.setData(ClipboardData(text: rawLogs.value));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.copied_to_clipboard("")),
),
);
}
},
),
],
),
body: SafeArea(
child: ListView.builder(
itemCount: logs.value.length,
itemBuilder: (context, index) {
final log = logs.value[index];
return Stack(
children: [
SectionCardWithHeading(
heading: log.date.toString(),
children: [
Padding(
padding: const EdgeInsets.all(12.0),
child: SelectableText(log.body),
),
],
),
Positioned(
right: 10,
top: 0,
child: IconButton(
icon: const Icon(SpotubeIcons.clipboard),
onPressed: () async {
await Clipboard.setData(
ClipboardData(text: log.body),
);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.copied_to_clipboard(
log.date.toString(),
),
),
),
);
}
},
),
),
],
);
},
),
),
);
}
}

View File

@ -406,6 +406,19 @@ class SettingsPage extends HookConsumerWidget {
),
],
),
SectionCardWithHeading(
heading: context.l10n.developers,
children: [
ListTile(
leading: const Icon(SpotubeIcons.logs),
title: Text(context.l10n.logs),
trailing: const Icon(SpotubeIcons.angleRight),
onTap: () {
GoRouter.of(context).push("/settings/logs");
},
)
],
),
SectionCardWithHeading(
heading: context.l10n.about,
children: [

View File

@ -1,7 +1,7 @@
import 'dart:async';
import 'dart:io';
// import 'package:background_downloader/background_downloader.dart';
import 'package:background_downloader/background_downloader.dart';
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart';
@ -19,8 +19,8 @@ import 'package:spotube/utils/type_conversion_utils.dart';
class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
final Ref ref;
final StreamController /* <TaskProgressUpdate> */ activeDownloadProgress;
final StreamController /* <Task> */ failedDownloads;
final StreamController<TaskProgressUpdate> activeDownloadProgress;
final StreamController<Task> failedDownloads;
Track? _activeItem;
FutureOr<bool> Function(Track)? onFileExists;
@ -29,78 +29,78 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
: activeDownloadProgress = StreamController.broadcast(),
failedDownloads = StreamController.broadcast(),
super([]) {
// FileDownloader().registerCallbacks(
// group: FileDownloader.defaultGroup,
// taskNotificationTapCallback: (task, notificationType) {
// router.go("/library");
// },
// taskStatusCallback: (update) async {
// if (update.status == TaskStatus.running) {
// _activeItem =
// state.firstWhereOrNull((track) => track.id == update.task.taskId);
// state = state.toList();
// }
FileDownloader().registerCallbacks(
group: FileDownloader.defaultGroup,
taskNotificationTapCallback: (task, notificationType) {
router.go("/library");
},
taskStatusCallback: (update) async {
if (update.status == TaskStatus.running) {
_activeItem =
state.firstWhereOrNull((track) => track.id == update.task.taskId);
state = state.toList();
}
// if (update.status == TaskStatus.failed ||
// update.status == TaskStatus.notFound) {
// failedDownloads.add(update.task);
// }
if (update.status == TaskStatus.failed ||
update.status == TaskStatus.notFound) {
failedDownloads.add(update.task);
}
// if (update.status == TaskStatus.complete) {
// final track =
// state.firstWhere((element) => element.id == update.task.taskId);
if (update.status == TaskStatus.complete) {
final track =
state.firstWhere((element) => element.id == update.task.taskId);
// // resetting the replace downloaded file state on queue completion
// if (state.last == track) {
// ref.read(replaceDownloadedFileState.notifier).state = null;
// }
// resetting the replace downloaded file state on queue completion
if (state.last == track) {
ref.read(replaceDownloadedFileState.notifier).state = null;
}
// state = state
// .where((element) => element.id != update.task.taskId)
// .toList();
state = state
.where((element) => element.id != update.task.taskId)
.toList();
// final imageUri = TypeConversionUtils.image_X_UrlString(
// track.album?.images ?? [],
// placeholder: ImagePlaceholder.online,
// );
// final response = await get(Uri.parse(imageUri));
final imageUri = TypeConversionUtils.image_X_UrlString(
track.album?.images ?? [],
placeholder: ImagePlaceholder.online,
);
final response = await get(Uri.parse(imageUri));
// final tempFile = File(await update.task.filePath());
final tempFile = File(await update.task.filePath());
// final file = tempFile.copySync(_getPathForTrack(track));
final file = tempFile.copySync(_getPathForTrack(track));
// await tempFile.delete();
await tempFile.delete();
// await MetadataGod.writeMetadata(
// file: file.path,
// metadata: Metadata(
// title: track.name,
// artist: track.artists?.map((a) => a.name).join(", "),
// album: track.album?.name,
// albumArtist: track.artists?.map((a) => a.name).join(", "),
// year: track.album?.releaseDate != null
// ? int.tryParse(track.album!.releaseDate!)
// : null,
// trackNumber: track.trackNumber,
// discNumber: track.discNumber,
// durationMs: track.durationMs?.toDouble(),
// fileSize: file.lengthSync(),
// trackTotal: track.album?.tracks?.length,
// picture: response.headers['content-type'] != null
// ? Picture(
// data: response.bodyBytes,
// mimeType: response.headers['content-type']!,
// )
// : null,
// ),
// );
// }
// },
// taskProgressCallback: (update) {
// activeDownloadProgress.add(update);
// },
// );
// FileDownloader().trackTasks(markDownloadedComplete: true);
await MetadataGod.writeMetadata(
file: file.path,
metadata: Metadata(
title: track.name,
artist: track.artists?.map((a) => a.name).join(", "),
album: track.album?.name,
albumArtist: track.artists?.map((a) => a.name).join(", "),
year: track.album?.releaseDate != null
? int.tryParse(track.album!.releaseDate!)
: null,
trackNumber: track.trackNumber,
discNumber: track.discNumber,
durationMs: track.durationMs?.toDouble(),
fileSize: file.lengthSync(),
trackTotal: track.album?.tracks?.length,
picture: response.headers['content-type'] != null
? Picture(
data: response.bodyBytes,
mimeType: response.headers['content-type']!,
)
: null,
),
);
}
},
taskProgressCallback: (update) {
activeDownloadProgress.add(update);
},
);
FileDownloader().trackTasks(markDownloadedComplete: true);
}
UserPreferences get preferences => ref.read(userPreferencesProvider);
@ -115,9 +115,9 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
"${track.name} - ${track.artists?.map((a) => a.name).join(", ")}.m4a",
);
Future /* <Task> */ _ensureSpotubeTrack(Track track) async {
Future<Task> _ensureSpotubeTrack(Track track) async {
if (state.any((element) => element.id == track.id)) {
final task = null /* await FileDownloader().taskForId(track.id!) */;
final task = await FileDownloader().taskForId(track.id!);
if (task != null) {
return task;
}
@ -133,17 +133,16 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
pipedClient,
);
state = [...state, spotubeTrack];
// final task = DownloadTask(
// url: spotubeTrack.ytUri,
// baseDirectory: BaseDirectory.applicationSupport,
// taskId: spotubeTrack.id!,
// updates: Updates.statusAndProgress,
// );
// return task;
return null;
final task = DownloadTask(
url: spotubeTrack.ytUri,
baseDirectory: BaseDirectory.applicationSupport,
taskId: spotubeTrack.id!,
updates: Updates.statusAndProgress,
);
return task;
}
Future /* <Task?> */ enqueue(Track track) async {
Future<Task?> enqueue(Track track) async {
final replaceFileGlobal = ref.read(replaceDownloadedFileState);
final file = File(_getPathForTrack(track));
if (file.existsSync() &&
@ -156,11 +155,11 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
final task = await _ensureSpotubeTrack(track);
// await FileDownloader().enqueue(task);
await FileDownloader().enqueue(task);
return task;
}
Future<List /* <Task> */ > enqueueAll(List<Track> tracks) async {
Future<List<Task>> enqueueAll(List<Track> tracks) async {
final tasks = await Future.wait(tracks.mapIndexed((i, e) {
if (i != 0) {
/// One second delay between each download to avoid
@ -174,16 +173,16 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
ref.read(replaceDownloadedFileState.notifier).state = null;
}
return tasks. /* whereType<Task>(). */ toList();
return tasks.whereType<Task>().toList();
}
Future<void> cancel(Track track) async {
// await FileDownloader().cancelTaskWithId(track.id!);
await FileDownloader().cancelTaskWithId(track.id!);
state = state.where((element) => element.id != track.id).toList();
}
Future<void> cancelAll() async {
// (await FileDownloader().reset());
(await FileDownloader().reset());
state = [];
}
}

View File

@ -6,15 +6,20 @@ import 'package:spotube/models/spotube_track.dart';
class ProxyPlaylist {
final Set<Track> tracks;
final Set<String> collections;
final int? active;
ProxyPlaylist(this.tracks, [this.active]);
ProxyPlaylist(this.tracks, [this.active, this.collections = const {}]);
factory ProxyPlaylist.fromJson(Map<String, dynamic> json) {
return ProxyPlaylist(
List.castFrom<dynamic, Map<String, dynamic>>(
json['tracks'] ?? <Map<String, dynamic>>[],
).map(_makeAppropriateTrack).toSet(),
json['active'] as int?,
json['collections'] == null
? {}
: (json['collections'] as List).toSet().cast<String>(),
);
}
@ -26,6 +31,10 @@ class ProxyPlaylist {
activeTrack is! SpotubeTrack &&
activeTrack is! LocalTrack;
bool containsCollection(String collection) {
return collections.contains(collection);
}
bool containsTrack(TrackSimple track) {
return tracks.firstWhereOrNull((element) => element.id == track.id) != null;
}
@ -57,16 +66,19 @@ class ProxyPlaylist {
return {
'tracks': tracks.map(_makeAppropriateTrackJson).toList(),
'active': active,
'collections': collections.toList(),
};
}
ProxyPlaylist copyWith({
Set<Track>? tracks,
int? active,
Set<String>? collections,
}) {
return ProxyPlaylist(
tracks ?? this.tracks,
active ?? this.active,
collections ?? this.collections,
);
}
}

View File

@ -22,7 +22,7 @@ import 'package:spotube/utils/type_conversion_utils.dart';
/// Things to implement:
/// * [x] Sponsor-Block skip
/// * [x] Prefetch next track as [SpotubeTrack] on 80% of current track
/// * [ ] Mixed Queue containing both [SpotubeTrack] and [LocalTrack]
/// * [x] Mixed Queue containing both [SpotubeTrack] and [LocalTrack]
/// * [ ] Modification of the Queue
/// * [x] Add track at the end
/// * [x] Add track at the beginning
@ -185,6 +185,19 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
}
}
void addCollection(String collectionId) {
state = state.copyWith(collections: {
...state.collections,
collectionId,
});
}
void removeCollection(String collectionId) {
state = state.copyWith(collections: {
...state.collections..remove(collectionId),
});
}
// TODO: Safely Remove playing tracks
Future<void> removeTrack(String trackId) async {
@ -218,29 +231,39 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
bool autoPlay = false,
}) async {
tracks = blacklist.filter(tracks).toList() as List<Track>;
final addableTrack = await SpotubeTrack.fetchFromTrack(
tracks.elementAtOrNull(initialIndex) ?? tracks.first,
preferences,
pipedClient,
);
final indexTrack = tracks.elementAtOrNull(initialIndex) ?? tracks.first;
state = state.copyWith(
tracks: mergeTracks([addableTrack], tracks),
active: initialIndex,
);
if (indexTrack is LocalTrack) {
state = state.copyWith(
tracks: tracks.toSet(),
active: initialIndex,
collections: {},
);
await notificationService.addTrack(indexTrack);
} else {
final addableTrack = await SpotubeTrack.fetchFromTrack(
tracks.elementAtOrNull(initialIndex) ?? tracks.first,
preferences,
pipedClient,
);
await notificationService.addTrack(addableTrack);
state = state.copyWith(
tracks: mergeTracks([addableTrack], tracks),
active: initialIndex,
collections: {},
);
await notificationService.addTrack(addableTrack);
await storeTrack(
tracks.elementAt(initialIndex),
addableTrack,
);
}
await audioPlayer.openPlaylist(
state.tracks.map(makeAppropriateSource).toList(),
initialIndex: initialIndex,
autoPlay: autoPlay,
);
await storeTrack(
tracks.elementAt(initialIndex),
addableTrack,
);
}
Future<void> jumpTo(int index) async {
@ -439,12 +462,13 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
@override
onInit() async {
if (state.tracks.isEmpty) return null;
final oldCollections = state.collections;
await load(
state.tracks,
initialIndex: state.active ?? 0,
autoPlay: false,
);
state = state.copyWith(collections: oldCollections);
}
@override

View File

@ -0,0 +1,31 @@
import 'dart:async';
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class SleepTimerNotifier extends StateNotifier<Duration?> {
SleepTimerNotifier() : super(null);
Timer? _timer;
static final provider = StateNotifierProvider<SleepTimerNotifier, Duration?>(
(ref) => SleepTimerNotifier(),
);
static AlwaysAliveRefreshable<SleepTimerNotifier> get notifier =>
provider.notifier;
void setSleepTimer(Duration duration) {
state = duration;
_timer = Timer(duration, () {
//! This can be a reason for app termination in iOS AppStore
exit(0);
});
}
void cancelSleepTimer() {
state = null;
_timer?.cancel();
}
}

View File

@ -323,7 +323,7 @@ class _MprisMediaPlayer2Player extends DBusObject {
"xesam:url": DBusString(
playlist.activeTrack is SpotubeTrack
? (playlist.activeTrack as SpotubeTrack).ytUri
: playlist.activeTrack!.previewUrl!,
: playlist.activeTrack!.previewUrl ?? "",
),
"xesam:genre": const DBusString("Unknown"),
}),

View File

@ -1,58 +0,0 @@
import 'package:catcher/model/platform_type.dart';
import 'package:catcher/model/report.dart';
import 'package:catcher/model/report_handler.dart';
import 'package:flutter/material.dart';
import 'package:motion_toast/motion_toast.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/extensions/context.dart';
class CustomToastHandler extends ReportHandler {
CustomToastHandler();
@override
Future<bool> handle(Report error, BuildContext? context) async {
final theme = Theme.of(context!);
MotionToast(
primaryColor: theme.colorScheme.errorContainer,
icon: SpotubeIcons.error,
title: Text(
context.l10n.something_went_wrong,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
color: theme.colorScheme.onError,
),
),
description: Text(
error.error.toString(),
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onError,
),
),
dismissable: true,
toastDuration: const Duration(seconds: 5),
borderRadius: 10,
).show(context);
return true;
}
@override
List<PlatformType> getSupportedPlatforms() => [
PlatformType.android,
PlatformType.iOS,
PlatformType.web,
PlatformType.linux,
PlatformType.macOS,
PlatformType.windows,
];
@override
bool isContextRequired() {
return true;
}
@override
bool shouldHandleWhenRejected() {
return false;
}
}

View File

@ -251,6 +251,10 @@ abstract class ServiceUtils {
}
static void navigate(BuildContext context, String location, {Object? extra}) {
GoRouter.of(context).go(location, extra: extra);
}
static void push(BuildContext context, String location, {Object? extra}) {
GoRouter.of(context).push(location, extra: extra);
}
@ -267,12 +271,14 @@ abstract class ServiceUtils {
0;
case SortBy.ascending:
return a.name?.compareTo(b.name ?? "") ?? 0;
case SortBy.dateAdded:
final aDate =
double.parse(a.album?.releaseDate?.split("-").first ?? "2069");
final bDate =
double.parse(b.album?.releaseDate?.split("-").first ?? "2069");
case SortBy.oldest:
final aDate = DateTime.parse(a.album?.releaseDate ?? "2069-01-01");
final bDate = DateTime.parse(b.album?.releaseDate ?? "2069-01-01");
return aDate.compareTo(bDate);
case SortBy.newest:
final aDate = DateTime.parse(a.album?.releaseDate ?? "2069-01-01");
final bDate = DateTime.parse(b.album?.releaseDate ?? "2069-01-01");
return bDate.compareTo(aDate);
case SortBy.descending:
return b.name?.compareTo(a.name ?? "") ?? 0;
default:

View File

@ -153,6 +153,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
background_downloader:
dependency: "direct main"
description:
name: background_downloader
sha256: "5e38a1d5d88a5cfea35c44cb376b89427688070518471ee52f6b04d07d85668e"
url: "https://pub.dev"
source: hosted
version: "7.4.0"
badges:
dependency: "direct main"
description:
@ -466,6 +474,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
duration:
dependency: "direct main"
description:
name: duration
sha256: d0b29d0a345429e3986ac56d60e4aef65b37d11e653022b2b9a4b361332b777f
url: "https://pub.dev"
source: hosted
version: "3.0.12"
envied:
dependency: "direct main"
description:
@ -1126,14 +1142,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.0.4"
motion_toast:
dependency: "direct main"
description:
name: motion_toast
sha256: f33fad8264d6d5359e41f2027d2d833614401c3983102e8f0aa13ccbbdcdeecd
url: "https://pub.dev"
source: hosted
version: "2.6.8"
mutex:
dependency: transitive
description:

View File

@ -102,7 +102,8 @@ dependencies:
device_preview: ^1.1.0
media_kit_native_event_loop: ^1.0.4
dbus: ^0.7.8
motion_toast: ^2.6.8
background_downloader: ^7.4.0
duration: ^3.0.12
dev_dependencies:
build_runner: ^2.3.2