Compare commits

...

12 Commits

Author SHA1 Message Date
sucupira 6262a02fec docs: README à jour (stack, fonctionnalités mobile/PWA, exemple RSS en +page.server.ts) 2026-07-21 19:35:15 -04:00
sucupira 220d6d8f2d chore: retire les fichiers et l'outillage hérités de la librairie
- Configs cassées sous Svelte 5 : eslint (eslint-plugin-svelte3), prettier
  (plugin v2), leurs scripts lint/format et dépendances associées
- Fichiers sans usage : .npmrc, .vscode/, .github/ (vide), CHANGELOG.md
  de la librairie, .eslintcache
- Player.svelte (composant headless) conservé : c'est le moteur de
  radio-player.svelte — check 0/0, build vert
2026-07-21 19:33:45 -04:00
sucupira 67c701f85a feat: réparation audio, mobile et PWA + charte OKI légère
- Volume propagé en direct sur l'élément audio (le slider ne faisait
  que persister) ; volume 0 respecté (?? 1) ; slider masqué sur iOS
  (can_set_volume) ; mute synchronisé store/élément, persistant entre pistes
- Race condition loadedmetadata, USER_PROGRESS borné à 200 entrées,
  erreurs radio affichées avec bouton Réessayer
- Mobile : barre de progression avec zone tactile 44 px (tap + scrub au doigt),
  boutons 48 px, contrôles wrap, listes paginées 50×50 + content-visibility
- PWA : manifest + icônes 192/512 (any+maskable), service worker manuel
  (shell + pochettes, streams exclus), Media Session API (metadata + 4 handlers)
- SEO : Seo.svelte (canonical, OG, Twitter), footer fédéré OKI + flag-bar
- Mesuré puppeteer 390x844 : seek tactile OK, scrollWidth OK, SW actif
2026-07-21 18:57:26 -04:00
sucupira 4320258468 chore: upgrade SvelteKit 2 + Svelte 5 (mode compat) + nettoyage packaging
- svelte 5.56, kit 2.70, adapter-static 3, vite 6, svelte-check 4, TS 5.9
- Syntaxe legacy conservée (on:click, $:, export let) — compile en mode compat
- Load RSS : +page.ts → +page.server.ts (XML de 10,9 Mo hors du HTML,
  page podcast 11,9 Mo → 1,5 Mo ; règle au passage le CORS en navigation client)
- package.json : renommé oki-podcast-reader, champs de librairie retirés,
  deps mortes supprimées (changesets, publint, highlights, meta-tags)
- check 0/0, build vert, radio Funkwhale et flux Syntax.fm fonctionnels
2026-07-21 18:09:44 -04:00
sucupira 72f4d2e160 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).
2026-07-21 17:54:36 -04:00
Ollie Taylor b1165a817e Version Packages (#49)
This PR was opened by the [Changesets
release](https://github.com/changesets/action) GitHub action. When
you're ready to do a release, you can merge this and the packages will
be published to npm automatically. If you're not ready to do a release
yet, that's fine, whenever you add more changesets to main, this PR will
be updated.


# Releases
## svelte-podcast@0.9.0

### Minor Changes

-   528457f: Restructures package exports
-   528457f: - improved export structure
    -   simplify audio methods
    -   improve typescript types
-   bba2421: Improve example components
-   bba2421: improve progress component style overrides
-   f42a00f: removes autoplay as it doesn't behave as users would expect

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2023-07-29 20:13:05 +01:00
Ollie Taylor f42a00f04c Disable autoplay (#51) 2023-07-29 20:11:22 +01:00
Ollie Taylor 528457fe59 Cleanup lib exports (#50) 2023-07-29 17:57:14 +01:00
Ollie Taylor bba24215eb Docs UI (#48) 2023-07-28 22:27:47 +01:00
Ollie Taylor dde9bec49c fixes meta image url 2023-07-13 01:09:28 +01:00
Ollie Taylor bac7bc605d uses meta images 2023-07-13 01:07:49 +01:00
Ollie Taylor 63ef9beafd Adds meta images 2023-07-13 01:06:22 +01:00
129 changed files with 3019 additions and 6651 deletions
-8
View File
@@ -1,8 +0,0 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
with multi-package repos, or single-package repos to help you version and publish your code. You can
find the full documentation for it [in our repository](https://github.com/changesets/changesets)
We have a quick list of common questions to get you started engaging with this project in
[our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
-11
View File
@@ -1,11 +0,0 @@
{
"$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": []
}
-20
View File
@@ -1,20 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
/dist
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
**/components/_*
*.config.ts
*.config.js
*.config.cjs
-43
View File
@@ -1,43 +0,0 @@
module.exports = {
root: true,
parser: '@typescript-eslint/parser',
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
// 'plugin:@typescript-eslint/recommended-requiring-type-checking',
'prettier',
],
plugins: ['svelte3', '@typescript-eslint', 'unused-imports'],
ignorePatterns: ['*.cjs'],
overrides: [{ files: ['*.svelte'], processor: 'svelte3/svelte3' }],
settings: {
'svelte3/typescript': () => require('typescript'),
},
parserOptions: {
sourceType: 'module',
ecmaVersion: 2020,
project: ['./tsconfig.json'],
},
env: {
browser: true,
es2017: true,
node: true,
},
rules: {
// '@typescript-eslint/no-inferrable-types': 'off',
// '@typescript-eslint/no-unsafe-argument': 'off',
// '@typescript-eslint/no-unsafe-assignment': 'off',
'logical-assignment-operators': ['error', 'always'],
// "no-unused-vars": "off",
'unused-imports/no-unused-imports': 'error',
'unused-imports/no-unused-vars': [
'warn',
{
vars: 'all',
varsIgnorePattern: '^_',
args: 'after-used',
argsIgnorePattern: '^_',
},
],
},
};
-129
View File
@@ -1,129 +0,0 @@
name: 'CI'
on:
push:
branches:
- main
pull_request:
# cancel in-progress runs on new commits to same PR (gitub.event.number)
concurrency:
group: ${{ github.workflow }}-${{ github.event.number || github.sha }}
cancel-in-progress: true
permissions:
contents: read # to fetch code (actions/checkout)
jobs:
setup:
name: Setup Enviroment
timeout-minutes: 4
runs-on: ubuntu-latest
outputs:
cache_name: ${{ steps.cache.outputs.name }}
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set Node to v18.13.0
uses: actions/setup-node@v3
with:
node-version: v18.13.0
- name: Get Node version
id: node
run: echo "version=$(node -v)" >> $GITHUB_OUTPUT
#run: echo "::set-output name=version::$(node --v)"
- name: Get Cache Name
id: cache
run: echo "name=${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }}-${{ steps.node.outputs.version }}" >> $GITHUB_OUTPUT
- name: Get node_modules cache
uses: actions/cache@v3
id: node_modules
with:
path: |
**/node_modules
**/.svelte-kit
key: ${{ steps.cache.outputs.name }}
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Sync generated typings
run: yarn run sync
linting:
name: Linting Checks
needs: setup
timeout-minutes: 4
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Get node_modules cache
uses: actions/cache/restore@v3
id: cache
with:
path: |
**/node_modules
**/.svelte-kit
key: ${{ needs.setup.outputs.cache_name }}
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Lint prettier
run: yarn run lint:prettier
- name: Lint eslint
run: yarn run lint:eslint
type_check:
name: Typescript Checks
needs: setup
timeout-minutes: 4
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Get node_modules cache
uses: actions/cache/restore@v3
id: cache
with:
path: |
**/node_modules
**/.svelte-kit
key: ${{ needs.setup.outputs.cache_name }}
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Check for type errors
run: yarn run ts
# svelte_check:
# name: Svelte Checks
# needs: setup
# timeout-minutes: 4
# runs-on: ubuntu-latest
# steps:
# - name: Checkout code
# uses: actions/checkout@v3
#
# - name: Get node_modules cache
# uses: actions/cache/restore@v3
# id: cache
# with:
# path: |
# **/node_modules
# **/.svelte-kit
# key: ${{ needs.setup.outputs.cache_name }}
#
# - name: Install dependencies
# run: yarn install --frozen-lockfile
#
# - name: Check for best practices
# run: yarn run check
-89
View File
@@ -1,89 +0,0 @@
name: 'Publish to NPM'
on:
push:
branches:
- 'main'
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
setup:
name: Setup Enviroment
timeout-minutes: 4
runs-on: ubuntu-latest
outputs:
cache_name: ${{ steps.cache.outputs.name }}
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Set Node to v18.13.0
uses: actions/setup-node@v3
with:
node-version: v18.13.0
- name: Get Node version
id: node
run: echo "version=$(node -v)" >> $GITHUB_OUTPUT
#run: echo "::set-output name=version::$(node --v)"
- name: Get Cache Name
id: cache
run: echo "name=${{ runner.os }}-node_modules-${{ hashFiles('**/yarn.lock') }}-${{ steps.node.outputs.version }}" >> $GITHUB_OUTPUT
- name: Get node_modules cache
uses: actions/cache@v3
id: node_modules
with:
path: |
**/node_modules
**/.svelte-kit
key: ${{ steps.cache.outputs.name }}
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Sync generated typings
run: yarn run sync
release:
if: github.repository == 'OllieJT/svelte-podcast'
name: Release
needs: setup
timeout-minutes: 5
runs-on: ubuntu-latest
# prevents this action from running on forks
permissions:
contents: write # to create release (changesets/action)
pull-requests: write # to create pull request (changesets/action)
steps:
- name: Checkout code
uses: actions/checkout@v3
with:
# This makes Actions fetch all Git history so that Changesets can generate changelogs with the correct commits
fetch-depth: 0
- name: Get node_modules cache
uses: actions/cache/restore@v3
id: cache
with:
path: |
**/node_modules
**/.svelte-kit
key: ${{ needs.setup.outputs.cache_name }}
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Package svelte src/lib
run: yarn run package
- name: Create Release Pull Request or Publish to npm
id: changesets
uses: changesets/action@v1
with:
publish: yarn run release
env:
GITHUB_TOKEN: ${{ secrets.TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
UPDATE_TEMPLATE_SSH_KEY: ${{ secrets.UPDATE_TEMPLATE_SSH_KEY }}
-1
View File
@@ -1 +0,0 @@
engine-strict=true
-14
View File
@@ -1,14 +0,0 @@
.DS_Store
node_modules
/build
/.svelte-kit
/package
/dist
.env
.env.*
!.env.example
# Ignore files for PNPM, NPM and YARN
pnpm-lock.yaml
package-lock.json
yarn.lock
-17
View File
@@ -1,17 +0,0 @@
{
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"arrowParens": "always",
"bracketSpacing": true,
"bracketSameLine": false,
"semi": true,
"singleQuote": true,
"useTabs": true,
"tabWidth": 3,
"trailingComma": "all",
"overrides": [
{
"files": "*.svelte",
"options": { "parser": "svelte" }
}
]
}
-10
View File
@@ -1,10 +0,0 @@
{
// See http://go.microsoft.com/fwlink/?LinkId=827846
// for the documentation about the extensions.json format
"recommendations": [
"dbaeumer.vscode-eslint",
"aaron-bond.better-comments",
"esbenp.prettier-vscode",
"gruntfuggly.todo-tree"
]
}
-45
View File
@@ -1,45 +0,0 @@
{
"[svelte]": {
"editor.defaultFormatter": "svelte.svelte-vscode",
"editor.codeActionsOnSave": {
"source.organizeImports": true
}
},
"[typescript]": {
"editor.codeActionsOnSave": {
"source.organizeImports": true
}
},
"[javascript]": {
"editor.codeActionsOnSave": {
"source.organizeImports": true
}
},
"eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }],
"typescript.tsdk": "node_modules/typescript/lib",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.formatOnType": false,
"files.exclude": {
"**/.cache": true,
"**/.DS_Store": true,
"**/.gitattributes": true,
"**/.svelte-kit": true,
"**/*-lock.*": true,
"**/*.Identifier": true,
"**/*.map": true,
"**/.turbo": true,
"**/.vercel": true,
"**/node_modules": true,
"**/tsconfig.tsbuildinfo": true,
"**/.npmrc": true
},
"files.watcherExclude": {
"**/node_modules": true,
"**/.svelte-kit": true
},
"problems.sortOrder": "severity",
"javascript.preferences.importModuleSpecifier": "relative",
"typescript.preferences.importModuleSpecifier": "relative",
"todo-tree.tree.scanMode": "workspace only"
}
-98
View File
@@ -1,98 +0,0 @@
# svelte-podcast
## 0.8.0
### Minor Changes
- d18b0c7: refactor audio element logic
- 29ef657: switch to jsdoc from ts
- d18b0c7: rewrite docs with code samples
## 0.7.0
### Minor Changes
- 16366ec: improvements to headless components
- 415d083: switch to jsdoc from ts
## 0.6.0
### Minor Changes
- 9232f4d: Improves theming with css variables
Improves developer logs
Fixes various package export issues
Adds player components:
- Player: `<PlayerStack />`
- Player: `<PlayerWidget />`
- Utility: `<HeadlessTimeline />`
## 0.5.0
### Minor Changes
- feba0d4: Improvements to audio stores:
- Adds missing `is_paused` value.
- Fixes to `start_at` value
- Refactors localStorage stores & user preferences
## 0.4.0
### Minor Changes
- ff6a6d9: refactored audio element bindings for better control over element
## 0.3.4
### Patch Changes
- ff5caf2: fix to allow override of internal types
## 0.3.3
### Patch Changes
- 802c704: fixes dist exports
## 0.3.2
### Patch Changes
- 6c849b7: Fixes derived store causing exessive updates
## 0.3.1
### Patch Changes
- 02598d4: fixes exported typings
## 0.3.0
### Minor Changes
- 0b12aab: Adds mechanic for managing user preferences
## 0.2.0
### Minor Changes
- 6f89448: Adds the ability to bind episode metadata to the audio store
- a70d68d:
- Restructures lib to make dist cleaner and dev easier to grock
- Refactors <audio /> bindings to make it easier to change values when loading a different source
- Moves more values to micro-stores
- 1841199: Adds save/load mechanic for episode progress
## 0.1.1
### Patch Changes
- 549d858: Fixes release build
## 0.1.0
### Minor Changes
- 2e6f082: Adds initial audio element abstraction with store
+80 -71
View File
@@ -1,83 +1,92 @@
<!-- # Radyobòkaz Player
41.9 kB
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`). Dépôt de travail :
[labola.o-k-i.net/cyber-mawonaj/oki-podcast-reader](https://labola.o-k-i.net/cyber-mawonaj/oki-podcast-reader).
# Svelte Podcast ## Stack
Svelte-Podcast streamlines the creation of custom audio players and simplifies state management in Svelte apps. - **SvelteKit 2 + Svelte 5** (mode compatibilité : syntaxe `on:click`/`$:`/`export let` conservée) + TypeScript
- **Tailwind CSS 3** — aucune autre dépendance UI
- **adapter-static** — pré-rendu statique dans `build/`
- Audio : `<audio>` natif unique (aucune lib tierce), stores Svelte legacy
- Flux RSS : parsing maison via `fast-xml-parser` (compatible SSR, pas de `DOMParser`)
| Version | License | Status | ## Fonctionnalités
| :-------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------: |
| [![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) |
## What's inside? - 📻 **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 côté serveur + lecture des épisodes (démo Syntax.fm sur `/podcast`)
- ⏭️ Passage automatique au titre suivant en fin de piste
- 🎛️ Contrôles : lecture/pause, précédent/suivant, volume persisté (masqué sur iOS, où `volume` est en lecture seule), mute persistant, seek
- 📱 **Mobile** : cibles tactiles ≥ 48 px, barre de progression avec zone tactile de 44 px (tap + scrub au doigt), listes paginées 50×50
- 🔒 **Media Session API** : titre/artiste/pochette + contrôles depuis l'écran de verrouillage
- 📲 **PWA installable** : manifest, service worker (shell + pochettes en cache, streams audio exclus)
- 🖼️ Pochette, titre, artiste et album du morceau en cours
- 🔃 L'audio continue de jouer lors de la navigation entre pages
- ⚠️ Message d'erreur + bouton Réessayer si l'API est indisponible
- 💾 Préférences (volume, vitesse, mute) et progression persistées en localStorage (bornées à 200 entrées)
### Build custom Audio Player UI ## Développement
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 ```bash
# with npm yarn install
npm install svelte-podcast@latest yarn dev # serveur de dev
yarn build # build statique (adapter-static) dans build/
# with yarn yarn preview # prévisualiser le build
yarn add svelte-podcast@latest yarn run check # types + accessibilité (svelte-check)
# with pnpm
pnpm add svelte-podcast@latest
``` ```
You can find docs and guides for getting started on [svelte-podcast.com](https://svelte-podcast.com/) Note : la page `/podcast/` fetch son flux au build (pré-rendu côté serveur) —
un build hors-ligne la fait échouer ; c'est attendu.
| Resource | Link | ## Utilitaires RSS (SSR)
| ----------------------------------------------------------------- | ------------------------------------------------------------- |
| Provides guidance on Installation, Audio Sources, and Type Safety | [Getting Started](https://svelte-podcast.com/getting-started) | ```ts
| Covers API methods for audio, user preferences, and user progress | [API](https://svelte-podcast.com/api) | // src/routes/podcast/+page.server.ts
| Offers examples of Headless UI, Tailwind, and CSS players | [Examples](https://svelte-podcast.com/examples) | import { fetch_podcast_feed } from '$lib/rss';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => ({
// le fetch reste côté serveur : le XML n'est jamais inliné dans le HTML
// et la navigation client n'a pas de problème CORS
feed: await fetch_podcast_feed('https://feed.syntax.fm/rss', fetch),
});
```
- `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…)
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.
## 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/lib/volume-support.ts` | `can_set_volume()` — détection iOS par capacité |
| `src/lib/Seo.svelte`, `src/lib/site.js` | Meta SEO (canonical, OG, Twitter) + URL du site |
| `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 : radio aléatoire / liste, erreurs, Media Session |
| `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) |
| `src/service-worker.js` | PWA : shell + pochettes en cache, streams audio exclus |
| `static/` | Icônes PWA, manifest.webmanifest, robots.txt |
+13 -55
View File
@@ -1,83 +1,41 @@
{ {
"name": "svelte-podcast", "name": "oki-podcast-reader",
"version": "0.8.0", "version": "1.0.0",
"private": true,
"description": "Lecteur web pour la radio Radyobòkaz (mizik.o-k-i.net), basé sur svelte-podcast.",
"license": "MIT", "license": "MIT",
"author": "Ollie Taylor",
"homepage": "https://github.com/OllieJT/svelte-podcast/blob/main/README.md",
"bugs": {
"url": "https://github.com/OllieJT/svelte-podcast/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/OllieJT/svelte-podcast.git"
},
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"package": "svelte-kit sync && svelte-package && publint",
"sync": "svelte-kit sync", "sync": "svelte-kit sync",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint:prettier": "prettier --check --plugin-search-dir=. .",
"lint:eslint": "TIMING=1 eslint . --ext .ts,.tsx,.svelte --cache",
"lint": "yarn lint:prettier && yarn lint:eslint",
"format": "prettier --write --plugin-search-dir=. .",
"release": "changeset publish",
"ts": "tsc --pretty --noImplicitAny --noEmit" "ts": "tsc --pretty --noImplicitAny --noEmit"
}, },
"exports": {
".": {
"types": "./dist/index.d.ts",
"svelte": "./dist/index.js"
}
},
"files": [
"dist"
],
"peerDependencies": {
"@sveltejs/kit": ">=1.0.0",
"svelte": ">=3.0.0"
},
"dependencies": { "dependencies": {
"@inqling/svelte-icons": "^3.3.2", "@inqling/svelte-icons": "^3.3.2",
"clsx": "^1.2.1", "clsx": "^1.2.1",
"esm-env": "^1.0.0", "esm-env": "^1.0.0",
"fast-xml-parser": "^5.10.1",
"just-clamp": "^4.2.0", "just-clamp": "^4.2.0",
"svelte-local-storage-store": "^0.5.0" "svelte-local-storage-store": "^0.6.4"
}, },
"devDependencies": { "devDependencies": {
"@changesets/cli": "^2.26.2", "@sveltejs/adapter-static": "^3",
"@sveltejs/adapter-static": "^2.0.2", "@sveltejs/kit": "^2",
"@sveltejs/kit": "^1.22.1", "@sveltejs/vite-plugin-svelte": "^5",
"@sveltejs/package": "^2.1.0",
"@tailwindcss/forms": "^0.5.3", "@tailwindcss/forms": "^0.5.3",
"@tailwindcss/typography": "^0.5.9", "@tailwindcss/typography": "^0.5.9",
"@typescript-eslint/eslint-plugin": "^5.61.0",
"@typescript-eslint/parser": "^5.61.0",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"eslint": "^8.44.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-svelte3": "^4.0.0",
"eslint-plugin-unused-imports": "^2.0.0",
"postcss": "^8.4.25", "postcss": "^8.4.25",
"postcss-load-config": "^4.0.1", "postcss-load-config": "^4.0.1",
"prettier": "^2.8.8", "svelte": "^5",
"prettier-plugin-svelte": "^2.10.1", "svelte-check": "^4",
"prettier-plugin-tailwindcss": "^0.3.0",
"publint": "^0.1.16",
"svelte": "^4.0.5",
"svelte-check": "^3.4.5",
"svelte-highlight": "^7.3.0",
"svelte-meta-tags": "^2.8.0",
"svelte-preprocess": "^5.0.4",
"svhighlight": "^0.7.1",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"tslib": "^2.6.0", "tslib": "^2.6.0",
"typescript": "^5.1.6", "typescript": "^5.3.3",
"vite": "^4.4.2" "vite": "^6"
}, },
"svelte": "./dist/index.js",
"types": "./dist/index.d.ts",
"type": "module" "type": "module"
} }
+9 -6
View File
@@ -1,19 +1,22 @@
<!DOCTYPE html> <!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> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<link rel="icon" href="%sveltekit.assets%/icon.png" /> <link rel="icon" href="%sveltekit.assets%/icon.png" />
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/icon-192.png" />
<meta name="theme-color" content="#f97316" /> <meta name="theme-color" content="#ea580c" />
<meta name="color-scheme" content="light" /> <meta name="color-scheme" content="light" />
<link rel="bookmark" title="svelte-podcast Docs" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body class="h-full scroll-smooth hover:scroll-auto"> <body class="h-full">
<div>%sveltekit.body%</div> <div>%sveltekit.body%</div>
</body> </body>
</html> </html>
-156
View File
@@ -2,159 +2,3 @@
@tailwind base; @tailwind base;
@tailwind components; @tailwind components;
@tailwind utilities; @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;
}
}
-1
View File
@@ -1 +0,0 @@
export { default as PlayerWidget } from './player-widget.svelte';
@@ -1,444 +0,0 @@
<script>
import { Pause, Play } from '@inqling/svelte-icons/heroicon-20-solid';
import { AudioPlayer, user_preferences } from 'svelte-podcast';
import { A11yIcon, Skip, Spinner, Timestamp } from './utility';
/** @type {string | undefined} */
export let src;
/** @type {import('svelte-podcast/audio').AudioMetadata} */
export let metadata = {};
export let hide_duration = false;
export let hide_current_time = false;
export let hide_playback_rate = false;
export let hide_skip_back = false;
export let hide_skip_forward = false;
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--font: ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
--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);
--bg: var(--svpod--surface--darker);
--fg: var(--svpod--content--darker);
--svpod--radius--rounded: 9999px;
/* element : timeline */
--svpod--timeline-track--shape--height: 20px;
--svpod--timeline-track--shape--radius: 0;
--svpod--timeline-track--shape--border: 0;
--svpod--timeline-thumb--shape--width: 8px;
--svpod--timeline-thumb--shape--height: 32px;
--svpod--timeline-thumb--shape--radius: 6px;
--svpod--timeline-thumb--shape--border: 2px;
}
.svpod--reset {
text-transform: none;
font-style: normal;
text-indent: 0;
text-shadow: none;
text-align: left;
margin: 0;
padding: 0;
}
.svpod--a11y-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.svpod--button,
.svpod--icon-button {
--fg: var(--svpod--content--base);
--bg: var(--svpod--surface--base);
position: relative;
line-height: 1em;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 16px;
border: 0px solid transparent;
border-radius: var(--svpod--radius--rounded);
background-color: var(--bg);
color: var(--fg);
cursor: pointer;
}
.svpod--button:hover,
.svpod--icon-button:hover {
--fg: var(--svpod--content--lighter);
--bg: var(--svpod--surface--lighter);
}
.svpod--button {
padding: 4px calc(1em);
}
.svpod--icon-button {
padding: 8px;
min-width: 48px;
min-height: 48px;
}
</style>
</svelte:head>
<AudioPlayer {src} {metadata} let:Player let:action let:attributes>
<div
{...$$restProps}
class="svpod--container svpod--reset"
data-loaded={attributes.is_loaded ? 'true' : 'false'}
>
{#if !hide_skip_back}
<button
on:click={() => action.skip_back(skip_back)}
class="svpod--reset svpod--toggle-pause"
type="button"
>
<Skip value={skip_back} type="backward" />
</button>
{/if}
<!-- toggle play -->
{#if attributes.is_loaded}
<button
on:click={() => action.toggle()}
class="svpod--reset svpod--toggle-pause"
type="button"
>
<A11yIcon
icon={attributes.is_paused ? Play : Pause}
label={attributes.is_paused ? 'Play' : 'Pause'}
/>
</button>
{:else}
<div class="svpod--reset svpod--toggle-pause">
<A11yIcon icon={Spinner} label="Waiting for audio..." />
</div>
{/if}
{#if !hide_skip_forward}
<button
on:click={() => action.skip_forward(skip_forward)}
class="svpod--reset svpod--toggle-pause"
type="button"
>
<Skip value={skip_forward} type="forward" />
</button>
{/if}
{#if !hide_current_time}
<div class="svpod--timestamp">
<Timestamp
value={attributes.current_time}
force_hours={attributes.duration >= 3600}
/>
</div>
{/if}
<div class="svpod--timeline">
<Player.AudioProgress />
</div>
{#if !hide_duration}
<div class="svpod--timestamp">
<Timestamp value={attributes.duration} />
</div>
{/if}
{#if !hide_playback_rate}
<select
class="svpod--playback-rate"
value={$user_preferences.playback_rate}
on:change={(e) => {
const value = parseFloat(e.currentTarget.value);
action.set_playback_rate(value);
}}
>
{#each playback_rate_values as value}
<option {value}>
{#if Number.isInteger(value)}
{value}.0
{:else}
{value}
{/if}
</option>
{/each}
</select>
{/if}
</div>
</AudioPlayer>
<style lang="postcss">
.svpod--container {
--fg: var(--svpod--content--base);
--bg: var(--svpod--surface--darker);
--padding: 2px;
--radius: 0.5em;
--inner-radius: calc(var(--radius) - var(--padding));
--svpod--timeline-track--shape--height: 3em;
--svpod--timeline-track--shape--radius: var(--inner-radius);
--svpod--timeline-track--shape--border: 2px;
--svpod--timeline-thumb--shape--height: var(
--svpod--timeline-track--shape--height
);
--svpod--timeline-thumb--shape--width: 3px;
--svpod--timeline-thumb--shape--radius: 1px;
--svpod--timeline-thumb--shape--border: 0px;
background-color: var(--bg);
color: var(--fg);
display: inline-flex;
flex-direction: row;
align-items: stretch;
font-size: 16px;
line-height: 1em;
min-width: 320px;
max-width: 100%;
border-radius: var(--radius);
font-weight: 600;
padding: var(--padding);
gap: var(--padding);
}
div.svpod--timeline {
display: flex;
align-items: center;
justify-content: stretch;
flex-grow: 1;
flex-shrink: 1;
font-size: 1em;
background: none;
}
.svpod--toggle-pause {
align-items: center;
border: none;
display: flex;
font-size: 1em;
height: 3em;
justify-content: center;
width: 3em;
border-radius: var(--inner-radius);
}
button.svpod--toggle-pause {
cursor: pointer;
}
.svpod--toggle-pause :global(svg) {
width: 1.5em;
height: 1.5em;
}
div.svpod--timestamp {
background-color: var(--bg);
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 4px;
font-size: 1em;
line-height: 1em;
pointer-events: none;
border-radius: var(--inner-radius);
width: max-content;
flex-grow: 0;
text-align: center;
padding: 0 0.1em;
}
select.svpod--playback-rate {
background-color: var(--bg);
appearance: none;
padding: 0 0.75em;
color: var(--fg);
overflow: hidden;
letter-spacing: 0.5px;
font-size: 0.8em;
line-height: 1em;
text-transform: uppercase;
font-weight: 600;
text-align: center;
min-width: 48px;
outline-color: var(--svpod--accent--lighter);
cursor: pointer;
margin: 0;
border-radius: var(--inner-radius);
}
select.svpod--playback-rate,
select.svpod--playback-rate:hover,
select.svpod--playback-rate:focus {
border: none;
shadow: none;
}
select.svpod--playback-rate:focus {
--fg: var(--svpod--accent--lighter);
}
button.svpod--toggle-pause,
select.svpod--playback-rate {
background-color: var(--bg);
color: var(--fg);
}
button.svpod--toggle-pause:hover,
select.svpod--playback-rate:hover {
--bg: var(--svpod--surface--base);
--fg: var(--svpod--content--lighter);
background-color: var(--bg);
color: var(--fg);
}
.svpod--timeline {
:global(input[type='range']) {
--track--shape--height: calc(
var(--svpod--timeline-track--shape--height)
);
--thumb-border-offset: calc(
var(--svpod--timeline-thumb--shape--border) * 2
);
--thumb--shape--height: calc(
var(--svpod--timeline-thumb--shape--height) -
var(--thumb-border-offset)
);
--svpod--timeline-track--bg: var(--svpod--surface--base);
--svpod--timeline-track--border: var(--bg);
--svpod--timeline-thumb--bg: var(--svpod--content--base);
--svpod--timeline-thumb--border: var(--bg);
font-size: 1em;
--borders: calc(var(--svpod--timeline-thumb--shape--border) * 2);
--no-shadow: 0px 0px 0px transparent;
height: calc(var(--thumb--shape--height) + var(--borders));
appearance: none;
margin: 0;
width: 100%;
background: transparent;
}
:global(input[type='range']:hover) {
--svpod--timeline-track--bg: var(--svpod--accent--darker);
--svpod--timeline-track--border: var(--svpod--accent--base);
--svpod--timeline-thumb--bg: var(--svpod--accent--lighter);
}
:global(input[type='range']:focus) {
outline: none;
}
:global(input[type='range']::-webkit-slider-runnable-track) {
width: 100%;
height: var(--track--shape--height);
cursor: pointer;
animate: 0.2s;
box-shadow: var(--no-shadow);
background: var(--svpod--timeline-track--bg);
border-radius: var(--svpod--timeline-track--shape--radius);
border: var(--svpod--timeline-track--shape--border) solid
var(--svpod--timeline-track--border);
}
:global(input[type='range']::-webkit-slider-thumb) {
--offset: calc(var(--svpod--timeline-track--shape--border) * -1);
box-shadow: var(--no-shadow);
border: var(--svpod--timeline-thumb--shape--border) solid
var(--svpod--timeline-thumb--border);
height: var(--thumb--shape--height);
width: var(--svpod--timeline-thumb--shape--width);
border-radius: var(--svpod--timeline-thumb--shape--radius);
background: var(--svpod--timeline-thumb--bg);
cursor: pointer;
-webkit-appearance: none;
margin-top: var(--offset);
}
:global(input[type='range']:focus::-webkit-slider-runnable-track) {
background: var(--svpod--timeline-track--bg);
}
:global(input[type='range']::-moz-range-track) {
width: 100%;
height: var(--track--shape--height);
cursor: pointer;
animate: 0.2s;
box-shadow: var(--no-shadow);
background: var(--svpod--timeline-track--bg);
border-radius: var(--svpod--timeline-track--shape--radius);
border: var(--svpod--timeline-track--shape--border) solid
var(--svpod--timeline-track--border);
}
:global(input[type='range']::-moz-range-thumb) {
box-shadow: var(--no-shadow);
border: var(--svpod--timeline-thumb--shape--border) solid
var(--svpod--timeline-thumb--border);
height: var(--thumb--shape--height);
width: var(--svpod--timeline-thumb--shape--width);
border-radius: var(--svpod--timeline-thumb--shape--radius);
background: var(--svpod--timeline-thumb--bg);
cursor: pointer;
}
:global(input[type='range']::-ms-track) {
width: 100%;
height: var(--track--shape--height);
cursor: pointer;
animate: 0.2s;
background: transparent;
border-color: transparent;
color: transparent;
}
:global(input[type='range']::-ms-fill-lower),
:global(input[type='range']::-ms-fill-upper) {
background: var(--svpod--timeline-track--bg);
border: var(--svpod--timeline-track--shape--border) solid
var(--svpod--timeline-track--border);
border-radius: calc(var(--svpod--timeline-track--shape--radius) * 2);
box-shadow: var(--no-shadow);
}
:global(input[type='range']::-ms-thumb) {
margin-top: 1px;
box-shadow: var(--no-shadow);
border: var(--svpod--timeline-thumb--shape--border) solid
var(--svpod--timeline-thumb--border);
height: var(--thumb--shape--height);
width: var(--svpod--timeline-thumb--shape--width);
border-radius: var(--svpod--timeline-thumb--shape--radius);
background: var(--svpod--timeline-thumb--bg);
cursor: pointer;
}
:global(input[type='range']:focus::-ms-fill-lower),
:global(input[type='range']:focus::-ms-fill-upper) {
background: var(--svpod--timeline-track--bg);
}
}
</style>
@@ -1,10 +0,0 @@
<script>
/** @type {import('@inqling/svelte-icons').SvelteIcon} */
export let icon;
/** @type {string} */
export let label;
</script>
<svelte:component this={icon} />
<span class="svpod--a11y-hidden">{label}</span>
@@ -1,4 +0,0 @@
export { default as A11yIcon } from './a11y-icon.svelte';
export { default as Skip } from './skip.svelte';
export { default as Spinner } from './spinner.svelte';
export { default as Timestamp } from './timestamp.svelte';
@@ -1,156 +0,0 @@
<script>
/** @type {number} [size = 24] */
export let size = 24;
/** @type {number} */
export let value;
/** @type {'forward' | 'backward'} */
export let type;
</script>
<div class="svpod--skip-container" style="font-size:{size}px">
<span class="svpod--skip-label">
<span class="svpod--a11y-hidden">skip {type}</span>
{value}
<span class="svpod--a11y-hidden">seconds</span>
</span>
<span
class="svpod--skip-mask"
class:svpod--skip-mask--right={type === 'backward'}
class:svpod--skip-mask--left={type === 'backward'}
/>
<div class="svpod--skip-icon">
{#if type === 'forward'}
<!-- forward -->
<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>
{:else}
<!-- back -->
<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>
{/if}
</div>
</div>
<style>
div.svpod--skip-container {
display: inline-flex;
position: relative;
align-items: center;
justify-content: center;
width: 1.5em;
height: 1.5em;
}
div.svpod--skip-icon,
span.svpod--skip-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
span.svpod--skip-mask {
z-index: 1;
background: linear-gradient(0deg, var(--bg), transparent);
opacity: 0.5;
}
div.svpod--skip-icon {
z-index: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--svpod--content--base);
}
span.svpod--skip-label {
position: relative;
z-index: 2;
font-size: 0.5em;
line-height: 1em;
color: var(--svpod--content--lighter);
}
@keyframes Spin {
0%,
100% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>
@@ -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,22 +0,0 @@
<script>
import { seconds_to_timestamp } from 'svelte-podcast/utility';
/** @type {number} */
export let value;
/** @type {boolean} */
export let force_hours = false;
$: timestamp = seconds_to_timestamp(value, force_hours);
</script>
<span style="width:{timestamp.length}ch">{timestamp}</span>
<style>
span {
letter-spacing: 0.5px;
font-size: 0.8em;
display: block;
width: 8ch;
}
</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;
};
-34
View File
@@ -1,34 +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 = [];
</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>
-215
View File
@@ -1,215 +0,0 @@
<script>
import { assets } from '$app/paths';
import { page } from '$app/stores';
import clsx from 'clsx';
import { circIn, 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: 0,
}}
out:fly={{
x: -320,
duration: 300,
easing: circIn,
}}
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>
+32
View File
@@ -0,0 +1,32 @@
<script lang="ts">
import { createEventDispatcher, onMount } from 'svelte';
import { AudioProgress } from '.';
import { use_audio_element } from './_internal_/audio-element';
import { audio } from './actions';
import { audio_attributes } from './attributes';
import { user_preferences } from './user-preferences';
import { user_progress } from './user-progress';
const dispatch = createEventDispatcher();
onMount(() => {
use_audio_element('initialise');
dispatch('ready');
});
$: $audio;
</script>
<slot
PlayerProgress={AudioProgress}
attributes={$audio_attributes}
preference={{
set_playback_rate: user_preferences.set_playback_rate,
set_volume: user_preferences.set_volume,
save_progress: user_progress.save,
}}
play={audio.play}
mute={audio.mute}
seek_to={audio.seek_to}
skip_by={audio.skip_by}
/>
+244
View File
@@ -0,0 +1,244 @@
<script lang="ts">
import clamp from 'just-clamp';
import { announce } from './_internal_/announce';
import { audio_element } from './_internal_/audio-element';
import { audio_attributes } from './attributes';
export let step = 10;
let was_paused = true;
/**
* @param {string} t - The type of event that triggered the drag end.
*/
function handle_drag_start(t: string) {
announce.info('drag_start :: ', t);
was_paused = $audio_attributes.is_paused;
$audio_element?.pause();
}
/**
* @param {string} t - The type of event that triggered the drag end.
*/
function handle_drag_end(t: string) {
announce.info('drag_end :: ', t);
if (was_paused) return;
else $audio_element?.play();
}
/**
* Seek to time audio
* @param {number} seconds - Seconds to seek
* @returns {void}
*/
const handle_seek_to = (seconds: number) => {
const el = $audio_element;
if (!el) return;
el.currentTime = clamp(0, el.duration, seconds);
};
/**
* Seek to the position of a touch on the track. Needed because
* tapping the track of a styled range input does not reposition the
* thumb on several mobile engines — the thumb (a few px wide) was
* the only touchable spot. Also enables scrubbing while dragging.
* @param {number} client_x - Touch position in viewport coordinates
* @param {HTMLInputElement} input - The range input being touched
*/
const handle_seek_from_touch = (
client_x: number,
input: HTMLInputElement,
) => {
const rect = input.getBoundingClientRect();
if (rect.width <= 0 || !Number.isFinite($audio_attributes.duration))
return;
const fraction = clamp(0, 1, (client_x - rect.left) / rect.width);
handle_seek_to(fraction * $audio_attributes.duration);
};
</script>
{#if $audio_attributes.is_loaded}
<input
class={$$restProps.class}
style="display:block; width:100%;"
type="range"
data-paused={$audio_attributes.is_paused ? 'true' : 'false'}
min={0}
{step}
max={$audio_attributes.duration}
value={$audio_attributes.current_time}
on:change={(e) => handle_seek_to(e.currentTarget.valueAsNumber)}
on:touchstart={(e) => {
handle_drag_start('touchstart');
const touch = e.touches[0];
if (touch) handle_seek_from_touch(touch.clientX, e.currentTarget);
}}
on:touchmove={(e) => {
const touch = e.touches[0];
if (touch) handle_seek_from_touch(touch.clientX, e.currentTarget);
}}
on:mousedown={() => handle_drag_start('mousedown')}
on:touchend={() => handle_drag_end('touchend')}
on:mouseup={() => handle_drag_end('mouseup')}
/>
{:else}
<input
class={$$restProps.class}
style="display:block; width:100%;"
type="range"
data-paused={true}
min={0}
step={1}
max={1}
value={0}
disabled
readonly
/>
{/if}
<svelte:head>
<style>
:root {
/* initial player progress colors */
--progress-bg: rgb(0, 0, 0);
--progress-fg: rgb(155, 155, 155);
--progress-border: rgb(105, 105, 105);
--progress-active-bg: rgb(55, 55, 55);
--progress-active-fg: rgb(255, 255, 255);
--progress-active-border: rgb(155, 155, 155);
/* initial player progress shape */
--progress-radius: 4px;
--progress-thumb-width: 2px;
--progress-height: 40px;
}
</style>
</svelte:head>
<style lang="postcss">
input[type='range'] {
--thumb-border-offset: calc(var(--thumb-border-size) * 2);
--thumb-shape-height: calc(
var(--progress-height) - var(--thumb-border-offset)
);
--track-bg: var(--progress-bg);
--track-border-color: var(--progress-border);
--track-border-size: 1px;
--track-radius: var(--progress-radius);
--thumb--bg: var(--progress-fg);
--thumb-border-color: var(--progress-border);
--thumb-border-size: 0px;
--thumb--radius: var(--progress-radius);
--thumb-width: var(--progress-thumb-width);
font-size: 1em;
--borders: calc(var(--thumb-border-size) * 2);
/* Zone tactile généreuse : tout l'input est cliquable/touchable
(≥ 44 px) alors que la piste visible reste fine
(--progress-height). Le navigateur centre la piste
verticalement dans l'input. */
height: max(44px, calc(var(--thumb-shape-height) + var(--borders)));
appearance: none;
margin: 0;
width: 100%;
background: transparent;
outline: 1px solid transparent;
&:hover {
--track-bg: var(--progress-active-bg);
--track-border-color: var(--progress-active-border);
--thumb--bg: var(--progress-active-fg);
--thumb-border-color: var(--progress-active-border);
}
&:focus {
outline-color: var(--progress-active-fg);
--track-border-color: var(--progress-active-fg);
--thumb--bg: var(--progress-active-fg);
--thumb-border-color: var(--progress-active-fg);
&::-webkit-slider-runnable-track,
&::-ms-fill-lower,
&::-ms-fill-upper {
background: var(--track-bg);
}
}
}
/* webkit */
input[type='range']::-webkit-slider-runnable-track {
width: 100%;
height: var(--progress-height);
cursor: pointer;
animate: 0.2s;
box-shadow: none;
background: var(--track-bg);
border-radius: var(--track-radius);
border: var(--track-border-size) solid var(--track-border-color);
}
input[type='range']::-webkit-slider-thumb {
--offset: calc(var(--track-border-size) * -1);
box-shadow: none;
border: var(--thumb-border-size) solid var(--thumb-border-color);
height: var(--thumb-shape-height);
width: var(--thumb-width);
border-radius: var(--thumb-radius);
background: var(--thumb--bg);
cursor: pointer;
-webkit-appearance: none;
margin-top: var(--offset);
}
/* mozilla */
input[type='range']::-moz-range-track {
width: 100%;
height: var(--progress-height);
cursor: pointer;
animate: 0.2s;
box-shadow: none;
background: var(--track-bg);
border-radius: var(--track-radius);
border: var(--track-border-size) solid var(--track-border-color);
}
input[type='range']::-moz-range-thumb {
box-shadow: none;
border: var(--thumb-border-size) solid var(--thumb-border-color);
height: var(--thumb-shape-height);
width: var(--thumb-width);
border-radius: var(--thumb-radius);
background: var(--thumb--bg);
cursor: pointer;
}
/* internet explorer */
input[type='range']::-ms-track {
width: 100%;
height: var(--progress-height);
cursor: pointer;
animate: 0.2s;
background: transparent;
border-color: transparent;
color: transparent;
}
input[type='range']::-ms-fill-lower,
input[type='range']::-ms-fill-upper {
background: var(--track-bg);
border: var(--track-border-size) solid var(--track-border-color);
border-radius: calc(var(--track-radius) * 2);
box-shadow: none;
}
input[type='range']::-ms-thumb {
margin-top: 1px;
box-shadow: none;
border: var(--thumb-border-size) solid var(--thumb-border-color);
height: var(--thumb-shape-height);
width: var(--thumb-width);
border-radius: var(--thumb-radius);
background: var(--thumb--bg);
cursor: pointer;
}
</style>
+27
View File
@@ -0,0 +1,27 @@
<script>
import { SITE_NAME, SITE_URL } from '$lib/site.js';
export let title;
export let description;
export let path = '/';
$: full_title = title ? `${title}${SITE_NAME}` : SITE_NAME;
$: url = `${SITE_URL}${path}`;
</script>
<svelte:head>
<title>{full_title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={url} />
<meta property="og:type" content="website" />
<meta property="og:url" content={url} />
<meta property="og:title" content={full_title} />
<meta property="og:description" content={description} />
<meta property="og:locale" content="fr_FR" />
<meta property="og:site_name" content={SITE_NAME} />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={full_title} />
<meta name="twitter:description" content={description} />
</svelte:head>
+43
View File
@@ -0,0 +1,43 @@
import { DEV } from 'esm-env';
const use_logger = Object.freeze({
info: console.info,
error: console.error,
warn: console.warn,
});
const base_styles = `padding: 4px 8px; border-radius:4px;`;
const logger_styles = {
error: `color: #FFFFFF; background-color: #FF0055; ${base_styles}`,
warn: `color: #662200; background-color: #FFBB33; ${base_styles}`,
info: `color: #80EAFF; background-color: #132686; ${base_styles}`,
} satisfies { [K in keyof typeof use_logger]: string };
function log(type: keyof typeof use_logger, ...content: unknown[]) {
// If type is info and not in dev mode, return early
if (type === 'info' && !DEV) return;
const logger = use_logger[type];
const styles = logger_styles[type];
// Log message
logger('%c🔊 svelte-podcast:', styles, ...content);
}
export const announce = {
/**
* Log an info message.
* @param {...unknown} content - Content to log.
*/
info: (...content: unknown[]) => log('info', ...content),
/**
* Log a warning message.
* @param {...unknown} content - Content to log.
*/
warn: (...content: unknown[]) => log('warn', ...content),
/**
* Log an error message.
* @param {...unknown} content - Content to log.
*/
error: (...content: unknown[]) => log('error', ...content),
} as const;
+95
View File
@@ -0,0 +1,95 @@
import { BROWSER } from 'esm-env';
import { derived, get, type Readable } from 'svelte/store';
import { user_preferences } from '../user-preferences';
import { can_set_volume } from '../volume-support';
import { announce } from './announce';
import { audio_state } from './audio-state';
const ELEMENT_ID = 'svpod--generated-audio-element';
/** A derived Svelte store containing an HTMLAudioElement or undefined. */
export const audio_element: Readable<HTMLAudioElement | null> = derived(
[audio_state],
([$audio_state], set) => {
// If not in a browser environment, return early.
if (!BROWSER) {
set(null);
return () => null;
}
// Find an existing HTMLAudioElement or create a new one.
const found = document.getElementById(ELEMENT_ID);
const el =
found instanceof HTMLAudioElement
? found
: document.createElement('audio');
// Set the HTMLAudioElement's attributes.
el.id = ELEMENT_ID;
el.setAttribute('preload', 'metadata');
el.autoplay = false;
el.controls = false;
if ($audio_state) {
el.src = $audio_state.src;
el.currentTime = $audio_state.start_at;
}
// Keep the element in sync with the persisted user preferences,
// both when a source is (re)loaded and on every later change.
// `??` is used so a persisted volume of 0 is respected.
// Note: `volume` is read-only on iOS Safari, so it is only
// written where supported — `muted` works everywhere.
const unsubscribe_preferences = user_preferences.subscribe((prefs) => {
if (can_set_volume()) el.volume = prefs.volume ?? 1;
el.playbackRate = prefs.playback_rate ?? 1;
el.muted = prefs.muted ?? false;
});
// If a new HTMLAudioElement was created, append it to the body.
if (!found) document.body.appendChild(el);
// Define a function to set the HTMLAudioElement and call it.
const handle_update = () => {
set(el);
};
handle_update();
announce.info('Setting audio element');
// Add event listeners to the HTMLAudioElement to update the store when it changes.
el.addEventListener('loadeddata', handle_update);
el.addEventListener('pause', handle_update);
el.addEventListener('playing', handle_update);
el.addEventListener('timeupdate', handle_update);
// Return a function to clean up the HTMLAudioElement when the store unsubscribes.
return () => {
// Stop syncing the user preferences.
unsubscribe_preferences();
// Remove the event listeners from the HTMLAudioElement.
el.removeEventListener('loadeddata', handle_update);
el.removeEventListener('pause', handle_update);
el.removeEventListener('playing', handle_update);
el.removeEventListener('timeupdate', handle_update);
// Remove the HTMLAudioElement from the DOM.
el ? el.remove() : null;
};
},
null as HTMLAudioElement | null,
);
/**
* Use audio element
* @param action - Action being performed
*/
export const use_audio_element = (action: string): HTMLAudioElement => {
const el = get(audio_element);
if (!el) {
throw announce.error(`could not ${action} :: no audio element found`);
}
return el;
};
+5
View File
@@ -0,0 +1,5 @@
import { writable, type Writable } from 'svelte/store';
import type { AudioMetadata } from '../metadata';
/** A writable Svelte store containing the audio metadata. */
export const audio_metadata: Writable<AudioMetadata | null> = writable(null);
+14
View File
@@ -0,0 +1,14 @@
import { writable, type Writable } from 'svelte/store';
/**
* An object representing the state of an audio player.
*/
interface AudioState {
src: string;
start_at: number;
playback_rate?: number;
volume?: number;
}
/** A writable Svelte store containing the audio state. */
export const audio_state: Writable<AudioState | null> = writable(null);
+8
View File
@@ -0,0 +1,8 @@
/**
* Utility functions module.
* @module Core
*/
export * from './audio-element';
export * from './audio-metadata';
export * from './audio-state';
+33
View File
@@ -0,0 +1,33 @@
/**
* An object representing a relative URL.
*/
interface RelativeURL {
pathname: string;
search: string;
hash: string;
searchParams: URLSearchParams;
}
/**
* Converts a string to a URL object. If the string is not a valid URL, it will be appended to the Svelte website URL.
* @param href - The string to convert to a URL object.
* @returns A URL object.
*/
function string_to_url(href: string): URL {
try {
return new URL(href);
} catch (e) {
return new URL(href, 'https://svelte.dev');
}
}
/**
* Parses a URL string and returns an object containing its pathname, search, hash, and searchParams.
* @param href - The URL string to parse.
* @returns An object containing the pathname, search, hash, and searchParams of the URL.
*/
export function use_url(href: string): RelativeURL {
const { pathname, search, hash, searchParams } = string_to_url(href);
return { pathname, search, hash, searchParams };
}
+140
View File
@@ -0,0 +1,140 @@
import { BROWSER } from 'esm-env';
import clamp from 'just-clamp';
import { get } from 'svelte/store';
import { use_audio_element } from './_internal_/audio-element';
import { audio_metadata } from './_internal_/audio-metadata';
import { audio_state } from './_internal_/audio-state';
import { audio_attributes } from './attributes';
import type { AudioMetadata } from './metadata';
import { user_preferences } from './user-preferences';
import { user_progress } from './user-progress';
/**
* Load audio
* @param src - Audio source
* @param metadata - Audio metadata
*/
const load_src = (src: string, metadata: AudioMetadata) => {
if (!BROWSER) return;
// Save the current progress if audio is already loaded
user_progress.save();
// Get user preferences
const preferences = get(user_preferences);
// Set audio element source
audio_state.set({
src,
start_at: user_progress.get(src) || 0,
playback_rate: preferences.playback_rate,
volume: preferences.volume,
});
// Set metadata for current audio
audio_metadata.set(metadata);
};
/**
* Unload audio
*/
const unload_src = () => {
if (!BROWSER) return;
// Save the current progress if audio is already loaded
user_progress.save();
// Unset audio element source
audio_state.set(null);
// Unset metadata
audio_metadata.set(null);
};
/**
* Play / Pause audio
* @param playing - Set or toggle the play state
*/
const play = (playing: boolean | 'toggle' = 'toggle') => {
const el = use_audio_element('play');
// 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 ? safe_play() : el.pause();
} else {
el.paused ? safe_play() : el.pause();
}
};
/**
* Mute / Unmute audio
*
* The persisted `user_preferences.muted` flag is the single source of
* truth: the element is updated here and re-synced from the store
* whenever the audio element is (re)derived.
* @param muted - Set or toggle the mute state
*/
const mute = (muted: boolean | 'toggle' = 'toggle') => {
const el = use_audio_element('mute');
const next_muted = typeof muted === 'boolean' ? muted : !el.muted;
el.muted = next_muted;
user_preferences.set_muted(next_muted);
};
/**
* Seeks to a specific time in the audio.
* @param seconds Timestamp in seconds
* @param from Seek from-start or from-end
*/
const seek_to = (
seconds: number,
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);
} else {
el.currentTime = clamp(0, el.duration, seconds);
}
};
/**
* Skips the audio forward or backward by a specified number of seconds.
* @param seconds - Time to skip in seconds
* @param type [type='forward'] - Skip forward or backward
*/
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);
} else {
el.currentTime = clamp(0, el.duration, el.currentTime + seconds);
}
};
/**
* Audio controls
*/
export const audio = {
subscribe: audio_attributes.subscribe,
src: {
load: load_src,
unload: unload_src,
},
play,
mute,
seek_to,
skip_by,
};
+49
View File
@@ -0,0 +1,49 @@
import { derived, type Readable } from 'svelte/store';
import { audio_element } from './_internal_/audio-element';
import { audio_metadata } from './_internal_/audio-metadata';
import type { AudioMetadata } from './metadata';
import { format_seconds } from './utility/format-seconds';
/**
* An object representing the attributes of an audio element.
*/
export type AudioAttributes = {
current_time: number;
duration: number;
is_loaded: boolean;
is_paused: boolean;
metadata: AudioMetadata | null;
src: string | null;
timestamp_current: string;
timestamp_end: string;
};
/** A readable Svelte store containing the audio attributes. */
export const audio_attributes: Readable<AudioAttributes> = derived(
[audio_element, audio_metadata],
([$el, $meta], set) => {
if (!$el) {
return set({
src: null,
is_loaded: false,
is_paused: true,
current_time: 0,
duration: 0,
timestamp_current: format_seconds.to_timestamp(0),
timestamp_end: format_seconds.to_timestamp(0),
metadata: null,
} satisfies AudioAttributes);
}
set({
src: $el.src,
is_loaded: Boolean($el.src),
is_paused: $el.paused,
current_time: $el.currentTime,
duration: $el.duration,
timestamp_current: format_seconds.to_timestamp($el.currentTime),
timestamp_end: format_seconds.to_timestamp($el.duration),
metadata: $meta,
} satisfies AudioAttributes);
},
);
-165
View File
@@ -1,165 +0,0 @@
import { BROWSER } from 'esm-env';
import clamp from 'just-clamp';
import { get } from 'svelte/store';
import { user_preferences, user_progress } from '../user';
import { audio_attributes, use_audio_element } from './audio-element';
import { audio_element_source } from './audio-element-source';
import { audio_metadata } from './audio-metadata';
/**
* Load audio
* @param {string} src - Audio source
* @param {import('./audio-metadata').AudioMetadata } metadata - Audio metadata
* @returns {void}
*/
const load = (src, metadata = {}, autoplay = false) => {
if (!BROWSER) return;
// Save the current progress if audio is already loaded
user_progress.save();
// Get user preferences
const preferences = get(user_preferences);
// Set audio element source
audio_element_source.set({
src,
start_at: user_progress.get(src) || 0,
playback_rate: preferences.playback_rate,
volume: preferences.volume,
autoplay: autoplay,
});
// Set metadata for current audio
audio_metadata.set(metadata);
};
/**
* Unload audio
* @returns {void}
*/
const unload = () => {
if (!BROWSER) return;
// Save the current progress if audio is already loaded
user_progress.save();
// Unset audio element source
audio_element_source.set(null);
// Unset metadata
audio_metadata.set(null);
};
/**
* @typedef {'toggle' | 'set'} HANDLE_TYPE
*/
/**
* Play audio
* @param {HANDLE_TYPE} t - Handle type
* @returns {void}
*/
const play = (t = 'set') => {
const el = use_audio_element('play');
if (t === 'toggle') {
el.paused ? el.play() : el.pause();
} else {
el.play();
}
};
/**
* Pause audio
* @param {HANDLE_TYPE} t - Handle type
* @returns {void}
*/
const pause = (t = 'set') => {
user_progress.save();
const el = use_audio_element('pause');
if (t === 'toggle') {
el.paused ? el.play() : el.pause();
} else {
el.pause();
}
};
/**
* Mute audio
* @param {HANDLE_TYPE} t - Handle type
* @returns {void}
*/
const mute = (t = 'set') => {
const el = use_audio_element('mute');
if (t === 'toggle') {
el.muted = !el.muted;
} else {
el.muted = true;
}
};
/**
* Unmute audio
* @param {HANDLE_TYPE} t - Handle type
* @returns {void}
*/
const unmute = (t = 'set') => {
const el = use_audio_element('unmute');
if (t === 'toggle') {
el.muted = !el.muted;
} else {
el.muted = false;
}
};
/**
* Seek to time audio
* @param {number} seconds - Seconds to seek
* @param {'from-start' | 'from-end'} [from="from-start"] - Seek from start or end
* @returns {void}
*/
const seek = (seconds, from = 'from-start') => {
const el = use_audio_element('seek');
if (from === 'from-end') {
el.currentTime = clamp(0, el.duration, el.duration - seconds);
} else {
el.currentTime = clamp(0, el.duration, seconds);
}
};
/**
* Skip by about audio
* @param {number} seconds - Seconds to skip
* @param {'forward' | 'backward'} [type='forward'] - Skip forward or backward
* @returns {void}
*/
const skip = (seconds, type = 'forward') => {
const el = use_audio_element('skip');
if (type === 'backward') {
el.currentTime = clamp(0, el.duration, el.currentTime - seconds);
} else {
el.currentTime = clamp(0, el.duration, el.currentTime + seconds);
}
};
/**
* Audio controls
*/
export const audio = {
subscribe: audio_attributes.subscribe,
load,
unload,
play,
pause,
mute,
unmute,
seek,
skip,
};
-15
View File
@@ -1,15 +0,0 @@
import { writable } from 'svelte/store';
// TODO: implement autoplay
/**
* @typedef {Object} AudioSource
* @property {string} src - A path or URL to the audio file
* @property {number} start_at - The starting point of the audio
* @property {number} [playback_rate=1] - The playback speed of the audio
* @property {number} [volume=1] - The volume of the audio
* @property {boolean} [autoplay=false] - Whether the audio should play as soon as it's loaded
*/
/** @type {import('svelte/store').Writable<AudioSource | null>} */
export const audio_element_source = writable(null);
-132
View File
@@ -1,132 +0,0 @@
import { BROWSER } from 'esm-env';
import { derived, get } from 'svelte/store';
import { announce } from '../internal';
import { seconds_to_timestamp } from '../utility';
import { audio_element_source } from './audio-element-source';
/**
* @typedef {import('svelte/store').Readable<HTMLAudioElement | undefined>} ReadableAudioElement
*/
/**
* @typedef {import('../user').UserPreferences} Preferences
*/
/**
* @typedef {(value: HTMLAudioElement | undefined) => void} SetAudioElement
*/
const ELEMENT_ID = 'svpod--generated-audio-element';
/** @type {ReadableAudioElement} */
export const audio_element = derived(
[audio_element_source],
([$audio_element_src], /** @type {SetAudioElement} */ set) => {
// If not in a browser environment, return early.
if (BROWSER) {
// Find an existing HTMLAudioElement or create a new one.
const found = document.getElementById(ELEMENT_ID);
const el =
found instanceof HTMLAudioElement
? found
: document.createElement('audio');
// Set the HTMLAudioElement's attributes.
el.id = ELEMENT_ID;
el.setAttribute('preload', 'metadata');
el.muted = false;
el.autoplay = false;
el.controls = false;
if ($audio_element_src) {
el.src = $audio_element_src.src;
el.currentTime = $audio_element_src.start_at;
el.playbackRate = $audio_element_src.playback_rate || 1;
el.volume = $audio_element_src.volume || 1;
el.autoplay = $audio_element_src.autoplay || false;
}
// If a new HTMLAudioElement was created, append it to the body.
if (!found) document.body.appendChild(el);
// Define a function to set the HTMLAudioElement and call it.
const handle_update = () => {
set(el);
$audio_element_src?.autoplay && el.play();
announce.info('Updating audio element');
};
handle_update();
// Add event listeners to the HTMLAudioElement to update the store when it changes.
el.addEventListener('loadeddata', handle_update);
el.addEventListener('pause', handle_update);
el.addEventListener('playing', handle_update);
el.addEventListener('timeupdate', handle_update);
// Return a function to clean up the HTMLAudioElement when the store unsubscribes.
return () => {
// Remove the event listeners from the HTMLAudioElement.
el.removeEventListener('loadeddata', handle_update);
el.removeEventListener('pause', handle_update);
el.removeEventListener('playing', handle_update);
el.removeEventListener('timeupdate', handle_update);
// Remove the HTMLAudioElement from the DOM.
el ? el.remove() : null;
};
} else {
set(undefined);
return () => null;
}
},
undefined,
);
/**
* Use audio element
* @param {string} action - Action being performed
* @returns {HTMLAudioElement} - Audio element
* @throws {string} - Error message if no audio element exists
*/
export const use_audio_element = (action) => {
const el = get(audio_element);
if (!el) {
throw announce.error(`could not ${action} :: no audio element found`);
}
return el;
};
/**
* @typedef {Object} AudioAttributes
* @property {boolean} is_loaded - Whether the audio element is loaded or not
* @property {boolean} is_paused - Whether the audio element is paused or not
* @property {number} current_time - The current time of the audio element in seconds
* @property {number} duration - The duration of the audio element in seconds
* @property {string} timestamp_current - The current time of the audio element in timestamp format (hh:mm:ss)
* @property {string} timestamp_end - The end time of the audio element in timestamp format (hh:mm:ss)
*/
/** @type {import('svelte/store').Readable<AudioAttributes>} */
export const audio_attributes = derived(audio_element, ($el, set) => {
if (!$el) {
return set({
is_loaded: false,
is_paused: true,
current_time: 0,
duration: 0,
timestamp_current: seconds_to_timestamp(0),
timestamp_end: seconds_to_timestamp(0),
});
}
set({
is_loaded: Boolean($el.src),
is_paused: $el.paused,
current_time: $el.currentTime,
duration: $el.duration,
timestamp_current: seconds_to_timestamp($el.currentTime),
timestamp_end: seconds_to_timestamp($el.duration),
});
});
-12
View File
@@ -1,12 +0,0 @@
import { writable } from 'svelte/store';
/**
* @typedef {Object.<string, unknown>} AudioMetadata
*/
/**
* Audio metadata store.
* @type {import('svelte/store').Writable<AudioMetadata | null>}
* @description This store holds the metadata for a single audio source.
*/
export const audio_metadata = writable(null);
-11
View File
@@ -1,11 +0,0 @@
/**
* Audio context module.
* @module audio
*/
export * from './actions';
export * from './audio-element';
export * from './audio-element-source';
export * from './audio-metadata';
export { default as AudioPlayer } from './player.svelte';
export { default as AudioProgress } from './progress.svelte';
-61
View File
@@ -1,61 +0,0 @@
<script>
import { AudioProgress, audio_attributes } from '.';
import { audio } from '../audio';
import { user_preferences } from '../user';
/** @type {string|undefined} */
export let src;
/** @type {import('./audio-metadata').AudioMetadata} */
export let metadata = {};
$: src && audio.load(src, metadata || {});
/**
* Skips the audio forward by a specified number of seconds.
* @param {number} seconds - The number of seconds to skip forward.
*/
const skip_forward = (seconds) => audio.skip(seconds, 'forward');
/**
* Skips the audio backward by a specified number of seconds.
* @param {number} seconds - The number of seconds to skip backward.
*/
const skip_back = (seconds) => audio.skip(seconds, 'backward');
/**
* Seeks to a specific time in the audio.
* @param {number} value - The time to seek to, in seconds.
*/
const seek_to = (value) => audio.seek(value);
/**
* Toggles the audio playback between play and pause.
*/
const toggle = () => audio.play('toggle');
/**
* Starts playing the audio.
*/
const play = () => audio.play('set');
/**
* Pauses the audio.
*/
const pause = () => audio.pause('set');
</script>
<slot
Player={{ AudioProgress }}
action={{
set_playback_rate: user_preferences.set_playback_rate,
set_volume: user_preferences.set_volume,
skip_forward,
skip_back,
seek_to,
toggle,
play,
pause,
}}
attributes={$audio_attributes}
/>
-70
View File
@@ -1,70 +0,0 @@
<script>
import clamp from 'just-clamp';
import { announce } from '../internal';
import { audio_attributes, audio_element } from './audio-element';
export let step = 10;
let was_paused = true;
/**
* @param {string} t - The type of event that triggered the drag end.
*/
function handle_drag_start(t) {
announce.info('drag_start :: ', t);
was_paused = $audio_attributes.is_paused;
$audio_element?.pause();
}
/**
* @param {string} t - The type of event that triggered the drag end.
*/
function handle_drag_end(t) {
announce.info('drag_end :: ', t);
if (was_paused) return;
else $audio_element?.play();
}
/**
* Seek to time audio
* @param {number} seconds - Seconds to seek
* @returns {void}
*/
const handle_seek_to = (seconds) => {
const el = $audio_element;
if (!el) return;
el.currentTime = clamp(0, el.duration, seconds);
};
</script>
{#if $audio_attributes.is_loaded}
<input
class={$$restProps.class}
style="display:block; width:100%;"
type="range"
data-paused={$audio_attributes.is_paused ? 'true' : 'false'}
min={0}
{step}
max={$audio_attributes.duration}
value={$audio_attributes.current_time}
on:change={(e) => handle_seek_to(e.currentTarget.valueAsNumber)}
on:touchstart={() => handle_drag_start('touchstart')}
on:mousedown={() => handle_drag_start('mousedown')}
on:touchend={() => handle_drag_end('touchend')}
on:mouseup={() => handle_drag_end('mouseup')}
/>
{:else}
<input
class={$$restProps.class}
style="display:block; width:100%;"
type="range"
data-paused={true}
min={0}
step={1}
max={1}
value={0}
disabled
readonly
/>
{/if}
-7
View File
@@ -1,7 +0,0 @@
import { default as AudioPlayer } from './audio/player.svelte';
export * from './audio/actions';
export * from './user';
export { AudioPlayer };
export default AudioPlayer;
+12
View File
@@ -0,0 +1,12 @@
export { default as AudioPlayer, default as default } from './Player.svelte';
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';
export * from './volume-support';
// export type { AudioMetadata } from './audio-metadata';
-67
View File
@@ -1,67 +0,0 @@
import { DEV } from 'esm-env';
/**
* Object containing logger functions.
* @typedef {Object} Announce
* @property {function} info - Log info like events and data.
* @property {function} warn - Log warnings for developers.
* @property {function} error - Log errors for critical failures.
*/
/**
* Type of logger.
* @typedef {('info' | 'error' | 'warn')} Logger
*/
/**
* Object containing Logger functions.
*
* @type {Readonly<{[K in Logger]: Function}>}
*/
const use_logger = Object.freeze({
info: console.info,
error: console.error,
warn: console.warn,
});
/**
* Log function.
* @param {Logger} type - Type of logger.
* @param {...unknown} content - Content to log.
*/
function log(type, ...content) {
const logger = use_logger[type];
// If type is info and not in dev mode, return early
if (type === 'info' && !DEV) return;
// Log message
logger(
'%c🔊 svelte-podcast:',
'color: #FF3E00; background-color: rgba(255, 62, 0, 0.15); padding: 4px 8px; border-radius:4px;',
...content,
);
}
/**
* Object containing logger functions.
* @type {Announce}
* @exports announce
*/
export const announce = {
/**
* Log an info message.
* @param {...unknown} content - Content to log.
*/
info: (...content) => log('info', ...content),
/**
* Log a warning message.
* @param {...unknown} content - Content to log.
*/
warn: (...content) => log('warn', ...content),
/**
* Log an error message.
* @param {...unknown} content - Content to log.
*/
error: (...content) => log('error', ...content),
};
-7
View File
@@ -1,7 +0,0 @@
/**
* Utility functions module.
* @module internal
*/
export * from './announce';
export * from './use-url';
-31
View File
@@ -1,31 +0,0 @@
/**
* @typedef {Object} RelativeURL
* @property {string} pathname - The pathname of the URL.
* @property {string} search - The search string of the URL.
* @property {string} hash - The hash string of the URL.
* @property {URLSearchParams} searchParams - The search parameters of the URL.
*/
/**
* Converts a string to a URL object. If the string is not a valid URL, it will be appended to the Svelte website URL.
* @param {string} href - The string to convert to a URL object.
* @returns {URL} A URL object.
*/
const string_to_url = (href) => {
try {
return new URL(href);
} catch (e) {
return new URL(href, 'https://svelte.dev');
}
};
/**
* Parses a URL string and returns an object containing its pathname, search, hash, and searchParams.
* @param {string} href - The URL string to parse.
* @returns {RelativeURL} An object containing the pathname, search, hash, and searchParams of the URL.
*/
export const use_url = (href) => {
const { pathname, search, hash, searchParams } = string_to_url(href);
return { pathname, search, hash, searchParams };
};
+6
View File
@@ -0,0 +1,6 @@
/**
* An object representing the metadata for an audio source.
*/
export interface AudioMetadata {
[key: string]: unknown;
}
+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());
}
+3
View File
@@ -0,0 +1,3 @@
// URL publique du déploiement — à ajuster si le domaine change.
export const SITE_URL = 'https://radyobokaz.o-k-i.net';
export const SITE_NAME = 'Radyobòkaz Player';
+71
View File
@@ -0,0 +1,71 @@
import clamp from 'just-clamp';
import { persisted } from 'svelte-local-storage-store';
export interface UserPreferences {
playback_rate: number;
volume: number;
muted: boolean;
}
/**
* The default user preferences
*/
const DEFAULT_USER_PREFERENCES: UserPreferences = {
playback_rate: 1,
volume: 1,
muted: false,
};
/**
* The user preferences store
*/
export const USER_PREFERENCES_STORE = persisted<UserPreferences>(
'USER_PREFERENCE',
DEFAULT_USER_PREFERENCES,
);
/**
* Sets the playback rate for the user
* @param value - The playback rate value
*/
const set_playback_rate = (value: number) => {
const playback_rate = clamp(value, 0.5, 5);
return USER_PREFERENCES_STORE.update((prefs) => ({
...prefs,
playback_rate,
}));
};
/**
* Sets the volume for the user
* @param value - The volume value
*/
const set_volume = (value: number) => {
const volume = clamp(value, 0, 1);
return USER_PREFERENCES_STORE.update((prefs) => ({ ...prefs, volume }));
};
/**
* Sets the muted state for the user. This is the single source of truth
* for mute: the audio element is kept in sync with it.
* @param value - The muted value
*/
const set_muted = (value: boolean) => {
return USER_PREFERENCES_STORE.update((prefs) => ({ ...prefs, muted: value }));
};
/**
* Clears user preferences store
*/
const clear_store = () => USER_PREFERENCES_STORE.set(DEFAULT_USER_PREFERENCES);
/**
* The user preferences object
*/
export const user_preferences = {
subscribe: USER_PREFERENCES_STORE.subscribe,
set_playback_rate,
set_volume,
set_muted,
clear: clear_store,
};
+83
View File
@@ -0,0 +1,83 @@
import { persisted } from 'svelte-local-storage-store';
import { get } from 'svelte/store';
import { announce } from './_internal_/announce';
import { audio_element } from './_internal_/audio-element';
import { use_url } from './_internal_/safe-use-url';
export interface UserProgress {
[key: string]: number;
}
const DEFAULT_USER_PROGRESS: UserProgress = {};
/**
* Maximum number of entries kept in the progress store. Beyond this
* limit, the least recently written entries are evicted so the
* localStorage footprint stays bounded.
*/
const MAX_PROGRESS_ENTRIES = 200;
/**
* User progress store
*/
const USER_PROGRESS_STORE = persisted<UserProgress>(
'USER_PROGRESS',
DEFAULT_USER_PROGRESS,
);
/**
* Saves user progress
*/
const save_user_progress = () => {
const audio = get(audio_element);
if (!audio?.src) return;
announce.info('saving progress: ', audio.src);
const pathname = use_url(audio.src).pathname;
const current_time = audio.currentTime;
USER_PROGRESS_STORE.update((prev) => {
// Re-insert the key at the end to refresh its LRU order
// (string keys keep insertion order in JS objects).
const next: UserProgress = { ...prev };
delete next[pathname];
next[pathname] = current_time;
// Evict the oldest entries beyond the cap.
const keys = Object.keys(next);
if (keys.length > MAX_PROGRESS_ENTRIES) {
for (const key of keys.slice(0, keys.length - MAX_PROGRESS_ENTRIES)) {
delete next[key];
}
}
return next;
});
};
/**
* Gets user progress for a given audio source
* @param src - Audio source
*/
const get_user_progress = (src: string) => {
const pathname = use_url(src).pathname;
const store: UserProgress = get(USER_PROGRESS_STORE);
const value = store[pathname];
return value;
};
/**
* Clears user progress store
*/
const clear_user_progress = () =>
USER_PROGRESS_STORE.set(DEFAULT_USER_PROGRESS);
export const user_progress = {
subscribe: USER_PROGRESS_STORE.subscribe,
get: get_user_progress,
clear: clear_user_progress,
save: save_user_progress,
};
-7
View File
@@ -1,7 +0,0 @@
/**
* User storage module.
* @module user
*/
export * from './user-preferences';
export * from './user-progress';
-88
View File
@@ -1,88 +0,0 @@
import clamp from 'just-clamp';
import { persisted } from 'svelte-local-storage-store';
/**
* @typedef {Object} UserPreferences
* @property {number} playback_rate - The audio player playback rate (speed)
* @property {number} volume - The audio player volume
*/
/**
* The default user preferences
* @type {UserPreferences}
*/
const DEFAULT_USER_PREFERENCES = { playback_rate: 1, volume: 1 };
/**
* The user preferences store
* @type {import('svelte/store').Writable<UserPreferences>}
*/
const USER_PREFERENCES_STORE = persisted(
'USER_PREFERENCE',
DEFAULT_USER_PREFERENCES,
);
/**
* Sets the playback rate for the user
* @param {number} value - The playback rate value
* @returns {void}
*/
const set_playback_rate = (value) => {
const playback_rate = clamp(value, 0.5, 5);
return USER_PREFERENCES_STORE.update((prefs) => ({
...prefs,
playback_rate,
}));
};
/**
* Sets the volume for the user
* @param {number} value - The volume value
* @returns {void}
*/
const set_volume = (value) => {
const volume = clamp(value, 0, 1);
return USER_PREFERENCES_STORE.update((prefs) => ({ ...prefs, volume }));
};
/**
* Clears user preferences store
*/
const clear_store = () => USER_PREFERENCES_STORE.set(DEFAULT_USER_PREFERENCES);
/**
* @typedef {Object} UserPreferencesObject
* @property {typeof USER_PREFERENCES_STORE.subscribe} subscribe - Subscribes to the user preferences store
* @property {typeof set_playback_rate} set_playback_rate - Sets the playback rate for the user
* @property {typeof set_volume} set_volume - Sets the volume for the user
* @property {typeof clear_store} clear - Clears saved user preferences
*/
/**
* The user preferences object
* @type {UserPreferencesObject}
*/
export const user_preferences = {
/**
* Subscribes to user preferences store
*/
subscribe: USER_PREFERENCES_STORE.subscribe,
/**
* Sets the playback rate for the user
* @type {typeof set_playback_rate}
*/
set_playback_rate,
/**
* Sets the volume for the user
* @type {typeof set_volume}
*/
set_volume,
/**
* Clears user preferences store
* @type {typeof clear_store}
*/
clear: clear_store,
};
-81
View File
@@ -1,81 +0,0 @@
import { persisted } from 'svelte-local-storage-store';
import { get } from 'svelte/store';
import { audio_element } from '../audio';
import { announce, use_url } from '../internal';
/**
* User progress object type.
* This object acts as a dictionary with string keys and number values.
* @typedef {Object.<string, number>} UserProgress
*/
/**
* Default user progress object
* @type {UserProgress}
*/
const DEFAULT_USER_PROGRESS = {};
/**
* User progress store
* @type {import('svelte/store').Writable<UserProgress>}
*/
const USER_PROGRESS_STORE = persisted('USER_PROGRESS', DEFAULT_USER_PROGRESS);
/**
* Saves user progress
* @function
* @returns {void}
*/
const save_user_progress = () => {
const audio = get(audio_element);
if (!audio?.src) return;
announce.info('saving progress: ', audio.src);
const pathname = use_url(audio.src).pathname;
const current_time = audio.currentTime;
USER_PROGRESS_STORE.update((prev) => ({
...prev,
[pathname]: current_time,
}));
};
/**
* Gets user progress for a given audio source
* @function
* @param {string} src - Audio source
* @returns {number|undefined} - User progress for the given audio source, or undefined if not found
*/
const get_user_progress = (src) => {
const pathname = use_url(src).pathname;
const store = get(USER_PROGRESS_STORE);
/**@type {number|undefined} */
const value = store[pathname];
return value;
};
/**
* Clears user progress store
* @function
* @returns {void}
*/
const clear_user_progress = () =>
USER_PROGRESS_STORE.set(DEFAULT_USER_PROGRESS);
/**
* User progress object
* @namespace
* @property {function} subscribe - Subscribes to user progress store
* @property {function} get - Gets user progress for a given audio source
* @property {function} clear - Clears user progress store
* @property {function} save - Saves user progress
*/
export const user_progress = {
subscribe: USER_PROGRESS_STORE.subscribe,
get: get_user_progress,
clear: clear_user_progress,
save: save_user_progress,
};
+36
View File
@@ -0,0 +1,36 @@
/**
* Returns a string timestamp in the format of "hh:mm:ss" from a given number of seconds.
* @param seconds - The number of seconds to convert to a timestamp.
* @param force_hours - Whether to always include hours in the timestamp, even if it's 0.
* @param seperator - The seperator to use between the hours, minutes, and seconds.
* @returns A string timestamp in the format of "hh:mm:ss".
*/
function seconds_to_timestamp(
seconds: number,
force_hours = false,
seperator = ':',
): string {
const safe_seconds = Number(seconds) || 0;
const hours = Math.floor(safe_seconds / 3600);
const hours_remainder = safe_seconds % 3600;
const minutes = Math.floor(hours_remainder / 60);
const minutes_remainder = hours_remainder % 60;
const secs = Math.floor(minutes_remainder);
const show_hours = force_hours || hours > 0;
const HH = hours.toString().padStart(2, '0');
const MM = minutes.toString().padStart(2, '0');
const SS = secs.toString().padStart(2, '0');
const values = show_hours ? [HH, MM, SS] : [MM, SS];
return values.join(seperator);
}
export const format_seconds = Object.freeze({
to_timestamp: seconds_to_timestamp,
});
-6
View File
@@ -1,6 +0,0 @@
/**
* Utility functions module.
* @module utility
*/
export * from './seconds-to-timestamp';
-31
View File
@@ -1,31 +0,0 @@
/**
* Returns a string timestamp in the format of "hh:mm:ss" from a given number of seconds.
* @param {number} seconds - The number of seconds to convert to a timestamp.
* @param {boolean} [force_hours=false] - Whether to always include hours in the timestamp, even if it's 0.
* @returns {string} A string timestamp in the format of "hh:mm:ss".
*/
export function seconds_to_timestamp(seconds, force_hours = false) {
const hours = Math.floor(seconds / 3600);
const hours_remainder = seconds % 3600;
const minutes = Math.floor(hours_remainder / 60);
const minutes_remainder = hours_remainder % 60;
const secs = Math.floor(minutes_remainder);
const hours_str = force_hours || hours > 0 ? `${section(hours)}:` : '';
const minutes_str = minutes ? `${section(minutes)}:` : '00:';
const seconds_str = secs ? `${section(secs)}` : '00';
return `${hours_str}${minutes_str}${seconds_str}`;
}
/**
* Returns a string representation of a number with a leading 0 if it's less than 10.
* @param {number} num - The number to format.
* @returns {string} A string representation of the number with a leading 0 if it's less than 10.
*/
function section(num) {
if (num < 10) return `0${num}`;
return num.toString();
}
+24
View File
@@ -0,0 +1,24 @@
import { BROWSER } from 'esm-env';
let cached: boolean | null = null;
/**
* Whether the current environment allows writing to
* `HTMLMediaElement.volume`. On iOS Safari the property is read-only
* (assignment is ignored or throws), so volume must be controlled
* through the system UI instead.
*/
export const can_set_volume = (): boolean => {
if (cached !== null) return cached;
if (!BROWSER) return false;
try {
const probe = document.createElement('audio');
probe.volume = 0.5;
cached = probe.volume === 0.5;
} catch {
cached = false;
}
return cached;
};
+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;
}
+190
View File
@@ -0,0 +1,190 @@
<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, can_set_volume, user_preferences } from '$lib/index';
import { onMount } from 'svelte';
import { radio_player } from './radio-store';
// The volume slider is hidden where HTMLMediaElement.volume is
// read-only (iOS Safari); the mute button keeps working there.
let volume_control_supported = false;
onMount(() => {
volume_control_supported = can_set_volume();
});
$: current = $radio_player.current;
const retry = () => {
if ($radio_player.current) {
void radio_player.next();
} else {
void radio_player.start_radio();
}
};
</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 flex-wrap items-center gap-2">
<button
type="button"
on:click={() => radio_player.previous()}
disabled={!current}
class="grid h-12 w-12 place-content-center rounded-full 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="grid h-12 w-12 place-content-center rounded-full bg-orange-500 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="grid h-12 w-12 place-content-center rounded-full 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')}
class="grid h-12 w-12 place-content-center rounded-full hover:bg-slate-700"
aria-label="Activer / couper le son"
>
{#if $user_preferences.muted || $user_preferences.volume === 0}
<SpeakerXMarkIcon class="h-5 w-5" />
{:else}
<SpeakerWaveIcon class="h-5 w-5" />
{/if}
</button>
{#if volume_control_supported}
<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-10 w-24 accent-orange-500"
aria-label="Volume"
/>
{/if}
</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>
<!-- Error -->
{#if $radio_player.error}
<div
class="mt-3 flex items-center justify-between gap-3 rounded-lg bg-red-900/40 px-3 py-2 text-sm text-red-200"
role="alert"
>
<p>{$radio_player.error}</p>
<button
type="button"
on:click={retry}
disabled={$radio_player.is_loading}
class="shrink-0 rounded-full bg-red-200/10 px-3 py-1 font-medium hover:bg-red-200/20 disabled:opacity-30"
>
Réessayer
</button>
</div>
{/if}
</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>
+243
View File
@@ -0,0 +1,243 @@
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;
/** Last fetch/playback error, shown in the UI. Null when all is well. */
error: string | null;
}
const initial_state: RadioPlayerState = {
mode: 'list',
queue: [],
index: -1,
current: null,
is_loading: false,
error: null,
};
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 {
const element = get(audio_element);
const reset_position_if_finished = () => {
if (
element &&
Number.isFinite(element.duration) &&
element.currentTime > element.duration - 5
) {
element.currentTime = 0;
}
};
// Attach the listener BEFORE assigning the new source: with a cached
// stream, `loadedmetadata` can fire before `audio.src.load` returns.
element?.addEventListener('loadedmetadata', reset_position_if_finished, {
once: true,
});
audio.src.load(track.stream_url, {
title: track.title,
artist: track.artist,
album: track.album,
artwork: track.cover,
});
// Media Session : rend la lecture contrôlable depuis l'écran de
// verrouillage, les écouteurs Bluetooth et le centre de contrôle.
if (BROWSER && 'mediaSession' in navigator) {
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artist,
album: track.album ?? '',
artwork: track.cover ? [{ src: track.cover }] : [],
});
}
// Metadata can already be available (instant/cached load): handle it
// now and drop the listener that will never fire for this source.
if (element && element.readyState >= 1) {
element.removeEventListener('loadedmetadata', reset_position_if_finished);
reset_position_if_finished();
}
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, error: null });
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, error: null });
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,
error: null,
});
load_and_play(first_track);
} catch (error) {
console.error(error);
state.set({
...get(state),
is_loading: false,
error: 'Impossible de charger la radio. Vérifie ta connexion et réessaie.',
});
}
},
/**
* 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,
error: null,
});
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, error: null });
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,
error: null,
});
load_and_play(first_track);
} catch (error) {
console.error(error);
state.set({
...get(state),
is_loading: false,
error: 'Impossible de charger la suite. Vérifie ta connexion et réessaie.',
});
}
}
},
/**
* 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);
});
// Media Session action handlers (lock screen, headset buttons…).
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => audio.play(true));
navigator.mediaSession.setActionHandler('pause', () => audio.play(false));
navigator.mediaSession.setActionHandler('previoustrack', () =>
radio_player.previous(),
);
navigator.mediaSession.setActionHandler('nexttrack', () =>
void radio_player.next(),
);
}
}
+148
View File
@@ -0,0 +1,148 @@
<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 class="track-row">
<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>
<style lang="postcss">
/* Rendu différé des lignes hors écran (longues listes mobiles). */
.track-row {
content-visibility: auto;
contain-intrinsic-size: auto 52px;
}
</style>
+30 -224
View File
@@ -1,232 +1,38 @@
<script> <script>
import Sidebar from '$src/layout/sidebar.svelte';
import GitHubIcon from '@inqling/svelte-icons/simple-icons/github.svelte';
import NPMIcon from '@inqling/svelte-icons/simple-icons/npm.svelte';
import github from 'svelte-highlight/styles/github';
import { circIn, circOut } from 'svelte/easing';
import { fade } from 'svelte/transition';
import '../app.postcss'; 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: 'API',
href: '/api',
sections: [
{ label: 'audio', anchor: 'audio' },
{ label: 'audio_data', anchor: 'audio_data' },
{ label: 'audio.subscribe', anchor: 'audio.subscribe' },
{ label: 'audio.load', anchor: 'audio.load' },
{ label: 'audio.unload', anchor: 'audio.unload' },
{ label: 'audio.play', anchor: 'audio.play' },
{ label: 'audio.pause', anchor: 'audio.pause' },
{ label: 'audio.mute', anchor: 'audio.mute' },
{ label: 'audio.unmute', anchor: 'audio.unmute' },
{ 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' },
],
},
{
label: 'Examples',
href: '/examples',
sections: [],
},
]);
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',
},
]);
let is_menu_open = false;
const open_menu = () => (is_menu_open = true);
const close_menu = () => (is_menu_open = false);
</script> </script>
<svelte:head> <main
{@html github} class="mx-auto min-h-full max-w-4xl px-4 pb-[calc(2rem+env(safe-area-inset-bottom))] pt-8"
</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: 300,
easing: circOut,
}}
out:fade={{
duration: 300,
easing: circIn,
}}
class="fixed inset-0 bg-mono-900/80"
/>
{/if}
<div class="fixed inset-0 flex">
{#if is_menu_open}
<div class="relative mr-16 flex w-full max-w-xs flex-1">
<div
in:fade
out:fade
class="absolute left-full top-0 flex w-16 justify-center pt-5"
>
<button
on:click={close_menu}
type="button"
class="pointer-events-auto -m-2.5 p-2.5"
>
<span class="sr-only">Close sidebar</span>
<svg
class="h-6 w-6 text-white"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
aria-hidden="true"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<Sidebar
class="pb-2"
pages={page_links}
resources={resource_links}
podcasts={podcast_links}
/>
</div>
{/if}
</div>
</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">
<slot /> <slot />
</div>
</main> </main>
<footer /> <footer class="mt-12">
<div class="flag-bar" aria-hidden="true"></div>
<p class="px-4 py-4 text-center text-sm text-slate-500">
Un service libre opéré par
<a
href="https://o-k-i.net"
target="_blank"
rel="noreferrer"
class="text-orange-600 underline decoration-orange-300 underline-offset-2 hover:text-orange-500"
>
ORGANISATION KA INTERNATIONALE · o-k-i.net
</a>
</p>
</footer>
<style>
/* Flag-bar OKI — 4 segments francs, 6 px (charte §1.5) */
.flag-bar {
height: 6px;
background: linear-gradient(
to right,
#0d0d0d 0 25%,
#fdb813 25% 50%,
#00d66c 50% 75%,
#ff1654 75% 100%
);
}
</style>
+41 -124
View File
@@ -1,137 +1,54 @@
<script> <script>
import { base } from '$app/paths'; import { base } from '$app/paths';
import { PlayerWidget } from '$src/components/example-player'; import RadioIcon from '@inqling/svelte-icons/heroicon-24-solid/radio.svelte';
import { episodes } from '$src/content/episodes'; import Seo from '$lib/Seo.svelte';
import { DocsPage } from '$src/layout/page'; import RadioPlayer from '$src/radio/radio-player.svelte';
import TrackList from '$src/radio/track-list.svelte';
/** @type { string | undefined} */ import { radio_player } from '$src/radio/radio-store';
let audio_src = episodes.knomii.src;
</script> </script>
<DocsPage title="Docs" let:Section> <Seo
<header title="Lecteur radio"
slot="header" description="Écoute la radio Radyobòkaz de mizik.o-k-i.net : lecture aléatoire continue ou à la carte."
class="relative isolate bg-white" path="/"
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)"
/> />
</svg>
<header class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div> <div>
<div class="relative py-24"> <h1 class="text-3xl font-bold text-slate-900">Radyobòkaz</h1>
<div class="mx-auto max-w-7xl px-6 lg:px-8"> <p class="text-slate-500">
<div class="mx-auto max-w-2xl text-center"> La radio de
<h1 id="hero"> <a
<span class="block text-xl font-medium text-primary-600"> href="https://mizik.o-k-i.net/library/radios/7"
svelte-podcast<span class="sr-only">: </span> target="_blank"
</span> rel="noreferrer"
<span class="text-orange-600 underline decoration-orange-300 underline-offset-2 hover:text-orange-500"
class="text-4xl font-bold tracking-tight text-mono-900 sm:text-6xl"
> >
The fastest way to build a podcast site with Svelte. mizik.o-k-i.net
</span> </a>
</h1> ·
<p class="mt-6 text-xl leading-8 text-mono-600"> <a
A suite of tools and components to build your own podcast href="{base}/podcast/"
players, and work with RSS podcast data in SvelteKit. class="text-orange-600 underline decoration-orange-300 underline-offset-2 hover:text-orange-500"
<span
class="mt-3 block text-base leading-none text-primary-800"
> >
<span Démo podcast RSS →
class="inline-block rounded-full bg-primary-50 px-3 py-1.5 text-xs font-medium uppercase tracking-wider text-primary-600" </a>
>
Coming Soon<span class="sr-only">:</span>
</span>
<span class="tracking-wide"
>SSR utilities for consuming RSS podcast feeds</span
>
</span>
</p> </p>
<div class="mt-10 flex items-center justify-center gap-x-3"> </div>
<a
href="{base}/setup" <button
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" 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"
> >
Get started <RadioIcon class="h-5 w-5" />
</a> {$radio_player.is_loading ? 'Chargement…' : 'Lecture radio aléatoire'}
<a </button>
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"
>
Examples <span aria-hidden="true"></span>
</a>
</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"
>
<PlayerWidget
src={audio_src}
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> </header>
<Section title="Introduction"> <div class="sticky top-[max(1rem,env(safe-area-inset-top))] z-10 mb-8">
<p> <RadioPlayer />
Svelte-Podcast streamlines the creation of custom audio players and </div>
simplifies state management in Svelte apps.
</p>
<p> <TrackList />
<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>
-311
View File
@@ -1,311 +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_pause,
audio_play,
audio_seek,
audio_skip,
audio_subscribe,
audio_unload,
audio_unmute,
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.load">
<p>
Load a new audio source. This will stop the current audio source and
replace it with the new one.
</p>
<div class="not-prose codeblock">
<Highlight code={audio_load} language={lang_ts} />
</div>
</SectionArticle>
<SectionArticle title="audio.unload">
<p>
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_unload} 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.pause">
<p>Set or toggle the pause state.</p>
<div class="not-prose codeblock">
<Highlight code={audio_pause} 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.unmute">
<p>Set or toggle the unmute state.</p>
<div class="not-prose codeblock">
<Highlight code={audio_unmute} 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>
-17
View File
@@ -1,17 +0,0 @@
import { audio } from 'svelte-podcast';
// load a new audio source
audio.load(
// path to audio file
'/episode-377.mp3',
// custom metadata
{
title: 'Supper Club × Rich Harris, Author of Svelte',
guests: ['Rich Harris'],
hosts: ['Wes Bos', 'Scott Tolinski'],
},
// autoplay
true,
);
-9
View File
@@ -1,9 +0,0 @@
import { audio } from 'svelte-podcast';
// mute the current audio source
// do nothing if it's already muted
audio.mute('set');
// mute the current audio if it's unmuted
// unmute the current audio if it's muted
audio.mute('toggle');
-9
View File
@@ -1,9 +0,0 @@
import { audio } from 'svelte-podcast';
// pause the current audio source
// do nothing if it's already paused
audio.pause('set');
// pause the current audio if it's playing
// play the current audio if it's paused
audio.pause('toggle');
-9
View File
@@ -1,9 +0,0 @@
import { audio } from 'svelte-podcast';
// play the current audio source
// do nothing if it's already playing
audio.play('set');
// play the current audio if it's paused
// pause the current audio if it's playing
audio.play('toggle');
-7
View File
@@ -1,7 +0,0 @@
import { audio } from 'svelte-podcast';
// go to 200 seconds from the start
audio.seek(200);
// go to 200 seconds before the end
audio.seek(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(30);
// skip backward 30 seconds from the current position
audio.skip(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>
-4
View File
@@ -1,4 +0,0 @@
import { audio } from 'svelte-podcast';
// unload the current audio source
audio.unload();
-9
View File
@@ -1,9 +0,0 @@
import { audio } from 'svelte-podcast';
// unmute the current audio source
// do nothing if it's already unmuted
audio.unmute('set');
// unmute the current audio if it's muted
// mute the current audio if it's unmuted
audio.unmute('toggle');
-48
View File
@@ -1,48 +0,0 @@
import { format_code } from '../../../layout/helper';
import { default as audio_load_src } from './audio-load.js?raw';
import { default as audio_mute_src } from './audio-mute.js?raw';
import { default as audio_pause_src } from './audio-pause.js?raw';
import { default as audio_play_src } from './audio-play.js?raw';
import { default as audio_seek_src } from './audio-seek.js?raw';
import { default as audio_skip_src } from './audio-skip.js?raw';
import { default as audio_subscribe_src } from './audio-subscribe.svelte?raw';
import { default as audio_unload_src } from './audio-unload.js?raw';
import { default as audio_unmute_src } from './audio-unmute.js?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_unload = format_code(audio_unload_src);
export const audio_pause = format_code(audio_pause_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 audio_unmute = format_code(audio_unmute_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,16 +0,0 @@
<script>
import { audio } from 'svelte-podcast';
</script>
<!-- load the episode on click -->
<button
on:click={() =>
audio.load('/episode-audio.mp3', {
/* optional metadata */
})}
>
Load Episode
</button>
<!-- unload the episode on click -->
<button on:click={() => audio.unload()}>Unload Episode</button>
@@ -1,11 +0,0 @@
<script>
import { onMount } from 'svelte';
import { audio } from 'svelte-podcast';
onMount(() => {
// load the episode on mount without any metadata
audio.load('/episode-audio.mp3', {
/* optional metadata */
});
});
</script>
@@ -1,8 +0,0 @@
import { seconds_to_timestamp } from 'svelte-podcast/utility';
// Example
seconds_to_timestamp(5173); // 01:26:13
// force hours
seconds_to_timestamp(2700, true); // 00:45:00
seconds_to_timestamp(2700, false); // 45:00 (default)
@@ -1,4 +0,0 @@
import { user_preferences } from '../../../lib/user';
// Clears all user preferences
user_preferences.clear();
@@ -1,4 +0,0 @@
import { user_preferences } from '../../../lib/user';
// Sets the playback speed to 150%
user_preferences.set_playback_rate(1.5);
@@ -1,4 +0,0 @@
import { user_preferences } from '../../../lib/user';
// 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>
-69
View File
@@ -1,69 +0,0 @@
<script>
import PlayerWidget from '$src/components/example-player/player-widget.svelte';
import { episodes } from '$src/content/episodes';
import { DocsPage } from '$src/layout/page';
import PreviewComponent from '$src/layout/preview-component.svelte';
$: player_widget = {
current_time: true,
playback_rate: true,
duration: true,
skip_back: 10,
skip_forward: 30,
};
/** @type { string | undefined} */
let audio_src = episodes.knomii.src;
</script>
<DocsPage title="Examples" let:Section>
<p class="text-xl font-semibold text-red-500">Work in progress!</p>
<Section title="PlayerWidget">
<PreviewComponent name="PlayerWidget">
<svelte:fragment slot="options">
<div>
<label for="pw_current_time">current_time</label>
<input
id="pw_current_time"
type="checkbox"
bind:checked={player_widget.current_time}
/>
</div>
<div>
<label for="pw_playback_rate">playback_rate</label>
<input
id="pw_playback_rate"
type="checkbox"
bind:checked={player_widget.playback_rate}
/>
</div>
<div>
<label for="pw_duration">duration</label>
<input
id="pw_duration"
type="checkbox"
bind:checked={player_widget.duration}
/>
</div>
<div>
<label for="pw_skip_back">skip_back</label>
<input
id="pw_skip_back"
type="number"
bind:value={player_widget.skip_back}
/>
</div>
<div>
<label for="pw_skip_forward">skip_forward</label>
<input
id="pw_skip_forward"
type="number"
bind:value={player_widget.skip_forward}
/>
</div>
</svelte:fragment>
<PlayerWidget src={audio_src} include={player_widget} />
</PreviewComponent>
</Section>
</DocsPage>

Some files were not shown because too many files have changed in this diff Show More