mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
Add a selectable Mornye theme with shared glass controls, dialogs, sheets, notifications, and collection headers while retaining Material components. Update player artwork, lyrics transitions and highlighting, synchronized rolling time labels, volume controls, and compact navigation. Unify metadata, Library, search, and extension surfaces; preserve restored artwork and report extension load failures. Validation: full Flutter analyzer clean; 713 tests passed and 4 skipped; pinned Dart formatting and diff checks passed; signed ARM64 and ARM32 release APKs built and verified.
171 lines
4.8 KiB
Dart
171 lines
4.8 KiB
Dart
import 'package:flutter/foundation.dart' show ValueListenable;
|
|
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;
|
|
bool _headerVisible = true;
|
|
ValueListenable<TickerModeData>? _tickerMode;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addObserver(this);
|
|
_initialize();
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
final header = context
|
|
.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
|
|
// A collapsed FlexibleSpaceBar hides its background with opacity only;
|
|
// its TickerMode remains enabled while the pinned toolbar is visible.
|
|
_headerVisible = header == null || header.currentExtent > header.minExtent;
|
|
// TickerMode is off while this subtree is hidden: covered by an opaque
|
|
// route (Navigator offstages it) or on an inactive shell tab. Follow it
|
|
// so the decoder doesn't keep running behind other screens.
|
|
final notifier = TickerMode.getValuesNotifier(context);
|
|
if (!identical(notifier, _tickerMode)) {
|
|
_tickerMode?.removeListener(_syncPlayback);
|
|
_tickerMode = notifier;
|
|
notifier.addListener(_syncPlayback);
|
|
}
|
|
_syncPlayback();
|
|
}
|
|
|
|
bool get _visible {
|
|
final lifecycle = WidgetsBinding.instance.lifecycleState;
|
|
final appVisible =
|
|
lifecycle == null || lifecycle == AppLifecycleState.resumed;
|
|
return appVisible && _headerVisible && (_tickerMode?.value.enabled ?? true);
|
|
}
|
|
|
|
void _syncPlayback() {
|
|
final controller = _controller;
|
|
if (controller == null || !_ready) return;
|
|
final shouldPlay = _visible;
|
|
if (controller.value.isPlaying == shouldPlay) return;
|
|
if (shouldPlay) {
|
|
controller.play();
|
|
} else {
|
|
controller.pause();
|
|
}
|
|
}
|
|
|
|
@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: Uri.parse(url).path.toLowerCase().endsWith('.m3u8')
|
|
? VideoFormat.hls
|
|
: null,
|
|
videoPlayerOptions: VideoPlayerOptions(mixWithOthers: true),
|
|
);
|
|
_controller = controller;
|
|
try {
|
|
await controller.initialize();
|
|
if (!mounted) {
|
|
await controller.dispose();
|
|
return;
|
|
}
|
|
await controller.setVolume(0);
|
|
await controller.setLooping(true);
|
|
setState(() => _ready = true);
|
|
_syncPlayback();
|
|
} 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) {
|
|
_syncPlayback();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tickerMode?.removeListener(_syncPlayback);
|
|
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(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|