Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,29 @@ JWT_REFRESH_EXPIRY=30d
# -----------------------------------------------------------------------------
# Host paths for e-books, audio output, and local sources.
# On NAS devices, set these to your shared folders.
#
# NAS_EBOOK_PATH accepts either a single path or a colon/comma-
# separated list of *container* paths the server scans. The
# matching *host* paths must be mounted into each container path
# separately — see NAS_EBOOK_PATHS_0/1 below.
# NAS_EBOOK_PATH=/mnt/nas1/books
# NAS_EBOOK_PATH=/data/ebooks,/data/ebooks2
# NAS_EBOOK_PATH=/data/ebooks:/data/ebooks2
NAS_EBOOK_PATH=./data/ebooks

# When NAS_EBOOK_PATH contains multiple container paths, set one
# host path per root. Order matters: index 0 maps to the first
# path in NAS_EBOOK_PATH, etc. Unset indices fall back to a
# `./data/ebooks<N>` directory so a stock checkout still works.
# NAS_EBOOK_PATHS_0=/mnt/nas1/books
# NAS_EBOOK_PATHS_1=/mnt/nas2/more-books
# NAS_EBOOK_PATHS_0=./data/ebooks
# NAS_EBOOK_PATHS_1=./data/ebooks2
NAS_AUDIO_PATH=./data/audio
NAS_SOURCE_PATH=./data/sources
NAS_DB_PATH=./data/db

# Optional: 封面图片缓存独立存储路径(未设置时回退到 NAS_EBOOK_PATH/covers)
# Optional: 封面图片缓存独立存储路径(未设置时回退到 NAS_EBOOK_PATH[0]/covers)
# CACHE_PATH=./data/cache

# -----------------------------------------------------------------------------
Expand Down
11 changes: 11 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,17 @@ COPY apps/desktop ./apps/desktop
COPY packages ./packages
COPY tsconfig.json ./

# Re-run pnpm install offline to fix any dangling symlinks left over
# from the deps stage. With `shamefully-hoist=true` in .npmrc, pnpm
# can leave symlinks under `apps/desktop/node_modules/<pkg>` pointing
# at a `.pnpm/<pkg>@<ver>/` directory that the lockfile never
# actually populated (the real package lives at
# `.pnpm/<pkg>@<ver>_<peer>/node_modules/<pkg>/`). Those dangling
# symlinks break `require('tailwindcss')` and friends from the
# postcss / vite plugins. Re-installing against the same lockfile
# (offline, frozen) reconciles the symlinks without re-downloading.
RUN pnpm install --offline --frozen-lockfile

# Build the web app (browser mode)
RUN pnpm --filter @bookdock/desktop exec vite build

