mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-02 19:05:57 +02:00
95f5ae610e
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.
132 lines
3.3 KiB
Dart
132 lines
3.3 KiB
Dart
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(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|