Files
SpotiFLAC-Mobile/lib/widgets/mornye_artist_header.dart
T

451 lines
16 KiB
Dart

import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
import 'package:spotiflac_android/theme/cover_palette.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/widgets/album_detail_header.dart';
/// Share the same artwork-derived dark surface while loading and after the
/// artist metadata arrives, including when the app itself uses light mode.
class MornyeArtistSurface extends StatefulWidget {
const MornyeArtistSurface({
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.
final bool neutralActions;
@override
State<MornyeArtistSurface> createState() => _MornyeArtistSurfaceState();
}
class _MornyeArtistSurfaceState extends State<MornyeArtistSurface> {
@override
void dispose() {
ShellNavigationService.clearChromeBrightness(this);
super.dispose();
}
@override
Widget build(BuildContext context) {
final darkTheme = MornyeTheme.build(Brightness.dark);
return Theme(
data: darkTheme.copyWith(
colorScheme: darkTheme.colorScheme.copyWith(primary: Colors.grey),
),
child: CoverPaletteBuilder(
imageSource: widget.imageSource,
builder: (context, palette) {
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(
owner: this,
route: route,
brightness: Brightness.dark,
surface: surface,
);
}
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(
Colors.white.withValues(alpha: 0.08),
surface,
),
surfaceContainerHigh: Color.alphaBlend(
Colors.white.withValues(alpha: 0.12),
surface,
),
);
return Theme(
data: darkTheme.copyWith(
scaffoldBackgroundColor: surface,
colorScheme: scheme,
),
child: HeaderPalette(scheme: scheme, child: widget.child),
);
},
);
},
),
);
}
}
/// Portrait and centered identity from Mornye's MobileArtistView. Actions stay
/// supplied by the screen so remote artists retain download/favorite behavior.
class MornyeArtistHeader extends StatelessWidget {
const MornyeArtistHeader({
super.key,
required this.name,
required this.artwork,
required this.actions,
required this.showTitle,
this.listeners,
this.logoUrl,
this.badge,
});
final String name;
final Widget artwork;
final List<Widget> actions;
final bool showTitle;
final String? listeners;
final String? logoUrl;
final Widget? badge;
// A SliverMainAxisGroup clamps negative overlap, preventing this header from
// receiving the overscroll used for its stretch animation.
@override
Widget build(BuildContext context) => buildSlivers(context).single;
List<Widget> buildSlivers(BuildContext context) {
final surface = Theme.of(context).colorScheme.surface;
return [
// 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,
stretch: !MediaQuery.disableAnimationsOf(context),
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,
),
),
),
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
stretchModes: const [StretchMode.zoomBackground],
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: [
if (badge != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: badge,
),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: identityHeight,
),
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: 16),
SizedBox(
height: 74,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 24,
children: actions,
),
),
],
],
),
),
),
],
),
),
);
},
),
];
}
Widget _buildIdentity() {
final fallback = Text(
name,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Colors.white,
),
);
final logo = logoUrl?.trim();
if (logo == null || logo.isEmpty) return fallback;
return Semantics(
label: name,
image: true,
excludeSemantics: true,
child: CachedNetworkImage(
imageUrl: logo,
fadeInDuration: Duration.zero,
fadeOutDuration: Duration.zero,
imageBuilder: (_, provider) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320, maxHeight: 96),
child: Image(image: provider, fit: BoxFit.contain),
),
placeholder: (_, _) => fallback,
errorWidget: (_, _, _) => fallback,
),
);
}
}
/// Follow the sliver's actual collapse extent so scrolling back restores the
/// same artwork immediately, without a timer or rebuilding the discography.
class _ArtistCollapsingArtwork extends ConsumerWidget {
const _ArtistCollapsingArtwork({required this.surface, required this.child});
final Color surface;
final Widget child;
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = context
.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final range = settings == null
? 0.0
: settings.maxExtent - settings.minExtent;
final collapse = range <= 0
? 0.0
: ((settings!.maxExtent - settings.currentExtent) / range).clamp(
0.0,
1.0,
);
final fade = const Interval(
0.30,
0.88,
curve: Curves.easeInOut,
).transform(collapse);
final blur = 18 * Curves.easeOut.transform(collapse);
final blurEnabled =
!MediaQuery.disableAnimationsOf(context) &&
(!ref.watch(lowEndDeviceProvider) ||
ref.watch(backdropBlurEnabledProvider));
return ClipRect(
child: Stack(
fit: StackFit.expand,
children: [
TickerMode(
enabled: fade < 1,
child: ImageFiltered(
enabled: blurEnabled && blur > 0 && fade < 1,
imageFilter: ImageFilter.blur(
sigmaX: blur,
sigmaY: blur,
tileMode: TileMode.clamp,
),
child: LayoutBuilder(
builder: (context, constraints) {
final height = settings?.maxExtent ?? constraints.maxHeight;
final zoom = height > 0
? (constraints.maxHeight / height).clamp(
1.0,
double.infinity,
)
: 1.0;
// Stretch the photograph uniformly around its top edge.
// Identity and actions remain outside this transform, moving
// down with the sliver without growing along with the image.
return OverflowBox(
alignment: Alignment.topCenter,
minHeight: height,
maxHeight: height,
child: Transform.scale(
scale: zoom,
alignment: Alignment.topCenter,
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.48, 0.82, 1],
colors: [
Colors.black.withValues(alpha: 0.15),
surface.withValues(alpha: 0),
surface.withValues(alpha: 0.55),
surface,
],
),
),
),
ColoredBox(
key: const ValueKey('artist-artwork-fade'),
color: surface.withValues(alpha: fade),
),
],
),
);
}
}