aves_mio0.31/lib/widgets/viewer/fullscreen_cast_viewer.dart
2026-07-18 13:39:22 +02:00

372 lines
12 KiB
Dart

// lib/widgets/viewer/fullscreen_cast_viewer.dart
//
// Viewer FULLSCREEN per Google Cast.
// FAST e DLNA hanno viewer separati.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import 'package:aves/model/entry/entry.dart';
import 'package:aves/widgets/cast/cast_controller.dart';
class FullscreenCastViewer extends StatefulWidget {
final List<AvesEntry> entries;
final int startIndex;
const FullscreenCastViewer({
super.key,
required this.entries,
required this.startIndex,
});
@override
State<FullscreenCastViewer> createState() => _FullscreenCastViewerState();
}
class _FullscreenCastViewerState extends State<FullscreenCastViewer> {
late final PageController _pageController;
late int _currentIndex;
bool _isUpdatingFromCast = false;
bool _controlsVisible = true;
@override
void initState() {
super.initState();
_currentIndex = widget.startIndex.clamp(0, widget.entries.length - 1);
_pageController = PageController(initialPage: _currentIndex);
CastController.instance.currentIndexNotifier.addListener(_onCastIndexChanged);
}
@override
void dispose() {
CastController.instance.currentIndexNotifier.removeListener(_onCastIndexChanged);
_pageController.dispose();
super.dispose();
}
// ------------------------------------------------------------
// SYNC CAST -> UI
// ------------------------------------------------------------
void _onCastIndexChanged() {
final newIndex = CastController.instance.currentIndexNotifier.value;
if (newIndex == _currentIndex) return;
_isUpdatingFromCast = true;
_currentIndex = newIndex;
if (_pageController.hasClients) {
_pageController.jumpToPage(newIndex);
}
if (mounted) setState(() {});
Future.delayed(const Duration(milliseconds: 100), () {
_isUpdatingFromCast = false;
});
}
// ------------------------------------------------------------
// SYNC UI -> CAST
// ------------------------------------------------------------
void _onPageChanged(int index) {
if (_isUpdatingFromCast) return;
if (index > _currentIndex) {
CastController.instance.nextManual();
} else if (index < _currentIndex) {
CastController.instance.prevManual();
}
setState(() => _currentIndex = index);
}
// ------------------------------------------------------------
// Helpers UI
// ------------------------------------------------------------
String _formatDuration(Duration d) {
final totalSeconds = d.inSeconds;
final hours = totalSeconds ~/ 3600;
final minutes = (totalSeconds % 3600) ~/ 60;
final seconds = totalSeconds % 60;
if (hours > 0) {
return '${hours.toString().padLeft(2, '0')}:'
'${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
Widget _buildTopBar(BuildContext context) {
return SafeArea(
child: Container(
color: Colors.black54,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).maybePop(),
),
Expanded(
child: Text(
'${_currentIndex + 1}/${widget.entries.length}',
style: const TextStyle(color: Colors.white),
textAlign: TextAlign.center,
),
),
IconButton(
icon: const Icon(Icons.stop, color: Colors.white),
onPressed: () async {
await CastController.instance.stopCast();
if (mounted) Navigator.of(context).maybePop();
},
),
],
),
),
);
}
Widget _buildBottomControls() {
final controller = CastController.instance;
return SafeArea(
top: false,
child: Container(
color: Colors.black54,
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// --------------------------------------------------
// SEEK BAR (solo Google)
// --------------------------------------------------
ValueListenableBuilder<CastBackend>(
valueListenable: controller.backendNotifier,
builder: (context, backend, _) {
if (backend != CastBackend.google) return const SizedBox.shrink();
return ValueListenableBuilder<Duration>(
valueListenable: controller.durationNotifier,
builder: (context, duration, _) {
return ValueListenableBuilder<Duration>(
valueListenable: controller.positionNotifier,
builder: (context, position, __) {
final maxMs = duration.inMilliseconds <= 0 ? 1 : duration.inMilliseconds;
final valueMs = position.inMilliseconds.clamp(0, maxMs);
return Column(
children: [
Slider(
value: valueMs.toDouble(),
min: 0,
max: maxMs.toDouble(),
onChanged: (_) {},
onChangeEnd: (_) {},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
Text(_formatDuration(position), style: const TextStyle(color: Colors.white70)),
const Spacer(),
Text(_formatDuration(duration), style: const TextStyle(color: Colors.white70)),
],
),
),
],
);
},
);
},
);
},
),
const SizedBox(height: 8),
// --------------------------------------------------
// PREV / PLAY-PAUSE / NEXT
// --------------------------------------------------
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
iconSize: 34,
icon: const Icon(Icons.skip_previous, color: Colors.white),
onPressed: () => controller.prevManual(),
),
const SizedBox(width: 12),
// PLAY/PAUSE solo Google
ValueListenableBuilder<CastBackend>(
valueListenable: controller.backendNotifier,
builder: (context, backend, _) {
if (backend != CastBackend.google) return const SizedBox.shrink();
return ValueListenableBuilder<bool>(
valueListenable: controller.isPlayingNotifier,
builder: (context, isPlaying, _) {
return IconButton(
iconSize: 40,
icon: Icon(
isPlaying ? Icons.pause_circle : Icons.play_circle,
color: Colors.white,
),
onPressed: () {},
);
},
);
},
),
const SizedBox(width: 12),
IconButton(
iconSize: 34,
icon: const Icon(Icons.skip_next, color: Colors.white),
onPressed: () => controller.nextManual(),
),
],
),
const SizedBox(height: 12),
// --------------------------------------------------
// VOLUME solo Google
// --------------------------------------------------
ValueListenableBuilder<CastBackend>(
valueListenable: controller.backendNotifier,
builder: (context, backend, _) {
if (backend != CastBackend.google) return const SizedBox.shrink();
return ValueListenableBuilder<double>(
valueListenable: controller.volumeNotifier,
builder: (context, volume, _) {
return Row(
children: [
IconButton(
icon: const Icon(Icons.volume_up, color: Colors.white),
onPressed: () {},
),
Expanded(
child: Slider(
value: volume.clamp(0.0, 1.0),
min: 0,
max: 1,
onChanged: (_) {},
),
),
],
);
},
);
},
),
],
),
),
);
}
// ------------------------------------------------------------
// RENDER ENTRY (locale o remoto)
// ------------------------------------------------------------
Widget _buildEntry(AvesEntry entry) {
final isVideo = (entry.durationMillis ?? 0) > 0;
final path = entry.path;
// VIDEO LOCALE
if (isVideo && path != null && File(path).existsSync()) {
return Center(child: VideoWidget.file(File(path)));
}
// FOTO LOCALE
if (!isVideo && path != null && File(path).existsSync()) {
return InteractiveViewer(
child: Center(child: Image.file(File(path), fit: BoxFit.contain)),
);
}
return const Center(
child: Icon(Icons.broken_image, size: 64, color: Colors.white54),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _controlsVisible = !_controlsVisible),
child: Stack(
children: [
PageView.builder(
controller: _pageController,
itemCount: widget.entries.length,
onPageChanged: _onPageChanged,
itemBuilder: (context, i) => _buildEntry(widget.entries[i]),
),
if (_controlsVisible) ...[
Align(alignment: Alignment.topCenter, child: _buildTopBar(context)),
Align(alignment: Alignment.bottomCenter, child: _buildBottomControls()),
],
],
),
),
);
}
}
// ------------------------------------------------------------
// VIDEO WIDGET (solo locale)
// ------------------------------------------------------------
class VideoWidget extends StatefulWidget {
final File file;
const VideoWidget.file(this.file, {super.key});
@override
State<VideoWidget> createState() => _VideoWidgetState();
}
class _VideoWidgetState extends State<VideoWidget> {
VideoPlayerController? controller;
@override
void initState() {
super.initState();
controller = VideoPlayerController.file(widget.file)
..initialize().then((_) {
if (mounted) setState(() {});
});
}
@override
void dispose() {
controller?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (controller == null || !controller!.value.isInitialized) {
return const Center(child: CircularProgressIndicator());
}
return AspectRatio(
aspectRatio: controller!.value.aspectRatio,
child: VideoPlayer(controller!),
);
}
}