feat(reader): add core infrastructure for feature-based architecture
- Add reader-context.ts: defines ReaderContext interface and factory - Add reader-events.ts: event bus for feature communication - Add reader-state.ts: centralized state management - Add reader-navigation.ts: unified navigation and rendering API - Add reader-services.ts: shared services (progress, chapters) - Establishes foundation for Feature Registration Pattern
This commit is contained in:
@@ -0,0 +1,164 @@
|
|||||||
|
// Parser Manager - Routes files to appropriate parsers
|
||||||
|
// Procedural style: Functions, not classes
|
||||||
|
|
||||||
|
import JSZip from "jszip";
|
||||||
|
import { parseEPUB } from "../parsers/epub-parserss";
|
||||||
|
import { parseFB2 } from "../parsers/fb2-parserr";
|
||||||
|
import { parseTXT } from "../parsers/txt-parserr";
|
||||||
|
import { parseHTML } from "../parsers/html-parserr";
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Parser Registry
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const PARSER_REGISTRY: ParserEntry[] = [
|
||||||
|
{
|
||||||
|
format: "epub",
|
||||||
|
mimeType: "application/epub+zip",
|
||||||
|
extensions: [".epub"],
|
||||||
|
side: "client",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: "fb2",
|
||||||
|
mimeType: "application/fb2",
|
||||||
|
extensions: [".fb2", ".fb2.zip"],
|
||||||
|
side: "client",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: "txt",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
extensions: [".txt"],
|
||||||
|
side: "client",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: "html",
|
||||||
|
mimeType: "text/html",
|
||||||
|
extensions: [".html", ".htm"],
|
||||||
|
side: "client",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: "mobi",
|
||||||
|
mimeType: "application/x-mobipocket-ebook",
|
||||||
|
extensions: [".mobi", ".azw"],
|
||||||
|
side: "server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: "azw3",
|
||||||
|
mimeType: "application/vnd.amazon.mobi8-ebook",
|
||||||
|
extensions: [".azw3"],
|
||||||
|
side: "server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: "docx",
|
||||||
|
mimeType:
|
||||||
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||||
|
extensions: [".docx"],
|
||||||
|
side: "server",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
format: "rtf",
|
||||||
|
mimeType: "application/rtf",
|
||||||
|
extensions: [".rtf"],
|
||||||
|
side: "server",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
interface ParserEntry {
|
||||||
|
format: string;
|
||||||
|
mimeType: string;
|
||||||
|
extensions: string[];
|
||||||
|
side: "client" | "server";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Parser Detection
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export function detectParserFormat(
|
||||||
|
mimeType: string,
|
||||||
|
extension: string,
|
||||||
|
): ParserEntry | null {
|
||||||
|
return (
|
||||||
|
PARSER_REGISTRY.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.mimeType === mimeType ||
|
||||||
|
entry.extensions.includes(extension.toLowerCase()),
|
||||||
|
) || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requiresServerParsing(
|
||||||
|
mimeType: string,
|
||||||
|
extension: string,
|
||||||
|
): boolean {
|
||||||
|
const entry = detectParserFormat(mimeType, extension);
|
||||||
|
return entry?.side === "server" || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Main Parse Function (Router)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export async function parseEbook(
|
||||||
|
file: Blob,
|
||||||
|
mimeType: string,
|
||||||
|
extension: string,
|
||||||
|
): Promise<EbookCIF> {
|
||||||
|
const entry = detectParserFormat(mimeType, extension);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
throw new Error(`Unsupported ebook format: ${mimeType}, ${extension}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.side === "server") {
|
||||||
|
return parseEbookOnServer(file, entry.format);
|
||||||
|
} else {
|
||||||
|
return parseEbookOnClient(file, entry.format);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Client-Side Parsing
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function parseEbookOnClient(
|
||||||
|
file: Blob,
|
||||||
|
format: string,
|
||||||
|
): Promise<EbookCIF> {
|
||||||
|
switch (format) {
|
||||||
|
case "epub":
|
||||||
|
return parseEPUB(file);
|
||||||
|
case "fb2":
|
||||||
|
return parseFB2(file);
|
||||||
|
case "txt":
|
||||||
|
return parseTXT(file);
|
||||||
|
case "html":
|
||||||
|
return parseHTML(file);
|
||||||
|
default:
|
||||||
|
throw new Error(`Client-side parser not implemented for: ${format}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Server-Side Parsing (API Call)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function parseEbookOnServer(
|
||||||
|
file: Blob,
|
||||||
|
format: string,
|
||||||
|
): Promise<EbookCIF> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("format", format);
|
||||||
|
|
||||||
|
const response = await fetch("/readers/parse", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Server parsing failed: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import type { ReaderMetadata } from "../../types/reader";
|
||||||
|
|
||||||
|
type ReaderType = "ebook" | "pdf" | "comic" | "manga";
|
||||||
|
|
||||||
|
interface UniversalReader {
|
||||||
|
type: "ebook";
|
||||||
|
cif: any;
|
||||||
|
currentSpineIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PDFReader {
|
||||||
|
type: "pdf";
|
||||||
|
doc: any;
|
||||||
|
currentPage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ComicReader {
|
||||||
|
type: "comic";
|
||||||
|
images: Blob[];
|
||||||
|
currentPage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MangaReader {
|
||||||
|
type: "manga";
|
||||||
|
images: Blob[];
|
||||||
|
currentPage: number;
|
||||||
|
readingDirection: "rtl" | "vertical";
|
||||||
|
}
|
||||||
|
|
||||||
|
type CurrentReader = UniversalReader | PDFReader | ComicReader | MangaReader;
|
||||||
|
|
||||||
|
export interface ReaderContext {
|
||||||
|
getState: () => {
|
||||||
|
currentReader: CurrentReader | null;
|
||||||
|
readerMetadata: ReaderMetadata | null;
|
||||||
|
};
|
||||||
|
setState: (updates: Partial<ReaderState>) => void;
|
||||||
|
navigation: {
|
||||||
|
nextPage: () => void;
|
||||||
|
previousPage: () => void;
|
||||||
|
goToPage: (page: number) => void;
|
||||||
|
goToChapter: (chapterIndex: number) => void;
|
||||||
|
};
|
||||||
|
render: () => void;
|
||||||
|
elements: {
|
||||||
|
readerContent: HTMLElement;
|
||||||
|
chrome: HTMLElement | null;
|
||||||
|
progressDisplay: HTMLElement | null;
|
||||||
|
};
|
||||||
|
events: {
|
||||||
|
on: (event: string, handler: Function) => void;
|
||||||
|
emit: (event: string, data?: any) => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReaderState {
|
||||||
|
currentReader: CurrentReader | null;
|
||||||
|
readerMetadata: ReaderMetadata | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createReaderContext(
|
||||||
|
getState: () => ReaderState,
|
||||||
|
setState: (updates: Partial<ReaderState>) => void,
|
||||||
|
navigation: ReaderContext["navigation"],
|
||||||
|
render: () => void,
|
||||||
|
): ReaderContext {
|
||||||
|
return {
|
||||||
|
getState,
|
||||||
|
setState,
|
||||||
|
navigation,
|
||||||
|
render,
|
||||||
|
elements: {
|
||||||
|
readerContent: document.getElementById("reader-content")!,
|
||||||
|
chrome: document.getElementById("reader-chrome"),
|
||||||
|
progressDisplay: document.getElementById("progress-display"),
|
||||||
|
},
|
||||||
|
events: {
|
||||||
|
on: (event: string, handler: Function) => readerEvents.on(event, handler),
|
||||||
|
emit: (event: string, data?: any) => readerEvents.emit(event, data),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
import { readerEvents } from "./reader-events";
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
type ReaderEventType =
|
||||||
|
| "readerReady"
|
||||||
|
| "pageChanged"
|
||||||
|
| "chapterChanged"
|
||||||
|
| "zoomChanged"
|
||||||
|
| "themeChanged"
|
||||||
|
| "progressUpdated"
|
||||||
|
| "beforePageChange"
|
||||||
|
| "afterPageChange";
|
||||||
|
|
||||||
|
class EventBus {
|
||||||
|
private listeners = new Map<ReaderEventType, Function[]>();
|
||||||
|
|
||||||
|
on(event: ReaderEventType, handler: Function): void {
|
||||||
|
if (!this.listeners.has(event)) {
|
||||||
|
this.listeners.set(event, []);
|
||||||
|
}
|
||||||
|
this.listeners.get(event)!.push(handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
off(event: ReaderEventType, handler: Function): void {
|
||||||
|
const handlers = this.listeners.get(event);
|
||||||
|
if (handlers) {
|
||||||
|
const index = handlers.indexOf(handler);
|
||||||
|
if (index > -1) {
|
||||||
|
handlers.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(event: ReaderEventType, data?: any): void {
|
||||||
|
this.listeners.get(event)?.forEach((handler) => handler(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(): void {
|
||||||
|
this.listeners.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const readerEvents = new EventBus();
|
||||||
|
export type { ReaderEventType };
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
import type { ReaderMetadata } from "../../types/reader";
|
||||||
|
import type { CurrentReader } from "./reader-context";
|
||||||
|
import { getState, setState } from "./reader-state";
|
||||||
|
import { readerEvents } from "./reader-events";
|
||||||
|
import { updateReadingProgress } from "./reader-services";
|
||||||
|
|
||||||
|
export function createNavigationAPI() {
|
||||||
|
return {
|
||||||
|
nextPage: () => {
|
||||||
|
const state = getState();
|
||||||
|
if (!state.currentReader) return;
|
||||||
|
|
||||||
|
readerEvents.emit("beforePageChange", state.currentReader);
|
||||||
|
|
||||||
|
if (state.currentReader.type === "ebook") {
|
||||||
|
if (
|
||||||
|
state.currentReader.currentSpineIndex <
|
||||||
|
state.currentReader.cif.spine.length - 1
|
||||||
|
) {
|
||||||
|
state.currentReader.currentSpineIndex++;
|
||||||
|
renderSpineItem();
|
||||||
|
}
|
||||||
|
} else if (state.currentReader.type === "pdf") {
|
||||||
|
const totalPages = state.readerMetadata?.total_pages || 0;
|
||||||
|
if (state.currentReader.currentPage < totalPages) {
|
||||||
|
state.currentReader.currentPage++;
|
||||||
|
renderPDFPage();
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
state.currentReader.type === "comic" ||
|
||||||
|
state.currentReader.type === "manga"
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
state.currentReader.currentPage <
|
||||||
|
state.currentReader.images.length - 1
|
||||||
|
) {
|
||||||
|
state.currentReader.currentPage++;
|
||||||
|
renderComicPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({ currentReader: state.currentReader });
|
||||||
|
sendProgressUpdate();
|
||||||
|
readerEvents.emit("pageChanged", getCurrentPage());
|
||||||
|
readerEvents.emit("afterPageChange", state.currentReader);
|
||||||
|
},
|
||||||
|
|
||||||
|
previousPage: () => {
|
||||||
|
const state = getState();
|
||||||
|
if (!state.currentReader) return;
|
||||||
|
|
||||||
|
readerEvents.emit("beforePageChange", state.currentReader);
|
||||||
|
|
||||||
|
if (state.currentReader.type === "ebook") {
|
||||||
|
if (state.currentReader.currentSpineIndex > 0) {
|
||||||
|
state.currentReader.currentSpineIndex--;
|
||||||
|
renderSpineItem();
|
||||||
|
}
|
||||||
|
} else if (state.currentReader.type === "pdf") {
|
||||||
|
if (state.currentReader.currentPage > 1) {
|
||||||
|
state.currentReader.currentPage--;
|
||||||
|
renderPDFPage();
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
state.currentReader.type === "comic" ||
|
||||||
|
state.currentReader.type === "manga"
|
||||||
|
) {
|
||||||
|
if (state.currentReader.currentPage > 0) {
|
||||||
|
state.currentReader.currentPage--;
|
||||||
|
renderComicPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({ currentReader: state.currentReader });
|
||||||
|
sendProgressUpdate();
|
||||||
|
readerEvents.emit("pageChanged", getCurrentPage());
|
||||||
|
readerEvents.emit("afterPageChange", state.currentReader);
|
||||||
|
},
|
||||||
|
|
||||||
|
goToPage: (page: number) => {
|
||||||
|
const state = getState();
|
||||||
|
if (!state.currentReader) return;
|
||||||
|
|
||||||
|
readerEvents.emit("beforePageChange", state.currentReader);
|
||||||
|
|
||||||
|
if (state.currentReader.type === "ebook") {
|
||||||
|
if (page >= 0 && page < state.currentReader.cif.spine.length) {
|
||||||
|
state.currentReader.currentSpineIndex = page;
|
||||||
|
renderSpineItem();
|
||||||
|
}
|
||||||
|
} else if (state.currentReader.type === "pdf") {
|
||||||
|
if (page >= 1 && page <= (state.readerMetadata?.total_pages || 0)) {
|
||||||
|
state.currentReader.currentPage = page;
|
||||||
|
renderPDFPage();
|
||||||
|
}
|
||||||
|
} else if (
|
||||||
|
state.currentReader.type === "comic" ||
|
||||||
|
state.currentReader.type === "manga"
|
||||||
|
) {
|
||||||
|
if (page >= 0 && page < state.currentReader.images.length) {
|
||||||
|
state.currentReader.currentPage = page;
|
||||||
|
renderComicPage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState({ currentReader: state.currentReader });
|
||||||
|
sendProgressUpdate();
|
||||||
|
readerEvents.emit("pageChanged", page);
|
||||||
|
readerEvents.emit("afterPageChange", state.currentReader);
|
||||||
|
},
|
||||||
|
|
||||||
|
goToChapter: (chapterIndex: number) => {
|
||||||
|
const state = getState();
|
||||||
|
if (!state.readerMetadata?.chapter_metadata?.chapters) return;
|
||||||
|
|
||||||
|
const chapters = state.readerMetadata.chapter_metadata.chapters;
|
||||||
|
if (chapterIndex < 0 || chapterIndex >= chapters.length) return;
|
||||||
|
|
||||||
|
const chapter = chapters[chapterIndex];
|
||||||
|
const pageAPI = createNavigationAPI();
|
||||||
|
pageAPI.goToPage(chapter.start_page);
|
||||||
|
|
||||||
|
readerEvents.emit("chapterChanged", chapter);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSpineItem() {
|
||||||
|
const state = getState();
|
||||||
|
if (state.currentReader?.type !== "ebook") return;
|
||||||
|
|
||||||
|
const spineItem =
|
||||||
|
state.currentReader.cif.spine[state.currentReader.currentSpineIndex];
|
||||||
|
const container = document.getElementById("reader-content");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.innerHTML = spineItem.content;
|
||||||
|
applyReaderTheme();
|
||||||
|
applyTypography();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderPDFPage() {
|
||||||
|
const state = getState();
|
||||||
|
if (state.currentReader?.type !== "pdf") return;
|
||||||
|
|
||||||
|
const container = document.getElementById("reader-content");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const { getPDFPage } = await import("../pdf/pdfjs-wrapper");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await getPDFPage(state.currentReader.currentPage);
|
||||||
|
const viewport = page.getViewport({ scale: 1.5 });
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
canvas.className = "pdf-page mx-auto";
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (ctx) {
|
||||||
|
await page.render({
|
||||||
|
canvasContext: ctx,
|
||||||
|
viewport: viewport,
|
||||||
|
canvas: canvas,
|
||||||
|
}).promise;
|
||||||
|
container.innerHTML = "";
|
||||||
|
container.appendChild(canvas);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to render PDF page:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderComicPage() {
|
||||||
|
const state = getState();
|
||||||
|
if (
|
||||||
|
state.currentReader?.type !== "comic" &&
|
||||||
|
state.currentReader?.type !== "manga"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const container = document.getElementById("reader-content");
|
||||||
|
if (
|
||||||
|
!container ||
|
||||||
|
!state.currentReader.images[state.currentReader.currentPage]
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = URL.createObjectURL(
|
||||||
|
state.currentReader.images[state.currentReader.currentPage],
|
||||||
|
);
|
||||||
|
img.className = "w-full h-contain object-contain";
|
||||||
|
img.alt = `Page ${state.currentReader.currentPage + 1}`;
|
||||||
|
container.innerHTML = "";
|
||||||
|
container.appendChild(img);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyReaderTheme() {
|
||||||
|
const state = getState();
|
||||||
|
const settings = getReaderSettings();
|
||||||
|
const container = document.getElementById("reader-content");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.className = `ebook-content theme-${settings.reading_theme}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTypography() {
|
||||||
|
const state = getState();
|
||||||
|
const settings = getReaderSettings();
|
||||||
|
const container = document.getElementById("reader-content");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
container.style.fontSize = `${settings.font_size}px`;
|
||||||
|
container.style.lineHeight = settings.line_height.toString();
|
||||||
|
container.style.fontFamily = getFontStack(settings.reading_font);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFontStack(font: string): string {
|
||||||
|
const stacks: Record<string, string> = {
|
||||||
|
literata: '"Literata", serif',
|
||||||
|
crimson: '"Crimson Text", serif',
|
||||||
|
"source-serif": '"Source Serif 4", serif',
|
||||||
|
"eb-garamond": '"EB Garamond", serif',
|
||||||
|
libertinus: '"Libertinus Serif", serif',
|
||||||
|
"noto-serif": '"Noto Serif", serif',
|
||||||
|
"charis-sil": '"Charis SIL", serif',
|
||||||
|
"ibm-plex": '"IBM Plex Serif", serif',
|
||||||
|
};
|
||||||
|
return stacks[font] || stacks["literata"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getReaderSettings() {
|
||||||
|
// TODO: Load from settings manager
|
||||||
|
return {} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendProgressUpdate() {
|
||||||
|
const state = getState();
|
||||||
|
if (!state.currentReader || !state.readerMetadata) return;
|
||||||
|
|
||||||
|
let currentPage = 0;
|
||||||
|
let totalPages = 0;
|
||||||
|
|
||||||
|
if (state.currentReader.type === "ebook") {
|
||||||
|
currentPage = state.currentReader.currentSpineIndex + 1;
|
||||||
|
totalPages = state.currentReader.cif.spine.length;
|
||||||
|
} else if (state.currentReader.type === "pdf") {
|
||||||
|
totalPages = state.readerMetadata.total_pages || 0;
|
||||||
|
currentPage = state.currentReader.currentPage;
|
||||||
|
} else if (
|
||||||
|
state.currentReader.type === "comic" ||
|
||||||
|
state.currentReader.type === "manga"
|
||||||
|
) {
|
||||||
|
totalPages = state.currentReader.images.length;
|
||||||
|
currentPage = state.currentReader.currentPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateReadingProgress(state.readerMetadata.media_item_id, {
|
||||||
|
current_page: currentPage,
|
||||||
|
total_pages: totalPages,
|
||||||
|
});
|
||||||
|
|
||||||
|
readerEvents.emit("progressUpdated", { currentPage, totalPages });
|
||||||
|
}
|
||||||
|
|
||||||
|
import { getCurrentPage } from "./reader-state";
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { apiPut } from "../../api";
|
||||||
|
|
||||||
|
interface ReadingProgress {
|
||||||
|
current_page: number;
|
||||||
|
total_pages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateReadingProgress(
|
||||||
|
mediaItemId: string,
|
||||||
|
progress: ReadingProgress,
|
||||||
|
): Promise<void> {
|
||||||
|
const response = await apiPut(
|
||||||
|
`/media-items/${mediaItemId}/progress`,
|
||||||
|
progress,
|
||||||
|
);
|
||||||
|
await response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChapterNavigation(chapters: any[]) {
|
||||||
|
return {
|
||||||
|
getNextChapter: (currentPage: number) => {
|
||||||
|
for (let i = 0; i < chapters.length - 1; i++) {
|
||||||
|
const chapter = chapters[i];
|
||||||
|
const nextChapter = chapters[i + 1];
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentPage >= chapter.start_page &&
|
||||||
|
currentPage < nextChapter.start_page
|
||||||
|
) {
|
||||||
|
return nextChapter.start_page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPreviousChapter: (currentPage: number) => {
|
||||||
|
for (let i = 1; i < chapters.length; i++) {
|
||||||
|
const chapter = chapters[i];
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentPage >= chapter.start_page &&
|
||||||
|
currentPage < chapter.start_page + chapter.page_count
|
||||||
|
) {
|
||||||
|
return chapters[i - 1].start_page;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentPage < chapters[0].start_page) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return chapters[0].start_page;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import type { ReaderMetadata, ReaderState } from "./reader-context";
|
||||||
|
|
||||||
|
type CurrentReader = import("./reader-context").CurrentReader;
|
||||||
|
|
||||||
|
let currentState: ReaderState = {
|
||||||
|
currentReader: null,
|
||||||
|
readerMetadata: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getState(): ReaderState {
|
||||||
|
return { ...currentState };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setState(updates: Partial<ReaderState>): void {
|
||||||
|
const oldState = { ...currentState };
|
||||||
|
currentState = { ...currentState, ...updates };
|
||||||
|
|
||||||
|
// Emit events for significant changes
|
||||||
|
if (
|
||||||
|
updates.currentReader &&
|
||||||
|
oldState.currentReader !== updates.currentReader
|
||||||
|
) {
|
||||||
|
readerEvents.emit("readerReady", currentState.currentReader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentReader(): CurrentReader | null {
|
||||||
|
return currentState.currentReader;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getReaderMetadata(): ReaderMetadata | null {
|
||||||
|
return currentState.readerMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentPage(): number {
|
||||||
|
const reader = currentState.currentReader;
|
||||||
|
if (!reader) return 0;
|
||||||
|
|
||||||
|
if (reader.type === "ebook") {
|
||||||
|
return reader.currentSpineIndex;
|
||||||
|
}
|
||||||
|
return reader.currentPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
import { readerEvents } from "./reader-events";
|
||||||
Reference in New Issue
Block a user