feat: re-designed playlist/album page

This commit is contained in:
Kingkor Roy Tirtho 2023-06-17 13:08:33 +06:00
parent a0767f4664
commit 0cedc7a418
6 changed files with 295 additions and 170 deletions

View File

@ -79,8 +79,8 @@ final router = GoRouter(
routes: [
GoRoute(
path: "blacklist",
pageBuilder: (context, state) => const SpotubePage(
child: BlackListPage(),
pageBuilder: (context, state) => SpotubeSlidePage(
child: const BlackListPage(),
),
),
GoRoute(

View File

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

View File

@ -1,14 +1,13 @@
import 'dart:ui';
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';
@ -66,68 +65,33 @@ class TrackCollectionView<T> extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
final mediaQuery = MediaQuery.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,
),
icon: const Icon(SpotubeIcons.share),
onPressed: onShare,
),
if (heartBtn != null && auth != null) heartBtn!,
IconButton(
tooltip: context.l10n.shuffle,
icon: Icon(
SpotubeIcons.shuffle,
color: color?.titleTextColor,
onPressed: isPlaying
? null
: tracksSnapshot.data != null
? onAddToQueue
: null,
icon: const Icon(
SpotubeIcons.queueAdd,
),
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,
@ -147,48 +111,21 @@ class TrackCollectionView<T> extends HookConsumerWidget {
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,
? const PageWindowTitleBar(
backgroundColor: Colors.transparent,
foregroundColor: Colors.white,
leadingWidth: 400,
leading: Row(
mainAxisSize: MainAxisSize.min,
children: [
BackButton(color: color?.titleTextColor),
const SizedBox(width: 10),
searchbar,
],
leading: Align(
alignment: Alignment.centerLeft,
child: BackButton(color: Colors.white),
),
)
: null,
extendBodyBehindAppBar: kIsDesktop,
body: RefreshIndicator(
onRefresh: () async {
await tracksSnapshot.refresh();
@ -199,13 +136,36 @@ class TrackCollectionView<T> extends HookConsumerWidget {
slivers: [
SliverAppBar(
actions: [
if (kIsMobile)
CompactSearch(
onChanged: (value) => searchText.value = value,
placeholder: context.l10n.search_tracks,
iconColor: color?.titleTextColor,
AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: collapsed.value ? 1 : 0,
child: Row(
mainAxisSize: MainAxisSize.min,
children: buttons,
),
if (collapsed.value) ...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,
@ -220,7 +180,7 @@ class TrackCollectionView<T> extends HookConsumerWidget {
title: collapsed.value
? Text(
title,
style: theme.textTheme.titleLarge!.copyWith(
style: theme.textTheme.titleMedium!.copyWith(
color: color?.titleTextColor,
fontWeight: FontWeight.w600,
),
@ -230,80 +190,140 @@ class TrackCollectionView<T> extends HookConsumerWidget {
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,
image: DecorationImage(
image: UniversalImage.imageProvider(titleImage),
fit: BoxFit.cover,
),
),
child: Material(
type: MaterialType.transparency,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
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: 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,
),
),
child: Material(
type: MaterialType.transparency,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
child: Wrap(
spacing: 20,
runSpacing: 20,
crossAxisAlignment: WrapCrossAlignment.center,
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
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,
Container(
constraints:
const BoxConstraints(maxHeight: 200),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: UniversalImage(
path: titleImage,
placeholder:
Assets.albumPlaceholder.path,
),
),
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,
),
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
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)
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),
Row(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: color?.color,
),
label: Text(context.l10n.shuffle),
icon: const Icon(
SpotubeIcons.shuffle),
onPressed:
tracksSnapshot.data == null ||
isPlaying
? null
: onShuffledPlay,
),
const SizedBox(width: 10),
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,
),
),
],
),
],
)
],
)
],
),
),
),
),
),
@ -324,13 +344,15 @@ class TrackCollectionView<T> extends HookConsumerWidget {
return TracksTableView(
List.from(
(filteredTracks ?? []).map(
(e) {
if (e is Track) {
return e;
(tracksSnapshot.data ?? []).map(
(track) {
if (track is Track) {
return track;
} else {
return TypeConversionUtils.simpleTrack_X_Track(
e, album!);
track,
album!,
);
}
},
),

View File

@ -100,10 +100,14 @@ class TrackTile extends HookConsumerWidget {
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: UniversalImage(
path: TypeConversionUtils.image_X_UrlString(
track.album?.images,
placeholder: ImagePlaceholder.albumArt,
child: AspectRatio(
aspectRatio: 1,
child: UniversalImage(
path: TypeConversionUtils.image_X_UrlString(
track.album?.images,
placeholder: ImagePlaceholder.albumArt,
),
fit: BoxFit.cover,
),
),
),

View File

@ -1,6 +1,8 @@
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:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
@ -43,7 +45,9 @@ class TracksTableView extends HookConsumerWidget {
@override
Widget build(context, ref) {
final playlist = ref.watch(ProxyPlaylistNotifier.provider);
final theme = Theme.of(context);
ref.watch(ProxyPlaylistNotifier.provider);
final playback = ref.watch(ProxyPlaylistNotifier.notifier);
ref.watch(downloadManagerProvider);
final downloader = ref.watch(downloadManagerProvider.notifier);
@ -54,11 +58,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 +92,7 @@ class TracksTableView extends HookConsumerWidget {
[sortedTracks],
);
final children = sortedTracks.isEmpty
final children = tracks.isEmpty
? [const NotFound(vertical: true)]
: [
if (heading != null) heading!,
@ -105,7 +129,7 @@ class TracksTableView extends HookConsumerWidget {
: const SizedBox(width: 16),
),
Expanded(
flex: 5,
flex: 7,
child: Row(
children: [
Text(
@ -139,6 +163,28 @@ class TracksTableView extends HookConsumerWidget {
.state = value;
},
),
IconButton(
tooltip: context.l10n.filter_playlists,
icon: const Icon(SpotubeIcons.filter),
style: IconButton.styleFrom(
foregroundColor: isFiltering.value
? theme.colorScheme.secondary
: null,
backgroundColor: isFiltering.value
? theme.colorScheme.secondaryContainer
: null,
minimumSize: const Size(22, 22),
),
onPressed: () {
isFiltering.value = !isFiltering.value;
if (isFiltering.value) {
searchFocus.requestFocus();
} else {
searchController.clear();
searchFocus.unfocus();
}
},
),
AdaptivePopSheetList(
tooltip: context.l10n.more_actions,
headings: [
@ -250,6 +296,38 @@ class TracksTableView extends HookConsumerWidget {
],
);
}),
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(
autofocus: true,
focusNode: searchFocus,
controller: searchController,
decoration: InputDecoration(
hintText: context.l10n.search_tracks,
isDense: true,
prefixIcon: const Icon(SpotubeIcons.search),
),
),
),
),
),
),
),
...sortedTracks.mapIndexed((i, track) {
return TrackTile(
index: i,

View File

@ -237,5 +237,6 @@
"likes": "Likes",
"dislikes": "Dislikes",
"views": "Views",
"streamUrl": "Stream URL"
"streamUrl": "Stream URL",
"stop": "Stop"
}