feat: transformation en application Radyobòkaz Player (WIP sécurisé)

Fork de la librairie headless svelte-podcast transformé en lecteur :
radio Funkwhale mizik.o-k-i.net (~2000 titres), page démo RSS Syntax.fm,
file de lecture, préférences et progression persistées.

État avant refonte playbook OKI (bugs connus : volume inerte,
bouton démo RSS cassé en navigation client, cibles tactiles mobiles).
This commit is contained in:
sucupira
2026-07-21 17:54:36 -04:00
parent b1165a817e
commit 72f4d2e160
82 changed files with 1226 additions and 2330 deletions
+64 -74
View File
@@ -1,83 +1,73 @@
<!--
41.9 kB
# Radyobòkaz Player
First make this easier to understand. Next convert comments to JSDOC comments, then add additional JSDOC comments where needed. JSDOC comments should not include types that conflict with existing Typescript types. Finally make sure variables are snake_case and not cammalCase, and that types are in PascalCase.
Lecteur audio web pour la radio **Radyobòkaz**, hébergée sur l'instance Funkwhale
[mizik.o-k-i.net](https://mizik.o-k-i.net/library/radios/7) (~2000 titres).
Convert typescrtipt types to JSDOC types, object types should be extracted into an explicit typedef with descriptions for each property. Add JSDOC comments to explain anything that is exported. Make sure variables are snake_case and not cammalCase.
-->
Construit sur une version modifiée de [svelte-podcast](https://github.com/OllieJT/svelte-podcast)
(librairie headless de lecteur audio, voir `src/lib`).
# Svelte Podcast
## Utilitaires RSS (SSR)
Svelte-Podcast streamlines the creation of custom audio players and simplifies state management in Svelte apps.
La librairie inclut des utilitaires pour consommer des flux RSS de podcasts,
compatibles SSR (pas de `DOMParser`, parsing via `fast-xml-parser`) :
| Version | License | Status |
| :-------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------: |
| [![npm](https://img.shields.io/npm/v/svelte-podcast)](https://www.npmjs.com/package/svelte-podcast) | [![npm](https://img.shields.io/npm/l/svelte-podcast)](https://www.npmjs.com/package/svelte-podcast) | ![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/olliejt/svelte-podcast/publish.yml) |
```ts
// src/routes/podcast/+page.ts
import { fetch_podcast_feed } from '$lib/rss';
import type { PageLoad } from './$types';
## What's inside?
### Build custom Audio Player UI
Simplify the creation of custom audio players with a set of headless components that keep out of your way and take care of core functionality.
### Easily manage Audio State
Loading, controlling, and keeping track of multiple audio sources is a pain. svelte-podcast abstracts this away and provides a simple interface to manage audio state.
### Track user preferences
Users expect a lot from media players. It should remember their preferences like playback speed, and it should remember where they were in an episode even after reloading the page. svelte-podcast takes care of this for you, and provides you with access to extend this with your own database.
### Features
- 🔊 Load and play audio files via URL or local files
- 🔃 Navigate via client-side routing while audio continues to play
- 🎛️ Simpler control over audio playback:
- Seek to a specific time
- skip forward ﹢ backward
- play ﹢ pause
- mute ﹢ unmute
- 🛟 Save and load a users progress for each episode in localStorage
- 💾 Save and load a users preferences (like playback speed) in localStorage
- ﹢ volume
- ﹢ playback speed
- 🖼️ Inject episode metadata into the audio store for ea
**Roadmap**
In no particular order, here are some of the things I'm confident will be added to this library:
- [ ] Podcast player component utilities
- [ ] Pre-built player components
- [ ] RSS Feed parsing
- [ ] Looping segments of an episode
[And more ideas being discussed](https://github.com/OllieJT/svelte-podcast/labels/feature)
## Docs
> **Warning**
> We're getting close to v1, but this project is stil pre v1.0.0. Braking changes are still possible.
> **Note**
> Contributions are welcome but I do not plan to provide guides for contributing until the project is more stable.
#### Install
```bash
# with npm
npm install svelte-podcast@latest
# with yarn
yarn add svelte-podcast@latest
# with pnpm
pnpm add svelte-podcast@latest
export const load: PageLoad = async ({ fetch }) => ({
// passer le `fetch` de SvelteKit : SSR, cache et hydration
feed: await fetch_podcast_feed('https://feed.syntax.fm/rss', fetch),
});
```
You can find docs and guides for getting started on [svelte-podcast.com](https://svelte-podcast.com/)
- `fetch_podcast_feed(url, fetcher?)` — télécharge et parse un flux (RSS 2.0 + tags iTunes)
- `parse_podcast_feed(xml)` — parse une chaîne XML déjà récupérée
- Types exportés : `PodcastFeed`, `PodcastEpisode` (titre, guid, date ISO, durée en
secondes, enclosure, pochette, numéros d'épisode/saison, explicit…)
| Resource | Link |
| ----------------------------------------------------------------- | ------------------------------------------------------------- |
| Provides guidance on Installation, Audio Sources, and Type Safety | [Getting Started](https://svelte-podcast.com/getting-started) |
| Covers API methods for audio, user preferences, and user progress | [API](https://svelte-podcast.com/api) |
| Offers examples of Headless UI, Tailwind, and CSS players | [Examples](https://svelte-podcast.com/examples) |
Les durées `itunes:duration` sont acceptées aux formats `3723`, `62:03` et `1:02:03`.
Les épisodes sans enclosure audio sont ignorés. Voir la route `/podcast` pour un
exemple complet (flux chargé côté serveur, épisodes joués dans le lecteur).
## Fonctionnalités
- 📻 **Mode radio aléatoire** : file de lecture infinie de titres piochés au hasard
- 🎵 **Écoute à la carte** : liste complète des titres avec recherche (titre, artiste, album)
- 🎙️ **Flux RSS de podcasts** : parsing SSR + lecture des épisodes (démo sur `/podcast`)
- ⏭️ Passage automatique au titre suivant en fin de piste
- 🎛️ Contrôles : lecture/pause, précédent/suivant, volume, mute, seek
- 🖼️ Pochette, titre, artiste et album du morceau en cours
- 🔃 L'audio continue de jouer lors de la navigation entre pages
## Développement
```bash
yarn install
yarn dev # serveur de dev
yarn build # build statique (adapter-static) dans build/
yarn run check # svelte-check
```
## Comment ça marche
L'API publique de Funkwhale est utilisée côté client, sans authentification :
- `GET /api/v1/radios/radios/7/tracks/?page=N&page_size=50` — liste des titres de la radio
- `GET /api/v1/listen/<uuid>/` — flux audio du titre (CORS ouvert, supporte `Range`)
Structure du code :
| Fichier | Rôle |
| ------------------------------- | ----------------------------------------------------------------- |
| `src/lib/` | Librairie svelte-podcast (store audio, composants headless) |
| `src/lib/rss.ts` | Utilitaires SSR pour flux RSS de podcasts (parse + fetch) |
| `src/radio/funkwhale.ts` | Client de l'API Funkwhale |
| `src/radio/playable.ts` | Type `PlayableTrack` (titre jouable, quelle que soit la source) |
| `src/radio/radio-store.ts` | File de lecture : mode radio aléatoire / liste, suivant/précédent |
| `src/radio/library-store.ts` | Chargement progressif de la liste complète des titres |
| `src/radio/radio-player.svelte` | Barre de lecture (pochette, contrôles, progression) |
| `src/radio/track-list.svelte` | Liste des titres avec recherche |
| `src/routes/+page.svelte` | Page radio Radyobòkaz |
| `src/routes/podcast/` | Démo des utilitaires RSS (flux chargé en SSR, épisodes jouables) |
+4 -2
View File
@@ -1,6 +1,7 @@
{
"name": "svelte-podcast",
"version": "0.9.0",
"name": "radyobokaz-player",
"version": "0.1.0",
"description": "Lecteur web pour la radio Radyobòkaz (mizik.o-k-i.net), basé sur svelte-podcast.",
"license": "MIT",
"author": "Ollie Taylor (https://olliejt.com)",
"homepage": "https://github.com/OllieJT/svelte-podcast/blob/main/README.md",
@@ -43,6 +44,7 @@
"@inqling/svelte-icons": "^3.3.2",
"clsx": "^1.2.1",
"esm-env": "^1.0.0",
"fast-xml-parser": "^5.10.1",
"just-clamp": "^4.2.0",
"svelte-local-storage-store": "^0.5.0"
},
+3 -5
View File
@@ -1,19 +1,17 @@
<!DOCTYPE html>
<html lang="en" class="h-full scroll-smooth bg-white hover:scroll-auto">
<html lang="fr" class="h-full bg-slate-100">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/icon.png" />
<meta name="theme-color" content="#f97316" />
<meta name="theme-color" content="#ea580c" />
<meta name="color-scheme" content="light" />
<link rel="bookmark" title="Svelte Podcast Docs" />
%sveltekit.head%
</head>
<body class="h-full scroll-smooth hover:scroll-auto">
<body class="h-full">
<div>%sveltekit.body%</div>
</body>
</html>
-156
View File
@@ -2,159 +2,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
article.section-content p {
@apply my-2 text-lg leading-normal;
}
article.section-content li p {
@apply my-0;
}
article.section-content mark {
@apply bg-primary-100 text-primary-900;
}
}
@layer components {
.richtext h1 a,
.richtext h2 a,
.richtext h3 a,
.richtext h4 a,
.richtext h5 a,
.richtext h6 a {
@apply relative break-all underline decoration-transparent decoration-solid underline-offset-2;
&::before {
@apply absolute -left-[1.25ch] inline-block font-light leading-normal text-mono-400;
content: '#';
}
&:hover::before {
@apply text-primary-500;
}
&:hover {
@apply decoration-primary-500;
}
}
.richtext code.hljs {
@apply overflow-x-auto p-0 text-sm;
overflow-wrap: anywhere;
}
.richtext .codeblock code.hljs {
@apply overflow-auto rounded-xl border border-mono-200 p-4 font-mono text-base leading-normal shadow-2xl shadow-mono-200;
}
.richtext {
@apply prose prose-slate max-w-none sm:prose-lg prose-headings:px-2 prose-h5:mb-1 prose-h5:mt-10 prose-h5:text-sm prose-h5:font-medium prose-h5:leading-normal prose-h5:text-primary-800 prose-p:px-2 prose-code:before:content-none prose-code:after:content-none;
}
.richtext code:not(.hljs) {
@apply inline whitespace-pre-wrap break-all rounded-md border border-mono-200 bg-white px-1.5 py-0.5 font-mono font-normal tracking-wide text-mono-950;
}
.richtext td,
.richtext th {
@apply px-4 py-2;
}
.richtext td {
@apply min-w-max;
}
/* HLJS */
pre code.hljs {
display: block;
overflow-x: auto;
padding: 1em;
}
code.hljs {
padding: 3px 5px;
}
/*
Theme: GitHub
Description: Light theme as seen on github.com
Author: github.com
Maintainer: @Hirse
Updated: 2021-05-15
Outdated base version: https://github.com/primer/github-syntax-light
Current colors taken from GitHub's CSS
*/
.hljs {
color: #24292e;
background: #fff;
}
.hljs-doctag,
.hljs-keyword,
.hljs-meta .hljs-keyword,
.hljs-template-tag,
.hljs-template-variable,
.hljs-type,
.hljs-variable.language_ {
color: #d73a49;
}
.hljs-title,
.hljs-title.class_,
.hljs-title.class_.inherited__,
.hljs-title.function_ {
color: #6f42c1;
}
.hljs-attr,
.hljs-attribute,
.hljs-literal,
.hljs-meta,
.hljs-number,
.hljs-operator,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-id,
.hljs-variable {
color: #005cc5;
}
.hljs-meta .hljs-string,
.hljs-regexp,
.hljs-string {
color: #032f62;
}
.hljs-built_in,
.hljs-symbol {
color: #e36209;
}
.hljs-code,
.hljs-comment,
.hljs-formula {
color: #6a737d;
}
.hljs-name,
.hljs-quote,
.hljs-selector-pseudo,
.hljs-selector-tag {
color: #22863a;
}
.hljs-subst {
color: #24292e;
}
.hljs-section {
color: #005cc5;
font-weight: 700;
}
.hljs-bullet {
color: #735c0f;
}
.hljs-emphasis {
color: #24292e;
font-style: italic;
}
.hljs-strong {
color: #24292e;
font-weight: 700;
}
.hljs-addition {
color: #22863a;
background-color: #f0fff4;
}
.hljs-deletion {
color: #b31d28;
background-color: #ffeef0;
}
}
-5
View File
@@ -1,5 +0,0 @@
export { default as Pause } from '@inqling/svelte-icons/heroicon-20-solid/pause.svelte';
export { default as Play } from '@inqling/svelte-icons/heroicon-20-solid/play.svelte';
export { default as SkipBack } from './skip-backward.svelte';
export { default as SkipForward } from './skip-forward.svelte';
export { default as LoadingSpinner } from './spinner.svelte';
-42
View File
@@ -1,42 +0,0 @@
<script>
/** @type {number} [size = 24] */
export let size = 24;
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style="font-size:{size}px"
>
<path
d="M11.0003 21.9499V23.9583C10.6215 23.9266 10.2447 23.877 9.87115 23.8096L10.391 21.8697C10.5931 21.9026 10.7963 21.9294 11.0003 21.9499Z"
fill="currentColor"
/>
<path
d="M7.8915 21.1169C7.98478 21.159 8.07876 21.1996 8.17342 21.2388C8.26813 21.278 8.36337 21.3158 8.45911 21.352L7.9393 23.292C7.7608 23.2278 7.58367 23.1593 7.40805 23.0866C7.23249 23.0138 7.05886 22.937 6.8873 22.8562L7.8915 21.1169Z"
fill="currentColor"
/>
<path
d="M5.67173 19.7427C5.8305 19.8725 5.99315 19.9973 6.15943 20.1169L5.15524 21.8563C4.84345 21.6397 4.54188 21.4084 4.25159 21.1629L5.67173 19.7427Z"
fill="currentColor"
/>
<path
d="M3.88341 17.841C4.00303 18.0072 4.12778 18.1698 4.25752 18.3285L2.83737 19.7487C2.59192 19.4584 2.36059 19.1569 2.14409 18.8452L3.88341 17.841Z"
fill="currentColor"
/>
<path
d="M2.64825 15.5412C2.68449 15.6369 2.72223 15.7321 2.76146 15.8268C2.80069 15.9215 2.84134 16.0156 2.88341 16.1089L1.14409 17.1131C1.06327 16.9415 0.986441 16.7678 0.913699 16.5922C0.840963 16.4166 0.772485 16.2395 0.708295 16.061L2.64825 15.5412Z"
fill="currentColor"
/>
<path
d="M2.05038 13C2.07088 13.204 2.09764 13.4072 2.1306 13.6093L0.19065 14.1291C0.123306 13.7556 0.0736704 13.3788 0.0419922 13H2.05038Z"
fill="currentColor"
/>
<path
d="M2.05038 11C2.14788 10.0298 2.38695 9.07731 2.76146 8.17317C3.26401 6.95991 4.0006 5.85752 4.92919 4.92893C5.85777 4.00035 6.96016 3.26375 8.17342 2.7612C9.38667 2.25866 10.687 2 12.0003 2V0C10.4244 1.8792e-08 8.86396 0.310389 7.40805 0.913446C5.95215 1.5165 4.62928 2.40042 3.51497 3.51472C2.40067 4.62902 1.51676 5.95189 0.913699 7.4078C0.438578 8.55484 0.145121 9.76677 0.0419922 11H2.05038Z"
fill="currentColor"
/>
</svg>
-42
View File
@@ -1,42 +0,0 @@
<script>
/** @type {number} [size = 24] */
export let size = 24;
</script>
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style="font-size:{size}px"
>
<path
d="M15.5922 0.913446C14.1363 0.310389 12.5759 -1.8792e-08 11 0L11 2C12.3132 2 13.6136 2.25866 14.8268 2.7612C16.0401 3.26375 17.1425 4.00035 18.0711 4.92893C18.9997 5.85752 19.7362 6.95991 20.2388 8.17317C20.6133 9.07731 20.8524 10.0298 20.9499 11H22.9583C22.8551 9.76677 22.5617 8.55484 22.0866 7.4078C21.4835 5.95189 20.5996 4.62902 19.4853 3.51472C18.371 2.40042 17.0481 1.5165 15.5922 0.913446Z"
fill="currentColor"
/>
<path
d="M22.9583 13C22.9338 13.2923 22.8987 13.5835 22.853 13.8728L20.9082 13.3517C20.9242 13.2348 20.9381 13.1175 20.9499 13H22.9583Z"
fill="currentColor"
/>
<path
d="M20.4408 15.297L22.3771 15.8159C22.2894 16.0775 22.1925 16.3365 22.0866 16.5922C22.078 16.6128 22.0694 16.6334 22.0608 16.6539L20.3123 15.6444C20.3573 15.5293 20.4002 15.4135 20.4408 15.297Z"
fill="currentColor"
/>
<path
d="M19.3994 17.4268L21.1333 18.4278C20.9675 18.6892 20.7916 18.944 20.6061 19.1919L19.1744 17.7602C19.2516 17.6505 19.3267 17.5394 19.3994 17.4268Z"
fill="currentColor"
/>
<path
d="M17.8609 19.2751L19.2757 20.6899C19.0399 20.9144 18.7954 21.1289 18.5429 21.333L17.5281 19.5752C17.6412 19.4778 17.7521 19.3777 17.8609 19.2751Z"
fill="currentColor"
/>
<path
d="M15.8834 20.7265L16.8835 22.4587C16.5838 22.6273 16.2766 22.7831 15.9628 22.9257L15.4366 20.9619C15.5876 20.8872 15.7366 20.8087 15.8834 20.7265Z"
fill="currentColor"
/>
<path
d="M13.5553 21.668L14.0729 23.5999C13.7189 23.6937 13.3609 23.7712 13 23.8322V21.798C13.1862 21.7599 13.3714 21.7166 13.5553 21.668Z"
fill="currentColor"
/>
</svg>
-63
View File
@@ -1,63 +0,0 @@
<script>
/** @type {number} */
export let size = 24;
</script>
<div class="svpod--spinner-container" style="width:{size}px; height:{size}px">
{#each [0, 0.075, 0.15] as delay}
<div class="svpod--item">
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style="animation-delay: {delay}s;"
>
<path
d="M24 12C24 14.3734 23.2962 16.6935 21.9776 18.6668C20.6591 20.6402 18.7849 22.1783 16.5922 23.0866C14.3995 23.9948 11.9867 24.2324 9.65892 23.7694C7.33114 23.3064 5.19295 22.1635 3.51472 20.4853C1.83649 18.8071 0.693599 16.6689 0.230577 14.3411C-0.232446 12.0133 0.00519403 9.60051 0.913446 7.4078C1.8217 5.21509 3.35977 3.34094 5.33316 2.02236C7.30655 0.703788 9.62662 -2.83022e-08 12 0L12 3C10.22 3 8.47991 3.52784 6.99987 4.51677C5.51983 5.50571 4.36627 6.91131 3.68508 8.55585C3.0039 10.2004 2.82567 12.01 3.17293 13.7558C3.5202 15.5016 4.37737 17.1053 5.63604 18.364C6.89471 19.6226 8.49836 20.4798 10.2442 20.8271C11.99 21.1743 13.7996 20.9961 15.4442 20.3149C17.0887 19.6337 18.4943 18.4802 19.4832 17.0001C20.4722 15.5201 21 13.78 21 12H24Z"
fill="currentColor"
/>
</svg>
</div>
{/each}
</div>
<style>
div.svpod--spinner-container {
display: inline-block;
position: relative;
}
div.svpod--item {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
opacity: 0.5;
color: var(--svpod--accent--lighter);
}
div.svpod--item:nth-child(1) {
opacity: 1;
color: var(--svpod--content--lighter);
z-index: 2;
}
@keyframes Spin {
0%,
100% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
svg {
animation: Spin 1s cubic-bezier(0.5, 0.1, 0.1, 0.8) infinite;
}
</style>
-1
View File
@@ -1 +0,0 @@
export { default as PlayerWidget } from './player-widget.svelte';
-315
View File
@@ -1,315 +0,0 @@
<script lang="ts">
import {
LoadingSpinner,
Pause,
Play,
SkipBack,
SkipForward,
} from '$src/components/icon';
import { AudioPlayer, user_preferences } from 'svelte-podcast';
export let skip_back = 30;
export let skip_forward = 10;
export let playback_rate_values = [1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.4];
</script>
<svelte:head>
<style>
:root {
--svpod-surface-darker: rgb(0, 0, 0);
--svpod-surface-base: rgb(40, 40, 40);
--svpod-surface-lighter: rgb(60, 60, 60);
--svpod-content-darker: rgb(150, 150, 150);
--svpod-content-base: rgb(200, 200, 200);
--svpod-content-lighter: rgb(255, 255, 255);
--svpod-accent-darker: rgb(75, 15, 0);
--svpod-accent-base: rgb(180, 40, 0);
--svpod-accent-lighter: rgb(255, 55, 25);
}
</style>
</svelte:head>
<AudioPlayer
let:PlayerProgress
let:skip_by
let:play
let:preference
let:attributes
>
<div
class="svpod-container {$$restProps.class}"
data-loaded={attributes.is_loaded ? 'true' : 'false'}
>
<!-- Player Action: Skip back -->
<button
on:click={() => skip_by(skip_back, 'backward')}
class="svpod-skip"
type="button"
aria-label="Skip back {skip_back} seconds"
>
<div class="svpod-skip-label">
<span>
{skip_back}
</span>
</div>
<div class="svpod-skip-icon">
<SkipBack />
</div>
</button>
<!-- Player Action: Toggle play / pause -->
{#if attributes.is_loaded}
<button
on:click={() => play('toggle')}
class="svpod-toggle"
type="button"
aria-label={attributes.is_paused ? 'Play' : 'Pause'}
>
<svelte:component this={attributes.is_paused ? Play : Pause} />
</button>
{:else}
<div class="svpod-toggle text-white">
<LoadingSpinner />
<span class="svpod--a11y">Waiting for audio...</span>
</div>
{/if}
<!-- Player Action: Skip forward -->
<button
on:click={() => skip_by(skip_forward, 'forward')}
class="svpod-skip"
type="button"
aria-label="Skip forward {skip_forward} seconds"
>
<div class="svpod-skip-label">
<span>
{skip_forward}
</span>
</div>
<div class="svpod-skip-icon">
<SkipForward />
</div>
</button>
<!-- Player Data: Current timestamp -->
<div class="svpod-timestamp" class:active={!attributes.is_paused}>
<span style="width:{attributes.timestamp_end.length}ch">
{attributes.timestamp_current}
</span>
</div>
<!-- Player Component: Timeline -->
<div class="svpod--timeline">
<PlayerProgress />
</div>
<!-- Player Data: Total duration timestamp -->
<div class="svpod-timestamp">
<span style="width:{attributes.timestamp_end.length}ch">
{attributes.timestamp_end}
</span>
</div>
<!-- Player Action: Select playback speed -->
<select
class="svpod-playback"
value={$user_preferences.playback_rate}
on:change={(e) => {
const value = parseFloat(e.currentTarget.value);
preference.set_playback_rate(value);
}}
>
{#each playback_rate_values as value}
<option {value}>
{#if Number.isInteger(value)}
{value}.0
{:else}
{value}
{/if}
</option>
{/each}
</select>
</div>
</AudioPlayer>
<style lang="postcss">
* {
/* css-reset */
text-transform: none;
font-style: normal;
text-indent: 0;
text-shadow: none;
text-align: left;
margin: 0;
padding: 0;
}
.svpod--a11y {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.svpod-container {
--size: 48px;
--column: var(--size);
--row: var(--size);
--text: calc(var(--size) / 3);
--padding: 2px;
--radius: 12px;
--inner-radius: calc(var(--radius) - var(--padding));
--bg: var(--svpod-surface-darker);
--fg: var(--svpod-content-base);
display: grid;
grid-template-columns:
var(--column)
var(--column)
var(--column)
max-content
auto
max-content
var(--column);
grid-template-rows: var(--column);
user-select: none;
background-color: var(--bg);
padding: var(--padding);
gap: var(--padding);
border-radius: var(--radius);
}
button,
select {
background-color: var(--bg);
color: var(--fg);
border-radius: var(--inner-radius);
overflow: hidden;
font-size: 0.8em;
font-weight: 500;
&:hover {
--fg: var(--svpod-content-lighter);
--bg: var(--svpod-surface-base);
}
&:focus {
outline: 2px solid var(--svpod-accent-lighter);
box-shadow: none;
}
}
.svpod-skip {
position: relative;
display: grid;
align-content: stretch;
justify-content: stretch;
.svpod-skip-icon {
position: absolute;
inset: 4px;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
:global(svg) {
width: 100%;
height: 100%;
}
}
.svpod-skip-label {
width: 100%;
height: 100%;
display: grid;
place-content: center;
z-index: 30;
background: linear-gradient(0deg, var(--bg), transparent);
span {
line-height: 1em;
}
}
}
.svpod-toggle {
--fg: var(--svpod-content-lighter);
display: grid;
place-content: center;
&:hover {
--fg: var(--svpod-accent-lighter);
--bg: var(--svpod-accent-darker);
}
}
.svpod-playback {
appearance: none;
background-image: none;
margin: 0;
text-align: center;
text-align-last: center;
padding: 0 0.5em;
letter-spacing: 0.05em;
border: none;
&::before,
&::after {
content: none;
}
}
.svpod-timestamp {
display: grid;
place-content: center;
letter-spacing: 0.05em;
font-size: 0.8em;
text-align: center;
padding: 0.25em 1em;
color: var(--fg);
background-color: var(--bg);
border-radius: var(--inner-radius);
place-self: center;
span {
display: inline-block;
text-align: center;
}
&.active {
--fg: var(--svpod-accent-lighter);
--bg: var(--svpod-accent-darker);
}
}
.svpod--timeline {
display: flex;
align-items: center;
justify-content: stretch;
flex-grow: 1;
flex-shrink: 1;
font-size: 1em;
background: none;
--progress-height: var(--size);
--progress-fg: var(--svpod-content-base);
--progress-bg: var(--svpod-surface-base);
--progress-border: var(--svpod-surface-base);
--progress-active-fg: var(--svpod-accent-lighter);
--progress-active-bg: var(--svpod-accent-darker);
--progress-active-border: var(--svpod-accent-base);
}
</style>
-25
View File
@@ -1,25 +0,0 @@
import { assets } from '$app/paths';
/**
* Audio context module.
* @module episodes
*/
export const episodes = Object.freeze({
syntax: {
src: `${assets}/example-syntax.mp3`,
title: `Supper Club × Rich Harris, Author of Svelte`,
artwork: `https://ssl-static.libsyn.com/p/assets/b/3/c/d/b3cdf28da11ad39fe5bbc093207a2619/Syntax_-_499.jpg`,
},
knomii: {
src: `${assets}/example-knomii.mp3`,
title: `Empowerment starts with letting go of control`,
artwork: `https://ssl-static.libsyn.com/p/assets/f/a/8/d/fa8d56d5226884335f2e77a3093c12a1/ep-6.png`,
},
svelte: {
src: 'https://media.transistor.fm/6d6c49e4/4cecfd0d.mp3?src=site',
title: 'SvelteKit-superforms with Andreas Söderlund',
artwork:
'https://images.transistor.fm/file/transistor/images/show/12899/medium_1597678946-artwork.jpg',
},
});
-23
View File
@@ -1,23 +0,0 @@
import { BROWSER } from 'esm-env';
export const slugify = (/** @type {string} */ str) =>
encodeURI(str.toLowerCase().trim().replaceAll(' ', '_'));
export const format_code = (/** @type {string} */ code) =>
code.replaceAll('\t', ' ');
export const on_this_page = () => {
if (!BROWSER) return [];
let anchorLinks = [];
let aTags = document.getElementsByTagName('a');
for (let i = 0; i < aTags.length; i++) {
let href = aTags[i]?.getAttribute('href');
if (href && href.startsWith('#')) {
anchorLinks.push(href);
}
}
return anchorLinks;
};
-41
View File
@@ -1,41 +0,0 @@
<script>
import { page } from '$app/stores';
import { MetaTags } from 'svelte-meta-tags';
const site_name = 'Svelte Podcast';
/** @type {string} */
export let title = 'The fastest way to build a podcast site with Svelte.';
/** @type {string} */
export let description =
'A suite of tools and components to build your own podcast players, and work with RSS podcast data in SvelteKit.';
/** @type {string} */
export let canonical = 'https://svelte-podcast.com' + $page.url.pathname;
// TODO: add screenshots under images
/** @type {import('svelte-meta-tags').OpenGraph["images"]}*/
export let images = [
{
url: 'https://svelte-podcast.com/thumb-4_3.png',
width: 800,
height: 600,
alt: 'Svelte Podcast Tools',
},
];
</script>
<MetaTags
titleTemplate="%s | {site_name}"
{title}
{description}
{canonical}
openGraph={{
locale: 'en_US',
site_name,
type: 'website',
url: canonical,
title: title,
description,
images,
}}
/>
-32
View File
@@ -1,32 +0,0 @@
<script>
import { on_this_page, slugify } from '$src/layout/helper';
import Metadata from '$src/layout/metadata.svelte';
import Container from '$src/layout/page/container.svelte';
import SectionArticle from '$src/layout/page/section-article.svelte';
import Section from '$src/layout/page/section.svelte';
import TableSchema from '$src/layout/page/table-schema.svelte';
import { onMount } from 'svelte';
/** @type {string}*/
export let title;
export let anchor = slugify(title);
const anchor_links = on_this_page();
onMount(() => {
console.log('on_this_page', JSON.stringify(anchor_links, null, 3));
});
</script>
<Metadata {title} />
<slot name="header" {anchor} {title}>
<Container as="header" aria-labelledby={anchor}>
<div class="richtext sm:pt-10 lg:pt-20">
<h1 id={anchor} class="my-0">{title}</h1>
</div>
</Container>
</slot>
<slot {Section} {SectionArticle} {TableSchema} />
-12
View File
@@ -1,12 +0,0 @@
<script>
/** @type {'header'|'section'|'div'}*/
export let as = 'div';
</script>
<svelte:element
this={as}
{...$$restProps}
class="relative mx-auto w-full max-w-6xl px-2 py-4 md:px-6 md:py-6"
>
<slot />
</svelte:element>
-1
View File
@@ -1 +0,0 @@
export { default as DocsPage } from './component.svelte';
-19
View File
@@ -1,19 +0,0 @@
<script>
import { slugify } from '$src/layout/helper';
/** @type {string} */
export let title;
export let anchor = slugify(title);
/** @type {'h3'|'h4'|'h5'} */
export let level = 'h3';
</script>
<article aria-labelledby={anchor}>
<svelte:element this={level} id={anchor}>
<a href="#{anchor}">{title}</a>
</svelte:element>
<slot />
</article>
-22
View File
@@ -1,22 +0,0 @@
<script>
import { slugify } from '$src/layout/helper';
import Container from '$src/layout/page/container.svelte';
/** @type {string} */
export let title;
export let anchor = slugify(title);
/** @type {'h2'|'h3'|'h4'|'h5'} */
export let level = 'h2';
</script>
<Container as="section" aria-labelledby={anchor}>
<div class="richtext">
<svelte:element this={level} id={anchor} class="mt-0">
<a href="#{anchor}">{title}</a>
</svelte:element>
<slot />
</div>
</Container>
-40
View File
@@ -1,40 +0,0 @@
<script>
import { Highlight } from 'svelte-highlight';
import lang_ts from 'svelte-highlight/languages/typescript';
/**
* Table Row
* @typedef {Object} Row
* @property {string} property - Property on the model
* @property {string} type - Value type
* @property {string} description - Description of the property
*/
/** @type {Row[]} */
export let rows;
</script>
<div class="-mx-6 min-w-full overflow-x-auto bg-white md:-mx-8">
<table class="my-2 w-max min-w-full">
<thead class="">
<th class="w-max">Property</th>
<th class="w-max">Type</th>
<th>Description</th>
</thead>
<tbody>
{#each rows as { property, type, description }}
<tr>
<!-- <td class="pr-4 font-mono">will_autoplay</td> -->
<!-- <td class="pr-4 font-mono text-primary-600">boolean</td> -->
<td class="not-prose">
<Highlight code={property} language={lang_ts} />
</td>
<td class="not-prose">
<Highlight code={type} language={lang_ts} />
</td>
<td> {description} </td>
</tr>
{/each}
</tbody>
</table>
</div>
-21
View File
@@ -1,21 +0,0 @@
<script>
/** @type {string} */
export let name;
</script>
<div class="flex flex-col items-stretch space-y-6">
<div class="prose prose-lg prose-slate">
<h3>{name}</h3>
</div>
<fieldset>
<legend>{name} options</legend>
<slot name="options" />
</fieldset>
<div class="w-full rounded-md border p-3">
<div class="mx-auto w-max">
<slot />
</div>
</div>
</div>
-216
View File
@@ -1,216 +0,0 @@
<script>
import { assets } from '$app/paths';
import { page } from '$app/stores';
import clsx from 'clsx';
import { circOut } from 'svelte/easing';
import { fly } from 'svelte/transition';
/**
* Audio context module.
* @module sidebar
*/
/**
* @typedef {Object} SectionLink
* @property {string} label - The label of the page link.
* @property {string} anchor - The URL of the page link.
*/
/**
* @typedef {Object} PageLink
* @property {string} label - The label of the page link.
* @property {string} href - The URL of the page link.
* @property {SectionLink[]} sections - The sections of the page link.
*/
/** @type {Readonly<PageLink[]>} */
export let pages;
/**
* @typedef {Object} ResourceLink
* @property {string} label - The label of the page link.
* @property {string} href - The URL of the page link.
* @property {import('@inqling/svelte-icons').SvelteIcon} icon - The URL of the page link.
*/
/** @type {Readonly<ResourceLink[]>} */
export let resources;
/**
* @typedef {Object} PodcastLink
* @property {string} label - The label of the page link.
* @property {string} href - The URL of the page link.
* @property {string} src - The URL of the page link.
*/
/** @type {Readonly<PodcastLink[]>} */
export let podcasts;
$: use_is_current = (/** @type {string } */ href) => {
let href_pathname = href;
if (href.startsWith('http')) {
href_pathname = new URL(href).pathname;
}
// remove leading and training slashes
href_pathname = href_pathname.replace(/^\/|\/$/g, '');
const page_pathname = $page.url.pathname.replace(/^\/|\/$/g, '');
return page_pathname === href_pathname;
};
const use_is_current_section = (
/** @type {boolean} */ is_current_page,
/** @type {string} */ anchor,
) => {
if (!is_current_page) return false;
return $page.url.hash.includes(anchor);
};
</script>
<!-- Sidebar component, swap this element with another sidebar if you like -->
<div
in:fly={{
x: -320,
duration: 300,
easing: circOut,
opacity: 1,
}}
out:fly={{
x: -320,
duration: 300,
easing: circOut,
opacity: 1,
}}
class={clsx(
'pointer-events-auto flex grow origin-left flex-col gap-y-5 overflow-y-auto bg-white px-6',
$$restProps.class,
)}
>
<a href="/" class="flex h-16 shrink-0 items-center">
<img
class="h-8 w-auto"
width={60}
height={32}
src="{assets}/logo.png"
alt="Svelte-Podcast"
/>
</a>
<nav class="flex flex-1 flex-col">
<ul role="list" class="flex flex-1 flex-col gap-y-7">
<li>
<ul role="list" class="-mx-2">
{#each pages as link}
{@const is_current = use_is_current(link.href)}
<li class="py-0.5">
<a
class:active={is_current}
href={link.href}
class={clsx(
'group flex gap-x-3 rounded-md px-3 py-1.5 text-sm font-semibold leading-6',
is_current
? 'bg-primary-50 text-primary-600'
: 'text-mono-600 hover:bg-mono-50 hover:text-mono-950',
)}
>
{link.label}
</a>
{#if link.sections.length}
<ul role="list" class="border-l border-mono-200 pl-3">
{#each link.sections as section}
{@const is_current_section =
use_is_current_section(
is_current,
section.anchor,
)}
{@const section_href = [
link.href,
section.anchor,
].join('#')}
<li class="py-0.5">
<a
class:active={is_current_section}
href={section_href}
class={clsx(
'group flex gap-x-3 rounded-md px-3 py-1.5 text-xs font-medium leading-6',
is_current_section
? 'text-primary-600'
: 'text-mono-500 hover:bg-mono-50 hover:text-mono-950',
)}
>
{section.label}
</a>
</li>
{/each}
</ul>
{/if}
</li>
{/each}
</ul>
</li>
<li>
<div class="text-xs font-semibold leading-6 text-mono-400">
Resources
</div>
<ul role="list" class="-mx-2 mt-2 space-y-1">
{#each resources as link}
{@const is_current = use_is_current(link.href)}
<li>
<a
href={link.href}
target="_blank"
class={clsx(
'group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6',
is_current
? 'bg-mono-50 text-primary-600'
: 'text-mono-700 hover:bg-mono-50 hover:text-primary-600',
)}
>
<span
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-mono-200 bg-white text-[0.625rem] font-medium text-mono-400 group-hover:border-primary-600 group-hover:text-primary-600"
>
<svelte:component this={link.icon} class="h-4 w-4" />
</span>
<span class="truncate">{link.label}</span>
</a>
</li>
{/each}
</ul>
</li>
<li>
<div class="text-xs font-semibold leading-6 text-mono-400">
Svelte Podcasts
</div>
<ul role="list" class="-mx-2 mt-2 space-y-1">
{#each podcasts as link}
{@const is_current = use_is_current(link.href)}
<li>
<a
href={link.href}
target="_blank"
class={clsx(
'group flex gap-x-3 rounded-md p-2 text-sm font-semibold leading-6',
is_current
? 'bg-mono-50 text-primary-600'
: 'text-mono-700 hover:bg-mono-50 hover:text-primary-600',
)}
>
<img
src={link.src}
height="56"
width="56"
alt=""
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-mono-200 bg-white text-[0.625rem] font-medium text-mono-400 group-hover:border-primary-600 group-hover:text-primary-600"
/>
<span class="truncate">{link.label}</span>
</a>
</li>
{/each}
</ul>
</li>
</ul>
</nav>
</div>
+11 -3
View File
@@ -58,12 +58,18 @@ const unload_src = () => {
*/
const play = (playing: boolean | 'toggle' = 'toggle') => {
const el = use_audio_element('play');
console.log(el, el.paused);
// Catch promise rejections: playback can be interrupted when a new
// source is loaded before the previous one started playing.
const safe_play = () =>
void el.play().catch(() => {
/* playback interrupted: ignore */
});
if (typeof playing === 'boolean') {
playing ? el.play() : el.pause();
playing ? safe_play() : el.pause();
} else {
el.paused ? el.play() : el.pause();
el.paused ? safe_play() : el.pause();
}
};
@@ -91,6 +97,7 @@ const seek_to = (
from: 'from-start' | 'from-end' = 'from-start',
) => {
const el = use_audio_element('seek');
if (!Number.isFinite(el.duration)) return;
if (from === 'from-end') {
el.currentTime = clamp(0, el.duration, el.duration - seconds);
@@ -106,6 +113,7 @@ const seek_to = (
*/
const skip_by = (seconds: number, type: 'forward' | 'backward' = 'forward') => {
const el = use_audio_element('skip');
if (!Number.isFinite(el.duration)) return;
if (type === 'backward') {
el.currentTime = clamp(0, el.duration, el.currentTime - seconds);
+1
View File
@@ -3,6 +3,7 @@ export { default as AudioProgress } from './Progress.svelte';
export * from './actions';
export * from './attributes';
export * from './metadata';
export * from './rss';
export * from './user-preferences';
export * from './user-progress';
export * from './utility/format-seconds';
+219
View File
@@ -0,0 +1,219 @@
import { XMLParser } from 'fast-xml-parser';
/**
* An episode of a podcast feed, normalized from RSS 2.0 + iTunes tags.
* Only episodes with a playable audio enclosure are included in a feed.
*/
export interface PodcastEpisode {
/** Unique identifier (guid, or the audio URL as a fallback). */
id: string;
title: string;
/** Episode description (itunes:summary, or description as a fallback). May contain HTML. */
description: string | null;
/** URL of the episode's web page. */
link: string | null;
/** Publication date as an ISO 8601 string. */
published_at: string | null;
/** Duration in seconds, parsed from itunes:duration. */
duration: number | null;
/** URL of the audio file (enclosure). */
src: string;
/** MIME type of the audio file, e.g. "audio/mpeg". */
mime_type: string | null;
/** Size of the audio file in bytes. */
bytes: number | null;
/** Episode artwork (itunes:image), if any. */
artwork: string | null;
episode_number: number | null;
season_number: number | null;
explicit: boolean;
}
/**
* A podcast feed, normalized from an RSS 2.0 document.
*/
export interface PodcastFeed {
title: string;
description: string | null;
/** URL of the podcast's website. */
link: string | null;
language: string | null;
author: string | null;
/** Feed artwork: itunes:image, or the channel image as a fallback. */
artwork: string | null;
/** Playable episodes, in feed order (most recent first). */
episodes: PodcastEpisode[];
}
const parser = new XMLParser({
ignoreAttributes: false,
attributeNamePrefix: '@_',
textNodeName: '#text',
// Keep values as strings: URLs and GUIDs must not be parsed as numbers.
parseTagValue: false,
parseAttributeValue: false,
trimValues: true,
isArray: (tag_name) => tag_name === 'item',
});
type XmlNode = Record<string, unknown>;
/**
* Extracts the text content of an XML node, whether it is a plain
* string, a number, or an object with a `#text` property (CDATA with
* attributes). Returns null for missing or empty values.
*/
function as_text(value: unknown): string | null {
if (value === null || value === undefined) return null;
if (typeof value === 'string' || typeof value === 'number') {
const text = String(value).trim();
return text === '' ? null : text;
}
if (typeof value === 'object' && '#text' in value) {
return as_text((value as XmlNode)['#text']);
}
return null;
}
/** Extracts an attribute value from an XML node. */
function as_attribute(value: unknown, name: string): string | null {
if (typeof value !== 'object' || value === null) return null;
return as_text((value as XmlNode)[`@_${name}`]);
}
/**
* Parses an itunes:duration value into seconds.
* Supported formats: "3723", "62:03" (mm:ss), "1:02:03" (hh:mm:ss).
*/
function parse_duration(value: unknown): number | null {
const text = as_text(value);
if (!text) return null;
const parts = text.split(':').map((part) => Number.parseFloat(part));
if (parts.some((part) => Number.isNaN(part))) return null;
let seconds = 0;
for (const part of parts) seconds = seconds * 60 + part;
return Math.round(seconds);
}
/** Parses an RFC 822 date into an ISO 8601 string. */
function parse_date(value: unknown): string | null {
const text = as_text(value);
if (!text) return null;
const timestamp = Date.parse(text);
return Number.isNaN(timestamp) ? null : new Date(timestamp).toISOString();
}
/** Parses an itunes:explicit value ("yes", "true", "explicit", "clean", "no"). */
function parse_explicit(value: unknown): boolean {
const text = as_text(value)?.toLowerCase();
return text === 'yes' || text === 'true' || text === 'explicit';
}
/** Parses an integer value, e.g. itunes:episode or itunes:season. */
function parse_integer(value: unknown): number | null {
const text = as_text(value);
if (!text) return null;
const integer = Number.parseInt(text, 10);
return Number.isNaN(integer) ? null : integer;
}
/**
* Parses an RSS 2.0 podcast feed (with iTunes namespace tags) into a
* normalized PodcastFeed object. Works in any environment (server or
* browser): it does not rely on DOMParser.
*
* Episodes without a playable audio enclosure are skipped.
*
* @param xml - The raw XML document of the feed
* @throws If the document is not an RSS feed
*/
export function parse_podcast_feed(xml: string): PodcastFeed {
const document: XmlNode = parser.parse(xml);
const channel = (document.rss as XmlNode | undefined)?.channel as
| XmlNode
| undefined;
if (!channel) {
throw new Error('Invalid podcast feed: no <rss><channel> element found');
}
const channel_artwork =
as_attribute(channel['itunes:image'], 'href') ??
as_text((channel.image as XmlNode | undefined)?.url);
const episodes: PodcastEpisode[] = [];
for (const item of (channel.item as XmlNode[] | undefined) ?? []) {
const src = as_attribute(item.enclosure, 'url');
if (!src) continue; // not a playable episode
const bytes = Number.parseInt(
as_attribute(item.enclosure, 'length') ?? '',
10,
);
episodes.push({
id: as_text(item.guid) ?? src,
title: as_text(item.title) ?? src,
description:
as_text(item['itunes:summary']) ?? as_text(item.description),
link: as_text(item.link),
published_at: parse_date(item.pubDate),
duration: parse_duration(item['itunes:duration']),
src,
mime_type: as_attribute(item.enclosure, 'type'),
bytes: Number.isNaN(bytes) ? null : bytes,
artwork: as_attribute(item['itunes:image'], 'href'),
episode_number: parse_integer(item['itunes:episode']),
season_number: parse_integer(item['itunes:season']),
explicit: parse_explicit(item['itunes:explicit']),
});
}
return {
title: as_text(channel.title) ?? 'Untitled podcast',
description:
as_text(channel['itunes:summary']) ?? as_text(channel.description),
link: as_text(channel.link),
language: as_text(channel.language),
author: as_text(channel['itunes:author']),
artwork: channel_artwork,
episodes,
};
}
/**
* Fetches and parses a podcast feed. Designed for SvelteKit `load`
* functions: pass the provided `fetch` so the request benefits from
* SSR, caching and hydration.
*
* @example
* ```ts
* // src/routes/podcast/+page.ts
* import { fetch_podcast_feed } from 'svelte-podcast';
* import type { PageLoad } from './$types';
*
* export const load: PageLoad = async ({ fetch }) => ({
* feed: await fetch_podcast_feed('https://feed.syntax.fm/rss', fetch),
* });
* ```
*
* @param url - URL of the RSS feed
* @param fetcher - The fetch implementation to use (SvelteKit's by default)
* @throws If the request fails or the response is not an RSS feed
*/
export async function fetch_podcast_feed(
url: string,
fetcher: typeof fetch = fetch,
): Promise<PodcastFeed> {
const response = await fetcher(url);
if (!response.ok) {
throw new Error(
`Could not fetch podcast feed: ${response.status} ${response.statusText}`,
);
}
return parse_podcast_feed(await response.text());
}
+79
View File
@@ -0,0 +1,79 @@
/**
* Minimal client for the Funkwhale API of mizik.o-k-i.net,
* restricted to the public radio "Radyobòkaz" (id 7).
*/
import type { PlayableTrack } from './playable';
export const FUNKWHALE_BASE = 'https://mizik.o-k-i.net';
export const RADIO_ID = 7;
export const RADIO_PAGE_SIZE = 50; // max allowed by the API
/** A track from the radio, ready to be played. */
export interface RadioTrack extends PlayableTrack {
id: number;
}
interface ApiImage {
urls: {
original: string | null;
medium_square_crop: string | null;
};
}
interface ApiTrack {
id: number;
title: string;
listen_url: string;
artist: { name: string };
album: { title: string; cover: ApiImage | null } | null;
uploads: { duration: number | null }[];
}
interface ApiPage {
count: number;
next: string | null;
results: ApiTrack[];
}
function to_radio_track(track: ApiTrack): RadioTrack {
const cover = track.album?.cover?.urls;
return {
id: track.id,
title: track.title,
artist: track.artist.name,
album: track.album?.title ?? null,
cover: cover?.medium_square_crop ?? cover?.original ?? null,
stream_url: `${FUNKWHALE_BASE}${track.listen_url}`,
duration: track.uploads[0]?.duration ?? null,
};
}
/**
* Fetches one page of the radio's track list.
* @param page - 1-based page number
*/
export async function fetch_radio_page(
page: number,
): Promise<{ count: number; tracks: RadioTrack[] }> {
const url = `${FUNKWHALE_BASE}/api/v1/radios/radios/${RADIO_ID}/tracks/?page=${page}&page_size=${RADIO_PAGE_SIZE}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Funkwhale API error: ${response.status}`);
}
const data: ApiPage = await response.json();
return { count: data.count, tracks: data.results.map(to_radio_track) };
}
/**
* Shuffles an array in place (FisherYates) and returns it.
*/
export function shuffle<T>(array: T[]): T[] {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const temporary = array[i] as T;
array[i] = array[j] as T;
array[j] = temporary;
}
return array;
}
+93
View File
@@ -0,0 +1,93 @@
import { get, writable } from 'svelte/store';
import {
fetch_radio_page,
RADIO_PAGE_SIZE,
type RadioTrack,
} from './funkwhale';
import { radio_player } from './radio-store';
/**
* State of the browsable library (all tracks of the radio).
*/
export interface LibraryState {
tracks: RadioTrack[];
/** Total number of tracks on the radio (known after the first page). */
total: number | null;
loaded_pages: number;
total_pages: number | null;
is_loading: boolean;
is_done: boolean;
error: string | null;
}
const initial_state: LibraryState = {
tracks: [],
total: null,
loaded_pages: 0,
total_pages: null,
is_loading: false,
is_done: false,
error: null,
};
export const library = writable<LibraryState>(initial_state);
/**
* Loads every page of the radio's track list, one after another.
* Idempotent: does nothing if already loading or fully loaded.
*/
export async function load_library(): Promise<void> {
const state = get(library);
if (state.is_loading || state.is_done) return;
library.set({ ...state, is_loading: true, error: null });
try {
// First page: tells us how many tracks / pages exist.
const first = await fetch_radio_page(1);
const total_pages = Math.ceil(first.count / RADIO_PAGE_SIZE);
library.set({
tracks: first.tracks,
total: first.count,
loaded_pages: 1,
total_pages,
is_loading: true,
is_done: total_pages <= 1,
error: null,
});
// Load remaining pages in small parallel batches.
const CONCURRENCY = 4;
for (let start = 2; start <= total_pages; start += CONCURRENCY) {
// Pause while playback is loading a track: streaming and
// playback-related requests have priority over the library.
while (get(radio_player).is_loading) {
await new Promise((resolve) => setTimeout(resolve, 300));
}
const pages = await Promise.all(
Array.from(
{ length: Math.min(CONCURRENCY, total_pages - start + 1) },
(_, i) => fetch_radio_page(start + i),
),
);
library.update((prev) => ({
...prev,
tracks: [...prev.tracks, ...pages.flatMap((p) => p.tracks)],
loaded_pages: Math.min(start + CONCURRENCY - 1, total_pages),
}));
}
library.update((prev) => ({
...prev,
is_loading: false,
is_done: true,
}));
} catch (error) {
library.update((prev) => ({
...prev,
is_loading: false,
error: error instanceof Error ? error.message : String(error),
}));
}
}
+16
View File
@@ -0,0 +1,16 @@
/**
* A track that can be queued in the player, regardless of its source
* (Funkwhale radio, podcast episode…).
*/
export interface PlayableTrack {
id: string | number;
title: string;
artist: string;
album: string | null;
/** URL of the artwork, if any. */
cover: string | null;
/** Absolute URL of the audio stream. */
stream_url: string;
/** Duration in seconds, if known. */
duration: number | null;
}
+159
View File
@@ -0,0 +1,159 @@
<script lang="ts">
import BackwardIcon from '@inqling/svelte-icons/heroicon-24-solid/backward.svelte';
import ForwardIcon from '@inqling/svelte-icons/heroicon-24-solid/forward.svelte';
import MusicalNoteIcon from '@inqling/svelte-icons/heroicon-24-solid/musical-note.svelte';
import PauseIcon from '@inqling/svelte-icons/heroicon-24-solid/pause.svelte';
import PlayIcon from '@inqling/svelte-icons/heroicon-24-solid/play.svelte';
import SpeakerWaveIcon from '@inqling/svelte-icons/heroicon-24-solid/speaker-wave.svelte';
import SpeakerXMarkIcon from '@inqling/svelte-icons/heroicon-24-solid/speaker-x-mark.svelte';
import { AudioPlayer, user_preferences } from '$lib/index';
import { radio_player } from './radio-store';
let is_muted = false;
$: current = $radio_player.current;
</script>
<AudioPlayer let:PlayerProgress let:play let:mute let:attributes>
<div class="rounded-2xl bg-slate-900 p-4 text-slate-200 shadow-xl">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
<!-- Cover -->
<div
class="h-24 w-24 shrink-0 overflow-hidden rounded-xl bg-slate-700"
>
{#if current?.cover}
<img
src={current.cover}
alt="Pochette de {current.album ?? current.title}"
class="h-full w-full object-cover"
/>
{:else}
<div
class="grid h-full w-full place-content-center text-slate-500"
>
<MusicalNoteIcon class="h-10 w-10" />
</div>
{/if}
</div>
<!-- Track info -->
<div class="min-w-0 flex-1">
{#if current}
<p class="truncate text-lg font-semibold text-white">
{current.title}
</p>
<p class="truncate text-sm text-slate-400">
{current.artist}
{#if current.album}· {current.album}{/if}
</p>
{#if $radio_player.mode === 'radio'}
<p
class="mt-1 text-xs uppercase tracking-wider text-orange-400"
>
Mode radio aléatoire
</p>
{/if}
{:else}
<p class="text-lg font-semibold text-slate-400">
Aucun titre en cours
</p>
<p class="text-sm text-slate-500">
Lance la radio ou choisis un titre dans la liste.
</p>
{/if}
</div>
<!-- Controls -->
<div class="flex items-center gap-2">
<button
type="button"
on:click={() => radio_player.previous()}
disabled={!current}
class="rounded-full p-2 hover:bg-slate-700 disabled:opacity-30"
aria-label="Titre précédent"
>
<BackwardIcon class="h-6 w-6" />
</button>
<button
type="button"
on:click={() => play('toggle')}
disabled={!attributes.is_loaded}
class="rounded-full bg-orange-500 p-3 text-white hover:bg-orange-400 disabled:opacity-30"
aria-label={attributes.is_paused ? 'Lecture' : 'Pause'}
>
{#if attributes.is_paused}
<PlayIcon class="h-7 w-7" />
{:else}
<PauseIcon class="h-7 w-7" />
{/if}
</button>
<button
type="button"
on:click={() => radio_player.next()}
disabled={!current || $radio_player.is_loading}
class="rounded-full p-2 hover:bg-slate-700 disabled:opacity-30"
aria-label="Titre suivant"
>
<ForwardIcon class="h-6 w-6" />
</button>
<button
type="button"
on:click={() => {
mute('toggle');
is_muted = !is_muted;
}}
class="rounded-full p-2 hover:bg-slate-700"
aria-label="Activer / couper le son"
>
{#if is_muted || $user_preferences.volume === 0}
<SpeakerXMarkIcon class="h-5 w-5" />
{:else}
<SpeakerWaveIcon class="h-5 w-5" />
{/if}
</button>
<input
type="range"
min="0"
max="1"
step="0.05"
value={$user_preferences.volume}
on:input={(e) =>
user_preferences.set_volume(e.currentTarget.valueAsNumber)}
class="h-1 w-20 accent-orange-500"
aria-label="Volume"
/>
</div>
</div>
<!-- Progress -->
<div
class="mt-4 flex items-center gap-3 text-xs tabular-nums text-slate-400"
>
<span>{attributes.timestamp_current}</span>
<div class="flex-1">
<PlayerProgress />
</div>
<span>{attributes.timestamp_end}</span>
</div>
</div>
</AudioPlayer>
<style lang="postcss">
/* Theme the svelte-podcast progress bar */
:global(:root) {
--progress-bg: rgb(51, 65, 85);
--progress-fg: rgb(249, 115, 22);
--progress-border: rgb(51, 65, 85);
--progress-active-bg: rgb(71, 85, 105);
--progress-active-fg: rgb(251, 146, 60);
--progress-active-border: rgb(249, 115, 22);
--progress-radius: 4px;
--progress-thumb-width: 3px;
--progress-height: 10px;
}
</style>
+197
View File
@@ -0,0 +1,197 @@
import { BROWSER } from 'esm-env';
import { get, writable } from 'svelte/store';
import { audio } from '$lib/actions';
import { audio_element } from '$lib/_internal_/audio-element';
import { fetch_radio_page, RADIO_PAGE_SIZE, shuffle } from './funkwhale';
import type { PlayableTrack } from './playable';
export type PlayMode = 'radio' | 'list';
/**
* State of the playback queue.
*/
export interface RadioPlayerState {
/** 'radio' = endless shuffle, 'list' = play tracks from the library. */
mode: PlayMode;
queue: PlayableTrack[];
index: number;
current: PlayableTrack | null;
is_loading: boolean;
}
const initial_state: RadioPlayerState = {
mode: 'list',
queue: [],
index: -1,
current: null,
is_loading: false,
};
const state = writable<RadioPlayerState>(initial_state);
/**
* Loads a track into the audio element and starts playback.
* If the track was previously played to its end, restarts from 0
* (svelte-podcast restores the saved position otherwise).
*/
function load_and_play(track: PlayableTrack): void {
audio.src.load(track.stream_url, {
title: track.title,
artist: track.artist,
album: track.album,
artwork: track.cover,
});
const element = get(audio_element);
if (element) {
element.addEventListener(
'loadedmetadata',
() => {
if (
Number.isFinite(element.duration) &&
element.currentTime > element.duration - 5
) {
element.currentTime = 0;
}
},
{ once: true },
);
}
audio.play(true);
}
function play_at(index: number): void {
const current_state = get(state);
const track = current_state.queue[index];
if (!track) return;
state.set({ ...current_state, index, current: track });
load_and_play(track);
}
/** Fetches a random page of the radio, shuffled. */
async function fetch_random_queue(): Promise<PlayableTrack[]> {
// Page 1 is cheap and tells us the total number of pages.
const { count } = await fetch_radio_page(1);
const total_pages = Math.max(1, Math.ceil(count / RADIO_PAGE_SIZE));
const page = 1 + Math.floor(Math.random() * total_pages);
const { tracks } = await fetch_radio_page(page);
return shuffle(tracks);
}
export const radio_player = {
subscribe: state.subscribe,
/**
* Starts radio mode: an endless shuffled queue of random tracks.
*/
async start_radio(): Promise<void> {
const current_state = get(state);
if (current_state.is_loading) return;
state.set({ ...current_state, mode: 'radio', is_loading: true });
try {
const queue = await fetch_random_queue();
const first_track = queue[0];
if (!first_track) throw new Error('La radio ne contient aucun titre');
state.set({
mode: 'radio',
queue,
index: 0,
current: first_track,
is_loading: false,
});
load_and_play(first_track);
} catch (error) {
console.error(error);
state.set({ ...get(state), is_loading: false });
}
},
/**
* Plays a list of tracks (e.g. from the library) starting at index.
*/
play_tracks(tracks: PlayableTrack[], index: number): void {
const track = tracks[index];
if (!track) return;
state.set({
mode: 'list',
queue: tracks,
index,
current: track,
is_loading: false,
});
load_and_play(track);
},
/**
* Skips to the next track. In radio mode, fetches a new random
* page when the queue is exhausted. In list mode, stops at the end.
*/
async next(): Promise<void> {
const current_state = get(state);
if (current_state.is_loading) return;
if (current_state.index + 1 < current_state.queue.length) {
play_at(current_state.index + 1);
} else if (current_state.mode === 'radio') {
state.set({ ...current_state, is_loading: true });
try {
const queue = await fetch_random_queue();
const first_track = queue[0];
if (!first_track) {
state.set({ ...get(state), is_loading: false });
return;
}
state.set({
mode: 'radio',
queue,
index: 0,
current: first_track,
is_loading: false,
});
load_and_play(first_track);
} catch (error) {
console.error(error);
state.set({ ...get(state), is_loading: false });
}
}
},
/**
* Goes back to the previous track, or restarts the current one
* if it has been playing for more than 5 seconds.
*/
previous(): void {
const current_state = get(state);
const element = get(audio_element);
if (element && element.currentTime > 5) {
audio.seek_to(0);
return;
}
if (current_state.index > 0) {
play_at(current_state.index - 1);
} else {
audio.seek_to(0);
}
},
};
// Automatically play the next track when the current one ends.
if (BROWSER) {
let attached: HTMLAudioElement | null = null;
const handle_ended = () => {
void radio_player.next();
};
audio_element.subscribe((element) => {
if (attached) attached.removeEventListener('ended', handle_ended);
attached = element;
if (element) element.addEventListener('ended', handle_ended);
});
}
+140
View File
@@ -0,0 +1,140 @@
<script lang="ts">
import MagnifyingGlassIcon from '@inqling/svelte-icons/heroicon-24-solid/magnifying-glass.svelte';
import MusicalNoteIcon from '@inqling/svelte-icons/heroicon-24-solid/musical-note.svelte';
import PlayIcon from '@inqling/svelte-icons/heroicon-24-solid/play.svelte';
import { format_seconds } from '$lib/index';
import { onMount } from 'svelte';
import type { RadioTrack } from './funkwhale';
import { library, load_library } from './library-store';
import { radio_player } from './radio-store';
/** Maximum number of rows rendered at once. */
const MAX_ROWS = 200;
let search = '';
onMount(() => {
void load_library();
});
function matches(track: RadioTrack, query: string): boolean {
const haystack = `${track.title} ${track.artist} ${
track.album ?? ''
}`.toLowerCase();
return query
.toLowerCase()
.split(/\s+/)
.filter(Boolean)
.every((word) => haystack.includes(word));
}
$: filtered = search.trim()
? $library.tracks.filter((track) => matches(track, search))
: $library.tracks;
$: visible = filtered.slice(0, MAX_ROWS);
function play_track(track: RadioTrack) {
const index = filtered.indexOf(track);
radio_player.play_tracks(filtered, index);
}
</script>
<section>
<div class="mb-4 flex flex-wrap items-center gap-4">
<h2 class="text-xl font-bold text-slate-900">Titres de la radio</h2>
<div class="min-w-60 relative flex-1">
<div
class="pointer-events-none absolute inset-y-0 left-3 flex items-center text-slate-400"
>
<MagnifyingGlassIcon class="h-5 w-5" />
</div>
<input
type="search"
bind:value={search}
placeholder="Rechercher un titre, un artiste, un album…"
class="w-full rounded-lg border-slate-300 pl-10 text-sm focus:border-orange-500 focus:ring-orange-500"
/>
</div>
<p class="text-sm tabular-nums text-slate-500">
{#if $library.is_done}
{$library.total} titres
{:else if $library.total}
{$library.tracks.length} / {$library.total} titres chargés…
{:else}
Chargement…
{/if}
</p>
</div>
{#if $library.error}
<p class="rounded-lg bg-red-50 p-4 text-sm text-red-700">
Erreur de chargement : {$library.error}
</p>
{:else}
<ul
class="divide-y divide-slate-200 rounded-xl border border-slate-200 bg-white"
>
{#each visible as track (track.id)}
{@const is_current = $radio_player.current?.id === track.id}
<li>
<button
type="button"
on:click={() => play_track(track)}
class="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-orange-50"
class:bg-orange-100={is_current}
>
<span
class="grid h-8 w-8 shrink-0 place-content-center rounded-full text-slate-400"
class:bg-orange-500={is_current}
class:text-white={is_current}
>
{#if is_current}
<MusicalNoteIcon class="h-4 w-4" />
{:else}
<PlayIcon class="h-4 w-4" />
{/if}
</span>
<span class="min-w-0 flex-1">
<span
class="block truncate text-sm font-medium text-slate-900"
>
{track.title}
</span>
<span class="block truncate text-xs text-slate-500">
{track.artist}
{#if track.album}· {track.album}{/if}
</span>
</span>
{#if track.duration}
<span class="shrink-0 text-xs tabular-nums text-slate-400">
{format_seconds.to_timestamp(track.duration)}
</span>
{/if}
</button>
</li>
{:else}
<li class="px-4 py-8 text-center text-sm text-slate-500">
{#if $library.is_loading}
Chargement des titres…
{:else if search.trim()}
Aucun titre ne correspond à « {search} ».
{:else}
Aucun titre.
{/if}
</li>
{/each}
</ul>
{#if filtered.length > visible.length}
<p class="mt-2 text-center text-sm text-slate-500">
… et {filtered.length - visible.length} autres titres. Précise ta recherche
pour les trouver.
</p>
{/if}
{/if}
</section>
+1 -264
View File
@@ -1,270 +1,7 @@
<script>
import { afterNavigate } from '$app/navigation';
import Sidebar from '$src/layout/sidebar.svelte';
import CloseIcon from '@inqling/svelte-icons/heroicon-24-outline/x-mark.svelte';
import GitHubIcon from '@inqling/svelte-icons/simple-icons/github.svelte';
import NPMIcon from '@inqling/svelte-icons/simple-icons/npm.svelte';
import { onMount } from 'svelte';
import github from 'svelte-highlight/styles/github';
import { circOut, cubicInOut, cubicOut } from 'svelte/easing';
import { fade, scale } from 'svelte/transition';
import '../app.postcss';
const page_links = Object.freeze([
{
label: 'About',
href: '/',
sections: [],
},
{
label: 'Setup',
href: '/setup',
sections: [
{ label: 'Installation', anchor: 'installation' },
{ label: 'Audio Sources', anchor: 'audio_sources' },
{ label: 'Type Safety', anchor: 'type_safety' },
],
},
{
label: 'Components',
href: '/components',
sections: [
{
label: 'AudioPlayer',
anchor: 'audioplayer',
},
{
label: 'AudioProgress',
anchor: 'audioprogress',
},
],
},
{
label: 'Examples',
href: '/examples',
sections: [],
},
{
label: 'API',
href: '/api',
sections: [
{ label: 'audio', anchor: 'audio' },
{ label: 'audio_data', anchor: 'audio_data' },
{ label: 'audio.subscribe', anchor: 'audio.subscribe' },
{ label: 'audio.src', anchor: 'audio.src' },
{ label: 'audio.play', anchor: 'audio.play' },
{ label: 'audio.mute', anchor: 'audio.mute' },
{ label: 'audio.seek', anchor: 'audio.seek' },
{ label: 'audio.skip', anchor: 'audio.skip' },
{ label: 'user_preferences', anchor: 'user_preferences' },
{ label: 'user_preferences_data', anchor: 'user_preferences_data' },
{
label: 'user_preferences.subscribe',
anchor: 'user_preferences.subscribe',
},
{
label: 'user_preferences.set_playback_rate',
anchor: 'user_preferences.set_playback_rate',
},
{
label: 'user_preferences.set_volume',
anchor: 'user_preferences.set_volume',
},
{
label: 'user_preferences.clear',
anchor: 'user_preferences.clear',
},
{ label: 'user_progress', anchor: 'user_progress' },
{ label: 'user_progress_data', anchor: 'user_progress_data' },
{
label: 'user_progress.subscribe',
anchor: 'user_progress.subscribe',
},
{ label: 'user_progress.get', anchor: 'user_progress.get' },
{ label: 'user_progress.clear', anchor: 'user_progress.clear' },
{ label: 'user_progress.save', anchor: 'user_progress.save' },
{ label: 'seconds_to_timestamp', anchor: 'seconds_to_timestamp' },
],
},
]);
const resource_links = Object.freeze([
{
label: 'GitHub',
icon: GitHubIcon,
href: 'https://github.com/OllieJT/svelte-podcast',
},
{
label: 'NPM',
icon: NPMIcon,
href: 'https://www.npmjs.com/package/svelte-podcast',
},
]);
const podcast_links = Object.freeze([
{
label: 'Svelte Radio',
href: 'https://www.svelteradio.com/',
src: '/svelte-radio.png',
},
{
label: 'Syntax',
href: 'https://syntax.fm/',
src: '/syntax.png',
},
{
label: 'PodStack',
href: 'https://podstack.club/',
src: '/podstack.png',
},
]);
let is_menu_open = false;
const open_menu = () => (is_menu_open = true);
const close_menu = () => (is_menu_open = false);
const toggle_menu = () => (is_menu_open = !is_menu_open);
/**
* @param {KeyboardEvent} event - The keyboard event object.
*/
const handle_keydown = (event) => {
// on esc close menu
if (event.key === 'Escape' || event.key === 'Esc') {
close_menu();
}
// on command + / open menu
if (event.metaKey && event.key === '/') {
toggle_menu();
}
// on command + return open menu
if (event.metaKey && event.key === 'Enter') {
toggle_menu();
}
};
onMount(() => {
// close menu on esc
window.addEventListener('keydown', handle_keydown);
return () => {
window.removeEventListener('keydown', handle_keydown);
};
});
afterNavigate(close_menu);
</script>
<svelte:head>
{@html github}
</svelte:head>
<!-- Off-canvas menu for mobile, show/hide based on off-canvas menu state. -->
<div
class="pointer-events-none relative z-50 lg:hidden"
role="dialog"
aria-modal="true"
>
{#if is_menu_open}
<div
in:fade={{
duration: 400,
easing: cubicOut,
}}
out:fade={{
duration: 120,
easing: cubicInOut,
}}
class="fixed inset-0 bg-mono-900/80"
/>
{/if}
{#if is_menu_open}
<button
type="button"
on:click={close_menu}
class="pointer-events-auto fixed inset-0 flex"
>
<div class="relative mr-16 flex w-full max-w-xs flex-1">
<div class="absolute left-full top-0 flex w-16 justify-center pt-5">
<div
class="pointer-events-auto -m-2.5 origin-center p-2.5"
in:scale={{ delay: 200, easing: circOut, duration: 150 }}
out:scale={{ easing: cubicInOut, duration: 80 }}
>
<span class="sr-only">Close sidebar</span>
<CloseIcon class="h-6 w-6 text-white" />
</div>
</div>
<Sidebar
class="pb-2"
pages={page_links}
resources={resource_links}
podcasts={podcast_links}
/>
</div>
</button>
{/if}
</div>
<!-- Static sidebar for desktop -->
<div class="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-72 lg:flex-col">
<Sidebar
class="border-r border-mono-200"
pages={page_links}
resources={resource_links}
podcasts={podcast_links}
/>
</div>
<div
class="sticky top-0 z-40 flex items-center gap-x-6 bg-white px-4 py-4 shadow-sm sm:px-6 lg:hidden"
>
<button
on:click={open_menu}
type="button"
class="-m-2.5 p-2.5 text-mono-700 lg:hidden"
>
<span class="sr-only">Open sidebar</span>
<svg
class="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
/>
</svg>
</button>
<div class="flex-1 text-sm font-semibold leading-6 text-mono-900">Menu</div>
<div class="flex items-center gap-4">
{#each resource_links as link}
<a
href={link.href}
target="_blank"
class="flex h-8 w-8 items-center justify-center rounded-full bg-mono-100 text-mono-600 hover:bg-mono-800 hover:text-white"
>
<span class="sr-only">{link.label}</span>
<svelte:component this={link.icon} class="h-6 w-6" />
</a>
{/each}
</div>
</div>
<main class="py-10 lg:pl-72">
<div class="px-2">
<main class="mx-auto min-h-full max-w-4xl px-4 py-8">
<slot />
</div>
</main>
<footer />
+39 -123
View File
@@ -1,139 +1,55 @@
<script>
import { base } from '$app/paths';
import { PlayerWidget } from '$src/components/player';
import { episodes } from '$src/content/episodes';
import { DocsPage } from '$src/layout/page';
import { audio } from '../lib/actions';
audio.src.load(episodes.syntax.src, {
title: episodes.syntax.title,
artwork: episodes.syntax.artwork,
});
import RadioIcon from '@inqling/svelte-icons/heroicon-24-solid/radio.svelte';
import RadioPlayer from '$src/radio/radio-player.svelte';
import TrackList from '$src/radio/track-list.svelte';
import { radio_player } from '$src/radio/radio-store';
</script>
<DocsPage title="Docs" let:Section>
<header
slot="header"
class="relative isolate bg-white"
aria-labelledby="hero"
>
<svg
class="absolute inset-0 -z-10 h-full w-full stroke-mono-200 [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)]"
aria-hidden="true"
>
<defs>
<pattern
id="0787a7c5-978c-4f66-83c7-11c213f99cb7"
width="200"
height="200"
x="50%"
y="-1"
patternUnits="userSpaceOnUse"
>
<path d="M.5 200V.5H200" fill="none" />
</pattern>
</defs>
<rect
width="100%"
height="100%"
stroke-width="0"
fill="url(#0787a7c5-978c-4f66-83c7-11c213f99cb7)"
<svelte:head>
<title>Radyobòkaz — Lecteur radio</title>
<meta
name="description"
content="Écoute la radio Radyobòkaz de mizik.o-k-i.net : lecture aléatoire continue ou à la carte."
/>
</svg>
</svelte:head>
<header class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div>
<div class="relative py-24">
<div class="mx-auto max-w-7xl px-6 lg:px-8">
<div class="mx-auto max-w-2xl text-center">
<h1 id="hero">
<span class="block text-xl font-medium text-primary-600">
svelte-podcast<span class="sr-only">: </span>
</span>
<span
class="text-4xl font-bold tracking-tight text-mono-900 sm:text-6xl"
>
The fastest way to build a podcast site with Svelte.
</span>
</h1>
<p class="mt-6 text-xl leading-8 text-mono-600">
A suite of tools and components to build your own podcast
players, and work with RSS podcast data in SvelteKit.
<span
class="mt-3 block text-base leading-none text-primary-800"
>
<span
class="inline-block rounded-full bg-primary-50 px-3 py-1.5 text-xs font-medium uppercase tracking-wider text-primary-600"
>
Coming Soon<span class="sr-only">:</span>
</span>
<span class="tracking-wide"
>SSR utilities for consuming RSS podcast feeds</span
>
</span>
</p>
<div class="mt-10 flex items-center justify-center gap-x-3">
<h1 class="text-3xl font-bold text-slate-900">Radyobòkaz</h1>
<p class="text-slate-500">
La radio de
<a
href="{base}/setup"
class="rounded-md bg-primary-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600"
href="https://mizik.o-k-i.net/library/radios/7"
target="_blank"
rel="noreferrer"
class="text-orange-600 underline decoration-orange-300 underline-offset-2 hover:text-orange-500"
>
Get started
mizik.o-k-i.net
</a>
·
<a
href="{base}/api"
class="rounded-md bg-white px-3.5 py-2.5 text-sm font-semibold text-mono-600 hover:bg-primary-50 hover:text-primary-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600"
href="{base}/podcast/"
class="text-orange-600 underline decoration-orange-300 underline-offset-2 hover:text-orange-500"
>
Examples <span aria-hidden="true"></span>
Démo podcast RSS →
</a>
</p>
</div>
</div>
<div class="mt-16 flow-root sm:mt-24">
<div
class="-m-2 grid grid-cols-1 grid-rows-1 rounded-xl border border-mono-100 bg-white p-1 shadow-2xl shadow-mono-200 sm:p-2 lg:-m-4 lg:rounded-2xl lg:p-4"
<button
type="button"
on:click={() => radio_player.start_radio()}
disabled={$radio_player.is_loading}
class="flex items-center gap-2 rounded-full bg-orange-600 px-5 py-2.5 font-semibold text-white shadow hover:bg-orange-500 disabled:opacity-50"
>
<PlayerWidget
class="w-full border border-mono-100"
options={{
duration: true,
current_time: true,
playback_rate: true,
skip_back: 10,
skip_forward: 30,
}}
/>
</div>
</div>
</div>
</div>
</div>
</header>
<RadioIcon class="h-5 w-5" />
{$radio_player.is_loading ? 'Chargement…' : 'Lecture radio aléatoire'}
</button>
</header>
<Section title="Introduction">
<p>
Svelte-Podcast streamlines the creation of custom audio players and
simplifies state management in Svelte apps.
</p>
<div class="sticky top-4 z-10 mb-8">
<RadioPlayer />
</div>
<p>
<b>Build custom Audio Player UI</b>
<br />
Simplify the creation of custom audio players with a set of headless components
that keep out of your way and take care of core functionality.
</p>
<p>
<b>Easily manage Audio State</b>
<br />
Loading, controlling, and keeping track of multiple audio sources is a pain.
svelte-podcast abstracts this away and provides a simple interface to manage
audio state.
</p>
<p>
<b>Track user preferences</b>
<br />
Users expect a lot from media players. It should remember their preferences
like playback speed, and it should remember where they were in an episode
even after reloading the page. svelte-podcast takes care of this for you,
and provides you with access to extend this with your own database.
</p>
</Section>
</DocsPage>
<TrackList />
-288
View File
@@ -1,288 +0,0 @@
<script>
import { DocsPage } from '$src/layout/page';
import { Highlight, HighlightSvelte } from 'svelte-highlight';
import lang_ts from 'svelte-highlight/languages/typescript';
import {
audio_load,
audio_mute,
audio_play,
audio_seek,
audio_skip,
audio_subscribe,
seconds_to_timestamp,
user_preferences_clear,
user_preferences_set_playback_rate,
user_preferences_set_volume,
user_preferences_subscribe,
user_progress_clear,
user_progress_get,
user_progress_save,
user_progress_subscribe,
} from './code';
</script>
<DocsPage title="API" let:Section let:SectionArticle let:TableSchema>
<Section title="audio">
<SectionArticle title="audio data">
<TableSchema
rows={[
{
property: 'is_loaded',
type: 'boolean',
description: 'Whether the audio element is loaded or not',
},
{
property: 'is_paused',
type: 'boolean',
description: 'Whether the audio element is paused or not',
},
{
property: 'current_time',
type: 'number',
description:
'The current time of the audio element in seconds',
},
{
property: 'duration',
type: 'number',
description: 'The duration of the audio element in seconds',
},
{
property: 'timestamp_current',
type: 'string',
description:
'The current time of the audio element in timestamp format (hh:mm:ss)',
},
{
property: 'timestamp_end',
type: 'string',
description:
'The end time of the audio element in timestamp format (hh:mm:ss)',
},
]}
/>
</SectionArticle>
<!--
audio.subscribe
audio.load
audio.unload
audio.play
audio.pause
audio.mute
audio.unmute
audio.seek
audio.skip
-->
<SectionArticle title="audio.subscribe">
<p>
Subscribe to specific changes in the audio attributes. This will
return a readable store that you can use to access the attributes.
</p>
<div class="not-prose codeblock">
<HighlightSvelte code={audio_subscribe} />
</div>
</SectionArticle>
<SectionArticle title="audio.src">
<p>
Load a new audio source. This will stop the current audio source and
replace it with the new one.
</p>
<p>
Alternatively, unload the current audio source. This will stop the
current audio source and remove it from the audio state.
</p>
<div class="not-prose codeblock">
<Highlight code={audio_load} language={lang_ts} />
</div>
</SectionArticle>
<SectionArticle title="audio.play">
<p>Set or toggle the play state.</p>
<div class="not-prose codeblock">
<Highlight code={audio_play} language={lang_ts} />
</div>
</SectionArticle>
<SectionArticle title="audio.mute">
<p>Set or toggle the mute state.</p>
<div class="not-prose codeblock">
<Highlight code={audio_mute} language={lang_ts} />
</div>
</SectionArticle>
<SectionArticle title="audio.seek">
<p>
Seek to a specific time in the audio. The `from` parameter
determines whether the time is relative to the start or end of the
audio.
</p>
<div class="not-prose codeblock">
<Highlight code={audio_seek} language={lang_ts} />
</div>
</SectionArticle>
<SectionArticle title="audio.skip">
<p>
Skip forward or backward in the audio by a specific number of
seconds.
</p>
<div class="not-prose codeblock">
<Highlight code={audio_skip} language={lang_ts} />
</div>
</SectionArticle>
</Section>
<Section title="user_preferences">
<SectionArticle title="user_preferences data">
<TableSchema
rows={[
{
property: 'playback_rate',
type: 'number',
description: 'The audio player playback rate (speed)',
},
{
property: 'volume',
type: 'number',
description: 'The audio player volume.',
},
]}
/>
</SectionArticle>
<!--
user_preferences.subscribe
user_preferences.set_playback_rate
user_preferences.set_volume
user_preferences.clear
-->
<SectionArticle title="user_preferences.subscribe">
<p>
Subscribe to changes in the user preferences. This will return a
readable store that you can use to access the user preferences.
</p>
<div class="not-prose codeblock">
<HighlightSvelte code={user_preferences_subscribe} />
</div>
</SectionArticle>
<SectionArticle title="user_preferences.set_playback_rate">
<p>
Set the playback rate. This will update the user preferences and
apply the new playback rate to the audio.
</p>
<div class="not-prose codeblock">
<Highlight
code={user_preferences_set_playback_rate}
language={lang_ts}
/>
</div>
</SectionArticle>
<SectionArticle title="user_preferences.set_volume">
<p>
Set the volume. This will update the user preferences and apply the
new volume to the audio.
</p>
<div class="not-prose codeblock">
<Highlight code={user_preferences_set_volume} language={lang_ts} />
</div>
</SectionArticle>
<SectionArticle title="user_preferences.clear">
<p>
Clear the user preferences. This will reset the user preferences to
the default values.
</p>
<div class="not-prose codeblock">
<Highlight code={user_preferences_clear} language={lang_ts} />
</div>
</SectionArticle>
</Section>
<!-- user_progress -->
<Section title="user_progress">
<SectionArticle title="user_progress data">
<p>
User progress is a record where the keys are audio sources, and the
values are timestamps in seconds. Progress is retrieved by matching
the source, and returning the timestamp.
</p>
<div class="not-prose codeblock">
<Highlight code={`Record<string, number>`} language={lang_ts} />
</div>
</SectionArticle>
<!--
* user_progress.subscribe - Subscribes to user progress store
* user_progress.get - Gets user progress for a given audio source
* user_progress.clear - Clears user progress store
* user_progress.save - Saves user progress
-->
<SectionArticle title="user_progress.subscribe">
<p>
Subscribe to changes in the user progress. This will return a
readable store that you can use to access the user progress.
</p>
<div class="not-prose codeblock">
<HighlightSvelte code={user_progress_subscribe} />
</div>
</SectionArticle>
<SectionArticle title="user_progress.get">
<p>
Get the user progress for a given audio source. This will return the
timestamp in seconds.
</p>
<div class="not-prose codeblock">
<Highlight code={user_progress_get} language={lang_ts} />
</div>
</SectionArticle>
<SectionArticle title="user_progress.clear">
<p>
Clear the user progress. This will reset the user progress to the
default values.
</p>
<div class="not-prose codeblock">
<Highlight code={user_progress_clear} language={lang_ts} />
</div>
</SectionArticle>
<SectionArticle title="user_progress.save">
<p>
Save the user progress for a given audio source. This will update
the user progress with the new timestamp.
</p>
<div class="not-prose codeblock">
<Highlight code={user_progress_save} language={lang_ts} />
</div>
</SectionArticle>
</Section>
<!-- seconds_to_timestamp -->
<Section title="seconds_to_timestamp">
<TableSchema
rows={[
{
property: 'seconds',
type: 'number',
description: 'The number of seconds to convert.',
},
{
property: 'force_hours',
type: 'boolean',
description:
"Whether to include hours in the timestamp, even if it's 0.",
},
]}
/>
<div class="not-prose codeblock">
<Highlight code={seconds_to_timestamp} language={lang_ts} />
</div>
</Section>
</DocsPage>
-19
View File
@@ -1,19 +0,0 @@
import { audio } from 'svelte-podcast';
// load a new audio source into the player
audio.src.load(
// path to audio file
'/episode-377.mp3',
// custom metadata defined by you
{
title: 'Supper Club × Rich Harris, Author of Svelte',
artwork:
'https://ssl-static.libsyn.com/p/assets/b/3/c/d/b3cdf28da11ad39fe5bbc093207a2619/Syntax_-_499.jpg',
guests: ['Rich Harris'],
hosts: ['Wes Bos', 'Scott Tolinski'],
},
);
// unload the current audio source
audio.src.unload();
-10
View File
@@ -1,10 +0,0 @@
import { audio } from 'svelte-podcast';
// mute the current audio source
audio.mute(true);
// unmute the current audio source
audio.mute(false);
// invert the mute state of the current audio source
audio.mute('toggle');
-10
View File
@@ -1,10 +0,0 @@
import { audio } from 'svelte-podcast';
// play the current audio source
audio.play(true);
// pause the current audio source
audio.play(false);
// invert the play state of the current audio source
audio.play('toggle');
-7
View File
@@ -1,7 +0,0 @@
import { audio } from 'svelte-podcast';
// go to 200 seconds from the start
audio.seek_to(200);
// go to 200 seconds before the end
audio.seek_to(200, 'from-end');
-7
View File
@@ -1,7 +0,0 @@
import { audio } from 'svelte-podcast';
// skip forward 30 seconds from the current position
audio.skip_by(30);
// skip backward 30 seconds from the current position
audio.skip_by(30, 'backward');
@@ -1,16 +0,0 @@
<script>
import { audio } from 'svelte-podcast';
// return the current user preferences
const attributes = $audio;
// or subscribe to changes in the user preferences
audio.subscribe((attr) => {
// do something with the new preferences whenever they change
console.log(attr);
});
</script>
<p>Current timestamp {attributes.timestamp_current}.</p>
<p>{attributes.is_paused ? 'Paused...' : 'Playing!'}.</p>
-42
View File
@@ -1,42 +0,0 @@
import { format_code } from '../../../layout/helper';
import { default as audio_load_src } from './audio-load?raw';
import { default as audio_mute_src } from './audio-mute?raw';
import { default as audio_play_src } from './audio-play?raw';
import { default as audio_seek_src } from './audio-seek?raw';
import { default as audio_skip_src } from './audio-skip?raw';
import { default as audio_subscribe_src } from './audio-subscribe.svelte?raw';
import { default as load_audio_after_click_src } from './load-audio-after-click.svelte?raw';
import { default as load_audio_after_mount_src } from './load-audio-after-mount.svelte?raw';
import { default as seconds_to_timestamp_src } from './seconds-to-timestamp?raw';
import { default as user_preferences_clear_src } from './user_preferences-clear?raw';
import { default as user_preferences_set_playback_rate_src } from './user_preferences-set_playback_rate?raw';
import { default as user_preferences_set_volume_src } from './user_preferences-set_volume?raw';
import { default as user_preferences_subscribe_src } from './user_preferences-subscribe.svelte?raw';
import { default as user_progress_clear_src } from './user_progress-clear?raw';
import { default as user_progress_get_src } from './user_progress-get?raw';
import { default as user_progress_save_src } from './user_progress-save?raw';
import { default as user_progress_subscribe_src } from './user_progress-subscribe.svelte?raw';
export const load_audio_after_click = format_code(load_audio_after_click_src);
export const load_audio_after_mount = format_code(load_audio_after_mount_src);
export const seconds_to_timestamp = format_code(seconds_to_timestamp_src);
export const audio_load = format_code(audio_load_src);
export const audio_play = format_code(audio_play_src);
export const audio_skip = format_code(audio_skip_src);
export const audio_seek = format_code(audio_seek_src);
export const audio_mute = format_code(audio_mute_src);
export const user_preferences_clear = format_code(user_preferences_clear_src);
export const user_preferences_set_playback_rate = format_code(
user_preferences_set_playback_rate_src,
);
export const user_preferences_set_volume = format_code(
user_preferences_set_volume_src,
);
export const user_preferences_subscribe = format_code(
user_preferences_subscribe_src,
);
export const audio_subscribe = format_code(audio_subscribe_src);
export const user_progress_clear = format_code(user_progress_clear_src);
export const user_progress_get = format_code(user_progress_get_src);
export const user_progress_save = format_code(user_progress_save_src);
export const user_progress_subscribe = format_code(user_progress_subscribe_src);
@@ -1,18 +0,0 @@
<script>
import { audio } from 'svelte-podcast';
</script>
<!-- load the episode on click -->
<button
on:click={() =>
audio.src.load('/episode-audio.mp3', {
/* your metadata */
title: 'Episode Title',
artwork: '/artwork.png',
})}
>
Load Episode
</button>
<!-- unload the episode on click -->
<button on:click={() => audio.src.unload()}>Unload Episode</button>
@@ -1,13 +0,0 @@
<script>
import { onMount } from 'svelte';
import { audio } from 'svelte-podcast';
onMount(() => {
// load the episode on mount without any metadata
audio.src.load('/episode-audio.mp3', {
/* your metadata */
title: 'Episode Title',
artwork: '/artwork.png',
});
});
</script>
@@ -1,10 +0,0 @@
import { format_seconds } from 'svelte-podcast/utility/format-seconds';
// Example
format_seconds.to_timestamp(5173); // '01:26:13';
// force hours to be displayed
format_seconds.to_timestamp(2700, true); // '00:45:00';
// only display hours if there are any
format_seconds.to_timestamp(2700, false); // '45:00';
@@ -1,4 +0,0 @@
import { user_preferences } from 'svelte-podcast';
// Clears all user preferences
user_preferences.clear();
@@ -1,4 +0,0 @@
import { user_preferences } from 'svelte-podcast';
// Sets the playback speed to 150%
user_preferences.set_playback_rate(1.5);
@@ -1,4 +0,0 @@
import { user_preferences } from 'svelte-podcast';
// Sets the volume to 75%
user_preferences.set_volume(0.75);
@@ -1,16 +0,0 @@
<script>
import { user_preferences } from 'svelte-podcast';
// return the current user preferences
const preferences = $user_preferences;
// or subscribe to changes in the user preferences
user_preferences.subscribe((prefs) => {
// do something with the new preferences whenever they change
console.log(prefs);
});
</script>
<p>Listening at {preferences.playback_rate * 100}% speed.</p>
<p>Volume set to {preferences.volume * 100}%.</p>
@@ -1,4 +0,0 @@
import { user_progress } from 'svelte-podcast';
// Clear the user's progress for ALL episodes.
user_progress.clear();
-4
View File
@@ -1,4 +0,0 @@
import { user_progress } from 'svelte-podcast';
// Get the user's progress for a specific episode.
user_progress.get('https://example.com/episodes/1');
@@ -1,4 +0,0 @@
import { user_progress } from 'svelte-podcast';
// Save the user's progress for the current episode.
user_progress.save();
@@ -1,16 +0,0 @@
<script>
import { user_progress } from 'svelte-podcast';
// return the current user progress
const progress = $user_progress;
// or subscribe to changes in the user progress
user_progress.subscribe((prog) => {
// do something with the new progress whenever they change
console.log(prog);
});
</script>
<p>
Listened to {progress['https://example.com/episodes/1']} seconds of episode 1.
</p>
-38
View File
@@ -1,38 +0,0 @@
<script>
import { episodes } from '$src/content/episodes';
import { DocsPage } from '$src/layout/page';
import { HighlightSvelte } from 'svelte-highlight';
import { PlayerCss, ProgressCss, player_css, progress_css } from './code';
/** @type { string | undefined} */
let audio_src = episodes.knomii.src;
audio_src;
</script>
<DocsPage title="Examples" let:Section>
<Section title="AudioPlayer">
<p>
A component that does not render any UI. Instead, it provides
convenient functions, values, and headless components via expoded
values
</p>
<p>This component should help</p>
<div class="not-prose py-4">
<PlayerCss />
</div>
<div class="codeblock not-prose flex flex-col items-stretch gap-2 py-1">
<HighlightSvelte code={player_css} />
</div>
</Section>
<Section title="AudioProgress">
<div class="not-prose py-4">
<ProgressCss />
</div>
<div class="codeblock not-prose flex flex-col items-stretch gap-2 py-1">
<HighlightSvelte code={progress_css} />
</div>
</Section>
</DocsPage>
@@ -1,21 +0,0 @@
<script>
// @ts-nocheck
import { AudioPlayer } from 'svelte-podcast';
/** @type { string | undefined} */
let src = '/static/example-syntax.mp3';
</script>
<AudioPlayer
src="/static/example-syntax.mp3"
metadata={{
title: `Supper Club × Rich Harris, Author of Svelte`,
artwork: `https://ssl-static.libsyn.com/p/assets/b/3/c/d/b3cdf28da11ad39fe5bbc093207a2619/Syntax_-_499.jpg`,
}}
let:Player
let:action
let:attributes
>
<pre>{JSON.stringify({ attributes }, null, 3)}</pre>
</AudioPlayer>
@@ -1,5 +0,0 @@
<script>
import { AudioProgress } from 'svelte-podcast';
</script>
<AudioProgress step={10} />
-8
View File
@@ -1,8 +0,0 @@
import { format_code } from '$src/layout/helper';
import { default as player_css_src } from './audio-player.svelte?raw';
import { default as progress_css_src } from './audio-progress.svelte?raw';
export { default as PlayerCss } from './audio-player.svelte';
export { default as ProgressCss } from './audio-progress.svelte';
export const player_css = format_code(player_css_src);
export const progress_css = format_code(progress_css_src);
-33
View File
@@ -1,33 +0,0 @@
<script>
import PlayerWidget from '$src/components/player/player-widget.svelte';
import { episodes } from '$src/content/episodes';
import { DocsPage } from '$src/layout/page';
import { HighlightSvelte } from 'svelte-highlight';
import { player_css } from './code';
/** @type { string | undefined} */
let audio_src = episodes.knomii.src;
</script>
<DocsPage title="Examples" let:Section>
<Section title="Coming Soon...">
<p class="text-xl font-semibold text-red-500">
This page is a work in progress.
</p>
</Section>
<Section title="PlayerWidget">
<div class="not-prose py-4">
<PlayerWidget
src={audio_src}
skip_back={30}
skip_forward={10}
metadata={{}}
playback_rate_values={[1.0, 1.2, 1.4, 1.6, 1.8, 2.0, 2.2, 2.4]}
/>
</div>
<div class="codeblock not-prose flex flex-col items-stretch gap-2 py-1">
<HighlightSvelte code={player_css} />
</div>
</Section>
</DocsPage>
-4
View File
@@ -1,4 +0,0 @@
import { default as player_css_src } from '$src/components/player/player-widget.svelte?raw';
import { format_code } from '$src/layout/helper';
export const player_css = format_code(player_css_src);
+129
View File
@@ -0,0 +1,129 @@
<script lang="ts">
import { base } from '$app/paths';
import MusicalNoteIcon from '@inqling/svelte-icons/heroicon-24-solid/musical-note.svelte';
import PlayIcon from '@inqling/svelte-icons/heroicon-24-solid/play.svelte';
import { format_seconds, type PodcastEpisode } from '$lib/index';
import RadioPlayer from '$src/radio/radio-player.svelte';
import type { PlayableTrack } from '$src/radio/playable';
import { radio_player } from '$src/radio/radio-store';
import type { PageData } from './$types';
export let data: PageData;
$: feed = data.feed;
/** Maps a podcast episode to a track playable by the queue. */
function to_track(episode: PodcastEpisode): PlayableTrack {
return {
id: episode.id,
title: episode.title,
artist: feed.author ?? feed.title,
album: feed.title,
cover: episode.artwork ?? feed.artwork,
stream_url: episode.src,
duration: episode.duration,
};
}
$: tracks = feed.episodes.map(to_track);
function play_episode(index: number) {
radio_player.play_tracks(tracks, index);
}
function format_date(iso: string | null): string {
if (!iso) return '';
return new Date(iso).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}
</script>
<svelte:head>
<title>{feed.title} — Lecteur podcast</title>
<meta name="description" content={feed.description ?? feed.title} />
</svelte:head>
<header class="mb-6 flex flex-wrap items-center gap-4">
{#if feed.artwork}
<img
src={feed.artwork}
alt="Pochette de {feed.title}"
class="h-20 w-20 rounded-xl object-cover shadow"
/>
{/if}
<div class="min-w-0 flex-1">
<h1 class="text-2xl font-bold text-slate-900">{feed.title}</h1>
{#if feed.author}
<p class="text-slate-500">{feed.author}</p>
{/if}
<p class="mt-1 text-sm text-slate-400">
{feed.episodes.length} épisodes ·
<a
href={data.feed_url}
target="_blank"
rel="noreferrer"
class="text-orange-600 underline decoration-orange-300 underline-offset-2 hover:text-orange-500"
>
flux RSS
</a>
·
<a
href="{base}/"
class="text-orange-600 underline decoration-orange-300 underline-offset-2 hover:text-orange-500"
>
← Radyobòkaz
</a>
</p>
</div>
</header>
<div class="sticky top-4 z-10 mb-8">
<RadioPlayer />
</div>
<ul
class="divide-y divide-slate-200 rounded-xl border border-slate-200 bg-white"
>
{#each feed.episodes as episode, index (episode.id)}
{@const is_current = $radio_player.current?.id === episode.id}
<li>
<button
type="button"
on:click={() => play_episode(index)}
class="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-orange-50"
class:bg-orange-100={is_current}
>
<span
class="grid h-8 w-8 shrink-0 place-content-center rounded-full text-slate-400"
class:bg-orange-500={is_current}
class:text-white={is_current}
>
{#if is_current}
<MusicalNoteIcon class="h-4 w-4" />
{:else}
<PlayIcon class="h-4 w-4" />
{/if}
</span>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium text-slate-900">
{episode.title}
</span>
<span class="block truncate text-xs text-slate-500">
{format_date(episode.published_at)}
</span>
</span>
{#if episode.duration}
<span class="shrink-0 text-xs tabular-nums text-slate-400">
{format_seconds.to_timestamp(episode.duration)}
</span>
{/if}
</button>
</li>
{/each}
</ul>
+9
View File
@@ -0,0 +1,9 @@
import { fetch_podcast_feed } from '$lib/rss';
import type { PageLoad } from './$types';
const FEED_URL = 'https://feed.syntax.fm/rss';
export const load: PageLoad = async ({ fetch }) => {
const feed = await fetch_podcast_feed(FEED_URL, fetch);
return { feed, feed_url: FEED_URL };
};
-103
View File
@@ -1,103 +0,0 @@
<script>
import { DocsPage } from '$src/layout/page';
import { Highlight } from 'svelte-highlight';
import lang_shell from 'svelte-highlight/languages/shell';
import lang_ts from 'svelte-highlight/languages/typescript';
import {
install,
load_audio_advanced,
load_audio_local,
load_audio_remote,
override_episode_state,
} from './code';
</script>
<DocsPage title="Setup" let:Section>
<Section title="Installation">
<p>
Install the latest version of svelte-podcast with your preferred
package manager.
</p>
<div class="codeblock not-prose flex flex-col items-stretch gap-2 py-1">
<Highlight language={lang_shell} code={install} />
</div>
</Section>
<Section title="Audio Sources">
<p>
To get started, you'll need an audio file. velte-podcast uses a html <code
>{'<audio />'}</code
> element under the hood, so any file compatible with the audio element
will work.
</p>
<p>
You can provide your audio files via absolute URL's - hosted online, or
relative paths - hosted within your project.
</p>
<h3>Using a remote file (URL)</h3>
<p>
An <em>audio url</em> could be a URL to an MP3 file from an RSS feed,
like this:
<code>https://media.transistor.fm/27a058c9/27b595e2.mp3</code>. It
could also be a path to a static file on your server.
</p>
<div class="codeblock not-prose flex flex-col items-stretch gap-2 py-1">
<Highlight code={load_audio_remote} language={lang_ts} />
</div>
<h3>Using a local file (relative path)</h3>
<p>
If you're using SvelteKit, you can store <em>static files</em> in the
/static directory. When your site is built, everything in the static
directory will be at the root of your site. If you have a file in
<code>/static/episides/episode-01.mp3</code>
you could load it as <code>/episides/episode-01.mp3</code>
</p>
<div class="codeblock not-prose flex flex-col items-stretch gap-2 py-1">
<Highlight code={load_audio_local} language={lang_ts} />
</div>
<h3>Additional options</h3>
<p>
Along side your audio source, you can optionally define arbitrary
metadata for you to use in your player. Learn more in the <a
href="/api/#audio.src">api docs</a
>
</p>
<div class="codeblock not-prose flex flex-col items-stretch gap-2 py-1">
<Highlight code={load_audio_advanced} language={lang_ts} />
</div>
</Section>
<Section title="Type Safety">
<p>
Svelte podast makes it easy for you to associate arbitrary metadata
with each audio source. We recommend defining the shape of your
metadata in a <code>.d.ts</code> file.
</p>
<p>
In this example, we'll assume that your metadata consists of an episode
title, episode artwork, and an optional episode guest name.
</p>
<div class="not-prose codeblock">
<Highlight code={override_episode_state} language={lang_ts} />
</div>
<p>
If you're using SvelteKit, we recommend keeping this in <code
>src/app.d.ts</code
>.
</p>
</Section>
</DocsPage>
@@ -1,4 +0,0 @@
<script>
</script>
<slot />
-14
View File
@@ -1,14 +0,0 @@
import { format_code } from '$src/layout/helper';
import { default as add_audio_loader_src } from './add-audio-loader.svelte?raw';
import { default as install_src } from './install.sh?raw';
import { default as load_audio_advanced_src } from './load-audio-advanced?raw';
import { default as load_audio_local_src } from './load-audio-local?raw';
import { default as load_audio_remote_src } from './load-audio-remote?raw';
import { default as override_episode_state_src } from './override-episode-state?raw';
export const add_audio_loader = format_code(add_audio_loader_src);
export const install = format_code(install_src);
export const override_episode_state = format_code(override_episode_state_src);
export const load_audio_local = format_code(load_audio_local_src);
export const load_audio_remote = format_code(load_audio_remote_src);
export const load_audio_advanced = format_code(load_audio_advanced_src);
-5
View File
@@ -1,5 +0,0 @@
npm install svelte-podcast@latest
yarn add svelte-podcast@latest
pnpm add svelte-podcast@latest
@@ -1,13 +0,0 @@
import { audio } from 'svelte-podcast';
audio.src.load(
// Audio file
'/episode-audio.mp3',
// Your custom metadata
{
title: 'A deep dive into Svelte Podcast',
artwork: '/artwork.png',
guest_name: 'OllieJT',
},
);
@@ -1,6 +0,0 @@
import { audio } from 'svelte-podcast';
audio.src.load('/episode-audio.mp3', {
title: 'Episode Title',
artwork: '/artwork.png',
});
@@ -1,6 +0,0 @@
import { audio } from 'svelte-podcast';
audio.src.load('https://media.transistor.fm/27a058c9/27b595e2.mp3', {
title: 'Episode Title',
artwork: '/artwork.png',
});
@@ -1,10 +0,0 @@
// include this in your /src/app.d.ts file
declare module 'svelte-podcast' {
interface AudioMetadata {
title: string;
artwork: string | null;
}
}
export {};
View File
Binary file not shown.
Binary file not shown.
-7
View File
@@ -1,7 +0,0 @@
/* Maintainer */
Name: Ollie Taylor
Website: https://olliejt.com
/* Contributor */
Name: Ollie Taylor
Website: https://olliejt.com
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

+52
View File
@@ -473,6 +473,11 @@
globby "^11.0.0"
read-yaml-file "^1.1.0"
"@nodable/entities@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@nodable/entities/-/entities-3.0.0.tgz#694703bc864d30eaed55c2e3def00dbd61493670"
integrity sha512-8L9xFeTYKhm49xfIypoe2W5wV1m/3Z58kT+7kR9A8OyFxcPduI4VmxaUMQyKYrRjUoLLSXv6EKKID5Tvj9cUVw==
"@nodelib/fs.scandir@2.1.5":
version "2.1.5"
resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5"
@@ -757,6 +762,11 @@ anymatch@~3.1.2:
normalize-path "^3.0.0"
picomatch "^2.0.4"
anynum@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/anynum/-/anynum-1.0.1.tgz#2aac00e08dfad3726c1d462e60dbc2f831659a44"
integrity sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==
arg@^5.0.2:
version "5.0.2"
resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c"
@@ -1528,6 +1538,26 @@ fast-levenshtein@^2.0.6:
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==
fast-xml-builder@^1.2.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/fast-xml-builder/-/fast-xml-builder-1.3.0.tgz#bc849804796fe47bf915022dd939b0fc30ba455d"
integrity sha512-F74cZEdCvuw9P41GAC3rod4X04jjWGM1JPEv/GWSqFTWLsdyMSBMBMlm9Hk3GLBgLBbdBNY8yee0pQh2RBVESQ==
dependencies:
path-expression-matcher "^1.6.2"
xml-naming "^0.3.0"
fast-xml-parser@^5.10.1:
version "5.10.1"
resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-5.10.1.tgz#19313f7c9386c47fa4a1de527547b73ed2cfcede"
integrity sha512-IEMIf7298kXuZSRFoGfMYrl7is8LpavODgbNz1cwIudv7KwVFnuU+UsMporfq6PD6aXSlawZlARiA3UywCTfMw==
dependencies:
"@nodable/entities" "^3.0.0"
fast-xml-builder "^1.2.0"
is-unsafe "^2.0.0"
path-expression-matcher "^1.6.2"
strnum "^2.4.1"
xml-naming "^0.3.0"
fastq@^1.6.0:
version "1.15.0"
resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.15.0.tgz#d04d07c6a2a68fe4599fea8d2e103a937fae6b3a"
@@ -2051,6 +2081,11 @@ is-typed-array@^1.1.10, is-typed-array@^1.1.9:
gopd "^1.0.1"
has-tostringtag "^1.0.0"
is-unsafe@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/is-unsafe/-/is-unsafe-2.0.0.tgz#c0dce4e06742662dde26360160e414ea487da2e9"
integrity sha512-2LdV822R+wmI86unXA93WCFpL6g+av8ynWk0nrHyJqGop5VoocYsSLFgN8jrfalT6iGeLNM4KXuVSsULP53kEA==
is-weakref@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2"
@@ -2561,6 +2596,11 @@ path-exists@^4.0.0:
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
path-expression-matcher@^1.6.2:
version "1.6.2"
resolved "https://registry.yarnpkg.com/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz#567c73c07197e9dcef24e90edcdc571056599168"
integrity sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==
path-is-absolute@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
@@ -3124,6 +3164,13 @@ strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
strnum@^2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/strnum/-/strnum-2.4.1.tgz#85417f683113badea0fe7e17227676f889ff7e58"
integrity sha512-M9eUSMT2dCB2cTNPG7UYj6KuK7RJR2SN2+yCV/fTW3xzTCS6EaGZ5pSMgDIjB7r8zSfTGk+dvvn9rTjpVS9Mwg==
dependencies:
anynum "^1.0.1"
sucrase@^3.32.0:
version "3.32.0"
resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.32.0.tgz#c4a95e0f1e18b6847127258a75cf360bc568d4a7"
@@ -3539,6 +3586,11 @@ wrappy@1:
resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
xml-naming@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/xml-naming/-/xml-naming-0.3.0.tgz#46c1e18bfe2858479982dd2accf34d16e749eda2"
integrity sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==
y18n@^4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"