mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
feat(library): refine album and artist artwork headers
Raise album details and artist actions over the artwork, soften the lower artist banner, and derive action colors from the artist logo. Match loading skeletons to the updated header layouts and preserve scrolling and large text behavior.
This commit is contained in:
1 parent
bb057fd210
commit
9a62244263
8 files changed
+697
-287
No files matched your search
@@ -540,7 +540,6 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen>
|
||||
child: HeaderFilledButton(
|
||||
icon: CupertinoIcons.arrow_down_circle_fill,
|
||||
label: context.l10n.dialogDownload,
|
||||
tonal: true,
|
||||
onPressed: tracks.isEmpty
|
||||
? null
|
||||
: () => _downloadAll(context),
|
||||
|
||||
@@ -608,6 +608,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen>
|
||||
return MornyeArtistSurface(
|
||||
imageSource:
|
||||
_headerImageUrl ?? widget.headerImageUrl ?? widget.coverUrl,
|
||||
logoSource: _headerLogoUrl ?? widget.headerLogoUrl,
|
||||
child: Builder(builder: _buildPage),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,10 +76,45 @@ extension _ArtistScreenSections on _ArtistScreenState {
|
||||
actions: isSelectionMode
|
||||
? const []
|
||||
: [
|
||||
HeaderCircleButton(
|
||||
icon: CupertinoIcons.square_arrow_up,
|
||||
buttonSize: 50,
|
||||
iconSize: 24,
|
||||
iconColor: colorScheme.primary,
|
||||
glassTintColor: Colors.white,
|
||||
glassTintOpacity: 0.10,
|
||||
tooltip: context.l10n.openInOtherServices,
|
||||
onPressed: () => _showShareSheet(context),
|
||||
),
|
||||
if (_isLoadingDiscography)
|
||||
const ShimmerLoading(
|
||||
child: SkeletonBox(width: 74, height: 74, borderRadius: 37),
|
||||
)
|
||||
else if (hasDiscography)
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
borderRadius: BorderRadius.circular(37),
|
||||
color: colorScheme.primary,
|
||||
onPressed: () =>
|
||||
_showDiscographyOptions(context, colorScheme, albums),
|
||||
child: SizedBox.square(
|
||||
dimension: 74,
|
||||
child: Semantics(
|
||||
label: context.l10n.discographyDownload,
|
||||
child: Icon(
|
||||
CupertinoIcons.arrow_down,
|
||||
size: 34,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
HeaderCircleButton(
|
||||
icon: isFavoriteArtist
|
||||
? CupertinoIcons.heart_fill
|
||||
: CupertinoIcons.heart,
|
||||
? CupertinoIcons.star_fill
|
||||
: CupertinoIcons.star,
|
||||
buttonSize: 50,
|
||||
iconSize: 24,
|
||||
iconColor: colorScheme.primary,
|
||||
glassTintColor: Colors.white,
|
||||
glassTintOpacity: 0.10,
|
||||
@@ -88,37 +123,6 @@ extension _ArtistScreenSections on _ArtistScreenState {
|
||||
: context.l10n.artistOptionAddToFavorites,
|
||||
onPressed: () => _toggleFavoriteArtist(context),
|
||||
),
|
||||
if (_isLoadingDiscography)
|
||||
const ShimmerLoading(
|
||||
child: SkeletonBox(width: 60, height: 60, borderRadius: 30),
|
||||
)
|
||||
else if (hasDiscography)
|
||||
CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
color: colorScheme.primary,
|
||||
onPressed: () =>
|
||||
_showDiscographyOptions(context, colorScheme, albums),
|
||||
child: SizedBox.square(
|
||||
dimension: 60,
|
||||
child: Semantics(
|
||||
label: context.l10n.discographyDownload,
|
||||
child: Icon(
|
||||
CupertinoIcons.arrow_down,
|
||||
size: 26,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
HeaderCircleButton(
|
||||
icon: CupertinoIcons.ellipsis,
|
||||
iconColor: colorScheme.primary,
|
||||
glassTintColor: Colors.white,
|
||||
glassTintOpacity: 0.10,
|
||||
tooltip: context.l10n.openInOtherServices,
|
||||
onPressed: () => _showShareSheet(context),
|
||||
),
|
||||
],
|
||||
).buildSlivers(context);
|
||||
}
|
||||
|
||||
@@ -864,21 +864,12 @@ class _ArtistLoadingScaffold extends StatelessWidget {
|
||||
: const ShimmerLoading(
|
||||
child: SkeletonBox(
|
||||
width: double.infinity,
|
||||
height: 342,
|
||||
height: double.infinity,
|
||||
borderRadius: 0,
|
||||
),
|
||||
),
|
||||
actions: const [
|
||||
ShimmerLoading(
|
||||
child: Row(
|
||||
spacing: 24,
|
||||
children: [
|
||||
SkeletonBox(width: 44, height: 44, borderRadius: 22),
|
||||
SkeletonBox(width: 60, height: 60, borderRadius: 30),
|
||||
SkeletonBox(width: 44, height: 44, borderRadius: 22),
|
||||
],
|
||||
),
|
||||
),
|
||||
ShimmerLoading(child: ArtistHeaderActionsSkeleton()),
|
||||
],
|
||||
).buildSlivers(context),
|
||||
const SliverToBoxAdapter(
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:ui';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart' show RenderProxySliver;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
|
||||
import 'package:spotiflac_android/theme/app_tokens.dart';
|
||||
@@ -121,136 +122,159 @@ class AlbumDetailHeader extends StatelessWidget {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
final edgeInset = detailHeaderEdgeInset(context);
|
||||
final artworkSize = MediaQuery.sizeOf(context).width.clamp(0.0, 440.0);
|
||||
// Keep the artwork and text in normal sliver flow: Dynamic Type and long
|
||||
// album names must grow the header instead of overflowing a fixed height.
|
||||
return [
|
||||
// Only the toolbar needs safe-area padding. Reading it on the caller's
|
||||
// context rebuilds all header slivers as the navigation bar folds.
|
||||
Builder(
|
||||
builder: (context) => SliverAppBar(
|
||||
pinned: true,
|
||||
expandedHeight: immersive
|
||||
? squareArtwork
|
||||
? artworkSize - MediaQuery.paddingOf(context).top
|
||||
: MediaQuery.sizeOf(context).width.clamp(280.0, 440.0)
|
||||
: null,
|
||||
backgroundColor: scheme.surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
flexibleSpace: immersive
|
||||
? FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.pin,
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (squareArtwork)
|
||||
Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: SizedBox.square(
|
||||
dimension: artworkSize,
|
||||
child: background,
|
||||
),
|
||||
)
|
||||
else
|
||||
background,
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: const [0, 0.52, 1],
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.12),
|
||||
scheme.surface.withValues(alpha: 0),
|
||||
scheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: null,
|
||||
title: AnimatedOpacity(
|
||||
opacity: showTitleInAppBar || appBarTitle != null ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: Text(
|
||||
appBarTitle ?? title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
leadingWidth: kToolbarHeight + edgeInset,
|
||||
leading: Padding(
|
||||
padding: EdgeInsets.only(left: edgeInset),
|
||||
child:
|
||||
leading ??
|
||||
HeaderCircleButton(
|
||||
icon: CupertinoIcons.chevron_back,
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
actionsPadding: EdgeInsets.only(right: edgeInset),
|
||||
actions: appBarActions,
|
||||
final details = _buildMornyeDetails(context);
|
||||
Widget toolbar({bool collapsed = true}) => SliverAppBar(
|
||||
pinned: true,
|
||||
backgroundColor: collapsed ? scheme.surface : Colors.transparent,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
scrolledUnderElevation: 0,
|
||||
title: AnimatedOpacity(
|
||||
opacity:
|
||||
showTitleInAppBar || appBarTitle != null || (immersive && collapsed)
|
||||
? 1
|
||||
: 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: Text(
|
||||
appBarTitle ?? title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, immersive ? 0 : 20, 20, 28),
|
||||
child: Column(
|
||||
leadingWidth: kToolbarHeight + edgeInset,
|
||||
leading: Padding(
|
||||
padding: EdgeInsets.only(left: edgeInset),
|
||||
child:
|
||||
leading ??
|
||||
HeaderCircleButton(
|
||||
icon: CupertinoIcons.chevron_back,
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
actionsPadding: EdgeInsets.only(right: edgeInset),
|
||||
actions: appBarActions,
|
||||
);
|
||||
if (immersive) {
|
||||
final detailsTop = (artworkSize - 20).clamp(0.0, double.infinity);
|
||||
final artwork = Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
background,
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: const [0, 0.48, 1],
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.12),
|
||||
scheme.surface.withValues(alpha: 0),
|
||||
scheme.surface,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
return [
|
||||
// The toolbar paints above the header without reserving a second
|
||||
// status-bar inset. Metadata still has its natural, scrollable height.
|
||||
SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final toolbarHeight =
|
||||
kToolbarHeight + MediaQuery.paddingOf(context).top;
|
||||
return _AlbumOverlayToolbar(
|
||||
child: toolbar(
|
||||
collapsed:
|
||||
constraints.scrollOffset >= detailsTop - toolbarHeight,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Stack(
|
||||
children: [
|
||||
if (!immersive)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.16),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) =>
|
||||
coverBuilder!(context, constraints.maxWidth),
|
||||
Positioned.fill(
|
||||
child: squareArtwork
|
||||
? Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: SizedBox.square(
|
||||
dimension: artworkSize,
|
||||
child: artwork,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!immersive) const SizedBox(height: 22),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
)
|
||||
: artwork,
|
||||
),
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(height: detailsTop),
|
||||
details,
|
||||
],
|
||||
),
|
||||
if (subtitle != null) ...[const SizedBox(height: 5), subtitle!],
|
||||
if (meta != null) ...[const SizedBox(height: 5), meta!],
|
||||
if (actions != null) ...[
|
||||
const SizedBox(height: 24),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 460),
|
||||
child: actions!,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
return [
|
||||
Builder(builder: (_) => toolbar()),
|
||||
SliverToBoxAdapter(child: details),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildMornyeDetails(BuildContext context) => Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, immersive ? 0 : 20, 20, 28),
|
||||
child: Column(
|
||||
children: [
|
||||
if (!immersive)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 280),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.16),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) =>
|
||||
coverBuilder!(context, constraints.maxWidth),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!immersive) const SizedBox(height: 22),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[const SizedBox(height: 5), subtitle!],
|
||||
if (meta != null) ...[const SizedBox(height: 5), meta!],
|
||||
if (actions != null) ...[
|
||||
SizedBox(height: immersive ? 16 : 24),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 460),
|
||||
child: actions!,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildAppBar(BuildContext context, ColorScheme headerScheme) {
|
||||
final tokens = context.tokens;
|
||||
final plainArtworkHeader = context.isMornye && coverBuilder != null;
|
||||
@@ -434,6 +458,30 @@ class AlbumDetailHeader extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the pinned toolbar above the artwork without shifting the artwork or
|
||||
/// metadata down by the toolbar's safe-area height.
|
||||
class _AlbumOverlayToolbar extends SingleChildRenderObjectWidget {
|
||||
const _AlbumOverlayToolbar({required super.child});
|
||||
|
||||
@override
|
||||
RenderProxySliver createRenderObject(BuildContext context) =>
|
||||
_RenderAlbumOverlayToolbar();
|
||||
}
|
||||
|
||||
class _RenderAlbumOverlayToolbar extends RenderProxySliver {
|
||||
@override
|
||||
void performLayout() {
|
||||
super.performLayout();
|
||||
final childGeometry = geometry!;
|
||||
if (childGeometry.scrollOffsetCorrection != null) return;
|
||||
geometry = childGeometry.copyWith(
|
||||
scrollExtent: 0,
|
||||
layoutExtent: 0,
|
||||
cacheExtent: 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Primary "play" pill + shuffle circle used by the local and downloaded album
|
||||
/// headers. Colours follow the [HeaderPalette].
|
||||
class AlbumPlayActions extends StatelessWidget {
|
||||
@@ -462,7 +510,6 @@ class AlbumPlayActions extends StatelessWidget {
|
||||
icon: CupertinoIcons.play_fill,
|
||||
label: playLabel,
|
||||
onPressed: onPlay,
|
||||
tonal: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -568,6 +615,8 @@ class HeaderCircleButton extends ConsumerWidget {
|
||||
this.tonal = false,
|
||||
this.glassTintColor,
|
||||
this.glassTintOpacity,
|
||||
this.buttonSize,
|
||||
this.iconSize = 22,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
@@ -583,6 +632,8 @@ class HeaderCircleButton extends ConsumerWidget {
|
||||
/// Optional tint for controls floating over artwork-colored surfaces.
|
||||
final Color? glassTintColor;
|
||||
final double? glassTintOpacity;
|
||||
final double? buttonSize;
|
||||
final double iconSize;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -593,17 +644,17 @@ class HeaderCircleButton extends ConsumerWidget {
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: SizedBox.square(
|
||||
dimension: 44,
|
||||
dimension: buttonSize ?? 44,
|
||||
child: CupertinoButton(
|
||||
padding: EdgeInsets.zero,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular((buttonSize ?? 48) / 2),
|
||||
color: scheme.onSurface.withValues(
|
||||
alpha: scheme.brightness == Brightness.dark ? 0.10 : 0.06,
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: Icon(
|
||||
mornyeIconFor(icon),
|
||||
size: 22,
|
||||
size: iconSize,
|
||||
color: onPressed == null
|
||||
? scheme.onSurfaceVariant
|
||||
: iconColor ?? scheme.primary,
|
||||
@@ -618,16 +669,16 @@ class HeaderCircleButton extends ConsumerWidget {
|
||||
final button = IconButton(
|
||||
onPressed: onPressed,
|
||||
tooltip: tooltip,
|
||||
icon: Icon(mornyeIconFor(icon), size: 22),
|
||||
icon: Icon(mornyeIconFor(icon), size: iconSize),
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size.square(44),
|
||||
minimumSize: Size.square(buttonSize ?? 44),
|
||||
foregroundColor: iconColor ?? scheme.onSurface,
|
||||
),
|
||||
);
|
||||
return Center(
|
||||
widthFactor: 1,
|
||||
child: MornyeGlass.navigation(
|
||||
radius: 24,
|
||||
radius: (buttonSize ?? 48) / 2,
|
||||
tintColor:
|
||||
glassTintColor ??
|
||||
(scheme.brightness == Brightness.dark
|
||||
@@ -643,10 +694,10 @@ class HeaderCircleButton extends ConsumerWidget {
|
||||
}
|
||||
return IconButton.filledTonal(
|
||||
onPressed: onPressed,
|
||||
icon: Icon(icon, size: 22),
|
||||
icon: Icon(icon, size: iconSize),
|
||||
tooltip: tooltip,
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: Size.square(tokens.minTouchTarget),
|
||||
minimumSize: Size.square(buttonSize ?? tokens.minTouchTarget),
|
||||
backgroundColor: scheme.surfaceContainerHighest.withValues(alpha: 0.7),
|
||||
foregroundColor: iconColor ?? scheme.onSurfaceVariant,
|
||||
),
|
||||
|
||||
@@ -381,24 +381,36 @@ class _CollectionHeaderSkeleton extends StatelessWidget {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final coverSize = constraints.maxWidth.clamp(0.0, 440.0);
|
||||
return Column(
|
||||
return Stack(
|
||||
children: [
|
||||
ShaderMask(
|
||||
blendMode: BlendMode.dstIn,
|
||||
shaderCallback: (bounds) => const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: [0, 0.52, 1],
|
||||
colors: [Colors.white, Colors.white, Colors.transparent],
|
||||
).createShader(bounds),
|
||||
child: SkeletonBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
borderRadius: 0,
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: ShaderMask(
|
||||
blendMode: BlendMode.dstIn,
|
||||
shaderCallback: (bounds) => const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: [0, 0.48, 1],
|
||||
colors: [Colors.white, Colors.white, Colors.transparent],
|
||||
).createShader(bounds),
|
||||
child: SkeletonBox(
|
||||
width: coverSize,
|
||||
height: coverSize,
|
||||
borderRadius: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 28),
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
20,
|
||||
(coverSize - 20).clamp(0.0, double.infinity),
|
||||
20,
|
||||
28,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const FractionallySizedBox(
|
||||
@@ -422,7 +434,7 @@ class _CollectionHeaderSkeleton extends StatelessWidget {
|
||||
],
|
||||
const SizedBox(height: 5),
|
||||
const SkeletonBox(width: 140, height: 16, borderRadius: 4),
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 16),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 460),
|
||||
child: const Row(
|
||||
@@ -494,6 +506,23 @@ class _CollectionHeaderSkeleton extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirrors the larger primary action and two smaller artist header actions.
|
||||
class ArtistHeaderActionsSkeleton extends StatelessWidget {
|
||||
const ArtistHeaderActionsSkeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 24,
|
||||
children: [
|
||||
SkeletonBox(width: 50, height: 50, borderRadius: 25),
|
||||
SkeletonBox(width: 74, height: 74, borderRadius: 37),
|
||||
SkeletonBox(width: 50, height: 50, borderRadius: 25),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Artist screen skeleton shown below the SliverAppBar header while the
|
||||
/// discography loads: optional cover placeholder, "Popular" section, and the
|
||||
/// horizontal album sections.
|
||||
@@ -519,24 +548,48 @@ class ArtistScreenSkeleton extends StatelessWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (showCoverHeader) ...[
|
||||
const SkeletonBox(
|
||||
width: double.infinity,
|
||||
height: 342,
|
||||
borderRadius: 0,
|
||||
if (showCoverHeader)
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) => Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ShaderMask(
|
||||
blendMode: BlendMode.dstIn,
|
||||
shaderCallback: (bounds) => const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: [0, 0.48, 1],
|
||||
colors: [
|
||||
Colors.white,
|
||||
Colors.white,
|
||||
Colors.transparent,
|
||||
],
|
||||
).createShader(bounds),
|
||||
child: const SkeletonBox(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
borderRadius: 0,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
24,
|
||||
(constraints.maxWidth * 0.76).clamp(240.0, 340.0),
|
||||
24,
|
||||
32,
|
||||
),
|
||||
child: const Column(
|
||||
children: [
|
||||
Center(child: SkeletonBox(width: 220, height: 36)),
|
||||
SizedBox(height: 20),
|
||||
ArtistHeaderActionsSkeleton(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Center(child: SkeletonBox(width: 220, height: 36)),
|
||||
const SizedBox(height: 16),
|
||||
const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: 24,
|
||||
children: [
|
||||
SkeletonBox(width: 44, height: 44, borderRadius: 22),
|
||||
SkeletonBox(width: 60, height: 60, borderRadius: 30),
|
||||
SkeletonBox(width: 44, height: 44, borderRadius: 22),
|
||||
],
|
||||
),
|
||||
],
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 28, 20, 0),
|
||||
child: Container(
|
||||
|
||||
@@ -16,10 +16,12 @@ class MornyeArtistSurface extends StatefulWidget {
|
||||
super.key,
|
||||
required this.imageSource,
|
||||
required this.child,
|
||||
this.logoSource,
|
||||
this.neutralActions = false,
|
||||
});
|
||||
|
||||
final String? imageSource;
|
||||
final String? logoSource;
|
||||
final Widget child;
|
||||
|
||||
/// Album and playlist actions use white controls over the artwork tint.
|
||||
@@ -46,13 +48,30 @@ class _MornyeArtistSurfaceState extends State<MornyeArtistSurface> {
|
||||
child: CoverPaletteBuilder(
|
||||
imageSource: widget.imageSource,
|
||||
builder: (context, palette) {
|
||||
// Retain the artwork's hue with a muted, slightly lifted surface.
|
||||
// Large backgrounds need less saturation than the cover's accent.
|
||||
final dominant = HSLColor.fromColor(palette.primary);
|
||||
final surface = dominant
|
||||
.withSaturation((dominant.saturation * 0.45).clamp(0.0, 0.22))
|
||||
.withLightness(0.34)
|
||||
.toColor();
|
||||
final source = widget.imageSource;
|
||||
final dominant = HSLColor.fromColor(
|
||||
source == null
|
||||
? Colors.grey
|
||||
: CoverPalette.sourceColor(source, Brightness.dark) ??
|
||||
Colors.grey,
|
||||
);
|
||||
// Averaging a portrait mixes its highlights and foliage into gray.
|
||||
// Artist pages use the extracted accent at a deeper tone instead;
|
||||
// album surfaces and monochrome artwork keep their neutral shading.
|
||||
final useAccent =
|
||||
!widget.neutralActions && dominant.saturation >= 0.08;
|
||||
final accent = HSLColor.fromColor(palette.primary);
|
||||
final surface = useAccent
|
||||
? accent
|
||||
.withSaturation((accent.saturation * 0.9).clamp(0.0, 0.88))
|
||||
.withLightness(0.14 + dominant.lightness * 0.05)
|
||||
.toColor()
|
||||
: dominant
|
||||
.withSaturation(
|
||||
(dominant.saturation * 0.45).clamp(0.0, 0.22),
|
||||
)
|
||||
.withLightness(0.24 + dominant.lightness * 0.20)
|
||||
.toColor();
|
||||
final route = ModalRoute.of(context);
|
||||
if (route != null) {
|
||||
ShellNavigationService.setChromeBrightness(
|
||||
@@ -62,12 +81,34 @@ class _MornyeArtistSurfaceState extends State<MornyeArtistSurface> {
|
||||
surface: surface,
|
||||
);
|
||||
}
|
||||
return Theme(
|
||||
data: darkTheme.copyWith(
|
||||
scaffoldBackgroundColor: surface,
|
||||
colorScheme: darkTheme.colorScheme.copyWith(
|
||||
primary: widget.neutralActions ? Colors.white : palette.primary,
|
||||
onPrimary: widget.neutralActions ? surface : palette.onPrimary,
|
||||
return CoverPaletteBuilder(
|
||||
imageSource: widget.neutralActions ? null : widget.logoSource,
|
||||
builder: (context, _) {
|
||||
final logo = widget.logoSource;
|
||||
// Use the logo's original pixels, including white lettering,
|
||||
// without Material's pastel accent mapping.
|
||||
final logoColor = logo == null
|
||||
? null
|
||||
: CoverPalette.sourceColor(logo, Brightness.dark);
|
||||
final primary = widget.neutralActions
|
||||
? Colors.white
|
||||
: logoColor ?? palette.primary;
|
||||
final primaryLuminance = primary.computeLuminance();
|
||||
final surfaceContrast =
|
||||
(primaryLuminance + 0.05) /
|
||||
(surface.computeLuminance() + 0.05);
|
||||
final onPrimary = widget.neutralActions
|
||||
? Colors.black
|
||||
: logoColor == null
|
||||
? palette.onPrimary
|
||||
: surfaceContrast >= 4.5
|
||||
? surface
|
||||
: primaryLuminance > 0.179
|
||||
? Colors.black
|
||||
: Colors.white;
|
||||
final scheme = darkTheme.colorScheme.copyWith(
|
||||
primary: primary,
|
||||
onPrimary: onPrimary,
|
||||
onSurfaceVariant: widget.neutralActions ? Colors.white70 : null,
|
||||
surface: surface,
|
||||
surfaceContainer: Color.alphaBlend(
|
||||
@@ -78,9 +119,15 @@ class _MornyeArtistSurfaceState extends State<MornyeArtistSurface> {
|
||||
Colors.white.withValues(alpha: 0.12),
|
||||
surface,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: widget.child,
|
||||
);
|
||||
return Theme(
|
||||
data: darkTheme.copyWith(
|
||||
scaffoldBackgroundColor: surface,
|
||||
colorScheme: scheme,
|
||||
),
|
||||
child: HeaderPalette(scheme: scheme, child: widget.child),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -115,70 +162,121 @@ class MornyeArtistHeader extends StatelessWidget {
|
||||
List<Widget> buildSlivers(BuildContext context) {
|
||||
final surface = Theme.of(context).colorScheme.surface;
|
||||
return [
|
||||
// Padding changes while the shell folds. Keep that dependency on the
|
||||
// toolbar so callers do not rebuild the artist's entire discography.
|
||||
Builder(
|
||||
builder: (context) => SliverAppBar(
|
||||
pinned: true,
|
||||
expandedHeight: 342 - MediaQuery.paddingOf(context).top,
|
||||
backgroundColor: surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leadingWidth: 64,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: HeaderCircleButton(
|
||||
icon: Icons.arrow_back,
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
// Keep safe-area dependencies inside this sliver: folding the shell
|
||||
// navigation must not rebuild the artist's entire discography.
|
||||
SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final width = constraints.crossAxisExtent;
|
||||
final textWidth = (width - 48).clamp(0.0, double.infinity);
|
||||
final textScaler = MediaQuery.textScalerOf(context);
|
||||
double textHeight(String text, TextStyle style) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: text,
|
||||
style: DefaultTextStyle.of(context).style.merge(style),
|
||||
),
|
||||
textDirection: Directionality.of(context),
|
||||
textScaler: textScaler,
|
||||
textAlign: TextAlign.center,
|
||||
)..layout(maxWidth: textWidth);
|
||||
final height = painter.height;
|
||||
painter.dispose();
|
||||
return height;
|
||||
}
|
||||
|
||||
final nameHeight = textHeight(
|
||||
name,
|
||||
const TextStyle(fontSize: 30, fontWeight: FontWeight.bold),
|
||||
);
|
||||
final hasLogo = logoUrl?.trim().isNotEmpty == true;
|
||||
final identityHeight = hasLogo
|
||||
? nameHeight.clamp(96.0, double.infinity)
|
||||
: nameHeight;
|
||||
final listenersHeight = listeners == null
|
||||
? 0.0
|
||||
: 7 + textHeight(listeners!, const TextStyle(fontSize: 13));
|
||||
final photoHeight = (width * 0.76).clamp(240.0, 340.0);
|
||||
final expandedHeight =
|
||||
photoHeight +
|
||||
identityHeight +
|
||||
listenersHeight +
|
||||
(actions.isEmpty ? 0 : 94) +
|
||||
32;
|
||||
return SliverAppBar(
|
||||
pinned: true,
|
||||
expandedHeight: expandedHeight - MediaQuery.paddingOf(context).top,
|
||||
backgroundColor: surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leadingWidth: 64,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.only(left: 12),
|
||||
child: HeaderCircleButton(
|
||||
icon: Icons.arrow_back,
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
title: AnimatedOpacity(
|
||||
opacity: showTitle ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||
title: AnimatedOpacity(
|
||||
opacity: showTitle ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.pin,
|
||||
background: _ArtistCollapsingArtwork(
|
||||
surface: surface,
|
||||
child: artwork,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 4),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildIdentity(),
|
||||
if (listeners != null) ...[
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
listeners!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
flexibleSpace: FlexibleSpaceBar(
|
||||
collapseMode: CollapseMode.pin,
|
||||
background: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_ArtistCollapsingArtwork(surface: surface, child: artwork),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: identityHeight,
|
||||
child: Center(child: _buildIdentity()),
|
||||
),
|
||||
if (listeners != null) ...[
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
listeners!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.white.withValues(alpha: 0.7),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (actions.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
height: 74,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: 24,
|
||||
children: actions,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (actions.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
spacing: 24,
|
||||
children: actions,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -204,7 +302,7 @@ class MornyeArtistHeader extends StatelessWidget {
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: Duration.zero,
|
||||
imageBuilder: (_, provider) => ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 320, maxHeight: 124),
|
||||
constraints: const BoxConstraints(maxWidth: 260, maxHeight: 96),
|
||||
child: Image(image: provider, fit: BoxFit.contain),
|
||||
),
|
||||
placeholder: (_, _) => fallback,
|
||||
@@ -262,15 +360,45 @@ class _ArtistCollapsingArtwork extends ConsumerWidget {
|
||||
child: RepaintBoundary(child: child),
|
||||
),
|
||||
),
|
||||
if (blurEnabled && fade < 1)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: FractionallySizedBox(
|
||||
heightFactor: 0.48,
|
||||
widthFactor: 1,
|
||||
child: ClipRect(
|
||||
child: ShaderMask(
|
||||
blendMode: BlendMode.dstIn,
|
||||
shaderCallback: (bounds) => const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Colors.white],
|
||||
stops: [0, 0.7],
|
||||
).createShader(bounds),
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16),
|
||||
child: ColoredBox(
|
||||
color: surface.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
stops: const [0, 0.52, 1],
|
||||
stops: const [0, 0.48, 0.82, 1],
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.15),
|
||||
surface.withValues(alpha: 0),
|
||||
surface.withValues(alpha: 0.55),
|
||||
surface,
|
||||
],
|
||||
),
|
||||
|
||||
Reference in new issue
Block a user