refactor: playlist and album pages

This commit is contained in:
Kingkor Roy Tirtho 2024-12-28 14:30:25 +06:00
parent ced85d3f0c
commit d53782da23
37 changed files with 1793 additions and 1516 deletions

View File

@ -1,3 +1,3 @@
{
"flutterSdkVersion": "3.27.1"
"flutterSdkVersion": "3.28.0-0.1.pre"
}

2
.fvmrc
View File

@ -1,4 +1,4 @@
{
"flutter": "3.27.1",
"flutter": "3.28.0-0.1.pre",
"flavors": {}
}

View File

@ -28,5 +28,5 @@
"README.md": "LICENSE,CODE_OF_CONDUCT.md,CONTRIBUTING.md,SECURITY.md,CONTRIBUTION.md,CHANGELOG.md,PRIVACY_POLICY.md",
"*.dart": "${capture}.g.dart,${capture}.freezed.dart"
},
"dart.flutterSdkPath": ".fvm/versions/3.27.1"
"dart.flutterSdkPath": ".fvm/versions/3.28.0-0.1.pre"
}

View File

@ -1,4 +1,4 @@
import 'package:flutter/material.dart' show ListTile, showModalBottomSheet;
import 'package:flutter/material.dart' show showModalBottomSheet;
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart';
@ -39,6 +39,8 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
final Offset offset;
final ButtonVariance variance;
const AdaptivePopSheetList({
super.key,
required this.children,
@ -49,6 +51,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
this.onSelected,
required this.tooltip,
this.offset = Offset.zero,
this.variance = ButtonVariance.ghost,
}) : assert(
!(icon != null && child != null),
'Either icon or child must be provided',
@ -79,7 +82,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
}).toList();
if (mediaQuery.mdAndUp) {
await showDropdown<T>(
await showDropdown<T?>(
context: context,
rootOverlay: useRootNavigator,
// heightConstraint: PopoverConstraint.anchorFixedSize,
@ -113,19 +116,21 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
itemBuilder: (context, index) {
final data = childrenModified[index];
return ListTile(
dense: true,
leading: data.leading,
title: data.child,
return Button(
enabled: data.enabled,
trailing: data.trailing,
focusNode: data.focusNode,
onTap: () {
style: ButtonVariance.ghost.copyWith(
padding: (context, state, value) => const EdgeInsets.all(16),
),
onPressed: () {
data.onPressed?.call(context);
if (data.autoClose) {
Navigator.of(context).pop();
}
},
leading: data.leading,
trailing: data.trailing,
alignment: Alignment.centerLeft,
child: data.child,
);
},
);
@ -142,7 +147,8 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
tooltip: TooltipContainer(
child: Text(tooltip),
),
child: IconButton.ghost(
child: IconButton(
variance: variance,
icon: icon ?? const Icon(SpotubeIcons.moreVertical),
onPressed: () {
final renderBox = context.findRenderObject() as RenderBox;
@ -167,7 +173,7 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
tooltip: TooltipContainer(child: Text(tooltip)),
child: Button(
onPressed: () => showDropdownMenu(context, Offset.zero),
style: const ButtonStyle.ghost(),
style: variance,
child: IgnorePointer(child: child),
),
);
@ -175,7 +181,8 @@ class AdaptivePopSheetList<T> extends StatelessWidget {
return Tooltip(
tooltip: TooltipContainer(child: Text(tooltip)),
child: IconButton.ghost(
child: IconButton(
variance: variance,
icon: icon ?? const Icon(SpotubeIcons.moreVertical),
onPressed: () => showDropdownMenu(context, Offset.zero),
),

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/heart_button/use_track_toggle_like.dart';
@ -13,12 +13,16 @@ class HeartButton extends HookConsumerWidget {
final IconData? icon;
final Color? color;
final String? tooltip;
final ButtonVariance variance;
final ButtonSize size;
const HeartButton({
required this.isLiked,
required this.onPressed,
this.color,
this.tooltip,
this.icon,
this.variance = ButtonVariance.ghost,
this.size = ButtonSize.normal,
super.key,
});
@ -28,8 +32,11 @@ class HeartButton extends HookConsumerWidget {
if (auth.asData?.value == null) return const SizedBox.shrink();
return IconButton(
tooltip: tooltip,
return Tooltip(
tooltip: TooltipContainer(child: Text(tooltip ?? "")),
child: IconButton(
variance: variance,
size: size,
icon: AnimatedSwitcher(
switchInCurve: Curves.fastOutSlowIn,
switchOutCurve: Curves.fastOutSlowIn,
@ -50,6 +57,7 @@ class HeartButton extends HookConsumerWidget {
),
),
onPressed: onPressed,
),
);
}
}

View File

@ -1,88 +0,0 @@
import 'package:flutter/material.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/modules/library/user_local_tracks.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/extensions/context.dart';
class SortTracksDropdown extends StatelessWidget {
final SortBy? value;
final void Function(SortBy)? onChanged;
const SortTracksDropdown({
this.onChanged,
this.value,
super.key,
});
@override
Widget build(BuildContext context) {
var theme = Theme.of(context);
return ListTileTheme(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: AdaptivePopSheetList<SortBy>(
children: [
AdaptiveMenuButton(
value: SortBy.none,
enabled: value != SortBy.none,
child: Text(context.l10n.none),
),
AdaptiveMenuButton(
value: SortBy.ascending,
enabled: value != SortBy.ascending,
child: Text(context.l10n.sort_a_z),
),
AdaptiveMenuButton(
value: SortBy.descending,
enabled: value != SortBy.descending,
child: Text(context.l10n.sort_z_a),
),
AdaptiveMenuButton(
value: SortBy.newest,
enabled: value != SortBy.newest,
child: Text(context.l10n.sort_newest),
),
AdaptiveMenuButton(
value: SortBy.oldest,
enabled: value != SortBy.oldest,
child: Text(context.l10n.sort_oldest),
),
AdaptiveMenuButton(
value: SortBy.duration,
enabled: value != SortBy.duration,
child: Text(context.l10n.sort_duration),
),
AdaptiveMenuButton(
value: SortBy.artist,
enabled: value != SortBy.artist,
child: Text(context.l10n.sort_artist),
),
AdaptiveMenuButton(
value: SortBy.album,
enabled: value != SortBy.album,
child: Text(context.l10n.sort_album),
),
],
headings: [
Text(context.l10n.sort_tracks),
],
onSelected: onChanged,
tooltip: context.l10n.sort_tracks,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
child: DefaultTextStyle(
style: theme.textTheme.titleSmall!,
child: Row(
children: [
const Icon(SpotubeIcons.sort),
const SizedBox(width: 8),
Text(context.l10n.sort_tracks),
],
),
),
),
),
);
}
}

View File

@ -0,0 +1,220 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/dialogs/confirm_download_dialog.dart';
import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/presentation_state.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
class TrackPresentationActionsSection extends HookConsumerWidget {
const TrackPresentationActionsSection({super.key});
showToastForAction(BuildContext context, String action, int count) {
final message = switch (action) {
"download" => (context.l10n.download_count(count), SpotubeIcons.download),
"add-to-playlist" => (
context.l10n.add_count_to_playlist(count),
SpotubeIcons.playlistAdd
),
"add-to-queue" => (
context.l10n.add_count_to_queue(count),
SpotubeIcons.queueAdd
),
"play-next" => (
context.l10n.play_count_next(count),
SpotubeIcons.lightning
),
_ => ("", SpotubeIcons.error),
};
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Basic(
leading: Icon(message.$2),
title: Text(message.$1),
leadingAlignment: Alignment.center,
trailing: IconButton.ghost(
size: ButtonSize.small,
icon: const Icon(SpotubeIcons.close),
onPressed: () {
overlay.close();
},
),
),
);
},
);
}
@override
Widget build(BuildContext context, ref) {
final options = TrackPresentationOptions.of(context);
ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final audioSource =
ref.watch(userPreferencesProvider.select((s) => s.audioSource));
final state = ref.watch(presentationStateProvider(options.collection));
final notifier =
ref.watch(presentationStateProvider(options.collection).notifier);
final selectedTracks = state.selectedTracks;
return AdaptivePopSheetList(
tooltip: context.l10n.more_actions,
headings: [
Text(
context.l10n.more_actions,
style: context.theme.typography.large,
),
],
onSelected: (action) async {
var tracks = selectedTracks;
if (selectedTracks.isEmpty) {
tracks = await options.pagination.onFetchAll();
notifier.selectAllTracks();
}
if (!context.mounted) return;
switch (action) {
case "download":
{
final confirmed = audioSource == AudioSource.piped ||
await showDialog(
context: context,
builder: (context) {
return const ConfirmDownloadDialog();
},
);
if (confirmed != true) return;
downloader.batchAddToQueue(tracks);
notifier.deselectAllTracks();
if (!context.mounted) return;
showToastForAction(context, action, tracks.length);
break;
}
case "add-to-playlist":
{
if (context.mounted) {
final worked = await showDialog<bool>(
context: context,
builder: (context) {
return PlaylistAddTrackDialog(
openFromPlaylist: options.collectionId,
tracks: tracks.toList(),
);
},
);
if (!context.mounted || worked != true) return;
showToastForAction(context, action, tracks.length);
}
break;
}
case "play-next":
{
playlistNotifier.addTracksAtFirst(tracks);
playlistNotifier.addCollection(options.collectionId);
if (options.collection is AlbumSimple) {
historyNotifier.addAlbums([options.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([options.collection as PlaylistSimple]);
}
notifier.deselectAllTracks();
if (!context.mounted) return;
showToastForAction(context, action, tracks.length);
break;
}
case "add-to-queue":
{
playlistNotifier.addTracks(tracks);
playlistNotifier.addCollection(options.collectionId);
if (options.collection is AlbumSimple) {
historyNotifier.addAlbums([options.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([options.collection as PlaylistSimple]);
}
notifier.deselectAllTracks();
if (!context.mounted) return;
showToastForAction(context, action, tracks.length);
break;
}
default:
}
if (!context.mounted) return;
},
icon: const Icon(SpotubeIcons.moreVertical),
variance: ButtonVariance.outline,
children: [
AdaptiveMenuButton(
value: "download",
leading: const Icon(SpotubeIcons.download),
child: selectedTracks.isEmpty ||
selectedTracks.length == options.tracks.length
? Text(
context.l10n.download_all,
)
: Text(
context.l10n.download_count(selectedTracks.length),
),
),
AdaptiveMenuButton(
value: "add-to-playlist",
leading: const Icon(SpotubeIcons.playlistAdd),
child: selectedTracks.isEmpty ||
selectedTracks.length == options.tracks.length
? Text(
context.l10n.add_all_to_playlist,
)
: Text(
context.l10n.add_count_to_playlist(selectedTracks.length),
),
),
AdaptiveMenuButton(
value: "add-to-queue",
leading: const Icon(SpotubeIcons.queueAdd),
child: selectedTracks.isEmpty ||
selectedTracks.length == options.tracks.length
? Text(
context.l10n.add_all_to_queue,
)
: Text(
context.l10n.add_count_to_queue(selectedTracks.length),
),
),
AdaptiveMenuButton(
value: "play-next",
leading: const Icon(SpotubeIcons.lightning),
child: selectedTracks.isEmpty ||
selectedTracks.length == options.tracks.length
? Text(
context.l10n.play_all_next,
)
: Text(
context.l10n.play_count_next(selectedTracks.length),
),
),
],
);
}
}

View File

@ -0,0 +1,84 @@
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/presentation_state.dart';
import 'package:spotube/components/track_presentation/use_track_tile_play_callback.dart';
import 'package:spotube/components/track_tile/track_tile.dart';
import 'package:spotube/components/track_presentation/use_is_user_playlist.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class PresentationListSection extends HookConsumerWidget {
const PresentationListSection({super.key});
@override
Widget build(BuildContext context, ref) {
final options = TrackPresentationOptions.of(context);
final playlist = ref.watch(audioPlayerProvider);
final state = ref.watch(presentationStateProvider(options.collection));
final notifier =
ref.read(presentationStateProvider(options.collection).notifier);
final isUserPlaylist = useIsUserPlaylist(ref, options.collectionId);
final onTileTap = useTrackTilePlayCallback(ref);
return SliverInfiniteList(
isLoading: options.pagination.isLoading,
onFetchData: options.pagination.onFetchMore,
itemCount: state.presentationTracks.length,
hasReachedMax: !options.pagination.hasNextPage,
loadingBuilder: (context) {
return Skeletonizer(
enabled: true,
child: TrackTile(
index: 0,
playlist: playlist,
track: FakeData.track,
),
);
},
emptyBuilder: (context) => Skeletonizer(
enabled: true,
child: Column(
children: List.generate(
10,
(index) => TrackTile(
track: FakeData.track,
index: index,
playlist: playlist,
),
),
),
),
itemBuilder: (context, index) {
final track = state.presentationTracks[index];
final isSelected = state.selectedTracks.any((e) => e.id == track.id);
return TrackTile(
userPlaylist: isUserPlaylist,
playlistId: options.collectionId,
index: index,
playlist: playlist,
track: track,
selected: isSelected,
onTap: () => onTileTap(track, index),
onChanged: state.selectedTracks.isEmpty
? null
: (isSelected) {
if (isSelected == true) {
notifier.selectTrack(track);
} else {
notifier.deselectTrack(track);
}
},
onLongPress: () {
notifier.selectTrack(track);
HapticFeedback.selectionClick();
},
);
},
);
}
}

View File

@ -0,0 +1,117 @@
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/track_presentation/sort_tracks_dropdown.dart';
import 'package:spotube/components/track_presentation/presentation_actions.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/presentation_state.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
class TrackPresentationModifiersSection extends HookConsumerWidget {
const TrackPresentationModifiersSection({super.key});
@override
Widget build(BuildContext context, ref) {
final options = TrackPresentationOptions.of(context);
final state = ref.watch(presentationStateProvider(options.collection));
final notifier = ref.watch(
presentationStateProvider(options.collection).notifier,
);
final controller = useTextEditingController();
return LayoutBuilder(builder: (context, constrains) {
return Padding(
padding: EdgeInsets.symmetric(
horizontal: constrains.mdAndUp ? 16 : 8,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Checkbox(
state: state.selectedTracks.length == options.tracks.length
? CheckboxState.checked
: CheckboxState.unchecked,
onChanged: (value) {
if (value == CheckboxState.checked) {
notifier.selectAllTracks();
} else {
notifier.deselectAllTracks();
}
},
),
],
),
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
spacing: 8,
children: [
Flexible(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 320,
),
child: TextField(
controller: controller,
leading: Icon(
SpotubeIcons.search,
color: context.theme.colorScheme.mutedForeground,
),
placeholder: Text(context.l10n.search_tracks),
onChanged: (value) {
if (value.isEmpty) {
notifier.clearFilter();
} else {
notifier.filterTracks(value);
}
},
trailing: ListenableBuilder(
listenable: controller,
builder: (context, _) {
return AnimatedCrossFade(
duration: const Duration(milliseconds: 300),
crossFadeState: controller.text.isEmpty
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
firstChild:
const SizedBox.square(dimension: 20),
secondChild: AnimatedScale(
duration: const Duration(milliseconds: 300),
scale: controller.text.isEmpty ? 0 : 1,
child: IconButton.ghost(
size: const ButtonSize(.6),
icon: const Icon(SpotubeIcons.close),
onPressed: () {
controller.clear();
notifier.clearFilter();
},
),
),
);
}),
),
),
),
SortTracksDropdown(
value: state.sortBy,
onChanged: (value) {
notifier.sortTracks(value);
},
),
const TrackPresentationActionsSection(),
],
),
),
],
),
);
});
}
}

View File

@ -1,6 +1,6 @@
import 'dart:async';
import 'package:flutter/material.dart' hide Page;
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
class PaginationProps {
@ -38,31 +38,33 @@ class PaginationProps {
onRefresh.hashCode;
}
class InheritedTrackView extends InheritedWidget {
class TrackPresentationOptions {
final Object collection;
final String title;
final String? description;
final String? owner;
final String? ownerImage;
final String image;
final String routePath;
final List<Track> tracks;
final PaginationProps pagination;
final bool isLiked;
final String shareUrl;
final String? shareUrl;
// events
final FutureOr<bool?> Function()? onHeart; // if null heart button will hidden
const InheritedTrackView({
super.key,
required super.child,
const TrackPresentationOptions({
required this.collection,
required this.title,
this.description,
this.owner,
this.ownerImage,
required this.image,
required this.tracks,
required this.pagination,
required this.routePath,
required this.shareUrl,
this.shareUrl,
this.isLiked = false,
this.onHeart,
}) : assert(collection is AlbumSimple || collection is PlaylistSimple);
@ -71,29 +73,36 @@ class InheritedTrackView extends InheritedWidget {
? (collection as AlbumSimple).id!
: (collection as PlaylistSimple).id!;
@override
bool updateShouldNotify(InheritedTrackView oldWidget) {
return oldWidget.title != title ||
oldWidget.description != description ||
oldWidget.image != image ||
oldWidget.tracks != tracks ||
oldWidget.pagination != pagination ||
oldWidget.isLiked != isLiked ||
oldWidget.onHeart != onHeart ||
oldWidget.shareUrl != shareUrl ||
oldWidget.routePath != routePath ||
oldWidget.collection != collection ||
oldWidget.child != child;
static TrackPresentationOptions of(BuildContext context) {
return Data.of<TrackPresentationOptions>(context);
}
static InheritedTrackView of(BuildContext context) {
final widget =
context.dependOnInheritedWidgetOfExactType<InheritedTrackView>();
if (widget == null) {
throw Exception(
'InheritedTrackView not found. Make sure to wrap [TrackView] with [InheritedTrackView]',
);
}
return widget;
@override
operator ==(Object other) {
return other is TrackPresentationOptions &&
other.collection == collection &&
other.title == title &&
other.description == description &&
other.image == image &&
other.routePath == routePath &&
other.tracks == tracks &&
other.pagination == pagination &&
other.isLiked == isLiked &&
other.shareUrl == shareUrl &&
other.onHeart == onHeart;
}
@override
int get hashCode =>
super.hashCode ^
collection.hashCode ^
title.hashCode ^
description.hashCode ^
image.hashCode ^
routePath.hashCode ^
tracks.hashCode ^
pagination.hashCode ^
isLiked.hashCode ^
shareUrl.hashCode ^
onHeart.hashCode;
}

View File

@ -0,0 +1,157 @@
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/modules/library/user_local_tracks.dart';
import 'package:spotube/provider/spotify/spotify.dart';
import 'package:spotube/utils/service_utils.dart';
class PresentationState {
final List<Track> selectedTracks;
final List<Track> presentationTracks;
final SortBy sortBy;
const PresentationState({
required this.selectedTracks,
required this.presentationTracks,
required this.sortBy,
});
PresentationState copyWith({
List<Track>? selectedTracks,
List<Track>? presentationTracks,
SortBy? sortBy,
}) {
return PresentationState(
selectedTracks: selectedTracks ?? this.selectedTracks,
presentationTracks: presentationTracks ?? this.presentationTracks,
sortBy: sortBy ?? this.sortBy,
);
}
}
class PresentationStateNotifier
extends AutoDisposeFamilyNotifier<PresentationState, Object> {
@override
PresentationState build(collection) {
final isPlaylist = arg is PlaylistSimple;
if ((isPlaylist && (arg as PlaylistSimple).id != "user-liked-tracks") ||
arg is AlbumSimple) {
ref.listen(
isPlaylist
? playlistTracksProvider((arg as PlaylistSimple).id!)
: albumTracksProvider((arg as AlbumSimple)),
(previous, next) {
next.whenData((value) {
state = state.copyWith(
presentationTracks: ServiceUtils.sortTracks(
value.items,
state.sortBy,
),
);
});
},
);
}
return PresentationState(
selectedTracks: [],
presentationTracks: tracks,
sortBy: SortBy.none,
);
}
List<Track> get tracks {
assert(
arg is PlaylistSimple || arg is AlbumSimple,
"arg must be PlaylistSimple or AlbumSimple",
);
final isPlaylist = arg is PlaylistSimple;
final isSavedTrackPlaylist =
isPlaylist && (arg as PlaylistSimple).id == "user-liked-tracks";
final tracks = switch ((isPlaylist, isSavedTrackPlaylist)) {
(true, true) => ref.read(likedTracksProvider).asData?.value,
(true, false) => ref
.read(playlistTracksProvider((arg as PlaylistSimple).id!))
.asData
?.value
.items,
_ => ref
.read(albumTracksProvider((arg as AlbumSimple)))
.asData
?.value
.items,
} ??
[];
return tracks;
}
void selectTrack(Track track) {
if (state.selectedTracks.any((e) => e.id == track.id)) {
return;
}
state = state.copyWith(
selectedTracks: [...state.selectedTracks, track],
);
}
void selectAllTracks() {
state = state.copyWith(
selectedTracks: tracks,
);
}
void deselectTrack(Track track) {
state = state.copyWith(
selectedTracks: state.selectedTracks.where((e) => e != track).toList(),
);
}
void deselectAllTracks() {
state = state.copyWith(
selectedTracks: [],
);
}
void filterTracks(String query) {
if (query.isEmpty) {
return;
}
state = state.copyWith(
presentationTracks: ServiceUtils.sortTracks(
tracks
.map((e) => (weightedRatio(e.name!, query), e))
.sorted((a, b) => b.$1.compareTo(a.$1))
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList(),
state.sortBy,
),
);
}
void clearFilter() {
state = state.copyWith(
presentationTracks: ServiceUtils.sortTracks(tracks, state.sortBy),
);
}
void sortTracks(SortBy sortBy) {
state = state.copyWith(
presentationTracks: sortBy == SortBy.none
? tracks
: ServiceUtils.sortTracks(state.presentationTracks, sortBy),
sortBy: sortBy,
);
}
}
final presentationStateProvider = AutoDisposeNotifierProviderFamily<
PresentationStateNotifier, PresentationState, Object>(
() => PresentationStateNotifier(),
);

View File

@ -0,0 +1,262 @@
import 'package:auto_size_text/auto_size_text.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/heart_button/heart_button.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/use_action_callbacks.dart';
import 'package:spotube/components/track_presentation/use_is_user_playlist.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
class TrackPresentationTopSection extends HookConsumerWidget {
const TrackPresentationTopSection({super.key});
@override
Widget build(BuildContext context, ref) {
final mediaQuery = MediaQuery.sizeOf(context);
final options = TrackPresentationOptions.of(context);
final scale = context.theme.scaling;
final isUserPlaylist = useIsUserPlaylist(ref, options.collectionId);
final imageDimension = mediaQuery.mdAndUp ? 200 : 120;
final (:isLoading, :isActive, :onPlay, :onShuffle) =
useActionCallbacks(ref);
final playbackActions = Row(
spacing: 8 * scale,
children: [
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.shuffle_playlist),
),
child: IconButton.secondary(
icon: isLoading
? const Center(
child:
CircularProgressIndicator(onSurface: false, size: 20),
)
: const Icon(SpotubeIcons.shuffle),
enabled: !isLoading && !isActive,
onPressed: onShuffle,
),
),
if (mediaQuery.width <= 320)
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.add_to_queue),
),
child: IconButton.secondary(
icon: const Icon(SpotubeIcons.queueAdd),
enabled: !isLoading && !isActive,
onPressed: () {},
),
)
else
Button.secondary(
leading: const Icon(SpotubeIcons.add),
enabled: !isLoading && !isActive,
child: Text(context.l10n.queue),
onPressed: () {},
),
Button.primary(
alignment: Alignment.center,
leading: switch ((isActive, isLoading)) {
(true, false) => const Icon(SpotubeIcons.pause),
(false, true) => const Center(
child: CircularProgressIndicator(onSurface: true, size: 18),
),
_ => const Icon(SpotubeIcons.play),
},
onPressed: onPlay,
enabled: !isLoading && !isActive,
child: isActive ? Text(context.l10n.pause) : Text(context.l10n.play),
),
],
);
final additionalActions = Row(
spacing: 8 * scale,
children: [
if (isUserPlaylist)
IconButton.outline(
size: ButtonSize.small,
icon: const Icon(SpotubeIcons.edit),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return PlaylistCreateDialog(
playlistId: options.collectionId,
trackIds: options.tracks.map((e) => e.id!).toList(),
);
},
);
},
),
if (options.shareUrl != null)
Tooltip(
tooltip: TooltipContainer(
child: Text(context.l10n.share),
),
child: IconButton.outline(
icon: const Icon(SpotubeIcons.share),
size: ButtonSize.small,
onPressed: () async {
await Clipboard.setData(
ClipboardData(text: options.shareUrl!),
);
if (!context.mounted) return;
showToast(
context: context,
location: ToastLocation.topRight,
builder: (context, overlay) {
return SurfaceCard(
child: Text(
context.l10n
.copied_shareurl_to_clipboard(options.shareUrl!),
).small(),
);
},
);
},
),
),
if (options.onHeart != null)
HeartButton(
isLiked: options.isLiked,
tooltip: options.isLiked
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
variance: ButtonVariance.outline,
size: ButtonSize.small,
onPressed: options.onHeart,
),
],
);
return SliverMainAxisGroup(
slivers: [
if (mediaQuery.mdAndUp) SliverGap(16 * scale),
SliverPadding(
padding: EdgeInsets.symmetric(
horizontal: (mediaQuery.mdAndUp ? 16 : 8.0) * scale,
),
sliver: SliverList.list(
children: [
DecoratedBox(
decoration: BoxDecoration(
image: DecorationImage(
image: UniversalImage.imageProvider(options.image),
fit: BoxFit.cover,
),
borderRadius: BorderRadius.circular(45),
),
child: OutlinedContainer(
surfaceOpacity: context.theme.surfaceOpacity,
surfaceBlur: context.theme.surfaceBlur,
padding: EdgeInsets.all(24 * scale),
borderRadius: BorderRadius.circular(22 * scale),
borderWidth: 2,
child: Column(
mainAxisSize: MainAxisSize.min,
spacing: 16 * scale,
children: [
Row(
spacing: 16 * scale,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: imageDimension * scale,
width: imageDimension * scale,
decoration: BoxDecoration(
borderRadius: context.theme.borderRadiusXl,
image: DecorationImage(
image:
UniversalImage.imageProvider(options.image),
fit: BoxFit.cover,
),
),
),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AutoSizeText(
options.title,
maxLines: 2,
minFontSize: 16,
style: context.theme.typography.h3,
),
if (options.description != null)
AutoSizeText(
options.description!,
maxLines: 2,
minFontSize: 14,
maxFontSize: 18,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: context
.theme.colorScheme.mutedForeground,
fontSize: 18,
),
),
const Gap(16),
Flex(
crossAxisAlignment: CrossAxisAlignment.start,
direction: mediaQuery.smAndUp
? Axis.horizontal
: Axis.vertical,
spacing: 8 * scale,
children: [
if (options.owner != null)
OutlineBadge(
leading: options.ownerImage != null
? Avatar(
initials:
options.owner?[0] ?? "U",
provider: UniversalImage
.imageProvider(
options.ownerImage!,
),
)
: null,
child: Text(
options.owner!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
).small(),
),
additionalActions,
],
),
if (mediaQuery.mdAndUp) ...[
const Gap(16),
playbackActions
],
],
),
),
],
),
if (mediaQuery.smAndDown) playbackActions,
],
),
),
),
],
),
)
],
);
}
}

