mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
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.
416 lines
15 KiB
Dart
416 lines
15 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,
|
|
});
|
|
|
|
final String name;
|
|
final Widget artwork;
|
|
final List<Widget> actions;
|
|
final bool showTitle;
|
|
final String? listeners;
|
|
final String? logoUrl;
|
|
|
|
@override
|
|
Widget build(BuildContext context) =>
|
|
SliverMainAxisGroup(slivers: buildSlivers(context));
|
|
|
|
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,
|
|
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,
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
];
|
|
}
|
|
|
|
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: 260, 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: 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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|