JJ_MKVMaker/lib/core/playlist.dart

156 lines
6.0 KiB
Dart

import 'package:path/path.dart' as p;
import 'output_paths.dart';
import 'subtitle_detector.dart';
import '../l10n/tr.dart';
/// 동영상 하나를 재생할 때 재생 목록 만드는 방법
enum PlaylistMode {
single('그 파일 하나만'),
series('같은 시리즈 (번호만 다른 파일)'),
folder('같은 폴더의 모든 동영상');
/// 한국어 원문 (번역 사전의 열쇠)
final String koLabel;
/// 화면에 보일 이름 (화면 언어로)
String get label => tr(koLabel);
const PlaylistMode(this.koLabel);
}
bool isVideoFile(String path) =>
videoExtensions.contains(p.extension(path).replaceFirst('.', '').toLowerCase());
bool isAudioFile(String path) =>
audioExtensions.contains(p.extension(path).replaceFirst('.', '').toLowerCase());
// ── 재생 목록 모듈 (관리자 지시 10/21): 무엇을 한 목록에 넣을지는 여기서만 정한다. 화면 · 컨트롤러는 부르기만 ──
/// 내장 플레이어로 틀 수 있는 파일 (동영상 · 소리)
bool isPlayableFile(String path) => isVideoFile(path) || isAudioFile(path);
/// [other] 를 [chosen] 과 같은 재생 목록에 넣을지: 같은 종류끼리 (동영상끼리 · 소리끼리).
/// 예전에는 형제를 동영상으로만 걸러 mp3 를 틀면 같은 폴더의 다른 곡이 늘 빠졌다 ("폴더 전체" 여도 1/1)
bool samePlayKind(String chosen, String other) => isAudioFile(chosen) ? isAudioFile(other) : isVideoFile(other);
/// 폴더 하나를 틀 때의 목록 (이름 순): 동영상이 있으면 동영상, 없으면 소리 파일. 틀 것이 없으면 빈 목록
List<String> folderPlaylist(Iterable<String> files) {
final videos = files.where(isVideoFile).toList();
final list = videos.isNotEmpty ? videos : files.where(isAudioFile).toList();
return list..sort((a, b) => naturalCompare(p.basename(a), p.basename(b)));
}
/// 시리즈 비교용 이름: 숫자를 모두 # 으로 바꾼다.
/// file_001 ≡ file_0002, Show.S01E02 ≡ Show.S02E10, 드라마 3화 ≡ 드라마 12화
String seriesKey(String path) => p
.basenameWithoutExtension(path)
.toLowerCase()
.replaceAll(RegExp(r'\d+'), '#')
.replaceAll(RegExp(r'[\s._\-]+'), ' ')
.trim();
/// 사람이 읽는 순서 (file2 < file10)
int naturalCompare(String a, String b) {
final re = RegExp(r'(\d+)|(\D+)');
final ma = re.allMatches(a.toLowerCase()).toList();
final mb = re.allMatches(b.toLowerCase()).toList();
for (var i = 0; i < ma.length && i < mb.length; i++) {
final x = ma[i], y = mb[i];
if (x[1] != null && y[1] != null) {
final c = BigInt.parse(x[1]!).compareTo(BigInt.parse(y[1]!));
if (c != 0) return c;
final l = x[1]!.length.compareTo(y[1]!.length); // 0010 과 10
if (l != 0) return l;
} else {
final c = x[0]!.compareTo(y[0]!);
if (c != 0) return c;
}
}
return ma.length.compareTo(mb.length);
}
/// 파일 하나 → 재생 목록 (선택한 파일이 들어 있고, 이름 순). 형제는 고른 파일과 같은 종류만 ([samePlayKind])
/// 돌려주는 값: (목록, 선택한 파일의 위치)
(List<String>, int) buildPlaylist(String file, Iterable<String> filesInFolder, PlaylistMode mode) {
if (mode == PlaylistMode.single) return ([file], 0);
final key = seriesKey(file);
final list = filesInFolder
.where((f) => samePlayKind(file, f))
.where((f) => mode == PlaylistMode.folder || seriesKey(f) == key)
.toList();
if (!list.any((f) => p.equals(f, file))) list.add(file);
list.sort((a, b) => naturalCompare(p.basename(a), p.basename(b)));
return (list, list.indexWhere((f) => p.equals(f, file)));
}
/// 끌어다 놓은 파일·폴더 목록에서 동영상만 (폴더는 안쪽까지, jj_ 출력 폴더 제외)
/// [listDir] 은 폴더 안 항목 (파일, 하위 폴더) 을 돌려준다.
Future<List<String>> collectVideos(
Iterable<String> paths, {
required Future<bool> Function(String) isDirectory,
required Future<List<String>> Function(String) listDir,
}) async {
final out = <String>[];
Future<void> walk(String path, int depth) async {
if (await isDirectory(path)) {
final name = p.basename(path).toLowerCase();
if (depth > 0 && (name.startsWith('jj_') || name == outputFolderName)) return;
if (depth > 5) return;
final children = await listDir(path)..sort((a, b) => naturalCompare(a, b));
for (final c in children) {
await walk(c, depth + 1);
}
} else if (isVideoFile(path)) {
out.add(path);
}
}
for (final x in paths) {
await walk(x, 0);
}
return out;
}
/// 실행 인수 (탐색기 오른쪽 메뉴)
/// --play 파일... → 플레이어로 재생
/// --subtitle 파일... → 목록에 추가하고 AI 자막 만들기
/// 파일... → 목록에 추가
class LaunchRequest {
final LaunchAction action;
final List<String> files;
const LaunchRequest(this.action, this.files);
static LaunchRequest parse(List<String> args) {
var action = LaunchAction.add;
final files = <String>[];
for (final a in args) {
switch (a) {
case '--play':
action = LaunchAction.play;
case '--subtitle':
action = LaunchAction.subtitle;
case '--lsync':
action = LaunchAction.lsync;
default:
// 99: 알림을 누르면 jjmkvmaker://lsync 로 열린다 (실시간 동기화 화면으로)
if (a.toLowerCase().startsWith('jjmkvmaker://')) {
if (a.toLowerCase().startsWith('jjmkvmaker://lsync')) action = LaunchAction.lsync;
} else if (!a.startsWith('--')) {
files.add(a);
}
}
}
return LaunchRequest(action, files);
}
List<String> toArgs() => [
if (action == LaunchAction.play) '--play',
if (action == LaunchAction.subtitle) '--subtitle',
if (action == LaunchAction.lsync) '--lsync',
...files,
];
}
/// [lsync]: 실시간 동기화 화면 (모니터링) 을 연다 (동기화가 멈췄다는 알림을 누름 - 99)
enum LaunchAction { add, play, subtitle, lsync }