View File

@ -0,0 +1,70 @@
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/modules/library/user_local_tracks.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/extensions/context.dart';
class SortTracksDropdown extends StatelessWidget {
final SortBy? value;
final void Function(SortBy)? onChanged;
const SortTracksDropdown({
this.onChanged,
this.value,
super.key,
});
@override
Widget build(BuildContext context) {
return AdaptivePopSheetList<SortBy>(
variance: ButtonVariance.outline,
headings: [
Text(context.l10n.sort_tracks),
],
onSelected: onChanged,
tooltip: context.l10n.sort_tracks,
icon: const Icon(SpotubeIcons.sort),
children: [
AdaptiveMenuButton(
value: SortBy.none,
enabled: value != SortBy.none,
child: Text(context.l10n.none),
),
AdaptiveMenuButton(
value: SortBy.ascending,
enabled: value != SortBy.ascending,
child: Text(context.l10n.sort_a_z),
),
AdaptiveMenuButton(
value: SortBy.descending,
enabled: value != SortBy.descending,
child: Text(context.l10n.sort_z_a),
),
AdaptiveMenuButton(
value: SortBy.newest,
enabled: value != SortBy.newest,
child: Text(context.l10n.sort_newest),
),
AdaptiveMenuButton(
value: SortBy.oldest,
enabled: value != SortBy.oldest,
child: Text(context.l10n.sort_oldest),
),
AdaptiveMenuButton(
value: SortBy.duration,
enabled: value != SortBy.duration,
child: Text(context.l10n.sort_duration),
),
AdaptiveMenuButton(
value: SortBy.artist,
enabled: value != SortBy.artist,
child: Text(context.l10n.sort_artist),
),
AdaptiveMenuButton(
value: SortBy.album,
enabled: value != SortBy.album,
child: Text(context.l10n.sort_album),
),
],
);
}
}

View File

@ -0,0 +1,72 @@
import 'package:flutter/material.dart' show ListTile;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/components/track_presentation/presentation_list.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/presentation_top.dart';
import 'package:spotube/components/track_presentation/presentation_modifiers.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
class TrackPresentation extends HookConsumerWidget {
final TrackPresentationOptions options;
const TrackPresentation({
super.key,
required this.options,
});
@override
Widget build(BuildContext context, ref) {
final headerTextStyle = context.theme.typography.small.copyWith(
color: context.theme.colorScheme.mutedForeground,
);
return Data<TrackPresentationOptions>.inherit(
data: options,
child: SafeArea(
child: Scaffold(
headers: const [TitleBar()],
child: CustomScrollView(
slivers: [
const TrackPresentationTopSection(),
const SliverGap(16),
SliverLayoutBuilder(
builder: (context, constrains) {
return SliverList.list(
children: [
const TrackPresentationModifiersSection(),
ListTile(
titleTextStyle: headerTextStyle,
subtitleTextStyle: headerTextStyle,
leadingAndTrailingTextStyle: headerTextStyle,
leading: constrains.mdAndUp ? const Text(" #") : null,
title: Row(
children: [
Expanded(
flex: constrains.lgAndUp ? 5 : 6,
child: Text(context.l10n.title),
),
if (constrains.mdAndUp)
Expanded(
flex: 3,
child: Text(context.l10n.album),
),
Text(context.l10n.duration),
],
),
),
],
);
},
),
const PresentationListSection(),
const SliverGap(200),
],
),
),
),
);
}
}

View File

@ -0,0 +1,135 @@
import 'dart:math';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
typedef UseActionCallbacks = ({
bool isActive,
bool isLoading,
Future<void> Function() onShuffle,
Future<void> Function() onPlay,
});
UseActionCallbacks useActionCallbacks(WidgetRef ref) {
final isLoading = useState(false);
final context = useContext();
final options = TrackPresentationOptions.of(context);
final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final isActive = useMemoized(
() => playlist.collections.contains(options.collectionId),
[playlist.collections, options.collectionId],
);
final onShuffle = useCallback(() async {
try {
isLoading.value = true;
final initialTracks = options.tracks;
if (!context.mounted) return;
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) {
final allTracks = await options.pagination.onFetchAll();
final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load(
options.collection is AlbumSimple
? WebSocketLoadEventData.album(
tracks: allTracks,
collection: options.collection as AlbumSimple,
initialIndex: Random().nextInt(allTracks.length))
: WebSocketLoadEventData.playlist(
tracks: allTracks,
collection: options.collection as PlaylistSimple,
initialIndex: Random().nextInt(allTracks.length),
),
);
await remotePlayback.setShuffle(true);
} else {
await playlistNotifier.load(
initialTracks,
autoPlay: true,
initialIndex: Random().nextInt(initialTracks.length),
);
await audioPlayer.setShuffle(true);
playlistNotifier.addCollection(options.collectionId);
if (options.collection is AlbumSimple) {
historyNotifier.addAlbums([options.collection as AlbumSimple]);
} else {
historyNotifier.addPlaylists([options.collection as PlaylistSimple]);
}
final allTracks = await options.pagination.onFetchAll();
await playlistNotifier.addTracks(
allTracks.sublist(initialTracks.length),
);
}
} finally {
isLoading.value = false;
}
}, [options, playlistNotifier, historyNotifier]);
final onPlay = useCallback(() async {
try {
isLoading.value = true;
final initialTracks = options.tracks;
if (!context.mounted) return;
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) {
final allTracks = await options.pagination.onFetchAll();
final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load(
options.collection is AlbumSimple
? WebSocketLoadEventData.album(
tracks: allTracks,
collection: options.collection as AlbumSimple,
)
: WebSocketLoadEventData.playlist(
tracks: allTracks,
collection: options.collection as PlaylistSimple,
),
);
} else {
await playlistNotifier.load(initialTracks, autoPlay: true);
playlistNotifier.addCollection(options.collectionId);
if (options.collection is AlbumSimple) {
historyNotifier.addAlbums([options.collection as AlbumSimple]);
} else {
historyNotifier.addPlaylists([options.collection as PlaylistSimple]);
}
final allTracks = await options.pagination.onFetchAll();
await playlistNotifier.addTracks(
allTracks.sublist(initialTracks.length),
);
}
} finally {
if (context.mounted) {
isLoading.value = false;
}
}
}, [options, playlistNotifier, historyNotifier]);
return (
isActive: isActive,
isLoading: isLoading.value,
onShuffle: onShuffle,
onPlay: onPlay,
);
}

View File

@ -0,0 +1,84 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/presentation_state.dart';
import 'package:spotube/extensions/list.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
Future<void> Function(Track track, int index) useTrackTilePlayCallback(
WidgetRef ref,
) {
final context = useContext();
final options = TrackPresentationOptions.of(context);
final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final isActive = useMemoized(
() => playlist.collections.contains(options.collectionId),
[playlist.collections, options.collectionId],
);
final onTapTrackTile = useCallback((Track track, int index) async {
final state = ref.read(presentationStateProvider(options.collection));
final notifier =
ref.read(presentationStateProvider(options.collection).notifier);
if (state.selectedTracks.isNotEmpty) {
notifier.selectTrack(track);
return;
}
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
final remoteQueue = ref.read(queueProvider);
if (remoteQueue.collections.contains(options.collectionId) ||
remoteQueue.tracks.any((s) => s.id == track.id)) {
await playlistNotifier.jumpToTrack(track);
} else {
final tracks = await options.pagination.onFetchAll();
await remotePlayback.load(
options.collection is AlbumSimple
? WebSocketLoadEventData.album(
tracks: tracks,
collection: options.collection as AlbumSimple,
initialIndex: index,
)
: WebSocketLoadEventData.playlist(
tracks: tracks,
collection: options.collection as PlaylistSimple,
initialIndex: index,
),
);
}
} else {
if (isActive || playlist.tracks.containsBy(track, (a) => a.id)) {
await playlistNotifier.jumpToTrack(track);
} else {
final tracks = await options.pagination.onFetchAll();
await playlistNotifier.load(
tracks,
initialIndex: index,
autoPlay: true,
);
playlistNotifier.addCollection(options.collectionId);
if (options.collection is AlbumSimple) {
historyNotifier.addAlbums([options.collection as AlbumSimple]);
} else {
historyNotifier.addPlaylists([options.collection as PlaylistSimple]);
}
}
}
}, [isActive, playlist, options, playlistNotifier, historyNotifier]);
return onTapTrackTile;
}

View File

@ -1,10 +1,11 @@
import 'dart:async';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' show ListTile, Material, MaterialType;
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
@ -88,7 +89,11 @@ class TrackTile extends HookConsumerWidget {
},
child: HoverBuilder(
permanentState: isSelected || constrains.smAndDown ? true : null,
builder: (context, isHovering) => ListTile(
builder: (context, isHovering) => Material(
type: MaterialType.transparency,
child: ListTile(
selectedColor: theme.colorScheme.primary,
selectedTileColor: theme.colorScheme.primary.withOpacity(0.1),
selected: isSelected,
onTap: () async {
try {
@ -103,44 +108,63 @@ class TrackTile extends HookConsumerWidget {
onLongPress: onLongPress,
enabled: !isBlackListed,
contentPadding: EdgeInsets.zero,
tileColor: isBlackListed ? theme.colorScheme.errorContainer : null,
tileColor: isBlackListed ? theme.colorScheme.destructive : null,
horizontalTitleGap: 12,
leadingAndTrailingTextStyle: theme.textTheme.bodyMedium,
leadingAndTrailingTextStyle: theme.typography.normal.copyWith(
color: theme.colorScheme.foreground,
),
titleTextStyle: theme.typography.normal.copyWith(
color: theme.colorScheme.foreground,
),
subtitleTextStyle: theme.typography.xSmall.copyWith(
color: theme.colorScheme.mutedForeground,
),
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
...?leadingActions,
if (index != null && onChanged == null && constrains.mdAndUp)
SizedBox(
AnimatedCrossFade(
duration: const Duration(milliseconds: 300),
crossFadeState: index != null && onChanged == null
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: Checkbox(
state: selected
? CheckboxState.checked
: CheckboxState.unchecked,
onChanged: (state) =>
onChanged?.call(state == CheckboxState.checked),
),
secondChild: constrains.smAndDown
? const SizedBox(width: 16)
: SizedBox(
width: 50,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
padding:
const EdgeInsets.symmetric(horizontal: 6),
child: Text(
'${(index ?? 0) + 1}',
maxLines: 1,
style: theme.textTheme.bodySmall,
style: theme.typography.small,
textAlign: TextAlign.center,
),
),
)
else if (constrains.smAndDown)
const SizedBox(width: 16),
if (onChanged != null)
Checkbox(
value: selected,
onChanged: onChanged,
),
),
Stack(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: AspectRatio(
aspectRatio: 1,
child: UniversalImage(
path: (track.album?.images).asUrlString(
Container(
height: 40,
width: 40,
decoration: BoxDecoration(
borderRadius: theme.borderRadiusMd,
image: DecorationImage(
fit: BoxFit.cover,
image: UniversalImage.imageProvider(
(track.album?.images).asUrlString(
placeholder: ImagePlaceholder.albumArt,
),
fit: BoxFit.cover,
),
),
),
),
@ -148,7 +172,7 @@ class TrackTile extends HookConsumerWidget {
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
borderRadius: theme.borderRadiusMd,
color: isHovering
? Colors.black.withOpacity(0.4)
: Colors.transparent,
@ -157,9 +181,6 @@ class TrackTile extends HookConsumerWidget {
),
Positioned.fill(
child: Center(
child: IconTheme(
data: theme.iconTheme
.copyWith(size: 26, color: Colors.white),
child: Skeleton.ignore(
child: Consumer(
builder: (context, ref, _) {
@ -167,31 +188,37 @@ class TrackTile extends HookConsumerWidget {
ref.watch(queryingTrackInfoProvider);
return AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: (isPlaying && isFetchingActiveTrack) ||
child: switch ((
isPlaying,
isFetchingActiveTrack,
isPlaying,
isHovering,
isLoading.value
? const SizedBox(
)) {
(true, true, _, _, _) ||
(_, _, _, _, true) =>
const SizedBox(
width: 26,
height: 26,
child: CircularProgressIndicator(
strokeWidth: 1.5,
color: Colors.white,
size: 1.5),
),
)
: isPlaying
? Icon(
(_, _, true, _, _) => Icon(
SpotubeIcons.pause,
color: theme.colorScheme.primary,
)
: !isHovering
? const SizedBox.shrink()
: const Icon(SpotubeIcons.play),
),
(_, _, _, true, _) => const Icon(
SpotubeIcons.play,
color: Colors.white,
),
_ => const SizedBox.shrink(),
},
);
},
),
),
),
),
),
],
),
],
@ -283,6 +310,7 @@ class TrackTile extends HookConsumerWidget {
),
),
),
),
);
});
}

View File

@ -1,192 +0,0 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/fake.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/expandable_search/expandable_search.dart';
import 'package:spotube/components/track_tile/track_tile.dart';
import 'package:spotube/components/tracks_view/sections/body/track_view_body_headers.dart';
import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/components/tracks_view/track_view_provider.dart';
import 'package:spotube/extensions/list.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/utils/service_utils.dart';
import 'package:very_good_infinite_list/very_good_infinite_list.dart';
class TrackViewBodySection extends HookConsumerWidget {
const TrackViewBodySection({super.key});
@override
Widget build(BuildContext context, ref) {
final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final props = InheritedTrackView.of(context);
final trackViewState = ref.watch(trackViewProvider(props.tracks));
final searchController = useTextEditingController();
final searchFocus = useFocusNode();
useValueListenable(searchController);
final searchQuery = searchController.text;
final isFiltering = useState(false);
final uniqTracks = useMemoized(() {
final trackIds = props.tracks.map((e) => e.id).toSet();
return props.tracks.where((e) => trackIds.remove(e.id)).toList();
}, [props.tracks]);
final tracks = useMemoized(() {
List<Track> filteredTracks;
if (searchQuery.isEmpty) {
filteredTracks = uniqTracks;
} else {
filteredTracks = uniqTracks
.map((e) => (weightedRatio(e.name!, searchQuery), e))
.sorted((a, b) => b.$1.compareTo(a.$1))
.where((e) => e.$1 > 50)
.map((e) => e.$2)
.toList();
}
return ServiceUtils.sortTracks(filteredTracks, trackViewState.sortBy);
}, [trackViewState.sortBy, searchQuery, uniqTracks]);
final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId);
final isActive = playlist.collections.contains(props.collectionId);
final onTapTrackTile = useCallback((Track track, int index) async {
if (trackViewState.isSelecting) {
trackViewState.toggleTrackSelection(track.id!);
return;
}
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) {
final remotePlayback = ref.read(connectProvider.notifier);
final remoteQueue = ref.read(queueProvider);
if (remoteQueue.collections.contains(props.collectionId) ||
remoteQueue.tracks.any((s) => s.id == track.id)) {
await playlistNotifier.jumpToTrack(track);
} else {
final tracks = await props.pagination.onFetchAll();
await remotePlayback.load(
props.collection is AlbumSimple
? WebSocketLoadEventData.album(
tracks: tracks,
collection: props.collection as AlbumSimple,
initialIndex: index,
)
: WebSocketLoadEventData.playlist(
tracks: tracks,
collection: props.collection as PlaylistSimple,
initialIndex: index,
),
);
}
} else {
if (isActive || playlist.tracks.containsBy(track, (a) => a.id)) {
await playlistNotifier.jumpToTrack(track);
} else {
final tracks = await props.pagination.onFetchAll();
await playlistNotifier.load(
tracks,
initialIndex: index,
autoPlay: true,
);
playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier.addPlaylists([props.collection as PlaylistSimple]);
}
}
}
}, [isActive, playlist, props, playlistNotifier, historyNotifier]);
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: TrackViewBodyHeaders(
isFiltering: isFiltering,
searchFocus: searchFocus,
),
),
const SliverGap(8),
SliverToBoxAdapter(
child: ExpandableSearchField(
isFiltering: isFiltering.value,
onChangeFiltering: (value) {
isFiltering.value = value;
},
searchController: searchController,
searchFocus: searchFocus,
),
),
SliverSafeArea(
top: false,
sliver: SliverInfiniteList(
itemCount: tracks.length,
onFetchData: props.pagination.onFetchMore,
isLoading: props.pagination.isLoading,
hasReachedMax: !props.pagination.hasNextPage,
loadingBuilder: (context) => Skeletonizer(
enabled: true,
child: TrackTile(
playlist: playlist,
track: FakeData.track,
index: 0,
),
),
emptyBuilder: (context) => Skeletonizer(
enabled: true,
child: Column(
children: List.generate(
10,
(index) => TrackTile(
track: FakeData.track,
index: index,
playlist: playlist,
),
),
),
),
itemBuilder: (context, index) {
final track = tracks[index];
return TrackTile(
playlist: playlist,
track: track,
index: index,
selected: trackViewState.selectedTrackIds.contains(track.id!),
playlistId: props.collectionId,
userPlaylist: isUserPlaylist,
onChanged: !trackViewState.isSelecting
? null
: (value) {
trackViewState.toggleTrackSelection(track.id!);
},
onLongPress: () {
trackViewState.selectTrack(track.id!);
HapticFeedback.selectionClick();
},
onTap: () => onTapTrackTile(track, index),
);
},
),
),
],
);
}
}

