Examples

#| '!! 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,
                )
            )
        ),
    )