JJ_MKVMaker/lib/ui/subtitle_editor_panes.dart

438 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// 자막 편집 화면의 조각 (영상 위 자막 · 자막 표 · 재생 조작 막대) - 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) 에서
/// 오른쪽으로 넘쳤다 - 내용 칸까지 한 줄에 들지 않으면 두 단 (위: # · 시작 · 끝 · 단추, 아래: 내용). 시간 칸은 줄이지 않는다 (편집이 어려움)
bool _cueNarrow(double width) => width < 48 + 130 + 130 + 96 + 200;
/// 두 단일 때 # 칸 · 단추 하나의 폭 (위 단이 412 에 들게: 28 + 130 + 130 + 2 × 40 = 368)
const _narrowNo = 28.0;
const _narrowButton = 40.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),
);
// 좁으면 단추 누를 자리를 40 으로 (기본 48) - 위 단이 폭에 들게
final small = narrow ? const BoxConstraints(minWidth: _narrowButton, minHeight: _narrowButton) : null;
final buttons = [
IconButton(
tooltip: tr('아래에 줄 추가'),
iconSize: 18,
constraints: small,
padding: narrow ? EdgeInsets.zero : null,
icon: Icon(Icons.add, color: JjColors.textDim),
onPressed: () => e.insertAfter(widget.index),
),
IconButton(
tooltip: tr('줄 삭제'),
iconSize: 18,
constraints: small,
padding: narrow ? EdgeInsets.zero : null,
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: 좁을 때 두 단 - 위 # · 시작 · 끝 · 단추, 아래 내용 (# 칸만큼 들여)
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [number, start, end, const Spacer(), ...buttons]),
Padding(padding: const EdgeInsets.only(left: _narrowNo), child: text),
]);
}),
);
}
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();
},
),
);
}
}