JJ_MKVMaker/lib/ui/home/video_list_view.dart

320 lines
13 KiB
Dart

/// 약속: MKV 목록 (정렬 · 체크 · 상태 · 줄 메뉴 · 세 번 누르기) - 바꾸기는 AppController 가.
library;
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show HapticFeedback;
import '../../app/app_controller.dart';
import '../../core/models.dart';
import '../ai_dialog.dart';
import '../player_page.dart';
import '../subtitle_search_dialog.dart';
import '../confirm_dialog.dart';
import '../theme.dart';
import '../../l10n/tr.dart';
import 'move_to_target.dart';
class HomeVideoList extends StatelessWidget {
final AppController c;
/// 좁은 화면 '자동': 줄이 적으면 내용 높이만 (빈 칸을 남기지 않게)
final bool shrink;
const HomeVideoList({super.key, required this.c, this.shrink = false});
/// 정렬 아이콘: 지금 적용된 정렬은 강조색 + 방향 화살표
Widget _sortButton(String by, IconData icon, String name) {
final on = c.sortedBy == by;
return IconButton(
tooltip: trf('{0} 순으로 정렬' '{1}', [name, on ? (c.sortAscending ? tr(' (지금: 오름차순 · 다시 누르면 내림차순)') : tr(' (지금: 내림차순 · 다시 누르면 오름차순)')) : '']),
iconSize: 18,
visualDensity: VisualDensity.compact,
onPressed: c.videos.length < 2 ? null : () => c.sortVideos(by),
icon: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, color: on ? JjColors.accent : JjColors.textDim),
if (on)
Icon(c.sortAscending ? Icons.arrow_upward : Icons.arrow_downward, size: 12, color: JjColors.accent),
]),
);
}
@override
Widget build(BuildContext context) {
return Container(
color: JjColors.panel,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: shrink ? MainAxisSize.min : MainAxisSize.max,
children: [
// 전체 선택 · 선택 개수 · 선택 제거 · 모두 지우기
Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 4, 2),
child: Row(children: [
Checkbox(
visualDensity: VisualDensity.compact,
tristate: true,
value: c.videos.isEmpty || c.checked.isEmpty
? false
: (c.checked.length == c.videos.length ? true : null),
onChanged: c.videos.isEmpty ? null : (_) => c.toggleAllChecked(),
),
Expanded(
child: InkWell(
onTap: c.videos.isEmpty ? null : c.toggleAllChecked,
// 좁으면 글자를 줄여서 한 줄에 다 보이게
child: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
c.checked.isEmpty
? trf('동영상 {0}개 · 전체 선택', [c.videos.length])
: trf('선택 {0} / {1}개', [c.checked.length, c.videos.length]),
maxLines: 1,
style: TextStyle(color: JjColors.textDim, fontSize: 12)),
),
),
),
// 정렬: 같은 것을 다시 누르면 반대 순서
_sortButton('name', Icons.sort_by_alpha, tr('파일 이름')),
_sortButton('date', Icons.calendar_month, tr('날짜')),
if (c.checked.isNotEmpty)
IconButton(
tooltip: tr('선택한 동영상을 목록에서 제거'),
iconSize: 18,
visualDensity: VisualDensity.compact,
icon: Icon(Icons.playlist_remove, color: JjColors.textDim),
onPressed: c.removeChecked,
),
IconButton(
tooltip: tr('모두 지우기'),
iconSize: 18,
visualDensity: VisualDensity.compact,
icon: Icon(Icons.clear_all, color: JjColors.textDim),
// 46: 확인 뒤 (목록에서만 빼고 파일은 그대로)
onPressed: c.busy || c.videos.isEmpty
? null
: () async {
final ok = await confirmAction(
context,
title: tr('목록을 모두 비울까요?'),
body: trf('동영상 {0}개를 MKV 목록에서 뺍니다. 파일은 지우지 않습니다.', [c.videos.length]),
ok: tr('모두 지우기'),
);
if (ok) c.clearVideos();
},
),
]),
),
Flexible(
fit: shrink ? FlexFit.loose : FlexFit.tight,
child: ListView.builder(
shrinkWrap: shrink,
itemCount: c.videos.length,
itemBuilder: (_, i) => VideoTile(c: c, v: c.videos[i]),
),
),
],
),
);
}
}
class VideoTile extends StatelessWidget {
final AppController c;
final VideoItem v;
const VideoTile({super.key, required this.c, required this.v});
Future<void> _menu(BuildContext context, Offset global) async {
c.select(v);
final busy = v.status == JobStatus.running;
PopupMenuItem<String> item(String id, IconData icon, String label, {bool enabled = true}) => PopupMenuItem(
value: id,
enabled: enabled,
height: 36,
child: Row(children: [
Icon(icon, size: 18, color: enabled ? JjColors.text : JjColors.textDim),
const SizedBox(width: 10),
Text(label, style: const TextStyle(fontSize: 13)),
]),
);
// 화면 크기 배율이 걸려 있어도 누른 자리에 뜨도록 메뉴가 그려질 곳 기준으로 바꾼다
final overlay = Overlay.of(context).context.findRenderObject()! as RenderBox;
final at = overlay.globalToLocal(global);
final pick = await showMenu<String>(
context: context,
position: RelativeRect.fromLTRB(at.dx, at.dy, overlay.size.width - at.dx, overlay.size.height - at.dy),
items: [
item('folder', Icons.folder_open, tr('대상 폴더 열기')),
if (c.services.createMediaPlayer != null) item('play', Icons.play_arrow, tr('재생')),
const PopupMenuDivider(),
if (c.aiAvailable)
item('ai', Icons.auto_awesome, tr('AI 자막 만들기'), enabled: !busy && c.ffmpegVersion != null),
if (c.subtitleProvider != null)
item('search', Icons.travel_explore, tr('인터넷 자막 찾기'), enabled: !busy),
item('subtitle', Icons.subtitles_outlined, tr('자막 파일 추가'), enabled: !busy),
const PopupMenuDivider(),
item('remove', Icons.close, tr('목록에서 빼기 (파일은 그대로)'), enabled: !busy),
],
);
if (pick == null || !context.mounted) return;
switch (pick) {
case 'folder':
await c.services.shell.revealFile(v.path);
case 'play':
await playFiles(context, c, [v.path]);
case 'ai':
await showAiDialog(context, c, v, targets: [v]);
case 'search':
await showSubtitleSearch(context, c, v);
case 'subtitle':
await c.pickSubtitlesFor(v);
case 'remove':
c.removeVideo(v);
}
}
// 연달아 누른 횟수 (두 번이면 재생, 세 번 이상이면 이동). 마지막으로 누르고 잠시 뒤에 정한다.
static VideoItem? _tapItem;
static int _taps = 0;
static Timer? _tapTimer;
void _tapped(BuildContext context) {
c.select(v);
_taps = _tapItem == v ? _taps + 1 : 1;
_tapItem = v;
_tapTimer?.cancel();
_tapTimer = Timer(const Duration(milliseconds: 350), () {
final n = _taps;
_taps = 0;
_tapItem = null;
if (!context.mounted) return;
if (n == 2) {
playFiles(context, c, [v.path]);
} else if (n >= 3 && c.settings.mkv.tripleTapAction == 'move') {
// 47: 잘못 세 번 눌러도 파일이 옮겨지지 않게 묻는다
moveToTarget(context, c, [v], confirm: true);
}
});
}
@override
Widget build(BuildContext context) {
final isSel = c.selected == v;
final subs = v.subtitles.where((s) => s.enabled).length;
// 51: 터치 화면에서는 길게 누르면 오른쪽 클릭과 같은 메뉴 (누른 자리에)
return GestureDetector(
onLongPressStart: (d) {
HapticFeedback.selectionClick();
_menu(context, d.globalPosition);
},
child: InkWell(
// 한 번: 보기 · 두 번: 재생 · 세 번 이상: 이동 폴더로 옮기기
onTap: () => _tapped(context),
// 오른쪽 클릭: 이 동영상으로 할 수 있는 일
onSecondaryTapDown: (d) => _menu(context, d.globalPosition),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
padding: const EdgeInsets.fromLTRB(2, 10, 4, 10),
decoration: BoxDecoration(
color: isSel ? JjColors.panelHigh : Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSel ? JjColors.accent.withValues(alpha: 0.6) : Colors.transparent),
),
child: Row(
children: [
// 체크: 여러 개를 골라 일괄 작업 (줄을 누르면 지금처럼 한 개 보기)
Checkbox(
visualDensity: VisualDensity.compact,
value: c.checked.contains(v),
onChanged: (_) => c.toggleChecked(v),
),
JobStatusIcon(v.status),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(v.fileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 13)),
const SizedBox(height: 4),
if (v.status == JobStatus.running) ...[
LinearProgressIndicator(value: v.progress, minHeight: 3),
if (v.phase != null)
Padding(
padding: const EdgeInsets.only(top: 3),
child: Text(v.phase!,
style: TextStyle(fontSize: 11, color: JjColors.accent)),
),
] else if (v.phase != null)
Text(v.phase!, style: TextStyle(fontSize: 11, color: JjColors.textDim))
else
Text(
v.status == JobStatus.failed
? tr('실패')
: v.status == JobStatus.cancelled
? tr('취소됨')
: trf('자막 {0}개', [subs]),
style: TextStyle(
fontSize: 11,
color: v.status == JobStatus.failed
? JjColors.danger
: JjColors.textDim),
),
],
),
),
if (v.status == JobStatus.done || v.status == JobStatus.failed || v.status == JobStatus.cancelled)
// 262: 끝나는 순간 ■ 와 같은 자리에 나타난다 - 묶음이 끝날 때까지는 눌리지 않게 (자리는 그대로 두어 ✕ 가 밀려오지 않게)
IconButton(
tooltip: c.inCurrentBatch(v) ? tr('다시 만들기 대기로 (지금 묶음이 끝난 뒤)') : tr('다시 만들기 대기로'),
iconSize: 16,
onPressed: c.inCurrentBatch(v) ? null : () => c.resetStatus(v),
icon: Icon(Icons.refresh, color: JjColors.textDim),
),
// 240: 만드는 중인 동영상 하나만 취소 (다른 것 · 남은 것은 그대로)
if (v.status == JobStatus.running && c.buildingMkv)
IconButton(
tooltip: tr('이 동영상만 취소'),
iconSize: 16,
onPressed: () => c.cancelVideos([v]),
icon: Icon(Icons.stop, color: JjColors.danger),
),
IconButton(
tooltip: tr('목록에서 제거'),
iconSize: 16,
onPressed: v.status == JobStatus.running ? null : () => c.removeVideo(v),
icon: Icon(Icons.close, color: JjColors.textDim),
),
],
),
),
));
}
}
class JobStatusIcon extends StatelessWidget {
final JobStatus s;
const JobStatusIcon(this.s, {super.key});
@override
Widget build(BuildContext context) => switch (s) {
JobStatus.ready =>
Icon(Icons.movie_outlined, size: 20, color: JjColors.textDim),
JobStatus.running =>
Icon(Icons.autorenew, size: 20, color: JjColors.accent),
JobStatus.done =>
Icon(Icons.check_circle, size: 20, color: JjColors.success),
JobStatus.failed =>
Icon(Icons.error, size: 20, color: JjColors.danger),
// 187: 취소는 실패가 아니라 회색
JobStatus.cancelled =>
Icon(Icons.cancel_outlined, size: 20, color: JjColors.textDim),
};
}
// ───────── 오른쪽: 선택한 동영상 ─────────