Expand Down
116 changes: 86 additions & 30 deletions apps/desktop/src/pages/Reader-TTS.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,19 @@ import React, {
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
import { getCoverImageUrl } from "../utils/network";

function BookCover({ book, className = "" }: { book: Book; className?: string }) {
function BookCover({
book,
className = "",
}: {
book: Book;
className?: string;
}) {
const [coverError, setCoverError] = useState(false);
const coverSrc = getCoverImageUrl(book.coverUrl);
return (
<div className={`bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden shadow-lg ${className}`}>
<div
className={`bg-gray-100 dark:bg-gray-700 rounded-lg overflow-hidden shadow-lg ${className}`}
>
{coverSrc && !coverError ? (
<img
src={coverSrc}
Expand Down Expand Up @@ -259,7 +267,7 @@ export default function ReaderTTS() {
return;
}
setChapters(chRes.data);
chaptersRef.current = chRes.data;
chaptersRef.current = chRes.data;
// Chapter index resolution, in priority order:
// 1. ?ci=N in the URL (deep-link / "继续听书" button target)
// 2. GET /books/:id/last-read (global "last listened" pointer)
Expand All @@ -269,7 +277,7 @@ export default function ReaderTTS() {
// 4. 0
let ci = 0;
const ciParam = searchParams.get("ci");
console.log('[Reader-TTS] URL ciParam:', ciParam);
console.log("[Reader-TTS] URL ciParam:", ciParam);
if (ciParam !== null) {
const parsed = parseInt(ciParam, 10);
if (Number.isFinite(parsed) && parsed >= 0) {
Expand All @@ -278,7 +286,7 @@ export default function ReaderTTS() {
} else {
try {
const lastRes = await apiClient.getBookLastRead(id);
console.log('[Reader-TTS] getBookLastRead res:', lastRes);
console.log("[Reader-TTS] getBookLastRead res:", lastRes);
if (lastRes.success && lastRes.data) {
ci = lastRes.data.chapterIndex;
} else {
Expand All @@ -297,13 +305,23 @@ export default function ReaderTTS() {
}
}
} catch (e) {
console.error('[Reader-TTS] getBookLastRead error:', e);
console.error("[Reader-TTS] getBookLastRead error:", e);
}
}
console.log('[Reader-TTS] resolved ci:', ci, 'chapters:', chRes.data.length);
console.log(
"[Reader-TTS] resolved ci:",
ci,
"chapters:",
chRes.data.length,
);
ci = Math.max(0, Math.min(ci, chRes.data.length - 1));
console.log('[Reader-TTS] clamped ci:', ci);
await loadChapter(apiClient, id, ci);
console.log("[Reader-TTS] clamped ci:", ci);
// skipEmpty: true on initial open so EPUBs that start with a
// cover/copyright page silently advance to the first chapter
// with readable text. User-driven navigation later
// (chapter picker, deep-link, queue-end auto-advance) keeps
// its original ci.
await loadChapter(apiClient, id, ci, { skipEmpty: true });
} catch (err) {
setError((err as Error).message);
} finally {
Expand All @@ -318,11 +336,43 @@ export default function ReaderTTS() {
apiClient: ReturnType<typeof getApiClient>,
bookId: string,
ci: number,
options: { skipEmpty?: boolean } = {},
) => {
setChapterIndex(ci);
chapterIndexRef.current = ci;
manager.setConfig({ chapterIndex: ci });
const r = await apiClient.getChapterParagraphs(bookId, ci);
let r = await apiClient.getChapterParagraphs(bookId, ci);
let effectiveCi = ci;
// Some EPUBs open with cover/copyright/TOC pages that contain no
// readable text. When the user hasn't explicitly asked for that
// chapter (deep-link or manual selection), silently advance to the
// next chapter that actually has paragraphs so the TTS screen
// doesn't fall into its empty-state UI. User-initiated navigation
// (URL ?ci=N, chapter picker, queue end) is still honoured verbatim.
if (
options.skipEmpty &&
(!r.success || !r.data || r.data.paragraphs.length === 0) &&
chaptersRef.current.length > 1
) {
for (let i = ci + 1; i < chaptersRef.current.length; i++) {
const tryRes = await apiClient.getChapterParagraphs(bookId, i);
if (
tryRes.success &&
tryRes.data &&
tryRes.data.paragraphs.length > 0
) {
console.log(
`[Reader-TTS] Chapter ${ci} has no readable text; auto-advancing to ${i}`,
);
effectiveCi = i;
setChapterIndex(i);
chapterIndexRef.current = i;
manager.setConfig({ chapterIndex: i });
r = tryRes;
break;
}
}
}
if (!r.success || !r.data) {
showError(r.error || "加载章节失败");
return;
Expand All @@ -341,7 +391,7 @@ export default function ReaderTTS() {

// Resume from saved cloud progress (cross-device sync)
try {
const p = await apiClient.getTtsProgress(bookId, ci);
const p = await apiClient.getTtsProgress(bookId, effectiveCi);
if (p.success && p.data && !Array.isArray(p.data)) {
const rec = p.data as TtsProgressRecord;
// Apply the saved voice/provider into the manager IMMEDIATELY
Expand Down Expand Up @@ -427,7 +477,9 @@ export default function ReaderTTS() {
const apiClient = getApiClient();
await loadChapter(apiClient, bookIdRef.current!, nextChapterIndex);
// Update URL to reflect new chapter
navigate(`/book/${bookIdRef.current}/tts?ci=${nextChapterIndex}`, { replace: true });
navigate(`/book/${bookIdRef.current}/tts?ci=${nextChapterIndex}`, {
replace: true,
});
// Wait for state to settle then start playing with fresh paragraphs
setTimeout(() => {
const cfg = manager.getConfig();
Expand All @@ -437,25 +489,29 @@ export default function ReaderTTS() {
rate: cfg.rate,
volume: cfg.volume,
};
manager.play(
paragraphsRef.current,
0,
{
onStart: () => setState("playing"),
onPause: () => setState("paused"),
onResume: () => setState("playing"),
onEnd: () => setState("idle"),
onError: (e) => {
showError(e.message || "朗读失败");
setState("error");
manager
.play(
paragraphsRef.current,
0,
{
onStart: () => setState("playing"),
onPause: () => setState("paused"),
onResume: () => setState("playing"),
onEnd: () => setState("idle"),
onError: (e) => {
showError(e.message || "朗读失败");
setState("error");
},
onProgress: (p) => setProgress(p),
onParagraphChange: (idx) =>
setProgress((prev) => ({ ...prev, paragraphIndex: idx })),
},
onProgress: (p) => setProgress(p),
onParagraphChange: (idx) =>
setProgress((prev) => ({ ...prev, paragraphIndex: idx })),
},
freshOverrides,
0,
).catch((e) => console.error("Auto-play next chapter failed", e));
freshOverrides,
0,
)
.catch((e) =>
console.error("Auto-play next chapter failed", e),
);
}, 800);
}
},
Expand Down
Loading
Loading