/// 약속: 자막 한 줄 (언어 · 문자셋 고르기 · 편집 · 번역 · 빼기) - 바꾸기는 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 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 = [ 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 = [ 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? onChanged; const LanguageDropdown({super.key, required this.value, required this.onChanged}); @override Widget build(BuildContext context) { final items = [undetermined, ...languages]; return DropdownButton( 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? onChanged; const CharsetDropdown({super.key, required this.value, required this.onChanged}); @override Widget build(BuildContext context) => DropdownButton( 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!), ); } // ───────── 아래: 작업 기록 ─────────