WIP X-plore 맞추기 (관리자 지시 10/20): 보기 화면 볼륨 키로 넘기기 (아래 = 다음 장, 위 = 이전 장, Android) - 처음 끔 (환경 설정 > 보기), 켰을 때만 보기 화면에서 가로채고 (처리한 키는 시스템 볼륨을 바꾸지 않음) 동영상 · 다른 화면은 그대로 · 시험 (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xplore_view
RyuJongJin 2026-10-20 21:29:55 +09:00
parent 99de4f4bc9
commit ed24463192
7 changed files with 75 additions and 2 deletions

View File

@ -1411,6 +1411,7 @@
"보기": "View", "보기": "View",
"보기 {0}초 · Ctrl+휠 확대/축소": "View {0} s · Ctrl+wheel to zoom", "보기 {0}초 · Ctrl+휠 확대/축소": "View {0} s · Ctrl+wheel to zoom",
"보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. 새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다": "Shows [Improve quality] in the viewer: refines with AI image (image → image, light) and then upscales. Saves a new file (name_q); needs the AI image model", "보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. 새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다": "Shows [Improve quality] in the viewer: refines with AI image (image → image, light) and then upscales. Saves a new file (name_q); needs the AI image model",
"보기 화면에서 볼륨 아래 = 다음 장, 위 = 이전 장 (동영상 · 다른 화면의 볼륨은 그대로)": "In the viewer, volume down = next, volume up = previous (videos and other screens keep normal volume keys)",
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "How often [Slideshow] in the viewer turns to the next page", "보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "How often [Slideshow] in the viewer turns to the next page",
"보내지 못했습니다": "Could not send", "보내지 못했습니다": "Could not send",
"보낼 것 {0} / 있는 것 {1}": "sending {0} / existing {1}", "보낼 것 {0} / 있는 것 {1}": "sending {0} / existing {1}",
@ -1461,6 +1462,7 @@
"복사했습니다.": "copied.", "복사했습니다.": "copied.",
"볼 그림이 없습니다.": "No images to show.", "볼 그림이 없습니다.": "No images to show.",
"볼 수 없는 영상입니다 (삭제 · 지역 제한).": "This video is unavailable (deleted · region restricted).", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "This video is unavailable (deleted · region restricted).",
"볼륨 키로 넘기기": "Turn pages with volume keys",
"분석": "Analyze", "분석": "Analyze",
"분석 실패: {0} - {1}": "Analysis failed: {0} - {1}", "분석 실패: {0} - {1}": "Analysis failed: {0} - {1}",
"분석에 실패했던 동영상 {0}개를 다시 분석했습니다": "Re-analyzed {0} videos that had failed", "분석에 실패했던 동영상 {0}개를 다시 분석했습니다": "Re-analyzed {0} videos that had failed",

View File

@ -1411,6 +1411,7 @@
"보기": "表示", "보기": "表示",
"보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小", "보기 {0}초 · Ctrl+휠 확대/축소": "表示 {0}秒・Ctrl+ホイールで拡大/縮小",
"보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. 새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다": "ビューアーに [画質を上げる]: AI 画像 (画像 → 画像、弱め) で整えてから解像度を上げます。新しいファイル (名前_q) に保存し、AI 画像モデルが必要です", "보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. 새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다": "ビューアーに [画質を上げる]: AI 画像 (画像 → 画像、弱め) で整えてから解像度を上げます。新しいファイル (名前_q) に保存し、AI 画像モデルが必要です",
"보기 화면에서 볼륨 아래 = 다음 장, 위 = 이전 장 (동영상 · 다른 화면의 볼륨은 그대로)": "ビューアーで音量下 = 次、音量上 = 前 (動画・ほかの画面の音量はそのまま)",
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "表示画面の [連続表示] が次のページへ進む間隔", "보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "表示画面の [連続表示] が次のページへ進む間隔",
"보내지 못했습니다": "送信できませんでした", "보내지 못했습니다": "送信できませんでした",
"보낼 것 {0} / 있는 것 {1}": "送る方 {0} / 既存 {1}", "보낼 것 {0} / 있는 것 {1}": "送る方 {0} / 既存 {1}",
@ -1461,6 +1462,7 @@
"복사했습니다.": "コピーしました。", "복사했습니다.": "コピーしました。",
"볼 그림이 없습니다.": "表示する画像がありません。", "볼 그림이 없습니다.": "表示する画像がありません。",
"볼 수 없는 영상입니다 (삭제 · 지역 제한).": "視聴できない動画です(削除・地域制限)。", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "視聴できない動画です(削除・地域制限)。",
"볼륨 키로 넘기기": "音量キーでページ送り",
"분석": "分析", "분석": "分析",
"분석 실패: {0} - {1}": "分析失敗: {0} - {1}", "분석 실패: {0} - {1}": "分析失敗: {0} - {1}",
"분석에 실패했던 동영상 {0}개를 다시 분석했습니다": "分析に失敗していた動画 {0} 件を再分析しました", "분석에 실패했던 동영상 {0}개를 다시 분석했습니다": "分析に失敗していた動画 {0} 件を再分析しました",

View File

@ -1411,6 +1411,7 @@
"보기": "查看", "보기": "查看",
"보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放", "보기 {0}초 · Ctrl+휠 확대/축소": "显示 {0} 秒 · Ctrl+滚轮缩放",
"보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. 새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다": "在查看器中显示 [提升画质]: 先用 AI 图片 (图生图,轻度) 优化再提高分辨率。保存为新文件 (名称_q),需要 AI 图片模型", "보기 화면에 [품질 올리기]: AI 그림 (그림 → 그림, 약하게) 으로 다듬은 뒤 해상도 올리기를 이어서. 새 파일 (이름_q) 로 저장하며 AI 그림 모델이 필요합니다": "在查看器中显示 [提升画质]: 先用 AI 图片 (图生图,轻度) 优化再提高分辨率。保存为新文件 (名称_q),需要 AI 图片模型",
"보기 화면에서 볼륨 아래 = 다음 장, 위 = 이전 장 (동영상 · 다른 화면의 볼륨은 그대로)": "在查看器中音量减 = 下一张, 音量加 = 上一张 (视频和其他界面的音量不变)",
"보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "浏览画面中 [连续浏览] 翻到下一页的间隔", "보기 화면의 [계속 보기] 가 다음 장으로 넘기는 간격": "浏览画面中 [连续浏览] 翻到下一页的间隔",
"보내지 못했습니다": "无法发送", "보내지 못했습니다": "无法发送",
"보낼 것 {0} / 있는 것 {1}": "要发送 {0} / 已有 {1}", "보낼 것 {0} / 있는 것 {1}": "要发送 {0} / 已有 {1}",
@ -1461,6 +1462,7 @@
"복사했습니다.": "已复制。", "복사했습니다.": "已复制。",
"볼 그림이 없습니다.": "没有可显示的图片。", "볼 그림이 없습니다.": "没有可显示的图片。",
"볼 수 없는 영상입니다 (삭제 · 지역 제한).": "该视频无法观看(已删除 · 地区限制)。", "볼 수 없는 영상입니다 (삭제 · 지역 제한).": "该视频无法观看(已删除 · 地区限制)。",
"볼륨 키로 넘기기": "用音量键翻页",
"분석": "分析", "분석": "分析",
"분석 실패: {0} - {1}": "分析失败: {0} - {1}", "분석 실패: {0} - {1}": "分析失败: {0} - {1}",
"분석에 실패했던 동영상 {0}개를 다시 분석했습니다": "已重新分析之前分析失败的 {0} 个视频", "분석에 실패했던 동영상 {0}개를 다시 분석했습니다": "已重新分析之前分析失败的 {0} 个视频",

View File

@ -558,6 +558,10 @@ class AppSettings {
/// X-plore 맞추기 (관리자 지시 10/20): 그림 보기 화면 아래 갤러리 줄 (썸네일, 누르면 그 장으로). 처음 끔 /// X-plore 맞추기 (관리자 지시 10/20): 그림 보기 화면 아래 갤러리 줄 (썸네일, 누르면 그 장으로). 처음 끔
bool readerGallery = false; bool readerGallery = false;
/// X-plore 맞추기 (관리자 지시 10/20): 보기 화면에서 볼륨 키로 넘기기 (아래 = 다음 장, 위 = 이전 장, Android). 처음 끔.
/// 보기 화면에서만 가로챈다 (동영상 · 다른 화면의 볼륨은 그대로)
bool readerVolumeKeys = false;
/// 119: 한 장에 두 쪽이 붙은 그림을 반씩 나눠 보기 - 'auto' 가로가 세로보다 긴 그림만 · 'on' 늘 · 'off' 안 함 /// 119: 한 장에 두 쪽이 붙은 그림을 반씩 나눠 보기 - 'auto' 가로가 세로보다 긴 그림만 · 'on' 늘 · 'off' 안 함
String readerSplit = 'auto'; String readerSplit = 'auto';
@ -871,6 +875,7 @@ class AppSettings {
'readerSystemBars': readerSystemBars, 'readerSystemBars': readerSystemBars,
'readerAutoSeconds': readerAutoSeconds, 'readerAutoSeconds': readerAutoSeconds,
'readerGallery': readerGallery, 'readerGallery': readerGallery,
'readerVolumeKeys': readerVolumeKeys,
'readerSplit': readerSplit, 'readerSplit': readerSplit,
'startMenuShortcut': startMenuShortcut, 'startMenuShortcut': startMenuShortcut,
'syncStopToast': syncStopToast, 'syncStopToast': syncStopToast,
@ -1127,6 +1132,7 @@ class AppSettings {
..readerSystemBars = j['readerSystemBars'] != false ..readerSystemBars = j['readerSystemBars'] != false
..readerAutoSeconds = ((j['readerAutoSeconds'] as num?)?.toInt() ?? 3).clamp(1, 60) ..readerAutoSeconds = ((j['readerAutoSeconds'] as num?)?.toInt() ?? 3).clamp(1, 60)
..readerGallery = j['readerGallery'] as bool? ?? false ..readerGallery = j['readerGallery'] as bool? ?? false
..readerVolumeKeys = j['readerVolumeKeys'] as bool? ?? false
..readerSplit = const ['auto', 'on', 'off'].contains(j['readerSplit']) ? j['readerSplit'] as String : 'auto' ..readerSplit = const ['auto', 'on', 'off'].contains(j['readerSplit']) ? j['readerSplit'] as String : 'auto'
..startMenuShortcut = j['startMenuShortcut'] != false ..startMenuShortcut = j['startMenuShortcut'] != false
..syncStopToast = j['syncStopToast'] != false ..syncStopToast = j['syncStopToast'] != false

View File

@ -72,6 +72,15 @@ class ViewerSettings extends StatelessWidget {
title: Text(tr('그림 보기 아래 갤러리 줄')), title: Text(tr('그림 보기 아래 갤러리 줄')),
subtitle: Text(tr('그림을 볼 때 아래에 작은 그림 줄을 보입니다 (누르면 그 장으로)')), subtitle: Text(tr('그림을 볼 때 아래에 작은 그림 줄을 보입니다 (누르면 그 장으로)')),
), ),
// X-plore 맞추기 (관리자 지시 10/20): 볼륨 키로 넘기기 (Android)
if (Platform.isAndroid)
SwitchListTile(
secondary: const Icon(Icons.volume_down_outlined),
value: s.readerVolumeKeys,
onChanged: (v) => c.updateSettings((x) => x.readerVolumeKeys = v),
title: Text(tr('볼륨 키로 넘기기')),
subtitle: Text(tr('보기 화면에서 볼륨 아래 = 다음 장, 위 = 이전 장 (동영상 · 다른 화면의 볼륨은 그대로)')),
),
// 117: 계속 보기 간격 // 117: 계속 보기 간격
SettingTile( SettingTile(
leading: const Icon(Icons.slideshow_outlined), leading: const Icon(Icons.slideshow_outlined),

View File

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io' show Platform;
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -50,6 +51,9 @@ class ReaderPage extends StatefulWidget {
final bool autoPlay; final bool autoPlay;
const ReaderPage({super.key, required this.c, required this.source, this.start = 0, this.autoPlay = false}); const ReaderPage({super.key, required this.c, required this.source, this.start = 0, this.autoPlay = false});
/// 볼륨 키로 넘기기를 쓰는 기기인지 (Android - 시험에서 바꿈)
static bool Function() volumeKeysSupported = () => Platform.isAndroid;
/// 계속 보기 동안 화면 켜 두기 (시험에서 바꿈) /// 계속 보기 동안 화면 켜 두기 (시험에서 바꿈)
static Future<void> Function(bool on) keepScreenOn = (on) => on ? WakelockPlus.enable() : WakelockPlus.disable(); static Future<void> Function(bool on) keepScreenOn = (on) => on ? WakelockPlus.enable() : WakelockPlus.disable();
@ -412,6 +416,15 @@ class _ReaderPageState extends State<ReaderPage> {
_jumpLater(); _jumpLater();
} }
/// X-plore 맞추기 (관리자 지시 10/20): 볼륨 키로 넘기기 (설정을 켰고 Android 일 때만 - 처리한 키는 시스템 볼륨을 바꾸지 않는다)
KeyEventResult _onKey(FocusNode _, KeyEvent e) {
if (!c.settings.readerVolumeKeys || !ReaderPage.volumeKeysSupported()) return KeyEventResult.ignored;
final k = e.logicalKey;
if (k != LogicalKeyboardKey.audioVolumeDown && k != LogicalKeyboardKey.audioVolumeUp) return KeyEventResult.ignored;
if (e is KeyDownEvent || e is KeyRepeatEvent) _go(k == LogicalKeyboardKey.audioVolumeDown ? 1 : -1);
return KeyEventResult.handled;
}
void _go(int delta) { void _go(int delta) {
final to = (_index + delta).clamp(0, math.max(0, _v.length - 1)).toInt(); final to = (_index + delta).clamp(0, math.max(0, _v.length - 1)).toInt();
if (to == _index) { if (to == _index) {
@ -933,7 +946,10 @@ class _ReaderPageState extends State<ReaderPage> {
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
}, },
child: Scaffold( child: Focus(
autofocus: true,
onKeyEvent: _onKey,
child: Scaffold(
backgroundColor: Colors.black, backgroundColor: Colors.black,
body: LayoutBuilder(builder: (context, box) { body: LayoutBuilder(builder: (context, box) {
// 화면 너비 (기기 픽셀) 에 맞춰 그림을 줄여 읽는다 // 화면 너비 (기기 픽셀) 에 맞춰 그림을 줄여 읽는다
@ -977,6 +993,7 @@ class _ReaderPageState extends State<ReaderPage> {
], ],
]); ]);
}), }),
),
), ),
); );
} }

