mirror of
https://github.com/kodjodevf/mangayomi.git
synced 2026-03-11 21:35:32 +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/anime/widgets/desktop.dart';
|
||||||
import 'package:mangayomi/modules/manga/reader/providers/crop_borders_provider.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/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/double_columm_view_center.dart';
|
||||||
import 'package:mangayomi/modules/manga/reader/providers/color_filter_provider.dart';
|
import 'package:mangayomi/modules/manga/reader/providers/color_filter_provider.dart';
|
||||||
import 'package:mangayomi/modules/manga/reader/widgets/color_filter_widget.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/extensions/others.dart';
|
||||||
import 'package:mangayomi/utils/global_style.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_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/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/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/manga/reader/widgets/transition_view_paged.dart';
|
||||||
import 'package:mangayomi/modules/more/settings/reader/reader_screen.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/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:mangayomi/modules/widgets/progress_center.dart';
|
||||||
import 'package:photo_view/photo_view.dart';
|
import 'package:photo_view/photo_view.dart';
|
||||||
import 'package:photo_view/photo_view_gallery.dart';
|
import 'package:photo_view/photo_view_gallery.dart';
|
||||||
|
|
@ -63,8 +61,7 @@ class MangaReaderView extends ConsumerWidget {
|
||||||
|
|
||||||
return chapterData.when(
|
return chapterData.when(
|
||||||
loading: () => scaffoldWith(context, const ProgressCenter()),
|
loading: () => scaffoldWith(context, const ProgressCenter()),
|
||||||
error:
|
error: (error, _) =>
|
||||||
(error, _) =>
|
|
||||||
scaffoldWith(context, Center(child: Text(error.toString()))),
|
scaffoldWith(context, Center(child: Text(error.toString()))),
|
||||||
data: (data) {
|
data: (data) {
|
||||||
final chapter = data.chapter;
|
final chapter = data.chapter;
|
||||||
|
|
@ -406,8 +403,8 @@ class _MangaChapterPageGalleryState
|
||||||
context.l10n.save,
|
context.l10n.save,
|
||||||
Icons.save_outlined,
|
Icons.save_outlined,
|
||||||
() async {
|
() async {
|
||||||
final dir =
|
final dir = await StorageProvider()
|
||||||
await StorageProvider().getGalleryDirectory();
|
.getGalleryDirectory();
|
||||||
final file = File(p.join(dir!.path, "$name.png"));
|
final file = File(p.join(dir!.path, "$name.png"));
|
||||||
file.writeAsBytesSync(imageBytes);
|
file.writeAsBytesSync(imageBytes);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
|
|
@ -532,121 +529,93 @@ class _MangaChapterPageGalleryState
|
||||||
_isVerticalOrHorizontalContinous()
|
_isVerticalOrHorizontalContinous()
|
||||||
? PhotoViewGallery.builder(
|
? PhotoViewGallery.builder(
|
||||||
itemCount: 1,
|
itemCount: 1,
|
||||||
builder:
|
builder: (_, _) =>
|
||||||
(_, _) => PhotoViewGalleryPageOptions.customChild(
|
PhotoViewGalleryPageOptions.customChild(
|
||||||
controller: _photoViewController,
|
controller: _photoViewController,
|
||||||
scaleStateController:
|
scaleStateController:
|
||||||
_photoViewScaleStateController,
|
_photoViewScaleStateController,
|
||||||
basePosition: _scalePosition,
|
basePosition: _scalePosition,
|
||||||
onScaleEnd: _onScaleEnd,
|
onScaleEnd: _onScaleEnd,
|
||||||
child: ScrollablePositionedList.separated(
|
child: VirtualReaderView(
|
||||||
scrollDirection:
|
pages: _uChapDataPreload,
|
||||||
isHorizontalContinuaous
|
itemScrollController: _itemScrollController,
|
||||||
|
scrollOffsetController:
|
||||||
|
_pageOffsetController,
|
||||||
|
itemPositionsListener:
|
||||||
|
_itemPositionsListener,
|
||||||
|
scrollDirection: isHorizontalContinuaous
|
||||||
? Axis.horizontal
|
? Axis.horizontal
|
||||||
: Axis.vertical,
|
: Axis.vertical,
|
||||||
minCacheExtent:
|
minCacheExtent:
|
||||||
pagePreloadAmount * context.height(1),
|
pagePreloadAmount * context.height(1),
|
||||||
initialScrollIndex:
|
initialScrollIndex: _readerController
|
||||||
_readerController.getPageIndex(),
|
.getPageIndex(),
|
||||||
itemCount:
|
|
||||||
(_pageMode == PageMode.doublePage &&
|
|
||||||
!isHorizontalContinuaous)
|
|
||||||
? (_uChapDataPreload.length / 2)
|
|
||||||
.ceil() +
|
|
||||||
1
|
|
||||||
: _uChapDataPreload.length,
|
|
||||||
physics: const ClampingScrollPhysics(),
|
physics: const ClampingScrollPhysics(),
|
||||||
itemScrollController: _itemScrollController,
|
onLongPressData: (data) =>
|
||||||
scrollOffsetController: _pageOffsetController,
|
_onLongPressImageDialog(data, context),
|
||||||
itemPositionsListener: _itemPositionsListener,
|
onFailedToLoadImage: (value) {
|
||||||
itemBuilder: (context, index) {
|
// Handle failed image loading
|
||||||
if (_uChapDataPreload[index]
|
if (_failedToLoadImage.value != value &&
|
||||||
.isTransitionPage) {
|
mounted) {
|
||||||
return TransitionViewVertical(
|
_failedToLoadImage.value = value;
|
||||||
data: _uChapDataPreload[index],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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: () {},
|
onDoubleTap: () {},
|
||||||
child:
|
// Chapter transition callbacks
|
||||||
(_pageMode == PageMode.doublePage &&
|
onChapterChanged: (newChapter) {
|
||||||
!isHorizontalContinuaous)
|
// Update the current chapter when a chapter change is detected
|
||||||
? DoubleColummVerticalView(
|
if (newChapter.id != chapter.id) {
|
||||||
datas:
|
if (mounted) {
|
||||||
index == 0
|
setState(() {
|
||||||
? [
|
_readerController = ref.read(
|
||||||
_uChapDataPreload[0],
|
readerControllerProvider(
|
||||||
null,
|
chapter: newChapter,
|
||||||
]
|
).notifier,
|
||||||
: [
|
|
||||||
index1 <
|
|
||||||
_uChapDataPreload
|
|
||||||
.length
|
|
||||||
? _uChapDataPreload[index1]
|
|
||||||
: null,
|
|
||||||
index2 <
|
|
||||||
_uChapDataPreload
|
|
||||||
.length
|
|
||||||
? _uChapDataPreload[index2]
|
|
||||||
: null,
|
|
||||||
],
|
|
||||||
backgroundColor:
|
|
||||||
backgroundColor,
|
|
||||||
isFailedToLoadImage: (val) {},
|
|
||||||
onLongPressData: (datas) {
|
|
||||||
_onLongPressImageDialog(
|
|
||||||
datas,
|
|
||||||
context,
|
|
||||||
);
|
);
|
||||||
|
chapter = newChapter;
|
||||||
|
_isBookmarked = _readerController
|
||||||
|
.getChapterBookmarked();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
onReachedLastPage: (lastPageIndex) {
|
||||||
|
try {
|
||||||
|
ref
|
||||||
|
.watch(
|
||||||
|
getChapterPagesProvider(
|
||||||
|
chapter: _readerController
|
||||||
|
.getNextChapter(),
|
||||||
|
).future,
|
||||||
)
|
)
|
||||||
: ImageViewVertical(
|
.then(
|
||||||
data: _uChapDataPreload[index],
|
(value) => _preloadNextChapter(
|
||||||
failedToLoadImage: (value) {
|
value,
|
||||||
// _failedToLoadImage.value = value;
|
chapter,
|
||||||
},
|
|
||||||
onLongPressData: (datas) {
|
|
||||||
_onLongPressImageDialog(
|
|
||||||
datas,
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
isHorizontal:
|
|
||||||
ref.watch(
|
|
||||||
_currentReaderMode,
|
|
||||||
) ==
|
|
||||||
ReaderMode
|
|
||||||
.horizontalContinuous,
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} 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 index1 = index * 2 - 1;
|
||||||
int index2 = index1 + 1;
|
int index2 = index1 + 1;
|
||||||
final pageList =
|
final pageList = (index == 0
|
||||||
(index == 0
|
|
||||||
? [_uChapDataPreload[0], null]
|
? [_uChapDataPreload[0], null]
|
||||||
: [
|
: [
|
||||||
index1 <
|
index1 < _uChapDataPreload.length
|
||||||
_uChapDataPreload.length
|
|
||||||
? _uChapDataPreload[index1]
|
? _uChapDataPreload[index1]
|
||||||
: null,
|
: null,
|
||||||
index2 <
|
index2 < _uChapDataPreload.length
|
||||||
_uChapDataPreload.length
|
|
||||||
? _uChapDataPreload[index2]
|
? _uChapDataPreload[index2]
|
||||||
: null,
|
: null,
|
||||||
]);
|
]);
|
||||||
return DoubleColummView(
|
return DoubleColummView(
|
||||||
datas:
|
datas: _isReverseHorizontal
|
||||||
_isReverseHorizontal
|
|
||||||
? pageList.reversed.toList()
|
? pageList.reversed.toList()
|
||||||
: pageList,
|
: pageList,
|
||||||
backgroundColor: backgroundColor,
|
backgroundColor: backgroundColor,
|
||||||
|
|
@ -723,10 +688,7 @@ class _MangaChapterPageGalleryState
|
||||||
? !(gestureDetails.totalScale! > 1.0)
|
? !(gestureDetails.totalScale! > 1.0)
|
||||||
: true;
|
: true;
|
||||||
},
|
},
|
||||||
itemBuilder: (
|
itemBuilder: (BuildContext context, int index) {
|
||||||
BuildContext context,
|
|
||||||
int index,
|
|
||||||
) {
|
|
||||||
if (_uChapDataPreload[index]
|
if (_uChapDataPreload[index]
|
||||||
.isTransitionPage) {
|
.isTransitionPage) {
|
||||||
return TransitionViewPaged(
|
return TransitionViewPaged(
|
||||||
|
|
@ -779,7 +741,8 @@ class _MangaChapterPageGalleryState
|
||||||
return ExtendedImageGesture(
|
return ExtendedImageGesture(
|
||||||
state,
|
state,
|
||||||
canScaleImage: (_) => true,
|
canScaleImage: (_) => true,
|
||||||
imageBuilder: (
|
imageBuilder:
|
||||||
|
(
|
||||||
Widget image, {
|
Widget image, {
|
||||||
ExtendedImageGestureState?
|
ExtendedImageGestureState?
|
||||||
imageGestureState,
|
imageGestureState,
|
||||||
|
|
@ -829,17 +792,18 @@ class _MangaChapterPageGalleryState
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
state.reLoadImage();
|
state.reLoadImage();
|
||||||
_failedToLoadImage
|
_failedToLoadImage
|
||||||
.value = false;
|
.value =
|
||||||
|
false;
|
||||||
},
|
},
|
||||||
onTap: () {
|
onTap: () {
|
||||||
state.reLoadImage();
|
state.reLoadImage();
|
||||||
_failedToLoadImage
|
_failedToLoadImage
|
||||||
.value = false;
|
.value =
|
||||||
|
false;
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color:
|
color: context
|
||||||
context
|
|
||||||
.primaryColor,
|
.primaryColor,
|
||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.circular(
|
BorderRadius.circular(
|
||||||
|
|
@ -911,7 +875,8 @@ class _MangaChapterPageGalleryState
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
_doubleClickAnimation = Tween(
|
_doubleClickAnimation =
|
||||||
|
Tween(
|
||||||
begin: begin,
|
begin: begin,
|
||||||
end: end,
|
end: end,
|
||||||
).animate(
|
).animate(
|
||||||
|
|
@ -969,8 +934,9 @@ class _MangaChapterPageGalleryState
|
||||||
}
|
}
|
||||||
|
|
||||||
Duration? _doubleTapAnimationDuration() {
|
Duration? _doubleTapAnimationDuration() {
|
||||||
int doubleTapAnimationValue =
|
int doubleTapAnimationValue = isar.settings
|
||||||
isar.settings.getSync(227)!.doubleTapAnimationSpeed!;
|
.getSync(227)!
|
||||||
|
.doubleTapAnimationSpeed!;
|
||||||
if (doubleTapAnimationValue == 0) {
|
if (doubleTapAnimationValue == 0) {
|
||||||
return const Duration(milliseconds: 10);
|
return const Duration(milliseconds: 10);
|
||||||
} else if (doubleTapAnimationValue == 1) {
|
} else if (doubleTapAnimationValue == 1) {
|
||||||
|
|
@ -1075,13 +1041,11 @@ class _MangaChapterPageGalleryState
|
||||||
pageIndex: currentLength,
|
pageIndex: currentLength,
|
||||||
);
|
);
|
||||||
|
|
||||||
final newPages =
|
final newPages = chapterData.uChapDataPreload
|
||||||
chapterData.uChapDataPreload
|
|
||||||
.asMap()
|
.asMap()
|
||||||
.entries
|
.entries
|
||||||
.map(
|
.map(
|
||||||
(entry) =>
|
(entry) => entry.value..pageIndex = currentLength + 1 + entry.key,
|
||||||
entry.value..pageIndex = currentLength + 1 + entry.key,
|
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
|
@ -1095,8 +1059,7 @@ class _MangaChapterPageGalleryState
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isChapterAlreadyLoaded(Chapter chapter) {
|
bool _isChapterAlreadyLoaded(Chapter chapter) {
|
||||||
final existingIdentifiers =
|
final existingIdentifiers = _uChapDataPreload
|
||||||
_uChapDataPreload
|
|
||||||
.map((item) => item.chapter)
|
.map((item) => item.chapter)
|
||||||
.where((ch) => ch != null)
|
.where((ch) => ch != null)
|
||||||
.map((ch) => _getChapterIdentifier(ch!))
|
.map((ch) => _getChapterIdentifier(ch!))
|
||||||
|
|
@ -1350,8 +1313,7 @@ class _MangaChapterPageGalleryState
|
||||||
|
|
||||||
int index =
|
int index =
|
||||||
(_pageMode == PageMode.doublePage &&
|
(_pageMode == PageMode.doublePage &&
|
||||||
!(ref.watch(_currentReaderMode) ==
|
!(ref.watch(_currentReaderMode) == ReaderMode.horizontalContinuous))
|
||||||
ReaderMode.horizontalContinuous))
|
|
||||||
? (_currentIndex! / 2).ceil()
|
? (_currentIndex! / 2).ceil()
|
||||||
: _currentIndex!;
|
: _currentIndex!;
|
||||||
ref.read(_currentReaderMode.notifier).state = value;
|
ref.read(_currentReaderMode.notifier).state = value;
|
||||||
|
|
@ -1406,8 +1368,8 @@ class _MangaChapterPageGalleryState
|
||||||
).future,
|
).future,
|
||||||
)
|
)
|
||||||
.then((value) {
|
.then((value) {
|
||||||
_uChapDataPreload[index] =
|
_uChapDataPreload[index] = _uChapDataPreload[index]
|
||||||
_uChapDataPreload[index]..cropImage = value;
|
..cropImage = value;
|
||||||
});
|
});
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
|
@ -1446,8 +1408,7 @@ class _MangaChapterPageGalleryState
|
||||||
|
|
||||||
Widget _appBar() {
|
Widget _appBar() {
|
||||||
final fullScreenReader = ref.watch(fullScreenReaderStateProvider);
|
final fullScreenReader = ref.watch(fullScreenReaderStateProvider);
|
||||||
double height =
|
double height = _isView
|
||||||
_isView
|
|
||||||
? Platform.isIOS
|
? Platform.isIOS
|
||||||
? 120
|
? 120
|
||||||
: !fullScreenReader && !isDesktop
|
: !fullScreenReader && !isDesktop
|
||||||
|
|
@ -1542,25 +1503,19 @@ class _MangaChapterPageGalleryState
|
||||||
? Positioned(
|
? Positioned(
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child:
|
child: !_isView
|
||||||
!_isView
|
|
||||||
? ValueListenableBuilder(
|
? ValueListenableBuilder(
|
||||||
valueListenable: _autoScrollPage,
|
valueListenable: _autoScrollPage,
|
||||||
builder:
|
builder: (context, valueT, child) => valueT
|
||||||
(context, valueT, child) =>
|
|
||||||
valueT
|
|
||||||
? ValueListenableBuilder(
|
? ValueListenableBuilder(
|
||||||
valueListenable: _autoScroll,
|
valueListenable: _autoScroll,
|
||||||
builder:
|
builder: (context, value, child) => IconButton(
|
||||||
(context, value, child) => IconButton(
|
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_autoPagescroll();
|
_autoPagescroll();
|
||||||
_autoScroll.value = !value;
|
_autoScroll.value = !value;
|
||||||
},
|
},
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
value
|
value ? Icons.pause_circle : Icons.play_circle,
|
||||||
? Icons.pause_circle
|
|
||||||
: Icons.play_circle,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -1600,13 +1555,11 @@ class _MangaChapterPageGalleryState
|
||||||
radius: 23,
|
radius: 23,
|
||||||
backgroundColor: _backgroundColor(context),
|
backgroundColor: _backgroundColor(context),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
onPressed:
|
onPressed: hasPrevChapter
|
||||||
hasPrevChapter
|
|
||||||
? () {
|
? () {
|
||||||
pushReplacementMangaReaderView(
|
pushReplacementMangaReaderView(
|
||||||
context: context,
|
context: context,
|
||||||
chapter:
|
chapter: _readerController.getPrevChapter(),
|
||||||
_readerController.getPrevChapter(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -1614,11 +1567,8 @@ class _MangaChapterPageGalleryState
|
||||||
scaleX: 1,
|
scaleX: 1,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.skip_previous_rounded,
|
Icons.skip_previous_rounded,
|
||||||
color:
|
color: hasPrevChapter
|
||||||
hasPrevChapter
|
? Theme.of(context).textTheme.bodyLarge!.color
|
||||||
? Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.bodyLarge!.color
|
|
||||||
: Theme.of(context)
|
: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.bodyLarge!
|
.bodyLarge!
|
||||||
|
|
@ -1694,15 +1644,13 @@ class _MangaChapterPageGalleryState
|
||||||
},
|
},
|
||||||
onChangeEnd: (newValue) {
|
onChangeEnd: (newValue) {
|
||||||
try {
|
try {
|
||||||
final index =
|
final index = _uChapDataPreload
|
||||||
_uChapDataPreload
|
|
||||||
.firstWhere(
|
.firstWhere(
|
||||||
(element) =>
|
(element) =>
|
||||||
element.chapter ==
|
element.chapter ==
|
||||||
chapter &&
|
chapter &&
|
||||||
element.index ==
|
element.index ==
|
||||||
newValue
|
newValue.toInt(),
|
||||||
.toInt(),
|
|
||||||
)
|
)
|
||||||
.pageIndex;
|
.pageIndex;
|
||||||
|
|
||||||
|
|
@ -1715,13 +1663,11 @@ class _MangaChapterPageGalleryState
|
||||||
},
|
},
|
||||||
divisions:
|
divisions:
|
||||||
_readerController.getPageLength(
|
_readerController.getPageLength(
|
||||||
_chapterUrlModel
|
_chapterUrlModel.pageUrls,
|
||||||
.pageUrls,
|
|
||||||
) ==
|
) ==
|
||||||
1
|
1
|
||||||
? null
|
? null
|
||||||
: _pageMode ==
|
: _pageMode == PageMode.doublePage
|
||||||
PageMode.doublePage
|
|
||||||
? ((_readerController.getPageLength(
|
? ((_readerController.getPageLength(
|
||||||
_chapterUrlModel
|
_chapterUrlModel
|
||||||
.pageUrls,
|
.pageUrls,
|
||||||
|
|
@ -1729,10 +1675,8 @@ class _MangaChapterPageGalleryState
|
||||||
2)
|
2)
|
||||||
.ceil() +
|
.ceil() +
|
||||||
1
|
1
|
||||||
: _readerController
|
: _readerController.getPageLength(
|
||||||
.getPageLength(
|
_chapterUrlModel.pageUrls,
|
||||||
_chapterUrlModel
|
|
||||||
.pageUrls,
|
|
||||||
) -
|
) -
|
||||||
1,
|
1,
|
||||||
value: min(
|
value: min(
|
||||||
|
|
@ -1743,8 +1687,7 @@ class _MangaChapterPageGalleryState
|
||||||
) ==
|
) ==
|
||||||
ReaderMode
|
ReaderMode
|
||||||
.horizontalContinuous))
|
.horizontalContinuous))
|
||||||
? ((_readerController
|
? ((_readerController.getPageLength(
|
||||||
.getPageLength(
|
|
||||||
_chapterUrlModel
|
_chapterUrlModel
|
||||||
.pageUrls,
|
.pageUrls,
|
||||||
)) /
|
)) /
|
||||||
|
|
@ -1753,7 +1696,8 @@ class _MangaChapterPageGalleryState
|
||||||
1
|
1
|
||||||
: (_readerController
|
: (_readerController
|
||||||
.getPageLength(
|
.getPageLength(
|
||||||
_chapterUrlModel.pageUrls,
|
_chapterUrlModel
|
||||||
|
.pageUrls,
|
||||||
)
|
)
|
||||||
.toDouble()),
|
.toDouble()),
|
||||||
),
|
),
|
||||||
|
|
@ -1815,13 +1759,11 @@ class _MangaChapterPageGalleryState
|
||||||
radius: 23,
|
radius: 23,
|
||||||
backgroundColor: _backgroundColor(context),
|
backgroundColor: _backgroundColor(context),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
onPressed:
|
onPressed: hasNextChapter
|
||||||
hasNextChapter
|
|
||||||
? () {
|
? () {
|
||||||
pushReplacementMangaReaderView(
|
pushReplacementMangaReaderView(
|
||||||
context: context,
|
context: context,
|
||||||
chapter:
|
chapter: _readerController.getNextChapter(),
|
||||||
_readerController.getNextChapter(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
|
@ -1829,11 +1771,8 @@ class _MangaChapterPageGalleryState
|
||||||
scaleX: 1,
|
scaleX: 1,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
Icons.skip_next_rounded,
|
Icons.skip_next_rounded,
|
||||||
color:
|
color: hasNextChapter
|
||||||
hasNextChapter
|
? Theme.of(context).textTheme.bodyLarge!.color
|
||||||
? Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.bodyLarge!.color
|
|
||||||
: Theme.of(context)
|
: Theme.of(context)
|
||||||
.textTheme
|
.textTheme
|
||||||
.bodyLarge!
|
.bodyLarge!
|
||||||
|
|
@ -1864,8 +1803,7 @@ class _MangaChapterPageGalleryState
|
||||||
ref.read(_currentReaderMode.notifier).state = value;
|
ref.read(_currentReaderMode.notifier).state = value;
|
||||||
_setReaderMode(value, ref);
|
_setReaderMode(value, ref);
|
||||||
},
|
},
|
||||||
itemBuilder:
|
itemBuilder: (context) => [
|
||||||
(context) => [
|
|
||||||
for (var mode in ReaderMode.values)
|
for (var mode in ReaderMode.values)
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: mode,
|
value: mode,
|
||||||
|
|
@ -1873,8 +1811,7 @@ class _MangaChapterPageGalleryState
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.check,
|
Icons.check,
|
||||||
color:
|
color: readerMode == mode
|
||||||
readerMode == mode
|
|
||||||
? Colors.white
|
? Colors.white
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
|
|
@ -1944,8 +1881,7 @@ class _MangaChapterPageGalleryState
|
||||||
true,
|
true,
|
||||||
isSlide: true,
|
isSlide: true,
|
||||||
);
|
);
|
||||||
newPageMode =
|
newPageMode = _pageMode == PageMode.onePage
|
||||||
_pageMode == PageMode.onePage
|
|
||||||
? PageMode.doublePage
|
? PageMode.doublePage
|
||||||
: PageMode.onePage;
|
: PageMode.onePage;
|
||||||
|
|
||||||
|
|
@ -2070,29 +2006,27 @@ class _MangaChapterPageGalleryState
|
||||||
_isViewFunction();
|
_isViewFunction();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDoubleTapDown:
|
onDoubleTapDown: _isVerticalOrHorizontalContinous()
|
||||||
_isVerticalOrHorizontalContinous()
|
|
||||||
? (details) {
|
? (details) {
|
||||||
_toggleScale(details.globalPosition);
|
_toggleScale(details.globalPosition);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
onSecondaryTapDown:
|
onSecondaryTapDown: _isVerticalOrHorizontalContinous()
|
||||||
_isVerticalOrHorizontalContinous()
|
|
||||||
? (details) {
|
? (details) {
|
||||||
_toggleScale(details.globalPosition);
|
_toggleScale(details.globalPosition);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null,
|
onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null,
|
||||||
onSecondaryTap:
|
onSecondaryTap: _isVerticalOrHorizontalContinous()
|
||||||
_isVerticalOrHorizontalContinous() ? () {} : null,
|
? () {}
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
/// center region
|
/// center region
|
||||||
Expanded(
|
Expanded(
|
||||||
flex: 2,
|
flex: 2,
|
||||||
child:
|
child: failedToLoadImage
|
||||||
failedToLoadImage
|
|
||||||
? SizedBox(
|
? SizedBox(
|
||||||
width: context.width(1),
|
width: context.width(1),
|
||||||
height: context.height(0.7),
|
height: context.height(0.7),
|
||||||
|
|
@ -2102,22 +2036,22 @@ class _MangaChapterPageGalleryState
|
||||||
onTap: () {
|
onTap: () {
|
||||||
_isViewFunction();
|
_isViewFunction();
|
||||||
},
|
},
|
||||||
onDoubleTapDown:
|
onDoubleTapDown: _isVerticalOrHorizontalContinous()
|
||||||
_isVerticalOrHorizontalContinous()
|
|
||||||
? (details) {
|
? (details) {
|
||||||
_toggleScale(details.globalPosition);
|
_toggleScale(details.globalPosition);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
onSecondaryTapDown:
|
onSecondaryTapDown: _isVerticalOrHorizontalContinous()
|
||||||
_isVerticalOrHorizontalContinous()
|
|
||||||
? (details) {
|
? (details) {
|
||||||
_toggleScale(details.globalPosition);
|
_toggleScale(details.globalPosition);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
onDoubleTap:
|
onDoubleTap: _isVerticalOrHorizontalContinous()
|
||||||
_isVerticalOrHorizontalContinous() ? () {} : null,
|
? () {}
|
||||||
onSecondaryTap:
|
: null,
|
||||||
_isVerticalOrHorizontalContinous() ? () {} : null,
|
onSecondaryTap: _isVerticalOrHorizontalContinous()
|
||||||
|
? () {}
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
@ -2137,21 +2071,20 @@ class _MangaChapterPageGalleryState
|
||||||
_isViewFunction();
|
_isViewFunction();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onDoubleTapDown:
|
onDoubleTapDown: _isVerticalOrHorizontalContinous()
|
||||||
_isVerticalOrHorizontalContinous()
|
|
||||||
? (details) {
|
? (details) {
|
||||||
_toggleScale(details.globalPosition);
|
_toggleScale(details.globalPosition);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
onSecondaryTapDown:
|
onSecondaryTapDown: _isVerticalOrHorizontalContinous()
|
||||||
_isVerticalOrHorizontalContinous()
|
|
||||||
? (details) {
|
? (details) {
|
||||||
_toggleScale(details.globalPosition);
|
_toggleScale(details.globalPosition);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null,
|
onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null,
|
||||||
onSecondaryTap:
|
onSecondaryTap: _isVerticalOrHorizontalContinous()
|
||||||
_isVerticalOrHorizontalContinous() ? () {} : null,
|
? () {}
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -2177,21 +2110,20 @@ class _MangaChapterPageGalleryState
|
||||||
? _onBtnTapped(_currentIndex! - 1, true)
|
? _onBtnTapped(_currentIndex! - 1, true)
|
||||||
: _isViewFunction();
|
: _isViewFunction();
|
||||||
},
|
},
|
||||||
onDoubleTapDown:
|
onDoubleTapDown: _isVerticalOrHorizontalContinous()
|
||||||
_isVerticalOrHorizontalContinous()
|
|
||||||
? (details) {
|
? (details) {
|
||||||
_toggleScale(details.globalPosition);
|
_toggleScale(details.globalPosition);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
onSecondaryTapDown:
|
onSecondaryTapDown: _isVerticalOrHorizontalContinous()
|
||||||
_isVerticalOrHorizontalContinous()
|
|
||||||
? (details) {
|
? (details) {
|
||||||
_toggleScale(details.globalPosition);
|
_toggleScale(details.globalPosition);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null,
|
onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null,
|
||||||
onSecondaryTap:
|
onSecondaryTap: _isVerticalOrHorizontalContinous()
|
||||||
_isVerticalOrHorizontalContinous() ? () {} : null,
|
? () {}
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
@ -2210,21 +2142,20 @@ class _MangaChapterPageGalleryState
|
||||||
? _onBtnTapped(_currentIndex! + 1, false)
|
? _onBtnTapped(_currentIndex! + 1, false)
|
||||||
: _isViewFunction();
|
: _isViewFunction();
|
||||||
},
|
},
|
||||||
onDoubleTapDown:
|
onDoubleTapDown: _isVerticalOrHorizontalContinous()
|
||||||
_isVerticalOrHorizontalContinous()
|
|
||||||
? (details) {
|
? (details) {
|
||||||
_toggleScale(details.globalPosition);
|
_toggleScale(details.globalPosition);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
onSecondaryTapDown:
|
onSecondaryTapDown: _isVerticalOrHorizontalContinous()
|
||||||
_isVerticalOrHorizontalContinous()
|
|
||||||
? (details) {
|
? (details) {
|
||||||
_toggleScale(details.globalPosition);
|
_toggleScale(details.globalPosition);
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null,
|
onDoubleTap: _isVerticalOrHorizontalContinous() ? () {} : null,
|
||||||
onSecondaryTap:
|
onSecondaryTap: _isVerticalOrHorizontalContinous()
|
||||||
_isVerticalOrHorizontalContinous() ? () {} : null,
|
? () {}
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
@ -2341,8 +2272,7 @@ class _MangaChapterPageGalleryState
|
||||||
if (valueT)
|
if (valueT)
|
||||||
ValueListenableBuilder(
|
ValueListenableBuilder(
|
||||||
valueListenable: _pageOffset,
|
valueListenable: _pageOffset,
|
||||||
builder:
|
builder: (context, value, child) => Slider(
|
||||||
(context, value, child) => Slider(
|
|
||||||
min: 2.0,
|
min: 2.0,
|
||||||
max: 30.0,
|
max: 30.0,
|
||||||
divisions: max(28, 3),
|
divisions: max(28, 3),
|
||||||
|
|
@ -2406,12 +2336,11 @@ class _MangaChapterPageGalleryState
|
||||||
.set(ScaleType.values[value.index]);
|
.set(ScaleType.values[value.index]);
|
||||||
},
|
},
|
||||||
value: scaleType,
|
value: scaleType,
|
||||||
list:
|
list: ScaleType.values.where((scale) {
|
||||||
ScaleType.values.where((scale) {
|
|
||||||
try {
|
try {
|
||||||
return getScaleTypeNames(context).contains(
|
return getScaleTypeNames(
|
||||||
getScaleTypeNames(context)[scale.index],
|
context,
|
||||||
);
|
).contains(getScaleTypeNames(context)[scale.index]);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -2642,8 +2571,7 @@ class CustomPopupMenuButton<T> extends StatelessWidget {
|
||||||
offset: Offset.fromDirection(1),
|
offset: Offset.fromDirection(1),
|
||||||
color: Colors.black,
|
color: Colors.black,
|
||||||
onSelected: onSelected,
|
onSelected: onSelected,
|
||||||
itemBuilder:
|
itemBuilder: (context) => [
|
||||||
(context) => [
|
|
||||||
for (var d in list)
|
for (var d in list)
|
||||||
PopupMenuItem(
|
PopupMenuItem(
|
||||||
value: d,
|
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