Skip to content

Commit ffa6a27

Browse files
committed
changes
1 parent 45e81df commit ffa6a27

1 file changed

Lines changed: 206 additions & 0 deletions

File tree

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
<script>
2+
let { src = "" } = $props();
3+
4+
const BARS = 72;
5+
6+
let canvas;
7+
let audio;
8+
let peaks = $state([]);
9+
let duration = $state(0);
10+
let current = $state(0);
11+
let playing = $state(false);
12+
let decodeFailed = $state(false);
13+
14+
function fmt(t) {
15+
if (!Number.isFinite(t) || t < 0) return "0:00";
16+
const m = Math.floor(t / 60);
17+
const s = Math.floor(t % 60);
18+
return `${m}:${s.toString().padStart(2, "0")}`;
19+
}
20+
21+
async function decode() {
22+
if (!src) return;
23+
decodeFailed = false;
24+
try {
25+
const res = await fetch(src);
26+
const buf = await res.arrayBuffer();
27+
const Ctx = window.AudioContext || window.webkitAudioContext;
28+
const ctx = new Ctx();
29+
const audioBuf = await ctx.decodeAudioData(buf);
30+
duration = audioBuf.duration;
31+
const data = audioBuf.getChannelData(0);
32+
const samplesPerBar = Math.max(1, Math.floor(data.length / BARS));
33+
const out = new Array(BARS);
34+
for (let i = 0; i < BARS; i++) {
35+
let sum = 0;
36+
const start = i * samplesPerBar;
37+
const end = Math.min(start + samplesPerBar, data.length);
38+
for (let j = start; j < end; j++) sum += data[j] * data[j];
39+
out[i] = Math.sqrt(sum / Math.max(1, end - start));
40+
}
41+
const max = Math.max(...out, 1e-6);
42+
peaks = out.map((v) => v / max);
43+
ctx.close?.();
44+
} catch {
45+
decodeFailed = true;
46+
}
47+
}
48+
49+
function draw() {
50+
if (!canvas || peaks.length === 0) return;
51+
const dpr = window.devicePixelRatio || 1;
52+
const rect = canvas.getBoundingClientRect();
53+
if (rect.width === 0) return;
54+
canvas.width = rect.width * dpr;
55+
canvas.height = rect.height * dpr;
56+
const ctx2 = canvas.getContext("2d");
57+
ctx2.setTransform(dpr, 0, 0, dpr, 0, 0);
58+
ctx2.clearRect(0, 0, rect.width, rect.height);
59+
const styles = getComputedStyle(canvas);
60+
const played = (styles.getPropertyValue("--wave-played") || "#f97316").trim();
61+
const base = (styles.getPropertyValue("--wave-base") || "#9ca3af").trim();
62+
const progress = duration > 0 ? current / duration : 0;
63+
const barW = rect.width / peaks.length;
64+
const mid = rect.height / 2;
65+
for (let i = 0; i < peaks.length; i++) {
66+
const h = Math.max(2, peaks[i] * rect.height * 0.85);
67+
const frac = (i + 0.5) / peaks.length;
68+
ctx2.fillStyle = frac < progress ? played : base;
69+
const w = Math.max(1.5, barW - 2);
70+
ctx2.fillRect(i * barW + (barW - w) / 2, mid - h / 2, w, h);
71+
}
72+
}
73+
74+
function toggle() {
75+
if (!audio) return;
76+
if (audio.paused) {
77+
audio.play().catch(() => {});
78+
} else {
79+
audio.pause();
80+
}
81+
}
82+
83+
function seek(e) {
84+
if (!audio || !duration) return;
85+
const rect = canvas.getBoundingClientRect();
86+
const x = e.clientX - rect.left;
87+
const t = Math.max(0, Math.min(duration, (x / rect.width) * duration));
88+
audio.currentTime = t;
89+
current = t;
90+
}
91+
92+
$effect(() => {
93+
src;
94+
peaks = [];
95+
current = 0;
96+
duration = 0;
97+
playing = false;
98+
decode();
99+
});
100+
101+
$effect(() => {
102+
peaks;
103+
current;
104+
duration;
105+
draw();
106+
});
107+
</script>
108+
109+
<svelte:window onresize={draw} />
110+
111+
<div class="wave-wrap">
112+
<button
113+
class="play-btn"
114+
onclick={toggle}
115+
aria-label={playing ? "Pause" : "Play"}
116+
disabled={decodeFailed}
117+
>
118+
{#if playing}
119+
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
120+
<rect x="6" y="5" width="4" height="14" rx="1"/>
121+
<rect x="14" y="5" width="4" height="14" rx="1"/>
122+
</svg>
123+
{:else}
124+
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
125+
<path d="M7 5v14l12-7z"/>
126+
</svg>
127+
{/if}
128+
</button>
129+
130+
<!-- svelte-ignore a11y_click_events_have_key_events -->
131+
<!-- svelte-ignore a11y_no_static_element_interactions -->
132+
<canvas
133+
class="wave"
134+
bind:this={canvas}
135+
onclick={seek}
136+
></canvas>
137+
138+
<span class="time">{fmt(current)} / {fmt(duration)}</span>
139+
140+
<audio
141+
bind:this={audio}
142+
{src}
143+
preload="metadata"
144+
ontimeupdate={() => (current = audio.currentTime)}
145+
onloadedmetadata={() => (duration = audio.duration)}
146+
onplay={() => (playing = true)}
147+
onpause={() => (playing = false)}
148+
onended={() => {
149+
playing = false;
150+
current = 0;
151+
}}
152+
>
153+
<track kind="captions" />
154+
</audio>
155+
</div>
156+
157+
<style>
158+
.wave-wrap {
159+
--wave-base: var(--body-text-color-subdued, #9ca3af);
160+
--wave-played: var(--color-accent, #f97316);
161+
display: flex;
162+
align-items: center;
163+
gap: 8px;
164+
padding: 8px 10px;
165+
background: var(--background-fill-secondary, #f9fafb);
166+
border: 1px solid var(--border-color-primary, #e5e7eb);
167+
border-radius: var(--radius-lg, 8px);
168+
color: var(--body-text-color, #1f2937);
169+
}
170+
.play-btn {
171+
display: inline-flex;
172+
align-items: center;
173+
justify-content: center;
174+
width: 28px;
175+
height: 28px;
176+
border-radius: 50%;
177+
background: var(--color-accent, #f97316);
178+
color: white;
179+
border: none;
180+
cursor: pointer;
181+
flex-shrink: 0;
182+
padding: 0;
183+
}
184+
.play-btn:disabled {
185+
opacity: 0.4;
186+
cursor: not-allowed;
187+
}
188+
.play-btn:hover:not(:disabled) {
189+
filter: brightness(1.1);
190+
}
191+
.wave {
192+
flex: 1;
193+
height: 32px;
194+
min-width: 0;
195+
cursor: pointer;
196+
}
197+
.time {
198+
font-size: 11px;
199+
color: var(--body-text-color-subdued, #6b7280);
200+
font-variant-numeric: tabular-nums;
201+
flex-shrink: 0;
202+
}
203+
audio {
204+
display: none;
205+
}
206+
</style>

0 commit comments

Comments
 (0)