JJ_MKVMaker/lib/ui/subtitle_editor_page.dart

448 lines
16 KiB
Dart

// 자막 편집 화면: 자막을 불러와 열고 ([openSubtitleEditor]) 재생 · 저장 · 단축키를 맡는다.
// 영상 위 자막 · 자막 표 · 재생 조작 막대는 subtitle_editor_panes.dart 의 조각을 부르기만 한다.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path/path.dart' as p;
import '../app/app_controller.dart';
import '../app/subtitle_editor_controller.dart';
import '../core/models.dart';
import '../core/srt.dart';
import '../core/text_codec.dart';
import '../services/preview_player.dart';
import 'app_actions.dart';
import 'path_label.dart' show middleEllipsis;
import 'subtitle_editor_panes.dart';
import 'theme.dart';
import 'timeline.dart';
import '../l10n/tr.dart';
/// 자막을 불러와 편집 화면을 연다.
Future<void> openSubtitleEditor(
BuildContext context, AppController c, VideoItem v, SubtitleEntry s) async {
final nav = Navigator.of(context);
final messenger = ScaffoldMessenger.of(context);
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const Center(child: CircularProgressIndicator()),
);
List<Cue> cues;
try {
cues = await c.loadCues(v, s);
} catch (e) {
nav.pop();
messenger.showSnackBar(SnackBar(content: Text(trf('자막을 불러올 수 없습니다: {0}', [e]))));
return;
}
nav.pop();
final charset = s.kind == SubtitleKind.external && s.codec == 'srt'
? (s.charset ?? 'UTF-8')
: 'UTF-8';
await nav.push(MaterialPageRoute<void>(
builder: (_) => SubtitleEditorPage(
app: c,
video: v,
entry: s,
editor: SubtitleEditorController(cues, saveCharset: charset),
player: c.services.createPlayer?.call(),
),
));
}
class SubtitleEditorPage extends StatefulWidget {
final AppController app;
final VideoItem video;
final SubtitleEntry entry;
final SubtitleEditorController editor;
/// 영상 재생기 (없으면 자막 표만 표시)
final PreviewPlayer? player;
const SubtitleEditorPage({
super.key,
required this.app,
required this.video,
required this.entry,
required this.editor,
this.player,
});
@override
State<SubtitleEditorPage> createState() => _SubtitleEditorPageState();
}
class _SubtitleEditorPageState extends State<SubtitleEditorPage> {
late SubtitleEntry _entry = widget.entry;
final _shift = TextEditingController(text: '0');
bool _saving = false;
// 재생 상태
final _position = ValueNotifier(Duration.zero);
final _active = ValueNotifier<Cue?>(null);
final _subs = <StreamSubscription<Object?>>[];
Duration _duration = Duration.zero;
bool _playing = false;
double _rate = 1.0;
String? _playerError;
SubtitleEditorController get e => widget.editor;
PreviewPlayer? get _pl => widget.player;
@override
void initState() {
super.initState();
final pl = _pl;
if (pl == null) return;
_subs
..add(pl.positionStream.listen((d) {
_position.value = d;
_updateActive();
}))
..add(pl.durationStream.listen((d) => setState(() => _duration = d)))
..add(pl.playingStream.listen((b) => setState(() => _playing = b)))
..add(pl.errorStream.listen((m) => setState(() => _playerError = m)));
e.addListener(_updateActive);
pl.open(widget.video.path).catchError((Object err) {
if (mounted) setState(() => _playerError = '$err');
});
}
void _updateActive() {
final list = e.activeAt(_position.value);
_active.value = list.isEmpty ? null : list.first;
}
@override
void dispose() {
for (final s in _subs) {
s.cancel();
}
e.removeListener(_updateActive);
_pl?.dispose();
_position.dispose();
_active.dispose();
_shift.dispose();
widget.editor.dispose();
super.dispose();
}
// ───────── 재생 조작 ─────────
void _seek(Duration d) {
if (d.isNegative) d = Duration.zero;
if (_duration > Duration.zero && d > _duration) d = _duration;
_position.value = d; // 화면 즉시 반영
_updateActive();
_pl?.seek(d);
}
void _seekBy(int ms) => _seek(_position.value + Duration(milliseconds: ms));
/// 줄 선택 + 그 줄 시작으로 이동
void _selectCue(Cue c) {
e.select(c);
_seek(c.start);
}
void _playSelected() {
final c = e.selected;
if (c == null || _pl == null) return;
_seek(c.start);
if (!_playing) _pl!.playOrPause();
}
void _setStartHere() {
final c = e.selected;
if (c != null) e.setStart(c, _position.value);
}
void _setEndHere() {
final c = e.selected;
if (c != null) e.setEnd(c, _position.value);
}
void _alignHere() {
final c = e.selected;
if (c != null) e.alignFrom(c, _position.value);
}
void _insertHere() => e.insertAt(_position.value);
Map<ShortcutActivator, VoidCallback> get _shortcuts => {
const SingleActivator(LogicalKeyboardKey.space, control: true): () => _pl?.playOrPause(),
const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): () => _seekBy(-1000),
const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): () => _seekBy(1000),
const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true, shift: true): () => _seekBy(-100),
const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true, shift: true): () => _seekBy(100),
const SingleActivator(LogicalKeyboardKey.f7): _insertHere,
const SingleActivator(LogicalKeyboardKey.f8): _playSelected,
const SingleActivator(LogicalKeyboardKey.f9): _setStartHere,
const SingleActivator(LogicalKeyboardKey.f10): _setEndHere,
};
/// 표현할 수 없는 글자가 있으면 확인
Future<bool> _confirmLoss() async {
final lost = encodeText(formatSrt(e.cues), e.saveCharset).lostChars;
if (lost == 0) return true;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('문자셋 경고')),
content: Text(trf('{0} 로 표현할 수 없는 글자 {1}개가 ' '"?" 로 바뀝니다.\n계속 저장할까요? (UTF-8 을 권장합니다)', [saveCharsets[e.saveCharset], lost])),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('저장'))),
],
),
);
return ok ?? false;
}
Future<void> _save() async {
if (!await _confirmLoss() || !mounted) return;
setState(() => _saving = true);
final messenger = ScaffoldMessenger.of(context);
try {
final (path, _) = await widget.app
.saveEdited(widget.video, _entry, e.cues, e.saveCharset);
_entry = widget.video.subtitles
.firstWhere((s) => s.path != null && p.equals(s.path!, path));
e.markSaved();
messenger.showSnackBar(
SnackBar(content: Text(trf('저장했습니다: {0} (MKV 에 반영됨)', [p.basename(path)]))));
} catch (err) {
messenger.showSnackBar(SnackBar(content: Text(trf('저장 실패: {0}', [err]))));
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<void> _saveAs() async {
if (!await _confirmLoss() || !mounted) return;
final messenger = ScaffoldMessenger.of(context);
final name = '${widget.video.baseName}_${_entry.language.code}.srt';
try {
final (path, _) = await widget.app
.saveCuesAs(widget.video, e.cues, e.saveCharset, name);
if (path != null) {
messenger.showSnackBar(SnackBar(content: Text(trf('저장했습니다: {0}', [path]))));
}
} catch (err) {
messenger.showSnackBar(SnackBar(content: Text(trf('저장 실패: {0}', [err]))));
}
}
Future<bool> _confirmLeave() async {
if (!e.dirty) return true;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('저장하지 않은 변경')),
content: Text(tr('변경 내용을 저장하지 않고 나갈까요?')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('계속 편집'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('나가기'))),
],
),
);
return ok ?? false;
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: e,
builder: (context, _) => PopScope(
canPop: !e.dirty,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
final nav = Navigator.of(context);
if (await _confirmLeave()) {
e.markSaved();
nav.pop();
}
},
child: CallbackShortcuts(
bindings: _shortcuts,
child: Focus(
autofocus: true,
child: Scaffold(
body: Column(
children: [
_toolbar(context),
_statusBar(),
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_pl != null) ...[
Expanded(flex: 9, child: _videoPane()),
const VerticalDivider(width: 1),
],
Expanded(flex: 11, child: _cueTable()),
],
),
),
if (_pl != null) ...[
const Divider(height: 1),
_transportBar(),
SizedBox(
height: 104,
child: SubtitleTimeline(
editor: e,
position: _position,
duration: _duration,
followPlayhead: _playing,
onSeek: _seek,
onSelect: _selectCue,
),
),
],
],
),
floatingActionButton: e.cues.isEmpty
? FloatingActionButton.extended(
onPressed: () => e.insertAfter(-1),
icon: const Icon(Icons.add),
label: Text(tr('첫 줄 추가')),
)
: null,
),
),
),
),
);
}
// ───────── 화면 조각 (subtitle_editor_panes.dart) ─────────
Widget _videoPane() => SubtitleVideoPane(view: _pl!.buildView(), editor: e, position: _position, error: _playerError);
Widget _cueTable() => SubtitleCueTable(editor: e, active: _active, onSelect: _selectCue);
Widget _transportBar() => SubtitleTransportBar(
position: _position,
duration: _duration,
playing: _playing,
rate: _rate,
hasSelection: e.selected != null,
onSeekBy: _seekBy,
onPlayPause: () => _pl?.playOrPause(),
onRate: (r) {
setState(() => _rate = r);
_pl?.setRate(r);
},
onInsert: _insertHere,
onPlaySelected: _playSelected,
onSetStart: _setStartHere,
onSetEnd: _setEndHere,
onAlign: _alignHere,
);
Widget _toolbar(BuildContext context) => AppTopBar(
nav: const AppNavButtons(),
actions: const AppActions(),
middle: Row(
children: [
// 관리자 지시 (10/21, 28 찾음): 960 에서도 "자막 편집 · …" 로 잘려 파일 이름이 안 보였다 - 제목은 "자막 편집" 만,
// 파일 이름은 그 아래 한 줄 (가운데를 줄여 앞 · 끝 (.srt) 이 보이게, 길게 누르거나 마우스를 올리면 전체)
Expanded(
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(tr('자막 편집'),
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
LayoutBuilder(builder: (context, box) {
final style = TextStyle(fontSize: 12, color: JjColors.textDim);
return Tooltip(
message: _entry.displayName,
child: Text(middleEllipsis(context, _entry.displayName, style, box.maxWidth),
maxLines: 1, overflow: TextOverflow.clip, style: style),
);
}),
]),
),
// 폭이 좁으면 (휴대폰) 도구 줄을 밀어서 본다 - 설정 · 종료 버튼은 늘 오른쪽 끝에
Flexible(
flex: 4,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(mainAxisSize: MainAxisSize.min, children: [
// 전체 싱크 이동
Text(tr('싱크'), style: TextStyle(fontSize: 12, color: JjColors.textDim)),
const SizedBox(width: 6),
SizedBox(
width: 80,
child: TextField(
controller: _shift,
textAlign: TextAlign.right,
style: const TextStyle(fontSize: 13),
decoration: const InputDecoration(
isDense: true, suffixText: 'ms', border: OutlineInputBorder()),
),
),
const SizedBox(width: 4),
OutlinedButton(
onPressed: () {
final ms = int.tryParse(_shift.text.trim());
if (ms == null) return;
e.shiftAll(ms);
},
child: Text(tr('전체 이동')),
),
const SizedBox(width: 8),
OutlinedButton(onPressed: e.sortByTime, child: Text(tr('시간순 정렬'))),
const SizedBox(width: 12),
DropdownButton<String>(
value: e.saveCharset,
isDense: true,
underline: const SizedBox(),
style: TextStyle(fontSize: 13, color: JjColors.text),
items: [
for (final c in saveCharsets.entries)
DropdownMenuItem(value: c.key, child: Text(c.value)),
],
onChanged: (v) => e.setCharset(v!),
),
const SizedBox(width: 8),
OutlinedButton(
onPressed: e.canSave ? _saveAs : null,
child: Text(tr('다른 이름으로 저장')),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: e.canSave && !_saving ? _save : null,
icon: const Icon(Icons.save, size: 18),
label: Text(e.dirty ? tr('저장 (MKV 반영) *') : tr('저장 (MKV 반영)')),
),
]),
),
),
],
),
);
Widget _statusBar() {
final order = e.invalidOrder;
final msgs = <String>[
trf('{0}줄', [e.cues.length]),
if (e.badInput.isNotEmpty) trf('시간 형식 오류 {0}곳', [e.badInput.length]),
if (order.isNotEmpty)
trf('끝 시간이 시작보다 빠른 줄: {0}' '{1}', [order.take(10).map((i) => i + 1).join(', '), order.length > 10 ? ' …' : '']),
];
final hasError = e.badInput.isNotEmpty || order.isNotEmpty;
return Container(
width: double.infinity,
color: hasError ? JjColors.danger.withValues(alpha: 0.12) : JjColors.bg,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
child: Text(msgs.join(' · '),
style: TextStyle(
fontSize: 12, color: hasError ? JjColors.danger : JjColors.textDim)),
);
}
}