Compare commits

..
2 Commits
84 changed files with 7906 additions and 2179 deletions
+9
View File
@@ -0,0 +1,9 @@
# Database URL (SQLite default for local development)
DATABASE_URL="file:./dev.db"
# Google Gemini API Key (Milestone 2 Screenwriter engine)
GEMINI_API_KEY="your-gemini-api-key"
GEMINI_MODEL="gemini-2.5-flash"
# Control Key: restricts access to server default API credentials (bypassable if caller supplies BYO key)
APP_CONTROL_KEY="your-strong-password-here"
+33
View File
@@ -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
+35 -10
View File
@@ -1,14 +1,39 @@
# Virtual Environment
venv/
.env
# Dependencies
node_modules/
.pnp
.pnp.js
# Build artifacts
*.egg-info/
# Testing
coverage/
# Next.js build output
.next/
out/
build/
dist/
# Test artifacts
.pytest_cache/
__pycache__/
*.pyc
run/
# Environment files
.env
.env*.local
# Database
prisma/*.db
prisma/*.db-journal
*.sqlite
*.sqlite3
# Audio generated outputs (if local storage)
public/audio/episodes/*
!public/audio/episodes/.gitkeep
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# OS / Editor
.DS_Store
*.pem
.vscode/
.idea/
scratch/
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-2026 Pengzhan Hao <[email protected]>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+24
View File
@@ -0,0 +1,24 @@
.PHONY: install setup dev build start db-push db-studio clean
install:
npm install
setup: install db-push
db-push:
npx prisma db push
db-studio:
npx prisma studio
dev:
npm run dev
build:
npm run build
start:
npm run start
clean:
rm -rf .next
+50 -5
View File
@@ -1,10 +1,55 @@
# Be my anchor
# Be My Anchor 🎙️
Make you a podcast like audio fomr articles you'd like to read but want to listen instead.
> Transform articles, tech blogs, and research papers into an engaging, hearing-first multimedia podcast experience.
## How to run it
---
## Features (Milestone 1)
- **High-Fidelity Neural Audio Generation**: Multi-provider TTS architecture powered by Microsoft Edge Neural Voices (free, zero-key) with OpenAI TTS interface ready.
- **Smart Text Chunking & Audio Stitching**: Automatically splits long articles and paragraphs into natural spoken chunks, stitches them into a unified `.mp3`, and calculates precise timing cues.
- **Local File System Audio Storage**: Decoupled audio management with native HTTP Range streaming support for smooth scrubbing.
- **Synchronized Web Player**:
- Interactive player with Play / Pause, Progress scrubbing, Speed controls (0.8x - 2.0x), and ±15s jumps.
- Live synchronized transcript that auto-scrolls and highlights the active spoken paragraph.
- Click-to-seek directly from transcript.
- **Episode Library**: Local database persistence via Prisma & SQLite (easily migratable to MongoDB / PostgreSQL).
---
## Tech Stack
- **Framework**: Next.js 16 (App Router) + React 19 + TypeScript
- **Styling**: Tailwind CSS + Lucide Icons
- **Database & ORM**: Prisma + SQLite
- **Audio Engine**: `msedge-tts` (Neural TTS)
---
## Quick Start
### 1. Install Dependencies
```bash
npm install
```
make setup
python -m src.server.app
### 2. Initialize Database
```bash
npx prisma db push
```
### 3. Run Development Server
```bash
npm run dev
```
Open [http://localhost:3000](http://localhost:3000) in your browser.
---
## Architecture & Roadmap
Refer to [Product Requirements Document (`docs/prd.md`)](docs/prd.md) for full architecture details:
- **Phase 1 (Complete)**: Audio generation core, chunking, stitching, local audio storage, and synchronized web player.
- **Phase 2 (Upcoming)**: Hearing-front LLM transformation (Feynman Deep-Dive Presenter & Dual-Host Banter).
- **Phase 3 (Upcoming)**: Synchronized visual stage (displaying diagrams, figures, and code in sync with audio).
+250
View File
@@ -0,0 +1,250 @@
# Product Requirements Document (PRD): Be My Anchor
**Status**: Approved
**Target Version**: 2.0 (Listening-First Audio Platform)
**Architecture**: TypeScript / Next.js Fullstack
**Primary Output**: `docs/prd.md`
---
## 1. Executive Summary & Vision
**Be My Anchor** transforms written materials—technical blogs, research papers, news articles, essays, and documentation—into an engaging, listening-first audio experience.
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**: Multi-provider neural TTS (Edge TTS, OpenAI, ElevenLabs) with smart text chunking and audio stitching.
2. **Hearing-Front Content Adaptation**: An LLM-powered scriptwriting engine that adapts dense text into conversational spoken formats (e.g., Feynman-style technical breakdown or 2-host conversational dialogue).
3. **Mobile-First Zen Listening Experience**: A distraction-free PWA listening environment with uninterrupted playback, synchronized auto-scrolling transcript, and tap-to-seek navigation.
---
## 2. Core Philosophy & Phased Strategy
```
┌─────────────────────────────────────────────────────────────┐
│ Phase 1: Robust Audio Generation Core │
│ - Multi-provider TTS (Edge TTS, OpenAI, ElevenLabs) │
│ - Long-form text chunking & audio stitching │
│ - Persistent audio player & episode library │
└──────────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 2: Hearing-Front Content Transformation │
│ - "Feynman" Deep-Dive Presenter Persona │
│ - Dual-Host Dialogue Persona (NotebookLM style) │
│ - Synchronized sentence & speaker timing manifests │
└──────────────────────────────┬──────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Phase 3: Mobile PWA & Zen Listening Experience │
│ - Fullscreen Zen player with synchronized transcript │
│ - Mobile PWA & MediaSession background lockscreen controls │
│ - Dedicated article studio and queue status tracking │
└─────────────────────────────────────────────────────────────┘
```
---
## 3. User Personas & Modes of Operation
### Persona 1: The Engineer / Researcher ("The Feynman Mode")
- **Content**: Technical architecture posts, RFCs, research papers, algorithm deep-dives.
- **Narrator Style**: A world-class communicator (like Richard Feynman or a top tech lead) explaining concepts using intuitive analogies and breaking down dense material for the ear.
### Persona 2: The Informed Professional ("The Dual-Host Dialogue")
- **Content**: Industry news, opinion pieces, market shifts, newsletters.
- **Narrator Style**: Two AI co-hosts bantering, asking each other questions, and adding contextual background not directly present in the source article.
### Persona 3: The Audiobook / Direct Listener ("Verbatim Mode")
- **Content**: Novels, essays, speeches, narrative non-fiction.
- **Narrator Style**: Clear, expressive single-narrator audiobook delivery with sentence-by-sentence paragraph tracking.
---
## 4. System Architecture
```
┌────────────────────────────┐
│ API Ingestion Endpoint │
│ (Markdown / Text Input) │
└─────────────┬──────────────┘
│
▼
┌────────────────────────────┐
│ Hearing-Front Script Engine│
│ (LLM: OpenAI / Claude) │
│ - Rewrites text for ear │
│ - Speaker turn separation │
└─────────────┬──────────────┘
│
▼
┌────────────────────────────┐
│ TTS Generation Core │
│ ┌──────────┐ ┌───────────┐ │
│ │ Edge TTS │ │OpenAI TTS │ │
│ └──────────┘ └───────────┘ │
│ ┌────────────────────────┐ │
│ │ ElevenLabs (Multi-Voice│ │
│ └────────────────────────┘ │
│ - Chunker & Audio Stitcher │
│ - Timing Aligner (JSON) │
└─────────────┬──────────────┘
│
▼
┌───────────────────────────────────┴───────────────────────────────────┐
│ Data & Storage │
│ - Database: Prisma ORM (SQLite / PostgreSQL) │
│ - File Storage: Local static / S3-compatible (Audio MP3s) │
└───────────────────────────────────┬───────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────────┐
│ Mobile-First Web App (Next.js / TS / PWA) │
│ ┌───────────────────────┐ ┌───────────────────────────────────────┐ │
│ │ Main Index View │ │ Zen Mode Player │ │
│ │ - Episode list │ │ - Fullscreen distraction-free player │ │
│ │ - Live status pills │ │ - Synchronized auto-scrolled script │ │
│ │ - Persistent bar │ │ - Tap-to-seek, large touch controls │ │
│ └───────────────────────┘ └───────────────────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ Dedicated Add Article View (/new) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────────┘
```
---
## 5. Functional Requirements
### 5.1 Content Ingestion (API & UI Driven)
- Clean REST API & dedicated UI accepting:
- `title`: String
- `content`: Plain text or Markdown
- `mode`: `'VERBATIM'` | `'FEYNMAN'` | `'DUAL_HOST'`
- `voice_config`: Provider preference (`edge` | `openai` | `elevenlabs`), voice IDs, speed
### 5.2 Hearing-Front Content Transformation
- **Verbatim Cleaner**: Strips markdown artifacts, normalizes punctuation, phonetic hints.
- **Feynman Script Generator**: Rewrites technical content into an oral lecture/walkthrough.
- **Dual-Host Dialogue Generator**: Turns article content into an interactive conversation between Host A and Host B with speaker-tagged turns (`SpeakerA: ...`, `SpeakerB: ...`).
### 5.3 TTS Engine & Audio Processing
- **Provider Abstraction Interface (`ITextToSpeechProvider`)**:
- `EdgeTTSProvider`: Free, instant neural voices (`zh-CN-XiaoxiaoNeural`, `en-US-ChristopherNeural`, etc.).
- `OpenAITTSProvider`: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`.
- `ElevenLabsProvider`: High-fidelity multi-voice assignment for dual-host dialogue.
- **Long-Text Chunker & Audio Stitcher**:
- Breaks script into sentence/clause chunks respecting character limits.
- Stitches chunk audio files into a unified master `.mp3`.
- Generates synchronized sentence/turn timing manifests (`timingsJson`).
### 5.4 3-View Mobile-First Listening Experience
1. **Main Index View (`/`)**:
- Episode list with live statuses (`QUEUED`, `PROCESSING`, `READY`, `FAILED`, `CANCELED`).
- Card play/pause, duration, auto-polling while jobs are active.
- Persistent bottom mini-player bar that never interrupts playing audio.
2. **Zen Fullscreen Player**:
- Toggled via full-screen button on persistent bar.
- Synchronized transcript with active sentence highlighting and smooth auto-scroll.
- Tap-to-seek anywhere in transcript.
- Swipe-down gesture or minimize button to return to index without audio interruption.
3. **Dedicated Add Article View (`/new`)**:
- Standalone view for new posts with character/word count and duration estimation.
- Extensible for future M2 settings (prompts, dual voices).
---
## 6. Data Model (Prisma Schema Specification)
```prisma
datasource db {
provider = "sqlite" // or "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model Episode {
id String @id @default(cuid())
title String
originalContent String
scriptContent String?
mode String @default("VERBATIM") // VERBATIM | FEYNMAN | DUAL_HOST
status String @default("PENDING") // QUEUED | PROCESSING | READY | FAILED | CANCELED
errorMessage String?
durationSeconds Float?
audioUrl String?
timingsJson String? // Serialized array of cues [{text, startTime, endTime, speaker}]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
---
## 7. API Specification
### `POST /api/episodes`
Creates a new conversion job.
```json
{
"title": "Understanding Kubernetes Ingress",
"content": "# Markdown content...",
"mode": "VERBATIM",
"provider": "edge",
"voice": "en-US-ChristopherNeural"
}
```
### `GET /api/episodes/:id`
Retrieves episode details, audio URL, processing status, and transcript timings.
### `GET /api/episodes`
Lists all episodes for the index view.
### `DELETE /api/episodes/:id`
Deletes or cancels an episode.
---
## 8. Implementation Roadmap
### Phase 1: Core Audio Generation & Minimal Player (Completed)
- [x] Next.js + TypeScript project structure and Prisma configuration.
- [x] `ITextToSpeechProvider` interface with `EdgeTTSProvider` and `OpenAITTSProvider`.
- [x] Long-text chunking, concatenation, and static file delivery.
- [x] Basic synchronized transcript viewer and audio player.
### Phase 2: Hearing-Front Script Engine (Next)
- [ ] Implement prompt templates for:
- Feynman Master Presenter (conversational breakdown).
- Dual-Host Dialogue (speaker-tagged turns).
- [ ] Parse speaker tags and route chunks to distinct voices in the TTS pipeline.
- [ ] Output synchronized paragraph/sentence timestamp manifests.
### Phase 3: Mobile PWA & Zen Listening Experience (Current)
- [x] Global `AudioProvider` ensuring uninterrupted audio playback across all navigation.
- [x] Fullscreen Zen Player with auto-scrolling synchronized transcript & tap-to-seek.
- [x] Dedicated Add Article View (`/new`) for extensible settings.
- [x] PWA manifest & mobile-responsive design for phone listening.
-25
View File
@@ -1,25 +0,0 @@
download-webdriver:
curl -L https://storage.googleapis.com/chrome-for-testing-public/132.0.6834.110/linux64/chrome-linux64.zip -o chrome-linux64.zip
curl -L https://storage.googleapis.com/chrome-for-testing-public/132.0.6834.110/linux64/chromedriver-linux64.zip -o chromedriver-linux64.zip
prepare-webdriver: download-webdriver
mkdir -p run/webdriver/chrome
unzip chrome-linux64.zip -d run/webdriver/chrome
rm -f chrome-linux64.zip
mkdir -p run/webdriver/chromedriver
unzip -j chromedriver-linux64.zip -d run/webdriver/chromedriver
rm -f chromedriver-linux64.zip
setup: prepare-webdriver
python3 -m venv venv
pip install -e .
clean-webdriver:
rm -rf run/webdriver
clean: clean-webdriver
rm -rf run
rm -rf venv
all: setup
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+7
View File
@@ -0,0 +1,7 @@
/** @type {import('next').NextConfig} */
// Reload Prisma Client with targetLanguage schema
const nextConfig = {
serverExternalPackages: ["msedge-tts", "@prisma/client"],
};
export default nextConfig;
+3090
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "be-my-anchor",
"version": "1.0.0",
"description": "Transform articles, tech blogs, and papers into a personalized listening-first audio experience.",
"scripts": {
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start",
"lint": "next lint",
"gitleaks": "gitleaks protect --staged -v",
"prepare": "git config core.hooksPath .githooks || true"
},
"repository": {
"type": "git",
"url": "https://git.pengzhan.dev/haopengzhan/be-my-anchor.git"
},
"keywords": ["podcast", "tts", "nextjs", "audio", "reader"],
"author": "Pengzhan Hao <[email protected]>",
"license": "MIT",
"type": "module",
"dependencies": {
"@prisma/client": "^6.19.3",
"lucide-react": "^1.39.0",
"msedge-tts": "^2.0.7",
"next": "^16.3.4",
"react": "^19.2.8",
"react-dom": "^19.2.8"
},
"devDependencies": {
"@types/node": "^26.4.1",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.5",
"autoprefixer": "^10.5.4",
"postcss": "^8.5.26",
"prisma": "^6.19.3",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3"
}
}
+8
View File
@@ -0,0 +1,8 @@
const config = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
export default config;
+45
View File
@@ -0,0 +1,45 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = "file:./dev.db"
}
model Episode {
id String @id @default(cuid())
title String
originalContent String
contentHash String? // SHA-256 for deduplication
scriptContent String?
mode String @default("VERBATIM") // VERBATIM | FEYNMAN | DUAL_HOST
targetLanguage String @default("auto") // auto | zh | en
status String @default("QUEUED") // QUEUED | PROCESSING | READY | FAILED
retryCount Int @default(0)
maxRetries Int @default(3)
nextAttemptAt DateTime?
errorMessage String?
durationSeconds Float?
audioUrl String?
timingsJson String? // Serialized array of cues / timestamps [{text, startTime, endTime, speaker}]
stages StageItem[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([contentHash])
}
model StageItem {
id String @id @default(cuid())
episodeId String
timestampSeconds Float
type String // IMAGE | CODE | CALLOUT
title String?
content String // Image URL, Code snippet, or Callout text
caption String?
episode Episode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
}
+1
View File
@@ -0,0 +1 @@
+8
View File
@@ -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

+18
View File
@@ -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"
}
]
}
-106
View File
@@ -1,106 +0,0 @@
[build-system]
requires = ["setuptools>=42", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "be-my-anchor"
version = "0.1.0"
authors = [
{name="Pengzhan Hao", email="[email protected]"},
]
description = "Web content to podcast converter"
readme = "README.md"
requires-python = ">=3.11"
classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
]
[project.dependencies]
annotated-types==0.7.0
anyio==4.8.0
attrs==25.1.0
beautifulsoup4==4.12.3
blinker==1.9.0
cachetools==5.5.1
certifi==2024.12.14
chardet==5.2.0
charset-normalizer==3.4.1
click==8.1.8
cssselect==1.2.0
distro==1.9.0
feedfinder2==0.0.4
feedparser==6.0.11
filelock==3.17.0
Flask==3.1.0
google-ai-generativelanguage==0.6.15
google-api-core==2.24.0
google-api-python-client==2.159.0
google-auth==2.38.0
google-auth-httplib2==0.2.0
google-cloud-texttospeech==2.24.0
google-generativeai==0.8.4
googleapis-common-protos==1.66.0
greenlet==3.1.1
grpcio==1.70.0
grpcio-status==1.70.0
h11==0.14.0
html2text==2024.2.26
httpcore==1.0.7
httplib2==0.22.0
httpx==0.28.1
idna==3.10
itsdangerous==2.2.0
jieba3k==0.35.1
Jinja2==3.1.5
jiter==0.8.2
joblib==1.4.2
lxml==5.3.0
lxml_html_clean==0.4.1
MarkupSafe==3.0.2
newspaper3k==0.2.8
nltk==3.9.1
openai==1.60.1
outcome==1.3.0.post0
pillow==11.1.0
pip-chill==1.0.3
playwright==1.49.1
proto-plus==1.25.0
protobuf==5.29.3
pyasn1==0.6.1
pyasn1_modules==0.4.1
pydantic==2.10.6
pydantic_core==2.27.2
pyee==12.0.0
pyfranc==0.2.3
pyparsing==3.2.1
PySocks==1.7.1
python-dateutil==2.9.0.post0
python-dotenv==1.0.1
PyYAML==6.0.2
readability-lxml==0.8.1
regex==2024.11.6
requests==2.32.3
requests-file==2.1.0
rsa==4.9
selenium==4.28.1
sgmllib3k==1.0.0
six==1.17.0
sniffio==1.3.1
sortedcontainers==2.4.0
soupsieve==2.6
tinysegmenter==0.3
tldextract==5.1.3
tqdm==4.67.1
trio==0.28.0
trio-websocket==0.11.1
typing_extensions==4.12.2
uritemplate==4.1.1
urllib3==2.3.0
websocket-client==1.8.0
Werkzeug==3.1.3
wsproto==1.2.0
[project.optional-dependencies]
dev = ["pytest", "black"]
-15
View File
@@ -1,15 +0,0 @@
flask
google-cloud-texttospeech
google-generativeai
grpcio-status
html2text
lxml-html-clean
newspaper3k
openai
pip-chill
playwright
pyfranc
pysocks
python-dotenv
readability-lxml
selenium
-5
View File
@@ -1,5 +0,0 @@
-r requirements.txt
pytest>=7.0
black>=23.0
isort>=5.0
pip-chill>=1.0
+9
View File
@@ -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(),
});
}
+127
View File
@@ -0,0 +1,127 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { storage } from "@/lib/storage/storage";
export async function GET(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const episode = await prisma.episode.findUnique({
where: { id },
include: { stages: true },
});
if (!episode) {
return NextResponse.json(
{ success: false, error: "Episode not found" },
{ status: 404 }
);
}
return NextResponse.json({
success: true,
episode: {
...episode,
timings: episode.timingsJson ? JSON.parse(episode.timingsJson) : [],
},
task: {
id: episode.id,
status: episode.status,
retryCount: episode.retryCount,
maxRetries: episode.maxRetries,
nextAttemptAt: episode.nextAttemptAt,
errorMessage: episode.errorMessage,
durationSeconds: episode.durationSeconds,
audioUrl: episode.audioUrl,
isSuccess: episode.status === "READY",
isProcessing: episode.status === "PROCESSING",
isQueued: episode.status === "QUEUED",
isFailed: episode.status === "FAILED",
},
});
} catch (error: any) {
return NextResponse.json(
{ success: false, error: error.message || "Failed to fetch episode" },
{ status: 500 }
);
}
}
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const episode = await prisma.episode.findUnique({ where: { id } });
if (!episode) {
return NextResponse.json(
{ success: false, error: "Episode not found" },
{ status: 404 }
);
}
const { taskQueue } = await import("@/lib/queue/task-queue");
const updated = await prisma.episode.update({
where: { id },
data: {
status: "QUEUED",
retryCount: 0,
errorMessage: null,
nextAttemptAt: null,
},
});
taskQueue.enqueue({
id: episode.id,
title: episode.title,
content: episode.originalContent,
mode: episode.mode,
});
return NextResponse.json({
success: true,
message: "Episode re-queued for processing.",
episode: updated,
});
} catch (error: any) {
return NextResponse.json(
{ success: false, error: error.message || "Failed to retry episode" },
{ status: 500 }
);
}
}
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
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 }
);
}
// Delete audio file from storage
await storage.deleteAudio(id);
// Delete record from DB
await prisma.episode.delete({ where: { id } });
return NextResponse.json({ success: true, message: "Episode deleted" });
} catch (error: any) {
return NextResponse.json(
{ success: false, error: error.message || "Failed to delete episode" },
{ status: 500 }
);
}
}
+187
View File
@@ -0,0 +1,187 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { cleanHtmlTags } from "@/lib/tts/chunker";
import { validateDefaultAuthAccess } from "@/lib/auth/control-key";
import { taskQueue, computeContentHash } from "@/lib/queue/task-queue";
export async function GET() {
try {
const episodes = await prisma.episode.findMany({
orderBy: { createdAt: "desc" },
include: { stages: true },
});
return NextResponse.json({ success: true, episodes });
} catch (error: any) {
console.error("Failed to list episodes:", error);
return NextResponse.json(
{ success: false, error: error.message || "Failed to list episodes" },
{ status: 500 }
);
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const {
title,
content,
mode = "VERBATIM",
targetLanguage = "auto",
provider = "edge",
voice,
voiceA,
voiceB,
apiKey,
controlKey,
} = body;
// If LLM mode is requested without a BYO key, verify Control Key to authorize using server's default credentials
const hasByoKey = typeof apiKey === "string" && apiKey.trim().length > 0;
const isLlmMode = mode === "DUAL_HOST" || mode === "FEYNMAN";
if (isLlmMode && !hasByoKey) {
const isAuthorized = validateDefaultAuthAccess(req, {
hasByoKey: false,
bodyControlKey: controlKey,
});
if (!isAuthorized) {
return NextResponse.json(
{
success: false,
error:
"Unauthorized: Using the server's default AI credentials requires a valid Control Key. Alternatively, you can provide your own Gemini API Key (BYO key) without needing a Control Key.",
},
{ status: 401 }
);
}
}
if (!title || typeof title !== "string" || !title.trim()) {
return NextResponse.json(
{ success: false, error: "Title is required." },
{ status: 400 }
);
}
if (!content || typeof content !== "string" || !content.trim()) {
return NextResponse.json(
{ success: false, error: "Content is required." },
{ status: 400 }
);
}
const sanitizedTitle = cleanHtmlTags(title.trim()) || "Untitled Episode";
const sanitizedContent = content.trim();
// Compute content hash based strictly on title + content + mode + targetLanguage
const contentHash = computeContentHash(sanitizedTitle, sanitizedContent, mode, targetLanguage);
// 1. Deduplication check: look for identical content already submitted
const existing = await prisma.episode.findFirst({
where: { contentHash },
orderBy: { createdAt: "desc" },
});
if (existing) {
if (existing.status === "READY" || existing.status === "PROCESSING" || existing.status === "QUEUED") {
console.log(`[POST /api/episodes] Deduplicated: identical content already exists as episode [${existing.id}] (${existing.status})`);
return NextResponse.json(
{
success: true,
deduplicated: true,
message: `Identical article already exists with status: ${existing.status}.`,
episode: existing,
},
{ status: 200 }
);
}
if (existing.status === "FAILED") {
// Re-poke: re-queue the existing failed record instead of creating duplicate clutter
console.log(`[POST /api/episodes] Re-queuing previously failed episode [${existing.id}]...`);
const updated = await prisma.episode.update({
where: { id: existing.id },
data: {
status: "QUEUED",
targetLanguage,
retryCount: 0,
errorMessage: null,
nextAttemptAt: null,
},
});
taskQueue.enqueue({
id: existing.id,
title: sanitizedTitle,
content: sanitizedContent,
mode,
targetLanguage,
provider,
voice,
voiceA,
voiceB,
apiKey,
});
return NextResponse.json(
{
success: true,
deduplicated: false,
requeued: true,
message: "Previously failed episode re-queued for processing.",
episode: updated,
},
{ status: 202 }
);
}
}
// 2. Create new episode record in QUEUED state
const episode = await prisma.episode.create({
data: {
title: sanitizedTitle,
originalContent: 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) {
console.error("Episode creation error:", error);
return NextResponse.json(
{ success: false, error: error.message || "Internal server error" },
{ status: 500 }
);
}
}
+9
View File
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import { POPULAR_EDGE_VOICES } from "@/lib/tts/edge-provider";
export async function GET() {
return NextResponse.json({
success: true,
voices: POPULAR_EDGE_VOICES,
});
}
+20
View File
@@ -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 }
);
}
}
+56
View File
@@ -0,0 +1,56 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--background: #f8fafc;
--foreground: #0f172a;
}
.dark {
--background: #090d16;
--foreground: #f1f5f9;
}
body {
background-color: var(--background);
color: var(--foreground);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
margin: 0;
padding: 0;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
-webkit-tap-highlight-color: transparent;
}
/* Safe area inset for mobile phones with notch / home indicators */
.safe-bottom {
padding-bottom: max(1.5rem, env(safe-area-inset-bottom));
}
/* Custom scrollbars */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
.dark ::-webkit-scrollbar-thumb {
background: #334155;
}
::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.dark ::-webkit-scrollbar-thumb:hover {
background: #475569;
}
+70
View File
@@ -0,0 +1,70 @@
import type { Metadata, Viewport } from "next";
import "./globals.css";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { AudioProvider } from "@/contexts/AudioContext";
import { AudioPlayer } from "@/components/AudioPlayer";
import { ZenPlayer } from "@/components/ZenPlayer";
export const metadata: Metadata = {
title: "Be My Anchor - Listen to Any Article",
description:
"Convert written articles, tech blogs, and papers into a personalized listening experience with synchronized 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({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark" suppressHydrationWarning>
<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}
{/* Persistent bottom mini player bar */}
<AudioPlayer />
{/* Full Screen Zen Mode overlay */}
<ZenPlayer />
</AudioProvider>
</ThemeProvider>
</body>
</html>
);
}
+607
View File
@@ -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>
);
}
+388
View File
@@ -0,0 +1,388 @@
"use client";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import {
Radio,
Plus,
Headphones,
Trash2,
Clock,
Play,
Pause,
Maximize2,
Search,
RefreshCw,
Loader2,
AlertCircle,
RotateCw,
Hourglass,
Sparkles,
} from "lucide-react";
import { useAudio, Episode } from "@/contexts/AudioContext";
import { ThemeToggle } from "@/components/ThemeToggle";
export default function HomePage() {
const { activeEpisode, isPlaying, playEpisode, togglePlay, openZen } = useAudio();
const [episodes, setEpisodes] = useState<Episode[]>([]);
const [workerStatus, setWorkerStatus] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState("");
const fetchEpisodes = async () => {
try {
const [epRes, workerRes] = await Promise.all([
fetch("/api/episodes"),
fetch("/api/worker/status").catch(() => null),
]);
const data = await epRes.json();
if (data.success && data.episodes) {
setEpisodes(data.episodes);
}
if (workerRes && workerRes.ok) {
const workerData = await workerRes.json();
if (workerData.success) {
setWorkerStatus(workerData.worker);
}
}
} catch (err) {
console.error("Failed to load episodes:", err);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchEpisodes();
}, []);
// Real-time polling when any episode is QUEUED or PROCESSING, or worker is active
useEffect(() => {
const hasPending = episodes.some(
(ep) => ep.status === "QUEUED" || ep.status === "PROCESSING"
);
const workerActive = workerStatus && (!workerStatus.isIdle || workerStatus.isBackingOff);
if (!hasPending && !workerActive) return;
const interval = setInterval(() => {
fetchEpisodes();
}, 2500);
return () => clearInterval(interval);
}, [episodes, workerStatus]);
const handleDeleteEpisode = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm("Are you sure you want to delete this episode?")) return;
try {
await fetch(`/api/episodes/${id}`, { method: "DELETE" });
await fetchEpisodes();
} catch (err) {
console.error("Failed to delete episode:", err);
}
};
const handleRetryEpisode = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
try {
await fetch(`/api/episodes/${id}`, { method: "POST" });
await fetchEpisodes();
} catch (err) {
console.error("Failed to retry episode:", err);
}
};
const formatDuration = (secs?: number | null) => {
if (!secs) return "0:00";
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
};
// Filter episodes purely by search query
const filteredEpisodes = episodes.filter((ep) => {
if (!searchQuery.trim()) return true;
const q = searchQuery.toLowerCase();
return (
ep.title.toLowerCase().includes(q) ||
ep.originalContent.toLowerCase().includes(q)
);
});
return (
<div className="flex-1 flex flex-col pb-36">
{/* Top Navbar */}
<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-5xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between">
<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">
<Radio className="w-5 h-5" />
</div>
<div>
<span className="font-bold text-base sm:text-lg text-slate-900 dark:text-slate-100 tracking-tight">
Be My Anchor
</span>
<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
</span>
</div>
</div>
<div className="flex items-center gap-2.5">
<ThemeToggle />
<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"
>
<Plus className="w-4 h-4" />
<span>Add Article</span>
</Link>
</div>
</div>
</header>
{/* Main Container */}
<main className="max-w-5xl mx-auto px-4 sm:px-6 py-6 w-full flex-1 space-y-6">
{/* Search Bar Header */}
<div className="flex items-center justify-between gap-4">
<div className="relative flex-1 max-w-md">
<Search className="w-4 h-4 text-slate-400 dark:text-slate-500 absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search your articles..."
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"
/>
</div>
<div className="text-xs text-slate-500 font-mono shrink-0">
{episodes.length} {episodes.length === 1 ? "article" : "articles"}
</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>
<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.
</div>
</div>
)}
{/* Episode List */}
{isLoading ? (
<div className="flex flex-col items-center justify-center py-24 text-slate-500">
<RefreshCw className="w-8 h-8 animate-spin mb-3 text-blue-500" />
<p className="text-sm">Loading articles...</p>
</div>
) : filteredEpisodes.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">
<Headphones className="w-12 h-12 text-slate-400 dark:text-slate-600 mx-auto mb-3" />
<h3 className="text-slate-800 dark:text-slate-200 font-medium text-base mb-1">
{searchQuery ? "No matching articles" : "No articles yet"}
</h3>
<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>
{!searchQuery && (
<Link
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"
>
<Plus className="w-4 h-4" />
<span>Add Your First Article</span>
</Link>
)}
</div>
) : (
<div className="space-y-3">
{filteredEpisodes.map((ep) => {
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 (
<div
key={ep.id}
onClick={() => {
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
? "bg-blue-50/70 dark:bg-slate-900 border-blue-400 dark:border-blue-500/50 shadow-md shadow-blue-500/5"
: "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-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="flex items-center gap-2 mb-1.5 flex-wrap">
{/* Mode Badge */}
<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">
{ep.mode === "DUAL_HOST"
? "🎙️ 双人播客"
: 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)}
</span>
)}
<span className="text-[11px] text-slate-400 dark:text-slate-600">•</span>
<span className="text-[11px] text-slate-500">
{new Date(ep.createdAt).toLocaleDateString()}
</span>
</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>
{/* 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
onClick={(e) => handleDeleteEpisode(ep.id, e)}
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 article"
aria-label="Delete article"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
})}
</div>
)}
</main>
</div>
);
}
+127
View File
@@ -0,0 +1,127 @@
"use client";
import React from "react";
import { useAudio } from "@/contexts/AudioContext";
import { Maximize2, Play, Pause } from "lucide-react";
export function AudioPlayer() {
const {
activeEpisode,
isPlaying,
currentTime,
duration,
togglePlay,
seekTo,
openZen,
} = useAudio();
if (!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 progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
return (
<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">
{/* Interactive Top Mini Scrubber */}
<div
className="w-full h-1 bg-slate-200 dark:bg-slate-900 hover:h-2 cursor-pointer transition-all group relative"
onClick={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const ratio = Math.max(0, Math.min(clickX / rect.width, 1));
seekTo(ratio * duration);
}}
>
<div
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
onClick={togglePlay}
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 ? "Click to Pause" : "Click to Play"}
aria-label={isPlaying ? "Pause audio" : "Play audio"}
>
{/* Outer Vinyl Body with spinning animation */}
<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 ${
isPlaying ? "animate-[spin_5s_linear_infinite]" : ""
}`}
>
{/* Vinyl grooves styling */}
<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>
{/* Hover / Status Overlay Icon */}
<div
className={`absolute inset-0 rounded-full flex items-center justify-center bg-black/40 backdrop-blur-[1px] transition-opacity ${
isPlaying ? "opacity-0 group-hover:opacity-100" : "opacity-90"
}`}
>
{isPlaying ? (
<Pause className="w-4 h-4 text-white fill-current drop-shadow" />
) : (
<Play className="w-4 h-4 text-white fill-current translate-x-0.5 drop-shadow" />
)}
</div>
</button>
{/* Track Info (Click to open Zen Player) */}
<div
onClick={openZen}
className="min-w-0 flex-1 cursor-pointer group py-1"
title="Click to open Full Screen Zen Mode"
>
<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>
{/* 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>
);
}
+36
View File
@@ -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>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import React, { useEffect, useRef } from "react";
import { TimingCue } from "@/lib/tts/types";
import { Clock, PlayCircle } from "lucide-react";
interface TranscriptViewerProps {
timings: TimingCue[];
currentTime: number;
onSeek: (time: number) => void;
}
export function TranscriptViewer({
timings,
currentTime,
onSeek,
}: TranscriptViewerProps) {
const activeRef = useRef<HTMLDivElement | null>(null);
// Auto-scroll active cue into view smoothly
useEffect(() => {
if (activeRef.current) {
activeRef.current.scrollIntoView({
behavior: "smooth",
block: "nearest",
});
}
}, [currentTime]);
const formatSeconds = (secs: number) => {
const m = Math.floor(secs / 60);
const s = Math.floor(secs % 60);
return `${m.toString().padStart(2, "0")}:${s.toString().padStart(2, "0")}`;
};
if (!timings || timings.length === 0) {
return (
<div className="flex flex-col items-center justify-center p-8 text-slate-500 border border-dashed border-slate-800 rounded-xl">
<Clock className="w-8 h-8 mb-2 opacity-50" />
<p className="text-sm">No synchronized transcript available for this episode.</p>
</div>
);
}
return (
<div className="space-y-2 max-h-[500px] overflow-y-auto pr-2">
{timings.map((cue, idx) => {
const isActive = currentTime >= cue.startTime && currentTime <= cue.endTime;
return (
<div
key={idx}
ref={isActive ? activeRef : null}
onClick={() => onSeek(cue.startTime)}
className={`group flex items-start gap-3 p-3 rounded-lg cursor-pointer transition-all duration-200 border ${
isActive
? "bg-blue-950/40 border-blue-500/40 text-blue-100 shadow-sm"
: "bg-slate-900/30 border-slate-800/60 hover:bg-slate-850 hover:border-slate-700 text-slate-300"
}`}
>
{/* Timestamp badge */}
<span
className={`text-xs font-mono px-2 py-0.5 rounded shrink-0 flex items-center gap-1 ${
isActive
? "bg-blue-600 text-white font-semibold"
: "bg-slate-800 text-slate-400 group-hover:bg-slate-700 group-hover:text-slate-200"
}`}
>
<PlayCircle className="w-3 h-3" />
{formatSeconds(cue.startTime)}
</span>
{/* Cue text */}
<p className="text-sm leading-relaxed flex-1">
{cue.text}
</p>
</div>
);
})}
</div>
);
}
+280
View File
@@ -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>
);
}
+231
View File
@@ -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;
}
+66
View File
@@ -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);
}
+62
View File
@@ -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;
}
+13
View File
@@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
export default prisma;
+347
View File
@@ -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;
}
+124
View File
@@ -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();
+117
View File
@@ -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();
+34
View File
@@ -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;
}
+66
View File
@@ -0,0 +1,66 @@
import fs from "fs";
import path from "path";
export interface IStorageProvider {
saveAudio(key: string, buffer: Buffer): Promise<string>;
getAudioUrl(key: string): string;
deleteAudio(key: string): Promise<boolean>;
exists(key: string): Promise<boolean>;
}
/**
* Local filesystem storage provider.
* Saves audio files directly inside public/audio/episodes so Next.js
* can serve them with native HTTP Range requests (scrubbing/seeking).
*/
export class LocalStorageProvider implements IStorageProvider {
private baseDir: string;
private publicUrlPrefix: string;
constructor() {
this.baseDir = path.join(process.cwd(), "public", "audio", "episodes");
this.publicUrlPrefix = "/audio/episodes";
this.ensureDirectory();
}
private ensureDirectory(): void {
if (!fs.existsSync(this.baseDir)) {
fs.mkdirSync(this.baseDir, { recursive: true });
}
}
async saveAudio(key: string, buffer: Buffer): Promise<string> {
this.ensureDirectory();
const filename = key.endsWith(".mp3") ? key : `${key}.mp3`;
const filepath = path.join(this.baseDir, filename);
await fs.promises.writeFile(filepath, buffer);
return this.getAudioUrl(filename);
}
getAudioUrl(key: string): string {
const filename = key.endsWith(".mp3") ? key : `${key}.mp3`;
return `${this.publicUrlPrefix}/${filename}`;
}
async deleteAudio(key: string): Promise<boolean> {
const filename = key.endsWith(".mp3") ? key : `${key}.mp3`;
const filepath = path.join(this.baseDir, filename);
try {
if (fs.existsSync(filepath)) {
await fs.promises.unlink(filepath);
}
return true;
} catch {
return false;
}
}
async exists(key: string): Promise<boolean> {
const filename = key.endsWith(".mp3") ? key : `${key}.mp3`;
const filepath = path.join(this.baseDir, filename);
return fs.existsSync(filepath);
}
}
// Global default storage provider instance
export const storage: IStorageProvider = new LocalStorageProvider();
+233
View File
@@ -0,0 +1,233 @@
import { EdgeTTSProvider } from "./edge-provider";
import { OpenAITTSProvider } from "./openai-provider";
import { chunkText } from "./chunker";
import { ITextToSpeechProvider, SynthesizeResult, TimingCue, TTSOptions } from "./types";
import { storage } from "../storage/storage";
/**
* Accurately estimate duration of an MP3 buffer based on frame count or 48kbps bitrate fallback.
*/
export function estimateMp3Duration(buffer: Buffer): number {
if (!buffer || buffer.length === 0) return 0;
let frames = 0;
let sampleRate = 24000;
let foundHeaders = false;
for (let i = 0; i < buffer.length - 4; i++) {
// Look for MP3 frame sync: 11 bits set (0xFF followed by 0xE0 or higher)
if (buffer[i] === 0xff && (buffer[i + 1] & 0xe0) === 0xe0) {
const versionBits = (buffer[i + 1] >> 3) & 0x03; // 0=MPEG2.5, 2=MPEG2, 3=MPEG1
const layerBits = (buffer[i + 1] >> 1) & 0x03; // 1=Layer3
const sampleRateBits = (buffer[i + 2] >> 2) & 0x03;
if (layerBits === 1) { // Layer III
foundHeaders = true;
frames++;
// Determine sample rate
if (versionBits === 3) {
// MPEG 1
sampleRate = [44100, 48000, 32000, 44100][sampleRateBits];
} else {
// MPEG 2 or 2.5
sampleRate = [22050, 24000, 16000, 24000][sampleRateBits];
}
i += 100; // Skip ahead inside frame
}
}
}
if (foundHeaders && frames > 0) {
// MPEG2/2.5 Layer 3 has 576 samples per frame; MPEG1 Layer 3 has 1152 samples per frame
const samplesPerFrame = sampleRate <= 24000 ? 576 : 1152;
return (frames * samplesPerFrame) / sampleRate;
}
// Fallback based on 48 kbps mono MP3 (48000 bits/s = 6000 bytes/s)
return buffer.length / 6000;
}
export class AudioService {
private providers: Map<string, ITextToSpeechProvider>;
constructor() {
this.providers = new Map();
this.providers.set("edge", new EdgeTTSProvider());
this.providers.set("openai", new OpenAITTSProvider());
}
getProvider(name: string = "edge"): ITextToSpeechProvider {
const provider = this.providers.get(name.toLowerCase());
if (!provider) {
throw new Error(`Unsupported TTS provider: ${name}`);
}
return provider;
}
async getAvailableVoices(providerName: string = "edge") {
const provider = this.getProvider(providerName);
return provider.getAvailableVoices();
}
/**
* Synthesize long text into a single stitched MP3 with timing cues
*/
async synthesizeEpisode(
content: string,
options?: {
provider?: string;
voice?: string;
rate?: string;
}
): Promise<SynthesizeResult> {
const provider = this.getProvider(options?.provider || "edge");
const chunks = chunkText(content);
if (chunks.length === 0) {
throw new Error("No readable text content found to synthesize.");
}
const audioBuffers: Buffer[] = [];
const timings: TimingCue[] = [];
let currentTime = 0;
const ttsOptions: TTSOptions = {
voice: options?.voice,
rate: options?.rate,
};
for (const chunk of chunks) {
const chunkBuffer = await provider.synthesizeChunk(chunk.text, ttsOptions);
const chunkDuration = estimateMp3Duration(chunkBuffer);
timings.push({
text: chunk.text,
startTime: Number(currentTime.toFixed(2)),
endTime: Number((currentTime + chunkDuration).toFixed(2)),
stageCue: chunk.stageCue,
});
audioBuffers.push(chunkBuffer);
currentTime += chunkDuration;
}
const fullAudioBuffer = Buffer.concat(audioBuffers);
const totalDurationSeconds = Number(currentTime.toFixed(2));
return {
audioBuffer: fullAudioBuffer,
totalDurationSeconds,
timings,
};
}
/**
* 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
*/
async processAndSaveEpisode(
episodeId: string,
content: string,
options?: {
provider?: string;
voice?: string;
rate?: string;
}
) {
const result = await this.synthesizeEpisode(content, options);
const audioUrl = await storage.saveAudio(episodeId, result.audioBuffer);
return {
audioUrl,
durationSeconds: result.totalDurationSeconds,
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();
+161
View File
@@ -0,0 +1,161 @@
export interface TextChunk {
index: number;
text: string;
originalIndex?: number;
speaker?: string;
stageCue?: string;
}
/**
* Remove HTML tags from any string.
* When preserveNewlines is true, paragraph structure and linebreaks are maintained.
*/
export function cleanHtmlTags(raw: string, preserveNewlines: boolean = false): string {
if (!raw) return "";
let text = raw
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
.replace(/<\/div>/gi, "\n")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/gi, " ")
.replace(/&amp;/gi, "&")
.replace(/&quot;/gi, '"')
.replace(/&#39;|&apos;/gi, "'")
.replace(/&lt;/gi, "<")
.replace(/&gt;/gi, ">")
.replace(/&#\d+;/g, "");
if (preserveNewlines) {
return text
.split("\n")
.map((line) => line.replace(/[ \t]+/g, " ").trim())
.join("\n");
} else {
return text.replace(/\s+/g, " ").trim();
}
}
/**
* Detects if text contains Chinese characters (CJK unified ideographs)
*/
export function hasChineseCharacters(text: string): boolean {
return /[\u4e00-\u9fa5]/.test(text);
}
/**
* Common web boilerplate / navigation noise lines to discard
*/
const NOISE_LINE_PATTERNS = [
/^(previous|next|share|tweet|follow|subscribe|read more|continue reading|back to top|related)$/i,
/^(上一页|下一页|上一篇|下一篇|分享|点赞|在看|关注|阅读原文|返回顶部|相关推荐|版权声明|目录)$/,
/^https?:\/\/\S+$/i, // lone URLs
];
/**
* Clean markdown and HTML formatting to make text speech-friendly
*/
export function cleanTextForSpeech(raw: string): string {
if (!raw) return "";
// 1. Remove HTML tags while preserving line breaks for noise filtering
let text = cleanHtmlTags(raw, true);
// 2. Remove markdown image syntax ![alt](url)
text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, "");
// 3. Convert markdown links [text](url) to just text
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1");
// 4. Remove headers (# Header)
text = text.replace(/^#{1,6}\s+/gm, "");
// 5. Remove bold/italic (* or _)
text = text.replace(/[*_]{1,3}([^*_]+)[*_]{1,3}/g, "$1");
// 6. Remove inline code ticks & code blocks
text = text.replace(/```[\s\S]*?```/g, "");
text = text.replace(/`([^`]+)`/g, "$1");
// 7. Remove blockquotes and list bullet markers
text = text.replace(/^\s*>\s+/gm, "");
text = text.replace(/^[\s*-•–]+\s+/gm, "");
// 8. Filter out common boilerplate / noise lines
const lines = text.split("\n").map((l) => l.trim());
const filteredLines = lines.filter((line) => {
if (!line) return false;
for (const pattern of NOISE_LINE_PATTERNS) {
if (pattern.test(line)) return false;
}
return true;
});
// 9. Normalize excessive newlines
return filteredLines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
}
/**
* Splits long text into natural sentence or paragraph chunks.
* Handles English (. ? !) with spaces, AND Chinese / CJK (。!?;) without spaces.
* Chinese defaults to ~120 chars; English defaults to ~350 chars.
*/
export function chunkText(rawText: string, maxChunkLength?: number): TextChunk[] {
const cleaned = cleanTextForSpeech(rawText);
if (!cleaned) return [];
const isChinese = hasChineseCharacters(cleaned);
const targetLength = maxChunkLength || (isChinese ? 120 : 350);
// Split into raw paragraphs first
const rawParagraphs = cleaned
.split(/\n\s*\n/)
.map((p) => p.trim())
.filter(Boolean);
const chunks: TextChunk[] = [];
let chunkIndex = 0;
for (const para of rawParagraphs) {
if (para.length <= targetLength) {
chunks.push({
index: chunkIndex++,
text: para,
});
continue;
}
// Split large paragraph into sentences:
// Matches CJK sentence enders (。!?;) with optional space
// OR English sentence enders (.?!;) followed by required space or end of string
const sentences = para
.split(/(?<=[。!?;])\s*|(?<=[.?!;])\s+/)
.map((s) => s.trim())
.filter(Boolean);
let currentChunk = "";
for (const sentence of sentences) {
const combined = currentChunk ? `${currentChunk} ${sentence}` : sentence;
if (combined.length <= targetLength) {
currentChunk = combined;
} else {
if (currentChunk) {
chunks.push({
index: chunkIndex++,
text: currentChunk,
});
}
currentChunk = sentence;
}
}
if (currentChunk) {
chunks.push({
index: chunkIndex++,
text: currentChunk,
});
}
}
return chunks;
}
+158
View File
@@ -0,0 +1,158 @@
import { MsEdgeTTS, OUTPUT_FORMAT, ProsodyOptions } from "msedge-tts";
import { ITextToSpeechProvider, TTSOptions, VoiceOption } from "./types";
import { hasChineseCharacters } from "./chunker";
export const DEFAULT_ENGLISH_VOICE = "en-US-ChristopherNeural";
export const DEFAULT_CHINESE_VOICE = "zh-CN-XiaoxiaoNeural";
export const POPULAR_EDGE_VOICES: VoiceOption[] = [
// Chinese / Multilingual (Mixed Chinese + English)
{
id: "zh-CN-XiaoxiaoNeural",
name: "晓晓 Xiaoxiao (CN Female - 温暖亲切 / 中英混合流利)",
gender: "female",
locale: "zh-CN",
},
{
id: "zh-CN-YunxiNeural",
name: "云希 Yunxi (CN Male - 生动活泼 / 科技与播客)",
gender: "male",
locale: "zh-CN",
},
{
id: "zh-CN-YunjianNeural",
name: "云健 Yunjian (CN Male - 沉稳大气 / 新闻专栏)",
gender: "male",
locale: "zh-CN",
},
{
id: "zh-CN-XiaoyiNeural",
name: "晓伊 Xiaoyi (CN Female - 温柔自然)",
gender: "female",
locale: "zh-CN",
},
{
id: "zh-TW-HsiaoChenNeural",
name: "曉臻 HsiaoChen (TW Female - 繁體中文)",
gender: "female",
locale: "zh-TW",
},
{
id: "zh-HK-HiuMaanNeural",
name: "曉曼 HiuMaan (HK Female - 廣東話 / 粵語)",
gender: "female",
locale: "zh-HK",
},
// English Voices
{
id: "en-US-ChristopherNeural",
name: "Christopher (US Male - Clear & Authoritative)",
gender: "male",
locale: "en-US",
},
{
id: "en-US-JennyNeural",
name: "Jenny (US Female - Natural & Engaging)",
gender: "female",
locale: "en-US",
},
{
id: "en-US-GuyNeural",
name: "Guy (US Male - Conversational)",
gender: "male",
locale: "en-US",
},
{
id: "en-US-AriaNeural",
name: "Aria (US Female - Expressive)",
gender: "female",
locale: "en-US",
},
{
id: "en-GB-RyanNeural",
name: "Ryan (UK Male - British Accent)",
gender: "male",
locale: "en-GB",
},
{
id: "en-GB-SoniaNeural",
name: "Sonia (UK Female - British Accent)",
gender: "female",
locale: "en-GB",
},
];
/**
* Resolves the optimal voice based on text language and user preference
*/
export function resolveVoiceForText(text: string, requestedVoice?: string): string {
const isChinese = hasChineseCharacters(text);
if (!requestedVoice) {
return isChinese ? DEFAULT_CHINESE_VOICE : DEFAULT_ENGLISH_VOICE;
}
// If the text has Chinese characters, but an English-only voice (en-*) was selected,
// automatically route to a Chinese neural voice so synthesis does NOT skip Chinese characters!
if (isChinese && requestedVoice.startsWith("en-")) {
const isMale = requestedVoice.includes("Christopher") || requestedVoice.includes("Guy") || requestedVoice.includes("Ryan");
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;
}
export class EdgeTTSProvider implements ITextToSpeechProvider {
readonly name = "edge";
async getAvailableVoices(): Promise<VoiceOption[]> {
return POPULAR_EDGE_VOICES;
}
async synthesizeChunk(text: string, options?: TTSOptions): Promise<Buffer> {
const voice = resolveVoiceForText(text, options?.voice);
const tts = new MsEdgeTTS();
try {
await tts.setMetadata(
voice,
OUTPUT_FORMAT.AUDIO_24KHZ_48KBITRATE_MONO_MP3
);
const prosody: ProsodyOptions = {};
if (options?.rate) prosody.rate = options.rate;
if (options?.pitch) prosody.pitch = options.pitch;
if (options?.volume) prosody.volume = options.volume;
const { audioStream } = tts.toStream(text, prosody);
const chunks: Buffer[] = [];
return await new Promise<Buffer>((resolve, reject) => {
audioStream.on("data", (chunk: Buffer) => {
chunks.push(chunk);
});
audioStream.on("end", () => {
resolve(Buffer.concat(chunks));
});
audioStream.on("error", (err: Error) => {
reject(err);
});
});
} finally {
try {
tts.close();
} catch {
// ignore close error
}
}
}
}
+23
View File
@@ -0,0 +1,23 @@
import { ITextToSpeechProvider, TTSOptions, VoiceOption } from "./types";
export class OpenAITTSProvider implements ITextToSpeechProvider {
readonly name = "openai";
async getAvailableVoices(): Promise<VoiceOption[]> {
return [
{ id: "alloy", name: "Alloy (Neutral)", gender: "neutral", locale: "en-US" },
{ id: "echo", name: "Echo (Male)", gender: "male", locale: "en-US" },
{ id: "fable", name: "Fable (British Accent)", gender: "neutral", locale: "en-GB" },
{ id: "onyx", name: "Onyx (Deep Male)", gender: "male", locale: "en-US" },
{ id: "nova", name: "Nova (Warm Female)", gender: "female", locale: "en-US" },
{ id: "shimmer", name: "Shimmer (Clear Female)", gender: "female", locale: "en-US" },
];
}
async synthesizeChunk(text: string, options?: TTSOptions): Promise<Buffer> {
// Placeholder for Milestone 1
throw new Error(
"OpenAI TTS provider is reserved for Milestone 2. Please use 'edge' provider for Milestone 1."
);
}
}
+40
View File
@@ -0,0 +1,40 @@
export interface TimingCue {
text: string;
startTime: number; // in seconds
endTime: number; // in seconds
speaker?: string;
stageCue?: string; // Optional stage / figure cue ID e.g. "fig-1"
}
export interface TTSOptions {
voice?: string;
rate?: string; // e.g. "+0%", "-10%"
pitch?: string; // e.g. "+0Hz"
volume?: string; // e.g. "+0%"
}
export interface TTSChunkResult {
chunkIndex: number;
text: string;
audioBuffer: Buffer;
durationSeconds: number;
}
export interface SynthesizeResult {
audioBuffer: Buffer;
totalDurationSeconds: number;
timings: TimingCue[];
}
export interface VoiceOption {
id: string;
name: string;
gender: "male" | "female" | "neutral";
locale: string;
}
export interface ITextToSpeechProvider {
readonly name: string;
getAvailableVoices(): Promise<VoiceOption[]>;
synthesizeChunk(text: string, options?: TTSOptions): Promise<Buffer>;
}
-266
View File
@@ -1,266 +0,0 @@
"""
Web Content Extractor Module
Provides functionalities to fetch web content using headless Chrome,
sanitize HTML, and save results with comprehensive logging and error handling.
"""
import logging
import os
import time
import random
import string
from typing import Optional
from lxml.html.clean import Cleaner
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from bs4 import BeautifulSoup
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler()]
)
logger = logging.getLogger(__name__)
# Configuration constants
DEFAULT_CHROME_PATH = "run/webdriver/chrome"
DEFAULT_WEB_DRIVER_PATH = "run/webdriver/chromedriver"
DEFAULT_WAIT_TIME = 10
DEFAULT_OUTPUT_DIR = "run/tmp"
CHROME_OPTIONS = [
"--headless",
"--no-sandbox",
"--disable-dev-shm-usage",
"--disable-extensions",
"--disable-gpu",
"window-size=1920,1080"
]
CLEANER_CONFIG = {
'page_structure': True,
'meta': True,
'embedded': True,
'links': True,
'style': True,
'processing_instructions': True,
'inline_style': True,
'scripts': True,
'javascript': True,
'comments': True,
'frames': True,
'forms': True,
'annoying_tags': True,
'remove_unknown_tags': True,
'safe_attrs_only': True,
'safe_attrs': frozenset(['src', 'color', 'href', 'title', 'class', 'name', 'id']),
'remove_tags': ('span', 'font', 'div')
}
ALLOWED_TAGS = {
'img': ['src', 'alt', 'width', 'height'],
'a': ['href', 'title'],
'video': ['src', 'controls', 'width', 'height'],
'source': ['src', 'type']
}
UNWANTED_TAGS = [
'script', 'style', 'nav', 'footer', 'header', 'aside',
'form', 'iframe', 'noscript', 'svg', 'canvas', 'applet',
'object', 'embed'
]
def configure_chrome_driver() -> webdriver.Chrome:
"""Configure and return a Chrome WebDriver instance."""
try:
chrome_options = Options()
for option in CHROME_OPTIONS:
chrome_options.add_argument(option)
chrome_path = os.path.join(
DEFAULT_CHROME_PATH, 'chrome-linux64', 'chrome'
)
chrome_options.binary_location = chrome_path
driver_path = os.path.join(
DEFAULT_WEB_DRIVER_PATH, 'chromedriver'
)
chrome_service = Service(driver_path)
logger.info("Initializing Chrome WebDriver")
return webdriver.Chrome(
service=chrome_service,
options=chrome_options
)
except Exception as e:
logger.error("Chrome driver configuration failed")
raise RuntimeError("WebDriver initialization failed") from e
def extract_html_with_chromedriver(
url: str,
wait_time: int = DEFAULT_WAIT_TIME
) -> Optional[str]:
"""
Extract HTML content from a URL using headless Chrome.
Args:
url: Target URL to scrape
wait_time: Maximum wait time for page load (seconds)
Returns:
str: Raw HTML content or None if extraction fails
"""
driver = None
try:
driver = configure_chrome_driver()
logger.info(f"Fetching URL: {url}")
driver.get(url)
WebDriverWait(driver, wait_time).until(
EC.presence_of_element_located((By.TAG_NAME, 'body'))
)
logger.debug("Page load completed successfully")
return driver.page_source
except Exception as e:
logger.error(f"Failed to extract HTML: {str(e)}")
return None
finally:
if driver:
logger.debug("Closing WebDriver instance")
driver.quit()
def sanitize_html(dirty_html: str) -> str:
"""
Sanitize HTML content using lxml cleaner.
Args:
dirty_html: Raw HTML input
Returns:
str: Sanitized HTML output
"""
try:
logger.info("Sanitizing HTML content")
cleaner = Cleaner(**CLEANER_CONFIG)
return cleaner.clean_html(dirty_html)
except Exception as e:
logger.error(f"HTML sanitization failed: {str(e)}")
raise
def extract_content(html_content: str) -> Optional[str]:
"""
Extract and clean main content from HTML.
Args:
html_content: Raw HTML input
Returns:
str: Cleaned HTML content or None if processing fails
"""
try:
if not html_content:
logger.warning("Empty HTML content received")
return None
logger.info("Processing HTML content")
soup = BeautifulSoup(html_content, "html.parser")
# Remove unwanted tags
for tag in soup.find_all(UNWANTED_TAGS):
tag.decompose()
# Clean tag attributes
for tag in soup.find_all():
allowed = ALLOWED_TAGS.get(tag.name, [])
attrs = list(tag.attrs.keys())
for attr in attrs:
if attr not in allowed:
del tag[attr]
# Remove empty tags
for tag in soup.find_all():
if not tag.get_text(strip=True):
tag.decompose()
return str(soup)
except Exception as e:
logger.error(f"Content extraction failed: {str(e)}")
return None
def save_html_to_file(
html_content: str,
output_dir: str = DEFAULT_OUTPUT_DIR
) -> Optional[str]:
"""
Save HTML content to timestamped file.
Args:
html_content: Content to save
output_dir: Target directory path
Returns:
str: Path to saved file or None if save fails
"""
try:
os.makedirs(output_dir, exist_ok=True)
timestamp = time.strftime("%Y%m%d%H%M%S")
rand_str = ''.join(random.choices(string.ascii_letters + string.digits, k=8))
filename = f"output_{timestamp}_{rand_str}.html"
filepath = os.path.join(output_dir, filename)
logger.info(f"Saving output to: {filepath}")
with open(filepath, "w", encoding="utf-8") as f:
f.write(html_content)
return filepath
except Exception as e:
logger.error(f"File save failed: {str(e)}")
return None
def get_content_from_url(url: str) -> Optional[str]:
"""
Main pipeline: Fetch URL -> Extract Content -> Sanitize HTML
Args:
url: Target URL to process
Returns:
str: Processed HTML content or None if any step fails
"""
try:
raw_html = extract_html_with_chromedriver(url)
if not raw_html:
return None
sanitized = sanitize_html(raw_html)
return extract_content(sanitized)
except Exception as e:
logger.error(f"Processing pipeline failed: {str(e)}")
return None
if __name__ == "__main__":
target_url = "https://www.cncf.io/blog/2025/01/22/kubernetes-in-2025-are-you-ready-for-these-top-5-trends-and-predictions/"
if (result := get_content_from_url(target_url)) is not None:
file_path = save_html_to_file(result)
if file_path:
logger.info(f"Successfully saved output to: {file_path}")
else:
logger.error("Failed to save output file")
else:
logger.error("Content extraction failed for target URL")
View File
-192
View File
@@ -1,192 +0,0 @@
"""
LLM-based Podcast Script Converter
将HTML内容转换为播客风格的稿件,支持多种LLM模型接口。
"""
import os
import logging
from abc import ABC, abstractmethod
from typing import Optional, Dict, Literal
import html2text
# 第三方库导入(需要额外安装)
try:
from openai import OpenAI # OpenAI官方库
import google.generativeai as genai # Google Gemini
except ImportError:
pass # 运行时检查
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class LLMConverter(ABC):
"""LLM转换器抽象基类"""
@abstractmethod
def convert_to_podcast(self, html_content: str) -> Optional[str]:
"""将HTML内容转换为播客稿件"""
pass
class OpenAIConverter(LLMConverter):
"""ChatGPT转换器实现"""
def __init__(self, model: str = "gpt-4-turbo", temperature: float = 0.7):
self.client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
self.model = model
self.temperature = temperature
def convert_to_podcast(self, html_content: str) -> Optional[str]:
try:
# 将HTML转换为Markdown格式
markdown_content = html2text.html2text(html_content)
response = self.client.chat.completions.create(
model=self.model,
temperature=self.temperature,
messages=[
{
"role": "system",
"content": """你是一位专业播客编剧,需要将技术文章转换为生动有趣的播客稿件。要求:
1. 使用口语化表达,避免专业术语
2. 结构清晰,包含引言、主体和总结
3. 突出文章的核心观点和技术亮点
4. 适当添加过渡语和互动提示
5. 总长度控制在1500字左右"""
},
{
"role": "user",
"content": markdown_content
}
]
)
return response.choices[0].message.content
except Exception as e:
logger.error(f"OpenAI转换失败: {str(e)}")
return None
class GeminiConverter(LLMConverter):
"""Google Gemini转换器实现"""
def __init__(self, model: str = "gemini-pro", temperature: float = 0.7):
genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
self.model = genai.GenerativeModel(model)
self.temperature = temperature
def convert_to_podcast(self, html_content: str) -> Optional[str]:
try:
markdown_content = html2text.html2text(html_content)
response = self.model.generate_content(
f"[播客稿件转换要求同OpenAI版本]\n\n{markdown_content}",
generation_config=genai.types.GenerationConfig(
temperature=self.temperature
)
)
return response.text
except Exception as e:
logger.error(f"Gemini转换失败: {str(e)}")
return None
class DeepSeekConverter(LLMConverter):
"""深度求索转换器实现"""
def __init__(self, model: str = "deepseek-chat", temperature: float = 0.7):
self.client = OpenAI(
api_key=os.getenv("DEEPSEEK_API_KEY"),
base_url="https://api.deepseek.com/v1"
)
self.model = model
self.temperature = temperature
def convert_to_podcast(self, html_content: str) -> Optional[str]:
try:
markdown_content = html2text.html2text(html_content)
response = self.client.chat.completions.create(
model=self.model,
temperature=self.temperature,
messages=[
{"role": "system", "content": "[播客稿件转换要求同OpenAI版本]"},
{"role": "user", "content": markdown_content}
]
)
return response.choices[0].message.content
except Exception as e:
logger.error(f"DeepSeek转换失败: {str(e)}")
return None
class PodcastConverter:
"""播客稿件转换管理器"""
def __init__(
self,
provider: Literal["openai", "gemini", "deepseek"] = "openai",
model: Optional[str] = None,
temperature: float = 0.7
):
self.provider = provider
self.model = model
self.temperature = temperature
self.converter = self._init_converter()
def _init_converter(self) -> LLMConverter:
"""初始化指定的LLM转换器"""
try:
if self.provider == "openai":
return OpenAIConverter(
model=self.model or "gpt-4-turbo",
temperature=self.temperature
)
elif self.provider == "gemini":
return GeminiConverter(
model=self.model or "gemini-pro",
temperature=self.temperature
)
elif self.provider == "deepseek":
return DeepSeekConverter(
model=self.model or "deepseek-chat",
temperature=self.temperature
)
else:
raise ValueError("不支持的LLM提供商")
except Exception as e:
logger.error(f"转换器初始化失败: {str(e)}")
raise
def convert(self, html_content: str) -> Optional[str]:
"""执行转换流程"""
if not html_content:
logger.warning("输入内容为空")
return None
logger.info(f"开始转换,使用模型: {self.provider}/{self.model}")
return self.converter.convert_to_podcast(html_content)
# 使用示例
if __name__ == "__main__":
# 测试用HTML内容(实际应从文件或网络获取)
sample_html = """
<html>
<body>
<h1>人工智能的未来发展</h1>
<p>近年来,AI技术取得了突破性进展...</p>
<ul>
<li>自然语言处理的进步</li>
<li>计算机视觉的应用扩展</li>
<li>伦理问题的重要性</li>
</ul>
</body>
</html>
"""
# 使用OpenAI转换
openai_converter = PodcastConverter(provider="openai")
result = openai_converter.convert(sample_html)
if result:
print("转换结果示例:")
print(result[:500] + "...") # 打印前500字符
else:
print("转换失败")
View File
-87
View File
@@ -1,87 +0,0 @@
from flask import Flask, request, render_template, redirect, url_for
from ..modules.html_extractor.extract import get_content_from_url
from flask_dance.contrib.google import make_google_blueprint, google
app = Flask(__name__)
app.config['SECRET_KEY'] = 'your-secret-key-here'
app.config['PREFIX'] = '/proxy/5000'
app.config['GOOGLE_OAUTH_CLIENT_ID'] = 'your-client-id'
app.config['GOOGLE_OAUTH_CLIENT_SECRET'] = 'your-client-secret'
@app.route("/")
def index():
print("index!")
return render_template("index.html", prefix=app.config['PREFIX'],)
@app.route("/page")
def article_page():
url = request.args.get("url")
if not url:
return redirect(url_for('index'))
try:
content = get_content_from_url(url)
return render_template('page.html',
prefix=app.config['PREFIX'],
url=url,
summary=content,
audio_url="/static/audio/sample.mp3")
except Exception as e:
app.logger.error(f"Error processing {url}: {str(e)}")
return render_template('error.html',
error_message="Failed to process the URL"), 500
@app.route("/login", methods=['GET', 'POST'])
def login():
if request.method == 'POST':
# Add authentication logic here
return redirect(url_for('index'))
return render_template("login.html", prefix=app.config['PREFIX'])
@app.route("/register", methods=['GET', 'POST'])
def register():
if request.method == 'POST':
# Add registration logic here
return redirect(url_for('login'))
return render_template("register.html", prefix=app.config['PREFIX'])
@app.errorhandler(404)
def page_not_found(e):
return render_template('error.html', prefix=app.config['PREFIX'],
error_message="Page not found"), 404
@app.errorhandler(500)
def internal_error(e):
return render_template('error.html', prefix=app.config['PREFIX'],
error_message="Internal server error"), 500
google_bp = make_google_blueprint(
scope=["profile", "email"],
redirect_to="auth_callback"
)
app.register_blueprint(google_bp, url_prefix="/auth")
@app.route("/auth/callback")
def auth_callback():
if not google.authorized:
return redirect(url_for("login"))
resp = google.get("/oauth2/v2/userinfo")
if resp.ok:
user_info = resp.json()
# Handle user authentication logic here
return redirect(url_for("index"))
return redirect(url_for("login"))
@app.route("/auth/google")
def google_auth():
return redirect(url_for("google.login"))
if __name__ == "__main__":
app.run(port=5000, debug=True)
-818
View File
@@ -1,818 +0,0 @@
:root {
--primary: #6366f1;
--surface: #f8fafc;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
touch-action: manipulation;
}
body {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100dvh;
font-family: system-ui, sans-serif;
background: #f8fafc;
}
/* 头部样式 */
header {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
padding: 12px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
z-index: 2000;
}
.branding {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: 0 12px;
}
.brand-logo {
width: 32px;
height: 32px;
object-fit: contain;
}
.site-name {
font-weight: 600;
color: #1e293b;
font-size: 1.2rem;
white-space: nowrap;
}
.nav-group {
display: flex;
gap: 8px;
justify-self: end;
/* 右侧菜单保持右对齐 */
}
.history-button {
padding: 8px;
border: none;
background: none;
cursor: pointer;
justify-self: start;
}
.history-button img {
width: 32px;
height: 32px;
}
/* 菜单系统 */
.menu {
position: relative;
}
.menu-button {
padding: 8px;
border: none;
background: none;
cursor: pointer;
}
.menu-button img {
width: 32px;
height: 32px;
}
.menu-dropdown {
position: absolute;
right: 0;
top: 100%;
z-index: 100;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
display: none;
min-width: 160px;
}
.menu-dropdown.active {
display: block;
}
.menu-item {
padding: 12px 16px;
color: #334155;
text-decoration: none;
display: block;
border-bottom: 1px solid #f1f5f9;
}
/* 主容器设置 */
main {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 20px;
overflow: hidden;
}
/* 图片容器 */
.image-container {
position: relative;
width: min(60vw, 300px);
height: min(60vw, 300px);
/* 明确设置高度 */
margin: 0 auto 20px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
z-index: 90;
}
/* 图片绝对居中方案 */
.image-container img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
/* 文字内容容器 */
.text-content {
width: 80%;
max-width: 800px;
text-align: center;
}
/* 主容器设置 */
main {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
padding: 20px;
overflow: hidden;
}
/* 图片容器 */
.image-container {
position: relative;
width: min(60vw, 300px);
height: min(60vw, 300px);
/* 明确设置高度 */
margin: 0 auto 20px;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
/* 图片绝对居中方案 */
.image-container img {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
}
/* 文字内容容器 */
.text-content {
width: 80%;
max-width: 800px;
text-align: center;
}
.url {
font-size: 1.1em;
color: #3b82f6;
margin-bottom: 12px;
word-break: break-all;
}
.summary {
line-height: 1.5;
color: #64748b;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
/* 默认显示4行 */
overflow: hidden;
text-overflow: ellipsis;
--lines: 4;
/* 默认显示行数 */
--mobile-lines: 3;
/* 移动端行数 */
}
/* 响应式调整 */
@media (max-width: 768px) {
.text-content {
width: 90%;
/* 小屏幕更宽 */
}
.summary {
-webkit-line-clamp: 3;
/* 减少显示行数 */
}
}
/* 播放控制栏 */
footer {
display: flex;
align-items: center;
padding: 16px;
gap: 4px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
z-index: 2000;
}
.progress-bar {
flex-grow: 1;
height: 6px;
background: #e2e8f0;
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--primary);
width: 0%;
transition: width 0.1s linear;
}
.control-button {
padding: 8px;
border: none;
background: none;
cursor: pointer;
}
.control-button img {
width: 40px;
height: 40px;
}
/* 历史记录面板样式 */
.history-panel {
position: fixed;
top: -100%;
left: 0;
right: 0;
height: 100vh;
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(30px);
z-index: 2000;
transition: top 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
padding: 60px 16px 16px;
}
.history-panel.active {
top: 0;
}
/* 关闭按钮位置调整 */
.history-close {
position: absolute;
top: 12px;
left: 12px;
/* 改为左上角 */
padding: 8px;
background: none;
border: none;
cursor: pointer;
}
.history-close img {
width: 32px;
height: 32px;
}
/* 滚动容器 */
.history-content {
height: calc(100vh - 100px);
overflow-y: auto;
padding: 0 8px;
}
/* 历史记录项样式 */
.history-item {
padding: 12px;
margin: 8px 0;
background: rgba(0, 0, 0, 0.03);
border-radius: 8px;
transition: all 0.2s;
cursor: pointer;
display: flex;
align-items: center;
gap: 12px;
}
.history-item:hover {
background: rgba(0, 0, 0, 0.05);
}
/* 滚动条美化 */
.history-content::-webkit-scrollbar {
width: 6px;
}
.history-content::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.05);
border-radius: 3px;
}
.history-content::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.2);
border-radius: 3px;
}
.search-form {
display: flex;
align-items: center;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
}
.search-input {
flex-grow: 1;
border: none;
padding: 15px;
font-size: 16px;
outline: none;
color: #333;
}
.search-button {
background-color: #007bff;
/* Blue button color */
color: #fff;
border: none;
padding: 15px 20px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.search-button:hover {
background-color: #0069d9;
}
/* Optional: Add a magnifying glass icon */
.search-input::placeholder {
color: #999;
}
/* Responsive adjustments (optional) */
@media (max-width: 768px) {
.search-form {
width: 100%;
}
}
/* Add to main.css */
/* Auth System */
.auth-main {
display: flex;
justify-content: center;
align-items: center;
padding: 2rem;
min-height: calc(100dvh - 120px);
}
.auth-container {
background: white;
padding: 2.5rem;
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 420px;
margin: 0 auto;
}
.auth-title {
text-align: center;
color: #1e293b;
font-size: 1.5rem;
margin-bottom: 2rem;
}
.input-group {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.input-group input {
width: 100%;
padding: 12px 16px;
border: 1px solid #e2e8f0;
border-radius: 8px;
font-size: 1rem;
transition: border-color 0.2s;
}
.input-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.primary-button {
width: 100%;
padding: 14px;
background-color: var(--primary);
color: white;
border: none;
border-radius: 8px;
font-weight: 500;
cursor: pointer;
transition: opacity 0.2s;
}
.primary-button:hover {
opacity: 0.9;
}
.oauth-divider {
display: flex;
align-items: center;
gap: 1rem;
margin: 1.5rem 0;
color: #64748b;
}
.oauth-divider::before,
.oauth-divider::after {
content: '';
flex: 1;
height: 1px;
background: #e2e8f0;
}
.google-login {
width: 100%;
padding: 12px;
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
cursor: pointer;
transition: background 0.2s;
}
.google-login:hover {
background: #f8fafc;
}
.google-login img {
width: 20px;
height: 20px;
}
.auth-links {
margin-top: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
text-align: center;
}
.auth-links a {
color: var(--primary);
text-decoration: none;
font-size: 0.9rem;
}
.auth-links a:hover {
text-decoration: underline;
}
/* 统一搜索页面样式 */
.search-main {
display: flex;
justify-content: center;
align-items: center;
min-height: calc(100dvh - 120px);
padding: 2rem;
}
.search-container {
width: 100%;
max-width: 720px;
text-align: center;
}
.search-title {
font-size: 2rem;
color: #1e293b;
margin-bottom: 2rem;
}
.search-form {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.search-input {
padding: 16px 24px;
border: 2px solid #e2e8f0;
border-radius: 12px;
font-size: 1.1rem;
transition: all 0.3s ease;
}
.search-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2);
}
/* 统一按钮悬停效果 */
.primary-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2);
}
/* 历史面板动画优化 */
.history-panel {
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transform: translateY(-100%);
}
.history-panel.active {
transform: translateY(0);
}
/* 新增CSS样式 */
.search-textarea:focus {
outline: none;
box-shadow: 0 0 0 2px var(--primary);
}
.search-icon-button {
position: absolute;
right: 16px;
bottom: 16px;
background: var(--primary);
border: none;
border-radius: 12px;
padding: 12px;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
}
.search-icon-button:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.search-icon-button img {
width: 24px;
height: 24px;
filter: brightness(0) invert(1);
}
.search-options {
margin-top: 1.5rem;
display: flex;
gap: 1.5rem;
justify-content: center;
flex-wrap: wrap;
}
.option-checkbox {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
color: #475569;
}
.voice-selection select {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid #e2e8f0;
background: white;
font-size: 0.95rem;
}
/* 加载动画 */
.loading-indicator {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 16px 24px;
background: rgba(255, 255, 255, 0.95);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
align-items: center;
gap: 12px;
}
.loading-spinner {
width: 24px;
height: 24px;
border: 3px solid #e2e8f0;
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 验证状态提示 */
.invalid-input {
border-color: #ef4444 !important;
background: #fef2f2;
}
.validation-error {
color: #ef4444;
font-size: 0.9rem;
margin-top: 4px;
display: none;
}
/* 新增/修改的CSS */
.search-group {
border-radius: 16px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.search-textarea {
width: 100%;
padding: 20px;
border: none;
resize: none;
font-size: 1.1rem;
line-height: 1.6;
min-height: 150px;
background: #fff;
}
.search-actions {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background: #f8fafc;
border-top: 1px solid #e2e8f0;
}
.advanced-toggle {
display: flex;
align-items: center;
gap: 8px;
background: none;
border: none;
color: var(--primary);
cursor: pointer;
padding: 8px 12px;
border-radius: 8px;
transition: all 0.2s;
}
.advanced-toggle:hover {
background: rgba(99, 102, 241, 0.1);
}
.advanced-toggle img {
width: 14px;
height: 14px;
transition: transform 0.2s;
}
.search-icon-button {
background: var(--primary);
border: none;
border-radius: 12px;
padding: 10px 16px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
color: white;
transition: all 0.2s;
}
.search-icon-button:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
}
.search-icon-button img {
width: 20px;
height: 20px;
filter: brightness(0) invert(1);
}
/* 高级选项面板 */
.advanced-options {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
background: #fff;
border-radius: 0 0 16px 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
}
.advanced-options.active {
max-height: 400px; /* 根据内容调整 */
}
.option-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
padding: 20px;
}
.option-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.option-group label {
font-size: 0.9rem;
color: #475569;
font-weight: 500;
}
.option-group select,
.option-group input {
width: 100%;
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: white;
}
/* 开关样式 */
.switch {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.slider {
position: relative;
width: 40px;
height: 20px;
background: #e2e8f0;
border-radius: 10px;
transition: 0.4s;
}
.slider:before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background: white;
border-radius: 50%;
transition: 0.4s;
}
input:checked + .slider {
background: var(--primary);
}
input:checked + .slider:before {
transform: translateX(20px);
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

-29
View File
@@ -1,29 +0,0 @@
// history.js
const HistoryManager = {
MAX_ITEMS: 20,
init: function() {
if (!localStorage.history) {
localStorage.history = JSON.stringify([]);
}
},
add: function(item) {
const history = JSON.parse(localStorage.history);
history.unshift({
timestamp: new Date().toISOString(),
...item
});
localStorage.history = JSON.stringify(history.slice(0, this.MAX_ITEMS));
},
render: function(container) {
const history = JSON.parse(localStorage.history);
container.innerHTML = history.map(item => `
<div class="history-item">
<time>${new Date(item.timestamp).toLocaleString()}</time>
<p>${item.title}</p>
</div>
`).join('');
}
};
-216
View File
@@ -1,216 +0,0 @@
// static/js/shared.js
const App = {
init: function () {
this.bindMenuToggle();
this.bindGlobalClick();
this.initHistoryPanel();
this.initAdvancedOptions();
this.checkLoginStatus();
this.loadSettings();
},
// 菜单切换功能
bindMenuToggle: function () {
document.querySelectorAll('.menu-button').forEach(button => {
button.addEventListener('click', (e) => {
e.stopPropagation();
const menu = e.currentTarget.closest('.menu').querySelector('.menu-dropdown');
menu.classList.toggle('active');
});
});
},
// 全局点击处理
bindGlobalClick: function () {
document.addEventListener('click', (e) => {
// 关闭所有菜单
document.querySelectorAll('.menu-dropdown.active').forEach(menu => {
if (!menu.contains(e.target)) {
menu.classList.remove('active');
}
});
// 关闭高级选项
const advancedPanel = document.getElementById('advancedOptions');
if (advancedPanel &&
!e.target.closest('.advanced-options') &&
!e.target.closest('.advanced-toggle')) {
advancedPanel.classList.remove('active');
const toggleImg = document.querySelector('.advanced-toggle img');
if (toggleImg) toggleImg.style.transform = 'rotate(0deg)';
}
});
},
// 历史记录面板
initHistoryPanel: function () {
const historyButton = document.querySelector('.history-button');
const historyPanel = document.getElementById('historyPanel');
if (historyButton && historyPanel) {
historyButton.addEventListener('click', () => {
historyPanel.classList.toggle('active');
});
// 触摸滑动处理
let touchStartY = 0;
historyPanel.addEventListener('touchstart', e => {
touchStartY = e.touches[0].clientY;
}, false);
historyPanel.addEventListener('touchmove', e => {
const touchY = e.touches[0].clientY;
if (touchY - touchStartY > 50) {
historyPanel.classList.remove('active');
}
}, false);
}
},
// 高级选项功能
initAdvancedOptions: function () {
const toggleButton = document.querySelector('.advanced-toggle');
if (toggleButton) {
toggleButton.addEventListener('click', () => {
const optionsPanel = document.getElementById('advancedOptions');
optionsPanel.classList.toggle('active');
const img = toggleButton.querySelector('img');
img.style.transform = optionsPanel.classList.contains('active') ?
'rotate(180deg)' : 'rotate(0deg)';
});
}
},
// 登录状态检查
checkLoginStatus: function () {
const loginItem = document.getElementById('loginItem');
if (loginItem) {
// 实际应通过API检查登录状态
const isLoggedIn = false;
if (isLoggedIn) {
loginItem.textContent = 'Profile';
loginItem.href = '/profile';
}
}
},
// 设置管理
loadSettings: function () {
if (document.querySelector('[name="voice"]')) {
const settings = JSON.parse(localStorage.getItem('userSettings')) || {};
document.querySelector('[name="voice"]').value = settings.voice || 'male';
document.querySelector('[name="style"]').value = settings.style || 'news';
document.querySelector('[name="experimental"]').checked = settings.experimental || false;
}
},
saveSettings: function () {
if (document.querySelector('[name="voice"]')) {
const settings = {
voice: document.querySelector('[name="voice"]').value,
style: document.querySelector('[name="style"]').value,
experimental: document.querySelector('[name="experimental"]').checked
};
localStorage.setItem('userSettings', JSON.stringify(settings));
}
},
// 通用表单验证
validateRegistration: function (form) {
const password = form.querySelector('#password').value;
const confirmPassword = form.querySelector('#confirmPassword').value;
if (password !== confirmPassword) {
alert('Passwords do not match!');
return false;
}
return true;
},
// 播放器控制
initAudioPlayer: function () {
const audio = document.getElementById('audioPlayer');
if (!audio) return;
// 设置有效音频源
if (window.audioSource) {
audio.src = window.audioSource;
}
const progress = document.querySelector('.progress-fill');
const playButton = document.getElementById('playButton');
// 安全更新播放按钮状态
const updatePlayButton = () => {
if (!audio.paused && !isFinite(audio.duration)) {
return; // 防止无效状态
}
const iconPath = audio.paused ? 'play.png' : 'pause.png';
playButton.querySelector('img').src =
`${window.prefix}/static/icons/${iconPath}`;
};
// 音频元数据加载完成后再启用控制
audio.addEventListener('loadedmetadata', () => {
// 初始化进度条
progress.style.width = '0%';
// 绑定控制事件
document.querySelectorAll('#playButton').forEach(btn => {
btn.addEventListener('click', () => {
audio.paused ? audio.play().catch(console.error) : audio.pause();
});
});
document.querySelectorAll('.jump-back').forEach(btn => {
btn.addEventListener('click', () => this.jumpAudio(-15, audio));
});
document.querySelectorAll('.jump-forward').forEach(btn => {
btn.addEventListener('click', () => this.jumpAudio(15, audio));
});
});
// 错误处理
audio.addEventListener('error', (e) => {
console.error('Audio error:', e.target.error);
alert('Error loading audio file');
});
},
jumpAudio: function (seconds, audio) {
try {
if (!isFinite(audio.duration) || audio.readyState < 2) {
console.warn('Audio not ready');
return;
}
const newTime = Math.max(0, Math.min(
audio.currentTime + seconds,
audio.duration
));
audio.currentTime = newTime;
} catch (error) {
console.error('Jump error:', error);
}
}
};
// 初始化应用
document.addEventListener('DOMContentLoaded', () => {
App.init();
App.initAudioPlayer();
// 注册表单验证
const regForm = document.querySelector('.auth-form');
if (regForm) {
regForm.addEventListener('submit', (e) => {
if (!App.validateRegistration(regForm)) {
e.preventDefault();
}
});
}
});
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.
-69
View File
@@ -1,69 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Error - Be my Anchor</title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<!-- 左侧历史按钮 -->
<button class="history-button" onclick="toggleHistory()">
<img src="/proxy/5000/static/icons/clock.png" alt="History">
</button>
<!-- 中间Logo和标题 -->
<div class="branding">
<img src="/proxy/5000/static/logo.png" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<!-- 右侧菜单组 -->
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="/proxy/5000/static/icons/people.png" alt="Menu">
</button>
<!-- 菜单下拉内容 -->
<div class="menu-dropdown" id="mainMenu">
<a href="/" class="menu-item">返回主页</a>
<a href="/report" class="menu-item">报告问题</a>
<a href="/login" class="menu-item" id="loginItem">登录</a>
</div>
</div>
</div>
</header>
<!-- 历史记录面板结构 -->
<div class="history-panel" id="historyPanel">
<button class="history-close" onclick="toggleHistory()">
<img src="/proxy/5000/static/icons/play-pause.png" alt="Close">
</button>
<div class="history-content">
<div class="history-item">
<span class="timestamp">10:30</span>
<span class="title">文章标题示例 1</span>
</div>
<!-- 更多历史记录项... -->
<div class="history-item">
<span class="timestamp">11:45</span>
<span class="title">文章标题示例 20</span>
</div>
</div>
</div>
<main>
<div class="error-container">
<h2>Oops! Something went wrong</h2>
<p class="error-message">{{ error_message }}</p>
<a href="/" class="primary-button">Return Home</a>
</div>
</main>
</body>
</html>
-108
View File
@@ -1,108 +0,0 @@
<!-- ./src/server/templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Be my Anchor</title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<button class="history-button" onclick="toggleHistory()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
</button>
<div class="branding">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
</button>
<div class="menu-dropdown" id="mainMenu">
<a href="{{ prefix }}/login" class="menu-item">Login</a>
<a href="{{ prefix }}/register" class="menu-item">Register</a>
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
</div>
</div>
</div>
</header>
<!-- index.html 修改后的搜索部分 -->
<main class="search-main">
<div class="search-container">
<h1 class="search-title">Convert Content to Podcast</h1>
<form class="search-form" action="{{ prefix }}/page" method="get">
<!-- 搜索输入区 -->
<div class="search-group">
<textarea name="content" class="search-textarea" placeholder="Paste your text or enter URL..."
rows="4" required></textarea>
<div class="search-actions">
<button type="button" class="advanced-toggle" onclick="toggleAdvancedOptions()">
Advanced Options
<img src="{{ prefix }}/static/icons/chevron-down.svg" alt="▼">
</button>
<button type="submit" class="search-icon-button">
<img src="{{ prefix }}/static/icons/convert.svg" alt="Convert">
</button>
</div>
</div>
<!-- 高级选项下拉 -->
<div class="advanced-options" id="advancedOptions">
<div class="option-grid">
<div class="option-group">
<label>Voice Gender</label>
<select name="voice">
<option value="male">Male</option>
<option value="female">Female</option>
<option value="neutral">Neutral</option>
</select>
</div>
<div class="option-group">
<label>Style</label>
<select name="style">
<option value="news">News Report</option>
<option value="blog">Casual Blog</option>
<option value="story">Story Telling</option>
<option value="academic">Academic</option>
</select>
</div>
<div class="option-group">
<label>Custom Prompt</label>
<input type="text" name="prompt" placeholder="Additional instructions...">
</div>
<div class="option-group">
<label class="switch">
<input type="checkbox" name="experimental">
<span class="slider"></span>
<span>Experimental Mode</span>
</label>
</div>
</div>
</div>
</form>
</div>
</main>
<!-- 在body末尾添加 -->
<div class="loading-indicator">
<div class="loading-spinner"></div>
<span>Processing...</span>
</div>
</body>
</html>
-65
View File
@@ -1,65 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Login - Be my Anchor</title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<button class="history-button" onclick="toggleHistory()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
</button>
<div class="branding">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
</button>
<div class="menu-dropdown" id="mainMenu">
<a href="{{ prefix }}/" class="menu-item">Home</a>
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
<a href="{{ prefix }}/register" class="menu-item">Register</a>
</div>
</div>
</div>
</header>
<main class="auth-main">
<div class="auth-container">
<h2 class="auth-title">Welcome Back</h2>
<form class="auth-form" method="POST" action="/login">
<div class="input-group">
<input type="text" name="username" placeholder="Username" required>
<input type="password" name="password" placeholder="Password" required>
</div>
<button type="submit" class="primary-button">Login</button>
<div class="oauth-divider">
<span>or continue with</span>
</div>
<button type="button" class="google-login" onclick="loginWithGoogle()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/google.png') }}" alt="Google">
Continue with Google
</button>
</form>
<div class="auth-links">
<a href="{{ prefix }}/register">Create new account</a>
<a href="{{ prefix }}/forgot-password">Forgot password?</a>
</div>
</div>
</main>
</body>
</html>
-102
View File
@@ -1,102 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Be my Anchor - {{ url }} </title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script>
// 定义全局路径前缀
window.prefix = "{{ prefix }}";
// 初始化音频源
window.audioSource = "{{ prefix }}/{{ url_for('static', filename='test.mp3') }}";
</script>
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<!-- 左侧历史按钮 -->
<button class="history-button" onclick="toggleHistory()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
</button>
<!-- 中间Logo和标题 -->
<div class="branding">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<!-- 右侧菜单组 -->
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
</button>
<!-- 菜单下拉内容 -->
<div class="menu-dropdown" id="mainMenu">
<a href="{{ prefix }}/" class="menu-item">Home</a>
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
<a href="{{ prefix }}/login" class="menu-item" id="loginItem">Login</a>
</div>
</div>
</div>
</header>
<!-- 历史记录面板结构 -->
<div class="history-panel" id="historyPanel">
<button class="history-close" onclick="toggleHistory()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/play-pause.png') }}" alt="Close">
</button>
<div class="history-content">
<div class="history-item">
<span class="timestamp">10:30</span>
<span class="title">文章标题示例 1</span>
</div>
<!-- 更多历史记录项... -->
<div class="history-item">
<span class="timestamp">11:45</span>
<span class="title">文章标题示例 20</span>
</div>
</div>
</div>
<main>
<div class="image-container">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Cover">
</div>
<!-- 独立文字容器 -->
<div class="text-content">
<div class="url">{{url}}</div>
<div class="summary">{{summary}}</div>
</div>
</main>
<!-- page.html 修改后的按钮部分 -->
<footer>
<button class="control-button jump-back">
<img src="{{ prefix }}/static/icons/back.png" alt="-15s">
</button>
<button class="control-button" id="playButton">
<img src="{{ prefix }}/static/icons/play.png" alt="Play/Pause">
</button>
<div class="progress-bar">
<div class="progress-fill"></div>
</div>
<button class="control-button jump-forward">
<img src="{{ prefix }}/static/icons/fast.png" alt="+15s">
</button>
</footer>
<audio id="audioPlayer" src="{{ prefix }}/{{ url_for('static', filename='test.mp3') }}"></audio>
</body>
</html>
-61
View File
@@ -1,61 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-zoom=user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<title>Register - Be my Anchor</title>
<link rel="stylesheet" href="{{ prefix }}/{{ url_for('static', filename='css/main.css') }}">
<script src="{{ prefix }}/{{ url_for('static', filename='js/main.js') }}"></script>
</head>
<body>
<header>
<button class="history-button" onclick="handleHistoryButton()">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/clock.png') }}" alt="History">
</button>
<div class="branding">
<img src="{{ prefix }}/{{ url_for('static', filename='logo.png') }}" alt="Logo" class="brand-logo">
<span class="site-name">Be my Anchor</span>
</div>
<div class="nav-group">
<div class="menu">
<button class="menu-button" onclick="toggleMenu(event)">
<img src="{{ prefix }}/{{ url_for('static', filename='icons/people.png') }}" alt="Menu">
</button>
<div class="menu-dropdown" id="mainMenu">
<a href="{{ prefix }}/" class="menu-item">Home</a>
<a href="{{ prefix }}/login" class="menu-item">Login</a>
<a href="{{ prefix }}/report" class="menu-item">Report Issue</a>
</div>
</div>
</div>
</header>
<main class="auth-main">
<div class="auth-container">
<h2 class="auth-title">Create Account</h2>
<form class="auth-form" onsubmit="return validateRegistration(event)">
<div class="input-group">
<input type="text" id="username" placeholder="Username" required>
<input type="email" id="email" placeholder="Email" required>
<input type="password" id="password" placeholder="Password" required>
<input type="password" id="confirmPassword" placeholder="Confirm Password" required>
</div>
<button type="submit" class="primary-button">Register</button>
<div class="auth-links">
<span>Already have an account? <a href="{{ prefix }}/login">Login here</a></span>
</div>
</form>
</div>
</main>
</body>
</html>
+22
View File
@@ -0,0 +1,22 @@
import type { Config } from "tailwindcss";
const config: Config = {
darkMode: "class",
content: [
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
colors: {
slate: {
850: "#151f32",
},
},
},
},
plugins: [],
};
export default config;
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+14
View File
@@ -0,0 +1,14 @@
[
{
"title": "",
"content": ""
},
{
"title": "",
"content": ""
},
{
"title": "",
"content": ""
}
]
+14
View File
@@ -0,0 +1,14 @@
[
{
"title": "",
"content": ""
},
{
"title": "",
"content": ""
},
{
"title": "",
"content": ""
}
]
+14
View File
@@ -0,0 +1,14 @@
[
{
"title": "",
"content": ""
},
{
"title": "",
"content": ""
},
{
"title": "",
"content": ""
}
]
+14
View File
@@ -0,0 +1,14 @@
[
{
"title": "",
"content": ""
},
{
"title": "",
"content": ""
},
{
"title": "",
"content": ""
}
]
+14
View File
@@ -0,0 +1,14 @@
[
{
"title": "",
"content": ""
},
{
"title": "",
"content": ""
},
{
"title": "",
"content": ""
}
]
+202
View File
@@ -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()
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}