JJ_MKVMaker/lib/ui/home/video_detail.dart

205 lines
9.2 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/// 약속: 고른 동영상의 자세히 (정보 · 자막 목록 · 이동 버튼) - 바꾸기는 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)),
);
}