diff --git a/READER_IMPLEMENTATION_PLAN.md b/READER_IMPLEMENTATION_PLAN.md index f22ab00..ef8fa39 100644 --- a/READER_IMPLEMENTATION_PLAN.md +++ b/READER_IMPLEMENTATION_PLAN.md @@ -9079,17 +9079,412 @@ const chapterMarkerCSS = ` --- -## 8. Lazy Loading & Caching +## 8. Manga Reader Implementation -### 8.1 Page Cache (5-Page Ahead) +Manga extends the comic reader with specialized reading modes: +- **RTL (Right-to-Left)** - Traditional Japanese manga reading +- **Vertical Scroll** - Webtoon/manhwa style (infinite vertical scroll) -**File:** `web/src/reader/comic/page-cache.ts` +### 8.1 RTL Navigator (Right-to-Left Reading) + +**File:** `web/src/reader/manga/rtl-navigator.ts` + +```typescript +// Right-to-left navigation for manga +// Reverses page turn direction and key bindings + +interface RTLNavigatorState { + currentPage: number; + totalPages: number; + readingDirection: 'rtl' | 'ltr'; +} + +function createRTLNavigator(totalPages: number): RTLNavigatorState { + return { + currentPage: 1, + totalPages, + readingDirection: 'rtl' + }; +} + +function getNextPage(state: RTLNavigatorState): number { + // In RTL, "next" page means moving left (decreasing page number) + if (state.readingDirection === 'rtl') { + return Math.max(1, state.currentPage - 1); + } + return Math.min(state.totalPages, state.currentPage + 1); +} + +function getPreviousPage(state: RTLNavigatorState): number { + // In RTL, "previous" page means moving right (increasing page number) + if (state.readingDirection === 'rtl') { + return Math.min(state.totalPages, state.currentPage + 1); + } + return Math.max(1, state.currentPage - 1); +} + +function navigateToPage(state: RTLNavigatorState, pageNumber: number): RTLNavigatorState { + return { + ...state, + currentPage: Math.max(1, Math.min(state.totalPages, pageNumber)) + }; +} + +function getProgress(state: RTLNavigatorState): { current: number; total: number } { + return { + current: state.currentPage, + total: state.totalPages + }; +} + +function getReadingProgressPercentage(state: RTLNavigatorState): number { + return (state.currentPage / state.totalPages) * 100; +} +``` + +### 8.2 Vertical Scroll Mode (Webtoon Style) + +**File:** `web/src/reader/manga/vertical-scroll-mode.ts` + +```typescript +// Vertical scroll mode for webtoons/manhwa +// Infinite scroll with image loading and lazy rendering + +interface VerticalScrollState { + container: HTMLElement; + loadedPages: Set; + loadingPages: Set; + currentPage: number; + totalPages: number; + threshold: number; // Distance from bottom to trigger next page load + mediaItemId: string; +} + +function createVerticalScroll( + container: HTMLElement, + mediaItemId: string, + totalPages: number +): VerticalScrollState { + const state: VerticalScrollState = { + container, + loadedPages: new Set(), + loadingPages: new Set(), + currentPage: 1, + totalPages, + threshold: 500, // Load next page when 500px from bottom + mediaItemId + }; + + // Initial page load + loadPage(state, 1); + + // Setup scroll listener + setupScrollListener(state); + + return state; +} + +async function loadPage( + state: VerticalScrollState, + pageNumber: number +): Promise { + if (state.loadedPages.has(pageNumber) || state.loadingPages.has(pageNumber)) { + return; + } + + state.loadingPages.add(pageNumber); + + try { + const token = localStorage.getItem('token'); + const response = await fetch( + `/readers/${state.mediaItemId}/pages/${pageNumber}`, + { + headers: { Authorization: `Bearer ${token}` } + } + ); + + if (!response.ok) { + throw new Error(`Failed to load page ${pageNumber}`); + } + + const blob = await response.blob(); + const imgUrl = URL.createObjectURL(blob); + + const pageContainer = document.createElement('div'); + pageContainer.className = 'vertical-page'; + pageContainer.dataset.pageNumber = pageNumber.toString(); + + const img = document.createElement('img'); + img.src = imgUrl; + img.alt = `Page ${pageNumber}`; + img.loading = 'lazy'; + + pageContainer.appendChild(img); + state.container.appendChild(pageContainer); + + state.loadedPages.add(pageNumber); + state.loadingPages.delete(pageNumber); + + // Load next pages proactively + if (pageNumber < state.totalPages) { + loadPage(state, pageNumber + 1); + if (pageNumber + 1 < state.totalPages) { + loadPage(state, pageNumber + 2); + } + } + } catch (error) { + console.error(`Failed to load page ${pageNumber}:`, error); + state.loadingPages.delete(pageNumber); + } +} + +function setupScrollListener(state: VerticalScrollState): void { + let scrollTimeout: number | undefined; + + state.container.addEventListener('scroll', () => { + clearTimeout(scrollTimeout); + scrollTimeout = window.setTimeout(() => { + checkScrollPosition(state); + }, 100); + }); +} + +function checkScrollPosition(state: VerticalScrollState): void { + const scrollBottom = state.container.scrollHeight - + state.container.scrollTop - + state.container.clientHeight; + + if (scrollBottom < state.threshold) { + const lastPage = Math.max(...state.loadedPages); + if (lastPage < state.totalPages) { + loadPage(state, lastPage + 1); + } + } + + // Update current page based on scroll position + const currentPage = getCurrentPageFromScroll(state); + if (currentPage !== state.currentPage) { + state.currentPage = currentPage; + // Dispatch event for progress tracking + window.dispatchEvent(new CustomEvent('page-change', { + detail: { page: currentPage } + })); + } +} + +function getCurrentPageFromScroll(state: VerticalScrollState): number { + const pages = state.container.querySelectorAll('.vertical-page'); + + for (const page of pages) { + const rect = page.getBoundingClientRect(); + const containerRect = state.container.getBoundingClientRect(); + + // Page is considered "current" if it's in the middle 50% of viewport + const pageMiddle = rect.top + rect.height / 2; + const viewportMiddle = containerRect.top + containerRect.height / 2; + + if (Math.abs(pageMiddle - viewportMiddle) < containerRect.height / 4) { + return parseInt(page.dataset.pageNumber || '1'); + } + } + + return state.currentPage; +} + +function destroyVerticalScroll(state: VerticalScrollState): void { + // Clean up blob URLs + const images = state.container.querySelectorAll('img'); + images.forEach(img => { + const url = img.src; + if (url.startsWith('blob:')) { + URL.revokeObjectURL(url); + } + }); + + state.container.innerHTML = ''; + state.loadedPages.clear(); + state.loadingPages.clear(); +} +``` + +### 8.3 Reading Direction Detection + +**File:** `web/src/reader/manga/reading-direction.ts` + +```typescript +// Detect reading direction from metadata or user preference + +type ReadingDirection = 'auto' | 'ltr' | 'rtl' | 'vertical'; + +interface ReadingDirectionState { + direction: ReadingDirection; + detectedDirection: 'ltr' | 'rtl' | 'vertical'; + userPreference: ReadingDirection | null; +} + +async function detectReadingDirection( + metadata: MediaItemMetadata +): Promise { + // Check user preference first + const userPreference = await getUserReadingDirectionPreference(); + if (userPreference && userPreference !== 'auto') { + return { + direction: userPreference, + detectedDirection: 'ltr', // Default fallback + userPreference + }; + } + + // Detect from metadata + const detectedDirection = detectFromMetadata(metadata); + + return { + direction: 'auto', + detectedDirection, + userPreference: null + }; +} + +function detectFromMetadata(metadata: MediaItemMetadata): 'ltr' | 'rtl' | 'vertical' { + // Check manga_type field from database + const mangaType = (metadata as any).manga_type; + if (mangaType === 'yes_and_right_to_left' || mangaType === 'yes') { + return 'rtl'; + } + + // Check reading_direction field + const readingDirection = (metadata as any).reading_direction; + if (readingDirection === 'rtl' || readingDirection === 'vertical') { + return readingDirection; + } + + // Detect from filename + const filename = metadata.filePath.toLowerCase(); + if (filename.includes('manga') || filename.includes('manhwa') || filename.includes('webtoon')) { + return 'vertical'; + } + + // Default to LTR + return 'ltr'; +} + +async function getUserReadingDirectionPreference(): Promise { + const userId = localStorage.getItem('userId'); + if (!userId) return null; + + try { + const response = await fetch(`/api/users/${userId}/settings`); + if (!response.ok) return null; + + const settings = await response.json(); + return settings.reading_direction || null; + } catch { + return null; + } +} + +function getEffectiveDirection(state: ReadingDirectionState): 'ltr' | 'rtl' | 'vertical' { + if (state.direction !== 'auto') { + return state.direction as 'ltr' | 'rtl' | 'vertical'; + } + return state.detectedDirection; +} + +function shouldUseRTL(state: ReadingDirectionState): boolean { + return getEffectiveDirection(state) === 'rtl'; +} + +function shouldUseVerticalScroll(state: ReadingDirectionState): boolean { + return getEffectiveDirection(state) === 'vertical'; +} +``` + +### 8.4 Manga Settings Integration + +**File:** `web/src/reader/manga/settings.ts` + +```typescript +// Manga-specific settings integration +// Extends the common reader settings manager + +interface MangaSettings { + readingDirection: 'auto' | 'ltr' | 'rtl' | 'vertical'; + verticalScrollSpeed: 'slow' | 'normal' | 'fast'; + rtlPageTransition: 'slide' | 'fade' | 'none'; + webtoonMode: boolean; +} + +async function getMangaSettings(): Promise { + const defaultSettings: MangaSettings = { + readingDirection: 'auto', + verticalScrollSpeed: 'normal', + rtlPageTransition: 'slide', + webtoonMode: false + }; + + try { + const userId = localStorage.getItem('userId'); + const response = await fetch(`/api/users/${userId}/settings`); + + if (response.ok) { + const settings = await response.json(); + return { ...defaultSettings, ...settings }; + } + } catch (error) { + console.error('Failed to load manga settings:', error); + } + + return defaultSettings; +} + +async function updateMangaSettings( + settings: Partial +): Promise { + const userId = localStorage.getItem('userId'); + + try { + const response = await fetch(`/api/users/${userId}/settings`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify(settings) + }); + + if (!response.ok) { + throw new Error('Failed to update manga settings'); + } + } catch (error) { + console.error('Failed to save manga settings:', error); + } +} + +function applyMangaSettings(settings: MangaSettings): void { + // Apply reading direction + document.documentElement.dataset.readingDirection = settings.readingDirection; + + // Apply vertical scroll speed + if (settings.verticalScrollSpeed === 'slow') { + document.documentElement.style.scrollBehavior = 'smooth'; + } else if (settings.verticalScrollSpeed === 'fast') { + document.documentElement.style.scrollBehavior = 'auto'; + } + + // Apply RTL page transition + if (settings.rtlPageTransition !== 'none') { + document.documentElement.dataset.pageTransition = settings.rtlPageTransition; + } +} +``` + +### 8.5 Manga Page Cache (Shared with Comics) + +**File:** `web/src/reader/comic/page-cache.ts` (shared for both comics and manga) ```typescript // Lazy-loading page cache with 5-page ahead prefetch - -// Lazy-loading page cache with 5-page ahead prefetch -// Procedural implementation (no OOP) +// Shared by both comic and manga readers interface PageCacheState { cache: Map; @@ -9123,7 +9518,7 @@ async function getCachedPage( resolve({ ...state, page: state.cache.get(pageNumber)! }); } }, 100); - }) as Promise; + }) as Promise; } const newLoading = new Set(state.loading); @@ -9140,7 +9535,7 @@ async function getCachedPage( prefetchPages(newState, pageNumber + 1); cleanupPageCache(newState, pageNumber); - return { ...newState, page: img }; + return { ...state, page: img }; } async function loadComicPage(