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 {
|
||||
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'
|
||||
}
|
||||
|
||||
@ -49,17 +49,21 @@
|
||||
|
||||
</activity>
|
||||
|
||||
<service android:name="com.ryanheise.audioservice.AudioService" android:exported="false">
|
||||
<!-- AudioService Config -->
|
||||
<service android:name="com.ryanheise.audioservice.AudioService"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver" android:exported="false">
|
||||
<receiver android:name="com.ryanheise.audioservice.MediaButtonReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<!-- =================== -->
|
||||
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.7.21'
|
||||
ext.kotlin_version = '1.8.22'
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
|
||||
@ -296,10 +296,10 @@ abstract class LanguageLocals {
|
||||
// name: "Inuktitut",
|
||||
// nativeName: "ᐃᓄᒃᑎᑐᑦ",
|
||||
// ),
|
||||
// "ja": const ISOLanguageName(
|
||||
// name: "Japanese",
|
||||
// nativeName: "日本語 (にほんご/にっぽんご)",
|
||||
// ),
|
||||
"ja": const ISOLanguageName(
|
||||
name: "Japanese",
|
||||
nativeName: "日本語 (にほんご/にっぽんご)",
|
||||
),
|
||||
// "jv": const ISOLanguageName(
|
||||
// name: "Javanese",
|
||||
// nativeName: "basa Jawa",
|
||||
|
||||
@ -8,6 +8,7 @@ import 'package:spotube/pages/lyrics/mini_lyrics.dart';
|
||||
import 'package:spotube/pages/search/search.dart';
|
||||
import 'package:spotube/pages/settings/blacklist.dart';
|
||||
import 'package:spotube/pages/settings/about.dart';
|
||||
import 'package:spotube/pages/settings/logs.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
import 'package:spotube/components/shared/spotube_page_route.dart';
|
||||
import 'package:spotube/pages/album/album.dart';
|
||||
@ -79,14 +80,20 @@ final router = GoRouter(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: "blacklist",
|
||||
pageBuilder: (context, state) => const SpotubePage(
|
||||
child: BlackListPage(),
|
||||
pageBuilder: (context, state) => SpotubeSlidePage(
|
||||
child: const BlackListPage(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: "logs",
|
||||
pageBuilder: (context, state) => SpotubeSlidePage(
|
||||
child: const LogsPage(),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: "about",
|
||||
pageBuilder: (context, state) => const SpotubePage(
|
||||
child: AboutSpotube(),
|
||||
pageBuilder: (context, state) => SpotubeSlidePage(
|
||||
child: const AboutSpotube(),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -86,4 +86,7 @@ abstract class SpotubeIcons {
|
||||
static const volumeMedium = FeatherIcons.volume1;
|
||||
static const volumeLow = FeatherIcons.volume;
|
||||
static const volumeMute = FeatherIcons.volumeX;
|
||||
static const timer = FeatherIcons.clock;
|
||||
static const logs = FeatherIcons.fileText;
|
||||
static const clipboard = FeatherIcons.clipboard;
|
||||
}
|
||||
|
||||
@ -46,14 +46,12 @@ class AlbumCard extends HookConsumerWidget {
|
||||
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
||||
final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
final queryClient = useQueryClient();
|
||||
final query = queryClient
|
||||
.getQuery<List<TrackSimple>, dynamic>("album-tracks/${album.id}");
|
||||
bool isPlaylistPlaying = useMemoized(
|
||||
() => playlist.containsTracks(query?.data ?? album.tracks ?? []),
|
||||
[playlistNotifier, query?.data, album.tracks],
|
||||
() => playlist.containsCollection(album.id!),
|
||||
[playlist, album.id],
|
||||
);
|
||||
final int marginH =
|
||||
useBreakpointValue(sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
|
||||
useBreakpointValue(xs: 10, sm: 10, md: 15, lg: 20, xl: 20, xxl: 20);
|
||||
|
||||
final updating = useState(false);
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
@ -70,7 +68,7 @@ class AlbumCard extends HookConsumerWidget {
|
||||
description:
|
||||
"${AlbumType.from(album.albumType!).formatted} • ${TypeConversionUtils.artists_X_String<ArtistSimple>(album.artists ?? [])}",
|
||||
onTap: () {
|
||||
ServiceUtils.navigate(context, "/album/${album.id}", extra: album);
|
||||
ServiceUtils.push(context, "/album/${album.id}", extra: album);
|
||||
},
|
||||
onPlaybuttonPressed: () async {
|
||||
updating.value = true;
|
||||
@ -89,6 +87,7 @@ class AlbumCard extends HookConsumerWidget {
|
||||
[],
|
||||
autoPlay: true,
|
||||
);
|
||||
playlistNotifier.addCollection(album.id!);
|
||||
} finally {
|
||||
updating.value = false;
|
||||
}
|
||||
@ -118,6 +117,7 @@ class AlbumCard extends HookConsumerWidget {
|
||||
|
||||
if (fetchedTracks == null || fetchedTracks.isEmpty) return;
|
||||
playlistNotifier.addTracks(fetchedTracks);
|
||||
playlistNotifier.addCollection(album.id!);
|
||||
if (context.mounted) {
|
||||
final snackbar = SnackBar(
|
||||
content: Text("Added ${album.tracks?.length} tracks to queue"),
|
||||
|
||||
@ -35,6 +35,7 @@ class ArtistCard extends HookConsumerWidget {
|
||||
final radius = BorderRadius.circular(15);
|
||||
|
||||
final double size = useBreakpointValue<double>(
|
||||
xs: 130,
|
||||
sm: 130,
|
||||
md: 150,
|
||||
others: 170,
|
||||
@ -62,7 +63,7 @@ class ArtistCard extends HookConsumerWidget {
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
ServiceUtils.navigate(context, "/artist/${artist.id}");
|
||||
ServiceUtils.push(context, "/artist/${artist.id}");
|
||||
},
|
||||
borderRadius: radius,
|
||||
child: Padding(
|
||||
|
||||
@ -188,7 +188,7 @@ class _MultiSelectDialog<T> extends HookWidget {
|
||||
return AlertDialog(
|
||||
scrollable: true,
|
||||
title: dialogTitle ?? const Text('Select'),
|
||||
contentPadding: mediaQuery.isSm ? const EdgeInsets.all(16) : null,
|
||||
contentPadding: mediaQuery.mdAndUp ? null : const EdgeInsets.all(16),
|
||||
insetPadding: const EdgeInsets.all(16),
|
||||
actions: [
|
||||
OutlinedButton(
|
||||
|
||||
@ -94,7 +94,7 @@ class RecommendationAttributeDials extends HookWidget {
|
||||
return Card(
|
||||
child: ExpansionTile(
|
||||
title: DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.titleMedium!,
|
||||
style: Theme.of(context).textTheme.titleSmall!,
|
||||
child: title,
|
||||
),
|
||||
shape: const Border(),
|
||||
|
||||
@ -93,7 +93,7 @@ class RecommendationAttributeFields extends HookWidget {
|
||||
return Card(
|
||||
child: ExpansionTile(
|
||||
title: DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.titleMedium!,
|
||||
style: Theme.of(context).textTheme.titleSmall!,
|
||||
child: title,
|
||||
),
|
||||
shape: const Border(),
|
||||
|
||||
@ -24,6 +24,7 @@ class UserAlbums extends HookConsumerWidget {
|
||||
final albumsQuery = useQueries.album.ofMine(ref);
|
||||
|
||||
final spacing = useBreakpointValue<double>(
|
||||
xs: 0,
|
||||
sm: 0,
|
||||
others: 20,
|
||||
);
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'package:auto_size_text/auto_size_text.dart';
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
// import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@ -65,14 +66,11 @@ class UserDownloads extends HookConsumerWidget {
|
||||
.where((element) => element.taskId == track.id),
|
||||
);
|
||||
final taskItSelf = useFuture(
|
||||
Future.value(null),
|
||||
// FileDownloader().database.recordForId(track.id!),
|
||||
FileDownloader().database.recordForId(track.id!),
|
||||
);
|
||||
|
||||
final hasFailed = failedTaskStream
|
||||
.hasData /* ||
|
||||
taskItSelf.data?.status == TaskStatus.failed */
|
||||
;
|
||||
final hasFailed = failedTaskStream.hasData ||
|
||||
taskItSelf.data?.status == TaskStatus.failed;
|
||||
|
||||
return ListTile(
|
||||
title: Text(track.name ?? ''),
|
||||
@ -91,10 +89,8 @@ class UserDownloads extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
horizontalTitleGap: 10,
|
||||
trailing: SizedBox(
|
||||
width: 30,
|
||||
height: 30,
|
||||
child: downloadManager.activeItem?.id == track.id
|
||||
trailing: downloadManager.activeItem?.id == track.id &&
|
||||
!hasFailed
|
||||
? CircularProgressIndicator(
|
||||
value: task.data?.progress ?? 0,
|
||||
)
|
||||
@ -105,7 +101,6 @@ class UserDownloads extends HookConsumerWidget {
|
||||
onPressed: () {
|
||||
downloadManager.cancel(track);
|
||||
}),
|
||||
),
|
||||
subtitle: TypeConversionUtils.artists_X_ClickableArtists(
|
||||
track.artists ?? <Artist>[],
|
||||
mainAxisAlignment: WrapAlignment.start,
|
||||
|
||||
@ -15,7 +15,7 @@ import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/compact_search.dart';
|
||||
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
|
||||
import 'package:spotube/components/shared/shimmers/shimmer_track_tile.dart';
|
||||
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
|
||||
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
||||
@ -52,7 +52,8 @@ enum SortBy {
|
||||
descending,
|
||||
artist,
|
||||
album,
|
||||
dateAdded,
|
||||
newest,
|
||||
oldest,
|
||||
}
|
||||
|
||||
final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {
|
||||
@ -95,7 +96,7 @@ final localTracksProvider = FutureProvider<List<LocalTrack>>((ref) async {
|
||||
return {"metadata": metadata, "file": f, "art": imageFile.path};
|
||||
} catch (e, stack) {
|
||||
if (e is FfiException) {
|
||||
return {};
|
||||
return {"file": f};
|
||||
}
|
||||
Catcher.reportCheckedError(e, stack);
|
||||
return {};
|
||||
@ -160,7 +161,10 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
playlist.containsTracks(trackSnapshot.value ?? []);
|
||||
final isMounted = useIsMounted();
|
||||
|
||||
final searchText = useState<String>("");
|
||||
final searchController = useTextEditingController();
|
||||
useValueListenable(searchController);
|
||||
final searchFocus = useFocusNode();
|
||||
final isFiltering = useState(false);
|
||||
|
||||
useAsyncEffect(
|
||||
() async {
|
||||
@ -175,11 +179,6 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
[],
|
||||
);
|
||||
|
||||
final searchbar = CompactSearch(
|
||||
onChanged: (value) => searchText.value = value,
|
||||
placeholder: context.l10n.search_local_tracks,
|
||||
);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
@ -213,7 +212,10 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
searchbar,
|
||||
ExpandableSearchButton(
|
||||
isFiltering: isFiltering,
|
||||
searchFocus: searchFocus,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
SortTracksDropdown(
|
||||
value: sortBy.value,
|
||||
@ -231,6 +233,11 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
ExpandableSearchField(
|
||||
searchController: searchController,
|
||||
searchFocus: searchFocus,
|
||||
isFiltering: isFiltering,
|
||||
),
|
||||
trackSnapshot.when(
|
||||
data: (tracks) {
|
||||
final sortedTracks = useMemoized(() {
|
||||
@ -238,14 +245,14 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
}, [sortBy.value, tracks]);
|
||||
|
||||
final filteredTracks = useMemoized(() {
|
||||
if (searchText.value.isEmpty) {
|
||||
if (searchController.text.isEmpty) {
|
||||
return sortedTracks;
|
||||
}
|
||||
return sortedTracks
|
||||
.map((e) => (
|
||||
weightedRatio(
|
||||
"${e.name} - ${TypeConversionUtils.artists_X_String<Artist>(e.artists ?? [])}",
|
||||
searchText.value,
|
||||
searchController.text,
|
||||
),
|
||||
e,
|
||||
))
|
||||
@ -257,7 +264,7 @@ class UserLocalTracks extends HookConsumerWidget {
|
||||
.map((e) => e.$2)
|
||||
.toList()
|
||||
.toList();
|
||||
}, [searchText.value, sortedTracks]);
|
||||
}, [searchController.text, sortedTracks]);
|
||||
|
||||
return Expanded(
|
||||
child: RefreshIndicator(
|
||||
|
||||
@ -3,26 +3,31 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotify/spotify.dart' hide Offset;
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/player/player_queue.dart';
|
||||
import 'package:spotube/components/player/sibling_tracks_sheet.dart';
|
||||
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
|
||||
import 'package:spotube/components/shared/heart_button.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/duration.dart';
|
||||
import 'package:spotube/models/local_track.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:spotube/provider/download_manager_provider.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/sleep_timer_provider.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class PlayerActions extends HookConsumerWidget {
|
||||
final MainAxisAlignment mainAxisAlignment;
|
||||
final bool floatingQueue;
|
||||
final bool showQueue;
|
||||
final List<Widget>? extraActions;
|
||||
PlayerActions({
|
||||
this.mainAxisAlignment = MainAxisAlignment.center,
|
||||
this.floatingQueue = true,
|
||||
this.showQueue = true,
|
||||
this.extraActions,
|
||||
Key? key,
|
||||
}) : super(key: key);
|
||||
@ -38,6 +43,8 @@ class PlayerActions extends HookConsumerWidget {
|
||||
downloader.activeItem!.id == playlist.activeTrack?.id;
|
||||
final localTracks = [] /* ref.watch(localTracksProvider).value */;
|
||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||
final sleepTimer = ref.watch(SleepTimerNotifier.provider);
|
||||
final sleepTimerNotifier = ref.watch(SleepTimerNotifier.notifier);
|
||||
|
||||
final isDownloaded = useMemoized(() {
|
||||
return localTracks.any(
|
||||
@ -52,9 +59,22 @@ class PlayerActions extends HookConsumerWidget {
|
||||
true;
|
||||
}, [localTracks, playlist.activeTrack]);
|
||||
|
||||
final sleepTimerEntries = useMemoized(
|
||||
() => {
|
||||
context.l10n.mins(15): const Duration(minutes: 15),
|
||||
context.l10n.mins(30): const Duration(minutes: 30),
|
||||
context.l10n.hour(1): const Duration(hours: 1),
|
||||
context.l10n.hour(2): const Duration(hours: 2),
|
||||
},
|
||||
[context.l10n],
|
||||
);
|
||||
|
||||
var customHoursEnabled =
|
||||
sleepTimer == null || sleepTimerEntries.values.contains(sleepTimer);
|
||||
return Row(
|
||||
mainAxisAlignment: mainAxisAlignment,
|
||||
children: [
|
||||
if (showQueue)
|
||||
IconButton(
|
||||
icon: const Icon(SpotubeIcons.queue),
|
||||
tooltip: context.l10n.queue,
|
||||
@ -127,6 +147,62 @@ class PlayerActions extends HookConsumerWidget {
|
||||
),
|
||||
if (playlist.activeTrack != null && !isLocalTrack && auth != null)
|
||||
TrackHeartButton(track: playlist.activeTrack!),
|
||||
AdaptivePopSheetList(
|
||||
offset: Offset(0, -50 * (sleepTimerEntries.values.length + 2)),
|
||||
headings: [
|
||||
Text(context.l10n.sleep_timer),
|
||||
],
|
||||
icon: Icon(
|
||||
SpotubeIcons.timer,
|
||||
color: sleepTimer != null ? Colors.red : null,
|
||||
),
|
||||
onSelected: (value) {
|
||||
if (value == Duration.zero) {
|
||||
sleepTimerNotifier.cancelSleepTimer();
|
||||
} else {
|
||||
sleepTimerNotifier.setSleepTimer(value);
|
||||
}
|
||||
},
|
||||
children: [
|
||||
for (final entry in sleepTimerEntries.entries)
|
||||
PopSheetEntry(
|
||||
value: entry.value,
|
||||
enabled: sleepTimer != entry.value,
|
||||
title: Text(entry.key),
|
||||
),
|
||||
PopSheetEntry(
|
||||
title: Text(
|
||||
customHoursEnabled
|
||||
? context.l10n.custom_hours
|
||||
: sleepTimer.format(abbreviated: true),
|
||||
),
|
||||
// only enabled when there's no preset timers selected
|
||||
enabled: customHoursEnabled,
|
||||
onTap: () async {
|
||||
final currentTime = TimeOfDay.now();
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: currentTime,
|
||||
);
|
||||
|
||||
if (time != null) {
|
||||
sleepTimerNotifier.setSleepTimer(
|
||||
Duration(
|
||||
hours: (time.hour - currentTime.hour).abs(),
|
||||
minutes: (time.minute - currentTime.minute).abs(),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
PopSheetEntry(
|
||||
value: Duration.zero,
|
||||
enabled: sleepTimer != Duration.zero && sleepTimer != null,
|
||||
textColor: Colors.green,
|
||||
title: Text(context.l10n.cancel),
|
||||
),
|
||||
],
|
||||
),
|
||||
...(extraActions ?? [])
|
||||
],
|
||||
);
|
||||
|
||||
@ -44,7 +44,7 @@ class PlayerOverlay extends HookConsumerWidget {
|
||||
int sensitivity = 8;
|
||||
if (details.primaryVelocity != null &&
|
||||
details.primaryVelocity! < -sensitivity) {
|
||||
ServiceUtils.navigate(context, "/player");
|
||||
ServiceUtils.push(context, "/player");
|
||||
}
|
||||
},
|
||||
child: ClipRRect(
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
|
||||
@ -6,6 +7,7 @@ import 'package:spotube/collections/assets.gen.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/utils/service_utils.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class PlayerTrackDetails extends HookConsumerWidget {
|
||||
@ -37,7 +39,7 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (mediaQuery.isSm || mediaQuery.isMd)
|
||||
if (mediaQuery.mdAndDown)
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -72,6 +74,9 @@ class PlayerTrackDetails extends HookConsumerWidget {
|
||||
),
|
||||
TypeConversionUtils.artists_X_ClickableArtists(
|
||||
playback.activeTrack?.artists ?? [],
|
||||
onRouteChange: (route) {
|
||||
ServiceUtils.push(context, route);
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
|
||||
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 =
|
||||
useStream(audioPlayer.playingStream).data ?? audioPlayer.isPlaying;
|
||||
final queryBowl = QueryClient.of(context);
|
||||
final query = queryBowl.getQuery<List<Track>, dynamic>(
|
||||
"playlist-tracks/${playlist.id}",
|
||||
);
|
||||
final tracks = useState<List<TrackSimple>?>(null);
|
||||
bool isPlaylistPlaying = useMemoized(
|
||||
() => playlistQueue.containsTracks(tracks.value ?? query?.data ?? []),
|
||||
[playlistNotifier, tracks.value, query?.data],
|
||||
() => playlistQueue.containsCollection(playlist.id!),
|
||||
[playlistQueue, playlist.id],
|
||||
);
|
||||
|
||||
final updating = useState(false);
|
||||
@ -48,7 +45,7 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
isLoading:
|
||||
(isPlaylistPlaying && playlistQueue.isFetching) || updating.value,
|
||||
onTap: () {
|
||||
ServiceUtils.navigate(
|
||||
ServiceUtils.push(
|
||||
context,
|
||||
"/playlist/${playlist.id}",
|
||||
extra: playlist,
|
||||
@ -72,6 +69,7 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
if (fetchedTracks.isEmpty) return;
|
||||
|
||||
await playlistNotifier.load(fetchedTracks, autoPlay: true);
|
||||
playlistNotifier.addCollection(playlist.id!);
|
||||
tracks.value = fetchedTracks;
|
||||
} finally {
|
||||
updating.value = false;
|
||||
@ -90,6 +88,7 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
if (fetchedTracks.isEmpty) return;
|
||||
|
||||
playlistNotifier.addTracks(fetchedTracks);
|
||||
playlistNotifier.addCollection(playlist.id!);
|
||||
tracks.value = fetchedTracks;
|
||||
if (context.mounted) {
|
||||
final snackbar = SnackBar(
|
||||
|
||||
@ -121,7 +121,7 @@ class PlaylistCreateDialogButton extends HookConsumerWidget {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final spotify = ref.watch(spotifyProvider);
|
||||
|
||||
if (mediaQuery.isSm) {
|
||||
if (mediaQuery.smAndDown) {
|
||||
return ElevatedButton(
|
||||
style: FilledButton.styleFrom(
|
||||
foregroundColor: Theme.of(context).colorScheme.primary,
|
||||
|
||||
@ -12,6 +12,7 @@ import 'package:spotube/components/player/player_actions.dart';
|
||||
import 'package:spotube/components/player/player_overlay.dart';
|
||||
import 'package:spotube/components/player/player_track_details.dart';
|
||||
import 'package:spotube/components/player/player_controls.dart';
|
||||
import 'package:spotube/components/player/volume_slider.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_brightness_value.dart';
|
||||
@ -20,7 +21,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/provider/volume_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
@ -58,8 +58,7 @@ class BottomPlayer extends HookConsumerWidget {
|
||||
// returning an empty non spacious Container as the overlay will take
|
||||
// place in the global overlay stack aka [_entries]
|
||||
if (layoutMode == LayoutMode.compact ||
|
||||
((mediaQuery.isSm || mediaQuery.isMd) &&
|
||||
layoutMode == LayoutMode.adaptive)) {
|
||||
((mediaQuery.mdAndDown) && layoutMode == LayoutMode.adaptive)) {
|
||||
return PlayerOverlay(albumArt: albumArt);
|
||||
}
|
||||
|
||||
@ -116,57 +115,7 @@ class BottomPlayer extends HookConsumerWidget {
|
||||
Container(
|
||||
height: 40,
|
||||
constraints: const BoxConstraints(maxWidth: 250),
|
||||
child: HookBuilder(builder: (context) {
|
||||
final volume = ref.watch(volumeProvider);
|
||||
final volumeNotifier =
|
||||
ref.watch(volumeProvider.notifier);
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
volume == 0
|
||||
? SpotubeIcons.volumeMute
|
||||
: volume <= 0.2
|
||||
? SpotubeIcons.volumeLow
|
||||
: volume <= 0.6
|
||||
? SpotubeIcons.volumeMedium
|
||||
: SpotubeIcons.volumeHigh,
|
||||
size: 16,
|
||||
),
|
||||
onPressed: () {
|
||||
if (volume == 0) {
|
||||
volumeNotifier.setVolume(1);
|
||||
} else {
|
||||
volumeNotifier.setVolume(0);
|
||||
}
|
||||
},
|
||||
),
|
||||
Listener(
|
||||
onPointerSignal: (event) async {
|
||||
if (event is PointerScrollEvent) {
|
||||
if (event.scrollDelta.dy > 0) {
|
||||
final value = volume - .2;
|
||||
volumeNotifier
|
||||
.setVolume(value < 0 ? 0 : value);
|
||||
} else {
|
||||
final value = volume + .2;
|
||||
volumeNotifier
|
||||
.setVolume(value > 1 ? 1 : value);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Slider.adaptive(
|
||||
min: 0,
|
||||
max: 1,
|
||||
value: volume,
|
||||
onChanged: volumeNotifier.setVolume,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
child: const VolumeSlider(),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
@ -82,16 +82,17 @@ class Sidebar extends HookConsumerWidget {
|
||||
}, [controller]);
|
||||
|
||||
useEffect(() {
|
||||
if (!context.mounted) return;
|
||||
if (mediaQuery.lgAndUp && !controller.extended) {
|
||||
controller.setExtended(true);
|
||||
} else if ((mediaQuery.isSm || mediaQuery.isMd) && controller.extended) {
|
||||
} else if (mediaQuery.mdAndDown && controller.extended) {
|
||||
controller.setExtended(false);
|
||||
}
|
||||
return null;
|
||||
}, [mediaQuery, controller]);
|
||||
|
||||
if (layoutMode == LayoutMode.compact ||
|
||||
(mediaQuery.isSm && layoutMode == LayoutMode.adaptive)) {
|
||||
(mediaQuery.smAndDown && layoutMode == LayoutMode.adaptive)) {
|
||||
return Scaffold(body: child);
|
||||
}
|
||||
|
||||
@ -186,7 +187,7 @@ class SidebarHeader extends HookWidget {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
if (mediaQuery.isSm || mediaQuery.isMd) {
|
||||
if (mediaQuery.mdAndDown) {
|
||||
return Container(
|
||||
height: 40,
|
||||
width: 40,
|
||||
@ -236,7 +237,7 @@ class SidebarFooter extends HookConsumerWidget {
|
||||
|
||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||
|
||||
if (mediaQuery.isSm || mediaQuery.isMd) {
|
||||
if (mediaQuery.mdAndDown) {
|
||||
return IconButton(
|
||||
icon: const Icon(SpotubeIcons.settings),
|
||||
onPressed: () => Sidebar.goToSettings(context),
|
||||
|
||||
@ -27,10 +27,11 @@ class AdaptiveListTile extends HookWidget {
|
||||
return ListTile(
|
||||
title: title,
|
||||
subtitle: subtitle,
|
||||
trailing:
|
||||
breakOn ?? mediaQuery.isSm ? null : trailing?.call(context, null),
|
||||
trailing: breakOn ?? mediaQuery.smAndDown
|
||||
? null
|
||||
: trailing?.call(context, null),
|
||||
leading: leading,
|
||||
onTap: breakOn ?? mediaQuery.isSm
|
||||
onTap: breakOn ?? mediaQuery.smAndDown
|
||||
? () {
|
||||
onTap?.call();
|
||||
showDialog(
|
||||
|
||||
@ -2,17 +2,47 @@ import 'package:flutter/material.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
|
||||
class PopSheetEntry<T> {
|
||||
final T? value;
|
||||
final VoidCallback? onTap;
|
||||
final Widget child;
|
||||
final bool enabled;
|
||||
_emptyCB() {}
|
||||
|
||||
class PopSheetEntry<T> extends ListTile {
|
||||
final T? value;
|
||||
const PopSheetEntry({
|
||||
required this.child,
|
||||
this.value,
|
||||
this.onTap,
|
||||
this.enabled = true,
|
||||
super.key,
|
||||
super.leading,
|
||||
super.title,
|
||||
super.subtitle,
|
||||
super.trailing,
|
||||
super.isThreeLine = false,
|
||||
super.dense,
|
||||
super.visualDensity,
|
||||
super.shape,
|
||||
super.style,
|
||||
super.selectedColor,
|
||||
super.iconColor,
|
||||
super.textColor,
|
||||
super.titleTextStyle,
|
||||
super.subtitleTextStyle,
|
||||
super.leadingAndTrailingTextStyle,
|
||||
super.contentPadding,
|
||||
super.enabled = true,
|
||||
super.onTap = _emptyCB,
|
||||
super.onLongPress,
|
||||
super.onFocusChange,
|
||||
super.mouseCursor,
|
||||
super.selected = false,
|
||||
super.focusColor,
|
||||
super.hoverColor,
|
||||
super.splashColor,
|
||||
super.focusNode,
|
||||
super.autofocus = false,
|
||||
super.tileColor,
|
||||
super.selectedTileColor,
|
||||
super.enableFeedback,
|
||||
super.horizontalTitleGap,
|
||||
super.minVerticalPadding,
|
||||
super.minLeadingWidth,
|
||||
super.titleAlignment,
|
||||
});
|
||||
}
|
||||
|
||||
@ -30,6 +60,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
|
||||
final ValueChanged<T>? onSelected;
|
||||
|
||||
final BorderRadius borderRadius;
|
||||
final Offset offset;
|
||||
|
||||
const AdaptivePopSheetList({
|
||||
super.key,
|
||||
@ -41,6 +72,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
|
||||
this.onSelected,
|
||||
this.borderRadius = const BorderRadius.all(Radius.circular(999)),
|
||||
this.tooltip,
|
||||
this.offset = Offset.zero,
|
||||
}) : assert(
|
||||
!(icon != null && child != null),
|
||||
'Either icon or child must be provided',
|
||||
@ -55,11 +87,13 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
|
||||
return PopupMenuButton(
|
||||
icon: icon,
|
||||
tooltip: tooltip,
|
||||
offset: offset,
|
||||
child: child == null ? null : IgnorePointer(child: child),
|
||||
itemBuilder: (context) => children
|
||||
.map(
|
||||
(item) => PopupMenuItem(
|
||||
padding: EdgeInsets.zero,
|
||||
enabled: false,
|
||||
child: _AdaptivePopSheetListItem(
|
||||
item: item,
|
||||
onSelected: onSelected,
|
||||
@ -74,24 +108,21 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
useRootNavigator: useRootNavigator,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: mediaQuery.size.height * 0.6,
|
||||
),
|
||||
builder: (context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
padding: const EdgeInsets.all(8.0).copyWith(top: 0),
|
||||
child: DefaultTextStyle(
|
||||
style: theme.textTheme.titleMedium!,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (headings != null) ...[
|
||||
Container(
|
||||
width: 180,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...headings!,
|
||||
const SizedBox(height: 8),
|
||||
Divider(
|
||||
@ -110,6 +141,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@ -153,27 +185,23 @@ class _AdaptivePopSheetListItem<T> extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: (theme.listTileTheme.shape as RoundedRectangleBorder?)
|
||||
?.borderRadius as BorderRadius? ??
|
||||
const BorderRadius.all(Radius.circular(10)),
|
||||
onTap: !item.enabled
|
||||
? null
|
||||
: () {
|
||||
item.onTap?.call();
|
||||
Navigator.pop(context);
|
||||
if (item.value != null) {
|
||||
Navigator.pop(context);
|
||||
onSelected?.call(item.value as T);
|
||||
}
|
||||
},
|
||||
child: DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
color: item.enabled
|
||||
? theme.textTheme.bodyMedium!.color
|
||||
: theme.textTheme.bodyMedium!.color!.withOpacity(0.5),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: item.child,
|
||||
),
|
||||
child: IgnorePointer(child: item),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
FilledButton(
|
||||
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:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_palette_color.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/services/mutations/mutations.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
class HeartButton extends HookConsumerWidget {
|
||||
final bool isLiked;
|
||||
final void Function()? onPressed;
|
||||
@ -163,15 +160,6 @@ class PlaylistHeartButton extends HookConsumerWidget {
|
||||
],
|
||||
);
|
||||
|
||||
final titleImage = useMemoized(
|
||||
() => TypeConversionUtils.image_X_UrlString(
|
||||
playlist.images,
|
||||
placeholder: ImagePlaceholder.collection,
|
||||
),
|
||||
[playlist.images]);
|
||||
|
||||
final color = usePaletteGenerator(titleImage).dominantColor;
|
||||
|
||||
if (me.isLoading || !me.hasData) {
|
||||
return const CircularProgressIndicator();
|
||||
}
|
||||
@ -181,7 +169,7 @@ class PlaylistHeartButton extends HookConsumerWidget {
|
||||
tooltip: isLikedQuery.data ?? false
|
||||
? context.l10n.remove_from_favorites
|
||||
: context.l10n.save_as_favorite,
|
||||
color: color?.titleTextColor,
|
||||
color: Colors.white,
|
||||
onPressed: isLikedQuery.hasData
|
||||
? () {
|
||||
togglePlaylistLike.mutate(isLikedQuery.data!);
|
||||
@ -224,6 +212,7 @@ class AlbumHeartButton extends HookConsumerWidget {
|
||||
tooltip: isLiked
|
||||
? context.l10n.remove_from_favorites
|
||||
: context.l10n.save_as_favorite,
|
||||
color: Colors.white,
|
||||
onPressed: albumIsSaved.hasData
|
||||
? () {
|
||||
toggleAlbumLike.mutate(isLiked);
|
||||
|
||||
@ -7,6 +7,7 @@ class AnchorButton<T> extends HookWidget {
|
||||
final TextAlign? textAlign;
|
||||
final TextOverflow? overflow;
|
||||
final void Function()? onTap;
|
||||
final int? maxLines;
|
||||
|
||||
const AnchorButton(
|
||||
this.text, {
|
||||
@ -14,6 +15,7 @@ class AnchorButton<T> extends HookWidget {
|
||||
this.onTap,
|
||||
this.textAlign,
|
||||
this.overflow,
|
||||
this.maxLines,
|
||||
this.style = const TextStyle(),
|
||||
}) : super(key: key);
|
||||
|
||||
@ -34,6 +36,7 @@ class AnchorButton<T> extends HookWidget {
|
||||
decoration:
|
||||
hover.value || tap.value ? TextDecoration.underline : null,
|
||||
),
|
||||
maxLines: maxLines,
|
||||
textAlign: textAlign,
|
||||
overflow: overflow,
|
||||
),
|
||||
|
||||
@ -8,6 +8,8 @@ class Hyperlink extends StatelessWidget {
|
||||
final TextAlign? textAlign;
|
||||
final TextOverflow? overflow;
|
||||
final String url;
|
||||
final int? maxLines;
|
||||
|
||||
const Hyperlink(
|
||||
this.text,
|
||||
this.url, {
|
||||
@ -15,6 +17,7 @@ class Hyperlink extends StatelessWidget {
|
||||
this.textAlign,
|
||||
this.overflow,
|
||||
this.style = const TextStyle(),
|
||||
this.maxLines,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@ -29,6 +32,7 @@ class Hyperlink extends StatelessWidget {
|
||||
},
|
||||
key: key,
|
||||
overflow: overflow,
|
||||
maxLines: maxLines,
|
||||
style: style.copyWith(color: Colors.blue),
|
||||
textAlign: textAlign,
|
||||
);
|
||||
|
||||
@ -9,6 +9,8 @@ class LinkText<T> extends StatelessWidget {
|
||||
final TextOverflow? overflow;
|
||||
final String route;
|
||||
final T? extra;
|
||||
|
||||
final bool push;
|
||||
const LinkText(
|
||||
this.text,
|
||||
this.route, {
|
||||
@ -17,6 +19,7 @@ class LinkText<T> extends StatelessWidget {
|
||||
this.extra,
|
||||
this.overflow,
|
||||
this.style = const TextStyle(),
|
||||
this.push = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@ -24,7 +27,11 @@ class LinkText<T> extends StatelessWidget {
|
||||
return AnchorButton(
|
||||
text,
|
||||
onTap: () {
|
||||
if (push) {
|
||||
ServiceUtils.push(context, route, extra: extra);
|
||||
} else {
|
||||
ServiceUtils.navigate(context, route, extra: extra);
|
||||
}
|
||||
},
|
||||
key: key,
|
||||
overflow: overflow,
|
||||
|
||||
@ -40,6 +40,7 @@ class PlaybuttonCard extends HookWidget {
|
||||
final radius = BorderRadius.circular(15);
|
||||
|
||||
final double size = useBreakpointValue<double>(
|
||||
xs: 130,
|
||||
sm: 130,
|
||||
md: 150,
|
||||
others: 170,
|
||||
@ -47,6 +48,7 @@ class PlaybuttonCard extends HookWidget {
|
||||
170;
|
||||
|
||||
final end = useBreakpointValue<double>(
|
||||
xs: 15,
|
||||
sm: 15,
|
||||
others: 20,
|
||||
) ??
|
||||
|
||||
@ -21,6 +21,7 @@ class ShimmerArtistProfile extends HookWidget {
|
||||
shimmerTheme.shimmerBackgroundColor ?? Colors.grey;
|
||||
|
||||
final avatarWidth = useBreakpointValue(
|
||||
xs: MediaQuery.of(context).size.width * 0.80,
|
||||
sm: MediaQuery.of(context).size.width * 0.80,
|
||||
md: MediaQuery.of(context).size.width * 0.50,
|
||||
lg: MediaQuery.of(context).size.width * 0.30,
|
||||
|
||||
@ -18,6 +18,7 @@ class ShimmerCategories extends HookWidget {
|
||||
shimmerTheme.shimmerBackgroundColor ?? Colors.grey;
|
||||
|
||||
final shimmerCount = useBreakpointValue(
|
||||
xs: 2,
|
||||
sm: 2,
|
||||
md: 3,
|
||||
lg: 3,
|
||||
|
||||
@ -32,7 +32,7 @@ class ShimmerLyrics extends HookWidget {
|
||||
if (mediaQuery.isMd) {
|
||||
widthsCp.removeLast();
|
||||
}
|
||||
if (mediaQuery.isSm) {
|
||||
if (mediaQuery.smAndDown) {
|
||||
widthsCp.removeLast();
|
||||
widthsCp.removeLast();
|
||||
}
|
||||
|
||||
@ -86,6 +86,7 @@ class ShimmerPlaybuttonCard extends HookWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final Size size = useBreakpointValue<Size>(
|
||||
xs: const Size(130, 200),
|
||||
sm: const Size(130, 200),
|
||||
md: const Size(150, 220),
|
||||
others: const Size(170, 240),
|
||||
|
||||
@ -16,6 +16,7 @@ class SortTracksDropdown extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var theme = Theme.of(context);
|
||||
return ListTileTheme(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@ -24,52 +25,39 @@ class SortTracksDropdown extends StatelessWidget {
|
||||
children: [
|
||||
PopSheetEntry(
|
||||
value: SortBy.none,
|
||||
enabled: value != SortBy.none,
|
||||
child: ListTile(
|
||||
enabled: value != SortBy.none,
|
||||
title: Text(context.l10n.none),
|
||||
),
|
||||
),
|
||||
PopSheetEntry(
|
||||
value: SortBy.ascending,
|
||||
enabled: value != SortBy.ascending,
|
||||
child: ListTile(
|
||||
enabled: value != SortBy.ascending,
|
||||
title: Text(context.l10n.sort_a_z),
|
||||
),
|
||||
),
|
||||
PopSheetEntry(
|
||||
value: SortBy.descending,
|
||||
enabled: value != SortBy.descending,
|
||||
child: ListTile(
|
||||
enabled: value != SortBy.descending,
|
||||
title: Text(context.l10n.sort_z_a),
|
||||
),
|
||||
PopSheetEntry(
|
||||
value: SortBy.newest,
|
||||
enabled: value != SortBy.newest,
|
||||
title: Text(context.l10n.sort_newest),
|
||||
),
|
||||
PopSheetEntry(
|
||||
value: SortBy.dateAdded,
|
||||
enabled: value != SortBy.dateAdded,
|
||||
child: ListTile(
|
||||
enabled: value != SortBy.dateAdded,
|
||||
title: Text(context.l10n.sort_date),
|
||||
),
|
||||
value: SortBy.oldest,
|
||||
enabled: value != SortBy.oldest,
|
||||
title: Text(context.l10n.sort_oldest),
|
||||
),
|
||||
PopSheetEntry(
|
||||
value: SortBy.artist,
|
||||
enabled: value != SortBy.artist,
|
||||
child: ListTile(
|
||||
enabled: value != SortBy.artist,
|
||||
title: Text(context.l10n.sort_artist),
|
||||
),
|
||||
),
|
||||
PopSheetEntry(
|
||||
value: SortBy.album,
|
||||
enabled: value != SortBy.album,
|
||||
child: ListTile(
|
||||
enabled: value != SortBy.album,
|
||||
title: Text(context.l10n.sort_album),
|
||||
),
|
||||
),
|
||||
],
|
||||
headings: [
|
||||
Text(context.l10n.sort_tracks),
|
||||
@ -79,7 +67,7 @@ class SortTracksDropdown extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
|
||||
child: DefaultTextStyle(
|
||||
style: Theme.of(context).textTheme.titleSmall!,
|
||||
style: theme.textTheme.titleSmall!,
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(SpotubeIcons.sort),
|
||||
|
||||
@ -1,5 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class SpotubePage<T> extends MaterialPage<T> {
|
||||
const SpotubePage({required super.child});
|
||||
}
|
||||
|
||||
class SpotubeSlidePage extends CustomTransitionPage {
|
||||
SpotubeSlidePage({
|
||||
required super.child,
|
||||
super.key,
|
||||
}) : super(
|
||||
reverseTransitionDuration: const Duration(milliseconds: 150),
|
||||
transitionDuration: const Duration(milliseconds: 150),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -18,6 +18,7 @@ class ThemedButtonsTabBar extends HookWidget implements PreferredSizeWidget {
|
||||
);
|
||||
|
||||
final breakpoint = useBreakpointValue(
|
||||
xs: 85.0,
|
||||
sm: 85.0,
|
||||
md: 35.0,
|
||||
others: 0.0,
|
||||
|
||||
@ -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/shared/adaptive/adaptive_pop_sheet_list.dart';
|
||||
import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart';
|
||||
import 'package:spotube/components/shared/dialogs/track_details_dialog.dart';
|
||||
import 'package:spotube/components/shared/heart_button.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
@ -29,6 +30,7 @@ enum TrackOptionValue {
|
||||
delete,
|
||||
playNext,
|
||||
favorite,
|
||||
details,
|
||||
}
|
||||
|
||||
class TrackOptions extends HookConsumerWidget {
|
||||
@ -163,6 +165,12 @@ class TrackOptions extends HookConsumerWidget {
|
||||
case TrackOptionValue.share:
|
||||
actionShare(context, track);
|
||||
break;
|
||||
case TrackOptionValue.details:
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => TrackDetailsDialog(track: track),
|
||||
);
|
||||
break;
|
||||
}
|
||||
},
|
||||
icon: const Icon(SpotubeIcons.moreHorizontal),
|
||||
@ -199,42 +207,32 @@ class TrackOptions extends HookConsumerWidget {
|
||||
LocalTrack => [
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.delete,
|
||||
child: ListTile(
|
||||
leading: const Icon(SpotubeIcons.trash),
|
||||
title: Text(context.l10n.delete),
|
||||
),
|
||||
)
|
||||
],
|
||||
_ => [
|
||||
if (!playlist.containsTrack(track)) ...[
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.addToQueue,
|
||||
child: ListTile(
|
||||
leading: const Icon(SpotubeIcons.queueAdd),
|
||||
title: Text(context.l10n.add_to_queue),
|
||||
),
|
||||
),
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.playNext,
|
||||
child: ListTile(
|
||||
leading: const Icon(SpotubeIcons.lightning),
|
||||
title: Text(context.l10n.play_next),
|
||||
),
|
||||
),
|
||||
] else
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.removeFromQueue,
|
||||
enabled: playlist.activeTrack?.id != track.id,
|
||||
child: ListTile(
|
||||
enabled: playlist.activeTrack?.id != track.id,
|
||||
leading: const Icon(SpotubeIcons.queueRemove),
|
||||
title: Text(context.l10n.remove_from_queue),
|
||||
),
|
||||
),
|
||||
if (favorites.me.hasData)
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.favorite,
|
||||
child: ListTile(
|
||||
leading: favorites.isLiked
|
||||
? const Icon(
|
||||
SpotubeIcons.heartFilled,
|
||||
@ -247,19 +245,15 @@ class TrackOptions extends HookConsumerWidget {
|
||||
: context.l10n.save_as_favorite,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (auth != null)
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.addToPlaylist,
|
||||
child: ListTile(
|
||||
leading: const Icon(SpotubeIcons.playlistAdd),
|
||||
title: Text(context.l10n.add_to_playlist),
|
||||
),
|
||||
),
|
||||
if (userPlaylist && auth != null)
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.removeFromPlaylist,
|
||||
child: ListTile(
|
||||
leading: (removeTrack.isMutating || !removeTrack.hasData) &&
|
||||
removingTrack.value == track.uri
|
||||
? const Center(
|
||||
@ -268,10 +262,8 @@ class TrackOptions extends HookConsumerWidget {
|
||||
: const Icon(SpotubeIcons.removeFilled),
|
||||
title: Text(context.l10n.remove_from_playlist),
|
||||
),
|
||||
),
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.blacklist,
|
||||
child: ListTile(
|
||||
leading: const Icon(SpotubeIcons.playlistRemove),
|
||||
iconColor: !isBlackListed ? Colors.red[400] : null,
|
||||
textColor: !isBlackListed ? Colors.red[400] : null,
|
||||
@ -281,14 +273,16 @@ class TrackOptions extends HookConsumerWidget {
|
||||
: context.l10n.add_to_blacklist,
|
||||
),
|
||||
),
|
||||
),
|
||||
PopSheetEntry(
|
||||
value: TrackOptionValue.share,
|
||||
child: ListTile(
|
||||
leading: const Icon(SpotubeIcons.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 HoverBuilder(
|
||||
permanentState: isPlaying || constrains.isSm ? true : null,
|
||||
permanentState: isPlaying || constrains.smAndDown ? true : null,
|
||||
builder: (context, isHovering) {
|
||||
return ListTile(
|
||||
selected: isPlaying,
|
||||
@ -89,7 +89,7 @@ class TrackTile extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (constrains.isSm)
|
||||
else if (constrains.smAndDown)
|
||||
const SizedBox(width: 16),
|
||||
if (onChanged != null)
|
||||
Checkbox.adaptive(
|
||||
@ -100,11 +100,15 @@ class TrackTile extends HookConsumerWidget {
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: UniversalImage(
|
||||
path: TypeConversionUtils.image_X_UrlString(
|
||||
track.album?.images,
|
||||
placeholder: ImagePlaceholder.albumArt,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
@ -176,6 +180,7 @@ class TrackTile extends HookConsumerWidget {
|
||||
track.album!.name!,
|
||||
"/album/${track.album?.id}",
|
||||
extra: track.album,
|
||||
push: true,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import 'package:spotify/spotify.dart';
|
||||
@ -8,6 +9,7 @@ import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/shared/adaptive/adaptive_pop_sheet_list.dart';
|
||||
import 'package:spotube/components/shared/dialogs/confirm_download_dialog.dart';
|
||||
import 'package:spotube/components/shared/dialogs/playlist_add_track_dialog.dart';
|
||||
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
|
||||
import 'package:spotube/components/shared/fallbacks/not_found.dart';
|
||||
import 'package:spotube/components/shared/sort_tracks_dropdown.dart';
|
||||
import 'package:spotube/components/shared/track_table/track_tile.dart';
|
||||
@ -31,10 +33,14 @@ class TracksTableView extends HookConsumerWidget {
|
||||
final bool isSliver;
|
||||
|
||||
final Widget? heading;
|
||||
|
||||
final VoidCallback? onFiltering;
|
||||
|
||||
const TracksTableView(
|
||||
this.tracks, {
|
||||
Key? key,
|
||||
this.onTrackPlayButtonPressed,
|
||||
this.onFiltering,
|
||||
this.userPlaylist = false,
|
||||
this.playlistId,
|
||||
this.heading,
|
||||
@ -43,7 +49,9 @@ class TracksTableView extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(context, ref) {
|
||||
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
ref.watch(ProxyPlaylistNotifier.provider);
|
||||
final playback = ref.watch(ProxyPlaylistNotifier.notifier);
|
||||
ref.watch(downloadManagerProvider);
|
||||
final downloader = ref.watch(downloadManagerProvider.notifier);
|
||||
@ -54,11 +62,31 @@ class TracksTableView extends HookConsumerWidget {
|
||||
final showCheck = useState<bool>(false);
|
||||
final sortBy = ref.watch(trackCollectionSortState(playlistId ?? ''));
|
||||
|
||||
final isFiltering = useState<bool>(false);
|
||||
|
||||
final searchController = useTextEditingController();
|
||||
final searchFocus = useFocusNode();
|
||||
|
||||
// this will trigger update on each change in searchController
|
||||
useValueListenable(searchController);
|
||||
|
||||
final filteredTracks = useMemoized(() {
|
||||
if (searchController.text.isEmpty) {
|
||||
return tracks;
|
||||
}
|
||||
return tracks
|
||||
.map((e) => (weightedRatio(e.name!, searchController.text), e))
|
||||
.sorted((a, b) => b.$1.compareTo(a.$1))
|
||||
.where((e) => e.$1 > 50)
|
||||
.map((e) => e.$2)
|
||||
.toList();
|
||||
}, [tracks, searchController.text]);
|
||||
|
||||
final sortedTracks = useMemoized(
|
||||
() {
|
||||
return ServiceUtils.sortTracks(tracks, sortBy);
|
||||
return ServiceUtils.sortTracks(filteredTracks, sortBy);
|
||||
},
|
||||
[tracks, sortBy],
|
||||
[filteredTracks, sortBy],
|
||||
);
|
||||
|
||||
final selectedTracks = useMemoized(
|
||||
@ -68,7 +96,7 @@ class TracksTableView extends HookConsumerWidget {
|
||||
[sortedTracks],
|
||||
);
|
||||
|
||||
final children = sortedTracks.isEmpty
|
||||
final children = tracks.isEmpty
|
||||
? [const NotFound(vertical: true)]
|
||||
: [
|
||||
if (heading != null) heading!,
|
||||
@ -105,7 +133,7 @@ class TracksTableView extends HookConsumerWidget {
|
||||
: const SizedBox(width: 16),
|
||||
),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
flex: 7,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
@ -139,6 +167,15 @@ class TracksTableView extends HookConsumerWidget {
|
||||
.state = value;
|
||||
},
|
||||
),
|
||||
ExpandableSearchButton(
|
||||
isFiltering: isFiltering,
|
||||
searchFocus: searchFocus,
|
||||
onPressed: (value) {
|
||||
if (isFiltering.value) {
|
||||
onFiltering?.call();
|
||||
}
|
||||
},
|
||||
),
|
||||
AdaptivePopSheetList(
|
||||
tooltip: context.l10n.more_actions,
|
||||
headings: [
|
||||
@ -147,55 +184,6 @@ class TracksTableView extends HookConsumerWidget {
|
||||
style: tableHeadStyle,
|
||||
),
|
||||
],
|
||||
children: [
|
||||
PopSheetEntry(
|
||||
enabled: selectedTracks.isNotEmpty,
|
||||
value: "download",
|
||||
child: ListTile(
|
||||
leading: const Icon(SpotubeIcons.download),
|
||||
enabled: selectedTracks.isNotEmpty,
|
||||
title: Text(
|
||||
context.l10n.download_count(selectedTracks.length),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!userPlaylist)
|
||||
PopSheetEntry(
|
||||
enabled: selectedTracks.isNotEmpty,
|
||||
value: "add-to-playlist",
|
||||
child: ListTile(
|
||||
leading: const Icon(SpotubeIcons.playlistAdd),
|
||||
enabled: selectedTracks.isNotEmpty,
|
||||
title: Text(
|
||||
context.l10n
|
||||
.add_count_to_playlist(selectedTracks.length),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopSheetEntry(
|
||||
enabled: selectedTracks.isNotEmpty,
|
||||
value: "add-to-queue",
|
||||
child: ListTile(
|
||||
leading: const Icon(SpotubeIcons.queueAdd),
|
||||
enabled: selectedTracks.isNotEmpty,
|
||||
title: Text(
|
||||
context.l10n
|
||||
.add_count_to_queue(selectedTracks.length),
|
||||
),
|
||||
),
|
||||
),
|
||||
PopSheetEntry(
|
||||
enabled: selectedTracks.isNotEmpty,
|
||||
value: "play-next",
|
||||
child: ListTile(
|
||||
leading: const Icon(SpotubeIcons.lightning),
|
||||
enabled: selectedTracks.isNotEmpty,
|
||||
title: Text(
|
||||
context.l10n.play_count_next(selectedTracks.length),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (action) async {
|
||||
switch (action) {
|
||||
case "download":
|
||||
@ -230,6 +218,9 @@ class TracksTableView extends HookConsumerWidget {
|
||||
case "play-next":
|
||||
{
|
||||
playback.addTracksAtFirst(selectedTracks);
|
||||
if (playlistId != null) {
|
||||
playback.addCollection(playlistId!);
|
||||
}
|
||||
selected.value = [];
|
||||
showCheck.value = false;
|
||||
break;
|
||||
@ -237,6 +228,9 @@ class TracksTableView extends HookConsumerWidget {
|
||||
case "add-to-queue":
|
||||
{
|
||||
playback.addTracks(selectedTracks);
|
||||
if (playlistId != null) {
|
||||
playback.addCollection(playlistId!);
|
||||
}
|
||||
selected.value = [];
|
||||
showCheck.value = false;
|
||||
break;
|
||||
@ -245,11 +239,53 @@ class TracksTableView extends HookConsumerWidget {
|
||||
}
|
||||
},
|
||||
icon: const Icon(SpotubeIcons.moreVertical),
|
||||
children: [
|
||||
PopSheetEntry(
|
||||
value: "download",
|
||||
leading: const Icon(SpotubeIcons.download),
|
||||
enabled: selectedTracks.isNotEmpty,
|
||||
title: Text(
|
||||
context.l10n.download_count(selectedTracks.length),
|
||||
),
|
||||
),
|
||||
if (!userPlaylist)
|
||||
PopSheetEntry(
|
||||
value: "add-to-playlist",
|
||||
leading: const Icon(SpotubeIcons.playlistAdd),
|
||||
enabled: selectedTracks.isNotEmpty,
|
||||
title: Text(
|
||||
context.l10n
|
||||
.add_count_to_playlist(selectedTracks.length),
|
||||
),
|
||||
),
|
||||
PopSheetEntry(
|
||||
enabled: selectedTracks.isNotEmpty,
|
||||
value: "add-to-queue",
|
||||
leading: const Icon(SpotubeIcons.queueAdd),
|
||||
title: Text(
|
||||
context.l10n
|
||||
.add_count_to_queue(selectedTracks.length),
|
||||
),
|
||||
),
|
||||
PopSheetEntry(
|
||||
enabled: selectedTracks.isNotEmpty,
|
||||
value: "play-next",
|
||||
leading: const Icon(SpotubeIcons.lightning),
|
||||
title: Text(
|
||||
context.l10n.play_count_next(selectedTracks.length),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
);
|
||||
}),
|
||||
ExpandableSearchField(
|
||||
isFiltering: isFiltering,
|
||||
searchController: searchController,
|
||||
searchFocus: searchFocus,
|
||||
),
|
||||
...sortedTracks.mapIndexed((i, track) {
|
||||
return TrackTile(
|
||||
index: i,
|
||||
@ -297,11 +333,17 @@ class TracksTableView extends HookConsumerWidget {
|
||||
}
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
}),
|
||||
// extra space for mobile devices where keyboard takes half of the screen
|
||||
if (isFiltering.value)
|
||||
SizedBox(
|
||||
height: mediaQuery.size.height * .75, //75% of the screen
|
||||
),
|
||||
];
|
||||
|
||||
if (isSliver) {
|
||||
return SliverSafeArea(
|
||||
top: false,
|
||||
sliver: SliverList(delegate: SliverChildListDelegate(children)),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,25 +1,39 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
extension ContainerBreakpoints on BoxConstraints {
|
||||
bool get isSm => biggest.width <= 640;
|
||||
bool get isXs => biggest.width <= 480;
|
||||
bool get isSm => biggest.width > 480 && biggest.width <= 640;
|
||||
bool get isMd => biggest.width > 640 && biggest.width <= 768;
|
||||
bool get isLg => biggest.width > 768 && biggest.width <= 1024;
|
||||
bool get isXl => biggest.width > 1024 && biggest.width <= 1280;
|
||||
bool get is2Xl => biggest.width > 1280;
|
||||
|
||||
bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl;
|
||||
bool get mdAndUp => isMd || isLg || isXl || is2Xl;
|
||||
bool get lgAndUp => isLg || isXl || is2Xl;
|
||||
bool get xlAndUp => isXl || is2Xl;
|
||||
|
||||
bool get smAndDown => isXs || isSm;
|
||||
bool get mdAndDown => isXs || isSm || isMd;
|
||||
bool get lgAndDown => isXs || isSm || isMd || isLg;
|
||||
bool get xlAndDown => isXs || isSm || isMd || isLg || isXl;
|
||||
}
|
||||
|
||||
extension ScreenBreakpoints on MediaQueryData {
|
||||
bool get isSm => size.width <= 640;
|
||||
bool get isXs => size.width <= 480;
|
||||
bool get isSm => size.width > 480 && size.width <= 640;
|
||||
bool get isMd => size.width > 640 && size.width <= 768;
|
||||
bool get isLg => size.width > 768 && size.width <= 1024;
|
||||
bool get isXl => size.width > 1024 && size.width <= 1280;
|
||||
bool get is2Xl => size.width > 1280;
|
||||
|
||||
bool get smAndUp => isSm || isMd || isLg || isXl || is2Xl;
|
||||
bool get mdAndUp => isMd || isLg || isXl || is2Xl;
|
||||
bool get lgAndUp => isLg || isXl || is2Xl;
|
||||
bool get xlAndUp => isXl || is2Xl;
|
||||
|
||||
bool get smAndDown => isXs || isSm;
|
||||
bool get mdAndDown => isXs || isSm || isMd;
|
||||
bool get lgAndDown => isXs || isSm || isMd || isLg;
|
||||
bool get xlAndDown => isXs || isSm || isMd || isLg || isXl;
|
||||
}
|
||||
|
||||
@ -1,6 +1,28 @@
|
||||
import 'package:duration/locale.dart';
|
||||
import 'package:spotube/utils/primitive_utils.dart';
|
||||
import 'package:duration/duration.dart';
|
||||
|
||||
extension DurationToHumanReadableString on Duration {
|
||||
toHumanReadableString() =>
|
||||
String toHumanReadableString() =>
|
||||
"${inMinutes.remainder(60)}:${PrimitiveUtils.zeroPadNumStr(inSeconds.remainder(60))}";
|
||||
|
||||
String format({
|
||||
DurationTersity tersity = DurationTersity.second,
|
||||
DurationTersity upperTersity = DurationTersity.week,
|
||||
DurationLocale locale = const EnglishDurationLocale(),
|
||||
String? spacer,
|
||||
String? delimiter,
|
||||
String? conjugation,
|
||||
bool abbreviated = false,
|
||||
}) =>
|
||||
printDuration(
|
||||
this,
|
||||
tersity: tersity,
|
||||
upperTersity: upperTersity,
|
||||
locale: locale,
|
||||
spacer: spacer,
|
||||
delimiter: delimiter,
|
||||
conjugation: conjugation,
|
||||
abbreviated: abbreviated,
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
|
||||
T useBreakpointValue<T>({
|
||||
T? xs,
|
||||
T? sm,
|
||||
T? md,
|
||||
T? lg,
|
||||
@ -10,8 +11,12 @@ T useBreakpointValue<T>({
|
||||
T? xxl,
|
||||
T? others,
|
||||
}) {
|
||||
final isSomeNull =
|
||||
sm == null || md == null || lg == null || xl == null || xxl == null;
|
||||
final isSomeNull = xs == null ||
|
||||
sm == null ||
|
||||
md == null ||
|
||||
lg == null ||
|
||||
xl == null ||
|
||||
xxl == null;
|
||||
assert(
|
||||
(isSomeNull && others != null) || (!isSomeNull && others == null),
|
||||
'You must provide a value for all breakpoints or a default value for others',
|
||||
@ -20,7 +25,9 @@ T useBreakpointValue<T>({
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
if (isSomeNull) {
|
||||
if (mediaQuery.isSm) {
|
||||
if (mediaQuery.isXs) {
|
||||
return xs ?? others!;
|
||||
} else if (mediaQuery.isSm) {
|
||||
return sm ?? others!;
|
||||
} else if (mediaQuery.isMd) {
|
||||
return md ?? others!;
|
||||
@ -32,7 +39,9 @@ T useBreakpointValue<T>({
|
||||
return lg ?? others!;
|
||||
}
|
||||
} else {
|
||||
if (mediaQuery.isSm) {
|
||||
if (mediaQuery.isXs) {
|
||||
return xs;
|
||||
} else if (mediaQuery.isSm) {
|
||||
return sm;
|
||||
} else if (mediaQuery.isMd) {
|
||||
return md;
|
||||
|
||||
@ -37,7 +37,6 @@
|
||||
"none": "কোনটিই না",
|
||||
"sort_a_z": "A-Z ক্রমে সাজান",
|
||||
"sort_z_a": "Z-A ক্রমে সাজান",
|
||||
"sort_date": "তারিখের ক্রমে সাজান",
|
||||
"sort_artist": "শিল্পীর ক্রমে সাজান",
|
||||
"sort_album": "অ্যালবামের ক্রমে সাজান",
|
||||
"sort_tracks": "গানের ক্রম",
|
||||
|
||||
@ -37,7 +37,6 @@
|
||||
"none": "None",
|
||||
"sort_a_z": "Sort by A-Z",
|
||||
"sort_z_a": "Sort by Z-A",
|
||||
"sort_date": "Sort by date",
|
||||
"sort_artist": "Sort by Artist",
|
||||
"sort_album": "Sort by Album",
|
||||
"sort_tracks": "Sort Tracks",
|
||||
@ -230,5 +229,22 @@
|
||||
"download_agreement_2": "I'll support the Artist wherever I can and I'm only doing this because I don't have money to buy their art",
|
||||
"download_agreement_3": "I'm completely aware that my IP can get blocked on YouTube & I don't hold Spotube or his owners/contributors responsible for any accidents caused by my current action",
|
||||
"decline": "Decline",
|
||||
"accept": "Accept"
|
||||
"accept": "Accept",
|
||||
"details": "Details",
|
||||
"youtube": "YouTube",
|
||||
"channel": "Channel",
|
||||
"likes": "Likes",
|
||||
"dislikes": "Dislikes",
|
||||
"views": "Views",
|
||||
"streamUrl": "Stream URL",
|
||||
"stop": "Stop",
|
||||
"sort_newest": "Sort by newest added",
|
||||
"sort_oldest": "Sort by oldest added",
|
||||
"sleep_timer": "Sleep Timer",
|
||||
"mins": "{minutes} Minutes",
|
||||
"hours": "{hours} Hours",
|
||||
"hour": "{hours} Hour",
|
||||
"custom_hours": "Custom Hours",
|
||||
"logs": "Logs",
|
||||
"developers": "Developers"
|
||||
}
|
||||
@ -37,7 +37,6 @@
|
||||
"none": "Aucun",
|
||||
"sort_a_z": "Trier par ordre alphabétique",
|
||||
"sort_z_a": "Trier par ordre alphabétique inverse",
|
||||
"sort_date": "Trier par date",
|
||||
"sort_artist": "Trier par artiste",
|
||||
"sort_album": "Trier par album",
|
||||
"sort_tracks": "Trier les pistes",
|
||||
|
||||
@ -37,7 +37,6 @@
|
||||
"none": "कोई नहीं",
|
||||
"sort_a_z": "A-Z सॉर्ट करें",
|
||||
"sort_z_a": "Z-A सॉर्ट करें",
|
||||
"sort_date": "तिथि के अनुसार सॉर्ट करें",
|
||||
"sort_artist": "कलाकार के अनुसार सॉर्ट करें",
|
||||
"sort_album": "एल्बम के अनुसार सॉर्ट करें",
|
||||
"sort_tracks": "ट्रैक को सॉर्ट करें",
|
||||
|
||||
250
lib/l10n/app_ja.arb
Normal file
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
|
||||
/// ChatGPT (GPT 3.5) XD => Hindi, French
|
||||
/// maboroshin@github => Japanese
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class L10n {
|
||||
@ -11,5 +12,6 @@ class L10n {
|
||||
const Locale('fr', 'FR'),
|
||||
const Locale('hi', 'IN'),
|
||||
const Locale ('ge', 'DE'),
|
||||
const Locale('ja', 'JA'),
|
||||
];
|
||||
}
|
||||
|
||||
@ -26,7 +26,6 @@ import 'package:spotube/provider/palette_provider.dart';
|
||||
import 'package:spotube/provider/user_preferences_provider.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/themes/theme.dart';
|
||||
import 'package:spotube/utils/custom_toast_handler.dart';
|
||||
import 'package:spotube/utils/persisted_state_notifier.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
import 'package:system_theme/system_theme.dart';
|
||||
@ -122,23 +121,18 @@ Future<void> main(List<String> rawArgs) async {
|
||||
releaseConfig: CatcherOptions(
|
||||
SilentReportMode(),
|
||||
[
|
||||
if (arguments["verbose"] ?? false)
|
||||
ConsoleHandler(
|
||||
enableDeviceParameters: false,
|
||||
enableApplicationParameters: false,
|
||||
),
|
||||
if (arguments["verbose"] ?? false) ConsoleHandler(),
|
||||
FileHandler(
|
||||
await getLogsPath(),
|
||||
printLogs: false,
|
||||
),
|
||||
CustomToastHandler(),
|
||||
],
|
||||
),
|
||||
runAppFunction: () {
|
||||
runApp(
|
||||
DevicePreview(
|
||||
availableLocales: L10n.all,
|
||||
enabled: !kReleaseMode,
|
||||
enabled: !kReleaseMode && DesktopTools.platform.isDesktop,
|
||||
builder: (context) {
|
||||
return ProviderScope(
|
||||
child: QueryClientProvider(
|
||||
|
||||
@ -4,7 +4,7 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/components/shared/heart_button.dart';
|
||||
import 'package:spotube/components/shared/track_table/track_collection_view.dart';
|
||||
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart';
|
||||
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart';
|
||||
@ -32,6 +32,7 @@ class AlbumPage extends HookConsumerWidget {
|
||||
sortedTracks,
|
||||
initialIndex: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||
);
|
||||
playback.addCollection(album.id!);
|
||||
} else if (isPlaylistPlaying &&
|
||||
currentTrack.id != null &&
|
||||
currentTrack.id != playlist.activeTrack?.id) {
|
||||
@ -67,7 +68,7 @@ class AlbumPage extends HookConsumerWidget {
|
||||
tracksSnapshot: tracksSnapshot,
|
||||
album: album,
|
||||
routePath: "/album/${album.id}",
|
||||
bottomSpace: mediaQuery.isSm || mediaQuery.isMd,
|
||||
bottomSpace: mediaQuery.mdAndDown,
|
||||
onPlay: ([track]) {
|
||||
if (tracksSnapshot.hasData) {
|
||||
if (!isAlbumPlaying) {
|
||||
@ -101,6 +102,7 @@ class AlbumPage extends HookConsumerWidget {
|
||||
TypeConversionUtils.simpleTrack_X_Track(track, album))
|
||||
.toList(),
|
||||
);
|
||||
playback.addCollection(album.id!);
|
||||
}
|
||||
},
|
||||
onShare: () {
|
||||
|
||||
@ -39,6 +39,7 @@ class ArtistPage extends HookConsumerWidget {
|
||||
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
||||
final textTheme = theme.textTheme;
|
||||
final chipTextVariant = useBreakpointValue(
|
||||
xs: textTheme.bodySmall,
|
||||
sm: textTheme.bodySmall,
|
||||
md: textTheme.bodyMedium,
|
||||
lg: textTheme.bodyLarge,
|
||||
@ -49,6 +50,7 @@ class ArtistPage extends HookConsumerWidget {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
|
||||
final avatarWidth = useBreakpointValue(
|
||||
xs: mediaQuery.size.width * 0.50,
|
||||
sm: mediaQuery.size.width * 0.50,
|
||||
md: mediaQuery.size.width * 0.40,
|
||||
lg: mediaQuery.size.width * 0.18,
|
||||
@ -155,7 +157,7 @@ class ArtistPage extends HookConsumerWidget {
|
||||
),
|
||||
Text(
|
||||
data.name!,
|
||||
style: mediaQuery.isSm
|
||||
style: mediaQuery.smAndDown
|
||||
? textTheme.headlineSmall
|
||||
: textTheme.headlineMedium,
|
||||
),
|
||||
@ -166,8 +168,9 @@ class ArtistPage extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
fontWeight:
|
||||
mediaQuery.isSm ? null : FontWeight.bold,
|
||||
fontWeight: mediaQuery.mdAndUp
|
||||
? FontWeight.bold
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
@ -35,7 +35,7 @@ class DesktopLoginPage extends HookConsumerWidget {
|
||||
children: [
|
||||
Assets.spotubeLogoPng.image(
|
||||
width: MediaQuery.of(context).size.width *
|
||||
(mediaQuery.isSm || mediaQuery.isMd ? .5 : .3),
|
||||
(mediaQuery.mdAndDown ? .5 : .3),
|
||||
),
|
||||
Text(
|
||||
context.l10n.add_spotify_credentials,
|
||||
|
||||
@ -54,7 +54,7 @@ class LoginTutorial extends ConsumerWidget {
|
||||
overrideDone: FilledButton(
|
||||
onPressed: authenticationNotifier.isLoggedIn
|
||||
? () {
|
||||
ServiceUtils.navigate(context, "/");
|
||||
ServiceUtils.push(context, "/");
|
||||
}
|
||||
: null,
|
||||
child: Center(child: Text(context.l10n.done)),
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotify/spotify.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/genre/category_card.dart';
|
||||
import 'package:spotube/components/shared/compact_search.dart';
|
||||
import 'package:spotube/components/shared/expandable_search/expandable_search.dart';
|
||||
import 'package:spotube/components/shared/shimmers/shimmer_categories.dart';
|
||||
import 'package:spotube/components/shared/waypoint.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
@ -18,15 +21,21 @@ class GenrePage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final theme = Theme.of(context);
|
||||
final scrollController = useScrollController();
|
||||
final recommendationMarket = ref.watch(
|
||||
userPreferencesProvider.select((s) => s.recommendationMarket),
|
||||
);
|
||||
final categoriesQuery = useQueries.category.list(ref, recommendationMarket);
|
||||
final isFiltering = useState(false);
|
||||
|
||||
final isMounted = useIsMounted();
|
||||
|
||||
final searchText = useState("");
|
||||
final searchController = useTextEditingController();
|
||||
final searchFocus = useFocusNode();
|
||||
|
||||
useValueListenable(searchController);
|
||||
|
||||
final categories = useMemoized(
|
||||
() {
|
||||
final categories = categoriesQuery.pages
|
||||
@ -34,12 +43,12 @@ class GenrePage extends HookConsumerWidget {
|
||||
(page) => page.items ?? const Iterable.empty(),
|
||||
)
|
||||
.toList();
|
||||
if (searchText.value.isEmpty) {
|
||||
if (searchController.text.isEmpty) {
|
||||
return categories;
|
||||
}
|
||||
return categories
|
||||
.map((e) => (
|
||||
weightedRatio(e.name!, searchText.value),
|
||||
weightedRatio(e.name!, searchController.text),
|
||||
e,
|
||||
))
|
||||
.sorted((a, b) => b.$1.compareTo(a.$1))
|
||||
@ -47,14 +56,7 @@ class GenrePage extends HookConsumerWidget {
|
||||
.map((e) => e.$2)
|
||||
.toList();
|
||||
},
|
||||
[categoriesQuery.pages, searchText.value],
|
||||
);
|
||||
|
||||
final searchbar = CompactSearch(
|
||||
onChanged: (value) {
|
||||
searchText.value = value;
|
||||
},
|
||||
placeholder: context.l10n.genre_categories_filter,
|
||||
[categoriesQuery.pages, searchController.text],
|
||||
);
|
||||
|
||||
final list = RefreshIndicator(
|
||||
@ -68,13 +70,20 @@ class GenrePage extends HookConsumerWidget {
|
||||
}
|
||||
},
|
||||
controller: scrollController,
|
||||
child: Column(
|
||||
children: [
|
||||
ExpandableSearchField(
|
||||
isFiltering: isFiltering,
|
||||
searchController: searchController,
|
||||
searchFocus: searchFocus,
|
||||
),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
itemCount: categories.length,
|
||||
shrinkWrap: true,
|
||||
itemBuilder: (context, index) {
|
||||
return AnimatedCrossFade(
|
||||
crossFadeState: searchText.value.isEmpty &&
|
||||
crossFadeState: searchController.text.isEmpty &&
|
||||
index == categories.length - 1 &&
|
||||
categoriesQuery.hasNextPage
|
||||
? CrossFadeState.showFirst
|
||||
@ -86,6 +95,9 @@ class GenrePage extends HookConsumerWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return Stack(
|
||||
@ -94,7 +106,20 @@ class GenrePage extends HookConsumerWidget {
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 10,
|
||||
child: searchbar,
|
||||
child: ExpandableSearchButton(
|
||||
isFiltering: isFiltering,
|
||||
searchFocus: searchFocus,
|
||||
icon: const Icon(SpotubeIcons.search),
|
||||
onPressed: (value) {
|
||||
if (isFiltering.value) {
|
||||
scrollController.animateTo(
|
||||
0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -10,6 +10,7 @@ import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart'
|
||||
import 'package:spotube/components/shared/waypoint.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
import 'package:spotube/provider/authentication_provider.dart';
|
||||
import 'package:spotube/services/queries/queries.dart';
|
||||
import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
|
||||
@ -94,6 +95,7 @@ class PersonalizedPage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final auth = ref.watch(AuthenticationNotifier.provider);
|
||||
final featuredPlaylistsQuery = useQueries.playlist.featured(ref);
|
||||
final playlists = useMemoized(
|
||||
() => featuredPlaylistsQuery.pages
|
||||
@ -132,6 +134,7 @@ class PersonalizedPage extends HookConsumerWidget {
|
||||
hasNextPage: featuredPlaylistsQuery.hasNextPage,
|
||||
onFetchMore: featuredPlaylistsQuery.fetchNext,
|
||||
),
|
||||
if (auth != null)
|
||||
PersonalizedItemCard(
|
||||
albums: albums,
|
||||
title: context.l10n.new_releases,
|
||||
|
||||
@ -248,7 +248,11 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
|
||||
title: Text(context.l10n.generate_playlist),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SafeArea(
|
||||
body: SliderTheme(
|
||||
data: const SliderThemeData(
|
||||
overlayShape: RoundSliderOverlayShape(),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: LayoutBuilder(builder: (context, constrains) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@ -494,6 +498,7 @@ class PlaylistGeneratorPage extends HookConsumerWidget {
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -111,10 +111,22 @@ class SyncedLyrics extends HookConsumerWidget {
|
||||
index: index,
|
||||
controller: controller,
|
||||
child: lyricSlice.text.isEmpty
|
||||
? Container()
|
||||
? Container(
|
||||
padding: index == lyricValue.lyrics.length - 1
|
||||
? EdgeInsets.only(
|
||||
bottom:
|
||||
MediaQuery.of(context).size.height /
|
||||
2,
|
||||
)
|
||||
: null,
|
||||
)
|
||||
: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
padding: index == lyricValue.lyrics.length - 1
|
||||
? const EdgeInsets.all(8.0).copyWith(
|
||||
bottom: 100,
|
||||
)
|
||||
: const EdgeInsets.all(8.0),
|
||||
child: AnimatedDefaultTextStyle(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
style: TextStyle(
|
||||
|
||||
@ -9,10 +9,14 @@ import 'package:spotube/collections/assets.gen.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/player/player_actions.dart';
|
||||
import 'package:spotube/components/player/player_controls.dart';
|
||||
import 'package:spotube/components/player/player_queue.dart';
|
||||
import 'package:spotube/components/player/volume_slider.dart';
|
||||
import 'package:spotube/components/shared/animated_gradient.dart';
|
||||
import 'package:spotube/components/shared/dialogs/track_details_dialog.dart';
|
||||
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_custom_status_bar_color.dart';
|
||||
import 'package:spotube/hooks/use_palette_color.dart';
|
||||
import 'package:spotube/models/local_track.dart';
|
||||
@ -74,6 +78,24 @@ class PlayerView extends HookConsumerWidget {
|
||||
foregroundColor: titleTextColor,
|
||||
toolbarOpacity: 1,
|
||||
leading: const BackButton(),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(SpotubeIcons.info, size: 18),
|
||||
tooltip: context.l10n.details,
|
||||
style: IconButton.styleFrom(foregroundColor: bodyTextColor),
|
||||
onPressed: currentTrack == null
|
||||
? null
|
||||
: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return TrackDetailsDialog(
|
||||
track: currentTrack,
|
||||
);
|
||||
});
|
||||
},
|
||||
)
|
||||
],
|
||||
),
|
||||
extendBodyBehindAppBar: true,
|
||||
body: SizedBox(
|
||||
@ -106,9 +128,8 @@ class PlayerView extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Container(
|
||||
Container(
|
||||
margin: const EdgeInsets.all(8),
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: 300, maxWidth: 300),
|
||||
decoration: BoxDecoration(
|
||||
@ -131,7 +152,6 @@ class PlayerView extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 60),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
@ -182,12 +202,63 @@ class PlayerView extends HookConsumerWidget {
|
||||
const SizedBox(height: 25),
|
||||
PlayerActions(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
floatingQueue: false,
|
||||
extraActions: [
|
||||
showQueue: false,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(SpotubeIcons.queue),
|
||||
label: Text(context.l10n.queue),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: bodyTextColor,
|
||||
side: BorderSide(
|
||||
color: bodyTextColor ?? Colors.white,
|
||||
),
|
||||
),
|
||||
onPressed: currentTrack != null
|
||||
? () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isDismissible: true,
|
||||
enableDrag: true,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.black12,
|
||||
barrierColor: Colors.black12,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius:
|
||||
BorderRadius.circular(10),
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight:
|
||||
MediaQuery.of(context)
|
||||
.size
|
||||
.height *
|
||||
.7,
|
||||
),
|
||||
builder: (context) {
|
||||
return PlayerQueue(
|
||||
floating: false);
|
||||
},
|
||||
);
|
||||
}
|
||||
: null),
|
||||
),
|
||||
if (auth != null) const SizedBox(width: 10),
|
||||
if (auth != null)
|
||||
IconButton(
|
||||
tooltip: "Open Lyrics",
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
label: Text(context.l10n.lyrics),
|
||||
icon: const Icon(SpotubeIcons.music),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: bodyTextColor,
|
||||
side: BorderSide(
|
||||
color: bodyTextColor ?? Colors.white,
|
||||
),
|
||||
),
|
||||
onPressed: () {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
@ -203,18 +274,39 @@ class PlayerView extends HookConsumerWidget {
|
||||
),
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
maxHeight:
|
||||
MediaQuery.of(context).size.height *
|
||||
maxHeight: MediaQuery.of(context)
|
||||
.size
|
||||
.height *
|
||||
0.8,
|
||||
),
|
||||
builder: (context) =>
|
||||
const LyricsPage(isModal: true),
|
||||
);
|
||||
},
|
||||
)
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 25)
|
||||
const SizedBox(height: 25),
|
||||
SliderTheme(
|
||||
data: theme.sliderTheme.copyWith(
|
||||
activeTrackColor: titleTextColor,
|
||||
inactiveTrackColor: bodyTextColor,
|
||||
thumbColor: titleTextColor,
|
||||
overlayColor: titleTextColor?.withOpacity(0.2),
|
||||
trackHeight: 2,
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 8,
|
||||
),
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: VolumeSlider(
|
||||
fullWidth: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -2,7 +2,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/components/shared/heart_button.dart';
|
||||
import 'package:spotube/components/shared/track_table/track_collection_view.dart';
|
||||
import 'package:spotube/components/shared/track_table/track_collection_view/track_collection_view.dart';
|
||||
import 'package:spotube/components/shared/track_table/tracks_table_view.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/models/logger.dart';
|
||||
@ -36,6 +36,7 @@ class PlaylistView extends HookConsumerWidget {
|
||||
initialIndex: sortedTracks.indexWhere((s) => s.id == currentTrack?.id),
|
||||
autoPlay: true,
|
||||
);
|
||||
playback.addCollection(playlist.id!);
|
||||
} else if (isPlaylistPlaying &&
|
||||
currentTrack.id != null &&
|
||||
currentTrack.id != proxyPlaylist.activeTrack?.id) {
|
||||
@ -97,9 +98,10 @@ class PlaylistView extends HookConsumerWidget {
|
||||
onAddToQueue: () {
|
||||
if (tracksSnapshot.hasData && !isPlaylistPlaying) {
|
||||
playlistNotifier.addTracks(tracksSnapshot.data!);
|
||||
playlistNotifier.addCollection(playlist.id!);
|
||||
}
|
||||
},
|
||||
bottomSpace: mediaQuery.isSm || mediaQuery.isMd,
|
||||
bottomSpace: mediaQuery.mdAndDown,
|
||||
showShare: playlist.id != "user-liked-tracks",
|
||||
routePath: "/playlist/${playlist.id}",
|
||||
onShare: () {
|
||||
|
||||
@ -77,6 +77,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
),
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(SpotubeIcons.search),
|
||||
hintText: "${context.l10n.search}...",
|
||||
|
||||
@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:spotube/collections/assets.gen.dart';
|
||||
import 'package:spotube/components/shared/image/universal_image.dart';
|
||||
import 'package:spotube/components/shared/links/hyper_link.dart';
|
||||
import 'package:spotube/components/shared/page_window_title_bar.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/hooks/use_package_info.dart';
|
||||
@ -24,6 +25,8 @@ class AboutSpotube extends HookConsumerWidget {
|
||||
final license = ref.watch(_licenseProvider);
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final colon = Text(":");
|
||||
|
||||
return Scaffold(
|
||||
appBar: PageWindowTitleBar(
|
||||
leading: const BackButton(),
|
||||
@ -40,76 +43,74 @@ class AboutSpotube extends HookConsumerWidget {
|
||||
),
|
||||
Center(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.spotube_description,
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
Table(
|
||||
columnWidths: const {
|
||||
0: FixedColumnWidth(95),
|
||||
1: FixedColumnWidth(10),
|
||||
2: IntrinsicColumnWidth(),
|
||||
},
|
||||
children: [
|
||||
Text(
|
||||
"${context.l10n.founder}: ${context.l10n.kingkor_roy_tirtho}",
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
TableRow(
|
||||
children: [
|
||||
Text(context.l10n.founder),
|
||||
colon,
|
||||
Hyperlink(
|
||||
context.l10n.kingkor_roy_tirtho,
|
||||
"https://github.com/KRTirtho",
|
||||
)
|
||||
],
|
||||
),
|
||||
TableRow(
|
||||
children: [
|
||||
Text(context.l10n.version),
|
||||
colon,
|
||||
Text("v${packageInfo.version}")
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 5),
|
||||
CircleAvatar(
|
||||
radius: 20,
|
||||
child: ClipOval(
|
||||
child: Image.network(
|
||||
"https://avatars.githubusercontent.com/u/61944859?v=4",
|
||||
),
|
||||
TableRow(
|
||||
children: [
|
||||
Text(context.l10n.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",
|
||||
),
|
||||
],
|
||||
),
|
||||
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(
|
||||
TableRow(
|
||||
children: [
|
||||
Text(context.l10n.license),
|
||||
colon,
|
||||
const Hyperlink(
|
||||
"BSD-4-Clause",
|
||||
"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(
|
||||
TableRow(
|
||||
children: [
|
||||
Text(context.l10n.bug_issues),
|
||||
colon,
|
||||
const Hyperlink(
|
||||
"github.com/KRTirtho/spotube/issues",
|
||||
"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/components/shared/page_window_title_bar.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/provider/blacklist_provider.dart';
|
||||
|
||||
class BlackListPage extends HookConsumerWidget {
|
||||
@ -38,7 +39,7 @@ class BlackListPage extends HookConsumerWidget {
|
||||
|
||||
return Scaffold(
|
||||
appBar: PageWindowTitleBar(
|
||||
title: const Text("Blacklist"),
|
||||
title: Text(context.l10n.blacklist),
|
||||
centerTitle: true,
|
||||
leading: const BackButton(),
|
||||
),
|
||||
@ -49,9 +50,9 @@ class BlackListPage extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextField(
|
||||
onChanged: (value) => searchText.value = value,
|
||||
decoration: const InputDecoration(
|
||||
hintText: "Search",
|
||||
prefixIcon: Icon(SpotubeIcons.search),
|
||||
decoration: InputDecoration(
|
||||
hintText: context.l10n.search,
|
||||
prefixIcon: const Icon(SpotubeIcons.search),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
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(
|
||||
heading: context.l10n.about,
|
||||
children: [
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
// import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:background_downloader/background_downloader.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:http/http.dart';
|
||||
@ -19,8 +19,8 @@ import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
|
||||
final Ref ref;
|
||||
|
||||
final StreamController /* <TaskProgressUpdate> */ activeDownloadProgress;
|
||||
final StreamController /* <Task> */ failedDownloads;
|
||||
final StreamController<TaskProgressUpdate> activeDownloadProgress;
|
||||
final StreamController<Task> failedDownloads;
|
||||
Track? _activeItem;
|
||||
|
||||
FutureOr<bool> Function(Track)? onFileExists;
|
||||
@ -29,78 +29,78 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
|
||||
: activeDownloadProgress = StreamController.broadcast(),
|
||||
failedDownloads = StreamController.broadcast(),
|
||||
super([]) {
|
||||
// FileDownloader().registerCallbacks(
|
||||
// group: FileDownloader.defaultGroup,
|
||||
// taskNotificationTapCallback: (task, notificationType) {
|
||||
// router.go("/library");
|
||||
// },
|
||||
// taskStatusCallback: (update) async {
|
||||
// if (update.status == TaskStatus.running) {
|
||||
// _activeItem =
|
||||
// state.firstWhereOrNull((track) => track.id == update.task.taskId);
|
||||
// state = state.toList();
|
||||
// }
|
||||
FileDownloader().registerCallbacks(
|
||||
group: FileDownloader.defaultGroup,
|
||||
taskNotificationTapCallback: (task, notificationType) {
|
||||
router.go("/library");
|
||||
},
|
||||
taskStatusCallback: (update) async {
|
||||
if (update.status == TaskStatus.running) {
|
||||
_activeItem =
|
||||
state.firstWhereOrNull((track) => track.id == update.task.taskId);
|
||||
state = state.toList();
|
||||
}
|
||||
|
||||
// if (update.status == TaskStatus.failed ||
|
||||
// update.status == TaskStatus.notFound) {
|
||||
// failedDownloads.add(update.task);
|
||||
// }
|
||||
if (update.status == TaskStatus.failed ||
|
||||
update.status == TaskStatus.notFound) {
|
||||
failedDownloads.add(update.task);
|
||||
}
|
||||
|
||||
// if (update.status == TaskStatus.complete) {
|
||||
// final track =
|
||||
// state.firstWhere((element) => element.id == update.task.taskId);
|
||||
if (update.status == TaskStatus.complete) {
|
||||
final track =
|
||||
state.firstWhere((element) => element.id == update.task.taskId);
|
||||
|
||||
// // resetting the replace downloaded file state on queue completion
|
||||
// if (state.last == track) {
|
||||
// ref.read(replaceDownloadedFileState.notifier).state = null;
|
||||
// }
|
||||
// resetting the replace downloaded file state on queue completion
|
||||
if (state.last == track) {
|
||||
ref.read(replaceDownloadedFileState.notifier).state = null;
|
||||
}
|
||||
|
||||
// state = state
|
||||
// .where((element) => element.id != update.task.taskId)
|
||||
// .toList();
|
||||
state = state
|
||||
.where((element) => element.id != update.task.taskId)
|
||||
.toList();
|
||||
|
||||
// final imageUri = TypeConversionUtils.image_X_UrlString(
|
||||
// track.album?.images ?? [],
|
||||
// placeholder: ImagePlaceholder.online,
|
||||
// );
|
||||
// final response = await get(Uri.parse(imageUri));
|
||||
final imageUri = TypeConversionUtils.image_X_UrlString(
|
||||
track.album?.images ?? [],
|
||||
placeholder: ImagePlaceholder.online,
|
||||
);
|
||||
final response = await get(Uri.parse(imageUri));
|
||||
|
||||
// final tempFile = File(await update.task.filePath());
|
||||
final tempFile = File(await update.task.filePath());
|
||||
|
||||
// final file = tempFile.copySync(_getPathForTrack(track));
|
||||
final file = tempFile.copySync(_getPathForTrack(track));
|
||||
|
||||
// await tempFile.delete();
|
||||
await tempFile.delete();
|
||||
|
||||
// await MetadataGod.writeMetadata(
|
||||
// file: file.path,
|
||||
// metadata: Metadata(
|
||||
// title: track.name,
|
||||
// artist: track.artists?.map((a) => a.name).join(", "),
|
||||
// album: track.album?.name,
|
||||
// albumArtist: track.artists?.map((a) => a.name).join(", "),
|
||||
// year: track.album?.releaseDate != null
|
||||
// ? int.tryParse(track.album!.releaseDate!)
|
||||
// : null,
|
||||
// trackNumber: track.trackNumber,
|
||||
// discNumber: track.discNumber,
|
||||
// durationMs: track.durationMs?.toDouble(),
|
||||
// fileSize: file.lengthSync(),
|
||||
// trackTotal: track.album?.tracks?.length,
|
||||
// picture: response.headers['content-type'] != null
|
||||
// ? Picture(
|
||||
// data: response.bodyBytes,
|
||||
// mimeType: response.headers['content-type']!,
|
||||
// )
|
||||
// : null,
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// },
|
||||
// taskProgressCallback: (update) {
|
||||
// activeDownloadProgress.add(update);
|
||||
// },
|
||||
// );
|
||||
// FileDownloader().trackTasks(markDownloadedComplete: true);
|
||||
await MetadataGod.writeMetadata(
|
||||
file: file.path,
|
||||
metadata: Metadata(
|
||||
title: track.name,
|
||||
artist: track.artists?.map((a) => a.name).join(", "),
|
||||
album: track.album?.name,
|
||||
albumArtist: track.artists?.map((a) => a.name).join(", "),
|
||||
year: track.album?.releaseDate != null
|
||||
? int.tryParse(track.album!.releaseDate!)
|
||||
: null,
|
||||
trackNumber: track.trackNumber,
|
||||
discNumber: track.discNumber,
|
||||
durationMs: track.durationMs?.toDouble(),
|
||||
fileSize: file.lengthSync(),
|
||||
trackTotal: track.album?.tracks?.length,
|
||||
picture: response.headers['content-type'] != null
|
||||
? Picture(
|
||||
data: response.bodyBytes,
|
||||
mimeType: response.headers['content-type']!,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
taskProgressCallback: (update) {
|
||||
activeDownloadProgress.add(update);
|
||||
},
|
||||
);
|
||||
FileDownloader().trackTasks(markDownloadedComplete: true);
|
||||
}
|
||||
|
||||
UserPreferences get preferences => ref.read(userPreferencesProvider);
|
||||
@ -115,9 +115,9 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
|
||||
"${track.name} - ${track.artists?.map((a) => a.name).join(", ")}.m4a",
|
||||
);
|
||||
|
||||
Future /* <Task> */ _ensureSpotubeTrack(Track track) async {
|
||||
Future<Task> _ensureSpotubeTrack(Track track) async {
|
||||
if (state.any((element) => element.id == track.id)) {
|
||||
final task = null /* await FileDownloader().taskForId(track.id!) */;
|
||||
final task = await FileDownloader().taskForId(track.id!);
|
||||
if (task != null) {
|
||||
return task;
|
||||
}
|
||||
@ -133,17 +133,16 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
|
||||
pipedClient,
|
||||
);
|
||||
state = [...state, spotubeTrack];
|
||||
// final task = DownloadTask(
|
||||
// url: spotubeTrack.ytUri,
|
||||
// baseDirectory: BaseDirectory.applicationSupport,
|
||||
// taskId: spotubeTrack.id!,
|
||||
// updates: Updates.statusAndProgress,
|
||||
// );
|
||||
// return task;
|
||||
return null;
|
||||
final task = DownloadTask(
|
||||
url: spotubeTrack.ytUri,
|
||||
baseDirectory: BaseDirectory.applicationSupport,
|
||||
taskId: spotubeTrack.id!,
|
||||
updates: Updates.statusAndProgress,
|
||||
);
|
||||
return task;
|
||||
}
|
||||
|
||||
Future /* <Task?> */ enqueue(Track track) async {
|
||||
Future<Task?> enqueue(Track track) async {
|
||||
final replaceFileGlobal = ref.read(replaceDownloadedFileState);
|
||||
final file = File(_getPathForTrack(track));
|
||||
if (file.existsSync() &&
|
||||
@ -156,11 +155,11 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
|
||||
|
||||
final task = await _ensureSpotubeTrack(track);
|
||||
|
||||
// await FileDownloader().enqueue(task);
|
||||
await FileDownloader().enqueue(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
Future<List /* <Task> */ > enqueueAll(List<Track> tracks) async {
|
||||
Future<List<Task>> enqueueAll(List<Track> tracks) async {
|
||||
final tasks = await Future.wait(tracks.mapIndexed((i, e) {
|
||||
if (i != 0) {
|
||||
/// One second delay between each download to avoid
|
||||
@ -174,16 +173,16 @@ class DownloadManagerProvider extends StateNotifier<List<SpotubeTrack>> {
|
||||
ref.read(replaceDownloadedFileState.notifier).state = null;
|
||||
}
|
||||
|
||||
return tasks. /* whereType<Task>(). */ toList();
|
||||
return tasks.whereType<Task>().toList();
|
||||
}
|
||||
|
||||
Future<void> cancel(Track track) async {
|
||||
// await FileDownloader().cancelTaskWithId(track.id!);
|
||||
await FileDownloader().cancelTaskWithId(track.id!);
|
||||
state = state.where((element) => element.id != track.id).toList();
|
||||
}
|
||||
|
||||
Future<void> cancelAll() async {
|
||||
// (await FileDownloader().reset());
|
||||
(await FileDownloader().reset());
|
||||
state = [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,15 +6,20 @@ import 'package:spotube/models/spotube_track.dart';
|
||||
|
||||
class ProxyPlaylist {
|
||||
final Set<Track> tracks;
|
||||
final Set<String> collections;
|
||||
final int? active;
|
||||
|
||||
ProxyPlaylist(this.tracks, [this.active]);
|
||||
ProxyPlaylist(this.tracks, [this.active, this.collections = const {}]);
|
||||
|
||||
factory ProxyPlaylist.fromJson(Map<String, dynamic> json) {
|
||||
return ProxyPlaylist(
|
||||
List.castFrom<dynamic, Map<String, dynamic>>(
|
||||
json['tracks'] ?? <Map<String, dynamic>>[],
|
||||
).map(_makeAppropriateTrack).toSet(),
|
||||
json['active'] as int?,
|
||||
json['collections'] == null
|
||||
? {}
|
||||
: (json['collections'] as List).toSet().cast<String>(),
|
||||
);
|
||||
}
|
||||
|
||||
@ -26,6 +31,10 @@ class ProxyPlaylist {
|
||||
activeTrack is! SpotubeTrack &&
|
||||
activeTrack is! LocalTrack;
|
||||
|
||||
bool containsCollection(String collection) {
|
||||
return collections.contains(collection);
|
||||
}
|
||||
|
||||
bool containsTrack(TrackSimple track) {
|
||||
return tracks.firstWhereOrNull((element) => element.id == track.id) != null;
|
||||
}
|
||||
@ -57,16 +66,19 @@ class ProxyPlaylist {
|
||||
return {
|
||||
'tracks': tracks.map(_makeAppropriateTrackJson).toList(),
|
||||
'active': active,
|
||||
'collections': collections.toList(),
|
||||
};
|
||||
}
|
||||
|
||||
ProxyPlaylist copyWith({
|
||||
Set<Track>? tracks,
|
||||
int? active,
|
||||
Set<String>? collections,
|
||||
}) {
|
||||
return ProxyPlaylist(
|
||||
tracks ?? this.tracks,
|
||||
active ?? this.active,
|
||||
collections ?? this.collections,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ import 'package:spotube/utils/type_conversion_utils.dart';
|
||||
/// Things to implement:
|
||||
/// * [x] Sponsor-Block skip
|
||||
/// * [x] Prefetch next track as [SpotubeTrack] on 80% of current track
|
||||
/// * [ ] Mixed Queue containing both [SpotubeTrack] and [LocalTrack]
|
||||
/// * [x] Mixed Queue containing both [SpotubeTrack] and [LocalTrack]
|
||||
/// * [ ] Modification of the Queue
|
||||
/// * [x] Add track at the end
|
||||
/// * [x] Add track at the beginning
|
||||
@ -185,6 +185,19 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
||||
}
|
||||
}
|
||||
|
||||
void addCollection(String collectionId) {
|
||||
state = state.copyWith(collections: {
|
||||
...state.collections,
|
||||
collectionId,
|
||||
});
|
||||
}
|
||||
|
||||
void removeCollection(String collectionId) {
|
||||
state = state.copyWith(collections: {
|
||||
...state.collections..remove(collectionId),
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: Safely Remove playing tracks
|
||||
|
||||
Future<void> removeTrack(String trackId) async {
|
||||
@ -218,6 +231,16 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
||||
bool autoPlay = false,
|
||||
}) async {
|
||||
tracks = blacklist.filter(tracks).toList() as List<Track>;
|
||||
final indexTrack = tracks.elementAtOrNull(initialIndex) ?? tracks.first;
|
||||
|
||||
if (indexTrack is LocalTrack) {
|
||||
state = state.copyWith(
|
||||
tracks: tracks.toSet(),
|
||||
active: initialIndex,
|
||||
collections: {},
|
||||
);
|
||||
await notificationService.addTrack(indexTrack);
|
||||
} else {
|
||||
final addableTrack = await SpotubeTrack.fetchFromTrack(
|
||||
tracks.elementAtOrNull(initialIndex) ?? tracks.first,
|
||||
preferences,
|
||||
@ -227,20 +250,20 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
||||
state = state.copyWith(
|
||||
tracks: mergeTracks([addableTrack], tracks),
|
||||
active: initialIndex,
|
||||
collections: {},
|
||||
);
|
||||
|
||||
await notificationService.addTrack(addableTrack);
|
||||
await storeTrack(
|
||||
tracks.elementAt(initialIndex),
|
||||
addableTrack,
|
||||
);
|
||||
}
|
||||
|
||||
await audioPlayer.openPlaylist(
|
||||
state.tracks.map(makeAppropriateSource).toList(),
|
||||
initialIndex: initialIndex,
|
||||
autoPlay: autoPlay,
|
||||
);
|
||||
|
||||
await storeTrack(
|
||||
tracks.elementAt(initialIndex),
|
||||
addableTrack,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> jumpTo(int index) async {
|
||||
@ -439,12 +462,13 @@ class ProxyPlaylistNotifier extends PersistedStateNotifier<ProxyPlaylist>
|
||||
@override
|
||||
onInit() async {
|
||||
if (state.tracks.isEmpty) return null;
|
||||
|
||||
final oldCollections = state.collections;
|
||||
await load(
|
||||
state.tracks,
|
||||
initialIndex: state.active ?? 0,
|
||||
autoPlay: false,
|
||||
);
|
||||
state = state.copyWith(collections: oldCollections);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
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(
|
||||
playlist.activeTrack is SpotubeTrack
|
||||
? (playlist.activeTrack as SpotubeTrack).ytUri
|
||||
: playlist.activeTrack!.previewUrl!,
|
||||
: playlist.activeTrack!.previewUrl ?? "",
|
||||
),
|
||||
"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}) {
|
||||
GoRouter.of(context).go(location, extra: extra);
|
||||
}
|
||||
|
||||
static void push(BuildContext context, String location, {Object? extra}) {
|
||||
GoRouter.of(context).push(location, extra: extra);
|
||||
}
|
||||
|
||||
@ -267,12 +271,14 @@ abstract class ServiceUtils {
|
||||
0;
|
||||
case SortBy.ascending:
|
||||
return a.name?.compareTo(b.name ?? "") ?? 0;
|
||||
case SortBy.dateAdded:
|
||||
final aDate =
|
||||
double.parse(a.album?.releaseDate?.split("-").first ?? "2069");
|
||||
final bDate =
|
||||
double.parse(b.album?.releaseDate?.split("-").first ?? "2069");
|
||||
case SortBy.oldest:
|
||||
final aDate = DateTime.parse(a.album?.releaseDate ?? "2069-01-01");
|
||||
final bDate = DateTime.parse(b.album?.releaseDate ?? "2069-01-01");
|
||||
return aDate.compareTo(bDate);
|
||||
case SortBy.newest:
|
||||
final aDate = DateTime.parse(a.album?.releaseDate ?? "2069-01-01");
|
||||
final bDate = DateTime.parse(b.album?.releaseDate ?? "2069-01-01");
|
||||
return bDate.compareTo(aDate);
|
||||
case SortBy.descending:
|
||||
return b.name?.compareTo(a.name ?? "") ?? 0;
|
||||
default:
|
||||
|
||||
24
pubspec.lock
24
pubspec.lock
@ -153,6 +153,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
background_downloader:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: background_downloader
|
||||
sha256: "5e38a1d5d88a5cfea35c44cb376b89427688070518471ee52f6b04d07d85668e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.4.0"
|
||||
badges:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -466,6 +474,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
duration:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: duration
|
||||
sha256: d0b29d0a345429e3986ac56d60e4aef65b37d11e653022b2b9a4b361332b777f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.12"
|
||||
envied:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1126,14 +1142,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
motion_toast:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: motion_toast
|
||||
sha256: f33fad8264d6d5359e41f2027d2d833614401c3983102e8f0aa13ccbbdcdeecd
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.8"
|
||||
mutex:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -102,7 +102,8 @@ dependencies:
|
||||
device_preview: ^1.1.0
|
||||
media_kit_native_event_loop: ^1.0.4
|
||||
dbus: ^0.7.8
|
||||
motion_toast: ^2.6.8
|
||||
background_downloader: ^7.4.0
|
||||
duration: ^3.0.12
|
||||
|
||||
dev_dependencies:
|
||||
build_runner: ^2.3.2
|
||||
|
||||
Loading…
Reference in New Issue
Block a user