Ny maskinrommet-handler som serverer en selvstående HTML-side med
podcast-spiller, designet for iframe-embedding på eksterne nettsider.
Spilleren inkluderer:
- Artwork (episode-spesifikk med fallback til samlingens)
- Tittel og podcast-navn
- Play/pause med loading-spinner
- WaveSurfer.js waveform-visualisering (CDN)
- Tidsvisning (nåværende/total)
- Kapittelmerkering (visuelt på waveform + klikkbar liste)
- Responsiv design (mobil-vennlig ned til 360px)
- Iframe-vennlige headers (X-Frame-Options, CSP frame-ancestors)
Rute: GET /pub/{slug}/{episode_id}/player
Registrert før {article_id} catch-all i rutehierarkiet.
667 lines
20 KiB
Rust
667 lines
20 KiB
Rust
//! Embed podcast-spiller: GET /pub/{slug}/{episode_id}/player
|
|
//!
|
|
//! Serverer en selvstående HTML-side med podcast-spiller som kan embeddees
|
|
//! som iframe. Inkluderer artwork, tittel, play/pause, progress, waveform
|
|
//! (WaveSurfer.js) og kapittelmerkering.
|
|
//!
|
|
//! Ref: docs/concepts/publisering.md, docs/features/podcast_hosting.md
|
|
|
|
use axum::{
|
|
extract::{Path, State},
|
|
http::{header, StatusCode},
|
|
response::Response,
|
|
};
|
|
use chrono::{DateTime, Utc};
|
|
use serde::Serialize;
|
|
use sqlx::PgPool;
|
|
use uuid::Uuid;
|
|
|
|
use crate::AppState;
|
|
|
|
// =============================================================================
|
|
// Data-modeller
|
|
// =============================================================================
|
|
|
|
#[derive(Serialize)]
|
|
struct EpisodePlayerData {
|
|
title: String,
|
|
podcast_title: String,
|
|
audio_url: String,
|
|
artwork_url: Option<String>,
|
|
duration_secs: Option<i64>,
|
|
chapters: Vec<ChapterData>,
|
|
base_url: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct ChapterData {
|
|
at_secs: i64,
|
|
at_display: String,
|
|
title: String,
|
|
}
|
|
|
|
// =============================================================================
|
|
// Handler
|
|
// =============================================================================
|
|
|
|
/// GET /pub/{slug}/{episode_id}/player — embed-klar podcast-spiller.
|
|
pub async fn serve_player(
|
|
State(state): State<AppState>,
|
|
Path((slug, episode_id)): Path<(String, String)>,
|
|
) -> Result<Response, StatusCode> {
|
|
let data = fetch_episode_player_data(&state.db, &slug, &episode_id)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!(slug = %slug, episode = %episode_id, error = %e, "Feil ved henting av episode for spiller");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?
|
|
.ok_or(StatusCode::NOT_FOUND)?;
|
|
|
|
let html = render_player_html(&data);
|
|
|
|
Ok(Response::builder()
|
|
.header(header::CONTENT_TYPE, "text/html; charset=utf-8")
|
|
.header(header::CACHE_CONTROL, "public, max-age=3600")
|
|
// Tillat iframe-embedding fra alle domener
|
|
.header("X-Frame-Options", "ALLOWALL")
|
|
.header(
|
|
"Content-Security-Policy",
|
|
"frame-ancestors *; default-src 'self' 'unsafe-inline' https://unpkg.com; media-src 'self'; img-src 'self' data:",
|
|
)
|
|
.body(html.into())
|
|
.unwrap())
|
|
}
|
|
|
|
// =============================================================================
|
|
// Database-spørring
|
|
// =============================================================================
|
|
|
|
async fn fetch_episode_player_data(
|
|
db: &PgPool,
|
|
slug: &str,
|
|
episode_short_id: &str,
|
|
) -> Result<Option<EpisodePlayerData>, sqlx::Error> {
|
|
// 1. Finn samling med publishing-trait
|
|
let collection_row: Option<(Uuid, Option<String>, serde_json::Value)> = sqlx::query_as(
|
|
r#"
|
|
SELECT id, title, metadata
|
|
FROM nodes
|
|
WHERE node_kind = 'collection'
|
|
AND metadata->'traits'->'publishing'->>'slug' = $1
|
|
LIMIT 1
|
|
"#,
|
|
)
|
|
.bind(slug)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
let Some((collection_id, collection_title, collection_metadata)) = collection_row else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let podcast_title = collection_title.unwrap_or_else(|| slug.to_string());
|
|
|
|
// Base URL for CAS og lenker
|
|
let base_url = collection_metadata
|
|
.get("traits")
|
|
.and_then(|t| t.get("publishing"))
|
|
.and_then(|p| p.get("custom_domain"))
|
|
.and_then(|d| d.as_str())
|
|
.map(|d| format!("https://{d}"))
|
|
.unwrap_or_else(|| format!("https://synops.no/pub/{slug}"));
|
|
|
|
// 2. Finn episoden (belongs_to samlingen, short_id-match)
|
|
let pattern = format!("{episode_short_id}%");
|
|
let episode_row: Option<(Uuid, Option<String>, serde_json::Value, DateTime<Utc>, Option<serde_json::Value>)> =
|
|
sqlx::query_as(
|
|
r#"
|
|
SELECT n.id, n.title, n.metadata, n.created_at, e.metadata
|
|
FROM edges e
|
|
JOIN nodes n ON n.id = e.source_id
|
|
WHERE e.target_id = $1
|
|
AND e.edge_type = 'belongs_to'
|
|
AND n.id::text LIKE $2
|
|
LIMIT 1
|
|
"#,
|
|
)
|
|
.bind(collection_id)
|
|
.bind(&pattern)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
let Some((episode_id, episode_title, _episode_metadata, created_at, edge_meta)) = episode_row
|
|
else {
|
|
return Ok(None);
|
|
};
|
|
|
|
// Sjekk publish_at — ikke server spilleren for upubliserte episoder
|
|
let publish_at = edge_meta
|
|
.as_ref()
|
|
.and_then(|m| m.get("publish_at"))
|
|
.and_then(|v| v.as_str())
|
|
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
|
|
.unwrap_or(created_at);
|
|
|
|
if publish_at > Utc::now() {
|
|
return Ok(None);
|
|
}
|
|
|
|
// 3. Hent medienode (has_media-edge) for audio-URL og varighet
|
|
let media_row: Option<(serde_json::Value,)> = sqlx::query_as(
|
|
r#"
|
|
SELECT m.metadata
|
|
FROM edges e
|
|
JOIN nodes m ON m.id = e.target_id
|
|
WHERE e.source_id = $1
|
|
AND e.edge_type = 'has_media'
|
|
AND m.node_kind = 'media'
|
|
LIMIT 1
|
|
"#,
|
|
)
|
|
.bind(episode_id)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
let Some((media_metadata,)) = media_row else {
|
|
return Ok(None); // Ingen lydfil — ingenting å spille
|
|
};
|
|
|
|
let cas_hash = media_metadata
|
|
.get("cas_hash")
|
|
.and_then(|v| v.as_str())
|
|
.ok_or_else(|| {
|
|
tracing::warn!(episode_id = %episode_id, "Media-node mangler cas_hash");
|
|
sqlx::Error::RowNotFound
|
|
})?;
|
|
|
|
let audio_url = format!("{base_url}/cas/{cas_hash}");
|
|
|
|
let duration_secs = media_metadata
|
|
.get("duration_secs")
|
|
.and_then(|v| v.as_i64());
|
|
|
|
// 4. Hent artwork — episodens egen og fallback til samlingens
|
|
let artwork_cas: Option<String> = sqlx::query_scalar(
|
|
r#"
|
|
SELECT m.metadata->>'cas_hash'
|
|
FROM edges e
|
|
JOIN nodes m ON m.id = e.source_id
|
|
WHERE e.target_id = $1
|
|
AND e.edge_type = 'og_image'
|
|
LIMIT 1
|
|
"#,
|
|
)
|
|
.bind(episode_id)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
// Fallback til samlingens artwork
|
|
let artwork_cas = match artwork_cas {
|
|
Some(h) => Some(h),
|
|
None => {
|
|
sqlx::query_scalar(
|
|
r#"
|
|
SELECT m.metadata->>'cas_hash'
|
|
FROM edges e
|
|
JOIN nodes m ON m.id = e.target_id
|
|
WHERE e.source_id = $1
|
|
AND e.edge_type = 'og_image'
|
|
LIMIT 1
|
|
"#,
|
|
)
|
|
.bind(collection_id)
|
|
.fetch_optional(db)
|
|
.await?
|
|
}
|
|
};
|
|
|
|
let artwork_url = artwork_cas.map(|h| format!("{base_url}/cas/{h}"));
|
|
|
|
// 5. Hent presentasjonselementer (tittel-edge)
|
|
let pres_title: Option<(Option<String>, Option<String>)> = sqlx::query_as(
|
|
r#"
|
|
SELECT n.title, n.content
|
|
FROM edges e
|
|
JOIN nodes n ON n.id = e.source_id
|
|
WHERE e.target_id = $1
|
|
AND e.edge_type = 'title'
|
|
ORDER BY e.created_at DESC
|
|
LIMIT 1
|
|
"#,
|
|
)
|
|
.bind(episode_id)
|
|
.fetch_optional(db)
|
|
.await?;
|
|
|
|
let title = pres_title
|
|
.and_then(|(t, c)| t.or(c))
|
|
.or(episode_title)
|
|
.unwrap_or_else(|| "Uten tittel".to_string());
|
|
|
|
// 6. Hent kapitler
|
|
let chapter_rows: Vec<(String, Option<String>)> = sqlx::query_as(
|
|
r#"
|
|
SELECT
|
|
COALESCE(e.metadata->>'at', '00:00:00') AS at,
|
|
e.metadata->>'title' AS title
|
|
FROM edges e
|
|
WHERE e.source_id = $1
|
|
AND e.edge_type = 'chapter'
|
|
ORDER BY e.metadata->>'at'
|
|
"#,
|
|
)
|
|
.bind(episode_id)
|
|
.fetch_all(db)
|
|
.await?;
|
|
|
|
let chapters: Vec<ChapterData> = chapter_rows
|
|
.into_iter()
|
|
.map(|(at, ch_title)| {
|
|
let secs = parse_time_to_secs(&at);
|
|
ChapterData {
|
|
at_secs: secs,
|
|
at_display: at,
|
|
title: ch_title.unwrap_or_else(|| format!("Kapittel")),
|
|
}
|
|
})
|
|
.collect();
|
|
|
|
Ok(Some(EpisodePlayerData {
|
|
title,
|
|
podcast_title,
|
|
audio_url,
|
|
artwork_url,
|
|
duration_secs,
|
|
chapters,
|
|
base_url,
|
|
}))
|
|
}
|
|
|
|
/// Parse "HH:MM:SS" eller "MM:SS" til sekunder.
|
|
fn parse_time_to_secs(time_str: &str) -> i64 {
|
|
let parts: Vec<&str> = time_str.split(':').collect();
|
|
match parts.len() {
|
|
3 => {
|
|
let h: i64 = parts[0].parse().unwrap_or(0);
|
|
let m: i64 = parts[1].parse().unwrap_or(0);
|
|
let s: i64 = parts[2].parse().unwrap_or(0);
|
|
h * 3600 + m * 60 + s
|
|
}
|
|
2 => {
|
|
let m: i64 = parts[0].parse().unwrap_or(0);
|
|
let s: i64 = parts[1].parse().unwrap_or(0);
|
|
m * 60 + s
|
|
}
|
|
_ => 0,
|
|
}
|
|
}
|
|
|
|
/// Escape HTML-tekst.
|
|
fn html_escape(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|
|
|
|
/// Escape streng for JavaScript (inni enkle anførselstegn).
|
|
fn js_escape(s: &str) -> String {
|
|
s.replace('\\', "\\\\")
|
|
.replace('\'', "\\'")
|
|
.replace('\n', "\\n")
|
|
.replace('\r', "")
|
|
.replace("</", "<\\/")
|
|
}
|
|
|
|
// =============================================================================
|
|
// HTML-rendering
|
|
// =============================================================================
|
|
|
|
fn render_player_html(data: &EpisodePlayerData) -> String {
|
|
let title = html_escape(&data.title);
|
|
let podcast_title = html_escape(&data.podcast_title);
|
|
|
|
let artwork_html = if let Some(ref url) = data.artwork_url {
|
|
format!(
|
|
r#"<img class="artwork" src="{}" alt="{}" />"#,
|
|
html_escape(url),
|
|
title
|
|
)
|
|
} else {
|
|
// Placeholder med podcast-ikon
|
|
r#"<div class="artwork artwork-placeholder"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 3v10.55c-.59-.34-1.27-.55-2-.55-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4V7h4V3h-6z"/></svg></div>"#.to_string()
|
|
};
|
|
|
|
let chapters_json = serde_json::to_string(&data.chapters).unwrap_or_else(|_| "[]".to_string());
|
|
|
|
let chapters_html = if data.chapters.is_empty() {
|
|
String::new()
|
|
} else {
|
|
let mut html = String::from(r#"<div class="chapters" id="chapters"><div class="chapters-toggle" onclick="toggleChapters()"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 6h16M4 12h16M4 18h7"/></svg><span>Kapitler</span><svg class="chevron" id="chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/></svg></div><div class="chapters-list" id="chapters-list">"#);
|
|
for (i, ch) in data.chapters.iter().enumerate() {
|
|
html.push_str(&format!(
|
|
r#"<button class="chapter-btn" data-index="{}" onclick="seekToChapter({})"><span class="chapter-time">{}</span><span class="chapter-title">{}</span></button>"#,
|
|
i,
|
|
ch.at_secs,
|
|
html_escape(&ch.at_display),
|
|
html_escape(&ch.title),
|
|
));
|
|
}
|
|
html.push_str("</div></div>");
|
|
html
|
|
};
|
|
|
|
let duration_secs = data.duration_secs.unwrap_or(0);
|
|
|
|
format!(
|
|
r##"<!DOCTYPE html>
|
|
<html lang="no">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{title} — {podcast_title}</title>
|
|
<style>
|
|
*,*::before,*::after{{box-sizing:border-box}}
|
|
body{{
|
|
margin:0;padding:0;
|
|
font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
|
|
background:#fff;color:#1a1a2e;
|
|
overflow-x:hidden;
|
|
}}
|
|
.player{{
|
|
display:flex;flex-direction:column;
|
|
max-width:640px;margin:0 auto;
|
|
padding:16px;gap:12px;
|
|
}}
|
|
/* Horisontal layout på bredere skjermer */
|
|
.player-top{{
|
|
display:flex;align-items:center;gap:16px;
|
|
}}
|
|
.artwork,.artwork-placeholder{{
|
|
width:80px;height:80px;min-width:80px;
|
|
border-radius:8px;object-fit:cover;
|
|
background:#e8e8f0;
|
|
}}
|
|
.artwork-placeholder{{
|
|
display:flex;align-items:center;justify-content:center;
|
|
color:#9ca3af;
|
|
}}
|
|
.artwork-placeholder svg{{width:36px;height:36px}}
|
|
.meta{{
|
|
flex:1;min-width:0;
|
|
}}
|
|
.meta h1{{
|
|
margin:0;font-size:16px;font-weight:600;
|
|
line-height:1.3;
|
|
overflow:hidden;text-overflow:ellipsis;
|
|
display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;
|
|
}}
|
|
.meta .podcast-name{{
|
|
margin:4px 0 0;font-size:12px;color:#6b7280;
|
|
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
|
|
}}
|
|
/* Kontroller */
|
|
.controls{{
|
|
display:flex;align-items:center;gap:12px;
|
|
}}
|
|
.play-btn{{
|
|
width:44px;height:44px;min-width:44px;
|
|
border:none;border-radius:50%;cursor:pointer;
|
|
background:#2563eb;color:#fff;
|
|
display:flex;align-items:center;justify-content:center;
|
|
transition:background .15s,transform .1s;
|
|
}}
|
|
.play-btn:hover{{background:#1d4ed8}}
|
|
.play-btn:active{{transform:scale(0.95)}}
|
|
.play-btn:disabled{{background:#d1d5db;cursor:not-allowed}}
|
|
.play-btn svg{{width:20px;height:20px}}
|
|
/* Waveform */
|
|
.waveform-wrap{{
|
|
flex:1;min-width:0;
|
|
}}
|
|
#waveform{{width:100%;height:48px}}
|
|
/* Tidsvisning */
|
|
.time-row{{
|
|
display:flex;justify-content:space-between;
|
|
font-size:11px;color:#9ca3af;
|
|
padding:0 2px;margin-top:-4px;
|
|
}}
|
|
/* Progressbar fallback (før waveform er klar) */
|
|
.progress-bar{{
|
|
width:100%;height:4px;background:#e5e7eb;border-radius:2px;
|
|
cursor:pointer;position:relative;overflow:hidden;
|
|
}}
|
|
.progress-bar .fill{{
|
|
height:100%;background:#2563eb;border-radius:2px;
|
|
transition:width .1s linear;
|
|
}}
|
|
/* Kapitler */
|
|
.chapters{{margin-top:4px}}
|
|
.chapters-toggle{{
|
|
display:flex;align-items:center;gap:6px;
|
|
font-size:13px;color:#6b7280;cursor:pointer;
|
|
padding:6px 0;border:none;background:none;width:100%;
|
|
}}
|
|
.chapters-toggle svg{{width:16px;height:16px}}
|
|
.chapters-toggle .chevron{{
|
|
margin-left:auto;transition:transform .2s;
|
|
}}
|
|
.chapters-toggle .chevron.open{{transform:rotate(180deg)}}
|
|
.chapters-list{{
|
|
display:none;flex-direction:column;gap:2px;
|
|
max-height:240px;overflow-y:auto;
|
|
padding:4px 0;
|
|
}}
|
|
.chapters-list.show{{display:flex}}
|
|
.chapter-btn{{
|
|
display:flex;align-items:center;gap:8px;
|
|
padding:8px 10px;border:none;background:none;
|
|
cursor:pointer;border-radius:6px;text-align:left;
|
|
font-size:13px;color:#374151;
|
|
transition:background .1s;
|
|
}}
|
|
.chapter-btn:hover{{background:#f3f4f6}}
|
|
.chapter-btn.active{{background:#eff6ff;color:#2563eb}}
|
|
.chapter-time{{
|
|
font-size:11px;color:#9ca3af;min-width:48px;
|
|
font-variant-numeric:tabular-nums;
|
|
}}
|
|
.chapter-title{{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}}
|
|
/* Loading-spinner */
|
|
.spinner{{
|
|
width:20px;height:20px;
|
|
border:2.5px solid rgba(255,255,255,.3);
|
|
border-top-color:#fff;border-radius:50%;
|
|
animation:spin .6s linear infinite;
|
|
}}
|
|
@keyframes spin{{to{{transform:rotate(360deg)}}}}
|
|
/* Feilmelding */
|
|
.error{{font-size:12px;color:#ef4444;padding:4px 0}}
|
|
/* Kapittelmerkører på waveform */
|
|
.chapter-marker{{
|
|
position:absolute;top:0;bottom:0;width:2px;
|
|
background:rgba(37,99,235,.4);pointer-events:none;
|
|
z-index:2;
|
|
}}
|
|
/* Responsiv: smalere skjermer */
|
|
@media(max-width:360px){{
|
|
.player{{padding:12px}}
|
|
.artwork,.artwork-placeholder{{width:64px;height:64px;min-width:64px}}
|
|
.meta h1{{font-size:14px}}
|
|
#waveform{{height:40px}}
|
|
.play-btn{{width:40px;height:40px;min-width:40px}}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="player">
|
|
<div class="player-top">
|
|
{artwork_html}
|
|
<div class="meta">
|
|
<h1>{title}</h1>
|
|
<p class="podcast-name">{podcast_title}</p>
|
|
</div>
|
|
</div>
|
|
<div class="controls">
|
|
<button class="play-btn" id="play-btn" disabled aria-label="Spill av">
|
|
<div class="spinner"></div>
|
|
</button>
|
|
<div class="waveform-wrap">
|
|
<div id="waveform"></div>
|
|
</div>
|
|
</div>
|
|
<div class="time-row">
|
|
<span id="current-time">0:00</span>
|
|
<span id="total-time">0:00</span>
|
|
</div>
|
|
{chapters_html}
|
|
</div>
|
|
|
|
<script src="https://unpkg.com/wavesurfer.js@7/dist/wavesurfer.min.js"></script>
|
|
<script>
|
|
(function() {{
|
|
'use strict';
|
|
var audioUrl = '{audio_url_js}';
|
|
var durationHint = {duration_secs};
|
|
var chapters = {chapters_json};
|
|
|
|
var playBtn = document.getElementById('play-btn');
|
|
var currentTimeEl = document.getElementById('current-time');
|
|
var totalTimeEl = document.getElementById('total-time');
|
|
var waveformEl = document.getElementById('waveform');
|
|
|
|
var playing = false;
|
|
var ready = false;
|
|
var ws = null;
|
|
|
|
function formatTime(s) {{
|
|
if (!s || !isFinite(s)) return '0:00';
|
|
var h = Math.floor(s / 3600);
|
|
var m = Math.floor((s % 3600) / 60);
|
|
var sec = Math.floor(s % 60);
|
|
if (h > 0) return h + ':' + String(m).padStart(2,'0') + ':' + String(sec).padStart(2,'0');
|
|
return m + ':' + String(sec).padStart(2,'0');
|
|
}}
|
|
|
|
if (durationHint > 0) {{
|
|
totalTimeEl.textContent = formatTime(durationHint);
|
|
}}
|
|
|
|
// Play/pause ikoner
|
|
var iconPlay = '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
|
|
var iconPause = '<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="4" width="4" height="16" rx="1"/><rect x="14" y="4" width="4" height="16" rx="1"/></svg>';
|
|
|
|
function updatePlayBtn() {{
|
|
playBtn.innerHTML = playing ? iconPause : iconPlay;
|
|
playBtn.setAttribute('aria-label', playing ? 'Pause' : 'Spill av');
|
|
}}
|
|
|
|
try {{
|
|
ws = WaveSurfer.create({{
|
|
container: waveformEl,
|
|
height: 48,
|
|
waveColor: '#93c5fd',
|
|
progressColor: '#2563eb',
|
|
cursorColor: '#1d4ed8',
|
|
cursorWidth: 2,
|
|
barWidth: 2,
|
|
barGap: 1,
|
|
barRadius: 2,
|
|
normalize: true,
|
|
url: audioUrl,
|
|
mediaControls: false,
|
|
}});
|
|
|
|
ws.on('ready', function() {{
|
|
ready = true;
|
|
playBtn.disabled = false;
|
|
updatePlayBtn();
|
|
totalTimeEl.textContent = formatTime(ws.getDuration());
|
|
addChapterMarkers(ws.getDuration());
|
|
}});
|
|
|
|
ws.on('timeupdate', function(t) {{
|
|
currentTimeEl.textContent = formatTime(t);
|
|
updateActiveChapter(t);
|
|
}});
|
|
|
|
ws.on('play', function() {{ playing = true; updatePlayBtn(); }});
|
|
ws.on('pause', function() {{ playing = false; updatePlayBtn(); }});
|
|
ws.on('finish', function() {{ playing = false; updatePlayBtn(); }});
|
|
|
|
ws.on('error', function() {{
|
|
playBtn.disabled = false;
|
|
updatePlayBtn();
|
|
var err = document.createElement('p');
|
|
err.className = 'error';
|
|
err.textContent = 'Kunne ikke laste lydfilen';
|
|
waveformEl.parentNode.appendChild(err);
|
|
}});
|
|
}} catch(e) {{
|
|
// WaveSurfer ikke tilgjengelig — faller tilbake
|
|
console.warn('WaveSurfer feil:', e);
|
|
playBtn.disabled = false;
|
|
updatePlayBtn();
|
|
}}
|
|
|
|
playBtn.addEventListener('click', function() {{
|
|
if (ws) ws.playPause();
|
|
}});
|
|
|
|
// Kapitler
|
|
function addChapterMarkers(totalDur) {{
|
|
if (!chapters.length || !totalDur) return;
|
|
var container = waveformEl;
|
|
container.style.position = 'relative';
|
|
chapters.forEach(function(ch) {{
|
|
var pct = (ch.at_secs / totalDur) * 100;
|
|
if (pct > 0 && pct < 100) {{
|
|
var marker = document.createElement('div');
|
|
marker.className = 'chapter-marker';
|
|
marker.style.left = pct + '%';
|
|
container.appendChild(marker);
|
|
}}
|
|
}});
|
|
}}
|
|
|
|
function updateActiveChapter(t) {{
|
|
var btns = document.querySelectorAll('.chapter-btn');
|
|
var activeIdx = -1;
|
|
for (var i = chapters.length - 1; i >= 0; i--) {{
|
|
if (t >= chapters[i].at_secs) {{ activeIdx = i; break; }}
|
|
}}
|
|
btns.forEach(function(btn, i) {{
|
|
btn.classList.toggle('active', i === activeIdx);
|
|
}});
|
|
}}
|
|
|
|
// Globale funksjoner for onclick i HTML
|
|
window.seekToChapter = function(secs) {{
|
|
if (ws && ready) {{
|
|
ws.setTime(secs);
|
|
if (!playing) ws.play();
|
|
}}
|
|
}};
|
|
|
|
window.toggleChapters = function() {{
|
|
var list = document.getElementById('chapters-list');
|
|
var chevron = document.getElementById('chevron');
|
|
if (list) {{
|
|
list.classList.toggle('show');
|
|
if (chevron) chevron.classList.toggle('open');
|
|
}}
|
|
}};
|
|
}})();
|
|
</script>
|
|
</body>
|
|
</html>"##,
|
|
title = title,
|
|
podcast_title = podcast_title,
|
|
artwork_html = artwork_html,
|
|
audio_url_js = js_escape(&data.audio_url),
|
|
duration_secs = duration_secs,
|
|
chapters_json = chapters_json,
|
|
chapters_html = chapters_html,
|
|
)
|
|
}
|