/// 약속: 고른 동영상의 자세히 (정보 · 자막 목록 · 이동 버튼) - 바꾸기는 AppController 가. library; import 'package:flutter/material.dart'; import '../../app/app_controller.dart'; import '../../core/languages.dart'; import '../../core/models.dart'; import '../../core/output_paths.dart'; import '../ai_dialog.dart'; import '../app_actions.dart'; import '../../app/settings.dart' show MoveTarget; import '../player_page.dart'; import '../subtitle_search_dialog.dart'; import '../theme.dart'; import '../../l10n/tr.dart'; import 'move_to_target.dart'; import 'subtitle_row.dart'; import '../notice_band.dart'; import '../floating_actions.dart'; class VideoDetailPane extends StatelessWidget { final AppController c; final VideoItem v; const VideoDetailPane({super.key, required this.c, required this.v}); @override Widget build(BuildContext context) { final info = v.info; final video = info?.ofType('video').firstOrNull; final audioCount = info?.ofType('audio').length ?? 0; // 이 동영상을 처리하는 중일 때만 잠금 (다른 작업 중이면 대기열에 넣는다) final locked = v.status == JobStatus.running; // 오른쪽 아래 이동 버튼들 (환경 설정 > MKV 만들기 > 이동 버튼): 표시 이름으로, 위로 하나씩. // 체크한 동영상이 있으면 그것들, 없으면 지금 보고 있는 이 동영상을 그 버튼의 폴더로 옮긴다. final moving = c.checked.isEmpty ? [v] : c.batchTargets; final canMove = moving.any((x) => x.status != JobStatus.running); final count = c.checked.isEmpty ? '' : ' (${c.checked.length})'; final buttons = c.settings.mkv.moveTargets; Widget button(MoveTarget? t) => Tooltip( message: t == null ? tr('이동: 옮길 폴더를 골라 이동 버튼을 만듭니다 (환경 설정 > MKV 만들기 > 이동 버튼에서 여러 개 만들 수 있음)') : c.checked.isEmpty ? trf('{0}: 이 동영상을 {1} 로 옮깁니다', [t.name, t.dir]) : trf('{0}: 체크한 동영상 {1}개를 {2} 로 옮깁니다', [t.name, c.checked.length, t.dir]), child: FloatingActionButton.extended( heroTag: null, onPressed: canMove ? () => moveToTarget(context, c, moving, target: t) : null, icon: const Icon(Icons.drive_file_move_outline), label: Text('${t?.name ?? tr('이동')}$count'), ), ); // 206: 좁은 화면 (폰 세로) 은 자세히 칸이 낮아 떠 있는 버튼이 칩 (음성 n개 등) 을 가렸다 - 버튼을 칩 줄 아래에 놓는다 // F7 (관리자 승인 10/21, 15 찾음): 높이가 낮은 화면 (폰 가로 등) 도 - 떠 있는 버튼이 [자막 파일 추가] 를 반쯤 가렸다. // 자리 판단 · 그리기는 공용 FloatingActions (넓은 화면은 떠 있는 버튼 높이만큼 목록 아래를 비운다) return FloatingActions( inline: floatingInline(context, compact: isCompact(context)), buttons: [ if (buttons.isEmpty) button(null), for (final t in buttons) button(t), ], content: (row, bottom) => _list(context, info, video, audioCount, locked, moveButtons: row, bottom: bottom), ); } Widget _list(BuildContext context, MediaInfo? info, StreamInfo? video, int audioCount, bool locked, {Widget? moveButtons, double bottom = 88}) { final compact = isCompact(context); final side = compact ? 12.0 : 20.0; // 198: 마지막 AI 자막의 원어를 자동 감지했으면 - 알림을 놓쳐도 여기서 다른 언어로 다시 만들 수 있게 final detected = v.aiDetected == null ? null : Wrap(crossAxisAlignment: WrapCrossAlignment.center, spacing: 8, children: [ Text(trf('감지한 언어: {0} (자동 감지)', [languageOf(v.aiDetected).name]), style: TextStyle(fontSize: 12, color: JjColors.textDim)), TextButton( onPressed: c.busy ? null : () async { final lang = await pickSourceLanguage(context, languageOf(v.aiDetected)); if (lang != null) await c.generateAiSubtitles([v], c.aiOptions.copyWith(source: lang)); }, child: Text(tr('다른 언어로 다시')), ), ]); // 자막 머리 줄: 좁으면 버튼이 다음 줄로 (오른쪽 정렬) final subtitleActions = Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(top: 8), child: Text(tr('자막'), style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), ), const SizedBox(width: 16), Expanded( child: Wrap(alignment: WrapAlignment.end, spacing: 8, runSpacing: 8, children: [ if (c.aiAvailable) FilledButton.icon( onPressed: locked || c.ffmpegVersion == null ? null : () => showAiDialog(context, c, v), icon: const Icon(Icons.auto_awesome, size: 18), label: Text(tr('AI 자막 만들기')), ), if (c.subtitleProvider != null) OutlinedButton.icon( onPressed: locked ? null : () => showSubtitleSearch(context, c, v), icon: const Icon(Icons.travel_explore, size: 18), label: Text(tr('인터넷 자막 찾기')), ), OutlinedButton.icon( onPressed: locked ? null : () => c.pickSubtitlesFor(v), icon: const Icon(Icons.subtitles_outlined, size: 18), label: Text(tr('자막 파일 추가')), ), ]), ), ]); return ListView( padding: EdgeInsets.fromLTRB(side, side, side, bottom), children: [ Row(children: [ Expanded( child: Text(v.fileName, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)), ), if (c.services.createMediaPlayer != null) ...[ const SizedBox(width: 8), FilledButton.icon( onPressed: () => playFiles(context, c, [v.path]), icon: const Icon(Icons.play_arrow, size: 18), label: Text(tr('재생')), ), ], const SizedBox(width: 8), OutlinedButton.icon( onPressed: () => c.services.shell.revealFile(v.path), icon: const Icon(Icons.folder_open, size: 18), label: Text(tr('폴더 열기')), ), ]), const SizedBox(height: 4), SelectableText(v.path, style: TextStyle(color: JjColors.textDim, fontSize: 12)), const SizedBox(height: 12), Wrap(spacing: 8, runSpacing: 8, children: [ if (info?.duration != null) InfoChip(trf('길이 {0}', [_fmt(info!.duration!)])), if (video != null) ...[ if (video.width != null) InfoChip('${video.width}×${video.height}'), InfoChip(trf('영상 {0}', [video.codec])), ], InfoChip(trf('음성 {0}개', [audioCount])), ]), // 261: 좁은 화면 (폰 세로) 은 세부 정보 칸이 낮다 (노트 10+ 에서 약 210) - "감지한 언어" 줄 · 이동 버튼이 // [AI 자막 만들기] 를 칸 밖으로 밀었다. 자막 버튼을 칩 바로 아래에 두고, 감지한 언어 · 이동 버튼은 그 다음 if (compact) ...[ const SizedBox(height: 12), subtitleActions, if (detected != null) ...[const SizedBox(height: 4), detected], if (moveButtons != null) ...[const SizedBox(height: 10), moveButtons], ] else ...[ if (moveButtons != null) ...[const SizedBox(height: 10), moveButtons], if (detected != null) ...[const SizedBox(height: 8), detected], ], if (v.message != null) ...[ const SizedBox(height: 12), NoticeBand(icon: Icons.error_outline, color: JjColors.danger, margin: EdgeInsets.zero, title: tr('실패'), body: v.message!, selectable: true), ], if (!compact) ...[ const SizedBox(height: 24), subtitleActions, ], const SizedBox(height: 8), if (v.subtitles.isEmpty) Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Text(tr('자막이 없습니다.'), style: TextStyle(color: JjColors.textDim)), ), for (final s in v.subtitles) SubtitleRow(c: c, v: v, s: s, locked: locked), const SizedBox(height: 24), Text(trf('출력: {0}', [outputMkvPath(v.path)]), style: TextStyle(color: JjColors.textDim, fontSize: 12)), ], ); } static String _fmt(Duration d) { String two(int n) => n.toString().padLeft(2, '0'); return '${d.inHours}:${two(d.inMinutes % 60)}:${two(d.inSeconds % 60)}'; } } class InfoChip extends StatelessWidget { final String text; const InfoChip(this.text, {super.key}); @override Widget build(BuildContext context) => Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: JjColors.panelHigh, borderRadius: BorderRadius.circular(20), ), child: Text(text, style: const TextStyle(fontSize: 12)), ); }