#| '!! shinylive warning !!': |
#| shinylive does not work in self-contained HTML documents.
#| Please set `embed-resources: false` in your metadata.
#| echo: false
#| standalone: true
#| components: [viewer]
#| layout: vertical
#| viewerHeight: 400
#| packages: ["shiny", "htmltools"]
## file: app.py
from shiny import App, ui, render
from htmltools import tags
from pathlib import Path
from wavesurfer import wavesurfer_player # adjust to match your actual exports
#from wavesurfer import wavesurfer_player
app_ui = ui.page_fluid(
tags.div(
tags.div(
tags.h1("WaveSurfer"),
tags.p("Shiny for Python · htmltools component demo"),
),
tags.div(
{"class": "card"},
ui.output_ui("player1_ui"),
),
),
)
def server(input, output, session):
@output
@render.ui
def player1_ui():
url = "example.wav"
#url = "https://file-examples.com/wp-content/storage/2017/11/file_example_WAV_1MG.wav"
url = "https://raw.githubusercontent.com/jwijffels/example/master/example.wav"
player = wavesurfer_player(url, id="player1", title=url.upper(), height=70,
waveform_color="#E67E22", progress_color="#a89eff")
return player
#app = App(app_ui, server)
#import importlib_resources
#assets_dir = str(importlib_resources.files("wavesurfer") / "assets")
from pathlib import Path
# import urllib.request
# path = urllib.request.urlretrieve("https://github.com/jwijffels/example/raw/refs/heads/main/example.wav", str(Path.cwd() / "assets" / "example.wav"))[0]
assets_dir = str(Path.cwd() / "assets")
app = App(app_ui, server, static_assets=assets_dir)
## file: requirements.txt
shiny
htmltools
## file: wavesurfer.py
"""
wavesurfer.py
-------------
A Shiny for Python component wrapper around WaveSurfer.js (v7).
Usage
-----
from shiny import App, ui, render, reactive
from wavesurfer import wavesurfer_player, wavesurfer_controls
app_ui = ui.page_fluid(
wavesurfer_player("https://example.com/audio.mp3", id="player1"),
wavesurfer_controls("player1"),
)
Shiny Inputs exposed per player (replace `<id>` with your player id):
input.<id>_playing – bool: True while playing, False when paused
input.<id>_time – float: current playback time in seconds
input.<id>_seek – float: fractional position (0–1) after a seek
input.<id>_finished – int: timestamp (ms) each time playback ends
input.<id>_duration – float: total duration in seconds once loaded
input.<id>_volume – float: current volume (0–1)
"""
import uuid
from htmltools import Tag, TagList, tags, HTML
# ---------------------------------------------------------------------------
# CDN dependency — deduplication handled by inserting once per TagList
# ---------------------------------------------------------------------------
_WAVESURFER_CDN = "https://unpkg.com/wavesurfer.js@7/dist/wavesurfer.min.js"
## #F8F8F8
_BASE_CSS = """
.ws-wrapper {
background: #F8F8F8;
border-radius: 12px;
padding: 20px 24px 16px;
color: #e0e0e8;
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
position: relative;
overflow: hidden;
}
.ws-wrapper::before {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(ellipse at 60% 0%, rgba(100,80,255,0.07) 0%, transparent 70%);
pointer-events: none;
}
.ws-title {
font-size: 0.7rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #5a5a72;
margin-bottom: 14px;
}
.ws-waveform {
border-radius: 6px;
overflow: hidden;
background: #F8F8F8;
}
.ws-controls {
display: flex;
align-items: center;
gap: 10px;
margin-top: 14px;
flex-wrap: wrap;
}
.ws-btn {
background: none;
border: 1px solid #2e2e40;
border-radius: 8px;
color: #c0c0d8;
padding: 6px 14px;
font-size: 0.78rem;
font-family: inherit;
cursor: pointer;
transition: all 0.15s ease;
letter-spacing: 0.05em;
}
.ws-btn:hover {
background: #1e1e2a;
border-color: #5A5A72;
color: #ffffff;
}
.ws-btn.ws-play-btn {
background: #5A5A72;
border-color: #5A5A72;
color: #fff;
padding: 6px 20px;
font-weight: 600;
}
.ws-btn.ws-play-btn:hover {
background: #7a6fff;
}
.ws-time-display {
font-size: 0.78rem;
color: #5a5a72;
margin-left: auto;
letter-spacing: 0.08em;
font-variant-numeric: tabular-nums;
}
"""
def _wavesurfer_js(
player_id: str,
audio_url: str,
waveform_color: str,
progress_color: str,
cursor_color: str,
bar_width: int,
bar_gap: int,
bar_radius: int,
height: int,
) -> str:
"""Return the JS that initialises a WaveSurfer instance and wires Shiny."""
return f"""
(function() {{
// Wait for WaveSurfer to be available
function init() {{
if (typeof WaveSurfer === 'undefined') {{
setTimeout(init, 50);
return;
}}
const ws = WaveSurfer.create({{
container: document.getElementById('{player_id}_waveform'),
waveColor: '{waveform_color}',
progressColor: '{progress_color}',
cursorColor: '{cursor_color}',
barWidth: {bar_width},
barGap: {bar_gap},
barRadius: {bar_radius},
height: {height},
url: '{audio_url}',
normalize: true,
interact: true,
splitChannels: true,
}});
// Store on window for programmatic control
window['ws_{player_id}'] = ws;
const playBtn = document.getElementById('{player_id}_play');
const stopBtn = document.getElementById('{player_id}_stop');
const timeEl = document.getElementById('{player_id}_time');
function fmt(s) {{
if (isNaN(s)) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60).toString().padStart(2, '0');
return m + ':' + sec;
}}
// ── Play / Pause button ──────────────────────────────────────────
if (playBtn) {{
console.log('{player_id}_play');
playBtn.addEventListener('click', () => ws.playPause());
}}
ws.on('play', () => {{
if (playBtn) playBtn.textContent = '⏸ Pause';
if (window.Shiny) Shiny.setInputValue('{player_id}_playing', true, {{priority: 'event'}});
}});
ws.on('pause', () => {{
if (playBtn) playBtn.textContent = '▶ Play';
if (window.Shiny) Shiny.setInputValue('{player_id}_playing', false, {{priority: 'event'}});
}});
// ── Stop button ──────────────────────────────────────────
if (stopBtn) {{
console.log('{player_id}_stop');
stopBtn.addEventListener('click', () => ws.stop());
}}
ws.on('stop', () => {{
if (window.Shiny) Shiny.setInputValue('{player_id}_stopped', true, {{priority: 'event'}});
}});
// ── Time display ────────────────────────────────────────────────
ws.on('timeupdate', (t) => {{
if (timeEl) {{
const dur = ws.getDuration();
timeEl.textContent = fmt(t) + ' / ' + fmt(dur);
}}
if (window.Shiny) Shiny.setInputValue('{player_id}_time', t);
}});
// ── Ready / duration ────────────────────────────────────────────
ws.on('ready', (dur) => {{
if (timeEl) timeEl.textContent = '0:00 / ' + fmt(dur);
if (window.Shiny) Shiny.setInputValue('{player_id}_duration', dur);
}});
// ── Seek ────────────────────────────────────────────────────────
ws.on('seeking', (progress) => {{
if (window.Shiny) Shiny.setInputValue('{player_id}_seek', progress, {{priority: 'event'}});
}});
// ── Finish ──────────────────────────────────────────────────────
ws.on('finish', () => {{
if (playBtn) playBtn.textContent = '▶ Play';
if (window.Shiny) Shiny.setInputValue('{player_id}_finished', Date.now(), {{priority: 'event'}});
}});
// ── Skip buttons ─────────────────────────────────────────────
const skipBwd = document.getElementById('{player_id}_skip_bwd');
const skipFwd = document.getElementById('{player_id}_skip_fwd');
if (skipBwd) skipBwd.addEventListener('click', () => ws.skip(-10));
if (skipFwd) skipFwd.addEventListener('click', () => ws.skip(10));
}}
init();
}})();
"""
def wavesurfer_player(
audio_url: str,
id: str | None = None,
title: str | None = None,
height: int = 80,
waveform_color: str = "#6458ff",
progress_color: str = "#a89eff",
cursor_color: str = "#ffffff",
bar_width: int = 2,
bar_gap: int = 1,
bar_radius: int = 3,
show_controls: bool = True,
) -> TagList:
"""
Create a WaveSurfer.js audio player widget.
Parameters
----------
audio_url : URL or data-URI of the audio to load.
id : Unique element ID (auto-generated if omitted).
title : Optional label shown above the waveform.
height : Waveform canvas height in pixels.
waveform_color: Hex colour for the un-played portion of the waveform.
progress_color: Hex colour for the played portion.
cursor_color : Hex colour for the playhead cursor.
bar_width : Width of each waveform bar (px).
bar_gap : Gap between waveform bars (px).
bar_radius : Corner radius of waveform bars (px).
show_controls : Render play/pause/skip controls.
Shiny inputs
------------
input.<id>_playing – bool
input.<id>_time – float (seconds)
input.<id>_duration – float (seconds)
input.<id>_seek – float (0–1)
input.<id>_finished – int (epoch ms)
input.<id>_volume – float (0–1)
"""
if id is None:
id = f"ws_{uuid.uuid4().hex[:8]}"
# ── Controls HTML ──────────────────────────────────────────────────────
controls_tag = Tag("div")
if show_controls:
controls_tag = tags.div(
{"class": "ws-controls"},
tags.button("▶ Play", id=f"{id}_play", class_="ws-btn ws-play-btn"),
tags.button("« 10s", id=f"{id}_skip_bwd", class_="ws-btn"),
tags.button("10s »", id=f"{id}_skip_fwd", class_="ws-btn"),
tags.button("◼ Stop", id=f"{id}_stop", class_="ws-btn ws-play-btn"),
tags.span("0:00 / 0:00", id=f"{id}_time", class_="ws-time-display"),
)
title_tag = tags.div(title or "AUDIO PLAYER", class_="ws-title")
player = tags.div(
{"class": "ws-wrapper", "id": id},
title_tag,
tags.div({"id": f"{id}_waveform", "class": "ws-waveform"}),
controls_tag,
)
return TagList(
tags.style(HTML(_BASE_CSS), id="ws-base-styles"),
# WaveSurfer CDN script
tags.script(src=_WAVESURFER_CDN),
# Player markup
player,
# Initialisation script
tags.script(
HTML(
_wavesurfer_js(
id,
audio_url,
waveform_color,
progress_color,
cursor_color,
bar_width,
bar_gap,
bar_radius,
height,
)
)
),
)