From 4985887a5bb0ff31c21679a7b7bc91bdb35151fe Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sun, 25 Oct 2026 04:31:13 +0900 Subject: [PATCH] =?UTF-8?q?P2=20=EC=9E=AC=EC=83=9D=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=88=20=EC=A4=84=20=EC=A2=81=EC=9D=84=20=EB=95=8C?= =?UTF-8?q?=20=EB=91=90=20=EC=A4=84=20=C2=B7=20=EC=9E=AC=EC=83=9D=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=AA=A8=EB=93=88=20(=EC=86=8C=EB=A6=AC?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=EC=9D=80=20=EC=86=8C=EB=A6=AC=EB=81=BC?= =?UTF-8?q?=EB=A6=AC)=20=EC=9D=84=20=EC=83=88=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=A1=9C=20=EC=98=AE=EA=B9=80=20(82a87da,=20after=5F1020)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/ui/row_fit.dart: fitRow (판단만 - 82a87da 그대로) + FitButtonRows (그 판단대로 한 줄 / 두 줄로 그림, 단추 크기 그대로, 소리 막대만 남는 폭 최대 100). 재생 화면은 부르기만 한다 (player_page 580 → 576줄) - 재생 목록 모듈 (core/playlist.dart): isPlayableFile · samePlayKind · folderPlaylist. buildPlaylist 의 형제는 고른 파일과 같은 종류만, 폴더 [재생] 은 동영상이 없으면 소리 파일 (안내 글 "이 폴더에 재생할 동영상 · 소리 파일이 없습니다." - en · ja · zh 사전 함께). 컨트롤러 (playback_prep) · 탐색기 (explorer_buttons · open_flow) 는 부르기만 - 시험: row_fit_contract_test (새) · player_test 약속 줄 · full_player_test 실제 mpv 장면 (82a87da 에서 옮김). explorer_flows_contract_test 의 알림 글을 새 글로 (틀 것이 없으면 알림은 그대로) - full_player_test: 앞 판은 mp3 목록이 [ep1.mkv, Other - Track 2.mp3] 로 실패 (결함 재현), 뒤 판은 목록은 통과하나 "audio 재생 시작" 에서 멈춤 - 이 PC 는 소리 출력 장치가 모두 꺼져 있음 (AudioEndpoint 모두 Unknown). 조절 줄 장면은 기기 (노트 10+) 확인 몫 Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 2 +- assets/l10n/ja.json | 2 +- assets/l10n/zh-Hans.json | 2 +- integration_test/full_player_test.dart | 83 ++++++++++++++++++++++ lib/app/controller/playback_prep.dart | 2 +- lib/core/playlist.dart | 20 +++++- lib/ui/explorer/explorer_buttons.dart | 4 +- lib/ui/explorer/open_flow.dart | 7 +- lib/ui/player_page.dart | 84 +++++++++++----------- lib/ui/row_fit.dart | 98 ++++++++++++++++++++++++++ test/explorer_flows_contract_test.dart | 3 +- test/player_test.dart | 7 ++ test/row_fit_contract_test.dart | 59 ++++++++++++++++ 13 files changed, 317 insertions(+), 56 deletions(-) create mode 100644 lib/ui/row_fit.dart create mode 100644 test/row_fit_contract_test.dart diff --git a/assets/l10n/en.json b/assets/l10n/en.json index 1fdf346..eedaaba 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -2291,9 +2291,9 @@ "이 폴더를 볼 권한이 없습니다": "No permission to view this folder", "이 폴더를 읽을 권한이 없습니다": "No permission to read this folder", "이 폴더를 지우면 보관함도 사라집니다. 폴더를 통째로 복사하면 그대로 백업이 됩니다 (비밀번호로 열림)": "Deleting this folder deletes the vault. Copying the whole folder is a complete backup (opens with the password)", - "이 폴더에 동영상이 없습니다.": "There are no videos in this folder.", "이 폴더에 빈 .nomedia 파일을 만듭니다. 갤러리 · 음악 앱이 이 폴더 (안의 폴더 포함) 의 그림 · 동영상 · 음악을 보이지 않게 됩니다.": "Creates an empty .nomedia file in this folder. Gallery and music apps will stop showing the pictures, videos and music in this folder (including subfolders).", "이 폴더에 새 폴더": "New folder in this folder", + "이 폴더에 재생할 동영상 · 소리 파일이 없습니다.": "No videos or audio files to play in this folder.", "이 폴더에 현재 페이지 추가": "Add the current page to this folder", "이 폴더에는 고를 파일이 없습니다": "There are no files to choose in this folder", "이 폴더에는 그림 · 동영상이 없습니다": "No pictures or videos in this folder", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index ae4c4a8..a6a1e7f 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -2291,9 +2291,9 @@ "이 폴더를 볼 권한이 없습니다": "このフォルダーを表示する権限がありません", "이 폴더를 읽을 권한이 없습니다": "このフォルダーを読む権限がありません", "이 폴더를 지우면 보관함도 사라집니다. 폴더를 통째로 복사하면 그대로 백업이 됩니다 (비밀번호로 열림)": "このフォルダーを削除すると保管庫も消えます。フォルダーごとコピーすればそのままバックアップになります (パスワードで開けます)", - "이 폴더에 동영상이 없습니다.": "このフォルダーに動画がありません。", "이 폴더에 빈 .nomedia 파일을 만듭니다. 갤러리 · 음악 앱이 이 폴더 (안의 폴더 포함) 의 그림 · 동영상 · 음악을 보이지 않게 됩니다.": "このフォルダーに空の .nomedia ファイルを作ります。ギャラリー・音楽アプリはこのフォルダー (サブフォルダーを含む) の画像・動画・音楽を表示しなくなります。", "이 폴더에 새 폴더": "このフォルダーに新しいフォルダー", + "이 폴더에 재생할 동영상 · 소리 파일이 없습니다.": "このフォルダーに再生できる動画・音声ファイルがありません。", "이 폴더에 현재 페이지 추가": "このフォルダーに現在のページを追加", "이 폴더에는 고를 파일이 없습니다": "このフォルダーには選べるファイルがありません", "이 폴더에는 그림 · 동영상이 없습니다": "このフォルダーには画像・動画がありません", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index b41c993..244f74a 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -2291,9 +2291,9 @@ "이 폴더를 볼 권한이 없습니다": "没有查看此文件夹的权限", "이 폴더를 읽을 권한이 없습니다": "没有读取此文件夹的权限", "이 폴더를 지우면 보관함도 사라집니다. 폴더를 통째로 복사하면 그대로 백업이 됩니다 (비밀번호로 열림)": "删除此文件夹将同时删除保管库。整个文件夹复制即为完整备份 (可用密码打开)", - "이 폴더에 동영상이 없습니다.": "此文件夹中没有视频。", "이 폴더에 빈 .nomedia 파일을 만듭니다. 갤러리 · 음악 앱이 이 폴더 (안의 폴더 포함) 의 그림 · 동영상 · 음악을 보이지 않게 됩니다.": "在此文件夹中创建空的 .nomedia 文件。图库和音乐应用将不再显示此文件夹 (含子文件夹) 中的图片、视频和音乐。", "이 폴더에 새 폴더": "在此文件夹中新建文件夹", + "이 폴더에 재생할 동영상 · 소리 파일이 없습니다.": "此文件夹中没有可播放的视频或音频文件。", "이 폴더에 현재 페이지 추가": "将当前页面添加到此文件夹", "이 폴더에는 고를 파일이 없습니다": "此文件夹中没有可选择的文件", "이 폴더에는 그림 · 동영상이 없습니다": "此文件夹中没有图片或视频", diff --git a/integration_test/full_player_test.dart b/integration_test/full_player_test.dart index 2c45854..bbecae8 100644 --- a/integration_test/full_player_test.dart +++ b/integration_test/full_player_test.dart @@ -1,12 +1,23 @@ 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: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_player.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/ui/player_page.dart'; +import 'package:jj_mkvmaker/ui/theme.dart'; import 'package:path/path.dart' as p; import 'package:window_manager/window_manager.dart'; import 'support/temp_dir.dart'; @@ -99,4 +110,76 @@ void main() { await tester.runAsync(() => pl.dispose()); dir.deleteSync(recursive: true); }, timeout: const Timeout(Duration(minutes: 3))); + + // P2 · 재생 목록 모듈 (관리자 지시 10/21, 28 찾음 - 82a87da 에서 옮김): 실제 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 make(String name, List 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.playback.playlistMode = PlaylistMode.folder; // 나눈 설정 (playback) + + // ② 재생 목록 모듈: 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.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.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.delayed(const Duration(milliseconds: 500))); + } + t.view.reset(); + await t.runAsync(() => i18n.apply('ko', save: false)); + }, timeout: const Timeout(Duration(minutes: 3))); } diff --git a/lib/app/controller/playback_prep.dart b/lib/app/controller/playback_prep.dart index 8875c81..a3ed4c1 100644 --- a/lib/app/controller/playback_prep.dart +++ b/lib/app/controller/playback_prep.dart @@ -23,7 +23,7 @@ class PlaybackPrep { bool internal = false, Future Function(String program, List videos)? external}) async { // 29: 음악도 내장 플레이어로 (탐색기 메뉴의 "내장 플레이어로 재생") - final videos = files.where((f) => isVideoFile(f) || isAudioFile(f)).toList(); + final videos = files.where(isPlayableFile).toList(); if (videos.isEmpty) return null; final ext = p.extension(videos.first).replaceFirst('.', '').toLowerCase(); final program = settings.externalPlayers[ext]; diff --git a/lib/core/playlist.dart b/lib/core/playlist.dart index 487cdce..5eb247c 100644 --- a/lib/core/playlist.dart +++ b/lib/core/playlist.dart @@ -24,6 +24,22 @@ bool isVideoFile(String path) => bool isAudioFile(String path) => audioExtensions.contains(p.extension(path).replaceFirst('.', '').toLowerCase()); +// ── 재생 목록 모듈 (관리자 지시 10/21, 82a87da 에서 옮김): 무엇을 한 목록에 넣을지는 여기서만 정한다. 화면 · 컨트롤러는 부르기만 ── + +/// 내장 플레이어로 틀 수 있는 파일 (동영상 · 소리) +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 folderPlaylist(Iterable 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 @@ -53,13 +69,13 @@ int naturalCompare(String a, String b) { return ma.length.compareTo(mb.length); } -/// 파일 하나 → 재생 목록 (선택한 파일이 들어 있고, 이름 순) +/// 파일 하나 → 재생 목록 (선택한 파일이 들어 있고, 이름 순). 형제는 고른 파일과 같은 종류만 ([samePlayKind]) /// 돌려주는 값: (목록, 선택한 파일의 위치) (List, int) buildPlaylist(String file, Iterable filesInFolder, PlaylistMode mode) { if (mode == PlaylistMode.single) return ([file], 0); final key = seriesKey(file); final list = filesInFolder - .where(isVideoFile) + .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); diff --git a/lib/ui/explorer/explorer_buttons.dart b/lib/ui/explorer/explorer_buttons.dart index 72abbf1..fb5fcbd 100644 --- a/lib/ui/explorer/explorer_buttons.dart +++ b/lib/ui/explorer/explorer_buttons.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import '../../core/file_ops.dart' show FileOps, samePath; -import '../../core/playlist.dart' show isAudioFile, isVideoFile; +import '../../core/playlist.dart' show isPlayableFile; import '../../core/vfs.dart' show vDirname, vIsDirSync; import '../../l10n/tr.dart'; import '../app_manager_page.dart' show openAppManager; @@ -157,7 +157,7 @@ class ExplorerButtons { case ExplorerButton.play: final t = sel.targets(pane); // 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) { await flows.opener.play(cmd.context, videos, keepOrder: !pane.sel.isEmpty); } else { diff --git a/lib/ui/explorer/open_flow.dart b/lib/ui/explorer/open_flow.dart index aad63ed..d9eec38 100644 --- a/lib/ui/explorer/open_flow.dart +++ b/lib/ui/explorer/open_flow.dart @@ -8,7 +8,7 @@ import '../../app/component_store.dart'; import '../../app/file_opener.dart'; import '../../core/file_ops.dart'; import '../../core/hwp_source.dart'; -import '../../core/playlist.dart' show isAudioFile, isVideoFile; +import '../../core/playlist.dart' show folderPlaylist, isAudioFile, isVideoFile; import '../../core/reader_sources.dart'; import '../../core/vfs.dart'; import '../file_error.dart' show explainFileError; @@ -286,13 +286,14 @@ class ExplorerOpener { Future playFolder(BuildContext context, String dir) async { final List list; try { - list = (await listEntries(dir)).where((e) => !e.isDir && isVideoFile(e.path)).map((e) => e.path).toList(); + // 재생 목록 모듈 (82a87da): 동영상이 없는 폴더 (음악 폴더) 는 소리 파일을 + list = folderPlaylist([for (final e in await listEntries(dir)) if (!e.isDir) e.path]); } catch (e) { ctl.host.snack(trf('읽을 수 없습니다: {0}', [explainFileError('$e', dav: isDav(dir)).$1])); return; } if (list.isEmpty) { - ctl.host.snack(tr('이 폴더에 동영상이 없습니다.')); + ctl.host.snack(tr('이 폴더에 재생할 동영상 · 소리 파일이 없습니다.')); return; } if (!context.mounted) return; diff --git a/lib/ui/player_page.dart b/lib/ui/player_page.dart index dc7bde3..a1bba91 100644 --- a/lib/ui/player_page.dart +++ b/lib/ui/player_page.dart @@ -18,6 +18,7 @@ import 'dav_external.dart'; import 'theme.dart'; import '../l10n/tr.dart'; import 'jj_header_row.dart'; +import 'row_fit.dart'; /// 파일들을 재생 (외부 프로그램이 지정된 확장자면 그 프로그램으로, [internal] 이면 늘 내장 플레이어) Future playFiles(BuildContext context, AppController c, List files, @@ -471,57 +472,52 @@ class _PlayerPageState extends State { Text(formatSrtTime(s.duration).substring(0, 8), style: const TextStyle(fontSize: 12, color: Colors.white70, fontFamily: 'Consolas')), ]), - // 손가락: 재생 버튼 줄과 나머지 (음량 · 자막 · 목록 · 전체 화면) 줄을 나눠 넉넉하게 - if (touch) - Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ + // 손가락: 재생 버튼 줄과 나머지 (음량 · 자막 · 목록 · 전체 화면) 줄을 나눠 넉넉하게. + // P2 (관리자 지시 10/21, 28 찾음 - 82a87da 는 after_1020 에만 있어 다시): Windows 창 폭 412 에서 한 줄이 184px 넘쳐 + // 속도 · 자막 · 목록 · 전체 화면이 잘렸다 - 한 줄에 들지 않으면 마우스여도 두 줄로 (FitButtonRows). 단추는 기본 크기 그대로, + // 소리 막대만 남는 폭만큼 (최대 100) + FitButtonRows( + buttonWidth: touch ? 48 : 40, + forceSplit: touch, + first: [ btn(Icons.skip_previous, tr('이전 (P)'), () => pl.previous()), - 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)'), - () => pl.playOrPause(), size: 40), + () => pl.playOrPause(), size: touch ? 40 : 34), btn(Icons.stop, tr('정지'), () => pl.stop()), - 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()), - ]), - Row(children: [ - if (!touch) ...[ - btn(Icons.skip_previous, tr('이전 (P)'), () => pl.previous()), - if (!compact) btn(Icons.replay_10, tr('10초 뒤로 (←)'), () => _seekBy(-10)), - btn(s.playing ? Icons.pause_circle_filled : Icons.play_circle_fill, tr('재생/일시정지 (Space)'), - () => pl.playOrPause(), size: 34), - btn(Icons.stop, tr('정지'), () => pl.stop()), - if (!compact) btn(Icons.forward_10, tr('10초 앞으로 (→)'), () => _seekBy(10)), - btn(Icons.skip_next, tr('다음 (N)'), () => pl.next()), - const SizedBox(width: 8), ], - btn(s.volume == 0 ? Icons.volume_off : Icons.volume_up, tr('음소거 (M)'), _toggleMute), - if (!compact) - SizedBox( - width: 100, - child: Slider(value: s.volume.clamp(0, 100), max: 100, onChanged: (v) => pl.setVolume(v)), - ), - const Spacer(), - if (!compact) ...[ - _menuButton(Icons.speed, tr('재생 속도'), [ - for (final r in const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]) - (label: '${r}x', checked: s.rate == r, onTap: () => pl.setRate(r)), - ]), - _menuButton(Icons.subtitles, tr('자막 (S)'), [ - (label: tr('끄기'), checked: s.subtitleId == null, onTap: () => _setSub(null)), - for (final t in _subtitleChoices()) - (label: t.label, checked: s.subtitleId == t.id, onTap: () => _setSub(t)), - ]), - if (s.audioTracks.length > 1) - _menuButton(Icons.audiotrack, tr('음성'), [ - for (final t in s.audioTracks) - (label: t.label, checked: s.audioId == t.id, onTap: () => pl.setAudioTrack(t)), + secondLead: [btn(s.volume == 0 ? Icons.volume_off : Icons.volume_up, tr('음소거 (M)'), _toggleMute)], + flex: compact + ? null + : (w) => SizedBox(width: w, child: Slider(value: s.volume.clamp(0, 100), max: 100, onChanged: (v) => pl.setVolume(v))), + flexMin: 48, + flexMax: 100, + secondTail: [ + if (!compact) ...[ + _menuButton(Icons.speed, tr('재생 속도'), [ + for (final r in const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]) + (label: '${r}x', checked: s.rate == r, onTap: () => pl.setRate(r)), ]), - btn(Icons.playlist_play, tr('재생 목록 (L)'), () => setState(() => _showList = !_showList)), + _menuButton(Icons.subtitles, tr('자막 (S)'), [ + (label: tr('끄기'), checked: s.subtitleId == null, onTap: () => _setSub(null)), + for (final t in _subtitleChoices()) + (label: t.label, checked: s.subtitleId == t.id, onTap: () => _setSub(t)), + ]), + if (s.audioTracks.length > 1) + _menuButton(Icons.audiotrack, tr('음성'), [ + for (final t in s.audioTracks) + (label: t.label, checked: s.audioId == t.id, onTap: () => pl.setAudioTrack(t)), + ]), + btn(Icons.playlist_play, tr('재생 목록 (L)'), () => setState(() => _showList = !_showList)), + ], + if (!Platform.isAndroid) + btn(_popup ? Icons.close_fullscreen : Icons.picture_in_picture_alt, _popup ? tr('팝업 끄기') : tr('팝업 보기'), + _togglePopup), + btn(_full ? Icons.fullscreen_exit : Icons.fullscreen, tr('전체 화면 (F)'), _toggleFull), ], - if (!Platform.isAndroid) - btn(_popup ? Icons.close_fullscreen : Icons.picture_in_picture_alt, _popup ? tr('팝업 끄기') : tr('팝업 보기'), - _togglePopup), - btn(_full ? Icons.fullscreen_exit : Icons.fullscreen, tr('전체 화면 (F)'), _toggleFull), - ]), + ), ]), ); }, diff --git a/lib/ui/row_fit.dart b/lib/ui/row_fit.dart new file mode 100644 index 0000000..a171a05 --- /dev/null +++ b/lib/ui/row_fit.dart @@ -0,0 +1,98 @@ +/// 한 줄 · 두 줄 배치 판단 (관리자 지시 10/21: 화면마다 따로 재지 않고 이것 하나를 부른다 - 82a87da P2, after_1020 에서 옮김). +/// +/// 약속 ([fitRow] - 판단만, 그리지 않음) +/// - 입력: [maxWidth] 쓸 수 있는 폭, [first] · [second] 앞 · 뒤 묶음 아이템의 최소 폭 (단추는 기본 누를 자리 그대로), +/// [gap] 한 줄일 때 두 묶음 사이, 줄일 수 있는 아이템 하나의 폭 범위 [flexMin] ~ [flexMax] (없으면 0 · 0). +/// - 출력: [RowFit.split] 이면 두 줄 (위 = first, 아래 = second + 줄일 수 있는 아이템), [RowFit.flexWidth] 줄일 수 있는 아이템의 폭 +/// (0 이면 그 줄에 넣지 않는다 - 같은 일을 하는 다른 단추가 남아 있어야 한다). +/// - 오류: 없음. 두 줄로도 [second] 가 들지 않으면 split = true · flexWidth = 0 이고 [RowFit.overflow] 에 모자란 폭. +/// +/// 약속 ([FitButtonRows] - 그림): 앞 단추들 · 뒤 앞 단추들 · 줄일 수 있는 것 (폭을 받아 그림) · 뒤 끝 단추들 → 한 줄 +/// (앞 · 간격 · 뒤 앞 · 줄일 것 · 빈칸 · 뒤 끝) 또는 두 줄 (위 앞 단추 고르게, 아래 뒤 묶음). 단추 크기는 줄이지 않는다. +/// 쓰는 곳: 재생 화면 조절 줄 (P2) +library; + +import 'package:flutter/material.dart'; + +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 first, + required List second, + double gap = 0, + double flexMin = 0, + double flexMax = 0, + bool forceSplit = false, +}) { + double sum(List 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); +} + +class FitButtonRows extends StatelessWidget { + const FitButtonRows({ + super.key, + required this.first, + required this.secondLead, + required this.secondTail, + required this.buttonWidth, + this.flex, + this.flexMin = 0, + this.flexMax = 0, + this.gap = 8, + this.forceSplit = false, + }); + + /// 위 줄 (두 줄일 때) · 앞 (한 줄일 때) 단추들 + final List first; + + /// 뒤 묶음의 앞 (줄일 것 앞) · 끝 (빈칸 뒤) 단추들 + final List secondLead, secondTail; + + /// 단추 하나의 누를 자리 (판단에 쓰는 폭) + final double buttonWidth; + + /// 줄일 수 있는 것 (받은 폭으로 그림 - 예: 소리 막대). 폭이 0 이면 넣지 않는다 + final Widget Function(double width)? flex; + final double flexMin, flexMax, gap; + + /// 늘 두 줄 (손가락 화면) + final bool forceSplit; + + @override + Widget build(BuildContext context) => LayoutBuilder(builder: (context, box) { + final fit = fitRow( + maxWidth: box.maxWidth, + first: List.filled(first.length, buttonWidth), + second: List.filled(secondLead.length + secondTail.length, buttonWidth), + gap: gap, + flexMin: flex == null ? 0 : flexMin, + flexMax: flex == null ? 0 : flexMax, + forceSplit: forceSplit, + ); + final rest = [ + ...secondLead, + if (flex != null && fit.flexWidth > 0) flex!(fit.flexWidth), + const Spacer(), + ...secondTail, + ]; + return Column(mainAxisSize: MainAxisSize.min, children: [ + if (fit.split) Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: first), + Row(children: [ + if (!fit.split) ...[...first, SizedBox(width: gap)], + ...rest, + ]), + ]); + }); +} diff --git a/test/explorer_flows_contract_test.dart b/test/explorer_flows_contract_test.dart index d6ffea4..b10da0a 100644 --- a/test/explorer_flows_contract_test.dart +++ b/test/explorer_flows_contract_test.dart @@ -133,7 +133,8 @@ void main() { await settle(t); await t.runAsync(() => f.opener.playFolder(f.host.context, out)); await t.pump(); - expect(find.text('이 폴더에 동영상이 없습니다.'), findsWidgets); + // 82a87da (재생 목록 모듈): 동영상이 없으면 소리 파일도 트므로 알림 글이 "동영상 · 소리 파일" 로 바뀜 (틀 것이 없으면 알림은 그대로) + expect(find.text('이 폴더에 재생할 동영상 · 소리 파일이 없습니다.'), findsWidgets); expect(ExplorerOpener.noAppMessage(ep1), contains('내장 플레이어')); expect(ExplorerOpener.noAppMessage(note), isNot(contains('내장 플레이어'))); }); diff --git a/test/player_test.dart b/test/player_test.dart index b39e530..ee53abb 100644 --- a/test/player_test.dart +++ b/test/player_test.dart @@ -122,6 +122,13 @@ void main() { expect(j, 1); final (all, _) = buildPlaylist(f, files, PlaylistMode.folder); expect(all, hasLength(7)); // 자막·텍스트 제외 + // 재생 목록 모듈 약속 (관리자 지시 10/21, 82a87da): 형제는 고른 파일과 같은 종류만 - 소리를 고르면 소리끼리, 동영상이면 동영상끼리 + 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 { diff --git a/test/row_fit_contract_test.dart b/test/row_fit_contract_test.dart new file mode 100644 index 0000000..2fc582d --- /dev/null +++ b/test/row_fit_contract_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/ui/row_fit.dart'; + +/// 약속 (row_fit - 82a87da P2 에서 옮김): fitRow 는 판단만 (나눌지 · 줄일 것의 폭 · 모자란 폭), FitButtonRows 는 그 판단대로 그린다 - +/// 단추 크기는 줄이지 않고, 한 줄에 안 들면 두 줄, 줄일 것은 남는 폭 (최대 flexMax, flexMin 보다 작으면 뺌). 모듈 하나에 한 번만 둔다 +void main() { + test('fitRow: 넉넉하면 한 줄 · 줄일 것은 최대 폭', () { + final r = fitRow(maxWidth: 960, first: List.filled(6, 40), second: List.filled(6, 40), gap: 8, flexMin: 48, flexMax: 100); + expect([r.split, r.flexWidth, r.overflow], [false, 100, 0]); + }); + + test('fitRow: 412 는 두 줄 · 줄일 것은 아래 줄의 남는 폭', () { + final r = fitRow(maxWidth: 412, first: List.filled(6, 40), second: List.filled(6, 40), gap: 8, flexMin: 48, flexMax: 100); + expect([r.split, r.flexWidth, r.overflow], [true, 100, 0]); + }); + + test('fitRow: 남는 폭이 flexMin 보다 작으면 줄일 것을 뺌 · 두 줄로도 안 들면 모자란 폭', () { + final r = fitRow(maxWidth: 260, first: List.filled(6, 40), second: List.filled(6, 40), flexMin: 48, flexMax: 100); + expect([r.split, r.flexWidth], [true, 0]); + final over = fitRow(maxWidth: 200, first: List.filled(6, 40), second: List.filled(2, 40)); + expect([over.split, over.overflow], [true, 40]); + }); + + test('fitRow: forceSplit (손가락 화면) 이면 넓어도 두 줄', () { + expect(fitRow(maxWidth: 2000, first: [48], second: [48], forceSplit: true).split, isTrue); + }); + + for (final width in [412.0, 600.0, 960.0]) { + testWidgets('FitButtonRows $width: 넘치지 않고 끝 단추가 화면 안 · 단추 크기 그대로', (t) async { + t.view.physicalSize = Size(width, 400); + t.view.devicePixelRatio = 1; + addTearDown(t.view.reset); + Widget b(IconData i, String tip) => IconButton(tooltip: tip, icon: Icon(i), onPressed: () {}); + await t.pumpWidget(MaterialApp( + home: Scaffold( + body: Align( + alignment: Alignment.bottomCenter, + child: FitButtonRows( + buttonWidth: 48, + first: [for (var i = 0; i < 6; i++) b(Icons.play_arrow, '재생 $i')], + secondLead: [b(Icons.volume_up, '음소거')], + flex: (w) => SizedBox(width: w, child: Slider(value: 50, max: 100, onChanged: (_) {})), + flexMin: 48, + flexMax: 100, + secondTail: [for (final n in ['속도', '자막', '목록', '팝업', '전체 화면']) b(Icons.more_horiz, n)], + ), + ), + ), + )); + expect(t.takeException(), isNull); + final full = t.getRect(find.byTooltip('전체 화면')); + expect(full.right, lessThanOrEqualTo(width)); + expect(full.width, greaterThanOrEqualTo(40), reason: '단추를 줄이지 않음'); + final twoRows = t.getRect(find.byTooltip('재생 0')).top < t.getRect(find.byTooltip('음소거')).top - 10; + expect(twoRows, width < 6 * 48 + 8 + 6 * 48 + 100, reason: '한 줄에 안 들 때만 두 줄'); + }); + } +}