WIP 계속 보기 시계는 그 장이 다 보인 뒤부터 (관리자 지시 10/20, 28 검토): WebDAV 처럼 느린 원본에서 받는 동안 시간이 흘러 보지도 못한 장을 넘겼다 - 장마다 한 번 타이머 (그림 첫 장면 · 실패 글이 그려진 뒤 시작, 받는 중에는 재지 않음) · 시험: 느린 원본에서 10초 동안 넘기지 않고 다 받은 뒤 3초에 넘김, 기존 시험은 넘긴 뒤 한 장면을 더 지나게 (FutureBuilder 가 그림을 받아 그리는 장면) (검토 대기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
xplore_view
RyuJongJin 2026-10-20 23:51:38 +09:00
parent 9ff8343835
commit 4ec505db13
2 changed files with 93 additions and 8 deletions

View File

@ -269,32 +269,57 @@ class _ReaderPageState extends State<ReaderPage> {
/// 계속 보기를 멈추게 한 누름은 장 넘기기 · 막대 숨기기로 쓰지 않는다 /// 계속 보기를 멈추게 한 누름은 장 넘기기 · 막대 숨기기로 쓰지 않는다
bool _swallowTap = false; 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) { void _play(bool on) {
_timer?.cancel(); _timer?.cancel();
_timer = null; _timer = null;
if (on && _v.isNotEmpty) { // 지금 장이 이미 보이면 바로 재기 시작, 아직 받는 중이면 보인 뒤에 ([_markShown])
_timer = Timer.periodic(Duration(seconds: c.settings.readerAutoSeconds), (_) => _tick()); if (on && _v.isNotEmpty && _shownKey == '$_version:$_index') _startTimer();
}
if (on == _playing) return; if (on == _playing) return;
setState(() => _playing = on); setState(() => _playing = on);
unawaited(ReaderPage.keepScreenOn(on).catchError((_) {})); unawaited(ReaderPage.keepScreenOn(on).catchError((_) {}));
} }
void _tick() { void _tick() {
if (!mounted) return; _timer = null;
if (!mounted || !_playing) return;
if (_index >= _v.length - 1) { if (_index >= _v.length - 1) {
_play(false); _play(false);
_snack(tr('마지막 장이라 계속 보기를 멈췄습니다.')); _snack(tr('마지막 장이라 계속 보기를 멈췄습니다.'));
return; return;
} }
_pages.jumpToPage(_index + 1); _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 { Future<void> _setSeconds(int d) async {
await c.updateSettings((x) => x.readerAutoSeconds = (x.readerAutoSeconds + d).clamp(1, 60)); await c.updateSettings((x) => x.readerAutoSeconds = (x.readerAutoSeconds + d).clamp(1, 60));
if (!mounted) return; if (!mounted) return;
setState(() {}); setState(() {});
if (_playing) _play(true); // 새 간격으로 if (_playing) _play(true); // 새 간격으로 (지금 장이 보이면 처음부터 다시)
} }
// ───────── 확대 (30) ───────── // ───────── 확대 (30) ─────────
@ -521,13 +546,26 @@ class _ReaderPageState extends State<ReaderPage> {
key: ValueKey('$_version:${slot.page}:${slot.part}:${_retry[slot.page] ?? 0}'), key: ValueKey('$_version:${slot.page}:${slot.part}:${_retry[slot.page] ?? 0}'),
future: _sharp[i] ?? (half ? _loadHalf(slot.page) : _load(slot.page)), future: _sharp[i] ?? (half ? _loadHalf(slot.page) : _load(slot.page)),
builder: (context, snap) { 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; final r = snap.data;
if (r == null) return const Center(child: CircularProgressIndicator()); if (r == null) return const Center(child: CircularProgressIndicator());
if (r.image != null) shown();
Widget picture = r.image != null Widget picture = r.image != null
? RawImage(image: r.image, filterQuality: FilterQuality.medium) ? RawImage(image: r.image, filterQuality: FilterQuality.medium)
: Image(image: r.provider!, gaplessPlayback: true, 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: 회전 · 상하 반전 (보기만, 파일은 그대로) // 120: 회전 · 상하 반전 (보기만, 파일은 그대로)
if (_turns != 0) picture = RotatedBox(quarterTurns: _turns, child: picture); if (_turns != 0) picture = RotatedBox(quarterTurns: _turns, child: picture);
if (_flipV) picture = Transform.flip(flipY: true, child: picture); if (_flipV) picture = Transform.flip(flipY: true, child: picture);

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/material.dart'; 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<void>();
_Slow(super.images, this.hold);
@override
Future<ReaderImage> load(int i, {required int maxWidth}) async {
if (i == hold) await release.future;
return super.load(i, maxWidth: maxWidth);
}
}
/// 223: 폴더의 그림들처럼 (자동 나누기는 다른 그림과 견주어 판단) /// 223: 폴더의 그림들처럼 (자동 나누기는 다른 그림과 견주어 판단)
class _FolderSized extends ImageFilesSource { class _FolderSized extends ImageFilesSource {
final List<ui.Image> images; final List<ui.Image> images;
@ -201,15 +214,49 @@ void main() {
expect(find.text('1 / 4'), findsOneWidget); expect(find.text('1 / 4'), findsOneWidget);
await t.pump(const Duration(milliseconds: 200)); await t.pump(const Duration(milliseconds: 200));
expect(find.text('2 / 4'), findsOneWidget); 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)); await t.pump(const Duration(seconds: 2));
expect(find.text('4 / 4'), findsOneWidget); expect(find.text('4 / 4'), findsOneWidget);
await t.pump();
await t.pump(const Duration(seconds: 2)); await t.pump(const Duration(seconds: 2));
expect(find.byTooltip('계속 보기 (2초마다 다음 장)'), findsOneWidget, reason: '마지막 장에서 멈춤'); expect(find.byTooltip('계속 보기 (2초마다 다음 장)'), findsOneWidget, reason: '마지막 장에서 멈춤');
expect(screenOn, [true, false]); expect(screenOn, [true, false]);
await t.pumpAndSettle(const Duration(seconds: 3)); 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 { testWidgets('117: 화면을 누르면 멈추고 그 누름으로는 넘기지 않음 · 손으로 밀어도 멈춤', (t) async {
final c = controller(); final c = controller();
final src = await source(t, [(100, 200), (100, 200), (100, 200), (100, 200)]); final src = await source(t, [(100, 200), (100, 200), (100, 200), (100, 200)]);