diff --git a/README.md b/README.md index c207d81..1d3e4f6 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,73 @@ - +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//` — 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) | diff --git a/package.json b/package.json index 936db13..27ce33f 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/app.html b/src/app.html index e0152dd..bc17aeb 100644 --- a/src/app.html +++ b/src/app.html @@ -1,19 +1,17 @@ - + - + - - %sveltekit.head% - +
%sveltekit.body%
diff --git a/src/app.postcss b/src/app.postcss index e311cc3..1a7b7cf 100644 --- a/src/app.postcss +++ b/src/app.postcss @@ -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; - } -} diff --git a/src/components/icon/index.js b/src/components/icon/index.js deleted file mode 100644 index 259ea22..0000000 --- a/src/components/icon/index.js +++ /dev/null @@ -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'; diff --git a/src/components/icon/skip-backward.svelte b/src/components/icon/skip-backward.svelte deleted file mode 100644 index 50cf4bc..0000000 --- a/src/components/icon/skip-backward.svelte +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - diff --git a/src/components/icon/skip-forward.svelte b/src/components/icon/skip-forward.svelte deleted file mode 100644 index 8d0691c..0000000 --- a/src/components/icon/skip-forward.svelte +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - - - - - - diff --git a/src/components/icon/spinner.svelte b/src/components/icon/spinner.svelte deleted file mode 100644 index 30ab187..0000000 --- a/src/components/icon/spinner.svelte +++ /dev/null @@ -1,63 +0,0 @@ - - -
- {#each [0, 0.075, 0.15] as delay} -
- - - -
- {/each} -
- - diff --git a/src/components/player/index.js b/src/components/player/index.js deleted file mode 100644 index ca912fc..0000000 --- a/src/components/player/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as PlayerWidget } from './player-widget.svelte'; diff --git a/src/components/player/player-widget.svelte b/src/components/player/player-widget.svelte deleted file mode 100644 index d124606..0000000 --- a/src/components/player/player-widget.svelte +++ /dev/null @@ -1,315 +0,0 @@ - - - - - - - -
- - - - - {#if attributes.is_loaded} - - {:else} -
- - Waiting for audio... -
- {/if} - - - - - -
- - {attributes.timestamp_current} - -
- - -
- -
- - -
- - {attributes.timestamp_end} - -
- - - - -
-
- - diff --git a/src/content/episodes.js b/src/content/episodes.js deleted file mode 100644 index 6d63e3f..0000000 --- a/src/content/episodes.js +++ /dev/null @@ -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', - }, -}); diff --git a/src/layout/helper.js b/src/layout/helper.js deleted file mode 100644 index b1294b3..0000000 --- a/src/layout/helper.js +++ /dev/null @@ -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; -}; diff --git a/src/layout/metadata.svelte b/src/layout/metadata.svelte deleted file mode 100644 index d55e606..0000000 --- a/src/layout/metadata.svelte +++ /dev/null @@ -1,41 +0,0 @@ - - - diff --git a/src/layout/page/component.svelte b/src/layout/page/component.svelte deleted file mode 100644 index 5df4fec..0000000 --- a/src/layout/page/component.svelte +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - -
-

{title}

-
-
-
- - diff --git a/src/layout/page/container.svelte b/src/layout/page/container.svelte deleted file mode 100644 index aeac839..0000000 --- a/src/layout/page/container.svelte +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/src/layout/page/index.js b/src/layout/page/index.js deleted file mode 100644 index a9beb79..0000000 --- a/src/layout/page/index.js +++ /dev/null @@ -1 +0,0 @@ -export { default as DocsPage } from './component.svelte'; diff --git a/src/layout/page/section-article.svelte b/src/layout/page/section-article.svelte deleted file mode 100644 index 9fb5fe2..0000000 --- a/src/layout/page/section-article.svelte +++ /dev/null @@ -1,19 +0,0 @@ - - - diff --git a/src/layout/page/section.svelte b/src/layout/page/section.svelte deleted file mode 100644 index ff93ff4..0000000 --- a/src/layout/page/section.svelte +++ /dev/null @@ -1,22 +0,0 @@ - - - -
- - {title} - - - -
-
diff --git a/src/layout/page/table-schema.svelte b/src/layout/page/table-schema.svelte deleted file mode 100644 index a5315fb..0000000 --- a/src/layout/page/table-schema.svelte +++ /dev/null @@ -1,40 +0,0 @@ - - -
- - - - - - - - {#each rows as { property, type, description }} - - - - - - - - {/each} - -
PropertyTypeDescription
- - - - {description}
-
diff --git a/src/layout/preview-component.svelte b/src/layout/preview-component.svelte deleted file mode 100644 index bc0d814..0000000 --- a/src/layout/preview-component.svelte +++ /dev/null @@ -1,21 +0,0 @@ - - -
-
-

{name}

-
- -
- {name} options - -
- -
-
- -
-
-
diff --git a/src/layout/sidebar.svelte b/src/layout/sidebar.svelte deleted file mode 100644 index 04e2a44..0000000 --- a/src/layout/sidebar.svelte +++ /dev/null @@ -1,216 +0,0 @@ - - - -
- - Svelte-Podcast - - -
diff --git a/src/lib/actions.ts b/src/lib/actions.ts index 1b4c0c8..437adac 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -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); diff --git a/src/lib/index.ts b/src/lib/index.ts index eac3b54..9aa9943 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -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'; diff --git a/src/lib/rss.ts b/src/lib/rss.ts new file mode 100644 index 0000000..43f781c --- /dev/null +++ b/src/lib/rss.ts @@ -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; + +/** + * 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 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 { + 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()); +} diff --git a/src/radio/funkwhale.ts b/src/radio/funkwhale.ts new file mode 100644 index 0000000..7ad2c76 --- /dev/null +++ b/src/radio/funkwhale.ts @@ -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 (Fisher–Yates) and returns it. + */ +export function shuffle(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; +} diff --git a/src/radio/library-store.ts b/src/radio/library-store.ts new file mode 100644 index 0000000..dc0d6df --- /dev/null +++ b/src/radio/library-store.ts @@ -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(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 { + 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), + })); + } +} diff --git a/src/radio/playable.ts b/src/radio/playable.ts new file mode 100644 index 0000000..30245a6 --- /dev/null +++ b/src/radio/playable.ts @@ -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; +} diff --git a/src/radio/radio-player.svelte b/src/radio/radio-player.svelte new file mode 100644 index 0000000..54137a7 --- /dev/null +++ b/src/radio/radio-player.svelte @@ -0,0 +1,159 @@ + + + +
+
+ +
+ {#if current?.cover} + Pochette de {current.album ?? current.title} + {:else} +
+ +
+ {/if} +
+ + +
+ {#if current} +

+ {current.title} +

+

+ {current.artist} + {#if current.album}· {current.album}{/if} +

+ {#if $radio_player.mode === 'radio'} +

+ Mode radio aléatoire +

+ {/if} + {:else} +

+ Aucun titre en cours +

+

+ Lance la radio ou choisis un titre dans la liste. +

+ {/if} +
+ + +
+ + + + + + + + + + user_preferences.set_volume(e.currentTarget.valueAsNumber)} + class="h-1 w-20 accent-orange-500" + aria-label="Volume" + /> +
+
+ + +
+ {attributes.timestamp_current} +
+ +
+ {attributes.timestamp_end} +
+
+
+ + diff --git a/src/radio/radio-store.ts b/src/radio/radio-store.ts new file mode 100644 index 0000000..bceb883 --- /dev/null +++ b/src/radio/radio-store.ts @@ -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(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 { + // 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 { + 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 { + 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); + }); +} diff --git a/src/radio/track-list.svelte b/src/radio/track-list.svelte new file mode 100644 index 0000000..0921ce8 --- /dev/null +++ b/src/radio/track-list.svelte @@ -0,0 +1,140 @@ + + +
+
+

Titres de la radio

+ +
+
+ +
+ +
+ +

+ {#if $library.is_done} + {$library.total} titres + {:else if $library.total} + {$library.tracks.length} / {$library.total} titres chargés… + {:else} + Chargement… + {/if} +

+
+ + {#if $library.error} +

+ Erreur de chargement : {$library.error} +

+ {:else} +
    + {#each visible as track (track.id)} + {@const is_current = $radio_player.current?.id === track.id} +
  • + +
  • + {:else} +
  • + {#if $library.is_loading} + Chargement des titres… + {:else if search.trim()} + Aucun titre ne correspond à « {search} ». + {:else} + Aucun titre. + {/if} +
  • + {/each} +
+ + {#if filtered.length > visible.length} +

+ … et {filtered.length - visible.length} autres titres. Précise ta recherche + pour les trouver. +

+ {/if} + {/if} +
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 37d6b87..7c92686 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,270 +1,7 @@ - - {@html github} - - - -