331 lines
10 KiB
Dart
331 lines
10 KiB
Dart
import 'dart:io';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter/services.dart';
|
||
import 'package:video_player/video_player.dart';
|
||
import 'package:open_filex/open_filex.dart';
|
||
|
||
class MediaItem {
|
||
final String path;
|
||
final bool isVideo;
|
||
|
||
MediaItem({
|
||
required this.path,
|
||
required this.isVideo,
|
||
});
|
||
}
|
||
|
||
class MediaViewer extends StatefulWidget {
|
||
final List<MediaItem> items;
|
||
final int initialIndex;
|
||
|
||
const MediaViewer({
|
||
super.key,
|
||
required this.items,
|
||
this.initialIndex = 0,
|
||
});
|
||
|
||
@override
|
||
State<MediaViewer> createState() => _MediaViewerState();
|
||
}
|
||
|
||
class _MediaViewerState extends State<MediaViewer> {
|
||
late PageController _pageController;
|
||
VideoPlayerController? _videoController;
|
||
String? _videoInitError;
|
||
|
||
int _index = 0;
|
||
bool _uiVisible = true;
|
||
bool _isLandscape = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
|
||
_index = widget.initialIndex;
|
||
_pageController = PageController(initialPage: _index);
|
||
|
||
// 1. Скрываем строку состояния и панель навигации при входе в плеер
|
||
_hideSystemUI();
|
||
|
||
_initVideoIfNeeded(_index);
|
||
}
|
||
|
||
Future<void> _initVideoIfNeeded(int index) async {
|
||
_videoController?.removeListener(_videoListener);
|
||
_videoController?.dispose();
|
||
_videoController = null;
|
||
_videoInitError = null;
|
||
|
||
final item = widget.items[index];
|
||
|
||
if (!item.isVideo) return;
|
||
|
||
final controller = VideoPlayerController.file(File(item.path));
|
||
_videoController = controller;
|
||
|
||
try {
|
||
await controller.initialize();
|
||
_videoController!.addListener(_videoListener);
|
||
controller.setLooping(false);
|
||
controller.play();
|
||
_videoInitError = null;
|
||
} catch (e) {
|
||
_videoInitError = e.toString();
|
||
_videoController?.removeListener(_videoListener);
|
||
await _videoController?.dispose().catchError((_) {});
|
||
_videoController = null;
|
||
} finally {
|
||
if (mounted) setState(() {});
|
||
}
|
||
}
|
||
|
||
void _videoListener() {
|
||
if (mounted) {
|
||
setState(() {});
|
||
}
|
||
}
|
||
|
||
// Метод скрытия системного UI (Status bar и Navigation bar)
|
||
void _hideSystemUI() {
|
||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||
}
|
||
|
||
// Метод показа системного UI при выходе из полноэкранного режима
|
||
void _showSystemUI() {
|
||
SystemChrome.setEnabledSystemUIMode(
|
||
SystemUiMode.manual,
|
||
overlays: SystemUiOverlay.values, // Возвращает статус-бар и нижний бар
|
||
);
|
||
}
|
||
|
||
void _toggleOrientation() {
|
||
setState(() {
|
||
_isLandscape = !_isLandscape;
|
||
});
|
||
|
||
if (_isLandscape) {
|
||
SystemChrome.setPreferredOrientations([
|
||
DeviceOrientation.landscapeLeft,
|
||
DeviceOrientation.landscapeRight,
|
||
]);
|
||
} else {
|
||
SystemChrome.setPreferredOrientations([
|
||
DeviceOrientation.portraitUp,
|
||
]);
|
||
}
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_videoController?.removeListener(_videoListener);
|
||
_videoController?.dispose();
|
||
_pageController.dispose();
|
||
|
||
// 2. Обязательно возвращаем системный UI и портретный режим при выходе
|
||
_showSystemUI();
|
||
SystemChrome.setPreferredOrientations([
|
||
DeviceOrientation.portraitUp,
|
||
]);
|
||
super.dispose();
|
||
}
|
||
|
||
void _toggleUI() {
|
||
setState(() => _uiVisible = !_uiVisible);
|
||
}
|
||
|
||
String _format(Duration d) {
|
||
String two(int n) => n.toString().padLeft(2, '0');
|
||
return "${two(d.inMinutes.remainder(60))}:${two(d.inSeconds.remainder(60))}";
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Scaffold(
|
||
backgroundColor: Colors.black,
|
||
// SafeArea гарантирует, что даже если системные бары скрыты/показаны,
|
||
// интерактивные элементы интерфейса (кнопки закрытия, плеер)
|
||
// никогда не уйдут под физические вырезы экрана (челку, скругления)
|
||
body: SafeArea(
|
||
child: GestureDetector(
|
||
onTap: _toggleUI,
|
||
behavior: HitTestBehavior.opaque,
|
||
child: Stack(
|
||
children: [
|
||
// MEDIA PAGES (Контент растягивается на весь экран)
|
||
Positioned.fill(
|
||
child: PageView.builder(
|
||
controller: _pageController,
|
||
onPageChanged: (i) async {
|
||
setState(() => _index = i);
|
||
await _initVideoIfNeeded(i);
|
||
},
|
||
itemCount: widget.items.length,
|
||
itemBuilder: (context, i) {
|
||
final item = widget.items[i];
|
||
|
||
if (item.isVideo) {
|
||
if (_videoInitError != null) {
|
||
return _buildVideoInitErrorFallback(item.path);
|
||
}
|
||
|
||
if (_videoController == null ||
|
||
!_videoController!.value.isInitialized) {
|
||
return const Center(
|
||
child: CircularProgressIndicator(color: Colors.white),
|
||
);
|
||
}
|
||
|
||
return Center(
|
||
child: AspectRatio(
|
||
aspectRatio: _videoController!.value.aspectRatio,
|
||
child: VideoPlayer(_videoController!),
|
||
),
|
||
);
|
||
}
|
||
|
||
return Center(
|
||
child: InteractiveViewer(
|
||
maxScale: 4.0,
|
||
child: Image.file(
|
||
File(item.path),
|
||
fit: BoxFit.contain,
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
|
||
// TOP BAR (Кнопки управления сверху)
|
||
if (_uiVisible)
|
||
Positioned(
|
||
top: 10, // Маленький фиксированный отступ, т.к. SafeArea уже защищает сверху
|
||
left: 16,
|
||
right: 16,
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
IconButton(
|
||
icon: const Icon(Icons.close, color: Colors.white, size: 28),
|
||
onPressed: () => Navigator.pop(context),
|
||
),
|
||
IconButton(
|
||
icon: Icon(
|
||
Icons.screen_rotation,
|
||
color: Colors.white,
|
||
size: 26,
|
||
),
|
||
onPressed: _toggleOrientation,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
|
||
// VIDEO CONTROLS (Нижняя панель управления видео)
|
||
if (_uiVisible &&
|
||
widget.items[_index].isVideo &&
|
||
_videoController != null &&
|
||
_videoController!.value.isInitialized)
|
||
Positioned(
|
||
bottom: 10, // Прижато к низу безопасной зоны SafeArea
|
||
left: 16,
|
||
right: 16,
|
||
child: _buildVideoControls(),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildVideoControls() {
|
||
final c = _videoController!;
|
||
final pos = c.value.position;
|
||
final dur = c.value.duration;
|
||
|
||
final posMs = pos.inMilliseconds.toDouble();
|
||
final maxMs = dur.inMilliseconds.toDouble().clamp(1, double.infinity).toDouble();
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: Colors.black54,
|
||
borderRadius: BorderRadius.circular(30),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
IconButton(
|
||
icon: Icon(
|
||
c.value.isPlaying ? Icons.pause : Icons.play_arrow,
|
||
color: Colors.white,
|
||
size: 28,
|
||
),
|
||
onPressed: () {
|
||
setState(() {
|
||
c.value.isPlaying ? c.pause() : c.play();
|
||
});
|
||
},
|
||
),
|
||
Expanded(
|
||
child: SliderTheme(
|
||
data: SliderTheme.of(context).copyWith(
|
||
trackHeight: 4,
|
||
activeTrackColor: Colors.white60,
|
||
inactiveTrackColor: Colors.white30,
|
||
thumbColor: Colors.white,
|
||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||
overlayColor: Colors.transparent,
|
||
),
|
||
child: Slider(
|
||
value: posMs.clamp(0, maxMs),
|
||
min: 0,
|
||
max: maxMs,
|
||
onChanged: (v) {
|
||
c.seekTo(Duration(milliseconds: v.toInt()));
|
||
},
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
"${_format(pos)} / ${_format(dur)}",
|
||
style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.bold),
|
||
),
|
||
const SizedBox(width: 8),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildVideoInitErrorFallback(String path) {
|
||
return Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(Icons.play_disabled, color: Colors.white70, size: 56),
|
||
const SizedBox(height: 10),
|
||
const Text(
|
||
'Видео не воспроизводится на этом устройстве',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(color: Colors.white70),
|
||
),
|
||
const SizedBox(height: 10),
|
||
OutlinedButton.icon(
|
||
onPressed: () async {
|
||
try {
|
||
await OpenFilex.open(path);
|
||
} catch (_) {}
|
||
},
|
||
icon: const Icon(Icons.open_in_new, color: Colors.white70),
|
||
label: const Text(
|
||
'Открыть внешним плеером',
|
||
style: TextStyle(color: Colors.white70),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|