refactor: migrate project from Flask to Next.js with TypeScript, Prisma, and Edge TTS integration
@@ -0,0 +1,9 @@
|
|||||||
|
# Environment Variables Example
|
||||||
|
# Database URL (SQLite default for local development)
|
||||||
|
DATABASE_URL="file:./dev.db"
|
||||||
|
|
||||||
|
# Optional: OpenAI API Key (reserved for Milestone 2)
|
||||||
|
# OPENAI_API_KEY="sk-..."
|
||||||
|
|
||||||
|
# Optional: ElevenLabs API Key (reserved for Milestone 2)
|
||||||
|
# ELEVENLABS_API_KEY="..."
|
||||||
@@ -1,14 +1,38 @@
|
|||||||
# Virtual Environment
|
# Dependencies
|
||||||
venv/
|
node_modules/
|
||||||
.env
|
.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
# Build artifacts
|
# Testing
|
||||||
*.egg-info/
|
coverage/
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
build/
|
build/
|
||||||
dist/
|
dist/
|
||||||
|
|
||||||
# Test artifacts
|
# Environment files
|
||||||
.pytest_cache/
|
.env
|
||||||
__pycache__/
|
.env*.local
|
||||||
*.pyc
|
|
||||||
run/
|
# 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/
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
# Product Requirements Document (PRD): Be My Anchor
|
||||||
|
|
||||||
|
**Status**: Approved (Post Grill-Me)
|
||||||
|
**Target Version**: 2.0 (Full Refactor)
|
||||||
|
**Architecture**: TypeScript / Node.js Fullstack
|
||||||
|
**Primary Output**: `docs/prd.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary & Vision
|
||||||
|
|
||||||
|
**Be My Anchor** transforms written materials—technical blogs, research papers, news articles, novels, and documentation—into an engaging, listening-first multimedia experience.
|
||||||
|
|
||||||
|
Rather than relying on brittle, heavy web scrapers or monotone text-to-speech readers, Be My Anchor adopts an **API-first ingestion** model combined with:
|
||||||
|
|
||||||
|
1. **High-Fidelity Audio Generation**: Seamless multi-provider TTS (Edge TTS, OpenAI, ElevenLabs) with smart text chunking and audio stitching.
|
||||||
|
2. **Hearing-Front Content Adaptation**: An LLM-powered scriptwriting engine that adapts dense text into conversational spoken formats (e.g., Feynman-style technical breakdown or 2-host conversational dialogue).
|
||||||
|
3. **Synchronized Visual Companion**: A web player that dynamically surfaces referenced figures, diagrams, and code snippets in sync with the audio narration.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Core Philosophy & Phased Strategy
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Phase 1: Robust Audio Generation Core │
|
||||||
|
│ - Multi-provider TTS (Edge TTS, OpenAI, ElevenLabs) │
|
||||||
|
│ - Long-form text chunking & audio stitching │
|
||||||
|
│ - Basic web player & episode library │
|
||||||
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Phase 2: Hearing-Front Content Transformation │
|
||||||
|
│ - "Feynman" Deep-Dive Presenter Persona │
|
||||||
|
│ - Dual-Host Dialogue Persona (NotebookLM style) │
|
||||||
|
│ - Script stage cue tagging │
|
||||||
|
└──────────────────────────────┬──────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ Phase 3: Interactive Synchronized Multimedia Player │
|
||||||
|
│ - Timestamped visual cues (figures, code, diagrams) │
|
||||||
|
│ - Karaoke-style synced transcript │
|
||||||
|
│ - Stage-aware responsive visual pane │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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, breaking down equations, and explaining diagrams.
|
||||||
|
- **Visual Need**: When the narrator says *"Looking at Figure 2, the ingress traffic routes through..."*, Figure 2 automatically appears on screen.
|
||||||
|
|
||||||
|
### Persona 2: The Informed Professional ("The Dual-Host Dialogue")
|
||||||
|
|
||||||
|
- **Content**: Industry news, opinion pieces, market shifts, newsletters.
|
||||||
|
- **Narrator Style**: Two AI co-hosts bantering, asking each other rhetorical questions, and adding contextual background not directly present in the source article.
|
||||||
|
- **Visual Need**: Highlight cards, quotes, and source citations appearing on the dashboard.
|
||||||
|
|
||||||
|
### 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 / Assets) │
|
||||||
|
└─────────────┬──────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────────────────────┐
|
||||||
|
│ Hearing-Front Script Engine│
|
||||||
|
│ (LLM: OpenAI / Claude) │
|
||||||
|
│ - Rewrites text for ear │
|
||||||
|
│ - Generates Stage Cues │
|
||||||
|
└─────────────┬──────────────┘
|
||||||
|
│
|
||||||
|
Script with Stage Marks
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────────────────────┐
|
||||||
|
│ TTS Generation Core │
|
||||||
|
│ ┌──────────┐ ┌───────────┐ │
|
||||||
|
│ │ Edge TTS │ │OpenAI TTS │ │
|
||||||
|
│ └──────────┘ └───────────┘ │
|
||||||
|
│ ┌────────────────────────┐ │
|
||||||
|
│ │ ElevenLabs (Multi-Voice│ │
|
||||||
|
│ └────────────────────────┘ │
|
||||||
|
│ - Chunker & Audio Stitcher │
|
||||||
|
│ - Timing Aligner (VTT/JSON)│
|
||||||
|
└─────────────┬──────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────────┴───────────────────────────────────┐
|
||||||
|
│ Data & Storage │
|
||||||
|
│ - Database: Prisma ORM (MongoDB / PostgreSQL) │
|
||||||
|
│ - File Storage: Local static / S3-compatible (Audio MP3s & Figures) │
|
||||||
|
└───────────────────────────────────┬───────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌───────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Interactive Web Player (Next.js / TS) │
|
||||||
|
│ ┌───────────────────────┐ ┌───────────────────────────────────────┐ │
|
||||||
|
│ │ Audio Player UI │ │ Synchronized Visual Stage │ │
|
||||||
|
│ │ - Waveform / Scrubber│ │ - Figures / Diagrams from post │ │
|
||||||
|
│ │ - Speed / ±15s Skips │ │ - Syntax-highlighted code snippets │ │
|
||||||
|
│ │ - Live Transcript │ │ - Key takeaway summary cards │ │
|
||||||
|
│ └───────────────────────┘ └───────────────────────────────────────┘ │
|
||||||
|
└───────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Functional Requirements
|
||||||
|
|
||||||
|
### 5.1 Content Ingestion (API-Driven)
|
||||||
|
|
||||||
|
- Provide a clean REST API accepting:
|
||||||
|
- `title`: String
|
||||||
|
- `content`: Plain text or Markdown (including image references ``)
|
||||||
|
- `format_mode`: `'feynman'` | `'dual_host'` | `'verbatim'`
|
||||||
|
- `voice_config`: Provider preference (`edge` | `openai` | `elevenlabs`), voice IDs, speed
|
||||||
|
- `supplemental_materials`: Optional array of images/figures, captions, and links
|
||||||
|
- No requirement for headless Selenium or browser dependencies on the server.
|
||||||
|
|
||||||
|
### 5.2 Hearing-Front Content Transformation
|
||||||
|
|
||||||
|
- **Verbatim Cleaner**: Strips markdown artifacts, normalizes punctuation, phonetic hints for acronyms.
|
||||||
|
- **Feynman Script Generator**:
|
||||||
|
- Rewrites technical content into an oral lecture/walkthrough.
|
||||||
|
- Generates `[STAGE: figure_id]` or `[STAGE: code_block_id]` inline tags corresponding to visual assets.
|
||||||
|
- **Dual-Host Dialogue Generator**:
|
||||||
|
- Turns article content into an interactive conversation between Host A (e.g., inquisitive, relatable) and Host B (e.g., domain expert).
|
||||||
|
- Emits speaker-tagged lines: `SpeakerA: ...` and `SpeakerB: ...`.
|
||||||
|
|
||||||
|
### 5.3 TTS Engine & Audio Processing
|
||||||
|
|
||||||
|
- **Provider Abstraction Interface (`ITextToSpeechProvider`)**:
|
||||||
|
- `EdgeTTSProvider`: Free, instant, high-quality neural voices (`en-US-ChristopherNeural`, `en-US-JennyNeural`, etc.), zero API key required.
|
||||||
|
- `OpenAITTSProvider`: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`.
|
||||||
|
- `ElevenLabsProvider`: High-fidelity multi-voice assignment with voice cloning or distinct character voices for multi-host dialogue.
|
||||||
|
- **Long-Text Chunker**:
|
||||||
|
- Breaks script into sentence/clause chunks respecting provider character limits (e.g. 4096 chars).
|
||||||
|
- Handles speaker switching in dual-host mode without awkward pauses.
|
||||||
|
- **Audio Stitcher & Aligner**:
|
||||||
|
- Stitches chunk audio files into a unified master `.mp3`.
|
||||||
|
- Outputs a timing manifest (`timings.json` or WebVTT) recording the start and end timestamp of each paragraph, speaker turn, and stage marker.
|
||||||
|
|
||||||
|
### 5.4 Synchronized Web View & Media Player
|
||||||
|
|
||||||
|
- **Episode Library**:
|
||||||
|
- List of generated episodes with duration, creation date, mode, and status (`processing`, `ready`, `failed`).
|
||||||
|
- **Audio Player Controls**:
|
||||||
|
- Play / Pause, Progress scrubber, Current time / Total duration.
|
||||||
|
- Jump Back 15s (`-15s`), Jump Forward 15s (`+15s`).
|
||||||
|
- Playback speed selection (0.8x, 1.0x, 1.25x, 1.5x, 2.0x).
|
||||||
|
- **Synchronized Transcript Pane**:
|
||||||
|
- Interactive transcript that auto-scrolls and highlights the active paragraph/dialogue turn as audio plays.
|
||||||
|
- Clicking any paragraph seeks playback directly to that timestamp.
|
||||||
|
- **Synchronized Stage (Supplemental Visual Information)**:
|
||||||
|
- Responsive visual viewport alongside or above the player.
|
||||||
|
- When the audio reaches a tagged stage (e.g. timestamp 02:15):
|
||||||
|
- Automatically displays the relevant figure, architecture diagram, equation, or code snippet.
|
||||||
|
- Provides zoom/expand view for detailed inspection.
|
||||||
|
- Gracefully transitions to summary notes when no specific figure is active.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Data Model (Prisma Schema Specification)
|
||||||
|
|
||||||
|
```prisma
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql" // or "mongodb"
|
||||||
|
url = env("DATABASE_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
generator client {
|
||||||
|
provider = "prisma-client-js"
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ProcessingStatus {
|
||||||
|
PENDING
|
||||||
|
SCRIPTING
|
||||||
|
SYNTHESIZING
|
||||||
|
READY
|
||||||
|
FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ScriptMode {
|
||||||
|
VERBATIM
|
||||||
|
FEYNMAN
|
||||||
|
DUAL_HOST
|
||||||
|
}
|
||||||
|
|
||||||
|
model Episode {
|
||||||
|
id String @id @default(cuid()) @map("_id")
|
||||||
|
title String
|
||||||
|
originalContent String
|
||||||
|
scriptContent String?
|
||||||
|
mode ScriptMode @default(FEYNMAN)
|
||||||
|
status ProcessingStatus @default(PENDING)
|
||||||
|
durationSeconds Float?
|
||||||
|
audioUrl String?
|
||||||
|
timingsJson String? // Serialized timestamp cues
|
||||||
|
|
||||||
|
stages StageItem[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
model StageItem {
|
||||||
|
id String @id @default(cuid()) @map("_id")
|
||||||
|
episodeId String
|
||||||
|
timestampSeconds Float
|
||||||
|
type String // "IMAGE" | "CODE" | "CALLOUT"
|
||||||
|
title String?
|
||||||
|
content String // Image URL or Code snippet / Callout text
|
||||||
|
caption String?
|
||||||
|
|
||||||
|
episode Episode @relation(fields: [episodeId], references: [id], onDelete: Cascade)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. API Specification
|
||||||
|
|
||||||
|
### `POST /api/episodes`
|
||||||
|
|
||||||
|
Creates a new conversion job.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"title": "Understanding Kubernetes Ingress Controllers",
|
||||||
|
"content": "# Markdown content...",
|
||||||
|
"mode": "FEYNMAN",
|
||||||
|
"ttsProvider": "edge",
|
||||||
|
"voiceOptions": {
|
||||||
|
"primaryVoice": "en-US-ChristopherNeural",
|
||||||
|
"speed": 1.0
|
||||||
|
},
|
||||||
|
"visualAssets": [
|
||||||
|
{
|
||||||
|
"id": "fig-1",
|
||||||
|
"type": "IMAGE",
|
||||||
|
"url": "https://example.com/arch.png",
|
||||||
|
"caption": "Figure 1: Ingress routing architecture"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**: `202 Accepted` with `{ "episodeId": "clx...", "status": "PENDING" }`.
|
||||||
|
|
||||||
|
### `GET /api/episodes/:id`
|
||||||
|
|
||||||
|
Retrieves episode details, audio URL, processing status, transcript timings, and stage markers.
|
||||||
|
|
||||||
|
### `GET /api/episodes`
|
||||||
|
|
||||||
|
Lists all generated episodes for the library view.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Technical Stack
|
||||||
|
|
||||||
|
| Component | Technology | Rationale |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Runtime & Language** | Node.js (v20+) / TypeScript | Rapid prototyping, strong typing, zero Python venv/setup friction. |
|
||||||
|
| **Framework** | Next.js (App Router) | Unified backend API routes + reactive frontend player in one repo. |
|
||||||
|
| **Database & ORM** | Prisma + MongoDB or PostgreSQL | Lightweight document/relational storage matching user preference. |
|
||||||
|
| **Audio Generation** | `msedge-tts`, OpenAI API, ElevenLabs SDK | Free zero-key tier (Edge) + premium commercial options. |
|
||||||
|
| **Audio Processing** | `fluent-ffmpeg` or Web Audio APIs | Fast server-side audio stitching and silence injection. |
|
||||||
|
| **LLM Orchestration** | Vercel AI SDK / OpenAI SDK | Streaming script generation and structured stage extraction. |
|
||||||
|
| **Styling** | Tailwind CSS / Lucide Icons | Clean modern aesthetic, responsive audio controls. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Implementation Roadmap
|
||||||
|
|
||||||
|
### Phase 1: Core Audio Generation & Minimal Player (Week 1)
|
||||||
|
|
||||||
|
- [ ] Initialize Next.js + TypeScript project structure and Prisma configuration.
|
||||||
|
- [ ] Implement `ITextToSpeechProvider` interface with `EdgeTTSProvider` and `OpenAITTSProvider`.
|
||||||
|
- [ ] Build audio chunking, concatenation, and static file delivery.
|
||||||
|
- [ ] Implement basic Web Player UI (Play/Pause, Scrubber, Episode list).
|
||||||
|
|
||||||
|
### Phase 2: Hearing-Front Script Engine (Week 2)
|
||||||
|
|
||||||
|
- [ ] Implement prompt templates for:
|
||||||
|
- Feynman Master Presenter (single host, conversational breakdown).
|
||||||
|
- Dual-Host Dialogue (speaker-tagged turns).
|
||||||
|
- [ ] Parse speaker tags and route chunks to distinct voices in the TTS pipeline.
|
||||||
|
- [ ] Output synchronized paragraph/sentence timestamp manifests.
|
||||||
|
|
||||||
|
### Phase 3: Synchronized Visual Stages (Week 3)
|
||||||
|
|
||||||
|
- [ ] Extract or link visual assets (figures/diagrams/code) to script stages.
|
||||||
|
- [ ] Build the interactive Stage Viewer in the web player:
|
||||||
|
- Figure transitions locked to audio progress.
|
||||||
|
- Interactive transcript with click-to-seek.
|
||||||
|
- [ ] End-to-end testing with technical papers, blog posts, and news feeds.
|
||||||
@@ -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
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
import "./.next/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.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
serverExternalPackages: ["msedge-tts"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "be-my-anchor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Transform articles, tech blogs, and papers into a personalized listening experience with synchronized visual companion.",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev --webpack",
|
||||||
|
"build": "next build --webpack",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "next lint"
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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
|
||||||
|
scriptContent String?
|
||||||
|
mode String @default("VERBATIM") // VERBATIM | FEYNMAN | DUAL_HOST
|
||||||
|
status String @default("PENDING") // PENDING | PROCESSING | READY | FAILED
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -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"]
|
|
||||||
@@ -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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
-r requirements.txt
|
|
||||||
pytest>=7.0
|
|
||||||
black>=23.0
|
|
||||||
isort>=5.0
|
|
||||||
pip-chill>=1.0
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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) : [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: error.message || "Failed to fetch 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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { audioService } from "@/lib/tts/audio-service";
|
||||||
|
import { cleanHtmlTags } from "@/lib/tts/chunker";
|
||||||
|
|
||||||
|
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", provider = "edge", voice } = body;
|
||||||
|
|
||||||
|
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";
|
||||||
|
|
||||||
|
// 1. Create episode record in PENDING state
|
||||||
|
const episode = await prisma.episode.create({
|
||||||
|
data: {
|
||||||
|
title: sanitizedTitle,
|
||||||
|
originalContent: content.trim(),
|
||||||
|
mode,
|
||||||
|
status: "PROCESSING",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Synthesize audio asynchronously or inline
|
||||||
|
try {
|
||||||
|
const audioResult = await audioService.processAndSaveEpisode(
|
||||||
|
episode.id,
|
||||||
|
content.trim(),
|
||||||
|
{ provider, voice }
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedEpisode = await prisma.episode.update({
|
||||||
|
where: { id: episode.id },
|
||||||
|
data: {
|
||||||
|
status: "READY",
|
||||||
|
audioUrl: audioResult.audioUrl,
|
||||||
|
durationSeconds: audioResult.durationSeconds,
|
||||||
|
timingsJson: JSON.stringify(audioResult.timings),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
episode: updatedEpisode,
|
||||||
|
});
|
||||||
|
} catch (synthError: any) {
|
||||||
|
console.error(`Audio synthesis failed for episode ${episode.id}:`, synthError);
|
||||||
|
await prisma.episode.update({
|
||||||
|
where: { id: episode.id },
|
||||||
|
data: {
|
||||||
|
status: "FAILED",
|
||||||
|
errorMessage: synthError.message || "Audio synthesis failed",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: synthError.message || "Failed to synthesize audio",
|
||||||
|
episodeId: episode.id,
|
||||||
|
},
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("Episode creation error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, error: error.message || "Internal server error" },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbars */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #334155;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #475569;
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Be My Anchor - Listen to Any Article",
|
||||||
|
description:
|
||||||
|
"Convert written articles, tech blogs, and papers into a personalized listening experience with synchronized visual companion.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: Readonly<{
|
||||||
|
children: React.ReactNode;
|
||||||
|
}>) {
|
||||||
|
return (
|
||||||
|
<html lang="en" className="dark">
|
||||||
|
<body className="bg-slate-950 text-slate-100 min-h-screen flex flex-col antialiased">
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,468 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Radio,
|
||||||
|
PlusCircle,
|
||||||
|
Headphones,
|
||||||
|
Trash2,
|
||||||
|
FileText,
|
||||||
|
Sparkles,
|
||||||
|
Loader2,
|
||||||
|
Clock,
|
||||||
|
ExternalLink,
|
||||||
|
Volume2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { AudioPlayer } from "@/components/AudioPlayer";
|
||||||
|
import { TranscriptViewer } from "@/components/TranscriptViewer";
|
||||||
|
import { TimingCue, VoiceOption } from "@/lib/tts/types";
|
||||||
|
|
||||||
|
interface Episode {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
originalContent: string;
|
||||||
|
mode: string;
|
||||||
|
status: string;
|
||||||
|
durationSeconds?: number | null;
|
||||||
|
audioUrl?: string | null;
|
||||||
|
timingsJson?: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Home() {
|
||||||
|
const [episodes, setEpisodes] = useState<Episode[]>([]);
|
||||||
|
const [activeEpisode, setActiveEpisode] = useState<Episode | null>(null);
|
||||||
|
const [activeTimings, setActiveTimings] = useState<TimingCue[]>([]);
|
||||||
|
const [currentTime, setCurrentTime] = useState<number>(0);
|
||||||
|
const [seekTime, setSeekTime] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [content, setContent] = useState("");
|
||||||
|
const [voice, setVoice] = useState("zh-CN-XiaoxiaoNeural");
|
||||||
|
const [voices, setVoices] = useState<VoiceOption[]>([]);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [formError, setFormError] = useState("");
|
||||||
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
|
||||||
|
const handleContentChange = (val: string) => {
|
||||||
|
setContent(val);
|
||||||
|
if (/[\u4e00-\u9fa5]/.test(val) && voice.startsWith("en-")) {
|
||||||
|
setVoice("zh-CN-XiaoxiaoNeural");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch episodes on mount
|
||||||
|
const fetchEpisodes = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/episodes");
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.episodes) {
|
||||||
|
setEpisodes(data.episodes);
|
||||||
|
// Default to first episode if none selected
|
||||||
|
if (!activeEpisode && data.episodes.length > 0) {
|
||||||
|
selectEpisode(data.episodes[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load episodes:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch voices on mount
|
||||||
|
const fetchVoices = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/voices");
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success && data.voices) {
|
||||||
|
setVoices(data.voices);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load voices:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchEpisodes();
|
||||||
|
fetchVoices();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectEpisode = (ep: Episode) => {
|
||||||
|
setActiveEpisode(ep);
|
||||||
|
if (ep.timingsJson) {
|
||||||
|
try {
|
||||||
|
setActiveTimings(JSON.parse(ep.timingsJson));
|
||||||
|
} catch {
|
||||||
|
setActiveTimings([]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setActiveTimings([]);
|
||||||
|
}
|
||||||
|
setCurrentTime(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateEpisode = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!title.trim() || !content.trim()) {
|
||||||
|
setFormError("Please provide both a title and article content.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setFormError("");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/episodes", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
mode: "VERBATIM",
|
||||||
|
provider: "edge",
|
||||||
|
voice,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data.success) {
|
||||||
|
throw new Error(data.error || "Synthesis failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setTitle("");
|
||||||
|
setContent("");
|
||||||
|
setShowCreateForm(false);
|
||||||
|
|
||||||
|
// Refresh list & select newly created episode
|
||||||
|
await fetchEpisodes();
|
||||||
|
if (data.episode) {
|
||||||
|
selectEpisode(data.episode);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setFormError(err.message || "Failed to generate audio.");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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" });
|
||||||
|
if (activeEpisode?.id === id) {
|
||||||
|
setActiveEpisode(null);
|
||||||
|
setActiveTimings([]);
|
||||||
|
}
|
||||||
|
await fetchEpisodes();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete 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")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col pb-32">
|
||||||
|
{/* Top Navbar */}
|
||||||
|
<header className="border-b border-slate-800 bg-slate-900/60 backdrop-blur-md sticky top-0 z-40">
|
||||||
|
<div className="max-w-7xl 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-lg text-slate-100 tracking-tight">
|
||||||
|
Be My Anchor
|
||||||
|
</span>
|
||||||
|
<span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-blue-500/10 text-blue-400 border border-blue-500/20 font-mono">
|
||||||
|
v2.0 M1
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(!showCreateForm)}
|
||||||
|
className="flex items-center gap-2 px-3.5 py-1.5 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium transition shadow-sm shadow-blue-600/20"
|
||||||
|
>
|
||||||
|
<PlusCircle className="w-4 h-4" />
|
||||||
|
<span>New Episode</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content Area */}
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6 w-full flex-1">
|
||||||
|
{/* Ingest Form (Collapsible) */}
|
||||||
|
{showCreateForm && (
|
||||||
|
<div className="mb-8 p-6 bg-slate-900 border border-slate-800 rounded-2xl shadow-xl animate-in fade-in slide-in-from-top-4 duration-200">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-blue-400" />
|
||||||
|
<h2 className="text-lg font-semibold text-slate-100">
|
||||||
|
Convert Content to Podcast Episode
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(false)}
|
||||||
|
className="text-xs text-slate-400 hover:text-slate-200"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{formError && (
|
||||||
|
<div className="mb-4 p-3 bg-red-950/40 border border-red-800 text-red-300 text-sm rounded-lg">
|
||||||
|
{formError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleCreateEpisode} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-400 mb-1">
|
||||||
|
Article or Episode Title
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="e.g., Understanding Kubernetes Ingress and Service Meshes"
|
||||||
|
className="w-full px-3.5 py-2.5 bg-slate-800/80 border border-slate-700 rounded-lg text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-blue-500"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-400 mb-1">
|
||||||
|
TTS Voice Engine (Edge Neural)
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={voice}
|
||||||
|
onChange={(e) => setVoice(e.target.value)}
|
||||||
|
className="w-full px-3.5 py-2.5 bg-slate-800/80 border border-slate-700 rounded-lg text-sm text-slate-100 focus:outline-none focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<optgroup label="🇨🇳 中文 / Chinese (支持中英双语混合)">
|
||||||
|
{voices
|
||||||
|
.filter((v) => v.locale.startsWith("zh"))
|
||||||
|
.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="🇺🇸 English (US & UK)">
|
||||||
|
{voices
|
||||||
|
.filter((v) => !v.locale.startsWith("zh"))
|
||||||
|
.map((v) => (
|
||||||
|
<option key={v.id} value={v.id}>
|
||||||
|
{v.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-400 mb-1">
|
||||||
|
Narration Mode
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
disabled
|
||||||
|
className="w-full px-3.5 py-2.5 bg-slate-850 border border-slate-700/50 rounded-lg text-sm text-slate-400 cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<option value="VERBATIM">Verbatim Reader (M1 Active)</option>
|
||||||
|
<option value="FEYNMAN" disabled>Feynman Tutor (M2 Preview)</option>
|
||||||
|
<option value="DUAL_HOST" disabled>Dual-Host Dialogue (M2 Preview)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-400 mb-1">
|
||||||
|
Article Text or Markdown
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
value={content}
|
||||||
|
onChange={(e) => handleContentChange(e.target.value)}
|
||||||
|
placeholder="Paste your technical article, newsletter, blog post, or notes here..."
|
||||||
|
rows={6}
|
||||||
|
className="w-full px-3.5 py-2.5 bg-slate-800/80 border border-slate-700 rounded-lg text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:border-blue-500 font-mono text-xs leading-relaxed"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 pt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCreateForm(false)}
|
||||||
|
className="px-4 py-2 text-sm text-slate-400 hover:text-slate-200"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex items-center gap-2 px-5 py-2 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-800 text-white text-sm font-medium rounded-lg shadow-md shadow-blue-600/30 transition"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>Synthesizing Audio...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Headphones className="w-4 h-4" />
|
||||||
|
<span>Synthesize Episode</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dashboard Columns */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
||||||
|
{/* Left: Episode Library (5 cols) */}
|
||||||
|
<section className="lg:col-span-5 flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span>Episodes ({episodes.length})</span>
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{episodes.length === 0 ? (
|
||||||
|
<div className="p-8 text-center bg-slate-900/50 border border-dashed border-slate-800 rounded-2xl">
|
||||||
|
<Headphones className="w-10 h-10 text-slate-600 mx-auto mb-3" />
|
||||||
|
<h3 className="text-slate-200 font-medium text-sm mb-1">No episodes generated yet</h3>
|
||||||
|
<p className="text-xs text-slate-500 mb-4">
|
||||||
|
Paste a blog post or article to generate your first podcast episode!
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateForm(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-1.5 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-500"
|
||||||
|
>
|
||||||
|
<PlusCircle className="w-3.5 h-3.5" />
|
||||||
|
<span>Generate Episode</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{episodes.map((ep) => {
|
||||||
|
const isSelected = activeEpisode?.id === ep.id;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={ep.id}
|
||||||
|
onClick={() => selectEpisode(ep)}
|
||||||
|
className={`p-4 rounded-xl cursor-pointer transition-all border ${
|
||||||
|
isSelected
|
||||||
|
? "bg-slate-850 border-blue-500/60 shadow-md shadow-blue-500/5"
|
||||||
|
: "bg-slate-900/70 border-slate-800 hover:border-slate-700 hover:bg-slate-850/60"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h3 className={`text-sm font-medium truncate ${isSelected ? "text-blue-200" : "text-slate-200"}`}>
|
||||||
|
{ep.title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-3 mt-1.5 text-xs text-slate-500">
|
||||||
|
<span className="flex items-center gap-1 font-mono">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{formatDuration(ep.durationSeconds)}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span className="capitalize">{ep.mode.toLowerCase()}</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span
|
||||||
|
className={`px-1.5 py-0.2 rounded text-[10px] font-medium uppercase ${
|
||||||
|
ep.status === "READY"
|
||||||
|
? "bg-emerald-500/10 text-emerald-400"
|
||||||
|
: ep.status === "PROCESSING"
|
||||||
|
? "bg-amber-500/10 text-amber-400"
|
||||||
|
: "bg-red-500/10 text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ep.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleDeleteEpisode(ep.id, e)}
|
||||||
|
className="p-1.5 text-slate-500 hover:text-red-400 hover:bg-red-950/30 rounded-lg transition"
|
||||||
|
title="Delete episode"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Right: Active Episode & Transcript (7 cols) */}
|
||||||
|
<section className="lg:col-span-7 bg-slate-900/80 border border-slate-800 rounded-2xl p-6 min-h-[480px] flex flex-col">
|
||||||
|
{activeEpisode ? (
|
||||||
|
<div className="flex flex-col flex-1">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-slate-800 pb-4 mb-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-mono uppercase tracking-wider text-blue-400 bg-blue-950/60 px-2.5 py-1 rounded border border-blue-900">
|
||||||
|
Now Playing
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
Created {new Date(activeEpisode.createdAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-slate-100 mt-2">
|
||||||
|
{activeEpisode.title}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Synchronized Transcript */}
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-3 flex items-center gap-1.5">
|
||||||
|
<Volume2 className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
<span>Synchronized Transcript</span>
|
||||||
|
</h3>
|
||||||
|
<TranscriptViewer
|
||||||
|
timings={activeTimings}
|
||||||
|
currentTime={currentTime}
|
||||||
|
onSeek={(t) => setSeekTime(t)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-center text-slate-500 py-16">
|
||||||
|
<Radio className="w-12 h-12 mb-3 opacity-30 text-blue-400" />
|
||||||
|
<p className="text-sm">Select an episode from the library to listen and follow along.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Persistent Bottom Audio Player */}
|
||||||
|
{activeEpisode && activeEpisode.audioUrl && (
|
||||||
|
<AudioPlayer
|
||||||
|
title={activeEpisode.title}
|
||||||
|
audioUrl={activeEpisode.audioUrl}
|
||||||
|
durationSeconds={activeEpisode.durationSeconds}
|
||||||
|
onTimeUpdate={(t) => setCurrentTime(t)}
|
||||||
|
seekTime={seekTime}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
RotateCcw,
|
||||||
|
RotateCw,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
Gauge,
|
||||||
|
Radio,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
interface AudioPlayerProps {
|
||||||
|
title: string;
|
||||||
|
audioUrl: string;
|
||||||
|
durationSeconds?: number | null;
|
||||||
|
onTimeUpdate?: (currentTime: number) => void;
|
||||||
|
onSeek?: (seekTo: number) => void;
|
||||||
|
seekTime?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AudioPlayer({
|
||||||
|
title,
|
||||||
|
audioUrl,
|
||||||
|
durationSeconds,
|
||||||
|
onTimeUpdate,
|
||||||
|
seekTime,
|
||||||
|
}: AudioPlayerProps) {
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [duration, setDuration] = useState(durationSeconds || 0);
|
||||||
|
const [playbackRate, setPlaybackRate] = useState(1);
|
||||||
|
const [volume, setVolume] = useState(1);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
|
||||||
|
// Handle external seek requests (e.g. clicking transcript)
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
seekTime !== undefined &&
|
||||||
|
seekTime !== null &&
|
||||||
|
audioRef.current &&
|
||||||
|
Math.abs(audioRef.current.currentTime - seekTime) > 0.5
|
||||||
|
) {
|
||||||
|
audioRef.current.currentTime = seekTime;
|
||||||
|
setCurrentTime(seekTime);
|
||||||
|
if (!isPlaying) {
|
||||||
|
audioRef.current.play().catch(console.error);
|
||||||
|
setIsPlaying(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [seekTime]);
|
||||||
|
|
||||||
|
// Handle source changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
setIsPlaying(false);
|
||||||
|
setCurrentTime(0);
|
||||||
|
audioRef.current.load();
|
||||||
|
}
|
||||||
|
}, [audioUrl]);
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
if (isPlaying) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
setIsPlaying(false);
|
||||||
|
} else {
|
||||||
|
audioRef.current.play().then(() => setIsPlaying(true)).catch(console.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const jump = (seconds: number) => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
const newTime = Math.max(0, Math.min(audioRef.current.currentTime + seconds, duration || 999999));
|
||||||
|
audioRef.current.currentTime = newTime;
|
||||||
|
setCurrentTime(newTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTimeUpdate = () => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
const time = audioRef.current.currentTime;
|
||||||
|
setCurrentTime(time);
|
||||||
|
onTimeUpdate?.(time);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadedMetadata = () => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
if (audioRef.current.duration && isFinite(audioRef.current.duration)) {
|
||||||
|
setDuration(audioRef.current.duration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScrub = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const time = parseFloat(e.target.value);
|
||||||
|
setCurrentTime(time);
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.currentTime = time;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlaybackRate = () => {
|
||||||
|
const rates = [0.8, 1.0, 1.25, 1.5, 2.0];
|
||||||
|
const nextIdx = (rates.indexOf(playbackRate) + 1) % rates.length;
|
||||||
|
const newRate = rates[nextIdx];
|
||||||
|
setPlaybackRate(newRate);
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.playbackRate = newRate;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
if (!audioRef.current) return;
|
||||||
|
const newMuted = !isMuted;
|
||||||
|
setIsMuted(newMuted);
|
||||||
|
audioRef.current.muted = newMuted;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const val = parseFloat(e.target.value);
|
||||||
|
setVolume(val);
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.volume = val;
|
||||||
|
if (val === 0) {
|
||||||
|
setIsMuted(true);
|
||||||
|
} else if (isMuted) {
|
||||||
|
setIsMuted(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (secs: number) => {
|
||||||
|
if (isNaN(secs) || !isFinite(secs)) return "00:00";
|
||||||
|
const minutes = Math.floor(secs / 60);
|
||||||
|
const seconds = Math.floor(secs % 60);
|
||||||
|
return `${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 z-50 bg-slate-900/95 backdrop-blur-md border-t border-slate-800 shadow-2xl px-4 py-3 sm:px-6">
|
||||||
|
<audio
|
||||||
|
ref={audioRef}
|
||||||
|
src={audioUrl}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onLoadedMetadata={handleLoadedMetadata}
|
||||||
|
onEnded={() => setIsPlaying(false)}
|
||||||
|
preload="metadata"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="max-w-6xl mx-auto flex flex-col gap-2">
|
||||||
|
{/* Scrubber and Timestamps */}
|
||||||
|
<div className="flex items-center gap-3 w-full">
|
||||||
|
<span className="text-xs font-mono text-slate-400 w-12 text-right">
|
||||||
|
{formatTime(currentTime)}
|
||||||
|
</span>
|
||||||
|
<div className="relative flex-1 group">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max={duration || 100}
|
||||||
|
step="0.1"
|
||||||
|
value={currentTime}
|
||||||
|
onChange={handleScrub}
|
||||||
|
className="w-full h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-blue-500 hover:h-2 transition-all"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono text-slate-400 w-12">
|
||||||
|
{formatTime(duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Controls Bar */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{/* Track Info */}
|
||||||
|
<div className="flex items-center gap-3 w-1/3 min-w-0">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-blue-600/20 border border-blue-500/30 flex items-center justify-center text-blue-400 shrink-0">
|
||||||
|
<Radio className="w-5 h-5 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-medium text-slate-200 truncate">{title}</p>
|
||||||
|
<p className="text-xs text-slate-400 truncate">Be My Anchor • Episode</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Playback Buttons */}
|
||||||
|
<div className="flex items-center gap-3 sm:gap-4 justify-center">
|
||||||
|
<button
|
||||||
|
onClick={() => jump(-15)}
|
||||||
|
className="p-2 text-slate-400 hover:text-slate-200 transition hover:bg-slate-800 rounded-full"
|
||||||
|
title="Jump backward 15s"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={togglePlay}
|
||||||
|
className="p-3 bg-blue-600 hover:bg-blue-500 text-white rounded-full shadow-lg shadow-blue-600/30 transition-transform active:scale-95"
|
||||||
|
title={isPlaying ? "Pause" : "Play"}
|
||||||
|
>
|
||||||
|
{isPlaying ? <Pause className="w-5 h-5" /> : <Play className="w-5 h-5 ml-0.5" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => jump(15)}
|
||||||
|
className="p-2 text-slate-400 hover:text-slate-200 transition hover:bg-slate-800 rounded-full"
|
||||||
|
title="Jump forward 15s"
|
||||||
|
>
|
||||||
|
<RotateCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Aux Controls: Speed & Volume */}
|
||||||
|
<div className="flex items-center gap-3 w-1/3 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={togglePlaybackRate}
|
||||||
|
className="flex items-center gap-1 text-xs font-medium px-2 py-1 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded border border-slate-700 transition"
|
||||||
|
title="Adjust Speed"
|
||||||
|
>
|
||||||
|
<Gauge className="w-3.5 h-3.5 text-blue-400" />
|
||||||
|
<span>{playbackRate}x</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="hidden sm:flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={toggleMute}
|
||||||
|
className="text-slate-400 hover:text-slate-200 transition"
|
||||||
|
title={isMuted ? "Unmute" : "Mute"}
|
||||||
|
>
|
||||||
|
{isMuted || volume === 0 ? (
|
||||||
|
<VolumeX className="w-4 h-4 text-red-400" />
|
||||||
|
) : (
|
||||||
|
<Volume2 className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.05"
|
||||||
|
value={isMuted ? 0 : volume}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
className="w-16 h-1 bg-slate-700 rounded appearance-none cursor-pointer accent-blue-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
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 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const audioService = new AudioService();
|
||||||
@@ -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(/ /gi, " ")
|
||||||
|
.replace(/&/gi, "&")
|
||||||
|
.replace(/"/gi, '"')
|
||||||
|
.replace(/'|'/gi, "'")
|
||||||
|
.replace(/</gi, "<")
|
||||||
|
.replace(/>/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 
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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")
|
|
||||||
@@ -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("转换失败")
|
|
||||||
@@ -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)
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 20 KiB |
@@ -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('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
Before Width: | Height: | Size: 274 KiB |
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||