/// 약속: 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 _menu(BuildContext context, Offset global) async { c.select(v); final busy = v.status == JobStatus.running; PopupMenuItem 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( 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), }; } // ───────── 오른쪽: 선택한 동영상 ─────────