448 lines
16 KiB
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)),
|
|
);
|
|
}
|
|
}
|
|
|