mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
- 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.
333 lines
13 KiB
Dart
333 lines
13 KiB
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: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';
|
|
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});
|
|
|
|
@override
|
|
Widget build(BuildContext context, ref) {
|
|
final mediaQuery = MediaQuery.sizeOf(context);
|
|
final options = TrackPresentationOptions.of(context);
|
|
final scale = context.theme.scaling;
|
|
final isUserPlaylist = useIsUserPlaylist(ref, options.collectionId);
|
|
|
|
final decorationImage = DecorationImage(
|
|
image: UniversalImage.imageProvider(options.image),
|
|
fit: BoxFit.cover,
|
|
);
|
|
|
|
final imageDimension = mediaQuery.mdAndUp ? 300.0 : 200.0;
|
|
|
|
final (:isLoading, :isActive, :onPlay, :onShuffle, :onAddToQueue) =
|
|
useActionCallbacks(ref);
|
|
|
|
final textTheme = SpotifyTypography.buildTextTheme(context);
|
|
|
|
return SliverMainAxisGroup(
|
|
slivers: [
|
|
if (mediaQuery.mdAndUp) SliverGap(16 * scale),
|
|
SliverPadding(
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: (mediaQuery.mdAndUp ? 16 : 8.0) * scale,
|
|
),
|
|
sliver: SliverList.list(
|
|
children: [
|
|
DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
image: decorationImage,
|
|
borderRadius: BorderRadius.circular(0),
|
|
),
|
|
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),
|
|
],
|
|
),
|
|
),
|
|
child: Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: Padding(
|
|
padding: EdgeInsets.all(24 * scale),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
AutoSizeText(
|
|
options.title,
|
|
maxLines: 2,
|
|
minFontSize: 20,
|
|
style: textTheme.headlineMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
color: SpotifyColors.primaryText,
|
|
),
|
|
),
|
|
if (options.description != null)
|
|
AutoSizeText(
|
|
options.description!,
|
|
maxLines: 2,
|
|
minFontSize: 14,
|
|
maxFontSize: 18,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
color: SpotifyColors.secondaryText,
|
|
fontSize: 16,
|
|
),
|
|
),
|
|
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,
|
|
),
|
|
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 (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,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|