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.
This commit is contained in:
kiloconnect[bot] 2026-10-20 13:34:48 +00:00
parent 69a310c78f
commit 8a193658af
29 changed files with 2413 additions and 1050 deletions

View File

@ -0,0 +1,3 @@
<resources>
<string name="app_name_en">Spotify</string>
</resources>

View File

@ -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"

View File

@ -7,7 +7,7 @@
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Spotube</string>
<string>Spotify</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>spotube</string>
<string>Spotify</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>

View File

@ -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,
),
),
],
),
),
),
);
},
);
}
}

View File

@ -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!,
],
),
),
);
},
);
}
}

View File

@ -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<OverlayCompleter<dynamic>?>(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();
}
}

View File

@ -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<void> main(List<String> 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) {

View File

@ -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,
);
}
}

View File

@ -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,
),
),
],
);
},
),
);
}

View File

@ -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,
);
}
}

View File

@ -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);
},
),
);
},
),
],
),
),
],
),
),
);
}
}

View File

@ -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(),
],
),
);
}
}

View File

@ -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<bool> 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,
),
),
);
}
}

View File

@ -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),
);
},
);
}
}

View File

@ -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()),
],
),
),
);
}
}

View File

@ -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,
),
),
),
);
}
}

View File

@ -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();
}
}

View File

@ -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<String>((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<String> 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),
);
},
);
}
}

View File

@ -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,

View File

@ -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(),
);
}

View File

@ -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,

View File

@ -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);
}

View File

@ -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<SpotubeSimpleArtistObject> 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<ContextMenuItem> 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,
);
}

View File

@ -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,
);
}
}

View File

@ -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;
}

View File

@ -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

View File

@ -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

View File

@ -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"

View File

@ -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