1349 lines
31 KiB
Svelte
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")}>↖</button
|
|
>
|
|
<button
|
|
class="pos-btn"
|
|
class:active={titlePosition === "top"}
|
|
on:click={() => (titlePosition = "top")}>↑</button
|
|
>
|
|
<button
|
|
class="pos-btn"
|
|
class:active={titlePosition === "top-right"}
|
|
on:click={() => (titlePosition = "top-right")}>↗</button
|
|
>
|
|
</div>
|
|
<div class="pos-row">
|
|
<button
|
|
class="pos-btn"
|
|
class:active={titlePosition === "left"}
|
|
on:click={() => (titlePosition = "left")}>←</button
|
|
>
|
|
<button
|
|
class="pos-btn"
|
|
class:active={titlePosition === "center"}
|
|
on:click={() => (titlePosition = "center")}>◉</button
|
|
>
|
|
<button
|
|
class="pos-btn"
|
|
class:active={titlePosition === "right"}
|
|
on:click={() => (titlePosition = "right")}>→</button
|
|
>
|
|
</div>
|
|
<div class="pos-row">
|
|
<button
|
|
class="pos-btn"
|
|
class:active={titlePosition === "bottom-left"}
|
|
on:click={() => (titlePosition = "bottom-left")}>↙</button
|
|
>
|
|
<button
|
|
class="pos-btn"
|
|
class:active={titlePosition === "bottom"}
|
|
on:click={() => (titlePosition = "bottom")}>↓</button
|
|
>
|
|
<button
|
|
class="pos-btn"
|
|
class:active={titlePosition === "bottom-right"}
|
|
on:click={() => (titlePosition = "bottom-right")}>↘</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
|
|
<span>F</span> Fullscreen
|
|
<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>
|