1263 lines
43 KiB
Dart
1263 lines
43 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();
|
|
}
|
|
}
|