diff --git a/assets/l10n/en.json b/assets/l10n/en.json index ccfbbdd..0b77140 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -800,11 +800,13 @@ "그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "Images · comic reading, PDF view · page editing, ZIP list · comic reading", "그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "Images and prompts leave this device ({0})", "그림 보기 대상": "Image viewer file types", + "그림 보기 아래 갤러리 줄": "Gallery strip in the image viewer", "그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식": "Can't read the image file - damaged or unsupported format", "그림으로 바꾸는 중": "Turning into an image", "그림을 만들지 못했습니다: {0}": "Couldn't make the image: {0}", "그림을 바꿔 만들기": "Make from this picture", "그림을 받을 수 없습니다 ({0})": "Couldn't get the image ({0})", + "그림을 볼 때 아래에 작은 그림 줄을 보입니다 (누르면 그 장으로)": "Shows a row of thumbnails at the bottom while viewing images (tap one to jump to it)", "그림을 열 수 없습니다": "Can't open the image", "그림을 올릴 수 없습니다 ({0})": "Couldn't upload the image ({0})", "그림을 읽을 수 없습니다": "Cannot read the image", @@ -1732,6 +1734,8 @@ "쓸 수 없는 이름": "Invalid name", "쓸 수 없는 이름입니다": "This name cannot be used", "아랍어": "Arabic", + "아래 갤러리 줄 보이기": "Show the gallery strip", + "아래 갤러리 줄 숨기기": "Hide the gallery strip", "아래 프로그램이 없어 일부 기능을 쓸 수 없습니다. 공식 배포처에서 내려받아 프로그램 폴더에 설치할까요?": "Some features are unavailable because the programs below are missing. Download them from the official sources and install them in the program folder?", "아래로": "Down", "아래에 줄 추가": "Add line below", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index c8877da..2808cb3 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -800,11 +800,13 @@ "그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "画像・マンガ表示、PDF 表示・ページ編集、ZIP 一覧・マンガ表示", "그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "画像・プロンプトが端末の外に送られます ({0})", "그림 보기 대상": "画像表示の対象", + "그림 보기 아래 갤러리 줄": "画像ビューアー下のギャラリー列", "그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식": "画像ファイルを読み込めません - 破損しているか未対応の形式", "그림으로 바꾸는 중": "画像に変換中", "그림을 만들지 못했습니다: {0}": "画像を作成できませんでした: {0}", "그림을 바꿔 만들기": "画像を変えて作成", "그림을 받을 수 없습니다 ({0})": "画像を受け取れません ({0})", + "그림을 볼 때 아래에 작은 그림 줄을 보입니다 (누르면 그 장으로)": "画像を見るとき下にサムネイルの列を表示します (タップでその画像へ)", "그림을 열 수 없습니다": "画像を開けません", "그림을 올릴 수 없습니다 ({0})": "画像をアップロードできません ({0})", "그림을 읽을 수 없습니다": "画像を読み込めません", @@ -1732,6 +1734,8 @@ "쓸 수 없는 이름": "使えない名前", "쓸 수 없는 이름입니다": "使用できない名前です", "아랍어": "アラビア語", + "아래 갤러리 줄 보이기": "下のギャラリー列を表示", + "아래 갤러리 줄 숨기기": "下のギャラリー列を隠す", "아래 프로그램이 없어 일부 기능을 쓸 수 없습니다. 공식 배포처에서 내려받아 프로그램 폴더에 설치할까요?": "以下のプログラムがないため一部の機能が使えません。公式配布元からダウンロードしてプログラムフォルダーにインストールしますか?", "아래로": "下へ", "아래에 줄 추가": "下に行を追加", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index f1f3c36..a2f68c3 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -800,11 +800,13 @@ "그림 · 만화 보기, PDF 보기 · 페이지 편집, ZIP 목록 · 만화 보기": "图片 · 漫画阅读,PDF 查看 · 页面编辑,ZIP 列表 · 漫画阅读", "그림 · 프롬프트가 기기 밖으로 나갑니다 ({0})": "图片和提示词将离开本设备 ({0})", "그림 보기 대상": "图片查看的文件类型", + "그림 보기 아래 갤러리 줄": "图片查看器底部图库条", "그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식": "无法读取图片文件 - 已损坏或格式不受支持", "그림으로 바꾸는 중": "正在生成图像", "그림을 만들지 못했습니다: {0}": "无法生成图片: {0}", "그림을 바꿔 만들기": "基于图片生成", "그림을 받을 수 없습니다 ({0})": "无法获取图片 ({0})", + "그림을 볼 때 아래에 작은 그림 줄을 보입니다 (누르면 그 장으로)": "查看图片时在底部显示缩略图条 (点按可跳到该图)", "그림을 열 수 없습니다": "无法打开图片", "그림을 올릴 수 없습니다 ({0})": "无法上传图片 ({0})", "그림을 읽을 수 없습니다": "无法读取图片", @@ -1732,6 +1734,8 @@ "쓸 수 없는 이름": "无效名称", "쓸 수 없는 이름입니다": "不能使用此名称", "아랍어": "阿拉伯语", + "아래 갤러리 줄 보이기": "显示底部图库条", + "아래 갤러리 줄 숨기기": "隐藏底部图库条", "아래 프로그램이 없어 일부 기능을 쓸 수 없습니다. 공식 배포처에서 내려받아 프로그램 폴더에 설치할까요?": "由于缺少以下程序,部分功能无法使用。要从官方来源下载并安装到程序文件夹吗?", "아래로": "向下", "아래에 줄 추가": "在下方添加一行", diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 5ccbb90..4cf8a82 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -555,6 +555,9 @@ class AppSettings { /// 117: 보기 화면 "계속 보기" 간격 (초, 1 ~ 60) int readerAutoSeconds = 3; + /// X-plore 맞추기 (관리자 지시 10/20): 그림 보기 화면 아래 갤러리 줄 (썸네일, 누르면 그 장으로). 처음 끔 + bool readerGallery = false; + /// 119: 한 장에 두 쪽이 붙은 그림을 반씩 나눠 보기 - 'auto' 가로가 세로보다 긴 그림만 · 'on' 늘 · 'off' 안 함 String readerSplit = 'auto'; @@ -867,6 +870,7 @@ class AppSettings { 'readerBrightness': readerBrightness, 'readerSystemBars': readerSystemBars, 'readerAutoSeconds': readerAutoSeconds, + 'readerGallery': readerGallery, 'readerSplit': readerSplit, 'startMenuShortcut': startMenuShortcut, 'syncStopToast': syncStopToast, @@ -1122,6 +1126,7 @@ class AppSettings { ..readerBrightness = ((j['readerBrightness'] as num?)?.toDouble() ?? 0).clamp(-0.7, 0.7) ..readerSystemBars = j['readerSystemBars'] != false ..readerAutoSeconds = ((j['readerAutoSeconds'] as num?)?.toInt() ?? 3).clamp(1, 60) + ..readerGallery = j['readerGallery'] as bool? ?? false ..readerSplit = const ['auto', 'on', 'off'].contains(j['readerSplit']) ? j['readerSplit'] as String : 'auto' ..startMenuShortcut = j['startMenuShortcut'] != false ..syncStopToast = j['syncStopToast'] != false diff --git a/lib/ui/component_settings.dart b/lib/ui/component_settings.dart index 98abc12..2833a2b 100644 --- a/lib/ui/component_settings.dart +++ b/lib/ui/component_settings.dart @@ -64,6 +64,14 @@ class ViewerSettings extends StatelessWidget { onChanged: (v) => c.updateSettings((x) => x.readerSplit = v!), ), ), + // X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 (보기 화면 위 막대에서도 켜고 끔) + SwitchListTile( + secondary: const Icon(Icons.view_carousel_outlined), + value: s.readerGallery, + onChanged: (v) => c.updateSettings((x) => x.readerGallery = v), + title: Text(tr('그림 보기 아래 갤러리 줄')), + subtitle: Text(tr('그림을 볼 때 아래에 작은 그림 줄을 보입니다 (누르면 그 장으로)')), + ), // 117: 계속 보기 간격 SettingTile( leading: const Icon(Icons.slideshow_outlined), diff --git a/lib/ui/reader_page.dart b/lib/ui/reader_page.dart index 27750a7..41f6f67 100644 --- a/lib/ui/reader_page.dart +++ b/lib/ui/reader_page.dart @@ -71,6 +71,12 @@ class _ReaderPageState extends State { /// PDF 는 페이지를 지우거나 넣으면 다시 그린다 int _version = 0; + /// X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 - 그림 원본 (폴더 · ZIP) 에서만 + bool get _pictures => src is ImageFilesSource || src is ZipImagesSource; + final _thumbs = >{}; + final _gallery = ScrollController(); + static const _thumbExtent = 64.0; + /// [다시 시도] 를 누른 횟수 (장마다) - 그 장의 화면을 새로 만든다 (28 지적: 같은 키면 실패한 그림을 그대로 다시 썼다) final _retry = {}; @@ -344,6 +350,10 @@ class _ReaderPageState extends State { @override void dispose() { + for (final f in _thumbs.values) { + unawaited(f.then((x) => x.dispose(), onError: (_) {})); + } + _gallery.dispose(); _life.dispose(); _timer?.cancel(); if (_playing) unawaited(ReaderPage.keepScreenOn(false).catchError((_) {})); @@ -653,6 +663,10 @@ class _ReaderPageState extends State { one(_Act(const Icon(Icons.delete_outline), tr('이 페이지 지우기'), _deletePage)), one(_Act(const Icon(Icons.add_photo_alternate_outlined), tr('그림을 페이지로 넣기 (이 페이지 뒤)'), _insertImages)), ], + // X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 켜고 끄기 (그림 원본만) + if (_pictures) + one(_Act(const Icon(Icons.view_carousel_outlined), s.readerGallery ? tr('아래 갤러리 줄 숨기기') : tr('아래 갤러리 줄 보이기'), + _toggleGallery, on: s.readerGallery)), // 119 · 120 one(_Act(const Icon(Icons.vertical_split_outlined), split, _cycleSplit, on: s.readerSplit != 'off')), one(_Act(const Icon(Icons.rotate_right), tr('90° 회전 (시계 방향)'), () => _rotate(1), on: _turns != 0)), @@ -810,13 +824,72 @@ class _ReaderPageState extends State { /// 쪽 번호: 통째면 "12", 반쪽이면 "12-1" · "12-2" String _label(ReaderSlot s) => s.part < 0 ? '${s.page + 1}' : '${s.page + 1}-${s.part + 1}'; + Future _toggleGallery() async { + await c.updateSettings((x) => x.readerGallery = !x.readerGallery); + if (!mounted) return; + setState(() {}); + WidgetsBinding.instance.addPostFrameCallback((_) => _scrollGallery(animate: false)); + } + + /// [page] 번째 장 (파일) 으로 (두 쪽 나눠 보고 있으면 그 장의 첫 쪽) + void _goPage(int page) { + final i = _v.indexWhere((e) => e.page == page); + if (i >= 0 && _pages.hasClients) _pages.jumpToPage(i); + } + + /// 갤러리 줄을 지금 장이 가운데 오게 + void _scrollGallery({bool animate = true}) { + if (!_gallery.hasClients) return; + final pos = _gallery.position; + final to = (_cur.page * _thumbExtent - pos.viewportDimension / 2 + _thumbExtent / 2).clamp(0.0, pos.maxScrollExtent); + animate ? _gallery.animateTo(to, duration: const Duration(milliseconds: 200), curve: Curves.easeOut) : _gallery.jumpTo(to); + } + + Future _thumb(int i) => _thumbs.putIfAbsent(i, () => src.load(i, maxWidth: 160)); + + Widget _galleryStrip() => SizedBox( + height: 76, + child: ListView.builder( + controller: _gallery, + scrollDirection: Axis.horizontal, + reverse: c.settings.readerRtl, + itemExtent: _thumbExtent, + itemCount: src.length, + itemBuilder: (_, i) => GestureDetector( + key: ValueKey('thumb$i'), + onTap: () => _goPage(i), + child: Container( + margin: const EdgeInsets.all(3), + decoration: BoxDecoration( + border: Border.all(color: i == _cur.page ? JjColors.accent : Colors.white24, width: i == _cur.page ? 2 : 1)), + child: FutureBuilder( + future: _thumb(i), + builder: (_, snap) { + final r = snap.data; + if (snap.hasError) return const Icon(Icons.broken_image_outlined, color: Colors.white38, size: 20); + if (r == null) return const SizedBox(); + return r.image != null + ? RawImage(image: r.image, fit: BoxFit.cover) + : Image( + image: r.provider!, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const Icon(Icons.broken_image_outlined, color: Colors.white38, size: 20)); + }, + ), + ), + ), + ), + ); + Widget _bottomBar() { final n = src.length; final count = _v.length; return Container( color: _barColor, padding: EdgeInsets.only(bottom: MediaQuery.paddingOf(context).bottom, left: 12, right: 12), - child: Row(children: [ + child: Column(mainAxisSize: MainAxisSize.min, children: [ + if (_pictures && c.settings.readerGallery && n > 0) _galleryStrip(), + Row(children: [ // 125: 반으로 나눈 쪽이 있으면 전체 수가 파일 수 (장) 임을 단위로 ("2-1 / 3장") Text( count != n ? trf('{0} / {1}장', [_label(_cur), n]) : '${n == 0 ? 0 : _label(_cur)} / $n', @@ -843,6 +916,7 @@ class _ReaderPageState extends State { textAlign: TextAlign.end, style: const TextStyle(color: Colors.white70, fontSize: 12)), ), + ]), ]), ); } @@ -883,11 +957,14 @@ class _ReaderPageState extends State { // 확대해 보는 동안은 밀어도 넘기지 않는다 (그림 옮기기) physics: _holdPage ? const NeverScrollableScrollPhysics() : null, itemCount: _v.length, - onPageChanged: (i) => setState(() { - _index = i; - _resetZoom(); - _trim(); - }), + onPageChanged: (i) { + setState(() { + _index = i; + _resetZoom(); + _trim(); + }); + _scrollGallery(); + }, itemBuilder: (_, i) => _page(i, box.biggest), ), ), diff --git a/test/reader_test.dart b/test/reader_test.dart index d79e976..ab6c2f3 100644 --- a/test/reader_test.dart +++ b/test/reader_test.dart @@ -189,6 +189,32 @@ void main() { expect(AppSettings.fromJson(c.settings.toJson()).readerSystemBars, isFalse); }); + testWidgets('X-plore 맞추기: 아래 갤러리 줄 - 처음은 끔, 켜면 썸네일 · 누르면 그 장으로, 위 막대에서 끄기', (tester) async { + tester.view.physicalSize = const Size(1400, 1200); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + expect(AppSettings().readerGallery, isFalse, reason: '처음 값은 지금 동작 그대로'); + expect(AppSettings.fromJson({'readerGallery': true}).readerGallery, isTrue); + 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(find.byKey(const ValueKey('thumb0')), findsNothing, reason: '처음은 끔'); + await tester.tap(find.byTooltip('아래 갤러리 줄 보이기')); + await tester.pumpAndSettle(); + expect(c.settings.readerGallery, isTrue); + expect(find.byKey(const ValueKey('thumb2')), findsOneWidget); + await tester.tap(find.byKey(const ValueKey('thumb2'))); + await tester.pumpAndSettle(); + expect(find.text('3 / 3'), findsOneWidget, reason: '누른 장으로'); + await tester.tap(find.byTooltip('아래 갤러리 줄 숨기기')); + await tester.pumpAndSettle(); + expect(find.byKey(const ValueKey('thumb0')), findsNothing); + await tester.pumpWidget(const SizedBox()); + }); + testWidgets('그림이 깨졌으면 화면 글 + [자세히], 파일을 고친 뒤 [다시 시도] 하면 열린다 (28 지적: 같은 키 · 같은 그림이라 Image 가 다시 읽지 않았다 - 장의 키를 올리고 그림 캐시도 비움)', (tester) async { tester.view.physicalSize = const Size(900, 1200); tester.view.devicePixelRatio = 1;