From 4ec505db13ca3fdd4b39362d52a54a9c13356d49 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Tue, 20 Oct 2026 23:51:38 +0900 Subject: [PATCH] =?UTF-8?q?WIP=20=EA=B3=84=EC=86=8D=20=EB=B3=B4=EA=B8=B0?= =?UTF-8?q?=20=EC=8B=9C=EA=B3=84=EB=8A=94=20=EA=B7=B8=20=EC=9E=A5=EC=9D=B4?= =?UTF-8?q?=20=EB=8B=A4=20=EB=B3=B4=EC=9D=B8=20=EB=92=A4=EB=B6=80=ED=84=B0?= =?UTF-8?q?=20(=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=A7=80=EC=8B=9C=2010/20,?= =?UTF-8?q?=2028=20=EA=B2=80=ED=86=A0):=20WebDAV=20=EC=B2=98=EB=9F=BC=20?= =?UTF-8?q?=EB=8A=90=EB=A6=B0=20=EC=9B=90=EB=B3=B8=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=B0=9B=EB=8A=94=20=EB=8F=99=EC=95=88=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EC=9D=B4=20=ED=9D=98=EB=9F=AC=20=EB=B3=B4=EC=A7=80=EB=8F=84=20?= =?UTF-8?q?=EB=AA=BB=ED=95=9C=20=EC=9E=A5=EC=9D=84=20=EB=84=98=EA=B2=BC?= =?UTF-8?q?=EB=8B=A4=20-=20=EC=9E=A5=EB=A7=88=EB=8B=A4=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=88=20=ED=83=80=EC=9D=B4=EB=A8=B8=20(=EA=B7=B8=EB=A6=BC?= =?UTF-8?q?=20=EC=B2=AB=20=EC=9E=A5=EB=A9=B4=20=C2=B7=20=EC=8B=A4=ED=8C=A8?= =?UTF-8?q?=20=EA=B8=80=EC=9D=B4=20=EA=B7=B8=EB=A0=A4=EC=A7=84=20=EB=92=A4?= =?UTF-8?q?=20=EC=8B=9C=EC=9E=91,=20=EB=B0=9B=EB=8A=94=20=EC=A4=91?= =?UTF-8?q?=EC=97=90=EB=8A=94=20=EC=9E=AC=EC=A7=80=20=EC=95=8A=EC=9D=8C)?= =?UTF-8?q?=20=C2=B7=20=EC=8B=9C=ED=97=98:=20=EB=8A=90=EB=A6=B0=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B8=EC=97=90=EC=84=9C=2010=EC=B4=88=20=EB=8F=99?= =?UTF-8?q?=EC=95=88=20=EB=84=98=EA=B8=B0=EC=A7=80=20=EC=95=8A=EA=B3=A0=20?= =?UTF-8?q?=EB=8B=A4=20=EB=B0=9B=EC=9D=80=20=EB=92=A4=203=EC=B4=88?= =?UTF-8?q?=EC=97=90=20=EB=84=98=EA=B9=80,=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=8B=9C=ED=97=98=EC=9D=80=20=EB=84=98=EA=B8=B4=20=EB=92=A4=20?= =?UTF-8?q?=ED=95=9C=20=EC=9E=A5=EB=A9=B4=EC=9D=84=20=EB=8D=94=20=EC=A7=80?= =?UTF-8?q?=EB=82=98=EA=B2=8C=20(FutureBuilder=20=EA=B0=80=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC=EC=9D=84=20=EB=B0=9B=EC=95=84=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=8A=94=20=EC=9E=A5=EB=A9=B4)=20(=EA=B2=80=ED=86=A0=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- lib/ui/reader_page.dart | 52 +++++++++++++++++++++++++++++++++----- test/reader_more_test.dart | 49 ++++++++++++++++++++++++++++++++++- 2 files changed, 93 insertions(+), 8 deletions(-) diff --git a/lib/ui/reader_page.dart b/lib/ui/reader_page.dart index 7d0d24e..05d158e 100644 --- a/lib/ui/reader_page.dart +++ b/lib/ui/reader_page.dart @@ -269,32 +269,57 @@ class _ReaderPageState extends State { /// 계속 보기를 멈추게 한 누름은 장 넘기기 · 막대 숨기기로 쓰지 않는다 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; - if (on && _v.isNotEmpty) { - _timer = Timer.periodic(Duration(seconds: c.settings.readerAutoSeconds), (_) => _tick()); - } + // 지금 장이 이미 보이면 바로 재기 시작, 아직 받는 중이면 보인 뒤에 ([_markShown]) + if (on && _v.isNotEmpty && _shownKey == '$_version:$_index') _startTimer(); if (on == _playing) return; setState(() => _playing = on); unawaited(ReaderPage.keepScreenOn(on).catchError((_) {})); } void _tick() { - if (!mounted) return; + _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 _setSeconds(int d) async { await c.updateSettings((x) => x.readerAutoSeconds = (x.readerAutoSeconds + d).clamp(1, 60)); if (!mounted) return; setState(() {}); - if (_playing) _play(true); // 새 간격으로 + if (_playing) _play(true); // 새 간격으로 (지금 장이 보이면 처음부터 다시) } // ───────── 확대 (30) ───────── @@ -521,13 +546,26 @@ class _ReaderPageState extends State { key: ValueKey('$_version:${slot.page}:${slot.part}:${_retry[slot.page] ?? 0}'), future: _sharp[i] ?? (half ? _loadHalf(slot.page) : _load(slot.page)), builder: (context, snap) { - if (snap.hasError) return Center(child: failed(snap.error)); + // 계속 보기: 받는 동안은 시계를 재지 않고, 보인 (또는 실패 글이 보인) 뒤부터 + 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, - errorBuilder: (_, e, _) => failed(e, provider: r.provider)); + 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); diff --git a/test/reader_more_test.dart b/test/reader_more_test.dart index d7c7c02..1228b58 100644 --- a/test/reader_more_test.dart +++ b/test/reader_more_test.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; @@ -28,6 +29,18 @@ class _Sized extends ReaderSource { } } +/// 관리자 지시 10/20: WebDAV 처럼 느린 원본 - [hold] 의 장은 [release] 할 때까지 받는 중 +class _Slow extends _Sized { + final int hold; + final release = Completer(); + _Slow(super.images, this.hold); + @override + Future load(int i, {required int maxWidth}) async { + if (i == hold) await release.future; + return super.load(i, maxWidth: maxWidth); + } +} + /// 223: 폴더의 그림들처럼 (자동 나누기는 다른 그림과 견주어 판단) class _FolderSized extends ImageFilesSource { final List images; @@ -201,15 +214,49 @@ void main() { expect(find.text('1 / 4'), findsOneWidget); await t.pump(const Duration(milliseconds: 200)); expect(find.text('2 / 4'), findsOneWidget); - await t.pump(const Duration(seconds: 2)); + // 관리자 지시 10/20: 시계는 그 장이 다 보인 뒤부터 - 넘긴 뒤 한 장면 (FutureBuilder 가 그림을 받아 그림) 이 지나야 잰다 + await t.pump(); + await t.pump(const Duration(milliseconds: 1900)); + expect(find.text('2 / 4'), findsOneWidget); + await t.pump(const Duration(milliseconds: 200)); + expect(find.text('3 / 4'), findsOneWidget); + await t.pump(); await t.pump(const Duration(seconds: 2)); expect(find.text('4 / 4'), findsOneWidget); + await t.pump(); await t.pump(const Duration(seconds: 2)); expect(find.byTooltip('계속 보기 (2초마다 다음 장)'), findsOneWidget, reason: '마지막 장에서 멈춤'); expect(screenOn, [true, false]); await t.pumpAndSettle(const Duration(seconds: 3)); }); + testWidgets('117 (관리자 지시 10/20): 느린 원본 - 받는 동안은 넘기지 않고, 다 보인 뒤부터 정한 초를 잰다', (t) async { + final c = controller(); + final sized = await source(t, [(100, 200), (100, 200), (100, 200)]); + final src = _Slow(sized.images, 1); + await open(t, c, src); + await t.tap(find.byTooltip('계속 보기 (3초마다 다음 장)')); + await t.pump(); + await t.pump(const Duration(seconds: 3)); + expect(find.text('2 / 3'), findsOneWidget); + // 2 장은 받는 중 - 한참 지나도 넘기지 않는다 + for (var k = 0; k < 10; k++) { + await t.pump(const Duration(seconds: 1)); + } + expect(find.text('2 / 3'), findsOneWidget, reason: '받는 동안 시계가 흐르지 않음'); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + // 다 받으면 그때부터 3초 + src.release.complete(); + await t.pump(); + await t.pump(); + await t.pump(const Duration(milliseconds: 2900)); + expect(find.text('2 / 3'), findsOneWidget); + await t.pump(const Duration(milliseconds: 200)); + expect(find.text('3 / 3'), findsOneWidget); + await t.tap(find.byTooltip('계속 보기 멈추기')); + await t.pumpAndSettle(); + }); + testWidgets('117: 화면을 누르면 멈추고 그 누름으로는 넘기지 않음 · 손으로 밀어도 멈춤', (t) async { final c = controller(); final src = await source(t, [(100, 200), (100, 200), (100, 200), (100, 200)]);