diff --git a/.env.example b/.env.example index 1e442e20c..ab501198c 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,9 @@ POSTGRES_DB=bookorbit # App secrets JWT_SECRET=change-this-to-a-long-random-secret +# Fresh installs should generate an independent key with: openssl rand -hex 32 +# Existing installs upgrading from an older release must initially copy their current JWT_SECRET here so existing podcast URLs remain readable. +PODCAST_ENCRYPTION_KEY=change-this-to-a-different-long-random-secret # Required for the initial /auth/setup endpoint (x-setup-token header) SETUP_BOOTSTRAP_TOKEN=change-this-to-a-long-random-secret diff --git a/README.md b/README.md index 4f25f2067..b9b80f35f 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ BOOKS_HOST_PATH=./books # folder on your server where your book fil POSTGRES_PASSWORD= # database password - openssl rand -hex 24 JWT_SECRET= # signs login tokens - openssl rand -hex 32 +PODCAST_ENCRYPTION_KEY= # encrypts podcast URLs - openssl rand -hex 32 SETUP_BOOTSTRAP_TOKEN= # one-time setup wizard token - openssl rand -hex 16 ``` @@ -107,6 +108,8 @@ Set `POSTGRES_PASSWORD=` and `POSTGRES_PASSWORD_FILE=/run/secrets/bookorbit_post On a NAS, or any host where your book folder is owned by a user other than UID 1000, also set `PUID` and `PGID` to match that owner. Run `id -u` and `id -g` as the owning user to find them. Getting these wrong is the most common cause of permission errors on first scan. +When upgrading an installation that previously used podcasts without `PODCAST_ENCRYPTION_KEY`, set it to the current `JWT_SECRET` first. A new key would make existing encrypted podcast URLs unreadable. Fresh installations should use an independent generated key. + Optionally set `LIBRARY_BROWSE_ROOT=/books` to start the library folder picker at `/books` instead of `/`. Then start: diff --git a/client/eslint.config.ts b/client/eslint.config.ts index c1c7f9916..9a11ddffd 100644 --- a/client/eslint.config.ts +++ b/client/eslint.config.ts @@ -23,7 +23,16 @@ export default defineConfigWithVueTs( { ...pluginVitest.configs.recommended, - files: ['src/**/__tests__/*'], + // Specs are colocated as `*.spec.ts`; `__tests__/` remains only in features not yet converted. + files: ['src/**/__tests__/*', 'src/**/*.spec.ts', 'src/**/*.test.ts'], + }, + + // Named handlers only: an inline arrow in a template cannot be named, reused or tested, and the + // podcast feature was swept clean of them. The rest of the app still carries several hundred, so + // widening this is its own task rather than a side effect of one feature's refactor. + { + files: ['src/features/podcast/**/*.vue', 'src/components/FormSheet.vue', 'src/features/collection/components/CollectionMembershipList.vue'], + rules: { 'vue/v-on-handler-style': ['error', ['method', 'inline']] }, }, { diff --git a/client/public/assets/foliate/overlayer.d.ts b/client/public/assets/foliate/overlayer.d.ts new file mode 100644 index 000000000..26e0c2314 --- /dev/null +++ b/client/public/assets/foliate/overlayer.d.ts @@ -0,0 +1,10 @@ +export declare class Overlayer { + add(key: string, range: Range, draw: (rects: DOMRectList, opts: unknown) => Element, options?: unknown): void + remove(key: string): void + redraw(): void + static highlight(rects: DOMRectList, options?: { color?: string }): Element + static underline(rects: DOMRectList, options?: { color?: string; width?: number }): Element + static strikethrough(rects: DOMRectList, options?: { color?: string; width?: number }): Element + static squiggly(rects: DOMRectList, options?: { color?: string; width?: number }): Element + static outline(rects: DOMRectList, options?: { color?: string; width?: number; radius?: number }): Element +} diff --git a/client/public/assets/foliate/text-walker.d.ts b/client/public/assets/foliate/text-walker.d.ts new file mode 100644 index 000000000..7f5a343d0 --- /dev/null +++ b/client/public/assets/foliate/text-walker.d.ts @@ -0,0 +1 @@ +export declare function textWalker(doc: Document): Generator<{ node: Text; start: number; end: number }> diff --git a/client/public/assets/foliate/tts.d.ts b/client/public/assets/foliate/tts.d.ts new file mode 100644 index 000000000..4647d5c2f --- /dev/null +++ b/client/public/assets/foliate/tts.d.ts @@ -0,0 +1,9 @@ +export declare class TTS { + constructor(doc: Document, textWalker: unknown, highlightCallback: (range: Range) => void, granularity: string) + start(): string | null + resume(): string | null + prev(paused?: boolean): string | null + next(paused?: boolean): string | null + from(range: Range): string | null + setMark(mark: string): void +} diff --git a/client/src/App.vue b/client/src/App.vue index 7d3efc791..708dd650f 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -2,6 +2,7 @@ import { computed, provide, watch } from 'vue' import { useRoute } from 'vue-router' import { INIT_OPTIONS_KEY, THEME_KEY } from 'vue-echarts' +import { APP_FEATURES } from '@bookorbit/types' import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog' import { useThemeStore } from '@/stores/theme' import { getBookorbitThemeName, initChartThemes } from '@/lib/echarts' @@ -9,22 +10,64 @@ import ChangePasswordDialog from '@/features/auth/ChangePasswordDialog.vue' import WhatsNewDialog from '@/features/whats-new/WhatsNewDialog.vue' import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew' import { useAuth } from '@/features/auth/composables/useAuth' +import TtsMiniPlayer from '@/features/tts/components/TtsMiniPlayer.vue' +import MediaOverlayMiniPlayer from '@/features/reader/media-overlay/components/MediaOverlayMiniPlayer.vue' +import PodcastMiniPlayer from '@/features/podcast/components/PodcastMiniPlayer.vue' +import PodcastLiveRegion from '@/features/podcast/components/PodcastLiveRegion.vue' +import PodcastDownloadWidget from '@/features/podcast/components/PodcastDownloadWidget.vue' +import PodcastShortcutsDialog from '@/features/podcast/components/PodcastShortcutsDialog.vue' +import { usePodcastPlayer } from '@/features/podcast/composables/usePodcastPlayer' +import { usePodcastKeyboardShortcuts } from '@/features/podcast/composables/usePodcastKeyboardShortcuts' +import { usePodcastDownloadBatches } from '@/features/podcast/composables/usePodcastDownloadBatches' +import { usePodcastEvents } from '@/features/podcast/composables/usePodcastEvents' import { Toaster } from '@/components/ui/sonner' import { TooltipProvider } from '@/components/ui/tooltip' import { resolveRouteViewKey } from '@/router/view-key' import LegalNotices from '@/components/legal/LegalNotices.vue' +// Bottom-edge media and download surfaces publish their measured heights, so the toaster clears +// whichever one currently sits highest. +const TOASTER_OFFSET = { + right: '16px', + bottom: + 'max(16px, var(--podcast-mini-player-clearance, 0px), var(--tts-mini-player-clearance, 0px), var(--podcast-download-widget-clearance, 0px))', +} +const TOASTER_MOBILE_OFFSET = { + left: '16px', + right: '16px', + bottom: + 'max(16px, var(--podcast-mini-player-clearance, 0px), var(--tts-mini-player-clearance, 0px), var(--podcast-download-widget-clearance, 0px))', +} + const { isOpen } = useChangePasswordDialog() const themeStore = useThemeStore() const route = useRoute() const { user } = useAuth() const { popupOpen, evaluate, syncPopup } = useWhatsNew() +const podcastPlayer = APP_FEATURES.podcasts ? usePodcastPlayer() : null +const podcastDownloads = APP_FEATURES.podcasts ? usePodcastDownloadBatches() : null +const podcastEvents = APP_FEATURES.podcasts ? usePodcastEvents() : null + +if (APP_FEATURES.podcasts) usePodcastKeyboardShortcuts() watch( () => user.value, - async (current) => { - if (!current) return + async (current, previous) => { + if (previous && current?.id !== previous.id) { + await podcastPlayer?.resetForUserChange() + podcastDownloads?.resetForUserChange() + podcastEvents?.resetForUserChange() + } + if (!current) { + if (!previous) { + await podcastPlayer?.resetForUserChange() + podcastDownloads?.resetForUserChange() + podcastEvents?.resetForUserChange() + } + return + } + await podcastPlayer?.loadPreferences() await evaluate() syncPopup(route.name as string | undefined) }, @@ -47,14 +90,20 @@ provide( diff --git a/client/src/assets/theme/bridge.css b/client/src/assets/theme/bridge.css index be1aa60b9..6afbcceea 100644 --- a/client/src/assets/theme/bridge.css +++ b/client/src/assets/theme/bridge.css @@ -35,6 +35,7 @@ --color-warning-foreground: var(--warning-foreground); --color-info: var(--info); --color-star-highlight: var(--star-highlight); + --color-info-foreground: var(--info-foreground); --color-volume-read: var(--volume-read); --color-volume-reading: var(--volume-reading); --color-volume-unread: var(--volume-unread); diff --git a/client/src/assets/theme/tokens.css b/client/src/assets/theme/tokens.css index 8c29e0d6f..f87fcc7fb 100644 --- a/client/src/assets/theme/tokens.css +++ b/client/src/assets/theme/tokens.css @@ -39,6 +39,12 @@ --destructive: oklch(0.577 0.245 27.325); --destructive-foreground: oklch(0.985 0 0); + --success: oklch(0.52 0.16 150); + --success-foreground: oklch(0.985 0 0); + --warning: oklch(0.57 0.15 75); + --warning-foreground: oklch(0.145 0 0); + --info: oklch(0.52 0.17 245); + --info-foreground: oklch(0.985 0 0); --success: oklch(0.52 0.14 148); --success-foreground: oklch(0.985 0 0); @@ -133,6 +139,8 @@ /* Annotation pill hues — fixed, accent-independent. Sources get distinct hues; status uses semantic tiers (Exact stays neutral via --muted). */ --pill-web: oklch(0.55 0.16 245); + --pill-ios: oklch(0.58 0.16 150); + --pill-watchos: oklch(0.58 0.18 35); --pill-koreader: oklch(0.52 0.2 295); --pill-kobo: oklch(0.55 0.1 195); --pill-repaired: oklch(0.62 0.14 70); @@ -297,6 +305,12 @@ --destructive: oklch(0.704 0.191 22.216); --destructive-foreground: oklch(0.985 0 0); + --success: oklch(0.74 0.15 150); + --success-foreground: oklch(0.145 0 0); + --warning: oklch(0.78 0.14 75); + --warning-foreground: oklch(0.145 0 0); + --info: oklch(0.75 0.14 245); + --info-foreground: oklch(0.145 0 0); --success: oklch(0.72 0.15 148); --success-foreground: oklch(0.145 0 0); @@ -360,6 +374,8 @@ /* Lifted lightness so pills read on dark surfaces */ --pill-web: oklch(0.72 0.14 245); + --pill-ios: oklch(0.75 0.14 150); + --pill-watchos: oklch(0.76 0.15 35); --pill-koreader: oklch(0.72 0.17 295); --pill-kobo: oklch(0.74 0.1 195); --pill-repaired: oklch(0.78 0.13 70); diff --git a/client/src/components/AppHeader.vue b/client/src/components/AppHeader.vue index bf12d7fbe..de1b98e4a 100644 --- a/client/src/components/AppHeader.vue +++ b/client/src/components/AppHeader.vue @@ -14,6 +14,7 @@ import { Trophy, MoreVertical, BadgeQuestionMark, + Headphones, ExternalLink, Sparkles, Languages, @@ -59,6 +60,7 @@ import { useThemeStore } from '@/stores/theme' import { useLocaleStore } from '@/stores/locale' import { getFormatColor } from '@/features/book/lib/format-colors' import { useLegalNotices } from '@/components/legal/useLegalNotices' +import { hasReadAlong, isReadAlongFormat, READ_ALONG_FORMAT_COLOR, READ_ALONG_FORMAT_TITLE } from '@/features/book/lib/file-capabilities' const { t } = useI18n() const router = useRouter() @@ -356,8 +358,15 @@ function resultFormats(result: GlobalSearchResult): string[] { return sortFormats([...formats]) } -function formatBadgeStyle(fmt: string) { - const color = getFormatColor(fmt) +function formatHasReadAlong(fmt: string, result: GlobalSearchResult): boolean { + return isReadAlongFormat( + fmt, + result.files.some((file) => hasReadAlong(file)), + ) +} + +function formatBadgeStyle(fmt: string, result?: GlobalSearchResult) { + const color = result && formatHasReadAlong(fmt, result) ? READ_ALONG_FORMAT_COLOR : getFormatColor(fmt) return { color, backgroundColor: `color-mix(in oklch, ${color} 10%, transparent)`, @@ -451,10 +460,12 @@ function formatBadgeStyle(fmt: string) { {{ fmt }} + @@ -578,10 +589,12 @@ function formatBadgeStyle(fmt: string) { {{ fmt }} + diff --git a/client/src/components/AppLayout.vue b/client/src/components/AppLayout.vue index 1ecc49ca2..eedaee3ba 100644 --- a/client/src/components/AppLayout.vue +++ b/client/src/components/AppLayout.vue @@ -32,6 +32,9 @@ const GRID_VIEW_NAMES = [ 'SeriesDetailView', 'AuthorDetailView', 'LibrariesView', + 'PodcastLibrariesView', + 'PodcastCollectionView', + 'PodcastScopeView', 'SmartScopesView', 'CollectionsView', ] @@ -53,9 +56,13 @@ const viewKey = computed(() => { - +
diff --git a/client/src/components/AppSidebar.vue b/client/src/components/AppSidebar.vue index 75a832b94..9738aa7f3 100644 --- a/client/src/components/AppSidebar.vue +++ b/client/src/components/AppSidebar.vue @@ -1,28 +1,33 @@