나누기: 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
parent
cac9a1b67a
commit
f183d5866f
|
|
@ -1,3 +1,5 @@
|
||||||
|
// 자막 편집 화면: 자막을 불러와 열고 ([openSubtitleEditor]) 재생 · 저장 · 단축키를 맡는다.
|
||||||
|
// 영상 위 자막 · 자막 표 · 재생 조작 막대는 subtitle_editor_panes.dart 의 조각을 부르기만 한다.
|
||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
@ -11,8 +13,8 @@ import '../core/srt.dart';
|
||||||
import '../core/text_codec.dart';
|
import '../core/text_codec.dart';
|
||||||
import '../services/preview_player.dart';
|
import '../services/preview_player.dart';
|
||||||
import 'app_actions.dart';
|
import 'app_actions.dart';
|
||||||
import 'key_hint.dart';
|
|
||||||
import 'path_label.dart' show middleEllipsis;
|
import 'path_label.dart' show middleEllipsis;
|
||||||
|
import 'subtitle_editor_panes.dart';
|
||||||
import 'theme.dart';
|
import 'theme.dart';
|
||||||
import 'timeline.dart';
|
import 'timeline.dart';
|
||||||
import '../l10n/tr.dart';
|
import '../l10n/tr.dart';
|
||||||
|
|
@ -317,172 +319,31 @@ class _SubtitleEditorPageState extends State<SubtitleEditorPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ───────── 영상 · 자막 겹쳐 보기 ─────────
|
// ───────── 화면 조각 (subtitle_editor_panes.dart) ─────────
|
||||||
|
|
||||||
Widget _videoPane() => Container(
|
Widget _videoPane() => SubtitleVideoPane(view: _pl!.buildView(), editor: e, position: _position, error: _playerError);
|
||||||
color: Colors.black,
|
|
||||||
child: Stack(
|
Widget _cueTable() => SubtitleCueTable(editor: e, active: _active, onSelect: _selectCue);
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
Widget _transportBar() => SubtitleTransportBar(
|
||||||
_pl!.buildView(),
|
position: _position,
|
||||||
// 편집 중인 자막을 영상 위에 표시
|
duration: _duration,
|
||||||
Positioned(
|
playing: _playing,
|
||||||
left: 16,
|
rate: _rate,
|
||||||
right: 16,
|
hasSelection: e.selected != null,
|
||||||
bottom: 20,
|
onSeekBy: _seekBy,
|
||||||
child: IgnorePointer(
|
onPlayPause: () => _pl?.playOrPause(),
|
||||||
child: ValueListenableBuilder<Duration>(
|
onRate: (r) {
|
||||||
valueListenable: _position,
|
setState(() => _rate = r);
|
||||||
builder: (_, pos, _) => ListenableBuilder(
|
_pl?.setRate(r);
|
||||||
listenable: e,
|
},
|
||||||
builder: (_, _) {
|
onInsert: _insertHere,
|
||||||
final text = e.activeAt(pos).map((c) => c.text).join('\n');
|
onPlaySelected: _playSelected,
|
||||||
if (text.isEmpty) return const SizedBox();
|
onSetStart: _setStartHere,
|
||||||
return Center(
|
onSetEnd: _setEndHere,
|
||||||
child: Container(
|
onAlign: _alignHere,
|
||||||
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 _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(
|
Widget _toolbar(BuildContext context) => AppTopBar(
|
||||||
nav: const AppNavButtons(),
|
nav: const AppNavButtons(),
|
||||||
actions: const AppActions(),
|
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();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue