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