Files
audio-visualizer/src/lib/Controls.svelte
2026-02-16 20:39:16 +02:00

1349 lines
31 KiB
Svelte

<script>
import { createEventDispatcher } from "svelte";
export let selectedPreset = "cyanPink";
export let logoUrl = "";
export let title = "Audio Visualizer";
export let subtitle = "";
export let bgImage = "";
export let bgColor = "#0a0a0a";
export let vignette = true;
export let audioSource = "mic";
export let customPrimary = "#00d4ff";
export let customSecondary = "#ff006e";
export let useCustomColors = false;
export let isListening = false;
export let colorPresets = {};
export let onToggle = () => {};
export let audioUrl = "";
// File playback state
export let fileCurrentTime = 0;
export let fileDuration = 0;
export let filePlaying = false;
export let fileName = "";
function formatTime(seconds) {
if (!seconds || !isFinite(seconds)) return "0:00";
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, "0")}`;
}
function handleSeek(e) {
dispatch("fileseek", parseFloat(e.target.value));
}
function toggleFilePlayback() {
if (filePlaying) {
dispatch("filepause");
} else {
dispatch("fileresume");
}
}
// New props
export let vizMode = "circular";
export let barCount = 48;
export let sensitivity = 1.0;
export let smoothing = 0.82;
export let bassEmphasis = 0.25;
export let particlesEnabled = true;
export let particleDensity = 1.0;
export let shakeAmount = 1.0;
export let zoomIntensity = 1.0;
export let glowIntensity = 0.6;
export let logoSpin = false;
export let logoSpinSpeed = 5;
export let titlePosition = "top";
const dispatch = createEventDispatcher();
let fileInput;
let bgFileInput;
let audioFileInput;
let dragOver = false;
// Detect if browser supports tab audio capture (Chrome/Edge only)
const isChromium =
/Chrome/.test(navigator.userAgent) && !/Edg/.test(navigator.userAgent);
const isEdge = /Edg/.test(navigator.userAgent);
const supportsTabAudio = isChromium || isEdge;
const vizModes = [
{ key: "circular", name: "Circular" },
{ key: "bars", name: "Bars" },
{ key: "wave", name: "Wave" },
{ key: "blob", name: "Blob" },
];
function readFileAsDataUrl(file, callback) {
const reader = new FileReader();
reader.onload = (e) => callback(e.target.result);
reader.readAsDataURL(file);
}
function handleFileUpload(e) {
const file = e.target.files[0];
if (file) readFileAsDataUrl(file, (url) => (logoUrl = url));
}
function handleBgUpload(e) {
const file = e.target.files[0];
if (file) readFileAsDataUrl(file, (url) => (bgImage = url));
}
function handleAudioFile(e) {
const file = e.target.files[0];
if (file && file.type.startsWith("audio/")) {
audioSource = "file";
dispatch("audiofile", file);
}
}
function handleDrop(e) {
e.preventDefault();
dragOver = false;
const file = e.dataTransfer.files[0];
if (file && file.type.startsWith("audio/")) {
audioSource = "file";
dispatch("audiofile", file);
}
}
function handleDragOver(e) {
e.preventDefault();
dragOver = true;
}
function clearLogo() {
logoUrl = "";
if (fileInput) fileInput.value = "";
}
function clearBg() {
bgImage = "";
if (bgFileInput) bgFileInput.value = "";
}
function selectPreset(key) {
selectedPreset = key;
useCustomColors = false;
}
function activateCustom() {
useCustomColors = true;
}
function getEmbedUrl(url) {
if (!url) return "";
try {
// YouTube
let match = url.match(
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([\w-]+)/,
);
if (match) return `https://www.youtube.com/embed/${match[1]}?autoplay=1`;
// Spotify
match = url.match(/open\.spotify\.com\/(track|album|playlist)\/([\w]+)/);
if (match)
return `https://open.spotify.com/embed/${match[1]}/${match[2]}?utm_source=generator&theme=0`;
// SoundCloud — use their oEmbed widget
if (url.includes("soundcloud.com"))
return `https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&auto_play=true&color=%2300d4ff&hide_related=true&show_comments=false&show_user=false&show_reposts=false`;
// Direct audio URL — won't embed, handled separately
return "";
} catch {
return "";
}
}
$: embedUrl = getEmbedUrl(audioUrl);
$: isYouTubeUrl =
audioUrl &&
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)/.test(
audioUrl,
);
function handleScreenshot() {
dispatch("screenshot");
}
function handleFullscreen() {
dispatch("fullscreen");
}
function handleReset() {
dispatch("reset");
}
</script>
<div class="controls">
<!-- Audio Section -->
<div class="control-section">
<h3>Audio</h3>
<div class="button-row">
<button class="btn" class:active={isListening} on:click={onToggle}>
{#if isListening}
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="currentColor"
>
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
Stop
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
<line x1="12" y1="19" x2="12" y2="23" />
<line x1="8" y1="23" x2="16" y2="23" />
</svg>
Start
{/if}
</button>
</div>
<div class="source-toggle">
<button
class="source-btn"
class:active={audioSource === "mic"}
on:click={() => (audioSource = "mic")}>Mic</button
>
<button
class="source-btn"
class:active={audioSource === "tab"}
on:click={() => (audioSource = "tab")}>Tab</button
>
<button
class="source-btn"
class:active={audioSource === "link"}
on:click={() => (audioSource = "link")}>Link</button
>
<button
class="source-btn"
class:active={audioSource === "file"}
on:click={() => audioFileInput.click()}>File</button
>
</div>
<input
type="file"
accept="audio/*"
on:change={handleAudioFile}
bind:this={audioFileInput}
id="audio-upload"
/>
{#if audioSource === "tab"}
{#if supportsTabAudio}
<p class="hint">
Click Start, pick a tab playing music, and check <strong
>"Share tab audio"</strong
>. Direct audio capture — no mic needed.
</p>
{:else}
<p class="hint error-hint">
Tab audio capture is only supported in Chrome and Edge. Use <strong
>Link</strong
>
or <strong>Mic</strong> instead.
</p>
{/if}
{:else if audioSource === "link"}
<input
type="text"
class="text-input url-input"
bind:value={audioUrl}
placeholder="Paste YouTube, Spotify, or SoundCloud link..."
/>
{#if embedUrl}
{#if isYouTubeUrl}
<a
class="open-link-btn"
href={audioUrl}
target="_blank"
rel="noopener noreferrer"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><path
d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"
/><polyline points="15 3 21 3 21 9" /><line
x1="10"
y1="14"
x2="21"
y2="3"
/></svg
>
Open on YouTube
</a>
<p class="hint">
Play the video in the new tab, then click Start here. Uses your mic
to capture the audio.
</p>
{:else}
<div class="embed-player">
<iframe
src={embedUrl}
title="Music Player"
allow="autoplay; encrypted-media"
frameborder="0"
width="100%"
height="80"
></iframe>
</div>
<p class="hint">
Play the music above, then click Start. Uses your mic to capture the
audio.
</p>
{/if}
{:else if audioUrl}
<p class="hint error-hint">
Could not parse this URL. Try a YouTube, Spotify, or SoundCloud link.
</p>
{/if}
{/if}
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div
class="drop-zone"
class:drag-over={dragOver}
on:drop={handleDrop}
on:dragover={handleDragOver}
on:dragleave={() => (dragOver = false)}
>
Drop audio file here
</div>
{#if fileName}
<div class="file-player">
<div class="file-name" title={fileName}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
><path d="M9 18V5l12-2v13" /><circle cx="6" cy="18" r="3" /><circle
cx="18"
cy="16"
r="3"
/></svg
>
<span>{fileName}</span>
</div>
<div class="file-controls">
<button
class="play-pause-btn"
on:click={toggleFilePlayback}
disabled={!isListening}
>
{#if filePlaying}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"
><rect x="6" y="4" width="4" height="16" /><rect
x="14"
y="4"
width="4"
height="16"
/></svg
>
{:else}
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="currentColor"><polygon points="5 3 19 12 5 21 5 3" /></svg
>
{/if}
</button>
<span class="file-time">{formatTime(fileCurrentTime)}</span>
<input
type="range"
class="seek-slider"
min="0"
max={fileDuration || 0}
step="0.1"
value={fileCurrentTime}
on:input={handleSeek}
disabled={!isListening}
/>
<span class="file-time">{formatTime(fileDuration)}</span>
</div>
{#if !isListening}
<p class="hint">File loaded. Click Start to begin playback.</p>
{/if}
</div>
{/if}
</div>
<!-- Visualization Mode -->
<div class="control-section">
<h3>Visualization</h3>
<div class="source-toggle">
{#each vizModes as mode}
<button
class="source-btn"
class:active={vizMode === mode.key}
on:click={() => (vizMode = mode.key)}>{mode.name}</button
>
{/each}
</div>
<div class="slider-row">
<label for="slider-bars"
>Bars <span class="slider-val">{barCount}</span></label
>
<input
id="slider-bars"
type="range"
min="16"
max="96"
step="2"
bind:value={barCount}
/>
</div>
</div>
<!-- Title Position -->
<div class="control-section">
<h3>Title Position</h3>
<div class="position-grid">
<div class="pos-row">
<button
class="pos-btn"
class:active={titlePosition === "top-left"}
on:click={() => (titlePosition = "top-left")}>&#8598;</button
>
<button
class="pos-btn"
class:active={titlePosition === "top"}
on:click={() => (titlePosition = "top")}>&#8593;</button
>
<button
class="pos-btn"
class:active={titlePosition === "top-right"}
on:click={() => (titlePosition = "top-right")}>&#8599;</button
>
</div>
<div class="pos-row">
<button
class="pos-btn"
class:active={titlePosition === "left"}
on:click={() => (titlePosition = "left")}>&#8592;</button
>
<button
class="pos-btn"
class:active={titlePosition === "center"}
on:click={() => (titlePosition = "center")}>&#9673;</button
>
<button
class="pos-btn"
class:active={titlePosition === "right"}
on:click={() => (titlePosition = "right")}>&#8594;</button
>
</div>
<div class="pos-row">
<button
class="pos-btn"
class:active={titlePosition === "bottom-left"}
on:click={() => (titlePosition = "bottom-left")}>&#8601;</button
>
<button
class="pos-btn"
class:active={titlePosition === "bottom"}
on:click={() => (titlePosition = "bottom")}>&#8595;</button
>
<button
class="pos-btn"
class:active={titlePosition === "bottom-right"}
on:click={() => (titlePosition = "bottom-right")}>&#8600;</button
>
</div>
</div>
</div>
<!-- Audio Reactivity -->
<div class="control-section">
<h3>Reactivity</h3>
<div class="slider-row">
<label for="slider-sensitivity"
>Sensitivity <span class="slider-val">{sensitivity.toFixed(1)}</span
></label
>
<input
id="slider-sensitivity"
type="range"
min="0.2"
max="3.0"
step="0.1"
bind:value={sensitivity}
/>
</div>
<div class="slider-row">
<label for="slider-smoothing"
>Smoothing <span class="slider-val">{smoothing.toFixed(2)}</span></label
>
<input
id="slider-smoothing"
type="range"
min="0.0"
max="0.99"
step="0.01"
bind:value={smoothing}
/>
</div>
<div class="slider-row">
<label for="slider-bass"
>Bass Emphasis <span class="slider-val">{bassEmphasis.toFixed(2)}</span
></label
>
<input
id="slider-bass"
type="range"
min="0.0"
max="2.0"
step="0.05"
bind:value={bassEmphasis}
/>
</div>
</div>
<!-- Effects -->
<div class="control-section">
<h3>Effects</h3>
<label class="toggle-row">
<input type="checkbox" bind:checked={particlesEnabled} />
<span>Particles</span>
</label>
{#if particlesEnabled}
<div class="slider-row">
<label for="slider-density"
>Density <span class="slider-val">{particleDensity.toFixed(1)}</span
></label
>
<input
id="slider-density"
type="range"
min="0.1"
max="3.0"
step="0.1"
bind:value={particleDensity}
/>
</div>
{/if}
<div class="slider-row">
<label for="slider-shake"
>Shake <span class="slider-val">{shakeAmount.toFixed(1)}</span></label
>
<input
id="slider-shake"
type="range"
min="0.0"
max="3.0"
step="0.1"
bind:value={shakeAmount}
/>
</div>
<div class="slider-row">
<label for="slider-zoom"
>Zoom <span class="slider-val">{zoomIntensity.toFixed(1)}</span></label
>
<input
id="slider-zoom"
type="range"
min="0.0"
max="3.0"
step="0.1"
bind:value={zoomIntensity}
/>
</div>
<div class="slider-row">
<label for="slider-glow"
>Glow <span class="slider-val">{glowIntensity.toFixed(1)}</span></label
>
<input
id="slider-glow"
type="range"
min="0.0"
max="2.0"
step="0.1"
bind:value={glowIntensity}
/>
</div>
</div>
<!-- Branding -->
<div class="control-section">
<h3>Branding</h3>
<input
type="text"
class="text-input"
bind:value={title}
placeholder="Title..."
/>
<input
type="text"
class="text-input"
bind:value={subtitle}
placeholder="Subtitle..."
style="margin-top: 0.35rem"
/>
<div class="inline-row" style="margin-top: 0.5rem">
<input
type="file"
accept="image/*"
on:change={handleFileUpload}
bind:this={fileInput}
id="logo-upload"
/>
<label for="logo-upload" class="upload-btn">Logo</label>
{#if logoUrl}
<button class="clear-btn" on:click={clearLogo}>Clear</button>
{/if}
</div>
{#if logoUrl}
<div class="slider-row" style="margin-top: 0.5rem">
<label>
<input type="checkbox" bind:checked={logoSpin} /> Spin Logo
</label>
</div>
{#if logoSpin}
<div class="slider-row">
<label for="slider-spin-speed"
>Speed <span class="slider-val">{logoSpinSpeed.toFixed(1)}</span
></label
>
<input
id="slider-spin-speed"
type="range"
min="0.5"
max="20"
step="0.5"
bind:value={logoSpinSpeed}
/>
</div>
{/if}
{/if}
</div>
<!-- Colors -->
<div class="control-section">
<h3>Colors</h3>
<div class="preset-buttons">
{#each Object.entries(colorPresets) as [key, preset]}
<button
class="preset-btn"
class:active={selectedPreset === key && !useCustomColors}
style="--preset-color: {preset.primary}"
on:click={() => selectPreset(key)}
>
<span
class="preset-dot"
style="background: linear-gradient(135deg, {preset.primary}, {preset.secondary ||
preset.primary})"
></span>
{preset.name}
</button>
{/each}
</div>
<div class="custom-colors" style="margin-top: 0.5rem">
<div class="color-row">
<label for="color-outer">Outer</label>
<input
id="color-outer"
type="color"
bind:value={customPrimary}
on:input={activateCustom}
/>
<input
type="text"
class="hex-input"
bind:value={customPrimary}
on:input={activateCustom}
placeholder="#00d4ff"
/>
</div>
<div class="color-row">
<label for="color-inner">Inner</label>
<input
id="color-inner"
type="color"
bind:value={customSecondary}
on:input={activateCustom}
/>
<input
type="text"
class="hex-input"
bind:value={customSecondary}
on:input={activateCustom}
placeholder="#ff006e"
/>
</div>
</div>
</div>
<!-- Background -->
<div class="control-section">
<h3>Background</h3>
<div class="inline-row">
<input
type="file"
accept="image/*"
on:change={handleBgUpload}
bind:this={bgFileInput}
id="bg-upload"
/>
<label for="bg-upload" class="upload-btn">Image</label>
{#if bgImage}
<button class="clear-btn" on:click={clearBg}>Clear</button>
{/if}
<input
type="color"
class="color-picker"
bind:value={bgColor}
title="Background Color"
/>
</div>
<label class="toggle-row">
<input type="checkbox" bind:checked={vignette} />
<span>Vignette</span>
</label>
</div>
<!-- Actions -->
<div class="control-section">
<h3>Actions</h3>
<div class="button-row action-row">
<button class="action-btn" on:click={handleScreenshot} title="Screenshot">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"
/>
<circle cx="12" cy="13" r="4" />
</svg>
Screenshot
</button>
<button class="action-btn" on:click={handleFullscreen} title="Fullscreen">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path
d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"
/>
</svg>
Fullscreen
</button>
<button class="action-btn reset-btn" on:click={handleReset} title="Reset">
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
Reset
</button>
</div>
</div>
<div class="keyboard-hints">
<span>Space</span> Play/Stop &nbsp;
<span>F</span> Fullscreen &nbsp;
<span>H</span> Hide Panel
</div>
</div>
<style>
.controls {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.control-section {
width: 100%;
}
h3 {
color: #888;
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.1em;
margin-bottom: 0.75rem;
}
.button-row {
display: flex;
gap: 0.5rem;
}
.btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 8px;
color: #fff;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s ease;
}
.btn:hover {
background: rgba(255, 255, 255, 0.15);
}
.btn.active {
background: rgba(255, 50, 50, 0.2);
border-color: rgba(255, 50, 50, 0.5);
color: #ff6b6b;
}
.preset-buttons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.preset-btn {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.75rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
color: #aaa;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s ease;
}
.preset-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.preset-btn.active {
border-color: var(--preset-color);
color: #fff;
box-shadow: 0 0 10px var(--preset-color);
}
.preset-dot {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
}
.inline-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
input[type="file"] {
display: none;
}
.upload-btn {
padding: 0.5rem 1rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 0.85rem;
}
.upload-btn:hover {
background: rgba(255, 255, 255, 0.15);
}
.clear-btn {
padding: 0.5rem 0.75rem;
background: rgba(255, 100, 100, 0.1);
border: 1px solid rgba(255, 100, 100, 0.3);
border-radius: 6px;
color: #ff6b6b;
cursor: pointer;
font-size: 0.8rem;
}
.clear-btn:hover {
background: rgba(255, 100, 100, 0.2);
}
.text-input {
width: 100%;
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 6px;
color: #fff;
font-size: 0.85rem;
box-sizing: border-box;
}
.text-input:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.4);
}
.color-picker {
width: 36px;
height: 30px;
border: none;
border-radius: 4px;
cursor: pointer;
background: none;
}
.custom-colors {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.color-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.color-row label {
color: #888;
font-size: 0.75rem;
min-width: 60px;
}
.color-row input[type="color"] {
width: 30px;
height: 26px;
border: none;
border-radius: 4px;
cursor: pointer;
background: none;
}
.hex-input {
width: 80px;
padding: 0.3rem 0.5rem;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 4px;
color: #fff;
font-size: 0.75rem;
font-family: monospace;
}
.hex-input:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.4);
}
.toggle-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
cursor: pointer;
color: #aaa;
font-size: 0.85rem;
}
.toggle-row input[type="checkbox"] {
accent-color: #00d4ff;
cursor: pointer;
}
.source-toggle {
display: flex;
gap: 0;
margin-top: 0.5rem;
border-radius: 6px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.15);
}
.source-btn {
flex: 1;
padding: 0.4rem 0.5rem;
background: rgba(255, 255, 255, 0.05);
border: none;
color: #888;
font-size: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.source-btn:not(:last-child) {
border-right: 1px solid rgba(255, 255, 255, 0.15);
}
.source-btn.active {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.source-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.drop-zone {
margin-top: 0.5rem;
padding: 0.6rem;
border: 1px dashed rgba(255, 255, 255, 0.15);
border-radius: 6px;
text-align: center;
color: #555;
font-size: 0.75rem;
transition: all 0.2s ease;
}
.drop-zone.drag-over {
border-color: #00d4ff;
color: #00d4ff;
background: rgba(0, 212, 255, 0.05);
}
.slider-row {
margin-top: 0.5rem;
}
.slider-row label {
display: flex;
justify-content: space-between;
color: #888;
font-size: 0.75rem;
margin-bottom: 0.25rem;
}
.slider-val {
color: #bbb;
font-family: monospace;
}
.slider-row input[type="range"] {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.slider-row input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #00d4ff;
cursor: pointer;
border: none;
}
.slider-row input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: #00d4ff;
cursor: pointer;
border: none;
}
.action-row {
flex-wrap: wrap;
}
.action-btn {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.75rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 6px;
color: #aaa;
cursor: pointer;
font-size: 0.8rem;
transition: all 0.2s ease;
}
.action-btn:hover {
background: rgba(255, 255, 255, 0.15);
color: #fff;
}
.action-btn.reset-btn {
border-color: rgba(255, 100, 100, 0.3);
color: #ff6b6b;
}
.action-btn.reset-btn:hover {
background: rgba(255, 100, 100, 0.15);
}
.keyboard-hints {
padding-top: 0.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.08);
color: #444;
font-size: 0.65rem;
text-align: center;
line-height: 1.8;
}
.keyboard-hints span {
display: inline-block;
padding: 0.1rem 0.4rem;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 3px;
color: #666;
font-family: monospace;
font-size: 0.65rem;
}
.url-input {
margin-top: 0.5rem;
}
.embed-player {
margin-top: 0.5rem;
border-radius: 6px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.embed-player iframe {
display: block;
}
.hint {
margin: 0.4rem 0 0 0;
color: #666;
font-size: 0.7rem;
line-height: 1.4;
}
.error-hint {
color: #ff6b6b;
}
.hint strong {
color: #aaa;
}
.open-link-btn {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: rgba(255, 0, 0, 0.08);
border: 1px solid rgba(255, 0, 0, 0.25);
border-radius: 6px;
color: #ff4444;
font-size: 0.8rem;
text-decoration: none;
transition: all 0.2s ease;
width: fit-content;
}
.open-link-btn:hover {
background: rgba(255, 0, 0, 0.15);
color: #ff6666;
}
.file-player {
margin-top: 0.6rem;
padding: 0.6rem;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.file-name {
display: flex;
align-items: center;
gap: 0.4rem;
color: #bbb;
font-size: 0.75rem;
margin-bottom: 0.5rem;
overflow: hidden;
}
.file-name span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-controls {
display: flex;
align-items: center;
gap: 0.4rem;
}
.play-pause-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 50%;
color: #fff;
cursor: pointer;
transition: all 0.2s ease;
}
.play-pause-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.2);
}
.play-pause-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.file-time {
color: #666;
font-size: 0.65rem;
font-family: monospace;
flex-shrink: 0;
min-width: 32px;
text-align: center;
}
.seek-slider {
flex: 1;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.15);
border-radius: 2px;
outline: none;
cursor: pointer;
min-width: 0;
}
.seek-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 10px;
height: 10px;
border-radius: 50%;
background: #00d4ff;
cursor: pointer;
border: none;
}
.seek-slider::-moz-range-thumb {
width: 10px;
height: 10px;
border-radius: 50%;
background: #00d4ff;
cursor: pointer;
border: none;
}
.seek-slider:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.position-grid {
display: flex;
flex-direction: column;
gap: 3px;
width: fit-content;
}
.pos-row {
display: flex;
gap: 3px;
}
.pos-btn {
width: 60px;
height: 28px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 4px;
color: #888;
font-size: 0.65rem;
cursor: pointer;
transition: all 0.2s ease;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.pos-btn:hover {
background: rgba(255, 255, 255, 0.1);
color: #ccc;
}
.pos-btn.active {
background: rgba(0, 212, 255, 0.15);
border-color: rgba(0, 212, 255, 0.4);
color: #00d4ff;
}
.pos-spacer {
width: 60px;
height: 28px;
}
</style>