From 0cedc7a4187771efce8152003f890e242116c78c Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Sat, 17 Jun 2023 13:08:33 +0600 Subject: [PATCH] feat: re-designed playlist/album page --- lib/collections/routes.dart | 4 +- lib/components/shared/spotube_page_route.dart | 20 ++ .../track_table/track_collection_view.dart | 336 ++++++++++-------- .../shared/track_table/track_tile.dart | 12 +- .../shared/track_table/tracks_table_view.dart | 88 ++++- lib/l10n/app_en.arb | 3 +- 6 files changed, 294 insertions(+), 169 deletions(-) diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index fc0fb8381..44f57defe 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -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( diff --git a/lib/components/shared/spotube_page_route.dart b/lib/components/shared/spotube_page_route.dart index 92049fb11..22e4d2f1d 100644 --- a/lib/components/shared/spotube_page_route.dart +++ b/lib/components/shared/spotube_page_route.dart @@ -1,5 +1,25 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; class SpotubePage extends MaterialPage { 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( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(animation), + child: child, + ); + }, + ); +} diff --git a/lib/components/shared/track_table/track_collection_view.dart b/lib/components/shared/track_table/track_collection_view.dart index da657f427..d6cb29cff 100644 --- a/lib/components/shared/track_table/track_collection_view.dart +++ b/lib/components/shared/track_table/track_collection_view.dart @@ -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 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 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 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 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, + ), + ), + 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, ), - if (collapsed.value) ...buttons, + ), + 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 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 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, + Container( + constraints: + const BoxConstraints(maxHeight: 200), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: UniversalImage( + path: titleImage, + placeholder: + Assets.albumPlaceholder.path, + ), ), ), - 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, + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: theme.textTheme.titleLarge! + .copyWith( + color: Colors.white, + fontWeight: FontWeight.w600, + ), ), - ), - if (description != null) - Text( - description!, - style: TextStyle( - color: color?.bodyTextColor, + 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, + ), ), - maxLines: 2, - overflow: TextOverflow.fade, - ), - const SizedBox(height: 10), - 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 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!, + ); } }, ), diff --git a/lib/components/shared/track_table/track_tile.dart b/lib/components/shared/track_table/track_tile.dart index 62d335144..5f8b97532 100644 --- a/lib/components/shared/track_table/track_tile.dart +++ b/lib/components/shared/track_table/track_tile.dart @@ -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, ), ), ), diff --git a/lib/components/shared/track_table/tracks_table_view.dart b/lib/components/shared/track_table/tracks_table_view.dart index 9ac8034ce..e6835129f 100644 --- a/lib/components/shared/track_table/tracks_table_view.dart +++ b/lib/components/shared/track_table/tracks_table_view.dart @@ -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(false); final sortBy = ref.watch(trackCollectionSortState(playlistId ?? '')); + final isFiltering = useState(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, diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index c820e5889..f29f29fcc 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -237,5 +237,6 @@ "likes": "Likes", "dislikes": "Dislikes", "views": "Views", - "streamUrl": "Stream URL" + "streamUrl": "Stream URL", + "stop": "Stop" } \ No newline at end of file