WIP 다음 판: 199 권한 앱 목록 안내 · 188 버튼 글 맞춤 · 206 이동 버튼 · 181 · 182 mp3 재생 · 184 직접 파일 일반 받기 · 185 [계속 받기] (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음6
RyuJongJin 2026-10-12 06:34:21 +09:00
parent 3327d9bf9d
commit 6a782b2fdb
18 changed files with 445 additions and 16 deletions

View File

@ -581,6 +581,7 @@
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "The path is too long (the Windows Recycle Bin doesn't accept paths over 260 characters) - you can delete it permanently",
"계속": "Always",
"계속 (앱이 켜져 있는 동안)": "Always (while the app is running)",
"계속 받기": "Keep downloading",
"계속 보기 ({0}초마다 다음 장)": "Slideshow (next page every {0} s)",
"계속 보기 간격": "Slideshow interval",
"계속 보기 간격 늘리기 (지금 {0}초)": "Longer slideshow interval (now {0} s)",
@ -648,6 +649,7 @@
"기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "Draw on this device (stable-diffusion.cpp · SD1.5 + LCM)",
"기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "To draw on this device, download the model first (SD1.5 + LCM, about 4.4 GB).",
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "To draw on this device, download the model first ({0})",
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "On some devices a list of apps opens instead - find JJ_MKVMaker in the list and turn it on.",
"기능 버튼 줄": "Button bar",
"기본": "Default",
"기본 DNS": "Primary DNS",
@ -782,6 +784,7 @@
"다시 시도": "Try again",
"다시 시작": "Restart",
"다시 쓰려면 다시 받아야 합니다.": "You'll need to download it again to use it.",
"다시 열기": "Open again",
"다시 켤 때 동기화": "Sync on restart",
"다시 확인": "Check again",
"다운로드": "Downloads",
@ -1375,6 +1378,7 @@
"아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "Not yet (downloaded when you copy with rsync · or press [Download])",
"아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "Still couldn't save. Please try again in a moment",
"아직 지원하지 않는 플랫폼입니다: {0}": "This platform is not supported yet: {0}",
"아직 허용되지 않았습니다": "Not allowed yet",
"안내 닫기": "Close tip",
"안쪽 파일을 확인하는 중 …": "Checking files inside …",
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "{0} files inside are newer on the receiving side - they are skipped when merging (they stay in the source)",
@ -1388,6 +1392,7 @@
"앞의 rsync 가 끝난 뒤에 하세요.": "Wait until the previous rsync finishes.",
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "Wait until the current copy · move finishes.",
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "Icon in the app list, home screen and the top-left button in the app. After changing it, the icon on your home screen may disappear and you may need to drag it from the app list again",
"앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "If a list of apps opened, find and tap JJ_MKVMaker in the list, turn on \"All files access\", and come back.",
"앱 설정 전체 (처음 상태로)": "All app settings (back to initial)",
"앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "Reset app settings to the initial state (restart the app to apply everything)",
"앱 아이콘": "App icon",

View File

@ -581,6 +581,7 @@
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "パスが長すぎます (Windows のごみ箱は 260 文字を超えるパスを受け付けません) - 完全に削除はできます",
"계속": "常時",
"계속 (앱이 켜져 있는 동안)": "常時 (アプリの起動中)",
"계속 받기": "ダウンロードを続ける",
"계속 보기 ({0}초마다 다음 장)": "連続表示 ({0} 秒ごとに次のページ)",
"계속 보기 간격": "連続表示の間隔",
"계속 보기 간격 늘리기 (지금 {0}초)": "連続表示の間隔を長く (現在 {0} 秒)",
@ -648,6 +649,7 @@
"기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "この端末で描く (stable-diffusion.cpp・SD1.5 + LCM)",
"기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "この端末で描くにはモデルのダウンロードが必要です (SD1.5 + LCM、約 4.4GB)。",
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "この端末で描くにはモデルのダウンロードが必要です ({0})",
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "機器によってはアプリ一覧が開きます。その場合は一覧から JJ_MKVMaker を探してオンにしてください。",
"기능 버튼 줄": "機能ボタンの列",
"기본": "既定",
"기본 DNS": "優先 DNS",
@ -782,6 +784,7 @@
"다시 시도": "再試行",
"다시 시작": "再起動",
"다시 쓰려면 다시 받아야 합니다.": "再び使うには再ダウンロードが必要です。",
"다시 열기": "もう一度開く",
"다시 켤 때 동기화": "再起動時の同期",
"다시 확인": "再確認",
"다운로드": "ダウンロード",
@ -1375,6 +1378,7 @@
"아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "まだありません (rsync でコピーするとき · [ダウンロード] で入手)",
"아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "まだ保存できませんでした。しばらくしてからもう一度お試しください",
"아직 지원하지 않는 플랫폼입니다: {0}": "まだ対応していないプラットフォームです: {0}",
"아직 허용되지 않았습니다": "まだ許可されていません",
"안내 닫기": "案内を閉じる",
"안쪽 파일을 확인하는 중 …": "中のファイルを確認中 …",
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "中に受け取る側の方が新しいファイルが {0} 件 - 合わせてもスキップします (元に残ります)",
@ -1388,6 +1392,7 @@
"앞의 rsync 가 끝난 뒤에 하세요.": "前の rsync が終わってから行ってください。",
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "前のコピー · 移動が終わってから行ってください。",
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "アプリ一覧・ホーム画面とアプリ内左上のボタンのアイコンです。変更するとホーム画面のアイコンが消えることがあり、アプリ一覧から再度ドラッグする必要がある場合があります",
"앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "アプリ一覧が開いた場合は、一覧から JJ_MKVMaker を探してタップし、「すべてのファイルへのアクセス」をオンにして戻ってください。",
"앱 설정 전체 (처음 상태로)": "アプリの設定すべて (初期状態に)",
"앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "アプリの設定を初期状態に戻しました (すべて反映するにはアプリを再起動してください)",
"앱 아이콘": "アプリアイコン",

View File

@ -581,6 +581,7 @@
"경로가 너무 깁니다 (Windows 의 휴지통은 260자 넘는 경로를 받지 않습니다) - 영구 삭제로 지울 수 있습니다": "路径过长 (Windows 回收站不接受超过 260 个字符的路径) - 可以永久删除",
"계속": "持续",
"계속 (앱이 켜져 있는 동안)": "持续 (应用运行期间)",
"계속 받기": "继续下载",
"계속 보기 ({0}초마다 다음 장)": "连续浏览 (每 {0} 秒下一页)",
"계속 보기 간격": "连续浏览间隔",
"계속 보기 간격 늘리기 (지금 {0}초)": "延长连续浏览间隔 (当前 {0} 秒)",
@ -648,6 +649,7 @@
"기기 안에서 그리기 (stable-diffusion.cpp · SD1.5 + LCM)": "在本设备上绘制 (stable-diffusion.cpp · SD1.5 + LCM)",
"기기 안에서 그리려면 모델을 받아야 합니다 (SD1.5 + LCM, 약 4.4GB).": "要在本设备上绘制,需要先下载模型 (SD1.5 + LCM,约 4.4GB)。",
"기기 안에서 그리려면 모델을 받아야 합니다 ({0})": "要在本设备上绘制,需要先下载模型 ({0})",
"기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.": "在某些设备上会打开应用列表,请在列表中找到 JJ_MKVMaker 并开启。",
"기능 버튼 줄": "功能按钮栏",
"기본": "默认",
"기본 DNS": "首选 DNS",
@ -782,6 +784,7 @@
"다시 시도": "重试",
"다시 시작": "重新启动",
"다시 쓰려면 다시 받아야 합니다.": "再次使用需要重新下载。",
"다시 열기": "再次打开",
"다시 켤 때 동기화": "重新启动时同步",
"다시 확인": "重新检查",
"다운로드": "下载",
@ -1375,6 +1378,7 @@
"아직 없음 (rsync 로 복사할 때 · 아래 [내려받기] 로 받습니다)": "尚未安装 (用 rsync 复制时 · 或按 [下载] 获取)",
"아직 저장하지 못했습니다. 잠시 뒤 다시 시도하세요": "仍未能保存。请稍后再试",
"아직 지원하지 않는 플랫폼입니다: {0}": "尚不支持此平台: {0}",
"아직 허용되지 않았습니다": "尚未允许",
"안내 닫기": "关闭提示",
"안쪽 파일을 확인하는 중 …": "正在检查里面的文件 …",
"안쪽에 받는 쪽이 더 새 파일 {0}개 - 합쳐도 건너뜁니다 (원본에 남음)": "里面有 {0} 个文件在接收端较新 - 合并时会跳过(保留在源中)",
@ -1388,6 +1392,7 @@
"앞의 rsync 가 끝난 뒤에 하세요.": "请等上一个 rsync 结束后再运行。",
"앞의 복사 · 이동이 끝난 뒤에 하세요.": "请等待前一个复制 · 移动完成。",
"앱 목록 · 홈 화면과 앱 안 왼쪽 위 버튼의 아이콘입니다. 바꾸면 홈 화면에 둔 아이콘이 없어질 수 있어 앱 목록에서 다시 끌어다 놓아야 할 수 있습니다": "应用列表、主屏幕以及应用内左上角按钮的图标。更改后主屏幕上的图标可能会消失,可能需要从应用列表重新拖放",
"앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 \"모든 파일에 대한 접근\" 을 켜고 돌아오세요.": "如果打开的是应用列表,请在列表中找到并点按 JJ_MKVMaker,开启“所有文件访问权限”后返回。",
"앱 설정 전체 (처음 상태로)": "全部应用设置 (恢复初始状态)",
"앱 설정을 처음 상태로 돌렸습니다 (모두 적용하려면 앱을 다시 켜 주세요)": "已将应用设置恢复为初始状态 (请重新启动应用以全部生效)",
"앱 아이콘": "应用图标",

View File

@ -161,6 +161,15 @@ class DownloadManager extends ChangeNotifier {
if (detectLinks(url).isNotEmpty) return add(url);
if (!RegExp(r'^https?://', caseSensitive: false).hasMatch(url)) return null;
if (tasks.any((t) => t.source == url && t.state != DownloadState.cancelled)) return null;
// 184: 동영상이 아닌 직접 파일 주소 (zip 등) 는 일반 파일 받기 (aria2) 로 - 원래 이름 그대로
if (isDirectFileUrl(url) && backends[DownloadKind.torrent] != null) {
final t = DownloadTask(id: '${_nextId++}', kind: DownloadKind.torrent, source: url.trim(), dir: settings().aria2Dir)
..title = Uri.tryParse(url.trim())?.pathSegments.lastOrNull ?? url;
tasks.insert(0, t);
_added.add(t);
_changed();
return t;
}
if (backends[DownloadKind.video] == null) return null;
final t = DownloadTask(id: '${_nextId++}', kind: DownloadKind.video, source: url, dir: settings().ytDlpDir);
tasks.insert(0, t);

View File

@ -4,10 +4,21 @@ enum DownloadKind {
/// YouTube 등 동영상 사이트 → yt-dlp
video,
/// 마그넷 링크 · .torrent 주소 → aria2
/// 마그넷 링크 · .torrent 주소 · 직접 파일 주소 (184: zip 등 - 원래 이름 그대로) → aria2
torrent,
}
/// 184: 동영상 · 소리가 아닌 직접 파일 주소 (…/100MB.zip 등). 동영상 받기 (yt-dlp) 로 받으면 다 받은 뒤
/// 후처리에서 "실패" 가 되고 이름도 "100MB [100MB].unknown_video" 가 됐다 - 일반 파일 받기 (aria2) 로 원래 이름 그대로 받는다.
/// 클립보드 자동 감지에는 쓰지 않는다 (복사만 해도 받기 시작하지 않게) - 사용자가 넣은 주소 · 브라우저 [다운로드] 에만
bool isDirectFileUrl(String url) => _directFile.hasMatch(url.trim());
final _directFile = RegExp(
r'^https?://[^\s<>"?#]+\.(?:zip|7z|rar|gz|tgz|tar|xz|bz2|zst|exe|msi|dmg|pkg|deb|rpm|appimage|iso|img|apk|apks|xapk|'
r'pdf|epub|docx?|xlsx?|pptx?|hwpx?|odt|ods|txt|csv|json|xml|bin|dat|ttf|otf|jpg|jpeg|png|gif|webp|bmp|svg|psd)'
r'(?:[?#][^\s<>"]*)?$',
caseSensitive: false);
class DetectedLink {
final DownloadKind kind;
final String url;

View File

@ -676,7 +676,7 @@ Future<void> runAndroid(String dataDir) async {
// 116: 다른 안내 · 분석 오류보다 먼저 (허용하고 돌아오면 실패한 분석을 다시 한다 - onResume)
final ctxA = navigatorKey.currentContext;
if (ctxA != null && ctxA.mounted && await allFilesGuideDue(controller, hasAccess: hadAccess) && ctxA.mounted) {
await showAllFilesGuide(ctxA, AndroidAccess.request);
await showAllFilesGuide(ctxA, AndroidAccess.request, hasAccess: AndroidAccess.hasAllFiles);
}
// 124: "처음 시작 시" 면 마스터 비밀번호
final ctxM = navigatorKey.currentContext;

View File

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart';
import '../app/app_controller.dart';
@ -17,7 +19,9 @@ Future<bool> allFilesGuideDue(AppController c, {required bool hasAccess}) async
/// 116: 처음 켤 때 "모든 파일에 대한 접근" 안내 (분석 오류보다 먼저). [허용] 은 Android 설정 화면을 연다.
/// 허용하고 돌아오면 앱이 실패한 분석을 저절로 다시 한다.
Future<void> showAllFilesGuide(BuildContext context, Future<void> Function() request) async {
/// 199: 기기에 따라 (노트 10+ · 삼성 Android 12) 이 앱 화면이 아니라 전체 앱 목록이 열린다 - 예외도 나지 않아 미리 알 수 없다.
/// 그래서 돌아왔을 때 [hasAccess] 가 아직 없으면 "목록에서 JJ_MKVMaker 를 찾아 켜세요" 와 [다시 열기] 를 보인다
Future<void> showAllFilesGuide(BuildContext context, Future<void> Function() request, {Future<bool> Function()? hasAccess}) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
@ -26,7 +30,8 @@ Future<void> showAllFilesGuide(BuildContext context, Future<void> Function() req
content: SizedBox(
width: 480,
child: Text('${tr('동영상을 고르고 MKV 를 만들려면 "모든 파일에 대한 접근" 권한이 필요합니다.')}\n\n'
'${tr('[허용] 을 누르면 Android 설정이 열립니다. JJ_MKVMaker 를 허용하고 돌아오면, 읽지 못했던 동영상을 저절로 다시 분석합니다.')}'),
'${tr('[허용] 을 누르면 Android 설정이 열립니다. JJ_MKVMaker 를 허용하고 돌아오면, 읽지 못했던 동영상을 저절로 다시 분석합니다.')}\n\n'
'${tr('기기에 따라 앱 목록이 열리면, 목록에서 JJ_MKVMaker 를 찾아 켜세요.')}'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))),
@ -34,5 +39,40 @@ Future<void> showAllFilesGuide(BuildContext context, Future<void> Function() req
],
),
);
if (ok == true) await request();
if (ok != true) return;
for (var i = 0; i < 3; i++) {
final back = _resumed();
await request();
if (hasAccess == null) return;
await back;
if (await hasAccess() || !context.mounted) return;
// 199: 돌아왔는데 아직 꺼져 있음 - 앱 목록이 열렸거나 켜지 않고 돌아옴
final again = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
scrollable: true,
title: Text(tr('아직 허용되지 않았습니다')),
content: SizedBox(
width: 480,
child: Text(tr('앱 목록이 열렸다면, 목록에서 JJ_MKVMaker 를 찾아 눌러 "모든 파일에 대한 접근" 을 켜고 돌아오세요.')),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('나중에'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('다시 열기'))),
],
),
);
if (again != true) return;
}
}
/// 다른 화면 (Android 설정) 에서 이 앱으로 돌아올 때까지
Future<void> _resumed() {
final done = Completer<void>();
late final AppLifecycleListener l;
l = AppLifecycleListener(onResume: () {
l.dispose();
if (!done.isCompleted) done.complete();
});
return done.future;
}

