feat(artists): add branded headers and incremental release loading

This commit is contained in:
zarzet committed 2026-09-20 11:46:16 +07:00
1 parent e61ff64adc
commit dc25370350
11 files changed
+556 -145

No files matched your search

+14 -4
View File
@@ -566,6 +566,8 @@ class HeaderCircleButton extends ConsumerWidget {
required this.onPressed,
this.iconColor,
this.tonal = false,
this.glassTintColor,
this.glassTintOpacity,
});
final IconData icon;
@@ -578,6 +580,10 @@ class HeaderCircleButton extends ConsumerWidget {
/// Uses the same translucent fill as the local album's Play/Shuffle pills.
final bool tonal;
/// Optional tint for controls floating over artwork-colored surfaces.
final Color? glassTintColor;
final double? glassTintOpacity;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tokens = context.tokens;
@@ -622,10 +628,14 @@ class HeaderCircleButton extends ConsumerWidget {
widthFactor: 1,
child: MornyeGlass.navigation(
radius: 24,
tintColor: scheme.brightness == Brightness.dark
? Colors.black
: Colors.white,
tintOpacity: scheme.brightness == Brightness.dark ? 0.45 : 0.55,
tintColor:
glassTintColor ??
(scheme.brightness == Brightness.dark
? Colors.black
: Colors.white),
tintOpacity:
glassTintOpacity ??
(scheme.brightness == Brightness.dark ? 0.45 : 0.55),
blurEnabled: blurEnabled,
child: button,
),
+115 -30
View File
@@ -1,4 +1,9 @@
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';
@@ -61,8 +66,8 @@ class _MornyeArtistSurfaceState extends State<MornyeArtistSurface> {
data: darkTheme.copyWith(
scaffoldBackgroundColor: surface,
colorScheme: darkTheme.colorScheme.copyWith(
primary: widget.neutralActions ? Colors.white : null,
onPrimary: widget.neutralActions ? surface : null,
primary: widget.neutralActions ? Colors.white : palette.primary,
onPrimary: widget.neutralActions ? surface : palette.onPrimary,
onSurfaceVariant: widget.neutralActions ? Colors.white70 : null,
surface: surface,
surfaceContainer: Color.alphaBlend(
@@ -93,6 +98,7 @@ class MornyeArtistHeader extends StatelessWidget {
required this.actions,
required this.showTitle,
this.listeners,
this.logoUrl,
});
final String name;
@@ -100,6 +106,7 @@ class MornyeArtistHeader extends StatelessWidget {
final List<Widget> actions;
final bool showTitle;
final String? listeners;
final String? logoUrl;
@override
Widget build(BuildContext context) =>
@@ -137,25 +144,9 @@ class MornyeArtistHeader extends StatelessWidget {
),
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: Stack(
fit: StackFit.expand,
children: [
artwork,
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0, 0.52, 1],
colors: [
Colors.black.withValues(alpha: 0.15),
surface.withValues(alpha: 0),
surface,
],
),
),
),
],
background: _ArtistCollapsingArtwork(
surface: surface,
child: artwork,
),
),
),
@@ -165,15 +156,7 @@ class MornyeArtistHeader extends StatelessWidget {
padding: const EdgeInsets.fromLTRB(24, 0, 24, 4),
child: Column(
children: [
Text(
name,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
_buildIdentity(),
if (listeners != null) ...[
const SizedBox(height: 7),
Text(
@@ -199,4 +182,106 @@ class MornyeArtistHeader extends StatelessWidget {
),
];
}
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: 124),
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),
),
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: const [0, 0.52, 1],
colors: [
Colors.black.withValues(alpha: 0.15),
surface.withValues(alpha: 0),
surface,
],
),
),
),
ColoredBox(
key: const ValueKey('artist-artwork-fade'),
color: surface.withValues(alpha: fade),
),
],
),
);
}
}