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 pub run flutter_launcher_icons
flutter_launcher_icons: flutter_launcher_icons:
image_path: "assets/branding/spotube-logo.png" image_path: "assets/images/spotify_logo.png"
android: true android: true
# image_path_android: "assets/branding/icon/icon.png" # image_path_android: "assets/branding/icon/icon.png"
min_sdk_android: 21 # android min sdk min:16, default 21 min_sdk_android: 21 # android min sdk min:16, default 21
adaptive_icon_background: "#242832" adaptive_icon_background: "#000000"
adaptive_icon_foreground: "assets/branding/spotube-logo-foreground.png" adaptive_icon_foreground: "assets/images/spotify_logo.png"
# adaptive_icon_monochrome: "assets/branding/icon/monochrome.png" # adaptive_icon_monochrome: "assets/branding/icon/monochrome.png"
ios: true ios: true
@ -21,9 +21,9 @@ flutter_launcher_icons:
windows: windows:
generate: true 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 icon_size: 48 # min:48, max:256, default: 48
macos: macos:
generate: true 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> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>Spotube</string> <string>Spotify</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>
@ -15,7 +15,7 @@
<key>CFBundleInfoDictionaryVersion</key> <key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string> <string>6.0</string>
<key>CFBundleName</key> <key>CFBundleName</key>
<string>spotube</string> <string>Spotify</string>
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>APPL</string> <string>APPL</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>

View File

@ -1,10 +1,10 @@
import 'package:auto_size_text/auto_size_text.dart'; 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: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/collections/spotube_icons.dart';
import 'package:spotube/components/heart_button/heart_button.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/image/universal_image.dart';
import 'package:spotube/components/track_presentation/presentation_props.dart'; import 'package:spotube/components/track_presentation/presentation_props.dart';
import 'package:spotube/components/track_presentation/use_action_callbacks.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/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/modules/playlist/playlist_create_dialog.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 { class TrackPresentationTopSection extends HookConsumerWidget {
const TrackPresentationTopSection({super.key}); const TrackPresentationTopSection({super.key});
@ -28,124 +30,12 @@ class TrackPresentationTopSection extends HookConsumerWidget {
fit: BoxFit.cover, fit: BoxFit.cover,
); );
final imageDimension = mediaQuery.mdAndUp ? 200 : 120; final imageDimension = mediaQuery.mdAndUp ? 300.0 : 200.0;
final (:isLoading, :isActive, :onPlay, :onShuffle, :onAddToQueue) = final (:isLoading, :isActive, :onPlay, :onShuffle, :onAddToQueue) =
useActionCallbacks(ref); useActionCallbacks(ref);
final playbackActions = Row( final textTheme = SpotifyTypography.buildTextTheme(context);
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,
),
],
);
return SliverMainAxisGroup( return SliverMainAxisGroup(
slivers: [ slivers: [
@ -159,32 +49,32 @@ class TrackPresentationTopSection extends HookConsumerWidget {
DecoratedBox( DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
image: decorationImage, image: decorationImage,
borderRadius: BorderRadius.circular(45), borderRadius: BorderRadius.circular(0),
), ),
child: OutlinedContainer( child: Column(
surfaceOpacity: context.theme.surfaceOpacity, mainAxisSize: MainAxisSize.min,
surfaceBlur: context.theme.surfaceBlur, children: [
padding: EdgeInsets.all(24 * scale), Container(
borderRadius: BorderRadius.circular(22 * scale), height: imageDimension * scale,
borderWidth: 2, width: double.infinity,
child: Column( decoration: BoxDecoration(
mainAxisSize: MainAxisSize.min, image: decorationImage,
spacing: 16 * scale, ),
children: [ child: Container(
Row( decoration: BoxDecoration(
spacing: 16 * scale, gradient: LinearGradient(
crossAxisAlignment: CrossAxisAlignment.start, begin: Alignment.topCenter,
mainAxisSize: MainAxisSize.min, end: Alignment.bottomCenter,
children: [ colors: [
Container( Colors.transparent,
height: imageDimension * scale, SpotifyColors.background.withValues(alpha: 0.85),
width: imageDimension * scale, ],
decoration: BoxDecoration(
borderRadius: context.theme.borderRadiusXl,
image: decorationImage,
),
), ),
Flexible( ),
child: Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.all(24 * scale),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -192,8 +82,11 @@ class TrackPresentationTopSection extends HookConsumerWidget {
AutoSizeText( AutoSizeText(
options.title, options.title,
maxLines: 2, maxLines: 2,
minFontSize: 16, minFontSize: 20,
style: context.theme.typography.h3, style: textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.w700,
color: SpotifyColors.primaryText,
),
), ),
if (options.description != null) if (options.description != null)
AutoSizeText( AutoSizeText(
@ -203,53 +96,92 @@ class TrackPresentationTopSection extends HookConsumerWidget {
maxFontSize: 18, maxFontSize: 18,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
color: context color: SpotifyColors.secondaryText,
.theme.colorScheme.mutedForeground, fontSize: 16,
fontSize: 18,
), ),
), ),
const Gap(16), const SizedBox(height: 24),
Flex( if (options.owner != null)
crossAxisAlignment: CrossAxisAlignment.start, Row(
direction: mediaQuery.smAndUp mainAxisSize: MainAxisSize.min,
? Axis.horizontal children: [
: Axis.vertical, if (options.ownerImage != null)
spacing: 8 * scale, CircleAvatar(
children: [ radius: 16 * scale,
if (options.owner != null) backgroundImage: UniversalImage
OutlineBadge( .imageProvider(
leading: options.ownerImage != null options.ownerImage!,
? Avatar( ),
initials: )
options.owner?[0] ?? "U", else
provider: UniversalImage const CircleAvatar(
.imageProvider( radius: 16,
options.ownerImage!, child: Icon(Icons.person_rounded,
), size: 16),
size: 20 * scale, ),
) const SizedBox(width: 8),
: null, Text(
child: Text( options.owner!,
options.owner!, style: textTheme.bodyMedium?.copyWith(
maxLines: 1, color: SpotifyColors.primaryText,
overflow: TextOverflow.ellipsis, fontSize: 14,
).small(), ),
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) ...[ if (options.onHeart != null) ...[
const Gap(16), const SizedBox(height: 16),
playbackActions 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: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/routes.dart';
import 'package:spotube/collections/spotube_icons.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/constrains.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/track_options/track_options_provider.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] /// [track] must be a [SpotubeFullTrackObject] or [SpotubeLocalTrackObject]
class TrackOptions extends HookConsumerWidget { class TrackOptions extends HookConsumerWidget {
@ -33,6 +35,7 @@ class TrackOptions extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final textTheme = SpotifyTypography.buildTextTheme(context);
final trackOptionActions = ref.watch(trackOptionActionsProvider(track)); final trackOptionActions = ref.watch(trackOptionActionsProvider(track));
final ( final (
@ -46,238 +49,314 @@ class TrackOptions extends HookConsumerWidget {
) = ref.watch(trackOptionsStateProvider(track)); ) = ref.watch(trackOptionsStateProvider(track));
final isLocalTrack = track is SpotubeLocalTrackObject; final isLocalTrack = track is SpotubeLocalTrackObject;
return Column( return ConstrainedBox(
mainAxisSize: MainAxisSize.min, constraints: const BoxConstraints(maxWidth: 220),
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
spacing: 8, mainAxisSize: MainAxisSize.min,
children: [ crossAxisAlignment: CrossAxisAlignment.start,
if (isLocalTrack) spacing: 4,
ButtonTile( children: [
style: ButtonVariance.menu, if (isLocalTrack)
onPressed: () async { _SpotifyMenuItem(
await trackOptionActions.action( icon: SpotubeIcons.trash,
rootNavigatorKey.currentContext!, label: context.l10n.delete,
TrackOptionValue.delete, onPressed: () async {
playlistId, await trackOptionActions.action(
); rootNavigatorKey.currentContext!,
onTapItem?.call(); TrackOptionValue.delete,
}, playlistId,
leading: const Icon(SpotubeIcons.trash), );
title: Text(context.l10n.delete), onTapItem?.call();
), },
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,
),
],
), ),
), if (mediaQuery.smAndDown && !isLocalTrack)
if (!isInQueue) ...[ _SpotifyMenuItem(
ButtonTile( icon: SpotubeIcons.album,
style: ButtonVariance.menu, leadingSubtitle: track.album.name,
onPressed: () async { label: context.l10n.go_to_album,
await trackOptionActions.action( onPressed: () async {
rootNavigatorKey.currentContext!, await trackOptionActions.action(
TrackOptionValue.addToQueue, rootNavigatorKey.currentContext!,
playlistId, TrackOptionValue.album,
); playlistId,
onTapItem?.call(); );
}, onTapItem?.call();
leading: const Icon(SpotubeIcons.queueAdd), },
title: Text(context.l10n.add_to_queue), ),
), if (!isInQueue) ...[
ButtonTile( _SpotifyMenuItem(
style: ButtonVariance.menu, icon: SpotubeIcons.queueAdd,
onPressed: () async { label: context.l10n.add_to_queue,
await trackOptionActions.action( onPressed: () async {
rootNavigatorKey.currentContext!, await trackOptionActions.action(
TrackOptionValue.playNext, rootNavigatorKey.currentContext!,
playlistId, TrackOptionValue.addToQueue,
); playlistId,
onTapItem?.call(); );
}, onTapItem?.call();
leading: const Icon(SpotubeIcons.lightning), },
title: Text(context.l10n.play_next), ),
), _SpotifyMenuItem(
] else icon: SpotubeIcons.lightning,
ButtonTile( label: context.l10n.play_next,
style: ButtonVariance.menu, onPressed: () async {
onPressed: () async { await trackOptionActions.action(
await trackOptionActions.action( rootNavigatorKey.currentContext!,
rootNavigatorKey.currentContext!, TrackOptionValue.playNext,
TrackOptionValue.removeFromQueue, playlistId,
playlistId, );
); onTapItem?.call();
onTapItem?.call(); },
}, ),
enabled: !isActiveTrack, ] else
leading: const Icon(SpotubeIcons.queueRemove), _SpotifyMenuItem(
title: Text(context.l10n.remove_from_queue), icon: SpotubeIcons.queueRemove,
), label: context.l10n.remove_from_queue,
if (isAuthenticated && !isLocalTrack) enabled: !isActiveTrack,
ButtonTile( onPressed: () async {
style: ButtonVariance.menu, await trackOptionActions.action(
onPressed: () async { rootNavigatorKey.currentContext!,
await trackOptionActions.action( TrackOptionValue.removeFromQueue,
rootNavigatorKey.currentContext!, playlistId,
TrackOptionValue.favorite, );
playlistId, onTapItem?.call();
); },
onTapItem?.call(); ),
}, if (isAuthenticated && !isLocalTrack)
leading: isLiked _SpotifyMenuItem(
? const Icon( icon: isLiked ? SpotubeIcons.heartFilled : SpotubeIcons.heart,
SpotubeIcons.heartFilled, iconColor: isLiked ? Colors.pink : null,
color: Colors.pink, label: isLiked
)
: const Icon(SpotubeIcons.heart),
title: Text(
isLiked
? context.l10n.remove_from_favorites ? context.l10n.remove_from_favorites
: context.l10n.save_as_favorite, : context.l10n.save_as_favorite,
onPressed: () async {
await trackOptionActions.action(
rootNavigatorKey.currentContext!,
TrackOptionValue.favorite,
playlistId,
);
onTapItem?.call();
},
), ),
), if (isAuthenticated && !isLocalTrack) ...[
if (isAuthenticated && !isLocalTrack) ...[ _SpotifyMenuItem(
ButtonTile( icon: SpotubeIcons.radio,
style: ButtonVariance.menu, label: context.l10n.start_a_radio,
onPressed: () async { onPressed: () async {
await trackOptionActions.action( await trackOptionActions.action(
rootNavigatorKey.currentContext!, rootNavigatorKey.currentContext!,
TrackOptionValue.startRadio, TrackOptionValue.startRadio,
playlistId, playlistId,
); );
onTapItem?.call(); 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,
), ),
title: Text( _SpotifyMenuItem(
isBlacklisted == true 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.remove_from_blacklist
: context.l10n.add_to_blacklist, : context.l10n.add_to_blacklist,
style: TextStyle( labelColor: isBlacklisted != true ? Colors.red[400] : null,
color: isBlacklisted != true ? Colors.red[400] : null, onPressed: () async {
), await trackOptionActions.action(
rootNavigatorKey.currentContext!,
TrackOptionValue.blacklist,
playlistId,
);
onTapItem?.call();
},
), ),
), if (!isLocalTrack)
if (!isLocalTrack) _SpotifyMenuItem(
ButtonTile( icon: SpotubeIcons.share,
style: ButtonVariance.menu, label: context.l10n.share,
onPressed: () async { onPressed: () async {
await trackOptionActions.action( await trackOptionActions.action(
rootNavigatorKey.currentContext!, rootNavigatorKey.currentContext!,
TrackOptionValue.share, TrackOptionValue.share,
playlistId, playlistId,
); );
onTapItem?.call(); onTapItem?.call();
}, },
leading: const Icon(SpotubeIcons.share), ),
title: Text(context.l10n.share), if (!isLocalTrack)
), _SpotifyMenuItem(
if (!isLocalTrack) icon: SpotubeIcons.info,
ButtonTile( label: context.l10n.details,
style: ButtonVariance.menu, onPressed: () async {
onPressed: () async { await trackOptionActions.action(
await trackOptionActions.action( rootNavigatorKey.currentContext!,
rootNavigatorKey.currentContext!, TrackOptionValue.details,
TrackOptionValue.details, playlistId,
playlistId, );
); onTapItem?.call();
onTapItem?.call(); },
}, ),
leading: const Icon(SpotubeIcons.info), ],
title: Text(context.l10n.details), ),
), );
], }
}
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:auto_route/auto_route.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:spotube/collections/routes.gr.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/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/constrains.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/extensions/duration.dart'; import 'package:spotube/extensions/duration.dart';
import 'package:spotube/models/metadata/metadata.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/querying_track_info.dart';
import 'package:spotube/provider/audio_player/state.dart';
import 'package:spotube/provider/blacklist_provider.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'; import 'package:spotube/utils/platform.dart';
final isBlacklistedProvider = final isBlacklistedProvider =
@ -32,8 +31,6 @@ final isBlacklistedProvider =
}, },
); );
final _overlay = ValueNotifier<OverlayCompleter<dynamic>?>(null);
class TrackTile extends HookConsumerWidget { class TrackTile extends HookConsumerWidget {
/// [index] will not be shown if null /// [index] will not be shown if null
final int? index; final int? index;
@ -67,6 +64,7 @@ class TrackTile extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final theme = Theme.of(context); final theme = Theme.of(context);
final textTheme = SpotifyTypography.buildTextTheme(context);
final isBlackListed = ref.watch(isBlacklistedProvider(track)); final isBlackListed = ref.watch(isBlacklistedProvider(track));
@ -74,8 +72,6 @@ class TrackTile extends HookConsumerWidget {
final isPlaying = playlist.activeTrack?.id == track.id; final isPlaying = playlist.activeTrack?.id == track.id;
final isSelected = isPlaying || isLoading.value;
final imageProvider = useMemoized( final imageProvider = useMemoized(
() => UniversalImage.imageProvider( () => UniversalImage.imageProvider(
(track.album.images).smallest(ImagePlaceholder.albumArt), (track.album.images).smallest(ImagePlaceholder.albumArt),
@ -93,11 +89,7 @@ class TrackTile extends HookConsumerWidget {
return Listener( return Listener(
onPointerDown: (event) { onPointerDown: (event) {
if (event.buttons != kSecondaryMouseButton) return; if (event.buttons != kSecondaryMouseButton) return;
if (_overlay.value != null) { TrackOptionsButton.showOptions(
_overlay.value?.remove();
_overlay.value = null;
}
_overlay.value = TrackOptionsButton.showOptions(
context, context,
Offset.zero, Offset.zero,
track, track,
@ -105,243 +97,42 @@ class TrackTile extends HookConsumerWidget {
playlistId: playlistId, playlistId: playlistId,
); );
}, },
child: HoverBuilder( child: SpotifyTrackRow(
permanentState: isSelected || constrains.smAndDown ? true : null, index: index,
builder: (context, isHovering) => ButtonTile( track: track,
selected: isSelected, isPlaying: isPlaying || isLoading.value,
onPressed: () async { selected: selected,
if (isBlackListed) return; onTap: () async {
try { if (isBlackListed) return;
isLoading.value = true; try {
await onTap?.call(); isLoading.value = true;
} finally { await onTap?.call();
if (context.mounted) { } finally {
isLoading.value = false; if (context.mounted) {
} isLoading.value = false;
} }
}, }
onLongPress: onLongPress, },
style: (isBlackListed onMorePressed: () {
? ButtonVariance.destructive TrackOptionsButton.showOptions(
: ButtonVariance.ghost) context,
.copyWith( Offset.zero,
padding: (context, states, value) => track,
const EdgeInsets.symmetric(vertical: 8, horizontal: 0), userPlaylist: userPlaylist,
), playlistId: playlistId,
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),
],
),
),
), ),
); );
}); });
} }
} }
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:desktop_webview_window/desktop_webview_window.dart';
import 'package:flutter/foundation.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/services.dart';
import 'package:flutter_discord_rpc/flutter_discord_rpc.dart'; import 'package:flutter_discord_rpc/flutter_discord_rpc.dart';
import 'package:flutter_hooks/flutter_hooks.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/hooks/configurators/use_has_touch.dart';
import 'package:spotube/models/database/database.dart'; import 'package:spotube/models/database/database.dart';
import 'package:spotube/modules/settings/color_scheme_picker_dialog.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/audio_player/audio_player_streams.dart';
import 'package:spotube/provider/database/database.dart'; import 'package:spotube/provider/database/database.dart';
import 'package:spotube/provider/glance/glance.dart'; import 'package:spotube/provider/glance/glance.dart';
@ -114,7 +115,7 @@ Future<void> main(List<String> rawArgs) async {
final database = AppDatabase(); final database = AppDatabase();
if (kIsDesktop) { if (kIsDesktop) {
await localNotifier.setup(appName: "Spotube"); await localNotifier.setup(appName: "Spotify");
await WindowManagerTools.initialize(); await WindowManagerTools.initialize();
} }
@ -144,8 +145,6 @@ class Spotube extends HookConsumerWidget {
final themeMode = final themeMode =
ref.watch(userPreferencesProvider.select((s) => s.themeMode)); ref.watch(userPreferencesProvider.select((s) => s.themeMode));
final locale = ref.watch(userPreferencesProvider.select((s) => s.locale)); final locale = ref.watch(userPreferencesProvider.select((s) => s.locale));
final accentMaterialColor =
ref.watch(userPreferencesProvider.select((s) => s.accentColorScheme));
final router = useMemoized(() => AppRouter(ref), []); final router = useMemoized(() => AppRouter(ref), []);
final hasTouchSupport = useHasTouch(); final hasTouchSupport = useHasTouch();
@ -191,7 +190,7 @@ class Spotube extends HookConsumerWidget {
], ],
routerConfig: router.config(), routerConfig: router.config(),
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
title: 'Spotube', title: 'Spotify',
builder: (context, child) { builder: (context, child) {
child = ScrollConfiguration( child = ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith( behavior: ScrollConfiguration.of(context).copyWith(
@ -216,38 +215,9 @@ class Spotube extends HookConsumerWidget {
return child; return child;
}, },
scaling: const AdaptiveScaling(1), scaling: const AdaptiveScaling(1),
theme: ThemeData( theme: SpotifyTheme.shadcnTheme(),
radius: .5, darkTheme: SpotifyTheme.shadcnTheme(),
iconTheme: const IconThemeProperties(), materialTheme: SpotifyTheme.materialTheme(context),
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,
),
),
themeMode: themeMode, themeMode: themeMode,
shortcuts: { shortcuts: {
...WidgetsApp.defaultShortcuts.map((key, value) { ...WidgetsApp.defaultShortcuts.map((key, value) {

View File

@ -1,11 +1,11 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/routes.gr.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart'; import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/playbutton_view/playbutton_card.dart'; import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/playbutton_view/playbutton_tile.dart'; import 'package:spotube/components/track_tile/track_options_button.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/models/metadata/metadata.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/audio_player/audio_player.dart';
import 'package:spotube/provider/metadata_plugin/tracks/album.dart'; import 'package:spotube/provider/metadata_plugin/tracks/album.dart';
import 'package:spotube/services/audio_player/audio_player.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 { extension FormattedAlbumType on SpotubeAlbumType {
String get formatted => name.replaceFirst(name[0], name[0].toUpperCase()); String get formatted => name.replaceFirst(name[0], name[0].toUpperCase());
@ -161,28 +164,31 @@ class AlbumCard extends HookConsumerWidget {
context context
]); ]);
if (_isTile) { return SpotifyCard(
return PlaybuttonTile( image: Image.network(
imageUrl: imageUrl, imageUrl,
isPlaying: isPlaylistPlaying, width: 150,
isLoading: isLoading, height: 150,
title: album.name, fit: BoxFit.cover,
description: description, errorBuilder: (_, __, ___) => Container(
onTap: onTap, width: 150,
onPlaybuttonPressed: onPlaybuttonPressed, height: 150,
onAddToQueuePressed: onAddToQueuePressed, color: SpotifyColors.surface,
); child: const Icon(SpotubeIcons.album,
} color: SpotifyColors.secondaryText, size: 40),
),
return PlaybuttonCard( loadingBuilder: (context, chunk, total) => Container(
imageUrl: imageUrl, width: 150,
height: 150,
color: SpotifyColors.surface,
),
),
title: album.name,
subtitle: description,
onTap: onTap,
onPlayPressed: onPlaybuttonPressed,
isPlaying: isPlaylistPlaying, isPlaying: isPlaylistPlaying,
isLoading: isLoading, 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_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:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/routes.gr.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/extensions/context.dart';
import 'package:spotube/models/metadata/metadata.dart'; import 'package:spotube/models/metadata/metadata.dart';
import 'package:spotube/provider/blacklist_provider.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 { class ArtistCard extends HookConsumerWidget {
final SpotubeFullArtistObject artist; final SpotubeFullArtistObject artist;
@ -17,12 +18,7 @@ class ArtistCard extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final theme = Theme.of(context); final textTheme = SpotifyTypography.buildTextTheme(context);
final backgroundImage = UniversalImage.imageProvider(
artist.images.asUrlString(
placeholder: ImagePlaceholder.artist,
),
);
final isBlackListed = ref.watch( final isBlackListed = ref.watch(
blacklistProvider.select( blacklistProvider.select(
(blacklist) => blacklist.asData?.value.any( (blacklist) => blacklist.asData?.value.any(
@ -32,43 +28,77 @@ class ArtistCard extends HookConsumerWidget {
); );
return SizedBox( return SizedBox(
width: 180, width: 150,
child: Button.card( child: HoverBuilder(
onPressed: () { builder: (context, hovering) {
context.navigateTo(ArtistRoute(artistId: artist.id)); final imageUrl = artist.images.asUrlString(
}, placeholder: ImagePlaceholder.artist,
child: Column( );
children: [ return Column(
Avatar( mainAxisSize: MainAxisSize.min,
initials: artist.name.trim()[0].toUpperCase(), crossAxisAlignment: CrossAxisAlignment.start,
provider: backgroundImage, children: [
size: 130, Stack(
), children: [
const Gap(10), ClipRRect(
AutoSizeText( borderRadius: BorderRadius.circular(8),
artist.name, child: Image.network(
maxLines: 2, imageUrl,
textAlign: TextAlign.center, width: 150,
overflow: TextOverflow.ellipsis, height: 150,
style: theme.typography.bold, fit: BoxFit.cover,
), errorBuilder: (_, __, ___) => Container(
const Spacer(), width: 150,
Row( height: 150,
mainAxisAlignment: MainAxisAlignment.end, color: SpotifyColors.surface,
children: [ child: const Icon(SpotubeIcons.artist,
if (isBlackListed == true) ...[ color: SpotifyColors.secondaryText, size: 40),
DestructiveBadge( ),
child: Text(context.l10n.blacklisted.toUpperCase()), 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:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/routes.gr.dart';
import 'package:spotube/components/dialogs/select_device_dialog.dart'; import 'package:spotube/components/dialogs/select_device_dialog.dart';
import 'package:spotube/components/playbutton_view/playbutton_card.dart'; import 'package:spotube/components/image/universal_image.dart';
import 'package:spotube/components/playbutton_view/playbutton_tile.dart';
import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/context.dart';
import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/connect/connect.dart';
import 'package:spotube/models/metadata/metadata.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/tracks/playlist.dart';
import 'package:spotube/provider/metadata_plugin/core/user.dart'; import 'package:spotube/provider/metadata_plugin/core/user.dart';
import 'package:spotube/services/audio_player/audio_player.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 { class PlaylistCard extends HookConsumerWidget {
final SpotubeSimplePlaylistObject playlist; final SpotubeSimplePlaylistObject playlist;
@ -193,32 +196,31 @@ class PlaylistCard extends HookConsumerWidget {
final isOwner = playlist.owner.id == me.asData?.value?.id && final isOwner = playlist.owner.id == me.asData?.value?.id &&
me.asData?.value?.id != null; me.asData?.value?.id != null;
if (_isTile) { return SpotifyCard(
return PlaybuttonTile( image: Image.network(
title: playlist.name, imageUrl,
description: playlist.description, width: 150,
image: null, height: 150,
imageUrl: imageUrl, fit: BoxFit.cover,
isPlaying: isPlaylistPlaying, errorBuilder: (_, __, ___) => Container(
isLoading: isLoading, width: 150,
isOwner: isOwner, height: 150,
onTap: onTap, color: SpotifyColors.surface,
onPlaybuttonPressed: onPlaybuttonPressed, child: const Icon(SpotubeIcons.playlist,
onAddToQueuePressed: onAddToQueuePressed, color: SpotifyColors.secondaryText, size: 40),
); ),
} loadingBuilder: (context, chunk, total) => Container(
width: 150,
return PlaybuttonCard( height: 150,
color: SpotifyColors.surface,
),
),
title: playlist.name, title: playlist.name,
description: playlist.description, subtitle: playlist.description.isNotEmpty ? playlist.description : null,
image: null, onTap: onTap,
imageUrl: imageUrl, onPlayPressed: onPlaybuttonPressed,
isPlaying: isPlaylistPlaying, isPlaying: isPlaylistPlaying,
isLoading: isLoading, 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:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:spotube/collections/routes.gr.dart'; import 'package:spotube/collections/routes.gr.dart';
import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/titlebar/titlebar.dart';
import 'package:spotube/models/database/database.dart';
import 'package:spotube/modules/connect/connect_device.dart'; import 'package:spotube/modules/connect/connect_device.dart';
import 'package:spotube/modules/home/sections/featured.dart'; import 'package:spotube/modules/home/sections/featured.dart';
import 'package:spotube/modules/home/sections/sections.dart'; import 'package:spotube/modules/home/sections/sections.dart';
import 'package:spotube/modules/home/sections/new_releases.dart'; import 'package:spotube/modules/home/sections/new_releases.dart';
import 'package:spotube/modules/home/sections/recent.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/extensions/constrains.dart';
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
import 'package:spotube/theme/spotify_colors.dart';
import 'package:spotube/utils/platform.dart'; import 'package:spotube/utils/platform.dart';
@RoutePage() @RoutePage()
@ -23,65 +22,69 @@ class HomePage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final theme = Theme.of(context);
final controller = useScrollController(); final controller = useScrollController();
final mediaQuery = MediaQuery.of(context); final mediaQuery = MediaQuery.of(context);
final layoutMode = final layoutMode =
ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
return SafeArea( return SafeArea(
bottom: false, bottom: false,
child: Scaffold( child: Scaffold(
headers: [ headers: [
if (kTitlebarVisible) const TitleBar(height: 30), if (kTitlebarVisible) const TitleBar(height: 30),
], ],
child: CustomScrollView( backgroundColor: SpotifyColors.background,
controller: controller, body: CustomScrollView(
slivers: [ controller: controller,
if (mediaQuery.smAndDown || layoutMode == LayoutMode.compact) slivers: [
SliverAppBar( if (mediaQuery.smAndDown || layoutMode == LayoutMode.compact)
floating: true, SliverAppBar(
title: DefaultTextStyle( floating: true,
style: TextStyle( title: Text(
fontFamily: "Cookie", "Spotify",
fontSize: 30, style: const TextStyle(
letterSpacing: 1.8, fontFamily: "Poppins",
color: theme.colorScheme.foreground, fontSize: 20,
), fontWeight: FontWeight.w700,
child: const Text("Spotube"), letterSpacing: -0.04,
color: SpotifyColors.primaryText,
), ),
backgroundColor: theme.colorScheme.background, ),
foregroundColor: theme.colorScheme.foreground, backgroundColor: SpotifyColors.background,
actions: [ foregroundColor: SpotifyColors.primaryText,
const ConnectDeviceButton(), elevation: 0,
const Gap(10), scrolledUnderElevation: 0,
IconButton.ghost( shadowColor: Colors.transparent,
icon: const Icon(SpotubeIcons.settings, size: 20), actions: [
onPressed: () { const ConnectDeviceButton(),
context.navigateTo(const SettingsRoute()); const Gap(10),
}, IconButton(
), icon: const Icon(Icons.settings_rounded,
const Gap(10), size: 20, color: SpotifyColors.secondaryText),
], onPressed: () {
) context.navigateTo(const SettingsRoute());
else if (kIsMacOS) },
const SliverGap(10), ),
const Gap(10),
],
)
else if (kIsMacOS)
const SliverGap(10), const SliverGap(10),
SliverList.builder( const SliverGap(10),
itemCount: 3, SliverList.builder(
itemBuilder: (context, index) { itemCount: 3,
return switch (index) { itemBuilder: (context, index) {
// 0 => const HomeGenresSection(), return switch (index) {
0 => const HomeRecentlyPlayedSection(), 0 => const HomeRecentlyPlayedSection(),
1 => const HomeFeaturedSection(), 1 => const HomeFeaturedSection(),
// 3 => const HomePageFriendsSection(), _ => const HomeNewReleasesSection()
_ => const HomeNewReleasesSection() };
}; },
}, ),
), const SliverSafeArea(sliver: HomePageBrowseSection()),
const SliverSafeArea(sliver: HomePageBrowseSection()), ],
], ),
), ),
)); );
} }
} }

View File

@ -1,15 +1,14 @@
import 'package:auto_route/auto_route.dart'; 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:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/routes.gr.dart';
import 'package:spotube/collections/side_bar_tiles.dart'; import 'package:spotube/collections/side_bar_tiles.dart';
import 'package:spotube/collections/spotube_icons.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/extensions/context.dart';
import 'package:spotube/provider/download_manager_provider.dart'; import 'package:spotube/provider/download_manager_provider.dart';
import 'package:spotube/theme/spotify_colors.dart';
import 'package:spotube/theme/spotify_typography.dart';
@RoutePage() @RoutePage()
class LibraryPage extends HookConsumerWidget { class LibraryPage extends HookConsumerWidget {
@ -50,12 +49,13 @@ class LibraryPage extends HookConsumerWidget {
bottom: false, bottom: false,
child: LayoutBuilder(builder: (context, constraints) { child: LayoutBuilder(builder: (context, constraints) {
return Scaffold( return Scaffold(
headers: [ backgroundColor: SpotifyColors.background,
if (constraints.smAndDown) body: Column(
TitleBar( crossAxisAlignment: CrossAxisAlignment.start,
automaticallyImplyLeading: false, children: [
child: SingleChildScrollView( if (constraints.smAndDown)
scrollDirection: Axis.horizontal, Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: TabList( child: TabList(
index: index, index: index,
onChanged: (index) { onChanged: (index) {
@ -63,31 +63,67 @@ class LibraryPage extends HookConsumerWidget {
}, },
children: [ children: [
for (final tile in sidebarLibraryTileList) for (final tile in sidebarLibraryTileList)
TabItem( _SpotifyTabItem(
child: Badge( selected: index == sidebarLibraryTileList.indexOf(tile),
isLabelVisible: tile.id == 'downloads' && label: tile.title,
downloadingCount > 0, badge: tile.id == 'downloads' && downloadingCount > 0
label: Text(downloadingCount.toString()), ? downloadingCount.toString()
child: Text(tile.title), : null,
), onPressed: () =>
context.navigateTo(tile.route),
), ),
], ],
), ),
), )
) else
else const SizedBox(height: 10),
const TitleBar( const SizedBox(height: 10),
automaticallyImplyLeading: false, Expanded(child: const AutoRouter()),
backgroundColor: Colors.transparent, ],
surfaceBlur: 0, ),
height: 32,
),
const Gap(10),
],
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/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/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/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/modules/root/use_global_subscriptions.dart';
import 'package:spotube/provider/glance/glance.dart'; import 'package:spotube/provider/glance/glance.dart';
@ -30,7 +27,7 @@ class RootAppPage extends HookConsumerWidget {
useEffect(() { useEffect(() {
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(
SystemUiOverlayStyle( SystemUiOverlayStyle(
statusBarColor: backgroundColor, // status bar color statusBarColor: backgroundColor,
statusBarIconBrightness: brightness == Brightness.dark statusBarIconBrightness: brightness == Brightness.dark
? Brightness.light ? Brightness.light
: Brightness.dark, : Brightness.dark,
@ -39,30 +36,6 @@ class RootAppPage extends HookConsumerWidget {
return null; return null;
}, [backgroundColor, brightness]); }, [backgroundColor, brightness]);
final scaffold = MediaQuery.removeViewInsets( return const SpotifyShell();
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;
} }
} }

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:shadcn_flutter/shadcn_flutter.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:fuzzywuzzy/fuzzywuzzy.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/collections/routes.gr.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/error_box.dart';
import 'package:spotube/components/fallbacks/no_default_metadata_plugin.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/context.dart';
import 'package:spotube/extensions/string.dart'; import 'package:spotube/extensions/string.dart';
import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.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:spotube/services/kv_store/kv_store.dart';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:spotube/services/metadata/errors/exceptions.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) { final searchTermStateProvider = StateProvider<String>((ref) {
return ""; return "";
@ -37,6 +37,7 @@ class SearchPage extends HookConsumerWidget {
Widget build(BuildContext context, ref) { Widget build(BuildContext context, ref) {
final controller = useShadcnTextEditingController(); final controller = useShadcnTextEditingController();
final focusNode = useFocusNode(); final focusNode = useFocusNode();
final textTheme = SpotifyTypography.buildTextTheme(context);
final searchTerm = ref.watch(searchTermStateProvider); final searchTerm = ref.watch(searchTermStateProvider);
final searchChipSnapshot = ref.watch(metadataPluginSearchChipsProvider); final searchChipSnapshot = ref.watch(metadataPluginSearchChipsProvider);
@ -79,11 +80,8 @@ class SearchPage extends HookConsumerWidget {
child: SafeArea( child: SafeArea(
bottom: false, bottom: false,
child: Scaffold( child: Scaffold(
headers: [ backgroundColor: SpotifyColors.background,
if (kTitlebarVisible) body: Builder(builder: (context) {
const TitleBar(automaticallyImplyLeading: false, height: 30)
],
child: Builder(builder: (context) {
if (searchChipSnapshot.error if (searchChipSnapshot.error
case MetadataPluginException( case MetadataPluginException(
errorCode: MetadataPluginErrorCode.noDefaultMetadataPlugin, errorCode: MetadataPluginErrorCode.noDefaultMetadataPlugin,
@ -103,114 +101,60 @@ class SearchPage extends HookConsumerWidget {
return Column( return Column(
children: [ children: [
Row( Container(
crossAxisAlignment: CrossAxisAlignment.center, padding: const EdgeInsets.symmetric(
children: [ horizontal: 20,
Expanded( vertical: 10,
child: Padding( ),
padding: const EdgeInsets.symmetric( color: SpotifyColors.background,
horizontal: 20, child: ListenableBuilder(
vertical: 10, listenable: controller,
), builder: (context, _) {
child: ListenableBuilder( final suggestions = controller.text.isEmpty
listenable: controller, ? KVStoreService.recentSearches
builder: (context, _) { : KVStoreService.recentSearches
final suggestions = controller.text.isEmpty .where(
? KVStoreService.recentSearches (s) =>
: KVStoreService.recentSearches weightedRatio(
.where( s.toLowerCase(),
(s) => controller.text.toLowerCase(),
weightedRatio( ) >
s.toLowerCase(), 50,
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),
);
},
) )
: ButtonVariance.secondary.copyWith( .toList();
decoration: (context, states, value) {
return ButtonVariance.secondary return SearchTextField(
.decoration(context, states) suggestions: suggestions.length <= 2
.copyWithIfBoxDecoration( ? [
borderRadius: ...suggestions,
BorderRadius.circular(100), "Twenty One Pilots",
); "Linkin Park",
}, ]
), : suggestions,
child: Text(chip.capitalize()), controller: controller,
onPressed: () { focusNode: focusNode,
selectedChip.value = chip; 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( Expanded(
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300), 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:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.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/modules/search/loading.dart';
import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/search/search.dart';
import 'package:spotube/provider/metadata_plugin/search/albums.dart'; import 'package:spotube/provider/metadata_plugin/search/albums.dart';
import 'package:spotube/theme/spotify_colors.dart';
class SearchPageAlbumsTab extends HookConsumerWidget { class SearchPageAlbumsTab extends HookConsumerWidget {
const SearchPageAlbumsTab({super.key}); const SearchPageAlbumsTab({super.key});
@ -38,6 +40,7 @@ class SearchPageAlbumsTab extends HookConsumerWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: CustomScrollView( child: CustomScrollView(
controller: controller,
slivers: [ slivers: [
PlaybuttonView( PlaybuttonView(
controller: controller, controller: controller,

View File

@ -1,5 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_undraw/flutter_undraw.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart';
import 'package:shadcn_flutter/shadcn_flutter_extension.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/modules/search/loading.dart';
import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/search/search.dart';
import 'package:spotube/provider/metadata_plugin/search/artists.dart'; import 'package:spotube/provider/metadata_plugin/search/artists.dart';
import 'package:spotube/theme/spotify_colors.dart';
class SearchPageArtistsTab extends HookConsumerWidget { class SearchPageArtistsTab extends HookConsumerWidget {
const SearchPageArtistsTab({super.key}); const SearchPageArtistsTab({super.key});
@ -49,15 +50,15 @@ class SearchPageArtistsTab extends HookConsumerWidget {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
spacing: 10, spacing: 10,
children: [ children: [
Undraw( const SizedBox(height: 40),
height: 200 * context.theme.scaling,
illustration: UndrawIllustration.taken,
color: Theme.of(context).colorScheme.primary,
),
Text( Text(
context.l10n.nothing_found, context.l10n.nothing_found,
textAlign: TextAlign.center, 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), padding: const EdgeInsets.all(16),
itemCount: searchArtists.length + 1, itemCount: searchArtists.length + 1,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200, maxCrossAxisExtent: 160,
mainAxisExtent: constrains.smAndDown ? 225 : 250, mainAxisExtent: constrains.smAndDown ? 200 : 220,
crossAxisSpacing: 8, crossAxisSpacing: 12,
mainAxisSpacing: 8, mainAxisSpacing: 12,
), ),
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (searchArtists.isNotEmpty && index == searchArtists.length) { if (searchArtists.isNotEmpty && index == searchArtists.length) {
@ -82,10 +83,7 @@ class SearchPageArtistsTab extends HookConsumerWidget {
controller: controller, controller: controller,
isGrid: true, isGrid: true,
onTouchEdge: searchArtistsNotifier.fetchMore, onTouchEdge: searchArtistsNotifier.fetchMore,
child: Skeletonizer( child: const SpotifyShimmerTrack(),
enabled: true,
child: ArtistCard(FakeData.artist),
),
); );
} }

View File

@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:shadcn_flutter/shadcn_flutter.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/modules/search/loading.dart';
import 'package:spotube/pages/search/search.dart'; import 'package:spotube/pages/search/search.dart';
import 'package:spotube/provider/metadata_plugin/search/playlists.dart'; import 'package:spotube/provider/metadata_plugin/search/playlists.dart';
import 'package:spotube/theme/spotify_colors.dart';
class SearchPagePlaylistsTab extends HookConsumerWidget { class SearchPagePlaylistsTab extends HookConsumerWidget {
const SearchPagePlaylistsTab({super.key}); const SearchPagePlaylistsTab({super.key});
@ -38,6 +40,7 @@ class SearchPagePlaylistsTab extends HookConsumerWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: CustomScrollView( child: CustomScrollView(
controller: controller,
slivers: [ slivers: [
PlaybuttonView( PlaybuttonView(
controller: controller, 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] [Desktop Entry]
Type=Application Type=Application
Name=Spotube Name=Spotify
Exec=/usr/bin/spotube Exec=/usr/bin/spotube
Icon=/usr/share/icons/spotube/spotube-logo.png Icon=/usr/share/icons/spotube/spotube-logo.png
Comment=A music streaming app combining the power of music metadata providers & YouTube Comment=A music streaming app combining the power of music metadata providers & YouTube

View File

@ -5,7 +5,7 @@
// 'flutter create' template. // 'flutter create' template.
// The application's name. By default this is also the title of the Flutter window. // 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 // The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube

View File

@ -139,6 +139,7 @@ dependencies:
url: https://github.com/KRTirtho/hetu_spotube_plugin.git url: https://github.com/KRTirtho/hetu_spotube_plugin.git
ref: main ref: main
get_it: ^8.0.3 get_it: ^8.0.3
google_fonts: ^6.2.1
flutter_markdown_plus: ^1.0.3 flutter_markdown_plus: ^1.0.3
pub_semver: ^2.2.0 pub_semver: ^2.2.0
change_case: ^1.1.0 change_case: ^1.1.0
@ -217,6 +218,7 @@ flutter:
assets: assets:
- assets/images/ - assets/images/
- assets/images/logos/ - assets/images/logos/
- assets/images/spotify_logo.png
- assets/branding/spotube-logo.png - assets/branding/spotube-logo.png
- assets/branding/spotube-logo-light.png - assets/branding/spotube-logo-light.png
- assets/branding/spotube-logo.ico - assets/branding/spotube-logo.ico
@ -273,10 +275,10 @@ flutter_gen:
flutter_native_splash: flutter_native_splash:
background_image: assets/bengali-patterns-bg.jpg background_image: assets/bengali-patterns-bg.jpg
image: assets/spotube-logo.png image: assets/images/spotify_logo.png
branding: assets/branding.png branding: assets/branding/branding.png
android_12: android_12:
image: assets/spotube-logo_android12.png image: assets/images/spotify_logo.png
branding: assets/branding.png branding: assets/branding/branding.png
color: "#000000" color: "#121212"
icon_background_color: "#000000" icon_background_color: "#121212"

View File

@ -90,12 +90,12 @@ BEGIN
BLOCK "040904e4" BLOCK "040904e4"
BEGIN BEGIN
VALUE "CompanyName", "oss.krtirtho" "\0" VALUE "CompanyName", "oss.krtirtho" "\0"
VALUE "FileDescription", "Spotube" "\0" VALUE "FileDescription", "Spotify" "\0"
VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "FileVersion", VERSION_AS_STRING "\0"
VALUE "InternalName", "spotube" "\0" VALUE "InternalName", "spotube" "\0"
VALUE "LegalCopyright", "Copyright (C) 2024 oss.krtirtho. All rights reserved." "\0" VALUE "LegalCopyright", "Copyright (C) 2024 oss.krtirtho. All rights reserved." "\0"
VALUE "OriginalFilename", "spotube.exe" "\0" VALUE "OriginalFilename", "spotube.exe" "\0"
VALUE "ProductName", "spotube" "\0" VALUE "ProductName", "Spotify" "\0"
VALUE "ProductVersion", VERSION_AS_STRING "\0" VALUE "ProductVersion", VERSION_AS_STRING "\0"
END END
END END