diff --git a/CHANGELOG.md b/CHANGELOG.md index d8bee9b3..8f1f965f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,9 @@ Per-PR attribution and contributor credits are published automatically on the co ## [Unreleased] ### Fixed -- Web UI waveform lifecycle cleanup: resize listeners/canvases no longer stack across generations, animation loop is start/stop safe, canvas resizes with the window. -- Web UI stream-to-file swap settles pending buffer operations so playback can't hang after seeking. +- Web UI improvements; better use of space, responsive components, stream-to-file swap settles pending buffer operations more cleanly. +- Web UI waveform lifecycle cleanup: waveform slowed and softened and made framerate-independent; respects `prefers-reduced-motion`. +- Downloads save as `{voice}_{timestamp}.{format}` instead of the temp name (#338). `/v1/download/{filename}` takes an optional `?name=` (sanitized, stored extension kept) and sets it in `Content-Disposition`, which also covers right-click "Save audio as". ## [v0.7.0] - 2026-07-31 ### Added diff --git a/README.md b/README.md index 2183c3c1..fd98e456 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # _`FastKoko`_ -![repoglyph](https://repoglyph.net/remsky/Kokoro-FastAPI.svg?palette=neon&commits=40&detail=15&branch=master&prefix=1&border=1&skip_dirs=ui%2Cexamples%2Cscripts%2Cdev%2Cdepr_tests) +![repoglyph](https://repoglyph.net/remsky/Kokoro-FastAPI.svg?palette=light&commits=40&detail=15&branch=master&prefix=1&border=1&skip_dirs=ui%2Cexamples%2Cscripts%2Cdev%2Cdepr_tests) [![Changelog](https://img.shields.io/badge/changelog-white)](./CHANGELOG.md) [![Tests](https://img.shields.io/badge/tests-100-darkgreen)]() [![Coverage](https://img.shields.io/badge/coverage-58%25-tan)]() @@ -157,9 +157,9 @@ with client.audio.speech.with_streaming_response.create( - Web Interface: http://localhost:8880/web -
- API Documentation - Web UI Screenshot +
+ Web UI Screenshot + API Documentation
diff --git a/api/src/routers/openai_compatible.py b/api/src/routers/openai_compatible.py index 42b4a761..c7d25786 100644 --- a/api/src/routers/openai_compatible.py +++ b/api/src/routers/openai_compatible.py @@ -11,7 +11,7 @@ import aiofiles import numpy as np import torch -from fastapi import APIRouter, Depends, Header, HTTPException, Request, Response +from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response from fastapi.responses import FileResponse, StreamingResponse from loguru import logger @@ -411,8 +411,37 @@ async def single_output(): ) +# everything outside this set is collapsed out of a client-supplied save-as name +_DOWNLOAD_NAME_UNSAFE = re.compile(r"[^A-Za-z0-9._-]+") +_DOWNLOAD_NAME_EXT = re.compile(r"\.[A-Za-z0-9]{1,5}$") +_DOWNLOAD_NAME_MAX_STEM = 100 + + +def _resolve_download_name(requested: str | None, stored_name: str) -> str: + """Resolve the save-as name for a download. + + Strips a client-supplied name to a safe charset and always keeps the stored + file's real extension. Falls back to the stored (temp) name. + """ + if not requested: + return stored_name + + stem = _DOWNLOAD_NAME_EXT.sub("", _DOWNLOAD_NAME_UNSAFE.sub("_", requested)) + stem = stem.strip("._-")[:_DOWNLOAD_NAME_MAX_STEM].strip("._-") + if not stem: + return stored_name + + return f"{stem}{os.path.splitext(stored_name)[1]}" + + @router.get("/download/{filename}") -async def download_audio_file(filename: str): +async def download_audio_file( + filename: str, + name: str | None = Query( + None, + description="Preferred save-as name. Sanitized; the stored file's extension is kept.", + ), +): """Download a generated audio file from temp storage""" try: from ..core.paths import _find_file, get_content_type @@ -425,14 +454,14 @@ async def download_audio_file(filename: str): # Get content type from path helper content_type = await get_content_type(file_path) + # browsers honor Content-Disposition over an anchor's download attribute + download_name = _resolve_download_name(name, os.path.basename(file_path)) + return FileResponse( file_path, media_type=content_type, - filename=filename, - headers={ - "Cache-Control": "no-cache", - "Content-Disposition": f"attachment; filename={filename}", - }, + filename=download_name, + headers={"Cache-Control": "no-cache"}, ) except FileNotFoundError: diff --git a/api/tests/test_openai_endpoints.py b/api/tests/test_openai_endpoints.py index f682c9f1..a122c1b0 100644 --- a/api/tests/test_openai_endpoints.py +++ b/api/tests/test_openai_endpoints.py @@ -12,6 +12,7 @@ from api.src.inference.base import AudioChunk from api.src.main import app from api.src.routers.openai_compatible import ( + _resolve_download_name, get_tts_service, load_openai_mappings, stream_audio_chunks, @@ -500,3 +501,77 @@ async def mock_error_stream(*args, **kwargs): writer.close() assert "Failed to initialize stream" in str(exc.value) + + +@pytest.mark.parametrize( + "requested,expected", + [ + (None, "tmprloey00i.mp3"), + ("", "tmprloey00i.mp3"), + ( + "af_bella_2026-08-01T12-30-00-000Z.mp3", + "af_bella_2026-08-01T12-30-00-000Z.mp3", + ), + ("af_bella+af_sky", "af_bella_af_sky.mp3"), + ("report.wav", "report.mp3"), # extension always comes from the stored file + ("../../etc/passwd", "etc_passwd.mp3"), + ("sub/dir/name", "sub_dir_name.mp3"), + ('bad";name', "bad_name.mp3"), + ("...", "tmprloey00i.mp3"), + ("x" * 200, f"{'x' * 100}.mp3"), + ], +) +def test_resolve_download_name(requested, expected): + """Client-supplied save-as names are sanitized and keep the stored extension""" + assert _resolve_download_name(requested, "tmprloey00i.mp3") == expected + + +@pytest.fixture +def temp_download_file(tmp_path): + """A stored temp audio file plus its patched temp dir""" + audio_file = tmp_path / "tmprloey00i.mp3" + audio_file.write_bytes(b"fake mp3 bytes") + with patch("api.src.routers.openai_compatible.settings") as mock_settings: + mock_settings.temp_file_dir = str(tmp_path) + yield audio_file + + +def test_download_uses_temp_name_by_default(temp_download_file): + """Without ?name= the stored temp name is served""" + response = client.get("/v1/download/tmprloey00i.mp3") + + assert response.status_code == 200 + assert response.headers["content-type"] == "audio/mpeg" + assert "tmprloey00i.mp3" in response.headers["content-disposition"] + + +def test_download_honors_requested_name(temp_download_file): + """?name= drives Content-Disposition so the save dialog shows a friendly name""" + response = client.get( + "/v1/download/tmprloey00i.mp3", + params={"name": "af_bella_2026-08-01T12-30-00-000Z.mp3"}, + ) + + assert response.status_code == 200 + disposition = response.headers["content-disposition"] + assert "af_bella_2026-08-01T12-30-00-000Z.mp3" in disposition + assert "tmprloey00i" not in disposition + + +def test_download_rejects_traversal_in_requested_name(temp_download_file): + """A path-like ?name= can't escape into a directory or swap the extension""" + response = client.get( + "/v1/download/tmprloey00i.mp3", params={"name": "../../evil.sh"} + ) + + assert response.status_code == 200 + disposition = response.headers["content-disposition"] + assert "evil.mp3" in disposition + assert "/" not in disposition and ".." not in disposition + + +def test_download_missing_file_returns_404(temp_download_file): + """Unknown temp names still 404""" + response = client.get("/v1/download/nope.mp3") + + assert response.status_code == 404 diff --git a/assets/docs-screenshot.png b/assets/docs-screenshot.png index d90bd5f9..572f2c9f 100644 Binary files a/assets/docs-screenshot.png and b/assets/docs-screenshot.png differ diff --git a/assets/webui-screenshot.png b/assets/webui-screenshot.png index 3fe9d267..6f0fd578 100644 Binary files a/assets/webui-screenshot.png and b/assets/webui-screenshot.png differ diff --git a/playwright.config.mjs b/playwright.config.mjs index 3e6c8e63..38cd03ae 100644 --- a/playwright.config.mjs +++ b/playwright.config.mjs @@ -1,14 +1,16 @@ import { defineConfig } from '@playwright/test'; +const port = Number(process.env.PLAYWRIGHT_STATIC_PORT || 4173); + export default defineConfig({ testDir: './web/tests/e2e', timeout: 30_000, use: { - baseURL: 'http://127.0.0.1:4173', + baseURL: `http://127.0.0.1:${port}`, }, webServer: { command: 'node web/tests/e2e/fixtures/static-server.mjs', - url: 'http://127.0.0.1:4173', + url: `http://127.0.0.1:${port}`, reuseExistingServer: true, timeout: 10_000, }, diff --git a/web/bmc-button.png b/web/bmc-button.png deleted file mode 100644 index fc581f23..00000000 Binary files a/web/bmc-button.png and /dev/null differ diff --git a/web/bmc-logo.svg b/web/bmc-logo.svg new file mode 100644 index 00000000..68c86fa4 --- /dev/null +++ b/web/bmc-logo.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/web/index.html b/web/index.html index e5ce5395..0d36650d 100644 --- a/web/index.html +++ b/web/index.html @@ -3,99 +3,94 @@ + FastKoko: Kokoro-based TTS - - - - - + + + - - + + -
+ -
-
-
- - HexGrad/Kokoro-82M on Hugging Face - -
-

FastKoko

-
-
-
+ +
+
+
+

FastKoko

