Files
SpotiFLAC-Mobile/lib/widgets/album_detail_header.dart
T
zarzet 63e1a9b9c8 feat(ui): add Mornye glass theme and adaptive music player
Add a selectable Mornye theme with shared glass controls, dialogs, sheets, notifications, and collection headers while retaining Material components.

Update player artwork, lyrics transitions and highlighting, synchronized rolling time labels, volume controls, and compact navigation. Unify metadata, Library, search, and extension surfaces; preserve restored artwork and report extension load failures.

Validation: full Flutter analyzer clean; 713 tests passed and 4 skipped; pinned Dart formatting and diff checks passed; signed ARM64 and ARM32 release APKs built and verified.
2026-09-19 12:10:10 +07:00

765 lines
26 KiB
Dart

import 'dart:ui';
import 'package:flutter/cupertino.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/app_tokens.dart';
import 'package:spotiflac_android/theme/cover_palette.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/theme/mornye_icons.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
/// Collapsing album-detail header shared by the album, local-album, and
/// downloaded-album screens: full-bleed [background] (optionally blurred and
/// scrimmed), bottom gradient, centered square cover, title, optional
/// subtitle/meta/actions rows, and a circular back button. Content fades out
/// below 30% expansion; the [title] fades into the toolbar instead.
///
/// Colours come from a scheme derived from [paletteSource] (see [CoverPalette])
/// and are published to descendants through [HeaderPalette], so the header
/// follows both the artwork and the app's light/dark mode instead of assuming a
/// black backdrop with white text.
class AlbumDetailHeader extends StatelessWidget {
const AlbumDetailHeader({
super.key,
required this.title,
required this.expandedHeight,
required this.showTitleInAppBar,
required this.background,
this.paletteSource,
this.blurAndScrimBackground = true,
this.coverBuilder,
this.subtitle,
this.meta,
this.actions,
this.appBarActions,
this.appBarTitle,
this.leading,
this.backgroundColor,
this.immersive = false,
this.squareArtwork = true,
});
final String title;
final double expandedHeight;
final bool showTitleInAppBar;
/// Full-bleed background content (cover image, motion banner, placeholder).
final Widget background;
/// Cover URL or local path the header palette is derived from. Null keeps the
/// app's own colour scheme.
final String? paletteSource;
/// Blur the background and dim it 35%; disable for motion banners and
/// placeholder backgrounds.
final bool blurAndScrimBackground;
/// Builds the centered square cover for the given side length; null hides
/// the cover block (the header wraps it in the shared shadow + rounding).
final Widget Function(BuildContext context, double coverSize)? coverBuilder;
/// Artist line under the title (plain text or a clickable artist widget).
final Widget? subtitle;
/// "•"-joined meta row under the subtitle.
final Widget? meta;
/// Action row (play/shuffle, download-all, ...) under the meta row.
final Widget? actions;
/// Extra toolbar actions (top-right).
final List<Widget>? appBarActions;
/// Toolbar title when it should differ from [title] (e.g. a selection
/// count); defaults to [title].
final String? appBarTitle;
/// Replaces the default circular back button.
final Widget? leading;
final Color? backgroundColor;
final bool immersive;
/// Ordinary cover art stays 1:1; provider-supplied banners may fill a header.
final bool squareArtwork;
/// Shrinks long titles so up to three lines fit the header.
double _titleFontSize() {
final length = title.trim().length;
if (length > 45) return 18;
if (length > 30) return 21;
return 24;
}
@override
Widget build(BuildContext context) {
if (context.isMornye && (coverBuilder != null || immersive)) {
final scheme = Theme.of(context).colorScheme;
return HeaderPalette(
scheme: scheme,
child: Builder(
builder: (context) =>
SliverMainAxisGroup(slivers: buildSlivers(context)),
),
);
}
return CoverPaletteBuilder(
imageSource: paletteSource,
builder: (context, headerScheme) => _buildAppBar(context, headerScheme),
);
}
/// Keep the toolbar at the collection scroll-view level so it stays pinned
/// after the dynamically sized artwork and metadata have scrolled away.
List<Widget> buildSlivers(BuildContext context) {
if (!context.isMornye || (coverBuilder == null && !immersive)) {
return [this];
}
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,
),
),
SliverToBoxAdapter(
child: 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) ...[
const SizedBox(height: 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;
// iOS does not add horizontal safe-area padding in portrait. Give toolbar
// controls and header content an explicit inset so circular actions do not
// sit against the glass edge on either iPhone or iPad. Android retains its
// existing spacing.
final iosEdgeInset = detailHeaderEdgeInset(context);
// Scrim and gradient are drawn from the palette surface instead of black,
// so a light theme gets a light header with dark text and a dark theme
// keeps the familiar dark treatment — both tinted by the artwork.
final scrimColor = headerScheme.surface;
final onHeader = headerScheme.onSurface;
return SliverAppBar(
expandedHeight: expandedHeight,
pinned: true,
stretch: true,
backgroundColor: backgroundColor ?? headerScheme.surface,
surfaceTintColor: Colors.transparent,
title: AnimatedOpacity(
duration: tokens.motionFast,
opacity: showTitleInAppBar ? 1.0 : 0.0,
child: Text(
appBarTitle ?? title,
style: TextStyle(
color: onHeader,
fontWeight: FontWeight.w600,
fontSize: 16,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
flexibleSpace: LayoutBuilder(
builder: (context, constraints) {
final collapseRatio =
(constraints.maxHeight - kToolbarHeight) /
(expandedHeight - kToolbarHeight);
final showContent = collapseRatio > 0.3;
return FlexibleSpaceBar(
collapseMode: CollapseMode.pin,
background: Stack(
fit: StackFit.expand,
children: [
if (plainArtworkHeader)
ColoredBox(color: headerScheme.surface)
else if (blurAndScrimBackground)
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 32, sigmaY: 32),
child: background,
)
else
background,
if (!plainArtworkHeader && blurAndScrimBackground)
ColoredBox(color: scrimColor.withValues(alpha: 0.4)),
if (!plainArtworkHeader)
Positioned(
left: 0,
right: 0,
bottom: 0,
height: expandedHeight * 0.65,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
scrimColor.withValues(alpha: 0),
scrimColor.withValues(alpha: 0.92),
],
),
),
),
),
Positioned(
left: 20 + iosEdgeInset,
right: 20 + iosEdgeInset,
bottom: 40,
child: AnimatedOpacity(
duration: tokens.motionFast,
opacity: showContent ? 1.0 : 0.0,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (coverBuilder != null) ...[
Builder(
builder: (context) {
final coverSize = (constraints.maxWidth * 0.5)
.clamp(150.0, 210.0)
.toDouble();
return Container(
width: coverSize,
height: coverSize,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
plainArtworkHeader
? tokens.radiusCover
: tokens.radiusControl,
),
boxShadow: [
BoxShadow(
color: headerScheme.shadow.withValues(
alpha: 0.45,
),
blurRadius: 24,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(
tokens.radiusControl,
),
child: coverBuilder!(context, coverSize),
),
);
},
),
const SizedBox(height: 20),
],
Text(
title,
style: TextStyle(
color: onHeader,
fontSize: _titleFontSize(),
fontWeight: FontWeight.bold,
height: 1.2,
),
textAlign: TextAlign.center,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
if (subtitle != null) ...[
const SizedBox(height: 6),
subtitle!,
],
if (meta != null) ...[
const SizedBox(height: 12),
meta!,
],
if (actions != null) ...[
const SizedBox(height: 16),
actions!,
],
],
),
),
),
],
),
stretchModes: const [StretchMode.zoomBackground],
);
},
),
leadingWidth: kToolbarHeight + iosEdgeInset,
leading: Padding(
padding: EdgeInsets.only(left: iosEdgeInset),
child:
leading ??
IconButton.filledTonal(
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
icon: Icon(
context.isMornye ? Icons.chevron_left : Icons.arrow_back,
),
style: IconButton.styleFrom(
minimumSize: Size.square(tokens.minTouchTarget),
backgroundColor: headerScheme.surfaceContainerHigh.withValues(
alpha: 0.75,
),
foregroundColor: headerScheme.onSurfaceVariant,
),
onPressed: () => Navigator.pop(context),
),
),
actionsPadding: EdgeInsets.only(right: iosEdgeInset),
actions: appBarActions,
);
}
}
/// Primary "play" pill + shuffle circle used by the local and downloaded album
/// headers. Colours follow the [HeaderPalette].
class AlbumPlayActions extends StatelessWidget {
const AlbumPlayActions({
super.key,
required this.playLabel,
required this.shuffleTooltip,
required this.onPlay,
required this.onShuffle,
});
final String playLabel;
final String shuffleTooltip;
final VoidCallback onPlay;
final VoidCallback onShuffle;
@override
Widget build(BuildContext context) {
final tokens = context.tokens;
final scheme = HeaderPalette.of(context);
if (context.isMornye) {
return Row(
children: [
Expanded(
child: HeaderFilledButton(
icon: CupertinoIcons.play_fill,
label: playLabel,
onPressed: onPlay,
tonal: true,
),
),
const SizedBox(width: 12),
Expanded(
child: _MornyeHeaderButton(
icon: CupertinoIcons.shuffle,
label: shuffleTooltip,
onPressed: onShuffle,
),
),
],
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Flexible(
child: FilledButton.icon(
onPressed: onPlay,
icon: const Icon(Icons.play_arrow, size: 20),
label: Text(
playLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
style: FilledButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
minimumSize: Size(0, tokens.minTouchTarget),
shape: const StadiumBorder(),
),
),
),
SizedBox(width: tokens.gapMd),
IconButton.filledTonal(
tooltip: shuffleTooltip,
onPressed: onShuffle,
icon: const Icon(Icons.shuffle),
style: IconButton.styleFrom(
minimumSize: Size.square(tokens.minTouchTarget),
backgroundColor: scheme.secondaryContainer.withValues(alpha: 0.8),
foregroundColor: scheme.onSecondaryContainer,
),
),
],
);
}
}
/// Primary action pill in a detail header (download all, play all). Reads its
/// colours from [HeaderPalette] so it works on light and dark headers alike.
class HeaderFilledButton extends StatelessWidget {
const HeaderFilledButton({
super.key,
required this.icon,
required this.label,
required this.onPressed,
this.tonal = false,
});
final IconData icon;
final String label;
final VoidCallback? onPressed;
final bool tonal;
@override
Widget build(BuildContext context) {
final tokens = context.tokens;
final scheme = HeaderPalette.of(context);
if (context.isMornye) {
return _MornyeHeaderButton(
icon: mornyeIconFor(icon),
label: label,
onPressed: onPressed,
prominent: !tonal,
);
}
return FilledButton.icon(
onPressed: onPressed,
icon: Icon(icon, size: 18),
label: Text(label, maxLines: 1, overflow: TextOverflow.ellipsis),
style: FilledButton.styleFrom(
backgroundColor: scheme.primary,
foregroundColor: scheme.onPrimary,
disabledBackgroundColor: scheme.primary.withValues(alpha: 0.4),
disabledForegroundColor: scheme.onPrimary.withValues(alpha: 0.7),
minimumSize: Size(0, tokens.minTouchTarget),
shape: const StadiumBorder(),
),
);
}
}
/// Circular header icon button (add-to-playlist, love-all, ...). Sized to the
/// minimum touch target and tinted from the [HeaderPalette].
class HeaderCircleButton extends ConsumerWidget {
const HeaderCircleButton({
super.key,
required this.icon,
required this.tooltip,
required this.onPressed,
this.iconColor,
this.tonal = false,
});
final IconData icon;
final String tooltip;
final VoidCallback? onPressed;
/// Overrides the palette foreground, e.g. to mark an active "loved" state.
final Color? iconColor;
/// Uses the same translucent fill as the local album's Play/Shuffle pills.
final bool tonal;
@override
Widget build(BuildContext context, WidgetRef ref) {
final tokens = context.tokens;
final scheme = HeaderPalette.of(context);
if (context.isMornye) {
if (tonal) {
return Tooltip(
message: tooltip,
child: SizedBox.square(
dimension: 44,
child: CupertinoButton(
padding: EdgeInsets.zero,
borderRadius: BorderRadius.circular(24),
color: scheme.onSurface.withValues(
alpha: scheme.brightness == Brightness.dark ? 0.10 : 0.06,
),
onPressed: onPressed,
child: Icon(
mornyeIconFor(icon),
size: 22,
color: onPressed == null
? scheme.onSurfaceVariant
: iconColor ?? scheme.primary,
),
),
),
);
}
final blurEnabled =
!ref.watch(lowEndDeviceProvider) ||
ref.watch(backdropBlurEnabledProvider);
final button = IconButton(
onPressed: onPressed,
tooltip: tooltip,
icon: Icon(mornyeIconFor(icon), size: 22),
style: IconButton.styleFrom(
minimumSize: const Size.square(44),
foregroundColor: iconColor ?? scheme.onSurface,
),
);
return Center(
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,
blurEnabled: blurEnabled,
child: button,
),
);
}
return IconButton.filledTonal(
onPressed: onPressed,
icon: Icon(icon, size: 22),
tooltip: tooltip,
style: IconButton.styleFrom(
minimumSize: Size.square(tokens.minTouchTarget),
backgroundColor: scheme.surfaceContainerHighest.withValues(alpha: 0.7),
foregroundColor: iconColor ?? scheme.onSurfaceVariant,
),
);
}
}
class _MornyeHeaderButton extends StatelessWidget {
const _MornyeHeaderButton({
required this.icon,
required this.label,
required this.onPressed,
this.prominent = false,
});
final IconData icon;
final String label;
final VoidCallback? onPressed;
final bool prominent;
@override
Widget build(BuildContext context) {
final scheme = HeaderPalette.of(context);
final foreground = onPressed == null
? scheme.onSurfaceVariant
: prominent
? scheme.onPrimary
: scheme.primary;
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 50),
child: CupertinoButton(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
borderRadius: BorderRadius.circular(28),
color: prominent
? scheme.primary
: scheme.onSurface.withValues(
alpha: scheme.brightness == Brightness.dark ? 0.10 : 0.06,
),
onPressed: onPressed,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 20, color: foreground),
const SizedBox(width: 8),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: foreground,
),
),
),
],
),
),
);
}
}
/// "•"-joined row of [HeaderMetaItem]s shown under the header subtitle
/// (track count, duration, quality, ...).
class HeaderMetaRow extends StatelessWidget {
const HeaderMetaRow({super.key, required this.items});
final List<Widget> items;
@override
Widget build(BuildContext context) {
final scheme = HeaderPalette.of(context);
final parts = <Widget>[];
for (final item in items) {
if (parts.isNotEmpty) {
parts.add(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6),
child: Text(
'•',
style: TextStyle(color: scheme.onSurfaceVariant, fontSize: 12),
),
),
);
}
parts.add(item);
}
return Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
runSpacing: 4,
children: parts,
);
}
}
/// Single meta label (optional leading icon) for [HeaderMetaRow].
class HeaderMetaItem extends StatelessWidget {
const HeaderMetaItem(this.label, {super.key, this.icon});
final String label;
final IconData? icon;
@override
Widget build(BuildContext context) {
final scheme = HeaderPalette.of(context);
final textStyle = TextStyle(
color: context.isMornye ? scheme.onSurfaceVariant : scheme.onSurface,
fontSize: 13,
fontWeight: FontWeight.w500,
);
if (icon == null) return Text(label, style: textStyle);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 15, color: scheme.onSurface),
const SizedBox(width: 4),
Text(label, style: textStyle),
],
);
}
}