View File

@ -0,0 +1,71 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:path/path.dart' as p;
import '../services/media_player.dart';
import 'theme.dart';
/// 182: 소리만 있는 파일 (mp3 등) 을 틀 때 영상 자리에 보일 것 - 예전에는 검은 화면뿐이었다.
/// 제목 (파일 이름) · 앨범 그림 (파일 안에 있으면) · 재생 위치
class AudioNowPlaying extends StatelessWidget {
final String file;
final ValueListenable<PlayerState> state;
/// 앨범 그림 파일 (없으면 null) - 파일마다 한 번만 꺼낸다
final Future<String?> cover;
const AudioNowPlaying({super.key, required this.file, required this.state, required this.cover});
static String _t(Duration d) {
final h = d.inHours, m = d.inMinutes % 60, s = (d.inSeconds % 60).toString().padLeft(2, '0');
return h > 0 ? '$h:${m.toString().padLeft(2, '0')}:$s' : '$m:$s';
}
@override
Widget build(BuildContext context) => Container(
color: Colors.black,
alignment: Alignment.center,
padding: const EdgeInsets.all(24),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Flexible(
child: FutureBuilder<String?>(
future: cover,
builder: (_, s) => ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 320, maxHeight: 320),
child: s.data != null
? Image.file(File(s.data!), fit: BoxFit.contain, errorBuilder: (_, _, _) => const _Note())
: const _Note(),
),
),
),
const SizedBox(height: 16),
Text(p.basenameWithoutExtension(file),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
ValueListenableBuilder<PlayerState>(
valueListenable: state,
builder: (_, s, _) => Text(
'${_t(s.position)} / ${_t(s.duration)}',
style: const TextStyle(color: JjColors.textDim, fontSize: 14, fontFeatures: [FontFeature.tabularFigures()]),
),
),
]),
);
}
class _Note extends StatelessWidget {
const _Note();
@override
Widget build(BuildContext context) => const AspectRatio(
aspectRatio: 1,
child: DecoratedBox(
decoration: BoxDecoration(color: Color(0xFF1E1E1E), borderRadius: BorderRadius.all(Radius.circular(12))),
child: Center(child: Icon(Icons.music_note, size: 96, color: JjColors.textDim)),
),
);
}