View File

@ -1,105 +0,0 @@
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/expandable_search/expandable_search.dart';
import 'package:spotube/components/sort_tracks_dropdown.dart';
import 'package:spotube/components/tracks_view/sections/body/track_view_options.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/components/tracks_view/track_view_provider.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/utils/platform.dart';
class TrackViewBodyHeaders extends HookConsumerWidget {
final ValueNotifier<bool> isFiltering;
final FocusNode searchFocus;
const TrackViewBodyHeaders({
super.key,
required this.isFiltering,
required this.searchFocus,
});
@override
Widget build(BuildContext context, ref) {
final ThemeData(:textTheme) = Theme.of(context);
final props = InheritedTrackView.of(context);
final trackViewState = ref.watch(trackViewProvider(props.tracks));
return LayoutBuilder(
builder: (context, constrains) {
return Row(
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: animation,
child: child,
),
);
},
child: Checkbox(
value: trackViewState.hasSelectedAll,
onChanged: (checked) {
if (checked == true) {
trackViewState.selectAll();
} else {
trackViewState.deselectAll();
}
},
),
),
Expanded(
flex: 7,
child: Row(
children: [
Text(
context.l10n.title,
style: textTheme.bodyLarge,
overflow: TextOverflow.ellipsis,
),
],
),
),
// used alignment of this table-head
if (constrains.mdAndUp)
Expanded(
flex: 3,
child: Row(
children: [
Text(
context.l10n.album,
overflow: TextOverflow.ellipsis,
style: textTheme.bodyLarge,
),
],
),
),
SortTracksDropdown(
value: trackViewState.sortBy,
onChanged: (value) {
trackViewState.sort(value);
},
),
ExpandableSearchButton(
isFiltering: isFiltering.value,
searchFocus: searchFocus,
onPressed: (value) {
isFiltering.value = value;
if (value) {
searchFocus.requestFocus();
} else {
searchFocus.unfocus();
}
},
),
const TrackViewBodyOptions(),
if (kIsDesktop) const Gap(10),
],
);
},
);
}
}

View File

@ -1,140 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/adaptive/adaptive_pop_sheet_list.dart';
import 'package:spotube/components/dialogs/confirm_download_dialog.dart';
import 'package:spotube/components/dialogs/playlist_add_track_dialog.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/components/tracks_view/track_view_provider.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
class TrackViewBodyOptions extends HookConsumerWidget {
const TrackViewBodyOptions({super.key});
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final ThemeData(:textTheme) = Theme.of(context);
ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final audioSource =
ref.watch(userPreferencesProvider.select((s) => s.audioSource));
final trackViewState = ref.watch(trackViewProvider(props.tracks));
final selectedTracks = trackViewState.selectedTracks;
return AdaptivePopSheetList(
tooltip: context.l10n.more_actions,
headings: [
Text(
context.l10n.more_actions,
style: textTheme.bodyLarge,
),
],
onSelected: (action) async {
switch (action) {
case "download":
{
final confirmed = audioSource == AudioSource.piped ||
await showDialog(
context: context,
builder: (context) {
return const ConfirmDownloadDialog();
},
);
if (confirmed != true) return;
await downloader.batchAddToQueue(selectedTracks);
trackViewState.deselectAll();
break;
}
case "add-to-playlist":
{
if (context.mounted) {
await showDialog(
context: context,
builder: (context) {
return PlaylistAddTrackDialog(
openFromPlaylist: props.collectionId,
tracks: selectedTracks.toList(),
);
},
);
}
break;
}
case "play-next":
{
playlistNotifier.addTracksAtFirst(selectedTracks);
playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([props.collection as PlaylistSimple]);
}
trackViewState.deselectAll();
break;
}
case "add-to-queue":
{
playlistNotifier.addTracks(selectedTracks);
playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([props.collection as PlaylistSimple]);
}
trackViewState.deselectAll();
break;
}
default:
}
},
icon: const Icon(SpotubeIcons.moreVertical),
children: [
AdaptiveMenuButton(
value: "download",
leading: const Icon(SpotubeIcons.download),
enabled: selectedTracks.isNotEmpty,
child: Text(
context.l10n.download_count(selectedTracks.length),
),
),
AdaptiveMenuButton(
value: "add-to-playlist",
leading: const Icon(SpotubeIcons.playlistAdd),
enabled: selectedTracks.isNotEmpty,
child: Text(
context.l10n.add_count_to_playlist(selectedTracks.length),
),
),
AdaptiveMenuButton(
enabled: selectedTracks.isNotEmpty,
value: "add-to-queue",
leading: const Icon(SpotubeIcons.queueAdd),
child: Text(
context.l10n.add_count_to_queue(selectedTracks.length),
),
),
AdaptiveMenuButton(
enabled: selectedTracks.isNotEmpty,
value: "play-next",
leading: const Icon(SpotubeIcons.lightning),
child: Text(
context.l10n.play_count_next(selectedTracks.length),
),
),
],
);
}
}

View File

@ -1,167 +0,0 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/assets.gen.dart';
import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/tracks_view/sections/header/header_actions.dart';
import 'package:spotube/components/tracks_view/sections/header/header_buttons.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:gap/gap.dart';
import 'package:spotube/extensions/constrains.dart';
import 'package:spotube/extensions/string.dart';
import 'package:spotube/hooks/utils/use_palette_color.dart';
import 'package:spotube/utils/platform.dart';
class TrackViewFlexHeader extends HookConsumerWidget {
const TrackViewFlexHeader({super.key});
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final ThemeData(:colorScheme, :textTheme, :iconTheme) = Theme.of(context);
final defaultTextStyle = DefaultTextStyle.of(context);
final mediaQuery = MediaQuery.of(context);
final palette = usePaletteColor(props.image, ref);
return IconTheme(
data: iconTheme.copyWith(color: palette.bodyTextColor),
child: SliverLayoutBuilder(
builder: (context, constrains) {
final isExpanded = constrains.scrollOffset < 350;
final headingStyle = (mediaQuery.mdAndDown
? textTheme.headlineSmall
: textTheme.headlineMedium)
?.copyWith(
color: palette.bodyTextColor,
);
return SliverAppBar(
iconTheme: iconTheme.copyWith(
color: palette.bodyTextColor,
size: 16,
),
actions: isExpanded
? []
: [
const TrackViewHeaderActions(),
TrackViewHeaderButtons(compact: true, color: palette),
],
floating: false,
pinned: true,
expandedHeight: 450,
automaticallyImplyLeading: kIsMobile,
backgroundColor: palette.color,
title: isExpanded ? null : Text(props.title, style: headingStyle),
flexibleSpace: FlexibleSpaceBar(
background: Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
image: DecorationImage(
image: UniversalImage.imageProvider(props.image),
fit: BoxFit.cover,
),
),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.black45,
colorScheme.surface,
],
begin: const FractionalOffset(0, 0),
end: const FractionalOffset(0, 1),
tileMode: TileMode.clamp,
),
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ConstrainedBox(
constraints: BoxConstraints(
maxWidth: mediaQuery.mdAndDown
? mediaQuery.size.width
: 800,
),
child: Flex(
direction: mediaQuery.mdAndDown
? Axis.vertical
: Axis.horizontal,
mainAxisSize: MainAxisSize.min,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: UniversalImage(
path: props.image,
width: 200,
height: 200,
placeholder: Assets.albumPlaceholder.path,
),
),
const Gap(20),
Flexible(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: mediaQuery.mdAndDown
? CrossAxisAlignment.center
: CrossAxisAlignment.start,
children: [
Text(
props.title,
style: headingStyle,
textAlign: mediaQuery.mdAndDown
? TextAlign.center
: TextAlign.start,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 10),
if (props.description != null &&
props.description!.isNotEmpty)
Text(
props.description!
.unescapeHtml()
.cleanHtml(),
style:
defaultTextStyle.style.copyWith(
color: palette.bodyTextColor,
),
textAlign: mediaQuery.mdAndDown
? TextAlign.center
: TextAlign.start,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const Gap(10),
const TrackViewHeaderActions(),
const Gap(10),
TrackViewHeaderButtons(color: palette),
],
),
),
],
),
),
],
),
),
),
),
),
),
),
);
},
),
);
}
}

View File

