JJ_MKVMaker/lib/ui/reader_page.dart

1149 lines
48 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import 'dart:async';
import 'dart:io' show File, Platform;
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import '../app/app_controller.dart';
import '../core/reader_sources.dart';
import '../core/hwp_source.dart' show HwpPdfException, HwpSource;
import '../core/file_ops.dart' show formatSize;
import '../core/photo_info.dart';
import '../core/vfs.dart';
import '../l10n/tr.dart';
import 'ai_image_page.dart';
import 'ai_upscale_ui.dart';
import 'theme.dart';
/// 그림 · 만화 · PDF 보기를 연다. [start] 처음 볼 장. 닫으면 [source] 를 정리한다.
Future<void> openReader(BuildContext context, AppController c, ReaderSource source, {int start = 0, bool autoPlay = false}) async {
await Navigator.of(context).push(MaterialPageRoute<void>(
builder: (_) => ReaderPage(c: c, source: source, start: start, autoPlay: autoPlay),
));
await source.dispose();
}
/// 보이는 한 쪽: 원본 [page] 장의 통째 (part -1) 또는 읽는 순서로 첫째 (0) · 둘째 (1) 반쪽 (119)
typedef ReaderSlot = ({int page, int part});
/// 그림 · 만화 · PDF 보기.
/// - 화면 오른쪽 (1/3) 을 누르면 다음 장, 왼쪽을 누르면 이전 장, 가운데를 누르면 위 · 아래 막대 보이기 · 숨기기
/// (오른쪽에서 왼쪽으로 넘기기를 켜면 반대). 좌우로 밀어도 넘어간다.
/// - 위 막대: 계속 보기 (117) · 두 쪽 나누기 (119) · 회전 · 상하 반전 (120) · 한 쪽 맞추기 · 좌우 맞추기, 넘기는 방향,
/// 어둡게 · 밝게, (PDF) 페이지 지우기 · 그림 넣기 · 저장. 좁으면 못 들어간 버튼은 ⋮ 메뉴로.
/// 223: "두 쪽 나눠 보기: 자동" 이 이 장 ([size], 회전 반영) 을 나눌지 - 두 쪽 스캔일 가능성이 높을 때만.
/// [bundle] (ZIP · CBZ · PDF 로 연 묶음): 가로로 긴 그림은 나눈다 (만화 두 쪽 스캔이 흔함).
/// 폴더의 그림들: 같은 폴더의 다른 그림 ([others], 크기를 아는 것) 대부분 (3/4 이상, 2장 이상) 이 세로인데 이것만 가로일 때만 -
/// 평범한 가로 사진은 나누지 않는다 (예전에는 모두 반으로 잘라 "그림이 잘렸다" 고 보였다)
bool autoSplitsPage(Size size, List<Size> others, {required bool bundle}) {
if (size.width <= size.height) return false;
if (bundle) return true;
final tall = others.where((s) => s.height > s.width).length;
return others.length >= 2 && tall * 4 >= others.length * 3;
}
class ReaderPage extends StatefulWidget {
final AppController c;
final ReaderSource source;
final int start;
/// X-plore 맞추기 (관리자 지시 10/20): 폴더 [사진 슬라이드쇼] - [계속 보기] (117) 를 켠 채 연다
final bool autoPlay;
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();
@override
State<ReaderPage> createState() => _ReaderPageState();
}
class _ReaderPageState extends State<ReaderPage> {
AppController get c => widget.c;
ReaderSource get src => widget.source;
late final PageController _pages;
/// 보는 쪽 ([_v] 의 번호)
late int _index;
bool _bars = true;
final _cache = <int, Future<ReaderImage>>{};
int _width = 0;
/// PDF 는 페이지를 지우거나 넣으면 다시 그린다
int _version = 0;
/// X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 - 그림 원본 (폴더 · ZIP) 에서만
bool get _pictures => src is ImageFilesSource || src is ZipImagesSource;
final _thumbs = <int, Future<ReaderImage>>{};
final _gallery = ScrollController();
static const _thumbExtent = 64.0;
/// [다시 시도] 를 누른 횟수 (장마다) - 그 장의 화면을 새로 만든다 (28 지적: 같은 키면 실패한 그림을 그대로 다시 썼다)
final _retry = <int, int>{};
@override
void initState() {
super.initState();
_rebuildPages();
final i = _v.indexWhere((e) => e.page == widget.start);
_index = i < 0 ? 0 : i;
_pages = PageController(initialPage: _index);
_applySystemBars();
// 224: 앱으로 돌아올 때 그사이 밖에서 지운 그림을 목록에서 뺀다
_life = AppLifecycleListener(onResume: () => unawaited(dropMissingFiles()));
if (widget.autoPlay) WidgetsBinding.instance.addPostFrameCallback((_) => mounted ? _play(true) : null);
}
late final AppLifecycleListener _life;
/// 224: 폴더의 그림 중 없어진 것 (앱 밖에서 지움) 을 목록에서 뺀다. 보던 그림이 없어졌으면 알리고 다음 그림으로,
/// 남은 것이 없으면 닫는다 (예전에는 지운 그림이 그대로 보였다). 시험에서도 부른다
@visibleForTesting
Future<void> dropMissingFiles() async {
final s = src;
if (s is! ImageFilesSource) return;
final gone = <int>{
for (var i = 0; i < s.paths.length; i++)
if (vMissingSync(s.paths[i])) i,
};
if (gone.isEmpty || !mounted) return;
final curPage = _cur.page;
final curGone = gone.contains(curPage);
// 보던 그림 (없어졌으면 그 뒤의 남은 그림) 의 새 번호
var keep = curPage;
while (keep < s.paths.length && gone.contains(keep)) {
keep++;
}
if (keep >= s.paths.length) {
keep = curPage;
while (keep >= 0 && gone.contains(keep)) {
keep--;
}
}
final newPage = keep < 0 ? -1 : keep - gone.where((g) => g < keep).length;
for (final g in gone.toList()..sort((a, b) => b - a)) {
s.paths.removeAt(g);
}
for (final f in [..._cache.values, ..._cacheHalf.values]) {
unawaited(f.then((x) => x.dispose(), onError: (_) {}));
}
_cache.clear();
_cacheHalf.clear();
_dims.clear();
if (s.paths.isEmpty) {
_snack(tr('파일이 없어졌습니다'));
Navigator.of(context).maybePop();
return;
}
setState(() {
_v = const [];
_rebuildPages();
final i = _v.indexWhere((e) => e.page == newPage);
_index = i < 0 ? 0 : i;
});
_jumpLater();
if (curGone) _snack(tr('파일이 없어졌습니다'));
}
/// 보기 화면은 위 · 아래 시스템 막대까지 그림으로 (30: 설정으로 막대를 그대로 둘 수 있다)
void _applySystemBars() => SystemChrome.setEnabledSystemUIMode(
c.settings.readerSystemBars ? SystemUiMode.edgeToEdge : SystemUiMode.immersiveSticky);
// ───────── 쪽 나누기 (119) · 회전 · 반전 (120) ─────────
/// 보이는 쪽들
List<ReaderSlot> _v = const [];
ReaderSlot get _cur => _v.isEmpty ? (page: 0, part: -1) : _v[_index.clamp(0, _v.length - 1)];
/// 원본 그림의 크기 (읽어 보고 안 것)
final _dims = <int, Size>{};
/// 시계 방향 90° 회전 수 · 상하 반전 (이 보기 화면을 닫을 때까지 모든 쪽에, 파일은 그대로)
int _turns = 0;
bool _flipV = false;
/// 이 장을 반씩 나눠 볼지. 자동은 두 쪽 스캔일 가능성이 높을 때만 ([autoSplitsPage])
bool _splits(int page) {
switch (c.settings.readerSplit) {
case 'on':
return true;
case 'off':
return false;
}
final d = _dims[page];
if (d == null) return false;
Size turned(Size s) => _turns.isOdd ? Size(s.height, s.width) : s;
return autoSplitsPage(turned(d), [for (final e in _dims.entries) if (e.key != page) turned(e.value)],
// 관리자 지시 (10/20) · 내 판단: HWP · HWPX 문서의 가로 쪽 (가로 표 등) 은 만화 두 쪽 스캔이 아니라 나누지 않는다
bundle: src is! ImageFilesSource && src is! HwpSource);
}
/// 223: 나눠 본 장에서 바로 한 장으로 (설정을 "끄기" 로)
Future<void> _splitOff() async {
await c.updateSettings((x) => x.readerSplit = 'off');
if (!mounted) return;
setState(() {
_resetZoom();
_rebuildPages();
});
}
/// 보이는 쪽 목록을 다시 만든다. 보던 장은 그대로 보이게 (쪽 번호가 밀리면 그 자리로 옮긴다)
void _rebuildPages() {
final cur = _v.isEmpty ? null : _cur;
_v = [
for (var i = 0; i < src.length; i++)
if (_splits(i)) ...[(page: i, part: 0), (page: i, part: 1)] else (page: i, part: -1),
];
if (cur == null) return;
int find(int part) => _v.indexWhere((e) => e.page == cur.page && e.part == part);
var to = find(cur.part);
if (to < 0) to = find(cur.part < 0 ? 0 : -1);
if (to < 0) to = _index.clamp(0, math.max(0, _v.length - 1));
if (to != _index) {
_index = to;
_jumpLater();
}
}
void _jumpLater() => WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _pages.hasClients && _pages.page?.round() != _index) _pages.jumpToPage(_index);
});
/// 읽은 그림의 크기를 기억한다 (자동 나누기 판단)
void _learn(int page, ReaderImage r) {
if (_dims.containsKey(page)) return;
final v = _version;
unawaited(_sizeOf(r).then((size) {
if (!mounted || size == null || v != _version || _dims.containsKey(page)) return;
_dims[page] = size;
if (c.settings.readerSplit == 'auto') setState(_rebuildPages);
}));
}
static Future<Size?> _sizeOf(ReaderImage r) {
final img = r.image;
if (img != null) return Future.value(Size(img.width.toDouble(), img.height.toDouble()));
final provider = r.provider;
if (provider == null) return Future.value(null);
final done = Completer<Size?>();
final stream = provider.resolve(ImageConfiguration.empty);
late final ImageStreamListener l;
l = ImageStreamListener((info, _) {
if (!done.isCompleted) done.complete(Size(info.image.width.toDouble(), info.image.height.toDouble()));
info.dispose();
stream.removeListener(l);
}, onError: (_, _) {
if (!done.isCompleted) done.complete(null);
stream.removeListener(l);
});
stream.addListener(l);
return done.future;
}
void _rotate(int d) => setState(() {
_turns = (_turns + d) % 4;
_resetZoom();
_rebuildPages();
});
Future<void> _cycleSplit() async {
await c.updateSettings((x) => x.readerSplit = switch (x.readerSplit) { 'auto' => 'on', 'on' => 'off', _ => 'auto' });
if (!mounted) return;
setState(() {
_resetZoom();
_rebuildPages();
});
}
// ───────── 계속 보기 (117) ─────────
bool _playing = false;
Timer? _timer;
/// 계속 보기를 멈추게 한 누름은 장 넘기기 · 막대 숨기기로 쓰지 않는다
bool _swallowTap = false;
/// 관리자 지시 10/20 (28 검토): 다 보인 장 ('$_version:$_index'). 계속 보기 시계는 그 장이 보인 뒤부터 잰다 -
/// WebDAV 처럼 느린 원본에서 받는 동안 시간이 흘러 보지도 못한 장을 넘겼다
String? _shownKey;
/// [i] 장이 화면에 다 그려졌다 (그림 첫 장면 · 실패 글). 지금 장이면 계속 보기 시계를 시작
void _markShown(int i) {
if (!mounted || i != _index) return;
final key = '$_version:$i';
if (_shownKey == key) return;
_shownKey = key;
if (_playing && _timer == null) _startTimer();
}
void _startTimer() {
_timer?.cancel();
_timer = Timer(Duration(seconds: c.settings.readerAutoSeconds), _tick);
}
void _play(bool on) {
_timer?.cancel();
_timer = null;
// 지금 장이 이미 보이면 바로 재기 시작, 아직 받는 중이면 보인 뒤에 ([_markShown])
if (on && _v.isNotEmpty && _shownKey == '$_version:$_index') _startTimer();
if (on == _playing) return;
setState(() => _playing = on);
unawaited(ReaderPage.keepScreenOn(on).catchError((_) {}));
}
void _tick() {
_timer = null;
if (!mounted || !_playing) return;
if (_index >= _v.length - 1) {
_play(false);
_snack(tr('마지막 장이라 계속 보기를 멈췄습니다.'));
return;
}
_pages.jumpToPage(_index + 1);
// 다음 장이 이미 받아 둔 것이라 다시 그리지 않으면 그 자리에서 알린다
WidgetsBinding.instance.addPostFrameCallback((_) => _rearmIfShown());
}
/// 넘긴 뒤 그 장이 곧바로 보였는데 (캐시) 알림을 놓쳤으면 시계를 다시 건다
void _rearmIfShown() {
if (mounted && _playing && _timer == null && _shownKey == '$_version:$_index') _startTimer();
}
Future<void> _setSeconds(int d) async {
await c.updateSettings((x) => x.readerAutoSeconds = (x.readerAutoSeconds + d).clamp(1, 60));
if (!mounted) return;
setState(() {});
if (_playing) _play(true); // 새 간격으로 (지금 장이 보이면 처음부터 다시)
}
// ───────── 확대 (30) ─────────
/// 쪽마다 확대 상태 (보는 쪽만 쓰고, 쪽을 넘기면 원래대로)
final _zoom = <int, TransformationController>{};
TransformationController _zoomOf(int i) => _zoom.putIfAbsent(i, TransformationController.new);
/// 확대해 보고 있는지 (그동안은 밀어도 장이 넘어가지 않고 그림을 옮긴다)
bool _zoomed = false;
/// 화면에 닿은 손가락 수: 둘이면 (벌리고 오므리기) 옆으로 움직여도 장을 넘기지 않고 확대 · 축소
int _fingers = 0;
bool get _pinching => _fingers >= 2;
void _finger(int d) {
final was = _pinching;
_fingers = math.max(0, _fingers + d);
if (was != _pinching) setState(() {});
}
bool get _holdPage => _zoomed || _pinching;
/// 확대한 배율에 맞춰 더 크게 다시 그린 쪽 (흐릿하지 않게)
final _sharp = <int, Future<ReaderImage>>{};
final _sharpWidth = <int, int>{};
/// 반쪽은 두 배 너비로 읽는다 (반쪽이 화면 가득이어도 원본 해상도로)
int _baseWidth(ReaderSlot s) => s.part >= 0 ? math.min(_width * 2, 8192) : _width;
void _onZoomEnd(int i) {
final s = _zoomOf(i).value.getMaxScaleOnAxis();
final zoomed = s > 1.01;
if (zoomed != _zoomed) setState(() => _zoomed = zoomed);
if (s < 1.3 || i >= _v.length) return;
final slot = _v[i];
final base = _baseWidth(slot);
final want = (base * s).round().clamp(base, 8192);
if (want <= (_sharpWidth[i] ?? base) * 1.2) return;
_sharpWidth[i] = want;
final old = _sharp[i];
setState(() {
_sharp[i] = src.load(slot.page, maxWidth: want);
});
if (old != null) unawaited(old.then((x) => x.dispose(), onError: (_) {}));
}
void _resetZoom() {
for (final z in _zoom.values) {
z.dispose();
}
_zoom.clear();
for (final f in _sharp.values) {
unawaited(f.then((x) => x.dispose(), onError: (_) {}));
}
_sharp.clear();
_sharpWidth.clear();
_zoomed = false;
}
@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((_) {}));
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
for (final f in [..._cache.values, ..._cacheHalf.values]) {
unawaited(f.then((x) => x.dispose(), onError: (_) {}));
}
_resetZoom();
_pages.dispose();
super.dispose();
}
Future<ReaderImage> _load(int page) => _cache.putIfAbsent(page, () {
final f = src.load(page, maxWidth: _width);
f.then((r) {
if (mounted) _learn(page, r);
}, onError: (_) {});
return f;
});
/// 반쪽으로 보는 장 (두 배 너비)
final _cacheHalf = <int, Future<ReaderImage>>{};
Future<ReaderImage> _loadHalf(int page) =>
_cacheHalf.putIfAbsent(page, () => src.load(page, maxWidth: math.min(_width * 2, 8192)));
/// 보는 장 가까이만 남기고 (메모리) 다음 장은 미리 읽는다. 앞 장도 크기를 알아 둔다 (자동 나누기로 쪽 번호가 밀리지 않게)
void _trim() {
final page = _cur.page;
for (final m in [_cache, _cacheHalf]) {
for (final k in m.keys.toList()) {
if ((k - page).abs() > 3) {
unawaited(m.remove(k)!.then((x) => x.dispose(), onError: (_) {}));
}
}
}
for (final n in [page + 1, page - 1]) {
if (n >= 0 && n < src.length) unawaited(_load(n).then((_) {}, onError: (_) {}));
}
}
/// 다시 읽기 (PDF 페이지를 바꿨을 때). [page] 를 보던 자리로
void _reset({int? page}) {
final keep = page ?? _cur.page;
for (final f in [..._cache.values, ..._cacheHalf.values]) {
unawaited(f.then((x) => x.dispose(), onError: (_) {}));
}
_cache.clear();
_cacheHalf.clear();
_dims.clear();
_resetZoom();
_version++;
_v = const [];
_rebuildPages();
final i = _v.indexWhere((e) => e.page == keep.clamp(0, math.max(0, src.length - 1)));
_index = i < 0 ? 0 : i;
_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) {
final to = (_index + delta).clamp(0, math.max(0, _v.length - 1)).toInt();
if (to == _index) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(SnackBar(
duration: const Duration(seconds: 1), content: Text(delta > 0 ? tr('마지막 장입니다.') : tr('첫 장입니다.'))));
return;
}
_pages.jumpToPage(to);
}
void _onTap(TapUpDetails d, double width) {
if (_swallowTap) {
_swallowTap = false;
return;
}
final x = d.localPosition.dx;
final rtl = c.settings.readerRtl;
if (x > width * 2 / 3) {
_go(rtl ? -1 : 1);
} else if (x < width / 3) {
_go(rtl ? 1 : -1);
} else {
setState(() => _bars = !_bars);
}
}
/// 그림을 누르거나 밀면 계속 보기를 멈춘다 (117)
void _onPointerDown() {
_finger(1);
_swallowTap = _playing;
if (_playing) {
_play(false);
_snack(tr('계속 보기를 멈췄습니다.'));
}
}
ColorFilter _brightness(double b) {
final double s = 1 + math.min(b, 0.0);
final double o = math.max(b, 0.0) * 160;
return ColorFilter.matrix(<double>[s, 0, 0, 0, o, 0, s, 0, 0, o, 0, 0, s, 0, o, 0, 0, 0, 1, 0]);
}
Widget _page(int i, Size box) {
final fit = c.settings.readerFit;
final slot = _v[i];
final half = slot.part >= 0;
// 관리자 지시 (10/20, HWP 2단계): 이유와 [다시 시도] - 그 장의 기억을 지우고 다시 부른다 (시간 제한을 넘긴 HWP 묶음 등)
// [provider]: 그림 파일 · ZIP 의 그림을 읽다 (디코드) 실패한 것 - 화면 글은 종류에 맞게, 원문은 [자세히] (28 지적)
Widget failed(Object? e, {ImageProvider? provider}) {
final text = e is HwpPdfException
? trf('열 수 없습니다: {0}', [e.message])
: provider != null
? tr('그림 파일을 읽을 수 없습니다 - 깨졌거나 지원하지 않는 형식')
: trf('열 수 없습니다: {0}', [e]);
final detail = e is HwpPdfException ? e.detail : (provider != null ? '$e' : '');
return SizedBox(
width: 320,
height: 160,
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Text(text, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white70)),
const SizedBox(height: 8),
// 관리자 지시 (10/20, 28 지적): 변환기 · 그림 디코드 원문은 화면 글 대신 [자세히] 에
if (detail.isNotEmpty)
TextButton(
onPressed: () => showDialog<void>(
context: context,
builder: (ctx) => AlertDialog(
content: SelectableText(detail, style: const TextStyle(fontSize: 12)),
actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('닫기')))],
),
),
child: Text(tr('자세히')),
),
OutlinedButton(
onPressed: () {
// 그림은 Flutter 의 그림 캐시에 실패가 남아 같은 키로 다시 쓰인다 - 비우고 (ResizeImage 의 키째) 다시 읽는다
if (provider != null) unawaited(provider.evict());
setState(() {
_cache.remove(slot.page);
_cacheHalf.remove(slot.page);
_sharp.remove(i);
_sharpWidth.remove(i);
_retry[slot.page] = (_retry[slot.page] ?? 0) + 1;
});
},
child: Text(tr('다시 시도')),
),
]));
}
// 확대해 다시 그린 것이 있으면 그것 (다 그릴 때까지는 앞의 그림을 그대로 보인다)
final page = FutureBuilder<ReaderImage>(
key: ValueKey('$_version:${slot.page}:${slot.part}:${_retry[slot.page] ?? 0}'),
future: _sharp[i] ?? (half ? _loadHalf(slot.page) : _load(slot.page)),
builder: (context, snap) {
// 계속 보기: 받는 동안은 시계를 재지 않고, 보인 (또는 실패 글이 보인) 뒤부터
void shown() => WidgetsBinding.instance.addPostFrameCallback((_) => _markShown(i));
if (snap.hasError) {
shown();
return Center(child: failed(snap.error));
}
final r = snap.data;
if (r == null) return const Center(child: CircularProgressIndicator());
if (r.image != null) shown();
Widget picture = r.image != null
? RawImage(image: r.image, filterQuality: FilterQuality.medium)
: Image(image: r.provider!, gaplessPlayback: true, filterQuality: FilterQuality.medium,
frameBuilder: (_, child, frame, sync) {
if (frame != null || sync) shown();
return child;
},
errorBuilder: (_, e, _) {
shown();
return failed(e, provider: r.provider);
});
// 120: 회전 · 상하 반전 (보기만, 파일은 그대로)
if (_turns != 0) picture = RotatedBox(quarterTurns: _turns, child: picture);
if (_flipV) picture = Transform.flip(flipY: true, child: picture);
// 119: 반쪽 - 읽는 방향의 쪽부터 (오른쪽 → 왼쪽이면 오른쪽 반이 먼저)
if (half) {
final right = (slot.part == 0) == c.settings.readerRtl;
picture = ClipRect(
child: Align(
alignment: right ? Alignment.centerRight : Alignment.centerLeft, widthFactor: 0.5, child: picture),
);
}
if (fit == 'width') {
return SingleChildScrollView(
physics: _holdPage ? const NeverScrollableScrollPhysics() : null,
child: SizedBox(width: box.width, child: FittedBox(fit: BoxFit.fitWidth, child: picture)));
}
return SizedBox.expand(child: FittedBox(fit: BoxFit.contain, child: picture));
},
);
// 30: 두 손가락으로 확대 · 축소 (확대했을 때만 그림을 끌어 옮긴다)
return InteractiveViewer(
transformationController: _zoomOf(i),
minScale: 1,
maxScale: 6,
panEnabled: _zoomed,
onInteractionUpdate: (_) {
final z = _zoomOf(i).value.getMaxScaleOnAxis() > 1.01;
if (z != _zoomed) setState(() => _zoomed = z);
},
onInteractionEnd: (_) => _onZoomEnd(i),
child: page,
);
}
Future<void> _updateSettings(void Function(dynamic s) f) async {
await c.updateSettings((s) => f(s));
if (mounted) setState(() {});
}
// ───────── PDF 편집 ─────────
PdfSource? get _pdf => src is PdfSource ? src as PdfSource : null;
/// 28 지적 (10/20): 위 막대 단추를 누른 그 이벤트 안에서 창을 띄우면 (장을 손으로 넘긴 뒤) 창을 닫을 때 디버그 판에서
/// 의미 트리 assertion ('node.built') 이 났다 - 창은 지금 장면이 끝난 뒤에 띄운다 (부른 쪽은 뒤에 mounted 확인)
Future<void> _afterFrame() => WidgetsBinding.instance.endOfFrame;
Future<void> _deletePage() async {
await _afterFrame();
if (!mounted) return;
final pdf = _pdf!;
if (pdf.length <= 1) {
_snack(tr('마지막 한 페이지는 지울 수 없습니다.'));
return;
}
final page = _cur.page;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(tr('페이지 지우기')),
content: Text(trf('{0} 페이지를 지울까요? (저장해야 파일에 반영됩니다)', [page + 1])),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text(tr('취소'))),
FilledButton(onPressed: () => Navigator.pop(ctx, true), child: Text(tr('지우기'))),
],
),
);
if (ok != true) return;
await pdf.deletePage(page);
if (!mounted) return;
setState(() => _reset(page: page));
}
Future<void> _insertImages() async {
await _afterFrame();
if (!mounted) return;
final pdf = _pdf!;
final page = _cur.page;
final files = await c.services.storage.pickFiles(title: tr('PDF 에 넣을 그림 선택'), extensions: c.settings.imageExts);
if (files.isEmpty || !mounted) return;
final temp = await c.services.storage.tempDirectory();
try {
await pdf.insertImages(files, after: page, tempDir: temp);
} catch (e) {
_snack(trf('넣지 못했습니다: {0}', [e]));
return;
}
setState(() => _reset(page: page));
_snack(trf('그림 {0}장을 {1} 페이지 뒤에 넣었습니다 (저장해야 파일에 반영됩니다).', [files.length, page + 1]));
}
Future<void> _save() async {
try {
final path = await _pdf!.save();
_snack(trf('저장했습니다: {0}', [vDisplay(path)]));
setState(() {});
} catch (e) {
_snack(trf('저장하지 못했습니다: {0}', [e]));
}
}
Future<bool> _confirmLeave() async {
final pdf = _pdf;
if (pdf == null || !pdf.dirty) return true;
await _afterFrame();
if (!mounted) return false;
final r = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(tr('저장하지 않은 변경')),
content: Text(tr('페이지를 바꾼 것을 저장할까요?')),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, 'cancel'), child: Text(tr('취소'))),
TextButton(onPressed: () => Navigator.pop(ctx, 'discard'), child: Text(tr('저장 안 함'))),
FilledButton(onPressed: () => Navigator.pop(ctx, 'save'), child: Text(tr('저장'))),
],
),
);
if (r == 'save') await _save();
return r == 'save' || r == 'discard';
}
void _snack(String text) {
if (!mounted) return;
final m = ScaffoldMessenger.maybeOf(context);
m?.hideCurrentSnackBar();
m?.showSnackBar(SnackBar(duration: const Duration(seconds: 2), content: Text(text)));
}
// ───────── 화면 ─────────
/// 위 막대의 버튼들 (앞의 것일수록 먼저 보이고, 못 들어가면 ⋮ 메뉴로)
List<_BarItem> _barItems() {
final s = c.settings;
final pdf = _pdf;
final secs = s.readerAutoSeconds;
_BarItem one(_Act a) => _BarItem(48, [a]);
final split = switch (s.readerSplit) {
'on' => tr('두 쪽 나눠 보기: 켜기 (누르면 끄기)'),
'off' => tr('두 쪽 나눠 보기: 끄기 (누르면 자동)'),
_ => tr('두 쪽 나눠 보기: 자동 - 두 쪽 스캔으로 보이는 그림만 (누르면 켜기)'),
};
return [
// 223: 나눠 보고 있으면 바로 한 장으로 (자동이 사진을 잘라 보인다고 여기지 않게 맨 앞에)
if (_cur.part >= 0) one(_Act(const Icon(Icons.crop_original), tr('두 쪽 나눔 끄기 (한 장으로 보기)'), _splitOff)),
// 117: 계속 보기 · 간격 (▲ 늘리기 · ▼ 줄이기)
one(_Act(Icon(_playing ? Icons.pause : Icons.play_arrow),
_playing ? tr('계속 보기 멈추기') : trf('계속 보기 ({0}초마다 다음 장)', [secs]), () => _play(!_playing),
on: _playing)),
_BarItem(112, [
_Act(const Icon(Icons.keyboard_arrow_up), trf('계속 보기 간격 늘리기 (지금 {0}초)', [secs]),
secs < 60 ? () => _setSeconds(1) : null),
_Act(const Icon(Icons.keyboard_arrow_down), trf('계속 보기 간격 줄이기 (지금 {0}초)', [secs]),
secs > 1 ? () => _setSeconds(-1) : null),
], seconds: secs),
if (pdf != null) ...[
one(_Act(const Icon(Icons.save_outlined), tr('저장'), pdf.dirty ? _save : null)),
one(_Act(const Icon(Icons.delete_outline), tr('이 페이지 지우기'), _deletePage)),
one(_Act(const Icon(Icons.add_photo_alternate_outlined), tr('그림을 페이지로 넣기 (이 페이지 뒤)'), _insertImages)),
],
// X-plore 맞추기 (관리자 지시 10/20): 사진 정보 (Exif) - 폴더의 그림만
if (src is ImageFilesSource) one(_Act(const Icon(Icons.info_outline), tr('사진 정보 (Exif)'), _showPhotoInfo)),
// 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)),
one(_Act(const Icon(Icons.rotate_left), tr('반대로 90° 회전'), () => _rotate(3), on: _turns != 0)),
one(_Act(const RotatedBox(quarterTurns: 1, child: Icon(Icons.flip)), tr('상하 반전'),
() => setState(() => _flipV = !_flipV), on: _flipV)),
one(_Act(const Icon(Icons.fit_screen_outlined), tr('한 쪽 맞추기'), () => _updateSettings((x) => x.readerFit = 'page'),
on: s.readerFit == 'page')),
one(_Act(const Icon(Icons.width_full_outlined), tr('좌우 맞추기 (세로로 밀어 봄)'),
() => _updateSettings((x) => x.readerFit = 'width'), on: s.readerFit == 'width')),
one(_Act(const Icon(Icons.swap_horiz),
s.readerRtl ? tr('넘기는 방향: 오른쪽 → 왼쪽 (만화)') : tr('넘기는 방향: 왼쪽 → 오른쪽'),
() => _updateSettings((x) => x.readerRtl = !x.readerRtl), on: s.readerRtl)),
one(_Act(const Icon(Icons.brightness_low), tr('어둡게'),
() => _updateSettings((x) => x.readerBrightness = (x.readerBrightness - 0.1).clamp(-0.7, 0.7)))),
one(_Act(const Icon(Icons.brightness_high), tr('밝게'),
() => _updateSettings((x) => x.readerBrightness = (x.readerBrightness + 0.1).clamp(-0.7, 0.7)))),
// 30: 시계 · 알림 · 뒤로 막대를 그대로 / 화면 가득
one(_Act(Icon(s.readerSystemBars ? Icons.fullscreen : Icons.fullscreen_exit),
s.readerSystemBars ? tr('화면 가득 (시스템 막대 숨기기)') : tr('시스템 막대 보이기 (시계 · 알림 · 뒤로)'), () async {
await _updateSettings((x) => x.readerSystemBars = !x.readerSystemBars);
_applySystemBars();
}, on: s.readerSystemBars)),
// AI 는 막대가 좁으면 먼저 ⋮ 로 (늘 쓰는 버튼이 막대에 남게)
// 121: AI 해상도 올리기 (이 장 · 모든 장) - 새 파일로 저장, 원본 · ZIP 은 그대로
if (_aiOn && src is! PdfSource) ...[
one(_Act(const Icon(Icons.hd_outlined), tr('AI 해상도 올리기 (이 장)'), _upscalePage)),
one(_Act(const Icon(Icons.burst_mode_outlined), tr('AI 해상도 올리기 (모든 장)'), () => upscaleAll(context, c, src))),
// 관리자 판단 (10/17): [품질 올리기] - 설정으로 켰을 때만
if (c.settings.aiQualityButton) one(_Act(const Icon(Icons.auto_fix_high), tr('품질 올리기 (이 장)'), _qualityPage)),
],
// 123: 이 그림을 바탕으로 AI 그림 (그림 → 그림, 원본은 그대로)
if (_aiSource case final path?)
one(_Act(const Icon(Icons.auto_awesome_outlined), tr('AI 그림으로 (이 그림을 바탕으로)'),
() => AiImagePage.open(Navigator.of(context), c: c, initImage: path))),
];
}
bool get _aiOn => c.settings.components.contains('aiimage');
Future<void> _upscalePage() async {
final page = _cur.page;
final file = await readerPageFile(c, src, page);
if (file == null || !mounted) return;
final saved = await upscaleOne(context, c, input: file.$1, nameFrom: file.$2, outDir: file.$3);
if (saved == null || !mounted) return;
// 저장한 뒤 올린 것으로 계속 보기 (설정) - 이 기기의 그림 파일 목록일 때
final s = src;
if (c.settings.aiUpAfter == 'upscaled' && s is ImageFilesSource && !isDav(s.paths[page])) {
setState(() {
s.paths[page] = saved;
_reset(page: page);
});
}
}
Future<void> _qualityPage() async {
final file = await readerPageFile(c, src, _cur.page);
if (file == null || !mounted) return;
await qualityUp(context, c, input: file.$1, nameFrom: file.$2, outDir: file.$3);
}
/// 지금 보는 그림 파일 (이 기기의 그림 파일일 때, AI 그림 컴포넌트가 켜져 있을 때)
String? get _aiSource {
final s = src;
if (!c.settings.components.contains('aiimage') || s is! ImageFilesSource || _v.isEmpty) return null;
final path = s.paths[_cur.page];
return isDav(path) ? null : path;
}
Widget _barButton(_Act a, {bool compact = false}) => IconButton(
tooltip: a.tip,
color: a.on ? JjColors.accent : Colors.white,
disabledColor: Colors.white38,
icon: a.icon,
visualDensity: compact ? VisualDensity.compact : null,
constraints: compact ? const BoxConstraints(minWidth: 36, minHeight: 40) : null,
padding: compact ? EdgeInsets.zero : null,
onPressed: a.onTap,
);
Widget _topBar() {
final pdf = _pdf;
return Container(
// 113: 반투명 검정은 그림 위에서만 보이고 옆의 검은 여백에서는 안 보여 막대가 갈라져 보였다 → 거의 불투명한 어두운 회색 한 줄
color: _barColor,
padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top),
child: SizedBox(
height: 52,
child: LayoutBuilder(builder: (context, box) {
final items = _barItems();
// 닫기 · 제목 (조금이라도) 을 뺀 자리에 들어가는 만큼 앞에서부터, 나머지는 ⋮ 메뉴 (없애지 않는다)
final room = box.maxWidth - 48 - 72;
var shown = items.length;
if (items.fold<double>(0, (a, x) => a + x.width) > room) {
var used = 48.0; // ⋮
shown = 0;
while (shown < items.length && used + items[shown].width <= room) {
used += items[shown].width;
shown++;
}
}
final rest = [for (final x in items.skip(shown)) ...x.acts];
return Row(children: [
_barButton(_Act(const Icon(Icons.arrow_back), tr('닫기'), () => Navigator.maybePop(context))),
Expanded(
child: Text(
'${src.title}${pdf?.dirty == true ? ' *' : ''}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white, fontSize: 14),
),
),
// 버튼은 오른쪽 끝에 붙인다 (113)
for (final x in items.take(shown))
if (x.seconds != null)
Row(mainAxisSize: MainAxisSize.min, children: [
_barButton(x.acts[0], compact: true),
SizedBox(
width: 40,
child: Text(trf('{0}초', [x.seconds!]),
textAlign: TextAlign.center, style: const TextStyle(color: Colors.white, fontSize: 13)),
),
_barButton(x.acts[1], compact: true),
])
else
_barButton(x.acts.single),
if (rest.isNotEmpty)
PopupMenuButton<int>(
tooltip: tr('더 보기'),
icon: const Icon(Icons.more_vert, color: Colors.white),
onSelected: (k) => rest[k].onTap?.call(),
itemBuilder: (_) => [
for (final (k, a) in rest.indexed)
PopupMenuItem<int>(
value: k,
enabled: a.onTap != null,
child: Row(children: [
IconTheme.merge(data: IconThemeData(color: a.on ? JjColors.accent : null), child: a.icon),
const SizedBox(width: 12),
Flexible(child: Text(a.tip)),
]),
),
],
),
]);
}),
),
);
}
/// 위 · 아래 막대 색 (그림 위 · 검은 여백 위 어디서나 같은 띠로 보이게 거의 불투명)
static const _barColor = Color(0xF0222428);
/// 쪽 번호: 통째면 "12", 반쪽이면 "12-1" · "12-2"
String _label(ReaderSlot s) => s.part < 0 ? '${s.page + 1}' : '${s.page + 1}-${s.part + 1}';
/// X-plore 맞추기 (관리자 지시 10/20): 지금 그림의 사진 정보 (찍은 날 · 기기 · 크기 · 위치). 위치는 [위치 보기] 를 누를 때만
/// 보이고, 지도 · 인터넷으로 보내지 않는다 (링크 없음)
Future<void> _showPhotoInfo() async {
// 지금 장면이 끝난 뒤에 띄운다 ([_afterFrame], Windows 통합 시험에서 재현 4번 → 0번)
await _afterFrame();
if (!mounted) return;
final s = src;
if (s is! ImageFilesSource || _v.isEmpty) return;
final path = s.paths[_cur.page];
PhotoInfo? info;
int? size;
Object? error;
try {
final local = await vLocalCopy(path, s.tempDir);
final f = File(local);
size = await f.length();
final bytes = await f.readAsBytes();
info = readPhotoInfo(bytes);
} catch (e) {
error = e;
}
if (!mounted) return;
var showLocation = false;
await showDialog<void>(
context: context,
builder: (ctx) => StatefulBuilder(
builder: (ctx, set) {
final i = info;
Widget row(String k, Widget v) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(width: 90, child: Text(k, style: const TextStyle(color: JjColors.textDim))),
Expanded(child: v),
]),
);
return AlertDialog(
scrollable: true,
title: Text(tr('사진 정보 (Exif)')),
content: SizedBox(
width: 420,
child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [
row(tr('파일'), SelectableText(vBasename(path))),
if (i == null)
Text(trf('읽을 수 없습니다: {0}', [error]))
else ...[
row(tr('크기'), Text([
if (i.width != null) trf('{0} × {1} 픽셀', [i.width!, i.height!]),
if (size != null) formatSize(size),
].join(' · '))),
row(tr('찍은 날'), Text(i.taken ?? '-')),
row(tr('기기'), Text(i.device ?? '-')),
row(
tr('위치'),
!i.hasLocation
? const Text('-')
: showLocation
? SelectableText(i.locationText)
: Align(
alignment: Alignment.centerLeft,
child: TextButton(onPressed: () => set(() => showLocation = true), child: Text(tr('위치 보기'))))),
if (i.hasLocation)
Text(tr('위치는 이 기기에서만 보이며 지도 · 인터넷으로 보내지 않습니다'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
if (!i.hasExif)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Text(tr('이 그림에는 Exif 정보가 없습니다 (JPEG 사진만 읽음)'),
style: const TextStyle(fontSize: 12, color: JjColors.textDim)),
),
],
]),
),
actions: [TextButton(onPressed: () => Navigator.of(ctx).pop(), child: Text(tr('닫기')))],
);
},
),
);
}
Future<void> _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<ReaderImage> _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<ReaderImage>(
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: 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',
style: const TextStyle(color: Colors.white, fontFamily: 'Consolas')),
Expanded(
child: count < 2
? const SizedBox(height: 48)
: Directionality(
textDirection: c.settings.readerRtl ? TextDirection.rtl : TextDirection.ltr,
child: Slider(
value: _index.clamp(0, count - 1).toDouble(),
max: (count - 1).toDouble(),
divisions: count - 1,
onChanged: (v) => _pages.jumpToPage(v.round()),
),
),
),
// 113: 쪽 이름은 오른쪽 끝 (넓은 화면에서 가운데로 떨어지지 않게)
ConstrainedBox(
constraints: BoxConstraints(maxWidth: MediaQuery.sizeOf(context).width * 0.3),
child: Text(n == 0 ? '' : src.pageName(_cur.page),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
style: const TextStyle(color: Colors.white70, fontSize: 12)),
),
]),
]),
);
}
@override
Widget build(BuildContext context) {
final s = c.settings;
return PopScope(
canPop: _pdf?.dirty != true,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
if (await _confirmLeave() && context.mounted) {
_pdf?.dirty = false;
Navigator.of(context).pop();
}
},
child: Focus(
autofocus: true,
onKeyEvent: _onKey,
child: Scaffold(
backgroundColor: Colors.black,
body: LayoutBuilder(builder: (context, box) {
// 화면 너비 (기기 픽셀) 에 맞춰 그림을 줄여 읽는다
_width = (box.maxWidth * MediaQuery.devicePixelRatioOf(context)).round().clamp(320, 4096);
return Stack(children: [
Positioned.fill(
child: Listener(
onPointerDown: (_) => _onPointerDown(),
onPointerUp: (_) => _finger(-1),
onPointerCancel: (_) => _finger(-1),
child: GestureDetector(
onTapUp: (d) => _onTap(d, box.maxWidth),
child: ColorFiltered(
colorFilter: _brightness(s.readerBrightness),
child: _v.isEmpty
? Center(child: Text(tr('볼 그림이 없습니다.'), style: const TextStyle(color: Colors.white70)))
: PageView.builder(
key: ValueKey(_version),
controller: _pages,
reverse: s.readerRtl,
// 확대해 보는 동안은 밀어도 넘기지 않는다 (그림 옮기기)
physics: _holdPage ? const NeverScrollableScrollPhysics() : null,
itemCount: _v.length,
onPageChanged: (i) {
setState(() {
_index = i;
_resetZoom();
_trim();
});
_scrollGallery();
},
itemBuilder: (_, i) => _page(i, box.biggest),
),
),
),
),
),
if (_bars) ...[
Positioned(left: 0, right: 0, top: 0, child: _topBar()),
Positioned(left: 0, right: 0, bottom: 0, child: _bottomBar()),
],
]);
}),
),
),
);
}
}
/// 위 막대 버튼 하나 (⋮ 메뉴에서는 [icon] 과 [tip] 으로 한 줄)
class _Act {
final Widget icon;
final String tip;
final VoidCallback? onTap;
final bool on;
const _Act(this.icon, this.tip, this.onTap, {this.on = false});
}
/// 위 막대의 한 칸 ([width] 만큼 자리를 차지). [seconds] 가 있으면 ▲ · 초 · ▼ 묶음
class _BarItem {
final double width;
final List<_Act> acts;
final int? seconds;
const _BarItem(this.width, this.acts, {this.seconds});
}