나누기: subtitle_editor_page (798) 에서 영상 위 자막 · 자막 표 · 재생 막대를 subtitle_editor_panes 모듈로 (→ 447 · 437)

- subtitle_editor_panes.dart (새): SubtitleVideoPane · SubtitleCueTable (+ 머리 줄 · 줄) · SubtitleTransportBar
  화면 필드 대신 편집기 · 위치 (ValueListenable) · 재생 상태 · 콜백을 인자로 받는다
- subtitle_editor_page.dart: 재생 · 저장 · 단축키만, 조각은 부르기만
- 약속 시험 1개 (실제 SubtitleEditorController)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
split_ui
RyuJongJin 2026-10-22 16:44:51 +09:00
parent cac9a1b67a
commit f183d5866f
3 changed files with 551 additions and 376 deletions

View File

@ -1,3 +1,5 @@
// 자막 편집 화면: 자막을 불러와 열고 ([openSubtitleEditor]) 재생 · 저장 · 단축키를 맡는다.
// 영상 위 자막 · 자막 표 · 재생 조작 막대는 subtitle_editor_panes.dart 의 조각을 부르기만 한다.
import 'dart:async';
import 'package:flutter/material.dart';
@ -11,8 +13,8 @@ import '../core/srt.dart';
import '../core/text_codec.dart';
import '../services/preview_player.dart';
import 'app_actions.dart';
import 'key_hint.dart';
import 'path_label.dart' show middleEllipsis;
import 'subtitle_editor_panes.dart';
import 'theme.dart';
import 'timeline.dart';
import '../l10n/tr.dart';
@ -317,172 +319,31 @@ class _SubtitleEditorPageState extends State<SubtitleEditorPage> {
);
}
// ───────── 영상 · 자막 겹쳐 보기 ─────────
// ───────── 화면 조각 (subtitle_editor_panes.dart) ─────────
Widget _videoPane() => Container(
color: Colors.black,
child: Stack(
fit: StackFit.expand,
children: [
_pl!.buildView(),
// 편집 중인 자막을 영상 위에 표시
Positioned(
left: 16,
right: 16,
bottom: 20,
child: IgnorePointer(
child: ValueListenableBuilder<Duration>(
valueListenable: _position,
builder: (_, pos, _) => ListenableBuilder(
listenable: e,
builder: (_, _) {
final text = e.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),
),
),
);
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);
},
),
),
),
),
if (_playerError != 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}', [_playerError]),
style: const TextStyle(fontSize: 12, color: Colors.white)),
),
),
],
),
onInsert: _insertHere,
onPlaySelected: _playSelected,
onSetStart: _setStartHere,
onSetEnd: _setEndHere,
onAlign: _alignHere,
);
Widget _cueTable() => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _HeaderRow(),
const Divider(height: 1),
Expanded(
child: ValueListenableBuilder<Cue?>(
valueListenable: _active,
builder: (_, active, _) => ListView.builder(
itemCount: e.cues.length,
itemBuilder: (_, i) {
final c = e.cues[i];
return _CueRow(
key: ObjectKey(c),
index: i,
cue: c,
editor: e,
orderError: e.invalidOrder.contains(i),
selected: e.selected == c,
active: active == c,
onSelect: () => _selectCue(c),
);
},
),
),
),
],
);
// ───────── 재생 조작 막대 ─────────
Widget _transportBar() {
final hasSel = e.selected != null;
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: () => _seekBy(-5000),
),
IconButton(
tooltip: keyHint(tr('재생/정지'), 'Ctrl+Space'),
iconSize: 30,
icon: Icon(_playing ? Icons.pause_circle : Icons.play_circle,
color: JjColors.accent),
onPressed: () => _pl?.playOrPause(),
),
IconButton(
tooltip: tr('5초 앞으로'),
icon: const Icon(Icons.forward_5),
onPressed: () => _seekBy(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) {
setState(() => _rate = r!);
_pl?.setRate(r!);
},
),
// 폭이 좁으면 (휴대폰) 밀어서 본다. 넓으면 지금처럼 오른쪽에 붙는다 (reverse)
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
reverse: true,
child: Row(mainAxisSize: MainAxisSize.min, children: [
btn(tr('+ 여기에 새 줄'), 'F7', _insertHere),
btn(tr('선택 줄 재생'), 'F8', hasSel ? _playSelected : null),
btn(tr('시작 = 현재'), 'F9', hasSel ? _setStartHere : null),
btn(tr('끝 = 현재'), 'F10', hasSel ? _setEndHere : null),
btn(tr('선택 줄부터 여기로 맞추기'), tr('선택한 줄과 그 뒤 모든 줄을 함께 이동'), hasSel ? _alignHere : null),
]),
),
),
],
),
);
}
Widget _toolbar(BuildContext context) => AppTopBar(
nav: const AppNavButtons(),
actions: const AppActions(),
@ -584,215 +445,3 @@ class _SubtitleEditorPageState extends State<SubtitleEditorPage> {
}
}
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();
},
),
);
}
}

