Files
SpotiFLAC-Mobile/lib/widgets/mornye_player_artwork.dart
T
zarzet 6038ce5777 perf(mornye): release outgoing artwork decoders when replaced
Retain the outgoing decoder only while its frame remains displayed, then release its provider subscription as soon as replacement artwork is ready. Verify decoder disposal while preserving cover transitions.
2026-09-20 17:20:52 +07:00

101 lines
3.3 KiB
Dart

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 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) {
final fallback = Align(
alignment: Alignment.topCenter,
child: AspectRatio(
aspectRatio: 1,
child: PlayerArtwork(
artUri: widget.mediaItem.artUri?.toString(),
colorScheme: Theme.of(context).colorScheme,
cacheWidth:
(MediaQuery.sizeOf(context).width *
MediaQuery.devicePixelRatioOf(context))
.round(),
),
),
);
if (MediaQuery.disableAnimationsOf(context)) {
_displayedSource = null;
_displayedController = null;
return fallback;
}
final videoUrl = widget.videoUrl;
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;
}
// Retain the outgoing decoder only while it is still displayed. Watching
// it before accepting the new frame keeps its heap alive until a later
// unrelated rebuild, even after the old banner has been removed.
if (_displayedSource != null && _displayedSource != videoUrl) {
ref.watch(playerArtworkVideoProvider(_displayedSource!));
}
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();
},
),
],
);
}
}