744 lines
24 KiB
Dart
744 lines
24 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 'theme.dart';
|
|
import 'timeline.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('자막을 불러올 수 없습니다: $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(
|
|
title: const Text('문자셋 경고'),
|
|
content: Text('${saveCharsets[e.saveCharset]} 로 표현할 수 없는 글자 $lost개가 '
|
|
'"?" 로 바뀝니다.\n계속 저장할까요? (UTF-8 을 권장합니다)'),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('취소')),
|
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('저장')),
|
|
],
|
|
),
|
|
);
|
|
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('저장했습니다: ${p.basename(path)} (MKV 에 반영됨)')));
|
|
} catch (err) {
|
|
messenger.showSnackBar(SnackBar(content: Text('저장 실패: $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('저장했습니다: $path')));
|
|
}
|
|
} catch (err) {
|
|
messenger.showSnackBar(SnackBar(content: Text('저장 실패: $err')));
|
|
}
|
|
}
|
|
|
|
Future<bool> _confirmLeave() async {
|
|
if (!e.dirty) return true;
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: const Text('저장하지 않은 변경'),
|
|
content: const Text('변경 내용을 저장하지 않고 나갈까요?'),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('계속 편집')),
|
|
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('나가기')),
|
|
],
|
|
),
|
|
);
|
|
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: const Text('첫 줄 추가'),
|
|
)
|
|
: 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('영상 재생 오류: $_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: '5초 뒤로',
|
|
icon: const Icon(Icons.replay_5),
|
|
onPressed: () => _seekBy(-5000),
|
|
),
|
|
IconButton(
|
|
tooltip: '재생/정지 (Ctrl+Space)',
|
|
iconSize: 30,
|
|
icon: Icon(_playing ? Icons.pause_circle : Icons.play_circle,
|
|
color: JjColors.accent),
|
|
onPressed: () => _pl?.playOrPause(),
|
|
),
|
|
IconButton(
|
|
tooltip: '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!);
|
|
},
|
|
),
|
|
const Spacer(),
|
|
btn('+ 여기에 새 줄', 'F7', _insertHere),
|
|
btn('선택 줄 재생', 'F8', hasSel ? _playSelected : null),
|
|
btn('시작 = 현재', 'F9', hasSel ? _setStartHere : null),
|
|
btn('끝 = 현재', 'F10', hasSel ? _setEndHere : null),
|
|
btn('선택 줄부터 여기로 맞추기', '선택한 줄과 그 뒤 모든 줄을 함께 이동', hasSel ? _alignHere : null),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _toolbar(BuildContext context) => Container(
|
|
height: appBarHeight,
|
|
color: JjColors.panel,
|
|
padding: const EdgeInsets.only(left: 8, right: appBarRightPadding),
|
|
child: Row(
|
|
children: [
|
|
IconButton(
|
|
tooltip: '돌아가기',
|
|
icon: const Icon(Icons.arrow_back),
|
|
onPressed: () => Navigator.maybePop(context),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Expanded(
|
|
child: Text(
|
|
'자막 편집 · ${_entry.displayName}',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
|
),
|
|
),
|
|
// 전체 싱크 이동
|
|
const Text('싱크', 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: const Text('전체 이동'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
OutlinedButton(onPressed: e.sortByTime, child: const Text('시간순 정렬')),
|
|
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: const Text('다른 이름으로 저장'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
FilledButton.icon(
|
|
onPressed: e.canSave && !_saving ? _save : null,
|
|
icon: const Icon(Icons.save, size: 18),
|
|
label: Text(e.dirty ? '저장 (MKV 반영) *' : '저장 (MKV 반영)'),
|
|
),
|
|
const AppActions(),
|
|
],
|
|
),
|
|
);
|
|
|
|
Widget _statusBar() {
|
|
final order = e.invalidOrder;
|
|
final msgs = <String>[
|
|
'${e.cues.length}줄',
|
|
if (e.badInput.isNotEmpty) '시간 형식 오류 ${e.badInput.length}곳',
|
|
if (order.isNotEmpty)
|
|
'끝 시간이 시작보다 빠른 줄: ${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: const Row(children: [
|
|
SizedBox(width: 48, child: Text('#', style: _hdr)),
|
|
SizedBox(width: 130, child: Text('시작', style: _hdr)),
|
|
SizedBox(width: 130, child: Text('끝', style: _hdr)),
|
|
Expanded(child: Text('내용', style: _hdr)),
|
|
SizedBox(width: 96),
|
|
]),
|
|
);
|
|
}
|
|
|
|
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: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 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)),
|
|
),
|
|
),
|
|
),
|
|
SizedBox(width: 130, child: _timeField(_start, _startFocus, _startBad, true)),
|
|
SizedBox(width: 130, child: _timeField(_end, _endFocus, _endBad, false)),
|
|
Expanded(
|
|
child: 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),
|
|
),
|
|
),
|
|
SizedBox(
|
|
width: 96,
|
|
child: Row(children: [
|
|
IconButton(
|
|
tooltip: '아래에 줄 추가',
|
|
iconSize: 18,
|
|
icon: const Icon(Icons.add, color: JjColors.textDim),
|
|
onPressed: () => e.insertAfter(widget.index),
|
|
),
|
|
IconButton(
|
|
tooltip: '줄 삭제',
|
|
iconSize: 18,
|
|
icon: const Icon(Icons.delete_outline, color: JjColors.textDim),
|
|
onPressed: () => e.removeAt(widget.index),
|
|
),
|
|
]),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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 ? '형식 오류' : 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();
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|