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