Compare commits

..

3 Commits

Author SHA1 Message Date
RyuJongJin 82a87dafb0 재생 화면 조절 줄 좁을 때 두 줄 (P2) · 재생 목록 모듈: 소리 파일은 소리끼리
- P2 (관리자 지시 10/21, 28 찾음): Windows 창 폭 412 에서 조절 줄이 184px 넘쳐 속도 · 자막 · 목록 · 전체 화면이 잘림 →
  한 줄에 들지 않으면 두 줄 (위 재생 단추, 아래 소리 · 메뉴 · 전체 화면). 단추 크기 그대로, 소리 막대만 남는 폭 (최대 100)
- 판단은 공용 함수 lib/ui/row_fit.dart fitRow (약속: 아이템 최소 폭 목록 · 줄일 수 있는 아이템 범위 → 나눌지 · 폭) - 관리자 지시, d3 AdaptiveRow 자리
- 재생 목록 모듈 (관리자 지시 10/21): buildPlaylist 가 형제를 동영상으로만 걸러 mp3 를 틀면 같은 폴더 곡이 빠짐 →
  playlist.dart 의 samePlayKind · isPlayableFile · folderPlaylist 한 곳에서 정하고 컨트롤러 · 탐색기는 부르기만.
  동영상이 없는 폴더의 [재생] 은 소리 파일을 (안내 글도 "동영상 · 소리 파일")
- 확인: full_player_test 에 실제 mpv 장면 (412 · 600 · 960 × ko · ja, mp3 폴더 목록) - 같은 판에서 2회 통과. player_test 약속 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:34:26 +09:00
RyuJongJin a22832d130 360 화면: 다운로드 종료 창 제목 두 줄 (G1) · AI 해상도 비교 화면 고르기를 둘째 줄로 (G2)
- G1 (관리자 승인 10/21, 15 찾음): 받는 중에 종료할 때 창 제목이 360 에서 넘쳐 개수와 [✕] 가 잘림 → 제목은 두 줄까지, [✕] 늘 보임
- G2 (관리자 승인 10/21, 15 찾음): 비교 화면 위 막대가 넘쳐 제목이 안 보이고 [저장] 글자가 잘림 →
  폭 600 미만이면 원본 / 올린 것 고르기를 위 막대 아래 둘째 줄로, [저장] 은 글자 그대로. 넓으면 그대로
- 시험: windows_hdr360_shot (ko · ja × 360 · 412). 종료 창의 "받는 중" 상태는 가짜 받기 백엔드로 만듦 (배치만 확인)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:06:05 +09:00
RyuJongJin 7247aeb55e 자막 편집 표 좁을 때 두 단 (F9) · 화면 · 색 보정 제목 줄 360 (F11)
- F9 (관리자 지시 10/21, 15 찾음): 412 에서 표가 오른쪽으로 넘침 → 좁으면 위 단 # · 시작 · 끝,
  아래 단 내용 + 단추 (기본 누를 자리 그대로, 오른쪽 끝). 시간 칸은 줄이지 않음
- F11 (관리자 승인 10/21): 360 에서 [✕] 가 화면 밖 → 좁으면 제목은 줄이고 (…), [모두 초기화] 는 아이콘 단추
  (도움말 이름 그대로, 누르면 같은 동작), [✕] 는 늘 보임. 안내 글은 숨기지 않고 제목 아래 줄로 (내 판단). 넓으면 그대로
- 시험: windows_ui2_shot (ko · ja × 360 · 412 · 960) - 넘침 없음 · ✕ 화면 안 · 단추 누를 자리

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 19:59:26 +09:00
16 changed files with 416 additions and 103 deletions

View File

@ -2103,8 +2103,8 @@
"이 폴더를 rsync 원본 · 대상으로 고르기": "Choose this folder as rsync source · target", "이 폴더를 rsync 원본 · 대상으로 고르기": "Choose this folder as rsync source · target",
"이 폴더를 볼 권한이 없습니다": "No permission to view this folder", "이 폴더를 볼 권한이 없습니다": "No permission to view this folder",
"이 폴더를 읽을 권한이 없습니다": "No permission to read this folder", "이 폴더를 읽을 권한이 없습니다": "No permission to read this folder",
"이 폴더에 동영상이 없습니다.": "There are no videos in this folder.",
"이 폴더에 새 폴더": "New folder in this folder", "이 폴더에 새 폴더": "New folder in this folder",
"이 폴더에 재생할 동영상 · 소리 파일이 없습니다.": "No videos or audio files to play in this folder.",
"이 폴더에 현재 페이지 추가": "Add the current page to this folder", "이 폴더에 현재 페이지 추가": "Add the current page to this folder",
"이 폴더에는 고를 파일이 없습니다": "There are no files to choose in this folder", "이 폴더에는 고를 파일이 없습니다": "There are no files to choose in this folder",
"이 폴더에는 그림 · 동영상이 없습니다": "No pictures or videos in this folder", "이 폴더에는 그림 · 동영상이 없습니다": "No pictures or videos in this folder",

View File

@ -2103,8 +2103,8 @@
"이 폴더를 rsync 원본 · 대상으로 고르기": "このフォルダーを rsync の元 · 対象に選ぶ", "이 폴더를 rsync 원본 · 대상으로 고르기": "このフォルダーを rsync の元 · 対象に選ぶ",
"이 폴더를 볼 권한이 없습니다": "このフォルダーを表示する権限がありません", "이 폴더를 볼 권한이 없습니다": "このフォルダーを表示する権限がありません",
"이 폴더를 읽을 권한이 없습니다": "このフォルダーを読む権限がありません", "이 폴더를 읽을 권한이 없습니다": "このフォルダーを読む権限がありません",
"이 폴더에 동영상이 없습니다.": "このフォルダーに動画がありません。",
"이 폴더에 새 폴더": "このフォルダーに新しいフォルダー", "이 폴더에 새 폴더": "このフォルダーに新しいフォルダー",
"이 폴더에 재생할 동영상 · 소리 파일이 없습니다.": "このフォルダーに再生できる動画・音声ファイルがありません。",
"이 폴더에 현재 페이지 추가": "このフォルダーに現在のページを追加", "이 폴더에 현재 페이지 추가": "このフォルダーに現在のページを追加",
"이 폴더에는 고를 파일이 없습니다": "このフォルダーには選べるファイルがありません", "이 폴더에는 고를 파일이 없습니다": "このフォルダーには選べるファイルがありません",
"이 폴더에는 그림 · 동영상이 없습니다": "このフォルダーには画像・動画がありません", "이 폴더에는 그림 · 동영상이 없습니다": "このフォルダーには画像・動画がありません",

