2026-01-11 19:49:43 +09:00
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 (
2026-01-11 19:57:20 +09:00
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 ) ) ,
) ,
] ,
) ,
] ,
) ,
) ,
) ,
) ,
) ,
) ,
2026-01-11 19:49:43 +09:00
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 ( ) ;
}
}