799 lines
28 KiB
Dart
799 lines
28 KiB
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 'key_hint.dart';
|
||
import 'path_label.dart' show middleEllipsis;
|
||
import 'theme.dart';
|
||
import 'timeline.dart';
|
||
import '../l10n/tr.dart';
|
||
|
||
/// 자막을 불러와 편집 화면을 연다.
|
||
Future<void> openSubtitleEditor(
|
||
BuildContext context, AppController c, VideoItem v, SubtitleEntry s) async {
|
||
final nav = Navigator.of(context);
|
||
final messenger = ScaffoldMessenger.of(context);
|
||
showDialog<void>(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (_) => const Center(child: CircularProgressIndicator()),
|
||
);
|
||
List<Cue> 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<void>(
|
||
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<SubtitleEditorPage> createState() => _SubtitleEditorPageState();
|
||
}
|
||
|
||
class _SubtitleEditorPageState extends State<SubtitleEditorPage> {
|
||
late SubtitleEntry _entry = widget.entry;
|
||
final _shift = TextEditingController(text: '0');
|
||
bool _saving = false;
|
||
|
||
// 재생 상태
|
||
final _position = ValueNotifier(Duration.zero);
|
||
final _active = ValueNotifier<Cue?>(null);
|
||
final _subs = <StreamSubscription<Object?>>[];
|
||
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<ShortcutActivator, VoidCallback> 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<bool> _confirmLoss() async {
|
||
final lost = encodeText(formatSrt(e.cues), e.saveCharset).lostChars;
|
||
if (lost == 0) return true;
|
||
final ok = await showDialog<bool>(
|
||
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<void> _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<void> _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<bool> _confirmLeave() async {
|
||
if (!e.dirty) return true;
|
||
final ok = await showDialog<bool>(
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ───────── 영상 · 자막 겹쳐 보기 ─────────
|
||
|
||
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),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
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: const 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(),
|
||
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) {
|
||
const 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<String>(
|
||
value: e.saveCharset,
|
||
isDense: true,
|
||
underline: const SizedBox(),
|
||
style: const 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 = <String>[
|
||
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)),
|
||
);
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
const _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: const 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: const Icon(Icons.add, color: JjColors.textDim),
|
||
onPressed: () => e.insertAfter(widget.index),
|
||
),
|
||
IconButton(
|
||
tooltip: tr('줄 삭제'),
|
||
iconSize: 18,
|
||
constraints: small,
|
||
padding: narrow ? EdgeInsets.zero : null,
|
||
icon: const 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();
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|