diff --git a/lib/modules/home/sections/genres.dart b/lib/modules/home/sections/genres.dart index 57006b59..add61a5a 100644 --- a/lib/modules/home/sections/genres.dart +++ b/lib/modules/home/sections/genres.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -36,209 +38,297 @@ class HomeGenresSection extends HookConsumerWidget { ], [mediaQuery.mdAndDown, categoriesQuery.asData?.value], ); + final controller = useMemoized(() => CarouselController(), []); + final interactedRef = useRef(false); - return SliverMainAxisGroup( - slivers: [ - SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - context.l10n.genres, - style: context.theme.typography.h4, - ), - Directionality( - textDirection: TextDirection.rtl, - child: Button.link( - onPressed: () { - context.pushNamed(GenrePage.name); - }, - leading: const Icon(SpotubeIcons.angleRight), - child: Text( - context.l10n.browse_all, - ).muted(), - ), + useEffect(() { + int times = 0; + Timer.periodic( + const Duration(seconds: 5), + (timer) { + if (times > 5 || interactedRef.value) { + timer.cancel(); + return; + } + controller.animateNext( + const Duration(seconds: 2), + ); + times++; + }, + ); + + return controller.dispose; + }, []); + + return SliverList.list( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + context.l10n.genres, + style: context.theme.typography.h4, + ), + Directionality( + textDirection: TextDirection.rtl, + child: Button.link( + onPressed: () { + context.pushNamed(GenrePage.name); + }, + leading: const Icon(SpotubeIcons.angleRight), + child: Text( + context.l10n.browse_all, + ).muted(), ), - ], - ), + ), + ], ), ), - const SliverGap(8), - SliverToBoxAdapter( - child: SizedBox( - height: 280 * theme.scaling, - child: Carousel( - transition: const CarouselTransition.sliding(gap: 24), - sizeConstraint: CarouselSizeConstraint.fixed( - mediaQuery.mdAndUp - ? mediaQuery.width * .6 - : mediaQuery.width * .95, - ), - itemCount: categories.length, - autoplaySpeed: const Duration(seconds: 2), - duration: const Duration(seconds: 5), - pauseOnHover: true, - direction: Axis.horizontal, - itemBuilder: (context, index) { - final category = categories[index]; - final playlists = - ref.watch(categoryPlaylistsProvider(category.id!)); - final playlistsData = playlists.asData?.value.items.take(8) ?? - List.generate(5, (index) => FakeData.playlistSimple); + const Gap(8), + Stack( + children: [ + SizedBox( + height: 280 * theme.scaling, + child: Carousel( + controller: controller, + transition: const CarouselTransition.sliding(gap: 24), + sizeConstraint: CarouselSizeConstraint.fixed( + mediaQuery.mdAndUp + ? mediaQuery.width * .6 + : mediaQuery.width * .95, + ), + itemCount: categories.length, + pauseOnHover: true, + direction: Axis.horizontal, + itemBuilder: (context, index) { + final category = categories[index]; + final playlists = + ref.watch(categoryPlaylistsProvider(category.id!)); + final playlistsData = playlists.asData?.value.items.take(8) ?? + List.generate(5, (index) => FakeData.playlistSimple); - return Container( - margin: const EdgeInsets.symmetric(horizontal: 8), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - borderRadius: theme.borderRadiusXxl, - border: Border.all( - color: theme.colorScheme.border, - width: 1, - ), - image: DecorationImage( - image: UniversalImage.imageProvider( - category.icons!.first.url!, + return Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: theme.borderRadiusXxl, + border: Border.all( + color: theme.colorScheme.border, + width: 1, ), - colorFilter: ColorFilter.mode( - theme.colorScheme.background.withAlpha(125), - BlendMode.darken, + image: DecorationImage( + image: UniversalImage.imageProvider( + category.icons!.first.url!, + ), + colorFilter: ColorFilter.mode( + theme.colorScheme.background.withAlpha(125), + BlendMode.darken, + ), + fit: BoxFit.cover, ), - fit: BoxFit.cover, ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 16, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - category.name!, - style: const TextStyle(color: Colors.white), - ).h3(), - Button.link( - onPressed: () { - context.pushNamed( - GenrePlaylistsPage.name, - pathParameters: {'categoryId': category.id!}, - extra: category, - ); - }, - child: Text( - context.l10n.view_all, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 16, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + category.name!, style: const TextStyle(color: Colors.white), - ).muted(), - ), - ], - ), - Expanded( - child: Skeleton.ignore( - child: Skeletonizer( - enabled: playlists.isLoading, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - spacing: 12, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - for (final playlist in playlistsData) - Container( - width: 115 * theme.scaling, - decoration: BoxDecoration( - color: theme.colorScheme.background - .withAlpha(75), - borderRadius: theme.borderRadiusMd, - ), - child: SurfaceBlur( - borderRadius: theme.borderRadiusMd, - surfaceBlur: theme.surfaceBlur, - child: Button( - style: - ButtonVariance.secondary.copyWith( - padding: (context, states, value) => - const EdgeInsets.all(8), - decoration: - (context, states, value) { - final decoration = ButtonVariance - .secondary - .decoration( - context, states) - as BoxDecoration; + ).h3(), + Button.link( + onPressed: () { + context.pushNamed( + GenrePlaylistsPage.name, + pathParameters: {'categoryId': category.id!}, + extra: category, + ); + }, + child: Text( + context.l10n.view_all, + style: const TextStyle(color: Colors.white), + ).muted(), + ), + ], + ), + Expanded( + child: Skeleton.ignore( + child: Skeletonizer( + enabled: playlists.isLoading, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + spacing: 12, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + for (final playlist in playlistsData) + Container( + width: 115 * theme.scaling, + decoration: BoxDecoration( + color: theme.colorScheme.background + .withAlpha(75), + borderRadius: theme.borderRadiusMd, + ), + child: SurfaceBlur( + borderRadius: theme.borderRadiusMd, + surfaceBlur: theme.surfaceBlur, + child: Button( + style: ButtonVariance.secondary + .copyWith( + padding: + (context, states, value) => + const EdgeInsets.all(8), + decoration: + (context, states, value) { + final decoration = + ButtonVariance.secondary + .decoration( + context, states) + as BoxDecoration; - if (states.isNotEmpty) { - return decoration; - } + if (states.isNotEmpty) { + return decoration; + } - return decoration.copyWith( - color: decoration.color - ?.withAlpha(180), + return decoration.copyWith( + color: decoration.color + ?.withAlpha(180), + ); + }, + ), + onPressed: () { + context.pushNamed( + PlaylistPage.name, + pathParameters: { + "id": playlist.id!, + }, + extra: playlist, ); }, - ), - onPressed: () { - context.pushNamed( - PlaylistPage.name, - pathParameters: { - "id": playlist.id!, - }, - extra: playlist, - ); - }, - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - spacing: 5, - children: [ - ClipRRect( - borderRadius: - theme.borderRadiusSm, - child: UniversalImage( - path: (playlist.images)! - .asUrlString( - placeholder: - ImagePlaceholder - .collection, - index: 1, + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + spacing: 5, + children: [ + ClipRRect( + borderRadius: + theme.borderRadiusSm, + child: UniversalImage( + path: (playlist.images)! + .asUrlString( + placeholder: + ImagePlaceholder + .collection, + index: 1, + ), + fit: BoxFit.cover, + height: 100 * theme.scaling, + width: 100 * theme.scaling, ), - fit: BoxFit.cover, - height: 100 * theme.scaling, - width: 100 * theme.scaling, ), - ), - Text( - playlist.name!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - ).semiBold().small(), - if (playlist.description != null) Text( - playlist.description - ?.unescapeHtml() - .cleanHtml() ?? - "", + playlist.name!, maxLines: 2, overflow: TextOverflow.ellipsis, - ).xSmall().muted(), - ], + ).semiBold().small(), + if (playlist.description != + null) + Text( + playlist.description + ?.unescapeHtml() + .cleanHtml() ?? + "", + maxLines: 2, + overflow: + TextOverflow.ellipsis, + ).xSmall().muted(), + ], + ), ), ), ), - ), - ], + ], + ), ), ), ), - ), - ) + ) + ], + ), + ); + }, + ), + ), + Positioned( + left: 0, + child: Container( + height: 280 * theme.scaling, + width: (mediaQuery.mdAndUp ? 80 : 50) * theme.scaling, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + theme.colorScheme.background.withAlpha(255), + theme.colorScheme.background.withAlpha(0), ], ), - ); - }, + ), + alignment: Alignment.center, + child: IconButton.ghost( + size: + mediaQuery.mdAndUp ? ButtonSize.normal : ButtonSize.small, + icon: const Icon(SpotubeIcons.angleLeft), + onPressed: () { + controller.animatePrevious( + const Duration(seconds: 1), + ); + interactedRef.value = true; + }, + ), + ), ), + Positioned( + right: 0, + child: Container( + height: 280 * theme.scaling, + width: (mediaQuery.mdAndUp ? 80 : 50) * theme.scaling, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + theme.colorScheme.background.withAlpha(0), + theme.colorScheme.background.withAlpha(255), + ], + ), + ), + alignment: Alignment.center, + child: IconButton.ghost( + size: + mediaQuery.mdAndUp ? ButtonSize.normal : ButtonSize.small, + icon: const Icon(SpotubeIcons.angleRight), + onPressed: () { + controller.animateNext( + const Duration(seconds: 1), + ); + interactedRef.value = true; + }, + ), + ), + ), + ], + ), + const Gap(8), + Center( + child: CarouselDotIndicator( + itemCount: categories.length, + controller: controller, ), ), ], diff --git a/lib/modules/library/local_folder/cache_export_dialog.dart b/lib/modules/library/local_folder/cache_export_dialog.dart index 1d1421be..0f10defc 100644 --- a/lib/modules/library/local_folder/cache_export_dialog.dart +++ b/lib/modules/library/local_folder/cache_export_dialog.dart @@ -1,10 +1,9 @@ import 'dart:io'; -import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:path/path.dart'; +import 'package:path/path.dart' as path; import 'package:spotube/extensions/context.dart'; import 'package:spotube/services/logger/logger.dart'; import 'package:spotube/services/sourced_track/enums.dart'; @@ -22,7 +21,7 @@ class LocalFolderCacheExportDialog extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final ThemeData(:textTheme, :colorScheme) = Theme.of(context); + final ThemeData(:typography, :colorScheme) = Theme.of(context); final files = useState>([]); final filesExported = useState(0); @@ -31,7 +30,7 @@ class LocalFolderCacheExportDialog extends HookConsumerWidget { final stream = cacheDir.list().where( (event) => event is File && - codecs.contains(extension(event.path).replaceAll(".", "")), + codecs.contains(path.extension(event.path).replaceAll(".", "")), ); stream.listen( @@ -76,8 +75,8 @@ class LocalFolderCacheExportDialog extends HookConsumerWidget { ), TextSpan( text: "\n${exportDir.path}?", - style: textTheme.labelMedium!.copyWith( - color: colorScheme.secondary, + style: typography.small.copyWith( + color: colorScheme.mutedForeground, ), ), ], @@ -102,7 +101,7 @@ class LocalFolderCacheExportDialog extends HookConsumerWidget { ), ), actions: [ - TextButton( + Button.outline( onPressed: isExportInProgress ? null : () { @@ -110,14 +109,14 @@ class LocalFolderCacheExportDialog extends HookConsumerWidget { }, child: Text(context.l10n.cancel), ), - TextButton( + Button.primary( onPressed: isExportInProgress ? null : () async { for (final file in files.value) { try { final destinationFile = File( - join(exportDir.path, basename(file.path)), + path.join(exportDir.path, path.basename(file.path)), ); if (await destinationFile.exists()) {