JJ_MKVMaker/lib/ui/home/subtitle_row.dart

187 lines
7.3 KiB
Dart

/// 약속: 자막 한 줄 (언어 · 문자셋 고르기 · 편집 · 번역 · 빼기) - 바꾸기는 AppController 가.
library;
import 'dart:math' as math;
import 'dart:io';
import 'package:flutter/material.dart';
import '../../app/app_controller.dart';
import '../../core/charset_detector.dart';
import '../../core/languages.dart';
import '../../core/models.dart';
import '../subtitle_editor_page.dart';
import '../theme.dart';
import '../translate_dialog.dart';
import '../../l10n/tr.dart';
class SubtitleRow extends StatelessWidget {
final AppController c;
final VideoItem v;
final SubtitleEntry s;
final bool locked;
const SubtitleRow(
{super.key, required this.c, required this.v, required this.s, required this.locked});
/// 관리자 지시 (10/21, 15 찾음 F6): 한 줄에 들어가는 폭 - 내장 표 · 이름 (최소) · 언어 고르기 · 문자셋 · 단추.
/// 고르기는 가장 긴 항목 글의 폭을 쓰므로 (DropdownButton) 그 글을 지금 글꼴 · 글자 배율로 잰다
static double _oneLineWidth(BuildContext context, {required bool embedded, required int buttons}) {
final scaler = MediaQuery.textScalerOf(context);
final base = DefaultTextStyle.of(context).style;
double textW(String t, double size) =>
(TextPainter(text: TextSpan(text: t, style: base.copyWith(fontSize: size)), textDirection: TextDirection.ltr, textScaler: scaler, maxLines: 1)
..layout())
.width;
double widest(Iterable<String> xs, double size) => xs.fold(0.0, (m, t) => math.max(m, textW(t, size)));
final lang = widest([tr('언어: 미지정'), for (final l in languages) '${l.name} (${l.code})'], 12) + 24;
final charset = embedded ? 0.0 : widest(supportedCharsets.values, 12) + 24 + 8;
final badge = textW(embedded ? tr('내장') : tr('외부'), 11) + 12;
const nameMin = 60.0;
return badge + 10 + nameMin + 8 + lang + charset + 4 + buttons * 48;
}
@override
Widget build(BuildContext context) {
final embedded = s.kind == SubtitleKind.embedded;
final off = !s.enabled;
final badge = Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: (embedded ? JjColors.textDim : JjColors.accent)
.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(4),
),
child: Text(embedded ? tr('내장') : tr('외부'),
style: TextStyle(
fontSize: 11,
color: embedded ? JjColors.textDim : JjColors.accent)),
);
final name = Text(
s.displayName,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color: off ? JjColors.textDim : JjColors.text,
decoration: off ? TextDecoration.lineThrough : null,
),
);
final pickers = <Widget>[
LanguageDropdown(
value: s.language,
onChanged: locked || off ? null : (l) => c.setLanguage(s, l),
),
if (!embedded)
CharsetDropdown(
value: s.charset ?? 'UTF-8',
onChanged: locked ? null : (cs) => c.setCharset(s, cs),
),
];
final buttons = <Widget>[
IconButton(
tooltip: c.canEdit(s) ? tr('내용 편집 · 문자셋 변환') : tr('이미지 자막은 편집할 수 없습니다'),
iconSize: 18,
onPressed: locked || off || !c.canEdit(s)
? null
: () => openSubtitleEditor(context, c, v, s),
icon: Icon(Icons.edit_note, color: JjColors.accent),
),
if (c.services.createTranslator != null)
IconButton(
tooltip: c.canEdit(s) ? trf('다른 언어로 번역 (AI · 이 {0}에서)', [Platform.isAndroid ? tr('기기') : 'PC']) : tr('이미지 자막은 번역할 수 없습니다'),
iconSize: 18,
onPressed: locked || off || !c.canEdit(s) ? null : () => showTranslateDialog(context, c, v, s),
icon: Icon(Icons.translate, color: JjColors.accent),
),
IconButton(
tooltip: embedded ? (off ? tr('되살리기') : tr('삭제 (출력에서 제외)')) : tr('목록에서 제거'),
iconSize: 18,
onPressed: locked ? null : () => c.removeSubtitle(v, s),
icon: Icon(
embedded && off ? Icons.undo : Icons.delete_outline,
color: JjColors.textDim,
),
),
];
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: JjColors.panel,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: JjColors.border),
),
child: LayoutBuilder(builder: (context, box) {
final fits = box.maxWidth >= _oneLineWidth(context, embedded: embedded, buttons: buttons.length);
if (fits) {
// 넓을 때: 지금처럼 한 줄
return Row(children: [
badge,
const SizedBox(width: 10),
Expanded(child: name),
const SizedBox(width: 8),
for (final (i, x) in pickers.indexed) ...[if (i > 0) const SizedBox(width: 8), x],
const SizedBox(width: 4),
...buttons,
]);
}
// 좁아 넘칠 때만 두 줄: 위 = 내장 표 · 트랙 이름, 아래 = 언어 고르기 · 단추 (언어 이름은 줄이지 않는다 - 무엇을 골랐는지 보이게)
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [badge, const SizedBox(width: 10), Expanded(child: name)]),
const SizedBox(height: 2),
Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
children: [...pickers, Row(mainAxisSize: MainAxisSize.min, children: buttons)],
),
]);
}),
);
}
}
class LanguageDropdown extends StatelessWidget {
final Language value;
final ValueChanged<Language>? onChanged;
const LanguageDropdown({super.key, required this.value, required this.onChanged});
@override
Widget build(BuildContext context) {
final items = [undetermined, ...languages];
return DropdownButton<Language>(
value: items.contains(value) ? value : undetermined,
isDense: true,
underline: const SizedBox(),
style: TextStyle(fontSize: 12, color: JjColors.text),
items: [
for (final l in items)
DropdownMenuItem(
value: l,
child: Text(l == undetermined ? tr('언어: 미지정') : '${l.name} (${l.code})'),
),
],
onChanged: onChanged == null ? null : (l) => onChanged!(l!),
);
}
}
class CharsetDropdown extends StatelessWidget {
final String value;
final ValueChanged<String>? onChanged;
const CharsetDropdown({super.key, required this.value, required this.onChanged});
@override
Widget build(BuildContext context) => DropdownButton<String>(
value: supportedCharsets.containsKey(value) ? value : 'UTF-8',
isDense: true,
underline: const SizedBox(),
style: TextStyle(fontSize: 12, color: JjColors.text),
items: [
for (final e in supportedCharsets.entries)
DropdownMenuItem(value: e.key, child: Text(e.value)),
],
onChanged: onChanged == null ? null : (v) => onChanged!(v!),
);
}
// ───────── 아래: 작업 기록 ─────────