+
+
+
+
+ +
+
- -
-
-
-
+ + +
+
+
-
-
- - -
- - - - -
- 0:00 -
-
-
-
-
-
- - - - -
-
+
+
0 characters
+
-
+
+ + +
+ +
+
+
+ + + + 0:00 +
+
+
+ +
-
-
-
- + diff --git a/web/siriwave.js b/web/siriwave.js index 022306c4..60550375 100644 --- a/web/siriwave.js +++ b/web/siriwave.js @@ -5,6 +5,7 @@ function SiriWave(opt) { this.phase = 0; this.run = false; this._frameId = null; + this._lastTs = null; this._boundDraw = this._draw.bind(this); // UI vars @@ -86,10 +87,16 @@ SiriWave.prototype._clear = function() { this.ctx.globalCompositeOperation = 'source-over'; }; -SiriWave.prototype._draw = function() { +SiriWave.prototype._draw = function(ts) { if (this.run === false) return; - this.phase = (this.phase + Math.PI*this.speed) % (2*Math.PI); + // speed is cycles/sec, a fixed per-frame step ran 2x on 120Hz displays + var dt = 1/60; + if (typeof ts === 'number') { + if (this._lastTs !== null) dt = Math.min((ts - this._lastTs) / 1000, 0.05); + this._lastTs = ts; + } + this.phase = (this.phase + 2*Math.PI*this.speed*dt) % (2*Math.PI); this._clear(); this._drawLine(-2, 'rgba(' + this.color + ',0.1)'); @@ -115,12 +122,14 @@ SiriWave.prototype.start = function() { // not idempotent without this guard, a second call would spawn a second loop if (this.run) return; this.phase = 0; + this._lastTs = null; this.run = true; this._draw(); }; SiriWave.prototype.stop = function() { this.phase = 0; + this._lastTs = null; this.run = false; this._cancelFrame(); }; diff --git a/web/src/App.js b/web/src/App.js index 78318faa..a7de9637 100644 --- a/web/src/App.js +++ b/web/src/App.js @@ -19,6 +19,7 @@ export class App { status: document.getElementById('status'), cancelBtn: document.getElementById('cancel-btn'), streamingNotice: document.getElementById('streaming-notice'), + charCount: document.getElementById('char-count'), cup: document.querySelector('.logo-container .cup') }; @@ -32,16 +33,20 @@ export class App { this.voiceService = new VoiceService(); this.renderVersionBadge(); + this.renderStarBadge(); // Initialize components this.playerControls = new PlayerControls(this.audioService, this.playerState); this.voiceSelector = new VoiceSelector(this.voiceService); this.waveVisualizer = new WaveVisualizer(this.playerState); - // Initialize text editor + // counter lives outside the component, in the editor status row const editorContainer = document.getElementById('text-editor'); this.textEditor = new TextEditor(editorContainer, { - linesPerPage: 20 + linesPerPage: 20, + onTextChange: (text) => { + this.elements.charCount.textContent = `${text.length} characters`; + } }); // Initialize voice selector @@ -57,6 +62,21 @@ export class App { this.applyBrowserStreamingNotice(); } + async renderStarBadge() { + const count = document.getElementById('gh-star-count'); + if (!count) return; + try { + const response = await fetch('https://api.github.com/repos/remsky/Kokoro-FastAPI'); + if (!response.ok) return; + const stars = (await response.json()).stargazers_count; + if (typeof stars !== 'number') return; + count.textContent = stars >= 1000 ? `${(stars / 1000).toFixed(1).replace(/\.0$/, '')}k` : `${stars}`; + count.hidden = false; + } catch (_) { + // leave hidden on failure + } + } + async renderVersionBadge() { const badge = document.getElementById('version-badge'); if (!badge) return; @@ -82,15 +102,15 @@ export class App { let message = ''; if (format === 'pcm') { - message = 'PCM output can be generated, but in-browser playback may be unsupported.'; + message = 'PCM may not play in-browser; download still works.'; } else if (format !== 'mp3') { - message = `${formatLabel} output will be generated, playback and/or download will be available when generation finishes.`; + message = `${formatLabel} plays/downloads once generation finishes.`; } else if (!this.audioService.supportsMSEMp3()) { message = isFirefox - ? 'Audio streaming is not currently supported in Firefox. Playback and/or download should stilll be available when generation finishes.' - : 'This browser may not support streaming. Playback and/or download should still be available when generation finishes.'; + ? 'No streaming in Firefox; playback/download ready once generation finishes.' + : 'Streaming may be unsupported here; playback/download ready once generation finishes.'; } else if (this.elements.autoplayToggle?.checked) { - message = 'Auto-play on: pause after generation completes to enable full seek/scrub.'; + message = 'Auto-play on; pause when done for full seek.'; } notice.textContent = message; @@ -101,8 +121,14 @@ export class App { // Generate button this.elements.generateBtn.addEventListener('click', () => this.generateSpeech()); - // Download button + // Download button (div with role=button, so handle keyboard activation too) this.elements.downloadBtn.addEventListener('click', () => this.downloadAudio()); + this.elements.downloadBtn.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.downloadAudio(); + } + }); // Keep browser/output warning aligned with the selected format and autoplay state this.elements.formatSelect.addEventListener('change', () => this.applyBrowserStreamingNotice()); @@ -266,14 +292,15 @@ export class App { } console.log('Starting download from:', downloadUrl); - - const format = this.elements.formatSelect.value; - const voice = this.voiceService.getSelectedVoiceString(); - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - + + // fallback only: the server's Content-Disposition wins when it's present + const name = this.audioService.getDownloadName(); + const a = document.createElement('a'); a.href = downloadUrl; - a.download = `${voice}_${timestamp}.${format}`; + if (name) { + a.download = name; + } document.body.appendChild(a); a.click(); document.body.removeChild(a); diff --git a/web/src/components/PlayerControls.js b/web/src/components/PlayerControls.js index 0bacba2c..6b71ab57 100644 --- a/web/src/components/PlayerControls.js +++ b/web/src/components/PlayerControls.js @@ -1,3 +1,6 @@ +const SPEED_MIN = 0.1; +const SPEED_MAX = 4; + export class PlayerControls { constructor(audioService, playerState) { this.audioService = audioService; @@ -6,8 +9,7 @@ export class PlayerControls { playPauseBtn: document.getElementById('play-pause-btn'), seekSlider: document.getElementById('seek-slider'), volumeSlider: document.getElementById('volume-slider'), - speedSlider: document.getElementById('speed-slider'), - speedValue: document.getElementById('speed-value'), + speedInput: document.getElementById('speed-input'), timeDisplay: document.getElementById('time-display'), cancelBtn: document.getElementById('cancel-btn') }; @@ -70,12 +72,16 @@ export class PlayerControls { } }); - // Seek slider - this.elements.seekSlider.addEventListener('mousedown', () => { + // Seek slider (pointer events cover mouse and touch drags) + this.elements.seekSlider.addEventListener('pointerdown', () => { this.elements.seekSlider.dragging = true; }); - this.elements.seekSlider.addEventListener('mouseup', () => { + this.elements.seekSlider.addEventListener('pointerup', () => { + this.elements.seekSlider.dragging = false; + }); + + this.elements.seekSlider.addEventListener('pointercancel', () => { this.elements.seekSlider.dragging = false; }); @@ -93,11 +99,20 @@ export class PlayerControls { this.playerState.setVolume(volume); }); - // Speed slider - this.elements.speedSlider.addEventListener('input', (e) => { + this.elements.speedInput.addEventListener('input', (e) => { const speed = parseFloat(e.target.value); - this.elements.speedValue.textContent = speed.toFixed(1); + if (speed >= SPEED_MIN && speed <= SPEED_MAX) { + this.playerState.setSpeed(speed); + } + }); + + this.elements.speedInput.addEventListener('change', (e) => { + const parsed = parseFloat(e.target.value); + const speed = Number.isFinite(parsed) + ? Math.min(SPEED_MAX, Math.max(SPEED_MIN, parsed)) + : this.playerState.getState().speed; this.playerState.setSpeed(speed); + e.target.value = speed.toFixed(1); }); // Cancel button @@ -152,9 +167,9 @@ export class PlayerControls { this.elements.volumeSlider.value = state.volume * 100; } - if (this.elements.speedSlider.value !== state.speed.toString()) { - this.elements.speedSlider.value = state.speed; - this.elements.speedValue.textContent = state.speed.toFixed(1); + if (document.activeElement !== this.elements.speedInput + && parseFloat(this.elements.speedInput.value) !== state.speed) { + this.elements.speedInput.value = state.speed.toFixed(1); } } diff --git a/web/src/components/TextEditor.js b/web/src/components/TextEditor.js index f3884cd8..5d5c1d87 100644 --- a/web/src/components/TextEditor.js +++ b/web/src/components/TextEditor.js @@ -15,6 +15,8 @@ export default class TextEditor { this.setupDOM(); this.bindEvents(); + // sync nav button disabled state so CSS can hide the row while single-page + this.updatePageDisplay(); } setupDOM() { @@ -47,10 +49,9 @@ export default class TextEditor { max="2000" title="Characters per page" > - chars/page - + /page +
-
0 characters
@@ -65,7 +66,6 @@ export default class TextEditor { fileInput: this.container.querySelector('.file-input'), uploadBtn: this.container.querySelector('.upload-btn'), clearBtn: this.container.querySelector('.clear-btn'), - charCount: this.container.querySelector('.char-count'), charsPerPage: this.container.querySelector('.chars-input'), formatBtn: this.container.querySelector('.format-btn') }; @@ -88,10 +88,9 @@ export default class TextEditor { this.updatePageDisplay(); } - // Update full text and char count - join with space since pages are just for UI + // pages are only a display split, join back into the full text this.fullText = this.pages.join(' '); - this.updateCharCount(); - + if (this.options.onTextChange) { this.options.onTextChange(this.fullText); } @@ -156,6 +155,9 @@ export default class TextEditor { if (value >= 100 && value <= 2000) { this.options.charsPerPage = value; this.splitIntoPages(this.fullText); + if (this.options.onTextChange) { + this.options.onTextChange(this.fullText); + } } }); } @@ -166,7 +168,6 @@ export default class TextEditor { this.fullText = ''; this.currentPage = 1; this.updatePageDisplay(); - this.updateCharCount(); return; } @@ -199,9 +200,8 @@ export default class TextEditor { // Keep current page in bounds this.currentPage = Math.min(this.currentPage, this.pages.length); } - + this.updatePageDisplay(); - this.updateCharCount(); } setText(text) { @@ -210,7 +210,6 @@ export default class TextEditor { this.pages = [text]; this.currentPage = 1; this.updatePageDisplay(); - this.updateCharCount(); } updatePageDisplay() { @@ -222,11 +221,6 @@ export default class TextEditor { this.elements.nextBtn.disabled = this.currentPage === this.pages.length; } - updateCharCount() { - const totalChars = this.fullText.length; - this.elements.charCount.textContent = `${totalChars} characters`; - } - prevPage() { if (this.currentPage > 1) { this.currentPage--; diff --git a/web/src/components/WaveVisualizer.js b/web/src/components/WaveVisualizer.js index 7f3ae07e..204aa0e2 100644 --- a/web/src/components/WaveVisualizer.js +++ b/web/src/components/WaveVisualizer.js @@ -19,8 +19,8 @@ export class WaveVisualizer { width: this.container.clientWidth, height: 100, autostart: false, - amplitude: 1, - speed: 0.03 + amplitude: 0.65, + speed: 0.25 }); // setWidth keeps the canvas and the clear rect in sync, a stale width leaves the uncovered strip un-erased @@ -63,7 +63,9 @@ export class WaveVisualizer { // start/stop only on transitions, a repeat start would reset the wave phase if (state.isPlaying && !this.wasPlaying) { - this.wave?.start(); + if (!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) { + this.wave?.start(); + } } else if (!state.isPlaying && this.wasPlaying) { this.wave?.stop(); } diff --git a/web/src/services/AudioService.js b/web/src/services/AudioService.js index b9d3f179..bb6ba383 100644 --- a/web/src/services/AudioService.js +++ b/web/src/services/AudioService.js @@ -13,6 +13,7 @@ export class AudioService { this.CHARS_PER_CHUNK = 150; this.MAX_LEAD_SECONDS = 60; this.serverDownloadPath = null; + this.downloadName = null; this.pendingOperations = []; this.objectUrl = null; this.chunkQueue = []; @@ -49,6 +50,26 @@ export class AudioService { this.audio.addEventListener('canplay', dispatchReady); } + attachAudioErrorEvents(mode) { + this.audio.addEventListener('error', (event) => { + const audioElement = event.target; + const errorCode = audioElement?.error?.code; + + console.error(`Audio error (${mode}):`, { + code: errorCode, + message: audioElement?.error?.message || 'Unknown audio error', + src: audioElement?.src, + networkState: audioElement?.networkState, + readyState: audioElement?.readyState + }); + + // an abort is user-initiated, not a playback failure + if (errorCode !== MediaError.MEDIA_ERR_ABORTED) { + this.dispatchEvent('playbackUnavailable'); + } + }); + } + async streamAudio(text, voice, speed, onProgress) { try { const canStreamMp3 = this.supportsMSEMp3(); @@ -68,6 +89,7 @@ export class AudioService { const estimatedChunks = Math.max(1, Math.ceil(this.textLength / this.CHARS_PER_CHUNK)); const responseFormat = document.getElementById('format-select').value || 'mp3'; const canUseMseStream = this.shouldUseMseStream(responseFormat, canStreamMp3); + this.downloadName = this.buildDownloadName(voice, responseFormat); const apiUrl = await config.getApiUrl('/v1/audio/speech'); const response = await fetch(apiUrl, { @@ -105,7 +127,7 @@ export class AudioService { const downloadPath = response.headers.get('x-download-path'); if (downloadPath) { - this.serverDownloadPath = `/v1${downloadPath}`; + await this.setDownloadPath(downloadPath); console.log('Download path received:', this.serverDownloadPath); } @@ -146,7 +168,7 @@ export class AudioService { const headers = Object.fromEntries(response.headers.entries()); const downloadPath = headers['x-download-path']; if (downloadPath) { - this.serverDownloadPath = await config.getApiUrl(`/v1${downloadPath}`); + await this.setDownloadPath(downloadPath); } onProgress?.(estimatedChunks, estimatedChunks); @@ -159,24 +181,7 @@ export class AudioService { this.audio.src = this.objectUrl; this.audio.load(); - this.audio.addEventListener('error', (event) => { - const audioElement = event.target; - const errorCode = audioElement?.error?.code; - const errorMessage = audioElement?.error?.message || 'Unknown audio error'; - - console.error('Audio error (block mode):', { - code: errorCode, - message: errorMessage, - src: audioElement?.src, - networkState: audioElement?.networkState, - readyState: audioElement?.readyState - }); - - // Don't dispatch playbackUnavailable for abort errors - if (errorCode !== MediaError.MEDIA_ERR_ABORTED) { - this.dispatchEvent('playbackUnavailable'); - } - }); + this.attachAudioErrorEvents('block mode'); this.audio.addEventListener('ended', () => { this.dispatchEvent('ended'); @@ -208,24 +213,7 @@ export class AudioService { this.objectUrl = URL.createObjectURL(this.mediaSource); this.audio.src = this.objectUrl; - this.audio.addEventListener('error', (event) => { - const audioElement = event.target; - const errorCode = audioElement?.error?.code; - const errorMessage = audioElement?.error?.message || 'Unknown audio error'; - - console.error('Audio error:', { - code: errorCode, - message: errorMessage, - src: audioElement?.src, - networkState: audioElement?.networkState, - readyState: audioElement?.readyState - }); - - // Don't dispatch playbackUnavailable for abort errors - if (errorCode !== MediaError.MEDIA_ERR_ABORTED) { - this.dispatchEvent('playbackUnavailable'); - } - }); + this.attachAudioErrorEvents('stream'); this.audio.addEventListener('ended', () => { this.dispatchEvent('ended'); @@ -279,7 +267,7 @@ export class AudioService { const downloadPath = headers['x-download-path']; if (downloadPath) { - this.serverDownloadPath = await config.getApiUrl(`/v1${downloadPath}`); + await this.setDownloadPath(downloadPath); console.log('Download path received:', this.serverDownloadPath); } else { console.warn('No X-Download-Path header found. Available headers:', @@ -745,6 +733,7 @@ export class AudioService { this.mediaSource = null; this.sourceBuffer = null; this.serverDownloadPath = null; + this.downloadName = null; this.rejectPendingOperations(new Error('AudioService cancelled')); this.chunkQueue = []; this.streamFinished = true; @@ -777,6 +766,7 @@ export class AudioService { this.mediaSource = null; this.sourceBuffer = null; this.serverDownloadPath = null; + this.downloadName = null; this.rejectPendingOperations(new Error('AudioService cleanup')); this.chunkQueue = []; this.streamFinished = true; @@ -786,6 +776,22 @@ export class AudioService { this.revokeObjectUrl(); } + // sent to the server so Content-Disposition carries it, which outranks a.download (#338) + buildDownloadName(voice, format) { + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const safeVoice = String(voice || '') + .replace(/[^A-Za-z0-9._-]+/g, '_') + .replace(/^[._-]+|[._-]+$/g, ''); + return `${safeVoice || 'speech'}_${stamp}.${format}`; + } + + async setDownloadPath(rawPath) { + const url = await config.getApiUrl(`/v1${rawPath}`); + this.serverDownloadPath = this.downloadName + ? `${url}?name=${encodeURIComponent(this.downloadName)}` + : url; + } + getDownloadUrl() { if (!this.serverDownloadPath) { console.warn('No download path available'); @@ -793,6 +799,10 @@ export class AudioService { } return this.serverDownloadPath; } + + getDownloadName() { + return this.downloadName; + } } export default AudioService; diff --git a/web/styles/badges.css b/web/styles/badges.css deleted file mode 100644 index 31386e04..00000000 --- a/web/styles/badges.css +++ /dev/null @@ -1,99 +0,0 @@ -.badges-container { - position: fixed; - top: 0; - left: 0; - right: 0; - padding: clamp(0.75rem, 1.5vh, 1rem) clamp(1rem, 2vw, 2rem); - display: flex; - justify-content: space-between; - align-items: center; - z-index: 100; - background: rgba(15, 23, 42, 0.95); - backdrop-filter: blur(12px); - border-bottom: 1px solid rgba(99, 102, 241, 0.2); - min-height: clamp(3.5rem, 6vh, 4.5rem); - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), - 0 2px 4px -1px rgba(0, 0, 0, 0.06); -} - -.badge { - height: clamp(24px, 3vh, 28px); - display: flex; - align-items: center; - transition: opacity 0.2s ease; - flex-shrink: 0; -} - -.logo-container { - display: flex; - align-items: center; - gap: clamp(0.5rem, 1vw, 1rem); - margin: 0 auto; - transform: translateX(-50%); - left: 50%; - position: absolute; -} - -@media (max-width: 768px) { - .badges-container { - padding: 0.75rem; - flex-wrap: wrap; - justify-content: center; - gap: 0.75rem; - min-height: clamp(4rem, 8vh, 5rem); - } - - .badge { - height: 24px; - } - - .badge iframe { - height: 24px !important; - max-width: 100%; - } - - .logo-container { - position: static; - transform: none; - margin: 0; - order: -1; - width: 100%; - justify-content: center; - margin-bottom: 0.5rem; - } -} - -.badge iframe { - height: 28px !important; -} - -.badge:hover { - opacity: 0.9; -} - -.badge img { - height: 100%; - border-radius: 4px; -} - -.version-badge { - position: fixed; - bottom: 0.75rem; - left: 0.75rem; - z-index: 100; - padding: 0.25rem 0.55rem; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-size: 0.75rem; - color: rgba(226, 232, 240, 0.7); - background: rgba(15, 23, 42, 0.7); - border: 1px solid rgba(99, 102, 241, 0.25); - border-radius: 999px; - text-decoration: none; - backdrop-filter: blur(8px); - transition: color 0.15s ease, border-color 0.15s ease; -} - -.version-badge:hover { - color: rgba(226, 232, 240, 1); - border-color: rgba(99, 102, 241, 0.6); -} diff --git a/web/styles/base.css b/web/styles/base.css index b01ab03c..cdb6320c 100644 --- a/web/styles/base.css +++ b/web/styles/base.css @@ -2,18 +2,16 @@ --bg-color: #0f172a; --fg-color: #6366f1; --surface: rgba(30, 41, 59, 1); + --card: rgba(30, 41, 59, 0.55); --text: #f8fafc; --text-light: #cbd5e1; --border: rgba(148, 163, 184, 0.2); + --border-accent: rgba(99, 102, 241, 0.25); --error: #ef4444; --success: #22c55e; --font-family: 'Inter', system-ui, sans-serif; -} - -html { - width: 100%; - height: 100%; - overflow-x: hidden; + color-scheme: dark; + accent-color: var(--fg-color); } * { @@ -22,34 +20,62 @@ html { box-sizing: border-box; } +html { + height: 100%; +} + body { font-family: var(--font-family); line-height: 1.6; color: var(--text); background: var(--bg-color); - min-height: 100vh; - position: relative; - padding: 0; width: 100%; - max-width: 100vw; overflow-x: hidden; - display: flex; - flex-direction: column; } -.overlay { +/* ── scene */ +.scene { position: fixed; inset: 0; - background: - radial-gradient(circle at top right, - var(--fg-color) 0%, - var(--bg-color) 100%); pointer-events: none; z-index: 0; + overflow: hidden; } -.grid-overlay { - position: fixed; +.scene .overlay { + position: absolute; + inset: 0; + background: + radial-gradient(circle at top right, + var(--fg-color) 0%, + var(--bg-color) 100%); + opacity: 0.9; +} + +/* sits low so the player dock reads as the horizon it sets behind */ +.scene .sun { + position: absolute; + left: 50%; + bottom: -140px; + transform: translateX(-50%); + width: min(48vw, 520px); + aspect-ratio: 1; + border-radius: 50%; + background: radial-gradient(circle at 50% 30%, + rgba(165, 180, 252, 0.85) 0%, + rgba(99, 102, 241, 0.55) 45%, + rgba(99, 102, 241, 0) 72%); + -webkit-mask-image: repeating-linear-gradient(to bottom, + black 0 13px, + transparent 13px 18px); + mask-image: repeating-linear-gradient(to bottom, + black 0 13px, + transparent 13px 18px); + opacity: 0.25; +} + +.scene .grid-overlay { + position: absolute; inset: 0; background-image: repeating-linear-gradient(0deg, @@ -62,123 +88,90 @@ body { rgba(255,255,255,0.03) 1px, transparent 1px, transparent 20px); - pointer-events: none; - z-index: 0; } -.container { - width: 100%; - max-width: min(1400px, 98vw); - margin: 0 auto; - display: flex; - flex-direction: column; - box-sizing: border-box; - padding: clamp(5rem, 8vh, 7rem) clamp(0.75rem, 2vw, 2rem) 2rem; - flex: 1; +.scene .scanline { + position: absolute; + left: 0; + right: 0; + top: -120px; + height: 120px; + background: linear-gradient(to bottom, + transparent, + rgba(148, 163, 184, 0.045), + transparent); + animation: scan 14s linear infinite; } -@media (max-width: 768px) { - .container { - padding-top: clamp(6rem, 10vh, 8rem); - padding-left: 0.75rem; - padding-right: 0.75rem; +@keyframes scan { + to { + transform: translateY(calc(100vh + 120px)); } } -main { - display: flex; - flex-direction: column; - gap: clamp(1rem, 2vh, 2rem); - min-width: 0; - width: 100%; - position: relative; - flex: 1; +/* ── shared bits */ +:focus-visible { + outline: 2px solid var(--fg-color); + outline-offset: 2px; +} + +.card-title { + font-size: 0.7rem; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--text-light); + margin-bottom: 0.5rem; } +/* takes no space until App.js applies a state class */ .status { - padding: 0.75rem 1rem; - border-radius: 0.25rem; - margin-bottom: 1rem; - transition: all 0.3s ease; - opacity: 0; + display: none; + width: 100%; + padding: 0.45rem 0.75rem; + border-radius: 0.35rem; font-weight: 500; + font-size: 0.8125rem; text-align: center; } +.status.info, +.status.error, +.status.success { + display: block; +} + .status.info { background: rgba(99, 102, 241, 0.1); border: 1px solid rgba(99, 102, 241, 0.2); - opacity: 1; } .status.error { background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.2); - opacity: 1; } .status.success { background: rgba(34, 197, 94, 0.1); border: 1px solid rgba(34, 197, 94, 0.2); - opacity: 1; } .streaming-notice { - padding: 0.625rem 0.875rem; - border-radius: 0.25rem; - background: rgba(234, 179, 8, 0.08); - border: 1px solid rgba(234, 179, 8, 0.25); - color: var(--text-light, #b9bcc7); - font-size: 0.8125rem; - line-height: 1.4; - margin-bottom: 0.5rem; + padding-left: 0.5rem; + border-left: 2px solid rgba(234, 179, 8, 0.45); + color: var(--text-light); + font-size: 0.75rem; + line-height: 1.35; + opacity: 0.85; } .streaming-notice[hidden] { display: none; } -.page-footer { - width: 100%; - padding: clamp(0.5rem, 1vh, 0.75rem) clamp(1rem, 2vw, 2rem); - display: flex; - justify-content: flex-end; - align-items: center; - background: rgba(15, 23, 42, 0.6); - border-top: 1px solid rgba(99, 102, 241, 0.15); - min-height: clamp(2.75rem, 5vh, 3.5rem); - position: relative; - z-index: 1; -} - -.bmc-link { - display: inline-block; - line-height: 0; - opacity: 0.85; - transition: opacity 0.2s ease, transform 0.2s ease; - border-radius: 8px; -} - -.bmc-link:hover, -.bmc-link:focus-visible { - opacity: 1; - transform: translateY(-1px); - outline: none; -} - -.bmc-link img { - height: 28px; - width: auto; - display: block; -} - -@media (max-width: 768px) { - .page-footer { - padding: 0.5rem 0.75rem; - min-height: 2.5rem; - justify-content: center; - } - .bmc-link img { - height: 24px; +@media (prefers-reduced-motion: reduce) { + .scene .scanline { + animation: none; + display: none; } } diff --git a/web/styles/controls.css b/web/styles/controls.css index 75bbcf2f..031d8d2e 100644 --- a/web/styles/controls.css +++ b/web/styles/controls.css @@ -1,334 +1,94 @@ -/* Controls Panel */ -.controls { +.settings-card { display: flex; flex-direction: column; - gap: 1rem; - background: var(--surface); - border: 1px solid var(--border); - border-radius: 0.35rem; - padding: clamp(0.75rem, 2vw, 1.25rem); - width: 100%; - min-width: 0; - height: fit-content; -} - -/* Voice Selection */ -.voice-select-container { - position: relative; - display: flex; - flex-direction: column; - gap: 0.5rem; - background: rgba(15, 23, 42, 0.3); - border: 1px solid var(--border); - border-radius: 0.25rem; - padding: 0.75rem; - width: 100%; - box-sizing: border-box; + gap: 0.6rem; } -.voice-search-wrapper { - position: relative; - width: 100%; -} - -.voice-search { - width: 100%; - padding: 0.5rem 0.75rem; - border: 1px solid var(--border); - border-radius: 0.25rem; - background: rgba(15, 23, 42, 0.3); - color: var(--text); - font-size: 1rem; - transition: all 0.2s ease; +.settings-split { + display: grid; + grid-template-columns: 4.25rem minmax(0, 1.15fr) minmax(0, 1fr); + gap: 0.6rem; } -.voice-search:focus { - outline: none; - border-color: var(--fg-color); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); -} - -.voice-search::placeholder { - color: var(--text-light); -} - -.selected-voices { +.setting-field { display: flex; flex-direction: column; - gap: 0.35rem; - padding: 0.75rem; - background: rgba(15, 23, 42, 0.3); - border: 1px solid var(--border); - border-radius: 0.2rem; - width: 100%; - box-sizing: border-box; -} - -.voice-dropdown { - visibility: hidden; - opacity: 0; - position: absolute; - top: calc(100% + 0.25rem); - left: 0; - right: 0; - background: var(--surface); - border: 1px solid var(--border); - border-radius: 0.25rem; - z-index: 999999; - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); - padding: 0.75rem; - transition: all 0.2s ease; - transform: translateY(-10px); - pointer-events: none; -} - -.voice-dropdown.show { - visibility: visible; - opacity: 1; - transform: translateY(0); - pointer-events: auto; -} - -.voice-options { - display: flex; - flex-direction: column; - gap: 0.5rem; - max-height: 320px; - overflow-y: auto; - padding-right: 0.5rem; - scrollbar-width: thin; - scrollbar-color: rgba(99, 102, 241, 0.2) transparent; -} - -.voice-options::-webkit-scrollbar { - width: 4px; -} - -.voice-options::-webkit-scrollbar-track { - background: transparent; -} - -.voice-options::-webkit-scrollbar-thumb { - background-color: rgba(99, 102, 241, 0.2); - border-radius: 2px; -} - -.voice-option { - display: flex; - align-items: center; - padding: 0.75rem 1rem; - cursor: pointer; - transition: all 0.2s ease; - color: var(--text); - border-radius: 0.2rem; - background: rgba(15, 23, 42, 0.3); - border: 1px solid var(--border); - font-size: 0.9375rem; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - user-select: none; -} - -.voice-option:hover { - background: rgba(99, 102, 241, 0.1); - border-color: var(--fg-color); - transform: translateX(2px); -} - -.voice-option.selected { - background: rgba(99, 102, 241, 0.2); - border-color: var(--fg-color); - padding-left: 0.75rem; -} - -.voice-option.selected::before { - content: "✓"; - margin-right: 0.5rem; - color: var(--fg-color); - font-weight: bold; -} - -.selected-voice-tag { - display: flex; - align-items: center; - padding: 0.25rem 0.45rem; - background: rgba(99, 102, 241, 0.2); - border: 1px solid rgba(99, 102, 241, 0.3); - border-radius: 0.2rem; - font-size: 0.8125rem; - gap: 0.5rem; - transition: all 0.2s ease; -} - -.selected-voice-tag .voice-name { - flex: 1; + gap: 0.25rem; min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.selected-voice-tag .voice-weight { - flex-shrink: 0; -} - -.selected-voice-tag:hover { - background: rgba(99, 102, 241, 0.3); - transform: translateX(2px); -} - -.selected-voice-tag input { - width: 3rem; - padding: 0.25rem; - background: transparent; - border: none; - color: inherit; - font-size: inherit; - text-align: left; - border-radius: 0.25rem; - transition: background-color 0.2s; - z-index: 1; } -.selected-voice-tag input:hover, -.selected-voice-tag input:focus { - background: rgba(99, 102, 241, 0.1); -} - -.remove-voice { - cursor: pointer; - opacity: 0.7; - transition: opacity 0.2s ease; - font-size: 1.2em; - line-height: 1; - padding: 0.25rem; - flex-shrink: 0; - z-index: 1; -} - -.remove-voice:hover { - opacity: 1; -} - -/* Speed Control */ -.speed-control { - display: flex; - flex-direction: column; - gap: 0.75rem; - padding: 0.75rem; - background: rgba(15, 23, 42, 0.3); - border: 1px solid var(--border); - border-radius: 0.25rem; -} - -.speed-control label { +.setting-field label { color: var(--text-light); - font-size: 0.875rem; -} - -.speed-control input[type="range"] { - width: 100%; - height: 4px; - -webkit-appearance: none; - background: rgba(99, 102, 241, 0.2); - border-radius: 2px; - outline: none; -} - -.speed-control input[type="range"]::-webkit-slider-thumb { - -webkit-appearance: none; - width: 16px; - height: 16px; - background: var(--fg-color); - border-radius: 50%; - cursor: pointer; - transition: transform 0.2s ease; -} - -.speed-control input[type="range"]::-webkit-slider-thumb:hover { - transform: scale(1.1); -} - -.speed-control input[type="range"]::-moz-range-thumb { - width: 16px; - height: 16px; - background: var(--fg-color); - border: none; - border-radius: 50%; - cursor: pointer; - transition: transform 0.2s ease; -} - -.speed-control input[type="range"]::-moz-range-thumb:hover { - transform: scale(1.1); -} - -/* Language Control */ -.lang-control { - display: flex; - flex-direction: column; - gap: 0.75rem; - padding: 0.75rem; - background: rgba(15, 23, 42, 0.3); - border: 1px solid var(--border); - border-radius: 0.25rem; -} - -.lang-control label { - color: var(--text-light); - font-size: 0.875rem; + font-size: 0.8125rem; } -.lang-select { +.speed-input, +.lang-select, +.format-select { background: rgba(15, 23, 42, 0.3); color: var(--text); border: 1px solid var(--border); border-radius: 0.25rem; - padding: 0.375rem 0.75rem; + padding: 0.375rem 0.6rem; font-family: var(--font-family); font-size: 0.875rem; cursor: pointer; transition: all 0.2s ease; + width: 100%; + min-width: 0; } -.lang-select:hover { +.speed-input:hover, +.lang-select:hover, +.format-select:hover { border-color: var(--fg-color); } -.lang-select:focus { +.speed-input:focus, +.lang-select:focus, +.format-select:focus { outline: none; border-color: var(--fg-color); box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); } -.lang-select option { +.speed-input { + font-variant-numeric: tabular-nums; + padding-right: 0.2rem; +} + +.lang-select option, +.format-select option { background: var(--surface); color: var(--text); } -/* Generation Controls */ +/* ── generate */ +.generate-card { + display: flex; + flex-direction: column; + gap: 0.6rem; +} + .button-group { display: flex; flex-direction: column; - gap: 0.75rem; - padding: 0.75rem; - background: rgba(15, 23, 42, 0.3); - border: 1px solid var(--border); - border-radius: 0.25rem; + gap: 0.5rem; } #generate-btn { background: var(--fg-color); color: var(--text); padding: 0.5rem 1rem; - border-radius: 0.25rem; + border-radius: 0.35rem; border: none; - font-weight: 500; + font-weight: 600; + font-size: 0.9375rem; cursor: pointer; transition: all 0.2s ease; width: 100%; - min-height: 36px; + min-height: 42px; + position: relative; display: flex; align-items: center; justify-content: center; @@ -340,54 +100,19 @@ box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2); } -.generation-options { - display: flex; - justify-content: space-between; - align-items: center; - gap: 1rem; - padding: 0.5rem 0; - margin-top: 0.5rem; - border-top: 1px solid var(--border); -} - -.generation-options label { - display: flex; - align-items: center; - gap: 0.5rem; - color: var(--text-light); - cursor: pointer; - font-size: 0.875rem; +#generate-btn:disabled { + cursor: default; } -.format-select { - background: rgba(15, 23, 42, 0.3); - color: var(--text); - border: 1px solid var(--border); - border-radius: 0.25rem; - padding: 0.375rem 0.75rem; - font-family: var(--font-family); - font-size: 0.875rem; - cursor: pointer; - transition: all 0.2s ease; - min-width: 100px; +#generate-btn:disabled:hover { + transform: none; + box-shadow: none; } -.format-select:hover { - border-color: var(--fg-color); -} - -.format-select:focus { - outline: none; - border-color: var(--fg-color); - box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); -} - -.format-select option { - background: var(--surface); - color: var(--text); +#generate-btn.loading .btn-text { + visibility: hidden; } -/* Loading Animation */ .loader { display: none; width: 24px; @@ -419,17 +144,6 @@ 100% { transform: rotate(360deg); } } -#generate-btn { - position: relative; - display: flex; - align-items: center; - justify-content: center; -} - -#generate-btn.loading .btn-text { - visibility: hidden; -} - #generate-btn.loading .loader { display: block; position: absolute; @@ -447,31 +161,26 @@ animation: spin 1.5s cubic-bezier(0.6, 0.2, 0.4, 0.8) infinite reverse; } -/* Responsive Styles */ -@media (max-width: 768px) { - .voice-options { - gap: 0.375rem; - } - - .voice-option { - padding: 0.5rem 0.75rem; - } - - .selected-voices { - padding: 0.5rem; - } - - .selected-voice-tag { - padding: 0.3rem 0.5rem; - } - - .generation-options { - flex-direction: column; - align-items: stretch; - gap: 0.5rem; - } +/* shares the status slot above generate; metrics match .status */ +#cancel-btn { + background: rgba(239, 68, 68, 0.12); + color: #fca5a5; + border: 1px solid rgba(239, 68, 68, 0.3); + padding: 0.45rem 0.75rem; + border-radius: 0.35rem; + font-weight: 500; + font-size: 0.8125rem; + font-family: var(--font-family); + line-height: normal; + cursor: pointer; + transition: all 0.2s ease; + width: 100%; +} - .format-select { - width: 100%; - } +#cancel-btn:hover { + background: rgba(239, 68, 68, 0.22); + border-color: rgba(239, 68, 68, 0.6); + color: #fecaca; + box-shadow: none; + transform: none; } diff --git a/web/styles/forms.css b/web/styles/editor.css similarity index 70% rename from web/styles/forms.css rename to web/styles/editor.css index d84d97aa..d99990c7 100644 --- a/web/styles/forms.css +++ b/web/styles/editor.css @@ -1,15 +1,41 @@ -/* Text Editor */ -.text-editor { +/* card chrome lives on the static wrapper so the status row renders inside it */ +.editor-card { + flex: 1; display: flex; flex-direction: column; - gap: 0.75rem; - background: var(--surface); + gap: 0.6rem; + min-width: 0; + min-height: 0; + background: var(--card); border: 1px solid var(--border); - border-radius: 0.35rem; + border-radius: 0.5rem; padding: clamp(0.75rem, 2vw, 1.25rem); +} + +.text-editor { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.75rem; width: 100%; min-width: 0; - box-sizing: border-box; + min-height: 0; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(99, 102, 241, 0.2) transparent; +} + +.text-editor::-webkit-scrollbar { + width: 6px; +} + +.text-editor::-webkit-scrollbar-thumb { + background: rgba(99, 102, 241, 0.2); + border-radius: 3px; +} + +.text-editor::-webkit-scrollbar-track { + background: transparent; } .editor-view { @@ -25,14 +51,14 @@ width: 100%; padding: 0.75rem; border: 1px solid var(--border); - border-radius: 0.25rem; + border-radius: 0.35rem; background: rgba(15, 23, 42, 0.3); color: var(--text); font-family: var(--font-family); font-size: 1rem; resize: vertical; transition: border-color 0.2s ease; - min-height: 300px; + min-height: 200px; box-sizing: border-box; min-width: 0; white-space: pre-wrap; @@ -49,77 +75,15 @@ display: flex; justify-content: center; align-items: center; - padding: 0.5rem; - border-bottom: 1px solid var(--border); - margin-bottom: 0.5rem; + padding: 0.25rem; background: rgba(15, 23, 42, 0.3); - border-radius: 0.25rem 0.25rem 0 0; -} - -.page-navigation .pagination { - transform: scale(0.9); -} - -.page-navigation .pagination button { - padding: 0.25rem 0.75rem; -} - -.editor-footer { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - align-items: center; - gap: 0.5rem 1rem; - padding: 0.5rem 0; -} - -.chars-per-page { - display: flex; - align-items: center; - gap: 0.5rem; -} - -.format-btn { - background: transparent; - border: 1px solid var(--border); - color: var(--text-light); - padding: 0.5rem 1rem; - border-radius: 0.25rem; - cursor: pointer; - transition: all 0.2s ease; - width: auto; - font-size: 0.875rem; - white-space: nowrap; -} - -.format-btn:hover { - background: rgba(99, 102, 241, 0.1); - border-color: var(--fg-color); - transform: none; - box-shadow: none; -} - -.chars-input { - width: 70px; - padding: 0.25rem 0.5rem; border: 1px solid var(--border); - border-radius: 0.25rem; - background: rgba(15, 23, 42, 0.3); - color: var(--text); - font-size: 0.875rem; - text-align: center; -} - -.chars-input:focus { - outline: none; - border-color: var(--fg-color); - box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); + border-radius: 0.35rem; } -.chars-label { - color: var(--text-light); - font-size: 0.875rem; - white-space: nowrap; +/* single page: both nav buttons disabled, hide the whole row */ +.page-navigation:has(.prev-btn:disabled):has(.next-btn:disabled) { + display: none; } .pagination { @@ -132,18 +96,16 @@ background: transparent; border: 1px solid var(--border); color: var(--text); - padding: 0.5rem 1rem; + padding: 0.25rem 0.75rem; border-radius: 0.25rem; cursor: pointer; transition: all 0.2s ease; - width: auto; + font-size: 0.875rem; } .pagination button:hover:not(:disabled) { background: rgba(99, 102, 241, 0.1); border-color: var(--fg-color); - transform: none; - box-shadow: none; } .pagination button:disabled { @@ -158,80 +120,108 @@ text-align: center; } -.char-count { - color: var(--text-light); - font-size: 0.875rem; - min-width: 100px; - text-align: right; - white-space: nowrap; +/* text buttons absorb the free space so the row fills edge to edge */ +.editor-footer { + display: flex; + flex-wrap: wrap; + justify-content: center; + align-items: center; + gap: 0.5rem; } .file-controls { display: flex; gap: 0.5rem; + flex: 1; } .upload-btn, .clear-btn { + flex: 1; + text-align: center; +} + +.upload-btn, +.clear-btn, +.format-btn { background: transparent; border: 1px solid var(--border); color: var(--text-light); - padding: 0.5rem 1rem; + padding: 0.4rem 0.9rem; border-radius: 0.25rem; cursor: pointer; transition: all 0.2s ease; - width: auto; font-size: 0.875rem; white-space: nowrap; } .upload-btn:hover, -.clear-btn:hover { +.clear-btn:hover, +.format-btn:hover { background: rgba(99, 102, 241, 0.1); border-color: var(--fg-color); - transform: none; - box-shadow: none; + color: var(--text); } -/* Scrollbar Styles */ -.text-editor { - scrollbar-width: thin; - scrollbar-color: rgba(99, 102, 241, 0.2) transparent; +.chars-per-page { + display: flex; + align-items: center; + gap: 0.5rem; } -.text-editor::-webkit-scrollbar { - width: 6px; +.chars-input { + width: 70px; + padding: 0.25rem 0.5rem; + border: 1px solid var(--border); + border-radius: 0.25rem; + background: rgba(15, 23, 42, 0.3); + color: var(--text); + font-size: 0.875rem; + text-align: center; } -.text-editor::-webkit-scrollbar-thumb { - background: rgba(99, 102, 241, 0.2); - border-radius: 3px; +.chars-input:focus { + outline: none; + border-color: var(--fg-color); + box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2); } -.text-editor::-webkit-scrollbar-track { - background: transparent; +.chars-label { + color: var(--text-light); + font-size: 0.875rem; + white-space: nowrap; } -/* Responsive Styles */ -@media (max-width: 768px) { - .editor-footer { - flex-direction: column; - gap: 0.75rem; - } +/* counter left, messages right; wraps at high zoom */ +.editor-status-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.35rem 1rem; +} - .chars-per-page { - width: 100%; - justify-content: space-between; +.editor-status-row .char-count { + margin-right: auto; + /* match the button text inset above (1px border + 0.9rem padding) */ + padding-left: calc(0.9rem + 1px); +} + +.char-count { + color: var(--text-light); + font-size: 0.875rem; + white-space: nowrap; +} + +@media (max-width: 900px) { + .text-editor { + overflow: visible; } - .file-controls { - width: 100%; - justify-content: space-between; + .page-content { + min-height: 260px; } - .upload-btn, - .clear-btn { - flex: 1; - text-align: center; + .chars-per-page { + justify-content: center; } } diff --git a/web/styles/header.css b/web/styles/header.css index 933e75da..30e7f09a 100644 --- a/web/styles/header.css +++ b/web/styles/header.css @@ -1,3 +1,22 @@ +.app-header { + background: rgba(15, 23, 42, 0.85); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border-accent); + position: relative; + z-index: 10; +} + +/* same width cap and padding as .app-shell so the badges end at the sidebar's edge */ +.header-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + max-width: 1500px; + margin: 0 auto; + padding: 0.6rem clamp(1rem, 2vw, 2rem); +} + .logo-container { display: flex; align-items: center; @@ -5,7 +24,7 @@ } h1 { - font-size: 1.75rem; + font-size: 1.6rem; font-weight: 700; margin: 0; line-height: 1; @@ -15,24 +34,103 @@ h1 { -webkit-background-clip: text; background-clip: text; color: var(--text); - text-shadow: - -1px -1px 0 rgba(0,0,0,0.5), + text-shadow: + -1px -1px 0 rgba(0,0,0,0.5), 1px -1px 0 rgba(0,0,0,0.5), -1px 1px 0 rgba(0,0,0,0.5), 1px 1px 0 rgba(0,0,0,0.5), 2px 2px var(--fg-color); } -@media (max-width: 768px) { - .logo-container { - gap: 0.5rem; - } - - h1 { - font-size: 1.5rem; - } +.version-badge { + padding: 0.15rem 0.5rem; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.7rem; + color: rgba(226, 232, 240, 0.7); + background: rgba(15, 23, 42, 0.7); + border: 1px solid var(--border-accent); + border-radius: 999px; + text-decoration: none; + transition: color 0.15s ease, border-color 0.15s ease; +} + +.version-badge:hover { + color: rgba(226, 232, 240, 1); + border-color: rgba(99, 102, 241, 0.6); +} + +.header-links { + display: flex; + align-items: center; + gap: 0.9rem; +} + +/* fixed width keeps the two badges matched */ +.badge { + width: 250px; + display: flex; + align-items: center; + transition: opacity 0.2s ease; + flex-shrink: 0; +} + +.badge:hover { + opacity: 0.9; +} + +.badge img { + width: 100%; + height: auto; + border-radius: 4px; + display: block; +} + +/* github star badge, primer dark-mode grays */ +.gh-star-badge { + --gh-gray-100: #c9d1d9; + --gh-gray-500: #484f58; + --gh-gray-600: #30363d; + --gh-gray-700: #21262d; + height: 22px; + /* grow past the matched width rather than ellipsize the full name */ + min-width: max-content; + align-items: stretch; + overflow: hidden; + border-radius: 4px; + border: 1px solid var(--gh-gray-500); + font-size: 0.6875rem; + font-weight: 600; + line-height: 1; + text-decoration: none; + white-space: nowrap; + color: var(--gh-gray-100); +} + +.gh-star-label { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0 0.6rem; + background: var(--gh-gray-700); +} + +.gh-repo { + overflow: hidden; + text-overflow: ellipsis; } +.gh-star-count { + display: flex; + align-items: center; + padding: 0 0.55rem; + background: var(--gh-gray-600); + border-left: 1px solid var(--gh-gray-500); + font-variant-numeric: tabular-nums; +} + +/* ── coffee cup */ .cup { width: 16px; height: 20px; @@ -107,3 +205,69 @@ h1 { border-color: #312e81; } } + +@media (prefers-reduced-motion: reduce) { + .cup.brewing .steam::before, + .cup.brewing .steam::after { + animation: none; + opacity: 0.5; + } + + .cup.done, + .cup.done .handle { + animation: none; + } +} + +@media (max-width: 900px) { + .header-inner { + gap: 0.6rem; + padding: 0.5rem 0.75rem; + } + + h1 { + font-size: 1.4rem; + } + + .logo-container { + gap: 0.5rem; + } + + .header-links { + flex: 0 1 205px; + min-width: 0; + flex-direction: column; + align-items: stretch; + gap: 0.3rem; + } + + .badge { + width: 100%; + } + + /* the stacked column can't grow, the owner prefix goes instead */ + .gh-star-badge { + height: 20px; + min-width: 0; + font-size: 0.625rem; + } + + .gh-owner { + display: none; + } + + .gh-star-label svg { + width: 13px; + height: 13px; + } +} + +@media (max-width: 360px) { + h1 { + font-size: 1.25rem; + } + + .version-badge { + display: none; + } +} diff --git a/web/styles/layout.css b/web/styles/layout.css index 8b93d910..b8d828fc 100644 --- a/web/styles/layout.css +++ b/web/styles/layout.css @@ -1,94 +1,117 @@ -/* Main Layout */ -main { +body { display: grid; - grid-template-columns: 1fr 320px; + grid-template-rows: auto minmax(0, 1fr) auto; + height: 100dvh; + overflow: hidden; +} + +.app-shell { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; gap: 1rem; - width: 80%; + width: 100%; + max-width: 1500px; margin: 0 auto; - min-width: 0; - height: calc(100vh - 8rem); + padding: 1rem clamp(1rem, 2vw, 2rem); + min-height: 0; + position: relative; + z-index: 1; } -/* Main Column */ -.main-column { +.editor-pane { display: flex; flex-direction: column; - gap: 1rem; - min-height: min-content; - height: auto; - overflow-y: auto; + min-width: 0; + min-height: 0; } -/* Text Editor Container */ -.text-editor { - min-height: 400px; - height: auto; - overflow: auto; - background: rgba(15, 23, 42, 0.3); - border: 1px solid var(--border); - border-radius: 0.35rem; - padding: 0.75rem; +#text-editor { + flex: 1; + display: flex; + min-width: 0; + min-height: 0; } -/* Controls Panel */ -.controls { +.side-pane { display: flex; flex-direction: column; - gap: 1rem; - width: 100%; - height: 100%; + min-height: 0; overflow-y: auto; + background: var(--card); + border: 1px solid var(--border); + border-radius: 0.5rem; scrollbar-width: thin; scrollbar-color: rgba(99, 102, 241, 0.2) transparent; } -.controls::-webkit-scrollbar { - width: 6px; +.side-pane > section { + padding: 0.7rem 0.85rem; } -.controls::-webkit-scrollbar-track { - background: transparent; +/* voices card absorbs leftover height so the pane meets the editor's bottom edge */ +.side-pane > .voice-card { + flex: 1; + display: flex; + flex-direction: column; + min-height: min-content; } -.controls::-webkit-scrollbar-thumb { - background-color: rgba(99, 102, 241, 0.2); - border-radius: 3px; +/* fixed-height cards: a status message borrows height from the voice list, not these */ +.side-pane > .settings-card, +.side-pane > .generate-card { + flex-shrink: 0; } -/* Controls Sections */ -.voice-select-container, -.speed-control, -.button-group { - width: 100%; - background: rgba(15, 23, 42, 0.3); - border: 1px solid var(--border); - border-radius: 0.25rem; - padding: 0.75rem; +/* keep generate reachable even when the pane has to scroll */ +.side-pane > .generate-card { + display: flex; + flex-direction: column; + gap: 0.55rem; + position: sticky; + bottom: 0; + background: rgba(30, 41, 59, 0.92); + backdrop-filter: blur(8px); } -/* Player Container */ -.player-container { - background: rgba(15, 23, 42, 0.3); - border: 1px solid var(--border); - border-radius: 0.35rem; - padding: 0.75rem; +.side-pane > section + section { + border-top: 1px solid var(--border); } -/* Responsive Layout */ -@media (max-width: 768px) { - main { - grid-template-columns: 1fr; - gap: 0.5rem; - width: 95%; +.side-pane::-webkit-scrollbar { + width: 6px; +} + +.side-pane::-webkit-scrollbar-track { + background: transparent; +} + +.side-pane::-webkit-scrollbar-thumb { + background-color: rgba(99, 102, 241, 0.2); + border-radius: 3px; +} + +@media (max-width: 900px) { + body { + display: flex; + flex-direction: column; height: auto; + min-height: 100dvh; + overflow: visible; } - .text-editor { - min-height: 300px; + .app-shell { + grid-template-columns: 1fr; + padding: 0.75rem; + gap: 0.75rem; } - .controls { - max-height: none; + .side-pane { overflow: visible; } + + /* pane doesn't scroll here and the dock is already sticky */ + .side-pane > .generate-card { + position: static; + backdrop-filter: none; + } } diff --git a/web/styles/player.css b/web/styles/player.css index fa687e3c..83cee602 100644 --- a/web/styles/player.css +++ b/web/styles/player.css @@ -1,87 +1,112 @@ -.player-container { - display: grid; - grid-template-columns: minmax(0, 1fr); - grid-template-rows: auto auto; - gap: 0.75rem; - position: relative; +/* sticky lives here, not layout.css: this file loads later and wins the cascade */ +.player-dock { + position: sticky; + bottom: 0; + z-index: 5; + border-top: 1px solid var(--border-accent); + background: rgba(15, 23, 42, 0.85); + backdrop-filter: blur(10px); + padding: 1.1rem clamp(1rem, 2vw, 2rem) 0.85rem; +} + +.wave-container { + position: absolute; + inset: 0; + z-index: 0; overflow: hidden; - min-width: 0; - box-sizing: border-box; + pointer-events: none; +} + +.wave-container canvas { + position: absolute; + top: 0; + left: 0; width: 100%; - padding: clamp(0.75rem, 1.5vw, 1.25rem); - background: var(--surface); - border: 1px solid var(--border); - border-radius: 0.35rem; + height: 100%; + opacity: 0.7; } -@media (max-width: 768px) { - .player-container { - padding: 0.75rem; - gap: 0.75rem; - } +.generation-progress { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 3px; + margin: 0; + background: rgba(99, 102, 241, 0.2); + border: none; + border-radius: 0; + position: absolute; + top: 0; + left: 0; +} + +.generation-progress::-webkit-progress-bar { + background: rgba(99, 102, 241, 0.2); +} + +.generation-progress::-webkit-progress-value { + background: var(--fg-color); + transition: width 0.2s ease; +} + +.generation-progress::-moz-progress-bar { + background: var(--fg-color); + transition: width 0.2s ease; } .player-controls { + position: relative; + z-index: 1; display: grid; - grid-template-columns: auto minmax(100px, 1fr) auto auto auto; + grid-template-columns: auto auto minmax(120px, 1fr) auto auto auto auto; align-items: center; gap: clamp(0.5rem, 1vw, 1rem); width: 100%; - height: 44px; - padding: 0.5rem 0.5rem; - border-radius: 0.35rem; - background: rgba(15, 23, 42, 0.2); - min-width: 0; + max-width: 1500px; + margin: 0 auto; + padding: 0.45rem 0.65rem; + border-radius: 0.5rem; + background: rgba(15, 23, 42, 0.55); border: 1px solid var(--border); + min-width: 0; } -@media (max-width: 768px) { - .player-controls { - grid-template-columns: auto 1fr auto; - grid-template-rows: auto auto; - height: auto; - gap: 0.5rem; - padding: 0.5rem; - } - - .volume-control { - grid-column: 2; - justify-self: end; - border-left: none; - padding-left: 0; - gap: 0.25rem; - } - - .time-display { - grid-column: 3; - border-left: none; - padding-left: 0; - min-width: 55px; - } +.player-btn { + background: var(--fg-color); + color: var(--text); + padding: 0.375rem 0.75rem; + border-radius: 0.25rem; + border: none; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + height: 28px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + min-width: 54px; +} - .seek-slider { - grid-column: 1 / -1; - grid-row: 2; - margin: 0.25rem 0; - } +.player-btn:hover:not(:disabled) { + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2); +} - .player-btn { - padding: 0.25rem 0.5rem; - min-width: 45px; - height: 26px; - font-size: 0.875rem; - } +.player-btn:disabled { + opacity: 0.5; + cursor: default; } .seek-slider, .volume-slider { -webkit-appearance: none; + appearance: none; height: 4px; border-radius: 2px; background: rgba(99, 102, 241, 0.2); outline: none; cursor: pointer; - transition: height 0.2s ease-in-out; } .seek-slider { @@ -89,6 +114,11 @@ min-width: 0; } +.seek-slider:disabled { + cursor: default; + opacity: 0.6; +} + .volume-slider { width: 80px; } @@ -96,6 +126,7 @@ .seek-slider::-webkit-slider-thumb, .volume-slider::-webkit-slider-thumb { -webkit-appearance: none; + appearance: none; width: 12px; height: 12px; border-radius: 50%; @@ -125,246 +156,43 @@ transform: scale(1.2); } -.volume-control { - display: flex; - align-items: center; - gap: 0.5rem; - padding-left: 0.75rem; - border-left: 1px solid rgba(99, 102, 241, 0.2); - min-width: 0; - box-sizing: border-box; -} - -@media (max-width: 768px) { - .volume-control { - gap: 0.25rem; - } - - .volume-slider { - width: 60px; - } - - .player-btn { - padding: 0.375rem 0.75rem; - min-width: 50px; - height: 28px; - font-size: 0.875rem; - } -} - -.volume-icon { - color: var(--fg-color); - opacity: 0.8; - transition: opacity 0.2s ease; +.autoplay-toggle { display: flex; align-items: center; -} - -.volume-icon:hover { - opacity: 1; -} - -.player-btn { - background: var(--fg-color); - color: var(--text); - padding: 0.375rem 0.75rem; - border-radius: 0.25rem; - border: none; - font-weight: 500; + gap: 0.35rem; + color: var(--text-light); + font-size: 0.8125rem; + white-space: nowrap; cursor: pointer; - transition: all 0.2s ease; - height: 28px; - line-height: 1; - display: flex; - align-items: center; - justify-content: center; - min-width: 54px; -} - -.player-btn:hover { - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(99, 102, 241, 0.2); -} - -/* Cancel Button Styles */ -.player-btn.cancel { - background: #976161; - color: #070707; - border: 1px solid #ffb3b3; -} - -.player-btn.cancel:hover { - background: #ffbaba; - box-shadow: 0 4px 12px rgba(255, 87, 87, 0.2); -} - -.wave-container { - width: 100%; - height: 60px; - background: rgba(15, 23, 42, 0.3); - border-radius: 0.25rem; - overflow: hidden; - position: relative; - display: flex; - align-items: center; - justify-content: center; - min-width: 0; -} - -@media (max-width: 768px) { - .wave-container { - height: 32px; - } - - .download-button { - bottom: 0.5rem; - right: 0.5rem; - width: 26px; - height: 26px; - } - - .download-icon { - width: 26px; - height: 26px; - } - - .download-placeholder { - width: 26px; - height: 26px; - margin: 0.25rem; - } - - .generation-progress { - height: 3px; - } -} - -/* Generation Controls Responsive Fixes */ -@media (max-width: 768px) { - .button-group { - display: flex; - flex-direction: column; - gap: 0.5rem; - width: 100%; - } - - .generation-options { - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; - gap: 0.5rem; - flex-wrap: wrap; - } - - .format-select { - min-width: 80px; - } - - #generate-btn { - width: 100%; - min-height: 40px; - } -} - -/* Ensure all controls stay within bounds */ -#text-editor { - min-width: 0; - width: 100%; -} - -.controls { - display: flex; - flex-direction: column; - gap: 1rem; - min-width: 0; - width: 100%; -} - -.voice-select-container { - min-width: 0; - width: 100%; -} - -.options { - min-width: 0; - width: 100%; -} - -.button-group { - min-width: 0; - width: 100%; -} - -.generation-progress { - -webkit-appearance: none; - appearance: none; - width: 100%; - height: 4px; - margin: 0; - background: rgba(99, 102, 241, 0.2); - border: none; - border-radius: 2px; - position: absolute; - bottom: 0; - left: 0; -} - -.generation-progress::-webkit-progress-bar { - background: rgba(99, 102, 241, 0.2); - border-radius: 2px; -} - -.generation-progress::-webkit-progress-value { - background: var(--fg-color); - border-radius: 2px; - transition: width 0.2s ease; -} - -.generation-progress::-moz-progress-bar { - background: var(--fg-color); - border-radius: 2px; - transition: width 0.2s ease; } .time-display { font-size: 0.875rem; color: var(--text-light); - text-align: right; + text-align: center; font-variant-numeric: tabular-nums; - padding-left: 0.75rem; - border-left: 1px solid rgba(99, 102, 241, 0.2); min-width: 70px; - box-sizing: border-box; + white-space: nowrap; } -@media (max-width: 768px) { - .time-display { - font-size: 0.75rem; - min-width: 60px; - } -} - - -.wave-container canvas { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; +.volume-control { + display: flex; + align-items: center; + gap: 0.5rem; + padding-left: 0.75rem; + border-left: 1px solid rgba(99, 102, 241, 0.2); + min-width: 0; } -.download-placeholder { - width: 32px; - height: 32px; - margin: 0.25rem; - visibility: hidden; +.volume-icon { + color: var(--fg-color); + opacity: 0.8; + flex-shrink: 0; } +/* ── download */ .download-button { - position: absolute; - bottom: 0.5rem; - right: 0.5rem; + position: relative; width: 32px; height: 32px; cursor: pointer; @@ -373,15 +201,22 @@ justify-content: center; transition: transform 0.2s ease; opacity: 0; + visibility: hidden; pointer-events: none; - z-index: 10; } .download-button.ready { opacity: 1; + visibility: visible; pointer-events: auto; } +.download-button:focus-visible { + outline: 2px solid var(--fg-color); + outline-offset: 2px; + border-radius: 4px; +} + .download-glow { position: absolute; inset: -15%; @@ -409,7 +244,7 @@ align-items: center; justify-content: center; color: var(--text); - transition: transform 0.2s ease, box-shadow 0.2s ease; + transition: box-shadow 0.2s ease; } .download-button:hover { @@ -424,3 +259,100 @@ from { transform: rotate(0deg); } to { transform: rotate(360deg); } } + +.bmc-link { + display: inline-block; + line-height: 0; + margin: 0 0.4rem; + opacity: 0.85; + transition: opacity 0.2s ease, transform 0.2s ease; + border-radius: 6px; +} + +.bmc-link:hover, +.bmc-link:focus-visible { + opacity: 1; + transform: translateY(-1px); +} + +.bmc-link img { + height: 26px; + width: auto; + display: block; +} + +@media (max-width: 900px) { + .player-dock { + padding: 1rem 0.75rem 0.75rem; + } + + .player-controls { + gap: 0.55rem; + padding: 0.45rem 0.5rem; + } + + .bmc-link { + margin: 0 0.2rem 0 0.7rem; + } + + .bmc-link img { + height: 23px; + } + + .player-btn { + min-width: 48px; + padding: 0.375rem 0.5rem; + } + + .autoplay-toggle { + font-size: 0.75rem; + gap: 0.25rem; + } + + .time-display { + min-width: 48px; + font-size: 0.75rem; + } + + .volume-control { + padding-left: 0.4rem; + gap: 0.25rem; + } + + .download-button, + .download-icon { + width: 28px; + height: 28px; + } +} + +@media (max-width: 620px) { + .player-controls { + grid-template-columns: auto auto auto auto minmax(0, 1fr) auto; + grid-template-rows: auto auto; + } + + .seek-slider { + grid-column: 1 / -1; + grid-row: 2; + margin: 0.25rem 0; + } + + .volume-slider { + width: 100%; + min-width: 30px; + max-width: 80px; + } +} + +@media (max-width: 360px) { + .autoplay-text { + display: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .download-glow { + animation: none; + } +} diff --git a/web/styles/responsive.css b/web/styles/responsive.css deleted file mode 100644 index 450c90f2..00000000 --- a/web/styles/responsive.css +++ /dev/null @@ -1,110 +0,0 @@ -@media (max-width: 1200px) { - .container { - max-width: 100%; - } - - main { - gap: 1rem; - } - - .text-editor, - .controls { - padding: 1rem; - } -} - -@media (max-width: 1023px) { - h1 { - font-size: clamp(1.5rem, 4vw, 2rem); - } - - .cup { - width: clamp(20px, 3vw, 30px); - height: clamp(25px, 4vw, 40px); - } - - .handle { - width: clamp(8px, 1.5vw, 12px); - height: clamp(15px, 2.5vw, 20px); - right: clamp(-8px, -1.5vw, -12px); - top: clamp(6px, 1vw, 8px); - } - - .steam { - top: clamp(-8px, -1.5vw, -12px); - } - - .steam::before, - .steam::after { - width: clamp(4px, 0.75vw, 6px); - } -} - -@media (max-width: 768px) { - .container { - padding-left: 0.5rem; - padding-right: 0.5rem; - } - - .text-editor, - .controls { - padding: 0.75rem; - } - - .voice-select-container { - flex-direction: column; - align-items: stretch; - } - - .options { - flex-direction: column; - gap: 0.75rem; - } - - .button-group { - flex-direction: column; - } - - .generation-options { - flex-direction: column; - align-items: stretch; - gap: 0.5rem; - } - - .format-select { - width: 100%; - } - - .player-container { - padding: 0.75rem; - } - - .player-controls { - padding: 0.5rem; - gap: 0.5rem; - } - - .volume-control { - gap: 0.25rem; - } - - .volume-slider { - width: 60px; - } - - .wave-container { - height: 32px; - } - - .download-button { - top: 0.5rem; - right: 0.5rem; - width: 26px; - height: 26px; - } - - .download-icon { - width: 26px; - height: 26px; - } -} diff --git a/web/styles/voices.css b/web/styles/voices.css new file mode 100644 index 00000000..4e032102 --- /dev/null +++ b/web/styles/voices.css @@ -0,0 +1,209 @@ +.voice-select-container { + position: relative; + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 100%; + flex: 1; + min-height: 0; +} + +.voice-search-wrapper { + position: relative; + width: 100%; +} + +.voice-search { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid var(--border); + border-radius: 0.25rem; + background: rgba(15, 23, 42, 0.3); + color: var(--text); + font-size: 0.9375rem; + transition: all 0.2s ease; +} + +.voice-search:focus { + outline: none; + border-color: var(--fg-color); + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); +} + +.voice-search::placeholder { + color: var(--text-light); +} + +.selected-voices { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.5rem; + background: rgba(15, 23, 42, 0.3); + border: 1px solid var(--border); + border-radius: 0.25rem; + width: 100%; + min-height: 2.4rem; + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: rgba(99, 102, 241, 0.2) transparent; +} + +.selected-voices:empty::before { + content: "No voices selected"; + color: var(--text-light); + font-size: 0.8125rem; + opacity: 0.7; + padding: 0.1rem 0.25rem; +} + +.voice-dropdown { + visibility: hidden; + opacity: 0; + position: absolute; + top: calc(100% + 0.25rem); + left: 0; + right: 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 0.25rem; + z-index: 50; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.35); + padding: 0.5rem; + transition: opacity 0.2s ease, transform 0.2s ease, visibility 0.2s ease; + transform: translateY(-10px); + pointer-events: none; +} + +.voice-dropdown:not(.show) { + max-height: 0; + padding-block: 0; + border-width: 0; + overflow: hidden; +} + +.voice-dropdown.show { + visibility: visible; + opacity: 1; + transform: translateY(0); + pointer-events: auto; +} + +.voice-options { + display: flex; + flex-direction: column; + gap: 0.35rem; + max-height: 300px; + overflow-y: auto; + padding-right: 0.5rem; + scrollbar-width: thin; + scrollbar-color: rgba(99, 102, 241, 0.2) transparent; +} + +.voice-options::-webkit-scrollbar { + width: 4px; +} + +.voice-options::-webkit-scrollbar-track { + background: transparent; +} + +.voice-options::-webkit-scrollbar-thumb { + background-color: rgba(99, 102, 241, 0.2); + border-radius: 2px; +} + +.voice-option { + display: flex; + align-items: center; + padding: 0.5rem 0.75rem; + cursor: pointer; + transition: all 0.2s ease; + color: var(--text); + border-radius: 0.2rem; + background: rgba(15, 23, 42, 0.3); + border: 1px solid var(--border); + font-size: 0.9375rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + user-select: none; +} + +.voice-option:hover { + background: rgba(99, 102, 241, 0.1); + border-color: var(--fg-color); + transform: translateX(2px); +} + +.voice-option.selected { + background: rgba(99, 102, 241, 0.2); + border-color: var(--fg-color); +} + +.voice-option.selected::before { + content: "✓"; + margin-right: 0.5rem; + color: var(--fg-color); + font-weight: bold; +} + +.selected-voice-tag { + display: flex; + align-items: center; + padding: 0.25rem 0.45rem; + background: rgba(99, 102, 241, 0.2); + border: 1px solid rgba(99, 102, 241, 0.3); + border-radius: 0.2rem; + font-size: 0.8125rem; + gap: 0.5rem; + transition: all 0.2s ease; +} + +.selected-voice-tag .voice-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.selected-voice-tag .voice-weight { + flex-shrink: 0; +} + +.selected-voice-tag:hover { + background: rgba(99, 102, 241, 0.3); +} + +.selected-voice-tag input { + width: 3rem; + padding: 0.25rem; + background: transparent; + border: none; + color: inherit; + font-size: inherit; + text-align: left; + border-radius: 0.25rem; + transition: background-color 0.2s; +} + +.selected-voice-tag input:hover, +.selected-voice-tag input:focus { + background: rgba(99, 102, 241, 0.1); +} + +.remove-voice { + cursor: pointer; + opacity: 0.7; + transition: opacity 0.2s ease; + font-size: 1.2em; + line-height: 1; + padding: 0.25rem; + flex-shrink: 0; +} + +.remove-voice:hover { + opacity: 1; +} diff --git a/web/tests/unit/audio-service.test.mjs b/web/tests/unit/audio-service.test.mjs index 493cc244..28516d8f 100644 --- a/web/tests/unit/audio-service.test.mjs +++ b/web/tests/unit/audio-service.test.mjs @@ -16,3 +16,29 @@ test('AudioService does not use MediaSource for unsupported or non-MP3 output', assert.equal(service.shouldUseMseStream('wav', true), false); assert.equal(service.shouldUseMseStream('pcm', true), false); }); + +test('download name is voice + timestamp with unsafe characters replaced', () => { + const service = new AudioService(); + + assert.match( + service.buildDownloadName('af_bella', 'mp3'), + /^af_bella_\d{4}-\d{2}-\d{2}T[\d-]+Z\.mp3$/ + ); + assert.match(service.buildDownloadName('af_bella(2)+af_sky(1)', 'wav'), /^af_bella_2_af_sky_1_\d/); + assert.ok(service.buildDownloadName('', 'mp3').startsWith('speech_')); +}); + +test('download URL carries the save-as name for the server to echo back', async () => { + const service = new AudioService(); + + service.downloadName = 'af_bella_2026-08-01T12-30-00-000Z.mp3'; + await service.setDownloadPath('/download/tmprloey00i.mp3'); + assert.equal( + service.getDownloadUrl(), + '/v1/download/tmprloey00i.mp3?name=af_bella_2026-08-01T12-30-00-000Z.mp3' + ); + + service.downloadName = null; + await service.setDownloadPath('/download/tmprloey00i.mp3'); + assert.equal(service.getDownloadUrl(), '/v1/download/tmprloey00i.mp3'); +});