Phase 4: CBZ comics module and reader
- comics: reads pages straight out of the archive on demand. No extraction pass, so a 200-page CBZ is not materialised for a reader who views three pages. Pages are read into memory and served via http.ServeContent, which gives Range for free. - natural page ordering: page2 sorts before page10. Lexical ordering shuffles a numbered archive, which is the difference between a readable book and a scrambled one. - skips __MACOSX and dotfiles; rejects archives with no images. - cover is the first page. - media.Item gained ModTime (from the backing file) so page responses carry a Last-Modified and support conditional GETs. - web: ComicReader with a bounded prefetch window (two pages each way), page jump, keyboard nav, and progress stored as a page number. Verified: 3-page CBZ served in natural order, out-of-range 404, cover from page 1, progress round-trip. svelte-check 0/0.
This commit is contained in:
parent
7a5584a83f
commit
c3b7db7114
10 changed files with 594 additions and 5 deletions
|
|
@ -17,6 +17,7 @@ import (
|
|||
"github.com/mithral/vellum/internal/httpapi"
|
||||
"github.com/mithral/vellum/internal/library"
|
||||
"github.com/mithral/vellum/internal/media"
|
||||
"github.com/mithral/vellum/internal/modules/comics"
|
||||
"github.com/mithral/vellum/internal/modules/ebooks"
|
||||
"github.com/mithral/vellum/migrations"
|
||||
)
|
||||
|
|
@ -75,6 +76,7 @@ func run() error {
|
|||
library.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||
media.NewHandlers(db, log, authenticator, cfg.ConfigDir).Register(api.Mux())
|
||||
ebooks.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||
comics.NewHandlers(db, log, authenticator).Register(api.Mux())
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Listen,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrItemNotFound means no item matched.
|
||||
|
|
@ -26,6 +27,10 @@ type Item struct {
|
|||
|
||||
// RootPath is the absolute library root, so AbsPath can be computed.
|
||||
RootPath string
|
||||
|
||||
// ModTime is the backing file's modification time, used for conditional
|
||||
// GETs. Zero when unknown.
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
// AbsPath returns the absolute filesystem path of the item's file.
|
||||
|
|
@ -39,21 +44,42 @@ func (i Item) AbsPath() string {
|
|||
// LoadItem loads an item and its library root.
|
||||
func LoadItem(ctx context.Context, db *sql.DB, id int64) (Item, error) {
|
||||
var it Item
|
||||
var mtime string
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year, 0), i.missing, l.root_path
|
||||
SELECT i.id, i.library_id, i.kind, i.path, i.title, COALESCE(i.year, 0), i.missing, l.root_path,
|
||||
COALESCE((SELECT f.mtime FROM media_files f WHERE f.item_id = i.id ORDER BY f.id LIMIT 1), '')
|
||||
FROM media_items i
|
||||
JOIN libraries l ON l.id = i.library_id
|
||||
WHERE i.id = ?`, id,
|
||||
).Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, &it.Year, &it.Missing, &it.RootPath)
|
||||
).Scan(&it.ID, &it.LibraryID, &it.Kind, &it.Path, &it.Title, &it.Year, &it.Missing, &it.RootPath, &mtime)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Item{}, ErrItemNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Item{}, fmt.Errorf("media: load item: %w", err)
|
||||
}
|
||||
if nanos := parseUnixNano(mtime); nanos > 0 {
|
||||
it.ModTime = time.Unix(0, nanos).UTC()
|
||||
}
|
||||
return it, nil
|
||||
}
|
||||
|
||||
// parseUnixNano parses a base-10 nanosecond timestamp, returning 0 on anything
|
||||
// that is not all digits.
|
||||
func parseUnixNano(s string) int64 {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
var n int64
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] < '0' || s[i] > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int64(s[i]-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// FileEntry is one physical file backing an item.
|
||||
type FileEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
|
|
|
|||
139
internal/modules/comics/comic.go
Normal file
139
internal/modules/comics/comic.go
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// Package comics reads CBZ archives (a zip of page images) and serves pages.
|
||||
//
|
||||
// Pages are read straight out of the archive on demand rather than extracted
|
||||
// to disk. A page is a single image, so it is read into memory and served
|
||||
// through http.ServeContent, which gives Range support for free and avoids
|
||||
// materialising a 200-page archive for a reader who views three pages.
|
||||
package comics
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"fmt"
|
||||
"io"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Kind is the media kind this module owns.
|
||||
const Kind = "comics"
|
||||
|
||||
// maxPageBytes caps a single page so a malformed archive cannot exhaust memory.
|
||||
const maxPageBytes = 64 << 20 // 64 MiB
|
||||
|
||||
// imageExts are the page formats a CBZ may contain.
|
||||
var imageExts = map[string]bool{
|
||||
".jpg": true,
|
||||
".jpeg": true,
|
||||
".png": true,
|
||||
".webp": true,
|
||||
".gif": true,
|
||||
".avif": true,
|
||||
".bmp": true,
|
||||
}
|
||||
|
||||
// Page is one page in reading order.
|
||||
type Page struct {
|
||||
Index int `json:"index"` // 1-based
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// ListPages returns the archive's image entries in reading order.
|
||||
//
|
||||
// Ordering is natural, not lexical: "page2.png" sorts before "page10.png",
|
||||
// which lexical ordering gets wrong. Comic archives are usually already
|
||||
// numbered, so this is the difference between a readable book and a shuffled
|
||||
// one.
|
||||
func ListPages(cbzPath string) ([]Page, error) {
|
||||
zr, err := zip.OpenReader(cbzPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("comics: open archive: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
var names []string
|
||||
for _, f := range zr.File {
|
||||
if f.FileInfo().IsDir() {
|
||||
continue
|
||||
}
|
||||
name := f.Name
|
||||
// Skip macOS resource forks and other hidden metadata.
|
||||
if strings.HasPrefix(path.Base(name), ".") || strings.Contains(name, "__MACOSX/") {
|
||||
continue
|
||||
}
|
||||
if imageExts[strings.ToLower(path.Ext(name))] {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return nil, fmt.Errorf("comics: archive contains no images")
|
||||
}
|
||||
|
||||
sort.Slice(names, func(i, j int) bool { return naturalLess(names[i], names[j]) })
|
||||
|
||||
pages := make([]Page, len(names))
|
||||
for i, n := range names {
|
||||
pages[i] = Page{Index: i + 1, Name: n}
|
||||
}
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
// ReadPage returns the bytes of one page by its archive name.
|
||||
func ReadPage(cbzPath, name string) ([]byte, error) {
|
||||
zr, err := zip.OpenReader(cbzPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("comics: open archive: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
|
||||
for _, f := range zr.File {
|
||||
if f.Name != name {
|
||||
continue
|
||||
}
|
||||
if f.UncompressedSize64 > maxPageBytes {
|
||||
return nil, fmt.Errorf("comics: page %q is too large", name)
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(io.LimitReader(rc, maxPageBytes))
|
||||
}
|
||||
return nil, fmt.Errorf("comics: page %q not found", name)
|
||||
}
|
||||
|
||||
// naturalLess compares two strings treating digit runs as numbers.
|
||||
func naturalLess(a, b string) bool {
|
||||
ai, bi := 0, 0
|
||||
for ai < len(a) && bi < len(b) {
|
||||
ad, bd := isDigit(a[ai]), isDigit(b[bi])
|
||||
if ad && bd {
|
||||
// Compare the full digit runs numerically.
|
||||
as, bs := ai, bi
|
||||
for ai < len(a) && isDigit(a[ai]) {
|
||||
ai++
|
||||
}
|
||||
for bi < len(b) && isDigit(b[bi]) {
|
||||
bi++
|
||||
}
|
||||
an := strings.TrimLeft(a[as:ai], "0")
|
||||
bn := strings.TrimLeft(b[bs:bi], "0")
|
||||
if len(an) != len(bn) {
|
||||
return len(an) < len(bn)
|
||||
}
|
||||
if an != bn {
|
||||
return an < bn
|
||||
}
|
||||
continue
|
||||
}
|
||||
if a[ai] != b[bi] {
|
||||
return a[ai] < b[bi]
|
||||
}
|
||||
ai++
|
||||
bi++
|
||||
}
|
||||
return len(a)-ai < len(b)-bi
|
||||
}
|
||||
|
||||
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
|
||||
102
internal/modules/comics/comic_test.go
Normal file
102
internal/modules/comics/comic_test.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package comics
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeCBZ(t *testing.T, entries map[string]string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "test.cbz")
|
||||
f, err := os.Create(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
zw := zip.NewWriter(f)
|
||||
for name, body := range entries {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestListPagesNaturalOrder(t *testing.T) {
|
||||
p := writeCBZ(t, map[string]string{
|
||||
"page10.jpg": "ten",
|
||||
"page2.jpg": "two",
|
||||
"page1.jpg": "one",
|
||||
"notes.txt": "not an image",
|
||||
"__MACOSX/._page1.jpg": "junk",
|
||||
})
|
||||
|
||||
pages, err := ListPages(p)
|
||||
if err != nil {
|
||||
t.Fatalf("ListPages: %v", err)
|
||||
}
|
||||
want := []string{"page1.jpg", "page2.jpg", "page10.jpg"}
|
||||
if len(pages) != len(want) {
|
||||
t.Fatalf("got %d pages, want %d: %+v", len(pages), len(want), pages)
|
||||
}
|
||||
for i, name := range want {
|
||||
if pages[i].Name != name {
|
||||
t.Errorf("page %d = %q, want %q", i+1, pages[i].Name, name)
|
||||
}
|
||||
if pages[i].Index != i+1 {
|
||||
t.Errorf("page %d index = %d, want %d", i, pages[i].Index, i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadPage(t *testing.T) {
|
||||
p := writeCBZ(t, map[string]string{"page1.jpg": "hello-page"})
|
||||
|
||||
data, err := ReadPage(p, "page1.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadPage: %v", err)
|
||||
}
|
||||
if string(data) != "hello-page" {
|
||||
t.Errorf("page content = %q", data)
|
||||
}
|
||||
|
||||
if _, err := ReadPage(p, "missing.jpg"); err == nil {
|
||||
t.Error("ReadPage succeeded for a missing entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListPagesRejectsArchiveWithoutImages(t *testing.T) {
|
||||
p := writeCBZ(t, map[string]string{"readme.txt": "nothing to see"})
|
||||
if _, err := ListPages(p); err == nil {
|
||||
t.Error("ListPages succeeded on an archive with no images")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNaturalLess(t *testing.T) {
|
||||
cases := []struct {
|
||||
a, b string
|
||||
want bool
|
||||
}{
|
||||
{"page1.jpg", "page2.jpg", true},
|
||||
{"page2.jpg", "page10.jpg", true},
|
||||
{"page10.jpg", "page2.jpg", false},
|
||||
{"page01.jpg", "page1.jpg", false}, // equal numerically
|
||||
{"page1.jpg", "page01.jpg", false},
|
||||
{"a.jpg", "b.jpg", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := naturalLess(c.a, c.b); got != c.want {
|
||||
t.Errorf("naturalLess(%q, %q) = %v, want %v", c.a, c.b, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
52
internal/modules/comics/cover.go
Normal file
52
internal/modules/comics/cover.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package comics
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mithral/vellum/internal/media"
|
||||
)
|
||||
|
||||
func init() {
|
||||
media.RegisterCoverExtractor(Kind, ExtractCover)
|
||||
}
|
||||
|
||||
// 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.
|
||||
func ExtractCover(ctx context.Context, db *sql.DB, item media.Item, absPath, cacheDir string) (string, error) {
|
||||
pages, err := ListPages(absPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
dir, err := media.CoverCacheDir(cacheDir, Kind)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
ext := strings.ToLower(path.Ext(pages[0].Name))
|
||||
if ext == "" {
|
||||
ext = ".jpg"
|
||||
}
|
||||
outPath := filepath.Join(dir, fmt.Sprintf("%d%s", item.ID, ext))
|
||||
|
||||
data, err := ReadPage(absPath, pages[0].Name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tmp := outPath + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o640); err != nil {
|
||||
return "", fmt.Errorf("comics: write cover: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, outPath); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", err
|
||||
}
|
||||
return outPath, nil
|
||||
}
|
||||
115
internal/modules/comics/handlers.go
Normal file
115
internal/modules/comics/handlers.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package comics
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/mithral/vellum/internal/auth"
|
||||
"github.com/mithral/vellum/internal/httpx"
|
||||
"github.com/mithral/vellum/internal/media"
|
||||
"github.com/mithral/vellum/internal/stream"
|
||||
)
|
||||
|
||||
// Handlers serves comic-specific endpoints.
|
||||
type Handlers struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
auth *auth.Authenticator
|
||||
}
|
||||
|
||||
// NewHandlers builds the comic HTTP layer.
|
||||
func NewHandlers(db *sql.DB, log *slog.Logger, a *auth.Authenticator) *Handlers {
|
||||
return &Handlers{db: db, log: log, auth: a}
|
||||
}
|
||||
|
||||
// Register mounts the routes.
|
||||
func (h *Handlers) Register(mux *http.ServeMux) {
|
||||
mux.Handle("GET /api/v1/items/{id}/pages", h.auth.Require(h.handleListPages))
|
||||
mux.Handle("GET /api/v1/items/{id}/pages/{page}", h.auth.Require(h.handlePage))
|
||||
}
|
||||
|
||||
// handleListPages returns the archive's page list in reading order.
|
||||
func (h *Handlers) handleListPages(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||
it, ok := h.authorized(w, r, u)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
pages, err := ListPages(it.AbsPath())
|
||||
if err != nil {
|
||||
h.log.Warn("list pages", "item", it.ID, "err", err)
|
||||
httpx.WriteError(w, http.StatusUnprocessableEntity, "could not read this archive")
|
||||
return
|
||||
}
|
||||
httpx.WriteJSON(w, http.StatusOK, map[string]any{"pages": pages, "count": len(pages)})
|
||||
}
|
||||
|
||||
// handlePage streams one page image.
|
||||
func (h *Handlers) handlePage(w http.ResponseWriter, r *http.Request, u auth.User) {
|
||||
it, ok := h.authorized(w, r, u)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
idx, err := strconv.Atoi(r.PathValue("page"))
|
||||
if err != nil || idx < 1 {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid page number")
|
||||
return
|
||||
}
|
||||
|
||||
pages, err := ListPages(it.AbsPath())
|
||||
if err != nil {
|
||||
httpx.WriteError(w, http.StatusUnprocessableEntity, "could not read this archive")
|
||||
return
|
||||
}
|
||||
if idx > len(pages) {
|
||||
httpx.WriteError(w, http.StatusNotFound, "page out of range")
|
||||
return
|
||||
}
|
||||
|
||||
data, err := ReadPage(it.AbsPath(), pages[idx-1].Name)
|
||||
if err != nil {
|
||||
h.log.Error("read page", "item", it.ID, "page", idx, "err", err)
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "could not read the page")
|
||||
return
|
||||
}
|
||||
|
||||
// Pages are immutable; let the browser keep them.
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
w.Header().Set("Content-Type", stream.ContentType(pages[idx-1].Name))
|
||||
http.ServeContent(w, r, pages[idx-1].Name, it.ModTime, bytes.NewReader(data))
|
||||
}
|
||||
|
||||
// authorized loads an item, confirms access, and checks it is a comic.
|
||||
func (h *Handlers) authorized(w http.ResponseWriter, r *http.Request, u auth.User) (media.Item, bool) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "invalid item id")
|
||||
return media.Item{}, false
|
||||
}
|
||||
|
||||
it, err := media.LoadItem(r.Context(), h.db, id)
|
||||
if errors.Is(err, media.ErrItemNotFound) {
|
||||
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||
return media.Item{}, false
|
||||
}
|
||||
if err != nil {
|
||||
h.log.Error("load item", "err", err)
|
||||
httpx.WriteError(w, http.StatusInternalServerError, "internal server error")
|
||||
return media.Item{}, false
|
||||
}
|
||||
|
||||
allowed, err := auth.CanAccessLibrary(r.Context(), h.db, u, it.LibraryID)
|
||||
if err != nil || !allowed {
|
||||
httpx.WriteError(w, http.StatusNotFound, "item not found")
|
||||
return media.Item{}, false
|
||||
}
|
||||
if it.Kind != Kind {
|
||||
httpx.WriteError(w, http.StatusBadRequest, "item is not a comic")
|
||||
return media.Item{}, false
|
||||
}
|
||||
return it, true
|
||||
}
|
||||
3
web/dist/.gitkeep
vendored
3
web/dist/.gitkeep
vendored
|
|
@ -1,3 +0,0 @@
|
|||
The Vite build output goes here. This file keeps the directory present so the
|
||||
Go embed directive in ../embed.go is always valid, even before the frontend has
|
||||
been built. Run `pnpm build` (or `make build` from the repo root) to populate it.
|
||||
|
|
@ -130,6 +130,11 @@ export const api = {
|
|||
|
||||
ebook: (id: number) => request<{ metadata: EbookMetadata }>('GET', `/api/v1/items/${id}/ebook`),
|
||||
|
||||
pages: (id: number) =>
|
||||
request<{ pages: { index: number; name: string }[]; count: number }>('GET', `/api/v1/items/${id}/pages`),
|
||||
|
||||
pageUrl: (id: number, page: number) => `/api/v1/items/${id}/pages/${page}`,
|
||||
|
||||
coverUrl: (id: number) => `/api/v1/items/${id}/cover`,
|
||||
|
||||
fileUrl: (id: number) => `/api/v1/items/${id}/file`,
|
||||
|
|
|
|||
148
web/src/lib/components/ComicReader.svelte
Normal file
148
web/src/lib/components/ComicReader.svelte
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { api, ApiError } from '../api'
|
||||
|
||||
let { itemId }: { itemId: number } = $props()
|
||||
|
||||
let count = $state(0)
|
||||
let current = $state(1)
|
||||
let loading = $state(true)
|
||||
let error = $state<string | null>(null)
|
||||
let pageInput = $state('1')
|
||||
|
||||
let saveTimer: number | undefined
|
||||
const preloaded = new Set<string>()
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const [list, prog] = await Promise.all([api.pages(itemId), api.progress(itemId)])
|
||||
count = list.count
|
||||
if (prog.progress && prog.progress.position > 0 && prog.progress.position <= count) {
|
||||
current = prog.progress.position
|
||||
}
|
||||
pageInput = String(current)
|
||||
window.addEventListener('keydown', onKey)
|
||||
} catch (err) {
|
||||
error = err instanceof ApiError ? err.message : 'could not open this comic'
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
window.removeEventListener('keydown', onKey)
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
flush()
|
||||
})
|
||||
|
||||
// Prefetch a window ahead and behind so a fast reader never sees a blank
|
||||
// page. Bounded to two in each direction to keep memory flat on weak clients.
|
||||
$effect(() => {
|
||||
if (count === 0) return
|
||||
for (const offset of [1, 2, -1, -2]) {
|
||||
const n = current + offset
|
||||
if (n < 1 || n > count) continue
|
||||
const url = api.pageUrl(itemId, n)
|
||||
if (preloaded.has(url)) continue
|
||||
preloaded.add(url)
|
||||
const img = new Image()
|
||||
img.src = url
|
||||
}
|
||||
})
|
||||
|
||||
function goTo(n: number) {
|
||||
const next = Math.min(Math.max(n, 1), count)
|
||||
if (next === current) return
|
||||
current = next
|
||||
pageInput = String(next)
|
||||
scheduleSave()
|
||||
}
|
||||
|
||||
function onKey(event: KeyboardEvent) {
|
||||
if (event.key === 'ArrowRight' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
goTo(current + 1)
|
||||
}
|
||||
if (event.key === 'ArrowLeft') goTo(current - 1)
|
||||
if (event.key === 'Home') goTo(1)
|
||||
if (event.key === 'End') goTo(count)
|
||||
}
|
||||
|
||||
function scheduleSave() {
|
||||
if (saveTimer) clearTimeout(saveTimer)
|
||||
saveTimer = window.setTimeout(flush, 900)
|
||||
}
|
||||
|
||||
function flush() {
|
||||
if (count === 0) return
|
||||
void api.putProgress(itemId, { position: current, percent: current / count }).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 if loading}
|
||||
<div class="stage center"><div class="spinner"></div></div>
|
||||
{:else}
|
||||
<div class="stage">
|
||||
<img class="page" src={api.pageUrl(itemId, current)} alt={`Page ${current}`} />
|
||||
</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" />
|
||||
<span class="muted mono">/ {count}</span>
|
||||
</form>
|
||||
<span class="pct mono">{Math.round((current / count) * 100)}%</span>
|
||||
<button class="btn btn-ghost" onclick={() => goTo(current + 1)} disabled={current >= count}>→</button>
|
||||
</footer>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.stage {
|
||||
height: calc(100vh - 52px - 96px);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: #000;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
}
|
||||
.center {
|
||||
place-items: center;
|
||||
}
|
||||
.page {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
.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(--text-faint);
|
||||
min-width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
import ePub from 'epubjs'
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import { api, ApiError, type Item, type EbookMetadata } from '../api'
|
||||
import ComicReader from './ComicReader.svelte'
|
||||
|
||||
let { itemId }: { itemId: number } = $props()
|
||||
|
||||
|
|
@ -89,6 +90,8 @@
|
|||
|
||||
{#if error}
|
||||
<div class="alert">{error}</div>
|
||||
{:else if item && item.kind === 'comics'}
|
||||
<ComicReader {itemId} />
|
||||
{:else if item && item.kind !== 'ebooks'}
|
||||
<div class="empty">
|
||||
<h3>{item.title}</h3>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue