mirror of
https://github.com/kodjodevf/mangayomi.git
synced 2026-01-11 22:40:36 +00:00
feat: Implement virtual scrolling for manga reader with optimized page management
- Added VirtualMangaList widget for displaying manga pages in a virtual scrolling list. - Introduced VirtualPageManager to handle page loading, caching, and memory optimization. - Created VirtualReaderView to integrate virtual scrolling with PhotoView for enhanced reading experience. - Implemented page loading states and memory cleanup mechanisms in VirtualPageManager. - Added debug information overlay for monitoring virtual page manager statistics. - Enhanced user experience with callbacks for chapter transitions and page changes.
This commit is contained in:
parent
c3ac07fa97
commit
c24df38506
4 changed files with 1341 additions and 560 deletions
|
|
@ -18,7 +18,6 @@ import 'package:mangayomi/models/settings.dart';
|
|||
import 'package:mangayomi/modules/anime/widgets/desktop.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/providers/crop_borders_provider.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/widgets/btn_chapter_list_dialog.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/double_columm_view_vertical.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/double_columm_view_center.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/providers/color_filter_provider.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/widgets/color_filter_widget.dart';
|
||||
|
|
@ -35,13 +34,12 @@ import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
|
|||
import 'package:mangayomi/utils/extensions/others.dart';
|
||||
import 'package:mangayomi/utils/global_style.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/image_view_paged.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/image_view_vertical.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/providers/reader_controller_provider.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/widgets/circular_progress_indicator_animate_rotate.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/widgets/transition_view_vertical.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/widgets/transition_view_paged.dart';
|
||||
import 'package:mangayomi/modules/more/settings/reader/reader_screen.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/providers/manga_reader_provider.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/virtual_scrolling/virtual_reader_view.dart';
|
||||
import 'package:mangayomi/modules/widgets/progress_center.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:photo_view/photo_view_gallery.dart';
|
||||
|
|
@ -63,8 +61,7 @@ class MangaReaderView extends ConsumerWidget {
|
|||
|
||||
return chapterData.when(
|
||||
loading: () => scaffoldWith(context, const ProgressCenter()),
|
||||
error:
|
||||
(error, _) =>
|
||||
error: (error, _) =>
|
||||
scaffoldWith(context, Center(child: Text(error.toString()))),
|
||||
data: (data) {
|
||||
final chapter = data.chapter;
|
||||
|
|
@ -406,8 +403,8 @@ class _MangaChapterPageGalleryState
|
|||
context.l10n.save,
|
||||
Icons.save_outlined,
|
||||
() async {
|
||||
final dir =
|
||||
await StorageProvider().getGalleryDirectory();
|
||||
final dir = await StorageProvider()
|
||||
.getGalleryDirectory();
|
||||
final file = File(p.join(dir!.path, "$name.png"));
|
||||
file.writeAsBytesSync(imageBytes);
|
||||
if (context.mounted) {
|
||||
|
|
@ -532,121 +529,93 @@ class _MangaChapterPageGalleryState
|
|||
_isVerticalOrHorizontalContinous()
|
||||
? PhotoViewGallery.builder(
|
||||
itemCount: 1,
|
||||
builder:
|
||||
(_, _) => PhotoViewGalleryPageOptions.customChild(
|
||||
builder: (_, _) =>
|
||||
PhotoViewGalleryPageOptions.customChild(
|
||||
controller: _photoViewController,
|
||||
scaleStateController:
|
||||
_photoViewScaleStateController,
|
||||
basePosition: _scalePosition,
|
||||
onScaleEnd: _onScaleEnd,
|
||||
child: ScrollablePositionedList.separated(
|
||||
scrollDirection:
|
||||
isHorizontalContinuaous
|
||||
child: VirtualReaderView(
|
||||
pages: _uChapDataPreload,
|
||||
itemScrollController: _itemScrollController,
|
||||
scrollOffsetController:
|
||||
_pageOffsetController,
|
||||
itemPositionsListener:
|
||||
_itemPositionsListener,
|
||||
scrollDirection: isHorizontalContinuaous
|
||||
? Axis.horizontal
|
||||
: Axis.vertical,
|
||||
minCacheExtent:
|
||||
pagePreloadAmount * context.height(1),
|
||||
initialScrollIndex:
|
||||
_readerController.getPageIndex(),
|
||||
itemCount:
|
||||
(_pageMode == PageMode.doublePage &&
|
||||
!isHorizontalContinuaous)
|
||||
? (_uChapDataPreload.length / 2)
|
||||
.ceil() +
|
||||
1
|
||||
: _uChapDataPreload.length,
|
||||
initialScrollIndex: _readerController
|
||||
.getPageIndex(),
|
||||
physics: const ClampingScrollPhysics(),
|
||||
itemScrollController: _itemScrollController,
|
||||
scrollOffsetController: _pageOffsetController,
|
||||
itemPositionsListener: _itemPositionsListener,
|
||||
itemBuilder: (context, index) {
|
||||
if (_uChapDataPreload[index]
|
||||
.isTransitionPage) {
|
||||
return TransitionViewVertical(
|
||||
data: _uChapDataPreload[index],
|
||||
);
|
||||
onLongPressData: (data) =>
|
||||
_onLongPressImageDialog(data, context),
|
||||
onFailedToLoadImage: (value) {
|
||||
// Handle failed image loading
|
||||
if (_failedToLoadImage.value != value &&
|
||||
mounted) {
|
||||
_failedToLoadImage.value = value;
|
||||
}
|
||||
|
||||
int index1 = index * 2 - 1;
|
||||
int index2 = index1 + 1;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onDoubleTapDown: (details) {
|
||||
_toggleScale(details.globalPosition);
|
||||
},
|
||||
backgroundColor: backgroundColor,
|
||||
isDoublePageMode:
|
||||
_pageMode == PageMode.doublePage &&
|
||||
!isHorizontalContinuaous,
|
||||
isHorizontalContinuous:
|
||||
isHorizontalContinuaous,
|
||||
readerMode: ref.watch(_currentReaderMode)!,
|
||||
photoViewController: _photoViewController,
|
||||
photoViewScaleStateController:
|
||||
_photoViewScaleStateController,
|
||||
scalePosition: _scalePosition,
|
||||
onScaleEnd: (details) => _onScaleEnd(
|
||||
context,
|
||||
details,
|
||||
_photoViewController.value,
|
||||
),
|
||||
onDoubleTapDown: (offset) =>
|
||||
_toggleScale(offset),
|
||||
onDoubleTap: () {},
|
||||
child:
|
||||
(_pageMode == PageMode.doublePage &&
|
||||
!isHorizontalContinuaous)
|
||||
? DoubleColummVerticalView(
|
||||
datas:
|
||||
index == 0
|
||||
? [
|
||||
_uChapDataPreload[0],
|
||||
null,
|
||||
]
|
||||
: [
|
||||
index1 <
|
||||
_uChapDataPreload
|
||||
.length
|
||||
? _uChapDataPreload[index1]
|
||||
: null,
|
||||
index2 <
|
||||
_uChapDataPreload
|
||||
.length
|
||||
? _uChapDataPreload[index2]
|
||||
: null,
|
||||
],
|
||||
backgroundColor:
|
||||
backgroundColor,
|
||||
isFailedToLoadImage: (val) {},
|
||||
onLongPressData: (datas) {
|
||||
_onLongPressImageDialog(
|
||||
datas,
|
||||
context,
|
||||
// Chapter transition callbacks
|
||||
onChapterChanged: (newChapter) {
|
||||
// Update the current chapter when a chapter change is detected
|
||||
if (newChapter.id != chapter.id) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_readerController = ref.read(
|
||||
readerControllerProvider(
|
||||
chapter: newChapter,
|
||||
).notifier,
|
||||
);
|
||||
chapter = newChapter;
|
||||
_isBookmarked = _readerController
|
||||
.getChapterBookmarked();
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
onReachedLastPage: (lastPageIndex) {
|
||||
try {
|
||||
ref
|
||||
.watch(
|
||||
getChapterPagesProvider(
|
||||
chapter: _readerController
|
||||
.getNextChapter(),
|
||||
).future,
|
||||
)
|
||||
: ImageViewVertical(
|
||||
data: _uChapDataPreload[index],
|
||||
failedToLoadImage: (value) {
|
||||
// _failedToLoadImage.value = value;
|
||||
},
|
||||
onLongPressData: (datas) {
|
||||
_onLongPressImageDialog(
|
||||
datas,
|
||||
context,
|
||||
);
|
||||
},
|
||||
isHorizontal:
|
||||
ref.watch(
|
||||
_currentReaderMode,
|
||||
) ==
|
||||
ReaderMode
|
||||
.horizontalContinuous,
|
||||
.then(
|
||||
(value) => _preloadNextChapter(
|
||||
value,
|
||||
chapter,
|
||||
),
|
||||
);
|
||||
} on RangeError {
|
||||
_addLastPageTransition(chapter);
|
||||
}
|
||||
},
|
||||
separatorBuilder:
|
||||
(_, __) =>
|
||||
ref.watch(_currentReaderMode) ==
|
||||
ReaderMode.webtoon
|
||||
? const SizedBox.shrink()
|
||||
: ref.watch(_currentReaderMode) ==
|
||||
ReaderMode
|
||||
.horizontalContinuous
|
||||
? VerticalDivider(
|
||||
color: getBackgroundColor(
|
||||
backgroundColor,
|
||||
),
|
||||
width: 6,
|
||||
)
|
||||
: Divider(
|
||||
color: getBackgroundColor(
|
||||
backgroundColor,
|
||||
),
|
||||
height: 6,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -675,22 +644,18 @@ class _MangaChapterPageGalleryState
|
|||
|
||||
int index1 = index * 2 - 1;
|
||||
int index2 = index1 + 1;
|
||||
final pageList =
|
||||
(index == 0
|
||||
final pageList = (index == 0
|
||||
? [_uChapDataPreload[0], null]
|
||||
: [
|
||||
index1 <
|
||||
_uChapDataPreload.length
|
||||
index1 < _uChapDataPreload.length
|
||||
? _uChapDataPreload[index1]
|
||||
: null,
|
||||
index2 <
|
||||
_uChapDataPreload.length
|
||||
index2 < _uChapDataPreload.length
|
||||
? _uChapDataPreload[index2]
|
||||
: null,
|
||||
]);
|
||||
return DoubleColummView(
|
||||
datas:
|
||||
_isReverseHorizontal
|
||||
datas: _isReverseHorizontal
|
||||
? pageList.reversed.toList()
|
||||
: pageList,
|
||||
backgroundColor: backgroundColor,
|
||||
|
|
@ -723,10 +688,7 @@ class _MangaChapterPageGalleryState
|
|||
? !(gestureDetails.totalScale! > 1.0)
|
||||
: true;
|
||||
},
|
||||
itemBuilder: (
|
||||
BuildContext context,
|
||||
int index,
|
||||
) {
|
||||
itemBuilder: (BuildContext context, int index) {
|
||||
if (_uChapDataPreload[index]
|
||||
.isTransitionPage) {
|
||||
return TransitionViewPaged(
|
||||
|
|
@ -779,7 +741,8 @@ class _MangaChapterPageGalleryState
|
|||
return ExtendedImageGesture(
|
||||
state,
|
||||
canScaleImage: (_) => true,
|
||||
imageBuilder: (
|
||||
imageBuilder:
|
||||
(
|
||||
Widget image, {
|
||||
ExtendedImageGestureState?
|
||||
imageGestureState,
|
||||
|
|
@ -829,17 +792,18 @@ class _MangaChapterPageGalleryState
|
|||
onLongPress: () {
|
||||
state.reLoadImage();
|
||||
_failedToLoadImage
|
||||
.value = false;
|
||||
.value =
|
||||
false;
|
||||
},
|
||||
onTap: () {
|
||||
state.reLoadImage();
|
||||
_failedToLoadImage
|
||||
.value = false;
|
||||
.value =
|
||||
false;
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
context
|
||||
color: context
|
||||
.primaryColor,
|
||||
borderRadius:
|
||||
BorderRadius.circular(
|
||||
|
|
@ -911,7 +875,8 @@ class _MangaChapterPageGalleryState
|
|||
);
|
||||
};
|
||||
|
||||
_doubleClickAnimation = Tween(
|
||||
_doubleClickAnimation =
|
||||
Tween(
|
||||
begin: begin,
|
||||
end: end,
|
||||
).animate(
|
||||
|
|
@ -969,8 +934,9 @@ class _MangaChapterPageGalleryState
|
|||
}
|
||||
|
||||
Duration? _doubleTapAnimationDuration() {
|
||||
int doubleTapAnimationValue =
|
||||
isar.settings.getSync(227)!.doubleTapAnimationSpeed!;
|
||||
int doubleTapAnimationValue = isar.settings
|
||||
.getSync(227)!
|
||||
.doubleTapAnimationSpeed!;
|
||||
if (doubleTapAnimationValue == 0) {
|
||||
return const Duration(milliseconds: 10);
|
||||
} else if (doubleTapAnimationValue == 1) {
|
||||
|
|
@ -1075,13 +1041,11 @@ class _MangaChapterPageGalleryState
|
|||
pageIndex: currentLength,
|
||||
);
|
||||
|
||||
final newPages =
|
||||
chapterData.uChapDataPreload
|
||||
final newPages = chapterData.uChapDataPreload
|
||||
.asMap()
|
||||
.entries
|
||||
.map(
|
||||
(entry) =>
|
||||
entry.value..pageIndex = currentLength + 1 + entry.key,
|
||||
(entry) => entry.value..pageIndex = currentLength + 1 + entry.key,
|
||||
)
|
||||
.toList();
|
||||
|
||||
|
|
@ -1095,8 +1059,7 @@ class _MangaChapterPageGalleryState
|
|||
}
|
||||
|
||||
bool _isChapterAlreadyLoaded(Chapter chapter) {
|
||||
final existingIdentifiers =
|
||||
_uChapDataPreload
|
||||
final existingIdentifiers = _uChapDataPreload
|
||||
.map((item) => item.chapter)
|
||||
.where((ch) => ch != null)
|
||||
.map((ch) => _getChapterIdentifier(ch!))
|
||||
|
|
@ -1350,8 +1313,7 @@ class _MangaChapterPageGalleryState
|
|||
|
||||
int index =
|
||||
(_pageMode == PageMode.doublePage &&
|
||||
!(ref.watch(_currentReaderMode) ==
|
||||
ReaderMode.horizontalContinuous))
|
||||
!(ref.watch(_currentReaderMode) == ReaderMode.horizontalContinuous))
|
||||
? (_currentIndex! / 2).ceil()
|
||||
: _currentIndex!;
|
||||
ref.read(_currentReaderMode.notifier).state = value;
|
||||
|
|
@ -1406,8 +1368,8 @@ class _MangaChapterPageGalleryState
|
|||
).future,
|
||||
)
|
||||
.then((value) {
|
||||
_uChapDataPreload[index] =
|
||||
_uChapDataPreload[index]..cropImage = value;
|
||||
_uChapDataPreload[index] = _uChapDataPreload[index]
|
||||
..cropImage = value;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
|
|
@ -1446,8 +1408,7 @@ class _MangaChapterPageGalleryState
|
|||
|
||||
Widget _appBar() {
|
||||
final fullScreenReader = ref.watch(fullScreenReaderStateProvider);
|
||||
double height =
|
||||
_isView
|
||||
double height = _isView
|
||||
? Platform.isIOS
|
||||
? 120
|
||||
: !fullScreenReader && !isDesktop
|
||||
|
|
@ -1542,25 +1503,19 @@ class _MangaChapterPageGalleryState
|
|||
? Positioned(
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
child:
|
||||
!_isView
|
||||
child: !_isView
|
||||
? ValueListenableBuilder(
|
||||
valueListenable: _autoScrollPage,
|
||||
builder:
|
||||
(context, valueT, child) =>
|
||||
valueT
|
||||
builder: (context, valueT, child) => valueT
|
||||
? ValueListenableBuilder(
|
||||
valueListenable: _autoScroll,
|
||||
builder:
|
||||
(context, value, child) => IconButton(
|
||||
builder: (context, value, child) => IconButton(
|
||||
onPressed: () {
|
||||
_autoPagescroll();
|
||||
_autoScroll.value = !value;
|
||||
},
|
||||
icon: Icon(
|
||||
value
|
||||
? Icons.pause_circle
|
||||
: Icons.play_circle,
|
||||
value ? Icons.pause_circle : Icons.play_circle,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -1600,13 +1555,11 @@ class _MangaChapterPageGalleryState
|
|||
radius: 23,
|
||||
backgroundColor: _backgroundColor(context),
|
||||
child: IconButton(
|
||||
onPressed:
|
||||
hasPrevChapter
|
||||
onPressed: hasPrevChapter
|
||||
? () {
|
||||
pushReplacementMangaReaderView(
|
||||
context: context,
|
||||
chapter:
|
||||
_readerController.getPrevChapter(),
|
||||
chapter: _readerController.getPrevChapter(),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
|
|
@ -1614,11 +1567,8 @@ class _MangaChapterPageGalleryState
|
|||
scaleX: 1,
|
||||
child: Icon(
|
||||
Icons.skip_previous_rounded,
|
||||
color:
|
||||
hasPrevChapter
|
||||
? Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge!.color
|
||||
color: hasPrevChapter
|
||||
? Theme.of(context).textTheme.bodyLarge!.color
|
||||
: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge!
|
||||
|
|
@ -1694,15 +1644,13 @@ class _MangaChapterPageGalleryState
|
|||
},
|
||||
onChangeEnd: (newValue) {
|
||||
try {
|
||||
final index =
|
||||
_uChapDataPreload
|
||||
final index = _uChapDataPreload
|
||||
.firstWhere(
|
||||
(element) =>
|
||||
element.chapter ==
|
||||
chapter &&
|
||||
element.index ==
|
||||
newValue
|
||||
.toInt(),
|
||||
newValue.toInt(),
|
||||
)
|
||||
.pageIndex;
|
||||
|
||||
|
|
@ -1715,13 +1663,11 @@ class _MangaChapterPageGalleryState
|
|||
},
|
||||
divisions:
|
||||
_readerController.getPageLength(
|
||||
_chapterUrlModel
|
||||
.pageUrls,
|
||||
_chapterUrlModel.pageUrls,
|
||||
) ==
|
||||
1
|
||||
? null
|
||||
: _pageMode ==
|
||||
PageMode.doublePage
|
||||
: _pageMode == PageMode.doublePage
|
||||
? ((_readerController.getPageLength(
|
||||
_chapterUrlModel
|
||||
.pageUrls,
|
||||
|
|
@ -1729,10 +1675,8 @@ class _MangaChapterPageGalleryState
|
|||
2)
|
||||
.ceil() +
|
||||
1
|
||||
: _readerController
|
||||
.getPageLength(
|
||||
_chapterUrlModel
|
||||
.pageUrls,
|
||||
: _readerController.getPageLength(
|
||||
_chapterUrlModel.pageUrls,
|
||||
) -
|
||||
1,
|
||||
value: min(
|
||||
|
|
@ -1743,8 +1687,7 @@ class _MangaChapterPageGalleryState
|
|||
) ==
|
||||
ReaderMode
|
||||
.horizontalContinuous))
|
||||
? ((_readerController
|
||||
.getPageLength(
|
||||
? ((_readerController.getPageLength(
|
||||
_chapterUrlModel
|
||||
.pageUrls,
|
||||
)) /
|
||||
|
|
@ -1753,7 +1696,8 @@ class _MangaChapterPageGalleryState
|
|||
1
|
||||
: (_readerController
|
||||
.getPageLength(
|
||||
_chapterUrlModel.pageUrls,
|
||||
_chapterUrlModel
|
||||
.pageUrls,
|
||||
)
|
||||
.toDouble()),
|
||||
),
|
||||
|
|
@ -1815,13 +1759,11 @@ class _MangaChapterPageGalleryState
|
|||
radius: 23,
|
||||
backgroundColor: _backgroundColor(context),
|
||||
child: IconButton(
|
||||
onPressed:
|
||||
hasNextChapter
|
||||
onPressed: hasNextChapter
|
||||
? () {
|
||||
pushReplacementMangaReaderView(
|
||||
context: context,
|
||||
chapter:
|
||||
_readerController.getNextChapter(),
|
||||
chapter: _readerController.getNextChapter(),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
|
|
@ -1829,11 +1771,8 @@ class _MangaChapterPageGalleryState
|
|||
scaleX: 1,
|
||||
child: Icon(
|
||||
Icons.skip_next_rounded,
|
||||
color:
|
||||
hasNextChapter
|
||||
? Theme.of(
|
||||
context,
|
||||
).textTheme.bodyLarge!.color
|
||||
color: hasNextChapter
|
||||
? Theme.of(context).textTheme.bodyLarge!.color
|
||||
: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge!
|
||||
|
|
@ -1864,8 +1803,7 @@ class _MangaChapterPageGalleryState
|
|||
ref.read(_currentReaderMode.notifier).state = value;
|
||||
_setReaderMode(value, ref);
|
||||
},
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
itemBuilder: (context) => [
|
||||
for (var mode in ReaderMode.values)
|
||||
PopupMenuItem(
|
||||
value: mode,
|
||||
|
|
@ -1873,8 +1811,7 @@ class _MangaChapterPageGalleryState
|
|||
children: [
|
||||
Icon(
|
||||
Icons.check,
|
||||
color:
|
||||
readerMode == mode
|
||||
color: readerMode == mode
|
||||
? Colors.white
|
||||
: Colors.transparent,
|
||||
),
|
||||
|
|
@ -1944,8 +1881,7 @@ class _MangaChapterPageGalleryState
|
|||
true,
|
||||
isSlide: true,
|
||||
);
|
||||
newPageMode =
|
||||
_pageMode == PageMode.onePage
|
||||
newPageMode = _pageMode == PageMode.onePage
|
||||
? PageMode.doublePage
|
||||
: PageMode.onePage;
|
||||
|
||||
|
|
@ -2070,29 +2006,27 @@ class _MangaChapterPageGalleryState
|
|||
_isViewFunction();
|
||||
}
|
||||
},
|
||||
onDoubleTapDown:
|
||||
_isVerticalOrHorizontalContinous()
|
||||
onDoubleTapDown: _isVerticalOrHorizontalContinous()
|
||||
? (details) {
|
||||
_toggleScale(details.globalPosition);
|
||||
}
|
||||
: null,
|
||||
onSecondaryTapDown:
|
||||
_isVerticalOrHorizontalContinous()
|
||||
onSecondaryTapDown: _isVerticalOrHorizontalContinous()
|
||||
? (details) {
|
||||
_toggleScale(details.globalPosition);
|
||||
}
|
||||
: null,
|
||||
onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null,
|
||||
onSecondaryTap:
|
||||
_isVerticalOrHorizontalContinous() ? () {} : null,
|
||||
onSecondaryTap: _isVerticalOrHorizontalContinous()
|
||||
? () {}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
|
||||
/// center region
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child:
|
||||
failedToLoadImage
|
||||
child: failedToLoadImage
|
||||
? SizedBox(
|
||||
width: context.width(1),
|
||||
height: context.height(0.7),
|
||||
|
|
@ -2102,22 +2036,22 @@ class _MangaChapterPageGalleryState
|
|||
onTap: () {
|
||||
_isViewFunction();
|
||||
},
|
||||
onDoubleTapDown:
|
||||
_isVerticalOrHorizontalContinous()
|
||||
onDoubleTapDown: _isVerticalOrHorizontalContinous()
|
||||
? (details) {
|
||||
_toggleScale(details.globalPosition);
|
||||
}
|
||||
: null,
|
||||
onSecondaryTapDown:
|
||||
_isVerticalOrHorizontalContinous()
|
||||
onSecondaryTapDown: _isVerticalOrHorizontalContinous()
|
||||
? (details) {
|
||||
_toggleScale(details.globalPosition);
|
||||
}
|
||||
: null,
|
||||
onDoubleTap:
|
||||
_isVerticalOrHorizontalContinous() ? () {} : null,
|
||||
onSecondaryTap:
|
||||
_isVerticalOrHorizontalContinous() ? () {} : null,
|
||||
onDoubleTap: _isVerticalOrHorizontalContinous()
|
||||
? () {}
|
||||
: null,
|
||||
onSecondaryTap: _isVerticalOrHorizontalContinous()
|
||||
? () {}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
|
||||
|
|
@ -2137,21 +2071,20 @@ class _MangaChapterPageGalleryState
|
|||
_isViewFunction();
|
||||
}
|
||||
},
|
||||
onDoubleTapDown:
|
||||
_isVerticalOrHorizontalContinous()
|
||||
onDoubleTapDown: _isVerticalOrHorizontalContinous()
|
||||
? (details) {
|
||||
_toggleScale(details.globalPosition);
|
||||
}
|
||||
: null,
|
||||
onSecondaryTapDown:
|
||||
_isVerticalOrHorizontalContinous()
|
||||
onSecondaryTapDown: _isVerticalOrHorizontalContinous()
|
||||
? (details) {
|
||||
_toggleScale(details.globalPosition);
|
||||
}
|
||||
: null,
|
||||
onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null,
|
||||
onSecondaryTap:
|
||||
_isVerticalOrHorizontalContinous() ? () {} : null,
|
||||
onSecondaryTap: _isVerticalOrHorizontalContinous()
|
||||
? () {}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -2177,21 +2110,20 @@ class _MangaChapterPageGalleryState
|
|||
? _onBtnTapped(_currentIndex! - 1, true)
|
||||
: _isViewFunction();
|
||||
},
|
||||
onDoubleTapDown:
|
||||
_isVerticalOrHorizontalContinous()
|
||||
onDoubleTapDown: _isVerticalOrHorizontalContinous()
|
||||
? (details) {
|
||||
_toggleScale(details.globalPosition);
|
||||
}
|
||||
: null,
|
||||
onSecondaryTapDown:
|
||||
_isVerticalOrHorizontalContinous()
|
||||
onSecondaryTapDown: _isVerticalOrHorizontalContinous()
|
||||
? (details) {
|
||||
_toggleScale(details.globalPosition);
|
||||
}
|
||||
: null,
|
||||
onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null,
|
||||
onSecondaryTap:
|
||||
_isVerticalOrHorizontalContinous() ? () {} : null,
|
||||
onSecondaryTap: _isVerticalOrHorizontalContinous()
|
||||
? () {}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
|
||||
|
|
@ -2210,21 +2142,20 @@ class _MangaChapterPageGalleryState
|
|||
? _onBtnTapped(_currentIndex! + 1, false)
|
||||
: _isViewFunction();
|
||||
},
|
||||
onDoubleTapDown:
|
||||
_isVerticalOrHorizontalContinous()
|
||||
onDoubleTapDown: _isVerticalOrHorizontalContinous()
|
||||
? (details) {
|
||||
_toggleScale(details.globalPosition);
|
||||
}
|
||||
: null,
|
||||
onSecondaryTapDown:
|
||||
_isVerticalOrHorizontalContinous()
|
||||
onSecondaryTapDown: _isVerticalOrHorizontalContinous()
|
||||
? (details) {
|
||||
_toggleScale(details.globalPosition);
|
||||
}
|
||||
: null,
|
||||
onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null,
|
||||
onSecondaryTap:
|
||||
_isVerticalOrHorizontalContinous() ? () {} : null,
|
||||
onSecondaryTap: _isVerticalOrHorizontalContinous()
|
||||
? () {}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
@ -2341,8 +2272,7 @@ class _MangaChapterPageGalleryState
|
|||
if (valueT)
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _pageOffset,
|
||||
builder:
|
||||
(context, value, child) => Slider(
|
||||
builder: (context, value, child) => Slider(
|
||||
min: 2.0,
|
||||
max: 30.0,
|
||||
divisions: max(28, 3),
|
||||
|
|
@ -2406,12 +2336,11 @@ class _MangaChapterPageGalleryState
|
|||
.set(ScaleType.values[value.index]);
|
||||
},
|
||||
value: scaleType,
|
||||
list:
|
||||
ScaleType.values.where((scale) {
|
||||
list: ScaleType.values.where((scale) {
|
||||
try {
|
||||
return getScaleTypeNames(context).contains(
|
||||
getScaleTypeNames(context)[scale.index],
|
||||
);
|
||||
return getScaleTypeNames(
|
||||
context,
|
||||
).contains(getScaleTypeNames(context)[scale.index]);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -2642,8 +2571,7 @@ class CustomPopupMenuButton<T> extends StatelessWidget {
|
|||
offset: Offset.fromDirection(1),
|
||||
color: Colors.black,
|
||||
onSelected: onSelected,
|
||||
itemBuilder:
|
||||
(context) => [
|
||||
itemBuilder: (context) => [
|
||||
for (var d in list)
|
||||
PopupMenuItem(
|
||||
value: d,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,343 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
import 'package:mangayomi/models/settings.dart';
|
||||
import 'package:mangayomi/models/chapter.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/virtual_scrolling/virtual_page_manager.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/reader_view.dart' as reader;
|
||||
import 'package:mangayomi/modules/manga/reader/image_view_vertical.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/double_columm_view_vertical.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/widgets/transition_view_vertical.dart';
|
||||
import 'package:mangayomi/modules/more/settings/reader/reader_screen.dart';
|
||||
import 'package:mangayomi/utils/extensions/build_context_extensions.dart';
|
||||
|
||||
/// Widget for displaying manga pages in a virtual scrolling list
|
||||
class VirtualMangaList extends ConsumerStatefulWidget {
|
||||
final VirtualPageManager pageManager;
|
||||
final ItemScrollController itemScrollController;
|
||||
final ScrollOffsetController scrollOffsetController;
|
||||
final ItemPositionsListener itemPositionsListener;
|
||||
final Axis scrollDirection;
|
||||
final double minCacheExtent;
|
||||
final int initialScrollIndex;
|
||||
final ScrollPhysics physics;
|
||||
final Function(reader.UChapDataPreload data) onLongPressData;
|
||||
final Function(bool) onFailedToLoadImage;
|
||||
final BackgroundColor backgroundColor;
|
||||
final bool isDoublePageMode;
|
||||
final bool isHorizontalContinuous;
|
||||
final ReaderMode readerMode;
|
||||
final Function(Offset) onDoubleTapDown;
|
||||
final VoidCallback onDoubleTap;
|
||||
final Function(Chapter chapter)? onChapterChanged;
|
||||
final Function(int lastPageIndex)? onReachedLastPage;
|
||||
final Function(int index)? onPageChanged;
|
||||
|
||||
const VirtualMangaList({
|
||||
super.key,
|
||||
required this.pageManager,
|
||||
required this.itemScrollController,
|
||||
required this.scrollOffsetController,
|
||||
required this.itemPositionsListener,
|
||||
required this.scrollDirection,
|
||||
required this.minCacheExtent,
|
||||
required this.initialScrollIndex,
|
||||
required this.physics,
|
||||
required this.onLongPressData,
|
||||
required this.onFailedToLoadImage,
|
||||
required this.backgroundColor,
|
||||
required this.isDoublePageMode,
|
||||
required this.isHorizontalContinuous,
|
||||
required this.readerMode,
|
||||
required this.onDoubleTapDown,
|
||||
required this.onDoubleTap,
|
||||
this.onChapterChanged,
|
||||
this.onReachedLastPage,
|
||||
this.onPageChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<VirtualMangaList> createState() => _VirtualMangaListState();
|
||||
}
|
||||
|
||||
class _VirtualMangaListState extends ConsumerState<VirtualMangaList> {
|
||||
Chapter? _currentChapter;
|
||||
int? _currentIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Listen to item positions to update virtual page manager
|
||||
widget.itemPositionsListener.itemPositions.addListener(_onPositionChanged);
|
||||
|
||||
// Initialize current chapter
|
||||
if (widget.pageManager.pageCount > 0) {
|
||||
final firstPage = widget.pageManager.getOriginalPage(
|
||||
widget.initialScrollIndex,
|
||||
);
|
||||
_currentChapter = firstPage?.chapter;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.itemPositionsListener.itemPositions.removeListener(
|
||||
_onPositionChanged,
|
||||
);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onPositionChanged() {
|
||||
final positions = widget.itemPositionsListener.itemPositions.value;
|
||||
if (positions.isNotEmpty) {
|
||||
// Get the first visible item
|
||||
final firstVisibleIndex = positions.first.index;
|
||||
final lastVisibleIndex = positions.last.index;
|
||||
|
||||
// Update virtual page manager
|
||||
widget.pageManager.updateVisibleIndex(firstVisibleIndex);
|
||||
|
||||
// Calculate actual page lengths considering page mode
|
||||
int pagesLength =
|
||||
widget.isDoublePageMode && !widget.isHorizontalContinuous
|
||||
? (widget.pageManager.pageCount / 2).ceil() + 1
|
||||
: widget.pageManager.pageCount;
|
||||
|
||||
// Check if index is valid
|
||||
if (firstVisibleIndex >= 0 && firstVisibleIndex < pagesLength) {
|
||||
final currentPage = widget.pageManager.getOriginalPage(
|
||||
firstVisibleIndex,
|
||||
);
|
||||
|
||||
if (currentPage != null) {
|
||||
// Check for chapter change
|
||||
if (_currentChapter?.id != currentPage.chapter?.id &&
|
||||
currentPage.chapter != null) {
|
||||
_currentChapter = currentPage.chapter;
|
||||
widget.onChapterChanged?.call(currentPage.chapter!);
|
||||
}
|
||||
|
||||
// Update current index
|
||||
if (_currentIndex != firstVisibleIndex) {
|
||||
_currentIndex = firstVisibleIndex;
|
||||
widget.onPageChanged?.call(firstVisibleIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if reached last page to trigger next chapter preload
|
||||
if (lastVisibleIndex >= pagesLength - 1) {
|
||||
widget.onReachedLastPage?.call(lastVisibleIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: widget.pageManager,
|
||||
builder: (context, child) {
|
||||
final itemCount =
|
||||
widget.isDoublePageMode && !widget.isHorizontalContinuous
|
||||
? (widget.pageManager.pageCount / 2).ceil() + 1
|
||||
: widget.pageManager.pageCount;
|
||||
|
||||
return ScrollablePositionedList.separated(
|
||||
scrollDirection: widget.scrollDirection,
|
||||
minCacheExtent: widget.minCacheExtent,
|
||||
initialScrollIndex: widget.initialScrollIndex,
|
||||
itemCount: itemCount,
|
||||
physics: widget.physics,
|
||||
itemScrollController: widget.itemScrollController,
|
||||
scrollOffsetController: widget.scrollOffsetController,
|
||||
itemPositionsListener: widget.itemPositionsListener,
|
||||
itemBuilder: (context, index) => _buildItem(context, index),
|
||||
separatorBuilder: _buildSeparator,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItem(BuildContext context, int index) {
|
||||
if (widget.isDoublePageMode && !widget.isHorizontalContinuous) {
|
||||
return _buildDoublePageItem(context, index);
|
||||
} else {
|
||||
return _buildSinglePageItem(context, index);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSinglePageItem(BuildContext context, int index) {
|
||||
final originalPage = widget.pageManager.getOriginalPage(index);
|
||||
if (originalPage == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
// Check if page should be loaded
|
||||
final pageInfo = widget.pageManager.getPageInfo(index);
|
||||
final shouldLoad = widget.pageManager.shouldPageBeLoaded(index);
|
||||
|
||||
if (!shouldLoad &&
|
||||
(pageInfo?.loadState == PageLoadState.notLoaded || pageInfo == null)) {
|
||||
// Return placeholder for unloaded pages
|
||||
return _buildPlaceholder(context);
|
||||
}
|
||||
|
||||
if (originalPage.isTransitionPage) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onDoubleTapDown: (details) =>
|
||||
widget.onDoubleTapDown(details.globalPosition),
|
||||
onDoubleTap: widget.onDoubleTap,
|
||||
child: TransitionViewVertical(data: originalPage),
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onDoubleTapDown: (details) =>
|
||||
widget.onDoubleTapDown(details.globalPosition),
|
||||
onDoubleTap: widget.onDoubleTap,
|
||||
child: ImageViewVertical(
|
||||
data: originalPage,
|
||||
failedToLoadImage: widget.onFailedToLoadImage,
|
||||
onLongPressData: widget.onLongPressData,
|
||||
isHorizontal: widget.isHorizontalContinuous,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDoublePageItem(BuildContext context, int index) {
|
||||
if (index >= widget.pageManager.pageCount) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final int index1 = index * 2 - 1;
|
||||
final int index2 = index1 + 1;
|
||||
|
||||
final List<reader.UChapDataPreload?> datas = index == 0
|
||||
? [widget.pageManager.getOriginalPage(0), null]
|
||||
: [
|
||||
index1 < widget.pageManager.pageCount
|
||||
? widget.pageManager.getOriginalPage(index1)
|
||||
: null,
|
||||
index2 < widget.pageManager.pageCount
|
||||
? widget.pageManager.getOriginalPage(index2)
|
||||
: null,
|
||||
];
|
||||
|
||||
// Check if pages should be loaded
|
||||
final shouldLoad1 = index1 >= 0
|
||||
? widget.pageManager.shouldPageBeLoaded(index1)
|
||||
: false;
|
||||
final shouldLoad2 = index2 < widget.pageManager.pageCount
|
||||
? widget.pageManager.shouldPageBeLoaded(index2)
|
||||
: false;
|
||||
|
||||
if (!shouldLoad1 && !shouldLoad2) {
|
||||
return _buildPlaceholder(context);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onDoubleTapDown: (details) =>
|
||||
widget.onDoubleTapDown(details.globalPosition),
|
||||
onDoubleTap: widget.onDoubleTap,
|
||||
child: DoubleColummVerticalView(
|
||||
datas: datas,
|
||||
backgroundColor: widget.backgroundColor,
|
||||
isFailedToLoadImage: widget.onFailedToLoadImage,
|
||||
onLongPressData: widget.onLongPressData,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholder(BuildContext context) {
|
||||
return Container(
|
||||
height: context.height(0.8),
|
||||
color: getBackgroundColor(widget.backgroundColor),
|
||||
child: const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSeparator(BuildContext context, int index) {
|
||||
if (widget.readerMode == ReaderMode.webtoon) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (widget.isHorizontalContinuous) {
|
||||
return VerticalDivider(
|
||||
color: getBackgroundColor(widget.backgroundColor),
|
||||
width: 6,
|
||||
);
|
||||
} else {
|
||||
return Divider(
|
||||
color: getBackgroundColor(widget.backgroundColor),
|
||||
height: 6,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug widget to show virtual page manager statistics
|
||||
class VirtualPageManagerDebugInfo extends ConsumerWidget {
|
||||
final VirtualPageManager pageManager;
|
||||
|
||||
const VirtualPageManagerDebugInfo({super.key, required this.pageManager});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ListenableBuilder(
|
||||
listenable: pageManager,
|
||||
builder: (context, child) {
|
||||
final stats = pageManager.getMemoryStats();
|
||||
|
||||
return Positioned(
|
||||
top: 100,
|
||||
right: 10,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'Virtual Page Manager',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Current: ${stats['currentIndex']}/${stats['totalPages']}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
'Loaded: ${stats['loadedPages']}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
'Cached: ${stats['cachedPages']}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
'Errors: ${stats['errorPages']}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
Text(
|
||||
'Queue: ${stats['preloadQueueSize']}',
|
||||
style: const TextStyle(color: Colors.white, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,285 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/reader_view.dart' as reader;
|
||||
|
||||
/// Page loading states for virtual scrolling
|
||||
enum PageLoadState { notLoaded, loading, loaded, error, cached }
|
||||
|
||||
/// Virtual page information for tracking state
|
||||
class VirtualPageInfo {
|
||||
final int index;
|
||||
final reader.UChapDataPreload originalData;
|
||||
PageLoadState loadState;
|
||||
DateTime? lastAccessTime;
|
||||
Object? error;
|
||||
|
||||
VirtualPageInfo({
|
||||
required this.index,
|
||||
required this.originalData,
|
||||
this.loadState = PageLoadState.notLoaded,
|
||||
this.lastAccessTime,
|
||||
this.error,
|
||||
});
|
||||
|
||||
bool get isVisible =>
|
||||
loadState == PageLoadState.loaded || loadState == PageLoadState.cached;
|
||||
bool get needsLoading => loadState == PageLoadState.notLoaded;
|
||||
bool get isLoading => loadState == PageLoadState.loading;
|
||||
bool get hasError => loadState == PageLoadState.error;
|
||||
|
||||
void markAccessed() {
|
||||
lastAccessTime = DateTime.now();
|
||||
}
|
||||
|
||||
Duration get timeSinceAccess {
|
||||
if (lastAccessTime == null) return Duration.zero;
|
||||
return DateTime.now().difference(lastAccessTime!);
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for virtual page manager
|
||||
class VirtualPageConfig {
|
||||
final int preloadDistance;
|
||||
final int maxCachedPages;
|
||||
final Duration cacheTimeout;
|
||||
final bool enableMemoryOptimization;
|
||||
|
||||
const VirtualPageConfig({
|
||||
this.preloadDistance = 3,
|
||||
this.maxCachedPages = 10,
|
||||
this.cacheTimeout = const Duration(minutes: 5),
|
||||
this.enableMemoryOptimization = true,
|
||||
});
|
||||
}
|
||||
|
||||
/// Manages virtual page loading and memory optimization
|
||||
class VirtualPageManager extends ChangeNotifier {
|
||||
final List<reader.UChapDataPreload> _originalPages;
|
||||
final VirtualPageConfig config;
|
||||
final Map<int, VirtualPageInfo> _pageInfoMap = {};
|
||||
final Set<int> _preloadQueue = {};
|
||||
|
||||
int _currentVisibleIndex = 0;
|
||||
Timer? _cleanupTimer;
|
||||
|
||||
VirtualPageManager({
|
||||
required List<reader.UChapDataPreload> pages,
|
||||
this.config = const VirtualPageConfig(),
|
||||
}) : _originalPages = List.from(pages) {
|
||||
_initializePages();
|
||||
_startCleanupTimer();
|
||||
}
|
||||
|
||||
void _initializePages() {
|
||||
for (int i = 0; i < _originalPages.length; i++) {
|
||||
_pageInfoMap[i] = VirtualPageInfo(
|
||||
index: i,
|
||||
originalData: _originalPages[i],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _startCleanupTimer() {
|
||||
_cleanupTimer?.cancel();
|
||||
_cleanupTimer = Timer.periodic(
|
||||
const Duration(seconds: 30),
|
||||
(_) => _performMemoryCleanup(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cleanupTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Get page count
|
||||
int get pageCount => _originalPages.length;
|
||||
|
||||
/// Get current visible index
|
||||
int get currentVisibleIndex => _currentVisibleIndex;
|
||||
|
||||
/// Get page info for a specific index
|
||||
VirtualPageInfo? getPageInfo(int index) {
|
||||
if (index < 0 || index >= _originalPages.length) return null;
|
||||
return _pageInfoMap[index];
|
||||
}
|
||||
|
||||
/// Get original page data
|
||||
reader.UChapDataPreload? getOriginalPage(int index) {
|
||||
if (index < 0 || index >= _originalPages.length) return null;
|
||||
return _originalPages[index];
|
||||
}
|
||||
|
||||
/// Update visible page index and trigger preloading
|
||||
void updateVisibleIndex(int index) {
|
||||
if (index == _currentVisibleIndex) return;
|
||||
|
||||
_currentVisibleIndex = index.clamp(0, _originalPages.length - 1);
|
||||
_pageInfoMap[_currentVisibleIndex]?.markAccessed();
|
||||
|
||||
_schedulePreloading();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Check if a page should be visible/loaded
|
||||
bool shouldPageBeLoaded(int index) {
|
||||
final distance = (index - _currentVisibleIndex).abs();
|
||||
return distance <= config.preloadDistance;
|
||||
}
|
||||
|
||||
/// Get priority for a page (higher = more important)
|
||||
int getPagePriority(int index) {
|
||||
final distance = (index - _currentVisibleIndex).abs();
|
||||
if (distance == 0) return 1000; // Current page has highest priority
|
||||
return max(0, 100 - distance * 10);
|
||||
}
|
||||
|
||||
/// Schedule preloading for nearby pages
|
||||
void _schedulePreloading() {
|
||||
_preloadQueue.clear();
|
||||
|
||||
// Add pages within preload distance
|
||||
for (int i = 0; i < _originalPages.length; i++) {
|
||||
if (shouldPageBeLoaded(i)) {
|
||||
final pageInfo = _pageInfoMap[i]!;
|
||||
if (pageInfo.needsLoading) {
|
||||
_preloadQueue.add(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process preload queue
|
||||
_processPreloadQueue();
|
||||
}
|
||||
|
||||
/// Process the preload queue
|
||||
void _processPreloadQueue() {
|
||||
final sortedQueue = _preloadQueue.toList()
|
||||
..sort((a, b) => getPagePriority(b).compareTo(getPagePriority(a)));
|
||||
|
||||
for (final index in sortedQueue.take(3)) {
|
||||
// Limit concurrent loading
|
||||
_loadPage(index);
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a specific page
|
||||
Future<void> _loadPage(int index) async {
|
||||
final pageInfo = _pageInfoMap[index];
|
||||
if (pageInfo == null || pageInfo.isLoading) return;
|
||||
|
||||
pageInfo.loadState = PageLoadState.loading;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// For now, we just mark as loaded since the actual image loading
|
||||
// is handled by the ImageView widgets
|
||||
await Future.delayed(const Duration(milliseconds: 10));
|
||||
|
||||
pageInfo.loadState = PageLoadState.loaded;
|
||||
pageInfo.markAccessed();
|
||||
} catch (error) {
|
||||
pageInfo.loadState = PageLoadState.error;
|
||||
pageInfo.error = error;
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Perform memory cleanup
|
||||
void _performMemoryCleanup() {
|
||||
if (!config.enableMemoryOptimization) return;
|
||||
|
||||
final pageEntries = _pageInfoMap.entries.toList();
|
||||
|
||||
// Sort by last access time and distance from current page
|
||||
pageEntries.sort((a, b) {
|
||||
final aDistance = (a.key - _currentVisibleIndex).abs();
|
||||
final bDistance = (b.key - _currentVisibleIndex).abs();
|
||||
final aTime =
|
||||
a.value.lastAccessTime ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
final bTime =
|
||||
b.value.lastAccessTime ?? DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
// First sort by distance, then by access time
|
||||
final distanceComparison = aDistance.compareTo(bDistance);
|
||||
return distanceComparison != 0
|
||||
? distanceComparison
|
||||
: aTime.compareTo(bTime);
|
||||
});
|
||||
|
||||
int cachedCount = pageEntries.where((e) => e.value.isVisible).length;
|
||||
|
||||
// Remove old cached pages if we exceed the limit
|
||||
for (final entry in pageEntries) {
|
||||
if (cachedCount <= config.maxCachedPages) break;
|
||||
|
||||
final pageInfo = entry.value;
|
||||
final distance = (entry.key - _currentVisibleIndex).abs();
|
||||
|
||||
// Don't unload pages within preload distance
|
||||
if (distance <= config.preloadDistance) continue;
|
||||
|
||||
// Don't unload recently accessed pages
|
||||
if (pageInfo.timeSinceAccess < config.cacheTimeout) continue;
|
||||
|
||||
if (pageInfo.isVisible) {
|
||||
pageInfo.loadState = PageLoadState.notLoaded;
|
||||
pageInfo.error = null;
|
||||
cachedCount--;
|
||||
}
|
||||
}
|
||||
|
||||
if (cachedCount != pageEntries.where((e) => e.value.isVisible).length) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// Force load a page immediately
|
||||
Future<void> forceLoadPage(int index) async {
|
||||
await _loadPage(index);
|
||||
}
|
||||
|
||||
/// Get memory usage statistics
|
||||
Map<String, dynamic> getMemoryStats() {
|
||||
final loadedCount = _pageInfoMap.values
|
||||
.where((p) => p.loadState == PageLoadState.loaded)
|
||||
.length;
|
||||
final cachedCount = _pageInfoMap.values
|
||||
.where((p) => p.loadState == PageLoadState.cached)
|
||||
.length;
|
||||
final errorCount = _pageInfoMap.values.where((p) => p.hasError).length;
|
||||
|
||||
return {
|
||||
'totalPages': _originalPages.length,
|
||||
'loadedPages': loadedCount,
|
||||
'cachedPages': cachedCount,
|
||||
'errorPages': errorCount,
|
||||
'currentIndex': _currentVisibleIndex,
|
||||
'preloadQueueSize': _preloadQueue.length,
|
||||
};
|
||||
}
|
||||
|
||||
/// Preload a range of pages
|
||||
Future<void> preloadRange(int startIndex, int endIndex) async {
|
||||
for (int i = startIndex; i <= endIndex && i < _originalPages.length; i++) {
|
||||
if (i >= 0) {
|
||||
await _loadPage(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all cached pages
|
||||
void clearCache() {
|
||||
for (final pageInfo in _pageInfoMap.values) {
|
||||
if (pageInfo.loadState != PageLoadState.loading) {
|
||||
pageInfo.loadState = PageLoadState.notLoaded;
|
||||
pageInfo.error = null;
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,225 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:photo_view/photo_view_gallery.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
import 'package:mangayomi/models/settings.dart';
|
||||
import 'package:mangayomi/models/chapter.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/virtual_scrolling/virtual_page_manager.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/virtual_scrolling/virtual_manga_list.dart';
|
||||
import 'package:mangayomi/modules/manga/reader/reader_view.dart' as reader;
|
||||
|
||||
/// Provides virtual page manager instances
|
||||
final virtualPageManagerProvider =
|
||||
Provider.family<VirtualPageManager, List<reader.UChapDataPreload>>((
|
||||
ref,
|
||||
pages,
|
||||
) {
|
||||
return VirtualPageManager(pages: pages);
|
||||
});
|
||||
|
||||
/// Main widget for virtual reading that replaces ScrollablePositionedList
|
||||
class VirtualReaderView extends ConsumerStatefulWidget {
|
||||
final List<reader.UChapDataPreload> pages;
|
||||
final ItemScrollController itemScrollController;
|
||||
final ScrollOffsetController scrollOffsetController;
|
||||
final ItemPositionsListener itemPositionsListener;
|
||||
final Axis scrollDirection;
|
||||
final double minCacheExtent;
|
||||
final int initialScrollIndex;
|
||||
final ScrollPhysics physics;
|
||||
final Function(reader.UChapDataPreload data) onLongPressData;
|
||||
final Function(bool) onFailedToLoadImage;
|
||||
final BackgroundColor backgroundColor;
|
||||
final bool isDoublePageMode;
|
||||
final bool isHorizontalContinuous;
|
||||
final ReaderMode readerMode;
|
||||
final PhotoViewController photoViewController;
|
||||
final PhotoViewScaleStateController photoViewScaleStateController;
|
||||
final Alignment scalePosition;
|
||||
final Function(ScaleEndDetails) onScaleEnd;
|
||||
final Function(Offset) onDoubleTapDown;
|
||||
final VoidCallback onDoubleTap;
|
||||
final bool showDebugInfo;
|
||||
// Callbacks pour gérer les transitions entre chapitres
|
||||
final Function(Chapter chapter)? onChapterChanged;
|
||||
final Function(int lastPageIndex)? onReachedLastPage;
|
||||
|
||||
const VirtualReaderView({
|
||||
super.key,
|
||||
required this.pages,
|
||||
required this.itemScrollController,
|
||||
required this.scrollOffsetController,
|
||||
required this.itemPositionsListener,
|
||||
required this.scrollDirection,
|
||||
required this.minCacheExtent,
|
||||
required this.initialScrollIndex,
|
||||
required this.physics,
|
||||
required this.onLongPressData,
|
||||
required this.onFailedToLoadImage,
|
||||
required this.backgroundColor,
|
||||
required this.isDoublePageMode,
|
||||
required this.isHorizontalContinuous,
|
||||
required this.readerMode,
|
||||
required this.photoViewController,
|
||||
required this.photoViewScaleStateController,
|
||||
required this.scalePosition,
|
||||
required this.onScaleEnd,
|
||||
required this.onDoubleTapDown,
|
||||
required this.onDoubleTap,
|
||||
this.showDebugInfo = false,
|
||||
this.onChapterChanged,
|
||||
this.onReachedLastPage,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<VirtualReaderView> createState() => _VirtualReaderViewState();
|
||||
}
|
||||
|
||||
class _VirtualReaderViewState extends ConsumerState<VirtualReaderView> {
|
||||
late VirtualPageManager _pageManager;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pageManager = VirtualPageManager(pages: widget.pages);
|
||||
|
||||
// Set initial visible index
|
||||
_pageManager.updateVisibleIndex(widget.initialScrollIndex);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(VirtualReaderView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Update page manager if pages changed
|
||||
if (widget.pages != oldWidget.pages) {
|
||||
_pageManager.dispose();
|
||||
_pageManager = VirtualPageManager(pages: widget.pages);
|
||||
_pageManager.updateVisibleIndex(widget.initialScrollIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageManager.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_pageManager.pageCount < widget.pages.length) {
|
||||
_pageManager = VirtualPageManager(pages: widget.pages);
|
||||
}
|
||||
return Stack(
|
||||
children: [
|
||||
PhotoViewGallery.builder(
|
||||
itemCount: 1,
|
||||
builder: (_, _) => PhotoViewGalleryPageOptions.customChild(
|
||||
controller: widget.photoViewController,
|
||||
scaleStateController: widget.photoViewScaleStateController,
|
||||
basePosition: widget.scalePosition,
|
||||
onScaleEnd: (context, details, controllerValue) =>
|
||||
widget.onScaleEnd(details),
|
||||
child: VirtualMangaList(
|
||||
pageManager: _pageManager,
|
||||
itemScrollController: widget.itemScrollController,
|
||||
scrollOffsetController: widget.scrollOffsetController,
|
||||
itemPositionsListener: widget.itemPositionsListener,
|
||||
scrollDirection: widget.scrollDirection,
|
||||
minCacheExtent: widget.minCacheExtent,
|
||||
initialScrollIndex: widget.initialScrollIndex,
|
||||
physics: widget.physics,
|
||||
onLongPressData: widget.onLongPressData,
|
||||
onFailedToLoadImage: widget.onFailedToLoadImage,
|
||||
backgroundColor: widget.backgroundColor,
|
||||
isDoublePageMode: widget.isDoublePageMode,
|
||||
isHorizontalContinuous: widget.isHorizontalContinuous,
|
||||
readerMode: widget.readerMode,
|
||||
onDoubleTapDown: widget.onDoubleTapDown,
|
||||
onDoubleTap: widget.onDoubleTap,
|
||||
// Passer les callbacks pour les transitions entre chapitres
|
||||
onChapterChanged: widget.onChapterChanged,
|
||||
onReachedLastPage: widget.onReachedLastPage,
|
||||
onPageChanged: (index) {
|
||||
// Ici on peut ajouter une logique supplémentaire si nécessaire
|
||||
// Par exemple, précaching d'images
|
||||
_pageManager.updateVisibleIndex(index);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Debug info overlay
|
||||
if (widget.showDebugInfo)
|
||||
VirtualPageManagerDebugInfo(pageManager: _pageManager),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mixin to add virtual page manager capabilities to existing widgets
|
||||
mixin VirtualPageManagerMixin<T extends ConsumerStatefulWidget>
|
||||
on ConsumerState<T> {
|
||||
VirtualPageManager? _virtualPageManager;
|
||||
|
||||
VirtualPageManager get virtualPageManager {
|
||||
_virtualPageManager ??= VirtualPageManager(pages: getPages());
|
||||
return _virtualPageManager!;
|
||||
}
|
||||
|
||||
/// Override this method to provide the pages list
|
||||
List<reader.UChapDataPreload> getPages();
|
||||
|
||||
/// Call this when pages change
|
||||
void updateVirtualPages(List<reader.UChapDataPreload> newPages) {
|
||||
_virtualPageManager?.dispose();
|
||||
_virtualPageManager = VirtualPageManager(pages: newPages);
|
||||
}
|
||||
|
||||
/// Call this when the visible page changes
|
||||
void updateVisiblePage(int index) {
|
||||
virtualPageManager.updateVisibleIndex(index);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_virtualPageManager?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration provider for virtual page manager
|
||||
final virtualPageConfigProvider = Provider<VirtualPageConfig>((ref) {
|
||||
// Get user preferences for virtual scrolling configuration
|
||||
final preloadAmount = ref.watch(readerPagePreloadAmountStateProvider);
|
||||
|
||||
return VirtualPageConfig(
|
||||
preloadDistance: preloadAmount,
|
||||
maxCachedPages: preloadAmount * 3,
|
||||
cacheTimeout: const Duration(minutes: 5),
|
||||
enableMemoryOptimization: true,
|
||||
);
|
||||
});
|
||||
|
||||
/// Provider for page preload amount (renamed to avoid conflicts)
|
||||
final readerPagePreloadAmountStateProvider = StateProvider<int>((ref) => 3);
|
||||
|
||||
/// Extension to convert ReaderMode to virtual scrolling parameters
|
||||
extension ReaderModeExtension on ReaderMode {
|
||||
bool get isContinuous {
|
||||
return this == ReaderMode.verticalContinuous ||
|
||||
this == ReaderMode.webtoon ||
|
||||
this == ReaderMode.horizontalContinuous;
|
||||
}
|
||||
|
||||
Axis get scrollDirection {
|
||||
return this == ReaderMode.horizontalContinuous
|
||||
? Axis.horizontal
|
||||
: Axis.vertical;
|
||||
}
|
||||
|
||||
bool get isHorizontalContinuous {
|
||||
return this == ReaderMode.horizontalContinuous;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue