mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
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:
parent
69a310c78f
commit
8a193658af
3
android/app/src/main/res/values/strings.xml
Normal file
3
android/app/src/main/res/values/strings.xml
Normal file
@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name_en">Spotify</string>
|
||||
</resources>
|
||||
@ -1,12 +1,12 @@
|
||||
# flutter pub run flutter_launcher_icons
|
||||
flutter_launcher_icons:
|
||||
image_path: "assets/branding/spotube-logo.png"
|
||||
image_path: "assets/images/spotify_logo.png"
|
||||
|
||||
android: true
|
||||
# image_path_android: "assets/branding/icon/icon.png"
|
||||
min_sdk_android: 21 # android min sdk min:16, default 21
|
||||
adaptive_icon_background: "#242832"
|
||||
adaptive_icon_foreground: "assets/branding/spotube-logo-foreground.png"
|
||||
adaptive_icon_background: "#000000"
|
||||
adaptive_icon_foreground: "assets/images/spotify_logo.png"
|
||||
# adaptive_icon_monochrome: "assets/branding/icon/monochrome.png"
|
||||
|
||||
ios: true
|
||||
@ -21,9 +21,9 @@ flutter_launcher_icons:
|
||||
|
||||
windows:
|
||||
generate: true
|
||||
image_path: "assets/branding/spotube-logo.png"
|
||||
image_path: "assets/images/spotify_logo.png"
|
||||
icon_size: 48 # min:48, max:256, default: 48
|
||||
|
||||
macos:
|
||||
generate: true
|
||||
image_path: "assets/branding/spotube-logo-macos.png"
|
||||
image_path: "assets/images/spotify_logo.png"
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Spotube</string>
|
||||
<string>Spotify</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@ -15,7 +15,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>spotube</string>
|
||||
<string>Spotify</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import 'package:auto_size_text/auto_size_text.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/heart_button/heart_button.dart';
|
||||
import 'package:spotube/components/hover_builder.dart';
|
||||
import 'package:spotube/components/image/universal_image.dart';
|
||||
import 'package:spotube/components/track_presentation/presentation_props.dart';
|
||||
import 'package:spotube/components/track_presentation/use_action_callbacks.dart';
|
||||
@ -12,6 +12,8 @@ import 'package:spotube/components/track_presentation/use_is_user_playlist.dart'
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/modules/playlist/playlist_create_dialog.dart';
|
||||
import 'package:spotube/theme/spotify_colors.dart';
|
||||
import 'package:spotube/theme/spotify_typography.dart';
|
||||
|
||||
class TrackPresentationTopSection extends HookConsumerWidget {
|
||||
const TrackPresentationTopSection({super.key});
|
||||
@ -28,124 +30,12 @@ class TrackPresentationTopSection extends HookConsumerWidget {
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
|
||||
final imageDimension = mediaQuery.mdAndUp ? 200 : 120;
|
||||
final imageDimension = mediaQuery.mdAndUp ? 300.0 : 200.0;
|
||||
|
||||
final (:isLoading, :isActive, :onPlay, :onShuffle, :onAddToQueue) =
|
||||
useActionCallbacks(ref);
|
||||
|
||||
final playbackActions = Row(
|
||||
spacing: 8 * scale,
|
||||
children: [
|
||||
Tooltip(
|
||||
tooltip: TooltipContainer(
|
||||
child: Text(context.l10n.shuffle_playlist),
|
||||
).call,
|
||||
child: IconButton.secondary(
|
||||
icon: isLoading
|
||||
? const Center(
|
||||
child:
|
||||
CircularProgressIndicator(onSurface: false, size: 20),
|
||||
)
|
||||
: const Icon(SpotubeIcons.shuffle),
|
||||
enabled: !isLoading && !isActive,
|
||||
onPressed: onShuffle,
|
||||
),
|
||||
),
|
||||
if (mediaQuery.width <= 320)
|
||||
Tooltip(
|
||||
tooltip: TooltipContainer(
|
||||
child: Text(context.l10n.add_to_queue),
|
||||
).call,
|
||||
child: IconButton.secondary(
|
||||
icon: const Icon(SpotubeIcons.queueAdd),
|
||||
enabled: !isLoading && !isActive,
|
||||
onPressed: onAddToQueue,
|
||||
),
|
||||
)
|
||||
else
|
||||
Button.secondary(
|
||||
leading: const Icon(SpotubeIcons.add),
|
||||
enabled: !isLoading && !isActive,
|
||||
onPressed: onAddToQueue,
|
||||
child: Text(context.l10n.queue),
|
||||
),
|
||||
Button.primary(
|
||||
alignment: Alignment.center,
|
||||
leading: switch ((isActive, isLoading)) {
|
||||
(true, false) => const Icon(SpotubeIcons.pause),
|
||||
(false, true) => const Center(
|
||||
child: CircularProgressIndicator(onSurface: true, size: 18),
|
||||
),
|
||||
_ => const Icon(SpotubeIcons.play),
|
||||
},
|
||||
onPressed: onPlay,
|
||||
enabled: !isLoading && !isActive,
|
||||
child: isActive ? Text(context.l10n.pause) : Text(context.l10n.play),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final additionalActions = Row(
|
||||
spacing: 8 * scale,
|
||||
children: [
|
||||
if (isUserPlaylist)
|
||||
IconButton.outline(
|
||||
size: ButtonSize.small,
|
||||
icon: const Icon(SpotubeIcons.edit),
|
||||
onPressed: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return PlaylistCreateDialog(
|
||||
playlistId: options.collectionId,
|
||||
trackIds: options.tracks.map((e) => e.id).toList(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
if (options.shareUrl != null)
|
||||
Tooltip(
|
||||
tooltip: TooltipContainer(
|
||||
child: Text(context.l10n.share),
|
||||
).call,
|
||||
child: IconButton.outline(
|
||||
icon: const Icon(SpotubeIcons.share),
|
||||
size: ButtonSize.small,
|
||||
onPressed: () async {
|
||||
await Clipboard.setData(
|
||||
ClipboardData(text: options.shareUrl!),
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
|
||||
showToast(
|
||||
context: context,
|
||||
location: ToastLocation.topRight,
|
||||
builder: (context, overlay) {
|
||||
return SurfaceCard(
|
||||
child: Text(
|
||||
context.l10n
|
||||
.copied_shareurl_to_clipboard(options.shareUrl!),
|
||||
).small(),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (options.onHeart != null)
|
||||
HeartButton(
|
||||
isLiked: options.isLiked,
|
||||
tooltip: options.isLiked
|
||||
? context.l10n.remove_from_favorites
|
||||
: context.l10n.save_as_favorite,
|
||||
variance: ButtonVariance.outline,
|
||||
size: ButtonSize.small,
|
||||
onPressed: options.onHeart,
|
||||
),
|
||||
],
|
||||
);
|
||||
final textTheme = SpotifyTypography.buildTextTheme(context);
|
||||
|
||||
return SliverMainAxisGroup(
|
||||
slivers: [
|
||||
@ -159,32 +49,32 @@ class TrackPresentationTopSection extends HookConsumerWidget {
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
image: decorationImage,
|
||||
borderRadius: BorderRadius.circular(45),
|
||||
borderRadius: BorderRadius.circular(0),
|
||||
),
|
||||
child: OutlinedContainer(
|
||||
surfaceOpacity: context.theme.surfaceOpacity,
|
||||
surfaceBlur: context.theme.surfaceBlur,
|
||||
padding: EdgeInsets.all(24 * scale),
|
||||
borderRadius: BorderRadius.circular(22 * scale),
|
||||
borderWidth: 2,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 16 * scale,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 16 * scale,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
height: imageDimension * scale,
|
||||
width: imageDimension * scale,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: context.theme.borderRadiusXl,
|
||||
image: decorationImage,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
height: imageDimension * scale,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
image: decorationImage,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
SpotifyColors.background.withValues(alpha: 0.85),
|
||||
],
|
||||
),
|
||||
Flexible(
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24 * scale),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@ -192,8 +82,11 @@ class TrackPresentationTopSection extends HookConsumerWidget {
|
||||
AutoSizeText(
|
||||
options.title,
|
||||
maxLines: 2,
|
||||
minFontSize: 16,
|
||||
style: context.theme.typography.h3,
|
||||
minFontSize: 20,
|
||||
style: textTheme.headlineMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SpotifyColors.primaryText,
|
||||
),
|
||||
),
|
||||
if (options.description != null)
|
||||
AutoSizeText(
|
||||
@ -203,53 +96,92 @@ class TrackPresentationTopSection extends HookConsumerWidget {
|
||||
maxFontSize: 18,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: context
|
||||
.theme.colorScheme.mutedForeground,
|
||||
fontSize: 18,
|
||||
color: SpotifyColors.secondaryText,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const Gap(16),
|
||||
Flex(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
direction: mediaQuery.smAndUp
|
||||
? Axis.horizontal
|
||||
: Axis.vertical,
|
||||
spacing: 8 * scale,
|
||||
children: [
|
||||
if (options.owner != null)
|
||||
OutlineBadge(
|
||||
leading: options.ownerImage != null
|
||||
? Avatar(
|
||||
initials:
|
||||
options.owner?[0] ?? "U",
|
||||
provider: UniversalImage
|
||||
.imageProvider(
|
||||
options.ownerImage!,
|
||||
),
|
||||
size: 20 * scale,
|
||||
)
|
||||
: null,
|
||||
child: Text(
|
||||
options.owner!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
).small(),
|
||||
const SizedBox(height: 24),
|
||||
if (options.owner != null)
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (options.ownerImage != null)
|
||||
CircleAvatar(
|
||||
radius: 16 * scale,
|
||||
backgroundImage: UniversalImage
|
||||
.imageProvider(
|
||||
options.ownerImage!,
|
||||
),
|
||||
)
|
||||
else
|
||||
const CircleAvatar(
|
||||
radius: 16,
|
||||
child: Icon(Icons.person_rounded,
|
||||
size: 16),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
options.owner!,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: SpotifyColors.primaryText,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
additionalActions,
|
||||
const SizedBox(width: 16),
|
||||
_SpotifyActionButton(
|
||||
icon: SpotubeIcons.moreHorizontal,
|
||||
label: "",
|
||||
onPressed: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
_SpotifyActionButton(
|
||||
icon: SpotubeIcons.shuffle,
|
||||
label: context.l10n.shuffle_playlist,
|
||||
onPressed: onShuffle,
|
||||
enabled: !isLoading && !isActive,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_SpotifyActionButton(
|
||||
icon: SpotubeIcons.add,
|
||||
label: context.l10n.queue,
|
||||
onPressed: onAddToQueue,
|
||||
enabled: !isLoading && !isActive,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_SpotifyPlayButton(
|
||||
isActive: isActive,
|
||||
isLoading: isLoading,
|
||||
onPressed: onPlay,
|
||||
enabled: !isLoading && !isActive,
|
||||
label: isActive
|
||||
? context.l10n.pause
|
||||
: context.l10n.play,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (mediaQuery.mdAndUp) ...[
|
||||
const Gap(16),
|
||||
playbackActions
|
||||
if (options.onHeart != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
HeartButton(
|
||||
isLiked: options.isLiked,
|
||||
tooltip: options.isLiked
|
||||
? context.l10n.remove_from_favorites
|
||||
: context.l10n.save_as_favorite,
|
||||
onPressed: options.onHeart,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (mediaQuery.smAndDown) playbackActions,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -259,3 +191,142 @@ class TrackPresentationTopSection extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SpotifyActionButton extends HookConsumerWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
final bool enabled;
|
||||
|
||||
const _SpotifyActionButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final textTheme = SpotifyTypography.buildTextTheme(context);
|
||||
return HoverBuilder(
|
||||
builder: (context, hovering) {
|
||||
return InkWell(
|
||||
onTap: enabled ? onPressed : null,
|
||||
borderRadius: BorderRadius.circular(500),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: (hovering || !enabled)
|
||||
? SpotifyColors.hover
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(500),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
color: enabled
|
||||
? (hovering
|
||||
? SpotifyColors.primaryText
|
||||
: SpotifyColors.secondaryText)
|
||||
: SpotifyColors.disabledText,
|
||||
size: 16,
|
||||
),
|
||||
if (label.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: textTheme.labelMedium?.copyWith(
|
||||
color: enabled
|
||||
? SpotifyColors.primaryText
|
||||
: SpotifyColors.disabledText,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SpotifyPlayButton extends HookConsumerWidget {
|
||||
final bool isActive;
|
||||
final bool isLoading;
|
||||
final VoidCallback onPressed;
|
||||
final bool enabled;
|
||||
final String label;
|
||||
|
||||
const _SpotifyPlayButton({
|
||||
required this.isActive,
|
||||
required this.isLoading,
|
||||
required this.onPressed,
|
||||
this.enabled = true,
|
||||
required this.label,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
return HoverBuilder(
|
||||
builder: (context, hovering) {
|
||||
final scale = hovering && enabled ? 1.05 : 1.0;
|
||||
return AnimatedScale(
|
||||
scale: scale,
|
||||
duration: const Duration(milliseconds: 150),
|
||||
child: InkWell(
|
||||
onTap: enabled ? onPressed : null,
|
||||
borderRadius: BorderRadius.circular(500),
|
||||
child: Container(
|
||||
width: 80,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: enabled
|
||||
? SpotifyColors.spotifyGreen
|
||||
: SpotifyColors.disabledText.withValues(alpha: 0.3),
|
||||
borderRadius: BorderRadius.circular(500),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (isLoading)
|
||||
const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation(Colors.black),
|
||||
),
|
||||
)
|
||||
else
|
||||
Icon(
|
||||
isActive
|
||||
? SpotubeIcons.pause
|
||||
: SpotubeIcons.play,
|
||||
color: Colors.black,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Colors.black,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
|
||||
import 'package:spotube/collections/routes.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/ui/button_tile.dart';
|
||||
import 'package:spotube/components/hover_builder.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/models/metadata/metadata.dart';
|
||||
import 'package:spotube/provider/track_options/track_options_provider.dart';
|
||||
import 'package:spotube/theme/spotify_colors.dart';
|
||||
import 'package:spotube/theme/spotify_typography.dart';
|
||||
|
||||
/// [track] must be a [SpotubeFullTrackObject] or [SpotubeLocalTrackObject]
|
||||
class TrackOptions extends HookConsumerWidget {
|
||||
@ -33,6 +35,7 @@ class TrackOptions extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final textTheme = SpotifyTypography.buildTextTheme(context);
|
||||
|
||||
final trackOptionActions = ref.watch(trackOptionActionsProvider(track));
|
||||
final (
|
||||
@ -46,238 +49,314 @@ class TrackOptions extends HookConsumerWidget {
|
||||
) = ref.watch(trackOptionsStateProvider(track));
|
||||
final isLocalTrack = track is SpotubeLocalTrackObject;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (isLocalTrack)
|
||||
ButtonTile(
|
||||
style: ButtonVariance.menu,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.delete,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
leading: const Icon(SpotubeIcons.trash),
|
||||
title: Text(context.l10n.delete),
|
||||
),
|
||||
if (mediaQuery.smAndDown && !isLocalTrack)
|
||||
ButtonTile(
|
||||
style: ButtonVariance.menu,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.album,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
leading: const Icon(SpotubeIcons.album),
|
||||
title: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.go_to_album),
|
||||
Text(
|
||||
track.album.name,
|
||||
style: context.theme.typography.xSmall,
|
||||
),
|
||||
],
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 220),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 4,
|
||||
children: [
|
||||
if (isLocalTrack)
|
||||
_SpotifyMenuItem(
|
||||
icon: SpotubeIcons.trash,
|
||||
label: context.l10n.delete,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.delete,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
),
|
||||
),
|
||||
if (!isInQueue) ...[
|
||||
ButtonTile(
|
||||
style: ButtonVariance.menu,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.addToQueue,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
leading: const Icon(SpotubeIcons.queueAdd),
|
||||
title: Text(context.l10n.add_to_queue),
|
||||
),
|
||||
ButtonTile(
|
||||
style: ButtonVariance.menu,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.playNext,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
leading: const Icon(SpotubeIcons.lightning),
|
||||
title: Text(context.l10n.play_next),
|
||||
),
|
||||
] else
|
||||
ButtonTile(
|
||||
style: ButtonVariance.menu,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.removeFromQueue,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
enabled: !isActiveTrack,
|
||||
leading: const Icon(SpotubeIcons.queueRemove),
|
||||
title: Text(context.l10n.remove_from_queue),
|
||||
),
|
||||
if (isAuthenticated && !isLocalTrack)
|
||||
ButtonTile(
|
||||
style: ButtonVariance.menu,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.favorite,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
leading: isLiked
|
||||
? const Icon(
|
||||
SpotubeIcons.heartFilled,
|
||||
color: Colors.pink,
|
||||
)
|
||||
: const Icon(SpotubeIcons.heart),
|
||||
title: Text(
|
||||
isLiked
|
||||
if (mediaQuery.smAndDown && !isLocalTrack)
|
||||
_SpotifyMenuItem(
|
||||
icon: SpotubeIcons.album,
|
||||
leadingSubtitle: track.album.name,
|
||||
label: context.l10n.go_to_album,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.album,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
),
|
||||
if (!isInQueue) ...[
|
||||
_SpotifyMenuItem(
|
||||
icon: SpotubeIcons.queueAdd,
|
||||
label: context.l10n.add_to_queue,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.addToQueue,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
),
|
||||
_SpotifyMenuItem(
|
||||
icon: SpotubeIcons.lightning,
|
||||
label: context.l10n.play_next,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.playNext,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
),
|
||||
] else
|
||||
_SpotifyMenuItem(
|
||||
icon: SpotubeIcons.queueRemove,
|
||||
label: context.l10n.remove_from_queue,
|
||||
enabled: !isActiveTrack,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.removeFromQueue,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
),
|
||||
if (isAuthenticated && !isLocalTrack)
|
||||
_SpotifyMenuItem(
|
||||
icon: isLiked ? SpotubeIcons.heartFilled : SpotubeIcons.heart,
|
||||
iconColor: isLiked ? Colors.pink : null,
|
||||
label: isLiked
|
||||
? context.l10n.remove_from_favorites
|
||||
: context.l10n.save_as_favorite,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.favorite,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
),
|
||||
),
|
||||
if (isAuthenticated && !isLocalTrack) ...[
|
||||
ButtonTile(
|
||||
style: ButtonVariance.menu,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.startRadio,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
leading: const Icon(SpotubeIcons.radio),
|
||||
title: Text(context.l10n.start_a_radio),
|
||||
),
|
||||
ButtonTile(
|
||||
style: ButtonVariance.menu,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.addToPlaylist,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
leading: const Icon(SpotubeIcons.playlistAdd),
|
||||
title: Text(context.l10n.add_to_playlist),
|
||||
),
|
||||
],
|
||||
if (userPlaylist && isAuthenticated && !isLocalTrack)
|
||||
ButtonTile(
|
||||
style: ButtonVariance.menu,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.removeFromPlaylist,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
leading: const Icon(SpotubeIcons.removeFilled),
|
||||
title: Text(context.l10n.remove_from_playlist),
|
||||
),
|
||||
if (!isLocalTrack)
|
||||
ButtonTile(
|
||||
style: ButtonVariance.menu,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.download,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
enabled: !isInDownloadQueue,
|
||||
leading: isInDownloadQueue
|
||||
? StreamBuilder(
|
||||
stream: downloadTask?.downloadedBytesStream,
|
||||
builder: (context, snapshot) {
|
||||
final progress = downloadTask?.totalSizeBytes == null ||
|
||||
downloadTask?.totalSizeBytes == 0
|
||||
? 0
|
||||
: (snapshot.data ?? 0) /
|
||||
downloadTask!.totalSizeBytes!;
|
||||
return CircularProgressIndicator(
|
||||
value: progress.toDouble(),
|
||||
);
|
||||
},
|
||||
)
|
||||
: const Icon(SpotubeIcons.download),
|
||||
title: Text(context.l10n.download_track),
|
||||
),
|
||||
if (!isLocalTrack)
|
||||
ButtonTile(
|
||||
style: ButtonVariance.menu,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.blacklist,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
leading: Icon(
|
||||
SpotubeIcons.playlistRemove,
|
||||
color: isBlacklisted != true ? Colors.red[400] : null,
|
||||
if (isAuthenticated && !isLocalTrack) ...[
|
||||
_SpotifyMenuItem(
|
||||
icon: SpotubeIcons.radio,
|
||||
label: context.l10n.start_a_radio,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.startRadio,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
),
|
||||
title: Text(
|
||||
isBlacklisted == true
|
||||
_SpotifyMenuItem(
|
||||
icon: SpotubeIcons.playlistAdd,
|
||||
label: context.l10n.add_to_playlist,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.addToPlaylist,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
if (userPlaylist && isAuthenticated && !isLocalTrack)
|
||||
_SpotifyMenuItem(
|
||||
icon: SpotubeIcons.removeFilled,
|
||||
label: context.l10n.remove_from_playlist,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.removeFromPlaylist,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
),
|
||||
if (!isLocalTrack)
|
||||
_SpotifyMenuItem(
|
||||
icon: SpotubeIcons.download,
|
||||
label: context.l10n.download_track,
|
||||
enabled: !isInDownloadQueue,
|
||||
trailing: isInDownloadQueue
|
||||
? StreamBuilder(
|
||||
stream: downloadTask?.downloadedBytesStream,
|
||||
builder: (context, snapshot) {
|
||||
final progress =
|
||||
downloadTask?.totalSizeBytes == null ||
|
||||
downloadTask?.totalSizeBytes == 0
|
||||
? 0
|
||||
: (snapshot.data ?? 0) /
|
||||
downloadTask!.totalSizeBytes!;
|
||||
return SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
value: progress.toDouble(),
|
||||
),
|
||||
);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.download,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
),
|
||||
if (!isLocalTrack)
|
||||
_SpotifyMenuItem(
|
||||
icon: SpotubeIcons.playlistRemove,
|
||||
iconColor:
|
||||
isBlacklisted != true ? Colors.red[400] : null,
|
||||
label: isBlacklisted == true
|
||||
? context.l10n.remove_from_blacklist
|
||||
: context.l10n.add_to_blacklist,
|
||||
style: TextStyle(
|
||||
color: isBlacklisted != true ? Colors.red[400] : null,
|
||||
),
|
||||
labelColor: isBlacklisted != true ? Colors.red[400] : null,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.blacklist,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
),
|
||||
),
|
||||
if (!isLocalTrack)
|
||||
ButtonTile(
|
||||
style: ButtonVariance.menu,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.share,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
leading: const Icon(SpotubeIcons.share),
|
||||
title: Text(context.l10n.share),
|
||||
),
|
||||
if (!isLocalTrack)
|
||||
ButtonTile(
|
||||
style: ButtonVariance.menu,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.details,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
leading: const Icon(SpotubeIcons.info),
|
||||
title: Text(context.l10n.details),
|
||||
),
|
||||
],
|
||||
if (!isLocalTrack)
|
||||
_SpotifyMenuItem(
|
||||
icon: SpotubeIcons.share,
|
||||
label: context.l10n.share,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.share,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
),
|
||||
if (!isLocalTrack)
|
||||
_SpotifyMenuItem(
|
||||
icon: SpotubeIcons.info,
|
||||
label: context.l10n.details,
|
||||
onPressed: () async {
|
||||
await trackOptionActions.action(
|
||||
rootNavigatorKey.currentContext!,
|
||||
TrackOptionValue.details,
|
||||
playlistId,
|
||||
);
|
||||
onTapItem?.call();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SpotifyMenuItem extends HookConsumerWidget {
|
||||
final IconData icon;
|
||||
final Color? iconColor;
|
||||
final String label;
|
||||
final Color? labelColor;
|
||||
final String? leadingSubtitle;
|
||||
final bool enabled;
|
||||
final Widget? trailing;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _SpotifyMenuItem({
|
||||
required this.icon,
|
||||
this.iconColor,
|
||||
required this.label,
|
||||
this.labelColor,
|
||||
this.leadingSubtitle,
|
||||
this.enabled = true,
|
||||
this.trailing,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final textTheme = SpotifyTypography.buildTextTheme(context);
|
||||
return HoverBuilder(
|
||||
builder: (context, hovering) {
|
||||
return InkWell(
|
||||
onTap: enabled ? onPressed : null,
|
||||
child: Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
color: hovering && enabled ? SpotifyColors.hover : Colors.transparent,
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(
|
||||
icon,
|
||||
color: enabled
|
||||
? (iconColor ?? SpotifyColors.secondaryText)
|
||||
: SpotifyColors.disabledText,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
if (leadingSubtitle != null) ...[
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: enabled
|
||||
? SpotifyColors.primaryText
|
||||
: SpotifyColors.disabledText,
|
||||
fontSize: 13,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Text(
|
||||
leadingSubtitle!,
|
||||
style: textTheme.bodySmall?.copyWith(
|
||||
color: SpotifyColors.disabledText,
|
||||
fontSize: 11,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: textTheme.bodyMedium?.copyWith(
|
||||
color: labelColor ??
|
||||
(enabled
|
||||
? SpotifyColors.primaryText
|
||||
: SpotifyColors.disabledText),
|
||||
fontSize: 13,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,25 +2,24 @@ import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:spotube/collections/routes.gr.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/hover_builder.dart';
|
||||
import 'package:spotube/components/image/universal_image.dart';
|
||||
import 'package:spotube/components/links/artist_link.dart';
|
||||
import 'package:spotube/components/links/link_text.dart';
|
||||
import 'package:spotube/components/track_tile/track_options_button.dart';
|
||||
import 'package:spotube/components/ui/button_tile.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/duration.dart';
|
||||
import 'package:spotube/models/metadata/metadata.dart';
|
||||
import 'package:spotube/provider/audio_player/audio_player.dart';
|
||||
import 'package:spotube/provider/audio_player/querying_track_info.dart';
|
||||
import 'package:spotube/provider/audio_player/state.dart';
|
||||
import 'package:spotube/provider/blacklist_provider.dart';
|
||||
import 'package:spotube/theme/spotify_colors.dart';
|
||||
import 'package:spotube/theme/spotify_components.dart';
|
||||
import 'package:spotube/theme/spotify_typography.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
|
||||
final isBlacklistedProvider =
|
||||
@ -32,8 +31,6 @@ final isBlacklistedProvider =
|
||||
},
|
||||
);
|
||||
|
||||
final _overlay = ValueNotifier<OverlayCompleter<dynamic>?>(null);
|
||||
|
||||
class TrackTile extends HookConsumerWidget {
|
||||
/// [index] will not be shown if null
|
||||
final int? index;
|
||||
@ -67,6 +64,7 @@ class TrackTile extends HookConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final theme = Theme.of(context);
|
||||
final textTheme = SpotifyTypography.buildTextTheme(context);
|
||||
|
||||
final isBlackListed = ref.watch(isBlacklistedProvider(track));
|
||||
|
||||
@ -74,8 +72,6 @@ class TrackTile extends HookConsumerWidget {
|
||||
|
||||
final isPlaying = playlist.activeTrack?.id == track.id;
|
||||
|
||||
final isSelected = isPlaying || isLoading.value;
|
||||
|
||||
final imageProvider = useMemoized(
|
||||
() => UniversalImage.imageProvider(
|
||||
(track.album.images).smallest(ImagePlaceholder.albumArt),
|
||||
@ -93,11 +89,7 @@ class TrackTile extends HookConsumerWidget {
|
||||
return Listener(
|
||||
onPointerDown: (event) {
|
||||
if (event.buttons != kSecondaryMouseButton) return;
|
||||
if (_overlay.value != null) {
|
||||
_overlay.value?.remove();
|
||||
_overlay.value = null;
|
||||
}
|
||||
_overlay.value = TrackOptionsButton.showOptions(
|
||||
TrackOptionsButton.showOptions(
|
||||
context,
|
||||
Offset.zero,
|
||||
track,
|
||||
@ -105,243 +97,42 @@ class TrackTile extends HookConsumerWidget {
|
||||
playlistId: playlistId,
|
||||
);
|
||||
},
|
||||
child: HoverBuilder(
|
||||
permanentState: isSelected || constrains.smAndDown ? true : null,
|
||||
builder: (context, isHovering) => ButtonTile(
|
||||
selected: isSelected,
|
||||
onPressed: () async {
|
||||
if (isBlackListed) return;
|
||||
try {
|
||||
isLoading.value = true;
|
||||
await onTap?.call();
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
child: SpotifyTrackRow(
|
||||
index: index,
|
||||
track: track,
|
||||
isPlaying: isPlaying || isLoading.value,
|
||||
selected: selected,
|
||||
onTap: () async {
|
||||
if (isBlackListed) return;
|
||||
try {
|
||||
isLoading.value = true;
|
||||
await onTap?.call();
|
||||
} finally {
|
||||
if (context.mounted) {
|
||||
isLoading.value = false;
|
||||
}
|
||||
},
|
||||
onLongPress: onLongPress,
|
||||
style: (isBlackListed
|
||||
? ButtonVariance.destructive
|
||||
: ButtonVariance.ghost)
|
||||
.copyWith(
|
||||
padding: (context, states, value) =>
|
||||
const EdgeInsets.symmetric(vertical: 8, horizontal: 0),
|
||||
),
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
...?leadingActions,
|
||||
AnimatedCrossFade(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
crossFadeState: index != null && onChanged == null
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
firstChild: Checkbox(
|
||||
state: selected
|
||||
? CheckboxState.checked
|
||||
: CheckboxState.unchecked,
|
||||
onChanged: (state) =>
|
||||
onChanged?.call(state == CheckboxState.checked),
|
||||
),
|
||||
secondChild: constrains.smAndDown
|
||||
? const SizedBox(width: 16)
|
||||
: SizedBox(
|
||||
width: 50,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
child: Text(
|
||||
'${(index ?? 0) + 1}',
|
||||
maxLines: 1,
|
||||
style: theme.typography.small,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Stack(
|
||||
children: [
|
||||
Container(
|
||||
height: 40,
|
||||
width: 40,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: theme.borderRadiusMd,
|
||||
image: DecorationImage(
|
||||
fit: BoxFit.cover,
|
||||
image: imageProvider,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: theme.borderRadiusMd,
|
||||
color: isHovering
|
||||
? Colors.black.withAlpha(102)
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: Center(
|
||||
child: Skeleton.ignore(
|
||||
child: Consumer(
|
||||
builder: (context, ref, _) {
|
||||
final isFetchingActiveTrack =
|
||||
ref.watch(queryingTrackInfoProvider);
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: switch ((
|
||||
isPlaying,
|
||||
isFetchingActiveTrack,
|
||||
isPlaying,
|
||||
isHovering,
|
||||
isLoading.value
|
||||
)) {
|
||||
(true, true, _, _, _) ||
|
||||
(_, _, _, _, true) =>
|
||||
const SizedBox(
|
||||
width: 26,
|
||||
height: 26,
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
(_, _, true, _, _) => Icon(
|
||||
SpotubeIcons.pause,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
(_, _, _, true, _) => const Icon(
|
||||
SpotubeIcons.play,
|
||||
color: Colors.white,
|
||||
),
|
||||
_ => const SizedBox.shrink(),
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 6,
|
||||
child: AbsorbPointer(
|
||||
absorbing: selectionMode,
|
||||
child: switch (track) {
|
||||
SpotubeLocalTrackObject() => Text(
|
||||
track.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
_ => Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Button(
|
||||
style: ButtonVariance.link.copyWith(
|
||||
padding: (context, states, value) =>
|
||||
EdgeInsets.zero,
|
||||
),
|
||||
onPressed: effectiveSelection
|
||||
? null
|
||||
: () {
|
||||
context
|
||||
.navigateTo(TrackRoute(trackId: track.id));
|
||||
},
|
||||
child: Text(
|
||||
track.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
if (constrains.mdAndUp) ...[
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: switch (track) {
|
||||
SpotubeLocalTrackObject() => Text(
|
||||
track.album.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
_ => Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: LinkText(
|
||||
track.album.name,
|
||||
AlbumRoute(
|
||||
album: track.album,
|
||||
id: track.album.id,
|
||||
),
|
||||
push: true,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
)
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
subtitle: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: track is SpotubeLocalTrackObject
|
||||
? Text(
|
||||
track.artists.asString(),
|
||||
)
|
||||
: ClipRect(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 40),
|
||||
child: AbsorbPointer(
|
||||
absorbing: effectiveSelection,
|
||||
child: ArtistLink(
|
||||
artists: track.artists,
|
||||
onOverflowArtistClick: effectiveSelection
|
||||
? () {}
|
||||
: () {
|
||||
context.navigateTo(
|
||||
TrackRoute(trackId: track.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
Duration(milliseconds: track.durationMs)
|
||||
.toHumanReadableString(padZero: false),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
return TrackOptionsButton(
|
||||
track: track,
|
||||
userPlaylist: userPlaylist,
|
||||
playlistId: playlistId,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (kIsDesktop) const Gap(10),
|
||||
],
|
||||
),
|
||||
),
|
||||
}
|
||||
},
|
||||
onMorePressed: () {
|
||||
TrackOptionsButton.showOptions(
|
||||
context,
|
||||
Offset.zero,
|
||||
track,
|
||||
userPlaylist: userPlaylist,
|
||||
playlistId: playlistId,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class SpotifyTrackTileSkeleton extends StatelessWidget {
|
||||
const SpotifyTrackTileSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const SpotifyShimmerTrack();
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import 'dart:io';
|
||||
|
||||
import 'package:desktop_webview_window/desktop_webview_window.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart' as material;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_discord_rpc/flutter_discord_rpc.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@ -28,6 +28,7 @@ import 'package:spotube/hooks/configurators/use_get_storage_perms.dart';
|
||||
import 'package:spotube/hooks/configurators/use_has_touch.dart';
|
||||
import 'package:spotube/models/database/database.dart';
|
||||
import 'package:spotube/modules/settings/color_scheme_picker_dialog.dart';
|
||||
import 'package:spotube/theme/spotify_theme.dart';
|
||||
import 'package:spotube/provider/audio_player/audio_player_streams.dart';
|
||||
import 'package:spotube/provider/database/database.dart';
|
||||
import 'package:spotube/provider/glance/glance.dart';
|
||||
@ -114,7 +115,7 @@ Future<void> main(List<String> rawArgs) async {
|
||||
final database = AppDatabase();
|
||||
|
||||
if (kIsDesktop) {
|
||||
await localNotifier.setup(appName: "Spotube");
|
||||
await localNotifier.setup(appName: "Spotify");
|
||||
await WindowManagerTools.initialize();
|
||||
}
|
||||
|
||||
@ -144,8 +145,6 @@ class Spotube extends HookConsumerWidget {
|
||||
final themeMode =
|
||||
ref.watch(userPreferencesProvider.select((s) => s.themeMode));
|
||||
final locale = ref.watch(userPreferencesProvider.select((s) => s.locale));
|
||||
final accentMaterialColor =
|
||||
ref.watch(userPreferencesProvider.select((s) => s.accentColorScheme));
|
||||
final router = useMemoized(() => AppRouter(ref), []);
|
||||
final hasTouchSupport = useHasTouch();
|
||||
|
||||
@ -191,7 +190,7 @@ class Spotube extends HookConsumerWidget {
|
||||
],
|
||||
routerConfig: router.config(),
|
||||
debugShowCheckedModeBanner: false,
|
||||
title: 'Spotube',
|
||||
title: 'Spotify',
|
||||
builder: (context, child) {
|
||||
child = ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(
|
||||
@ -216,38 +215,9 @@ class Spotube extends HookConsumerWidget {
|
||||
return child;
|
||||
},
|
||||
scaling: const AdaptiveScaling(1),
|
||||
theme: ThemeData(
|
||||
radius: .5,
|
||||
iconTheme: const IconThemeProperties(),
|
||||
colorScheme:
|
||||
colorSchemeMap[accentMaterialColor.name]?.call(ThemeMode.light) ??
|
||||
LegacyColorSchemes.lightSlate(),
|
||||
surfaceOpacity: .8,
|
||||
surfaceBlur: 10,
|
||||
),
|
||||
darkTheme: ThemeData(
|
||||
radius: .5,
|
||||
iconTheme: const IconThemeProperties(),
|
||||
colorScheme:
|
||||
colorSchemeMap[accentMaterialColor.name]?.call(ThemeMode.dark) ??
|
||||
LegacyColorSchemes.darkSlate(),
|
||||
surfaceOpacity: .8,
|
||||
surfaceBlur: 10,
|
||||
),
|
||||
materialTheme: material.ThemeData(
|
||||
brightness: switch (themeMode) {
|
||||
ThemeMode.system => MediaQuery.platformBrightnessOf(context),
|
||||
ThemeMode.light => Brightness.light,
|
||||
ThemeMode.dark => Brightness.dark,
|
||||
},
|
||||
splashFactory: material.NoSplash.splashFactory,
|
||||
appBarTheme: const material.AppBarTheme(
|
||||
surfaceTintColor: Colors.transparent,
|
||||
scrolledUnderElevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
theme: SpotifyTheme.shadcnTheme(),
|
||||
darkTheme: SpotifyTheme.shadcnTheme(),
|
||||
materialTheme: SpotifyTheme.materialTheme(context),
|
||||
themeMode: themeMode,
|
||||
shortcuts: {
|
||||
...WidgetsApp.defaultShortcuts.map((key, value) {
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:spotube/collections/routes.gr.dart';
|
||||
import 'package:spotube/components/dialogs/select_device_dialog.dart';
|
||||
import 'package:spotube/components/playbutton_view/playbutton_card.dart';
|
||||
import 'package:spotube/components/playbutton_view/playbutton_tile.dart';
|
||||
import 'package:spotube/components/image/universal_image.dart';
|
||||
import 'package:spotube/components/track_tile/track_options_button.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/models/connect/connect.dart';
|
||||
import 'package:spotube/models/metadata/metadata.dart';
|
||||
@ -15,6 +15,9 @@ import 'package:spotube/provider/history/history.dart';
|
||||
import 'package:spotube/provider/audio_player/audio_player.dart';
|
||||
import 'package:spotube/provider/metadata_plugin/tracks/album.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/theme/spotify_colors.dart';
|
||||
import 'package:spotube/theme/spotify_components.dart';
|
||||
import 'package:spotube/theme/spotify_typography.dart';
|
||||
|
||||
extension FormattedAlbumType on SpotubeAlbumType {
|
||||
String get formatted => name.replaceFirst(name[0], name[0].toUpperCase());
|
||||
@ -161,28 +164,31 @@ class AlbumCard extends HookConsumerWidget {
|
||||
context
|
||||
]);
|
||||
|
||||
if (_isTile) {
|
||||
return PlaybuttonTile(
|
||||
imageUrl: imageUrl,
|
||||
isPlaying: isPlaylistPlaying,
|
||||
isLoading: isLoading,
|
||||
title: album.name,
|
||||
description: description,
|
||||
onTap: onTap,
|
||||
onPlaybuttonPressed: onPlaybuttonPressed,
|
||||
onAddToQueuePressed: onAddToQueuePressed,
|
||||
);
|
||||
}
|
||||
|
||||
return PlaybuttonCard(
|
||||
imageUrl: imageUrl,
|
||||
return SpotifyCard(
|
||||
image: Image.network(
|
||||
imageUrl,
|
||||
width: 150,
|
||||
height: 150,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: 150,
|
||||
height: 150,
|
||||
color: SpotifyColors.surface,
|
||||
child: const Icon(SpotubeIcons.album,
|
||||
color: SpotifyColors.secondaryText, size: 40),
|
||||
),
|
||||
loadingBuilder: (context, chunk, total) => Container(
|
||||
width: 150,
|
||||
height: 150,
|
||||
color: SpotifyColors.surface,
|
||||
),
|
||||
),
|
||||
title: album.name,
|
||||
subtitle: description,
|
||||
onTap: onTap,
|
||||
onPlayPressed: onPlaybuttonPressed,
|
||||
isPlaying: isPlaylistPlaying,
|
||||
isLoading: isLoading,
|
||||
title: album.name,
|
||||
description: description,
|
||||
onTap: onTap,
|
||||
onPlaybuttonPressed: onPlaybuttonPressed,
|
||||
onAddToQueuePressed: onAddToQueuePressed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:auto_size_text/auto_size_text.dart';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import 'package:spotube/collections/routes.gr.dart';
|
||||
import 'package:spotube/components/image/universal_image.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/models/metadata/metadata.dart';
|
||||
|
||||
import 'package:spotube/provider/blacklist_provider.dart';
|
||||
import 'package:spotube/theme/spotify_colors.dart';
|
||||
import 'package:spotube/theme/spotify_components.dart';
|
||||
import 'package:spotube/theme/spotify_typography.dart';
|
||||
|
||||
class ArtistCard extends HookConsumerWidget {
|
||||
final SpotubeFullArtistObject artist;
|
||||
@ -17,12 +18,7 @@ class ArtistCard extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final theme = Theme.of(context);
|
||||
final backgroundImage = UniversalImage.imageProvider(
|
||||
artist.images.asUrlString(
|
||||
placeholder: ImagePlaceholder.artist,
|
||||
),
|
||||
);
|
||||
final textTheme = SpotifyTypography.buildTextTheme(context);
|
||||
final isBlackListed = ref.watch(
|
||||
blacklistProvider.select(
|
||||
(blacklist) => blacklist.asData?.value.any(
|
||||
@ -32,43 +28,77 @@ class ArtistCard extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
width: 180,
|
||||
child: Button.card(
|
||||
onPressed: () {
|
||||
context.navigateTo(ArtistRoute(artistId: artist.id));
|
||||
},
|
||||
child: Column(
|
||||
children: [
|
||||
Avatar(
|
||||
initials: artist.name.trim()[0].toUpperCase(),
|
||||
provider: backgroundImage,
|
||||
size: 130,
|
||||
),
|
||||
const Gap(10),
|
||||
AutoSizeText(
|
||||
artist.name,
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.typography.bold,
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
if (isBlackListed == true) ...[
|
||||
DestructiveBadge(
|
||||
child: Text(context.l10n.blacklisted.toUpperCase()),
|
||||
width: 150,
|
||||
child: HoverBuilder(
|
||||
builder: (context, hovering) {
|
||||
final imageUrl = artist.images.asUrlString(
|
||||
placeholder: ImagePlaceholder.artist,
|
||||
);
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
imageUrl,
|
||||
width: 150,
|
||||
height: 150,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: 150,
|
||||
height: 150,
|
||||
color: SpotifyColors.surface,
|
||||
child: const Icon(SpotubeIcons.artist,
|
||||
color: SpotifyColors.secondaryText, size: 40),
|
||||
),
|
||||
loadingBuilder: (context, chunk, total) => Container(
|
||||
width: 150,
|
||||
height: 150,
|
||||
color: SpotifyColors.surface,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Gap(5),
|
||||
],
|
||||
SecondaryBadge(
|
||||
child: Text(context.l10n.artist.toUpperCase()),
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
context.navigateTo(ArtistRoute(artistId: artist.id));
|
||||
},
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Text(
|
||||
artist.name,
|
||||
style: textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: SpotifyColors.primaryText,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
if (isBlackListed == true) ...[
|
||||
Text(
|
||||
context.l10n.blacklisted.toUpperCase(),
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: Colors.red,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
] else
|
||||
Text(
|
||||
context.l10n.artist.toUpperCase(),
|
||||
style: textTheme.labelSmall?.copyWith(
|
||||
color: SpotifyColors.disabledText,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:spotube/collections/routes.gr.dart';
|
||||
import 'package:spotube/components/dialogs/select_device_dialog.dart';
|
||||
import 'package:spotube/components/playbutton_view/playbutton_card.dart';
|
||||
import 'package:spotube/components/playbutton_view/playbutton_tile.dart';
|
||||
import 'package:spotube/components/image/universal_image.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/models/connect/connect.dart';
|
||||
import 'package:spotube/models/metadata/metadata.dart';
|
||||
@ -17,6 +17,9 @@ import 'package:spotube/provider/metadata_plugin/library/tracks.dart';
|
||||
import 'package:spotube/provider/metadata_plugin/tracks/playlist.dart';
|
||||
import 'package:spotube/provider/metadata_plugin/core/user.dart';
|
||||
import 'package:spotube/services/audio_player/audio_player.dart';
|
||||
import 'package:spotube/theme/spotify_colors.dart';
|
||||
import 'package:spotube/theme/spotify_components.dart';
|
||||
import 'package:spotube/theme/spotify_typography.dart';
|
||||
|
||||
class PlaylistCard extends HookConsumerWidget {
|
||||
final SpotubeSimplePlaylistObject playlist;
|
||||
@ -193,32 +196,31 @@ class PlaylistCard extends HookConsumerWidget {
|
||||
final isOwner = playlist.owner.id == me.asData?.value?.id &&
|
||||
me.asData?.value?.id != null;
|
||||
|
||||
if (_isTile) {
|
||||
return PlaybuttonTile(
|
||||
title: playlist.name,
|
||||
description: playlist.description,
|
||||
image: null,
|
||||
imageUrl: imageUrl,
|
||||
isPlaying: isPlaylistPlaying,
|
||||
isLoading: isLoading,
|
||||
isOwner: isOwner,
|
||||
onTap: onTap,
|
||||
onPlaybuttonPressed: onPlaybuttonPressed,
|
||||
onAddToQueuePressed: onAddToQueuePressed,
|
||||
);
|
||||
}
|
||||
|
||||
return PlaybuttonCard(
|
||||
return SpotifyCard(
|
||||
image: Image.network(
|
||||
imageUrl,
|
||||
width: 150,
|
||||
height: 150,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: 150,
|
||||
height: 150,
|
||||
color: SpotifyColors.surface,
|
||||
child: const Icon(SpotubeIcons.playlist,
|
||||
color: SpotifyColors.secondaryText, size: 40),
|
||||
),
|
||||
loadingBuilder: (context, chunk, total) => Container(
|
||||
width: 150,
|
||||
height: 150,
|
||||
color: SpotifyColors.surface,
|
||||
),
|
||||
),
|
||||
title: playlist.name,
|
||||
description: playlist.description,
|
||||
image: null,
|
||||
imageUrl: imageUrl,
|
||||
subtitle: playlist.description.isNotEmpty ? playlist.description : null,
|
||||
onTap: onTap,
|
||||
onPlayPressed: onPlaybuttonPressed,
|
||||
isPlaying: isPlaylistPlaying,
|
||||
isLoading: isLoading,
|
||||
isOwner: isOwner,
|
||||
onTap: onTap,
|
||||
onPlaybuttonPressed: onPlaybuttonPressed,
|
||||
onAddToQueuePressed: onAddToQueuePressed,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
163
lib/modules/root/spotify_bottom_player.dart
Normal file
163
lib/modules/root/spotify_bottom_player.dart
Normal 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);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
48
lib/modules/root/spotify_shell.dart
Normal file
48
lib/modules/root/spotify_shell.dart
Normal 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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
176
lib/modules/root/spotify_sidebar.dart
Normal file
176
lib/modules/root/spotify_sidebar.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
163
lib/modules/root/spotify_top_bar.dart
Normal file
163
lib/modules/root/spotify_top_bar.dart
Normal 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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,19 +1,18 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:spotube/collections/routes.gr.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/models/database/database.dart';
|
||||
import 'package:spotube/components/titlebar/titlebar.dart';
|
||||
import 'package:spotube/modules/connect/connect_device.dart';
|
||||
import 'package:spotube/modules/home/sections/featured.dart';
|
||||
import 'package:spotube/modules/home/sections/sections.dart';
|
||||
import 'package:spotube/modules/home/sections/new_releases.dart';
|
||||
import 'package:spotube/modules/home/sections/recent.dart';
|
||||
import 'package:spotube/components/titlebar/titlebar.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/provider/user_preferences/user_preferences_provider.dart';
|
||||
import 'package:spotube/theme/spotify_colors.dart';
|
||||
import 'package:spotube/utils/platform.dart';
|
||||
|
||||
@RoutePage()
|
||||
@ -23,65 +22,69 @@ class HomePage extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final theme = Theme.of(context);
|
||||
final controller = useScrollController();
|
||||
final mediaQuery = MediaQuery.of(context);
|
||||
final layoutMode =
|
||||
ref.watch(userPreferencesProvider.select((s) => s.layoutMode));
|
||||
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: Scaffold(
|
||||
headers: [
|
||||
if (kTitlebarVisible) const TitleBar(height: 30),
|
||||
],
|
||||
child: CustomScrollView(
|
||||
controller: controller,
|
||||
slivers: [
|
||||
if (mediaQuery.smAndDown || layoutMode == LayoutMode.compact)
|
||||
SliverAppBar(
|
||||
floating: true,
|
||||
title: DefaultTextStyle(
|
||||
style: TextStyle(
|
||||
fontFamily: "Cookie",
|
||||
fontSize: 30,
|
||||
letterSpacing: 1.8,
|
||||
color: theme.colorScheme.foreground,
|
||||
),
|
||||
child: const Text("Spotube"),
|
||||
bottom: false,
|
||||
child: Scaffold(
|
||||
headers: [
|
||||
if (kTitlebarVisible) const TitleBar(height: 30),
|
||||
],
|
||||
backgroundColor: SpotifyColors.background,
|
||||
body: CustomScrollView(
|
||||
controller: controller,
|
||||
slivers: [
|
||||
if (mediaQuery.smAndDown || layoutMode == LayoutMode.compact)
|
||||
SliverAppBar(
|
||||
floating: true,
|
||||
title: Text(
|
||||
"Spotify",
|
||||
style: const TextStyle(
|
||||
fontFamily: "Poppins",
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.04,
|
||||
color: SpotifyColors.primaryText,
|
||||
),
|
||||
backgroundColor: theme.colorScheme.background,
|
||||
foregroundColor: theme.colorScheme.foreground,
|
||||
actions: [
|
||||
const ConnectDeviceButton(),
|
||||
const Gap(10),
|
||||
IconButton.ghost(
|
||||
icon: const Icon(SpotubeIcons.settings, size: 20),
|
||||
onPressed: () {
|
||||
context.navigateTo(const SettingsRoute());
|
||||
},
|
||||
),
|
||||
const Gap(10),
|
||||
],
|
||||
)
|
||||
else if (kIsMacOS)
|
||||
const SliverGap(10),
|
||||
),
|
||||
backgroundColor: SpotifyColors.background,
|
||||
foregroundColor: SpotifyColors.primaryText,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
shadowColor: Colors.transparent,
|
||||
actions: [
|
||||
const ConnectDeviceButton(),
|
||||
const Gap(10),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_rounded,
|
||||
size: 20, color: SpotifyColors.secondaryText),
|
||||
onPressed: () {
|
||||
context.navigateTo(const SettingsRoute());
|
||||
},
|
||||
),
|
||||
const Gap(10),
|
||||
],
|
||||
)
|
||||
else if (kIsMacOS)
|
||||
const SliverGap(10),
|
||||
SliverList.builder(
|
||||
itemCount: 3,
|
||||
itemBuilder: (context, index) {
|
||||
return switch (index) {
|
||||
// 0 => const HomeGenresSection(),
|
||||
0 => const HomeRecentlyPlayedSection(),
|
||||
1 => const HomeFeaturedSection(),
|
||||
// 3 => const HomePageFriendsSection(),
|
||||
_ => const HomeNewReleasesSection()
|
||||
};
|
||||
},
|
||||
),
|
||||
const SliverSafeArea(sliver: HomePageBrowseSection()),
|
||||
],
|
||||
),
|
||||
));
|
||||
const SliverGap(10),
|
||||
SliverList.builder(
|
||||
itemCount: 3,
|
||||
itemBuilder: (context, index) {
|
||||
return switch (index) {
|
||||
0 => const HomeRecentlyPlayedSection(),
|
||||
1 => const HomeFeaturedSection(),
|
||||
_ => const HomeNewReleasesSection()
|
||||
};
|
||||
},
|
||||
),
|
||||
const SliverSafeArea(sliver: HomePageBrowseSection()),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,14 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart' show Badge;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:spotube/collections/routes.gr.dart';
|
||||
import 'package:spotube/collections/side_bar_tiles.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/titlebar/titlebar.dart';
|
||||
import 'package:spotube/extensions/constrains.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/provider/download_manager_provider.dart';
|
||||
import 'package:spotube/theme/spotify_colors.dart';
|
||||
import 'package:spotube/theme/spotify_typography.dart';
|
||||
|
||||
@RoutePage()
|
||||
class LibraryPage extends HookConsumerWidget {
|
||||
@ -50,12 +49,13 @@ class LibraryPage extends HookConsumerWidget {
|
||||
bottom: false,
|
||||
child: LayoutBuilder(builder: (context, constraints) {
|
||||
return Scaffold(
|
||||
headers: [
|
||||
if (constraints.smAndDown)
|
||||
TitleBar(
|
||||
automaticallyImplyLeading: false,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
backgroundColor: SpotifyColors.background,
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (constraints.smAndDown)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: TabList(
|
||||
index: index,
|
||||
onChanged: (index) {
|
||||
@ -63,31 +63,67 @@ class LibraryPage extends HookConsumerWidget {
|
||||
},
|
||||
children: [
|
||||
for (final tile in sidebarLibraryTileList)
|
||||
TabItem(
|
||||
child: Badge(
|
||||
isLabelVisible: tile.id == 'downloads' &&
|
||||
downloadingCount > 0,
|
||||
label: Text(downloadingCount.toString()),
|
||||
child: Text(tile.title),
|
||||
),
|
||||
_SpotifyTabItem(
|
||||
selected: index == sidebarLibraryTileList.indexOf(tile),
|
||||
label: tile.title,
|
||||
badge: tile.id == 'downloads' && downloadingCount > 0
|
||||
? downloadingCount.toString()
|
||||
: null,
|
||||
onPressed: () =>
|
||||
context.navigateTo(tile.route),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const TitleBar(
|
||||
automaticallyImplyLeading: false,
|
||||
backgroundColor: Colors.transparent,
|
||||
surfaceBlur: 0,
|
||||
height: 32,
|
||||
),
|
||||
const Gap(10),
|
||||
],
|
||||
child: const AutoRouter(),
|
||||
)
|
||||
else
|
||||
const SizedBox(height: 10),
|
||||
const SizedBox(height: 10),
|
||||
Expanded(child: const AutoRouter()),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SpotifyTabItem extends HookConsumerWidget {
|
||||
final bool selected;
|
||||
final String label;
|
||||
final String? badge;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _SpotifyTabItem({
|
||||
required this.selected,
|
||||
required this.label,
|
||||
this.badge,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final textTheme = SpotifyTypography.buildTextTheme(context);
|
||||
return InkWell(
|
||||
onTap: onPressed,
|
||||
child: Container(
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? SpotifyColors.hover : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(500),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
label,
|
||||
style: textTheme.labelMedium?.copyWith(
|
||||
color: selected
|
||||
? SpotifyColors.primaryText
|
||||
: SpotifyColors.secondaryText,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,13 +2,10 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
|
||||
|
||||
import 'package:spotube/hooks/configurators/use_check_yt_dlp_installed.dart';
|
||||
import 'package:spotube/modules/root/bottom_player.dart';
|
||||
import 'package:spotube/modules/root/sidebar/sidebar.dart';
|
||||
import 'package:spotube/modules/root/spotube_navigation_bar.dart';
|
||||
import 'package:spotube/hooks/configurators/use_endless_playback.dart';
|
||||
import 'package:spotube/modules/root/spotify_shell.dart';
|
||||
import 'package:spotube/modules/root/use_global_subscriptions.dart';
|
||||
import 'package:spotube/provider/glance/glance.dart';
|
||||
|
||||
@ -30,7 +27,7 @@ class RootAppPage extends HookConsumerWidget {
|
||||
useEffect(() {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
SystemUiOverlayStyle(
|
||||
statusBarColor: backgroundColor, // status bar color
|
||||
statusBarColor: backgroundColor,
|
||||
statusBarIconBrightness: brightness == Brightness.dark
|
||||
? Brightness.light
|
||||
: Brightness.dark,
|
||||
@ -39,30 +36,6 @@ class RootAppPage extends HookConsumerWidget {
|
||||
return null;
|
||||
}, [backgroundColor, brightness]);
|
||||
|
||||
final scaffold = MediaQuery.removeViewInsets(
|
||||
context: context,
|
||||
removeBottom: true,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: Scaffold(
|
||||
footers: const [
|
||||
BottomPlayer(),
|
||||
SpotubeNavigationBar(),
|
||||
],
|
||||
floatingFooter: true,
|
||||
child: Sidebar(
|
||||
child: MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
padding: MediaQuery.paddingOf(context)
|
||||
.copyWith(bottom: 100 * context.theme.scaling),
|
||||
),
|
||||
child: const AutoRouter(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return scaffold;
|
||||
return const SpotifyShell();
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,13 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fuzzywuzzy/fuzzywuzzy.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:spotube/collections/routes.gr.dart';
|
||||
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/components/fallbacks/error_box.dart';
|
||||
import 'package:spotube/components/fallbacks/no_default_metadata_plugin.dart';
|
||||
import 'package:spotube/components/titlebar/titlebar.dart';
|
||||
import 'package:spotube/collections/spotube_icons.dart';
|
||||
import 'package:spotube/extensions/context.dart';
|
||||
import 'package:spotube/extensions/string.dart';
|
||||
import 'package:spotube/hooks/controllers/use_shadcn_text_editing_controller.dart';
|
||||
@ -22,6 +20,8 @@ import 'package:spotube/provider/metadata_plugin/search/all.dart';
|
||||
import 'package:spotube/services/kv_store/kv_store.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:spotube/services/metadata/errors/exceptions.dart';
|
||||
import 'package:spotube/theme/spotify_colors.dart';
|
||||
import 'package:spotube/theme/spotify_typography.dart';
|
||||
|
||||
final searchTermStateProvider = StateProvider<String>((ref) {
|
||||
return "";
|
||||
@ -37,6 +37,7 @@ class SearchPage extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, ref) {
|
||||
final controller = useShadcnTextEditingController();
|
||||
final focusNode = useFocusNode();
|
||||
final textTheme = SpotifyTypography.buildTextTheme(context);
|
||||
|
||||
final searchTerm = ref.watch(searchTermStateProvider);
|
||||
final searchChipSnapshot = ref.watch(metadataPluginSearchChipsProvider);
|
||||
@ -79,11 +80,8 @@ class SearchPage extends HookConsumerWidget {
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Scaffold(
|
||||
headers: [
|
||||
if (kTitlebarVisible)
|
||||
const TitleBar(automaticallyImplyLeading: false, height: 30)
|
||||
],
|
||||
child: Builder(builder: (context) {
|
||||
backgroundColor: SpotifyColors.background,
|
||||
body: Builder(builder: (context) {
|
||||
if (searchChipSnapshot.error
|
||||
case MetadataPluginException(
|
||||
errorCode: MetadataPluginErrorCode.noDefaultMetadataPlugin,
|
||||
@ -103,114 +101,60 @@ class SearchPage extends HookConsumerWidget {
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 10,
|
||||
),
|
||||
child: ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) {
|
||||
final suggestions = controller.text.isEmpty
|
||||
? KVStoreService.recentSearches
|
||||
: KVStoreService.recentSearches
|
||||
.where(
|
||||
(s) =>
|
||||
weightedRatio(
|
||||
s.toLowerCase(),
|
||||
controller.text.toLowerCase(),
|
||||
) >
|
||||
50,
|
||||
)
|
||||
.toList();
|
||||
|
||||
return AutoComplete(
|
||||
suggestions: suggestions.length <= 2
|
||||
? [
|
||||
...suggestions,
|
||||
"Twenty One Pilots",
|
||||
"Linkin Park",
|
||||
]
|
||||
: suggestions,
|
||||
completer: (suggestion) => suggestion,
|
||||
mode: AutoCompleteMode.replaceAll,
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
features: [
|
||||
const InputFeature.leading(
|
||||
Icon(SpotubeIcons.search),
|
||||
),
|
||||
InputFeature.trailing(
|
||||
AnimatedCrossFade(
|
||||
duration:
|
||||
const Duration(milliseconds: 300),
|
||||
crossFadeState:
|
||||
controller.text.isNotEmpty
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
firstChild: IconButton.ghost(
|
||||
size: ButtonSize.small,
|
||||
icon: const Icon(SpotubeIcons.close),
|
||||
onPressed: () {
|
||||
controller.clear();
|
||||
},
|
||||
),
|
||||
secondChild: const SizedBox.square(
|
||||
dimension: 28),
|
||||
),
|
||||
)
|
||||
],
|
||||
textInputAction: TextInputAction.search,
|
||||
placeholder: Text(context.l10n.search),
|
||||
onSubmitted: onSubmitted,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
const Gap(12),
|
||||
if (searchChipSnapshot.asData?.value != null)
|
||||
for (final chip in searchChipSnapshot.asData!.value)
|
||||
Chip(
|
||||
style: selectedChip.value == chip
|
||||
? ButtonVariance.primary.copyWith(
|
||||
decoration: (context, states, value) {
|
||||
return ButtonVariance.primary
|
||||
.decoration(context, states)
|
||||
.copyWithIfBoxDecoration(
|
||||
borderRadius:
|
||||
BorderRadius.circular(100),
|
||||
);
|
||||
},
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 10,
|
||||
),
|
||||
color: SpotifyColors.background,
|
||||
child: ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) {
|
||||
final suggestions = controller.text.isEmpty
|
||||
? KVStoreService.recentSearches
|
||||
: KVStoreService.recentSearches
|
||||
.where(
|
||||
(s) =>
|
||||
weightedRatio(
|
||||
s.toLowerCase(),
|
||||
controller.text.toLowerCase(),
|
||||
) >
|
||||
50,
|
||||
)
|
||||
: ButtonVariance.secondary.copyWith(
|
||||
decoration: (context, states, value) {
|
||||
return ButtonVariance.secondary
|
||||
.decoration(context, states)
|
||||
.copyWithIfBoxDecoration(
|
||||
borderRadius:
|
||||
BorderRadius.circular(100),
|
||||
);
|
||||
},
|
||||
),
|
||||
child: Text(chip.capitalize()),
|
||||
onPressed: () {
|
||||
selectedChip.value = chip;
|
||||
},
|
||||
),
|
||||
],
|
||||
.toList();
|
||||
|
||||
return SearchTextField(
|
||||
suggestions: suggestions.length <= 2
|
||||
? [
|
||||
...suggestions,
|
||||
"Twenty One Pilots",
|
||||
"Linkin Park",
|
||||
]
|
||||
: suggestions,
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
onSubmitted: onSubmitted,
|
||||
);
|
||||
}),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
if (searchChipSnapshot.asData?.value != null)
|
||||
for (final chip in searchChipSnapshot.asData!.value)
|
||||
SpotifyChip(
|
||||
selected: selectedChip.value == chip,
|
||||
label: chip.capitalize(),
|
||||
onPressed: () {
|
||||
selectedChip.value = chip;
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
@ -231,3 +175,120 @@ class SearchPage extends HookConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SearchTextField extends HookConsumerWidget {
|
||||
final List<String> suggestions;
|
||||
final Object controller;
|
||||
final FocusNode focusNode;
|
||||
final void Function(String) onSubmitted;
|
||||
|
||||
const SearchTextField({
|
||||
super.key,
|
||||
required this.suggestions,
|
||||
required this.controller,
|
||||
required this.focusNode,
|
||||
required this.onSubmitted,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final textCtrl = controller as dynamic;
|
||||
final textTheme = SpotifyTypography.buildTextTheme(context);
|
||||
return AutoComplete(
|
||||
suggestions: suggestions,
|
||||
completer: (suggestion) => suggestion,
|
||||
mode: AutoCompleteMode.replaceAll,
|
||||
child: TextField(
|
||||
autofocus: true,
|
||||
controller: textCtrl,
|
||||
focusNode: focusNode,
|
||||
style: textTheme.bodyLarge?.copyWith(
|
||||
color: SpotifyColors.primaryText,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: SpotifyColors.surface,
|
||||
hintText: context.l10n.search,
|
||||
hintStyle: textTheme.bodyLarge?.copyWith(
|
||||
color: SpotifyColors.disabledText,
|
||||
),
|
||||
prefixIcon: const Icon(
|
||||
SpotubeIcons.search,
|
||||
color: SpotifyColors.secondaryText,
|
||||
size: 20,
|
||||
),
|
||||
suffixIcon: AnimatedCrossFade(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
crossFadeState:
|
||||
textCtrl.text.isNotEmpty
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
firstChild: IconButton(
|
||||
icon: const Icon(SpotubeIcons.close,
|
||||
color: SpotifyColors.secondaryText, size: 18),
|
||||
onPressed: () {
|
||||
textCtrl.clear();
|
||||
},
|
||||
),
|
||||
secondChild: const SizedBox.square(dimension: 28),
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(500),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(500),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(500),
|
||||
borderSide: const BorderSide(color: SpotifyColors.spotifyGreen),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
),
|
||||
textInputAction: TextInputAction.search,
|
||||
onSubmitted: onSubmitted,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SpotifyChip extends HookConsumerWidget {
|
||||
final bool selected;
|
||||
final String label;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const SpotifyChip({
|
||||
super.key,
|
||||
required this.selected,
|
||||
required this.label,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, ref) {
|
||||
final textTheme = SpotifyTypography.buildTextTheme(context);
|
||||
return HoverBuilder(
|
||||
builder: (context, hovering) {
|
||||
return ActionChip(
|
||||
label: Text(
|
||||
label,
|
||||
style: textTheme.labelMedium?.copyWith(
|
||||
color: selected
|
||||
? Colors.black
|
||||
: SpotifyColors.secondaryText,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
backgroundColor: selected
|
||||
? SpotifyColors.spotifyGreen
|
||||
: (hovering ? SpotifyColors.hover : SpotifyColors.surface),
|
||||
side: BorderSide.none,
|
||||
shape: const StadiumBorder(),
|
||||
onPressed: onPressed,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
@ -8,6 +9,7 @@ import 'package:spotube/modules/album/album_card.dart';
|
||||
import 'package:spotube/modules/search/loading.dart';
|
||||
import 'package:spotube/pages/search/search.dart';
|
||||
import 'package:spotube/provider/metadata_plugin/search/albums.dart';
|
||||
import 'package:spotube/theme/spotify_colors.dart';
|
||||
|
||||
class SearchPageAlbumsTab extends HookConsumerWidget {
|
||||
const SearchPageAlbumsTab({super.key});
|
||||
@ -38,6 +40,7 @@ class SearchPageAlbumsTab extends HookConsumerWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: CustomScrollView(
|
||||
controller: controller,
|
||||
slivers: [
|
||||
PlaybuttonView(
|
||||
controller: controller,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_undraw/flutter_undraw.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter_extension.dart';
|
||||
@ -13,6 +13,7 @@ import 'package:spotube/modules/artist/artist_card.dart';
|
||||
import 'package:spotube/modules/search/loading.dart';
|
||||
import 'package:spotube/pages/search/search.dart';
|
||||
import 'package:spotube/provider/metadata_plugin/search/artists.dart';
|
||||
import 'package:spotube/theme/spotify_colors.dart';
|
||||
|
||||
class SearchPageArtistsTab extends HookConsumerWidget {
|
||||
const SearchPageArtistsTab({super.key});
|
||||
@ -49,15 +50,15 @@ class SearchPageArtistsTab extends HookConsumerWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 10,
|
||||
children: [
|
||||
Undraw(
|
||||
height: 200 * context.theme.scaling,
|
||||
illustration: UndrawIllustration.taken,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
Text(
|
||||
context.l10n.nothing_found,
|
||||
textAlign: TextAlign.center,
|
||||
).muted().small()
|
||||
style: TextStyle(
|
||||
color: SpotifyColors.secondaryText,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@ -67,10 +68,10 @@ class SearchPageArtistsTab extends HookConsumerWidget {
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: searchArtists.length + 1,
|
||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||
maxCrossAxisExtent: 200,
|
||||
mainAxisExtent: constrains.smAndDown ? 225 : 250,
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
maxCrossAxisExtent: 160,
|
||||
mainAxisExtent: constrains.smAndDown ? 200 : 220,
|
||||
crossAxisSpacing: 12,
|
||||
mainAxisSpacing: 12,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
if (searchArtists.isNotEmpty && index == searchArtists.length) {
|
||||
@ -82,10 +83,7 @@ class SearchPageArtistsTab extends HookConsumerWidget {
|
||||
controller: controller,
|
||||
isGrid: true,
|
||||
onTouchEdge: searchArtistsNotifier.fetchMore,
|
||||
child: Skeletonizer(
|
||||
enabled: true,
|
||||
child: ArtistCard(FakeData.artist),
|
||||
),
|
||||
child: const SpotifyShimmerTrack(),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shadcn_flutter/shadcn_flutter.dart';
|
||||
@ -8,6 +9,7 @@ import 'package:spotube/modules/playlist/playlist_card.dart';
|
||||
import 'package:spotube/modules/search/loading.dart';
|
||||
import 'package:spotube/pages/search/search.dart';
|
||||
import 'package:spotube/provider/metadata_plugin/search/playlists.dart';
|
||||
import 'package:spotube/theme/spotify_colors.dart';
|
||||
|
||||
class SearchPagePlaylistsTab extends HookConsumerWidget {
|
||||
const SearchPagePlaylistsTab({super.key});
|
||||
@ -38,6 +40,7 @@ class SearchPagePlaylistsTab extends HookConsumerWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: CustomScrollView(
|
||||
controller: controller,
|
||||
slivers: [
|
||||
PlaybuttonView(
|
||||
controller: controller,
|
||||
|
||||
17
lib/theme/spotify_colors.dart
Normal file
17
lib/theme/spotify_colors.dart
Normal 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);
|
||||
}
|
||||
579
lib/theme/spotify_components.dart
Normal file
579
lib/theme/spotify_components.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
85
lib/theme/spotify_theme.dart
Normal file
85
lib/theme/spotify_theme.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
101
lib/theme/spotify_typography.dart
Normal file
101
lib/theme/spotify_typography.dart
Normal 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;
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Spotube
|
||||
Name=Spotify
|
||||
Exec=/usr/bin/spotube
|
||||
Icon=/usr/share/icons/spotube/spotube-logo.png
|
||||
Comment=A music streaming app combining the power of music metadata providers & YouTube
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
// 'flutter create' template.
|
||||
|
||||
// The application's name. By default this is also the title of the Flutter window.
|
||||
PRODUCT_NAME = Spotube
|
||||
PRODUCT_NAME = Spotify
|
||||
|
||||
// The application's bundle identifier
|
||||
PRODUCT_BUNDLE_IDENTIFIER = oss.krtirtho.spotube
|
||||
|
||||
14
pubspec.yaml
14
pubspec.yaml
@ -139,6 +139,7 @@ dependencies:
|
||||
url: https://github.com/KRTirtho/hetu_spotube_plugin.git
|
||||
ref: main
|
||||
get_it: ^8.0.3
|
||||
google_fonts: ^6.2.1
|
||||
flutter_markdown_plus: ^1.0.3
|
||||
pub_semver: ^2.2.0
|
||||
change_case: ^1.1.0
|
||||
@ -217,6 +218,7 @@ flutter:
|
||||
assets:
|
||||
- assets/images/
|
||||
- assets/images/logos/
|
||||
- assets/images/spotify_logo.png
|
||||
- assets/branding/spotube-logo.png
|
||||
- assets/branding/spotube-logo-light.png
|
||||
- assets/branding/spotube-logo.ico
|
||||
@ -273,10 +275,10 @@ flutter_gen:
|
||||
|
||||
flutter_native_splash:
|
||||
background_image: assets/bengali-patterns-bg.jpg
|
||||
image: assets/spotube-logo.png
|
||||
branding: assets/branding.png
|
||||
image: assets/images/spotify_logo.png
|
||||
branding: assets/branding/branding.png
|
||||
android_12:
|
||||
image: assets/spotube-logo_android12.png
|
||||
branding: assets/branding.png
|
||||
color: "#000000"
|
||||
icon_background_color: "#000000"
|
||||
image: assets/images/spotify_logo.png
|
||||
branding: assets/branding/branding.png
|
||||
color: "#121212"
|
||||
icon_background_color: "#121212"
|
||||
|
||||
@ -90,12 +90,12 @@ BEGIN
|
||||
BLOCK "040904e4"
|
||||
BEGIN
|
||||
VALUE "CompanyName", "oss.krtirtho" "\0"
|
||||
VALUE "FileDescription", "Spotube" "\0"
|
||||
VALUE "FileDescription", "Spotify" "\0"
|
||||
VALUE "FileVersion", VERSION_AS_STRING "\0"
|
||||
VALUE "InternalName", "spotube" "\0"
|
||||
VALUE "LegalCopyright", "Copyright (C) 2024 oss.krtirtho. All rights reserved." "\0"
|
||||
VALUE "OriginalFilename", "spotube.exe" "\0"
|
||||
VALUE "ProductName", "spotube" "\0"
|
||||
VALUE "ProductName", "Spotify" "\0"
|
||||
VALUE "ProductVersion", VERSION_AS_STRING "\0"
|
||||
END
|
||||
END
|
||||
|
||||
Loading…
Reference in New Issue
Block a user