mirror of
https://github.com/KRTirtho/spotube.git
synced 2025-12-08 16:27:31 +00:00
Merge branch 'dev' into patch-1
This commit is contained in:
commit
19ad11a5f1
@ -79,6 +79,17 @@ flutter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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'
|
implementation 'com.android.support:multidex:2.0.1'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,7 +24,7 @@
|
|||||||
android:requestLegacyExternalStorage="true"
|
android:requestLegacyExternalStorage="true"
|
||||||
>
|
>
|
||||||
<activity
|
<activity
|
||||||
android:name="com.ryanheise.audioservice.AudioServiceActivity"
|
android:name="com.ryanheise.audioservice.AudioServiceActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
@ -49,17 +49,21 @@
|
|||||||
|
|
||||||
</activity>
|
</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>
|
<intent-filter>
|
||||||
<action android:name="android.media.browse.MediaBrowserService" />
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</service>
|
</service>
|
||||||
|
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||||
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver" android:exported="false">
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</receiver>
|
</receiver>
|
||||||
|
<!-- =================== -->
|
||||||
|
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.7.21'
|
ext.kotlin_version = '1.8.22'
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
|
|||||||
@ -296,10 +296,10 @@ abstract class LanguageLocals {
|
|||||||
// name: "Inuktitut",
|
// name: "Inuktitut",
|
||||||
// nativeName: "ᐃᓄᒃᑎᑐᑦ",
|
// nativeName: "ᐃᓄᒃᑎᑐᑦ",
|
||||||
// ),
|
// ),
|
||||||
// "ja": const ISOLanguageName(
|
"ja": const ISOLanguageName(
|
||||||
// name: "Japanese",
|
name: "Japanese",
|
||||||
// nativeName: "日本語 (にほんご/にっぽんご)",
|
nativeName: "日本語 (にほんご/にっぽんご)",
|
||||||
// ),
|
),
|
||||||
// "jv": const ISOLanguageName(
|
// "jv": const ISOLanguageName(
|
||||||
// name: "Javanese",
|
// name: "Javanese",
|
||||||
// nativeName: "basa Jawa",
|
// nativeName: "basa Jawa",
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import 'package:spotube/pages/lyrics/mini_lyrics.dart';
|
|||||||
import 'package:spotube/pages/search/search.dart';
|
import 'package:spotube/pages/search/search.dart';
|
||||||
import 'package:spotube/pages/settings/blacklist.dart';
|
import 'package:spotube/pages/settings/blacklist.dart';
|
||||||
import 'package:spotube/pages/settings/about.dart';
|
import 'package:spotube/pages/settings/about.dart';
|
||||||
|
import 'package:spotube/pages/settings/logs.dart';
|
||||||
import 'package:spotube/utils/platform.dart';
|
import 'package:spotube/utils/platform.dart';
|
||||||
import 'package:spotube/components/shared/spotube_page_route.dart';
|
import 'package:spotube/components/shared/spotube_page_route.dart';
|
||||||
import 'package:spotube/pages/album/album.dart';
|
import 'package:spotube/pages/album/album.dart';
|
||||||
@ -79,14 +80,20 @@ final router = GoRouter(
|
|||||||
routes: [
|
routes: [
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: "blacklist",
|
path: "blacklist",
|
||||||
pageBuilder: (context, state) => const SpotubePage(
|
pageBuilder: (context, state) => SpotubeSlidePage(
|
||||||
child: BlackListPage(),
|
child: const BlackListPage(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: "logs",
|
||||||
|
pageBuilder: (context, state) => SpotubeSlidePage(
|
||||||
|
child: const LogsPage(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: "about",
|
path: "about",
|
||||||
pageBuilder: (context, state) => const SpotubePage(
|
pageBuilder: (context, state) => SpotubeSlidePage(
|
||||||
child: AboutSpotube(),
|
child: const AboutSpotube(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@ -86,4 +86,7 @@ abstract class SpotubeIcons {
|
|||||||
static const volumeMedium = FeatherIcons.volume1;
|
static const volumeMedium = FeatherIcons.volume1;
|
||||||
static const volumeLow = FeatherIcons.volume;
|
static const volumeLow = FeatherIcons.volume;
|
||||||
static const volumeMute = FeatherIcons.volumeX;
|
static const volumeMute = FeatherIcons.volumeX;
|
||||||
|
static const timer = FeatherIcons.clock;
|
||||||
|
static const logs = FeatherIcons.fileText;
|
||||||
|
static const clipboard = FeatherIcons.clipboard;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,14 +46,12 @@ class AlbumCard extends HookConsumerWidget {
|
|||||||
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
||||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||||
final queryClient = useQueryClient();
|
final queryClient = useQueryClient();
|
||||||
final query = queryClient
|
|
||||||
.getQuery<List<TrackSimple>, dynamic>("album-tracks/${album.id}");
|
|
||||||
bool isPlaylistPlaying = useMemoized(
|
bool isPlaylistPlaying = useMemoized(
|
||||||
() => playlist.containsTracks(query?.data ?? album.tracks ?? []),
|
() => playlist.containsCollection(album.id!),
|
||||||
[playlistNotifier, query?.data, album.tracks],
|
[playlist, album.id],
|
||||||
);
|
);
|
||||||
final int marginH =
|
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 updating = useState(false);
|
||||||
final spotify = ref.watch(spotifyProvider);
|
final spotify = ref.watch(spotifyProvider);
|
||||||
@ -70,7 +68,7 @@ class AlbumCard extends HookConsumerWidget {
|
|||||||
description:
|
description:
|
||||||
"${AlbumType.from(album.albumType!).formatted} • ${TypeConversionUtils.artists_X_String<ArtistSimple>(album.artists ?? [])}",
|
"${AlbumType.from(album.albumType!).formatted} • ${TypeConversionUtils.artists_X_String<ArtistSimple>(album.artists ?? [])}",
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ServiceUtils.navigate(context, "/album/${album.id}", extra: album);
|
ServiceUtils.push(context, "/album/${album.id}", extra: album);
|
||||||
},
|
},
|
||||||
onPlaybuttonPressed: () async {
|
onPlaybuttonPressed: () async {
|
||||||
updating.value = true;
|
updating.value = true;
|
||||||
@ -89,6 +87,7 @@ class AlbumCard extends HookConsumerWidget {
|
|||||||
[],
|
[],
|
||||||
autoPlay: true,
|
autoPlay: true,
|
||||||
);
|
);
|
||||||
|
playlistNotifier.addCollection(album.id!);
|
||||||
} finally {
|
} finally {
|
||||||
updating.value = false;
|
updating.value = false;
|
||||||
}
|
}
|
||||||
@ -118,6 +117,7 @@ class AlbumCard extends HookConsumerWidget {
|
|||||||
|
|
||||||
if (fetchedTracks == null || fetchedTracks.isEmpty) return;
|
if (fetchedTracks == null || fetchedTracks.isEmpty) return;
|
||||||
playlistNotifier.addTracks(fetchedTracks);
|
playlistNotifier.addTracks(fetchedTracks);
|
||||||
|
playlistNotifier.addCollection(album.id!);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
final snackbar = SnackBar(
|
final snackbar = SnackBar(
|
||||||
content: Text("Added ${album.tracks?.length} tracks to queue"),
|
content: Text("Added ${album.tracks?.length} tracks to queue"),
|
||||||
|
|||||||
@ -35,6 +35,7 @@ class ArtistCard extends HookConsumerWidget {
|
|||||||
final radius = BorderRadius.circular(15);
|
final radius = BorderRadius.circular(15);
|
||||||
|
|
||||||
final double size = useBreakpointValue<double>(
|
final double size = useBreakpointValue<double>(
|
||||||
|
xs: 130,
|
||||||
sm: 130,
|
sm: 130,
|
||||||
md: 150,
|
md: 150,
|
||||||
others: 170,
|
others: 170,
|
||||||
@ -62,7 +63,7 @@ class ArtistCard extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ServiceUtils.navigate(context, "/artist/${artist.id}");
|
ServiceUtils.push(context, "/artist/${artist.id}");
|
||||||
},
|
},
|
||||||
borderRadius: radius,
|
borderRadius: radius,
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@ -188,7 +188,7 @@ class _MultiSelectDialog<T> extends HookWidget {
|
|||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
scrollable: true,
|
scrollable: true,
|
||||||
title: dialogTitle ?? const Text('Select'),
|
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),
|
insetPadding: const EdgeInsets.all(16),
|
||||||
actions: [
|
actions: [
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
|
|||||||
@ -94,7 +94,7 @@ class RecommendationAttributeDials extends HookWidget {
|
|||||||
return Card(
|
return Card(
|
||||||
child: ExpansionTile(
|
child: ExpansionTile(
|
||||||
title: DefaultTextStyle(
|
title: DefaultTextStyle(
|
||||||
style: Theme.of(context).textTheme.titleMedium!,
|
style: Theme.of(context).textTheme.titleSmall!,
|
||||||
child: title,
|
child: title,
|
||||||
),
|
),
|
||||||
shape: const Border(),
|
shape: const Border(),
|
||||||
|
|||||||
@ -93,7 +93,7 @@ class RecommendationAttributeFields extends HookWidget {
|
|||||||
return Card(
|
return Card(
|
||||||
child: ExpansionTile(
|
child: ExpansionTile(
|
||||||
title: DefaultTextStyle(
|
title: DefaultTextStyle(
|
||||||
style: Theme.of(context).textTheme.titleMedium!,
|
style: Theme.of(context).textTheme.titleSmall!,
|
||||||
child: title,
|
child: title,
|
||||||
),
|
),
|
||||||
shape: const Border(),
|
shape: const Border(),
|
||||||
|
|||||||
@ -24,6 +24,7 @@ class UserAlbums extends HookConsumerWidget {
|
|||||||
final albumsQuery = useQueries.album.ofMine(ref);
|
final albumsQuery = useQueries.album.ofMine(ref);
|
||||||
|
|
||||||
final spacing = useBreakpointValue<double>(
|
final spacing = useBreakpointValue<double>(
|
||||||
|
xs: 0,
|
||||||
sm: 0,
|
sm: 0,
|
||||||
others: 20,
|
others: 20,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import 'package:auto_size_text/auto_size_text.dart';
|
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:background_downloader/background_downloader.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
@ -65,14 +66,11 @@ class UserDownloads extends HookConsumerWidget {
|
|||||||
.where((element) => element.taskId == track.id),
|
.where((element) => element.taskId == track.id),
|
||||||
);
|
);
|
||||||
final taskItSelf = useFuture(
|
final taskItSelf = useFuture(
|
||||||
Future.value(null),
|
FileDownloader().database.recordForId(track.id!),
|
||||||
// FileDownloader().database.recordForId(track.id!),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final hasFailed = failedTaskStream
|
final hasFailed = failedTaskStream.hasData ||
|
||||||
.hasData /* ||
|
taskItSelf.data?.status == TaskStatus.failed;
|
||||||
taskItSelf.data?.status == TaskStatus.failed */
|
|
||||||
;
|
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(track.name ?? ''),
|
title: Text(track.name ?? ''),
|
||||||
@ -91,21 +89,18 @@ class UserDownloads extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
horizontalTitleGap: 10,
|
horizontalTitleGap: 10,
|
||||||
trailing: SizedBox(
|
trailing: downloadManager.activeItem?.id == track.id &&
|
||||||
width: 30,
|
!hasFailed
|
||||||
height: 30,
|
? CircularProgressIndicator(
|
||||||
child: downloadManager.activeItem?.id == track.id
|
value: task.data?.progress ?? 0,
|
||||||
? CircularProgressIndicator(
|
)
|
||||||
value: task.data?.progress ?? 0,
|
: hasFailed
|
||||||
)
|
? Icon(SpotubeIcons.error, color: Colors.red[400])
|
||||||
: hasFailed
|
: IconButton(
|
||||||
? Icon(SpotubeIcons.error, color: Colors.red[400])
|
icon: const Icon(SpotubeIcons.close),
|
||||||
: IconButton(
|
onPressed: () {
|
||||||
icon: const Icon(SpotubeIcons.close),
|
downloadManager.cancel(track);
|
||||||
onPressed: () {
|
}),
|
||||||
downloadManager.cancel(track);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
subtitle: TypeConversionUtils.artists_X_ClickableArtists(
|
subtitle: TypeConversionUtils.artists_X_ClickableArtists(
|
||||||
track.artists ?? <Artist>[],
|
track.artists ?? <Artist>[],
|
||||||
mainAxisAlignment: WrapAlignment.start,
|
mainAxisAlignment: WrapAlignment.start,
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import 'package:permission_handler/permission_handler.dart';
|
|||||||
|
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/components/shared/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/shimmers/shimmer_track_tile.dart';
|
||||||
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
|
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
|
||||||
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
||||||
@ -52,7 +52,8 @@ enum SortBy {
|
|||||||
descending,
|
descending,
|
||||||
artist,
|
artist,
|
||||||
album,
|
album,
|
||||||
dateAdded,
|
newest,
|
||||||
|
oldest,
|
||||||
}
|
}
|
||||||
|
|
||||||
final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {
|
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};
|
return {"metadata": metadata, "file": f, "art": imageFile.path};
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
if (e is FfiException) {
|
if (e is FfiException) {
|
||||||
return {};
|
return {"file": f};
|
||||||
}
|
}
|
||||||
Catcher.reportCheckedError(e, stack);
|
Catcher.reportCheckedError(e, stack);
|
||||||
return {};
|
return {};
|
||||||
@ -160,7 +161,10 @@ class UserLocalTracks extends HookConsumerWidget {
|
|||||||
playlist.containsTracks(trackSnapshot.value ?? []);
|
playlist.containsTracks(trackSnapshot.value ?? []);
|
||||||
final isMounted = useIsMounted();
|
final isMounted = useIsMounted();
|
||||||
|
|
||||||
final searchText = useState<String>("");
|
final searchController = useTextEditingController();
|
||||||
|
useValueListenable(searchController);
|
||||||
|
final searchFocus = useFocusNode();
|
||||||
|
final isFiltering = useState(false);
|
||||||
|
|
||||||
useAsyncEffect(
|
useAsyncEffect(
|
||||||
() async {
|
() 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(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
@ -213,7 +212,10 @@ class UserLocalTracks extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
searchbar,
|
ExpandableSearchButton(
|
||||||
|
isFiltering: isFiltering,
|
||||||
|
searchFocus: searchFocus,
|
||||||
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
SortTracksDropdown(
|
SortTracksDropdown(
|
||||||
value: sortBy.value,
|
value: sortBy.value,
|
||||||
@ -231,6 +233,11 @@ class UserLocalTracks extends HookConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
ExpandableSearchField(
|
||||||
|
searchController: searchController,
|
||||||
|
searchFocus: searchFocus,
|
||||||
|
isFiltering: isFiltering,
|
||||||
|
),
|
||||||
trackSnapshot.when(
|
trackSnapshot.when(
|
||||||
data: (tracks) {
|
data: (tracks) {
|
||||||
final sortedTracks = useMemoized(() {
|
final sortedTracks = useMemoized(() {
|
||||||
@ -238,14 +245,14 @@ class UserLocalTracks extends HookConsumerWidget {
|
|||||||
}, [sortBy.value, tracks]);
|
}, [sortBy.value, tracks]);
|
||||||
|
|
||||||
final filteredTracks = useMemoized(() {
|
final filteredTracks = useMemoized(() {
|
||||||
if (searchText.value.isEmpty) {
|
if (searchController.text.isEmpty) {
|
||||||
return sortedTracks;
|
return sortedTracks;
|
||||||
}
|
}
|
||||||
return sortedTracks
|
return sortedTracks
|
||||||
.map((e) => (
|
.map((e) => (
|
||||||
weightedRatio(
|
weightedRatio(
|
||||||
"${e.name} - ${TypeConversionUtils.artists_X_String<Artist>(e.artists ?? [])}",
|
"${e.name} - ${TypeConversionUtils.artists_X_String<Artist>(e.artists ?? [])}",
|
||||||
searchText.value,
|
searchController.text,
|
||||||
),
|
),
|
||||||
e,
|
e,
|
||||||
))
|
))
|
||||||
@ -257,7 +264,7 @@ class UserLocalTracks extends HookConsumerWidget {
|
|||||||
.map((e) => e.$2)
|
.map((e) => e.$2)
|
||||||
.toList()
|
.toList()
|
||||||
.toList();
|
.toList();
|
||||||
}, [searchText.value, sortedTracks]);
|
}, [searchController.text, sortedTracks]);
|
||||||
|
|
||||||
return Expanded(
|
return Expanded(
|
||||||
child: RefreshIndicator(
|
child: RefreshIndicator(
|
||||||
|
|||||||
@ -3,26 +3,31 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart' hide Offset;
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/components/player/player_queue.dart';
|
import 'package:spotube/components/player/player_queue.dart';
|
||||||
import 'package:spotube/components/player/sibling_tracks_sheet.dart';
|
import 'package:spotube/components/player/sibling_tracks_sheet.dart';
|
||||||
|
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
|
||||||
import 'package:spotube/components/shared/heart_button.dart';
|
import 'package:spotube/components/shared/heart_button.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
|
import 'package:spotube/extensions/duration.dart';
|
||||||
import 'package:spotube/models/local_track.dart';
|
import 'package:spotube/models/local_track.dart';
|
||||||
import 'package:spotube/models/logger.dart';
|
import 'package:spotube/models/logger.dart';
|
||||||
import 'package:spotube/provider/download_manager_provider.dart';
|
import 'package:spotube/provider/download_manager_provider.dart';
|
||||||
import 'package:spotube/provider/authentication_provider.dart';
|
import 'package:spotube/provider/authentication_provider.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_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';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
|
|
||||||
class PlayerActions extends HookConsumerWidget {
|
class PlayerActions extends HookConsumerWidget {
|
||||||
final MainAxisAlignment mainAxisAlignment;
|
final MainAxisAlignment mainAxisAlignment;
|
||||||
final bool floatingQueue;
|
final bool floatingQueue;
|
||||||
|
final bool showQueue;
|
||||||
final List<Widget>? extraActions;
|
final List<Widget>? extraActions;
|
||||||
PlayerActions({
|
PlayerActions({
|
||||||
this.mainAxisAlignment = MainAxisAlignment.center,
|
this.mainAxisAlignment = MainAxisAlignment.center,
|
||||||
this.floatingQueue = true,
|
this.floatingQueue = true,
|
||||||
|
this.showQueue = true,
|
||||||
this.extraActions,
|
this.extraActions,
|
||||||
Key? key,
|
Key? key,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
@ -38,6 +43,8 @@ class PlayerActions extends HookConsumerWidget {
|
|||||||
downloader.activeItem!.id == playlist.activeTrack?.id;
|
downloader.activeItem!.id == playlist.activeTrack?.id;
|
||||||
final localTracks = [] /* ref.watch(localTracksProvider).value */;
|
final localTracks = [] /* ref.watch(localTracksProvider).value */;
|
||||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||||
|
final sleepTimer = ref.watch(SleepTimerNotifier.provider);
|
||||||
|
final sleepTimerNotifier = ref.watch(SleepTimerNotifier.notifier);
|
||||||
|
|
||||||
final isDownloaded = useMemoized(() {
|
final isDownloaded = useMemoized(() {
|
||||||
return localTracks.any(
|
return localTracks.any(
|
||||||
@ -52,34 +59,47 @@ class PlayerActions extends HookConsumerWidget {
|
|||||||
true;
|
true;
|
||||||
}, [localTracks, playlist.activeTrack]);
|
}, [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(
|
return Row(
|
||||||
mainAxisAlignment: mainAxisAlignment,
|
mainAxisAlignment: mainAxisAlignment,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
if (showQueue)
|
||||||
icon: const Icon(SpotubeIcons.queue),
|
IconButton(
|
||||||
tooltip: context.l10n.queue,
|
icon: const Icon(SpotubeIcons.queue),
|
||||||
onPressed: playlist.activeTrack != null
|
tooltip: context.l10n.queue,
|
||||||
? () {
|
onPressed: playlist.activeTrack != null
|
||||||
showModalBottomSheet(
|
? () {
|
||||||
context: context,
|
showModalBottomSheet(
|
||||||
isDismissible: true,
|
context: context,
|
||||||
enableDrag: true,
|
isDismissible: true,
|
||||||
isScrollControlled: true,
|
enableDrag: true,
|
||||||
backgroundColor: Colors.black12,
|
isScrollControlled: true,
|
||||||
barrierColor: Colors.black12,
|
backgroundColor: Colors.black12,
|
||||||
shape: RoundedRectangleBorder(
|
barrierColor: Colors.black12,
|
||||||
borderRadius: BorderRadius.circular(10),
|
shape: RoundedRectangleBorder(
|
||||||
),
|
borderRadius: BorderRadius.circular(10),
|
||||||
constraints: BoxConstraints(
|
),
|
||||||
maxHeight: MediaQuery.of(context).size.height * .7,
|
constraints: BoxConstraints(
|
||||||
),
|
maxHeight: MediaQuery.of(context).size.height * .7,
|
||||||
builder: (context) {
|
),
|
||||||
return PlayerQueue(floating: floatingQueue);
|
builder: (context) {
|
||||||
},
|
return PlayerQueue(floating: floatingQueue);
|
||||||
);
|
},
|
||||||
}
|
);
|
||||||
: null,
|
}
|
||||||
),
|
: null,
|
||||||
|
),
|
||||||
if (!isLocalTrack)
|
if (!isLocalTrack)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(SpotubeIcons.alternativeRoute),
|
icon: const Icon(SpotubeIcons.alternativeRoute),
|
||||||
@ -127,6 +147,62 @@ class PlayerActions extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
if (playlist.activeTrack != null && !isLocalTrack && auth != null)
|
if (playlist.activeTrack != null && !isLocalTrack && auth != null)
|
||||||
TrackHeartButton(track: playlist.activeTrack!),
|
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 ?? [])
|
...(extraActions ?? [])
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -44,7 +44,7 @@ class PlayerOverlay extends HookConsumerWidget {
|
|||||||
int sensitivity = 8;
|
int sensitivity = 8;
|
||||||
if (details.primaryVelocity != null &&
|
if (details.primaryVelocity != null &&
|
||||||
details.primaryVelocity! < -sensitivity) {
|
details.primaryVelocity! < -sensitivity) {
|
||||||
ServiceUtils.navigate(context, "/player");
|
ServiceUtils.push(context, "/player");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
|
|
||||||
@ -6,6 +7,7 @@ import 'package:spotube/collections/assets.gen.dart';
|
|||||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||||
import 'package:spotube/extensions/constrains.dart';
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
|
import 'package:spotube/utils/service_utils.dart';
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
|
|
||||||
class PlayerTrackDetails extends HookConsumerWidget {
|
class PlayerTrackDetails extends HookConsumerWidget {
|
||||||
@ -37,7 +39,7 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (mediaQuery.isSm || mediaQuery.isMd)
|
if (mediaQuery.mdAndDown)
|
||||||
Flexible(
|
Flexible(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@ -72,6 +74,9 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
TypeConversionUtils.artists_X_ClickableArtists(
|
TypeConversionUtils.artists_X_ClickableArtists(
|
||||||
playback.activeTrack?.artists ?? [],
|
playback.activeTrack?.artists ?? [],
|
||||||
|
onRouteChange: (route) {
|
||||||
|
ServiceUtils.push(context, route);
|
||||||
|
},
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
66
lib/components/player/volume_slider.dart
Normal file
66
lib/components/player/volume_slider.dart
Normal 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,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,13 +24,10 @@ class PlaylistCard extends HookConsumerWidget {
|
|||||||
final playing =
|
final playing =
|
||||||
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
||||||
final queryBowl = QueryClient.of(context);
|
final queryBowl = QueryClient.of(context);
|
||||||
final query = queryBowl.getQuery<List<Track>, dynamic>(
|
|
||||||
"playlist-tracks/${playlist.id}",
|
|
||||||
);
|
|
||||||
final tracks = useState<List<TrackSimple>?>(null);
|
final tracks = useState<List<TrackSimple>?>(null);
|
||||||
bool isPlaylistPlaying = useMemoized(
|
bool isPlaylistPlaying = useMemoized(
|
||||||
() => playlistQueue.containsTracks(tracks.value ?? query?.data ?? []),
|
() => playlistQueue.containsCollection(playlist.id!),
|
||||||
[playlistNotifier, tracks.value, query?.data],
|
[playlistQueue, playlist.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
final updating = useState(false);
|
final updating = useState(false);
|
||||||
@ -48,7 +45,7 @@ class PlaylistCard extends HookConsumerWidget {
|
|||||||
isLoading:
|
isLoading:
|
||||||
(isPlaylistPlaying && playlistQueue.isFetching) || updating.value,
|
(isPlaylistPlaying && playlistQueue.isFetching) || updating.value,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ServiceUtils.navigate(
|
ServiceUtils.push(
|
||||||
context,
|
context,
|
||||||
"/playlist/${playlist.id}",
|
"/playlist/${playlist.id}",
|
||||||
extra: playlist,
|
extra: playlist,
|
||||||
@ -72,6 +69,7 @@ class PlaylistCard extends HookConsumerWidget {
|
|||||||
if (fetchedTracks.isEmpty) return;
|
if (fetchedTracks.isEmpty) return;
|
||||||
|
|
||||||
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
||||||
|
playlistNotifier.addCollection(playlist.id!);
|
||||||
tracks.value = fetchedTracks;
|
tracks.value = fetchedTracks;
|
||||||
} finally {
|
} finally {
|
||||||
updating.value = false;
|
updating.value = false;
|
||||||
@ -90,6 +88,7 @@ class PlaylistCard extends HookConsumerWidget {
|
|||||||
if (fetchedTracks.isEmpty) return;
|
if (fetchedTracks.isEmpty) return;
|
||||||
|
|
||||||
playlistNotifier.addTracks(fetchedTracks);
|
playlistNotifier.addTracks(fetchedTracks);
|
||||||
|
playlistNotifier.addCollection(playlist.id!);
|
||||||
tracks.value = fetchedTracks;
|
tracks.value = fetchedTracks;
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
final snackbar = SnackBar(
|
final snackbar = SnackBar(
|
||||||
|
|||||||
@ -121,7 +121,7 @@ class PlaylistCreateDialogButton extends HookConsumerWidget {
|
|||||||
final mediaQuery = MediaQuery.of(context);
|
final mediaQuery = MediaQuery.of(context);
|
||||||
final spotify = ref.watch(spotifyProvider);
|
final spotify = ref.watch(spotifyProvider);
|
||||||
|
|
||||||
if (mediaQuery.isSm) {
|
if (mediaQuery.smAndDown) {
|
||||||
return ElevatedButton(
|
return ElevatedButton(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
foregroundColor: Theme.of(context).colorScheme.primary,
|
foregroundColor: Theme.of(context).colorScheme.primary,
|
||||||
|
|||||||
@ -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_overlay.dart';
|
||||||
import 'package:spotube/components/player/player_track_details.dart';
|
import 'package:spotube/components/player/player_track_details.dart';
|
||||||
import 'package:spotube/components/player/player_controls.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/constrains.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/hooks/use_brightness_value.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/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||||
import 'package:spotube/provider/volume_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/platform.dart';
|
||||||
import 'package:spotube/utils/type_conversion_utils.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
|
// returning an empty non spacious Container as the overlay will take
|
||||||
// place in the global overlay stack aka [_entries]
|
// place in the global overlay stack aka [_entries]
|
||||||
if (layoutMode == LayoutMode.compact ||
|
if (layoutMode == LayoutMode.compact ||
|
||||||
((mediaQuery.isSm || mediaQuery.isMd) &&
|
((mediaQuery.mdAndDown) && layoutMode == LayoutMode.adaptive)) {
|
||||||
layoutMode == LayoutMode.adaptive)) {
|
|
||||||
return PlayerOverlay(albumArt: albumArt);
|
return PlayerOverlay(albumArt: albumArt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -116,57 +115,7 @@ class BottomPlayer extends HookConsumerWidget {
|
|||||||
Container(
|
Container(
|
||||||
height: 40,
|
height: 40,
|
||||||
constraints: const BoxConstraints(maxWidth: 250),
|
constraints: const BoxConstraints(maxWidth: 250),
|
||||||
child: HookBuilder(builder: (context) {
|
child: const VolumeSlider(),
|
||||||
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|||||||
@ -82,16 +82,17 @@ class Sidebar extends HookConsumerWidget {
|
|||||||
}, [controller]);
|
}, [controller]);
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
|
if (!context.mounted) return;
|
||||||
if (mediaQuery.lgAndUp && !controller.extended) {
|
if (mediaQuery.lgAndUp && !controller.extended) {
|
||||||
controller.setExtended(true);
|
controller.setExtended(true);
|
||||||
} else if ((mediaQuery.isSm || mediaQuery.isMd) && controller.extended) {
|
} else if (mediaQuery.mdAndDown && controller.extended) {
|
||||||
controller.setExtended(false);
|
controller.setExtended(false);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}, [mediaQuery, controller]);
|
}, [mediaQuery, controller]);
|
||||||
|
|
||||||
if (layoutMode == LayoutMode.compact ||
|
if (layoutMode == LayoutMode.compact ||
|
||||||
(mediaQuery.isSm && layoutMode == LayoutMode.adaptive)) {
|
(mediaQuery.smAndDown && layoutMode == LayoutMode.adaptive)) {
|
||||||
return Scaffold(body: child);
|
return Scaffold(body: child);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -186,7 +187,7 @@ class SidebarHeader extends HookWidget {
|
|||||||
final mediaQuery = MediaQuery.of(context);
|
final mediaQuery = MediaQuery.of(context);
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
if (mediaQuery.isSm || mediaQuery.isMd) {
|
if (mediaQuery.mdAndDown) {
|
||||||
return Container(
|
return Container(
|
||||||
height: 40,
|
height: 40,
|
||||||
width: 40,
|
width: 40,
|
||||||
@ -236,7 +237,7 @@ class SidebarFooter extends HookConsumerWidget {
|
|||||||
|
|
||||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||||
|
|
||||||
if (mediaQuery.isSm || mediaQuery.isMd) {
|
if (mediaQuery.mdAndDown) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
icon: const Icon(SpotubeIcons.settings),
|
icon: const Icon(SpotubeIcons.settings),
|
||||||
onPressed: () => Sidebar.goToSettings(context),
|
onPressed: () => Sidebar.goToSettings(context),
|
||||||
|
|||||||
@ -17,7 +17,7 @@ class AdaptiveListTile extends HookWidget {
|
|||||||
this.title,
|
this.title,
|
||||||
this.subtitle,
|
this.subtitle,
|
||||||
this.leading,
|
this.leading,
|
||||||
this.breakOn ,
|
this.breakOn,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -27,10 +27,11 @@ class AdaptiveListTile extends HookWidget {
|
|||||||
return ListTile(
|
return ListTile(
|
||||||
title: title,
|
title: title,
|
||||||
subtitle: subtitle,
|
subtitle: subtitle,
|
||||||
trailing:
|
trailing: breakOn ?? mediaQuery.smAndDown
|
||||||
breakOn ?? mediaQuery.isSm ? null : trailing?.call(context, null),
|
? null
|
||||||
|
: trailing?.call(context, null),
|
||||||
leading: leading,
|
leading: leading,
|
||||||
onTap: breakOn ?? mediaQuery.isSm
|
onTap: breakOn ?? mediaQuery.smAndDown
|
||||||
? () {
|
? () {
|
||||||
onTap?.call();
|
onTap?.call();
|
||||||
showDialog(
|
showDialog(
|
||||||
|
|||||||
@ -2,17 +2,47 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/extensions/constrains.dart';
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
|
|
||||||
class PopSheetEntry<T> {
|
_emptyCB() {}
|
||||||
final T? value;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
final Widget child;
|
|
||||||
final bool enabled;
|
|
||||||
|
|
||||||
|
class PopSheetEntry<T> extends ListTile {
|
||||||
|
final T? value;
|
||||||
const PopSheetEntry({
|
const PopSheetEntry({
|
||||||
required this.child,
|
|
||||||
this.value,
|
this.value,
|
||||||
this.onTap,
|
super.key,
|
||||||
this.enabled = true,
|
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 ValueChanged<T>? onSelected;
|
||||||
|
|
||||||
final BorderRadius borderRadius;
|
final BorderRadius borderRadius;
|
||||||
|
final Offset offset;
|
||||||
|
|
||||||
const AdaptivePopSheetList({
|
const AdaptivePopSheetList({
|
||||||
super.key,
|
super.key,
|
||||||
@ -41,6 +72,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
|
|||||||
this.onSelected,
|
this.onSelected,
|
||||||
this.borderRadius = const BorderRadius.all(Radius.circular(999)),
|
this.borderRadius = const BorderRadius.all(Radius.circular(999)),
|
||||||
this.tooltip,
|
this.tooltip,
|
||||||
|
this.offset = Offset.zero,
|
||||||
}) : assert(
|
}) : assert(
|
||||||
!(icon != null && child != null),
|
!(icon != null && child != null),
|
||||||
'Either icon or child must be provided',
|
'Either icon or child must be provided',
|
||||||
@ -55,11 +87,13 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
|
|||||||
return PopupMenuButton(
|
return PopupMenuButton(
|
||||||
icon: icon,
|
icon: icon,
|
||||||
tooltip: tooltip,
|
tooltip: tooltip,
|
||||||
|
offset: offset,
|
||||||
child: child == null ? null : IgnorePointer(child: child),
|
child: child == null ? null : IgnorePointer(child: child),
|
||||||
itemBuilder: (context) => children
|
itemBuilder: (context) => children
|
||||||
.map(
|
.map(
|
||||||
(item) => PopupMenuItem(
|
(item) => PopupMenuItem(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
|
enabled: false,
|
||||||
child: _AdaptivePopSheetListItem(
|
child: _AdaptivePopSheetListItem(
|
||||||
item: item,
|
item: item,
|
||||||
onSelected: onSelected,
|
onSelected: onSelected,
|
||||||
@ -74,40 +108,38 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
|
|||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
useRootNavigator: useRootNavigator,
|
useRootNavigator: useRootNavigator,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: mediaQuery.size.height * 0.6,
|
||||||
|
),
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0).copyWith(top: 0),
|
||||||
child: DefaultTextStyle(
|
child: DefaultTextStyle(
|
||||||
style: theme.textTheme.titleMedium!,
|
style: theme.textTheme.titleMedium!,
|
||||||
child: Column(
|
child: SingleChildScrollView(
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
if (headings != null) ...[
|
children: [
|
||||||
Container(
|
if (headings != null) ...[
|
||||||
width: 180,
|
...headings!,
|
||||||
height: 6,
|
const SizedBox(height: 8),
|
||||||
decoration: BoxDecoration(
|
Divider(
|
||||||
color: theme.colorScheme.primary,
|
color: theme.colorScheme.primary,
|
||||||
borderRadius: BorderRadius.circular(999),
|
thickness: 0.3,
|
||||||
|
endIndent: 16,
|
||||||
|
indent: 16,
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(height: 8),
|
...children.map(
|
||||||
...headings!,
|
(item) => _AdaptivePopSheetListItem(
|
||||||
const SizedBox(height: 8),
|
item: item,
|
||||||
Divider(
|
onSelected: onSelected,
|
||||||
color: theme.colorScheme.primary,
|
),
|
||||||
thickness: 0.3,
|
)
|
||||||
endIndent: 16,
|
|
||||||
indent: 16,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
...children.map(
|
),
|
||||||
(item) => _AdaptivePopSheetListItem(
|
|
||||||
item: item,
|
|
||||||
onSelected: onSelected,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -153,27 +185,23 @@ class _AdaptivePopSheetListItem<T> extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: (theme.listTileTheme.shape as RoundedRectangleBorder?)
|
||||||
|
?.borderRadius as BorderRadius? ??
|
||||||
|
const BorderRadius.all(Radius.circular(10)),
|
||||||
onTap: !item.enabled
|
onTap: !item.enabled
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
item.onTap?.call();
|
item.onTap?.call();
|
||||||
Navigator.pop(context);
|
|
||||||
if (item.value != null) {
|
if (item.value != null) {
|
||||||
|
Navigator.pop(context);
|
||||||
onSelected?.call(item.value as T);
|
onSelected?.call(item.value as T);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: DefaultTextStyle(
|
child: Padding(
|
||||||
style: TextStyle(
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
color: item.enabled
|
child: IgnorePointer(child: item),
|
||||||
? theme.textTheme.bodyMedium!.color
|
|
||||||
: theme.textTheme.bodyMedium!.color!.withOpacity(0.5),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
||||||
child: item.child,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
166
lib/components/shared/dialogs/track_details_dialog.dart
Normal file
166
lib/components/shared/dialogs/track_details_dialog.dart
Normal 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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -24,7 +24,7 @@ class AnonymousFallback extends ConsumerWidget {
|
|||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
child: const Text("Login with Spotify"),
|
child: const Text("Login with Spotify"),
|
||||||
onPressed: () => ServiceUtils.navigate(context, "/settings"),
|
onPressed: () => ServiceUtils.push(context, "/settings"),
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -5,13 +5,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
|
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/extensions/context.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/provider/authentication_provider.dart';
|
||||||
import 'package:spotube/services/mutations/mutations.dart';
|
import 'package:spotube/services/mutations/mutations.dart';
|
||||||
import 'package:spotube/services/queries/queries.dart';
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
|
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
|
||||||
|
|
||||||
class HeartButton extends HookConsumerWidget {
|
class HeartButton extends HookConsumerWidget {
|
||||||
final bool isLiked;
|
final bool isLiked;
|
||||||
final void Function()? onPressed;
|
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) {
|
if (me.isLoading || !me.hasData) {
|
||||||
return const CircularProgressIndicator();
|
return const CircularProgressIndicator();
|
||||||
}
|
}
|
||||||
@ -181,7 +169,7 @@ class PlaylistHeartButton extends HookConsumerWidget {
|
|||||||
tooltip: isLikedQuery.data ?? false
|
tooltip: isLikedQuery.data ?? false
|
||||||
? context.l10n.remove_from_favorites
|
? context.l10n.remove_from_favorites
|
||||||
: context.l10n.save_as_favorite,
|
: context.l10n.save_as_favorite,
|
||||||
color: color?.titleTextColor,
|
color: Colors.white,
|
||||||
onPressed: isLikedQuery.hasData
|
onPressed: isLikedQuery.hasData
|
||||||
? () {
|
? () {
|
||||||
togglePlaylistLike.mutate(isLikedQuery.data!);
|
togglePlaylistLike.mutate(isLikedQuery.data!);
|
||||||
@ -224,6 +212,7 @@ class AlbumHeartButton extends HookConsumerWidget {
|
|||||||
tooltip: isLiked
|
tooltip: isLiked
|
||||||
? context.l10n.remove_from_favorites
|
? context.l10n.remove_from_favorites
|
||||||
: context.l10n.save_as_favorite,
|
: context.l10n.save_as_favorite,
|
||||||
|
color: Colors.white,
|
||||||
onPressed: albumIsSaved.hasData
|
onPressed: albumIsSaved.hasData
|
||||||
? () {
|
? () {
|
||||||
toggleAlbumLike.mutate(isLiked);
|
toggleAlbumLike.mutate(isLiked);
|
||||||
|
|||||||
@ -7,6 +7,7 @@ class AnchorButton<T> extends HookWidget {
|
|||||||
final TextAlign? textAlign;
|
final TextAlign? textAlign;
|
||||||
final TextOverflow? overflow;
|
final TextOverflow? overflow;
|
||||||
final void Function()? onTap;
|
final void Function()? onTap;
|
||||||
|
final int? maxLines;
|
||||||
|
|
||||||
const AnchorButton(
|
const AnchorButton(
|
||||||
this.text, {
|
this.text, {
|
||||||
@ -14,6 +15,7 @@ class AnchorButton<T> extends HookWidget {
|
|||||||
this.onTap,
|
this.onTap,
|
||||||
this.textAlign,
|
this.textAlign,
|
||||||
this.overflow,
|
this.overflow,
|
||||||
|
this.maxLines,
|
||||||
this.style = const TextStyle(),
|
this.style = const TextStyle(),
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@ -34,6 +36,7 @@ class AnchorButton<T> extends HookWidget {
|
|||||||
decoration:
|
decoration:
|
||||||
hover.value || tap.value ? TextDecoration.underline : null,
|
hover.value || tap.value ? TextDecoration.underline : null,
|
||||||
),
|
),
|
||||||
|
maxLines: maxLines,
|
||||||
textAlign: textAlign,
|
textAlign: textAlign,
|
||||||
overflow: overflow,
|
overflow: overflow,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -8,6 +8,8 @@ class Hyperlink extends StatelessWidget {
|
|||||||
final TextAlign? textAlign;
|
final TextAlign? textAlign;
|
||||||
final TextOverflow? overflow;
|
final TextOverflow? overflow;
|
||||||
final String url;
|
final String url;
|
||||||
|
final int? maxLines;
|
||||||
|
|
||||||
const Hyperlink(
|
const Hyperlink(
|
||||||
this.text,
|
this.text,
|
||||||
this.url, {
|
this.url, {
|
||||||
@ -15,6 +17,7 @@ class Hyperlink extends StatelessWidget {
|
|||||||
this.textAlign,
|
this.textAlign,
|
||||||
this.overflow,
|
this.overflow,
|
||||||
this.style = const TextStyle(),
|
this.style = const TextStyle(),
|
||||||
|
this.maxLines,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -29,6 +32,7 @@ class Hyperlink extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
key: key,
|
key: key,
|
||||||
overflow: overflow,
|
overflow: overflow,
|
||||||
|
maxLines: maxLines,
|
||||||
style: style.copyWith(color: Colors.blue),
|
style: style.copyWith(color: Colors.blue),
|
||||||
textAlign: textAlign,
|
textAlign: textAlign,
|
||||||
);
|
);
|
||||||
|
|||||||
@ -9,6 +9,8 @@ class LinkText<T> extends StatelessWidget {
|
|||||||
final TextOverflow? overflow;
|
final TextOverflow? overflow;
|
||||||
final String route;
|
final String route;
|
||||||
final T? extra;
|
final T? extra;
|
||||||
|
|
||||||
|
final bool push;
|
||||||
const LinkText(
|
const LinkText(
|
||||||
this.text,
|
this.text,
|
||||||
this.route, {
|
this.route, {
|
||||||
@ -17,6 +19,7 @@ class LinkText<T> extends StatelessWidget {
|
|||||||
this.extra,
|
this.extra,
|
||||||
this.overflow,
|
this.overflow,
|
||||||
this.style = const TextStyle(),
|
this.style = const TextStyle(),
|
||||||
|
this.push = false,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -24,7 +27,11 @@ class LinkText<T> extends StatelessWidget {
|
|||||||
return AnchorButton(
|
return AnchorButton(
|
||||||
text,
|
text,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
ServiceUtils.navigate(context, route, extra: extra);
|
if (push) {
|
||||||
|
ServiceUtils.push(context, route, extra: extra);
|
||||||
|
} else {
|
||||||
|
ServiceUtils.navigate(context, route, extra: extra);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
key: key,
|
key: key,
|
||||||
overflow: overflow,
|
overflow: overflow,
|
||||||
|
|||||||
@ -40,6 +40,7 @@ class PlaybuttonCard extends HookWidget {
|
|||||||
final radius = BorderRadius.circular(15);
|
final radius = BorderRadius.circular(15);
|
||||||
|
|
||||||
final double size = useBreakpointValue<double>(
|
final double size = useBreakpointValue<double>(
|
||||||
|
xs: 130,
|
||||||
sm: 130,
|
sm: 130,
|
||||||
md: 150,
|
md: 150,
|
||||||
others: 170,
|
others: 170,
|
||||||
@ -47,6 +48,7 @@ class PlaybuttonCard extends HookWidget {
|
|||||||
170;
|
170;
|
||||||
|
|
||||||
final end = useBreakpointValue<double>(
|
final end = useBreakpointValue<double>(
|
||||||
|
xs: 15,
|
||||||
sm: 15,
|
sm: 15,
|
||||||
others: 20,
|
others: 20,
|
||||||
) ??
|
) ??
|
||||||
|
|||||||
@ -21,6 +21,7 @@ class ShimmerArtistProfile extends HookWidget {
|
|||||||
shimmerTheme.shimmerBackgroundColor ?? Colors.grey;
|
shimmerTheme.shimmerBackgroundColor ?? Colors.grey;
|
||||||
|
|
||||||
final avatarWidth = useBreakpointValue(
|
final avatarWidth = useBreakpointValue(
|
||||||
|
xs: MediaQuery.of(context).size.width * 0.80,
|
||||||
sm: MediaQuery.of(context).size.width * 0.80,
|
sm: MediaQuery.of(context).size.width * 0.80,
|
||||||
md: MediaQuery.of(context).size.width * 0.50,
|
md: MediaQuery.of(context).size.width * 0.50,
|
||||||
lg: MediaQuery.of(context).size.width * 0.30,
|
lg: MediaQuery.of(context).size.width * 0.30,
|
||||||
|
|||||||
@ -18,6 +18,7 @@ class ShimmerCategories extends HookWidget {
|
|||||||
shimmerTheme.shimmerBackgroundColor ?? Colors.grey;
|
shimmerTheme.shimmerBackgroundColor ?? Colors.grey;
|
||||||
|
|
||||||
final shimmerCount = useBreakpointValue(
|
final shimmerCount = useBreakpointValue(
|
||||||
|
xs: 2,
|
||||||
sm: 2,
|
sm: 2,
|
||||||
md: 3,
|
md: 3,
|
||||||
lg: 3,
|
lg: 3,
|
||||||
|
|||||||
@ -32,7 +32,7 @@ class ShimmerLyrics extends HookWidget {
|
|||||||
if (mediaQuery.isMd) {
|
if (mediaQuery.isMd) {
|
||||||
widthsCp.removeLast();
|
widthsCp.removeLast();
|
||||||
}
|
}
|
||||||
if (mediaQuery.isSm) {
|
if (mediaQuery.smAndDown) {
|
||||||
widthsCp.removeLast();
|
widthsCp.removeLast();
|
||||||
widthsCp.removeLast();
|
widthsCp.removeLast();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -86,6 +86,7 @@ class ShimmerPlaybuttonCard extends HookWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final Size size = useBreakpointValue<Size>(
|
final Size size = useBreakpointValue<Size>(
|
||||||
|
xs: const Size(130, 200),
|
||||||
sm: const Size(130, 200),
|
sm: const Size(130, 200),
|
||||||
md: const Size(150, 220),
|
md: const Size(150, 220),
|
||||||
others: const Size(170, 240),
|
others: const Size(170, 240),
|
||||||
|
|||||||
@ -16,6 +16,7 @@ class SortTracksDropdown extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
var theme = Theme.of(context);
|
||||||
return ListTileTheme(
|
return ListTileTheme(
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
@ -25,50 +26,37 @@ class SortTracksDropdown extends StatelessWidget {
|
|||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: SortBy.none,
|
value: SortBy.none,
|
||||||
enabled: value != SortBy.none,
|
enabled: value != SortBy.none,
|
||||||
child: ListTile(
|
title: Text(context.l10n.none),
|
||||||
enabled: value != SortBy.none,
|
|
||||||
title: Text(context.l10n.none),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: SortBy.ascending,
|
value: SortBy.ascending,
|
||||||
enabled: value != SortBy.ascending,
|
enabled: value != SortBy.ascending,
|
||||||
child: ListTile(
|
title: Text(context.l10n.sort_a_z),
|
||||||
enabled: value != SortBy.ascending,
|
|
||||||
title: Text(context.l10n.sort_a_z),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: SortBy.descending,
|
value: SortBy.descending,
|
||||||
enabled: value != SortBy.descending,
|
enabled: value != SortBy.descending,
|
||||||
child: ListTile(
|
title: Text(context.l10n.sort_z_a),
|
||||||
enabled: value != SortBy.descending,
|
|
||||||
title: Text(context.l10n.sort_z_a),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: SortBy.dateAdded,
|
value: SortBy.newest,
|
||||||
enabled: value != SortBy.dateAdded,
|
enabled: value != SortBy.newest,
|
||||||
child: ListTile(
|
title: Text(context.l10n.sort_newest),
|
||||||
enabled: value != SortBy.dateAdded,
|
),
|
||||||
title: Text(context.l10n.sort_date),
|
PopSheetEntry(
|
||||||
),
|
value: SortBy.oldest,
|
||||||
|
enabled: value != SortBy.oldest,
|
||||||
|
title: Text(context.l10n.sort_oldest),
|
||||||
),
|
),
|
||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: SortBy.artist,
|
value: SortBy.artist,
|
||||||
enabled: value != SortBy.artist,
|
enabled: value != SortBy.artist,
|
||||||
child: ListTile(
|
title: Text(context.l10n.sort_artist),
|
||||||
enabled: value != SortBy.artist,
|
|
||||||
title: Text(context.l10n.sort_artist),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: SortBy.album,
|
value: SortBy.album,
|
||||||
enabled: value != SortBy.album,
|
enabled: value != SortBy.album,
|
||||||
child: ListTile(
|
title: Text(context.l10n.sort_album),
|
||||||
enabled: value != SortBy.album,
|
|
||||||
title: Text(context.l10n.sort_album),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
headings: [
|
headings: [
|
||||||
@ -79,7 +67,7 @@ class SortTracksDropdown extends StatelessWidget {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
|
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
|
||||||
child: DefaultTextStyle(
|
child: DefaultTextStyle(
|
||||||
style: Theme.of(context).textTheme.titleSmall!,
|
style: theme.textTheme.titleSmall!,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(SpotubeIcons.sort),
|
const Icon(SpotubeIcons.sort),
|
||||||
|
|||||||
@ -1,5 +1,25 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
class SpotubePage<T> extends MaterialPage<T> {
|
class SpotubePage<T> extends MaterialPage<T> {
|
||||||
const SpotubePage({required super.child});
|
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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final breakpoint = useBreakpointValue(
|
final breakpoint = useBreakpointValue(
|
||||||
|
xs: 85.0,
|
||||||
sm: 85.0,
|
sm: 85.0,
|
||||||
md: 35.0,
|
md: 35.0,
|
||||||
others: 0.0,
|
others: 0.0,
|
||||||
|
|||||||
@ -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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import 'package:spotube/collections/spotube_icons.dart';
|
|||||||
import 'package:spotube/components/library/user_local_tracks.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/adaptive/adaptive_pop_sheet_list.dart';
|
||||||
import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.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/heart_button.dart';
|
||||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
@ -29,6 +30,7 @@ enum TrackOptionValue {
|
|||||||
delete,
|
delete,
|
||||||
playNext,
|
playNext,
|
||||||
favorite,
|
favorite,
|
||||||
|
details,
|
||||||
}
|
}
|
||||||
|
|
||||||
class TrackOptions extends HookConsumerWidget {
|
class TrackOptions extends HookConsumerWidget {
|
||||||
@ -163,6 +165,12 @@ class TrackOptions extends HookConsumerWidget {
|
|||||||
case TrackOptionValue.share:
|
case TrackOptionValue.share:
|
||||||
actionShare(context, track);
|
actionShare(context, track);
|
||||||
break;
|
break;
|
||||||
|
case TrackOptionValue.details:
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => TrackDetailsDialog(track: track),
|
||||||
|
);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: const Icon(SpotubeIcons.moreHorizontal),
|
icon: const Icon(SpotubeIcons.moreHorizontal),
|
||||||
@ -199,96 +207,82 @@ class TrackOptions extends HookConsumerWidget {
|
|||||||
LocalTrack => [
|
LocalTrack => [
|
||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: TrackOptionValue.delete,
|
value: TrackOptionValue.delete,
|
||||||
child: ListTile(
|
leading: const Icon(SpotubeIcons.trash),
|
||||||
leading: const Icon(SpotubeIcons.trash),
|
title: Text(context.l10n.delete),
|
||||||
title: Text(context.l10n.delete),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
_ => [
|
_ => [
|
||||||
if (!playlist.containsTrack(track)) ...[
|
if (!playlist.containsTrack(track)) ...[
|
||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: TrackOptionValue.addToQueue,
|
value: TrackOptionValue.addToQueue,
|
||||||
child: ListTile(
|
leading: const Icon(SpotubeIcons.queueAdd),
|
||||||
leading: const Icon(SpotubeIcons.queueAdd),
|
title: Text(context.l10n.add_to_queue),
|
||||||
title: Text(context.l10n.add_to_queue),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: TrackOptionValue.playNext,
|
value: TrackOptionValue.playNext,
|
||||||
child: ListTile(
|
leading: const Icon(SpotubeIcons.lightning),
|
||||||
leading: const Icon(SpotubeIcons.lightning),
|
title: Text(context.l10n.play_next),
|
||||||
title: Text(context.l10n.play_next),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
] else
|
] else
|
||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: TrackOptionValue.removeFromQueue,
|
value: TrackOptionValue.removeFromQueue,
|
||||||
enabled: playlist.activeTrack?.id != track.id,
|
enabled: playlist.activeTrack?.id != track.id,
|
||||||
child: ListTile(
|
leading: const Icon(SpotubeIcons.queueRemove),
|
||||||
enabled: playlist.activeTrack?.id != track.id,
|
title: Text(context.l10n.remove_from_queue),
|
||||||
leading: const Icon(SpotubeIcons.queueRemove),
|
|
||||||
title: Text(context.l10n.remove_from_queue),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (favorites.me.hasData)
|
if (favorites.me.hasData)
|
||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: TrackOptionValue.favorite,
|
value: TrackOptionValue.favorite,
|
||||||
child: ListTile(
|
leading: favorites.isLiked
|
||||||
leading: favorites.isLiked
|
? const Icon(
|
||||||
? const Icon(
|
SpotubeIcons.heartFilled,
|
||||||
SpotubeIcons.heartFilled,
|
color: Colors.pink,
|
||||||
color: Colors.pink,
|
)
|
||||||
)
|
: const Icon(SpotubeIcons.heart),
|
||||||
: const Icon(SpotubeIcons.heart),
|
title: Text(
|
||||||
title: Text(
|
favorites.isLiked
|
||||||
favorites.isLiked
|
? context.l10n.remove_from_favorites
|
||||||
? context.l10n.remove_from_favorites
|
: context.l10n.save_as_favorite,
|
||||||
: context.l10n.save_as_favorite,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (auth != null)
|
if (auth != null)
|
||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: TrackOptionValue.addToPlaylist,
|
value: TrackOptionValue.addToPlaylist,
|
||||||
child: ListTile(
|
leading: const Icon(SpotubeIcons.playlistAdd),
|
||||||
leading: const Icon(SpotubeIcons.playlistAdd),
|
title: Text(context.l10n.add_to_playlist),
|
||||||
title: Text(context.l10n.add_to_playlist),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (userPlaylist && auth != null)
|
if (userPlaylist && auth != null)
|
||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: TrackOptionValue.removeFromPlaylist,
|
value: TrackOptionValue.removeFromPlaylist,
|
||||||
child: ListTile(
|
leading: (removeTrack.isMutating || !removeTrack.hasData) &&
|
||||||
leading: (removeTrack.isMutating || !removeTrack.hasData) &&
|
removingTrack.value == track.uri
|
||||||
removingTrack.value == track.uri
|
? const Center(
|
||||||
? const Center(
|
child: CircularProgressIndicator(),
|
||||||
child: CircularProgressIndicator(),
|
)
|
||||||
)
|
: const Icon(SpotubeIcons.removeFilled),
|
||||||
: const Icon(SpotubeIcons.removeFilled),
|
title: Text(context.l10n.remove_from_playlist),
|
||||||
title: Text(context.l10n.remove_from_playlist),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: TrackOptionValue.blacklist,
|
value: TrackOptionValue.blacklist,
|
||||||
child: ListTile(
|
leading: const Icon(SpotubeIcons.playlistRemove),
|
||||||
leading: const Icon(SpotubeIcons.playlistRemove),
|
iconColor: !isBlackListed ? Colors.red[400] : null,
|
||||||
iconColor: !isBlackListed ? Colors.red[400] : null,
|
textColor: !isBlackListed ? Colors.red[400] : null,
|
||||||
textColor: !isBlackListed ? Colors.red[400] : null,
|
title: Text(
|
||||||
title: Text(
|
isBlackListed
|
||||||
isBlackListed
|
? context.l10n.remove_from_blacklist
|
||||||
? context.l10n.remove_from_blacklist
|
: context.l10n.add_to_blacklist,
|
||||||
: context.l10n.add_to_blacklist,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
PopSheetEntry(
|
PopSheetEntry(
|
||||||
value: TrackOptionValue.share,
|
value: TrackOptionValue.share,
|
||||||
child: ListTile(
|
leading: const Icon(SpotubeIcons.share),
|
||||||
leading: const Icon(SpotubeIcons.share),
|
title: Text(context.l10n.share),
|
||||||
title: Text(context.l10n.share),
|
),
|
||||||
),
|
PopSheetEntry(
|
||||||
)
|
value: TrackOptionValue.details,
|
||||||
|
leading: const Icon(SpotubeIcons.info),
|
||||||
|
title: Text(context.l10n.details),
|
||||||
|
),
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@ -61,7 +61,7 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
|
|
||||||
return LayoutBuilder(builder: (context, constrains) {
|
return LayoutBuilder(builder: (context, constrains) {
|
||||||
return HoverBuilder(
|
return HoverBuilder(
|
||||||
permanentState: isPlaying || constrains.isSm ? true : null,
|
permanentState: isPlaying || constrains.smAndDown ? true : null,
|
||||||
builder: (context, isHovering) {
|
builder: (context, isHovering) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
selected: isPlaying,
|
selected: isPlaying,
|
||||||
@ -89,7 +89,7 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (constrains.isSm)
|
else if (constrains.smAndDown)
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
if (onChanged != null)
|
if (onChanged != null)
|
||||||
Checkbox.adaptive(
|
Checkbox.adaptive(
|
||||||
@ -100,10 +100,14 @@ class TrackTile extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
child: UniversalImage(
|
child: AspectRatio(
|
||||||
path: TypeConversionUtils.image_X_UrlString(
|
aspectRatio: 1,
|
||||||
track.album?.images,
|
child: UniversalImage(
|
||||||
placeholder: ImagePlaceholder.albumArt,
|
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!,
|
track.album!.name!,
|
||||||
"/album/${track.album?.id}",
|
"/album/${track.album?.id}",
|
||||||
extra: track.album,
|
extra: track.album,
|
||||||
|
push: true,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
@ -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/adaptive/adaptive_pop_sheet_list.dart';
|
||||||
import 'package:spotube/components/shared/dialogs/confirm_download_dialog.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/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/fallbacks/not_found.dart';
|
||||||
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
|
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
|
||||||
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
||||||
@ -31,10 +33,14 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
final bool isSliver;
|
final bool isSliver;
|
||||||
|
|
||||||
final Widget? heading;
|
final Widget? heading;
|
||||||
|
|
||||||
|
final VoidCallback? onFiltering;
|
||||||
|
|
||||||
const TracksTableView(
|
const TracksTableView(
|
||||||
this.tracks, {
|
this.tracks, {
|
||||||
Key? key,
|
Key? key,
|
||||||
this.onTrackPlayButtonPressed,
|
this.onTrackPlayButtonPressed,
|
||||||
|
this.onFiltering,
|
||||||
this.userPlaylist = false,
|
this.userPlaylist = false,
|
||||||
this.playlistId,
|
this.playlistId,
|
||||||
this.heading,
|
this.heading,
|
||||||
@ -43,7 +49,9 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(context, ref) {
|
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);
|
final playback = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||||
ref.watch(downloadManagerProvider);
|
ref.watch(downloadManagerProvider);
|
||||||
final downloader = ref.watch(downloadManagerProvider.notifier);
|
final downloader = ref.watch(downloadManagerProvider.notifier);
|
||||||
@ -54,11 +62,31 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
final showCheck = useState<bool>(false);
|
final showCheck = useState<bool>(false);
|
||||||
final sortBy = ref.watch(trackCollectionSortState(playlistId ?? ''));
|
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(
|
final sortedTracks = useMemoized(
|
||||||
() {
|
() {
|
||||||
return ServiceUtils.sortTracks(tracks, sortBy);
|
return ServiceUtils.sortTracks(filteredTracks, sortBy);
|
||||||
},
|
},
|
||||||
[tracks, sortBy],
|
[filteredTracks, sortBy],
|
||||||
);
|
);
|
||||||
|
|
||||||
final selectedTracks = useMemoized(
|
final selectedTracks = useMemoized(
|
||||||
@ -68,7 +96,7 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
[sortedTracks],
|
[sortedTracks],
|
||||||
);
|
);
|
||||||
|
|
||||||
final children = sortedTracks.isEmpty
|
final children = tracks.isEmpty
|
||||||
? [const NotFound(vertical: true)]
|
? [const NotFound(vertical: true)]
|
||||||
: [
|
: [
|
||||||
if (heading != null) heading!,
|
if (heading != null) heading!,
|
||||||
@ -105,7 +133,7 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
: const SizedBox(width: 16),
|
: const SizedBox(width: 16),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 5,
|
flex: 7,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
@ -139,6 +167,15 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
.state = value;
|
.state = value;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
ExpandableSearchButton(
|
||||||
|
isFiltering: isFiltering,
|
||||||
|
searchFocus: searchFocus,
|
||||||
|
onPressed: (value) {
|
||||||
|
if (isFiltering.value) {
|
||||||
|
onFiltering?.call();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
AdaptivePopSheetList(
|
AdaptivePopSheetList(
|
||||||
tooltip: context.l10n.more_actions,
|
tooltip: context.l10n.more_actions,
|
||||||
headings: [
|
headings: [
|
||||||
@ -147,55 +184,6 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
style: tableHeadStyle,
|
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 {
|
onSelected: (action) async {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "download":
|
case "download":
|
||||||
@ -230,6 +218,9 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
case "play-next":
|
case "play-next":
|
||||||
{
|
{
|
||||||
playback.addTracksAtFirst(selectedTracks);
|
playback.addTracksAtFirst(selectedTracks);
|
||||||
|
if (playlistId != null) {
|
||||||
|
playback.addCollection(playlistId!);
|
||||||
|
}
|
||||||
selected.value = [];
|
selected.value = [];
|
||||||
showCheck.value = false;
|
showCheck.value = false;
|
||||||
break;
|
break;
|
||||||
@ -237,6 +228,9 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
case "add-to-queue":
|
case "add-to-queue":
|
||||||
{
|
{
|
||||||
playback.addTracks(selectedTracks);
|
playback.addTracks(selectedTracks);
|
||||||
|
if (playlistId != null) {
|
||||||
|
playback.addCollection(playlistId!);
|
||||||
|
}
|
||||||
selected.value = [];
|
selected.value = [];
|
||||||
showCheck.value = false;
|
showCheck.value = false;
|
||||||
break;
|
break;
|
||||||
@ -245,11 +239,53 @@ class TracksTableView extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
icon: const Icon(SpotubeIcons.moreVertical),
|
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),
|
const SizedBox(width: 10),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
ExpandableSearchField(
|
||||||
|
isFiltering: isFiltering,
|
||||||
|
searchController: searchController,
|
||||||
|
searchFocus: searchFocus,
|
||||||
|
),
|
||||||
...sortedTracks.mapIndexed((i, track) {
|
...sortedTracks.mapIndexed((i, track) {
|
||||||
return TrackTile(
|
return TrackTile(
|
||||||
index: i,
|
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) {
|
if (isSliver) {
|
||||||
return SliverSafeArea(
|
return SliverSafeArea(
|
||||||
|
top: false,
|
||||||
sliver: SliverList(delegate: SliverChildListDelegate(children)),
|
sliver: SliverList(delegate: SliverChildListDelegate(children)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,25 +1,39 @@
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
extension ContainerBreakpoints on BoxConstraints {
|
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 isMd => biggest.width > 640 && biggest.width <= 768;
|
||||||
bool get isLg => biggest.width > 768 && biggest.width <= 1024;
|
bool get isLg => biggest.width > 768 && biggest.width <= 1024;
|
||||||
bool get isXl => biggest.width > 1024 && biggest.width <= 1280;
|
bool get isXl => biggest.width > 1024 && biggest.width <= 1280;
|
||||||
bool get is2Xl => 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 mdAndUp => isMd || isLg || isXl || is2Xl;
|
||||||
bool get lgAndUp => isLg || isXl || is2Xl;
|
bool get lgAndUp => isLg || isXl || is2Xl;
|
||||||
bool get xlAndUp => 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 {
|
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 isMd => size.width > 640 && size.width <= 768;
|
||||||
bool get isLg => size.width > 768 && size.width <= 1024;
|
bool get isLg => size.width > 768 && size.width <= 1024;
|
||||||
bool get isXl => size.width > 1024 && size.width <= 1280;
|
bool get isXl => size.width > 1024 && size.width <= 1280;
|
||||||
bool get is2Xl => 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 mdAndUp => isMd || isLg || isXl || is2Xl;
|
||||||
bool get lgAndUp => isLg || isXl || is2Xl;
|
bool get lgAndUp => isLg || isXl || is2Xl;
|
||||||
bool get xlAndUp => 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,28 @@
|
|||||||
|
import 'package:duration/locale.dart';
|
||||||
import 'package:spotube/utils/primitive_utils.dart';
|
import 'package:spotube/utils/primitive_utils.dart';
|
||||||
|
import 'package:duration/duration.dart';
|
||||||
|
|
||||||
extension DurationToHumanReadableString on Duration {
|
extension DurationToHumanReadableString on Duration {
|
||||||
toHumanReadableString() =>
|
String toHumanReadableString() =>
|
||||||
"${inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(inSeconds.remainder(60))}";
|
"${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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
import 'package:spotube/extensions/constrains.dart';
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
|
|
||||||
T useBreakpointValue<T>({
|
T useBreakpointValue<T>({
|
||||||
|
T? xs,
|
||||||
T? sm,
|
T? sm,
|
||||||
T? md,
|
T? md,
|
||||||
T? lg,
|
T? lg,
|
||||||
@ -10,8 +11,12 @@ T useBreakpointValue<T>({
|
|||||||
T? xxl,
|
T? xxl,
|
||||||
T? others,
|
T? others,
|
||||||
}) {
|
}) {
|
||||||
final isSomeNull =
|
final isSomeNull = xs == null ||
|
||||||
sm == null || md == null || lg == null || xl == null || xxl == null;
|
sm == null ||
|
||||||
|
md == null ||
|
||||||
|
lg == null ||
|
||||||
|
xl == null ||
|
||||||
|
xxl == null;
|
||||||
assert(
|
assert(
|
||||||
(isSomeNull && others != null) || (!isSomeNull && others == null),
|
(isSomeNull && others != null) || (!isSomeNull && others == null),
|
||||||
'You must provide a value for all breakpoints or a default value for others',
|
'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);
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
|
||||||
if (isSomeNull) {
|
if (isSomeNull) {
|
||||||
if (mediaQuery.isSm) {
|
if (mediaQuery.isXs) {
|
||||||
|
return xs ?? others!;
|
||||||
|
} else if (mediaQuery.isSm) {
|
||||||
return sm ?? others!;
|
return sm ?? others!;
|
||||||
} else if (mediaQuery.isMd) {
|
} else if (mediaQuery.isMd) {
|
||||||
return md ?? others!;
|
return md ?? others!;
|
||||||
@ -32,7 +39,9 @@ T useBreakpointValue<T>({
|
|||||||
return lg ?? others!;
|
return lg ?? others!;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (mediaQuery.isSm) {
|
if (mediaQuery.isXs) {
|
||||||
|
return xs;
|
||||||
|
} else if (mediaQuery.isSm) {
|
||||||
return sm;
|
return sm;
|
||||||
} else if (mediaQuery.isMd) {
|
} else if (mediaQuery.isMd) {
|
||||||
return md;
|
return md;
|
||||||
|
|||||||
@ -37,7 +37,6 @@
|
|||||||
"none": "কোনটিই না",
|
"none": "কোনটিই না",
|
||||||
"sort_a_z": "A-Z ক্রমে সাজান",
|
"sort_a_z": "A-Z ক্রমে সাজান",
|
||||||
"sort_z_a": "Z-A ক্রমে সাজান",
|
"sort_z_a": "Z-A ক্রমে সাজান",
|
||||||
"sort_date": "তারিখের ক্রমে সাজান",
|
|
||||||
"sort_artist": "শিল্পীর ক্রমে সাজান",
|
"sort_artist": "শিল্পীর ক্রমে সাজান",
|
||||||
"sort_album": "অ্যালবামের ক্রমে সাজান",
|
"sort_album": "অ্যালবামের ক্রমে সাজান",
|
||||||
"sort_tracks": "গানের ক্রম",
|
"sort_tracks": "গানের ক্রম",
|
||||||
|
|||||||
@ -37,7 +37,6 @@
|
|||||||
"none": "None",
|
"none": "None",
|
||||||
"sort_a_z": "Sort by A-Z",
|
"sort_a_z": "Sort by A-Z",
|
||||||
"sort_z_a": "Sort by Z-A",
|
"sort_z_a": "Sort by Z-A",
|
||||||
"sort_date": "Sort by date",
|
|
||||||
"sort_artist": "Sort by Artist",
|
"sort_artist": "Sort by Artist",
|
||||||
"sort_album": "Sort by Album",
|
"sort_album": "Sort by Album",
|
||||||
"sort_tracks": "Sort Tracks",
|
"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_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",
|
"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",
|
"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"
|
||||||
}
|
}
|
||||||
@ -37,7 +37,6 @@
|
|||||||
"none": "Aucun",
|
"none": "Aucun",
|
||||||
"sort_a_z": "Trier par ordre alphabétique",
|
"sort_a_z": "Trier par ordre alphabétique",
|
||||||
"sort_z_a": "Trier par ordre alphabétique inverse",
|
"sort_z_a": "Trier par ordre alphabétique inverse",
|
||||||
"sort_date": "Trier par date",
|
|
||||||
"sort_artist": "Trier par artiste",
|
"sort_artist": "Trier par artiste",
|
||||||
"sort_album": "Trier par album",
|
"sort_album": "Trier par album",
|
||||||
"sort_tracks": "Trier les pistes",
|
"sort_tracks": "Trier les pistes",
|
||||||
|
|||||||
@ -37,7 +37,6 @@
|
|||||||
"none": "कोई नहीं",
|
"none": "कोई नहीं",
|
||||||
"sort_a_z": "A-Z सॉर्ट करें",
|
"sort_a_z": "A-Z सॉर्ट करें",
|
||||||
"sort_z_a": "Z-A सॉर्ट करें",
|
"sort_z_a": "Z-A सॉर्ट करें",
|
||||||
"sort_date": "तिथि के अनुसार सॉर्ट करें",
|
|
||||||
"sort_artist": "कलाकार के अनुसार सॉर्ट करें",
|
"sort_artist": "कलाकार के अनुसार सॉर्ट करें",
|
||||||
"sort_album": "एल्बम के अनुसार सॉर्ट करें",
|
"sort_album": "एल्बम के अनुसार सॉर्ट करें",
|
||||||
"sort_tracks": "ट्रैक को सॉर्ट करें",
|
"sort_tracks": "ट्रैक को सॉर्ट करें",
|
||||||
|
|||||||
250
lib/l10n/app_ja.arb
Normal file
250
lib/l10n/app_ja.arb
Normal 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": "開発"
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@
|
|||||||
///
|
///
|
||||||
/// Kingkor Roy Tirtho => English, Bengali
|
/// Kingkor Roy Tirtho => English, Bengali
|
||||||
/// ChatGPT (GPT 3.5) XD => Hindi, French
|
/// ChatGPT (GPT 3.5) XD => Hindi, French
|
||||||
|
/// maboroshin@github => Japanese
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class L10n {
|
class L10n {
|
||||||
@ -11,5 +12,6 @@ class L10n {
|
|||||||
const Locale('fr', 'FR'),
|
const Locale('fr', 'FR'),
|
||||||
const Locale('hi', 'IN'),
|
const Locale('hi', 'IN'),
|
||||||
const Locale ('ge', 'DE'),
|
const Locale ('ge', 'DE'),
|
||||||
|
const Locale('ja', 'JA'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,6 @@ import 'package:spotube/provider/palette_provider.dart';
|
|||||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||||
import 'package:spotube/themes/theme.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:spotube/utils/persisted_state_notifier.dart';
|
||||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||||
import 'package:system_theme/system_theme.dart';
|
import 'package:system_theme/system_theme.dart';
|
||||||
@ -122,23 +121,18 @@ Future<void> main(List<String> rawArgs) async {
|
|||||||
releaseConfig: CatcherOptions(
|
releaseConfig: CatcherOptions(
|
||||||
SilentReportMode(),
|
SilentReportMode(),
|
||||||
[
|
[
|
||||||
if (arguments["verbose"] ?? false)
|
if (arguments["verbose"] ?? false) ConsoleHandler(),
|
||||||
ConsoleHandler(
|
|
||||||
enableDeviceParameters: false,
|
|
||||||
enableApplicationParameters: false,
|
|
||||||
),
|
|
||||||
FileHandler(
|
FileHandler(
|
||||||
await getLogsPath(),
|
await getLogsPath(),
|
||||||
printLogs: false,
|
printLogs: false,
|
||||||
),
|
),
|
||||||
CustomToastHandler(),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
runAppFunction: () {
|
runAppFunction: () {
|
||||||
runApp(
|
runApp(
|
||||||
DevicePreview(
|
DevicePreview(
|
||||||
availableLocales: L10n.all,
|
availableLocales: L10n.all,
|
||||||
enabled: !kReleaseMode,
|
enabled: !kReleaseMode && DesktopTools.platform.isDesktop,
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
return ProviderScope(
|
return ProviderScope(
|
||||||
child: QueryClientProvider(
|
child: QueryClientProvider(
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
import 'package:spotube/components/shared/heart_button.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/components/shared/track_table/tracks_table_view.dart';
|
||||||
import 'package:spotube/extensions/constrains.dart';
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||||
@ -32,6 +32,7 @@ class AlbumPage extends HookConsumerWidget {
|
|||||||
sortedTracks,
|
sortedTracks,
|
||||||
initialIndex: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
|
initialIndex: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||||
);
|
);
|
||||||
|
playback.addCollection(album.id!);
|
||||||
} else if (isPlaylistPlaying &&
|
} else if (isPlaylistPlaying &&
|
||||||
currentTrack.id != null &&
|
currentTrack.id != null &&
|
||||||
currentTrack.id != playlist.activeTrack?.id) {
|
currentTrack.id != playlist.activeTrack?.id) {
|
||||||
@ -67,7 +68,7 @@ class AlbumPage extends HookConsumerWidget {
|
|||||||
tracksSnapshot: tracksSnapshot,
|
tracksSnapshot: tracksSnapshot,
|
||||||
album: album,
|
album: album,
|
||||||
routePath: "/album/${album.id}",
|
routePath: "/album/${album.id}",
|
||||||
bottomSpace: mediaQuery.isSm || mediaQuery.isMd,
|
bottomSpace: mediaQuery.mdAndDown,
|
||||||
onPlay: ([track]) {
|
onPlay: ([track]) {
|
||||||
if (tracksSnapshot.hasData) {
|
if (tracksSnapshot.hasData) {
|
||||||
if (!isAlbumPlaying) {
|
if (!isAlbumPlaying) {
|
||||||
@ -101,6 +102,7 @@ class AlbumPage extends HookConsumerWidget {
|
|||||||
TypeConversionUtils.simpleTrack_X_Track(track, album))
|
TypeConversionUtils.simpleTrack_X_Track(track, album))
|
||||||
.toList(),
|
.toList(),
|
||||||
);
|
);
|
||||||
|
playback.addCollection(album.id!);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShare: () {
|
onShare: () {
|
||||||
|
|||||||
@ -39,6 +39,7 @@ class ArtistPage extends HookConsumerWidget {
|
|||||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||||
final textTheme = theme.textTheme;
|
final textTheme = theme.textTheme;
|
||||||
final chipTextVariant = useBreakpointValue(
|
final chipTextVariant = useBreakpointValue(
|
||||||
|
xs: textTheme.bodySmall,
|
||||||
sm: textTheme.bodySmall,
|
sm: textTheme.bodySmall,
|
||||||
md: textTheme.bodyMedium,
|
md: textTheme.bodyMedium,
|
||||||
lg: textTheme.bodyLarge,
|
lg: textTheme.bodyLarge,
|
||||||
@ -49,6 +50,7 @@ class ArtistPage extends HookConsumerWidget {
|
|||||||
final mediaQuery = MediaQuery.of(context);
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
|
||||||
final avatarWidth = useBreakpointValue(
|
final avatarWidth = useBreakpointValue(
|
||||||
|
xs: mediaQuery.size.width * 0.50,
|
||||||
sm: mediaQuery.size.width * 0.50,
|
sm: mediaQuery.size.width * 0.50,
|
||||||
md: mediaQuery.size.width * 0.40,
|
md: mediaQuery.size.width * 0.40,
|
||||||
lg: mediaQuery.size.width * 0.18,
|
lg: mediaQuery.size.width * 0.18,
|
||||||
@ -155,7 +157,7 @@ class ArtistPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
data.name!,
|
data.name!,
|
||||||
style: mediaQuery.isSm
|
style: mediaQuery.smAndDown
|
||||||
? textTheme.headlineSmall
|
? textTheme.headlineSmall
|
||||||
: textTheme.headlineMedium,
|
: textTheme.headlineMedium,
|
||||||
),
|
),
|
||||||
@ -166,8 +168,9 @@ class ArtistPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
style: textTheme.bodyMedium?.copyWith(
|
style: textTheme.bodyMedium?.copyWith(
|
||||||
fontWeight:
|
fontWeight: mediaQuery.mdAndUp
|
||||||
mediaQuery.isSm ? null : FontWeight.bold,
|
? FontWeight.bold
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|||||||
@ -35,7 +35,7 @@ class DesktopLoginPage extends HookConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Assets.spotubeLogoPng.image(
|
Assets.spotubeLogoPng.image(
|
||||||
width: MediaQuery.of(context).size.width *
|
width: MediaQuery.of(context).size.width *
|
||||||
(mediaQuery.isSm || mediaQuery.isMd ? .5 : .3),
|
(mediaQuery.mdAndDown ? .5 : .3),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
context.l10n.add_spotify_credentials,
|
context.l10n.add_spotify_credentials,
|
||||||
|
|||||||
@ -54,7 +54,7 @@ class LoginTutorial extends ConsumerWidget {
|
|||||||
overrideDone: FilledButton(
|
overrideDone: FilledButton(
|
||||||
onPressed: authenticationNotifier.isLoggedIn
|
onPressed: authenticationNotifier.isLoggedIn
|
||||||
? () {
|
? () {
|
||||||
ServiceUtils.navigate(context, "/");
|
ServiceUtils.push(context, "/");
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
child: Center(child: Text(context.l10n.done)),
|
child: Center(child: Text(context.l10n.done)),
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotify/spotify.dart';
|
import 'package:spotify/spotify.dart';
|
||||||
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/components/genre/category_card.dart';
|
import 'package:spotube/components/genre/category_card.dart';
|
||||||
import 'package:spotube/components/shared/compact_search.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/shimmers/shimmer_categories.dart';
|
||||||
import 'package:spotube/components/shared/waypoint.dart';
|
import 'package:spotube/components/shared/waypoint.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
@ -18,15 +21,21 @@ class GenrePage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
final scrollController = useScrollController();
|
final scrollController = useScrollController();
|
||||||
final recommendationMarket = ref.watch(
|
final recommendationMarket = ref.watch(
|
||||||
userPreferencesProvider.select((s) => s.recommendationMarket),
|
userPreferencesProvider.select((s) => s.recommendationMarket),
|
||||||
);
|
);
|
||||||
final categoriesQuery = useQueries.category.list(ref, recommendationMarket);
|
final categoriesQuery = useQueries.category.list(ref, recommendationMarket);
|
||||||
|
final isFiltering = useState(false);
|
||||||
|
|
||||||
final isMounted = useIsMounted();
|
final isMounted = useIsMounted();
|
||||||
|
|
||||||
final searchText = useState("");
|
final searchController = useTextEditingController();
|
||||||
|
final searchFocus = useFocusNode();
|
||||||
|
|
||||||
|
useValueListenable(searchController);
|
||||||
|
|
||||||
final categories = useMemoized(
|
final categories = useMemoized(
|
||||||
() {
|
() {
|
||||||
final categories = categoriesQuery.pages
|
final categories = categoriesQuery.pages
|
||||||
@ -34,12 +43,12 @@ class GenrePage extends HookConsumerWidget {
|
|||||||
(page) => page.items ?? const Iterable.empty(),
|
(page) => page.items ?? const Iterable.empty(),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
if (searchText.value.isEmpty) {
|
if (searchController.text.isEmpty) {
|
||||||
return categories;
|
return categories;
|
||||||
}
|
}
|
||||||
return categories
|
return categories
|
||||||
.map((e) => (
|
.map((e) => (
|
||||||
weightedRatio(e.name!, searchText.value),
|
weightedRatio(e.name!, searchController.text),
|
||||||
e,
|
e,
|
||||||
))
|
))
|
||||||
.sorted((a, b) => b.$1.compareTo(a.$1))
|
.sorted((a, b) => b.$1.compareTo(a.$1))
|
||||||
@ -47,14 +56,7 @@ class GenrePage extends HookConsumerWidget {
|
|||||||
.map((e) => e.$2)
|
.map((e) => e.$2)
|
||||||
.toList();
|
.toList();
|
||||||
},
|
},
|
||||||
[categoriesQuery.pages, searchText.value],
|
[categoriesQuery.pages, searchController.text],
|
||||||
);
|
|
||||||
|
|
||||||
final searchbar = CompactSearch(
|
|
||||||
onChanged: (value) {
|
|
||||||
searchText.value = value;
|
|
||||||
},
|
|
||||||
placeholder: context.l10n.genre_categories_filter,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final list = RefreshIndicator(
|
final list = RefreshIndicator(
|
||||||
@ -68,22 +70,32 @@ class GenrePage extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
child: ListView.builder(
|
child: Column(
|
||||||
controller: scrollController,
|
children: [
|
||||||
itemCount: categories.length,
|
ExpandableSearchField(
|
||||||
shrinkWrap: true,
|
isFiltering: isFiltering,
|
||||||
itemBuilder: (context, index) {
|
searchController: searchController,
|
||||||
return AnimatedCrossFade(
|
searchFocus: searchFocus,
|
||||||
crossFadeState: searchText.value.isEmpty &&
|
),
|
||||||
index == categories.length - 1 &&
|
Expanded(
|
||||||
categoriesQuery.hasNextPage
|
child: ListView.builder(
|
||||||
? CrossFadeState.showFirst
|
controller: scrollController,
|
||||||
: CrossFadeState.showSecond,
|
itemCount: categories.length,
|
||||||
duration: const Duration(milliseconds: 300),
|
itemBuilder: (context, index) {
|
||||||
firstChild: const ShimmerCategories(),
|
return AnimatedCrossFade(
|
||||||
secondChild: CategoryCard(categories[index]),
|
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(
|
Positioned(
|
||||||
top: 0,
|
top: 0,
|
||||||
right: 10,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'
|
|||||||
import 'package:spotube/components/shared/waypoint.dart';
|
import 'package:spotube/components/shared/waypoint.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/models/logger.dart';
|
import 'package:spotube/models/logger.dart';
|
||||||
|
import 'package:spotube/provider/authentication_provider.dart';
|
||||||
import 'package:spotube/services/queries/queries.dart';
|
import 'package:spotube/services/queries/queries.dart';
|
||||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||||
|
|
||||||
@ -94,6 +95,7 @@ class PersonalizedPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, ref) {
|
Widget build(BuildContext context, ref) {
|
||||||
|
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||||
final featuredPlaylistsQuery = useQueries.playlist.featured(ref);
|
final featuredPlaylistsQuery = useQueries.playlist.featured(ref);
|
||||||
final playlists = useMemoized(
|
final playlists = useMemoized(
|
||||||
() => featuredPlaylistsQuery.pages
|
() => featuredPlaylistsQuery.pages
|
||||||
@ -132,12 +134,13 @@ class PersonalizedPage extends HookConsumerWidget {
|
|||||||
hasNextPage: featuredPlaylistsQuery.hasNextPage,
|
hasNextPage: featuredPlaylistsQuery.hasNextPage,
|
||||||
onFetchMore: featuredPlaylistsQuery.fetchNext,
|
onFetchMore: featuredPlaylistsQuery.fetchNext,
|
||||||
),
|
),
|
||||||
PersonalizedItemCard(
|
if (auth != null)
|
||||||
albums: albums,
|
PersonalizedItemCard(
|
||||||
title: context.l10n.new_releases,
|
albums: albums,
|
||||||
hasNextPage: newReleases.hasNextPage,
|
title: context.l10n.new_releases,
|
||||||
onFetchMore: newReleases.fetchNext,
|
hasNextPage: newReleases.hasNextPage,
|
||||||
),
|
onFetchMore: newReleases.fetchNext,
|
||||||
|
),
|
||||||
...?madeForUser.data?["content"]?["items"]?.map((item) {
|
...?madeForUser.data?["content"]?["items"]?.map((item) {
|
||||||
final playlists = item["content"]?["items"]
|
final playlists = item["content"]?["items"]
|
||||||
?.where((itemL2) => itemL2["type"] == "playlist")
|
?.where((itemL2) => itemL2["type"] == "playlist")
|
||||||
|
|||||||
@ -248,251 +248,256 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
|
|||||||
title: Text(context.l10n.generate_playlist),
|
title: Text(context.l10n.generate_playlist),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SliderTheme(
|
||||||
child: LayoutBuilder(builder: (context, constrains) {
|
data: const SliderThemeData(
|
||||||
return ListView(
|
overlayShape: RoundSliderOverlayShape(),
|
||||||
padding: const EdgeInsets.all(16),
|
),
|
||||||
children: [
|
child: SafeArea(
|
||||||
ValueListenableBuilder(
|
child: LayoutBuilder(builder: (context, constrains) {
|
||||||
valueListenable: limit,
|
return ListView(
|
||||||
builder: (context, value, child) {
|
padding: const EdgeInsets.all(16),
|
||||||
return Column(
|
children: [
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
ValueListenableBuilder(
|
||||||
children: [
|
valueListenable: limit,
|
||||||
Text(
|
builder: (context, value, child) {
|
||||||
context.l10n.number_of_tracks_generate,
|
return Column(
|
||||||
style: textTheme.titleMedium,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
),
|
children: [
|
||||||
Row(
|
Text(
|
||||||
children: [
|
context.l10n.number_of_tracks_generate,
|
||||||
Container(
|
style: textTheme.titleMedium,
|
||||||
width: 40,
|
),
|
||||||
height: 40,
|
Row(
|
||||||
alignment: Alignment.center,
|
children: [
|
||||||
decoration: BoxDecoration(
|
Container(
|
||||||
color: theme.colorScheme.primary,
|
width: 40,
|
||||||
shape: BoxShape.circle,
|
height: 40,
|
||||||
),
|
alignment: Alignment.center,
|
||||||
child: Text(
|
decoration: BoxDecoration(
|
||||||
value.round().toString(),
|
color: theme.colorScheme.primary,
|
||||||
style: textTheme.bodyLarge?.copyWith(
|
shape: BoxShape.circle,
|
||||||
color: theme.colorScheme.primaryContainer,
|
),
|
||||||
|
child: Text(
|
||||||
|
value.round().toString(),
|
||||||
|
style: textTheme.bodyLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.primaryContainer,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Expanded(
|
||||||
Expanded(
|
child: Slider.adaptive(
|
||||||
child: Slider.adaptive(
|
value: value.toDouble(),
|
||||||
value: value.toDouble(),
|
min: 10,
|
||||||
min: 10,
|
max: 100,
|
||||||
max: 100,
|
divisions: 9,
|
||||||
divisions: 9,
|
label: value.round().toString(),
|
||||||
label: value.round().toString(),
|
onChanged: (value) {
|
||||||
onChanged: (value) {
|
limit.value = value.round();
|
||||||
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,
|
|
||||||
),
|
),
|
||||||
onChanged: (value) {
|
const SizedBox(height: 16),
|
||||||
durationMs.value = (
|
if (constrains.mdAndUp)
|
||||||
max: value.max * 1000,
|
Row(
|
||||||
target: value.target * 1000,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
min: value.min * 1000,
|
children: [
|
||||||
);
|
Expanded(
|
||||||
},
|
child: countrySelector,
|
||||||
presets: {
|
),
|
||||||
context.l10n.short: (min: 50, target: 90, max: 120),
|
const SizedBox(width: 16),
|
||||||
context.l10n.medium: (min: 120, target: 180, max: 200),
|
Expanded(
|
||||||
context.l10n.long: (min: 480, target: 560, max: 640)
|
child: genreSelector,
|
||||||
},
|
),
|
||||||
),
|
],
|
||||||
RecommendationAttributeFields(
|
)
|
||||||
title: Text(context.l10n.tempo),
|
else ...[
|
||||||
values: tempo.value,
|
countrySelector,
|
||||||
onChanged: (value) {
|
const SizedBox(height: 16),
|
||||||
tempo.value = value;
|
genreSelector,
|
||||||
},
|
],
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
RecommendationAttributeFields(
|
if (constrains.mdAndUp)
|
||||||
title: Text(context.l10n.mode),
|
Row(
|
||||||
values: mode.value,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
onChanged: (value) {
|
children: [
|
||||||
mode.value = value;
|
Expanded(
|
||||||
},
|
child: artistAutoComplete,
|
||||||
),
|
),
|
||||||
RecommendationAttributeFields(
|
const SizedBox(width: 16),
|
||||||
title: Text(context.l10n.time_signature),
|
Expanded(
|
||||||
values: timeSignature.value,
|
child: tracksAutocomplete,
|
||||||
onChanged: (value) {
|
),
|
||||||
timeSignature.value = value;
|
],
|
||||||
},
|
)
|
||||||
),
|
else ...[
|
||||||
const SizedBox(height: 20),
|
artistAutoComplete,
|
||||||
FilledButton.icon(
|
const SizedBox(height: 16),
|
||||||
icon: const Icon(SpotubeIcons.magic),
|
tracksAutocomplete,
|
||||||
label: Text(context.l10n.generate_playlist),
|
],
|
||||||
onPressed: artists.value.isEmpty &&
|
const SizedBox(height: 16),
|
||||||
tracks.value.isEmpty &&
|
RecommendationAttributeDials(
|
||||||
genres.value.isEmpty
|
title: Text(context.l10n.acousticness),
|
||||||
? null
|
values: acousticness.value,
|
||||||
: () {
|
onChanged: (value) {
|
||||||
final PlaylistGenerateResultRouteState routeState = (
|
acousticness.value = value;
|
||||||
seeds: (
|
},
|
||||||
artists: artists.value.map((a) => a.id!).toList(),
|
),
|
||||||
tracks: tracks.value.map((t) => t.id!).toList(),
|
RecommendationAttributeDials(
|
||||||
genres: genres.value
|
title: Text(context.l10n.danceability),
|
||||||
),
|
values: danceability.value,
|
||||||
market: market.value,
|
onChanged: (value) {
|
||||||
limit: limit.value,
|
danceability.value = value;
|
||||||
parameters: (
|
},
|
||||||
acousticness: acousticness.value,
|
),
|
||||||
danceability: danceability.value,
|
RecommendationAttributeDials(
|
||||||
energy: energy.value,
|
title: Text(context.l10n.energy),
|
||||||
instrumentalness: instrumentalness.value,
|
values: energy.value,
|
||||||
liveness: liveness.value,
|
onChanged: (value) {
|
||||||
loudness: loudness.value,
|
energy.value = value;
|
||||||
speechiness: speechiness.value,
|
},
|
||||||
valence: valence.value,
|
),
|
||||||
popularity: popularity.value,
|
RecommendationAttributeDials(
|
||||||
key: key.value,
|
title: Text(context.l10n.instrumentalness),
|
||||||
duration_ms: durationMs.value,
|
values: instrumentalness.value,
|
||||||
tempo: tempo.value,
|
onChanged: (value) {
|
||||||
mode: mode.value,
|
instrumentalness.value = value;
|
||||||
time_signature: timeSignature.value,
|
},
|
||||||
)
|
),
|
||||||
);
|
RecommendationAttributeDials(
|
||||||
GoRouter.of(context).push(
|
title: Text(context.l10n.liveness),
|
||||||
"/library/generate/result",
|
values: liveness.value,
|
||||||
extra: routeState,
|
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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -111,10 +111,22 @@ class SyncedLyrics extends HookConsumerWidget {
|
|||||||
index: index,
|
index: index,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
child: lyricSlice.text.isEmpty
|
child: lyricSlice.text.isEmpty
|
||||||
? Container()
|
? Container(
|
||||||
|
padding: index == lyricValue.lyrics.length - 1
|
||||||
|
? EdgeInsets.only(
|
||||||
|
bottom:
|
||||||
|
MediaQuery.of(context).size.height /
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
)
|
||||||
: Center(
|
: Center(
|
||||||
child: Padding(
|
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(
|
child: AnimatedDefaultTextStyle(
|
||||||
duration: const Duration(milliseconds: 250),
|
duration: const Duration(milliseconds: 250),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
|||||||
@ -9,10 +9,14 @@ import 'package:spotube/collections/assets.gen.dart';
|
|||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/components/player/player_actions.dart';
|
import 'package:spotube/components/player/player_actions.dart';
|
||||||
import 'package:spotube/components/player/player_controls.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/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/page_window_title_bar.dart';
|
||||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||||
import 'package:spotube/extensions/constrains.dart';
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/hooks/use_custom_status_bar_color.dart';
|
import 'package:spotube/hooks/use_custom_status_bar_color.dart';
|
||||||
import 'package:spotube/hooks/use_palette_color.dart';
|
import 'package:spotube/hooks/use_palette_color.dart';
|
||||||
import 'package:spotube/models/local_track.dart';
|
import 'package:spotube/models/local_track.dart';
|
||||||
@ -74,6 +78,24 @@ class PlayerView extends HookConsumerWidget {
|
|||||||
foregroundColor: titleTextColor,
|
foregroundColor: titleTextColor,
|
||||||
toolbarOpacity: 1,
|
toolbarOpacity: 1,
|
||||||
leading: const BackButton(),
|
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,
|
extendBodyBehindAppBar: true,
|
||||||
body: SizedBox(
|
body: SizedBox(
|
||||||
@ -106,29 +128,27 @@ class PlayerView extends HookConsumerWidget {
|
|||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Container(
|
||||||
padding: const EdgeInsets.all(8.0),
|
margin: const EdgeInsets.all(8),
|
||||||
child: Container(
|
constraints: const BoxConstraints(
|
||||||
constraints: const BoxConstraints(
|
maxHeight: 300, maxWidth: 300),
|
||||||
maxHeight: 300, maxWidth: 300),
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
borderRadius: BorderRadius.circular(20),
|
||||||
borderRadius: BorderRadius.circular(20),
|
boxShadow: const [
|
||||||
boxShadow: const [
|
BoxShadow(
|
||||||
BoxShadow(
|
color: Colors.black26,
|
||||||
color: Colors.black26,
|
spreadRadius: 2,
|
||||||
spreadRadius: 2,
|
blurRadius: 10,
|
||||||
blurRadius: 10,
|
offset: Offset(0, 0),
|
||||||
offset: Offset(0, 0),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
child: UniversalImage(
|
|
||||||
path: albumArt,
|
|
||||||
placeholder: Assets.albumPlaceholder.path,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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),
|
const SizedBox(height: 25),
|
||||||
PlayerActions(
|
PlayerActions(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
floatingQueue: false,
|
showQueue: false,
|
||||||
extraActions: [
|
),
|
||||||
|
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)
|
if (auth != null)
|
||||||
IconButton(
|
Expanded(
|
||||||
tooltip: "Open Lyrics",
|
child: OutlinedButton.icon(
|
||||||
icon: const Icon(SpotubeIcons.music),
|
label: Text(context.l10n.lyrics),
|
||||||
onPressed: () {
|
icon: const Icon(SpotubeIcons.music),
|
||||||
showModalBottomSheet(
|
style: OutlinedButton.styleFrom(
|
||||||
context: context,
|
foregroundColor: bodyTextColor,
|
||||||
isDismissible: true,
|
side: BorderSide(
|
||||||
enableDrag: true,
|
color: bodyTextColor ?? Colors.white,
|
||||||
isScrollControlled: true,
|
),
|
||||||
backgroundColor: Colors.black38,
|
),
|
||||||
barrierColor: Colors.black12,
|
onPressed: () {
|
||||||
shape: const RoundedRectangleBorder(
|
showModalBottomSheet(
|
||||||
borderRadius: BorderRadius.only(
|
context: context,
|
||||||
topLeft: Radius.circular(20),
|
isDismissible: true,
|
||||||
topRight: Radius.circular(20),
|
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(
|
||||||
constraints: BoxConstraints(
|
maxHeight: MediaQuery.of(context)
|
||||||
maxHeight:
|
.size
|
||||||
MediaQuery.of(context).size.height *
|
.height *
|
||||||
0.8,
|
0.8,
|
||||||
),
|
),
|
||||||
builder: (context) =>
|
builder: (context) =>
|
||||||
const LyricsPage(isModal: true),
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:spotube/components/shared/heart_button.dart';
|
import 'package:spotube/components/shared/heart_button.dart';
|
||||||
import 'package:spotube/components/shared/track_table/track_collection_view.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/components/shared/track_table/tracks_table_view.dart';
|
||||||
import 'package:spotube/extensions/constrains.dart';
|
import 'package:spotube/extensions/constrains.dart';
|
||||||
import 'package:spotube/models/logger.dart';
|
import 'package:spotube/models/logger.dart';
|
||||||
@ -36,6 +36,7 @@ class PlaylistView extends HookConsumerWidget {
|
|||||||
initialIndex: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
|
initialIndex: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||||
autoPlay: true,
|
autoPlay: true,
|
||||||
);
|
);
|
||||||
|
playback.addCollection(playlist.id!);
|
||||||
} else if (isPlaylistPlaying &&
|
} else if (isPlaylistPlaying &&
|
||||||
currentTrack.id != null &&
|
currentTrack.id != null &&
|
||||||
currentTrack.id != proxyPlaylist.activeTrack?.id) {
|
currentTrack.id != proxyPlaylist.activeTrack?.id) {
|
||||||
@ -97,9 +98,10 @@ class PlaylistView extends HookConsumerWidget {
|
|||||||
onAddToQueue: () {
|
onAddToQueue: () {
|
||||||
if (tracksSnapshot.hasData && !isPlaylistPlaying) {
|
if (tracksSnapshot.hasData && !isPlaylistPlaying) {
|
||||||
playlistNotifier.addTracks(tracksSnapshot.data!);
|
playlistNotifier.addTracks(tracksSnapshot.data!);
|
||||||
|
playlistNotifier.addCollection(playlist.id!);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
bottomSpace: mediaQuery.isSm || mediaQuery.isMd,
|
bottomSpace: mediaQuery.mdAndDown,
|
||||||
showShare: playlist.id != "user-liked-tracks",
|
showShare: playlist.id != "user-liked-tracks",
|
||||||
routePath: "/playlist/${playlist.id}",
|
routePath: "/playlist/${playlist.id}",
|
||||||
onShare: () {
|
onShare: () {
|
||||||
|
|||||||
@ -77,6 +77,7 @@ class SearchPage extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
color: theme.scaffoldBackgroundColor,
|
color: theme.scaffoldBackgroundColor,
|
||||||
child: TextField(
|
child: TextField(
|
||||||
|
autofocus: true,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
prefixIcon: const Icon(SpotubeIcons.search),
|
prefixIcon: const Icon(SpotubeIcons.search),
|
||||||
hintText: "${context.l10n.search}...",
|
hintText: "${context.l10n.search}...",
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:spotube/collections/assets.gen.dart';
|
import 'package:spotube/collections/assets.gen.dart';
|
||||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||||
|
import 'package:spotube/components/shared/links/hyper_link.dart';
|
||||||
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||||
import 'package:spotube/extensions/context.dart';
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/hooks/use_package_info.dart';
|
import 'package:spotube/hooks/use_package_info.dart';
|
||||||
@ -24,6 +25,8 @@ class AboutSpotube extends HookConsumerWidget {
|
|||||||
final license = ref.watch(_licenseProvider);
|
final license = ref.watch(_licenseProvider);
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
final colon = Text(":");
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: PageWindowTitleBar(
|
appBar: PageWindowTitleBar(
|
||||||
leading: const BackButton(),
|
leading: const BackButton(),
|
||||||
@ -40,77 +43,75 @@ class AboutSpotube extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
Center(
|
Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
context.l10n.spotube_description,
|
context.l10n.spotube_description,
|
||||||
style: theme.textTheme.titleLarge,
|
style: theme.textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
Row(
|
Table(
|
||||||
mainAxisSize: MainAxisSize.min,
|
columnWidths: const {
|
||||||
|
0: FixedColumnWidth(95),
|
||||||
|
1: FixedColumnWidth(10),
|
||||||
|
2: IntrinsicColumnWidth(),
|
||||||
|
},
|
||||||
children: [
|
children: [
|
||||||
Text(
|
TableRow(
|
||||||
"${context.l10n.founder}: ${context.l10n.kingkor_roy_tirtho}",
|
children: [
|
||||||
style: const TextStyle(
|
Text(context.l10n.founder),
|
||||||
fontWeight: FontWeight.bold,
|
colon,
|
||||||
),
|
Hyperlink(
|
||||||
|
context.l10n.kingkor_roy_tirtho,
|
||||||
|
"https://github.com/KRTirtho",
|
||||||
|
)
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 5),
|
TableRow(
|
||||||
CircleAvatar(
|
children: [
|
||||||
radius: 20,
|
Text(context.l10n.version),
|
||||||
child: ClipOval(
|
colon,
|
||||||
child: Image.network(
|
Text("v${packageInfo.version}")
|
||||||
"https://avatars.githubusercontent.com/u/61944859?v=4",
|
],
|
||||||
|
),
|
||||||
|
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",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
|
|
||||||
import 'package:spotube/collections/spotube_icons.dart';
|
import 'package:spotube/collections/spotube_icons.dart';
|
||||||
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||||
|
import 'package:spotube/extensions/context.dart';
|
||||||
import 'package:spotube/provider/blacklist_provider.dart';
|
import 'package:spotube/provider/blacklist_provider.dart';
|
||||||
|
|
||||||
class BlackListPage extends HookConsumerWidget {
|
class BlackListPage extends HookConsumerWidget {
|
||||||
@ -38,7 +39,7 @@ class BlackListPage extends HookConsumerWidget {
|
|||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: PageWindowTitleBar(
|
appBar: PageWindowTitleBar(
|
||||||
title: const Text("Blacklist"),
|
title: Text(context.l10n.blacklist),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
leading: const BackButton(),
|
leading: const BackButton(),
|
||||||
),
|
),
|
||||||
@ -49,9 +50,9 @@ class BlackListPage extends HookConsumerWidget {
|
|||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
onChanged: (value) => searchText.value = value,
|
onChanged: (value) => searchText.value = value,
|
||||||
decoration: const InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: "Search",
|
hintText: context.l10n.search,
|
||||||
prefixIcon: Icon(SpotubeIcons.search),
|
prefixIcon: const Icon(SpotubeIcons.search),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
139
lib/pages/settings/logs.dart
Normal file
139
lib/pages/settings/logs.dart
Normal 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(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(
|
SectionCardWithHeading(
|
||||||
heading: context.l10n.about,
|
heading: context.l10n.about,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
// import 'package:background_downloader/background_downloader.dart';
|
import 'package:background_downloader/background_downloader.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:http/http.dart';
|
import 'package:http/http.dart';
|
||||||
@ -19,8 +19,8 @@ import 'package:spotube/utils/type_conversion_utils.dart';
|
|||||||
class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
|
class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
|
||||||
final Ref ref;
|
final Ref ref;
|
||||||
|
|
||||||
final StreamController /* <TaskProgressUpdate> */ activeDownloadProgress;
|
final StreamController<TaskProgressUpdate> activeDownloadProgress;
|
||||||
final StreamController /* <Task> */ failedDownloads;
|
final StreamController<Task> failedDownloads;
|
||||||
Track? _activeItem;
|
Track? _activeItem;
|
||||||
|
|
||||||
FutureOr<bool> Function(Track)? onFileExists;
|
FutureOr<bool> Function(Track)? onFileExists;
|
||||||
@ -29,78 +29,78 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
|
|||||||
: activeDownloadProgress = StreamController.broadcast(),
|
: activeDownloadProgress = StreamController.broadcast(),
|
||||||
failedDownloads = StreamController.broadcast(),
|
failedDownloads = StreamController.broadcast(),
|
||||||
super([]) {
|
super([]) {
|
||||||
// FileDownloader().registerCallbacks(
|
FileDownloader().registerCallbacks(
|
||||||
// group: FileDownloader.defaultGroup,
|
group: FileDownloader.defaultGroup,
|
||||||
// taskNotificationTapCallback: (task, notificationType) {
|
taskNotificationTapCallback: (task, notificationType) {
|
||||||
// router.go("/library");
|
router.go("/library");
|
||||||
// },
|
},
|
||||||
// taskStatusCallback: (update) async {
|
taskStatusCallback: (update) async {
|
||||||
// if (update.status == TaskStatus.running) {
|
if (update.status == TaskStatus.running) {
|
||||||
// _activeItem =
|
_activeItem =
|
||||||
// state.firstWhereOrNull((track) => track.id == update.task.taskId);
|
state.firstWhereOrNull((track) => track.id == update.task.taskId);
|
||||||
// state = state.toList();
|
state = state.toList();
|
||||||
// }
|
}
|
||||||
|
|
||||||
// if (update.status == TaskStatus.failed ||
|
if (update.status == TaskStatus.failed ||
|
||||||
// update.status == TaskStatus.notFound) {
|
update.status == TaskStatus.notFound) {
|
||||||
// failedDownloads.add(update.task);
|
failedDownloads.add(update.task);
|
||||||
// }
|
}
|
||||||
|
|
||||||
// if (update.status == TaskStatus.complete) {
|
if (update.status == TaskStatus.complete) {
|
||||||
// final track =
|
final track =
|
||||||
// state.firstWhere((element) => element.id == update.task.taskId);
|
state.firstWhere((element) => element.id == update.task.taskId);
|
||||||
|
|
||||||
// // resetting the replace downloaded file state on queue completion
|
// resetting the replace downloaded file state on queue completion
|
||||||
// if (state.last == track) {
|
if (state.last == track) {
|
||||||
// ref.read(replaceDownloadedFileState.notifier).state = null;
|
ref.read(replaceDownloadedFileState.notifier).state = null;
|
||||||
// }
|
}
|
||||||
|
|
||||||
// state = state
|
state = state
|
||||||
// .where((element) => element.id != update.task.taskId)
|
.where((element) => element.id != update.task.taskId)
|
||||||
// .toList();
|
.toList();
|
||||||
|
|
||||||
// final imageUri = TypeConversionUtils.image_X_UrlString(
|
final imageUri = TypeConversionUtils.image_X_UrlString(
|
||||||
// track.album?.images ?? [],
|
track.album?.images ?? [],
|
||||||
// placeholder: ImagePlaceholder.online,
|
placeholder: ImagePlaceholder.online,
|
||||||
// );
|
);
|
||||||
// final response = await get(Uri.parse(imageUri));
|
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(
|
await MetadataGod.writeMetadata(
|
||||||
// file: file.path,
|
file: file.path,
|
||||||
// metadata: Metadata(
|
metadata: Metadata(
|
||||||
// title: track.name,
|
title: track.name,
|
||||||
// artist: track.artists?.map((a) => a.name).join(", "),
|
artist: track.artists?.map((a) => a.name).join(", "),
|
||||||
// album: track.album?.name,
|
album: track.album?.name,
|
||||||
// albumArtist: track.artists?.map((a) => a.name).join(", "),
|
albumArtist: track.artists?.map((a) => a.name).join(", "),
|
||||||
// year: track.album?.releaseDate != null
|
year: track.album?.releaseDate != null
|
||||||
// ? int.tryParse(track.album!.releaseDate!)
|
? int.tryParse(track.album!.releaseDate!)
|
||||||
// : null,
|
: null,
|
||||||
// trackNumber: track.trackNumber,
|
trackNumber: track.trackNumber,
|
||||||
// discNumber: track.discNumber,
|
discNumber: track.discNumber,
|
||||||
// durationMs: track.durationMs?.toDouble(),
|
durationMs: track.durationMs?.toDouble(),
|
||||||
// fileSize: file.lengthSync(),
|
fileSize: file.lengthSync(),
|
||||||
// trackTotal: track.album?.tracks?.length,
|
trackTotal: track.album?.tracks?.length,
|
||||||
// picture: response.headers['content-type'] != null
|
picture: response.headers['content-type'] != null
|
||||||
// ? Picture(
|
? Picture(
|
||||||
// data: response.bodyBytes,
|
data: response.bodyBytes,
|
||||||
// mimeType: response.headers['content-type']!,
|
mimeType: response.headers['content-type']!,
|
||||||
// )
|
)
|
||||||
// : null,
|
: null,
|
||||||
// ),
|
),
|
||||||
// );
|
);
|
||||||
// }
|
}
|
||||||
// },
|
},
|
||||||
// taskProgressCallback: (update) {
|
taskProgressCallback: (update) {
|
||||||
// activeDownloadProgress.add(update);
|
activeDownloadProgress.add(update);
|
||||||
// },
|
},
|
||||||
// );
|
);
|
||||||
// FileDownloader().trackTasks(markDownloadedComplete: true);
|
FileDownloader().trackTasks(markDownloadedComplete: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
UserPreferences get preferences => ref.read(userPreferencesProvider);
|
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",
|
"${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)) {
|
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) {
|
if (task != null) {
|
||||||
return task;
|
return task;
|
||||||
}
|
}
|
||||||
@ -133,17 +133,16 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
|
|||||||
pipedClient,
|
pipedClient,
|
||||||
);
|
);
|
||||||
state = [...state, spotubeTrack];
|
state = [...state, spotubeTrack];
|
||||||
// final task = DownloadTask(
|
final task = DownloadTask(
|
||||||
// url: spotubeTrack.ytUri,
|
url: spotubeTrack.ytUri,
|
||||||
// baseDirectory: BaseDirectory.applicationSupport,
|
baseDirectory: BaseDirectory.applicationSupport,
|
||||||
// taskId: spotubeTrack.id!,
|
taskId: spotubeTrack.id!,
|
||||||
// updates: Updates.statusAndProgress,
|
updates: Updates.statusAndProgress,
|
||||||
// );
|
);
|
||||||
// return task;
|
return task;
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future /* <Task?> */ enqueue(Track track) async {
|
Future<Task?> enqueue(Track track) async {
|
||||||
final replaceFileGlobal = ref.read(replaceDownloadedFileState);
|
final replaceFileGlobal = ref.read(replaceDownloadedFileState);
|
||||||
final file = File(_getPathForTrack(track));
|
final file = File(_getPathForTrack(track));
|
||||||
if (file.existsSync() &&
|
if (file.existsSync() &&
|
||||||
@ -156,11 +155,11 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
|
|||||||
|
|
||||||
final task = await _ensureSpotubeTrack(track);
|
final task = await _ensureSpotubeTrack(track);
|
||||||
|
|
||||||
// await FileDownloader().enqueue(task);
|
await FileDownloader().enqueue(task);
|
||||||
return 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) {
|
final tasks = await Future.wait(tracks.mapIndexed((i, e) {
|
||||||
if (i != 0) {
|
if (i != 0) {
|
||||||
/// One second delay between each download to avoid
|
/// One second delay between each download to avoid
|
||||||
@ -174,16 +173,16 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
|
|||||||
ref.read(replaceDownloadedFileState.notifier).state = null;
|
ref.read(replaceDownloadedFileState.notifier).state = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return tasks. /* whereType<Task>(). */ toList();
|
return tasks.whereType<Task>().toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cancel(Track track) async {
|
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();
|
state = state.where((element) => element.id != track.id).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> cancelAll() async {
|
Future<void> cancelAll() async {
|
||||||
// (await FileDownloader().reset());
|
(await FileDownloader().reset());
|
||||||
state = [];
|
state = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,15 +6,20 @@ import 'package:spotube/models/spotube_track.dart';
|
|||||||
|
|
||||||
class ProxyPlaylist {
|
class ProxyPlaylist {
|
||||||
final Set<Track> tracks;
|
final Set<Track> tracks;
|
||||||
|
final Set<String> collections;
|
||||||
final int? active;
|
final int? active;
|
||||||
|
|
||||||
ProxyPlaylist(this.tracks, [this.active]);
|
ProxyPlaylist(this.tracks, [this.active, this.collections = const {}]);
|
||||||
|
|
||||||
factory ProxyPlaylist.fromJson(Map<String, dynamic> json) {
|
factory ProxyPlaylist.fromJson(Map<String, dynamic> json) {
|
||||||
return ProxyPlaylist(
|
return ProxyPlaylist(
|
||||||
List.castFrom<dynamic, Map<String, dynamic>>(
|
List.castFrom<dynamic, Map<String, dynamic>>(
|
||||||
json['tracks'] ?? <Map<String, dynamic>>[],
|
json['tracks'] ?? <Map<String, dynamic>>[],
|
||||||
).map(_makeAppropriateTrack).toSet(),
|
).map(_makeAppropriateTrack).toSet(),
|
||||||
json['active'] as int?,
|
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! SpotubeTrack &&
|
||||||
activeTrack is! LocalTrack;
|
activeTrack is! LocalTrack;
|
||||||
|
|
||||||
|
bool containsCollection(String collection) {
|
||||||
|
return collections.contains(collection);
|
||||||
|
}
|
||||||
|
|
||||||
bool containsTrack(TrackSimple track) {
|
bool containsTrack(TrackSimple track) {
|
||||||
return tracks.firstWhereOrNull((element) => element.id == track.id) != null;
|
return tracks.firstWhereOrNull((element) => element.id == track.id) != null;
|
||||||
}
|
}
|
||||||
@ -57,16 +66,19 @@ class ProxyPlaylist {
|
|||||||
return {
|
return {
|
||||||
'tracks': tracks.map(_makeAppropriateTrackJson).toList(),
|
'tracks': tracks.map(_makeAppropriateTrackJson).toList(),
|
||||||
'active': active,
|
'active': active,
|
||||||
|
'collections': collections.toList(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
ProxyPlaylist copyWith({
|
ProxyPlaylist copyWith({
|
||||||
Set<Track>? tracks,
|
Set<Track>? tracks,
|
||||||
int? active,
|
int? active,
|
||||||
|
Set<String>? collections,
|
||||||
}) {
|
}) {
|
||||||
return ProxyPlaylist(
|
return ProxyPlaylist(
|
||||||
tracks ?? this.tracks,
|
tracks ?? this.tracks,
|
||||||
active ?? this.active,
|
active ?? this.active,
|
||||||
|
collections ?? this.collections,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,7 @@ import 'package:spotube/utils/type_conversion_utils.dart';
|
|||||||
/// Things to implement:
|
/// Things to implement:
|
||||||
/// * [x] Sponsor-Block skip
|
/// * [x] Sponsor-Block skip
|
||||||
/// * [x] Prefetch next track as [SpotubeTrack] on 80% of current track
|
/// * [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
|
/// * [ ] Modification of the Queue
|
||||||
/// * [x] Add track at the end
|
/// * [x] Add track at the end
|
||||||
/// * [x] Add track at the beginning
|
/// * [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
|
// TODO: Safely Remove playing tracks
|
||||||
|
|
||||||
Future<void> removeTrack(String trackId) async {
|
Future<void> removeTrack(String trackId) async {
|
||||||
@ -218,29 +231,39 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
|||||||
bool autoPlay = false,
|
bool autoPlay = false,
|
||||||
}) async {
|
}) async {
|
||||||
tracks = blacklist.filter(tracks).toList() as List<Track>;
|
tracks = blacklist.filter(tracks).toList() as List<Track>;
|
||||||
final addableTrack = await SpotubeTrack.fetchFromTrack(
|
final indexTrack = tracks.elementAtOrNull(initialIndex) ?? tracks.first;
|
||||||
tracks.elementAtOrNull(initialIndex) ?? tracks.first,
|
|
||||||
preferences,
|
|
||||||
pipedClient,
|
|
||||||
);
|
|
||||||
|
|
||||||
state = state.copyWith(
|
if (indexTrack is LocalTrack) {
|
||||||
tracks: mergeTracks([addableTrack], tracks),
|
state = state.copyWith(
|
||||||
active: initialIndex,
|
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(
|
await audioPlayer.openPlaylist(
|
||||||
state.tracks.map(makeAppropriateSource).toList(),
|
state.tracks.map(makeAppropriateSource).toList(),
|
||||||
initialIndex: initialIndex,
|
initialIndex: initialIndex,
|
||||||
autoPlay: autoPlay,
|
autoPlay: autoPlay,
|
||||||
);
|
);
|
||||||
|
|
||||||
await storeTrack(
|
|
||||||
tracks.elementAt(initialIndex),
|
|
||||||
addableTrack,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> jumpTo(int index) async {
|
Future<void> jumpTo(int index) async {
|
||||||
@ -439,12 +462,13 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
|||||||
@override
|
@override
|
||||||
onInit() async {
|
onInit() async {
|
||||||
if (state.tracks.isEmpty) return null;
|
if (state.tracks.isEmpty) return null;
|
||||||
|
final oldCollections = state.collections;
|
||||||
await load(
|
await load(
|
||||||
state.tracks,
|
state.tracks,
|
||||||
initialIndex: state.active ?? 0,
|
initialIndex: state.active ?? 0,
|
||||||
autoPlay: false,
|
autoPlay: false,
|
||||||
);
|
);
|
||||||
|
state = state.copyWith(collections: oldCollections);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
31
lib/provider/sleep_timer_provider.dart
Normal file
31
lib/provider/sleep_timer_provider.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -323,7 +323,7 @@ class _MprisMediaPlayer2Player extends DBusObject {
|
|||||||
"xesam:url": DBusString(
|
"xesam:url": DBusString(
|
||||||
playlist.activeTrack is SpotubeTrack
|
playlist.activeTrack is SpotubeTrack
|
||||||
? (playlist.activeTrack as SpotubeTrack).ytUri
|
? (playlist.activeTrack as SpotubeTrack).ytUri
|
||||||
: playlist.activeTrack!.previewUrl!,
|
: playlist.activeTrack!.previewUrl ?? "",
|
||||||
),
|
),
|
||||||
"xesam:genre": const DBusString("Unknown"),
|
"xesam:genre": const DBusString("Unknown"),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -251,6 +251,10 @@ abstract class ServiceUtils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static void navigate(BuildContext context, String location, {Object? extra}) {
|
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);
|
GoRouter.of(context).push(location, extra: extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -267,12 +271,14 @@ abstract class ServiceUtils {
|
|||||||
0;
|
0;
|
||||||
case SortBy.ascending:
|
case SortBy.ascending:
|
||||||
return a.name?.compareTo(b.name ?? "") ?? 0;
|
return a.name?.compareTo(b.name ?? "") ?? 0;
|
||||||
case SortBy.dateAdded:
|
case SortBy.oldest:
|
||||||
final aDate =
|
final aDate = DateTime.parse(a.album?.releaseDate ?? "2069-01-01");
|
||||||
double.parse(a.album?.releaseDate?.split("-").first ?? "2069");
|
final bDate = DateTime.parse(b.album?.releaseDate ?? "2069-01-01");
|
||||||
final bDate =
|
|
||||||
double.parse(b.album?.releaseDate?.split("-").first ?? "2069");
|
|
||||||
return aDate.compareTo(bDate);
|
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:
|
case SortBy.descending:
|
||||||
return b.name?.compareTo(a.name ?? "") ?? 0;
|
return b.name?.compareTo(a.name ?? "") ?? 0;
|
||||||
default:
|
default:
|
||||||
|
|||||||
24
pubspec.lock
24
pubspec.lock
@ -153,6 +153,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
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:
|
badges:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -466,6 +474,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
version: "2.1.0"
|
||||||
|
duration:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: duration
|
||||||
|
sha256: d0b29d0a345429e3986ac56d60e4aef65b37d11e653022b2b9a4b361332b777f
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.0.12"
|
||||||
envied:
|
envied:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -1126,14 +1142,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.4"
|
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:
|
mutex:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -102,7 +102,8 @@ dependencies:
|
|||||||
device_preview: ^1.1.0
|
device_preview: ^1.1.0
|
||||||
media_kit_native_event_loop: ^1.0.4
|
media_kit_native_event_loop: ^1.0.4
|
||||||
dbus: ^0.7.8
|
dbus: ^0.7.8
|
||||||
motion_toast: ^2.6.8
|
background_downloader: ^7.4.0
|
||||||
|
duration: ^3.0.12
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
build_runner: ^2.3.2
|
build_runner: ^2.3.2
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user