View File

@ -3,13 +3,15 @@ import 'package:flutter/material.dart';
import '../l10n/tr.dart';
import 'theme.dart';
/// 되돌리기 어려운 일 전에 한 번 묻는다 (46 · 47 등). [danger] 면 확인 버튼이 빨강. 확인하면 true
/// 되돌리기 어려운 일 전에 한 번 묻는다 (46 · 47 등). [danger] 면 확인 버튼이 빨강. 확인하면 true.
/// [cancel]: 하지 않는 쪽 버튼 이름 (처음 값 "취소" - 확인 버튼이 "… 취소" 면 헷갈리므로 바꾼다, 185)
Future<bool> confirmAction(
BuildContext context, {
required String title,
required String body,
required String ok,
bool danger = true,
String? cancel,
}) async {
final r = await showDialog<bool>(
context: context,
@ -18,7 +20,7 @@ Future<bool> confirmAction(
title: Text(title),
content: Text(body),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(cancel ?? tr('취소'))),
FilledButton(
style: danger ? FilledButton.styleFrom(backgroundColor: JjColors.danger) : null,
onPressed: () => Navigator.pop(ctx, true),

View File

@ -215,6 +215,8 @@ class _DownloadsPageState extends State<DownloadsPage> {
title: tr('다운로드 취소'),
body: trf('선택한 다운로드 {0}개를 취소할까요? 잠깐 멈추려면 [일시정지] 를 쓰세요.', [n]),
ok: tr('다운로드 취소'),
// 185: 바로 옆 [다운로드 취소] 와 헷갈리지 않게 - 취소하지 않는 쪽은 [계속 받기]
cancel: tr('계속 받기'),
);
if (ok) await widget.d.cancelSelected();
}

View File

@ -1405,7 +1405,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
await _reloadOpen();
case ExplorerButton.play:
final t = _targets(pane);
final videos = t.where((x) => !vIsDirSync(x) && isVideoFile(x)).toList();
// 181: 강조한 것이 mp3 등 소리 파일이어도 그것을 (예전에는 동영상이 아니라고 빼고 폴더의 동영상을 틀었다)
final videos = t.where((x) => !vIsDirSync(x) && (isVideoFile(x) || isAudioFile(x))).toList();
if (videos.isNotEmpty) {
await _play(videos, keepOrder: pane.marked.isNotEmpty);
} else {
@ -3650,11 +3651,20 @@ class _Toolbar extends StatelessWidget {
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(_icon(b), size: 22, color: !on ? JjColors.textDim.withValues(alpha: 0.4) : sel ? JjColors.accent : null),
const SizedBox(height: 2),
Text(tr(_label(b)),
// 188: 두 줄로 꺾인 글 (일본어 "新しいフォルダー" 등) 이 버튼 아래로 잘렸다 - 남는 높이에 맞게 줄여 다 보이게
Flexible(
child: FittedBox(
fit: BoxFit.scaleDown,
child: SizedBox(
width: 68,
child: Text(tr(_label(b)),
maxLines: 2,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 10.5, height: 1.1, color: on ? null : JjColors.textDim)),
),
),
),
]),
),
),

