Files
yommi_ff/lib/player_screen.dart
2026-01-11 19:49:43 +09:00

1257 lines
44 KiB
Dart

import 'dart:async';
import 'package:flutter/material.dart';
import 'dart:io';
import 'package:flutter/services.dart';
import 'dart:convert';
import 'dart:typed_data';
import 'package:file_picker/file_picker.dart';
import 'package:charset_converter/charset_converter.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:http/http.dart' as http;
class PlayerScreen extends StatefulWidget {
final String videoPath;
final String videoName;
final String? subtitleContent;
final String?
subtitlePath; // optional path to suggest where subtitle is located
const PlayerScreen({
super.key,
required this.videoPath,
required this.videoName,
this.subtitleContent,
this.subtitlePath,
});
@override
State<PlayerScreen> createState() => _PlayerScreenState();
}
class SubtitleCue {
final Duration start;
final Duration end;
final String text;
SubtitleCue({required this.start, required this.end, required this.text});
}
class _PlayerScreenState extends State<PlayerScreen> {
late final Player player = Player();
late final VideoController controller = VideoController(player);
List<SubtitleCue> _subtitles = [];
bool _showControls = true;
Timer? _controlsTimer;
String _gestureType = '';
double _gestureProgress = 0.0;
bool _isDraggingSubtitle = false;
// Playback resilience state
bool _isBuffering = false;
bool _playError = false;
String? _errorMessage;
Timer? _bufferTimer;
StreamSubscription<bool>? _playingSubscription;
@override
void initState() {
super.initState();
final media = Media(
widget.videoPath,
extras: {
'audio-channels': 'stereo',
},
);
player.open(media, play: true);
// media_kit uses 0..100 volume by convention in this project — use 50 default.
player.setVolume(50.0);
// Monitor playing state to detect buffering / failures
_playingSubscription = player.stream.playing.listen((playing) {
if (playing) {
_cancelBuffering();
if (mounted)
setState(() {
_isBuffering = false;
_playError = false;
_errorMessage = null;
});
} else {
_startBufferingTimeout();
}
}, onError: (e) {
debugPrint('Player error: $e');
if (mounted)
setState(() {
_playError = true;
_errorMessage = 'Playback error: $e';
});
});
// Parse and render subtitles ourselves to avoid double-rendering when the
// player also shows subtitles internally. We intentionally do NOT call
// player.setSubtitleTrack(...) here so only our overlay renders text.
if (widget.subtitleContent != null) {
_subtitles = _parseSrt(widget.subtitleContent!);
debugPrint('Parsed ${_subtitles.length} subtitle cues');
} else if (widget.subtitlePath != null) {
// Subtitles exist but we couldn't read them previously (permission issue possible).
// Offer the user a manual load via the UI (file picker button) — no automatic action.
debugPrint(
'Subtitle path provided but no content; press subtitle button to load: ${widget.subtitlePath}');
// Show a short SnackBar instructing the user how to load the subtitle manually
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('자막 파일에 접근할 수 없습니다. "자막 불러오기" 버튼을 눌러 파일을 직접 선택하세요.'),
action: SnackBarAction(
label: '자막 불러오기', onPressed: _loadSubtitleFromPicker),
duration: const Duration(seconds: 6),
),
);
});
}
SystemChrome.setPreferredOrientations(
[DeviceOrientation.landscapeLeft, DeviceOrientation.landscapeRight]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
_startHideTimer();
}
@override
void dispose() {
player.dispose();
_controlsTimer?.cancel();
_bufferTimer?.cancel();
_playingSubscription?.cancel();
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
void _startHideTimer() {
_controlsTimer?.cancel();
_controlsTimer = Timer(const Duration(seconds: 3), () {
if (mounted) setState(() => _showControls = false);
});
}
void _startBufferingTimeout() {
_bufferTimer?.cancel();
_bufferTimer = Timer(const Duration(seconds: 3), () {
if (!player.state.playing && mounted) {
setState(() {
_isBuffering = true;
_playError = false;
_errorMessage = null;
});
}
});
}
void _cancelBuffering() {
_bufferTimer?.cancel();
if (mounted) setState(() => _isBuffering = false);
}
void _setPlayError(String message) {
_cancelBuffering();
if (mounted)
setState(() {
_playError = true;
_errorMessage = message;
});
}
Future<void> _retryPlay() async {
debugPrint('Retrying playback: ${widget.videoPath}');
if (mounted)
setState(() {
_playError = false;
_errorMessage = null;
_isBuffering = true;
});
try {
await player.open(Media(widget.videoPath), play: true);
_startBufferingTimeout();
} catch (e) {
debugPrint('Retry failed: $e');
_setPlayError('Retry failed: $e');
}
}
Future<void> _inspectStream() async {
Uri? uri;
try {
uri = Uri.parse(widget.videoPath);
} catch (e) {
_setPlayError('Invalid URL: $e');
return;
}
if (!(uri.scheme == 'http' || uri.scheme == 'https')) {
if (mounted)
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content:
Text('Stream inspection is only supported for HTTP(S) URLs.')));
return;
}
try {
final resp = await http.head(uri).timeout(const Duration(seconds: 5));
if (resp.statusCode >= 200 && resp.statusCode < 400) {
if (mounted)
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
'Inspection OK: ${resp.statusCode} ${resp.reasonPhrase}')));
} else {
_setPlayError('HEAD returned ${resp.statusCode} ${resp.reasonPhrase}');
}
} catch (e) {
_setPlayError('Inspection failed: $e');
}
}
void _toggleControls() {
setState(() {
_showControls = !_showControls;
if (_showControls) _startHideTimer();
});
}
void _handleVerticalDragUpdate(
DragUpdateDetails details, Size size, bool isLeft) {
if (isLeft) return;
setState(() {
_gestureType = 'volume';
// delta is fraction of the screen height; scale to 0..100 range
double delta = -details.primaryDelta! / size.height * 100;
double currentVolume = player.state.volume;
double newVolume = (currentVolume + delta).clamp(0.0, 100.0);
player.setVolume(newVolume);
_gestureProgress = newVolume / 100.0;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: GestureDetector(
onTap: _toggleControls,
onVerticalDragUpdate: (details) {
final size = MediaQuery.of(context).size;
bool isLeft = details.localPosition.dx < size.width / 2;
_handleVerticalDragUpdate(details, size, isLeft);
},
onVerticalDragEnd: (_) => setState(() => _gestureType = ''),
child: LayoutBuilder(
builder: (context, constraints) {
final videoWidth = constraints.maxWidth;
final double dynamicFontSize =
(videoWidth * 0.03).clamp(12.0, 52.0);
final Widget subtitleWidget = _subtitles.isNotEmpty
? StreamBuilder<Duration>(
stream: player.stream.position,
builder: (context, snapshot) {
final pos = snapshot.data ?? Duration.zero;
final cue = _currentCue(pos);
if (cue == null || cue.text.trim().isEmpty) {
return const SizedBox.shrink();
}
final displayText = _sanitizeSubtitle(cue.text);
return Align(
alignment: const Alignment(0, 0.8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 30.0),
child: Text(
displayText,
textAlign: TextAlign.center,
softWrap: true,
maxLines: 4,
overflow: TextOverflow.visible,
style: TextStyle(
fontSize: dynamicFontSize,
color: Colors.white,
shadows: const [
Shadow(
blurRadius: 2.0,
color: Colors.black,
offset: Offset(2.0, 2.0)),
Shadow(
blurRadius: 4.0,
color: Colors.black,
offset: Offset(2.0, 2.0)),
],
),
),
),
);
},
)
: const SizedBox.shrink();
return Stack(
children: [
// DropTarget around the video so user can drag subtitle files onto the
// video area to load them (works well for testing or external drives).
Center(
child: DropTarget(
onDragEntered: (_) =>
setState(() => _isDraggingSubtitle = true),
onDragExited: (_) =>
setState(() => _isDraggingSubtitle = false),
onDragDone: (detail) async {
setState(() => _isDraggingSubtitle = false);
final path = detail.files.first.path;
if (path == null) return;
if (!(path.endsWith('.srt') ||
path.endsWith('.vtt') ||
path.endsWith('.ass'))) return;
try {
final bytes = await File(path).readAsBytes();
final content = await _decodeBytes(bytes);
if (content == null) throw Exception('디코딩 실패');
setState(() => _subtitles = _parseSrt(content));
if (mounted) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('자막 드래그로 로드됨')));
}
} catch (e) {
debugPrint('드래그 자막 로드 실패: $e');
if (mounted) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('자막 로드 실패: $e')));
}
}
},
child: Stack(children: [
Video(
controller: controller,
subtitleViewConfiguration: SubtitleViewConfiguration(
style: TextStyle(
fontSize: dynamicFontSize,
color: Colors.white,
shadows: const [
Shadow(
blurRadius: 2.0,
color: Colors.black,
offset: Offset(2.0, 2.0)),
Shadow(
blurRadius: 4.0,
color: Colors.black,
offset: Offset(2.0, 2.0)),
],
),
),
),
if (_isDraggingSubtitle)
Positioned.fill(
child: Container(
color: Colors.black45,
child: const Center(
child: Text('드롭하여 자막 로드',
style: TextStyle(
color: Colors.white, fontSize: 20)),
),
),
),
]),
),
),
subtitleWidget,
if (_gestureType.isNotEmpty) _buildGestureIndicator(),
if (_isBuffering)
Positioned.fill(
child: Container(
color: Colors.black45,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: const [
CircularProgressIndicator(),
SizedBox(height: 12),
Text('Buffering...',
style: TextStyle(color: Colors.white))
])))),
if (_playError)
Positioned.fill(
child: Container(
color: Colors.black54,
child: Center(
child: Card(
color: Colors.red.shade900,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_errorMessage ?? 'Playback failed',
style: const TextStyle(
color: Colors.white,
fontSize: 16)),
const SizedBox(height: 12),
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: _retryPlay,
child: const Text('Retry',
style: TextStyle(
color:
Colors.white))),
const SizedBox(width: 8),
TextButton(
onPressed: _inspectStream,
child: const Text('Inspect',
style: TextStyle(
color:
Colors.white)))
])
]))))),
AnimatedOpacity(
opacity: _showControls ? 1.0 : 0.0,
duration: const Duration(milliseconds: 300),
child: IgnorePointer(
ignoring: !_showControls, child: _buildControls()),
),
],
);
},
),
),
);
}
Widget _buildGestureIndicator() {
return Center(
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.black54, borderRadius: BorderRadius.circular(10)),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.volume_up, color: Colors.white, size: 40),
const SizedBox(height: 10),
SizedBox(
width: 100,
child: LinearProgressIndicator(
value: _gestureProgress, backgroundColor: Colors.white24)),
],
),
),
);
}
Widget _buildControls() {
return Container(
color: Colors.black26,
child:
Column(children: [_buildTopBar(), const Spacer(), _buildBottomBar()]),
);
}
Widget _buildTopBar() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 40),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: () => Navigator.pop(context)),
Expanded(
child: Text(widget.videoName,
style: const TextStyle(color: Colors.white, fontSize: 18),
overflow: TextOverflow.ellipsis)),
if (_isBuffering)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))),
if (_playError)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Icon(Icons.error, color: Colors.redAccent)),
IconButton(
icon: const Icon(Icons.info_outline, color: Colors.white),
tooltip: 'Inspect stream',
onPressed: _inspectStream),
// Subtitle control: load subtitle manually when automatic read fails
if (_subtitles.isEmpty)
IconButton(
icon: const Icon(Icons.subtitles, color: Colors.white),
tooltip: 'Load subtitles',
onPressed: _loadSubtitleFromPicker,
),
// Permission helper for macOS Full Disk Access (shows instructions and can open Security prefs)
IconButton(
icon: const Icon(Icons.lock, color: Colors.white),
tooltip: '권한 안내',
onPressed: _showPermissionDialog,
),
],
),
);
}
Widget _buildBottomBar() {
return Container(
padding: const EdgeInsets.all(20),
child: Column(
children: [
StreamBuilder(
stream: player.stream.position,
builder: (context, snapshot) {
final position = snapshot.data ?? Duration.zero;
final duration = player.state.duration;
return Column(
children: [
Slider(
value: position.inMilliseconds.toDouble(),
max: duration.inMilliseconds.toDouble() > 0
? duration.inMilliseconds.toDouble()
: 1.0,
onChanged: (v) =>
player.seek(Duration(milliseconds: v.toInt()))),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(_formatDuration(position),
style: const TextStyle(color: Colors.white)),
Text(_formatDuration(duration),
style: const TextStyle(color: Colors.white))
]),
],
);
},
),
IconButton(
iconSize: 48,
icon: StreamBuilder(
stream: player.stream.playing,
builder: (context, snapshot) => Icon(
(snapshot.data ?? false)
? Icons.pause_circle
: Icons.play_circle,
color: Colors.white)),
onPressed: () => player.playOrPause()),
],
),
);
}
String _formatDuration(Duration d) {
String twoDigits(int n) => n.toString().padLeft(2, "0");
return "${d.inHours > 0 ? '${d.inHours}:' : ''}${twoDigits(d.inMinutes.remainder(60))}:${twoDigits(d.inSeconds.remainder(60))}";
}
// Allow user to pick a subtitle file manually (granted access via file picker)
Future<void> _loadSubtitleFromPicker() async {
try {
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['srt', 'vtt', 'ass'],
);
if (result == null) return; // user cancelled
final path = result.files.single.path;
if (path == null) return;
final bytes = await File(path).readAsBytes();
final content = await _decodeBytes(bytes);
if (content == null) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('자막을 디코딩할 수 없습니다.'),
));
}
return;
}
// Set to player and to our overlay parser
// Don't call player.setSubtitleTrack to avoid duplicate on-screen captions.
// We only update our overlay parser and show subtitles from _subtitles.
debugPrint(
'Subtitles loaded into overlay parser (${_subtitles.length} cues)');
setState(() => _subtitles = _parseSrt(content));
if (mounted) {
// Remove prior instruction SnackBar (if present) so it is hidden after successful load
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('자막이 불러와졌습니다.'),
));
}
} catch (e) {
debugPrint('자막 파일 선택/읽기 실패: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('자막 불러오기 실패: $e'),
));
}
}
}
Future<String?> _decodeBytes(Uint8List bytes) async {
try {
// UTF-8 first
final s = utf8.decode(bytes);
if (!s.contains('\uFFFD')) return s.replaceFirst('\uFEFF', '');
} catch (_) {}
try {
final s = await CharsetConverter.decode('euc-kr', bytes);
return s.replaceFirst('\uFEFF', '');
} catch (e) {
debugPrint('euc-kr decoding failed: $e');
}
try {
return latin1.decode(bytes);
} catch (e) {
debugPrint('latin1 decoding failed: $e');
}
return null;
}
// Simple SRT parser to support our overlayed subtitle rendering.
List<SubtitleCue> _parseSrt(String content) {
final lines = content.replaceAll('\r', '').split('\n');
final cues = <SubtitleCue>[];
int i = 0;
Duration? _parseTime(String t) {
// Formats like 00:00:10,500 or 00:00:10.500
final cleaned = t.replaceAll(',', '.');
final parts = cleaned.split(':');
if (parts.length != 3) return null;
final hours = int.tryParse(parts[0]) ?? 0;
final minutes = int.tryParse(parts[1]) ?? 0;
final secParts = parts[2].split('.');
final seconds = int.tryParse(secParts[0]) ?? 0;
final millis = secParts.length > 1
? int.parse((secParts[1] + '000').substring(0, 3))
: 0;
return Duration(
hours: hours,
minutes: minutes,
seconds: seconds,
milliseconds: millis);
}
while (i < lines.length) {
// Skip empty lines or index lines
if (lines[i].trim().isEmpty) {
i++;
continue;
}
// Optional index line like '1'
if (RegExp(r'^\d+\s*$').hasMatch(lines[i].trim())) {
i++;
if (i >= lines.length) break;
}
// Time line
final timeLine = lines[i].trim();
if (!timeLine.contains('-->')) {
i++;
continue;
}
final parts = timeLine.split('-->');
final start = _parseTime(parts[0].trim());
final end = _parseTime(parts[1].trim());
i++;
final buffer = StringBuffer();
while (i < lines.length && lines[i].trim().isNotEmpty) {
if (buffer.isNotEmpty) buffer.writeln();
buffer.write(lines[i]);
i++;
}
if (start != null && end != null) {
cues.add(SubtitleCue(
start: start, end: end, text: buffer.toString().trim()));
}
}
return cues;
}
SubtitleCue? _currentCue(Duration position) {
// Linear scan is fine for moderate subtitle sizes; optimize if needed.
for (final c in _subtitles) {
if (position >= c.start && position <= c.end) return c;
}
return null;
}
String _sanitizeSubtitle(String raw) {
// Remove simple HTML tags and trim
final noTags = raw.replaceAll(RegExp(r'<[^>]*>'), '');
// Collapse multiple blank lines
final collapsed = noTags.replaceAll(RegExp(r'\n{2,}'), '\n');
return collapsed.trim();
}
// Show instructions and offer to open System Settings to the Privacy pane
void _showPermissionDialog() {
showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('파일 접근 권한 안내'),
content: const Text(
'macOS에서 외장 드라이브나 보호된 폴더의 파일에 접근하려면 앱에 Full Disk Access 또는 Files and Folders 권한을 부여해야 할 수 있습니다. "시스템 설정"을 열어 보안 및 개인정보 보호 → 파일 및 폴더 또는 전체 디스크 접근을 확인하세요.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('닫기')),
TextButton(
onPressed: () async {
Navigator.of(context).pop();
await _openPrivacySettings();
},
child: const Text('시스템 설정 열기'),
),
],
),
);
}
Future<void> _openPrivacySettings() async {
try {
// This attempts to open the Security & Privacy pane focused on Files and Folders.
await Process.run('open', [
'x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles'
]);
} catch (e) {
debugPrint('Failed to open Privacy settings: $e');
if (mounted)
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('시스템 설정을 열지 못했습니다. 수동으로 열어주세요.')));
}
}
}
class PlayerView extends StatefulWidget {
final String videoPath;
final String videoName;
final String? subtitleContent;
final bool inlineMode; // compact controls when true
const PlayerView(
{super.key,
required this.videoPath,
required this.videoName,
this.subtitleContent,
this.inlineMode = true});
@override
State<PlayerView> createState() => _PlayerViewState();
}
class _PlayerViewState extends State<PlayerView> {
late final Player _player = Player();
late final VideoController _controller = VideoController(_player);
List<SubtitleCue> _subtitles = [];
bool _showControls = true;
Timer? _controlsTimer;
String _gestureType = '';
double _gestureProgress = 0.0;
bool _showDraggingOverlay = false;
// Playback resilience state
bool _isBuffering = false;
bool _playError = false;
String? _errorMessage;
Timer? _bufferTimer;
StreamSubscription<bool>? _playingSubscription;
@override
void initState() {
super.initState();
final media = Media(widget.videoPath);
_player.open(media, play: true);
_player.setVolume(50.0);
if (widget.subtitleContent != null) {
_subtitles = _parseSrt(widget.subtitleContent!);
}
// Watch playing state to detect buffering / failure conditions.
_playingSubscription = _player.stream.playing.listen((playing) {
if (playing) {
_cancelBuffering();
if (mounted)
setState(() {
_isBuffering = false;
_playError = false;
_errorMessage = null;
});
} else {
// Start a short timer; if still not playing after timeout, show buffering
_startBufferingTimeout();
}
}, onError: (e) {
debugPrint('Playing stream error: $e');
if (mounted)
setState(() {
_playError = true;
_errorMessage = 'Playback error: $e';
});
});
_startHideTimer();
}
@override
void didUpdateWidget(covariant PlayerView oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.videoPath != oldWidget.videoPath) {
debugPrint('PlayerView: videoPath changed, opening ${widget.videoPath}');
_player.open(Media(widget.videoPath), play: true);
// refresh subtitles if provided
setState(() {
_subtitles = widget.subtitleContent != null
? _parseSrt(widget.subtitleContent!)
: <SubtitleCue>[];
});
}
}
@override
void dispose() {
_player.dispose();
_controlsTimer?.cancel();
_bufferTimer?.cancel();
_playingSubscription?.cancel();
super.dispose();
}
void _startHideTimer() {
_controlsTimer?.cancel();
_controlsTimer = Timer(const Duration(seconds: 3), () {
if (mounted) setState(() => _showControls = false);
});
}
void _toggleControls() {
setState(() {
_showControls = !_showControls;
if (_showControls) _startHideTimer();
});
}
void _handleVerticalDragUpdate(
DragUpdateDetails details, Size size, bool isLeft) {
if (isLeft) return;
setState(() {
_gestureType = 'volume';
double delta = -details.primaryDelta! / size.height * 100;
double currentVolume = _player.state.volume;
double newVolume = (currentVolume + delta).clamp(0.0, 100.0);
_player.setVolume(newVolume);
_gestureProgress = newVolume / 100.0;
});
}
void _startBufferingTimeout() {
_bufferTimer?.cancel();
_bufferTimer = Timer(const Duration(seconds: 3), () {
// If still not playing, show buffering indicator
if (!(_playingSubscription == null)) {
final playing = _player.state.playing;
if (!playing && mounted) {
setState(() {
_isBuffering = true;
_playError = false;
_errorMessage = null;
});
}
}
});
}
void _cancelBuffering() {
_bufferTimer?.cancel();
if (mounted)
setState(() {
_isBuffering = false;
});
}
void _setPlayError(String message) {
_cancelBuffering();
if (mounted)
setState(() {
_playError = true;
_errorMessage = message;
});
}
Future<void> _retryPlay() async {
debugPrint('Retrying playback: ${widget.videoPath}');
if (mounted)
setState(() {
_playError = false;
_errorMessage = null;
_isBuffering = true;
});
try {
await _player.open(Media(widget.videoPath), play: true);
_startBufferingTimeout();
} catch (e) {
debugPrint('Retry failed: $e');
_setPlayError('Retry failed: $e');
}
}
Future<void> _inspectStream() async {
// Only try basic HTTP(S) probe; other protocols (rtsp/rtmp) can't be probed this way.
Uri? uri;
try {
uri = Uri.parse(widget.videoPath);
} catch (e) {
_setPlayError('Invalid URL: $e');
return;
}
if (!(uri.scheme == 'http' || uri.scheme == 'https')) {
if (mounted)
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content:
Text('Stream inspection is only supported for HTTP(S) URLs.')));
return;
}
try {
final resp = await http.head(uri).timeout(const Duration(seconds: 5));
if (resp.statusCode >= 200 && resp.statusCode < 400) {
if (mounted)
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(
'Inspection OK: ${resp.statusCode} ${resp.reasonPhrase}')));
} else {
_setPlayError('HEAD returned ${resp.statusCode} ${resp.reasonPhrase}');
}
} catch (e) {
_setPlayError('Inspection failed: $e');
}
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
final double dynamicFontSize = (size.width * 0.03).clamp(12.0, 52.0);
return GestureDetector(
onTap: _toggleControls,
onVerticalDragUpdate: (details) {
bool isLeft = details.localPosition.dx < size.width / 2;
_handleVerticalDragUpdate(details, size, isLeft);
},
onVerticalDragEnd: (_) => setState(() => _gestureType = ''),
child: Stack(children: [
Video(
controller: _controller,
subtitleViewConfiguration: SubtitleViewConfiguration(
style:
TextStyle(fontSize: dynamicFontSize, color: Colors.white))),
if (_subtitles.isNotEmpty)
StreamBuilder<Duration>(
stream: _player.stream.position,
builder: (context, snapshot) {
final pos = snapshot.data ?? Duration.zero;
final cue = _currentCue(pos);
if (cue == null || cue.text.trim().isEmpty)
return const SizedBox.shrink();
final displayText = _sanitizeSubtitle(cue.text);
return Align(
alignment: const Alignment(0, 0.8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 30.0),
child: Text(displayText,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: dynamicFontSize, color: Colors.white)),
),
);
},
),
if (_gestureType.isNotEmpty) _buildGestureIndicator(),
if (_showControls)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: Container(
color: Colors.black45,
padding: const EdgeInsets.all(8),
child: Row(children: [
IconButton(
icon: StreamBuilder(
stream: _player.stream.playing,
builder: (context, snap) => Icon(
(snap.data ?? false)
? Icons.pause
: Icons.play_arrow,
color: Colors.white)),
onPressed: () => _player.playOrPause()),
if (_isBuffering)
const Padding(
padding: EdgeInsets.symmetric(horizontal: 8),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2))),
if (_playError)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Icon(Icons.error, color: Colors.redAccent)),
Expanded(
child: _player.state.duration.inMilliseconds > 0
? StreamBuilder(
stream: _player.stream.position,
builder: (context, snap) {
final pos = snap.data ?? Duration.zero;
final dur = _player.state.duration;
return Slider(
value: pos.inMilliseconds.toDouble().clamp(
0.0, dur.inMilliseconds.toDouble()),
max: dur.inMilliseconds.toDouble(),
onChanged: (v) => _player
.seek(Duration(milliseconds: v.toInt())));
})
: const SizedBox.shrink()),
IconButton(
icon: const Icon(Icons.info_outline, color: Colors.white),
tooltip: 'Inspect stream',
onPressed: _inspectStream),
IconButton(
icon: const Icon(Icons.open_in_full, color: Colors.white),
onPressed: widget.inlineMode
? () => Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PlayerScreen(
videoPath: widget.videoPath,
videoName: widget.videoName,
subtitleContent: widget.subtitleContent)))
: null),
]),
),
),
if (_isBuffering)
Positioned.fill(
child: Container(
color: Colors.black45,
child: Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
const CircularProgressIndicator(),
const SizedBox(height: 12),
const Text('Buffering...',
style: TextStyle(color: Colors.white))
])))),
if (_playError)
Positioned.fill(
child: Container(
color: Colors.black54,
child: Center(
child: Card(
color: Colors.red.shade900,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(_errorMessage ?? 'Playback failed',
style: const TextStyle(
color: Colors.white, fontSize: 16)),
const SizedBox(height: 12),
Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
onPressed: _retryPlay,
child: const Text('Retry',
style: TextStyle(
color: Colors.white))),
const SizedBox(width: 8),
TextButton(
onPressed: _inspectStream,
child: const Text('Inspect',
style: TextStyle(
color: Colors.white)))
])
])))))),
if (_showDraggingOverlay)
Positioned.fill(
child: Container(
color: Colors.black45,
child: const Center(
child: Text('드롭하여 자막 로드',
style:
TextStyle(color: Colors.white, fontSize: 20)))))
]),
);
}
Widget _buildGestureIndicator() {
return Center(
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.black54, borderRadius: BorderRadius.circular(10)),
child: Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.volume_up, color: Colors.white, size: 40),
const SizedBox(height: 10),
SizedBox(
width: 100,
child: LinearProgressIndicator(
value: _gestureProgress, backgroundColor: Colors.white24)),
]),
),
);
}
// Subtitle helpers
Future<void> _loadSubtitleFromPicker() async {
try {
final result = await FilePicker.platform.pickFiles(
type: FileType.custom, allowedExtensions: ['srt', 'vtt', 'ass']);
if (result == null) return;
final path = result.files.single.path;
if (path == null) return;
final bytes = await File(path).readAsBytes();
final content = await _decodeBytes(bytes);
if (content == null) {
if (mounted)
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('자막을 디코딩할 수 없습니다.')));
return;
}
setState(() => _subtitles = _parseSrt(content));
if (mounted) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context)
.showSnackBar(const SnackBar(content: Text('자막을 불러왔습니다.')));
}
} catch (e) {
debugPrint('자막 로드 실패: $e');
if (mounted)
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('자막 로드 실패: $e')));
}
}
Future<String?> _decodeBytes(Uint8List bytes) async {
try {
final s = utf8.decode(bytes);
if (!s.contains('\uFFFD')) return s.replaceFirst('\uFEFF', '');
} catch (_) {}
try {
final s = await CharsetConverter.decode('euc-kr', bytes);
return s.replaceFirst('\uFEFF', '');
} catch (_) {}
try {
return latin1.decode(bytes);
} catch (_) {}
return null;
}
List<SubtitleCue> _parseSrt(String content) {
final lines = content.replaceAll('\r', '').split('\n');
final cues = <SubtitleCue>[];
int i = 0;
Duration? _parseTime(String t) {
final cleaned = t.replaceAll(',', '.');
final parts = cleaned.split(':');
if (parts.length != 3) return null;
final hours = int.tryParse(parts[0]) ?? 0;
final minutes = int.tryParse(parts[1]) ?? 0;
final secParts = parts[2].split('.');
final seconds = int.tryParse(secParts[0]) ?? 0;
final millis = secParts.length > 1
? int.parse((secParts[1] + '000').substring(0, 3))
: 0;
return Duration(
hours: hours,
minutes: minutes,
seconds: seconds,
milliseconds: millis);
}
while (i < lines.length) {
if (lines[i].trim().isEmpty) {
i++;
continue;
}
if (RegExp(r'^\d+\s*$').hasMatch(lines[i].trim())) {
i++;
if (i >= lines.length) break;
}
final timeLine = lines[i].trim();
if (!timeLine.contains('-->')) {
i++;
continue;
}
final parts = timeLine.split('-->');
final start = _parseTime(parts[0].trim());
final end = _parseTime(parts[1].trim());
i++;
final buffer = StringBuffer();
while (i < lines.length && lines[i].trim().isNotEmpty) {
if (buffer.isNotEmpty) buffer.writeln();
buffer.write(lines[i]);
i++;
}
if (start != null && end != null) {
cues.add(SubtitleCue(
start: start, end: end, text: buffer.toString().trim()));
}
}
return cues;
}
SubtitleCue? _currentCue(Duration position) {
for (final c in _subtitles) {
if (position >= c.start && position <= c.end) return c;
}
return null;
}
String _sanitizeSubtitle(String raw) {
final noTags = raw.replaceAll(RegExp(r'<[^>]*>'), '');
final collapsed = noTags.replaceAll(RegExp(r'\n{2,}'), '\n');
return collapsed.trim();
}
}