View File

@ -2103,8 +2103,8 @@
"이 폴더를 rsync 원본 · 대상으로 고르기": "选择此文件夹作为 rsync 源 · 目标", "이 폴더를 rsync 원본 · 대상으로 고르기": "选择此文件夹作为 rsync 源 · 目标",
"이 폴더를 볼 권한이 없습니다": "没有查看此文件夹的权限", "이 폴더를 볼 권한이 없습니다": "没有查看此文件夹的权限",
"이 폴더를 읽을 권한이 없습니다": "没有读取此文件夹的权限", "이 폴더를 읽을 권한이 없습니다": "没有读取此文件夹的权限",
"이 폴더에 동영상이 없습니다.": "此文件夹中没有视频。",
"이 폴더에 새 폴더": "在此文件夹中新建文件夹", "이 폴더에 새 폴더": "在此文件夹中新建文件夹",
"이 폴더에 재생할 동영상 · 소리 파일이 없습니다.": "此文件夹中没有可播放的视频或音频文件。",
"이 폴더에 현재 페이지 추가": "将当前页面添加到此文件夹", "이 폴더에 현재 페이지 추가": "将当前页面添加到此文件夹",
"이 폴더에는 고를 파일이 없습니다": "此文件夹中没有可选择的文件", "이 폴더에는 고를 파일이 없습니다": "此文件夹中没有可选择的文件",
"이 폴더에는 그림 · 동영상이 없습니다": "此文件夹中没有图片或视频", "이 폴더에는 그림 · 동영상이 없습니다": "此文件夹中没有图片或视频",

View File

@ -1,12 +1,23 @@
import 'dart:io'; import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart'; import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/app_controller.dart';
import 'package:jj_mkvmaker/app/i18n_controller.dart';
import 'package:jj_mkvmaker/core/playlist.dart';
import 'package:jj_mkvmaker/l10n/tr.dart';
import 'package:jj_mkvmaker/platform/common/media_kit_full_player.dart'; import 'package:jj_mkvmaker/platform/common/media_kit_full_player.dart';
import 'package:jj_mkvmaker/platform/common/media_kit_player.dart'; import 'package:jj_mkvmaker/platform/common/media_kit_player.dart';
import 'package:jj_mkvmaker/platform/windows/desktop_shell.dart'; import 'package:jj_mkvmaker/platform/windows/desktop_shell.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/services/media_player.dart'; import 'package:jj_mkvmaker/services/media_player.dart';
import 'package:jj_mkvmaker/ui/player_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:window_manager/window_manager.dart'; import 'package:window_manager/window_manager.dart';
import 'support/temp_dir.dart'; import 'support/temp_dir.dart';
@ -99,4 +110,76 @@ void main() {
await tester.runAsync(() => pl.dispose()); await tester.runAsync(() => pl.dispose());
dir.deleteSync(recursive: true); dir.deleteSync(recursive: true);
}, timeout: const Timeout(Duration(minutes: 3))); }, timeout: const Timeout(Duration(minutes: 3)));
// P2 · 재생 목록 모듈 (관리자 지시 10/21, 28 찾음): 실제 mpv 로 재생 화면 - ① 창 폭 412 · 600 · 960 × ko · ja 에서 조절 줄이 넘치지 않고
// (좁으면 두 줄) 끝의 [전체 화면] 이 화면 안 ② mp3 를 고르면 같은 폴더의 다른 곡이 목록에 들고 동영상은 빠진다 ("폴더 전체")
testWidgets('재생 화면 조절 줄 · 음악 재생 목록 (실제 mpv)', (t) async {
MediaKitPreviewPlayer.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? '';
final dir = testTempDir('jj_fpctl_');
appTempInto(dir);
Future<void> make(String name, List<String> args) async {
final r = await t.runAsync(() => Process.run('ffmpeg', ['-y', '-v', 'error', ...args, p.join(dir.path, name)]));
expect(r!.exitCode, 0, reason: '${r.stderr}');
}
await make('ep1.mkv', ['-f', 'lavfi', '-i', 'testsrc=size=640x360:rate=25', '-f', 'lavfi', '-i', 'sine', '-t', '20', //
'-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac']);
for (final (n, hz) in [('Artist - Song A.mp3', 440), ('Other - Track 2.mp3', 550), ('Another title.mp3', 660)]) {
await make(n, ['-f', 'lavfi', '-i', 'sine=frequency=$hz:duration=20', '-c:a', 'libmp3lame']);
}
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool.locate(), storage: DesktopStorageService()));
c.settings.playlistMode = PlaylistMode.folder;
// ② 재생 목록 모듈: mp3 하나를 고르면 같은 폴더의 곡 셋 (동영상 ep1.mkv 는 빠짐)
final plan = await t.runAsync(() => c.preparePlayback([p.join(dir.path, 'Other - Track 2.mp3')], internal: true));
expect(plan!.$1.map(p.basename).toList(), ['Another title.mp3', 'Artist - Song A.mp3', 'Other - Track 2.mp3']);
expect(plan.$1[plan.$2], endsWith('Other - Track 2.mp3'));
final shotKey = GlobalKey();
for (final (files, start, tag) in [(plan.$1, plan.$2, 'audio'), ([p.join(dir.path, 'ep1.mkv')], 0, 'video')]) {
final pl = MediaKitFullPlayer();
await t.pumpWidget(RepaintBoundary(
key: shotKey,
child: MaterialApp(
theme: buildTheme(), debugShowCheckedModeBanner: false, home: PlayerPage(c: c, player: pl, files: files, start: start)),
));
for (var i = 0; i < 100 && !(pl.state.value.playing && pl.state.value.position > const Duration(milliseconds: 300)); i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 100)));
await t.pump();
}
expect(pl.state.value.playing, isTrue, reason: '$tag 재생 시작');
expect(pl.playlist.length, files.length, reason: '$tag 재생 목록');
for (final lang in ['ko', 'ja']) {
await t.runAsync(() => i18n.apply(lang, save: false));
for (final (w, h) in [(412, 900), (600, 800), (960, 600)]) {
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
t.view.devicePixelRatio = 1;
for (var i = 0; i < 6; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 80)));
await t.pump();
}
if (out.isNotEmpty) {
final r = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final img = await t.runAsync(() => r.toImage());
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true);
File(p.join(out, 'player_${tag}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
}
expect(t.takeException(), isNull, reason: '$tag $lang $w 넘침');
final full = t.getRect(find.byTooltip(tr('전체 화면 (F)')));
expect(full.right <= w + 0.5, isTrue, reason: '$tag $lang $w: [전체 화면] 이 화면 안 ($full)');
for (final tip in [tr('재생 속도'), tr('자막 (S)'), tr('재생 목록 (L)')]) {
final r = t.getRect(find.byTooltip(tip));
expect(r.right <= w + 0.5, isTrue, reason: '$tag $lang $w: $tip 화면 안 $r');
}
}
}
// PlayerPage 가 닫힐 때 플레이어를 정리한다
await t.pumpWidget(const SizedBox());
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 500)));
}
t.view.reset();
await t.runAsync(() => i18n.apply('ko', save: false));
}, timeout: const Timeout(Duration(minutes: 3)));
} }

