diff --git a/.env.example b/.env.example
index 408950d..fe22c36 100644
--- a/.env.example
+++ b/.env.example
@@ -1,9 +1,9 @@
-# Environment Variables Example
# Database URL (SQLite default for local development)
DATABASE_URL="file:./dev.db"
-# Optional: OpenAI API Key (reserved for Milestone 2)
-# OPENAI_API_KEY="sk-..."
+# Google Gemini API Key (Milestone 2 Screenwriter engine)
+GEMINI_API_KEY="your-gemini-api-key"
+GEMINI_MODEL="gemini-2.5-flash"
-# Optional: ElevenLabs API Key (reserved for Milestone 2)
-# ELEVENLABS_API_KEY="..."
+# Control Key: restricts access to server default API credentials (bypassable if caller supplies BYO key)
+APP_CONTROL_KEY="your-strong-password-here"
diff --git a/.githooks/pre-commit b/.githooks/pre-commit
new file mode 100755
index 0000000..ab93e7d
--- /dev/null
+++ b/.githooks/pre-commit
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+# Pre-commit hook: scan staged changes for leaked secrets, tokens, and API keys with gitleaks
+
+set -e
+
+# Resolve gitleaks executable
+if command -v gitleaks &> /dev/null; then
+ GITLEAKS_BIN="gitleaks"
+elif [ -x "/opt/homebrew/bin/gitleaks" ]; then
+ GITLEAKS_BIN="/opt/homebrew/bin/gitleaks"
+elif [ -x "/usr/local/bin/gitleaks" ]; then
+ GITLEAKS_BIN="/usr/local/bin/gitleaks"
+else
+ echo "⚠️ [gitleaks-hook] Warning: 'gitleaks' executable not found on PATH."
+ echo " Please install it to prevent committing credentials: brew install gitleaks"
+ echo " Skipping secret check for this commit."
+ exit 0
+fi
+
+echo "🔍 [gitleaks-hook] Scanning staged changes for secrets..."
+
+# Run gitleaks protect on staged changes
+if ! "$GITLEAKS_BIN" protect --staged -v; then
+ echo ""
+ echo "❌ [gitleaks-hook] Commit rejected: Potential secret or credential leak detected!"
+ echo " Please remove any sensitive keys, tokens, or passwords before committing."
+ echo " If this is a false positive and you are certain it is safe, bypass with: git commit --no-verify"
+ echo ""
+ exit 1
+fi
+
+echo "✅ [gitleaks-hook] No leaked secrets detected in staged files."
+exit 0
diff --git a/.gitignore b/.gitignore
index 830e3ca..de90f97 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,4 +35,5 @@ yarn-error.log*
.DS_Store
*.pem
.vscode/
-.idea/
\ No newline at end of file
+.idea/
+scratch/
\ No newline at end of file
diff --git a/docs/prd.md b/docs/prd.md
index a321435..d752c0b 100644
--- a/docs/prd.md
+++ b/docs/prd.md
@@ -1,21 +1,21 @@
# Product Requirements Document (PRD): Be My Anchor
-**Status**: Approved (Post Grill-Me)
-**Target Version**: 2.0 (Full Refactor)
-**Architecture**: TypeScript / Node.js Fullstack
+**Status**: Approved
+**Target Version**: 2.0 (Listening-First Audio Platform)
+**Architecture**: TypeScript / Next.js Fullstack
**Primary Output**: `docs/prd.md`
---
## 1. Executive Summary & Vision
-**Be My Anchor** transforms written materials—technical blogs, research papers, news articles, novels, and documentation—into an engaging, listening-first multimedia experience.
+**Be My Anchor** transforms written materials—technical blogs, research papers, news articles, essays, and documentation—into an engaging, listening-first audio experience.
-Rather than relying on brittle, heavy web scrapers or monotone text-to-speech readers, Be My Anchor adopts an **API-first ingestion** model combined with:
+Rather than relying on brittle web scrapers or monotone text-to-speech readers, Be My Anchor adopts an **API-driven ingestion** model combined with:
-1. **High-Fidelity Audio Generation**: Seamless multi-provider TTS (Edge TTS, OpenAI, ElevenLabs) with smart text chunking and audio stitching.
+1. **High-Fidelity Audio Generation**: Multi-provider neural TTS (Edge TTS, OpenAI, ElevenLabs) with smart text chunking and audio stitching.
2. **Hearing-Front Content Adaptation**: An LLM-powered scriptwriting engine that adapts dense text into conversational spoken formats (e.g., Feynman-style technical breakdown or 2-host conversational dialogue).
-3. **Synchronized Visual Companion**: A web player that dynamically surfaces referenced figures, diagrams, and code snippets in sync with the audio narration.
+3. **Mobile-First Zen Listening Experience**: A distraction-free PWA listening environment with uninterrupted playback, synchronized auto-scrolling transcript, and tap-to-seek navigation.
---
@@ -26,7 +26,7 @@ Rather than relying on brittle, heavy web scrapers or monotone text-to-speech re
│ Phase 1: Robust Audio Generation Core │
│ - Multi-provider TTS (Edge TTS, OpenAI, ElevenLabs) │
│ - Long-form text chunking & audio stitching │
-│ - Basic web player & episode library │
+│ - Persistent audio player & episode library │
└──────────────────────────────┬──────────────────────────────┘
│
▼
@@ -34,15 +34,15 @@ Rather than relying on brittle, heavy web scrapers or monotone text-to-speech re
│ Phase 2: Hearing-Front Content Transformation │
│ - "Feynman" Deep-Dive Presenter Persona │
│ - Dual-Host Dialogue Persona (NotebookLM style) │
-│ - Script stage cue tagging │
+│ - Synchronized sentence & speaker timing manifests │
└──────────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
-│ Phase 3: Interactive Synchronized Multimedia Player │
-│ - Timestamped visual cues (figures, code, diagrams) │
-│ - Karaoke-style synced transcript │
-│ - Stage-aware responsive visual pane │
+│ Phase 3: Mobile PWA & Zen Listening Experience │
+│ - Fullscreen Zen player with synchronized transcript │
+│ - Mobile PWA & MediaSession background lockscreen controls │
+│ - Dedicated article studio and queue status tracking │
└─────────────────────────────────────────────────────────────┘
```
@@ -53,14 +53,12 @@ Rather than relying on brittle, heavy web scrapers or monotone text-to-speech re
### Persona 1: The Engineer / Researcher ("The Feynman Mode")
- **Content**: Technical architecture posts, RFCs, research papers, algorithm deep-dives.
-- **Narrator Style**: A world-class communicator (like Richard Feynman or a top tech lead) explaining concepts using intuitive analogies, breaking down equations, and explaining diagrams.
-- **Visual Need**: When the narrator says *"Looking at Figure 2, the ingress traffic routes through..."*, Figure 2 automatically appears on screen.
+- **Narrator Style**: A world-class communicator (like Richard Feynman or a top tech lead) explaining concepts using intuitive analogies and breaking down dense material for the ear.
### Persona 2: The Informed Professional ("The Dual-Host Dialogue")
- **Content**: Industry news, opinion pieces, market shifts, newsletters.
-- **Narrator Style**: Two AI co-hosts bantering, asking each other rhetorical questions, and adding contextual background not directly present in the source article.
-- **Visual Need**: Highlight cards, quotes, and source citations appearing on the dashboard.
+- **Narrator Style**: Two AI co-hosts bantering, asking each other questions, and adding contextual background not directly present in the source article.
### Persona 3: The Audiobook / Direct Listener ("Verbatim Mode")
@@ -74,7 +72,7 @@ Rather than relying on brittle, heavy web scrapers or monotone text-to-speech re
```
┌────────────────────────────┐
│ API Ingestion Endpoint │
- │ (Markdown / Text / Assets) │
+ │ (Markdown / Text Input) │
└─────────────┬──────────────┘
│
▼
@@ -82,11 +80,9 @@ Rather than relying on brittle, heavy web scrapers or monotone text-to-speech re
│ Hearing-Front Script Engine│
│ (LLM: OpenAI / Claude) │
│ - Rewrites text for ear │
- │ - Generates Stage Cues │
+ │ - Speaker turn separation │
└─────────────┬──────────────┘
│
- Script with Stage Marks
- │
▼
┌────────────────────────────┐
│ TTS Generation Core │
@@ -97,25 +93,28 @@ Rather than relying on brittle, heavy web scrapers or monotone text-to-speech re
│ │ ElevenLabs (Multi-Voice│ │
│ └────────────────────────┘ │
│ - Chunker & Audio Stitcher │
- │ - Timing Aligner (VTT/JSON)│
+ │ - Timing Aligner (JSON) │
└─────────────┬──────────────┘
│
▼
┌───────────────────────────────────┴───────────────────────────────────┐
│ Data & Storage │
-│ - Database: Prisma ORM (MongoDB / PostgreSQL) │
-│ - File Storage: Local static / S3-compatible (Audio MP3s & Figures) │
+│ - Database: Prisma ORM (SQLite / PostgreSQL) │
+│ - File Storage: Local static / S3-compatible (Audio MP3s) │
└───────────────────────────────────┬───────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────────┐
-│ Interactive Web Player (Next.js / TS) │
+│ Mobile-First Web App (Next.js / TS / PWA) │
│ ┌───────────────────────┐ ┌───────────────────────────────────────┐ │
-│ │ Audio Player UI │ │ Synchronized Visual Stage │ │
-│ │ - Waveform / Scrubber│ │ - Figures / Diagrams from post │ │
-│ │ - Speed / ±15s Skips │ │ - Syntax-highlighted code snippets │ │
-│ │ - Live Transcript │ │ - Key takeaway summary cards │ │
+│ │ Main Index View │ │ Zen Mode Player │ │
+│ │ - Episode list │ │ - Fullscreen distraction-free player │ │
+│ │ - Live status pills │ │ - Synchronized auto-scrolled script │ │
+│ │ - Persistent bar │ │ - Tap-to-seek, large touch controls │ │
│ └───────────────────────┘ └───────────────────────────────────────┘ │
+│ ┌─────────────────────────────────────────────────────────────────┐ │
+│ │ Dedicated Add Article View (/new) │ │
+│ └─────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────┘
```
@@ -123,56 +122,45 @@ Rather than relying on brittle, heavy web scrapers or monotone text-to-speech re
## 5. Functional Requirements
-### 5.1 Content Ingestion (API-Driven)
+### 5.1 Content Ingestion (API & UI Driven)
-- Provide a clean REST API accepting:
+- Clean REST API & dedicated UI accepting:
- `title`: String
- - `content`: Plain text or Markdown (including image references ``)
- - `format_mode`: `'feynman'` | `'dual_host'` | `'verbatim'`
+ - `content`: Plain text or Markdown
+ - `mode`: `'VERBATIM'` | `'FEYNMAN'` | `'DUAL_HOST'`
- `voice_config`: Provider preference (`edge` | `openai` | `elevenlabs`), voice IDs, speed
- - `supplemental_materials`: Optional array of images/figures, captions, and links
-- No requirement for headless Selenium or browser dependencies on the server.
### 5.2 Hearing-Front Content Transformation
-- **Verbatim Cleaner**: Strips markdown artifacts, normalizes punctuation, phonetic hints for acronyms.
-- **Feynman Script Generator**:
- - Rewrites technical content into an oral lecture/walkthrough.
- - Generates `[STAGE: figure_id]` or `[STAGE: code_block_id]` inline tags corresponding to visual assets.
-- **Dual-Host Dialogue Generator**:
- - Turns article content into an interactive conversation between Host A (e.g., inquisitive, relatable) and Host B (e.g., domain expert).
- - Emits speaker-tagged lines: `SpeakerA: ...` and `SpeakerB: ...`.
+- **Verbatim Cleaner**: Strips markdown artifacts, normalizes punctuation, phonetic hints.
+- **Feynman Script Generator**: Rewrites technical content into an oral lecture/walkthrough.
+- **Dual-Host Dialogue Generator**: Turns article content into an interactive conversation between Host A and Host B with speaker-tagged turns (`SpeakerA: ...`, `SpeakerB: ...`).
### 5.3 TTS Engine & Audio Processing
- **Provider Abstraction Interface (`ITextToSpeechProvider`)**:
- - `EdgeTTSProvider`: Free, instant, high-quality neural voices (`en-US-ChristopherNeural`, `en-US-JennyNeural`, etc.), zero API key required.
+ - `EdgeTTSProvider`: Free, instant neural voices (`zh-CN-XiaoxiaoNeural`, `en-US-ChristopherNeural`, etc.).
- `OpenAITTSProvider`: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`.
- - `ElevenLabsProvider`: High-fidelity multi-voice assignment with voice cloning or distinct character voices for multi-host dialogue.
-- **Long-Text Chunker**:
- - Breaks script into sentence/clause chunks respecting provider character limits (e.g. 4096 chars).
- - Handles speaker switching in dual-host mode without awkward pauses.
-- **Audio Stitcher & Aligner**:
+ - `ElevenLabsProvider`: High-fidelity multi-voice assignment for dual-host dialogue.
+- **Long-Text Chunker & Audio Stitcher**:
+ - Breaks script into sentence/clause chunks respecting character limits.
- Stitches chunk audio files into a unified master `.mp3`.
- - Outputs a timing manifest (`timings.json` or WebVTT) recording the start and end timestamp of each paragraph, speaker turn, and stage marker.
+ - Generates synchronized sentence/turn timing manifests (`timingsJson`).
-### 5.4 Synchronized Web View & Media Player
+### 5.4 3-View Mobile-First Listening Experience
-- **Episode Library**:
- - List of generated episodes with duration, creation date, mode, and status (`processing`, `ready`, `failed`).
-- **Audio Player Controls**:
- - Play / Pause, Progress scrubber, Current time / Total duration.
- - Jump Back 15s (`-15s`), Jump Forward 15s (`+15s`).
- - Playback speed selection (0.8x, 1.0x, 1.25x, 1.5x, 2.0x).
-- **Synchronized Transcript Pane**:
- - Interactive transcript that auto-scrolls and highlights the active paragraph/dialogue turn as audio plays.
- - Clicking any paragraph seeks playback directly to that timestamp.
-- **Synchronized Stage (Supplemental Visual Information)**:
- - Responsive visual viewport alongside or above the player.
- - When the audio reaches a tagged stage (e.g. timestamp 02:15):
- - Automatically displays the relevant figure, architecture diagram, equation, or code snippet.
- - Provides zoom/expand view for detailed inspection.
- - Gracefully transitions to summary notes when no specific figure is active.
+1. **Main Index View (`/`)**:
+ - Episode list with live statuses (`QUEUED`, `PROCESSING`, `READY`, `FAILED`, `CANCELED`).
+ - Card play/pause, duration, auto-polling while jobs are active.
+ - Persistent bottom mini-player bar that never interrupts playing audio.
+2. **Zen Fullscreen Player**:
+ - Toggled via full-screen button on persistent bar.
+ - Synchronized transcript with active sentence highlighting and smooth auto-scroll.
+ - Tap-to-seek anywhere in transcript.
+ - Swipe-down gesture or minimize button to return to index without audio interruption.
+3. **Dedicated Add Article View (`/new`)**:
+ - Standalone view for new posts with character/word count and duration estimation.
+ - Extensible for future M2 settings (prompts, dual voices).
---
@@ -180,7 +168,7 @@ Rather than relying on brittle, heavy web scrapers or monotone text-to-speech re
```prisma
datasource db {
- provider = "postgresql" // or "mongodb"
+ provider = "sqlite" // or "postgresql"
url = env("DATABASE_URL")
}
@@ -188,46 +176,20 @@ generator client {
provider = "prisma-client-js"
}
-enum ProcessingStatus {
- PENDING
- SCRIPTING
- SYNTHESIZING
- READY
- FAILED
-}
-
-enum ScriptMode {
- VERBATIM
- FEYNMAN
- DUAL_HOST
-}
-
model Episode {
- id String @id @default(cuid()) @map("_id")
+ id String @id @default(cuid())
title String
originalContent String
scriptContent String?
- mode ScriptMode @default(FEYNMAN)
- status ProcessingStatus @default(PENDING)
+ mode String @default("VERBATIM") // VERBATIM | FEYNMAN | DUAL_HOST
+ status String @default("PENDING") // QUEUED | PROCESSING | READY | FAILED | CANCELED
+ errorMessage String?
durationSeconds Float?
audioUrl String?
- timingsJson String? // Serialized timestamp cues
+ timingsJson String? // Serialized array of cues [{text, startTime, endTime, speaker}]
- stages StageItem[]
- createdAt DateTime @default(now())
- updatedAt DateTime @updatedAt
-}
-
-model StageItem {
- id String @id @default(cuid()) @map("_id")
- episodeId String
- timestampSeconds Float
- type String // "IMAGE" | "CODE" | "CALLOUT"
- title String?
- content String // Image URL or Code snippet / Callout text
- caption String?
-
- episode Episode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
}
```
@@ -241,72 +203,48 @@ Creates a new conversion job.
```json
{
- "title": "Understanding Kubernetes Ingress Controllers",
+ "title": "Understanding Kubernetes Ingress",
"content": "# Markdown content...",
- "mode": "FEYNMAN",
- "ttsProvider": "edge",
- "voiceOptions": {
- "primaryVoice": "en-US-ChristopherNeural",
- "speed": 1.0
- },
- "visualAssets": [
- {
- "id": "fig-1",
- "type": "IMAGE",
- "url": "https://example.com/arch.png",
- "caption": "Figure 1: Ingress routing architecture"
- }
- ]
+ "mode": "VERBATIM",
+ "provider": "edge",
+ "voice": "en-US-ChristopherNeural"
}
```
-**Response**: `202 Accepted` with `{ "episodeId": "clx...", "status": "PENDING" }`.
-
### `GET /api/episodes/:id`
-Retrieves episode details, audio URL, processing status, transcript timings, and stage markers.
+Retrieves episode details, audio URL, processing status, and transcript timings.
### `GET /api/episodes`
-Lists all generated episodes for the library view.
+Lists all episodes for the index view.
+
+### `DELETE /api/episodes/:id`
+
+Deletes or cancels an episode.
---
-## 8. Technical Stack
+## 8. Implementation Roadmap
-| Component | Technology | Rationale |
-| --- | --- | --- |
-| **Runtime & Language** | Node.js (v20+) / TypeScript | Rapid prototyping, strong typing, zero Python venv/setup friction. |
-| **Framework** | Next.js (App Router) | Unified backend API routes + reactive frontend player in one repo. |
-| **Database & ORM** | Prisma + MongoDB or PostgreSQL | Lightweight document/relational storage matching user preference. |
-| **Audio Generation** | `msedge-tts`, OpenAI API, ElevenLabs SDK | Free zero-key tier (Edge) + premium commercial options. |
-| **Audio Processing** | `fluent-ffmpeg` or Web Audio APIs | Fast server-side audio stitching and silence injection. |
-| **LLM Orchestration** | Vercel AI SDK / OpenAI SDK | Streaming script generation and structured stage extraction. |
-| **Styling** | Tailwind CSS / Lucide Icons | Clean modern aesthetic, responsive audio controls. |
+### Phase 1: Core Audio Generation & Minimal Player (Completed)
----
+- [x] Next.js + TypeScript project structure and Prisma configuration.
+- [x] `ITextToSpeechProvider` interface with `EdgeTTSProvider` and `OpenAITTSProvider`.
+- [x] Long-text chunking, concatenation, and static file delivery.
+- [x] Basic synchronized transcript viewer and audio player.
-## 9. Implementation Roadmap
-
-### Phase 1: Core Audio Generation & Minimal Player (Week 1)
-
-- [ ] Initialize Next.js + TypeScript project structure and Prisma configuration.
-- [ ] Implement `ITextToSpeechProvider` interface with `EdgeTTSProvider` and `OpenAITTSProvider`.
-- [ ] Build audio chunking, concatenation, and static file delivery.
-- [ ] Implement basic Web Player UI (Play/Pause, Scrubber, Episode list).
-
-### Phase 2: Hearing-Front Script Engine (Week 2)
+### Phase 2: Hearing-Front Script Engine (Next)
- [ ] Implement prompt templates for:
- - Feynman Master Presenter (single host, conversational breakdown).
+ - Feynman Master Presenter (conversational breakdown).
- Dual-Host Dialogue (speaker-tagged turns).
- [ ] Parse speaker tags and route chunks to distinct voices in the TTS pipeline.
- [ ] Output synchronized paragraph/sentence timestamp manifests.
-### Phase 3: Synchronized Visual Stages (Week 3)
+### Phase 3: Mobile PWA & Zen Listening Experience (Current)
-- [ ] Extract or link visual assets (figures/diagrams/code) to script stages.
-- [ ] Build the interactive Stage Viewer in the web player:
- - Figure transitions locked to audio progress.
- - Interactive transcript with click-to-seek.
-- [ ] End-to-end testing with technical papers, blog posts, and news feeds.
+- [x] Global `AudioProvider` ensuring uninterrupted audio playback across all navigation.
+- [x] Fullscreen Zen Player with auto-scrolling synchronized transcript & tap-to-seek.
+- [x] Dedicated Add Article View (`/new`) for extensible settings.
+- [x] PWA manifest & mobile-responsive design for phone listening.
diff --git a/next-env.d.ts b/next-env.d.ts
index ce4e94a..a419cbe 100644
--- a/next-env.d.ts
+++ b/next-env.d.ts
@@ -1,7 +1,7 @@
///
///
-import "./.next/types/routes.d.ts";
-import "./.next/types/root-params.d.ts";
+import "./.next/dev/types/routes.d.ts";
+import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
diff --git a/next.config.mjs b/next.config.mjs
index a505021..76fa612 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */
+// Reload Prisma Client with targetLanguage schema
const nextConfig = {
- serverExternalPackages: ["msedge-tts"],
+ serverExternalPackages: ["msedge-tts", "@prisma/client"],
};
export default nextConfig;
diff --git a/package.json b/package.json
index acc72f3..814725c 100644
--- a/package.json
+++ b/package.json
@@ -1,12 +1,14 @@
{
"name": "be-my-anchor",
"version": "1.0.0",
- "description": "Transform articles, tech blogs, and papers into a personalized listening experience with synchronized visual companion.",
+ "description": "Transform articles, tech blogs, and papers into a personalized listening-first audio experience.",
"scripts": {
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start",
- "lint": "next lint"
+ "lint": "next lint",
+ "gitleaks": "gitleaks protect --staged -v",
+ "prepare": "git config core.hooksPath .githooks || true"
},
"repository": {
"type": "git",
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 77ff03c..5012833 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -11,9 +11,14 @@ model Episode {
id String @id @default(cuid())
title String
originalContent String
+ contentHash String? // SHA-256 for deduplication
scriptContent String?
mode String @default("VERBATIM") // VERBATIM | FEYNMAN | DUAL_HOST
- status String @default("PENDING") // PENDING | PROCESSING | READY | FAILED
+ targetLanguage String @default("auto") // auto | zh | en
+ status String @default("QUEUED") // QUEUED | PROCESSING | READY | FAILED
+ retryCount Int @default(0)
+ maxRetries Int @default(3)
+ nextAttemptAt DateTime?
errorMessage String?
durationSeconds Float?
audioUrl String?
@@ -22,6 +27,8 @@ model Episode {
stages StageItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+
+ @@index([contentHash])
}
model StageItem {
diff --git a/public/icon.svg b/public/icon.svg
new file mode 100644
index 0000000..956612f
--- /dev/null
+++ b/public/icon.svg
@@ -0,0 +1,8 @@
+
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 0000000..41af873
--- /dev/null
+++ b/public/manifest.json
@@ -0,0 +1,18 @@
+{
+ "name": "Be My Anchor",
+ "short_name": "Anchor",
+ "description": "Transform written articles and papers into a listening-first audio experience",
+ "start_url": "/",
+ "display": "standalone",
+ "background_color": "#090d16",
+ "theme_color": "#090d16",
+ "orientation": "portrait-primary",
+ "icons": [
+ {
+ "src": "/icon.svg",
+ "sizes": "any",
+ "type": "image/svg+xml",
+ "purpose": "any maskable"
+ }
+ ]
+}
diff --git a/src/app/api/auth/status/route.ts b/src/app/api/auth/status/route.ts
new file mode 100644
index 0000000..ce18fa5
--- /dev/null
+++ b/src/app/api/auth/status/route.ts
@@ -0,0 +1,9 @@
+import { NextResponse } from "next/server";
+import { isControlKeyConfigured } from "@/lib/auth/control-key";
+
+export async function GET() {
+ return NextResponse.json({
+ success: true,
+ isControlKeyConfigured: isControlKeyConfigured(),
+ });
+}
diff --git a/src/app/api/episodes/[id]/route.ts b/src/app/api/episodes/[id]/route.ts
index b3e436a..59f98c0 100644
--- a/src/app/api/episodes/[id]/route.ts
+++ b/src/app/api/episodes/[id]/route.ts
@@ -26,6 +26,20 @@ export async function GET(
...episode,
timings: episode.timingsJson ? JSON.parse(episode.timingsJson) : [],
},
+ task: {
+ id: episode.id,
+ status: episode.status,
+ retryCount: episode.retryCount,
+ maxRetries: episode.maxRetries,
+ nextAttemptAt: episode.nextAttemptAt,
+ errorMessage: episode.errorMessage,
+ durationSeconds: episode.durationSeconds,
+ audioUrl: episode.audioUrl,
+ isSuccess: episode.status === "READY",
+ isProcessing: episode.status === "PROCESSING",
+ isQueued: episode.status === "QUEUED",
+ isFailed: episode.status === "FAILED",
+ },
});
} catch (error: any) {
return NextResponse.json(
@@ -35,6 +49,53 @@ export async function GET(
}
}
+export async function POST(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ try {
+ const { id } = await params;
+ const episode = await prisma.episode.findUnique({ where: { id } });
+
+ if (!episode) {
+ return NextResponse.json(
+ { success: false, error: "Episode not found" },
+ { status: 404 }
+ );
+ }
+
+ const { taskQueue } = await import("@/lib/queue/task-queue");
+
+ const updated = await prisma.episode.update({
+ where: { id },
+ data: {
+ status: "QUEUED",
+ retryCount: 0,
+ errorMessage: null,
+ nextAttemptAt: null,
+ },
+ });
+
+ taskQueue.enqueue({
+ id: episode.id,
+ title: episode.title,
+ content: episode.originalContent,
+ mode: episode.mode,
+ });
+
+ return NextResponse.json({
+ success: true,
+ message: "Episode re-queued for processing.",
+ episode: updated,
+ });
+ } catch (error: any) {
+ return NextResponse.json(
+ { success: false, error: error.message || "Failed to retry episode" },
+ { status: 500 }
+ );
+ }
+}
+
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
diff --git a/src/app/api/episodes/route.ts b/src/app/api/episodes/route.ts
index f91450c..70ae61d 100644
--- a/src/app/api/episodes/route.ts
+++ b/src/app/api/episodes/route.ts
@@ -1,7 +1,8 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
-import { audioService } from "@/lib/tts/audio-service";
import { cleanHtmlTags } from "@/lib/tts/chunker";
+import { validateDefaultAuthAccess } from "@/lib/auth/control-key";
+import { taskQueue, computeContentHash } from "@/lib/queue/task-queue";
export async function GET() {
try {
@@ -22,7 +23,40 @@ export async function GET() {
export async function POST(req: NextRequest) {
try {
const body = await req.json();
- const { title, content, mode = "VERBATIM", provider = "edge", voice } = body;
+ const {
+ title,
+ content,
+ mode = "VERBATIM",
+ targetLanguage = "auto",
+ provider = "edge",
+ voice,
+ voiceA,
+ voiceB,
+ apiKey,
+ controlKey,
+ } = body;
+
+ // If LLM mode is requested without a BYO key, verify Control Key to authorize using server's default credentials
+ const hasByoKey = typeof apiKey === "string" && apiKey.trim().length > 0;
+ const isLlmMode = mode === "DUAL_HOST" || mode === "FEYNMAN";
+
+ if (isLlmMode && !hasByoKey) {
+ const isAuthorized = validateDefaultAuthAccess(req, {
+ hasByoKey: false,
+ bodyControlKey: controlKey,
+ });
+
+ if (!isAuthorized) {
+ return NextResponse.json(
+ {
+ success: false,
+ error:
+ "Unauthorized: Using the server's default AI credentials requires a valid Control Key. Alternatively, you can provide your own Gemini API Key (BYO key) without needing a Control Key.",
+ },
+ { status: 401 }
+ );
+ }
+ }
if (!title || typeof title !== "string" || !title.trim()) {
return NextResponse.json(
@@ -39,58 +73,110 @@ export async function POST(req: NextRequest) {
}
const sanitizedTitle = cleanHtmlTags(title.trim()) || "Untitled Episode";
+ const sanitizedContent = content.trim();
- // 1. Create episode record in PENDING state
+ // Compute content hash based strictly on title + content + mode + targetLanguage
+ const contentHash = computeContentHash(sanitizedTitle, sanitizedContent, mode, targetLanguage);
+
+ // 1. Deduplication check: look for identical content already submitted
+ const existing = await prisma.episode.findFirst({
+ where: { contentHash },
+ orderBy: { createdAt: "desc" },
+ });
+
+ if (existing) {
+ if (existing.status === "READY" || existing.status === "PROCESSING" || existing.status === "QUEUED") {
+ console.log(`[POST /api/episodes] Deduplicated: identical content already exists as episode [${existing.id}] (${existing.status})`);
+ return NextResponse.json(
+ {
+ success: true,
+ deduplicated: true,
+ message: `Identical article already exists with status: ${existing.status}.`,
+ episode: existing,
+ },
+ { status: 200 }
+ );
+ }
+
+ if (existing.status === "FAILED") {
+ // Re-poke: re-queue the existing failed record instead of creating duplicate clutter
+ console.log(`[POST /api/episodes] Re-queuing previously failed episode [${existing.id}]...`);
+ const updated = await prisma.episode.update({
+ where: { id: existing.id },
+ data: {
+ status: "QUEUED",
+ targetLanguage,
+ retryCount: 0,
+ errorMessage: null,
+ nextAttemptAt: null,
+ },
+ });
+
+ taskQueue.enqueue({
+ id: existing.id,
+ title: sanitizedTitle,
+ content: sanitizedContent,
+ mode,
+ targetLanguage,
+ provider,
+ voice,
+ voiceA,
+ voiceB,
+ apiKey,
+ });
+
+ return NextResponse.json(
+ {
+ success: true,
+ deduplicated: false,
+ requeued: true,
+ message: "Previously failed episode re-queued for processing.",
+ episode: updated,
+ },
+ { status: 202 }
+ );
+ }
+ }
+
+ // 2. Create new episode record in QUEUED state
const episode = await prisma.episode.create({
data: {
title: sanitizedTitle,
- originalContent: content.trim(),
+ originalContent: sanitizedContent,
+ contentHash,
mode,
- status: "PROCESSING",
+ targetLanguage,
+ status: "QUEUED",
+ retryCount: 0,
+ maxRetries: 3,
},
});
- // 2. Synthesize audio asynchronously or inline
- try {
- const audioResult = await audioService.processAndSaveEpisode(
- episode.id,
- content.trim(),
- { provider, voice }
- );
+ // 3. Enqueue task for asynchronous single-worker FIFO execution
+ taskQueue.enqueue({
+ id: episode.id,
+ title: sanitizedTitle,
+ content: sanitizedContent,
+ mode,
+ targetLanguage,
+ provider,
+ voice,
+ voiceA,
+ voiceB,
+ apiKey,
+ });
- const updatedEpisode = await prisma.episode.update({
- where: { id: episode.id },
- data: {
- status: "READY",
- audioUrl: audioResult.audioUrl,
- durationSeconds: audioResult.durationSeconds,
- timingsJson: JSON.stringify(audioResult.timings),
- },
- });
+ console.log(`[POST /api/episodes] Enqueued new episode [${episode.id}] "${sanitizedTitle}" (Mode: ${mode}, Lang: ${targetLanguage})`);
- return NextResponse.json({
+ return NextResponse.json(
+ {
success: true,
- episode: updatedEpisode,
- });
- } catch (synthError: any) {
- console.error(`Audio synthesis failed for episode ${episode.id}:`, synthError);
- await prisma.episode.update({
- where: { id: episode.id },
- data: {
- status: "FAILED",
- errorMessage: synthError.message || "Audio synthesis failed",
- },
- });
-
- return NextResponse.json(
- {
- success: false,
- error: synthError.message || "Failed to synthesize audio",
- episodeId: episode.id,
- },
- { status: 500 }
- );
- }
+ queued: true,
+ deduplicated: false,
+ episode,
+ },
+ { status: 202 }
+ );
} catch (error: any) {
console.error("Episode creation error:", error);
return NextResponse.json(
diff --git a/src/app/api/worker/status/route.ts b/src/app/api/worker/status/route.ts
new file mode 100644
index 0000000..8bd8c8d
--- /dev/null
+++ b/src/app/api/worker/status/route.ts
@@ -0,0 +1,20 @@
+import { NextResponse } from "next/server";
+import { taskQueue } from "@/lib/queue/task-queue";
+
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+ try {
+ const status = taskQueue.getStatus();
+ return NextResponse.json({
+ success: true,
+ worker: status,
+ });
+ } catch (error: any) {
+ console.error("Failed to retrieve worker status:", error);
+ return NextResponse.json(
+ { success: false, error: error.message || "Failed to fetch worker status" },
+ { status: 500 }
+ );
+ }
+}
diff --git a/src/app/globals.css b/src/app/globals.css
index cda5554..4a707da 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -3,6 +3,11 @@
@tailwind utilities;
:root {
+ --background: #f8fafc;
+ --foreground: #0f172a;
+}
+
+.dark {
--background: #090d16;
--foreground: #f1f5f9;
}
@@ -15,6 +20,12 @@ body {
padding: 0;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
+ -webkit-tap-highlight-color: transparent;
+}
+
+/* Safe area inset for mobile phones with notch / home indicators */
+.safe-bottom {
+ padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
}
/* Custom scrollbars */
@@ -24,14 +35,22 @@ body {
}
::-webkit-scrollbar-track {
- background: #0f172a;
+ background: transparent;
}
::-webkit-scrollbar-thumb {
- background: #334155;
+ background: #cbd5e1;
border-radius: 3px;
}
+.dark ::-webkit-scrollbar-thumb {
+ background: #334155;
+}
+
::-webkit-scrollbar-thumb:hover {
+ background: #94a3b8;
+}
+
+.dark ::-webkit-scrollbar-thumb:hover {
background: #475569;
}
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index 5c0a904..103dbff 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -1,10 +1,31 @@
-import type { Metadata } from "next";
+import type { Metadata, Viewport } from "next";
import "./globals.css";
+import { ThemeProvider } from "@/contexts/ThemeContext";
+import { AudioProvider } from "@/contexts/AudioContext";
+import { AudioPlayer } from "@/components/AudioPlayer";
+import { ZenPlayer } from "@/components/ZenPlayer";
export const metadata: Metadata = {
title: "Be My Anchor - Listen to Any Article",
description:
- "Convert written articles, tech blogs, and papers into a personalized listening experience with synchronized visual companion.",
+ "Convert written articles, tech blogs, and papers into a personalized listening experience with synchronized transcript.",
+ manifest: "/manifest.json",
+ appleWebApp: {
+ capable: true,
+ statusBarStyle: "black-translucent",
+ title: "Be My Anchor",
+ },
+};
+
+export const viewport: Viewport = {
+ themeColor: [
+ { media: "(prefers-color-scheme: dark)", color: "#090d16" },
+ { media: "(prefers-color-scheme: light)", color: "#f8fafc" },
+ ],
+ width: "device-width",
+ initialScale: 1,
+ maximumScale: 1,
+ viewportFit: "cover",
};
export default function RootLayout({
@@ -13,9 +34,36 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
-
-
- {children}
+
+
+
+
+
+
+
+ {children}
+ {/* Persistent bottom mini player bar */}
+
+ {/* Full Screen Zen Mode overlay */}
+
+
+
);
diff --git a/src/app/new/page.tsx b/src/app/new/page.tsx
new file mode 100644
index 0000000..41cc53a
--- /dev/null
+++ b/src/app/new/page.tsx
@@ -0,0 +1,607 @@
+"use client";
+
+import React, { useEffect, useState } from "react";
+import Link from "next/link";
+import { useRouter } from "next/navigation";
+import {
+ ArrowLeft,
+ Sparkles,
+ Headphones,
+ Loader2,
+ Volume2,
+ Clock,
+ FileText,
+ Layers,
+ KeyRound,
+ Globe,
+} from "lucide-react";
+import { VoiceOption } from "@/lib/tts/types";
+import { useAudio } from "@/contexts/AudioContext";
+import { ThemeToggle } from "@/components/ThemeToggle";
+
+export default function NewArticlePage() {
+ const router = useRouter();
+ const { playEpisode } = useAudio();
+
+ const [title, setTitle] = useState("");
+ const [content, setContent] = useState("");
+ const [targetLanguage, setTargetLanguage] = useState<"auto" | "zh" | "en">("auto");
+ const [voice, setVoice] = useState("zh-CN-XiaoxiaoNeural");
+ const [voiceA, setVoiceA] = useState("zh-CN-YunxiNeural");
+ const [voiceB, setVoiceB] = useState("zh-CN-XiaoxiaoNeural");
+ const [apiKey, setApiKey] = useState("");
+ const [controlKey, setControlKey] = useState("");
+ const [isControlKeyConfigured, setIsControlKeyConfigured] = useState(false);
+ const [mode, setMode] = useState("DUAL_HOST");
+ const [voices, setVoices] = useState([]);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [formError, setFormError] = useState("");
+
+ const handleLanguageChange = (lang: "auto" | "zh" | "en") => {
+ setTargetLanguage(lang);
+ if (lang === "zh") {
+ setVoice("zh-CN-YunxiNeural");
+ setVoiceA("zh-CN-YunxiNeural");
+ setVoiceB("zh-CN-XiaoxiaoNeural");
+ } else if (lang === "en") {
+ setVoice("en-US-ChristopherNeural");
+ setVoiceA("en-US-ChristopherNeural");
+ setVoiceB("en-US-JennyNeural");
+ }
+ };
+
+ // Load voices and control key requirement
+ useEffect(() => {
+ // Restore saved control key from localStorage
+ if (typeof window !== "undefined") {
+ const savedKey = localStorage.getItem("bma_control_key");
+ if (savedKey) setControlKey(savedKey);
+ }
+
+ // Check if server has configured a control key
+ fetch("/api/auth/status")
+ .then((res) => res.json())
+ .then((data) => {
+ if (data.success && data.isControlKeyConfigured) {
+ setIsControlKeyConfigured(true);
+ }
+ })
+ .catch(() => {});
+
+ async function loadVoices() {
+ try {
+ const res = await fetch("/api/voices");
+ const data = await res.json();
+ if (data.success && data.voices) {
+ setVoices(data.voices);
+ }
+ } catch (err) {
+ console.error("Failed to load voices:", err);
+ }
+ }
+ loadVoices();
+ }, []);
+
+ // Word count & estimated listening duration calculation
+ const charCount = content.length;
+ const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0;
+ // Estimate ~150 words per min for English, or ~280 characters per min for Chinese
+ const isChinese = /[\u4e00-\u9fa5]/.test(content);
+ const estimatedMins = isChinese
+ ? Math.max(1, Math.ceil(charCount / 280))
+ : Math.max(1, Math.ceil(wordCount / 150));
+
+ const handleContentChange = (val: string) => {
+ setContent(val);
+ if (/[\u4e00-\u9fa5]/.test(val) && voice.startsWith("en-")) {
+ setVoice("zh-CN-XiaoxiaoNeural");
+ setVoiceA("zh-CN-YunxiNeural");
+ setVoiceB("zh-CN-XiaoxiaoNeural");
+ } else if (!/[\u4e00-\u9fa5]/.test(val) && voice.startsWith("zh-")) {
+ setVoice("en-US-AndrewMultilingualNeural");
+ setVoiceA("en-US-AndrewMultilingualNeural");
+ setVoiceB("en-US-AvaMultilingualNeural");
+ }
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!title.trim() || !content.trim()) {
+ setFormError("Please provide both an article title and content.");
+ return;
+ }
+
+ const isLlmMode = mode === "DUAL_HOST" || mode === "FEYNMAN";
+ const hasByoKey = apiKey.trim().length > 0;
+
+ if (isLlmMode && isControlKeyConfigured && !hasByoKey && !controlKey.trim()) {
+ setFormError(
+ "Authorization required: Please provide either the server Control Key (to use default AI) or your own Gemini API Key (BYO Key)."
+ );
+ return;
+ }
+
+ setIsSubmitting(true);
+ setFormError("");
+
+ try {
+ const headers: Record = {
+ "Content-Type": "application/json",
+ };
+ if (controlKey.trim()) {
+ headers["x-control-key"] = controlKey.trim();
+ }
+
+ const res = await fetch("/api/episodes", {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ title: title.trim(),
+ content: content.trim(),
+ mode,
+ targetLanguage: mode === "VERBATIM" ? "auto" : targetLanguage,
+ provider: "edge",
+ voice: mode === "DUAL_HOST" ? voiceA : voice,
+ voiceA,
+ voiceB,
+ apiKey: apiKey.trim() || undefined,
+ controlKey: controlKey.trim() || undefined,
+ }),
+ });
+
+ const data = await res.json();
+ if (!res.ok || !data.success) {
+ throw new Error(data.error || "Failed to generate episode");
+ }
+
+ // Save control key in localStorage on success
+ if (typeof window !== "undefined" && controlKey.trim()) {
+ localStorage.setItem("bma_control_key", controlKey.trim());
+ }
+
+ // If episode audio is already ready (e.g. deduplicated), trigger playback
+ if (data.episode && data.episode.status === "READY" && data.episode.audioUrl) {
+ playEpisode(data.episode);
+ }
+
+ // Navigate back to index view immediately where progress is live-tracked
+ router.push("/");
+ } catch (err: any) {
+ setFormError(err.message || "Failed to queue audio episode.");
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+ {/* Top Header */}
+
+
+
+
+
Episodes
+
+
+
+ Add New Article
+
+
+
+
+
+
+
+
+ {/* Main Content Area */}
+
+ {formError && (
+
+ {formError}
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/app/page.tsx b/src/app/page.tsx
index dbf4000..83f6195 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,150 +1,78 @@
"use client";
import React, { useEffect, useState } from "react";
+import Link from "next/link";
import {
Radio,
- PlusCircle,
+ Plus,
Headphones,
Trash2,
- FileText,
- Sparkles,
- Loader2,
Clock,
- ExternalLink,
- Volume2,
+ Play,
+ Pause,
+ Maximize2,
+ Search,
+ RefreshCw,
+ Loader2,
+ AlertCircle,
+ RotateCw,
+ Hourglass,
+ Sparkles,
} from "lucide-react";
-import { AudioPlayer } from "@/components/AudioPlayer";
-import { TranscriptViewer } from "@/components/TranscriptViewer";
-import { TimingCue, VoiceOption } from "@/lib/tts/types";
+import { useAudio, Episode } from "@/contexts/AudioContext";
+import { ThemeToggle } from "@/components/ThemeToggle";
-interface Episode {
- id: string;
- title: string;
- originalContent: string;
- mode: string;
- status: string;
- durationSeconds?: number | null;
- audioUrl?: string | null;
- timingsJson?: string | null;
- createdAt: string;
-}
-
-export default function Home() {
+export default function HomePage() {
+ const { activeEpisode, isPlaying, playEpisode, togglePlay, openZen } = useAudio();
const [episodes, setEpisodes] = useState([]);
- const [activeEpisode, setActiveEpisode] = useState(null);
- const [activeTimings, setActiveTimings] = useState([]);
- const [currentTime, setCurrentTime] = useState(0);
- const [seekTime, setSeekTime] = useState(null);
+ const [workerStatus, setWorkerStatus] = useState(null);
+ const [isLoading, setIsLoading] = useState(true);
+ const [searchQuery, setSearchQuery] = useState("");
- // Form state
- const [title, setTitle] = useState("");
- const [content, setContent] = useState("");
- const [voice, setVoice] = useState("zh-CN-XiaoxiaoNeural");
- const [voices, setVoices] = useState([]);
- const [isSubmitting, setIsSubmitting] = useState(false);
- const [formError, setFormError] = useState("");
- const [showCreateForm, setShowCreateForm] = useState(false);
-
- const handleContentChange = (val: string) => {
- setContent(val);
- if (/[\u4e00-\u9fa5]/.test(val) && voice.startsWith("en-")) {
- setVoice("zh-CN-XiaoxiaoNeural");
- }
- };
-
- // Fetch episodes on mount
const fetchEpisodes = async () => {
try {
- const res = await fetch("/api/episodes");
- const data = await res.json();
+ const [epRes, workerRes] = await Promise.all([
+ fetch("/api/episodes"),
+ fetch("/api/worker/status").catch(() => null),
+ ]);
+
+ const data = await epRes.json();
if (data.success && data.episodes) {
setEpisodes(data.episodes);
- // Default to first episode if none selected
- if (!activeEpisode && data.episodes.length > 0) {
- selectEpisode(data.episodes[0]);
+ }
+
+ if (workerRes && workerRes.ok) {
+ const workerData = await workerRes.json();
+ if (workerData.success) {
+ setWorkerStatus(workerData.worker);
}
}
} catch (err) {
console.error("Failed to load episodes:", err);
- }
- };
-
- // Fetch voices on mount
- const fetchVoices = async () => {
- try {
- const res = await fetch("/api/voices");
- const data = await res.json();
- if (data.success && data.voices) {
- setVoices(data.voices);
- }
- } catch (err) {
- console.error("Failed to load voices:", err);
+ } finally {
+ setIsLoading(false);
}
};
useEffect(() => {
fetchEpisodes();
- fetchVoices();
}, []);
- const selectEpisode = (ep: Episode) => {
- setActiveEpisode(ep);
- if (ep.timingsJson) {
- try {
- setActiveTimings(JSON.parse(ep.timingsJson));
- } catch {
- setActiveTimings([]);
- }
- } else {
- setActiveTimings([]);
- }
- setCurrentTime(0);
- };
+ // Real-time polling when any episode is QUEUED or PROCESSING, or worker is active
+ useEffect(() => {
+ const hasPending = episodes.some(
+ (ep) => ep.status === "QUEUED" || ep.status === "PROCESSING"
+ );
+ const workerActive = workerStatus && (!workerStatus.isIdle || workerStatus.isBackingOff);
- const handleCreateEpisode = async (e: React.FormEvent) => {
- e.preventDefault();
- if (!title.trim() || !content.trim()) {
- setFormError("Please provide both a title and article content.");
- return;
- }
+ if (!hasPending && !workerActive) return;
- setIsSubmitting(true);
- setFormError("");
+ const interval = setInterval(() => {
+ fetchEpisodes();
+ }, 2500);
- try {
- const res = await fetch("/api/episodes", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- title,
- content,
- mode: "VERBATIM",
- provider: "edge",
- voice,
- }),
- });
-
- const data = await res.json();
- if (!res.ok || !data.success) {
- throw new Error(data.error || "Synthesis failed");
- }
-
- // Reset form
- setTitle("");
- setContent("");
- setShowCreateForm(false);
-
- // Refresh list & select newly created episode
- await fetchEpisodes();
- if (data.episode) {
- selectEpisode(data.episode);
- }
- } catch (err: any) {
- setFormError(err.message || "Failed to generate audio.");
- } finally {
- setIsSubmitting(false);
- }
- };
+ return () => clearInterval(interval);
+ }, [episodes, workerStatus]);
const handleDeleteEpisode = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
@@ -152,16 +80,22 @@ export default function Home() {
try {
await fetch(`/api/episodes/${id}`, { method: "DELETE" });
- if (activeEpisode?.id === id) {
- setActiveEpisode(null);
- setActiveTimings([]);
- }
await fetchEpisodes();
} catch (err) {
console.error("Failed to delete episode:", err);
}
};
+ const handleRetryEpisode = async (id: string, e: React.MouseEvent) => {
+ e.stopPropagation();
+ try {
+ await fetch(`/api/episodes/${id}`, { method: "POST" });
+ await fetchEpisodes();
+ } catch (err) {
+ console.error("Failed to retry episode:", err);
+ }
+ };
+
const formatDuration = (secs?: number | null) => {
if (!secs) return "0:00";
const m = Math.floor(secs / 60);
@@ -169,300 +103,286 @@ export default function Home() {
return `${m}:${s.toString().padStart(2, "0")}`;
};
+ // Filter episodes purely by search query
+ const filteredEpisodes = episodes.filter((ep) => {
+ if (!searchQuery.trim()) return true;
+ const q = searchQuery.toLowerCase();
+ return (
+ ep.title.toLowerCase().includes(q) ||
+ ep.originalContent.toLowerCase().includes(q)
+ );
+ });
+
return (
-