feat: implement authentication control with support for BYO API keys, episode deduplication, and a task queue for async processing.
This commit is contained in:
+5
-5
@@ -1,9 +1,9 @@
|
|||||||
# Environment Variables Example
|
|
||||||
# Database URL (SQLite default for local development)
|
# Database URL (SQLite default for local development)
|
||||||
DATABASE_URL="file:./dev.db"
|
DATABASE_URL="file:./dev.db"
|
||||||
|
|
||||||
# Optional: OpenAI API Key (reserved for Milestone 2)
|
# Google Gemini API Key (Milestone 2 Screenwriter engine)
|
||||||
# OPENAI_API_KEY="sk-..."
|
GEMINI_API_KEY="your-gemini-api-key"
|
||||||
|
GEMINI_MODEL="gemini-2.5-flash"
|
||||||
|
|
||||||
# Optional: ElevenLabs API Key (reserved for Milestone 2)
|
# Control Key: restricts access to server default API credentials (bypassable if caller supplies BYO key)
|
||||||
# ELEVENLABS_API_KEY="..."
|
APP_CONTROL_KEY="your-strong-password-here"
|
||||||
|
|||||||
Executable
+33
@@ -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
|
||||||
@@ -36,3 +36,4 @@ yarn-error.log*
|
|||||||
*.pem
|
*.pem
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
scratch/
|
||||||
+82
-144
@@ -1,21 +1,21 @@
|
|||||||
# Product Requirements Document (PRD): Be My Anchor
|
# Product Requirements Document (PRD): Be My Anchor
|
||||||
|
|
||||||
**Status**: Approved (Post Grill-Me)
|
**Status**: Approved
|
||||||
**Target Version**: 2.0 (Full Refactor)
|
**Target Version**: 2.0 (Listening-First Audio Platform)
|
||||||
**Architecture**: TypeScript / Node.js Fullstack
|
**Architecture**: TypeScript / Next.js Fullstack
|
||||||
**Primary Output**: `docs/prd.md`
|
**Primary Output**: `docs/prd.md`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. Executive Summary & Vision
|
## 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).
|
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 │
|
│ Phase 1: Robust Audio Generation Core │
|
||||||
│ - Multi-provider TTS (Edge TTS, OpenAI, ElevenLabs) │
|
│ - Multi-provider TTS (Edge TTS, OpenAI, ElevenLabs) │
|
||||||
│ - Long-form text chunking & audio stitching │
|
│ - 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 │
|
│ Phase 2: Hearing-Front Content Transformation │
|
||||||
│ - "Feynman" Deep-Dive Presenter Persona │
|
│ - "Feynman" Deep-Dive Presenter Persona │
|
||||||
│ - Dual-Host Dialogue Persona (NotebookLM style) │
|
│ - Dual-Host Dialogue Persona (NotebookLM style) │
|
||||||
│ - Script stage cue tagging │
|
│ - Synchronized sentence & speaker timing manifests │
|
||||||
└──────────────────────────────┬──────────────────────────────┘
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
│ Phase 3: Interactive Synchronized Multimedia Player │
|
│ Phase 3: Mobile PWA & Zen Listening Experience │
|
||||||
│ - Timestamped visual cues (figures, code, diagrams) │
|
│ - Fullscreen Zen player with synchronized transcript │
|
||||||
│ - Karaoke-style synced transcript │
|
│ - Mobile PWA & MediaSession background lockscreen controls │
|
||||||
│ - Stage-aware responsive visual pane │
|
│ - 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")
|
### Persona 1: The Engineer / Researcher ("The Feynman Mode")
|
||||||
|
|
||||||
- **Content**: Technical architecture posts, RFCs, research papers, algorithm deep-dives.
|
- **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.
|
- **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.
|
||||||
- **Visual Need**: When the narrator says *"Looking at Figure 2, the ingress traffic routes through..."*, Figure 2 automatically appears on screen.
|
|
||||||
|
|
||||||
### Persona 2: The Informed Professional ("The Dual-Host Dialogue")
|
### Persona 2: The Informed Professional ("The Dual-Host Dialogue")
|
||||||
|
|
||||||
- **Content**: Industry news, opinion pieces, market shifts, newsletters.
|
- **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.
|
- **Narrator Style**: Two AI co-hosts bantering, asking each other questions, and adding contextual background not directly present in the source article.
|
||||||
- **Visual Need**: Highlight cards, quotes, and source citations appearing on the dashboard.
|
|
||||||
|
|
||||||
### Persona 3: The Audiobook / Direct Listener ("Verbatim Mode")
|
### 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 │
|
│ 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│
|
│ Hearing-Front Script Engine│
|
||||||
│ (LLM: OpenAI / Claude) │
|
│ (LLM: OpenAI / Claude) │
|
||||||
│ - Rewrites text for ear │
|
│ - Rewrites text for ear │
|
||||||
│ - Generates Stage Cues │
|
│ - Speaker turn separation │
|
||||||
└─────────────┬──────────────┘
|
└─────────────┬──────────────┘
|
||||||
│
|
│
|
||||||
Script with Stage Marks
|
|
||||||
│
|
|
||||||
▼
|
▼
|
||||||
┌────────────────────────────┐
|
┌────────────────────────────┐
|
||||||
│ TTS Generation Core │
|
│ TTS Generation Core │
|
||||||
@@ -97,25 +93,28 @@ Rather than relying on brittle, heavy web scrapers or monotone text-to-speech re
|
|||||||
│ │ ElevenLabs (Multi-Voice│ │
|
│ │ ElevenLabs (Multi-Voice│ │
|
||||||
│ └────────────────────────┘ │
|
│ └────────────────────────┘ │
|
||||||
│ - Chunker & Audio Stitcher │
|
│ - Chunker & Audio Stitcher │
|
||||||
│ - Timing Aligner (VTT/JSON)│
|
│ - Timing Aligner (JSON) │
|
||||||
└─────────────┬──────────────┘
|
└─────────────┬──────────────┘
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌───────────────────────────────────┴───────────────────────────────────┐
|
┌───────────────────────────────────┴───────────────────────────────────┐
|
||||||
│ Data & Storage │
|
│ Data & Storage │
|
||||||
│ - Database: Prisma ORM (MongoDB / PostgreSQL) │
|
│ - Database: Prisma ORM (SQLite / PostgreSQL) │
|
||||||
│ - File Storage: Local static / S3-compatible (Audio MP3s & Figures) │
|
│ - 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 │ │
|
│ │ Main Index View │ │ Zen Mode Player │ │
|
||||||
│ │ - Waveform / Scrubber│ │ - Figures / Diagrams from post │ │
|
│ │ - Episode list │ │ - Fullscreen distraction-free player │ │
|
||||||
│ │ - Speed / ±15s Skips │ │ - Syntax-highlighted code snippets │ │
|
│ │ - Live status pills │ │ - Synchronized auto-scrolled script │ │
|
||||||
│ │ - Live Transcript │ │ - Key takeaway summary cards │ │
|
│ │ - 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. 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
|
- `title`: String
|
||||||
- `content`: Plain text or Markdown (including image references ``)
|
- `content`: Plain text or Markdown
|
||||||
- `format_mode`: `'feynman'` | `'dual_host'` | `'verbatim'`
|
- `mode`: `'VERBATIM'` | `'FEYNMAN'` | `'DUAL_HOST'`
|
||||||
- `voice_config`: Provider preference (`edge` | `openai` | `elevenlabs`), voice IDs, speed
|
- `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
|
### 5.2 Hearing-Front Content Transformation
|
||||||
|
|
||||||
- **Verbatim Cleaner**: Strips markdown artifacts, normalizes punctuation, phonetic hints for acronyms.
|
- **Verbatim Cleaner**: Strips markdown artifacts, normalizes punctuation, phonetic hints.
|
||||||
- **Feynman Script Generator**:
|
- **Feynman Script Generator**: Rewrites technical content into an oral lecture/walkthrough.
|
||||||
- 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: ...`).
|
||||||
- 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: ...`.
|
|
||||||
|
|
||||||
### 5.3 TTS Engine & Audio Processing
|
### 5.3 TTS Engine & Audio Processing
|
||||||
|
|
||||||
- **Provider Abstraction Interface (`ITextToSpeechProvider`)**:
|
- **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`.
|
- `OpenAITTSProvider`: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`.
|
||||||
- `ElevenLabsProvider`: High-fidelity multi-voice assignment with voice cloning or distinct character voices for multi-host dialogue.
|
- `ElevenLabsProvider`: High-fidelity multi-voice assignment for dual-host dialogue.
|
||||||
- **Long-Text Chunker**:
|
- **Long-Text Chunker & Audio Stitcher**:
|
||||||
- Breaks script into sentence/clause chunks respecting provider character limits (e.g. 4096 chars).
|
- Breaks script into sentence/clause chunks respecting character limits.
|
||||||
- Handles speaker switching in dual-host mode without awkward pauses.
|
|
||||||
- **Audio Stitcher & Aligner**:
|
|
||||||
- Stitches chunk audio files into a unified master `.mp3`.
|
- 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**:
|
1. **Main Index View (`/`)**:
|
||||||
- List of generated episodes with duration, creation date, mode, and status (`processing`, `ready`, `failed`).
|
- Episode list with live statuses (`QUEUED`, `PROCESSING`, `READY`, `FAILED`, `CANCELED`).
|
||||||
- **Audio Player Controls**:
|
- Card play/pause, duration, auto-polling while jobs are active.
|
||||||
- Play / Pause, Progress scrubber, Current time / Total duration.
|
- Persistent bottom mini-player bar that never interrupts playing audio.
|
||||||
- Jump Back 15s (`-15s`), Jump Forward 15s (`+15s`).
|
2. **Zen Fullscreen Player**:
|
||||||
- Playback speed selection (0.8x, 1.0x, 1.25x, 1.5x, 2.0x).
|
- Toggled via full-screen button on persistent bar.
|
||||||
- **Synchronized Transcript Pane**:
|
- Synchronized transcript with active sentence highlighting and smooth auto-scroll.
|
||||||
- Interactive transcript that auto-scrolls and highlights the active paragraph/dialogue turn as audio plays.
|
- Tap-to-seek anywhere in transcript.
|
||||||
- Clicking any paragraph seeks playback directly to that timestamp.
|
- Swipe-down gesture or minimize button to return to index without audio interruption.
|
||||||
- **Synchronized Stage (Supplemental Visual Information)**:
|
3. **Dedicated Add Article View (`/new`)**:
|
||||||
- Responsive visual viewport alongside or above the player.
|
- Standalone view for new posts with character/word count and duration estimation.
|
||||||
- When the audio reaches a tagged stage (e.g. timestamp 02:15):
|
- Extensible for future M2 settings (prompts, dual voices).
|
||||||
- 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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -180,7 +168,7 @@ Rather than relying on brittle, heavy web scrapers or monotone text-to-speech re
|
|||||||
|
|
||||||
```prisma
|
```prisma
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql" // or "mongodb"
|
provider = "sqlite" // or "postgresql"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,47 +176,21 @@ generator client {
|
|||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ProcessingStatus {
|
|
||||||
PENDING
|
|
||||||
SCRIPTING
|
|
||||||
SYNTHESIZING
|
|
||||||
READY
|
|
||||||
FAILED
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ScriptMode {
|
|
||||||
VERBATIM
|
|
||||||
FEYNMAN
|
|
||||||
DUAL_HOST
|
|
||||||
}
|
|
||||||
|
|
||||||
model Episode {
|
model Episode {
|
||||||
id String @id @default(cuid()) @map("_id")
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
originalContent String
|
originalContent String
|
||||||
scriptContent String?
|
scriptContent String?
|
||||||
mode ScriptMode @default(FEYNMAN)
|
mode String @default("VERBATIM") // VERBATIM | FEYNMAN | DUAL_HOST
|
||||||
status ProcessingStatus @default(PENDING)
|
status String @default("PENDING") // QUEUED | PROCESSING | READY | FAILED | CANCELED
|
||||||
|
errorMessage String?
|
||||||
durationSeconds Float?
|
durationSeconds Float?
|
||||||
audioUrl String?
|
audioUrl String?
|
||||||
timingsJson String? // Serialized timestamp cues
|
timingsJson String? // Serialized array of cues [{text, startTime, endTime, speaker}]
|
||||||
|
|
||||||
stages StageItem[]
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
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)
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -241,72 +203,48 @@ Creates a new conversion job.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"title": "Understanding Kubernetes Ingress Controllers",
|
"title": "Understanding Kubernetes Ingress",
|
||||||
"content": "# Markdown content...",
|
"content": "# Markdown content...",
|
||||||
"mode": "FEYNMAN",
|
"mode": "VERBATIM",
|
||||||
"ttsProvider": "edge",
|
"provider": "edge",
|
||||||
"voiceOptions": {
|
"voice": "en-US-ChristopherNeural"
|
||||||
"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"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response**: `202 Accepted` with `{ "episodeId": "clx...", "status": "PENDING" }`.
|
|
||||||
|
|
||||||
### `GET /api/episodes/:id`
|
### `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`
|
### `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 |
|
### Phase 1: Core Audio Generation & Minimal Player (Completed)
|
||||||
| --- | --- | --- |
|
|
||||||
| **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. |
|
|
||||||
|
|
||||||
---
|
- [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 2: Hearing-Front Script Engine (Next)
|
||||||
|
|
||||||
### 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)
|
|
||||||
|
|
||||||
- [ ] Implement prompt templates for:
|
- [ ] Implement prompt templates for:
|
||||||
- Feynman Master Presenter (single host, conversational breakdown).
|
- Feynman Master Presenter (conversational breakdown).
|
||||||
- Dual-Host Dialogue (speaker-tagged turns).
|
- Dual-Host Dialogue (speaker-tagged turns).
|
||||||
- [ ] Parse speaker tags and route chunks to distinct voices in the TTS pipeline.
|
- [ ] Parse speaker tags and route chunks to distinct voices in the TTS pipeline.
|
||||||
- [ ] Output synchronized paragraph/sentence timestamp manifests.
|
- [ ] 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.
|
- [x] Global `AudioProvider` ensuring uninterrupted audio playback across all navigation.
|
||||||
- [ ] Build the interactive Stage Viewer in the web player:
|
- [x] Fullscreen Zen Player with auto-scrolling synchronized transcript & tap-to-seek.
|
||||||
- Figure transitions locked to audio progress.
|
- [x] Dedicated Add Article View (`/new`) for extensible settings.
|
||||||
- Interactive transcript with click-to-seek.
|
- [x] PWA manifest & mobile-responsive design for phone listening.
|
||||||
- [ ] End-to-end testing with technical papers, blog posts, and news feeds.
|
|
||||||
|
|||||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
import "./.next/types/routes.d.ts";
|
import "./.next/dev/types/routes.d.ts";
|
||||||
import "./.next/types/root-params.d.ts";
|
import "./.next/dev/types/root-params.d.ts";
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,7 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
|
// Reload Prisma Client with targetLanguage schema
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
serverExternalPackages: ["msedge-tts"],
|
serverExternalPackages: ["msedge-tts", "@prisma/client"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
+4
-2
@@ -1,12 +1,14 @@
|
|||||||
{
|
{
|
||||||
"name": "be-my-anchor",
|
"name": "be-my-anchor",
|
||||||
"version": "1.0.0",
|
"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": {
|
"scripts": {
|
||||||
"dev": "next dev --webpack",
|
"dev": "next dev --webpack",
|
||||||
"build": "next build --webpack",
|
"build": "next build --webpack",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"gitleaks": "gitleaks protect --staged -v",
|
||||||
|
"prepare": "git config core.hooksPath .githooks || true"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -11,9 +11,14 @@ model Episode {
|
|||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
title String
|
title String
|
||||||
originalContent String
|
originalContent String
|
||||||
|
contentHash String? // SHA-256 for deduplication
|
||||||
scriptContent String?
|
scriptContent String?
|
||||||
mode String @default("VERBATIM") // VERBATIM | FEYNMAN | DUAL_HOST
|
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?
|
errorMessage String?
|
||||||
durationSeconds Float?
|
durationSeconds Float?
|
||||||
audioUrl String?
|
audioUrl String?
|
||||||
@@ -22,6 +27,8 @@ model Episode {
|
|||||||
stages StageItem[]
|
stages StageItem[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([contentHash])
|
||||||
}
|
}
|
||||||
|
|
||||||
model StageItem {
|
model StageItem {
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" fill="none">
|
||||||
|
<rect width="100" height="100" rx="24" fill="#2563eb" />
|
||||||
|
<circle cx="50" cy="50" r="14" fill="#ffffff" />
|
||||||
|
<path d="M50 20 A 30 30 0 0 0 20 50" stroke="#93c5fd" stroke-width="6" stroke-linecap="round" />
|
||||||
|
<path d="M50 20 A 30 30 0 0 1 80 50" stroke="#93c5fd" stroke-width="6" stroke-linecap="round" />
|
||||||
|
<path d="M50 8 A 42 42 0 0 0 8 50" stroke="#bfdbfe" stroke-width="6" stroke-linecap="round" stroke-dasharray="4 8" />
|
||||||
|
<path d="M50 8 A 42 42 0 0 1 92 50" stroke="#bfdbfe" stroke-width="6" stroke-linecap="round" stroke-dasharray="4 8" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 631 B |
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -26,6 +26,20 @@ export async function GET(
|
|||||||
...episode,
|
...episode,
|
||||||
timings: episode.timingsJson ? JSON.parse(episode.timingsJson) : [],
|
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) {
|
} catch (error: any) {
|
||||||
return NextResponse.json(
|
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(
|
export async function DELETE(
|
||||||
req: NextRequest,
|
req: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
|||||||
+128
-42
@@ -1,7 +1,8 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
import { audioService } from "@/lib/tts/audio-service";
|
|
||||||
import { cleanHtmlTags } from "@/lib/tts/chunker";
|
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() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
@@ -22,7 +23,40 @@ export async function GET() {
|
|||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
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()) {
|
if (!title || typeof title !== "string" || !title.trim()) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
@@ -39,58 +73,110 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sanitizedTitle = cleanHtmlTags(title.trim()) || "Untitled Episode";
|
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 episode = await prisma.episode.create({
|
const contentHash = computeContentHash(sanitizedTitle, sanitizedContent, mode, targetLanguage);
|
||||||
data: {
|
|
||||||
title: sanitizedTitle,
|
// 1. Deduplication check: look for identical content already submitted
|
||||||
originalContent: content.trim(),
|
const existing = await prisma.episode.findFirst({
|
||||||
mode,
|
where: { contentHash },
|
||||||
status: "PROCESSING",
|
orderBy: { createdAt: "desc" },
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Synthesize audio asynchronously or inline
|
if (existing) {
|
||||||
try {
|
if (existing.status === "READY" || existing.status === "PROCESSING" || existing.status === "QUEUED") {
|
||||||
const audioResult = await audioService.processAndSaveEpisode(
|
console.log(`[POST /api/episodes] Deduplicated: identical content already exists as episode [${existing.id}] (${existing.status})`);
|
||||||
episode.id,
|
return NextResponse.json(
|
||||||
content.trim(),
|
{
|
||||||
{ provider, voice }
|
|
||||||
);
|
|
||||||
|
|
||||||
const updatedEpisode = await prisma.episode.update({
|
|
||||||
where: { id: episode.id },
|
|
||||||
data: {
|
|
||||||
status: "READY",
|
|
||||||
audioUrl: audioResult.audioUrl,
|
|
||||||
durationSeconds: audioResult.durationSeconds,
|
|
||||||
timingsJson: JSON.stringify(audioResult.timings),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return NextResponse.json({
|
|
||||||
success: true,
|
success: true,
|
||||||
episode: updatedEpisode,
|
deduplicated: true,
|
||||||
});
|
message: `Identical article already exists with status: ${existing.status}.`,
|
||||||
} catch (synthError: any) {
|
episode: existing,
|
||||||
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",
|
|
||||||
},
|
},
|
||||||
|
{ 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(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: true,
|
||||||
error: synthError.message || "Failed to synthesize audio",
|
deduplicated: false,
|
||||||
episodeId: episode.id,
|
requeued: true,
|
||||||
|
message: "Previously failed episode re-queued for processing.",
|
||||||
|
episode: updated,
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 202 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create new episode record in QUEUED state
|
||||||
|
const episode = await prisma.episode.create({
|
||||||
|
data: {
|
||||||
|
title: sanitizedTitle,
|
||||||
|
originalContent: sanitizedContent,
|
||||||
|
contentHash,
|
||||||
|
mode,
|
||||||
|
targetLanguage,
|
||||||
|
status: "QUEUED",
|
||||||
|
retryCount: 0,
|
||||||
|
maxRetries: 3,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[POST /api/episodes] Enqueued new episode [${episode.id}] "${sanitizedTitle}" (Mode: ${mode}, Lang: ${targetLanguage})`);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
queued: true,
|
||||||
|
deduplicated: false,
|
||||||
|
episode,
|
||||||
|
},
|
||||||
|
{ status: 202 }
|
||||||
|
);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Episode creation error:", error);
|
console.error("Episode creation error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-2
@@ -3,6 +3,11 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--background: #f8fafc;
|
||||||
|
--foreground: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
--background: #090d16;
|
--background: #090d16;
|
||||||
--foreground: #f1f5f9;
|
--foreground: #f1f5f9;
|
||||||
}
|
}
|
||||||
@@ -15,6 +20,12 @@ body {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
-webkit-font-smoothing: antialiased;
|
-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 */
|
/* Custom scrollbars */
|
||||||
@@ -24,14 +35,22 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #0f172a;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #334155;
|
background: #cbd5e1;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb {
|
||||||
|
background: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::-webkit-scrollbar-thumb:hover {
|
||||||
background: #475569;
|
background: #475569;
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-4
@@ -1,10 +1,31 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata, Viewport } from "next";
|
||||||
import "./globals.css";
|
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 = {
|
export const metadata: Metadata = {
|
||||||
title: "Be My Anchor - Listen to Any Article",
|
title: "Be My Anchor - Listen to Any Article",
|
||||||
description:
|
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({
|
export default function RootLayout({
|
||||||
@@ -13,9 +34,36 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en" className="dark">
|
<html lang="en" className="dark" suppressHydrationWarning>
|
||||||
<body className="bg-slate-950 text-slate-100 min-h-screen flex flex-col antialiased">
|
<head>
|
||||||
|
<script
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: `(function() {
|
||||||
|
try {
|
||||||
|
var t = localStorage.getItem('bma_theme');
|
||||||
|
if (t === 'light' || (!t && window.matchMedia && !window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
})();`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
suppressHydrationWarning
|
||||||
|
className="bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100 min-h-screen flex flex-col antialiased selection:bg-blue-600 selection:text-white transition-colors duration-200"
|
||||||
|
>
|
||||||
|
<ThemeProvider>
|
||||||
|
<AudioProvider>
|
||||||
{children}
|
{children}
|
||||||
|
{/* Persistent bottom mini player bar */}
|
||||||
|
<AudioPlayer />
|
||||||
|
{/* Full Screen Zen Mode overlay */}
|
||||||
|
<ZenPlayer />
|
||||||
|
</AudioProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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<VoiceOption[]>([]);
|
||||||
|
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<string, string> = {
|
||||||
|
"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 (
|
||||||
|
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 text-slate-900 dark:text-slate-100 flex flex-col pb-36 transition-colors">
|
||||||
|
{/* Top Header */}
|
||||||
|
<header className="border-b border-slate-200 dark:border-slate-900 bg-white/80 dark:bg-slate-950/80 backdrop-blur-md sticky top-0 z-30 transition-colors">
|
||||||
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="flex items-center gap-2 text-sm text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-200 transition -ml-2 p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-900"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
<span>Episodes</span>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<span className="text-sm font-semibold text-slate-900 dark:text-slate-200">
|
||||||
|
Add New Article
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-center">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<main className="max-w-4xl mx-auto px-4 sm:px-6 py-8 w-full flex-1">
|
||||||
|
{formError && (
|
||||||
|
<div className="mb-6 p-4 bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-300 text-sm rounded-xl">
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
{/* Article Title */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider mb-2">
|
||||||
|
Article or Post Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="e.g., Understanding Raft Consensus Algorithm in Practice"
|
||||||
|
className="w-full px-4 py-3 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl text-slate-900 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:border-blue-500 text-base shadow-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Article Content / Markdown */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<label className="block text-xs font-semibold text-slate-500 dark:text-slate-400 uppercase tracking-wider">
|
||||||
|
Article Text or Markdown
|
||||||
|
</label>
|
||||||
|
{content.length > 0 && (
|
||||||
|
<div className="flex items-center gap-3 text-xs font-mono text-slate-500 dark:text-slate-400">
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<FileText className="w-3.5 h-3.5 text-blue-500 dark:text-blue-400" />
|
||||||
|
{charCount} chars
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Clock className="w-3.5 h-3.5 text-emerald-500 dark:text-emerald-400" />
|
||||||
|
~{estimatedMins} min listening
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => handleContentChange(e.target.value)}
|
||||||
|
placeholder="Paste the full article, technical documentation, newsletter, or blog post here..."
|
||||||
|
rows={12}
|
||||||
|
className="w-full p-4 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl text-slate-900 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:border-blue-500 font-mono text-xs sm:text-sm leading-relaxed shadow-sm"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode Selection */}
|
||||||
|
<div className="p-5 bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-2xl space-y-3 shadow-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Layers className="w-4 h-4 text-indigo-500 dark:text-indigo-400" />
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-200">
|
||||||
|
Narration Mode
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
|
Transform written text into an acoustic-first spoken masterpiece.
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 pt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode("DUAL_HOST")}
|
||||||
|
className={`p-3.5 rounded-xl border text-left transition flex flex-col justify-between gap-1.5 ${
|
||||||
|
mode === "DUAL_HOST"
|
||||||
|
? "bg-blue-50 dark:bg-blue-600/15 border-blue-400 dark:border-blue-500 text-slate-900 dark:text-slate-100 ring-1 ring-blue-400 dark:ring-blue-500/50"
|
||||||
|
: "bg-slate-50 dark:bg-slate-900/40 border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold flex items-center gap-1.5 text-blue-600 dark:text-blue-400">
|
||||||
|
🎙️ Dual-Host Dialogue
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400 leading-snug">
|
||||||
|
NotebookLM-style two-host dynamic conversation with distinct voices.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode("FEYNMAN")}
|
||||||
|
className={`p-3.5 rounded-xl border text-left transition flex flex-col justify-between gap-1.5 ${
|
||||||
|
mode === "FEYNMAN"
|
||||||
|
? "bg-amber-50 dark:bg-amber-600/15 border-amber-400 dark:border-amber-500 text-slate-900 dark:text-slate-100 ring-1 ring-amber-400 dark:ring-amber-500/50"
|
||||||
|
: "bg-slate-50 dark:bg-slate-900/40 border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
|
||||||
|
💡 Feynman Explainer
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400 leading-snug">
|
||||||
|
Solo mentor explaining core concepts via analogies and first principles.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode("VERBATIM")}
|
||||||
|
className={`p-3.5 rounded-xl border text-left transition flex flex-col justify-between gap-1.5 ${
|
||||||
|
mode === "VERBATIM"
|
||||||
|
? "bg-slate-100 dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-900 dark:text-slate-100 ring-1 ring-slate-300 dark:ring-slate-500"
|
||||||
|
: "bg-slate-50 dark:bg-slate-900/40 border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800/50"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-semibold flex items-center gap-1.5 text-slate-800 dark:text-slate-200">
|
||||||
|
📖 Verbatim Reader
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400 leading-snug">
|
||||||
|
Reads the exact original text clause-by-clause without rewriting.
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Output Language Section (AI modes) */}
|
||||||
|
{mode !== "VERBATIM" && (
|
||||||
|
<div className="p-4 bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-sm space-y-2.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Globe className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
||||||
|
<h4 className="text-xs font-semibold text-slate-700 dark:text-slate-200 uppercase tracking-wider">
|
||||||
|
Output Language (收听语言)
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-slate-400">
|
||||||
|
AI will adapt & translate dialogue to this language
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleLanguageChange("auto")}
|
||||||
|
className={`px-3 py-2 rounded-xl border text-xs font-medium transition flex items-center justify-center gap-1.5 ${
|
||||||
|
targetLanguage === "auto"
|
||||||
|
? "bg-blue-600 text-white border-blue-600 shadow-sm"
|
||||||
|
: "bg-slate-50 dark:bg-slate-800/60 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🌐 Auto (跟从原文)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleLanguageChange("zh")}
|
||||||
|
className={`px-3 py-2 rounded-xl border text-xs font-medium transition flex items-center justify-center gap-1.5 ${
|
||||||
|
targetLanguage === "zh"
|
||||||
|
? "bg-blue-600 text-white border-blue-600 shadow-sm"
|
||||||
|
: "bg-slate-50 dark:bg-slate-800/60 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🇨🇳 中文 (Chinese)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleLanguageChange("en")}
|
||||||
|
className={`px-3 py-2 rounded-xl border text-xs font-medium transition flex items-center justify-center gap-1.5 ${
|
||||||
|
targetLanguage === "en"
|
||||||
|
? "bg-blue-600 text-white border-blue-600 shadow-sm"
|
||||||
|
: "bg-slate-50 dark:bg-slate-800/60 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
🇺🇸 English (英文)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Voice Selection Section */}
|
||||||
|
{mode === "DUAL_HOST" ? (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{/* Host A Voice */}
|
||||||
|
<div className="p-4 bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-sm space-y-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Volume2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
||||||
|
<h4 className="text-xs font-semibold text-slate-700 dark:text-slate-200 uppercase tracking-wider">
|
||||||
|
Host A (Lead Host / 男声)
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={voiceA}
|
||||||
|
onChange={(e) => setVoiceA(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-xl text-xs text-slate-900 dark:text-slate-100 focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<optgroup label="🇨🇳 中文 / Chinese">
|
||||||
|
{voices
|
||||||
|
.filter((v) => v.locale.startsWith("zh"))
|
||||||
|
.map((v) => (
|
||||||
|
<option key={`ha-${v.id}`} value={v.id}>
|
||||||
|
{v.name} ({v.gender})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="🇺🇸 English">
|
||||||
|
{voices
|
||||||
|
.filter((v) => !v.locale.startsWith("zh"))
|
||||||
|
.map((v) => (
|
||||||
|
<option key={`ha-${v.id}`} value={v.id}>
|
||||||
|
{v.name} ({v.gender})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Host B Voice */}
|
||||||
|
<div className="p-4 bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-sm space-y-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Volume2 className="w-4 h-4 text-purple-500 dark:text-purple-400" />
|
||||||
|
<h4 className="text-xs font-semibold text-slate-700 dark:text-slate-200 uppercase tracking-wider">
|
||||||
|
Host B (Co-Host / 女声)
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={voiceB}
|
||||||
|
onChange={(e) => setVoiceB(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-xl text-xs text-slate-900 dark:text-slate-100 focus:outline-none focus:border-purple-500"
|
||||||
|
>
|
||||||
|
<optgroup label="🇨🇳 中文 / Chinese">
|
||||||
|
{voices
|
||||||
|
.filter((v) => v.locale.startsWith("zh"))
|
||||||
|
.map((v) => (
|
||||||
|
<option key={`hb-${v.id}`} value={v.id}>
|
||||||
|
{v.name} ({v.gender})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="🇺🇸 English">
|
||||||
|
{voices
|
||||||
|
.filter((v) => !v.locale.startsWith("zh"))
|
||||||
|
.map((v) => (
|
||||||
|
<option key={`hb-${v.id}`} value={v.id}>
|
||||||
|
{v.name} ({v.gender})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Single Voice Selection */
|
||||||
|
<div className="p-5 bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-sm space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Volume2 className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
||||||
|
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">
|
||||||
|
{mode === "FEYNMAN" ? "Narrator Voice" : "Reader Voice"}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={voice}
|
||||||
|
onChange={(e) => setVoice(e.target.value)}
|
||||||
|
className="w-full px-3.5 py-2.5 bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-xl text-sm text-slate-900 dark:text-slate-100 focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<optgroup label="🇨🇳 中文 / Chinese">
|
||||||
|
{voices
|
||||||
|
.filter((v) => v.locale.startsWith("zh"))
|
||||||
|
.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.name} ({v.gender})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="🇺🇸 English">
|
||||||
|
{voices
|
||||||
|
.filter((v) => !v.locale.startsWith("zh"))
|
||||||
|
.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.name} ({v.gender})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* AI Credentials & Authorization */}
|
||||||
|
{mode !== "VERBATIM" && (
|
||||||
|
<div className="p-5 bg-white dark:bg-slate-900/60 border border-slate-200 dark:border-slate-800 rounded-2xl shadow-sm space-y-4">
|
||||||
|
<div className="flex items-center justify-between border-b border-slate-200 dark:border-slate-800/80 pb-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-slate-900 dark:text-slate-200 flex items-center gap-2">
|
||||||
|
<Sparkles className="w-4 h-4 text-blue-500 dark:text-blue-400" />
|
||||||
|
AI Access & Credentials
|
||||||
|
</h4>
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5">
|
||||||
|
Choose to use the server's default AI with a Control Key, or provide your personal Gemini key (BYO Key).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{apiKey.trim() ? (
|
||||||
|
<span className="px-2.5 py-1 rounded-full text-[10px] font-semibold bg-emerald-100 dark:bg-emerald-950/80 text-emerald-800 dark:text-emerald-300 border border-emerald-300 dark:border-emerald-800/60">
|
||||||
|
BYO Key Active
|
||||||
|
</span>
|
||||||
|
) : isControlKeyConfigured ? (
|
||||||
|
<span className="px-2.5 py-1 rounded-full text-[10px] font-semibold bg-blue-100 dark:bg-blue-950/80 text-blue-800 dark:text-blue-300 border border-blue-300 dark:border-blue-800/60">
|
||||||
|
Server Key Mode
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-1">
|
||||||
|
{/* Option 1: Server Control Key */}
|
||||||
|
<div
|
||||||
|
className={`p-4 rounded-xl border transition space-y-2 ${
|
||||||
|
!apiKey.trim()
|
||||||
|
? "bg-blue-50/50 dark:bg-slate-850/60 border-blue-300 dark:border-blue-500/40 shadow-sm"
|
||||||
|
: "bg-slate-50 dark:bg-slate-900/30 border-slate-200 dark:border-slate-800 opacity-60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs font-semibold text-slate-800 dark:text-slate-300 flex items-center gap-1.5">
|
||||||
|
<KeyRound className="w-3.5 h-3.5 text-emerald-600 dark:text-emerald-400" />
|
||||||
|
Option A: Server Control Key (操作密钥)
|
||||||
|
</label>
|
||||||
|
<span className="text-[10px] text-slate-400 dark:text-slate-500">Saved in browser</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={controlKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
setControlKey(val);
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.setItem("bma_control_key", val);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={
|
||||||
|
isControlKeyConfigured
|
||||||
|
? "Enter server control key..."
|
||||||
|
: "Enter control key (optional)..."
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded-lg text-xs text-slate-900 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:border-blue-500 font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-slate-500 dark:text-slate-400">
|
||||||
|
Authorizes using the server's pre-configured Gemini AI.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Option 2: BYO Gemini API Key */}
|
||||||
|
<div
|
||||||
|
className={`p-4 rounded-xl border transition space-y-2 ${
|
||||||
|
apiKey.trim()
|
||||||
|
? "bg-emerald-50/50 dark:bg-slate-850/60 border-emerald-300 dark:border-emerald-500/40 shadow-sm"
|
||||||
|
: "bg-slate-50 dark:bg-slate-900/30 border-slate-200 dark:border-slate-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs font-semibold text-slate-800 dark:text-slate-300 flex items-center gap-1.5">
|
||||||
|
<Sparkles className="w-3.5 h-3.5 text-amber-500 dark:text-amber-400" />
|
||||||
|
Option B: BYO Gemini API Key (自备密钥)
|
||||||
|
</label>
|
||||||
|
<span className="text-[10px] text-emerald-600 dark:text-emerald-400 font-medium">
|
||||||
|
No Control Key needed
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={apiKey}
|
||||||
|
onChange={(e) => setApiKey(e.target.value)}
|
||||||
|
placeholder="AIzaSy... (personal Gemini key)"
|
||||||
|
className="w-full px-3 py-2 bg-white dark:bg-slate-900 border border-slate-300 dark:border-slate-700 rounded-lg text-xs text-slate-900 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:border-amber-500 font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-[11px] text-slate-500 dark:text-slate-400">
|
||||||
|
Bypasses the control key requirement and bills directly to your Google account.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sticky Bottom Actions */}
|
||||||
|
<div className="pt-6 flex items-center justify-end gap-4 border-t border-slate-200 dark:border-slate-900">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="px-5 py-2.5 text-sm text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 transition"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-800 text-white text-sm font-medium rounded-xl shadow-lg shadow-blue-600/30 transition active:scale-95"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>Queuing Audio Episode...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Headphones className="w-4 h-4" />
|
||||||
|
<span>
|
||||||
|
{mode === "DUAL_HOST"
|
||||||
|
? "Generate Dual-Host Podcast"
|
||||||
|
: mode === "FEYNMAN"
|
||||||
|
? "Generate Feynman Explainer"
|
||||||
|
: "Generate Audio Episode"}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+274
-354
@@ -1,150 +1,78 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import {
|
import {
|
||||||
Radio,
|
Radio,
|
||||||
PlusCircle,
|
Plus,
|
||||||
Headphones,
|
Headphones,
|
||||||
Trash2,
|
Trash2,
|
||||||
FileText,
|
|
||||||
Sparkles,
|
|
||||||
Loader2,
|
|
||||||
Clock,
|
Clock,
|
||||||
ExternalLink,
|
Play,
|
||||||
Volume2,
|
Pause,
|
||||||
|
Maximize2,
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
RotateCw,
|
||||||
|
Hourglass,
|
||||||
|
Sparkles,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { AudioPlayer } from "@/components/AudioPlayer";
|
import { useAudio, Episode } from "@/contexts/AudioContext";
|
||||||
import { TranscriptViewer } from "@/components/TranscriptViewer";
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
import { TimingCue, VoiceOption } from "@/lib/tts/types";
|
|
||||||
|
|
||||||
interface Episode {
|
export default function HomePage() {
|
||||||
id: string;
|
const { activeEpisode, isPlaying, playEpisode, togglePlay, openZen } = useAudio();
|
||||||
title: string;
|
|
||||||
originalContent: string;
|
|
||||||
mode: string;
|
|
||||||
status: string;
|
|
||||||
durationSeconds?: number | null;
|
|
||||||
audioUrl?: string | null;
|
|
||||||
timingsJson?: string | null;
|
|
||||||
createdAt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const [episodes, setEpisodes] = useState<Episode[]>([]);
|
const [episodes, setEpisodes] = useState<Episode[]>([]);
|
||||||
const [activeEpisode, setActiveEpisode] = useState<Episode | null>(null);
|
const [workerStatus, setWorkerStatus] = useState<any>(null);
|
||||||
const [activeTimings, setActiveTimings] = useState<TimingCue[]>([]);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [currentTime, setCurrentTime] = useState<number>(0);
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [seekTime, setSeekTime] = useState<number | null>(null);
|
|
||||||
|
|
||||||
// Form state
|
|
||||||
const [title, setTitle] = useState("");
|
|
||||||
const [content, setContent] = useState("");
|
|
||||||
const [voice, setVoice] = useState("zh-CN-XiaoxiaoNeural");
|
|
||||||
const [voices, setVoices] = useState<VoiceOption[]>([]);
|
|
||||||
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 () => {
|
const fetchEpisodes = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/episodes");
|
const [epRes, workerRes] = await Promise.all([
|
||||||
const data = await res.json();
|
fetch("/api/episodes"),
|
||||||
|
fetch("/api/worker/status").catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const data = await epRes.json();
|
||||||
if (data.success && data.episodes) {
|
if (data.success && data.episodes) {
|
||||||
setEpisodes(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) {
|
} catch (err) {
|
||||||
console.error("Failed to load episodes:", err);
|
console.error("Failed to load episodes:", err);
|
||||||
}
|
} finally {
|
||||||
};
|
setIsLoading(false);
|
||||||
|
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEpisodes();
|
fetchEpisodes();
|
||||||
fetchVoices();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectEpisode = (ep: Episode) => {
|
// Real-time polling when any episode is QUEUED or PROCESSING, or worker is active
|
||||||
setActiveEpisode(ep);
|
useEffect(() => {
|
||||||
if (ep.timingsJson) {
|
const hasPending = episodes.some(
|
||||||
try {
|
(ep) => ep.status === "QUEUED" || ep.status === "PROCESSING"
|
||||||
setActiveTimings(JSON.parse(ep.timingsJson));
|
);
|
||||||
} catch {
|
const workerActive = workerStatus && (!workerStatus.isIdle || workerStatus.isBackingOff);
|
||||||
setActiveTimings([]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setActiveTimings([]);
|
|
||||||
}
|
|
||||||
setCurrentTime(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateEpisode = async (e: React.FormEvent) => {
|
if (!hasPending && !workerActive) return;
|
||||||
e.preventDefault();
|
|
||||||
if (!title.trim() || !content.trim()) {
|
|
||||||
setFormError("Please provide both a title and article content.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsSubmitting(true);
|
const interval = setInterval(() => {
|
||||||
setFormError("");
|
fetchEpisodes();
|
||||||
|
}, 2500);
|
||||||
|
|
||||||
try {
|
return () => clearInterval(interval);
|
||||||
const res = await fetch("/api/episodes", {
|
}, [episodes, workerStatus]);
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDeleteEpisode = async (id: string, e: React.MouseEvent) => {
|
const handleDeleteEpisode = async (id: string, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -152,16 +80,22 @@ export default function Home() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/episodes/${id}`, { method: "DELETE" });
|
await fetch(`/api/episodes/${id}`, { method: "DELETE" });
|
||||||
if (activeEpisode?.id === id) {
|
|
||||||
setActiveEpisode(null);
|
|
||||||
setActiveTimings([]);
|
|
||||||
}
|
|
||||||
await fetchEpisodes();
|
await fetchEpisodes();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to delete episode:", 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) => {
|
const formatDuration = (secs?: number | null) => {
|
||||||
if (!secs) return "0:00";
|
if (!secs) return "0:00";
|
||||||
const m = Math.floor(secs / 60);
|
const m = Math.floor(secs / 60);
|
||||||
@@ -169,300 +103,286 @@ export default function Home() {
|
|||||||
return `${m}:${s.toString().padStart(2, "0")}`;
|
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 (
|
return (
|
||||||
<div className="flex-1 flex flex-col pb-32">
|
ep.title.toLowerCase().includes(q) ||
|
||||||
|
ep.originalContent.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col pb-36">
|
||||||
{/* Top Navbar */}
|
{/* Top Navbar */}
|
||||||
<header className="border-b border-slate-800 bg-slate-900/60 backdrop-blur-md sticky top-0 z-40">
|
<header className="border-b border-slate-200 dark:border-slate-900 bg-white/80 dark:bg-slate-950/80 backdrop-blur-md sticky top-0 z-30 transition-colors">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-blue-600 to-indigo-500 flex items-center justify-center text-white shadow-md shadow-blue-500/20">
|
<div className="w-9 h-9 rounded-xl bg-gradient-to-tr from-blue-600 to-indigo-500 flex items-center justify-center text-white shadow-md shadow-blue-500/20">
|
||||||
<Radio className="w-5 h-5" />
|
<Radio className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span className="font-bold text-lg text-slate-100 tracking-tight">
|
<span className="font-bold text-base sm:text-lg text-slate-900 dark:text-slate-100 tracking-tight">
|
||||||
Be My Anchor
|
Be My Anchor
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 font-mono">
|
<span className="ml-2 text-[10px] px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20 font-mono">
|
||||||
v2.0 M1
|
v2.0
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="flex items-center gap-2.5">
|
||||||
onClick={() => setShowCreateForm(!showCreateForm)}
|
<ThemeToggle />
|
||||||
className="flex items-center gap-2 px-3.5 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium transition shadow-sm shadow-blue-600/20"
|
<Link
|
||||||
|
href="/new"
|
||||||
|
className="flex items-center gap-1.5 px-3.5 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 active:scale-95 text-white text-xs sm:text-sm font-medium transition shadow-md shadow-blue-600/25"
|
||||||
>
|
>
|
||||||
<PlusCircle className="w-4 h-4" />
|
<Plus className="w-4 h-4" />
|
||||||
<span>New Episode</span>
|
<span>Add Article</span>
|
||||||
</button>
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Container */}
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6 w-full flex-1">
|
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-6 w-full flex-1 space-y-6">
|
||||||
{/* Ingest Form (Collapsible) */}
|
{/* Search Bar Header */}
|
||||||
{showCreateForm && (
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="mb-8 p-6 bg-slate-900 border border-slate-800 rounded-2xl shadow-xl animate-in fade-in slide-in-from-top-4 duration-200">
|
<div className="relative flex-1 max-w-md">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<Search className="w-4 h-4 text-slate-400 dark:text-slate-500 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Sparkles className="w-5 h-5 text-blue-400" />
|
|
||||||
<h2 className="text-lg font-semibold text-slate-100">
|
|
||||||
Convert Content to Podcast Episode
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowCreateForm(false)}
|
|
||||||
className="text-xs text-slate-400 hover:text-slate-200"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{formError && (
|
|
||||||
<div className="mb-4 p-3 bg-red-950/40 border border-red-800 text-red-300 text-sm rounded-lg">
|
|
||||||
{formError}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleCreateEpisode} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-slate-400 mb-1">
|
|
||||||
Article or Episode Title
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={title}
|
value={searchQuery}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="e.g., Understanding Kubernetes Ingress and Service Meshes"
|
placeholder="Search your articles..."
|
||||||
className="w-full px-3.5 py-2.5 bg-slate-800/80 border border-slate-700 rounded-lg text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
className="w-full pl-9 pr-4 py-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl text-xs sm:text-sm text-slate-900 dark:text-slate-100 placeholder-slate-400 dark:placeholder-slate-500 focus:outline-none focus:border-blue-500 transition shadow-sm"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="text-xs text-slate-500 font-mono shrink-0">
|
||||||
<div>
|
{episodes.length} {episodes.length === 1 ? "article" : "articles"}
|
||||||
<label className="block text-xs font-medium text-slate-400 mb-1">
|
|
||||||
TTS Voice Engine (Edge Neural)
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={voice}
|
|
||||||
onChange={(e) => setVoice(e.target.value)}
|
|
||||||
className="w-full px-3.5 py-2.5 bg-slate-800/80 border border-slate-700 rounded-lg text-sm text-slate-100 focus:outline-none focus:border-blue-500"
|
|
||||||
>
|
|
||||||
<optgroup label="🇨🇳 中文 / Chinese (支持中英双语混合)">
|
|
||||||
{voices
|
|
||||||
.filter((v) => v.locale.startsWith("zh"))
|
|
||||||
.map((v) => (
|
|
||||||
<option key={v.id} value={v.id}>
|
|
||||||
{v.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="🇺🇸 English (US & UK)">
|
|
||||||
{voices
|
|
||||||
.filter((v) => !v.locale.startsWith("zh"))
|
|
||||||
.map((v) => (
|
|
||||||
<option key={v.id} value={v.id}>
|
|
||||||
{v.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-slate-400 mb-1">
|
|
||||||
Narration Mode
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
disabled
|
|
||||||
className="w-full px-3.5 py-2.5 bg-slate-850 border border-slate-700/50 rounded-lg text-sm text-slate-400 cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<option value="VERBATIM">Verbatim Reader (M1 Active)</option>
|
|
||||||
<option value="FEYNMAN" disabled>Feynman Tutor (M2 Preview)</option>
|
|
||||||
<option value="DUAL_HOST" disabled>Dual-Host Dialogue (M2 Preview)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Worker Cooldown / Rate-limit Alert Banner */}
|
||||||
|
{workerStatus?.isBackingOff && (
|
||||||
|
<div className="mb-4 p-3.5 bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-800/60 rounded-xl flex items-center gap-3 text-xs text-amber-800 dark:text-amber-300 shadow-sm animate-pulse">
|
||||||
|
<Hourglass className="w-4 h-4 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-slate-400 mb-1">
|
<span className="font-semibold">Worker Cooldown Active:</span> AI provider is experiencing high demand or rate limits. The queue is automatically paused and will resume shortly.
|
||||||
Article Text or Markdown
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={content}
|
|
||||||
onChange={(e) => handleContentChange(e.target.value)}
|
|
||||||
placeholder="Paste your technical article, newsletter, blog post, or notes here..."
|
|
||||||
rows={6}
|
|
||||||
className="w-full px-3.5 py-2.5 bg-slate-800/80 border border-slate-700 rounded-lg text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-blue-500 font-mono text-xs leading-relaxed"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowCreateForm(false)}
|
|
||||||
className="px-4 py-2 text-sm text-slate-400 hover:text-slate-200"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={isSubmitting}
|
|
||||||
className="flex items-center gap-2 px-5 py-2 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-800 text-white text-sm font-medium rounded-lg shadow-md shadow-blue-600/30 transition"
|
|
||||||
>
|
|
||||||
{isSubmitting ? (
|
|
||||||
<>
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
<span>Synthesizing Audio...</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Headphones className="w-4 h-4" />
|
|
||||||
<span>Synthesize Episode</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dashboard Columns */}
|
{/* Episode List */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
{isLoading ? (
|
||||||
{/* Left: Episode Library (5 cols) */}
|
<div className="flex flex-col items-center justify-center py-24 text-slate-500">
|
||||||
<section className="lg:col-span-5 flex flex-col gap-4">
|
<RefreshCw className="w-8 h-8 animate-spin mb-3 text-blue-500" />
|
||||||
<div className="flex items-center justify-between">
|
<p className="text-sm">Loading articles...</p>
|
||||||
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2">
|
|
||||||
<FileText className="w-4 h-4" />
|
|
||||||
<span>Episodes ({episodes.length})</span>
|
|
||||||
</h2>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : filteredEpisodes.length === 0 ? (
|
||||||
{episodes.length === 0 ? (
|
<div className="p-12 text-center bg-slate-50 dark:bg-slate-900/40 border border-dashed border-slate-200 dark:border-slate-800 rounded-2xl shadow-sm">
|
||||||
<div className="p-8 text-center bg-slate-900/50 border border-dashed border-slate-800 rounded-2xl">
|
<Headphones className="w-12 h-12 text-slate-400 dark:text-slate-600 mx-auto mb-3" />
|
||||||
<Headphones className="w-10 h-10 text-slate-600 mx-auto mb-3" />
|
<h3 className="text-slate-800 dark:text-slate-200 font-medium text-base mb-1">
|
||||||
<h3 className="text-slate-200 font-medium text-sm mb-1">No episodes generated yet</h3>
|
{searchQuery ? "No matching articles" : "No articles yet"}
|
||||||
<p className="text-xs text-slate-500 mb-4">
|
</h3>
|
||||||
Paste a blog post or article to generate your first podcast episode!
|
<p className="text-xs sm:text-sm text-slate-500 max-w-sm mx-auto mb-5">
|
||||||
|
{searchQuery
|
||||||
|
? "Try a different search keyword."
|
||||||
|
: "Add your first article or technical paper to listen to it anytime."}
|
||||||
</p>
|
</p>
|
||||||
<button
|
{!searchQuery && (
|
||||||
onClick={() => setShowCreateForm(true)}
|
<Link
|
||||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-500"
|
href="/new"
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2 text-xs sm:text-sm bg-blue-600 text-white rounded-xl hover:bg-blue-500 transition shadow-md shadow-blue-600/25"
|
||||||
>
|
>
|
||||||
<PlusCircle className="w-3.5 h-3.5" />
|
<Plus className="w-4 h-4" />
|
||||||
<span>Generate Episode</span>
|
<span>Add Your First Article</span>
|
||||||
</button>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2.5">
|
<div className="space-y-3">
|
||||||
{episodes.map((ep) => {
|
{filteredEpisodes.map((ep) => {
|
||||||
const isSelected = activeEpisode?.id === ep.id;
|
const isSelected = activeEpisode?.id === ep.id;
|
||||||
|
const isEpisodePlaying = isSelected && isPlaying;
|
||||||
|
const isReady = ep.status === "READY" && !!ep.audioUrl;
|
||||||
|
const isProcessing = ep.status === "PROCESSING";
|
||||||
|
const isQueued = ep.status === "QUEUED";
|
||||||
|
const isFailed = ep.status === "FAILED";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={ep.id}
|
key={ep.id}
|
||||||
onClick={() => selectEpisode(ep)}
|
onClick={() => {
|
||||||
className={`p-4 rounded-xl cursor-pointer transition-all border ${
|
if (isReady) {
|
||||||
|
if (isSelected) {
|
||||||
|
togglePlay();
|
||||||
|
} else {
|
||||||
|
playEpisode(ep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={`p-4 sm:p-5 rounded-2xl transition-all border ${
|
||||||
|
isReady ? "cursor-pointer" : "cursor-default"
|
||||||
|
} ${
|
||||||
isSelected
|
isSelected
|
||||||
? "bg-slate-850 border-blue-500/60 shadow-md shadow-blue-500/5"
|
? "bg-blue-50/70 dark:bg-slate-900 border-blue-400 dark:border-blue-500/50 shadow-md shadow-blue-500/5"
|
||||||
: "bg-slate-900/70 border-slate-800 hover:border-slate-700 hover:bg-slate-850/60"
|
: "bg-white dark:bg-slate-900/60 border-slate-200 dark:border-slate-850 hover:border-slate-300 dark:hover:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-900 shadow-sm"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
{/* Status / Play icon button */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (isReady) {
|
||||||
|
if (isSelected) {
|
||||||
|
togglePlay();
|
||||||
|
} else {
|
||||||
|
playEpisode(ep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!isReady}
|
||||||
|
className={`w-11 h-11 rounded-xl flex items-center justify-center shrink-0 transition-transform active:scale-95 ${
|
||||||
|
isSelected
|
||||||
|
? "bg-blue-600 text-white shadow-md shadow-blue-600/30"
|
||||||
|
: isReady
|
||||||
|
? "bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white"
|
||||||
|
: isProcessing
|
||||||
|
? "bg-blue-50 dark:bg-blue-950/50 text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-800/60"
|
||||||
|
: isQueued
|
||||||
|
? "bg-amber-50 dark:bg-amber-950/50 text-amber-600 dark:text-amber-400 border border-amber-200 dark:border-amber-800/60"
|
||||||
|
: "bg-rose-50 dark:bg-rose-950/50 text-rose-600 dark:text-rose-400 border border-rose-200 dark:border-rose-800/60"
|
||||||
|
}`}
|
||||||
|
aria-label={isEpisodePlaying ? "Pause" : "Play"}
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<Loader2 className="w-5 h-5 animate-spin" />
|
||||||
|
) : isQueued ? (
|
||||||
|
<Clock className="w-5 h-5 animate-pulse" />
|
||||||
|
) : isFailed ? (
|
||||||
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
) : isEpisodePlaying ? (
|
||||||
|
<Pause className="w-5 h-5 fill-current" />
|
||||||
|
) : (
|
||||||
|
<Play className="w-5 h-5 fill-current translate-x-0.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Metadata & Title */}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<h3 className={`text-sm font-medium truncate ${isSelected ? "text-blue-200" : "text-slate-200"}`}>
|
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||||
{ep.title}
|
{/* Mode Badge */}
|
||||||
</h3>
|
<span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-300 border border-slate-200 dark:border-slate-700">
|
||||||
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-500">
|
{ep.mode === "DUAL_HOST"
|
||||||
<span className="flex items-center gap-1 font-mono">
|
? "🎙️ 双人播客"
|
||||||
<Clock className="w-3 h-3" />
|
: ep.mode === "FEYNMAN"
|
||||||
|
? "💡 费曼精讲"
|
||||||
|
: "📖 逐句朗读"}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Language Badge */}
|
||||||
|
{ep.targetLanguage && ep.targetLanguage !== "auto" && (
|
||||||
|
<span className="px-2 py-0.5 rounded text-[10px] font-semibold bg-indigo-50 dark:bg-indigo-950/60 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800">
|
||||||
|
{ep.targetLanguage === "zh" ? "🇨🇳 中文输出" : "🇺🇸 English"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Status Chip */}
|
||||||
|
{isProcessing ? (
|
||||||
|
<span className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium bg-blue-100 dark:bg-blue-950/80 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800">
|
||||||
|
<Loader2 className="w-2.5 h-2.5 animate-spin" />
|
||||||
|
{ep.retryCount && ep.retryCount > 0
|
||||||
|
? `Retrying (${ep.retryCount}/${ep.maxRetries || 3})...`
|
||||||
|
: "Synthesizing audio..."}
|
||||||
|
</span>
|
||||||
|
) : isQueued ? (
|
||||||
|
<span className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium bg-amber-100 dark:bg-amber-950/80 text-amber-700 dark:text-amber-300 border border-amber-200 dark:border-amber-800">
|
||||||
|
<Clock className="w-2.5 h-2.5 animate-pulse" />
|
||||||
|
In Queue
|
||||||
|
</span>
|
||||||
|
) : isFailed ? (
|
||||||
|
<span className="flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium bg-rose-100 dark:bg-rose-950/80 text-rose-700 dark:text-rose-300 border border-rose-200 dark:border-rose-800">
|
||||||
|
<AlertCircle className="w-2.5 h-2.5" />
|
||||||
|
Failed
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] font-mono text-slate-500 dark:text-slate-400 flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3 text-blue-500 dark:text-blue-400" />
|
||||||
{formatDuration(ep.durationSeconds)}
|
{formatDuration(ep.durationSeconds)}
|
||||||
</span>
|
</span>
|
||||||
<span>•</span>
|
)}
|
||||||
<span className="capitalize">{ep.mode.toLowerCase()}</span>
|
|
||||||
<span>•</span>
|
<span className="text-[11px] text-slate-400 dark:text-slate-600">•</span>
|
||||||
<span
|
<span className="text-[11px] text-slate-500">
|
||||||
className={`px-1.5 py-0.2 rounded text-[10px] font-medium uppercase ${
|
{new Date(ep.createdAt).toLocaleDateString()}
|
||||||
ep.status === "READY"
|
|
||||||
? "bg-emerald-500/10 text-emerald-400"
|
|
||||||
: ep.status === "PROCESSING"
|
|
||||||
? "bg-amber-500/10 text-amber-400"
|
|
||||||
: "bg-red-500/10 text-red-400"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{ep.status}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3
|
||||||
|
className={`text-sm sm:text-base font-medium truncate ${
|
||||||
|
isSelected
|
||||||
|
? "text-blue-600 dark:text-blue-300"
|
||||||
|
: "text-slate-900 dark:text-slate-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ep.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Error snippet if failed */}
|
||||||
|
{isFailed && ep.errorMessage && (
|
||||||
|
<p className="text-[11px] text-rose-500 dark:text-rose-400 mt-1 line-clamp-1">
|
||||||
|
{ep.errorMessage}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right side actions */}
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
{isFailed && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleRetryEpisode(ep.id, e)}
|
||||||
|
className="px-2.5 py-1.5 text-xs bg-rose-100 hover:bg-rose-200 dark:bg-rose-950/80 dark:hover:bg-rose-900 text-rose-700 dark:text-rose-300 rounded-lg flex items-center gap-1.5 transition font-medium border border-rose-200 dark:border-rose-800"
|
||||||
|
title="Retry generation"
|
||||||
|
>
|
||||||
|
<RotateCw className="w-3.5 h-3.5" />
|
||||||
|
<span>Retry</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isReady && isSelected && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openZen();
|
||||||
|
}}
|
||||||
|
className="p-2 text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-600/10 rounded-lg transition"
|
||||||
|
title="Open Zen Player"
|
||||||
|
aria-label="Open Zen Player"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleDeleteEpisode(ep.id, e)}
|
onClick={(e) => handleDeleteEpisode(ep.id, e)}
|
||||||
className="p-1.5 text-slate-500 hover:text-red-400 hover:bg-red-950/30 rounded-lg transition"
|
className="p-2 text-slate-400 hover:text-rose-600 dark:hover:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/30 rounded-lg transition"
|
||||||
title="Delete episode"
|
title="Delete article"
|
||||||
|
aria-label="Delete article"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Right: Active Episode & Transcript (7 cols) */}
|
|
||||||
<section className="lg:col-span-7 bg-slate-900/80 border border-slate-800 rounded-2xl p-6 min-h-[480px] flex flex-col">
|
|
||||||
{activeEpisode ? (
|
|
||||||
<div className="flex flex-col flex-1">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="border-b border-slate-800 pb-4 mb-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs font-mono uppercase tracking-wider text-blue-400 bg-blue-950/60 px-2.5 py-1 rounded border border-blue-900">
|
|
||||||
Now Playing
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-slate-500">
|
|
||||||
Created {new Date(activeEpisode.createdAt).toLocaleDateString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-xl font-bold text-slate-100 mt-2">
|
|
||||||
{activeEpisode.title}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Synchronized Transcript */}
|
|
||||||
<div className="flex-1 flex flex-col">
|
|
||||||
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3 flex items-center gap-1.5">
|
|
||||||
<Volume2 className="w-3.5 h-3.5 text-blue-400" />
|
|
||||||
<span>Synchronized Transcript</span>
|
|
||||||
</h3>
|
|
||||||
<TranscriptViewer
|
|
||||||
timings={activeTimings}
|
|
||||||
currentTime={currentTime}
|
|
||||||
onSeek={(t) => setSeekTime(t)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex-1 flex flex-col items-center justify-center text-center text-slate-500 py-16">
|
|
||||||
<Radio className="w-12 h-12 mb-3 opacity-30 text-blue-400" />
|
|
||||||
<p className="text-sm">Select an episode from the library to listen and follow along.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{/* Persistent Bottom Audio Player */}
|
|
||||||
{activeEpisode && activeEpisode.audioUrl && (
|
|
||||||
<AudioPlayer
|
|
||||||
title={activeEpisode.title}
|
|
||||||
audioUrl={activeEpisode.audioUrl}
|
|
||||||
durationSeconds={activeEpisode.durationSeconds}
|
|
||||||
onTimeUpdate={(t) => setCurrentTime(t)}
|
|
||||||
seekTime={seekTime}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+99
-225
@@ -1,252 +1,126 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from "react";
|
import React from "react";
|
||||||
import {
|
import { useAudio } from "@/contexts/AudioContext";
|
||||||
Play,
|
import { Maximize2, Play, Pause } from "lucide-react";
|
||||||
Pause,
|
|
||||||
RotateCcw,
|
|
||||||
RotateCw,
|
|
||||||
Volume2,
|
|
||||||
VolumeX,
|
|
||||||
Gauge,
|
|
||||||
Radio,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
interface AudioPlayerProps {
|
export function AudioPlayer() {
|
||||||
title: string;
|
const {
|
||||||
audioUrl: string;
|
activeEpisode,
|
||||||
durationSeconds?: number | null;
|
isPlaying,
|
||||||
onTimeUpdate?: (currentTime: number) => void;
|
currentTime,
|
||||||
onSeek?: (seekTo: number) => void;
|
duration,
|
||||||
seekTime?: number | null;
|
togglePlay,
|
||||||
}
|
seekTo,
|
||||||
|
openZen,
|
||||||
|
} = useAudio();
|
||||||
|
|
||||||
export function AudioPlayer({
|
if (!activeEpisode) return null;
|
||||||
title,
|
|
||||||
audioUrl,
|
|
||||||
durationSeconds,
|
|
||||||
onTimeUpdate,
|
|
||||||
seekTime,
|
|
||||||
}: AudioPlayerProps) {
|
|
||||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
|
||||||
const [currentTime, setCurrentTime] = useState(0);
|
|
||||||
const [duration, setDuration] = useState(durationSeconds || 0);
|
|
||||||
const [playbackRate, setPlaybackRate] = useState(1);
|
|
||||||
const [volume, setVolume] = useState(1);
|
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
|
||||||
|
|
||||||
// Handle external seek requests (e.g. clicking transcript)
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
seekTime !== undefined &&
|
|
||||||
seekTime !== null &&
|
|
||||||
audioRef.current &&
|
|
||||||
Math.abs(audioRef.current.currentTime - seekTime) > 0.5
|
|
||||||
) {
|
|
||||||
audioRef.current.currentTime = seekTime;
|
|
||||||
setCurrentTime(seekTime);
|
|
||||||
if (!isPlaying) {
|
|
||||||
audioRef.current.play().catch(console.error);
|
|
||||||
setIsPlaying(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [seekTime]);
|
|
||||||
|
|
||||||
// Handle source changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
setIsPlaying(false);
|
|
||||||
setCurrentTime(0);
|
|
||||||
audioRef.current.load();
|
|
||||||
}
|
|
||||||
}, [audioUrl]);
|
|
||||||
|
|
||||||
const togglePlay = () => {
|
|
||||||
if (!audioRef.current) return;
|
|
||||||
if (isPlaying) {
|
|
||||||
audioRef.current.pause();
|
|
||||||
setIsPlaying(false);
|
|
||||||
} else {
|
|
||||||
audioRef.current.play().then(() => setIsPlaying(true)).catch(console.error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const jump = (seconds: number) => {
|
|
||||||
if (!audioRef.current) return;
|
|
||||||
const newTime = Math.max(0, Math.min(audioRef.current.currentTime + seconds, duration || 999999));
|
|
||||||
audioRef.current.currentTime = newTime;
|
|
||||||
setCurrentTime(newTime);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTimeUpdate = () => {
|
|
||||||
if (!audioRef.current) return;
|
|
||||||
const time = audioRef.current.currentTime;
|
|
||||||
setCurrentTime(time);
|
|
||||||
onTimeUpdate?.(time);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLoadedMetadata = () => {
|
|
||||||
if (!audioRef.current) return;
|
|
||||||
if (audioRef.current.duration && isFinite(audioRef.current.duration)) {
|
|
||||||
setDuration(audioRef.current.duration);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScrub = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const time = parseFloat(e.target.value);
|
|
||||||
setCurrentTime(time);
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.currentTime = time;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const togglePlaybackRate = () => {
|
|
||||||
const rates = [0.8, 1.0, 1.25, 1.5, 2.0];
|
|
||||||
const nextIdx = (rates.indexOf(playbackRate) + 1) % rates.length;
|
|
||||||
const newRate = rates[nextIdx];
|
|
||||||
setPlaybackRate(newRate);
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.playbackRate = newRate;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleMute = () => {
|
|
||||||
if (!audioRef.current) return;
|
|
||||||
const newMuted = !isMuted;
|
|
||||||
setIsMuted(newMuted);
|
|
||||||
audioRef.current.muted = newMuted;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const val = parseFloat(e.target.value);
|
|
||||||
setVolume(val);
|
|
||||||
if (audioRef.current) {
|
|
||||||
audioRef.current.volume = val;
|
|
||||||
if (val === 0) {
|
|
||||||
setIsMuted(true);
|
|
||||||
} else if (isMuted) {
|
|
||||||
setIsMuted(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = (secs: number) => {
|
const formatTime = (secs: number) => {
|
||||||
if (isNaN(secs) || !isFinite(secs)) return "00:00";
|
if (!secs || isNaN(secs)) return "0:00";
|
||||||
const minutes = Math.floor(secs / 60);
|
const m = Math.floor(secs / 60);
|
||||||
const seconds = Math.floor(secs % 60);
|
const s = Math.floor(secs % 60);
|
||||||
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 z-50 bg-slate-900/95 backdrop-blur-md border-t border-slate-800 shadow-2xl px-4 py-3 sm:px-6">
|
<div className="fixed bottom-0 left-0 right-0 z-40 bg-white/95 dark:bg-slate-950/95 backdrop-blur-2xl border-t border-slate-200 dark:border-slate-900 text-slate-900 dark:text-slate-100 shadow-2xl safe-bottom transition-colors">
|
||||||
<audio
|
{/* Interactive Top Mini Scrubber */}
|
||||||
ref={audioRef}
|
<div
|
||||||
src={audioUrl}
|
className="w-full h-1 bg-slate-200 dark:bg-slate-900 hover:h-2 cursor-pointer transition-all group relative"
|
||||||
onTimeUpdate={handleTimeUpdate}
|
onClick={(e) => {
|
||||||
onLoadedMetadata={handleLoadedMetadata}
|
const rect = e.currentTarget.getBoundingClientRect();
|
||||||
onEnded={() => setIsPlaying(false)}
|
const clickX = e.clientX - rect.left;
|
||||||
preload="metadata"
|
const ratio = Math.max(0, Math.min(clickX / rect.width, 1));
|
||||||
/>
|
seekTo(ratio * duration);
|
||||||
|
}}
|
||||||
<div className="max-w-6xl mx-auto flex flex-col gap-2">
|
|
||||||
{/* Scrubber and Timestamps */}
|
|
||||||
<div className="flex items-center gap-3 w-full">
|
|
||||||
<span className="text-xs font-mono text-slate-400 w-12 text-right">
|
|
||||||
{formatTime(currentTime)}
|
|
||||||
</span>
|
|
||||||
<div className="relative flex-1 group">
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max={duration || 100}
|
|
||||||
step="0.1"
|
|
||||||
value={currentTime}
|
|
||||||
onChange={handleScrub}
|
|
||||||
className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500 hover:h-2 transition-all"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs font-mono text-slate-400 w-12">
|
|
||||||
{formatTime(duration)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Controls Bar */}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
{/* Track Info */}
|
|
||||||
<div className="flex items-center gap-3 w-1/3 min-w-0">
|
|
||||||
<div className="w-10 h-10 rounded-lg bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shrink-0">
|
|
||||||
<Radio className="w-5 h-5 animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="text-sm font-medium text-slate-200 truncate">{title}</p>
|
|
||||||
<p className="text-xs text-slate-400 truncate">Be My Anchor • Episode</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Playback Buttons */}
|
|
||||||
<div className="flex items-center gap-3 sm:gap-4 justify-center">
|
|
||||||
<button
|
|
||||||
onClick={() => jump(-15)}
|
|
||||||
className="p-2 text-slate-400 hover:text-slate-200 transition hover:bg-slate-800 rounded-full"
|
|
||||||
title="Jump backward 15s"
|
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-4 h-4" />
|
<div
|
||||||
</button>
|
className="h-full bg-blue-500 group-hover:bg-blue-400 transition-all relative"
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
>
|
||||||
|
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-2.5 h-2.5 bg-white rounded-full shadow opacity-0 group-hover:opacity-100 transition-opacity" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-5xl mx-auto px-4 sm:px-6 h-18 py-2.5 flex items-center justify-between gap-4">
|
||||||
|
{/* Left: Spinning Vinyl Album (Tap to toggle play/pause) + Track Info */}
|
||||||
|
<div className="flex items-center gap-3.5 min-w-0 flex-1">
|
||||||
|
{/* Vinyl Record */}
|
||||||
<button
|
<button
|
||||||
onClick={togglePlay}
|
onClick={togglePlay}
|
||||||
className="p-3 bg-blue-600 hover:bg-blue-500 text-white rounded-full shadow-lg shadow-blue-600/30 transition-transform active:scale-95"
|
className="group relative w-12 h-12 rounded-full shrink-0 flex items-center justify-center select-none shadow-md shadow-black/30 active:scale-95 transition-transform"
|
||||||
title={isPlaying ? "Pause" : "Play"}
|
title={isPlaying ? "Click to Pause" : "Click to Play"}
|
||||||
|
aria-label={isPlaying ? "Pause audio" : "Play audio"}
|
||||||
>
|
>
|
||||||
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5 ml-0.5" />}
|
{/* Outer Vinyl Body with spinning animation */}
|
||||||
</button>
|
<div
|
||||||
|
className={`w-full h-full rounded-full bg-gradient-to-tr from-slate-900 via-slate-950 to-slate-900 border border-slate-700/80 p-1 flex items-center justify-center shadow-inner ${
|
||||||
<button
|
isPlaying ? "animate-[spin_5s_linear_infinite]" : ""
|
||||||
onClick={() => jump(15)}
|
}`}
|
||||||
className="p-2 text-slate-400 hover:text-slate-200 transition hover:bg-slate-800 rounded-full"
|
|
||||||
title="Jump forward 15s"
|
|
||||||
>
|
>
|
||||||
<RotateCw className="w-4 h-4" />
|
{/* Vinyl grooves styling */}
|
||||||
</button>
|
<div className="w-full h-full rounded-full border border-slate-800/80 flex items-center justify-center">
|
||||||
|
<div className="w-3/4 h-3/4 rounded-full border border-slate-800/60 flex items-center justify-center">
|
||||||
|
{/* Center Album Art / Label */}
|
||||||
|
<div className="w-5 h-5 rounded-full bg-gradient-to-tr from-blue-500 via-indigo-500 to-purple-600 flex items-center justify-center border border-slate-900 shadow-sm">
|
||||||
|
{/* Center spindle hole */}
|
||||||
|
<div className="w-1.5 h-1.5 rounded-full bg-slate-950" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Aux Controls: Speed & Volume */}
|
{/* Hover / Status Overlay Icon */}
|
||||||
<div className="flex items-center gap-3 w-1/3 justify-end">
|
<div
|
||||||
<button
|
className={`absolute inset-0 rounded-full flex items-center justify-center bg-black/40 backdrop-blur-[1px] transition-opacity ${
|
||||||
onClick={togglePlaybackRate}
|
isPlaying ? "opacity-0 group-hover:opacity-100" : "opacity-90"
|
||||||
className="flex items-center gap-1 text-xs font-medium px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded border border-slate-700 transition"
|
}`}
|
||||||
title="Adjust Speed"
|
|
||||||
>
|
>
|
||||||
<Gauge className="w-3.5 h-3.5 text-blue-400" />
|
{isPlaying ? (
|
||||||
<span>{playbackRate}x</span>
|
<Pause className="w-4 h-4 text-white fill-current drop-shadow" />
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="hidden sm:flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={toggleMute}
|
|
||||||
className="text-slate-400 hover:text-slate-200 transition"
|
|
||||||
title={isMuted ? "Unmute" : "Mute"}
|
|
||||||
>
|
|
||||||
{isMuted || volume === 0 ? (
|
|
||||||
<VolumeX className="w-4 h-4 text-red-400" />
|
|
||||||
) : (
|
) : (
|
||||||
<Volume2 className="w-4 h-4" />
|
<Play className="w-4 h-4 text-white fill-current translate-x-0.5 drop-shadow" />
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<input
|
|
||||||
type="range"
|
{/* Track Info (Click to open Zen Player) */}
|
||||||
min="0"
|
<div
|
||||||
max="1"
|
onClick={openZen}
|
||||||
step="0.05"
|
className="min-w-0 flex-1 cursor-pointer group py-1"
|
||||||
value={isMuted ? 0 : volume}
|
title="Click to open Full Screen Zen Mode"
|
||||||
onChange={handleVolumeChange}
|
>
|
||||||
className="w-16 h-1 bg-slate-700 rounded appearance-none cursor-pointer accent-blue-500"
|
<h4 className="text-sm font-medium truncate text-slate-800 dark:text-slate-200 group-hover:text-blue-600 dark:group-hover:text-blue-300 transition-colors">
|
||||||
/>
|
{activeEpisode.title}
|
||||||
|
</h4>
|
||||||
|
<div className="text-xs font-mono text-slate-500 flex items-center gap-2 mt-0.5">
|
||||||
|
<span className={isPlaying ? "text-blue-500 dark:text-blue-400 font-medium" : "text-slate-400 dark:text-slate-500"}>
|
||||||
|
{isPlaying ? "Playing" : "Paused"}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-300 dark:text-slate-600">•</span>
|
||||||
|
<span className="text-slate-500 dark:text-slate-400">
|
||||||
|
{formatTime(currentTime)} / {formatTime(duration)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Full Screen Zen Button */}
|
||||||
|
<div className="flex items-center shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={openZen}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 sm:px-3.5 sm:py-2 rounded-xl bg-slate-100 dark:bg-slate-900 hover:bg-blue-50 dark:hover:bg-blue-600/15 border border-slate-200 dark:border-slate-800 text-slate-700 dark:text-slate-300 hover:text-blue-600 dark:hover:text-blue-300 text-xs sm:text-sm font-medium transition active:scale-95 shadow-sm"
|
||||||
|
title="Open Full Screen Zen Mode"
|
||||||
|
aria-label="Open Full Screen Zen Mode"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-blue-500 dark:text-blue-400" />
|
||||||
|
<span className="hidden sm:inline">Zen Mode</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Moon, Sun } from "lucide-react";
|
||||||
|
import { useTheme } from "@/contexts/ThemeContext";
|
||||||
|
|
||||||
|
export function ThemeToggle({ className = "" }: { className?: string }) {
|
||||||
|
const { theme, toggleTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className={`w-9 h-9 rounded-xl border border-transparent ${className}`} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleTheme}
|
||||||
|
className={`p-2 rounded-xl border transition-all duration-200 flex items-center justify-center text-slate-500 hover:text-slate-900 dark:text-slate-400 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-850 border-slate-200 dark:border-slate-800 ${className}`}
|
||||||
|
aria-label={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
||||||
|
title={`Switch to ${theme === "dark" ? "light" : "dark"} mode`}
|
||||||
|
>
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<Sun className="w-4 h-4 text-amber-400 animate-in fade-in duration-200" />
|
||||||
|
) : (
|
||||||
|
<Moon className="w-4 h-4 text-slate-700 animate-in fade-in duration-200" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { useAudio } from "@/contexts/AudioContext";
|
||||||
|
import {
|
||||||
|
ChevronDown,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
RotateCcw,
|
||||||
|
RotateCw,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
FileText,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||||
|
|
||||||
|
export function ZenPlayer() {
|
||||||
|
const {
|
||||||
|
activeEpisode,
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
playbackRate,
|
||||||
|
isMuted,
|
||||||
|
isZenOpen,
|
||||||
|
timings,
|
||||||
|
togglePlay,
|
||||||
|
seekTo,
|
||||||
|
jump,
|
||||||
|
setSpeed,
|
||||||
|
toggleMute,
|
||||||
|
closeZen,
|
||||||
|
} = useAudio();
|
||||||
|
|
||||||
|
const activeCueRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const touchStartY = useRef<number | null>(null);
|
||||||
|
|
||||||
|
const speakerNameMap = React.useMemo(() => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
hostA: "Host A",
|
||||||
|
hostB: "Host B",
|
||||||
|
};
|
||||||
|
if (activeEpisode?.scriptContent) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(activeEpisode.scriptContent);
|
||||||
|
if (Array.isArray(parsed.speakers)) {
|
||||||
|
for (const s of parsed.speakers) {
|
||||||
|
if (s.id && s.name) {
|
||||||
|
map[s.id] = s.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [activeEpisode?.scriptContent]);
|
||||||
|
|
||||||
|
// Auto-scroll active cue into center view
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeCueRef.current && isZenOpen) {
|
||||||
|
activeCueRef.current.scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "center",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [currentTime, isZenOpen]);
|
||||||
|
|
||||||
|
if (!isZenOpen || !activeEpisode) return null;
|
||||||
|
|
||||||
|
const formatTime = (secs: number) => {
|
||||||
|
if (!secs || isNaN(secs)) return "0:00";
|
||||||
|
const m = Math.floor(secs / 60);
|
||||||
|
const s = Math.floor(secs % 60);
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScrub = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const val = parseFloat(e.target.value);
|
||||||
|
seekTo(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cycleSpeed = () => {
|
||||||
|
const rates = [0.8, 1.0, 1.25, 1.5, 2.0];
|
||||||
|
const nextIdx = (rates.indexOf(playbackRate) + 1) % rates.length;
|
||||||
|
setSpeed(rates[nextIdx]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Swipe-down to minimize gesture on mobile
|
||||||
|
const handleTouchStart = (e: React.TouchEvent) => {
|
||||||
|
touchStartY.current = e.touches[0].clientY;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||||
|
if (touchStartY.current !== null) {
|
||||||
|
const deltaY = e.changedTouches[0].clientY - touchStartY.current;
|
||||||
|
if (deltaY > 100) {
|
||||||
|
closeZen();
|
||||||
|
}
|
||||||
|
touchStartY.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 bg-slate-50 dark:bg-slate-950 flex flex-col text-slate-900 dark:text-slate-100 select-none animate-in fade-in duration-200 transition-colors"
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
>
|
||||||
|
{/* Top Header / Drag Handle */}
|
||||||
|
<header className="px-6 py-4 flex items-center justify-between border-b border-slate-200 dark:border-slate-900 bg-white/80 dark:bg-slate-950/80 backdrop-blur-md shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={closeZen}
|
||||||
|
className="p-2 -ml-2 rounded-full hover:bg-slate-100 dark:hover:bg-slate-800/80 active:bg-slate-200 dark:active:bg-slate-700 text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 transition"
|
||||||
|
aria-label="Minimize Zen Player"
|
||||||
|
>
|
||||||
|
<ChevronDown className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1 text-center px-4">
|
||||||
|
<span className="text-[11px] uppercase tracking-widest text-blue-600 dark:text-blue-400 font-semibold font-mono">
|
||||||
|
Zen Listening Mode
|
||||||
|
</span>
|
||||||
|
<h2 className="text-sm font-semibold truncate text-slate-800 dark:text-slate-200 max-w-md mx-auto">
|
||||||
|
{activeEpisode.title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Header Right: Theme Toggle */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<ThemeToggle />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Synchronized Transcript View (No visible scrollbar) */}
|
||||||
|
<main
|
||||||
|
ref={containerRef}
|
||||||
|
className="flex-1 overflow-y-auto px-6 sm:px-12 py-8 max-w-3xl mx-auto w-full space-y-4 scroll-smooth [scrollbar-width:none] [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||||
|
>
|
||||||
|
{timings && timings.length > 0 ? (
|
||||||
|
timings.map((cue, idx) => {
|
||||||
|
const isActive = currentTime >= cue.startTime && currentTime <= cue.endTime;
|
||||||
|
const speakerName = cue.speaker ? speakerNameMap[cue.speaker] || cue.speaker : null;
|
||||||
|
const isHostB = cue.speaker === "hostB";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
ref={isActive ? activeCueRef : null}
|
||||||
|
onClick={() => seekTo(cue.startTime)}
|
||||||
|
className={`p-4 rounded-2xl cursor-pointer transition-all duration-300 ${
|
||||||
|
isActive
|
||||||
|
? "bg-blue-50 dark:bg-blue-600/15 border border-blue-400 dark:border-blue-500/30 text-slate-900 dark:text-white shadow-md shadow-blue-500/5 scale-[1.01]"
|
||||||
|
: "text-slate-500 dark:text-slate-400 hover:text-slate-800 dark:hover:text-slate-200 hover:bg-slate-100/70 dark:hover:bg-slate-900/40 border border-transparent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2.5 mb-2 text-xs font-mono text-slate-400 dark:text-slate-500">
|
||||||
|
<span className={isActive ? "text-blue-600 dark:text-blue-400 font-semibold" : ""}>
|
||||||
|
{formatTime(cue.startTime)}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{speakerName && (
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 rounded-md text-[11px] font-sans font-medium tracking-wide ${
|
||||||
|
isHostB
|
||||||
|
? "bg-purple-100 dark:bg-purple-950/80 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800/60"
|
||||||
|
: "bg-blue-100 dark:bg-blue-950/80 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{speakerName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isActive && (
|
||||||
|
<span className="inline-block w-1.5 h-1.5 rounded-full bg-blue-500 dark:bg-blue-400 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-base sm:text-lg leading-relaxed font-sans font-normal">
|
||||||
|
{cue.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-center text-slate-400 dark:text-slate-500 py-16">
|
||||||
|
<FileText className="w-12 h-12 mb-3 opacity-30 text-blue-500 dark:text-blue-400" />
|
||||||
|
<p className="text-base text-slate-700 dark:text-slate-300 font-medium mb-1">
|
||||||
|
Synchronized Transcript Not Available
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-400 dark:text-slate-500 max-w-sm">
|
||||||
|
Enjoy listening to the episode audio using the controls below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Bottom Control Bar */}
|
||||||
|
<footer className="px-6 py-6 border-t border-slate-200 dark:border-slate-900 bg-white/90 dark:bg-slate-950/90 backdrop-blur-xl shrink-0 safe-bottom">
|
||||||
|
<div className="max-w-xl mx-auto space-y-4">
|
||||||
|
{/* Scrubber Slider */}
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={duration || 100}
|
||||||
|
step={0.1}
|
||||||
|
value={currentTime}
|
||||||
|
onChange={handleScrub}
|
||||||
|
className="w-full h-1.5 bg-slate-200 dark:bg-slate-800 rounded-lg appearance-none cursor-pointer accent-blue-600 dark:accent-blue-500"
|
||||||
|
/>
|
||||||
|
<div className="flex justify-between text-xs font-mono text-slate-400 dark:text-slate-500">
|
||||||
|
<span>{formatTime(currentTime)}</span>
|
||||||
|
<span>-{formatTime(Math.max(0, duration - currentTime))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* The 5 Bottom Buttons: Speed, Back, Play/Pause, Forward, Mute */}
|
||||||
|
<div className="flex items-center justify-between sm:justify-center gap-4 sm:gap-8 pt-1">
|
||||||
|
{/* 1. Speed Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={cycleSpeed}
|
||||||
|
className="w-11 h-11 rounded-full text-xs font-mono font-semibold text-slate-700 dark:text-slate-300 hover:text-slate-900 dark:hover:text-white bg-slate-100 dark:bg-slate-900 hover:bg-slate-200 dark:hover:bg-slate-800 border border-slate-200 dark:border-slate-800 flex items-center justify-center active:scale-95 transition"
|
||||||
|
title="Playback Speed"
|
||||||
|
aria-label="Toggle playback speed"
|
||||||
|
>
|
||||||
|
{playbackRate}x
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 2. Jump Back 15s */}
|
||||||
|
<button
|
||||||
|
onClick={() => jump(-15)}
|
||||||
|
className="p-3 rounded-full text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-850 active:scale-95 transition"
|
||||||
|
title="Rewind 15 seconds"
|
||||||
|
aria-label="Rewind 15 seconds"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 3. Play / Pause (Large touch target) */}
|
||||||
|
<button
|
||||||
|
onClick={togglePlay}
|
||||||
|
className="w-16 h-16 rounded-full bg-blue-600 hover:bg-blue-500 active:scale-95 text-white flex items-center justify-center shadow-lg shadow-blue-600/30 transition"
|
||||||
|
aria-label={isPlaying ? "Pause" : "Play"}
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause className="w-7 h-7 fill-current" />
|
||||||
|
) : (
|
||||||
|
<Play className="w-7 h-7 fill-current translate-x-0.5" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 4. Jump Forward 15s */}
|
||||||
|
<button
|
||||||
|
onClick={() => jump(15)}
|
||||||
|
className="p-3 rounded-full text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-850 active:scale-95 transition"
|
||||||
|
title="Skip 15 seconds"
|
||||||
|
aria-label="Skip 15 seconds"
|
||||||
|
>
|
||||||
|
<RotateCw className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 5. Mute Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={toggleMute}
|
||||||
|
className="p-3 rounded-full text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-slate-850 active:scale-95 transition"
|
||||||
|
title={isMuted ? "Unmute" : "Mute"}
|
||||||
|
aria-label={isMuted ? "Unmute" : "Mute"}
|
||||||
|
>
|
||||||
|
{isMuted ? (
|
||||||
|
<VolumeX className="w-6 h-6 text-red-500 dark:text-red-400" />
|
||||||
|
) : (
|
||||||
|
<Volume2 className="w-6 h-6" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||||
|
import { TimingCue } from "@/lib/tts/types";
|
||||||
|
|
||||||
|
export interface Episode {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
originalContent: string;
|
||||||
|
scriptContent?: string | null;
|
||||||
|
mode: string;
|
||||||
|
targetLanguage?: string;
|
||||||
|
status: string;
|
||||||
|
retryCount?: number;
|
||||||
|
maxRetries?: number;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
nextAttemptAt?: string | null;
|
||||||
|
durationSeconds?: number | null;
|
||||||
|
audioUrl?: string | null;
|
||||||
|
timingsJson?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AudioContextType {
|
||||||
|
activeEpisode: Episode | null;
|
||||||
|
isPlaying: boolean;
|
||||||
|
currentTime: number;
|
||||||
|
duration: number;
|
||||||
|
playbackRate: number;
|
||||||
|
isMuted: boolean;
|
||||||
|
isZenOpen: boolean;
|
||||||
|
timings: TimingCue[];
|
||||||
|
playEpisode: (episode: Episode) => void;
|
||||||
|
togglePlay: () => void;
|
||||||
|
seekTo: (seconds: number) => void;
|
||||||
|
jump: (deltaSeconds: number) => void;
|
||||||
|
setSpeed: (rate: number) => void;
|
||||||
|
toggleMute: () => void;
|
||||||
|
openZen: () => void;
|
||||||
|
closeZen: () => void;
|
||||||
|
toggleZen: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AudioContext = createContext<AudioContextType | null>(null);
|
||||||
|
|
||||||
|
export function AudioProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const [activeEpisode, setActiveEpisode] = useState<Episode | null>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(0);
|
||||||
|
const [playbackRate, setPlaybackRate] = useState(1);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [isZenOpen, setIsZenOpen] = useState(false);
|
||||||
|
const [timings, setTimings] = useState<TimingCue[]>([]);
|
||||||
|
|
||||||
|
// Parse timings whenever activeEpisode changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeEpisode?.timingsJson) {
|
||||||
|
try {
|
||||||
|
setTimings(JSON.parse(activeEpisode.timingsJson));
|
||||||
|
} catch {
|
||||||
|
setTimings([]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTimings([]);
|
||||||
|
}
|
||||||
|
}, [activeEpisode]);
|
||||||
|
|
||||||
|
// Set up MediaSession API for mobile lock screen & notification controls
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined" || !("mediaSession" in navigator)) return;
|
||||||
|
|
||||||
|
if (activeEpisode) {
|
||||||
|
navigator.mediaSession.metadata = new MediaMetadata({
|
||||||
|
title: activeEpisode.title,
|
||||||
|
artist: "Be My Anchor",
|
||||||
|
album: "Episodes",
|
||||||
|
artwork: [
|
||||||
|
{ src: "/icon.svg", sizes: "512x512", type: "image/svg+xml" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
navigator.mediaSession.setActionHandler("play", () => {
|
||||||
|
audioRef.current?.play();
|
||||||
|
});
|
||||||
|
navigator.mediaSession.setActionHandler("pause", () => {
|
||||||
|
audioRef.current?.pause();
|
||||||
|
});
|
||||||
|
navigator.mediaSession.setActionHandler("seekbackward", () => {
|
||||||
|
jump(-15);
|
||||||
|
});
|
||||||
|
navigator.mediaSession.setActionHandler("seekforward", () => {
|
||||||
|
jump(15);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [activeEpisode]);
|
||||||
|
|
||||||
|
const playEpisode = (episode: Episode) => {
|
||||||
|
if (!episode.audioUrl) return;
|
||||||
|
|
||||||
|
const isSameEpisode = activeEpisode?.id === episode.id;
|
||||||
|
setActiveEpisode(episode);
|
||||||
|
|
||||||
|
if (audioRef.current) {
|
||||||
|
if (!isSameEpisode) {
|
||||||
|
audioRef.current.src = episode.audioUrl;
|
||||||
|
audioRef.current.currentTime = 0;
|
||||||
|
setCurrentTime(0);
|
||||||
|
}
|
||||||
|
audioRef.current
|
||||||
|
.play()
|
||||||
|
.then(() => setIsPlaying(true))
|
||||||
|
.catch((err) => console.error("Audio playback error:", err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
setIsPlaying(false);
|
||||||
|
} else {
|
||||||
|
audioRef.current
|
||||||
|
.play()
|
||||||
|
.then(() => setIsPlaying(true))
|
||||||
|
.catch((err) => console.error("Audio resume error:", err));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const seekTo = (seconds: number) => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
const target = Math.max(0, Math.min(seconds, duration || 999999));
|
||||||
|
audioRef.current.currentTime = target;
|
||||||
|
setCurrentTime(target);
|
||||||
|
if (!isPlaying) {
|
||||||
|
audioRef.current.play().then(() => setIsPlaying(true)).catch(console.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const jump = (deltaSeconds: number) => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
const target = Math.max(0, Math.min(audioRef.current.currentTime + deltaSeconds, duration || 999999));
|
||||||
|
audioRef.current.currentTime = target;
|
||||||
|
setCurrentTime(target);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setSpeed = (rate: number) => {
|
||||||
|
setPlaybackRate(rate);
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.playbackRate = rate;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
const next = !isMuted;
|
||||||
|
setIsMuted(next);
|
||||||
|
audioRef.current.muted = next;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openZen = () => setIsZenOpen(true);
|
||||||
|
const closeZen = () => setIsZenOpen(false);
|
||||||
|
const toggleZen = () => setIsZenOpen((prev) => !prev);
|
||||||
|
|
||||||
|
// Audio element event handlers
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
setCurrentTime(audioRef.current.currentTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
if (audioRef.current && isFinite(audioRef.current.duration)) {
|
||||||
|
setDuration(audioRef.current.duration);
|
||||||
|
} else if (activeEpisode?.durationSeconds) {
|
||||||
|
setDuration(activeEpisode.durationSeconds);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePlay = () => setIsPlaying(true);
|
||||||
|
const handlePause = () => setIsPlaying(false);
|
||||||
|
const handleEnded = () => {
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AudioContext.Provider
|
||||||
|
value={{
|
||||||
|
activeEpisode,
|
||||||
|
isPlaying,
|
||||||
|
currentTime,
|
||||||
|
duration,
|
||||||
|
playbackRate,
|
||||||
|
isMuted,
|
||||||
|
isZenOpen,
|
||||||
|
timings,
|
||||||
|
playEpisode,
|
||||||
|
togglePlay,
|
||||||
|
seekTo,
|
||||||
|
jump,
|
||||||
|
setSpeed,
|
||||||
|
toggleMute,
|
||||||
|
openZen,
|
||||||
|
closeZen,
|
||||||
|
toggleZen,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{/* Persistent HTML5 audio element */}
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
onPlay={handlePlay}
|
||||||
|
onPause={handlePause}
|
||||||
|
onEnded={handleEnded}
|
||||||
|
preload="auto"
|
||||||
|
/>
|
||||||
|
</AudioContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAudio() {
|
||||||
|
const context = useContext(AudioContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error("useAudio must be used within an AudioProvider");
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
type Theme = "dark" | "light";
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
theme: Theme;
|
||||||
|
toggleTheme: () => void;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType>({
|
||||||
|
theme: "dark",
|
||||||
|
toggleTheme: () => {},
|
||||||
|
setTheme: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [theme, setThemeState] = useState<Theme>("dark");
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
const saved = localStorage.getItem("bma_theme") as Theme | null;
|
||||||
|
if (saved === "light" || saved === "dark") {
|
||||||
|
setThemeState(saved);
|
||||||
|
applyTheme(saved);
|
||||||
|
} else {
|
||||||
|
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
const initial = prefersDark ? "dark" : "light";
|
||||||
|
setThemeState(initial);
|
||||||
|
applyTheme(initial);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyTheme = (t: Theme) => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
if (t === "dark") {
|
||||||
|
root.classList.add("dark");
|
||||||
|
} else {
|
||||||
|
root.classList.remove("dark");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setTheme = (newTheme: Theme) => {
|
||||||
|
setThemeState(newTheme);
|
||||||
|
applyTheme(newTheme);
|
||||||
|
localStorage.setItem("bma_theme", newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTheme = () => {
|
||||||
|
const nextTheme = theme === "dark" ? "light" : "dark";
|
||||||
|
setTheme(nextTheme);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, toggleTheme, setTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
return useContext(ThemeContext);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the configured control key from environment variables.
|
||||||
|
*/
|
||||||
|
export function getExpectedControlKey(): string | null {
|
||||||
|
const key = process.env.APP_CONTROL_KEY || process.env.CONTROL_KEY;
|
||||||
|
return key && key.trim().length > 0 ? key.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks whether a control key is configured on the server.
|
||||||
|
*/
|
||||||
|
export function isControlKeyConfigured(): boolean {
|
||||||
|
return getExpectedControlKey() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates whether the caller is authorized to use the server's default credentials.
|
||||||
|
* If the caller brings their own API key (BYO key), control key is NOT required.
|
||||||
|
* Otherwise, the provided control key is verified against APP_CONTROL_KEY.
|
||||||
|
*/
|
||||||
|
export function validateDefaultAuthAccess(
|
||||||
|
req: NextRequest,
|
||||||
|
options: {
|
||||||
|
hasByoKey?: boolean;
|
||||||
|
bodyControlKey?: string;
|
||||||
|
}
|
||||||
|
): boolean {
|
||||||
|
// If the caller supplied their own API key (BYO key), allow access without control key
|
||||||
|
if (options.hasByoKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedKey = getExpectedControlKey();
|
||||||
|
// If server has not configured a control key, permit access
|
||||||
|
if (!expectedKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Check body control key
|
||||||
|
if (options.bodyControlKey && options.bodyControlKey.trim() === expectedKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check x-control-key header
|
||||||
|
const headerKey = req.headers.get("x-control-key");
|
||||||
|
if (headerKey && headerKey.trim() === expectedKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Check Authorization: Bearer <token>
|
||||||
|
const authHeader = req.headers.get("authorization");
|
||||||
|
if (authHeader) {
|
||||||
|
const token = authHeader.replace(/^Bearer\s+/i, "").trim();
|
||||||
|
if (token === expectedKey) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { audioService } from "@/lib/tts/audio-service";
|
||||||
|
|
||||||
|
export interface QueueTask {
|
||||||
|
id: string; // matches Episode.id
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
mode: string;
|
||||||
|
targetLanguage?: string;
|
||||||
|
provider?: string;
|
||||||
|
voice?: string;
|
||||||
|
voiceA?: string;
|
||||||
|
voiceB?: string;
|
||||||
|
apiKey?: string;
|
||||||
|
retryCount: number;
|
||||||
|
maxRetries: number;
|
||||||
|
nextAttemptAt?: number; // timestamp in ms
|
||||||
|
enqueuedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes deterministic SHA-256 content hash based strictly on:
|
||||||
|
* title + content + mode + targetLanguage (voices excluded as requested).
|
||||||
|
*/
|
||||||
|
export function computeContentHash(
|
||||||
|
title: string,
|
||||||
|
content: string,
|
||||||
|
mode: string,
|
||||||
|
targetLanguage = "auto"
|
||||||
|
): string {
|
||||||
|
const normalizedTitle = title.trim().toLowerCase();
|
||||||
|
const normalizedContent = content.trim();
|
||||||
|
const normalizedMode = mode.trim().toUpperCase();
|
||||||
|
const normalizedLang = (targetLanguage || "auto").trim().toLowerCase();
|
||||||
|
return crypto
|
||||||
|
.createHash("sha256")
|
||||||
|
.update(`${normalizedTitle}\n${normalizedContent}\n${normalizedMode}\n${normalizedLang}`)
|
||||||
|
.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
class TaskQueueWorker {
|
||||||
|
private queue: QueueTask[] = [];
|
||||||
|
private isProcessing = false;
|
||||||
|
private currentTask: QueueTask | null = null;
|
||||||
|
private currentTaskStartedAt: number | null = null;
|
||||||
|
private workerBackoffUntil: number | null = null; // Worker-level cooldown
|
||||||
|
private loopTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Schedule recovery check on boot to resume any dangling tasks from DB
|
||||||
|
setTimeout(() => {
|
||||||
|
this.recoverDanglingTasks().catch((err) =>
|
||||||
|
console.error("[TaskQueueWorker] Recovery error:", err)
|
||||||
|
);
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue a new task or re-queue an existing one.
|
||||||
|
*/
|
||||||
|
public enqueue(task: Omit<QueueTask, "enqueuedAt" | "retryCount" | "maxRetries"> & { retryCount?: number; maxRetries?: number }): void {
|
||||||
|
// Avoid double-queuing if already in queue
|
||||||
|
const existingIndex = this.queue.findIndex((t) => t.id === task.id);
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullTask: QueueTask = {
|
||||||
|
...task,
|
||||||
|
retryCount: task.retryCount ?? 0,
|
||||||
|
maxRetries: task.maxRetries ?? 3,
|
||||||
|
enqueuedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.queue.push(fullTask);
|
||||||
|
this.scheduleProcessing();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule the worker loop to process the next item.
|
||||||
|
*/
|
||||||
|
private scheduleProcessing(delayMs = 0): void {
|
||||||
|
if (this.loopTimer) {
|
||||||
|
clearTimeout(this.loopTimer);
|
||||||
|
this.loopTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loopTimer = setTimeout(() => {
|
||||||
|
this.processNext().catch((err) =>
|
||||||
|
console.error("[TaskQueueWorker] Unhandled processing loop error:", err)
|
||||||
|
);
|
||||||
|
}, Math.max(0, delayMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Worker core loop: Strict concurrency = 1.
|
||||||
|
*/
|
||||||
|
private async processNext(): Promise<void> {
|
||||||
|
if (this.isProcessing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.queue.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Check worker-level rate limit / overload cooldown
|
||||||
|
if (this.workerBackoffUntil && this.workerBackoffUntil > now) {
|
||||||
|
const remainingCooldown = this.workerBackoffUntil - now;
|
||||||
|
console.log(`[TaskQueueWorker] Worker is cooling down for ${Math.round(remainingCooldown / 1000)}s...`);
|
||||||
|
this.scheduleProcessing(remainingCooldown);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
this.workerBackoffUntil = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find first task whose scheduled attempt time has arrived
|
||||||
|
const readyTaskIndex = this.queue.findIndex(
|
||||||
|
(t) => !t.nextAttemptAt || t.nextAttemptAt <= now
|
||||||
|
);
|
||||||
|
|
||||||
|
if (readyTaskIndex === -1) {
|
||||||
|
// All tasks are waiting on backoff timer; schedule for earliest nextAttemptAt
|
||||||
|
const earliest = Math.min(...this.queue.map((t) => t.nextAttemptAt || now));
|
||||||
|
const waitTime = Math.max(500, earliest - now);
|
||||||
|
this.scheduleProcessing(waitTime);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove task from queue for processing
|
||||||
|
const task = this.queue.splice(readyTaskIndex, 1)[0];
|
||||||
|
this.isProcessing = true;
|
||||||
|
this.currentTask = task;
|
||||||
|
this.currentTaskStartedAt = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Set DB status to PROCESSING
|
||||||
|
await prisma.episode.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: {
|
||||||
|
status: "PROCESSING",
|
||||||
|
retryCount: task.retryCount,
|
||||||
|
errorMessage: task.retryCount > 0 ? `Processing attempt ${task.retryCount + 1}/${task.maxRetries}...` : null,
|
||||||
|
nextAttemptAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[TaskQueueWorker] Processing task [${task.id}] "${task.title}" (Attempt ${task.retryCount + 1}/${task.maxRetries})...`);
|
||||||
|
|
||||||
|
let audioResult;
|
||||||
|
let scriptJson: string | null = null;
|
||||||
|
|
||||||
|
if (task.mode === "DUAL_HOST" || task.mode === "FEYNMAN") {
|
||||||
|
const { screenwriterService } = await import("@/lib/scriptwriter/screenwriter-service");
|
||||||
|
const screenplay = await screenwriterService.generateScript(
|
||||||
|
task.title,
|
||||||
|
task.content,
|
||||||
|
{
|
||||||
|
mode: task.mode as any,
|
||||||
|
targetLanguage: task.targetLanguage,
|
||||||
|
apiKey: task.apiKey,
|
||||||
|
voiceA: task.voiceA || task.voice,
|
||||||
|
voiceB: task.voiceB,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
scriptJson = JSON.stringify(screenplay);
|
||||||
|
|
||||||
|
const isTargetEn = task.targetLanguage === "en";
|
||||||
|
const fallbackA = isTargetEn ? "en-US-ChristopherNeural" : "zh-CN-YunxiNeural";
|
||||||
|
const fallbackB = isTargetEn ? "en-US-JennyNeural" : "zh-CN-XiaoxiaoNeural";
|
||||||
|
|
||||||
|
audioResult = await audioService.processAndSaveScreenplayEpisode(
|
||||||
|
task.id,
|
||||||
|
screenplay,
|
||||||
|
{
|
||||||
|
provider: task.provider || "edge",
|
||||||
|
voiceMap: {
|
||||||
|
hostA: task.voiceA || task.voice || screenplay.speakers[0]?.defaultVoice || fallbackA,
|
||||||
|
hostB: task.voiceB || screenplay.speakers[1]?.defaultVoice || fallbackB,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// VERBATIM mode
|
||||||
|
audioResult = await audioService.processAndSaveEpisode(
|
||||||
|
task.id,
|
||||||
|
task.content,
|
||||||
|
{ provider: task.provider || "edge", voice: task.voice }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Mark task READY on success
|
||||||
|
await prisma.episode.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: {
|
||||||
|
status: "READY",
|
||||||
|
audioUrl: audioResult.audioUrl,
|
||||||
|
durationSeconds: audioResult.durationSeconds,
|
||||||
|
timingsJson: JSON.stringify(audioResult.timings),
|
||||||
|
scriptContent: scriptJson,
|
||||||
|
errorMessage: null,
|
||||||
|
nextAttemptAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`[TaskQueueWorker] Successfully completed task [${task.id}] "${task.title}" (${audioResult.durationSeconds}s)`);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(`[TaskQueueWorker] Error processing task [${task.id}]:`, err.message);
|
||||||
|
|
||||||
|
const isRateLimitOrOverload = /429|503|rate limit|high demand|overloaded|quota/i.test(
|
||||||
|
err.message || ""
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isRateLimitOrOverload) {
|
||||||
|
// Enforce a 20-second worker-level cooldown so subsequent tasks don't get throttled
|
||||||
|
this.workerBackoffUntil = Date.now() + 20_000;
|
||||||
|
console.warn(`[TaskQueueWorker] Rate limit / service overload detected. Pausing worker until ${new Date(this.workerBackoffUntil).toLocaleTimeString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextRetry = task.retryCount + 1;
|
||||||
|
|
||||||
|
if (nextRetry < task.maxRetries) {
|
||||||
|
// Calculate task backoff delay (at least 15s if rate limited, otherwise scaled by attempt)
|
||||||
|
const delayMs = isRateLimitOrOverload
|
||||||
|
? 20_000
|
||||||
|
: Math.max(5_000, nextRetry * 5_000);
|
||||||
|
|
||||||
|
const scheduledTime = Date.now() + delayMs;
|
||||||
|
|
||||||
|
await prisma.episode.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: {
|
||||||
|
status: "PROCESSING", // Still processing/waiting for retry attempt
|
||||||
|
retryCount: nextRetry,
|
||||||
|
nextAttemptAt: new Date(scheduledTime),
|
||||||
|
errorMessage: `Attempt ${nextRetry}/${task.maxRetries} failed: ${err.message}. Retrying at ${new Date(scheduledTime).toLocaleTimeString()}...`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// PER-TRY FIFO: Push failed task to the TAIL (end) of queue so other items get their turn!
|
||||||
|
task.retryCount = nextRetry;
|
||||||
|
task.nextAttemptAt = scheduledTime;
|
||||||
|
this.queue.push(task);
|
||||||
|
console.log(`[TaskQueueWorker] Pushed task [${task.id}] to tail of queue for retry #${nextRetry} at ${new Date(scheduledTime).toLocaleTimeString()}`);
|
||||||
|
} else {
|
||||||
|
// Retries exhausted -> mark FAILED
|
||||||
|
await prisma.episode.update({
|
||||||
|
where: { id: task.id },
|
||||||
|
data: {
|
||||||
|
status: "FAILED",
|
||||||
|
retryCount: nextRetry,
|
||||||
|
errorMessage: `Failed after ${task.maxRetries} attempts: ${err.message}`,
|
||||||
|
nextAttemptAt: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.error(`[TaskQueueWorker] Task [${task.id}] exhausted all ${task.maxRetries} retries and marked as FAILED.`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.isProcessing = false;
|
||||||
|
this.currentTask = null;
|
||||||
|
this.currentTaskStartedAt = null;
|
||||||
|
// Loop to next task immediately or after backoff
|
||||||
|
this.scheduleProcessing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recovers any tasks left in QUEUED or PROCESSING state on server restart.
|
||||||
|
*/
|
||||||
|
private async recoverDanglingTasks(): Promise<void> {
|
||||||
|
const unfinished = await prisma.episode.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ["QUEUED", "PROCESSING"] },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const ep of unfinished) {
|
||||||
|
if (!this.queue.some((t) => t.id === ep.id)) {
|
||||||
|
this.enqueue({
|
||||||
|
id: ep.id,
|
||||||
|
title: ep.title,
|
||||||
|
content: ep.originalContent,
|
||||||
|
mode: ep.mode,
|
||||||
|
retryCount: ep.retryCount,
|
||||||
|
maxRetries: ep.maxRetries,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns current internal state of the worker.
|
||||||
|
*/
|
||||||
|
public getStatus() {
|
||||||
|
const now = Date.now();
|
||||||
|
const isBackingOff = this.workerBackoffUntil !== null && this.workerBackoffUntil > now;
|
||||||
|
const isIdle = !this.isProcessing && this.queue.length === 0;
|
||||||
|
const nextTask = this.queue.length > 0 ? this.queue[0] : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
isIdle,
|
||||||
|
isProcessing: this.isProcessing,
|
||||||
|
isBackingOff,
|
||||||
|
workerBackoffUntil: isBackingOff ? new Date(this.workerBackoffUntil!).toISOString() : null,
|
||||||
|
queueLength: this.queue.length,
|
||||||
|
currentTask: this.currentTask
|
||||||
|
? {
|
||||||
|
id: this.currentTask.id,
|
||||||
|
title: this.currentTask.title,
|
||||||
|
mode: this.currentTask.mode,
|
||||||
|
retryCount: this.currentTask.retryCount,
|
||||||
|
maxRetries: this.currentTask.maxRetries,
|
||||||
|
startedAt: this.currentTaskStartedAt ? new Date(this.currentTaskStartedAt).toISOString() : null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
nextTask: nextTask
|
||||||
|
? {
|
||||||
|
id: nextTask.id,
|
||||||
|
title: nextTask.title,
|
||||||
|
mode: nextTask.mode,
|
||||||
|
retryCount: nextTask.retryCount,
|
||||||
|
nextAttemptAt: nextTask.nextAttemptAt ? new Date(nextTask.nextAttemptAt).toISOString() : null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
queuedTasks: this.queue.map((t, idx) => ({
|
||||||
|
queuePosition: idx + 1,
|
||||||
|
id: t.id,
|
||||||
|
title: t.title,
|
||||||
|
mode: t.mode,
|
||||||
|
retryCount: t.retryCount,
|
||||||
|
nextAttemptAt: t.nextAttemptAt ? new Date(t.nextAttemptAt).toISOString() : null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve global singleton across Next.js reloads
|
||||||
|
const globalForQueue = globalThis as unknown as { bmaTaskQueue?: TaskQueueWorker };
|
||||||
|
export const taskQueue = globalForQueue.bmaTaskQueue ?? new TaskQueueWorker();
|
||||||
|
if (process.env.NODE_ENV !== "production") {
|
||||||
|
globalForQueue.bmaTaskQueue = taskQueue;
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* Direct HTTP Client for Google Gemini API
|
||||||
|
* Does not require external SDK dependencies.
|
||||||
|
*/
|
||||||
|
import dns from "node:dns";
|
||||||
|
try {
|
||||||
|
dns.setDefaultResultOrder("ipv4first");
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
export interface GeminiGenerateOptions {
|
||||||
|
apiKey?: string;
|
||||||
|
model?: string;
|
||||||
|
systemPrompt?: string;
|
||||||
|
userPrompt: string;
|
||||||
|
temperature?: number;
|
||||||
|
responseMimeType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GeminiClient {
|
||||||
|
private defaultModel = "gemini-2.5-flash";
|
||||||
|
|
||||||
|
async generateContent(options: GeminiGenerateOptions): Promise<string> {
|
||||||
|
const apiKey = options.apiKey || process.env.GEMINI_API_KEY;
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
throw new Error(
|
||||||
|
"GEMINI_API_KEY is not configured. Please provide it or set the GEMINI_API_KEY environment variable."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const preferred = options.model || process.env.GEMINI_MODEL || "gemini-2.5-flash";
|
||||||
|
const modelPool = [
|
||||||
|
preferred,
|
||||||
|
"gemini-2.5-flash",
|
||||||
|
"gemini-3.5-flash",
|
||||||
|
"gemini-flash-latest",
|
||||||
|
];
|
||||||
|
// Remove duplicates while preserving priority
|
||||||
|
const fallbackModels = [...new Set(modelPool)];
|
||||||
|
let modelIdx = 0;
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
const maxRetries = 4;
|
||||||
|
|
||||||
|
const requestBody: any = {
|
||||||
|
contents: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
parts: [{ text: options.userPrompt }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
generationConfig: {
|
||||||
|
temperature: options.temperature ?? 0.7,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.systemPrompt) {
|
||||||
|
requestBody.system_instruction = {
|
||||||
|
parts: [{ text: options.systemPrompt }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.responseMimeType) {
|
||||||
|
requestBody.generationConfig.response_mime_type = options.responseMimeType;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||||
|
const currentModel = fallbackModels[modelIdx % fallbackModels.length];
|
||||||
|
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(
|
||||||
|
currentModel
|
||||||
|
)}:generateContent?key=${encodeURIComponent(apiKey)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errDetail = "";
|
||||||
|
try {
|
||||||
|
const errorJson = await response.json();
|
||||||
|
errDetail = errorJson?.error?.message || JSON.stringify(errorJson);
|
||||||
|
} catch {
|
||||||
|
errDetail = await response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
const errMsg = `Gemini API request failed (${response.status} ${response.statusText}): ${errDetail}`;
|
||||||
|
|
||||||
|
// Retry on transient 503 (High Demand), 429 (Rate Limit), or 404 (Model deprecated)
|
||||||
|
if ((response.status === 503 || response.status === 429 || response.status === 404) && attempt < maxRetries) {
|
||||||
|
modelIdx++;
|
||||||
|
const nextModel = fallbackModels[modelIdx % fallbackModels.length];
|
||||||
|
console.warn(`[GeminiClient] Attempt ${attempt} failed with ${response.status} on ${currentModel}. Rotating to fallback model ${nextModel}...`);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, attempt * 1500));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const candidate = data.candidates?.[0];
|
||||||
|
|
||||||
|
if (!candidate || !candidate.content?.parts?.[0]?.text) {
|
||||||
|
throw new Error("Gemini API returned an empty or invalid candidate response.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate.content.parts[0].text;
|
||||||
|
} catch (err: any) {
|
||||||
|
lastError = err;
|
||||||
|
if (attempt >= maxRetries) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError || new Error("Gemini API request failed after retries.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const geminiClient = new GeminiClient();
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { ScriptMode } from "./types";
|
||||||
|
|
||||||
|
export function getSystemPrompt(mode: ScriptMode, language: "zh" | "en" | "auto" = "auto"): string {
|
||||||
|
const languageDirective =
|
||||||
|
language === "zh"
|
||||||
|
? `### 🔴 强制语言指令(TARGET LANGUAGE: 简体中文)
|
||||||
|
无论提供的原文使用的是英文、法文还是其他语言,你都必须将其【完全改编并翻译为纯正、流畅、生动的中文普通话口语】!
|
||||||
|
- 所有的台词 dialogue text 必须使用中文表达,严禁遗留未翻译的英文长句。
|
||||||
|
- 外语人名、地名、专业机构一律使用规范通用的中文译名(必要时可口语化顺口提及英文原名)。
|
||||||
|
- 输出的 language 字段必须为 "zh"。`
|
||||||
|
: language === "en"
|
||||||
|
? `### 🔴 MANDATORY LANGUAGE DIRECTIVE (TARGET LANGUAGE: ENGLISH)
|
||||||
|
Regardless of whether the source material is in Chinese, Spanish, or any other language, you MUST adapt and translate the screenplay COMPLETELY into natural, fluent conversational English!
|
||||||
|
- All dialogue turns and commentary MUST be in English.
|
||||||
|
- Do NOT output dialogue in Chinese or untranslated source languages.
|
||||||
|
- The output language field must be "en".`
|
||||||
|
: `### 语言指令(TARGET LANGUAGE: AUTO)
|
||||||
|
根据原文语言自动判断。若原文为中文则使用地道中文口语,若原文为英文则使用地道英文口语。`;
|
||||||
|
|
||||||
|
if (mode === "DUAL_HOST") {
|
||||||
|
return `你是一位专业的声音剧作大师与播客主编(类似 NotebookLM Audio Overview 深度对话风格)。
|
||||||
|
你的任务是将用户提供的文章、新闻或技术资料,改编为一场**双人对话式深度播客剧本(Dual-Host Dialogue)**。
|
||||||
|
|
||||||
|
${languageDirective}
|
||||||
|
|
||||||
|
### 核心人设与角色分工
|
||||||
|
1. **hostA(主讲人/主播)**:
|
||||||
|
- 具有宏观视野与叙事节奏感,负责引出主题、抛出核心事件或矛盾、掌控讨论进程。
|
||||||
|
- 声音沉稳亲和(中文默认男声“云溪”/ 英文“Andrew”)。
|
||||||
|
2. **hostB(搭档/观察员)**:
|
||||||
|
- 敏锐、好奇、富有洞察力,负责追问“等等,为什么会这样?”、补充关键内幕背景、发表精辟感叹或幽默吐槽。
|
||||||
|
- 声音明快灵动(中文默认女声“晓晓”/ 英文“Ava”)。
|
||||||
|
|
||||||
|
### 听觉化改编(Hearing-First)铁律
|
||||||
|
1. **彻底口语化与声学呼吸感**:
|
||||||
|
- 杜绝书面长难句!把复杂的嵌套从句拆解为 10~25 字以内的短句。
|
||||||
|
- 加入口语呼吸词与转折提示(如:“你看”、“有意思的是”、“对,没错”、“但这里有个细节很关键”等)。
|
||||||
|
2. **数字与术语声学平整化**:
|
||||||
|
- 复杂数字转化为口语表达(如“120个”写作“一百二十个”或“约120个”;“3.4区”写作“三点四区”)。
|
||||||
|
- 解释生僻缩写或组织机构(如将“Politico”自然代入为“美国政治新闻网 Politico”)。
|
||||||
|
- 绝不要包含任何 Markdown 格式符号(如 **粗体**、[链接]、# 标题 等),纯纯口语台词。
|
||||||
|
3. **高频互动与节奏感**:
|
||||||
|
- 两人轮流发言,每次发言 1 至 3 句话,严禁单人长篇大论背课文。
|
||||||
|
- 必须有真诚的情绪起伏、好奇心共鸣与思维碰撞。
|
||||||
|
|
||||||
|
### 输出格式要求
|
||||||
|
必须严格输出合法的 JSON 格式,不含任何其他解释文字或 Markdown 包裹(无需 \`\`\`json 标记):
|
||||||
|
{
|
||||||
|
"title": "简明吸引人的播客单集标题",
|
||||||
|
"summary": "一句话介绍本期核心议题",
|
||||||
|
"mode": "DUAL_HOST",
|
||||||
|
"language": "${language === "en" ? "en" : "zh"}",
|
||||||
|
"speakers": [
|
||||||
|
{ "id": "hostA", "name": "${language === "en" ? "Alex" : "云溪"}", "role": "${language === "en" ? "Lead Host" : "主讲人"}", "gender": "male", "defaultVoice": "${language === "en" ? "en-US-ChristopherNeural" : "zh-CN-YunxiNeural"}" },
|
||||||
|
{ "id": "hostB", "name": "${language === "en" ? "Emma" : "晓晓"}", "role": "${language === "en" ? "Co-host" : "观察员"}", "gender": "female", "defaultVoice": "${language === "en" ? "en-US-JennyNeural" : "zh-CN-XiaoxiaoNeural"}" }
|
||||||
|
],
|
||||||
|
"dialogue": [
|
||||||
|
{ "speaker": "hostA", "text": "欢迎收听今天的节目。" },
|
||||||
|
{ "speaker": "hostB", "text": "今天我们要聊一个特别有料的话题。" }
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "FEYNMAN") {
|
||||||
|
return `你是一位擅长费曼学习法的科普导师与演说家。
|
||||||
|
你的任务是将用户提供的文章、理论或新闻,改编为**费曼模式(Feynman Mode)第一人称叙述剧本**。
|
||||||
|
|
||||||
|
${languageDirective}
|
||||||
|
|
||||||
|
### 核心风格与技巧
|
||||||
|
1. **大白话与具象类比**:
|
||||||
|
- 绝不堆砌艰涩名词。如果必须使用专业术语,立刻用生活中的直观事物打比方(例如用“拼车”比喻“并发请求”,用“房东与租客”比喻“主权与租赁协议”)。
|
||||||
|
2. **第一原则与层层递进**:
|
||||||
|
- 从听众最熟悉的常识出发,抛出一个扣人心弦的疑问,一步步推导出背后的逻辑。
|
||||||
|
3. **亲切热忱的陪伴感**:
|
||||||
|
- 采用第一人称“我”,视听众为老朋友(“想像一下……”、“我们常说……”)。
|
||||||
|
- 语句短小有力,极适合纯声音入耳吸收。
|
||||||
|
|
||||||
|
### 输出格式要求
|
||||||
|
必须严格输出合法的 JSON 格式,不含任何其他解释文字或 Markdown 包裹:
|
||||||
|
{
|
||||||
|
"title": "通俗易懂的单集标题",
|
||||||
|
"summary": "一句话核心要义",
|
||||||
|
"mode": "FEYNMAN",
|
||||||
|
"language": "${language === "en" ? "en" : "zh"}",
|
||||||
|
"speakers": [
|
||||||
|
{ "id": "hostA", "name": "${language === "en" ? "Professor" : "理查德"}", "role": "${language === "en" ? "Mentor" : "讲师"}", "gender": "male", "defaultVoice": "${language === "en" ? "en-US-ChristopherNeural" : "zh-CN-YunxiNeural"}" }
|
||||||
|
],
|
||||||
|
"dialogue": [
|
||||||
|
{ "speaker": "hostA", "text": "我们今天聊一个非常有意思的话题。" }
|
||||||
|
]
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback / Verbatim
|
||||||
|
return `你是一位专业的声音编辑。请将以下内容整理为适合口语收听的完整文本。输出严格 JSON 格式。`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildScriptwriterUserPrompt(
|
||||||
|
title: string,
|
||||||
|
content: string,
|
||||||
|
language: "zh" | "en" | "auto" = "auto"
|
||||||
|
): string {
|
||||||
|
const langReminder =
|
||||||
|
language === "zh"
|
||||||
|
? "\n⚠️【强制语言要求:请全程使用中文普通话创作剧本,将英文等外文内容全部自然翻译为地道中文对白】"
|
||||||
|
: language === "en"
|
||||||
|
? "\n⚠️ [MANDATORY REQUIREMENT: Please write the entire script in conversational English, fully translating any foreign source content]"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `请根据以下文章标题与内容,进行剧本改编:${langReminder}
|
||||||
|
|
||||||
|
【原标题】:${title}
|
||||||
|
【原文内容】:
|
||||||
|
${content}
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { GeminiClient, geminiClient } from "./gemini-client";
|
||||||
|
import { getSystemPrompt, buildScriptwriterUserPrompt } from "./prompts";
|
||||||
|
import {
|
||||||
|
DialogueTurn,
|
||||||
|
ScreenplayScript,
|
||||||
|
ScriptMode,
|
||||||
|
ScriptwriterOptions,
|
||||||
|
SpeakerProfile,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export class ScreenwriterService {
|
||||||
|
private client: GeminiClient;
|
||||||
|
|
||||||
|
constructor(client: GeminiClient = geminiClient) {
|
||||||
|
this.client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to strip markdown code blocks if the LLM wraps JSON in ```json ... ```
|
||||||
|
*/
|
||||||
|
private cleanJsonResponse(raw: string): string {
|
||||||
|
let clean = raw.trim();
|
||||||
|
if (clean.startsWith("```json")) {
|
||||||
|
clean = clean.replace(/^```json\s*/i, "").replace(/```\s*$/, "");
|
||||||
|
} else if (clean.startsWith("```")) {
|
||||||
|
clean = clean.replace(/^```\s*/, "").replace(/```\s*$/, "");
|
||||||
|
}
|
||||||
|
return clean.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects whether text is primarily Chinese or English
|
||||||
|
*/
|
||||||
|
private detectLanguage(text: string): "zh" | "en" {
|
||||||
|
const chineseChars = text.match(/[\u4e00-\u9fa5]/g);
|
||||||
|
const count = chineseChars ? chineseChars.length : 0;
|
||||||
|
return count > 30 ? "zh" : "en";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform an article into an audio screenplay script
|
||||||
|
*/
|
||||||
|
async generateScript(
|
||||||
|
title: string,
|
||||||
|
content: string,
|
||||||
|
options: ScriptwriterOptions
|
||||||
|
): Promise<ScreenplayScript> {
|
||||||
|
const { mode, apiKey, model, voiceA, voiceB } = options;
|
||||||
|
|
||||||
|
if (mode === "VERBATIM") {
|
||||||
|
// Direct pass-through for verbatim mode
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
mode: "VERBATIM",
|
||||||
|
language: this.detectLanguage(content),
|
||||||
|
speakers: [
|
||||||
|
{
|
||||||
|
id: "hostA",
|
||||||
|
name: "Reader",
|
||||||
|
role: "Verbatim Narrator",
|
||||||
|
defaultVoice: voiceA || "zh-CN-XiaoxiaoNeural",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
dialogue: [{ speaker: "hostA", text: content }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetLang: "zh" | "en" =
|
||||||
|
options.targetLanguage && options.targetLanguage !== "auto"
|
||||||
|
? (options.targetLanguage as "zh" | "en")
|
||||||
|
: options.language && options.language !== "auto"
|
||||||
|
? (options.language as "zh" | "en")
|
||||||
|
: this.detectLanguage(content);
|
||||||
|
|
||||||
|
const systemPrompt = getSystemPrompt(mode, targetLang);
|
||||||
|
const userPrompt = buildScriptwriterUserPrompt(title, content, targetLang);
|
||||||
|
|
||||||
|
const rawResponse = await this.client.generateContent({
|
||||||
|
apiKey,
|
||||||
|
model,
|
||||||
|
systemPrompt,
|
||||||
|
userPrompt,
|
||||||
|
temperature: 0.7,
|
||||||
|
responseMimeType: "application/json",
|
||||||
|
});
|
||||||
|
|
||||||
|
const cleanedJson = this.cleanJsonResponse(rawResponse);
|
||||||
|
let parsed: any;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(cleanedJson);
|
||||||
|
} catch (e: any) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to parse screenplay JSON from Gemini: ${e.message}. Raw output: ${cleanedJson.slice(
|
||||||
|
0,
|
||||||
|
200
|
||||||
|
)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate and sanitize parsed output
|
||||||
|
const scriptTitle = parsed.title || title;
|
||||||
|
const isChinese = targetLang === "zh";
|
||||||
|
|
||||||
|
// Setup speakers with language-aware voice overrides
|
||||||
|
let speakers: SpeakerProfile[] = [];
|
||||||
|
if (Array.isArray(parsed.speakers) && parsed.speakers.length > 0) {
|
||||||
|
speakers = parsed.speakers.map((s: any, idx: number) => {
|
||||||
|
let assignedVoice = s.defaultVoice;
|
||||||
|
if (idx === 0 && voiceA) assignedVoice = voiceA;
|
||||||
|
if (idx === 1 && voiceB) assignedVoice = voiceB;
|
||||||
|
if (!assignedVoice) {
|
||||||
|
assignedVoice =
|
||||||
|
idx === 0
|
||||||
|
? isChinese
|
||||||
|
? "zh-CN-YunxiNeural"
|
||||||
|
: "en-US-ChristopherNeural"
|
||||||
|
: isChinese
|
||||||
|
? "zh-CN-XiaoxiaoNeural"
|
||||||
|
: "en-US-JennyNeural";
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: s.id || `host${idx === 0 ? "A" : "B"}`,
|
||||||
|
name: s.name || (idx === 0 ? "Host A" : "Host B"),
|
||||||
|
role: s.role || (idx === 0 ? "Lead Host" : "Co-host"),
|
||||||
|
gender: s.gender,
|
||||||
|
defaultVoice: assignedVoice,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Default speaker profiles
|
||||||
|
if (mode === "DUAL_HOST") {
|
||||||
|
speakers = [
|
||||||
|
{
|
||||||
|
id: "hostA",
|
||||||
|
name: isChinese ? "云溪" : "Alex",
|
||||||
|
role: isChinese ? "主讲人" : "Lead Host",
|
||||||
|
gender: "male",
|
||||||
|
defaultVoice:
|
||||||
|
voiceA ||
|
||||||
|
(isChinese ? "zh-CN-YunxiNeural" : "en-US-ChristopherNeural"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "hostB",
|
||||||
|
name: isChinese ? "晓晓" : "Emma",
|
||||||
|
role: isChinese ? "观察员" : "Co-host",
|
||||||
|
gender: "female",
|
||||||
|
defaultVoice:
|
||||||
|
voiceB ||
|
||||||
|
(isChinese ? "zh-CN-XiaoxiaoNeural" : "en-US-JennyNeural"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
speakers = [
|
||||||
|
{
|
||||||
|
id: "hostA",
|
||||||
|
name: isChinese ? "理查德" : "Professor",
|
||||||
|
role: isChinese ? "讲师" : "Mentor",
|
||||||
|
gender: "male",
|
||||||
|
defaultVoice:
|
||||||
|
voiceA ||
|
||||||
|
(isChinese ? "zh-CN-YunxiNeural" : "en-US-ChristopherNeural"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate dialogue turns
|
||||||
|
const dialogue: DialogueTurn[] = [];
|
||||||
|
if (Array.isArray(parsed.dialogue)) {
|
||||||
|
for (const turn of parsed.dialogue) {
|
||||||
|
if (turn && typeof turn.text === "string" && turn.text.trim().length > 0) {
|
||||||
|
const speakerId = turn.speaker || "hostA";
|
||||||
|
dialogue.push({
|
||||||
|
speaker: speakerId,
|
||||||
|
text: turn.text.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogue.length === 0) {
|
||||||
|
throw new Error("Screenplay generation produced empty dialogue turns.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output Language Strict Validation
|
||||||
|
const allDialogueText = dialogue.map((d) => d.text).join(" ");
|
||||||
|
const chineseChars = (allDialogueText.match(/[\u4e00-\u9fa5]/g) || []).length;
|
||||||
|
const chineseRatio = allDialogueText.length > 0 ? chineseChars / allDialogueText.length : 0;
|
||||||
|
|
||||||
|
if (targetLang === "zh" && chineseRatio < 0.25) {
|
||||||
|
throw new Error(
|
||||||
|
`Script validation failed: output was expected in Chinese ("zh"), but dialogue contains only ${Math.round(
|
||||||
|
chineseRatio * 100
|
||||||
|
)}% Chinese characters. Retrying...`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetLang === "en" && chineseRatio > 0.15) {
|
||||||
|
throw new Error(
|
||||||
|
`Script validation failed: output was expected in English ("en"), but dialogue contains excessive Chinese characters (${Math.round(
|
||||||
|
chineseRatio * 100
|
||||||
|
)}%). Retrying...`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: scriptTitle,
|
||||||
|
summary: parsed.summary,
|
||||||
|
mode,
|
||||||
|
language: targetLang,
|
||||||
|
speakers,
|
||||||
|
dialogue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const screenwriterService = new ScreenwriterService();
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export type ScriptMode = "VERBATIM" | "DUAL_HOST" | "FEYNMAN";
|
||||||
|
|
||||||
|
export interface SpeakerProfile {
|
||||||
|
id: string; // e.g. "hostA", "hostB"
|
||||||
|
name: string; // e.g. "云溪", "晓晓" or "Alex", "Sam"
|
||||||
|
role: string; // e.g. "主讲人 / Lead Host", "观察员 / Co-host"
|
||||||
|
gender?: "male" | "female" | "neutral";
|
||||||
|
defaultVoice: string; // e.g. "zh-CN-YunxiNeural"
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DialogueTurn {
|
||||||
|
speaker: string; // matches SpeakerProfile.id
|
||||||
|
text: string; // Acoustically refined spoken text
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScreenplayScript {
|
||||||
|
title: string;
|
||||||
|
summary?: string;
|
||||||
|
mode: ScriptMode;
|
||||||
|
language: "zh" | "en" | "auto";
|
||||||
|
speakers: SpeakerProfile[];
|
||||||
|
dialogue: DialogueTurn[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScriptwriterOptions {
|
||||||
|
mode: ScriptMode;
|
||||||
|
language?: "zh" | "en" | "auto";
|
||||||
|
targetLanguage?: "zh" | "en" | "auto" | string;
|
||||||
|
targetDurationMinutes?: number;
|
||||||
|
apiKey?: string;
|
||||||
|
model?: string;
|
||||||
|
voiceA?: string;
|
||||||
|
voiceB?: string;
|
||||||
|
}
|
||||||
@@ -122,6 +122,68 @@ export class AudioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synthesizes a structured ScreenplayScript with speaker-specific voices and timing cues
|
||||||
|
*/
|
||||||
|
async synthesizeScreenplay(
|
||||||
|
script: import("../scriptwriter/types").ScreenplayScript,
|
||||||
|
options?: {
|
||||||
|
provider?: string;
|
||||||
|
voiceMap?: Record<string, string>;
|
||||||
|
rate?: string;
|
||||||
|
}
|
||||||
|
): Promise<SynthesizeResult> {
|
||||||
|
const provider = this.getProvider(options?.provider || "edge");
|
||||||
|
|
||||||
|
if (!script.dialogue || script.dialogue.length === 0) {
|
||||||
|
throw new Error("No dialogue turns found in screenplay to synthesize.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build speaker to voice lookup map
|
||||||
|
const speakerVoices = new Map<string, string>();
|
||||||
|
for (const speaker of script.speakers) {
|
||||||
|
const mappedVoice = options?.voiceMap?.[speaker.id] || speaker.defaultVoice;
|
||||||
|
speakerVoices.set(speaker.id, mappedVoice);
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioBuffers: Buffer[] = [];
|
||||||
|
const timings: TimingCue[] = [];
|
||||||
|
let currentTime = 0;
|
||||||
|
|
||||||
|
for (const turn of script.dialogue) {
|
||||||
|
const voice =
|
||||||
|
speakerVoices.get(turn.speaker) ||
|
||||||
|
options?.voiceMap?.[turn.speaker] ||
|
||||||
|
"zh-CN-XiaoxiaoNeural";
|
||||||
|
|
||||||
|
const chunkBuffer = await provider.synthesizeChunk(turn.text, {
|
||||||
|
voice,
|
||||||
|
rate: options?.rate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const chunkDuration = estimateMp3Duration(chunkBuffer);
|
||||||
|
|
||||||
|
timings.push({
|
||||||
|
text: turn.text,
|
||||||
|
startTime: Number(currentTime.toFixed(2)),
|
||||||
|
endTime: Number((currentTime + chunkDuration).toFixed(2)),
|
||||||
|
speaker: turn.speaker,
|
||||||
|
});
|
||||||
|
|
||||||
|
audioBuffers.push(chunkBuffer);
|
||||||
|
currentTime += chunkDuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullAudioBuffer = Buffer.concat(audioBuffers);
|
||||||
|
const totalDurationSeconds = Number(currentTime.toFixed(2));
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioBuffer: fullAudioBuffer,
|
||||||
|
totalDurationSeconds,
|
||||||
|
timings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Synthesizes and saves audio to storage, returning audioUrl and timings
|
* Synthesizes and saves audio to storage, returning audioUrl and timings
|
||||||
*/
|
*/
|
||||||
@@ -143,6 +205,29 @@ export class AudioService {
|
|||||||
timings: result.timings,
|
timings: result.timings,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synthesizes and saves multi-voice screenplay audio to storage
|
||||||
|
*/
|
||||||
|
async processAndSaveScreenplayEpisode(
|
||||||
|
episodeId: string,
|
||||||
|
script: import("../scriptwriter/types").ScreenplayScript,
|
||||||
|
options?: {
|
||||||
|
provider?: string;
|
||||||
|
voiceMap?: Record<string, string>;
|
||||||
|
rate?: string;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const result = await this.synthesizeScreenplay(script, options);
|
||||||
|
const audioUrl = await storage.saveAudio(episodeId, result.audioBuffer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
audioUrl,
|
||||||
|
durationSeconds: result.totalDurationSeconds,
|
||||||
|
timings: result.timings,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const audioService = new AudioService();
|
export const audioService = new AudioService();
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,13 @@ export function resolveVoiceForText(text: string, requestedVoice?: string): stri
|
|||||||
return isMale ? "zh-CN-YunxiNeural" : DEFAULT_CHINESE_VOICE;
|
return isMale ? "zh-CN-YunxiNeural" : DEFAULT_CHINESE_VOICE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate that requested voice exists in Edge TTS; if unsupported, fall back safely
|
||||||
|
const isValidVoice = POPULAR_EDGE_VOICES.some((v) => v.id === requestedVoice);
|
||||||
|
if (!isValidVoice) {
|
||||||
|
console.warn(`[EdgeTTS] Voice "${requestedVoice}" is unrecognized. Falling back to default.`);
|
||||||
|
return isChinese ? DEFAULT_CHINESE_VOICE : DEFAULT_ENGLISH_VOICE;
|
||||||
|
}
|
||||||
|
|
||||||
return requestedVoice;
|
return requestedVoice;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"content": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"content": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"content": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"content": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"content": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"content": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"content": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"content": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"content": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"content": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"content": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"content": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"content": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"content": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"title": "",
|
||||||
|
"content": ""
|
||||||
|
}
|
||||||
|
]
|
||||||
Executable
+202
@@ -0,0 +1,202 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tool: fix_multiline_json.py
|
||||||
|
Description:
|
||||||
|
Fixes JSON files that contain invalid raw multiline strings (e.g. pasted articles
|
||||||
|
or multiline text in "content" or "title" fields) without altering any content,
|
||||||
|
newlines, spaces, or HTML tags.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python3 tools/fix_multiline_json.py <file1.json> [file2.json ...]
|
||||||
|
python3 tools/fix_multiline_json.py test/data/
|
||||||
|
python3 tools/fix_multiline_json.py --check <file.json>
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_json(text: str) -> bool:
|
||||||
|
try:
|
||||||
|
json.loads(text)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_multiline_json_array(text: str) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Parses a loosely formatted JSON array of objects where string fields
|
||||||
|
may contain raw unescaped newlines.
|
||||||
|
"""
|
||||||
|
lines = text.splitlines(keepends=True)
|
||||||
|
n = len(lines)
|
||||||
|
items: List[Dict[str, Any]] = []
|
||||||
|
i = 0
|
||||||
|
current_obj: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
while i < n:
|
||||||
|
line = lines[i]
|
||||||
|
stripped = line.strip()
|
||||||
|
|
||||||
|
# Start of a new object
|
||||||
|
if stripped == "{" and current_obj is None:
|
||||||
|
current_obj = {}
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# End of current object
|
||||||
|
if stripped in ("},", "}") and current_obj is not None:
|
||||||
|
items.append(current_obj)
|
||||||
|
current_obj = None
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Field detection: look for `"key":` pattern
|
||||||
|
if current_obj is not None:
|
||||||
|
key_match = re.match(r'^\s*"([^"]+)"\s*:\s*(.*)', line)
|
||||||
|
if key_match:
|
||||||
|
key = key_match.group(1)
|
||||||
|
val_start = key_match.group(2).lstrip()
|
||||||
|
|
||||||
|
# Case 1: String value
|
||||||
|
if val_start.startswith('"'):
|
||||||
|
val_after_quote = val_start[1:]
|
||||||
|
# Check if single-line closed: e.g. "value", or "value"
|
||||||
|
# But be careful if it ends with ", or "
|
||||||
|
if val_after_quote.endswith(',\n') or val_after_quote.endswith(',\r\n'):
|
||||||
|
trimmed = val_after_quote.rstrip('\r\n')[:-1].rstrip()
|
||||||
|
else:
|
||||||
|
trimmed = val_after_quote.rstrip('\r\n')
|
||||||
|
|
||||||
|
if trimmed.endswith('"'):
|
||||||
|
# Single line string
|
||||||
|
current_obj[key] = trimmed[:-1]
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Multiline string starting on this line
|
||||||
|
val_lines = [val_after_quote]
|
||||||
|
i += 1
|
||||||
|
while i < n:
|
||||||
|
next_line = lines[i]
|
||||||
|
next_stripped = next_line.strip()
|
||||||
|
# Check if next line is closing of object or next key
|
||||||
|
is_obj_end = next_stripped in ("},", "}")
|
||||||
|
is_next_key = bool(re.match(r'^\s*"[^"]+"\s*:\s*', next_line))
|
||||||
|
|
||||||
|
if is_obj_end or is_next_key:
|
||||||
|
# The previous line had the closing quote
|
||||||
|
last = val_lines[-1]
|
||||||
|
last_stripped = last.rstrip('\r\n')
|
||||||
|
if last_stripped.endswith(','):
|
||||||
|
last_stripped = last_stripped[:-1].rstrip()
|
||||||
|
if last_stripped.endswith('"'):
|
||||||
|
val_lines[-1] = last_stripped[:-1]
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
val_lines.append(next_line)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
current_obj[key] = "".join(val_lines)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Case 2: Number, boolean, null
|
||||||
|
else:
|
||||||
|
raw_val = val_start.rstrip('\r\n')
|
||||||
|
if raw_val.endswith(','):
|
||||||
|
raw_val = raw_val[:-1].strip()
|
||||||
|
try:
|
||||||
|
current_obj[key] = json.loads(raw_val)
|
||||||
|
except Exception:
|
||||||
|
current_obj[key] = raw_val
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def process_file(filepath: str, check_only: bool = False) -> bool:
|
||||||
|
print(f"Checking: {filepath}")
|
||||||
|
try:
|
||||||
|
with open(filepath, "r", encoding="utf-8") as f:
|
||||||
|
raw_text = f.read()
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [ERROR] Failed to read {filepath}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if is_valid_json(raw_text):
|
||||||
|
print(f" [OK] Already valid JSON syntax.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
print(f" [FIXING] Detected multiline or invalid JSON syntax...")
|
||||||
|
try:
|
||||||
|
parsed_items = parse_multiline_json_array(raw_text)
|
||||||
|
if not parsed_items:
|
||||||
|
print(f" [WARNING] Could not parse any objects from {filepath}.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
formatted_json = json.dumps(parsed_items, ensure_ascii=False, indent=4) + "\n"
|
||||||
|
|
||||||
|
# Verify round-trip syntax
|
||||||
|
test_load = json.loads(formatted_json)
|
||||||
|
assert len(test_load) == len(parsed_items)
|
||||||
|
|
||||||
|
if check_only:
|
||||||
|
print(f" [DRY-RUN] Can successfully repair {len(parsed_items)} items.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
with open(filepath, "w", encoding="utf-8") as f:
|
||||||
|
f.write(formatted_json)
|
||||||
|
|
||||||
|
print(f" [SUCCESS] Repaired and formatted {len(parsed_items)} items.")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [ERROR] Failed repairing {filepath}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python3 tools/fix_multiline_json.py [--check] <path-to-json-or-directory> [...]")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
args = sys.argv[1:]
|
||||||
|
check_only = False
|
||||||
|
if "--check" in args:
|
||||||
|
check_only = True
|
||||||
|
args.remove("--check")
|
||||||
|
|
||||||
|
files_to_process = []
|
||||||
|
for arg in args:
|
||||||
|
if os.path.isdir(arg):
|
||||||
|
files_to_process.extend(glob.glob(os.path.join(arg, "**/*.json"), recursive=True))
|
||||||
|
elif os.path.isfile(arg):
|
||||||
|
files_to_process.append(arg)
|
||||||
|
else:
|
||||||
|
matches = glob.glob(arg)
|
||||||
|
if matches:
|
||||||
|
files_to_process.extend(matches)
|
||||||
|
else:
|
||||||
|
print(f"[WARN] No files matched: {arg}")
|
||||||
|
|
||||||
|
if not files_to_process:
|
||||||
|
print("No JSON files found to process.")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
success_count = 0
|
||||||
|
for file_path in files_to_process:
|
||||||
|
if process_file(file_path, check_only=check_only):
|
||||||
|
success_count += 1
|
||||||
|
|
||||||
|
print(f"\nCompleted: {success_count}/{len(files_to_process)} files processed successfully.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user