View File

@ -0,0 +1,121 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img;
import 'package:integration_test/integration_test.dart';
import 'package:jj_mkvmaker/app/download_manager.dart';
import 'package:jj_mkvmaker/app/i18n_controller.dart';
import 'package:jj_mkvmaker/app/settings.dart';
import 'package:jj_mkvmaker/core/download_detect.dart';
import 'package:jj_mkvmaker/l10n/tr.dart';
import 'package:jj_mkvmaker/services/downloader.dart';
import 'package:jj_mkvmaker/ui/ai_upscale_ui.dart';
import 'package:jj_mkvmaker/ui/downloads_page.dart';
import 'package:jj_mkvmaker/ui/theme.dart';
import 'package:path/path.dart' as p;
import 'support/temp_dir.dart';
/// 받는 중에서 멈춰 있는 받기 (네트워크 없음)
class _Backend implements DownloadBackend {
@override
DownloadKind get kind => DownloadKind.video;
@override
Future<(String, List<PlaylistEntry>)?> expandPlaylist(String url) async => null;
@override
Future<void> start(DownloadTask t, void Function() changed) async {
t.state = DownloadState.downloading;
changed();
}
@override
Future<void> pause(DownloadTask t) async {}
@override
Future<void> cancel(DownloadTask t) async {}
@override
Future<void> shutdown() async {}
}
/// 관리자 지시 (10/21, F11 다음): "제목 + 단추 + ✕" 모양의 다른 창도 360 에서 - ① 다운로드 종료 확인 창 ② AI 해상도 비교 화면.
/// 넘치지 않고 ✕ (닫기 · 종료) 와 [저장] 이 화면 안. ko · ja × 360 · 412
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? '';
for (final lang in ['ko', 'ja']) {
for (final (w, h) in [(360, 780), (412, 900)]) {
testWidgets('제목 줄 $lang ${w}x$h', (t) async {
t.view.physicalSize = Size(w.toDouble(), h.toDouble());
t.view.devicePixelRatio = 1;
addTearDown(t.view.reset);
await t.runAsync(() => i18n.apply(lang, save: false));
addTearDown(() => i18n.apply('ko', save: false));
final tmp = testTempDir('jj_hdr360_');
final shotKey = GlobalKey();
final navKey = GlobalKey<NavigatorState>();
await t.pumpWidget(MaterialApp(
theme: buildTheme(),
debugShowCheckedModeBanner: false,
navigatorKey: navKey,
builder: (context, child) => RepaintBoundary(key: shotKey, child: child),
home: const Scaffold(body: SizedBox.expand()),
));
Future<void> settle([int n = 8]) async {
for (var i = 0; i < n; i++) {
await t.runAsync(() => Future<void>.delayed(const Duration(milliseconds: 50)));
await t.pump();
}
}
Future<void> shot(String name) async {
await settle(3);
if (out.isNotEmpty) {
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final im = await t.runAsync(() => b.toImage());
final bytes = await t.runAsync(() => im!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true);
File(p.join(out, 'hdr_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
}
expect(t.takeException(), isNull, reason: '$name 넘침');
}
void inside(Finder f, String what) {
final r = t.getRect(f);
expect(r.left >= -0.5 && r.right <= w + 0.5, isTrue, reason: '$what 화면 안 ($r)');
}
// ① 다운로드 종료 확인 창 (받는 중 2개)
final s = AppSettings()..downloadRoot = tmp.path;
final d = DownloadManager(backends: [_Backend()], settings: () => s, readClipboard: () async => null);
addTearDown(d.dispose);
d.addPage('https://cdn.example.com/a')!.title = 'アップロードされた とても長い 動画のタイトル - 第1話 高画質版';
d.addPage('https://cdn.example.com/b')!.title = 'Test video 2';
await settle();
unawaited(confirmExit(navKey.currentContext!, d));
await settle();
inside(find.byTooltip(tr('종료')), '✕ (종료)');
await shot('1_exit');
navKey.currentState!.popUntil((r) => r.isFirst);
await settle();
// ② AI 해상도 비교 화면 - 긴 그림 이름
final a = p.join(tmp.path, 'a.png');
final b = p.join(tmp.path, 'b.png');
File(a).writeAsBytesSync(img.encodePng(img.Image(width: 40, height: 30)));
File(b).writeAsBytesSync(img.encodePng(img.Image(width: 80, height: 60)));
unawaited(navKey.currentState!.push<bool>(MaterialPageRoute(
builder: (_) => UpscaleCompareView(original: a, upscaled: b, title: '아주 긴 이름의 만화 스캔 - 제1권 012쪽_x2.png'))));
await settle(12);
inside(find.byTooltip(tr('닫기')), '✕ (닫기)');
inside(find.text(tr('저장')), '[저장]');
inside(find.text(tr('올린 것')), '[올린 것]');
expect(t.getRect(find.text('아주 긴 이름의 만화 스캔 - 제1권 012쪽_x2.png')).width > 40, isTrue, reason: '제목이 보임');
await shot('2_compare');
});
}
}
}

View File

@ -25,13 +25,13 @@ import 'package:path/path.dart' as p;
import 'support/temp_dir.dart'; import 'support/temp_dir.dart';
/// 관리자 지시 (10/21, 28 찾음): ① 화면 · 색 보정 창 - 고른 비율 칩 ("세로 9:16") 은 ✓ 만 (그림 위에 체크가 겹치지 않게) /// 관리자 지시 (10/21, 28 찾음): ① 화면 · 색 보정 창 - 고른 비율 칩 ("세로 9:16") 은 ✓ 만 (그림 위에 체크가 겹치지 않게)
/// ② 자막 편집 화면 - 제목은 "자막 편집", 파일 이름은 그 아래 한 줄 (가운데를 줄여 끝 .srt 가 보이게). ko · ja × 412 · 960 /// ② 자막 편집 화면 - 제목은 "자막 편집", 파일 이름은 그 아래 한 줄 (가운데를 줄여 끝 .srt 가 보이게). ko · ja × 360 · 412 · 960
void main() { void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized(); IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final out = Platform.environment['JJ_SHOT_DIR'] ?? ''; final out = Platform.environment['JJ_SHOT_DIR'] ?? '';
for (final lang in ['ko', 'ja']) { for (final lang in ['ko', 'ja']) {
for (final (w, h) in [(412, 900), (960, 600)]) { for (final (w, h) in [(360, 780), (412, 900), (960, 600)]) {
testWidgets('화면 · 색 보정 칩 · 자막 편집 제목 $lang ${w}x$h', (t) async { testWidgets('화면 · 색 보정 칩 · 자막 편집 제목 $lang ${w}x$h', (t) async {
t.view.physicalSize = Size(w.toDouble(), h.toDouble()); t.view.physicalSize = Size(w.toDouble(), h.toDouble());
t.view.devicePixelRatio = 1; t.view.devicePixelRatio = 1;
@ -70,14 +70,16 @@ void main() {
Future<void> shot(String name) async { Future<void> shot(String name) async {
await settle(3); await settle(3);
expect(t.takeException(), isNull, reason: '$name 넘침'); // 넘쳐도 무엇이 넘쳤는지 볼 수 있게 먼저 찍는다
if (out.isEmpty) return; if (out.isNotEmpty) {
final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary; final b = shotKey.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final img = await t.runAsync(() => b.toImage()); final img = await t.runAsync(() => b.toImage());
final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png)); final bytes = await t.runAsync(() => img!.toByteData(format: ui.ImageByteFormat.png));
Directory(out).createSync(recursive: true); Directory(out).createSync(recursive: true);
File(p.join(out, 'ui2_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List()); File(p.join(out, 'ui2_${name}_${lang}_$w.png')).writeAsBytesSync(bytes!.buffer.asUint8List());
} }
expect(t.takeException(), isNull, reason: '$name 넘침');
}
// ① 화면 · 색 보정 - "세로 9:16" 을 고른다 // ① 화면 · 색 보정 - "세로 9:16" 을 고른다
unawaited(showVideoAdjust(navKey.currentContext!, c)); unawaited(showVideoAdjust(navKey.currentContext!, c));
@ -92,6 +94,11 @@ void main() {
expect(find.descendant(of: chip, matching: find.byIcon(Icons.check)), findsOneWidget, reason: '✓ 만'); expect(find.descendant(of: chip, matching: find.byIcon(Icons.check)), findsOneWidget, reason: '✓ 만');
expect(find.descendant(of: chip, matching: find.byIcon(Icons.crop_portrait)), findsNothing); expect(find.descendant(of: chip, matching: find.byIcon(Icons.crop_portrait)), findsNothing);
await shot('1_adjust'); await shot('1_adjust');
// F11 (관리자 10/21): 좁아도 [✕] 는 화면 안, [모두 초기화] 는 (좁으면 아이콘이라도) 도움말 이름 그대로
final close = t.getRect(find.byTooltip(tr('닫기')));
expect(close.right <= w + 0.5, isTrue, reason: '✕ 가 화면 안 ($close)');
expect(find.byTooltip(tr('모두 초기화')).evaluate().isNotEmpty || find.text(tr('모두 초기화')).evaluate().isNotEmpty, isTrue);
expect(find.textContaining(tr('MKV 만들기에 적용됩니다 (영상을 다시 인코딩)')), findsOneWidget, reason: '안내 글은 숨기지 않는다');
navKey.currentState!.popUntil((r) => r.isFirst); navKey.currentState!.popUntil((r) => r.isFirst);
await settle(); await settle();
@ -106,6 +113,12 @@ void main() {
final name = find.textContaining('.srt'); final name = find.textContaining('.srt');
expect(name, findsOneWidget, reason: '파일 이름은 아래 한 줄 - 끝 (.srt) 이 보임'); expect(name, findsOneWidget, reason: '파일 이름은 아래 한 줄 - 끝 (.srt) 이 보임');
await shot('2_editor'); await shot('2_editor');
// F9 (관리자 10/21): 줄의 단추는 줄이지 않는다 - 기본 누를 자리 48 (+ 기기의 밀도 조정: Windows 는 compact 라 40, Android 는 48)
final density = Theme.of(t.element(find.byType(SubtitleEditorPage))).visualDensity.baseSizeAdjustment.dx;
for (final tip in [tr('아래에 줄 추가'), tr('줄 삭제')]) {
final r = t.getRect(find.byTooltip(tip));
expect(r.width >= 48 + density - 0.5 && r.height >= 48 + density - 0.5, isTrue, reason: '$tip 누를 자리 $r (밀도 조정 $density)');
}
expect(t.widget<Text>(name).data!.endsWith('.srt'), isTrue, reason: '${t.widget<Text>(name).data}'); expect(t.widget<Text>(name).data!.endsWith('.srt'), isTrue, reason: '${t.widget<Text>(name).data}');
}); });
} }

View File

@ -1271,7 +1271,7 @@ class AppController extends ChangeNotifier {
bool internal = false, bool internal = false,
Future<void> Function(String program, List<String> videos)? external}) async { Future<void> Function(String program, List<String> videos)? external}) async {
// 29: 음악도 내장 플레이어로 (탐색기 메뉴의 "내장 플레이어로 재생") // 29: 음악도 내장 플레이어로 (탐색기 메뉴의 "내장 플레이어로 재생")
final videos = files.where((f) => isVideoFile(f) || isAudioFile(f)).toList(); final videos = files.where(isPlayableFile).toList();
if (videos.isEmpty) return null; if (videos.isEmpty) return null;
final ext = p.extension(videos.first).replaceFirst('.', '').toLowerCase(); final ext = p.extension(videos.first).replaceFirst('.', '').toLowerCase();
final program = settings.externalPlayers[ext]; final program = settings.externalPlayers[ext];

View File

@ -24,6 +24,22 @@ bool isVideoFile(String path) =>
bool isAudioFile(String path) => bool isAudioFile(String path) =>
audioExtensions.contains(p.extension(path).replaceFirst('.', '').toLowerCase()); 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화 /// file_001 ≡ file_0002, Show.S01E02 ≡ Show.S02E10, 드라마 3화 ≡ 드라마 12화
String seriesKey(String path) => p String seriesKey(String path) => p
@ -53,13 +69,13 @@ int naturalCompare(String a, String b) {
return ma.length.compareTo(mb.length); return ma.length.compareTo(mb.length);
} }
/// 파일 하나 → 재생 목록 (선택한 파일이 들어 있고, 이름 순) /// 파일 하나 → 재생 목록 (선택한 파일이 들어 있고, 이름 순). 형제는 고른 파일과 같은 종류만 ([samePlayKind])
/// 돌려주는 값: (목록, 선택한 파일의 위치) /// 돌려주는 값: (목록, 선택한 파일의 위치)
(List<String>, int) buildPlaylist(String file, Iterable<String> filesInFolder, PlaylistMode mode) { (List<String>, int) buildPlaylist(String file, Iterable<String> filesInFolder, PlaylistMode mode) {
if (mode == PlaylistMode.single) return ([file], 0); if (mode == PlaylistMode.single) return ([file], 0);
final key = seriesKey(file); final key = seriesKey(file);
final list = filesInFolder final list = filesInFolder
.where(isVideoFile) .where((f) => samePlayKind(file, f))
.where((f) => mode == PlaylistMode.folder || seriesKey(f) == key) .where((f) => mode == PlaylistMode.folder || seriesKey(f) == key)
.toList(); .toList();
if (!list.any((f) => p.equals(f, file))) list.add(file); if (!list.any((f) => p.equals(f, file))) list.add(file);

View File

@ -617,21 +617,30 @@ class UpscaleCompareViewState extends State<UpscaleCompareView> {
} }
Widget _page(BuildContext context) { Widget _page(BuildContext context) {
return Scaffold( final pick = SegmentedButton<bool>(
backgroundColor: Colors.black,
appBar: AppBar(
leading: IconButton(tooltip: tr('닫기'), icon: const Icon(Icons.close), onPressed: _close),
title: Text(widget.title, maxLines: 1, overflow: TextOverflow.ellipsis),
actions: [
SegmentedButton<bool>(
segments: [ segments: [
ButtonSegment(value: false, label: Text(tr('원본'))), ButtonSegment(value: false, label: Text(tr('원본'))),
ButtonSegment(value: true, label: Text(tr('올린 것'))), ButtonSegment(value: true, label: Text(tr('올린 것'))),
], ],
selected: {_up}, selected: {_up},
onSelectionChanged: (v) => setState(() => _up = v.first), onSelectionChanged: (v) => setState(() => _up = v.first),
), );
const SizedBox(width: 8), // 관리자 지시 (10/21, 15 찾음 G2): 360 에서 위 막대가 넘쳐 제목이 안 보이고 [저장] 글자가 잘렸다 -
// 좁으면 원본 / 올린 것 고르기를 위 막대 아래 둘째 줄로. 넓으면 그대로
final narrow = MediaQuery.sizeOf(context).width < 600;
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
leading: IconButton(tooltip: tr('닫기'), icon: const Icon(Icons.close), onPressed: _close),
title: Text(widget.title, maxLines: 1, overflow: TextOverflow.ellipsis),
bottom: narrow
? PreferredSize(
preferredSize: const Size.fromHeight(52),
child: Padding(padding: const EdgeInsets.only(bottom: 8), child: Center(child: pick)),
)
: null,
actions: [
if (!narrow) ...[pick, const SizedBox(width: 8)],
FilledButton.icon( FilledButton.icon(
onPressed: () => Navigator.pop(context, true), onPressed: () => Navigator.pop(context, true),
icon: const Icon(Icons.save_outlined, size: 18), icon: const Icon(Icons.save_outlined, size: 18),

View File

@ -509,9 +509,9 @@ class _ExitDialog extends StatelessWidget {
final active = d.tasks.where((t) => t.unfinished).toList(); final active = d.tasks.where((t) => t.unfinished).toList();
return AlertDialog( return AlertDialog(
titlePadding: const EdgeInsets.fromLTRB(24, 16, 8, 0), titlePadding: const EdgeInsets.fromLTRB(24, 16, 8, 0),
// 관리자 지시 (10/21, 15 찾음 G1): 360 에서 제목이 넘쳐 개수와 [✕] 가 잘렸다 - 제목은 두 줄까지 감싸고 [✕] 는 늘 보이게
title: Row(children: [ title: Row(children: [
Text(trf('받는 중인 다운로드 {0}개', [active.length])), Expanded(child: Text(trf('받는 중인 다운로드 {0}개', [active.length]), maxLines: 2, overflow: TextOverflow.ellipsis)),
const Spacer(),
IconButton( IconButton(
tooltip: tr('종료'), tooltip: tr('종료'),
icon: const Icon(Icons.close), icon: const Icon(Icons.close),

View File

@ -18,7 +18,7 @@ import '../app/transfer_job.dart';
import '../core/secret_gate.dart'; import '../core/secret_gate.dart';
import '../core/sync_tools.dart'; import '../core/sync_tools.dart';
import '../core/file_ops.dart'; import '../core/file_ops.dart';
import '../core/playlist.dart' show isAudioFile, isVideoFile; import '../core/playlist.dart' show folderPlaylist, isAudioFile, isPlayableFile, isVideoFile;
import '../core/vfs.dart'; import '../core/vfs.dart';
import '../core/sftp.dart' show SftpHostKeyException, SftpRegistry; import '../core/sftp.dart' show SftpHostKeyException, SftpRegistry;
import '../core/ftp.dart' show FtpCertException, FtpRegistry; import '../core/ftp.dart' show FtpCertException, FtpRegistry;
@ -2472,7 +2472,7 @@ class _ExplorerPageState extends State<ExplorerPage> {
case ExplorerButton.play: case ExplorerButton.play:
final t = _targets(pane); final t = _targets(pane);
// 181: 강조한 것이 mp3 등 소리 파일이어도 그것을 (예전에는 동영상이 아니라고 빼고 폴더의 동영상을 틀었다) // 181: 강조한 것이 mp3 등 소리 파일이어도 그것을 (예전에는 동영상이 아니라고 빼고 폴더의 동영상을 틀었다)
final videos = t.where((x) => !vIsDirSync(x) && (isVideoFile(x) || isAudioFile(x))).toList(); final videos = t.where((x) => !vIsDirSync(x) && isPlayableFile(x)).toList();
if (videos.isNotEmpty) { if (videos.isNotEmpty) {
await _play(videos, keepOrder: pane.marked.isNotEmpty); await _play(videos, keepOrder: pane.marked.isNotEmpty);
} else { } else {
@ -2557,13 +2557,14 @@ class _ExplorerPageState extends State<ExplorerPage> {
Future<void> _playFolder(String dir) async { Future<void> _playFolder(String dir) async {
final List<String> list; final List<String> list;
try { try {
list = (await listEntries(dir)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path).toList(); // 재생 목록 모듈: 동영상이 없는 폴더 (음악 폴더) 는 소리 파일을
list = folderPlaylist([for (final e in await listEntries(dir)) if (!e.isDir) e.path]);
} catch (e) { } catch (e) {
_snack(trf('읽을 수 없습니다: {0}', [explainFileError('$e', dav: isDav(dir)).$1])); _snack(trf('읽을 수 없습니다: {0}', [explainFileError('$e', dav: isDav(dir)).$1]));
return; return;
} }
if (list.isEmpty) { if (list.isEmpty) {
_snack(tr('이 폴더에 동영상이 없습니다.')); _snack(tr('이 폴더에 재생할 동영상 · 소리 파일이 없습니다.'));
return; return;
} }
if (!mounted) return; if (!mounted) return;

View File

@ -15,6 +15,7 @@ import '../services/media_player.dart';
import 'app_actions.dart'; import 'app_actions.dart';
import 'audio_now_playing.dart'; import 'audio_now_playing.dart';
import 'dav_external.dart'; import 'dav_external.dart';
import 'row_fit.dart';
import 'theme.dart'; import 'theme.dart';
import '../l10n/tr.dart'; import '../l10n/tr.dart';
@ -462,32 +463,39 @@ class _PlayerPageState extends State<PlayerPage> {
Text(formatSrtTime(s.duration).substring(0, 8), Text(formatSrtTime(s.duration).substring(0, 8),
style: const TextStyle(fontSize: 12, color: Colors.white70, fontFamily: 'Consolas')), style: const TextStyle(fontSize: 12, color: Colors.white70, fontFamily: 'Consolas')),
]), ]),
// 손가락: 재생 버튼 줄과 나머지 (음량 · 자막 · 목록 · 전체 화면) 줄을 나눠 넉넉하게 // 손가락: 재생 버튼 줄과 나머지 (음량 · 자막 · 목록 · 전체 화면) 줄을 나눠 넉넉하게.
if (touch) // P2 (관리자 지시 10/21, 28 찾음): Windows 창 폭 412 에서 한 줄이 184px 넘쳐 속도 · 자막 · 목록 · 전체 화면이 잘렸다 -
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ // 한 줄에 들지 않으면 마우스여도 두 줄로. 단추는 기본 크기 그대로, 소리 막대만 남는 폭만큼 (최대 100)
btn(Icons.skip_previous, tr('이전 (P)'), () => pl.previous()), LayoutBuilder(builder: (context, box) {
btn(Icons.replay_10, tr('10초 뒤로 (←)'), () => _seekBy(-10)), final bw = touch ? 48.0 : 40.0;
btn(s.playing ? Icons.pause_circle_filled : Icons.play_circle_fill, tr('재생/일시정지 (Space)'), final menus = compact ? 0 : 3 + (s.audioTracks.length > 1 ? 1 : 0);
() => pl.playOrPause(), size: 40), final restButtons = 1 + menus + (Platform.isAndroid ? 0 : 1) + 1;
btn(Icons.stop, tr('정지'), () => pl.stop()), final playButtons = compact ? 4 : 6;
btn(Icons.forward_10, tr('10초 앞으로 (→)'), () => _seekBy(10)), final fit = fitRow(
btn(Icons.skip_next, tr('다음 (N)'), () => pl.next()), maxWidth: box.maxWidth,
]), first: List.filled(playButtons, bw),
Row(children: [ second: List.filled(restButtons, bw),
if (!touch) ...[ gap: 8,
flexMin: compact ? 0 : 48,
flexMax: compact ? 0 : 100,
forceSplit: touch,
);
final twoRows = fit.split;
final sliderWidth = fit.flexWidth;
final play = [
btn(Icons.skip_previous, tr('이전 (P)'), () => pl.previous()), btn(Icons.skip_previous, tr('이전 (P)'), () => pl.previous()),
if (!compact) btn(Icons.replay_10, tr('10초 뒤로 (←)'), () => _seekBy(-10)), if (!compact) btn(Icons.replay_10, tr('10초 뒤로 (←)'), () => _seekBy(-10)),
btn(s.playing ? Icons.pause_circle_filled : Icons.play_circle_fill, tr('재생/일시정지 (Space)'), btn(s.playing ? Icons.pause_circle_filled : Icons.play_circle_fill, tr('재생/일시정지 (Space)'),
() => pl.playOrPause(), size: 34), () => pl.playOrPause(), size: touch ? 40 : 34),
btn(Icons.stop, tr('정지'), () => pl.stop()), btn(Icons.stop, tr('정지'), () => pl.stop()),
if (!compact) btn(Icons.forward_10, tr('10초 앞으로 (→)'), () => _seekBy(10)), if (!compact) btn(Icons.forward_10, tr('10초 앞으로 (→)'), () => _seekBy(10)),
btn(Icons.skip_next, tr('다음 (N)'), () => pl.next()), btn(Icons.skip_next, tr('다음 (N)'), () => pl.next()),
const SizedBox(width: 8), ];
], final rest = [
btn(s.volume == 0 ? Icons.volume_off : Icons.volume_up, tr('음소거 (M)'), _toggleMute), btn(s.volume == 0 ? Icons.volume_off : Icons.volume_up, tr('음소거 (M)'), _toggleMute),
if (!compact) if (sliderWidth > 0)
SizedBox( SizedBox(
width: 100, width: sliderWidth,
child: Slider(value: s.volume.clamp(0, 100), max: 100, onChanged: (v) => pl.setVolume(v)), child: Slider(value: s.volume.clamp(0, 100), max: 100, onChanged: (v) => pl.setVolume(v)),
), ),
const Spacer(), const Spacer(),
@ -512,7 +520,15 @@ class _PlayerPageState extends State<PlayerPage> {
btn(_popup ? Icons.close_fullscreen : Icons.picture_in_picture_alt, _popup ? tr('팝업 끄기') : tr('팝업 보기'), btn(_popup ? Icons.close_fullscreen : Icons.picture_in_picture_alt, _popup ? tr('팝업 끄기') : tr('팝업 보기'),
_togglePopup), _togglePopup),
btn(_full ? Icons.fullscreen_exit : Icons.fullscreen, tr('전체 화면 (F)'), _toggleFull), btn(_full ? Icons.fullscreen_exit : Icons.fullscreen, tr('전체 화면 (F)'), _toggleFull),
];
return Column(mainAxisSize: MainAxisSize.min, children: [
if (twoRows) Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: play),
Row(children: [
if (!twoRows) ...[...play, const SizedBox(width: 8)],
...rest,
]), ]),
]);
}),
]), ]),
); );
}, },

33
lib/ui/row_fit.dart Normal file
View File

@ -0,0 +1,33 @@
/// 한 줄 · 두 줄 배치 판단 (관리자 지시 10/21: 화면마다 따로 재지 않고 이것 하나를 부른다 - d3 설계의 AdaptiveRow 자리).
///
/// 약속
/// - 입력: [maxWidth] 쓸 수 있는 폭, [first] · [second] 앞 · 뒤 묶음 아이템의 최소 폭 (단추는 기본 누를 자리 그대로),
/// [gap] 한 줄일 때 두 묶음 사이, 줄일 수 있는 아이템 하나의 폭 범위 [flexMin] ~ [flexMax] (없으면 0 · 0).
/// - 출력: [RowFit.split] 이면 두 줄 (위 = first, 아래 = second + 줄일 수 있는 아이템), [RowFit.flexWidth] 줄일 수 있는 아이템의 폭
/// (0 이면 그 줄에 넣지 않는다 - 같은 일을 하는 다른 단추가 남아 있어야 한다).
/// - 오류: 없음. 두 줄로도 [second] 가 들지 않으면 split = true · flexWidth = 0 이고 [RowFit.overflow] 에 모자란 폭.
class RowFit {
final bool split;
final double flexWidth;
final double overflow;
const RowFit(this.split, this.flexWidth, this.overflow);
}
RowFit fitRow({
required double maxWidth,
required List<double> first,
required List<double> second,
double gap = 0,
double flexMin = 0,
double flexMax = 0,
bool forceSplit = false,
}) {
double sum(List<double> xs) => xs.fold(0.0, (a, b) => a + b);
final a = sum(first), b = sum(second);
final split = forceSplit || a + gap + b + flexMax > maxWidth;
final room = maxWidth - b - (split ? 0 : a + gap);
var flex = room.clamp(0.0, flexMax);
if (flex < flexMin) flex = 0;
final over = (split ? [a, b].reduce((x, y) => x > y ? x : y) : a + gap + b) - maxWidth;
return RowFit(split, flex, over > 0 ? over : 0);
}

View File

@ -615,12 +615,12 @@ class _HeaderRow extends StatelessWidget {
} }
/// 관리자 지시 (10/21, 15 찾음 F9): 자막 편집 표는 # · 시작 · 끝 · 단추 칸 폭이 정해져 있어 (48 + 130 + 130 + 96) 폰 세로 (412) 에서 /// 관리자 지시 (10/21, 15 찾음 F9): 자막 편집 표는 # · 시작 · 끝 · 단추 칸 폭이 정해져 있어 (48 + 130 + 130 + 96) 폰 세로 (412) 에서
/// 오른쪽으로 넘쳤다 - 내용 칸까지 한 줄에 들지 않으면 두 단 (위: # · 시작 · 끝 · 단추, 아래: 내용). 시간 칸은 줄이지 않는다 (편집이 어려움) /// 오른쪽으로 넘쳤다 - 내용 칸까지 한 줄에 들지 않으면 두 단 (위: # · 시작 · 끝, 아래: 내용 · 단추). 시간 칸은 줄이지 않는다 (편집이 어려움).
/// 단추는 두 단이 되는 폰 (손가락) 에서 누를 자리 48 을 지킨다 (관리자 10/21)
bool _cueNarrow(double width) => width < 48 + 130 + 130 + 96 + 200; bool _cueNarrow(double width) => width < 48 + 130 + 130 + 96 + 200;
/// 두 단일 때 # 칸 · 단추 하나의 폭 (위 단이 412 에 들게: 28 + 130 + 130 + 2 × 40 = 368) /// 두 단일 때 # 칸 폭 (위 단 28 + 130 + 130 = 288 - 360 폭에도 듬)
const _narrowNo = 28.0; const _narrowNo = 28.0;
const _narrowButton = 40.0;
const _hdr = TextStyle(fontSize: 12, color: JjColors.textDim); const _hdr = TextStyle(fontSize: 12, color: JjColors.textDim);
@ -723,22 +723,16 @@ class _CueRowState extends State<_CueRow> {
onTap: () => e.select(widget.cue), onTap: () => e.select(widget.cue),
onChanged: (t) => e.setText(widget.cue, t), onChanged: (t) => e.setText(widget.cue, t),
); );
// 좁으면 단추 누를 자리를 40 으로 (기본 48) - 위 단이 폭에 들게
final small = narrow ? const BoxConstraints(minWidth: _narrowButton, minHeight: _narrowButton) : null;
final buttons = [ final buttons = [
IconButton( IconButton(
tooltip: tr('아래에 줄 추가'), tooltip: tr('아래에 줄 추가'),
iconSize: 18, iconSize: 18,
constraints: small,
padding: narrow ? EdgeInsets.zero : null,
icon: const Icon(Icons.add, color: JjColors.textDim), icon: const Icon(Icons.add, color: JjColors.textDim),
onPressed: () => e.insertAfter(widget.index), onPressed: () => e.insertAfter(widget.index),
), ),
IconButton( IconButton(
tooltip: tr('줄 삭제'), tooltip: tr('줄 삭제'),
iconSize: 18, iconSize: 18,
constraints: small,
padding: narrow ? EdgeInsets.zero : null,
icon: const Icon(Icons.delete_outline, color: JjColors.textDim), icon: const Icon(Icons.delete_outline, color: JjColors.textDim),
onPressed: () => e.removeAt(widget.index), onPressed: () => e.removeAt(widget.index),
), ),
@ -752,10 +746,13 @@ class _CueRowState extends State<_CueRow> {
SizedBox(width: 96, child: Row(children: buttons)), SizedBox(width: 96, child: Row(children: buttons)),
]); ]);
} }
// F9: 좁을 때 두 단 - 위 # · 시작 · 끝 · 단추, 아래 내용 (# 칸만큼 들여) // F9: 좁을 때 두 단 - 위 # · 시작 · 끝, 아래 내용 (# 칸만큼 들여) · 단추 (오른쪽 끝, 누를 자리 48)
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [number, start, end, const Spacer(), ...buttons]), Row(crossAxisAlignment: CrossAxisAlignment.start, children: [number, start, end]),
Padding(padding: const EdgeInsets.only(left: _narrowNo), child: text), Padding(
padding: const EdgeInsets.only(left: _narrowNo),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [Expanded(child: text), ...buttons]),
),
]); ]);
}), }),
); );