View File

@ -1014,8 +1014,18 @@ class _VideoDetail extends StatelessWidget {
label: Text('${t?.name ?? tr('이동')}$count'),
),
);
// 206: 좁은 화면 (폰 세로) 은 자세히 칸이 낮아 떠 있는 버튼이 칩 (음성 n개 등) 을 가렸다 - 버튼을 칩 줄 아래에 놓는다
if (isCompact(context)) {
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),
_list(context, info, video, audioCount, locked, bottom: 16 + stacked * 66.0 + 16),
Positioned(
right: 16,
bottom: 16,
@ -1035,10 +1045,11 @@ class _VideoDetail extends StatelessWidget {
]);
}
Widget _list(BuildContext context, MediaInfo? info, StreamInfo? video, int audioCount, bool locked) {
Widget _list(BuildContext context, MediaInfo? info, StreamInfo? video, int audioCount, bool locked,
{Widget? moveButtons, double bottom = 88}) {
final side = isCompact(context) ? 12.0 : 20.0;
return ListView(
padding: EdgeInsets.fromLTRB(side, side, side, 88),
padding: EdgeInsets.fromLTRB(side, side, side, bottom),
children: [
Row(children: [
Expanded(
@ -1072,6 +1083,10 @@ class _VideoDetail extends StatelessWidget {
],
_Chip(trf('음성 {0}개', [audioCount])),
]),
if (moveButtons != null) ...[
const SizedBox(height: 10),
moveButtons,
],
// 198: 마지막 AI 자막의 원어를 자동 감지했으면 - 알림을 놓쳐도 여기서 다른 언어로 다시 만들 수 있게
if (v.aiDetected != null) ...[
const SizedBox(height: 8),

View File

@ -13,6 +13,7 @@ import '../core/playlist.dart';
import '../core/srt.dart' show formatSrtTime;
import '../services/media_player.dart';
import 'app_actions.dart';
import 'audio_now_playing.dart';
import 'dav_external.dart';
import 'theme.dart';
import '../l10n/tr.dart';
@ -92,6 +93,12 @@ class _PlayerPageState extends State<PlayerPage> {
if (_popup) c.services.shell.setPopup(false);
pl.dispose();
_focus.dispose();
// 182: 꺼낸 앨범 그림은 닫을 때 지운다 (임시 폴더에 쌓이지 않게)
for (final f in _covers.values) {
unawaited(f.then((path) async {
if (path != null) await File(path).delete();
}).catchError((_) {}));
}
super.dispose();
}
@ -313,6 +320,9 @@ class _PlayerPageState extends State<PlayerPage> {
onLongPressStart: (d) => _menu.open(position: d.localPosition),
child: Stack(fit: StackFit.expand, children: [
view,
// 182: 소리만 있는 파일은 검은 화면 대신 제목 · 앨범 그림 · 재생 위치
if (isAudioFile(_currentFile))
IgnorePointer(child: AudioNowPlaying(file: _currentFile, state: pl.state, cover: _cover(_currentFile))),
if (_error != null)
Positioned(
top: 8,
@ -342,6 +352,23 @@ class _PlayerPageState extends State<PlayerPage> {
);
}
/// 182: 소리 파일 안의 앨범 그림을 꺼낸다 (FFmpeg, 파일마다 한 번). 없거나 못 꺼내면 null - 음표를 보인다.
/// WebDAV 는 받지 않고 틀므로 꺼내지 않는다
final _covers = <String, Future<String?>>{};
Future<String?> _cover(String file) => _covers[file] ??= () async {
if (file.startsWith('dav://') || file.startsWith('http')) return null;
try {
final dir = await c.services.storage.tempDirectory();
final out = p.join(dir, 'jj_cover_${file.hashCode.toUnsigned(32)}.jpg');
final f = File(out);
if (await f.exists()) await f.delete();
await c.services.mediaTool.runFfmpeg(['-y', '-v', 'error', '-i', file, '-an', '-frames:v', '1', out]);
return await f.exists() && await f.length() > 0 ? out : null;
} catch (_) {
return null;
}
}();
List<Widget> _contextMenu() {
final s = pl.state.value;
final subs = _subtitleChoices();

View File

@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/core/playlist.dart';
import 'package:jj_mkvmaker/services/media_player.dart';
import 'package:jj_mkvmaker/ui/audio_now_playing.dart';
/// 181 · 182: 소리 파일 (mp3) 재생
void main() {
test('181: mp3 는 소리 파일 - 탐색기 [재생] 이 강조한 mp3 를 튼다 (동영상이 아니라고 빼지 않음)', () {
expect([isAudioFile(r'D:\m\song.mp3'), isVideoFile(r'D:\m\song.mp3')], [true, false]);
});
testWidgets('182: 소리 파일은 검은 화면 대신 제목 · 앨범 그림 (없으면 음표) · 재생 위치', (tester) async {
final state = ValueNotifier(const PlayerState(position: Duration(seconds: 83), duration: Duration(minutes: 4, seconds: 56)));
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: AudioNowPlaying(file: r'D:\m\내 노래.mp3', state: state, cover: Future.value(null))),
));
await tester.pump();
expect(find.text('내 노래'), findsOneWidget);
expect(find.text('1:23 / 4:56'), findsOneWidget);
expect(find.byIcon(Icons.music_note), findsOneWidget);
state.value = const PlayerState(position: Duration(seconds: 84), duration: Duration(minutes: 4, seconds: 56));
await tester.pump();
expect(find.text('1:24 / 4:56'), findsOneWidget, reason: '재생 위치가 따라 바뀐다');
});
}

View File

@ -394,6 +394,39 @@ void main() {
expect(find.textContaining('감지한 언어'), findsNothing);
});
for (final (name, size, targets) in [
('폰 세로', const Size(412, 900), 0),
('폰 세로 · 이동 버튼 3개', const Size(412, 900), 3),
('넓은 화면 · 이동 버튼 3개', const Size(1600, 520), 3),
]) {
testWidgets('206: MKV 자세히 칸의 [이동] 버튼이 칩 (음성 n개) 을 가리지 않는다 ($name)', (tester) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.moveTargets = [for (var i = 0; i < targets; i++) MoveTarget('보관$i', 'D:\\m$i')];
c.videos.add(VideoItem(r'D:\v\a.mp4')
..info = const MediaInfo(duration: Duration(minutes: 3), streams: [
StreamInfo(index: 0, type: 'video', codec: 'h264', width: 1920, height: 1080),
StreamInfo(index: 1, type: 'audio', codec: 'aac'),
]));
c.selected = c.videos.first;
await tester.pumpWidget(MaterialApp(home: HomePage(c: c)));
await tester.pump();
final chip = find.text('음성 1개');
// 끝까지 내려 본다 (넓은 화면은 떠 있는 버튼 - 끝에서도 가리지 않아야)
await tester.dragUntilVisible(chip, find.byType(ListView).last, const Offset(0, -80));
await tester.drag(find.byType(ListView).last, const Offset(0, -2000));
await tester.pumpAndSettle();
expect(chip, findsOneWidget);
final r = tester.getRect(chip);
for (final b in find.byType(FloatingActionButton).evaluate()) {
final br = tester.getRect(find.byWidget(b.widget));
expect(br.overlaps(r), isFalse, reason: '버튼 $br · 칩 $r');
}
});
}
testWidgets('MKV 화면: 체크 → "자막 만들기 (2)" · "MKV 만들기 (2)" 글, 전체 선택', (tester) async {
tester.view.physicalSize = const Size(1600, 900); // 위쪽 버튼 글이 다 보이는 너비
tester.view.devicePixelRatio = 1;

View File

@ -0,0 +1,74 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/download_manager.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/download_detect.dart';
import 'package:jj_mkvmaker/services/downloader.dart';
import 'package:jj_mkvmaker/ui/downloads_page.dart';
class _Backend implements DownloadBackend {
@override
final DownloadKind kind;
_Backend(this.kind);
final started = <String>[];
@override
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
@override
Future<void> start(DownloadTask t, void Function() changed) async {
started.add(t.source);
t.state = DownloadState.downloading;
}
@override
Future<void> pause(DownloadTask t) async {}
@override
Future<void> cancel(DownloadTask t) async {}
@override
Future<void> shutdown() async {}
}
/// 184 · 185: 직접 파일 주소는 일반 받기 · 다운로드 취소 창의 [계속 받기]
void main() {
test('184: 직접 파일 주소 알아보기 (동영상 · 소리 · 웹 페이지는 아님)', () {
expect(isDirectFileUrl('http://speedtest.tele2.net/100MB.zip'), isTrue);
expect(isDirectFileUrl('https://example.com/files/setup.EXE?v=2'), isTrue);
expect(isDirectFileUrl('https://example.com/doc.pdf#page=3'), isTrue);
expect(isDirectFileUrl('https://example.com/movie.mp4'), isFalse, reason: '동영상은 동영상 받기로');
expect(isDirectFileUrl('https://www.youtube.com/watch?v=abc'), isFalse);
expect(isDirectFileUrl('https://example.com/page'), isFalse);
expect(isDirectFileUrl('https://example.com/download.php?file=a.zip'), isFalse, reason: '경로가 .zip 으로 끝나지 않으면 모름 (동영상 받기 그대로)');
expect(detectLinks('http://speedtest.tele2.net/100MB.zip'), isEmpty, reason: '클립보드 자동 감지에는 쓰지 않음');
});
test('184: 직접 파일 주소는 일반 파일 받기 (aria2) 로 · 원래 이름 · aria2 폴더', () {
final s = AppSettings();
final video = _Backend(DownloadKind.video), files = _Backend(DownloadKind.torrent);
final d = DownloadManager(backends: [video, files], settings: () => s, readClipboard: () async => null);
addTearDown(d.dispose);
final t = d.addPage('http://speedtest.tele2.net/100MB.zip')!;
expect(t.kind, DownloadKind.torrent);
expect(t.title, '100MB.zip');
expect(t.dir, s.aria2Dir);
final page = d.addPage('https://example.com/some/page')!;
expect(page.kind, DownloadKind.video, reason: '그 밖의 웹 페이지는 지금처럼 동영상 받기');
});
testWidgets('185: 다운로드 취소 창 - 취소하지 않는 쪽은 [계속 받기] (옆의 [다운로드 취소] 와 헷갈리지 않게)', (tester) async {
tester.view.physicalSize = const Size(1500, 900);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final s = AppSettings();
final d = DownloadManager(backends: [_Backend(DownloadKind.video)], settings: () => s, readClipboard: () async => null);
addTearDown(d.dispose);
final t = d.addPage('https://example.com/some/page')!;
d.toggle(t);
await tester.pumpWidget(MaterialApp(home: DownloadsPage(d: d)));
await tester.pump();
await tester.tap(find.widgetWithText(OutlinedButton, '다운로드 취소'));
await tester.pump(const Duration(milliseconds: 500)); // 받는 중 진행 막대가 움직여 pumpAndSettle 은 끝나지 않는다
expect(find.widgetWithText(TextButton, '계속 받기'), findsOneWidget);
await tester.tap(find.widgetWithText(TextButton, '계속 받기'));
await tester.pump(const Duration(milliseconds: 500)); // 받는 중 진행 막대가 움직여 pumpAndSettle 은 끝나지 않는다
expect(t.state, isNot(DownloadState.cancelled));
});
}

View File

@ -0,0 +1,59 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/l10n/tr.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
import 'package:jj_mkvmaker/services/platform_services.dart';
import 'package:jj_mkvmaker/ui/explorer_page.dart';
/// 188: 탐색기 가운데 버튼 줄의 글이 두 줄로 꺾여도 버튼 아래로 잘리지 않는다 (일본어 "新しいフォルダー" · 세로 화면)
void main() {
late Directory tmp;
setUp(() => tmp = Directory.systemTemp.createTempSync('jj_labels_'));
tearDown(() {
setTranslations('ko', const {});
tmp.deleteSync(recursive: true);
});
Map<String, String> dict(String code) =>
Map<String, String>.from(jsonDecode(File('assets/l10n/$code.json').readAsStringSync()) as Map);
for (final lang in ['ko', 'en', 'ja', 'zh-Hans']) {
for (final size in [const Size(800, 1280), const Size(1280, 800), const Size(412, 900)]) {
for (final scale in [1.0, 1.6]) {
testWidgets('188: 버튼 글이 버튼 안에 다 보인다 ($lang, ${size.width.round()}×${size.height.round()}, 글자 $scale배)', (tester) async {
setTranslations(lang, lang == 'ko' ? const {} : dict(lang), lang == 'ko' ? const {} : dict('en'));
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
c.settings.explorerPaths = [tmp.path, tmp.path];
// 기기 글꼴 · 글자 크기가 시험 글꼴보다 크다 (S10 일본어에서 잘림) - 글자 크기를 키운 경우도
await tester.runAsync(() => tester.pumpWidget(MaterialApp(
builder: (ctx, child) => MediaQuery(data: MediaQuery.of(ctx).copyWith(textScaler: TextScaler.linear(scale)), child: child!),
home: ExplorerPage(c: c))));
for (var i = 0; i < 10; i++) {
await tester.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 30)));
await tester.pump();
}
// 1.6배에서는 목록 줄 (높이 고정) 이 넘친다 - 따로 알림 (이 시험은 버튼 줄만 본다)
if (scale == 1.0) {
expect(tester.takeException(), isNull);
} else {
tester.takeException();
}
final label = find.text(tr('새 폴더'));
if (label.evaluate().isEmpty) return; // 좁아서 [더 보기] 메뉴로 들어감
final text = tester.getRect(label.first);
final button = tester.getRect(find.ancestor(of: label.first, matching: find.byType(InkWell)).first);
expect(text.bottom, lessThanOrEqualTo(button.bottom + 0.5), reason: '글 $text · 버튼 $button');
expect(text.top, greaterThanOrEqualTo(button.top - 0.5));
});
}
}
}
}

View File

@ -202,5 +202,40 @@ void main() {
await t.pumpAndSettle();
expect(requested, 1);
});
testWidgets('199: 앱 목록이 열려 (노트 10+) 켜지 않고 돌아오면 "목록에서 찾아 켜세요" · [다시 열기], 켜고 돌아오면 끝', (t) async {
var requested = 0;
var allowed = false;
await t.pumpWidget(MaterialApp(
home: Builder(
builder: (ctx) => TextButton(
onPressed: () => showAllFilesGuide(ctx, () async => requested++, hasAccess: () async => allowed),
child: const Text('go'),
),
),
));
Future<void> goAndComeBack() async {
t.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
t.binding.handleAppLifecycleStateChanged(AppLifecycleState.hidden);
t.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
t.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await t.pumpAndSettle();
}
await t.tap(find.text('go'));
await t.pumpAndSettle();
expect(find.textContaining('목록에서 JJ_MKVMaker 를 찾아 켜세요'), findsOneWidget, reason: '처음 창에도 한 줄');
await t.tap(find.text('허용'));
await t.pumpAndSettle();
expect(requested, 1);
await goAndComeBack(); // 켜지 않고 돌아옴
expect(find.text('아직 허용되지 않았습니다'), findsOneWidget);
await t.tap(find.text('다시 열기'));
await t.pumpAndSettle();
expect(requested, 2);
allowed = true;
await goAndComeBack(); // 켜고 돌아옴
expect(find.text('아직 허용되지 않았습니다'), findsNothing);
});
});
}