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:
zarzet committed 2026-09-25 01:20:54 +07:00
1 parent bb057fd210
commit 9a62244263
8 files changed
+697 -287

No files matched your search

-1
View File
@@ -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),
+1
View File
@@ -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),
);
}
+37 -33
View File
@@ -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);
}
+2 -11
View File
@@ -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(
+179 -128
View File
@@ -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,
),
+85 -32
View File
@@ -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(
+207 -79
View File
@@ -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,
],
),