Embed podcast-spiller: /pub/{slug}/{episode}/player (oppgave 30.5)
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.
This commit is contained in:
parent
4b53adefa9
commit
4c1c470ed7
3 changed files with 671 additions and 2 deletions
667
maskinrommet/src/embed_player.rs
Normal file
667
maskinrommet/src/embed_player.rs
Normal file
|
|
@ -0,0 +1,667 @@
|
|||
//! 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,
|
||||
)
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ pub mod cas;
|
|||
pub mod cli_dispatch;
|
||||
pub mod clip;
|
||||
mod custom_domain;
|
||||
mod embed_player;
|
||||
pub mod describe_image;
|
||||
mod intentions;
|
||||
pub mod jobs;
|
||||
|
|
@ -273,6 +274,8 @@ async fn main() {
|
|||
.route("/pub/{slug}/sok", get(publishing::serve_search))
|
||||
.route("/pub/{slug}/om", get(publishing::serve_about))
|
||||
.route("/pub/{slug}/preview/{theme}", get(publishing::preview_theme))
|
||||
// Embed podcast-spiller (oppgave 30.5)
|
||||
.route("/pub/{slug}/{episode_id}/player", get(embed_player::serve_player))
|
||||
// NB: {article_id} catch-all må komme etter de spesifikke rutene
|
||||
.route("/pub/{slug}/{article_id}", get(publishing::serve_article))
|
||||
// Custom domains: Caddy on-demand TLS callback
|
||||
|
|
|
|||
3
tasks.md
3
tasks.md
|
|
@ -430,8 +430,7 @@ prøveimport-flyt.
|
|||
- [x] 30.4 Statistikk-dashboard: vis nedlastinger per episode, trend over tid, topp-episoder, klienter (Apple/Spotify/andre), geografi. Integrert i admin-panelet.
|
||||
|
||||
### Embed-spiller
|
||||
- [~] 30.5 Podcast-spiller komponent: Svelte-komponent med artwork, tittel, play/pause, progress, waveform, kapittelmerkering. Responsiv. Serveres som iframe-embed: `synops.no/pub/<slug>/<episode>/player`.
|
||||
> Påbegynt: 2026-03-18T23:45
|
||||
- [x] 30.5 Podcast-spiller komponent: Svelte-komponent med artwork, tittel, play/pause, progress, waveform, kapittelmerkering. Responsiv. Serveres som iframe-embed: `synops.no/pub/<slug>/<episode>/player`.
|
||||
|
||||
### Import
|
||||
- [ ] 30.6 `synops-import-podcast` CLI: importer eksisterende podcast fra RSS-feed. Parse metadata, last ned lydfiler/artwork/transkripsjoner til CAS, opprett noder med edges. Duplikatdeteksjon via `<guid>`. `--dry-run` for forhåndsvisning. Idempotent: kjør flere ganger, bare nye episoder importeres.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue