From 8a193658af22933e440c8c27a45ed1e66d1a723c Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:34:48 +0000 Subject: [PATCH] UI overhaul: Spotify design system, shell, and page redesign - Add Spotify design system (colors, typography, ThemeData, components) - Build Spotify shell (Sidebar, TopBar, BottomPlayer, ContentArea) - Redesign Home, Search, Library pages with Spotify cards/track rows - Restyle context menus, shimmer loaders, hover states - Rebrand app name to Spotify across Android/iOS/macOS/Linux/Windows - Update launcher icons and splash config to Spotify logo No backend/API/state/audio logic changed. --- android/app/src/main/res/values/strings.xml | 3 + flutter_launcher_icons.yaml | 10 +- ios/Runner/Info.plist | 4 +- .../track_presentation/presentation_top.dart | 431 +++++++------ lib/components/track_tile/track_options.dart | 533 +++++++++------- lib/components/track_tile/track_tile.dart | 291 ++------- lib/main.dart | 44 +- lib/modules/album/album_card.dart | 52 +- lib/modules/artist/artist_card.dart | 120 ++-- lib/modules/playlist/playlist_card.dart | 52 +- lib/modules/root/spotify_bottom_player.dart | 163 +++++ lib/modules/root/spotify_shell.dart | 48 ++ lib/modules/root/spotify_sidebar.dart | 176 ++++++ lib/modules/root/spotify_top_bar.dart | 163 +++++ lib/pages/home/home.dart | 115 ++-- lib/pages/library/library.dart | 94 ++- lib/pages/root/root_app.dart | 35 +- lib/pages/search/search.dart | 291 +++++---- lib/pages/search/tabs/albums.dart | 3 + lib/pages/search/tabs/artists.dart | 28 +- lib/pages/search/tabs/playlists.dart | 3 + lib/theme/spotify_colors.dart | 17 + lib/theme/spotify_components.dart | 579 ++++++++++++++++++ lib/theme/spotify_theme.dart | 85 +++ lib/theme/spotify_typography.dart | 101 +++ linux/spotube.desktop | 2 +- macos/Runner/Configs/AppInfo.xcconfig | 2 +- pubspec.yaml | 14 +- windows/runner/Runner.rc | 4 +- 29 files changed, 2413 insertions(+), 1050 deletions(-) create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 lib/modules/root/spotify_bottom_player.dart create mode 100644 lib/modules/root/spotify_shell.dart create mode 100644 lib/modules/root/spotify_sidebar.dart create mode 100644 lib/modules/root/spotify_top_bar.dart create mode 100644 lib/theme/spotify_colors.dart create mode 100644 lib/theme/spotify_components.dart create mode 100644 lib/theme/spotify_theme.dart create mode 100644 lib/theme/spotify_typography.dart diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..734829ae --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + Spotify + diff --git a/flutter_launcher_icons.yaml b/flutter_launcher_icons.yaml index e5b26882..f709cdfd 100644 --- a/flutter_launcher_icons.yaml +++ b/flutter_launcher_icons.yaml @@ -1,12 +1,12 @@ # flutter pub run flutter_launcher_icons flutter_launcher_icons: - image_path: "assets/branding/spotube-logo.png" + image_path: "assets/images/spotify_logo.png" android: true # image_path_android: "assets/branding/icon/icon.png" min_sdk_android: 21 # android min sdk min:16, default 21 - adaptive_icon_background: "#242832" - adaptive_icon_foreground: "assets/branding/spotube-logo-foreground.png" + adaptive_icon_background: "#000000" + adaptive_icon_foreground: "assets/images/spotify_logo.png" # adaptive_icon_monochrome: "assets/branding/icon/monochrome.png" ios: true @@ -21,9 +21,9 @@ flutter_launcher_icons: windows: generate: true - image_path: "assets/branding/spotube-logo.png" + image_path: "assets/images/spotify_logo.png" icon_size: 48 # min:48, max:256, default: 48 macos: generate: true - image_path: "assets/branding/spotube-logo-macos.png" + image_path: "assets/images/spotify_logo.png" diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 8d6c09a2..23f98ca8 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Spotube + Spotify CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -15,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - spotube + Spotify CFBundlePackageType APPL CFBundleShortVersionString diff --git a/lib/components/track_presentation/presentation_top.dart b/lib/components/track_presentation/presentation_top.dart index d2576cc0..b6730579 100644 --- a/lib/components/track_presentation/presentation_top.dart +++ b/lib/components/track_presentation/presentation_top.dart @@ -1,10 +1,10 @@ import 'package:auto_size_text/auto_size_text.dart'; -import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; +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/heart_button/heart_button.dart'; +import 'package:spotube/components/hover_builder.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'; @@ -12,6 +12,8 @@ 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'; +import 'package:spotube/theme/spotify_colors.dart'; +import 'package:spotube/theme/spotify_typography.dart'; class TrackPresentationTopSection extends HookConsumerWidget { const TrackPresentationTopSection({super.key}); @@ -28,124 +30,12 @@ class TrackPresentationTopSection extends HookConsumerWidget { fit: BoxFit.cover, ); - final imageDimension = mediaQuery.mdAndUp ? 200 : 120; + final imageDimension = mediaQuery.mdAndUp ? 300.0 : 200.0; final (:isLoading, :isActive, :onPlay, :onShuffle, :onAddToQueue) = useActionCallbacks(ref); - final playbackActions = Row( - spacing: 8 * scale, - children: [ - Tooltip( - tooltip: TooltipContainer( - child: Text(context.l10n.shuffle_playlist), - ).call, - 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), - ).call, - child: IconButton.secondary( - icon: const Icon(SpotubeIcons.queueAdd), - enabled: !isLoading && !isActive, - onPressed: onAddToQueue, - ), - ) - else - Button.secondary( - leading: const Icon(SpotubeIcons.add), - enabled: !isLoading && !isActive, - onPressed: onAddToQueue, - child: Text(context.l10n.queue), - ), - 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), - ).call, - 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, - ), - ], - ); + final textTheme = SpotifyTypography.buildTextTheme(context); return SliverMainAxisGroup( slivers: [ @@ -159,32 +49,32 @@ class TrackPresentationTopSection extends HookConsumerWidget { DecoratedBox( decoration: BoxDecoration( image: decorationImage, - borderRadius: BorderRadius.circular(45), + borderRadius: BorderRadius.circular(0), ), - 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, - ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: imageDimension * scale, + width: double.infinity, + decoration: BoxDecoration( + image: decorationImage, + ), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + SpotifyColors.background.withValues(alpha: 0.85), + ], ), - Flexible( + ), + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: EdgeInsets.all(24 * scale), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -192,8 +82,11 @@ class TrackPresentationTopSection extends HookConsumerWidget { AutoSizeText( options.title, maxLines: 2, - minFontSize: 16, - style: context.theme.typography.h3, + minFontSize: 20, + style: textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.w700, + color: SpotifyColors.primaryText, + ), ), if (options.description != null) AutoSizeText( @@ -203,53 +96,92 @@ class TrackPresentationTopSection extends HookConsumerWidget { maxFontSize: 18, overflow: TextOverflow.ellipsis, style: TextStyle( - color: context - .theme.colorScheme.mutedForeground, - fontSize: 18, + color: SpotifyColors.secondaryText, + fontSize: 16, ), ), - 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!, - ), - size: 20 * scale, - ) - : null, - child: Text( - options.owner!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ).small(), + const SizedBox(height: 24), + if (options.owner != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (options.ownerImage != null) + CircleAvatar( + radius: 16 * scale, + backgroundImage: UniversalImage + .imageProvider( + options.ownerImage!, + ), + ) + else + const CircleAvatar( + radius: 16, + child: Icon(Icons.person_rounded, + size: 16), + ), + const SizedBox(width: 8), + Text( + options.owner!, + style: textTheme.bodyMedium?.copyWith( + color: SpotifyColors.primaryText, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - additionalActions, + const SizedBox(width: 16), + _SpotifyActionButton( + icon: SpotubeIcons.moreHorizontal, + label: "", + onPressed: () {}, + ), + ], + ), + const SizedBox(height: 24), + Row( + children: [ + _SpotifyActionButton( + icon: SpotubeIcons.shuffle, + label: context.l10n.shuffle_playlist, + onPressed: onShuffle, + enabled: !isLoading && !isActive, + ), + const SizedBox(width: 12), + _SpotifyActionButton( + icon: SpotubeIcons.add, + label: context.l10n.queue, + onPressed: onAddToQueue, + enabled: !isLoading && !isActive, + ), + const SizedBox(width: 12), + _SpotifyPlayButton( + isActive: isActive, + isLoading: isLoading, + onPressed: onPlay, + enabled: !isLoading && !isActive, + label: isActive + ? context.l10n.pause + : context.l10n.play, + ), ], ), - if (mediaQuery.mdAndUp) ...[ - const Gap(16), - playbackActions + if (options.onHeart != null) ...[ + const SizedBox(height: 16), + HeartButton( + isLiked: options.isLiked, + tooltip: options.isLiked + ? context.l10n.remove_from_favorites + : context.l10n.save_as_favorite, + onPressed: options.onHeart, + ), ], ], ), ), - ], + ), ), - if (mediaQuery.smAndDown) playbackActions, - ], - ), + ), + ], ), ), ], @@ -259,3 +191,142 @@ class TrackPresentationTopSection extends HookConsumerWidget { ); } } + +class _SpotifyActionButton extends HookConsumerWidget { + final IconData icon; + final String label; + final VoidCallback onPressed; + final bool enabled; + + const _SpotifyActionButton({ + required this.icon, + required this.label, + required this.onPressed, + this.enabled = true, + }); + + @override + Widget build(BuildContext context, ref) { + final textTheme = SpotifyTypography.buildTextTheme(context); + return HoverBuilder( + builder: (context, hovering) { + return InkWell( + onTap: enabled ? onPressed : null, + borderRadius: BorderRadius.circular(500), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), + decoration: BoxDecoration( + color: (hovering || !enabled) + ? SpotifyColors.hover + : Colors.transparent, + borderRadius: BorderRadius.circular(500), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + color: enabled + ? (hovering + ? SpotifyColors.primaryText + : SpotifyColors.secondaryText) + : SpotifyColors.disabledText, + size: 16, + ), + if (label.isNotEmpty) ...[ + const SizedBox(width: 6), + Text( + label, + style: textTheme.labelMedium?.copyWith( + color: enabled + ? SpotifyColors.primaryText + : SpotifyColors.disabledText, + fontWeight: FontWeight.w500, + fontSize: 13, + ), + ), + ], + ], + ), + ), + ); + }, + ); + } +} + +class _SpotifyPlayButton extends HookConsumerWidget { + final bool isActive; + final bool isLoading; + final VoidCallback onPressed; + final bool enabled; + final String label; + + const _SpotifyPlayButton({ + required this.isActive, + required this.isLoading, + required this.onPressed, + this.enabled = true, + required this.label, + }); + + @override + Widget build(BuildContext context, ref) { + return HoverBuilder( + builder: (context, hovering) { + final scale = hovering && enabled ? 1.05 : 1.0; + return AnimatedScale( + scale: scale, + duration: const Duration(milliseconds: 150), + child: InkWell( + onTap: enabled ? onPressed : null, + borderRadius: BorderRadius.circular(500), + child: Container( + width: 80, + height: 36, + decoration: BoxDecoration( + color: enabled + ? SpotifyColors.spotifyGreen + : SpotifyColors.disabledText.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(500), + ), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (isLoading) + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation(Colors.black), + ), + ) + else + Icon( + isActive + ? SpotubeIcons.pause + : SpotubeIcons.play, + color: Colors.black, + size: 18, + ), + const SizedBox(width: 8), + Text( + label, + style: const TextStyle( + color: Colors.black, + fontWeight: FontWeight.w700, + fontSize: 14, + ), + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/components/track_tile/track_options.dart b/lib/components/track_tile/track_options.dart index 7d14493e..859d8636 100644 --- a/lib/components/track_tile/track_options.dart +++ b/lib/components/track_tile/track_options.dart @@ -1,14 +1,16 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; import 'package:spotube/collections/routes.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/ui/button_tile.dart'; +import 'package:spotube/components/hover_builder.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/provider/track_options/track_options_provider.dart'; +import 'package:spotube/theme/spotify_colors.dart'; +import 'package:spotube/theme/spotify_typography.dart'; /// [track] must be a [SpotubeFullTrackObject] or [SpotubeLocalTrackObject] class TrackOptions extends HookConsumerWidget { @@ -33,6 +35,7 @@ class TrackOptions extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final mediaQuery = MediaQuery.of(context); + final textTheme = SpotifyTypography.buildTextTheme(context); final trackOptionActions = ref.watch(trackOptionActionsProvider(track)); final ( @@ -46,238 +49,314 @@ class TrackOptions extends HookConsumerWidget { ) = ref.watch(trackOptionsStateProvider(track)); final isLocalTrack = track is SpotubeLocalTrackObject; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 8, - children: [ - if (isLocalTrack) - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.delete, - playlistId, - ); - onTapItem?.call(); - }, - leading: const Icon(SpotubeIcons.trash), - title: Text(context.l10n.delete), - ), - if (mediaQuery.smAndDown && !isLocalTrack) - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.album, - playlistId, - ); - onTapItem?.call(); - }, - leading: const Icon(SpotubeIcons.album), - title: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(context.l10n.go_to_album), - Text( - track.album.name, - style: context.theme.typography.xSmall, - ), - ], + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 220), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, + children: [ + if (isLocalTrack) + _SpotifyMenuItem( + icon: SpotubeIcons.trash, + label: context.l10n.delete, + onPressed: () async { + await trackOptionActions.action( + rootNavigatorKey.currentContext!, + TrackOptionValue.delete, + playlistId, + ); + onTapItem?.call(); + }, ), - ), - if (!isInQueue) ...[ - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.addToQueue, - playlistId, - ); - onTapItem?.call(); - }, - leading: const Icon(SpotubeIcons.queueAdd), - title: Text(context.l10n.add_to_queue), - ), - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.playNext, - playlistId, - ); - onTapItem?.call(); - }, - leading: const Icon(SpotubeIcons.lightning), - title: Text(context.l10n.play_next), - ), - ] else - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.removeFromQueue, - playlistId, - ); - onTapItem?.call(); - }, - enabled: !isActiveTrack, - leading: const Icon(SpotubeIcons.queueRemove), - title: Text(context.l10n.remove_from_queue), - ), - if (isAuthenticated && !isLocalTrack) - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.favorite, - playlistId, - ); - onTapItem?.call(); - }, - leading: isLiked - ? const Icon( - SpotubeIcons.heartFilled, - color: Colors.pink, - ) - : const Icon(SpotubeIcons.heart), - title: Text( - isLiked + if (mediaQuery.smAndDown && !isLocalTrack) + _SpotifyMenuItem( + icon: SpotubeIcons.album, + leadingSubtitle: track.album.name, + label: context.l10n.go_to_album, + onPressed: () async { + await trackOptionActions.action( + rootNavigatorKey.currentContext!, + TrackOptionValue.album, + playlistId, + ); + onTapItem?.call(); + }, + ), + if (!isInQueue) ...[ + _SpotifyMenuItem( + icon: SpotubeIcons.queueAdd, + label: context.l10n.add_to_queue, + onPressed: () async { + await trackOptionActions.action( + rootNavigatorKey.currentContext!, + TrackOptionValue.addToQueue, + playlistId, + ); + onTapItem?.call(); + }, + ), + _SpotifyMenuItem( + icon: SpotubeIcons.lightning, + label: context.l10n.play_next, + onPressed: () async { + await trackOptionActions.action( + rootNavigatorKey.currentContext!, + TrackOptionValue.playNext, + playlistId, + ); + onTapItem?.call(); + }, + ), + ] else + _SpotifyMenuItem( + icon: SpotubeIcons.queueRemove, + label: context.l10n.remove_from_queue, + enabled: !isActiveTrack, + onPressed: () async { + await trackOptionActions.action( + rootNavigatorKey.currentContext!, + TrackOptionValue.removeFromQueue, + playlistId, + ); + onTapItem?.call(); + }, + ), + if (isAuthenticated && !isLocalTrack) + _SpotifyMenuItem( + icon: isLiked ? SpotubeIcons.heartFilled : SpotubeIcons.heart, + iconColor: isLiked ? Colors.pink : null, + label: isLiked ? context.l10n.remove_from_favorites : context.l10n.save_as_favorite, + onPressed: () async { + await trackOptionActions.action( + rootNavigatorKey.currentContext!, + TrackOptionValue.favorite, + playlistId, + ); + onTapItem?.call(); + }, ), - ), - if (isAuthenticated && !isLocalTrack) ...[ - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.startRadio, - playlistId, - ); - onTapItem?.call(); - }, - leading: const Icon(SpotubeIcons.radio), - title: Text(context.l10n.start_a_radio), - ), - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.addToPlaylist, - playlistId, - ); - onTapItem?.call(); - }, - leading: const Icon(SpotubeIcons.playlistAdd), - title: Text(context.l10n.add_to_playlist), - ), - ], - if (userPlaylist && isAuthenticated && !isLocalTrack) - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.removeFromPlaylist, - playlistId, - ); - onTapItem?.call(); - }, - leading: const Icon(SpotubeIcons.removeFilled), - title: Text(context.l10n.remove_from_playlist), - ), - if (!isLocalTrack) - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.download, - playlistId, - ); - onTapItem?.call(); - }, - enabled: !isInDownloadQueue, - leading: isInDownloadQueue - ? StreamBuilder( - stream: downloadTask?.downloadedBytesStream, - builder: (context, snapshot) { - final progress = downloadTask?.totalSizeBytes == null || - downloadTask?.totalSizeBytes == 0 - ? 0 - : (snapshot.data ?? 0) / - downloadTask!.totalSizeBytes!; - return CircularProgressIndicator( - value: progress.toDouble(), - ); - }, - ) - : const Icon(SpotubeIcons.download), - title: Text(context.l10n.download_track), - ), - if (!isLocalTrack) - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.blacklist, - playlistId, - ); - onTapItem?.call(); - }, - leading: Icon( - SpotubeIcons.playlistRemove, - color: isBlacklisted != true ? Colors.red[400] : null, + if (isAuthenticated && !isLocalTrack) ...[ + _SpotifyMenuItem( + icon: SpotubeIcons.radio, + label: context.l10n.start_a_radio, + onPressed: () async { + await trackOptionActions.action( + rootNavigatorKey.currentContext!, + TrackOptionValue.startRadio, + playlistId, + ); + onTapItem?.call(); + }, ), - title: Text( - isBlacklisted == true + _SpotifyMenuItem( + icon: SpotubeIcons.playlistAdd, + label: context.l10n.add_to_playlist, + onPressed: () async { + await trackOptionActions.action( + rootNavigatorKey.currentContext!, + TrackOptionValue.addToPlaylist, + playlistId, + ); + onTapItem?.call(); + }, + ), + ], + if (userPlaylist && isAuthenticated && !isLocalTrack) + _SpotifyMenuItem( + icon: SpotubeIcons.removeFilled, + label: context.l10n.remove_from_playlist, + onPressed: () async { + await trackOptionActions.action( + rootNavigatorKey.currentContext!, + TrackOptionValue.removeFromPlaylist, + playlistId, + ); + onTapItem?.call(); + }, + ), + if (!isLocalTrack) + _SpotifyMenuItem( + icon: SpotubeIcons.download, + label: context.l10n.download_track, + enabled: !isInDownloadQueue, + trailing: isInDownloadQueue + ? StreamBuilder( + stream: downloadTask?.downloadedBytesStream, + builder: (context, snapshot) { + final progress = + downloadTask?.totalSizeBytes == null || + downloadTask?.totalSizeBytes == 0 + ? 0 + : (snapshot.data ?? 0) / + downloadTask!.totalSizeBytes!; + return SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + value: progress.toDouble(), + ), + ); + }, + ) + : null, + onPressed: () async { + await trackOptionActions.action( + rootNavigatorKey.currentContext!, + TrackOptionValue.download, + playlistId, + ); + onTapItem?.call(); + }, + ), + if (!isLocalTrack) + _SpotifyMenuItem( + icon: SpotubeIcons.playlistRemove, + iconColor: + isBlacklisted != true ? Colors.red[400] : null, + label: isBlacklisted == true ? context.l10n.remove_from_blacklist : context.l10n.add_to_blacklist, - style: TextStyle( - color: isBlacklisted != true ? Colors.red[400] : null, - ), + labelColor: isBlacklisted != true ? Colors.red[400] : null, + onPressed: () async { + await trackOptionActions.action( + rootNavigatorKey.currentContext!, + TrackOptionValue.blacklist, + playlistId, + ); + onTapItem?.call(); + }, ), - ), - if (!isLocalTrack) - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.share, - playlistId, - ); - onTapItem?.call(); - }, - leading: const Icon(SpotubeIcons.share), - title: Text(context.l10n.share), - ), - if (!isLocalTrack) - ButtonTile( - style: ButtonVariance.menu, - onPressed: () async { - await trackOptionActions.action( - rootNavigatorKey.currentContext!, - TrackOptionValue.details, - playlistId, - ); - onTapItem?.call(); - }, - leading: const Icon(SpotubeIcons.info), - title: Text(context.l10n.details), - ), - ], + if (!isLocalTrack) + _SpotifyMenuItem( + icon: SpotubeIcons.share, + label: context.l10n.share, + onPressed: () async { + await trackOptionActions.action( + rootNavigatorKey.currentContext!, + TrackOptionValue.share, + playlistId, + ); + onTapItem?.call(); + }, + ), + if (!isLocalTrack) + _SpotifyMenuItem( + icon: SpotubeIcons.info, + label: context.l10n.details, + onPressed: () async { + await trackOptionActions.action( + rootNavigatorKey.currentContext!, + TrackOptionValue.details, + playlistId, + ); + onTapItem?.call(); + }, + ), + ], + ), + ); + } +} + +class _SpotifyMenuItem extends HookConsumerWidget { + final IconData icon; + final Color? iconColor; + final String label; + final Color? labelColor; + final String? leadingSubtitle; + final bool enabled; + final Widget? trailing; + final VoidCallback onPressed; + + const _SpotifyMenuItem({ + required this.icon, + this.iconColor, + required this.label, + this.labelColor, + this.leadingSubtitle, + this.enabled = true, + this.trailing, + required this.onPressed, + }); + + @override + Widget build(BuildContext context, ref) { + final textTheme = SpotifyTypography.buildTextTheme(context); + return HoverBuilder( + builder: (context, hovering) { + return InkWell( + onTap: enabled ? onPressed : null, + child: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 12), + color: hovering && enabled ? SpotifyColors.hover : Colors.transparent, + child: Row( + children: [ + if (icon != null) ...[ + Icon( + icon, + color: enabled + ? (iconColor ?? SpotifyColors.secondaryText) + : SpotifyColors.disabledText, + size: 18, + ), + const SizedBox(width: 10), + ], + if (leadingSubtitle != null) ...[ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: textTheme.bodyMedium?.copyWith( + color: enabled + ? SpotifyColors.primaryText + : SpotifyColors.disabledText, + fontSize: 13, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + leadingSubtitle!, + style: textTheme.bodySmall?.copyWith( + color: SpotifyColors.disabledText, + fontSize: 11, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ] else ...[ + Expanded( + child: Text( + label, + style: textTheme.bodyMedium?.copyWith( + color: labelColor ?? + (enabled + ? SpotifyColors.primaryText + : SpotifyColors.disabledText), + fontSize: 13, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + if (trailing != null) trailing!, + ], + ), + ), + ); + }, ); } } diff --git a/lib/components/track_tile/track_tile.dart b/lib/components/track_tile/track_tile.dart index ec3f50f3..6497f815 100644 --- a/lib/components/track_tile/track_tile.dart +++ b/lib/components/track_tile/track_tile.dart @@ -2,25 +2,24 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/hover_builder.dart'; import 'package:spotube/components/image/universal_image.dart'; -import 'package:spotube/components/links/artist_link.dart'; -import 'package:spotube/components/links/link_text.dart'; -import 'package:spotube/components/track_tile/track_options_button.dart'; -import 'package:spotube/components/ui/button_tile.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/duration.dart'; import 'package:spotube/models/metadata/metadata.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/audio_player/querying_track_info.dart'; -import 'package:spotube/provider/audio_player/state.dart'; import 'package:spotube/provider/blacklist_provider.dart'; +import 'package:spotube/theme/spotify_colors.dart'; +import 'package:spotube/theme/spotify_components.dart'; +import 'package:spotube/theme/spotify_typography.dart'; import 'package:spotube/utils/platform.dart'; final isBlacklistedProvider = @@ -32,8 +31,6 @@ final isBlacklistedProvider = }, ); -final _overlay = ValueNotifier?>(null); - class TrackTile extends HookConsumerWidget { /// [index] will not be shown if null final int? index; @@ -67,6 +64,7 @@ class TrackTile extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final theme = Theme.of(context); + final textTheme = SpotifyTypography.buildTextTheme(context); final isBlackListed = ref.watch(isBlacklistedProvider(track)); @@ -74,8 +72,6 @@ class TrackTile extends HookConsumerWidget { final isPlaying = playlist.activeTrack?.id == track.id; - final isSelected = isPlaying || isLoading.value; - final imageProvider = useMemoized( () => UniversalImage.imageProvider( (track.album.images).smallest(ImagePlaceholder.albumArt), @@ -93,11 +89,7 @@ class TrackTile extends HookConsumerWidget { return Listener( onPointerDown: (event) { if (event.buttons != kSecondaryMouseButton) return; - if (_overlay.value != null) { - _overlay.value?.remove(); - _overlay.value = null; - } - _overlay.value = TrackOptionsButton.showOptions( + TrackOptionsButton.showOptions( context, Offset.zero, track, @@ -105,243 +97,42 @@ class TrackTile extends HookConsumerWidget { playlistId: playlistId, ); }, - child: HoverBuilder( - permanentState: isSelected || constrains.smAndDown ? true : null, - builder: (context, isHovering) => ButtonTile( - selected: isSelected, - onPressed: () async { - if (isBlackListed) return; - try { - isLoading.value = true; - await onTap?.call(); - } finally { - if (context.mounted) { - isLoading.value = false; - } + child: SpotifyTrackRow( + index: index, + track: track, + isPlaying: isPlaying || isLoading.value, + selected: selected, + onTap: () async { + if (isBlackListed) return; + try { + isLoading.value = true; + await onTap?.call(); + } finally { + if (context.mounted) { + isLoading.value = false; } - }, - onLongPress: onLongPress, - style: (isBlackListed - ? ButtonVariance.destructive - : ButtonVariance.ghost) - .copyWith( - padding: (context, states, value) => - const EdgeInsets.symmetric(vertical: 8, horizontal: 0), - ), - leading: Row( - mainAxisSize: MainAxisSize.min, - children: [ - ...?leadingActions, - 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), - child: Text( - '${(index ?? 0) + 1}', - maxLines: 1, - style: theme.typography.small, - textAlign: TextAlign.center, - ), - ), - ), - ), - Stack( - children: [ - Container( - height: 40, - width: 40, - decoration: BoxDecoration( - borderRadius: theme.borderRadiusMd, - image: DecorationImage( - fit: BoxFit.cover, - image: imageProvider, - ), - ), - ), - Positioned.fill( - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - decoration: BoxDecoration( - borderRadius: theme.borderRadiusMd, - color: isHovering - ? Colors.black.withAlpha(102) - : Colors.transparent, - ), - ), - ), - Positioned.fill( - child: Center( - child: Skeleton.ignore( - child: Consumer( - builder: (context, ref, _) { - final isFetchingActiveTrack = - ref.watch(queryingTrackInfoProvider); - return AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: switch (( - isPlaying, - isFetchingActiveTrack, - isPlaying, - isHovering, - isLoading.value - )) { - (true, true, _, _, _) || - (_, _, _, _, true) => - const SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator(), - ), - (_, _, true, _, _) => Icon( - SpotubeIcons.pause, - color: theme.colorScheme.primary, - ), - (_, _, _, true, _) => const Icon( - SpotubeIcons.play, - color: Colors.white, - ), - _ => const SizedBox.shrink(), - }, - ); - }, - ), - ), - ), - ), - ], - ), - ], - ), - title: Row( - children: [ - Expanded( - flex: 6, - child: AbsorbPointer( - absorbing: selectionMode, - child: switch (track) { - SpotubeLocalTrackObject() => Text( - track.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - _ => Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Button( - style: ButtonVariance.link.copyWith( - padding: (context, states, value) => - EdgeInsets.zero, - ), - onPressed: effectiveSelection - ? null - : () { - context - .navigateTo(TrackRoute(trackId: track.id)); - }, - child: Text( - track.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - ], - ), - }, - ), - ), - if (constrains.mdAndUp) ...[ - const SizedBox(width: 8), - Expanded( - flex: 4, - child: switch (track) { - SpotubeLocalTrackObject() => Text( - track.album.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - _ => Align( - alignment: Alignment.centerLeft, - child: LinkText( - track.album.name, - AlbumRoute( - album: track.album, - id: track.album.id, - ), - push: true, - overflow: TextOverflow.ellipsis, - ), - ) - }, - ), - ], - ], - ), - subtitle: Align( - alignment: Alignment.centerLeft, - child: track is SpotubeLocalTrackObject - ? Text( - track.artists.asString(), - ) - : ClipRect( - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 40), - child: AbsorbPointer( - absorbing: effectiveSelection, - child: ArtistLink( - artists: track.artists, - onOverflowArtistClick: effectiveSelection - ? () {} - : () { - context.navigateTo( - TrackRoute(trackId: track.id), - ); - }, - ), - ), - ), - ), - ), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 8), - Text( - Duration(milliseconds: track.durationMs) - .toHumanReadableString(padZero: false), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Builder( - builder: (context) { - return TrackOptionsButton( - track: track, - userPlaylist: userPlaylist, - playlistId: playlistId, - ); - }, - ), - if (kIsDesktop) const Gap(10), - ], - ), - ), + } + }, + onMorePressed: () { + TrackOptionsButton.showOptions( + context, + Offset.zero, + track, + userPlaylist: userPlaylist, + playlistId: playlistId, + ); + }, ), ); }); } } + +class SpotifyTrackTileSkeleton extends StatelessWidget { + const SpotifyTrackTileSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return const SpotifyShimmerTrack(); + } +} diff --git a/lib/main.dart b/lib/main.dart index ecf7148d..3135fa0a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -4,7 +4,7 @@ import 'dart:io'; import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart' as material; +import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_discord_rpc/flutter_discord_rpc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -28,6 +28,7 @@ import 'package:spotube/hooks/configurators/use_get_storage_perms.dart'; import 'package:spotube/hooks/configurators/use_has_touch.dart'; import 'package:spotube/models/database/database.dart'; import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart'; +import 'package:spotube/theme/spotify_theme.dart'; import 'package:spotube/provider/audio_player/audio_player_streams.dart'; import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/glance/glance.dart'; @@ -114,7 +115,7 @@ Future main(List rawArgs) async { final database = AppDatabase(); if (kIsDesktop) { - await localNotifier.setup(appName: "Spotube"); + await localNotifier.setup(appName: "Spotify"); await WindowManagerTools.initialize(); } @@ -144,8 +145,6 @@ class Spotube extends HookConsumerWidget { final themeMode = ref.watch(userPreferencesProvider.select((s) => s.themeMode)); final locale = ref.watch(userPreferencesProvider.select((s) => s.locale)); - final accentMaterialColor = - ref.watch(userPreferencesProvider.select((s) => s.accentColorScheme)); final router = useMemoized(() => AppRouter(ref), []); final hasTouchSupport = useHasTouch(); @@ -191,7 +190,7 @@ class Spotube extends HookConsumerWidget { ], routerConfig: router.config(), debugShowCheckedModeBanner: false, - title: 'Spotube', + title: 'Spotify', builder: (context, child) { child = ScrollConfiguration( behavior: ScrollConfiguration.of(context).copyWith( @@ -216,38 +215,9 @@ class Spotube extends HookConsumerWidget { return child; }, scaling: const AdaptiveScaling(1), - theme: ThemeData( - radius: .5, - iconTheme: const IconThemeProperties(), - colorScheme: - colorSchemeMap[accentMaterialColor.name]?.call(ThemeMode.light) ?? - LegacyColorSchemes.lightSlate(), - surfaceOpacity: .8, - surfaceBlur: 10, - ), - darkTheme: ThemeData( - radius: .5, - iconTheme: const IconThemeProperties(), - colorScheme: - colorSchemeMap[accentMaterialColor.name]?.call(ThemeMode.dark) ?? - LegacyColorSchemes.darkSlate(), - surfaceOpacity: .8, - surfaceBlur: 10, - ), - materialTheme: material.ThemeData( - brightness: switch (themeMode) { - ThemeMode.system => MediaQuery.platformBrightnessOf(context), - ThemeMode.light => Brightness.light, - ThemeMode.dark => Brightness.dark, - }, - splashFactory: material.NoSplash.splashFactory, - appBarTheme: const material.AppBarTheme( - surfaceTintColor: Colors.transparent, - scrolledUnderElevation: 0, - shadowColor: Colors.transparent, - elevation: 0, - ), - ), + theme: SpotifyTheme.shadcnTheme(), + darkTheme: SpotifyTheme.shadcnTheme(), + materialTheme: SpotifyTheme.materialTheme(context), themeMode: themeMode, shortcuts: { ...WidgetsApp.defaultShortcuts.map((key, value) { diff --git a/lib/modules/album/album_card.dart b/lib/modules/album/album_card.dart index 80dfd55b..a85685f1 100644 --- a/lib/modules/album/album_card.dart +++ b/lib/modules/album/album_card.dart @@ -1,11 +1,11 @@ import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/components/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/playbutton_view/playbutton_card.dart'; -import 'package:spotube/components/playbutton_view/playbutton_tile.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/components/track_tile/track_options_button.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/metadata/metadata.dart'; @@ -15,6 +15,9 @@ import 'package:spotube/provider/history/history.dart'; import 'package:spotube/provider/audio_player/audio_player.dart'; import 'package:spotube/provider/metadata_plugin/tracks/album.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/theme/spotify_colors.dart'; +import 'package:spotube/theme/spotify_components.dart'; +import 'package:spotube/theme/spotify_typography.dart'; extension FormattedAlbumType on SpotubeAlbumType { String get formatted => name.replaceFirst(name[0], name[0].toUpperCase()); @@ -161,28 +164,31 @@ class AlbumCard extends HookConsumerWidget { context ]); - if (_isTile) { - return PlaybuttonTile( - imageUrl: imageUrl, - isPlaying: isPlaylistPlaying, - isLoading: isLoading, - title: album.name, - description: description, - onTap: onTap, - onPlaybuttonPressed: onPlaybuttonPressed, - onAddToQueuePressed: onAddToQueuePressed, - ); - } - - return PlaybuttonCard( - imageUrl: imageUrl, + return SpotifyCard( + image: Image.network( + imageUrl, + width: 150, + height: 150, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + width: 150, + height: 150, + color: SpotifyColors.surface, + child: const Icon(SpotubeIcons.album, + color: SpotifyColors.secondaryText, size: 40), + ), + loadingBuilder: (context, chunk, total) => Container( + width: 150, + height: 150, + color: SpotifyColors.surface, + ), + ), + title: album.name, + subtitle: description, + onTap: onTap, + onPlayPressed: onPlaybuttonPressed, isPlaying: isPlaylistPlaying, isLoading: isLoading, - title: album.name, - description: description, - onTap: onTap, - onPlaybuttonPressed: onPlaybuttonPressed, - onAddToQueuePressed: onAddToQueuePressed, ); } } diff --git a/lib/modules/artist/artist_card.dart b/lib/modules/artist/artist_card.dart index d9e01206..8e07afa2 100644 --- a/lib/modules/artist/artist_card.dart +++ b/lib/modules/artist/artist_card.dart @@ -1,15 +1,16 @@ import 'package:auto_route/auto_route.dart'; -import 'package:auto_size_text/auto_size_text.dart'; - +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/metadata/metadata.dart'; - import 'package:spotube/provider/blacklist_provider.dart'; +import 'package:spotube/theme/spotify_colors.dart'; +import 'package:spotube/theme/spotify_components.dart'; +import 'package:spotube/theme/spotify_typography.dart'; class ArtistCard extends HookConsumerWidget { final SpotubeFullArtistObject artist; @@ -17,12 +18,7 @@ class ArtistCard extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final theme = Theme.of(context); - final backgroundImage = UniversalImage.imageProvider( - artist.images.asUrlString( - placeholder: ImagePlaceholder.artist, - ), - ); + final textTheme = SpotifyTypography.buildTextTheme(context); final isBlackListed = ref.watch( blacklistProvider.select( (blacklist) => blacklist.asData?.value.any( @@ -32,43 +28,77 @@ class ArtistCard extends HookConsumerWidget { ); return SizedBox( - width: 180, - child: Button.card( - onPressed: () { - context.navigateTo(ArtistRoute(artistId: artist.id)); - }, - child: Column( - children: [ - Avatar( - initials: artist.name.trim()[0].toUpperCase(), - provider: backgroundImage, - size: 130, - ), - const Gap(10), - AutoSizeText( - artist.name, - maxLines: 2, - textAlign: TextAlign.center, - overflow: TextOverflow.ellipsis, - style: theme.typography.bold, - ), - const Spacer(), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (isBlackListed == true) ...[ - DestructiveBadge( - child: Text(context.l10n.blacklisted.toUpperCase()), + width: 150, + child: HoverBuilder( + builder: (context, hovering) { + final imageUrl = artist.images.asUrlString( + placeholder: ImagePlaceholder.artist, + ); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Image.network( + imageUrl, + width: 150, + height: 150, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + width: 150, + height: 150, + color: SpotifyColors.surface, + child: const Icon(SpotubeIcons.artist, + color: SpotifyColors.secondaryText, size: 40), + ), + loadingBuilder: (context, chunk, total) => Container( + width: 150, + height: 150, + color: SpotifyColors.surface, + ), + ), ), - const Gap(5), ], - SecondaryBadge( - child: Text(context.l10n.artist.toUpperCase()), - ) - ], - ) - ], - ), + ), + const SizedBox(height: 12), + InkWell( + onTap: () { + context.navigateTo(ArtistRoute(artistId: artist.id)); + }, + borderRadius: BorderRadius.circular(4), + child: Text( + artist.name, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: SpotifyColors.primaryText, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(height: 4), + if (isBlackListed == true) ...[ + Text( + context.l10n.blacklisted.toUpperCase(), + style: textTheme.labelSmall?.copyWith( + color: Colors.red, + fontSize: 11, + ), + ), + ] else + Text( + context.l10n.artist.toUpperCase(), + style: textTheme.labelSmall?.copyWith( + color: SpotifyColors.disabledText, + fontSize: 11, + ), + ), + ], + ); + }, ), ); } diff --git a/lib/modules/playlist/playlist_card.dart b/lib/modules/playlist/playlist_card.dart index 1d221a33..c8409325 100644 --- a/lib/modules/playlist/playlist_card.dart +++ b/lib/modules/playlist/playlist_card.dart @@ -1,11 +1,11 @@ import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/components/dialogs/select_device_dialog.dart'; -import 'package:spotube/components/playbutton_view/playbutton_card.dart'; -import 'package:spotube/components/playbutton_view/playbutton_tile.dart'; +import 'package:spotube/components/image/universal_image.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/metadata/metadata.dart'; @@ -17,6 +17,9 @@ import 'package:spotube/provider/metadata_plugin/library/tracks.dart'; import 'package:spotube/provider/metadata_plugin/tracks/playlist.dart'; import 'package:spotube/provider/metadata_plugin/core/user.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; +import 'package:spotube/theme/spotify_colors.dart'; +import 'package:spotube/theme/spotify_components.dart'; +import 'package:spotube/theme/spotify_typography.dart'; class PlaylistCard extends HookConsumerWidget { final SpotubeSimplePlaylistObject playlist; @@ -193,32 +196,31 @@ class PlaylistCard extends HookConsumerWidget { final isOwner = playlist.owner.id == me.asData?.value?.id && me.asData?.value?.id != null; - if (_isTile) { - return PlaybuttonTile( - title: playlist.name, - description: playlist.description, - image: null, - imageUrl: imageUrl, - isPlaying: isPlaylistPlaying, - isLoading: isLoading, - isOwner: isOwner, - onTap: onTap, - onPlaybuttonPressed: onPlaybuttonPressed, - onAddToQueuePressed: onAddToQueuePressed, - ); - } - - return PlaybuttonCard( + return SpotifyCard( + image: Image.network( + imageUrl, + width: 150, + height: 150, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Container( + width: 150, + height: 150, + color: SpotifyColors.surface, + child: const Icon(SpotubeIcons.playlist, + color: SpotifyColors.secondaryText, size: 40), + ), + loadingBuilder: (context, chunk, total) => Container( + width: 150, + height: 150, + color: SpotifyColors.surface, + ), + ), title: playlist.name, - description: playlist.description, - image: null, - imageUrl: imageUrl, + subtitle: playlist.description.isNotEmpty ? playlist.description : null, + onTap: onTap, + onPlayPressed: onPlaybuttonPressed, isPlaying: isPlaylistPlaying, isLoading: isLoading, - isOwner: isOwner, - onTap: onTap, - onPlaybuttonPressed: onPlaybuttonPressed, - onAddToQueuePressed: onAddToQueuePressed, ); } } diff --git a/lib/modules/root/spotify_bottom_player.dart b/lib/modules/root/spotify_bottom_player.dart new file mode 100644 index 00000000..78da826a --- /dev/null +++ b/lib/modules/root/spotify_bottom_player.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/models/metadata/metadata.dart'; +import 'package:spotube/modules/player/player_controls.dart'; +import 'package:spotube/modules/player/volume_slider.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/provider/volume_provider.dart'; +import 'package:spotube/theme/spotify_colors.dart'; +import 'package:spotube/theme/spotify_typography.dart'; + +class SpotifyBottomPlayer extends HookConsumerWidget { + const SpotifyBottomPlayer({super.key}); + + @override + Widget build(BuildContext context, ref) { + final playlist = ref.watch(audioPlayerProvider); + final textTheme = SpotifyTypography.buildTextTheme(context); + + final albumArt = useMemoized( + () => playlist.activeTrack?.album.images.isNotEmpty == true + ? (playlist.activeTrack?.album.images).asUrlString( + index: (playlist.activeTrack?.album.images.length ?? 1) - 1, + placeholder: ImagePlaceholder.albumArt, + ) + : Assets.images.albumPlaceholder.path, + [playlist.activeTrack?.album.images], + ); + + return Container( + height: 90, + decoration: BoxDecoration( + color: SpotifyColors.surface, + border: Border(top: BorderSide(color: SpotifyColors.separator, width: 1)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + children: [ + const SizedBox(width: 8), + Expanded( + flex: 2, + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Image.network( + albumArt, + width: 56, + height: 56, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Image.asset( + Assets.images.albumPlaceholder.path, + width: 56, + height: 56, + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisCenter, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + playlist.activeTrack?.name ?? '', + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: SpotifyColors.primaryText, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + playlist.activeTrack?.artists.map((e) => e.name).join(', ') ?? '', + style: textTheme.bodySmall?.copyWith( + color: SpotifyColors.secondaryText, + fontSize: 13, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + icon: Icon( + playlist.activeTrack == null + ? SpotubeIcons.heart + : SpotubeIcons.heartFilled, + color: SpotifyColors.secondaryText, + size: 18, + ), + onPressed: () {}, + ), + ], + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 3, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 4), + PlayerControls(), + const SizedBox(height: 8), + ], + ), + ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + icon: const Icon(SpotubeIcons.queue, size: 18, color: SpotifyColors.secondaryText), + onPressed: () {}, + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(SpotubeIcons.speaker, size: 18, color: SpotifyColors.secondaryText), + onPressed: () {}, + ), + ], + ), + const SizedBox(height: 6), + Consumer( + builder: (context, ref, _) { + final volume = ref.watch(volumeProvider); + return SizedBox( + width: 110, + child: VolumeSlider( + fullWidth: true, + value: volume, + onChanged: (value) { + ref.read(volumeProvider.notifier).setVolume(value); + }, + ), + ); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/modules/root/spotify_shell.dart b/lib/modules/root/spotify_shell.dart new file mode 100644 index 00000000..13b0072b --- /dev/null +++ b/lib/modules/root/spotify_shell.dart @@ -0,0 +1,48 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; + +import 'package:spotube/theme/spotify_colors.dart'; +import 'package:spotube/modules/root/spotify_bottom_player.dart'; +import 'package:spotube/modules/root/spotify_sidebar.dart'; +import 'package:spotube/modules/root/spotify_top_bar.dart'; + +class SpotifyShell extends HookWidget { + const SpotifyShell({super.key}); + + @override + Widget build(BuildContext context) { + final scrollController = useScrollController(); + + return Scaffold( + backgroundColor: SpotifyColors.background, + body: Column( + children: [ + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SpotifySidebar(), + Container(width: 1, color: SpotifyColors.separator), + Expanded( + child: Column( + children: [ + SpotifyTopBar(scrollController: scrollController), + Expanded( + child: Padding( + padding: const EdgeInsets.only(bottom: 98), + child: AutoRouter(), + ), + ), + ], + ), + ), + ], + ), + ), + const SpotifyBottomPlayer(), + ], + ), + ); + } +} diff --git a/lib/modules/root/spotify_sidebar.dart b/lib/modules/root/spotify_sidebar.dart new file mode 100644 index 00000000..19539bb6 --- /dev/null +++ b/lib/modules/root/spotify_sidebar.dart @@ -0,0 +1,176 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:flutter/material.dart'; + +import 'package:spotube/collections/side_bar_tiles.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/theme/spotify_colors.dart'; +import 'package:spotube/theme/spotify_typography.dart'; +import 'package:spotube/components/hover_builder.dart'; +import 'package:spotube/collections/spotube_icons.dart'; + +class SpotifySidebar extends HookConsumerWidget { + const SpotifySidebar({super.key}); + + @override + Widget build(BuildContext context, ref) { + final l10n = context.l10n; + final router = context.watchRouter; + + final tileList = [...getSidebarTileList(l10n), ...getSidebarLibraryTileList(l10n)]; + + final collapsed = useState(false); + final selectedIndex = useState( + tileList.indexWhere((e) => router.currentPath.startsWith(e.pathPrefix)), + ); + + router.addListener(() { + final index = tileList.indexWhere( + (e) => router.currentPath.startsWith(e.pathPrefix), + ); + if (index != -1 && index != selectedIndex.value) { + selectedIndex.value = index; + } + }); + + final width = collapsed.value ? 72.0 : 240.0; + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: width, + color: SpotifyColors.sidebarBackground, + child: Column( + children: [ + const SizedBox(height: 16), + _NavButton( + selected: false, + icon: SpotubeIcons.home, + label: collapsed.value ? null : l10n.browse, + tooltip: l10n.browse, + onPressed: () => context.navigateTo(const HomeRoute()), + ), + _NavButton( + selected: false, + icon: SpotubeIcons.search, + label: collapsed.value ? null : l10n.search, + tooltip: l10n.search, + onPressed: () => context.navigateTo(const SearchRoute()), + ), + const Divider(height: 1, color: SpotifyColors.separator), + Padding( + padding: const EdgeInsets.fromLTRB(16, 24, 16, 8), + child: Row( + mainAxisAlignment: collapsed.value + ? MainAxisSize.min + : MainAxisSize.spaceBetween, + children: [ + if (!collapsed.value) + Text(l10n.library, style: SpotifyTypography.buildTextTheme(context).titleMedium), + const SizedBox(width: 8), + IconButton(icon: const Icon(SpotubeIcons.add), onPressed: {}), + const SizedBox(width: 8), + IconButton(icon: const Icon(SpotubeIcons.filter), onPressed: {}), + ], + ), + ), + Expanded( + child: ListView.builder( + itemCount: tileList.length, + itemBuilder: (context, index) { + final tile = tileList[index]; + final selected = selectedIndex.value == index; + return _NavButton( + selected: selected, + icon: tile.icon, + label: collapsed.value ? null : tile.title, + tooltip: tile.title, + onPressed: () => context.navigateTo(tile.route), + ); + }, + ), + ), + const SizedBox(height: 24), + _CollapseToggle(collapsed: collapsed), + const SizedBox(height: 16), + ], + ), + ); + } +} + +class _NavButton extends HookConsumerWidget { + final bool selected; + final IconData icon; + final String? label; + final String tooltip; + final VoidCallback onPressed; + + const _NavButton({ + required this.selected, + required this.icon, + this.label, + required this.tooltip, + required this.onPressed, + }); + + @override + Widget build(BuildContext context, ref) { + final textTheme = SpotifyTypography.buildTextTheme(context); + return HoverBuilder( + builder: (context, hovering) { + final active = selected || hovering; + return InkWell( + onTap: onPressed, + hoverColor: Colors.transparent, + highlightColor: Colors.transparent, + child: Container( + height: 56, + color: active ? SpotifyColors.hover : Colors.transparent, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Icon(icon, color: active ? SpotifyColors.primaryText : SpotifyColors.secondaryText, size: 20), + const SizedBox(width: 16), + if (label != null) + Expanded( + child: Text( + label!, + style: textTheme.labelLarge?.copyWith( + color: active ? SpotifyColors.primaryText : SpotifyColors.secondaryText, + fontWeight: active ? FontWeight.w700 : FontWeight.w400, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _CollapseToggle extends HookConsumerWidget { + final ValueNotifier collapsed; + + const _CollapseToggle({required this.collapsed}); + + @override + Widget build(BuildContext context, ref) { + return InkWell( + onTap: () => collapsed.value = !collapsed.value, + child: Container( + height: 48, + alignment: Alignment.center, + child: Icon( + collapsed.value ? Icons.chevron_right_rounded : Icons.chevron_left_rounded, + color: SpotifyColors.secondaryText, + size: 18, + ), + ), + ); + } +} diff --git a/lib/modules/root/spotify_top_bar.dart b/lib/modules/root/spotify_top_bar.dart new file mode 100644 index 00000000..fd6d721c --- /dev/null +++ b/lib/modules/root/spotify_top_bar.dart @@ -0,0 +1,163 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:spotube/theme/spotify_colors.dart'; +import 'package:spotube/theme/spotify_typography.dart'; +import 'package:spotube/components/hover_builder.dart'; + +class SpotifyTopBar extends HookConsumerWidget { + final ScrollController? scrollController; + + const SpotifyTopBar({this.scrollController, super.key}); + + @override + Widget build(BuildContext context, ref) { + final textTheme = SpotifyTypography.buildTextTheme(context); + final scrolled = useState(false); + + useEffect(() { + void listener() { + final value = (scrollController?.offset ?? 0) > 8; + if (value != scrolled.value) scrolled.value = value; + } + scrollController?.addListener(listener); + return () => scrollController?.removeListener(listener); + }, [scrollController]); + + final bgColor = scrolled.value + ? SpotifyColors.background.withValues(alpha: 0.92) + : Colors.transparent; + + return SizedBox( + height: 64, + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), + child: Container( + color: bgColor, + child: Row( + children: [ + _NavButton(icon: Icons.arrow_back_rounded, tooltip: 'Back'), + const SizedBox(width: 8), + _NavButton(icon: Icons.arrow_forward_rounded, tooltip: 'Forward'), + const SizedBox(width: 24), + const Spacer(), + _PremiumButton(), + const SizedBox(width: 16), + _IconButton(icon: Icons.notifications_none_rounded, tooltip: 'Notifications'), + const SizedBox(width: 12), + _ProfileAvatar(), + const SizedBox(width: 16), + ], + ), + ), + ), + ), + ); + } +} + +class _NavButton extends HookConsumerWidget { + final IconData icon; + final String tooltip; + + const _NavButton({required this.icon, required this.tooltip}); + + @override + Widget build(BuildContext context, ref) { + final textTheme = SpotifyTypography.buildTextTheme(context); + return HoverBuilder( + builder: (context, hovering) { + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: hovering ? SpotifyColors.hover : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: IconButton( + icon: Icon(icon, color: SpotifyColors.primaryText, size: 18), + onPressed: () {}, + tooltip: tooltip, + ), + ); + }, + ); + } +} + +class _PremiumButton extends HookConsumerWidget { + @override + Widget build(BuildContext context, ref) { + final textTheme = SpotifyTypography.buildTextTheme(context); + return HoverBuilder( + builder: (context, hovering) { + return OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: SpotifyColors.primaryText, + side: const BorderSide(color: SpotifyColors.primaryText), + backgroundColor: hovering ? SpotifyColors.hover : Colors.transparent, + shape: const StadiumBorder(), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + onPressed: () {}, + child: Text( + 'Explore Premium', + style: textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), + ), + ); + }, + ); + } +} + +class _IconButton extends HookConsumerWidget { + final IconData icon; + final String tooltip; + + const _IconButton({required this.icon, required this.tooltip}); + + @override + Widget build(BuildContext context, ref) { + return HoverBuilder( + builder: (context, hovering) { + return Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: hovering ? SpotifyColors.hover : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: IconButton( + icon: Icon(icon, color: SpotifyColors.primaryText, size: 18), + onPressed: () {}, + tooltip: tooltip, + ), + ); + }, + ); + } +} + +class _ProfileAvatar extends HookConsumerWidget { + @override + Widget build(BuildContext context, ref) { + return HoverBuilder( + builder: (context, hovering) { + return Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: hovering ? SpotifyColors.hover : Colors.transparent, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: SpotifyColors.secondaryText, width: 0.5), + ), + child: const Icon(Icons.person_rounded, color: SpotifyColors.primaryText, size: 18), + ); + }, + ); + } +} diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index a92c776e..d31dc57f 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -1,19 +1,18 @@ import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; - import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/routes.gr.dart'; -import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/models/database/database.dart'; +import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/modules/connect/connect_device.dart'; import 'package:spotube/modules/home/sections/featured.dart'; import 'package:spotube/modules/home/sections/sections.dart'; import 'package:spotube/modules/home/sections/new_releases.dart'; import 'package:spotube/modules/home/sections/recent.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; +import 'package:spotube/theme/spotify_colors.dart'; import 'package:spotube/utils/platform.dart'; @RoutePage() @@ -23,65 +22,69 @@ class HomePage extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { - final theme = Theme.of(context); final controller = useScrollController(); final mediaQuery = MediaQuery.of(context); final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); return SafeArea( - bottom: false, - child: Scaffold( - headers: [ - if (kTitlebarVisible) const TitleBar(height: 30), - ], - child: CustomScrollView( - controller: controller, - slivers: [ - if (mediaQuery.smAndDown || layoutMode == LayoutMode.compact) - SliverAppBar( - floating: true, - title: DefaultTextStyle( - style: TextStyle( - fontFamily: "Cookie", - fontSize: 30, - letterSpacing: 1.8, - color: theme.colorScheme.foreground, - ), - child: const Text("Spotube"), + bottom: false, + child: Scaffold( + headers: [ + if (kTitlebarVisible) const TitleBar(height: 30), + ], + backgroundColor: SpotifyColors.background, + body: CustomScrollView( + controller: controller, + slivers: [ + if (mediaQuery.smAndDown || layoutMode == LayoutMode.compact) + SliverAppBar( + floating: true, + title: Text( + "Spotify", + style: const TextStyle( + fontFamily: "Poppins", + fontSize: 20, + fontWeight: FontWeight.w700, + letterSpacing: -0.04, + color: SpotifyColors.primaryText, ), - backgroundColor: theme.colorScheme.background, - foregroundColor: theme.colorScheme.foreground, - actions: [ - const ConnectDeviceButton(), - const Gap(10), - IconButton.ghost( - icon: const Icon(SpotubeIcons.settings, size: 20), - onPressed: () { - context.navigateTo(const SettingsRoute()); - }, - ), - const Gap(10), - ], - ) - else if (kIsMacOS) - const SliverGap(10), + ), + backgroundColor: SpotifyColors.background, + foregroundColor: SpotifyColors.primaryText, + elevation: 0, + scrolledUnderElevation: 0, + shadowColor: Colors.transparent, + actions: [ + const ConnectDeviceButton(), + const Gap(10), + IconButton( + icon: const Icon(Icons.settings_rounded, + size: 20, color: SpotifyColors.secondaryText), + onPressed: () { + context.navigateTo(const SettingsRoute()); + }, + ), + const Gap(10), + ], + ) + else if (kIsMacOS) const SliverGap(10), - SliverList.builder( - itemCount: 3, - itemBuilder: (context, index) { - return switch (index) { - // 0 => const HomeGenresSection(), - 0 => const HomeRecentlyPlayedSection(), - 1 => const HomeFeaturedSection(), - // 3 => const HomePageFriendsSection(), - _ => const HomeNewReleasesSection() - }; - }, - ), - const SliverSafeArea(sliver: HomePageBrowseSection()), - ], - ), - )); + const SliverGap(10), + SliverList.builder( + itemCount: 3, + itemBuilder: (context, index) { + return switch (index) { + 0 => const HomeRecentlyPlayedSection(), + 1 => const HomeFeaturedSection(), + _ => const HomeNewReleasesSection() + }; + }, + ), + const SliverSafeArea(sliver: HomePageBrowseSection()), + ], + ), + ), + ); } } diff --git a/lib/pages/library/library.dart b/lib/pages/library/library.dart index de438451..5fe62128 100644 --- a/lib/pages/library/library.dart +++ b/lib/pages/library/library.dart @@ -1,15 +1,14 @@ import 'package:auto_route/auto_route.dart'; -import 'package:flutter/material.dart' show Badge; +import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/side_bar_tiles.dart'; import 'package:spotube/collections/spotube_icons.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; -import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/theme/spotify_colors.dart'; +import 'package:spotube/theme/spotify_typography.dart'; @RoutePage() class LibraryPage extends HookConsumerWidget { @@ -50,12 +49,13 @@ class LibraryPage extends HookConsumerWidget { bottom: false, child: LayoutBuilder(builder: (context, constraints) { return Scaffold( - headers: [ - if (constraints.smAndDown) - TitleBar( - automaticallyImplyLeading: false, - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, + backgroundColor: SpotifyColors.background, + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (constraints.smAndDown) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), child: TabList( index: index, onChanged: (index) { @@ -63,31 +63,67 @@ class LibraryPage extends HookConsumerWidget { }, children: [ for (final tile in sidebarLibraryTileList) - TabItem( - child: Badge( - isLabelVisible: tile.id == 'downloads' && - downloadingCount > 0, - label: Text(downloadingCount.toString()), - child: Text(tile.title), - ), + _SpotifyTabItem( + selected: index == sidebarLibraryTileList.indexOf(tile), + label: tile.title, + badge: tile.id == 'downloads' && downloadingCount > 0 + ? downloadingCount.toString() + : null, + onPressed: () => + context.navigateTo(tile.route), ), ], ), - ), - ) - else - const TitleBar( - automaticallyImplyLeading: false, - backgroundColor: Colors.transparent, - surfaceBlur: 0, - height: 32, - ), - const Gap(10), - ], - child: const AutoRouter(), + ) + else + const SizedBox(height: 10), + const SizedBox(height: 10), + Expanded(child: const AutoRouter()), + ], + ), ); }), ), ); } } + +class _SpotifyTabItem extends HookConsumerWidget { + final bool selected; + final String label; + final String? badge; + final VoidCallback onPressed; + + const _SpotifyTabItem({ + required this.selected, + required this.label, + this.badge, + required this.onPressed, + }); + + @override + Widget build(BuildContext context, ref) { + final textTheme = SpotifyTypography.buildTextTheme(context); + return InkWell( + onTap: onPressed, + child: Container( + height: 32, + decoration: BoxDecoration( + color: selected ? SpotifyColors.hover : Colors.transparent, + borderRadius: BorderRadius.circular(500), + ), + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + label, + style: textTheme.labelMedium?.copyWith( + color: selected + ? SpotifyColors.primaryText + : SpotifyColors.secondaryText, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } +} diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index 44b8416f..a810c0aa 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -2,13 +2,10 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/services.dart'; 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/hooks/configurators/use_check_yt_dlp_installed.dart'; -import 'package:spotube/modules/root/bottom_player.dart'; -import 'package:spotube/modules/root/sidebar/sidebar.dart'; -import 'package:spotube/modules/root/spotube_navigation_bar.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; +import 'package:spotube/modules/root/spotify_shell.dart'; import 'package:spotube/modules/root/use_global_subscriptions.dart'; import 'package:spotube/provider/glance/glance.dart'; @@ -30,7 +27,7 @@ class RootAppPage extends HookConsumerWidget { useEffect(() { SystemChrome.setSystemUIOverlayStyle( SystemUiOverlayStyle( - statusBarColor: backgroundColor, // status bar color + statusBarColor: backgroundColor, statusBarIconBrightness: brightness == Brightness.dark ? Brightness.light : Brightness.dark, @@ -39,30 +36,6 @@ class RootAppPage extends HookConsumerWidget { return null; }, [backgroundColor, brightness]); - final scaffold = MediaQuery.removeViewInsets( - context: context, - removeBottom: true, - child: SafeArea( - top: false, - child: Scaffold( - footers: const [ - BottomPlayer(), - SpotubeNavigationBar(), - ], - floatingFooter: true, - child: Sidebar( - child: MediaQuery( - data: MediaQuery.of(context).copyWith( - padding: MediaQuery.paddingOf(context) - .copyWith(bottom: 100 * context.theme.scaling), - ), - child: const AutoRouter(), - ), - ), - ), - ), - ); - - return scaffold; + return const SpotifyShell(); } } diff --git a/lib/pages/search/search.dart b/lib/pages/search/search.dart index da5cc0e2..0aabf40a 100644 --- a/lib/pages/search/search.dart +++ b/lib/pages/search/search.dart @@ -1,15 +1,13 @@ -import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/routes.gr.dart'; - -import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/fallbacks/error_box.dart'; import 'package:spotube/components/fallbacks/no_default_metadata_plugin.dart'; -import 'package:spotube/components/titlebar/titlebar.dart'; +import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/string.dart'; import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart'; @@ -22,6 +20,8 @@ import 'package:spotube/provider/metadata_plugin/search/all.dart'; import 'package:spotube/services/kv_store/kv_store.dart'; import 'package:auto_route/auto_route.dart'; import 'package:spotube/services/metadata/errors/exceptions.dart'; +import 'package:spotube/theme/spotify_colors.dart'; +import 'package:spotube/theme/spotify_typography.dart'; final searchTermStateProvider = StateProvider((ref) { return ""; @@ -37,6 +37,7 @@ class SearchPage extends HookConsumerWidget { Widget build(BuildContext context, ref) { final controller = useShadcnTextEditingController(); final focusNode = useFocusNode(); + final textTheme = SpotifyTypography.buildTextTheme(context); final searchTerm = ref.watch(searchTermStateProvider); final searchChipSnapshot = ref.watch(metadataPluginSearchChipsProvider); @@ -79,11 +80,8 @@ class SearchPage extends HookConsumerWidget { child: SafeArea( bottom: false, child: Scaffold( - headers: [ - if (kTitlebarVisible) - const TitleBar(automaticallyImplyLeading: false, height: 30) - ], - child: Builder(builder: (context) { + backgroundColor: SpotifyColors.background, + body: Builder(builder: (context) { if (searchChipSnapshot.error case MetadataPluginException( errorCode: MetadataPluginErrorCode.noDefaultMetadataPlugin, @@ -103,114 +101,60 @@ class SearchPage extends HookConsumerWidget { return Column( children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 10, - ), - child: ListenableBuilder( - listenable: controller, - builder: (context, _) { - final suggestions = controller.text.isEmpty - ? KVStoreService.recentSearches - : KVStoreService.recentSearches - .where( - (s) => - weightedRatio( - s.toLowerCase(), - controller.text.toLowerCase(), - ) > - 50, - ) - .toList(); - - return AutoComplete( - suggestions: suggestions.length <= 2 - ? [ - ...suggestions, - "Twenty One Pilots", - "Linkin Park", - ] - : suggestions, - completer: (suggestion) => suggestion, - mode: AutoCompleteMode.replaceAll, - child: TextField( - autofocus: true, - controller: controller, - focusNode: focusNode, - features: [ - const InputFeature.leading( - Icon(SpotubeIcons.search), - ), - InputFeature.trailing( - AnimatedCrossFade( - duration: - const Duration(milliseconds: 300), - crossFadeState: - controller.text.isNotEmpty - ? CrossFadeState.showFirst - : CrossFadeState.showSecond, - firstChild: IconButton.ghost( - size: ButtonSize.small, - icon: const Icon(SpotubeIcons.close), - onPressed: () { - controller.clear(); - }, - ), - secondChild: const SizedBox.square( - dimension: 28), - ), - ) - ], - textInputAction: TextInputAction.search, - placeholder: Text(context.l10n.search), - onSubmitted: onSubmitted, - ), - ); - }), - ), - ), - ], - ), - Row( - spacing: 8, - children: [ - const Gap(12), - if (searchChipSnapshot.asData?.value != null) - for (final chip in searchChipSnapshot.asData!.value) - Chip( - style: selectedChip.value == chip - ? ButtonVariance.primary.copyWith( - decoration: (context, states, value) { - return ButtonVariance.primary - .decoration(context, states) - .copyWithIfBoxDecoration( - borderRadius: - BorderRadius.circular(100), - ); - }, + Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 10, + ), + color: SpotifyColors.background, + child: ListenableBuilder( + listenable: controller, + builder: (context, _) { + final suggestions = controller.text.isEmpty + ? KVStoreService.recentSearches + : KVStoreService.recentSearches + .where( + (s) => + weightedRatio( + s.toLowerCase(), + controller.text.toLowerCase(), + ) > + 50, ) - : ButtonVariance.secondary.copyWith( - decoration: (context, states, value) { - return ButtonVariance.secondary - .decoration(context, states) - .copyWithIfBoxDecoration( - borderRadius: - BorderRadius.circular(100), - ); - }, - ), - child: Text(chip.capitalize()), - onPressed: () { - selectedChip.value = chip; - }, - ), - ], + .toList(); + + return SearchTextField( + suggestions: suggestions.length <= 2 + ? [ + ...suggestions, + "Twenty One Pilots", + "Linkin Park", + ] + : suggestions, + controller: controller, + focusNode: focusNode, + onSubmitted: onSubmitted, + ); + }), ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + spacing: 8, + children: [ + if (searchChipSnapshot.asData?.value != null) + for (final chip in searchChipSnapshot.asData!.value) + SpotifyChip( + selected: selectedChip.value == chip, + label: chip.capitalize(), + onPressed: () { + selectedChip.value = chip; + }, + ), + ], + ), + ), + const SizedBox(height: 10), Expanded( child: AnimatedSwitcher( duration: const Duration(milliseconds: 300), @@ -231,3 +175,120 @@ class SearchPage extends HookConsumerWidget { ); } } + +class SearchTextField extends HookConsumerWidget { + final List suggestions; + final Object controller; + final FocusNode focusNode; + final void Function(String) onSubmitted; + + const SearchTextField({ + super.key, + required this.suggestions, + required this.controller, + required this.focusNode, + required this.onSubmitted, + }); + + @override + Widget build(BuildContext context, ref) { + final textCtrl = controller as dynamic; + final textTheme = SpotifyTypography.buildTextTheme(context); + return AutoComplete( + suggestions: suggestions, + completer: (suggestion) => suggestion, + mode: AutoCompleteMode.replaceAll, + child: TextField( + autofocus: true, + controller: textCtrl, + focusNode: focusNode, + style: textTheme.bodyLarge?.copyWith( + color: SpotifyColors.primaryText, + ), + decoration: InputDecoration( + filled: true, + fillColor: SpotifyColors.surface, + hintText: context.l10n.search, + hintStyle: textTheme.bodyLarge?.copyWith( + color: SpotifyColors.disabledText, + ), + prefixIcon: const Icon( + SpotubeIcons.search, + color: SpotifyColors.secondaryText, + size: 20, + ), + suffixIcon: AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + crossFadeState: + textCtrl.text.isNotEmpty + ? CrossFadeState.showFirst + : CrossFadeState.showSecond, + firstChild: IconButton( + icon: const Icon(SpotubeIcons.close, + color: SpotifyColors.secondaryText, size: 18), + onPressed: () { + textCtrl.clear(); + }, + ), + secondChild: const SizedBox.square(dimension: 28), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(500), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(500), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(500), + borderSide: const BorderSide(color: SpotifyColors.spotifyGreen), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + ), + textInputAction: TextInputAction.search, + onSubmitted: onSubmitted, + ), + ); + } +} + +class SpotifyChip extends HookConsumerWidget { + final bool selected; + final String label; + final VoidCallback onPressed; + + const SpotifyChip({ + super.key, + required this.selected, + required this.label, + required this.onPressed, + }); + + @override + Widget build(BuildContext context, ref) { + final textTheme = SpotifyTypography.buildTextTheme(context); + return HoverBuilder( + builder: (context, hovering) { + return ActionChip( + label: Text( + label, + style: textTheme.labelMedium?.copyWith( + color: selected + ? Colors.black + : SpotifyColors.secondaryText, + fontWeight: FontWeight.w500, + ), + ), + backgroundColor: selected + ? SpotifyColors.spotifyGreen + : (hovering ? SpotifyColors.hover : SpotifyColors.surface), + side: BorderSide.none, + shape: const StadiumBorder(), + onPressed: onPressed, + padding: const EdgeInsets.symmetric(horizontal: 12), + ); + }, + ); + } +} diff --git a/lib/pages/search/tabs/albums.dart b/lib/pages/search/tabs/albums.dart index e27772c6..6b57a626 100644 --- a/lib/pages/search/tabs/albums.dart +++ b/lib/pages/search/tabs/albums.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -8,6 +9,7 @@ import 'package:spotube/modules/album/album_card.dart'; import 'package:spotube/modules/search/loading.dart'; import 'package:spotube/pages/search/search.dart'; import 'package:spotube/provider/metadata_plugin/search/albums.dart'; +import 'package:spotube/theme/spotify_colors.dart'; class SearchPageAlbumsTab extends HookConsumerWidget { const SearchPageAlbumsTab({super.key}); @@ -38,6 +40,7 @@ class SearchPageAlbumsTab extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: CustomScrollView( + controller: controller, slivers: [ PlaybuttonView( controller: controller, diff --git a/lib/pages/search/tabs/artists.dart b/lib/pages/search/tabs/artists.dart index 8cea7b58..71dfc74a 100644 --- a/lib/pages/search/tabs/artists.dart +++ b/lib/pages/search/tabs/artists.dart @@ -1,5 +1,5 @@ +import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:flutter_undraw/flutter_undraw.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter_extension.dart'; @@ -13,6 +13,7 @@ import 'package:spotube/modules/artist/artist_card.dart'; import 'package:spotube/modules/search/loading.dart'; import 'package:spotube/pages/search/search.dart'; import 'package:spotube/provider/metadata_plugin/search/artists.dart'; +import 'package:spotube/theme/spotify_colors.dart'; class SearchPageArtistsTab extends HookConsumerWidget { const SearchPageArtistsTab({super.key}); @@ -49,15 +50,15 @@ class SearchPageArtistsTab extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, spacing: 10, children: [ - Undraw( - height: 200 * context.theme.scaling, - illustration: UndrawIllustration.taken, - color: Theme.of(context).colorScheme.primary, - ), + const SizedBox(height: 40), Text( context.l10n.nothing_found, textAlign: TextAlign.center, - ).muted().small() + style: TextStyle( + color: SpotifyColors.secondaryText, + fontSize: 16, + ), + ), ], ), ); @@ -67,10 +68,10 @@ class SearchPageArtistsTab extends HookConsumerWidget { padding: const EdgeInsets.all(16), itemCount: searchArtists.length + 1, gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 200, - mainAxisExtent: constrains.smAndDown ? 225 : 250, - crossAxisSpacing: 8, - mainAxisSpacing: 8, + maxCrossAxisExtent: 160, + mainAxisExtent: constrains.smAndDown ? 200 : 220, + crossAxisSpacing: 12, + mainAxisSpacing: 12, ), itemBuilder: (context, index) { if (searchArtists.isNotEmpty && index == searchArtists.length) { @@ -82,10 +83,7 @@ class SearchPageArtistsTab extends HookConsumerWidget { controller: controller, isGrid: true, onTouchEdge: searchArtistsNotifier.fetchMore, - child: Skeletonizer( - enabled: true, - child: ArtistCard(FakeData.artist), - ), + child: const SpotifyShimmerTrack(), ); } diff --git a/lib/pages/search/tabs/playlists.dart b/lib/pages/search/tabs/playlists.dart index f00153cb..884c8bbc 100644 --- a/lib/pages/search/tabs/playlists.dart +++ b/lib/pages/search/tabs/playlists.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -8,6 +9,7 @@ import 'package:spotube/modules/playlist/playlist_card.dart'; import 'package:spotube/modules/search/loading.dart'; import 'package:spotube/pages/search/search.dart'; import 'package:spotube/provider/metadata_plugin/search/playlists.dart'; +import 'package:spotube/theme/spotify_colors.dart'; class SearchPagePlaylistsTab extends HookConsumerWidget { const SearchPagePlaylistsTab({super.key}); @@ -38,6 +40,7 @@ class SearchPagePlaylistsTab extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: CustomScrollView( + controller: controller, slivers: [ PlaybuttonView( controller: controller, diff --git a/lib/theme/spotify_colors.dart b/lib/theme/spotify_colors.dart new file mode 100644 index 00000000..d92200dd --- /dev/null +++ b/lib/theme/spotify_colors.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +abstract class SpotifyColors { + static const Color background = Color(0xFF121212); + static const Color surface = Color(0xFF181818); + static const Color hover = Color(0xFF282828); + static const Color separator = Color(0xFF282828); + static const Color sidebarBackground = Color(0xFF000000); + + static const Color spotifyGreen = Color(0xFF1DB954); + static const Color spotifyGreenHover = Color(0xFF1ED760); + + static const Color primaryText = Color(0xFFFFFFFF); + static const Color secondaryText = Color(0xFFB3B3B3); + static const Color disabledText = Color(0xFF6A6A6A); + static const Color invertedText = Color(0xFF000000); +} diff --git a/lib/theme/spotify_components.dart b/lib/theme/spotify_components.dart new file mode 100644 index 00000000..9dfbaefd --- /dev/null +++ b/lib/theme/spotify_components.dart @@ -0,0 +1,579 @@ +import 'dart:ui'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:skeletonizer/skeletonizer.dart'; + +import 'package:spotube/collections/assets.gen.dart'; +import 'package:spotube/collections/routes.gr.dart'; +import 'package:spotube/components/hover_builder.dart'; +import 'package:spotube/components/image/universal_image.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/duration.dart'; +import 'package:spotube/models/metadata/metadata.dart'; +import 'package:spotube/provider/audio_player/audio_player.dart'; +import 'package:spotube/theme/spotify_colors.dart'; +import 'package:spotube/theme/spotify_typography.dart'; + +class SpotifyCard extends HookConsumerWidget { + final Widget image; + final String title; + final String? subtitle; + final VoidCallback? onTap; + final VoidCallback? onPlayPressed; + final bool isPlaying; + final bool isLoading; + + const SpotifyCard({ + super.key, + required this.image, + required this.title, + this.subtitle, + this.onTap, + this.onPlayPressed, + this.isPlaying = false, + this.isLoading = false, + }); + + @override + Widget build(BuildContext context, ref) { + final textTheme = SpotifyTypography.buildTextTheme(context); + return HoverBuilder( + builder: (context, hovering) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + hoverColor: Colors.transparent, + highlightColor: Colors.transparent, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: hovering ? SpotifyColors.hover : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: image, + ), + if (onPlayPressed != null) + Positioned( + right: 8, + bottom: 8, + child: AnimatedScale( + duration: const Duration(milliseconds: 150), + scale: hovering && !isLoading ? 1 : 0.7, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 150), + opacity: hovering && !isLoading ? 1 : 0, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: SpotifyColors.spotifyGreen, + shape: BoxShape.circle, + ), + child: Icon( + isLoading + ? SpotubeIcons.refresh + : (isPlaying + ? SpotubeIcons.pause + : SpotubeIcons.play), + color: Colors.black, + size: 16, + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + title, + style: textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: SpotifyColors.primaryText, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (subtitle != null && subtitle!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + subtitle!, + style: textTheme.bodySmall?.copyWith( + color: SpotifyColors.secondaryText, + fontSize: 13, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ], + ), + ), + ); + }, + ); + } +} + +class SpotifyTrackRow extends HookConsumerWidget { + final int? index; + final SpotubeTrackObject track; + final bool isPlaying; + final VoidCallback onTap; + final VoidCallback? onLikePressed; + final VoidCallback? onMorePressed; + final bool selected; + + const SpotifyTrackRow({ + super.key, + this.index, + required this.track, + this.isPlaying = false, + required this.onTap, + this.onLikePressed, + this.onMorePressed, + this.selected = false, + }); + + @override + Widget build(BuildContext context, ref) { + final textTheme = SpotifyTypography.buildTextTheme(context); + final playlist = ref.watch(audioPlayerProvider); + final isActive = playlist.activeTrack?.id == track.id; + final hover = useState(false); + final imageProvider = useMemoized( + () => UniversalImage.imageProvider( + (track.album.images).smallest(ImagePlaceholder.albumArt), + ), + [track.album.images], + ); + + return MouseRegion( + onEnter: (_) => hover.value = true, + onExit: (_) => hover.value = false, + child: InkWell( + onTap: onTap, + hoverColor: Colors.transparent, + highlightColor: Colors.transparent, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + color: (hover.value || isActive || selected) + ? SpotifyColors.hover + : Colors.transparent, + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + Container( + width: 40, + alignment: Alignment.center, + child: isActive + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + SpotifyColors.spotifyGreen, + ), + ), + ) + : (hover.value && !isActive + ? const Icon( + SpotubeIcons.play, + size: 16, + color: SpotifyColors.primaryText, + ) + : Text( + '${(index ?? 0) + 1}', + style: textTheme.bodyMedium?.copyWith( + color: (isActive || hover.value) + ? SpotifyColors.primaryText + : SpotifyColors.disabledText, + fontWeight: FontWeight.w500, + fontSize: 13, + ), + textAlign: TextAlign.center, + )), + ), + const SizedBox(width: 16), + Expanded( + flex: 4, + child: Column( + mainAxisAlignment: MainAxisCenter, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + track.name, + style: textTheme.bodyMedium?.copyWith( + color: isActive + ? SpotifyColors.spotifyGreen + : SpotifyColors.primaryText, + fontWeight: isActive + ? FontWeight.w700 + : FontWeight.w400, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + ClipRect( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 16), + child: AbsorbPointer( + child: ArtistLinkSpotify( + artists: track.artists, + onPressed: () { + context.navigateTo( + TrackRoute(trackId: track.id), + ); + }, + ), + ), + ), + ), + ], + ), + ), + if (MediaQuery.sizeOf(context).width > 820) ...[ + Expanded( + flex: 2, + child: Text( + track.album.name, + style: textTheme.bodySmall?.copyWith( + color: SpotifyColors.secondaryText, + fontSize: 13, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Expanded( + flex: 1, + child: Text( + _formatDate(track), + style: textTheme.bodySmall?.copyWith( + color: SpotifyColors.secondaryText, + fontSize: 13, + ), + textAlign: TextAlign.right, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Expanded( + flex: 1, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + Duration( + milliseconds: track.durationMs, + ).toHumanReadableString(padZero: false), + style: textTheme.bodySmall?.copyWith( + color: SpotifyColors.secondaryText, + fontSize: 13, + ), + ), + const SizedBox(width: 8), + if (onMorePressed != null) + IconButton( + icon: Icon( + SpotubeIcons.moreHorizontal, + color: SpotifyColors.secondaryText, + size: 16, + ), + onPressed: onMorePressed, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + ), + ], + ), + ), + ] else ...[ + Expanded( + child: Align( + alignment: Alignment.centerRight, + child: Text( + Duration( + milliseconds: track.durationMs, + ).toHumanReadableString(padZero: false), + style: textTheme.bodySmall?.copyWith( + color: SpotifyColors.secondaryText, + fontSize: 13, + ), + ), + ), + ), + ], + const SizedBox(width: 8), + ], + ), + ), + ), + ); + } + + String _formatDate(SpotubeTrackObject track) { + return ""; + }} + +class ArtistLinkSpotify extends ConsumerWidget { + final List artists; + final VoidCallback? onPressed; + + const ArtistLinkSpotify({ + super.key, + required this.artists, + this.onPressed, + }); + + @override + Widget build(BuildContext context, ref) { + final textTheme = SpotifyTypography.buildTextTheme(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (int i = 0; i < artists.length; i++) ...[ + if (i > 0) + Text( + " • ", + style: textTheme.bodySmall?.copyWith( + color: SpotifyColors.disabledText, + fontSize: 11, + ), + ), + InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(4), + child: Text( + artists[i].name, + style: textTheme.bodySmall?.copyWith( + color: SpotifyColors.secondaryText, + fontSize: 13, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ], + ); + } +} + +class SpotifyShimmer extends StatelessWidget { + final double width; + final double height; + final BorderRadius? borderRadius; + + const SpotifyShimmer({ + super.key, + this.width = double.infinity, + this.height = 16, + this.borderRadius, + }); + + @override + Widget build(BuildContext context) { + return Skeletonizer( + enabled: true, + child: Container( + width: width, + height: height, + decoration: BoxDecoration( + color: SpotifyColors.surface, + borderRadius: borderRadius ?? BorderRadius.circular(4), + ), + ), + ); + } +} + +class SpotifyShimmerTrack extends StatelessWidget { + const SpotifyShimmerTrack({super.key}); + + @override + Widget build(BuildContext context) { + return Skeletonizer( + enabled: true, + child: Container( + height: 56, + color: Colors.transparent, + child: Row( + children: [ + Container(width: 40, height: 40, color: SpotifyColors.surface), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisAlignment: MainAxisCenter, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SpotifyShimmer(width: 120, height: 14), + const SizedBox(height: 6), + SpotifyShimmer(width: 200, height: 12), + ], + ), + ), + const SizedBox(width: 32), + SpotifyShimmer(width: 40, height: 14), + ], + ), + ), + ); + } +} + +class SpotifyShimmerCard extends StatelessWidget { + const SpotifyShimmerCard({super.key}); + + @override + Widget build(BuildContext context) { + return Skeletonizer( + enabled: true, + child: SpotifyCard( + image: Container( + width: 150, + height: 150, + color: SpotifyColors.surface, + ), + title: "", + subtitle: "", + ), + ); + } +} + +class SpotifyContextMenu extends StatelessWidget { + final List items; + final Offset position; + + const SpotifyContextMenu({ + super.key, + required this.items, + required this.position, + }); + + @override + Widget build(BuildContext context) { + return Positioned( + left: position.dx, + top: position.dy, + child: Material( + color: Colors.transparent, + child: Container( + decoration: BoxDecoration( + color: SpotifyColors.surface, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.4), + blurRadius: 16, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final item in items) ...[ + if (item.isDivider) const Divider(height: 1), + _MenuItem(item: item), + ], + ], + ), + ), + ), + ); + } +} + +class _MenuItem extends StatelessWidget { + final ContextMenuItem item; + + const _MenuItem({required this.item}); + + @override + Widget build(BuildContext context) { + final textTheme = SpotifyTypography.buildTextTheme(context); + return HoverBuilder( + builder: (context, hovering) { + return InkWell( + onTap: item.onPressed, + child: Container( + height: 36, + padding: const EdgeInsets.symmetric(horizontal: 16), + color: hovering ? SpotifyColors.hover : Colors.transparent, + alignment: Alignment.centerLeft, + child: Row( + children: [ + if (item.icon != null) + Padding( + padding: const EdgeInsets.only(right: 12), + child: Icon( + item.icon!, + color: SpotifyColors.secondaryText, + size: 18, + ), + ), + Expanded( + child: Text( + item.label, + style: textTheme.bodyMedium?.copyWith( + color: SpotifyColors.primaryText, + fontSize: 13, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (item.shortcut != null) + Text( + item.shortcut!, + style: textTheme.labelSmall?.copyWith( + color: SpotifyColors.disabledText, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class ContextMenuItem { + final String label; + final IconData? icon; + final String? shortcut; + final bool isDivider; + final VoidCallback? onPressed; + + ContextMenuItem({ + required this.label, + this.icon, + this.shortcut, + this.isDivider = false, + this.onPressed, + }); + + ContextMenuItem.divider() : this( + label: "", + isDivider: true, + ); +} diff --git a/lib/theme/spotify_theme.dart b/lib/theme/spotify_theme.dart new file mode 100644 index 00000000..87d5bfef --- /dev/null +++ b/lib/theme/spotify_theme.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; +import 'package:spotube/theme/spotify_colors.dart'; +import 'package:spotube/theme/spotify_typography.dart'; + +class SpotifyTheme { + static ThemeData materialTheme(BuildContext context) { + final textTheme = SpotifyTypography.buildTextTheme(context); + return ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + scaffoldBackgroundColor: SpotifyColors.background, + backgroundColor: SpotifyColors.background, + cardColor: SpotifyColors.surface, + dividerColor: SpotifyColors.separator, + primaryColor: SpotifyColors.spotifyGreen, + indicatorColor: SpotifyColors.spotifyGreen, + focusColor: SpotifyColors.spotifyGreen, + hoverColor: SpotifyColors.hover, + highlightColor: SpotifyColors.hover.withValues(alpha: 0.1), + splashColor: Colors.transparent, + canvasColor: SpotifyColors.background, + textTheme: textTheme, + primaryTextTheme: textTheme, + iconTheme: const IconThemeData( + color: SpotifyColors.primaryText, + size: 20, + ), + primaryIconTheme: const IconThemeData( + color: SpotifyColors.primaryText, + ), + dividerTheme: const DividerThemeData( + color: SpotifyColors.separator, + space: 1, + thickness: 1, + ), + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: ZoomPageTransitionsBuilder(), + TargetPlatform.iOS: ZoomPageTransitionsBuilder(), + TargetPlatform.macOS: ZoomPageTransitionsBuilder(), + TargetPlatform.linux: ZoomPageTransitionsBuilder(), + TargetPlatform.windows: ZoomPageTransitionsBuilder(), + }, + ), + listTileTheme: const ListTileThemeData( + dense: true, + contentPadding: EdgeInsets.symmetric(horizontal: 16), + selectedTileColor: SpotifyColors.hover, + selectedColor: SpotifyColors.primaryText, + unselectedColor: SpotifyColors.secondaryText, + iconColor: SpotifyColors.secondaryText, + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: SpotifyColors.surface, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: const BorderSide(color: SpotifyColors.hover), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: const BorderSide(color: SpotifyColors.hover), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(4), + borderSide: const BorderSide(color: SpotifyColors.spotifyGreen), + ), + hintStyle: textTheme.bodySmall?.copyWith( + color: SpotifyColors.disabledText, + ), + ), + ); + } + + static ShadcnThemeData shadcnTheme() { + return const ShadcnThemeData( + radius: 0.125, + iconTheme: IconThemeProperties(), + colorScheme: LegacyColorSchemes.darkSlate(), + surfaceOpacity: 0, + surfaceBlur: 0, + ); + } +} diff --git a/lib/theme/spotify_typography.dart b/lib/theme/spotify_typography.dart new file mode 100644 index 00000000..f6148256 --- /dev/null +++ b/lib/theme/spotify_typography.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:spotube/theme/spotify_colors.dart'; + +class SpotifyTypography { + static const double _letterSpacing = -0.04; + + static TextTheme buildTextTheme(BuildContext context) { + final base = GoogleFonts.poppinsTextTheme(); + return base.copyWith( + displayLarge: base.displayLarge!.copyWith( + fontWeight: FontWeight.w700, + color: SpotifyColors.primaryText, + letterSpacing: _letterSpacing, + ), + displayMedium: base.displayMedium!.copyWith( + fontWeight: FontWeight.w700, + color: SpotifyColors.primaryText, + letterSpacing: _letterSpacing, + ), + displaySmall: base.displaySmall!.copyWith( + fontWeight: FontWeight.w600, + color: SpotifyColors.primaryText, + letterSpacing: _letterSpacing, + ), + headlineLarge: base.headlineLarge!.copyWith( + fontWeight: FontWeight.w700, + color: SpotifyColors.primaryText, + letterSpacing: _letterSpacing, + ), + headlineMedium: base.headlineMedium!.copyWith( + fontWeight: FontWeight.w600, + color: SpotifyColors.primaryText, + letterSpacing: _letterSpacing, + ), + headlineSmall: base.headlineSmall!.copyWith( + fontWeight: FontWeight.w600, + color: SpotifyColors.primaryText, + letterSpacing: _letterSpacing, + ), + titleLarge: base.titleLarge!.copyWith( + fontWeight: FontWeight.w700, + color: SpotifyColors.primaryText, + fontSize: 16, + letterSpacing: _letterSpacing, + ), + titleMedium: base.titleMedium!.copyWith( + fontWeight: FontWeight.w700, + color: SpotifyColors.primaryText, + fontSize: 14, + letterSpacing: _letterSpacing, + ), + titleSmall: base.titleSmall!.copyWith( + fontWeight: FontWeight.w600, + color: SpotifyColors.primaryText, + fontSize: 14, + letterSpacing: _letterSpacing, + ), + bodyLarge: base.bodyLarge!.copyWith( + fontWeight: FontWeight.w400, + color: SpotifyColors.primaryText, + fontSize: 16, + letterSpacing: _letterSpacing, + ), + bodyMedium: base.bodyMedium!.copyWith( + fontWeight: FontWeight.w400, + color: SpotifyColors.primaryText, + fontSize: 14, + letterSpacing: _letterSpacing, + ), + bodySmall: base.bodySmall!.copyWith( + fontWeight: FontWeight.w400, + color: SpotifyColors.secondaryText, + fontSize: 13, + letterSpacing: _letterSpacing, + ), + labelLarge: base.labelLarge!.copyWith( + fontWeight: FontWeight.w600, + color: SpotifyColors.primaryText, + fontSize: 12, + letterSpacing: 0.16, + ), + labelMedium: base.labelMedium!.copyWith( + fontWeight: FontWeight.w500, + color: SpotifyColors.secondaryText, + fontSize: 11, + letterSpacing: 0.16, + ), + labelSmall: base.labelSmall!.copyWith( + fontWeight: FontWeight.w500, + color: SpotifyColors.disabledText, + fontSize: 11, + letterSpacing: 0.16, + ), + ); + } + + static const FontWeight regular = FontWeight.w400; + static const FontWeight medium = FontWeight.w500; + static const FontWeight bold = FontWeight.w700; +} diff --git a/linux/spotube.desktop b/linux/spotube.desktop index 89b457b3..998cb7f8 100644 --- a/linux/spotube.desktop +++ b/linux/spotube.desktop @@ -1,6 +1,6 @@ [Desktop Entry] Type=Application -Name=Spotube +Name=Spotify Exec=/usr/bin/spotube Icon=/usr/share/icons/spotube/spotube-logo.png Comment=A music streaming app combining the power of music metadata providers & YouTube diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index eac0f8cf..af647195 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -5,7 +5,7 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = Spotube +PRODUCT_NAME = Spotify // The application's bundle identifier PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube diff --git a/pubspec.yaml b/pubspec.yaml index dd91f204..5970de50 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -139,6 +139,7 @@ dependencies: url: https://github.com/KRTirtho/hetu_spotube_plugin.git ref: main get_it: ^8.0.3 + google_fonts: ^6.2.1 flutter_markdown_plus: ^1.0.3 pub_semver: ^2.2.0 change_case: ^1.1.0 @@ -217,6 +218,7 @@ flutter: assets: - assets/images/ - assets/images/logos/ + - assets/images/spotify_logo.png - assets/branding/spotube-logo.png - assets/branding/spotube-logo-light.png - assets/branding/spotube-logo.ico @@ -273,10 +275,10 @@ flutter_gen: flutter_native_splash: background_image: assets/bengali-patterns-bg.jpg - image: assets/spotube-logo.png - branding: assets/branding.png + image: assets/images/spotify_logo.png + branding: assets/branding/branding.png android_12: - image: assets/spotube-logo_android12.png - branding: assets/branding.png - color: "#000000" - icon_background_color: "#000000" + image: assets/images/spotify_logo.png + branding: assets/branding/branding.png + color: "#121212" + icon_background_color: "#121212" diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index c77ce0c6..a767693c 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -90,12 +90,12 @@ BEGIN BLOCK "040904e4" BEGIN VALUE "CompanyName", "oss.krtirtho" "\0" - VALUE "FileDescription", "Spotube" "\0" + VALUE "FileDescription", "Spotify" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "spotube" "\0" VALUE "LegalCopyright", "Copyright (C) 2024 oss.krtirtho. All rights reserved." "\0" VALUE "OriginalFilename", "spotube.exe" "\0" - VALUE "ProductName", "spotube" "\0" + VALUE "ProductName", "Spotify" "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0" END END