View File

@ -0,0 +1,437 @@
// 자막 편집 화면의 조각 (영상 위 자막 · 자막 표 · 재생 조작 막대) - 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();
},
),
);
}
}

View File

@ -0,0 +1,89 @@
// subtitle_editor_panes.dart 약속 시험: 실제 SubtitleEditorController 로 조각의 입력 · 출력 (편집기 · 콜백) · 오류 표시를 본다.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/subtitle_editor_controller.dart';
import 'package:jj_mkvmaker/core/srt.dart';
import 'package:jj_mkvmaker/ui/subtitle_editor_panes.dart';
SubtitleEditorController _editor() => SubtitleEditorController([
Cue(const Duration(seconds: 1), const Duration(seconds: 2), '첫 줄'),
Cue(const Duration(seconds: 3), const Duration(seconds: 4), '둘째 줄'),
], saveCharset: 'UTF-8');
Widget _host(Widget child) => MaterialApp(home: Scaffold(body: child));
void main() {
testWidgets('SubtitleCueTable: 번호를 누르면 onSelect · 시간 고치기 · 틀린 형식 · 줄 추가 · 삭제는 편집기로', (tester) async {
tester.view.physicalSize = const Size(1200, 700);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final e = _editor();
final active = ValueNotifier<Cue?>(null);
final picked = <Cue>[];
await tester.pumpWidget(_host(ListenableBuilder(
listenable: e, builder: (_, _) => SubtitleCueTable(editor: e, active: active, onSelect: picked.add))));
await tester.tap(find.text('2'));
expect(picked, [e.cues[1]]);
await tester.enterText(find.text('00:00:01,000'), '00:00:01,500');
expect(e.cues[0].start, const Duration(milliseconds: 1500));
await tester.enterText(find.text('00:00:03,000'), '말도 안 됨');
await tester.pump();
expect(find.text('형식 오류'), findsOneWidget);
expect(e.cues[1].start, const Duration(seconds: 3));
await tester.tap(find.byTooltip('아래에 줄 추가').first);
await tester.pump();
expect(e.cues.length, 3);
await tester.tap(find.byTooltip('줄 삭제').last);
await tester.pump();
expect(e.cues.length, 2);
});
testWidgets('SubtitleTransportBar: 고른 줄이 없으면 줄 단추 꺼짐 · 누르면 콜백', (tester) async {
// 줄 단추가 모두 보이는 넓은 화면 (좁으면 밀어서 본다)
tester.view.physicalSize = const Size(1800, 600);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final calls = <String>[];
Widget bar(bool sel) => _host(SubtitleTransportBar(
position: ValueNotifier(const Duration(seconds: 5)),
duration: const Duration(minutes: 1),
playing: false,
rate: 1.0,
hasSelection: sel,
onSeekBy: (ms) => calls.add('seek $ms'),
onPlayPause: () => calls.add('play'),
onRate: (r) => calls.add('rate $r'),
onInsert: () => calls.add('insert'),
onPlaySelected: () => calls.add('playSel'),
onSetStart: () => calls.add('start'),
onSetEnd: () => calls.add('end'),
onAlign: () => calls.add('align'),
));
await tester.pumpWidget(bar(false));
expect(find.text('00:00:05,000 / 00:01:00,000'), findsOneWidget);
expect(tester.widget<OutlinedButton>(find.widgetWithText(OutlinedButton, '시작 = 현재')).onPressed, isNull);
await tester.tap(find.byIcon(Icons.replay_5));
await tester.tap(find.byIcon(Icons.play_circle));
await tester.tap(find.text('+ 여기에 새 줄'));
await tester.pumpWidget(bar(true));
await tester.tap(find.text('끝 = 현재'));
await tester.tap(find.text('1.0x'));
await tester.pumpAndSettle();
await tester.tap(find.text('2.0x').last);
await tester.pumpAndSettle();
expect(calls, ['seek -5000', 'play', 'insert', 'end', 'rate 2.0']);
});
testWidgets('SubtitleVideoPane: 지금 위치의 줄 · 재생 오류를 겹쳐 보인다', (tester) async {
final e = _editor();
final pos = ValueNotifier(const Duration(milliseconds: 1200));
await tester.pumpWidget(_host(SubtitleVideoPane(view: const SizedBox(), editor: e, position: pos, error: '열 수 없음')));
expect(find.text('첫 줄'), findsOneWidget);
expect(find.textContaining('열 수 없음'), findsOneWidget);
pos.value = const Duration(milliseconds: 2500);
await tester.pump();
expect(find.text('첫 줄'), findsNothing);
});
}