mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 05:12:00 +02:00
fix(player): preserve offline motion artwork and smooth cover changes
This commit is contained in:
@@ -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}'),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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++,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user