JJ_MKVMaker/lib/ui/subtitle_editor_panes.dart

435 lines
16 KiB
Dart

// 자막 편집 화면의 조각 (영상 위 자막 · 자막 표 · 재생 조작 막대) - subtitle_editor_page.dart 에서 나눔.
// 약속:
// - 입력: 자막 편집기 [SubtitleEditorController], 재생 위치 · 지금 보이는 줄 ([ValueListenable]), 재생기 화면 · 오류 글,
// 재생 상태 (길이 · 재생 중 · 빠르기 · 고른 줄이 있는지) 와 누를 때 부를 콜백.
// - 출력: 화면 조각. 고치기 (시간 · 내용 · 줄 추가 · 삭제) 는 [SubtitleEditorController] 로, 재생 조작은 콜백으로만.
// - 오류: 던지지 않는다. 시간 형식이 틀리면 그 칸에 "형식 오류" 만 보인다.
// 화면 (State) 의 필드에 기대지 않는다 - 다른 화면에서도 같은 인자로 부를 수 있다.
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import '../app/subtitle_editor_controller.dart';
import '../core/srt.dart';
import '../l10n/tr.dart';
import 'key_hint.dart';
import 'theme.dart';
/// 영상 위에 편집 중인 자막 (지금 위치의 줄) · 재생 오류를 겹쳐 보인다
class SubtitleVideoPane extends StatelessWidget {
/// 재생기 화면 (PreviewPlayer.buildView)
final Widget view;
final SubtitleEditorController editor;
final ValueListenable<Duration> position;
final String? error;
const SubtitleVideoPane({super.key, required this.view, required this.editor, required this.position, this.error});
@override
Widget build(BuildContext context) => Container(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [
view,
// 편집 중인 자막을 영상 위에 표시
Positioned(
left: 16,
right: 16,
bottom: 20,
child: IgnorePointer(
child: ValueListenableBuilder<Duration>(
valueListenable: position,
builder: (_, pos, _) => ListenableBuilder(
listenable: editor,
builder: (_, _) {
final text = editor.activeAt(pos).map((c) => c.text).join('\n');
if (text.isEmpty) return const SizedBox();
return Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
color: Colors.black.withValues(alpha: 0.55),
child: Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 20, color: Colors.white, height: 1.3),
),
),
);
},
),
),
),
),
if (error != null)
Positioned(
left: 8,
right: 8,
top: 8,
child: Container(
padding: const EdgeInsets.all(8),
color: JjColors.danger.withValues(alpha: 0.85),
child: Text(trf('영상 재생 오류: {0}', [error]),
style: const TextStyle(fontSize: 12, color: Colors.white)),
),
),
],
),
);
}
/// 자막 표 (머리 줄 + 줄마다 시작 · 끝 · 내용 · 단추). [active] 는 지금 위치에서 보이는 줄
class SubtitleCueTable extends StatelessWidget {
final SubtitleEditorController editor;
final ValueListenable<Cue?> active;
final void Function(Cue c) onSelect;
const SubtitleCueTable({super.key, required this.editor, required this.active, required this.onSelect});
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _HeaderRow(),
const Divider(height: 1),
Expanded(
child: ValueListenableBuilder<Cue?>(
valueListenable: active,
builder: (_, active, _) => ListView.builder(
itemCount: editor.cues.length,
itemBuilder: (_, i) {
final c = editor.cues[i];
return _CueRow(
key: ObjectKey(c),
index: i,
cue: c,
editor: editor,
orderError: editor.invalidOrder.contains(i),
selected: editor.selected == c,
active: active == c,
onSelect: () => onSelect(c),
);
},
),
),
),
],
);
}
/// 재생 조작 막대: 5초 뒤 · 재생 · 5초 앞 · 위치 · 빠르기 · 줄 단추 (F7 ~ F10, 고른 줄이 없으면 줄 단추는 꺼짐)
class SubtitleTransportBar extends StatelessWidget {
final ValueListenable<Duration> position;
final Duration duration;
final bool playing;
final double rate;
final bool hasSelection;
final void Function(int ms) onSeekBy;
final VoidCallback onPlayPause;
final void Function(double rate) onRate;
final VoidCallback onInsert, onPlaySelected, onSetStart, onSetEnd, onAlign;
const SubtitleTransportBar({
super.key,
required this.position,
required this.duration,
required this.playing,
required this.rate,
required this.hasSelection,
required this.onSeekBy,
required this.onPlayPause,
required this.onRate,
required this.onInsert,
required this.onPlaySelected,
required this.onSetStart,
required this.onSetEnd,
required this.onAlign,
});
@override
Widget build(BuildContext context) {
final hasSel = hasSelection;
Widget btn(String label, String key, VoidCallback? onTap) => Padding(
padding: const EdgeInsets.only(left: 6),
child: Tooltip(
message: key,
child: OutlinedButton(
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 10),
visualDensity: VisualDensity.compact),
onPressed: onTap,
child: Text(label, style: const TextStyle(fontSize: 12)),
),
),
);
return Container(
height: 48,
color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
IconButton(
tooltip: tr('5초 뒤로'),
icon: const Icon(Icons.replay_5),
onPressed: () => onSeekBy(-5000),
),
IconButton(
tooltip: keyHint(tr('재생/정지'), 'Ctrl+Space'),
iconSize: 30,
icon: Icon(playing ? Icons.pause_circle : Icons.play_circle,
color: JjColors.accent),
onPressed: onPlayPause,
),
IconButton(
tooltip: tr('5초 앞으로'),
icon: const Icon(Icons.forward_5),
onPressed: () => onSeekBy(5000),
),
const SizedBox(width: 6),
ValueListenableBuilder<Duration>(
valueListenable: position,
builder: (_, pos, _) => Text(
'${formatSrtTime(pos)} / ${formatSrtTime(duration)}',
style: const TextStyle(fontFamily: 'Consolas', fontSize: 13),
),
),
const SizedBox(width: 10),
DropdownButton<double>(
value: rate,
isDense: true,
underline: const SizedBox(),
style: TextStyle(fontSize: 12, color: JjColors.text),
items: [
for (final r in const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0])
DropdownMenuItem(value: r, child: Text('${r}x')),
],
onChanged: (r) => onRate(r!),
),
// 폭이 좁으면 (휴대폰) 밀어서 본다. 넓으면 지금처럼 오른쪽에 붙는다 (reverse)
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: Row(mainAxisSize: MainAxisSize.min, children: [
btn(tr('+ 여기에 새 줄'), 'F7', onInsert),
btn(tr('선택 줄 재생'), 'F8', hasSel ? onPlaySelected : null),
btn(tr('시작 = 현재'), 'F9', hasSel ? onSetStart : null),
btn(tr('끝 = 현재'), 'F10', hasSel ? onSetEnd : null),
btn(tr('선택 줄부터 여기로 맞추기'), tr('선택한 줄과 그 뒤 모든 줄을 함께 이동'), hasSel ? onAlign : null),
]),
),
),
],
),
);
}
}
class _HeaderRow extends StatelessWidget {
const _HeaderRow();
@override
Widget build(BuildContext context) => Container(
color: JjColors.panel,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: LayoutBuilder(builder: (context, box) {
if (!_cueNarrow(box.maxWidth)) {
return Row(children: [
SizedBox(width: 48, child: Text('#', style: _hdr)),
SizedBox(width: 130, child: Text(tr('시작'), style: _hdr)),
SizedBox(width: 130, child: Text(tr('끝'), style: _hdr)),
Expanded(child: Text(tr('내용'), style: _hdr)),
SizedBox(width: 96),
]);
}
// F9: 좁을 때 - 위 # · 시작 · 끝, 아래 내용 (줄과 같은 두 단)
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
SizedBox(width: _narrowNo, child: Text('#', style: _hdr)),
SizedBox(width: 130, child: Text(tr('시작'), style: _hdr)),
SizedBox(width: 130, child: Text(tr('끝'), style: _hdr)),
]),
Padding(padding: const EdgeInsets.only(left: _narrowNo), child: Text(tr('내용'), style: _hdr)),
]);
}),
);
}
/// 관리자 지시 (10/21, 15 찾음 F9): 자막 편집 표는 # · 시작 · 끝 · 단추 칸 폭이 정해져 있어 (48 + 130 + 130 + 96) 폰 세로 (412) 에서
/// 오른쪽으로 넘쳤다 - 내용 칸까지 한 줄에 들지 않으면 두 단 (위: # · 시작 · 끝, 아래: 내용 · 단추). 시간 칸은 줄이지 않는다 (편집이 어려움).
/// 단추는 두 단이 되는 폰 (손가락) 에서 누를 자리 48 을 지킨다 (관리자 10/21, 7247aeb 는 after_1020 에만 있어 다시 - 360 에서 위 단이 넘쳤다)
bool _cueNarrow(double width) => width < 48 + 130 + 130 + 96 + 200;
/// 두 단일 때 # 칸 폭 (위 단 28 + 130 + 130 = 288 - 360 폭에도 듬)
const _narrowNo = 28.0;
final _hdr = TextStyle(fontSize: 12, color: JjColors.textDim);
class _CueRow extends StatefulWidget {
final int index;
final Cue cue;
final SubtitleEditorController editor;
final bool orderError;
final bool selected;
/// 현재 재생 위치에서 표시 중인 줄
final bool active;
final VoidCallback? onSelect;
const _CueRow({
super.key,
required this.index,
required this.cue,
required this.editor,
required this.orderError,
this.selected = false,
this.active = false,
this.onSelect,
});
@override
State<_CueRow> createState() => _CueRowState();
}
class _CueRowState extends State<_CueRow> {
late final _start = TextEditingController(text: formatSrtTime(widget.cue.start));
late final _end = TextEditingController(text: formatSrtTime(widget.cue.end));
late final _text = TextEditingController(text: widget.cue.text);
final _startFocus = FocusNode();
final _endFocus = FocusNode();
bool _startBad = false, _endBad = false;
@override
void didUpdateWidget(covariant _CueRow old) {
super.didUpdateWidget(old);
// 전체 이동 등 바깥에서 바뀐 시간을 입력칸에 반영 (입력 중이 아닐 때만)
if (!_startFocus.hasFocus && !_startBad) _sync(_start, formatSrtTime(widget.cue.start));
if (!_endFocus.hasFocus && !_endBad) _sync(_end, formatSrtTime(widget.cue.end));
}
void _sync(TextEditingController c, String v) {
if (c.text != v) c.text = v;
}
@override
void dispose() {
_start.dispose();
_end.dispose();
_text.dispose();
_startFocus.dispose();
_endFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final e = widget.editor;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
color: widget.orderError
? JjColors.danger.withValues(alpha: 0.08)
: widget.selected
? JjColors.accent.withValues(alpha: 0.12)
: null,
border: Border(
bottom: BorderSide(color: JjColors.border),
left: BorderSide(
color: widget.active ? JjColors.accent : Colors.transparent, width: 3),
),
),
child: LayoutBuilder(builder: (context, box) {
final narrow = _cueNarrow(box.maxWidth);
final number = SizedBox(
width: narrow ? _narrowNo : 48,
child: InkWell(
onTap: widget.onSelect,
child: Padding(
padding: const EdgeInsets.only(top: 10, bottom: 10),
child: Text('${widget.index + 1}',
style: TextStyle(
fontSize: 12,
color: widget.selected ? JjColors.accent : JjColors.textDim)),
),
),
);
final start = SizedBox(width: 130, child: _timeField(_start, _startFocus, _startBad, true));
final end = SizedBox(width: 130, child: _timeField(_end, _endFocus, _endBad, false));
final text = TextField(
controller: _text,
minLines: 1,
maxLines: 4,
style: const TextStyle(fontSize: 14),
decoration: const InputDecoration(isDense: true, border: InputBorder.none),
onTap: () => e.select(widget.cue),
onChanged: (t) => e.setText(widget.cue, t),
);
final buttons = [
IconButton(
tooltip: tr('아래에 줄 추가'),
iconSize: 18,
icon: Icon(Icons.add, color: JjColors.textDim),
onPressed: () => e.insertAfter(widget.index),
),
IconButton(
tooltip: tr('줄 삭제'),
iconSize: 18,
icon: Icon(Icons.delete_outline, color: JjColors.textDim),
onPressed: () => e.removeAt(widget.index),
),
];
if (!narrow) {
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
number,
start,
end,
Expanded(child: text),
SizedBox(width: 96, child: Row(children: buttons)),
]);
}
// F9: 좁을 때 두 단 - 위 # · 시작 · 끝, 아래 내용 (# 칸만큼 들여) · 단추 (오른쪽 끝, 누를 자리 48)
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [number, start, end]),
Padding(
padding: const EdgeInsets.only(left: _narrowNo),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [Expanded(child: text), ...buttons]),
),
]);
}),
);
}
Widget _timeField(
TextEditingController c, FocusNode f, bool bad, bool isStart) {
return Padding(
padding: const EdgeInsets.only(right: 10),
child: TextField(
controller: c,
focusNode: f,
style: const TextStyle(fontSize: 13, fontFamily: 'Consolas'),
decoration: InputDecoration(
isDense: true,
border: InputBorder.none,
errorText: bad ? tr('형식 오류') : null,
errorStyle: const TextStyle(fontSize: 10),
),
onTap: () => widget.editor.select(widget.cue),
onChanged: (t) {
final ok = widget.editor.setTime(widget.cue, t, isStart: isStart);
setState(() {
if (isStart) {
_startBad = !ok;
} else {
_endBad = !ok;
}
});
},
onEditingComplete: () {
// 입력을 마치면 표준 형식으로 정리
final d = parseSrtTime(c.text);
if (d != null) c.text = formatSrtTime(d);
f.unfocus();
},
),
);
}
}