mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-29 06:10:35 +02:00
feat(banner): HLS motion-artwork header banners and audio-quality badges
Add a MotionHeaderBanner (video_player) that plays a looping muted HLS header video with a static image fallback for artist, album, and playlist screens. The Go backend now exposes header_video, header_image, and audio_traits from extensions. Album/playlist headers show the release year and Dolby Atmos / Lossless badges inline, full date and song count in a footer, a centered square cover when no video is present, and a full-bleed video when one is.
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
|
||||
final _log = AppLogger('MotionHeaderBanner');
|
||||
|
||||
class MotionHeaderBanner extends StatefulWidget {
|
||||
final String videoUrl;
|
||||
final Widget fallback;
|
||||
final BoxFit fit;
|
||||
final Alignment alignment;
|
||||
|
||||
const MotionHeaderBanner({
|
||||
super.key,
|
||||
required this.videoUrl,
|
||||
required this.fallback,
|
||||
this.fit = BoxFit.cover,
|
||||
this.alignment = Alignment.topCenter,
|
||||
});
|
||||
|
||||
@override
|
||||
State<MotionHeaderBanner> createState() => _MotionHeaderBannerState();
|
||||
}
|
||||
|
||||
class _MotionHeaderBannerState extends State<MotionHeaderBanner>
|
||||
with WidgetsBindingObserver {
|
||||
VideoPlayerController? _controller;
|
||||
bool _ready = false;
|
||||
bool _failed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_initialize();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(MotionHeaderBanner oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.videoUrl != widget.videoUrl) {
|
||||
_disposeController();
|
||||
_ready = false;
|
||||
_failed = false;
|
||||
_initialize();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initialize() async {
|
||||
final url = widget.videoUrl.trim();
|
||||
if (url.isEmpty) {
|
||||
setState(() => _failed = true);
|
||||
return;
|
||||
}
|
||||
|
||||
final controller = VideoPlayerController.networkUrl(
|
||||
Uri.parse(url),
|
||||
formatHint: VideoFormat.hls,
|
||||
);
|
||||
_controller = controller;
|
||||
try {
|
||||
await controller.initialize();
|
||||
if (!mounted) {
|
||||
await controller.dispose();
|
||||
return;
|
||||
}
|
||||
await controller.setVolume(0);
|
||||
await controller.setLooping(true);
|
||||
await controller.play();
|
||||
setState(() => _ready = true);
|
||||
} catch (e) {
|
||||
_log.w('Failed to play motion banner: $e');
|
||||
if (!mounted) return;
|
||||
setState(() => _failed = true);
|
||||
}
|
||||
}
|
||||
|
||||
void _disposeController() {
|
||||
final controller = _controller;
|
||||
_controller = null;
|
||||
controller?.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
final controller = _controller;
|
||||
if (controller == null || !_ready) return;
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
controller.play();
|
||||
} else if (state == AppLifecycleState.paused ||
|
||||
state == AppLifecycleState.inactive) {
|
||||
controller.pause();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_disposeController();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = _controller;
|
||||
final showVideo = _ready && !_failed && controller != null;
|
||||
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
widget.fallback,
|
||||
AnimatedOpacity(
|
||||
opacity: showVideo ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 400),
|
||||
child: showVideo
|
||||
? FittedBox(
|
||||
fit: widget.fit,
|
||||
alignment: widget.alignment,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: SizedBox(
|
||||
width: controller.value.size.width,
|
||||
height: controller.value.size.height,
|
||||
child: VideoPlayer(controller),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user