JJ_MKVMaker/lib/ui/reader_page.dart

508 lines
19 KiB
Dart

import 'dart:async';
import 'dart:io' show 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 HwpSource;
import '../core/vfs.dart';
import '../l10n/tr.dart';
import 'reader/reader_ai.dart';
import 'reader/reader_bar_items.dart';
import 'reader/reader_bars.dart';
import 'reader/reader_controls.dart';
import 'reader/reader_dialogs.dart';
import 'reader/reader_images.dart';
import 'reader/reader_page_view.dart';
import 'reader/reader_pdf_edit.dart';
import 'reader/reader_slots.dart';
import 'reader/reader_stage.dart';
export 'reader/reader_slots.dart' show ReaderSlot, autoSplitsPage;
/// 그림 · 만화 · 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();
}
/// 그림 · 만화 · PDF 보기.
/// - 화면 오른쪽 (1/3) 을 누르면 다음 장, 왼쪽을 누르면 이전 장, 가운데를 누르면 위 · 아래 막대 보이기 · 숨기기
/// (오른쪽에서 왼쪽으로 넘기기를 켜면 반대). 좌우로 밀어도 넘어간다.
/// - 위 막대: 계속 보기 (117) · 두 쪽 나누기 (119) · 회전 · 상하 반전 (120) · 한 쪽 맞추기 · 좌우 맞추기, 넘기는 방향,
/// 어둡게 · 밝게, (PDF) 페이지 지우기 · 그림 넣기 · 저장. 좁으면 못 들어간 버튼은 ⋮ 메뉴로.
/// 이 화면은 모듈 (lib/ui/reader/) 을 부르기만 한다: 쪽 계산 (reader_slots) · 그림 기억 (reader_images) ·
/// 계속 보기 · 확대 · 키 (reader_controls) · 무대 (reader_stage) · 한 쪽 (reader_page_view) · 막대 (reader_bars · reader_bar_items) ·
/// 창 (reader_dialogs) · PDF 편집 (reader_pdf_edit) · AI (reader_ai)
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;
late final _images = ReaderImageCache(src);
int _width = 0;
/// PDF 는 페이지를 지우거나 넣으면 다시 그린다
int _version = 0;
/// X-plore 맞추기 (관리자 지시 10/20): 아래 갤러리 줄 - 그림 원본 (폴더 · ZIP) 에서만
bool get _pictures => src is ImageFilesSource || src is ZipImagesSource;
final _gallery = ScrollController();
/// [다시 시도] 를 누른 횟수 (장마다) - 그 장의 화면을 새로 만든다 (28 지적: 같은 키면 실패한 그림을 그대로 다시 썼다)
final _retry = <int, int>{};
@override
void initState() {
super.initState();
_rebuildPages();
final i = indexOfPage(_v, 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 || !mounted) return;
// 보던 그림 (없어졌으면 그 뒤의 남은 그림) 의 새 번호
final r = removeMissingPaths(s.paths, _cur.page, vMissingSync);
if (r == null) return;
_images.clearPages();
_dims.clear();
if (s.paths.isEmpty) {
_snack(tr('파일이 없어졌습니다'));
Navigator.of(context).maybePop();
return;
}
setState(() {
_v = const [];
_rebuildPages();
final i = indexOfPage(_v, r.newPage);
_index = i < 0 ? 0 : i;
});
_jumpLater();
if (r.curGone) _snack(tr('파일이 없어졌습니다'));
}
/// 보기 화면은 위 · 아래 시스템 막대까지 그림으로 (30: 설정으로 막대를 그대로 둘 수 있다)
void _applySystemBars() => SystemChrome.setEnabledSystemUIMode(
c.settings.reader.systemBars ? SystemUiMode.edgeToEdge : SystemUiMode.immersiveSticky);
// ───────── 쪽 나누기 (119) · 회전 · 반전 (120) ─────────
/// 보이는 쪽들
List<ReaderSlot> _v = const [];
ReaderSlot get _cur => slotAt(_v, _index);
/// 원본 그림의 크기 (읽어 보고 안 것)
final _dims = <int, Size>{};
/// 시계 방향 90° 회전 수 · 상하 반전 (이 보기 화면을 닫을 때까지 모든 쪽에, 파일은 그대로)
int _turns = 0;
bool _flipV = false;
/// 이 장을 반씩 나눠 볼지. 자동은 두 쪽 스캔일 가능성이 높을 때만 ([autoSplitsPage])
bool _splits(int page) => splitsPage(c.settings.reader.split, page, _dims,
turns: _turns,
// 관리자 지시 (10/20) · 내 판단: HWP · HWPX 문서의 가로 쪽 (가로 표 등) 은 만화 두 쪽 스캔이 아니라 나누지 않는다
bundle: src is! ImageFilesSource && src is! HwpSource);
/// 223: 나눠 본 장에서 바로 한 장으로 (설정을 "끄기" 로)
Future<void> _splitOff() async {
await c.updateSettings((x) => x.reader.split = 'off');
if (!mounted) return;
setState(() {
_resetZoom();
_rebuildPages();
});
}
/// 보이는 쪽 목록을 다시 만든다. 보던 장은 그대로 보이게 (쪽 번호가 밀리면 그 자리로 옮긴다)
void _rebuildPages() {
final cur = _v.isEmpty ? null : _cur;
_v = buildSlots(src.length, _splits);
if (cur == null) return;
final to = relocateSlot(_v, cur, _index);
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(ReaderImageCache.imageSize(r).then((size) {
if (!mounted || size == null || v != _version || _dims.containsKey(page)) return;
_dims[page] = size;
if (c.settings.reader.split == 'auto') setState(_rebuildPages);
}));
}
void _rotate(int d) => setState(() {
_turns = (_turns + d) % 4;
_resetZoom();
_rebuildPages();
});
Future<void> _cycleSplit() async {
await c.updateSettings((x) => x.reader.split = switch (x.reader.split) { 'auto' => 'on', 'on' => 'off', _ => 'auto' });
if (!mounted) return;
setState(() {
_resetZoom();
_rebuildPages();
});
}
// ───────── 계속 보기 (117) ─────────
late final _show = ReaderSlideshow(seconds: () => c.settings.reader.autoSeconds, onTick: _tick);
bool get _playing => _show.playing;
/// 계속 보기를 멈추게 한 누름은 장 넘기기 · 막대 숨기기로 쓰지 않는다
bool _swallowTap = false;
/// 다 보인 장의 이름 (계속 보기 시계는 그 장이 보인 뒤부터 잰다)
String get _curKey => '$_version:$_index';
/// [i] 장이 화면에 다 그려졌다 (그림 첫 장면 · 실패 글). 지금 장이면 계속 보기 시계를 시작
void _markShown(int i) {
if (!mounted || i != _index) return;
_show.markShown('$_version:$i');
}
void _play(bool on) {
// 지금 장이 이미 보이면 바로 재기 시작, 아직 받는 중이면 보인 뒤에 ([_markShown])
if (!_show.set(on, _v.isNotEmpty ? _curKey : null)) return;
setState(() {});
unawaited(ReaderPage.keepScreenOn(on).catchError((_) {}));
}
void _tick() {
if (!mounted) return;
if (_index >= _v.length - 1) {
_play(false);
_snack(tr('마지막 장이라 계속 보기를 멈췄습니다.'));
return;
}
_pages.jumpToPage(_index + 1);
// 다음 장이 이미 받아 둔 것이라 다시 그리지 않으면 그 자리에서 알린다
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _show.rearmIfShown(_curKey);
});
}
Future<void> _setSeconds(int d) async {
await c.updateSettings((x) => x.reader.autoSeconds = (x.reader.autoSeconds + d).clamp(1, 60));
if (!mounted) return;
setState(() {});
if (_playing) _play(true); // 새 간격으로 (지금 장이 보이면 처음부터 다시)
}
// ───────── 확대 (30) ─────────
final _zoom = ReaderZoom();
void _finger(int d) {
if (_zoom.finger(d)) setState(() {});
}
void _setZoomed(bool z) {
if (z != _zoom.zoomed) setState(() => _zoom.zoomed = z);
}
void _onZoomEnd(int i) {
final s = _zoom.scaleOf(i);
_setZoomed(s > 1.01);
if (i >= _v.length) return;
final slot = _v[i];
final want = _images.sharpWidthFor(i, s, slotBaseWidth(slot, _width));
if (want == null) return;
// 확대한 배율에 맞춰 더 크게 다시 그린다 (흐릿하지 않게)
setState(() => _images.sharpen(i, slot.page, want));
}
void _resetZoom() {
_zoom.reset();
_images.clearSharp();
}
@override
void dispose() {
_gallery.dispose();
_life.dispose();
_show.dispose();
if (_playing) unawaited(ReaderPage.keepScreenOn(false).catchError((_) {}));
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
_images.dispose();
_zoom.reset();
_pages.dispose();
super.dispose();
}
Future<ReaderImage> _load(int page) => _images.full(page, _width, onLoaded: (r) {
if (mounted) _learn(page, r);
});
/// 보는 장 가까이만 남기고 (메모리) 다음 장은 미리 읽는다. 앞 장도 크기를 알아 둔다 (자동 나누기로 쪽 번호가 밀리지 않게)
void _trim() {
final page = _cur.page;
_images.trim(page);
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;
_images.clearPages();
_dims.clear();
_resetZoom();
_version++;
_v = const [];
_rebuildPages();
final i = indexOfPage(_v, 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) {
final step = volumeKeyStep(e, enabled: c.settings.reader.volumeKeys && ReaderPage.volumeKeysSupported());
if (step == null) return KeyEventResult.ignored;
if (step != 0) _go(step);
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 step = tapZone(d.localPosition.dx, width, rtl: c.settings.reader.rtl);
if (step != 0) {
_go(step);
} else {
setState(() => _bars = !_bars);
}
}
/// 그림을 누르거나 밀면 계속 보기를 멈춘다 (117)
void _onPointerDown() {
_finger(1);
_swallowTap = _playing;
if (_playing) {
_play(false);
_snack(tr('계속 보기를 멈췄습니다.'));
}
}
Widget _page(int i, Size box) {
final slot = _v[i];
return ReaderPageView(
slot: slot,
imageKey: '$_version:${slot.page}:${slot.part}:${_retry[slot.page] ?? 0}',
image: _images.sharp(i) ?? (slot.part >= 0 ? _images.half(slot.page, _width) : _load(slot.page)),
zoom: _zoom.of(i), zoomed: _zoom.zoomed, holdPage: _zoom.holdPage,
turns: _turns, flipV: _flipV, rtl: c.settings.reader.rtl, fit: c.settings.reader.fit, box: box,
onShown: () => _markShown(i), onZoomed: _setZoomed, onZoomEnd: () => _onZoomEnd(i),
// 관리자 지시 (10/20, HWP 2단계): [다시 시도] - 그 장의 기억을 지우고 다시 부른다
onRetry: () => setState(() {
_images.forget(slot.page, i);
_retry[slot.page] = (_retry[slot.page] ?? 0) + 1;
}),
);
}
Future<void> _updateSettings(void Function(dynamic s) f) async {
await c.updateSettings((s) => f(s));
if (mounted) setState(() {});
}
// ───────── PDF 편집 (reader_pdf_edit - 창은 지금 장면이 끝난 뒤에) ─────────
PdfSource? get _pdf => src is PdfSource ? src as PdfSource : null;
Future<void> _deletePage() async {
final page = await deletePdfPage(context, _pdf!, () => _cur.page, _snack);
if (page == null || !mounted) return;
setState(() => _reset(page: page));
}
Future<void> _insertImages() async {
final r = await insertPdfImages(context, c, _pdf!, () => _cur.page, _snack);
if (r == null || !mounted) return;
setState(() => _reset(page: r.page));
_snack(insertedPdfImagesText(r));
}
Future<void> _save() async {
if (await savePdf(_pdf!, _snack) && mounted) setState(() {});
}
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)));
}
// ───────── 화면 ─────────
/// 위 막대의 버튼들 (reader_bar_items)
List<ReaderBarItem> _barItems() => readerBarItems(
context,
c,
src,
(halfShown: _cur.part >= 0, playing: _playing, turns: _turns, flipV: _flipV, aiSource: readerAiSource(c, src, _cur.page, hasPages: _v.isNotEmpty)),
ReaderBarHandlers(
splitOff: _splitOff, play: _play, setSeconds: _setSeconds, cycleSplit: _cycleSplit, rotate: _rotate,
save: _save, deletePage: _deletePage, insertImages: _insertImages, toggleGallery: _toggleGallery,
photoInfo: () => showReaderPhotoInfo(context, src, () => _cur.page, hasPages: () => _v.isNotEmpty),
toggleFlip: () => setState(() => _flipV = !_flipV),
updateSettings: _updateSettings, applySystemBars: _applySystemBars,
upscalePage: _upscalePage, qualityPage: () => qualityReaderPage(context, c, src, _cur.page),
),
);
/// 121: AI 해상도 올리기 (reader_ai) - 저장한 뒤 올린 것으로 계속 보기 (설정) 면 그 장을 바꿔 다시 읽는다
Future<void> _upscalePage() async {
final page = _cur.page;
final saved = await upscaleReaderPage(context, c, src, page);
if (saved == null || !mounted) return;
setState(() {
(src as ImageFilesSource).paths[page] = saved;
_reset(page: page);
});
}
Future<void> _toggleGallery() async {
await c.updateSettings((x) => x.reader.gallery = !x.reader.gallery);
if (!mounted) return;
setState(() {});
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollGallery(animate: false));
}
/// [page] 번째 장 (파일) 으로 (두 쪽 나눠 보고 있으면 그 장의 첫 쪽)
void _goPage(int page) {
final i = indexOfPage(_v, page);
if (i >= 0 && _pages.hasClients) _pages.jumpToPage(i);
}
/// 갤러리 줄을 지금 장이 가운데 오게
void _scrollGallery({bool animate = true}) => scrollGalleryTo(_gallery, _cur.page, animate: animate);
Widget _bottomBar() {
final n = src.length;
return ReaderBottomBar(
pages: n, slots: _v.length, index: _index, label: slotLabel(_cur),
pageName: n == 0 ? '' : src.pageName(_cur.page), rtl: c.settings.reader.rtl, onSlide: _pages.jumpToPage,
gallery: _pictures && c.settings.reader.gallery && n > 0
? ReaderGalleryStrip(controller: _gallery, count: n, current: _cur.page, rtl: c.settings.reader.rtl,
thumb: _images.thumb, onTap: _goPage)
: null,
);
}
@override
Widget build(BuildContext context) {
final s = c.settings;
return PopScope(
canPop: _pdf?.dirty != true,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
if (await confirmPdfLeave(context, _pdf, _save) && context.mounted) {
_pdf?.dirty = false;
Navigator.of(context).pop();
}
},
// 키만 받는 화면 전체 감싸개 - 접근성 노드는 만들지 않는다 (28 지적 10/20: 장을 넘긴 뒤 창을 닫아 포커스가 돌아올 때
// 이 노드가 바뀌며 디버그 판에서 접근성 assert 'node.built' 가 났다. 화면 읽기 프로그램이 읽을 것은 안의 단추 · 그림)
child: Focus(
autofocus: true,
includeSemantics: false,
onKeyEvent: _onKey,
child: Scaffold(
backgroundColor: Colors.black,
body: ReaderStage(
version: _version,
count: _v.length,
controller: _pages,
rtl: s.reader.rtl,
holdPage: _zoom.holdPage,
brightness: s.reader.brightness,
onWidth: (w) => _width = w,
onPointerDown: _onPointerDown,
onPointerUp: () => _finger(-1),
onTapUp: _onTap,
onPageChanged: (i) {
setState(() {
_index = i;
_resetZoom();
_trim();
});
_scrollGallery();
},
page: _page,
topBar: _bars ? ReaderTopBar(title: '${src.title}${_pdf?.dirty == true ? ' *' : ''}', items: _barItems) : null,
bottomBar: _bars ? _bottomBar() : null,
),
),
),
);
}
}