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 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 { late final Player player = Player(); late final VideoController controller = VideoController(player); List _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? _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 _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 _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( 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 _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 _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 _parseSrt(String content) { final lines = content.replaceAll('\r', '').split('\n'); final cues = []; 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( 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 _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 createState() => _PlayerViewState(); } class _PlayerViewState extends State { late final Player _player = Player(); late final VideoController _controller = VideoController(_player); List _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? _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!) : []; }); } } @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 _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 _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( 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 _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 _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 _parseSrt(String content) { final lines = content.replaceAll('\r', '').split('\n'); final cues = []; 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(); } }