View File

@ -1,8 +1,8 @@
import 'dart:io'; import 'dart:io';
import 'dart:typed_data';
import 'package:archive/archive_io.dart'; import 'package:archive/archive_io.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:jj_mkvmaker/app/app_controller.dart'; import 'package:jj_mkvmaker/app/app_controller.dart';
@ -215,6 +215,41 @@ void main() {
await tester.pumpWidget(const SizedBox()); await tester.pumpWidget(const SizedBox());
}); });
testWidgets('X-plore 맞추기: 볼륨 키로 넘기기 - 처음은 끔 (키를 가로채지 않음), 켜면 아래 = 다음 · 위 = 이전 (Android 만)', (tester) async {
tester.view.physicalSize = const Size(900, 1200);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.reset);
final was = ReaderPage.volumeKeysSupported;
addTearDown(() => ReaderPage.volumeKeysSupported = was);
ReaderPage.volumeKeysSupported = () => true;
expect(AppSettings().readerVolumeKeys, isFalse);
final files = [
for (final (i, n) in ['a.png', 'b.png', 'c.png'].indexed) (File(p.join(tmp.path, n))..writeAsBytesSync(_png(i * 80, 0, 0))).path,
];
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
await tester.pumpWidget(MaterialApp(home: ReaderPage(c: c, source: ImageFilesSource(files, tempDir: tmp.path, title: 'imgs'))));
await tester.pump();
// 끔: 볼륨 키를 가로채지 않는다 (시스템 볼륨이 바뀜)
expect(await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeDown), isFalse);
await tester.pumpAndSettle();
expect(find.text('1 / 3'), findsOneWidget);
// 켬
c.settings.readerVolumeKeys = true;
expect(await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeDown), isTrue, reason: '처리함 - 시스템 볼륨은 바뀌지 않음');
await tester.pumpAndSettle();
expect(find.text('2 / 3'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeDown);
await tester.pumpAndSettle();
expect(find.text('3 / 3'), findsOneWidget);
await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeUp);
await tester.pumpAndSettle();
expect(find.text('2 / 3'), findsOneWidget);
// Android 가 아니면 쓰지 않음
ReaderPage.volumeKeysSupported = () => false;
expect(await tester.sendKeyEvent(LogicalKeyboardKey.audioVolumeDown), isFalse);
await tester.pumpWidget(const SizedBox());
});
testWidgets('그림이 깨졌으면 화면 글 + [자세히], 파일을 고친 뒤 [다시 시도] 하면 열린다 (28 지적: 같은 키 · 같은 그림이라 Image 가 다시 읽지 않았다 - 장의 키를 올리고 그림 캐시도 비움)', (tester) async { testWidgets('그림이 깨졌으면 화면 글 + [자세히], 파일을 고친 뒤 [다시 시도] 하면 열린다 (28 지적: 같은 키 · 같은 그림이라 Image 가 다시 읽지 않았다 - 장의 키를 올리고 그림 캐시도 비움)', (tester) async {
tester.view.physicalSize = const Size(900, 1200); tester.view.physicalSize = const Size(900, 1200);
tester.view.devicePixelRatio = 1; tester.view.devicePixelRatio = 1;