diff --git a/web/src/reader/manga/reading-direction.ts b/web/src/reader/manga/reading-direction.ts new file mode 100644 index 0000000..a16ec2b --- /dev/null +++ b/web/src/reader/manga/reading-direction.ts @@ -0,0 +1,93 @@ +// 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"; +} diff --git a/web/src/reader/manga/rtl-navigator.ts b/web/src/reader/manga/rtl-navigator.ts new file mode 100644 index 0000000..1610d99 --- /dev/null +++ b/web/src/reader/manga/rtl-navigator.ts @@ -0,0 +1,56 @@ +// 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; +} diff --git a/web/src/reader/manga/settings.ts b/web/src/reader/manga/settings.ts new file mode 100644 index 0000000..aa53474 --- /dev/null +++ b/web/src/reader/manga/settings.ts @@ -0,0 +1,73 @@ +// 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; + } +} diff --git a/web/src/reader/manga/vertical-scroll-mode.ts b/web/src/reader/manga/vertical-scroll-mode.ts new file mode 100644 index 0000000..34684b1 --- /dev/null +++ b/web/src/reader/manga/vertical-scroll-mode.ts @@ -0,0 +1,161 @@ +// 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(); +}