PDF reading via pdf.js
- PdfReader renders PDFs to canvas with page navigation and page-number progress, reusing the same integer progress model as comics. - Reader routes by file container, not media kind, so a PDF in a comics library and a PDF ebook both open in the PDF reader. - pdf.js is dynamically imported: the main bundle stays 443 kB and the 474 kB pdf chunk plus 1.26 MB worker load only when a PDF is actually opened. - comics cover extractor now only handles zip archives. A PDF in a comics library is a valid item but not a zip, so it returns no cover instead of logging an error on every request. - fixed the page scale to leave slack for the vertical scrollbar, which was otherwise forcing a horizontal scrollbar on tall pages. Verified live: a 3-page PDF renders, navigates, and reports 33% progress.
This commit is contained in:
parent
dc95a9f78a
commit
5a377a2198
5 changed files with 345 additions and 1 deletions
|
|
@ -18,7 +18,17 @@ func init() {
|
||||||
|
|
||||||
// ExtractCover writes the first page of the archive into the cache and returns
|
// ExtractCover writes the first page of the archive into the cache and returns
|
||||||
// its path. A comic's first page is its cover by convention.
|
// its path. A comic's first page is its cover by convention.
|
||||||
|
//
|
||||||
|
// Only zip-based archives are handled. A PDF in a comics library is a valid
|
||||||
|
// item but not a zip, so it returns no cover rather than logging an error on
|
||||||
|
// every request — the grid falls back to a placeholder.
|
||||||
func ExtractCover(ctx context.Context, db *sql.DB, item media.Item, absPath, cacheDir string) (string, error) {
|
func ExtractCover(ctx context.Context, db *sql.DB, item media.Item, absPath, cacheDir string) (string, error) {
|
||||||
|
switch strings.ToLower(filepath.Ext(item.Path)) {
|
||||||
|
case ".cbz", ".zip":
|
||||||
|
default:
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
pages, err := ListPages(absPath)
|
pages, err := ListPages(absPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,8 @@
|
||||||
"check": "svelte-check --tsconfig ./tsconfig.json"
|
"check": "svelte-check --tsconfig ./tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"epubjs": "^0.3.93"
|
"epubjs": "^0.3.93",
|
||||||
|
"pdfjs-dist": "^6.4.299"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
"@sveltejs/vite-plugin-svelte": "^5.0.3",
|
||||||
|
|
|
||||||
134
web/pnpm-lock.yaml
generated
134
web/pnpm-lock.yaml
generated
|
|
@ -11,6 +11,9 @@ importers:
|
||||||
epubjs:
|
epubjs:
|
||||||
specifier: ^0.3.93
|
specifier: ^0.3.93
|
||||||
version: 0.3.93
|
version: 0.3.93
|
||||||
|
pdfjs-dist:
|
||||||
|
specifier: ^6.4.299
|
||||||
|
version: 6.4.299
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@sveltejs/vite-plugin-svelte':
|
'@sveltejs/vite-plugin-svelte':
|
||||||
specifier: ^5.0.3
|
specifier: ^5.0.3
|
||||||
|
|
@ -202,6 +205,81 @@ packages:
|
||||||
'@jridgewell/trace-mapping@0.3.31':
|
'@jridgewell/trace-mapping@0.3.31':
|
||||||
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
|
||||||
|
|
||||||
|
'@napi-rs/canvas-android-arm64@1.0.10':
|
||||||
|
resolution: {integrity: sha512-0sDMvme+7fj6bHERUaBs5SvEyWaORc91VUkaB4RrtG7p6dBrBNGy1of8cyEKDxygd1FlHtW+Thunj8lSixzY7A==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-darwin-arm64@1.0.10':
|
||||||
|
resolution: {integrity: sha512-pdHL1TEfFMrnchRYqmPWXKN7hR4XtOYmzJqnh4XhpfWD4/eeSokOGVzrdFP/iQ3sfr2rf0939gOGq9uCp8BGmg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-darwin-x64@1.0.10':
|
||||||
|
resolution: {integrity: sha512-iuuk5plGxAcxurxv2ycF2yR20y5zvaBZ4heQdhPpg3EMFD21PhlY1V+YwSRmYRWzbvKL474UYdw3WIg2qfafyw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.10':
|
||||||
|
resolution: {integrity: sha512-kH7GFR5Unpm77pKSEsmkqvzeQCRD7/MQ7upmeLLjg4XpQ62dX/R6THFsp6vkF1Pkco2aKUq9KhoLM4W5twdk7g==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm64-gnu@1.0.10':
|
||||||
|
resolution: {integrity: sha512-0/Hj7IwM9pmzdwxiqkp4YLJ8dKqASBrWQ3Y7dWUSU/4dgvCL9O6YNvX7xVUJjonQvdD3IE9ESS/nXa5srQqoVw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm64-musl@1.0.10':
|
||||||
|
resolution: {integrity: sha512-fmjKM0P2MLF9O6XzCQsRjhB+jlLzaf6tFQU36hMPMsYup+VThYvpjjhqNLOc/Ee3tHUE3r13H2BSJx/yXz4pFA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-riscv64-gnu@1.0.10':
|
||||||
|
resolution: {integrity: sha512-O/BUrGwrs3pVP9PpD/3++ZiOpjHMJlmiB7aBgswP8rbe4egPfiFkFxmcCczoLvZa72TaQX5lJ8Udedb6rc/Hvg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-x64-gnu@1.0.10':
|
||||||
|
resolution: {integrity: sha512-48HkZPQeAN/R+9NPpY64tceoyCUW5xYYtHKZnC+BG11qiihXJCbH+xfbgGU+OdYp1Q4s84HDl9ILU0KBK6SBOQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-x64-musl@1.0.10':
|
||||||
|
resolution: {integrity: sha512-QYHVi7WZ9v+Wm9OQyHLMTnJ8QiBPVX94BgbCHFJC/4bCFMfvX7fRzo4FJPgkRP0ISz3ulYNow/FCbQOPjzhYjg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-win32-arm64-msvc@1.0.10':
|
||||||
|
resolution: {integrity: sha512-bWK+YajM/8sL0mMgLtug2s063FVBPzFV4QAn987CQomMKlmzrBY6Trz43KY+24hq7IOa3qNhUAgP/XMEF50ZEQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-win32-x64-msvc@1.0.10':
|
||||||
|
resolution: {integrity: sha512-9WtzW85PaIhtB27iXaR/9RrLCqHLbqD3Z8Q+kh17NjFxwQXFXU5vWbYH4vEH+drM307TTyK3hidZgpt09G5T3g==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@napi-rs/canvas@1.0.10':
|
||||||
|
resolution: {integrity: sha512-V+qptzsGnPMFxEbhwc6eDNMe5eDrfiBIp8qGpuRjAVIX4Kn2zxvwQ1Xco6Fwe+3tYIe3iLW8LM9D+SvjyJhqbQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||||
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
|
resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==}
|
||||||
engines: {node: ^22.20 || ^24.12 || >=25}
|
engines: {node: ^22.20 || ^24.12 || >=25}
|
||||||
|
|
@ -545,6 +623,10 @@ packages:
|
||||||
path-webpack@0.0.3:
|
path-webpack@0.0.3:
|
||||||
resolution: {integrity: sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==}
|
resolution: {integrity: sha512-AmeDxedoo5svf7aB3FYqSAKqMxys014lVKBzy1o/5vv9CtU7U4wgGWL1dA2o6MOzcD53ScN4Jmiq6VbtLz1vIQ==}
|
||||||
|
|
||||||
|
pdfjs-dist@6.4.299:
|
||||||
|
resolution: {integrity: sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==}
|
||||||
|
engines: {node: '>=22.13.0 || >=24'}
|
||||||
|
|
||||||
picocolors@1.1.1:
|
picocolors@1.1.1:
|
||||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||||
|
|
||||||
|
|
@ -765,6 +847,54 @@ snapshots:
|
||||||
'@jridgewell/resolve-uri': 3.1.2
|
'@jridgewell/resolve-uri': 3.1.2
|
||||||
'@jridgewell/sourcemap-codec': 1.6.0
|
'@jridgewell/sourcemap-codec': 1.6.0
|
||||||
|
|
||||||
|
'@napi-rs/canvas-android-arm64@1.0.10':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-darwin-arm64@1.0.10':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-darwin-x64@1.0.10':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm-gnueabihf@1.0.10':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm64-gnu@1.0.10':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm64-musl@1.0.10':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-riscv64-gnu@1.0.10':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-x64-gnu@1.0.10':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-x64-musl@1.0.10':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-win32-arm64-msvc@1.0.10':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-win32-x64-msvc@1.0.10':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas@1.0.10':
|
||||||
|
optionalDependencies:
|
||||||
|
'@napi-rs/canvas-android-arm64': 1.0.10
|
||||||
|
'@napi-rs/canvas-darwin-arm64': 1.0.10
|
||||||
|
'@napi-rs/canvas-darwin-x64': 1.0.10
|
||||||
|
'@napi-rs/canvas-linux-arm-gnueabihf': 1.0.10
|
||||||
|
'@napi-rs/canvas-linux-arm64-gnu': 1.0.10
|
||||||
|
'@napi-rs/canvas-linux-arm64-musl': 1.0.10
|
||||||
|
'@napi-rs/canvas-linux-riscv64-gnu': 1.0.10
|
||||||
|
'@napi-rs/canvas-linux-x64-gnu': 1.0.10
|
||||||
|
'@napi-rs/canvas-linux-x64-musl': 1.0.10
|
||||||
|
'@napi-rs/canvas-win32-arm64-msvc': 1.0.10
|
||||||
|
'@napi-rs/canvas-win32-x64-msvc': 1.0.10
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
'@napi-rs/lzma-linux-x64-gnu@1.5.1':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
|
@ -1049,6 +1179,10 @@ snapshots:
|
||||||
|
|
||||||
path-webpack@0.0.3: {}
|
path-webpack@0.0.3: {}
|
||||||
|
|
||||||
|
pdfjs-dist@6.4.299:
|
||||||
|
optionalDependencies:
|
||||||
|
'@napi-rs/canvas': 1.0.10
|
||||||
|
|
||||||
picocolors@1.1.1: {}
|
picocolors@1.1.1: {}
|
||||||
|
|
||||||
picomatch@4.0.7: {}
|
picomatch@4.0.7: {}
|
||||||
|
|
|
||||||
194
web/src/lib/components/PdfReader.svelte
Normal file
194
web/src/lib/components/PdfReader.svelte
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import workerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
|
||||||
|
import { onMount, onDestroy } from 'svelte'
|
||||||
|
import { api, ApiError } from '../api'
|
||||||
|
|
||||||
|
let { itemId }: { itemId: number } = $props()
|
||||||
|
|
||||||
|
let canvasEl = $state<HTMLCanvasElement>()
|
||||||
|
let doc: any = null
|
||||||
|
let numPages = $state(0)
|
||||||
|
let current = $state(1)
|
||||||
|
let loading = $state(true)
|
||||||
|
let rendering = $state(false)
|
||||||
|
let error = $state<string | null>(null)
|
||||||
|
let pageInput = $state('1')
|
||||||
|
let saveTimer: number | undefined
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const prog = await api.progress(itemId)
|
||||||
|
|
||||||
|
// pdf.js is ~1 MB and most people never open a PDF. Load it on demand so
|
||||||
|
// it lands in its own chunk instead of the main bundle.
|
||||||
|
const pdfjs: any = await import('pdfjs-dist')
|
||||||
|
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl
|
||||||
|
|
||||||
|
doc = await pdfjs.getDocument({
|
||||||
|
url: api.fileUrl(itemId),
|
||||||
|
withCredentials: true,
|
||||||
|
}).promise
|
||||||
|
numPages = doc.numPages
|
||||||
|
|
||||||
|
// Progress is stored as a page number, same as comics.
|
||||||
|
if (prog.progress && prog.progress.position > 0 && prog.progress.position <= numPages) {
|
||||||
|
current = prog.progress.position
|
||||||
|
}
|
||||||
|
pageInput = String(current)
|
||||||
|
await render()
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
window.addEventListener('resize', onResize)
|
||||||
|
} catch (err) {
|
||||||
|
error = err instanceof ApiError ? err.message : 'could not open this document'
|
||||||
|
} finally {
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
window.removeEventListener('keydown', onKey)
|
||||||
|
window.removeEventListener('resize', onResize)
|
||||||
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
flush()
|
||||||
|
void doc?.destroy?.()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
if (!doc || !canvasEl) return
|
||||||
|
rendering = true
|
||||||
|
try {
|
||||||
|
const page = await doc.getPage(current)
|
||||||
|
const container = canvasEl.parentElement
|
||||||
|
const base = page.getViewport({ scale: 1 })
|
||||||
|
// Leave slack for the vertical scrollbar that appears on tall pages;
|
||||||
|
// measuring clientWidth before it exists makes the page too wide and
|
||||||
|
// triggers a horizontal scrollbar in turn.
|
||||||
|
const available = container ? container.clientWidth - 28 : base.width
|
||||||
|
const scale = Math.max(available / base.width, 0.1)
|
||||||
|
const viewport = page.getViewport({ scale })
|
||||||
|
|
||||||
|
const dpr = window.devicePixelRatio || 1
|
||||||
|
canvasEl.width = Math.floor(viewport.width * dpr)
|
||||||
|
canvasEl.height = Math.floor(viewport.height * dpr)
|
||||||
|
canvasEl.style.width = `${Math.floor(viewport.width)}px`
|
||||||
|
canvasEl.style.height = `${Math.floor(viewport.height)}px`
|
||||||
|
|
||||||
|
const ctx = canvasEl.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
await page.render({
|
||||||
|
canvasContext: ctx,
|
||||||
|
viewport,
|
||||||
|
transform: dpr !== 1 ? [dpr, 0, 0, dpr, 0, 0] : undefined,
|
||||||
|
}).promise
|
||||||
|
} finally {
|
||||||
|
rendering = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let resizeTimer: number | undefined
|
||||||
|
function onResize() {
|
||||||
|
if (resizeTimer) clearTimeout(resizeTimer)
|
||||||
|
resizeTimer = window.setTimeout(() => void render(), 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function goTo(n: number) {
|
||||||
|
const next = Math.min(Math.max(n, 1), numPages)
|
||||||
|
if (next === current) return
|
||||||
|
current = next
|
||||||
|
pageInput = String(next)
|
||||||
|
void render()
|
||||||
|
scheduleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'ArrowRight') goTo(current + 1)
|
||||||
|
if (event.key === 'ArrowLeft') goTo(current - 1)
|
||||||
|
if (event.key === 'Home') goTo(1)
|
||||||
|
if (event.key === 'End') goTo(numPages)
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSave() {
|
||||||
|
if (saveTimer) clearTimeout(saveTimer)
|
||||||
|
saveTimer = window.setTimeout(flush, 900)
|
||||||
|
}
|
||||||
|
|
||||||
|
function flush() {
|
||||||
|
if (numPages === 0) return
|
||||||
|
void api.putProgress(itemId, { position: current, percent: current / numPages }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitJump(event: SubmitEvent) {
|
||||||
|
event.preventDefault()
|
||||||
|
const n = parseInt(pageInput, 10)
|
||||||
|
if (!Number.isNaN(n)) goTo(n)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<div class="alert">{error}</div>
|
||||||
|
{:else}
|
||||||
|
<div class="stage">
|
||||||
|
<canvas bind:this={canvasEl}></canvas>
|
||||||
|
{#if loading || rendering}
|
||||||
|
<div class="overlay"><div class="spinner"></div></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="bar">
|
||||||
|
<button class="btn btn-ghost" onclick={() => goTo(current - 1)} disabled={current <= 1}>←</button>
|
||||||
|
<form class="jump" onsubmit={submitJump}>
|
||||||
|
<input class="input page-input" bind:value={pageInput} aria-label="Page number" disabled={loading} />
|
||||||
|
<span class="muted mono">/ {numPages || '–'}</span>
|
||||||
|
</form>
|
||||||
|
<span class="pct mono">{numPages ? Math.round((current / numPages) * 100) : 0}%</span>
|
||||||
|
<button class="btn btn-ghost" onclick={() => goTo(current + 1)} disabled={current >= numPages}>→</button>
|
||||||
|
</footer>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.stage {
|
||||||
|
position: relative;
|
||||||
|
height: calc(100vh - 60px - 96px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: #17171b;
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
place-items: start center;
|
||||||
|
padding: var(--space-4) 0;
|
||||||
|
}
|
||||||
|
canvas {
|
||||||
|
display: block;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: var(--shadow-cover);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background: color-mix(in srgb, var(--bg) 60%, transparent);
|
||||||
|
}
|
||||||
|
.bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
height: 56px;
|
||||||
|
}
|
||||||
|
.jump {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
.page-input {
|
||||||
|
width: 64px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.pct {
|
||||||
|
color: var(--faint);
|
||||||
|
min-width: 40px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import ComicReader from './ComicReader.svelte'
|
import ComicReader from './ComicReader.svelte'
|
||||||
import AudiobookPlayer from './AudiobookPlayer.svelte'
|
import AudiobookPlayer from './AudiobookPlayer.svelte'
|
||||||
import PhotoViewer from './PhotoViewer.svelte'
|
import PhotoViewer from './PhotoViewer.svelte'
|
||||||
|
import PdfReader from './PdfReader.svelte'
|
||||||
|
|
||||||
let { itemId }: { itemId: number } = $props()
|
let { itemId }: { itemId: number } = $props()
|
||||||
|
|
||||||
|
|
@ -14,6 +15,7 @@
|
||||||
let error = $state<string | null>(null)
|
let error = $state<string | null>(null)
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
let percent = $state(0)
|
let percent = $state(0)
|
||||||
|
let fileContainer = $state('')
|
||||||
|
|
||||||
let book: any = null
|
let book: any = null
|
||||||
let rendition: any = null
|
let rendition: any = null
|
||||||
|
|
@ -23,6 +25,7 @@
|
||||||
try {
|
try {
|
||||||
const detail = await api.item(itemId)
|
const detail = await api.item(itemId)
|
||||||
item = detail.item
|
item = detail.item
|
||||||
|
fileContainer = detail.files?.[0]?.container ?? ''
|
||||||
if (item.kind !== 'ebooks') return
|
if (item.kind !== 'ebooks') return
|
||||||
|
|
||||||
meta = (await api.ebook(itemId)).metadata
|
meta = (await api.ebook(itemId)).metadata
|
||||||
|
|
@ -92,6 +95,8 @@
|
||||||
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<div class="alert">{error}</div>
|
<div class="alert">{error}</div>
|
||||||
|
{:else if item && fileContainer === 'pdf'}
|
||||||
|
<PdfReader {itemId} />
|
||||||
{:else if item && item.kind === 'comics'}
|
{:else if item && item.kind === 'comics'}
|
||||||
<ComicReader {itemId} />
|
<ComicReader {itemId} />
|
||||||
{:else if item && (item.kind === 'audiobooks' || item.kind === 'music')}
|
{:else if item && (item.kind === 'audiobooks' || item.kind === 'music')}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue