// 자막 편집 화면: 자막을 불러와 열고 ([openSubtitleEditor]) 재생 · 저장 · 단축키를 맡는다. // 영상 위 자막 · 자막 표 · 재생 조작 막대는 subtitle_editor_panes.dart 의 조각을 부르기만 한다. import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import '../app/app_controller.dart'; import '../app/subtitle_editor_controller.dart'; import '../core/models.dart'; import '../core/srt.dart'; import '../core/text_codec.dart'; import '../services/preview_player.dart'; import 'app_actions.dart'; import 'path_label.dart' show middleEllipsis; import 'subtitle_editor_panes.dart'; import 'theme.dart'; import 'timeline.dart'; import '../l10n/tr.dart'; /// 자막을 불러와 편집 화면을 연다. Future openSubtitleEditor( BuildContext context, AppController c, VideoItem v, SubtitleEntry s) async { final nav = Navigator.of(context); final messenger = ScaffoldMessenger.of(context); showDialog( context: context, barrierDismissible: false, builder: (_) => const Center(child: CircularProgressIndicator()), ); List cues; try { cues = await c.loadCues(v, s); } catch (e) { nav.pop(); messenger.showSnackBar(SnackBar(content: Text(trf('자막을 불러올 수 없습니다: {0}', [e])))); return; } nav.pop(); final charset = s.kind == SubtitleKind.external && s.codec == 'srt' ? (s.charset ?? 'UTF-8') : 'UTF-8'; await nav.push(MaterialPageRoute( builder: (_) => SubtitleEditorPage( app: c, video: v, entry: s, editor: SubtitleEditorController(cues, saveCharset: charset), player: c.services.createPlayer?.call(), ), )); } class SubtitleEditorPage extends StatefulWidget { final AppController app; final VideoItem video; final SubtitleEntry entry; final SubtitleEditorController editor; /// 영상 재생기 (없으면 자막 표만 표시) final PreviewPlayer? player; const SubtitleEditorPage({ super.key, required this.app, required this.video, required this.entry, required this.editor, this.player, }); @override State createState() => _SubtitleEditorPageState(); } class _SubtitleEditorPageState extends State { late SubtitleEntry _entry = widget.entry; final _shift = TextEditingController(text: '0'); bool _saving = false; // 재생 상태 final _position = ValueNotifier(Duration.zero); final _active = ValueNotifier(null); final _subs = >[]; Duration _duration = Duration.zero; bool _playing = false; double _rate = 1.0; String? _playerError; SubtitleEditorController get e => widget.editor; PreviewPlayer? get _pl => widget.player; @override void initState() { super.initState(); final pl = _pl; if (pl == null) return; _subs ..add(pl.positionStream.listen((d) { _position.value = d; _updateActive(); })) ..add(pl.durationStream.listen((d) => setState(() => _duration = d))) ..add(pl.playingStream.listen((b) => setState(() => _playing = b))) ..add(pl.errorStream.listen((m) => setState(() => _playerError = m))); e.addListener(_updateActive); pl.open(widget.video.path).catchError((Object err) { if (mounted) setState(() => _playerError = '$err'); }); } void _updateActive() { final list = e.activeAt(_position.value); _active.value = list.isEmpty ? null : list.first; } @override void dispose() { for (final s in _subs) { s.cancel(); } e.removeListener(_updateActive); _pl?.dispose(); _position.dispose(); _active.dispose(); _shift.dispose(); widget.editor.dispose(); super.dispose(); } // ───────── 재생 조작 ───────── void _seek(Duration d) { if (d.isNegative) d = Duration.zero; if (_duration > Duration.zero && d > _duration) d = _duration; _position.value = d; // 화면 즉시 반영 _updateActive(); _pl?.seek(d); } void _seekBy(int ms) => _seek(_position.value + Duration(milliseconds: ms)); /// 줄 선택 + 그 줄 시작으로 이동 void _selectCue(Cue c) { e.select(c); _seek(c.start); } void _playSelected() { final c = e.selected; if (c == null || _pl == null) return; _seek(c.start); if (!_playing) _pl!.playOrPause(); } void _setStartHere() { final c = e.selected; if (c != null) e.setStart(c, _position.value); } void _setEndHere() { final c = e.selected; if (c != null) e.setEnd(c, _position.value); } void _alignHere() { final c = e.selected; if (c != null) e.alignFrom(c, _position.value); } void _insertHere() => e.insertAt(_position.value); Map get _shortcuts => { const SingleActivator(LogicalKeyboardKey.space, control: true): () => _pl?.playOrPause(), const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): () => _seekBy(-1000), const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): () => _seekBy(1000), const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true, shift: true): () => _seekBy(-100), const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true, shift: true): () => _seekBy(100), const SingleActivator(LogicalKeyboardKey.f7): _insertHere, const SingleActivator(LogicalKeyboardKey.f8): _playSelected, const SingleActivator(LogicalKeyboardKey.f9): _setStartHere, const SingleActivator(LogicalKeyboardKey.f10): _setEndHere, }; /// 표현할 수 없는 글자가 있으면 확인 Future _confirmLoss() async { final lost = encodeText(formatSrt(e.cues), e.saveCharset).lostChars; if (lost == 0) return true; final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, title: Text(tr('문자셋 경고')), content: Text(trf('{0} 로 표현할 수 없는 글자 {1}개가 ' '"?" 로 바뀝니다.\n계속 저장할까요? (UTF-8 을 권장합니다)', [saveCharsets[e.saveCharset], lost])), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('저장'))), ], ), ); return ok ?? false; } Future _save() async { if (!await _confirmLoss() || !mounted) return; setState(() => _saving = true); final messenger = ScaffoldMessenger.of(context); try { final (path, _) = await widget.app .saveEdited(widget.video, _entry, e.cues, e.saveCharset); _entry = widget.video.subtitles .firstWhere((s) => s.path != null && p.equals(s.path!, path)); e.markSaved(); messenger.showSnackBar( SnackBar(content: Text(trf('저장했습니다: {0} (MKV 에 반영됨)', [p.basename(path)])))); } catch (err) { messenger.showSnackBar(SnackBar(content: Text(trf('저장 실패: {0}', [err])))); } finally { if (mounted) setState(() => _saving = false); } } Future _saveAs() async { if (!await _confirmLoss() || !mounted) return; final messenger = ScaffoldMessenger.of(context); final name = '${widget.video.baseName}_${_entry.language.code}.srt'; try { final (path, _) = await widget.app .saveCuesAs(widget.video, e.cues, e.saveCharset, name); if (path != null) { messenger.showSnackBar(SnackBar(content: Text(trf('저장했습니다: {0}', [path])))); } } catch (err) { messenger.showSnackBar(SnackBar(content: Text(trf('저장 실패: {0}', [err])))); } } Future _confirmLeave() async { if (!e.dirty) return true; final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( scrollable: true, title: Text(tr('저장하지 않은 변경')), content: Text(tr('변경 내용을 저장하지 않고 나갈까요?')), actions: [ TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('계속 편집'))), FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('나가기'))), ], ), ); return ok ?? false; } @override Widget build(BuildContext context) { return ListenableBuilder( listenable: e, builder: (context, _) => PopScope( canPop: !e.dirty, onPopInvokedWithResult: (didPop, _) async { if (didPop) return; final nav = Navigator.of(context); if (await _confirmLeave()) { e.markSaved(); nav.pop(); } }, child: CallbackShortcuts( bindings: _shortcuts, child: Focus( autofocus: true, child: Scaffold( body: Column( children: [ _toolbar(context), _statusBar(), Expanded( child: Row( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (_pl != null) ...[ Expanded(flex: 9, child: _videoPane()), const VerticalDivider(width: 1), ], Expanded(flex: 11, child: _cueTable()), ], ), ), if (_pl != null) ...[ const Divider(height: 1), _transportBar(), SizedBox( height: 104, child: SubtitleTimeline( editor: e, position: _position, duration: _duration, followPlayhead: _playing, onSeek: _seek, onSelect: _selectCue, ), ), ], ], ), floatingActionButton: e.cues.isEmpty ? FloatingActionButton.extended( onPressed: () => e.insertAfter(-1), icon: const Icon(Icons.add), label: Text(tr('첫 줄 추가')), ) : null, ), ), ), ), ); } // ───────── 화면 조각 (subtitle_editor_panes.dart) ───────── 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 _toolbar(BuildContext context) => AppTopBar( nav: const AppNavButtons(), actions: const AppActions(), middle: Row( children: [ // 관리자 지시 (10/21, 28 찾음): 960 에서도 "자막 편집 · …" 로 잘려 파일 이름이 안 보였다 - 제목은 "자막 편집" 만, // 파일 이름은 그 아래 한 줄 (가운데를 줄여 앞 · 끝 (.srt) 이 보이게, 길게 누르거나 마우스를 올리면 전체) Expanded( child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(tr('자막 편집'), maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), LayoutBuilder(builder: (context, box) { final style = TextStyle(fontSize: 12, color: JjColors.textDim); return Tooltip( message: _entry.displayName, child: Text(middleEllipsis(context, _entry.displayName, style, box.maxWidth), maxLines: 1, overflow: TextOverflow.clip, style: style), ); }), ]), ), // 폭이 좁으면 (휴대폰) 도구 줄을 밀어서 본다 - 설정 · 종료 버튼은 늘 오른쪽 끝에 Flexible( flex: 4, child: SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row(mainAxisSize: MainAxisSize.min, children: [ // 전체 싱크 이동 Text(tr('싱크'), style: TextStyle(fontSize: 12, color: JjColors.textDim)), const SizedBox(width: 6), SizedBox( width: 80, child: TextField( controller: _shift, textAlign: TextAlign.right, style: const TextStyle(fontSize: 13), decoration: const InputDecoration( isDense: true, suffixText: 'ms', border: OutlineInputBorder()), ), ), const SizedBox(width: 4), OutlinedButton( onPressed: () { final ms = int.tryParse(_shift.text.trim()); if (ms == null) return; e.shiftAll(ms); }, child: Text(tr('전체 이동')), ), const SizedBox(width: 8), OutlinedButton(onPressed: e.sortByTime, child: Text(tr('시간순 정렬'))), const SizedBox(width: 12), DropdownButton( value: e.saveCharset, isDense: true, underline: const SizedBox(), style: TextStyle(fontSize: 13, color: JjColors.text), items: [ for (final c in saveCharsets.entries) DropdownMenuItem(value: c.key, child: Text(c.value)), ], onChanged: (v) => e.setCharset(v!), ), const SizedBox(width: 8), OutlinedButton( onPressed: e.canSave ? _saveAs : null, child: Text(tr('다른 이름으로 저장')), ), const SizedBox(width: 8), FilledButton.icon( onPressed: e.canSave && !_saving ? _save : null, icon: const Icon(Icons.save, size: 18), label: Text(e.dirty ? tr('저장 (MKV 반영) *') : tr('저장 (MKV 반영)')), ), ]), ), ), ], ), ); Widget _statusBar() { final order = e.invalidOrder; final msgs = [ trf('{0}줄', [e.cues.length]), if (e.badInput.isNotEmpty) trf('시간 형식 오류 {0}곳', [e.badInput.length]), if (order.isNotEmpty) trf('끝 시간이 시작보다 빠른 줄: {0}' '{1}', [order.take(10).map((i) => i + 1).join(', '), order.length > 10 ? ' …' : '']), ]; final hasError = e.badInput.isNotEmpty || order.isNotEmpty; return Container( width: double.infinity, color: hasError ? JjColors.danger.withValues(alpha: 0.12) : JjColors.bg, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), child: Text(msgs.join(' · '), style: TextStyle( fontSize: 12, color: hasError ? JjColors.danger : JjColors.textDim)), ); } }