224 lines
10 KiB
Dart
224 lines
10 KiB
Dart
/// 약속: 고른 동영상의 자세히 (정보 · 자막 목록 · 이동 버튼) - 바꾸기는 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';
|
||
|
||
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 찾음): 높이가 낮은 화면 (폰 가로 등) 도 - 떠 있는 버튼이 [자막 파일 추가] 를 반쯤 가렸다.
|
||
// 높이는 글자 배율로 나눠 본다 (글자가 크면 같은 높이에 들어가는 줄이 적다 - 960×600 · 글자 1.6 에서도 가렸다)
|
||
if (isCompact(context) || MediaQuery.sizeOf(context).height < 500 * MediaQuery.textScalerOf(context).scale(1)) {
|
||
final row = Wrap(alignment: WrapAlignment.end, spacing: 10, runSpacing: 10, children: [
|
||
if (buttons.isEmpty) button(null),
|
||
for (final t in buttons) button(t),
|
||
]);
|
||
return _list(context, info, video, audioCount, locked, moveButtons: row, bottom: 24);
|
||
}
|
||
// 넓은 화면: 떠 있는 버튼 높이만큼 목록 아래를 비워 끝까지 내리면 가리지 않게 (버튼이 여러 개면 그만큼)
|
||
final stacked = buttons.isEmpty ? 1 : buttons.length;
|
||
return Stack(children: [
|
||
_list(context, info, video, audioCount, locked, bottom: 16 + stacked * 66.0 + 16),
|
||
Positioned(
|
||
right: 16,
|
||
bottom: 16,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
if (buttons.isEmpty) button(null),
|
||
// 첫 번째 버튼이 맨 아래 (예전 [이동] 자리), 다음 버튼은 그 위로
|
||
for (var i = buttons.length - 1; i >= 0; i--) ...[
|
||
button(buttons[i]),
|
||
if (i > 0) const SizedBox(height: 10),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
]);
|
||
}
|
||
|
||
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)),
|
||
);
|
||
}
|