@ -1,111 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/heart_button/heart_button.dart';
import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication/authentication.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
class TrackViewHeaderActions extends HookConsumerWidget {
const TrackViewHeaderActions({super.key});
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final isActive = playlist.collections.contains(props.collectionId);
final isUserPlaylist = useIsUserPlaylist(ref, props.collectionId);
final scaffoldMessenger = ScaffoldMessenger.of(context);
final auth = ref.watch(authenticationProvider);
final copiedText =
context.l10n.copied_shareurl_to_clipboard(props.shareUrl);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: context.l10n.share,
icon: const Icon(SpotubeIcons.share),
onPressed: () async {
await Clipboard.setData(
ClipboardData(text: props.shareUrl),
);
scaffoldMessenger.showSnackBar(
SnackBar(
width: 300,
behavior: SnackBarBehavior.floating,
content: Text(
copiedText,
textAlign: TextAlign.center,
),
),
);
},
),
IconButton(
icon: const Icon(SpotubeIcons.queueAdd),
tooltip: context.l10n.add_to_queue,
onPressed: isActive || props.tracks.isEmpty
? null
: () async {
final tracks = await props.pagination.onFetchAll();
await playlistNotifier.addTracks(tracks);
playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier
.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier
.addPlaylists([props.collection as PlaylistSimple]);
}
},
),
if (props.onHeart != null && auth.asData?.value != null)
HeartButton(
isLiked: props.isLiked,
icon: isUserPlaylist ? SpotubeIcons.trash : null,
tooltip: props.isLiked
? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite,
onPressed: () async {
final shouldPop = await props.onHeart?.call();
if (isUserPlaylist && shouldPop == true && context.mounted) {
context.pop();
}
},
),
if (isUserPlaylist)
IconButton(
icon: const Icon(SpotubeIcons.edit),
onPressed: () {
showDialog(
context: context,
builder: (context) {
return PlaylistCreateDialog(
playlistId: props.collectionId,
trackIds: props.tracks.map((e) => e.id!).toList(),
);
},
);
},
),
],
);
}
}

View File

@ -1,206 +0,0 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:gap/gap.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:palette_generator/palette_generator.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/provider/connect/connect.dart';
import 'package:spotube/provider/history/history.dart';
import 'package:spotube/provider/audio_player/audio_player.dart';
import 'package:spotube/services/audio_player/audio_player.dart';
class TrackViewHeaderButtons extends HookConsumerWidget {
final PaletteColor color;
final bool compact;
const TrackViewHeaderButtons({
super.key,
required this.color,
this.compact = false,
});
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final playlist = ref.watch(audioPlayerProvider);
final playlistNotifier = ref.watch(audioPlayerProvider.notifier);
final historyNotifier = ref.watch(playbackHistoryActionsProvider);
final isActive = playlist.collections.contains(props.collectionId);
final isLoading = useState(false);
const progressIndicator = Center(
child: SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: .8),
),
);
void onShuffle() async {
try {
isLoading.value = true;
final initialTracks = props.tracks;
if (!context.mounted) return;
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) {
final allTracks = await props.pagination.onFetchAll();
final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load(
props.collection is AlbumSimple
? WebSocketLoadEventData.album(
tracks: allTracks,
collection: props.collection as AlbumSimple,
initialIndex: Random().nextInt(allTracks.length))
: WebSocketLoadEventData.playlist(
tracks: allTracks,
collection: props.collection as PlaylistSimple,
initialIndex: Random().nextInt(allTracks.length),
),
);
await remotePlayback.setShuffle(true);
} else {
await playlistNotifier.load(
initialTracks,
autoPlay: true,
initialIndex: Random().nextInt(initialTracks.length),
);
await audioPlayer.setShuffle(true);
playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier.addPlaylists([props.collection as PlaylistSimple]);
}
final allTracks = await props.pagination.onFetchAll();
await playlistNotifier.addTracks(
allTracks.sublist(initialTracks.length),
);
}
} finally {
isLoading.value = false;
}
}
void onPlay() async {
try {
isLoading.value = true;
final initialTracks = props.tracks;
if (!context.mounted) return;
final isRemoteDevice = await showSelectDeviceDialog(context, ref);
if (isRemoteDevice) {
final allTracks = await props.pagination.onFetchAll();
final remotePlayback = ref.read(connectProvider.notifier);
await remotePlayback.load(
props.collection is AlbumSimple
? WebSocketLoadEventData.album(
tracks: allTracks,
collection: props.collection as AlbumSimple,
)
: WebSocketLoadEventData.playlist(
tracks: allTracks,
collection: props.collection as PlaylistSimple,
),
);
} else {
await playlistNotifier.load(initialTracks, autoPlay: true);
playlistNotifier.addCollection(props.collectionId);
if (props.collection is AlbumSimple) {
historyNotifier.addAlbums([props.collection as AlbumSimple]);
} else {
historyNotifier.addPlaylists([props.collection as PlaylistSimple]);
}
final allTracks = await props.pagination.onFetchAll();
await playlistNotifier.addTracks(
allTracks.sublist(initialTracks.length),
);
}
} finally {
if (context.mounted) {
isLoading.value = false;
}
}
}
if (compact) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!isActive && !isLoading.value)
IconButton(
icon: const Icon(SpotubeIcons.shuffle),
onPressed: props.tracks.isEmpty ? null : onShuffle,
),
const Gap(10),
IconButton.filledTonal(
icon: isActive
? const Icon(SpotubeIcons.pause)
: isLoading.value
? progressIndicator
: const Icon(SpotubeIcons.play),
onPressed: isActive || props.tracks.isEmpty || isLoading.value
? null
: onPlay,
),
const Gap(10),
],
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedOpacity(
duration: const Duration(milliseconds: 300),
opacity: isActive || isLoading.value ? 0 : 1,
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
child: SizedBox.square(
dimension: isActive || isLoading.value ? 0 : null,
child: FilledButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
minimumSize: const Size(150, 40)),
label: Text(context.l10n.shuffle),
icon: const Icon(SpotubeIcons.shuffle),
onPressed: props.tracks.isEmpty ? null : onShuffle,
),
),
),
),
const Gap(10),
FilledButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: color.color,
foregroundColor: color.bodyTextColor,
minimumSize: const Size(150, 40)),
onPressed: isActive || props.tracks.isEmpty || isLoading.value
? null
: onPlay,
icon: isActive
? const Icon(SpotubeIcons.pause)
: isLoading.value
? progressIndicator
: const Icon(SpotubeIcons.play),
label: Text(context.l10n.play),
),
],
);
}
}

View File

@ -1,52 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/components/tracks_view/sections/header/flexible_header.dart';
import 'package:spotube/components/tracks_view/sections/body/track_view_body.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/utils/platform.dart';
class TrackView extends HookConsumerWidget {
const TrackView({super.key});
@override
Widget build(BuildContext context, ref) {
final props = InheritedTrackView.of(context);
final controller = useScrollController();
return Scaffold(
appBar: kIsDesktop
? const TitleBar(
backgroundColor: Colors.transparent,
leading: [
Align(
alignment: Alignment.centerLeft,
child: BackButton(color: Colors.white),
)
],
)
: null,
extendBodyBehindAppBar: true,
body: RefreshIndicator(
onRefresh: props.pagination.onRefresh,
child: InterScrollbar(
controller: controller,
child: CustomScrollView(
controller: controller,
slivers: const [
TrackViewFlexHeader(),
SliverAnimatedSwitcher(
duration: Duration(milliseconds: 500),
child: TrackViewBodySection(),
),
],
),
),
),
);
}
}

View File

@ -1,64 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/modules/library/user_local_tracks.dart';
class TrackViewNotifier extends ChangeNotifier {
List<Track> tracks;
List<String> selectedTrackIds;
SortBy sortBy;
String? searchQuery;
TrackViewNotifier(
this.tracks, {
this.selectedTrackIds = const [],
this.sortBy = SortBy.none,
this.searchQuery,
});
bool get isSelecting => selectedTrackIds.isNotEmpty;
bool get hasSelectedAll =>
selectedTrackIds.length == tracks.length && tracks.isNotEmpty;
List<Track> get selectedTracks =>
tracks.where((e) => selectedTrackIds.contains(e.id)).toList();
void selectTrack(String trackId) {
selectedTrackIds = [...selectedTrackIds, trackId];
notifyListeners();
}
void unselectTrack(String trackId) {
selectedTrackIds = selectedTrackIds.where((e) => e != trackId).toList();
notifyListeners();
}
void toggleTrackSelection(String trackId) {
if (selectedTrackIds.contains(trackId)) {
unselectTrack(trackId);
} else {
selectTrack(trackId);
}
}
void selectAll() {
selectedTrackIds = tracks.map((e) => e.id!).toList();
notifyListeners();
}
void deselectAll() {
selectedTrackIds = [];
notifyListeners();
}
void sort(SortBy sortBy) {
this.sortBy = sortBy;
notifyListeners();
}
}
final trackViewProvider = ChangeNotifierProvider.autoDispose
.family<TrackViewNotifier, List<Track>>((ref, tracks) {
return TrackViewNotifier(tracks);
});

View File

@ -402,5 +402,10 @@
"found_n_files": "Found {count} files",
"export_cache_confirmation": "Do you want to export these files to",
"exported_n_out_of_m_files": "Exported {filesExported} out of {files} files",
"undo": "Undo"
"undo": "Undo",
"download_all": "Download all",
"add_all_to_playlist": "Add all to playlist",
"add_all_to_queue": "Add all to queue",
"play_all_next": "Play all next",
"pause": "Pause"
}

View File

