320 lines
13 KiB
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),
|
|
};
|
|
}
|
|
|
|
// ───────── 오른쪽: 선택한 동영상 ─────────
|