fix(player): preserve offline motion artwork and smooth cover changes

This commit is contained in:
zarzet
2026-09-20 12:20:24 +07:00
parent dc25370350
commit 0c930c691f
25 changed files with 1464 additions and 339 deletions
+15
View File
@@ -3,6 +3,8 @@ import 'package:flutter/cupertino.dart' show CupertinoIcons;
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/music_player_provider.dart';
import 'package:spotiflac_android/providers/player_motion_artwork_provider.dart';
import 'package:spotiflac_android/providers/player_artwork_video_provider.dart';
import 'package:spotiflac_android/providers/runtime_profile_provider.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/screens/now_playing_screen.dart';
@@ -43,6 +45,19 @@ class _MiniPlayerState extends ConsumerState<MiniPlayer> {
final colorScheme = Theme.of(context).colorScheme;
final mornye = context.isMornye;
final compact = mornye && widget.compact;
if (mornye && !MediaQuery.disableAnimationsOf(context)) {
final artwork = ref
.watch(
playerMotionArtworkProvider((
album: mediaItem.album ?? '',
artist: mediaItem.artist ?? '',
)),
)
.value;
if (artwork != null) {
ref.watch(playerArtworkVideoProvider(artwork.source));
}
}
final player = Dismissible(
key: ValueKey('mini-player-${mediaItem.id}'),
+135
View File
@@ -0,0 +1,135 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
/// Samples rendered video and its fade, rather than a static album palette.
/// Read back only 48 pixels across, at most three times a second when visible.
class MornyeArtworkContrast extends StatefulWidget {
const MornyeArtworkContrast({
super.key,
required this.enabled,
required this.targets,
required this.onChanged,
required this.child,
});
final bool enabled;
final Map<String, GlobalKey> targets;
final ValueChanged<Map<String, Color>> onChanged;
final Widget child;
@override
State<MornyeArtworkContrast> createState() => _MornyeArtworkContrastState();
}
class _MornyeArtworkContrastState extends State<MornyeArtworkContrast> {
final _background = GlobalKey();
Timer? _timer;
bool _sampling = false;
Map<String, Color> _colors = {};
@override
void initState() {
super.initState();
_schedule();
}
@override
void didUpdateWidget(MornyeArtworkContrast oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.enabled != widget.enabled) _schedule();
}
void _schedule() {
_timer?.cancel();
if (!widget.enabled) {
_colors = {};
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) => _sample());
_timer = Timer.periodic(
const Duration(milliseconds: 333),
(_) => _sample(),
);
}
Future<void> _sample() async {
if (!mounted || !widget.enabled || _sampling) return;
final lifecycle = WidgetsBinding.instance.lifecycleState;
if (lifecycle != null && lifecycle != AppLifecycleState.resumed) return;
final route = ModalRoute.of(context);
if (route != null &&
(!route.isCurrent || route.animation?.isAnimating == true)) {
return;
}
final boundary = _background.currentContext?.findRenderObject();
if (boundary is! RenderRepaintBoundary ||
!boundary.hasSize ||
boundary.debugNeedsPaint ||
boundary.size.isEmpty) {
return;
}
_sampling = true;
ui.Image? image;
try {
final scale = (48 / boundary.size.width).clamp(0.01, 1.0);
image = await boundary.toImage(pixelRatio: scale);
final pixels = await image.toByteData(format: ui.ImageByteFormat.rawRgba);
if (!mounted || !widget.enabled || pixels == null) return;
final origin = boundary.localToGlobal(Offset.zero);
final next = <String, Color>{};
for (final entry in widget.targets.entries) {
final target = entry.value.currentContext?.findRenderObject();
if (target is! RenderBox || !target.hasSize || !target.attached) {
continue;
}
final rect = target.localToGlobal(Offset.zero) - origin & target.size;
final left = (rect.left * scale).floor().clamp(0, image.width);
final right = (rect.right * scale).ceil().clamp(0, image.width);
final top = (rect.top * scale).floor().clamp(0, image.height);
final bottom = (rect.bottom * scale).ceil().clamp(0, image.height);
var luminance = 0.0;
var count = 0;
for (var y = top; y < bottom; y++) {
for (var x = left; x < right; x++) {
final index = (y * image.width + x) * 4;
luminance += Color.fromARGB(
255,
pixels.getUint8(index),
pixels.getUint8(index + 1),
pixels.getUint8(index + 2),
).computeLuminance();
count++;
}
}
if (count == 0) continue;
// Hysteresis avoids flicker when frames hover around the crossover.
final threshold = _colors[entry.key] == Colors.black ? 0.16 : 0.20;
next[entry.key] = luminance / count > threshold
? Colors.black
: Colors.white;
}
if (next.entries.any((entry) => _colors[entry.key] != entry.value)) {
_colors = next;
widget.onChanged(next);
}
} catch (_) {
// Unreadable surfaces keep the light controls over the dark fade.
} finally {
image?.dispose();
_sampling = false;
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) =>
RepaintBoundary(key: _background, child: widget.child);
}
+64 -12
View File
@@ -1,21 +1,35 @@
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/player_artwork_video_provider.dart';
import 'package:spotiflac_android/widgets/motion_header_banner.dart';
import 'package:spotiflac_android/widgets/player_artwork.dart';
import 'package:video_player/video_player.dart';
class MornyePlayerArtwork extends StatelessWidget {
class MornyePlayerArtwork extends ConsumerStatefulWidget {
const MornyePlayerArtwork({
super.key,
required this.mediaItem,
this.videoUrl,
this.onAspectRatioChanged,
this.onError,
this.resolvingVideo = false,
});
final MediaItem mediaItem;
final String? videoUrl;
final ValueChanged<double>? onAspectRatioChanged;
final VoidCallback? onError;
final bool resolvingVideo;
@override
ConsumerState<MornyePlayerArtwork> createState() =>
_MornyePlayerArtworkState();
}
class _MornyePlayerArtworkState extends ConsumerState<MornyePlayerArtwork> {
String? _displayedSource;
VideoPlayerController? _displayedController;
@override
Widget build(BuildContext context) {
@@ -24,7 +38,7 @@ class MornyePlayerArtwork extends StatelessWidget {
child: AspectRatio(
aspectRatio: 1,
child: PlayerArtwork(
artUri: mediaItem.artUri?.toString(),
artUri: widget.mediaItem.artUri?.toString(),
colorScheme: Theme.of(context).colorScheme,
cacheWidth:
(MediaQuery.sizeOf(context).width *
@@ -33,15 +47,53 @@ class MornyePlayerArtwork extends StatelessWidget {
),
),
);
if (MediaQuery.disableAnimationsOf(context)) return fallback;
return videoUrl == null
? fallback
: MotionHeaderBanner(
key: ValueKey(videoUrl),
videoUrl: videoUrl!,
fallback: fallback,
onAspectRatioChanged: onAspectRatioChanged,
onError: onError,
);
if (MediaQuery.disableAnimationsOf(context)) {
_displayedSource = null;
_displayedController = null;
return fallback;
}
final videoUrl = widget.videoUrl;
// Keep one outgoing decoder alive until the next cover is ready. Rapid
// skips must not replace a visible frame with a loading placeholder.
if (_displayedSource != null && _displayedSource != videoUrl) {
ref.watch(playerArtworkVideoProvider(_displayedSource!));
}
final prepared = videoUrl == null
? null
: ref.watch(playerArtworkVideoProvider(videoUrl));
if (prepared?.hasError == true) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) widget.onError?.call();
});
}
if (prepared?.value case final controller?) {
_displayedSource = videoUrl;
_displayedController = controller;
} else if (!widget.resolvingVideo &&
(videoUrl == null || prepared?.hasError == true)) {
_displayedSource = null;
_displayedController = null;
}
return Stack(
fit: StackFit.expand,
children: [
fallback,
if (_displayedController != null)
MotionHeaderBanner(
videoUrl: _displayedSource!,
fallback: const SizedBox.shrink(),
controller: _displayedController,
fadeDuration: Duration.zero,
onAspectRatioChanged: (ratio) {
if (_displayedSource == widget.videoUrl) {
widget.onAspectRatioChanged?.call(ratio);
}
},
onError: () {
if (_displayedSource == widget.videoUrl) widget.onError?.call();
},
),
],
);
}
}
+1 -3
View File
@@ -58,9 +58,7 @@ class MornyePlayerBackground extends StatelessWidget {
child: Stack(
fit: StackFit.expand,
children: [
AnimatedPositioned(
duration: motion,
curve: Curves.easeInOutCubic,
Positioned(
left: 0,
right: 0,
top: 0,
@@ -12,10 +12,12 @@ class MornyePlayerFavoriteButton extends ConsumerStatefulWidget {
super.key,
required this.mediaItem,
this.compact = false,
this.color,
});
final MediaItem mediaItem;
final bool compact;
final Color? color;
@override
ConsumerState<MornyePlayerFavoriteButton> createState() =>
@@ -66,7 +68,7 @@ class _MornyePlayerFavoriteButtonState
tooltip: loved
? context.l10n.trackOptionRemoveFromLoved
: context.l10n.trackOptionAddToLoved,
color: Theme.of(context).colorScheme.onSurface,
color: widget.color ?? Theme.of(context).colorScheme.onSurface,
iconSize: widget.compact ? 20 : 24,
onPressed: _saving ? null : _toggle,
),
+9 -7
View File
@@ -36,7 +36,9 @@ final systemVolumeWriterProvider = Provider<Future<void> Function(double)>((
});
class MornyeVolumeControl extends ConsumerStatefulWidget {
const MornyeVolumeControl({super.key});
const MornyeVolumeControl({super.key, this.foreground = Colors.white});
final Color foreground;
@override
ConsumerState<MornyeVolumeControl> createState() =>
@@ -103,10 +105,10 @@ class _MornyeVolumeControlState extends ConsumerState<MornyeVolumeControl> {
padding: const EdgeInsets.symmetric(horizontal: 28),
child: Row(
children: [
const Icon(
Icon(
CupertinoIcons.speaker_fill,
size: 16,
color: Colors.white54,
color: widget.foreground.withValues(alpha: 0.54),
),
const SizedBox(width: 10),
Expanded(
@@ -117,8 +119,8 @@ class _MornyeVolumeControlState extends ConsumerState<MornyeVolumeControl> {
opacity: volume == null ? 0.4 : 1,
child: MornyePlayerSlider(
value: value,
activeColor: Colors.white70,
inactiveColor: Colors.white12,
activeColor: widget.foreground.withValues(alpha: 0.70),
inactiveColor: widget.foreground.withValues(alpha: 0.12),
onChangeStart: (_) => _dragging = true,
onChanged: _setVolume,
onChangeEnd: (value) {
@@ -131,10 +133,10 @@ class _MornyeVolumeControlState extends ConsumerState<MornyeVolumeControl> {
),
),
const SizedBox(width: 10),
const Icon(
Icon(
CupertinoIcons.speaker_3_fill,
size: 16,
color: Colors.white54,
color: widget.foreground.withValues(alpha: 0.54),
),
],
),
+26 -3
View File
@@ -14,6 +14,8 @@ class MotionHeaderBanner extends StatefulWidget {
final Alignment alignment;
final ValueChanged<double>? onAspectRatioChanged;
final VoidCallback? onError;
final VideoPlayerController? controller;
final Duration fadeDuration;
const MotionHeaderBanner({
super.key,
@@ -23,6 +25,8 @@ class MotionHeaderBanner extends StatefulWidget {
this.alignment = Alignment.topCenter,
this.onAspectRatioChanged,
this.onError,
this.controller,
this.fadeDuration = const Duration(milliseconds: 400),
});
@override
@@ -32,6 +36,7 @@ class MotionHeaderBanner extends StatefulWidget {
class _MotionHeaderBannerState extends State<MotionHeaderBanner>
with WidgetsBindingObserver {
VideoPlayerController? _controller;
bool _ownsController = true;
bool _ready = false;
bool _failed = false;
bool _headerVisible = true;
@@ -86,7 +91,8 @@ class _MotionHeaderBannerState extends State<MotionHeaderBanner>
@override
void didUpdateWidget(MotionHeaderBanner oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.videoUrl != widget.videoUrl) {
if (oldWidget.videoUrl != widget.videoUrl ||
oldWidget.controller != widget.controller) {
_disposeController();
_ready = false;
_failed = false;
@@ -95,6 +101,19 @@ class _MotionHeaderBannerState extends State<MotionHeaderBanner>
}
Future<void> _initialize() async {
final prepared = widget.controller;
if (prepared != null && prepared.value.isInitialized) {
_ownsController = false;
_controller = prepared;
_ready = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !identical(prepared, _controller)) return;
widget.onAspectRatioChanged?.call(prepared.value.aspectRatio);
_syncPlayback();
});
return;
}
_ownsController = true;
final url = widget.videoUrl.trim();
if (url.isEmpty) {
setState(() => _failed = true);
@@ -136,7 +155,11 @@ class _MotionHeaderBannerState extends State<MotionHeaderBanner>
void _disposeController() {
final controller = _controller;
_controller = null;
controller?.dispose();
if (_ownsController) {
controller?.dispose();
} else {
controller?.pause();
}
}
@override
@@ -163,7 +186,7 @@ class _MotionHeaderBannerState extends State<MotionHeaderBanner>
widget.fallback,
AnimatedOpacity(
opacity: showVideo ? 1.0 : 0.0,
duration: const Duration(milliseconds: 400),
duration: widget.fadeDuration,
child: showVideo
? FittedBox(
fit: widget.fit,
+4
View File
@@ -66,6 +66,10 @@ class _PlaybackSeekSliderState extends State<PlaybackSeekSlider> {
child: IgnorePointer(
ignoring: !enabled,
child: MornyePlayerSlider(
activeColor:
SliderTheme.of(context).activeTrackColor ?? Colors.white,
inactiveColor:
SliderTheme.of(context).inactiveTrackColor ?? Colors.white12,
value: enabled ? currentMs : 0,
max: maxMs,
onChangeStart: (_) => _gestureGeneration++,
+3 -1
View File
@@ -40,8 +40,9 @@ class PlayerArtwork extends StatelessWidget {
fit: BoxFit.cover,
cacheManager: CoverCacheManager.instance,
memCacheWidth: cacheWidth,
fadeInDuration: const Duration(milliseconds: 150),
fadeInDuration: Duration.zero,
fadeOutDuration: const Duration(milliseconds: 0),
useOldImageOnUrlChange: true,
placeholder: (_, _) => placeholder,
errorWidget: (_, _, _) => placeholder,
);
@@ -54,6 +55,7 @@ class PlayerArtwork extends StatelessWidget {
File(path),
fit: BoxFit.cover,
cacheWidth: cacheWidth,
gaplessPlayback: true,
errorBuilder: (_, _, _) => placeholder,
);
}