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