diff --git a/lib/ui/subtitle_editor_page.dart b/lib/ui/subtitle_editor_page.dart index 787035f..6276b07 100644 --- a/lib/ui/subtitle_editor_page.dart +++ b/lib/ui/subtitle_editor_page.dart @@ -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 { ); } - // ───────── 영상 · 자막 겹쳐 보기 ───────── + // ───────── 화면 조각 (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( - 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), - ), - ), - ); - }, - ), - ), - ), - ), - 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)), - ), - ), - ], - ), + 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 _cueTable() => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - const _HeaderRow(), - const Divider(height: 1), - Expanded( - child: ValueListenableBuilder( - 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( - valueListenable: _position, - builder: (_, pos, _) => Text( - '${formatSrtTime(pos)} / ${formatSrtTime(_duration)}', - style: const TextStyle(fontFamily: 'Consolas', fontSize: 13), - ), - ), - const SizedBox(width: 10), - DropdownButton( - 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 { } } -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(); - }, - ), - ); - } -} diff --git a/lib/ui/subtitle_editor_panes.dart b/lib/ui/subtitle_editor_panes.dart new file mode 100644 index 0000000..8ac4d02 --- /dev/null +++ b/lib/ui/subtitle_editor_panes.dart @@ -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 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( + 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 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( + 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 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( + valueListenable: position, + builder: (_, pos, _) => Text( + '${formatSrtTime(pos)} / ${formatSrtTime(duration)}', + style: const TextStyle(fontFamily: 'Consolas', fontSize: 13), + ), + ), + const SizedBox(width: 10), + DropdownButton( + 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(); + }, + ), + ); + } +} diff --git a/test/subtitle_editor_panes_contract_test.dart b/test/subtitle_editor_panes_contract_test.dart new file mode 100644 index 0000000..f5f9081 --- /dev/null +++ b/test/subtitle_editor_panes_contract_test.dart @@ -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(null); + final picked = []; + 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 = []; + 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(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); + }); +}