@ -3,6 +3,7 @@ import 'dart:ui';
import 'package:desktop_webview_window/desktop_webview_window.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart' as material;
import 'package:flutter/services.dart';
import 'package:flutter_discord_rpc/flutter_discord_rpc.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
@ -225,6 +226,9 @@ class Spotube extends HookConsumerWidget {
surfaceOpacity: .8,
surfaceBlur: 10,
),
materialTheme: material.ThemeData(
splashFactory: material.NoSplash.splashFactory,
),
themeMode: themeMode,
shortcuts: {
...WidgetsApp.defaultShortcuts.map((key, value) {

View File

@ -78,8 +78,8 @@ class Sidebar extends HookConsumerWidget {
isLabelVisible: tile.title == "Library" && downloadCount > 0,
label: Text(
downloadCount.toString(),
style: const TextStyle(
color: Colors.white,
style: TextStyle(
color: context.theme.colorScheme.primaryForeground,
fontSize: 10,
),
),

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart' show ListTileTheme, ListTileThemeData;
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart' hide Theme, ThemeData;
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
class SectionCardWithHeading extends StatelessWidget {
@ -35,7 +35,9 @@ class SectionCardWithHeading extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Text(
heading,
style: context.theme.typography.large,
style: context.theme.typography.large.copyWith(
color: context.theme.colorScheme.foreground,
),
),
),
Padding(

View File

@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/tracks_view/track_view.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/track_presentation.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/provider/spotify/spotify.dart';
@ -23,7 +23,8 @@ class AlbumPage extends HookConsumerWidget {
final favoriteAlbumsNotifier = ref.watch(favoriteAlbumsProvider.notifier);
final isSavedAlbum = ref.watch(albumsIsSavedProvider(album.id!));
return InheritedTrackView(
return TrackPresentation(
options: TrackPresentationOptions(
collection: album,
image: album.images.asUrlString(
placeholder: ImagePlaceholder.albumArt,
@ -49,6 +50,7 @@ class AlbumPage extends HookConsumerWidget {
shareUrl: album.externalUrls?.spotify ??
"https://open.spotify.com/album/${album.id}",
isLiked: isSavedAlbum.asData?.value ?? false,
owner: album.artists!.first.name,
onHeart: isSavedAlbum.asData?.value == null
? null
: () async {
@ -59,7 +61,7 @@ class AlbumPage extends HookConsumerWidget {
}
return null;
},
child: const TrackView(),
),
);
}
}

View File

@ -17,7 +17,7 @@ import 'package:spotube/components/expandable_search/expandable_search.dart';
import 'package:spotube/components/fallbacks/not_found.dart';
import 'package:spotube/components/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/components/sort_tracks_dropdown.dart';
import 'package:spotube/components/track_presentation/sort_tracks_dropdown.dart';
import 'package:spotube/components/track_tile/track_tile.dart';
import 'package:spotube/extensions/artist_simple.dart';
import 'package:spotube/extensions/context.dart';

View File

@ -1,8 +1,8 @@
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/tracks_view/track_view.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/track_presentation.dart';
import 'package:spotube/pages/playlist/playlist.dart';
import 'package:spotube/provider/spotify/spotify.dart';
@ -20,7 +20,8 @@ class LikedPlaylistPage extends HookConsumerWidget {
final likedTracks = ref.watch(likedTracksProvider);
final tracks = likedTracks.asData?.value ?? <Track>[];
return InheritedTrackView(
return TrackPresentation(
options: TrackPresentationOptions(
collection: playlist,
image: "assets/liked-tracks.jpg",
pagination: PaginationProps(
@ -39,9 +40,10 @@ class LikedPlaylistPage extends HookConsumerWidget {
tracks: tracks,
routePath: '/playlist/${playlist.id}',
isLiked: false,
shareUrl: "",
shareUrl: null,
onHeart: null,
child: const TrackView(),
owner: playlist.owner?.displayName,
),
);
}
}

View File

@ -3,9 +3,9 @@ import 'package:flutter/material.dart' hide Page;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/dialogs/prompt_dialog.dart';
import 'package:spotube/components/tracks_view/sections/body/use_is_user_playlist.dart';
import 'package:spotube/components/tracks_view/track_view.dart';
import 'package:spotube/components/tracks_view/track_view_props.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/track_presentation.dart';
import 'package:spotube/components/track_presentation/use_is_user_playlist.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/image.dart';
import 'package:spotube/provider/spotify/spotify.dart';
@ -45,7 +45,8 @@ class PlaylistPage extends HookConsumerWidget {
final isUserPlaylist = useIsUserPlaylist(ref, playlist.id!);
return InheritedTrackView(
return TrackPresentation(
options: TrackPresentationOptions(
collection: playlist,
image: playlist.images.asUrlString(
placeholder: ImagePlaceholder.collection,
@ -63,6 +64,8 @@ class PlaylistPage extends HookConsumerWidget {
),
title: playlist.name!,
description: playlist.description,
owner: playlist.owner?.displayName,
ownerImage: playlist.owner?.images?.lastOrNull?.url,
tracks: tracks.asData?.value.items ?? [],
routePath: '/playlist/${playlist.id}',
isLiked: isFavoritePlaylist.asData?.value ?? false,
@ -87,7 +90,7 @@ class PlaylistPage extends HookConsumerWidget {
}
return isUserPlaylist;
},
child: const TrackView(),
),
);
}
}

View File

@ -48,6 +48,7 @@
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
enableGPUValidationMode = "1"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">

View File

@ -98,10 +98,10 @@ packages:
dependency: "direct main"
description:
name: async
sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c"
sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63
url: "https://pub.dev"
source: hosted
version: "2.11.0"
version: "2.12.0"
audio_service:
dependency: "direct main"
description:
@ -203,10 +203,10 @@ packages:
dependency: transitive
description:
name: boolean_selector
sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66"
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
version: "2.1.2"
build:
dependency: transitive
description:
@ -347,10 +347,10 @@ packages:
dependency: transitive
description:
name: clock
sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
source: hosted
version: "1.1.1"
version: "1.1.2"
code_builder:
dependency: transitive
description:
@ -598,10 +598,10 @@ packages:
dependency: transitive
description:
name: fake_async
sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78"
sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
version: "1.3.2"
ffi:
dependency: transitive
description:
@ -614,10 +614,10 @@ packages:
dependency: transitive
description:
name: file
sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c"
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.0"
version: "7.0.1"
file_picker:
dependency: "direct main"
description:
@ -1330,18 +1330,18 @@ packages:
dependency: transitive
description:
name: leak_tracker
sha256: "7bb2830ebd849694d1ec25bf1f44582d6ac531a57a365a803a6034ff751d2d06"
sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec
url: "https://pub.dev"
source: hosted
version: "10.0.7"
version: "10.0.8"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "9491a714cca3667b60b5c420da8217e6de0d1ba7a5ec322fab01758f6998f379"
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
url: "https://pub.dev"
source: hosted
version: "3.0.8"
version: "3.0.9"
leak_tracker_testing:
dependency: transitive
description:
@ -1642,10 +1642,10 @@ packages:
dependency: "direct main"
description:
name: path
sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af"
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.0"
version: "1.9.1"
path_drawing:
dependency: transitive
description:
@ -1786,10 +1786,10 @@ packages:
dependency: transitive
description:
name: platform
sha256: "9b71283fc13df574056616011fb138fd3b793ea47cc509c189a6c3fa5f8a1a65"
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.5"
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
@ -1826,10 +1826,10 @@ packages:
dependency: transitive
description:
name: process
sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32"
sha256: "107d8be718f120bbba9dcd1e95e3bd325b1b4a4f07db64154635ba03f2567a0d"
url: "https://pub.dev"
source: hosted
version: "5.0.2"
version: "5.0.3"
process_run:
dependency: "direct dev"
description:
@ -2257,10 +2257,10 @@ packages:
dependency: transitive
description:
name: string_scanner
sha256: "688af5ed3402a4bde5b3a6c15fd768dbf2621a614950b17f04626c431ab3c4c3"
sha256: "0bd04f5bb74fcd6ff0606a888a30e917af9bd52820b178eaa464beb11dca84b6"
url: "https://pub.dev"
source: hosted
version: "1.3.0"
version: "1.4.0"
stroke_text:
dependency: "direct main"
description:
@ -2553,10 +2553,10 @@ packages:
dependency: transitive
description:
name: vm_service
sha256: f6be3ed8bd01289b34d679c2b62226f63c0e69f9fd2e50a6b3c1c729a961041b
sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14"
url: "https://pub.dev"
source: hosted
version: "14.3.0"
version: "14.3.1"
watcher:
dependency: transitive
description:

View File

@ -1,105 +1,235 @@
{
"ar": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"bn": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"ca": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"cs": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"de": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"es": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"eu": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"fa": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"fi": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"fr": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"hi": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"id": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"it": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"ja": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"ka": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"ko": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"ne": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"nl": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"pl": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"pt": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"ru": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"th": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"tr": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"uk": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"vi": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
],
"zh": [
"undo"
"undo",
"download_all",
"add_all_to_playlist",
"add_all_to_queue",
"play_all_next",
"pause"
]
}