View File

@ -122,22 +122,39 @@ class _VideoAdjustState extends State<_VideoAdjust> {
Container( Container(
color: JjColors.panelHigh, color: JjColors.panelHigh,
padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), padding: const EdgeInsets.fromLTRB(16, 8, 8, 8),
child: Row(children: [ // 관리자 지시 (10/21, 15 찾음 F11): 360 에서 제목 줄이 넘쳐 [✕] 가 화면 밖으로 밀렸다 - 좁으면 제목은 줄이고 (…)
// [모두 초기화] 는 아이콘 단추 (도움말 · 읽어 주기 이름 그대로) 로, [✕] 는 늘 보이게. 넓으면 그대로
child: LayoutBuilder(builder: (context, box) {
final narrow = box.maxWidth < 560;
final reset = s.adjusts ? () => _set(s.resetAdjust()) : null;
return Row(children: [
const Icon(Icons.tune, color: JjColors.accent, size: 20), const Icon(Icons.tune, color: JjColors.accent, size: 20),
const SizedBox(width: 8), const SizedBox(width: 8),
if (narrow)
// 안내 글은 숨기지 않고 제목 아래 줄로
Expanded(
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(tr('화면 · 색 보정'),
maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
Text(tr('MKV 만들기에 적용됩니다 (영상을 다시 인코딩)'),
maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
]),
)
else ...[
Text(tr('화면 · 색 보정'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), Text(tr('화면 · 색 보정'), style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text(tr('MKV 만들기에 적용됩니다 (영상을 다시 인코딩)'), child: Text(tr('MKV 만들기에 적용됩니다 (영상을 다시 인코딩)'),
overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)), overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
), ),
TextButton.icon( ],
onPressed: s.adjusts ? () => _set(s.resetAdjust()) : null, if (narrow)
icon: const Icon(Icons.restart_alt, size: 18), IconButton(tooltip: tr('모두 초기화'), icon: const Icon(Icons.restart_alt), onPressed: reset)
label: Text(tr('모두 초기화')), else
), TextButton.icon(onPressed: reset, icon: const Icon(Icons.restart_alt, size: 18), label: Text(tr('모두 초기화'))),
IconButton(tooltip: tr('닫기'), icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)), IconButton(tooltip: tr('닫기'), icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
]), ]);
}),
), ),
Expanded( Expanded(
child: wide child: wide

View File

@ -122,6 +122,13 @@ void main() {
expect(j, 1); expect(j, 1);
final (all, _) = buildPlaylist(f, files, PlaylistMode.folder); final (all, _) = buildPlaylist(f, files, PlaylistMode.folder);
expect(all, hasLength(7)); // 자막·텍스트 제외 expect(all, hasLength(7)); // 자막·텍스트 제외
// 재생 목록 모듈 약속 (관리자 지시 10/21): 형제는 고른 파일과 같은 종류만 - 소리를 고르면 소리끼리, 동영상이면 동영상끼리
final music = [for (final n in ['b.mp3', 'a.flac', 'c.mp4', 'd.txt']) p.join(dir, n)];
final (songs, s) = buildPlaylist(music.first, music, PlaylistMode.folder);
expect([songs.map(p.basename), s], [['a.flac', 'b.mp3'], 1]);
expect(buildPlaylist(music[2], music, PlaylistMode.folder).$1.map(p.basename), ['c.mp4']);
expect(folderPlaylist(music).map(p.basename), ['c.mp4'], reason: '폴더를 틀 때 동영상이 있으면 동영상');
expect(folderPlaylist([music[0], music[1], music[3]]).map(p.basename), ['a.flac', 'b.mp3'], reason: '없으면 소리');
}); });
test('끌어다 놓기: 동영상만, 폴더 안쪽, jj_ 출력 폴더 제외', () async { test('끌어다 놓기: 동영상만, 폴더 안쪽, jj_ 출력 폴더 제외', () async {