Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67c701f85a | |||
| 4320258468 | |||
| 72f4d2e160 | |||
| b1165a817e | |||
| f42a00f04c | |||
| 528457fe59 | |||
| bba24215eb | |||
| dde9bec49c | |||
| bac7bc605d | |||
| 63ef9beafd |
@@ -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)
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,5 +1,17 @@
|
||||
# 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
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
@@ -1,83 +1,73 @@
|
||||
<!--
|
||||
41.9 kB
|
||||
# Radyobòkaz Player
|
||||
|
||||
First make this easier to understand. Next convert comments to JSDOC comments, then add additional JSDOC comments where needed. JSDOC comments should not include types that conflict with existing Typescript types. Finally make sure variables are snake_case and not cammalCase, and that types are in PascalCase.
|
||||
Lecteur audio web pour la radio **Radyobòkaz**, hébergée sur l'instance Funkwhale
|
||||
[mizik.o-k-i.net](https://mizik.o-k-i.net/library/radios/7) (~2000 titres).
|
||||
|
||||
Convert typescrtipt types to JSDOC types, object types should be extracted into an explicit typedef with descriptions for each property. Add JSDOC comments to explain anything that is exported. Make sure variables are snake_case and not cammalCase.
|
||||
-->
|
||||
Construit sur une version modifiée de [svelte-podcast](https://github.com/OllieJT/svelte-podcast)
|
||||
(librairie headless de lecteur audio, voir `src/lib`).
|
||||
|
||||
# Svelte Podcast
|
||||
## Utilitaires RSS (SSR)
|
||||
|
||||
Svelte-Podcast streamlines the creation of custom audio players and simplifies state management in Svelte apps.
|
||||
La librairie inclut des utilitaires pour consommer des flux RSS de podcasts,
|
||||
compatibles SSR (pas de `DOMParser`, parsing via `fast-xml-parser`) :
|
||||
|
||||
| Version | License | Status |
|
||||
| :-------------------------------------------------------------------------------------------------: | :-------------------------------------------------------------------------------------------------: | :-----------------------------------------------------------------------------------------------------------------: |
|
||||
| [](https://www.npmjs.com/package/svelte-podcast) | [](https://www.npmjs.com/package/svelte-podcast) |  |
|
||||
```ts
|
||||
// src/routes/podcast/+page.ts
|
||||
import { fetch_podcast_feed } from '$lib/rss';
|
||||
import type { PageLoad } from './$types';
|
||||
|
||||
## What's inside?
|
||||
|
||||
### Build custom Audio Player UI
|
||||
|
||||
Simplify the creation of custom audio players with a set of headless components that keep out of your way and take care of core functionality.
|
||||
|
||||
### Easily manage Audio State
|
||||
|
||||
Loading, controlling, and keeping track of multiple audio sources is a pain. svelte-podcast abstracts this away and provides a simple interface to manage audio state.
|
||||
|
||||
### Track user preferences
|
||||
|
||||
Users expect a lot from media players. It should remember their preferences like playback speed, and it should remember where they were in an episode even after reloading the page. svelte-podcast takes care of this for you, and provides you with access to extend this with your own database.
|
||||
|
||||
### Features
|
||||
|
||||
- 🔊 Load and play audio files via URL or local files
|
||||
- 🔃 Navigate via client-side routing while audio continues to play
|
||||
- 🎛️ Simpler control over audio playback:
|
||||
- Seek to a specific time
|
||||
- skip forward ﹢ backward
|
||||
- play ﹢ pause
|
||||
- mute ﹢ unmute
|
||||
- 🛟 Save and load a users progress for each episode in localStorage
|
||||
- 💾 Save and load a users preferences (like playback speed) in localStorage
|
||||
- ﹢ volume
|
||||
- ﹢ playback speed
|
||||
- 🖼️ Inject episode metadata into the audio store for ea
|
||||
|
||||
**Roadmap**
|
||||
In no particular order, here are some of the things I'm confident will be added to this library:
|
||||
|
||||
- [ ] Podcast player component utilities
|
||||
- [ ] Pre-built player components
|
||||
- [ ] RSS Feed parsing
|
||||
- [ ] Looping segments of an episode
|
||||
|
||||
[And more ideas being discussed](https://github.com/OllieJT/svelte-podcast/labels/feature)
|
||||
|
||||
## Docs
|
||||
|
||||
> **Warning**
|
||||
> We're getting close to v1, but this project is stil pre v1.0.0. Braking changes are still possible.
|
||||
|
||||
> **Note**
|
||||
> Contributions are welcome but I do not plan to provide guides for contributing until the project is more stable.
|
||||
|
||||
#### Install
|
||||
|
||||
```bash
|
||||
# with npm
|
||||
npm install svelte-podcast@latest
|
||||
|
||||
# with yarn
|
||||
yarn add svelte-podcast@latest
|
||||
|
||||
# with pnpm
|
||||
pnpm add svelte-podcast@latest
|
||||
export const load: PageLoad = async ({ fetch }) => ({
|
||||
// passer le `fetch` de SvelteKit : SSR, cache et hydration
|
||||
feed: await fetch_podcast_feed('https://feed.syntax.fm/rss', fetch),
|
||||
});
|
||||
```
|
||||
|
||||
You can find docs and guides for getting started on [svelte-podcast.com](https://svelte-podcast.com/)
|
||||
- `fetch_podcast_feed(url, fetcher?)` — télécharge et parse un flux (RSS 2.0 + tags iTunes)
|
||||
- `parse_podcast_feed(xml)` — parse une chaîne XML déjà récupérée
|
||||
- Types exportés : `PodcastFeed`, `PodcastEpisode` (titre, guid, date ISO, durée en
|
||||
secondes, enclosure, pochette, numéros d'épisode/saison, explicit…)
|
||||
|
||||
| Resource | Link |
|
||||
| ----------------------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| Provides guidance on Installation, Audio Sources, and Type Safety | [Getting Started](https://svelte-podcast.com/getting-started) |
|
||||
| Covers API methods for audio, user preferences, and user progress | [API](https://svelte-podcast.com/api) |
|
||||
| Offers examples of Headless UI, Tailwind, and CSS players | [Examples](https://svelte-podcast.com/examples) |
|
||||
Les durées `itunes:duration` sont acceptées aux formats `3723`, `62:03` et `1:02:03`.
|
||||
Les épisodes sans enclosure audio sont ignorés. Voir la route `/podcast` pour un
|
||||
exemple complet (flux chargé côté serveur, épisodes joués dans le lecteur).
|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- 📻 **Mode radio aléatoire** : file de lecture infinie de titres piochés au hasard
|
||||
- 🎵 **Écoute à la carte** : liste complète des titres avec recherche (titre, artiste, album)
|
||||
- 🎙️ **Flux RSS de podcasts** : parsing SSR + lecture des épisodes (démo sur `/podcast`)
|
||||
- ⏭️ Passage automatique au titre suivant en fin de piste
|
||||
- 🎛️ Contrôles : lecture/pause, précédent/suivant, volume, mute, seek
|
||||
- 🖼️ Pochette, titre, artiste et album du morceau en cours
|
||||
- 🔃 L'audio continue de jouer lors de la navigation entre pages
|
||||
|
||||
## Développement
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
yarn dev # serveur de dev
|
||||
yarn build # build statique (adapter-static) dans build/
|
||||
yarn run check # svelte-check
|
||||
```
|
||||
|
||||
## Comment ça marche
|
||||
|
||||
L'API publique de Funkwhale est utilisée côté client, sans authentification :
|
||||
|
||||
- `GET /api/v1/radios/radios/7/tracks/?page=N&page_size=50` — liste des titres de la radio
|
||||
- `GET /api/v1/listen/<uuid>/` — flux audio du titre (CORS ouvert, supporte `Range`)
|
||||
|
||||
Structure du code :
|
||||
|
||||
| Fichier | Rôle |
|
||||
| ------------------------------- | ----------------------------------------------------------------- |
|
||||
| `src/lib/` | Librairie svelte-podcast (store audio, composants headless) |
|
||||
| `src/lib/rss.ts` | Utilitaires SSR pour flux RSS de podcasts (parse + fetch) |
|
||||
| `src/radio/funkwhale.ts` | Client de l'API Funkwhale |
|
||||
| `src/radio/playable.ts` | Type `PlayableTrack` (titre jouable, quelle que soit la source) |
|
||||
| `src/radio/radio-store.ts` | File de lecture : mode radio aléatoire / liste, suivant/précédent |
|
||||
| `src/radio/library-store.ts` | Chargement progressif de la liste complète des titres |
|
||||
| `src/radio/radio-player.svelte` | Barre de lecture (pochette, contrôles, progression) |
|
||||
| `src/radio/track-list.svelte` | Liste des titres avec recherche |
|
||||
| `src/routes/+page.svelte` | Page radio Radyobòkaz |
|
||||
| `src/routes/podcast/` | Démo des utilitaires RSS (flux chargé en SSR, épisodes jouables) |
|
||||
|
||||
+13
-42
@@ -1,21 +1,13 @@
|
||||
{
|
||||
"name": "svelte-podcast",
|
||||
"version": "0.8.0",
|
||||
"name": "oki-podcast-reader",
|
||||
"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",
|
||||
"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": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"package": "svelte-kit sync && svelte-package && publint",
|
||||
"sync": "svelte-kit sync",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
@@ -23,34 +15,20 @@
|
||||
"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"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"svelte": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"peerDependencies": {
|
||||
"@sveltejs/kit": ">=1.0.0",
|
||||
"svelte": ">=3.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inqling/svelte-icons": "^3.3.2",
|
||||
"clsx": "^1.2.1",
|
||||
"esm-env": "^1.0.0",
|
||||
"fast-xml-parser": "^5.10.1",
|
||||
"just-clamp": "^4.2.0",
|
||||
"svelte-local-storage-store": "^0.5.0"
|
||||
"svelte-local-storage-store": "^0.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/cli": "^2.26.2",
|
||||
"@sveltejs/adapter-static": "^2.0.2",
|
||||
"@sveltejs/kit": "^1.22.1",
|
||||
"@sveltejs/package": "^2.1.0",
|
||||
"@sveltejs/adapter-static": "^3",
|
||||
"@sveltejs/kit": "^2",
|
||||
"@sveltejs/vite-plugin-svelte": "^5",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@typescript-eslint/eslint-plugin": "^5.61.0",
|
||||
@@ -65,19 +43,12 @@
|
||||
"prettier": "^2.8.8",
|
||||
"prettier-plugin-svelte": "^2.10.1",
|
||||
"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",
|
||||
"svelte": "^5",
|
||||
"svelte-check": "^4",
|
||||
"tailwindcss": "^3.3.2",
|
||||
"tslib": "^2.6.0",
|
||||
"typescript": "^5.1.6",
|
||||
"vite": "^4.4.2"
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^6"
|
||||
},
|
||||
"svelte": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"type": "module"
|
||||
}
|
||||
|
||||
+9
-6
@@ -1,19 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full scroll-smooth bg-white hover:scroll-auto">
|
||||
<html lang="fr" class="h-full bg-slate-100">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||
/>
|
||||
|
||||
<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" />
|
||||
|
||||
<link rel="bookmark" title="svelte-podcast Docs" />
|
||||
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body class="h-full scroll-smooth hover:scroll-auto">
|
||||
<body class="h-full">
|
||||
<div>%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
-156
@@ -2,159 +2,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
article.section-content p {
|
||||
@apply my-2 text-lg leading-normal;
|
||||
}
|
||||
article.section-content li p {
|
||||
@apply my-0;
|
||||
}
|
||||
article.section-content mark {
|
||||
@apply bg-primary-100 text-primary-900;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.richtext h1 a,
|
||||
.richtext h2 a,
|
||||
.richtext h3 a,
|
||||
.richtext h4 a,
|
||||
.richtext h5 a,
|
||||
.richtext h6 a {
|
||||
@apply relative break-all underline decoration-transparent decoration-solid underline-offset-2;
|
||||
|
||||
&::before {
|
||||
@apply absolute -left-[1.25ch] inline-block font-light leading-normal text-mono-400;
|
||||
content: '#';
|
||||
}
|
||||
&:hover::before {
|
||||
@apply text-primary-500;
|
||||
}
|
||||
&:hover {
|
||||
@apply decoration-primary-500;
|
||||
}
|
||||
}
|
||||
|
||||
.richtext code.hljs {
|
||||
@apply overflow-x-auto p-0 text-sm;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.richtext .codeblock code.hljs {
|
||||
@apply overflow-auto rounded-xl border border-mono-200 p-4 font-mono text-base leading-normal shadow-2xl shadow-mono-200;
|
||||
}
|
||||
|
||||
.richtext {
|
||||
@apply prose prose-slate max-w-none sm:prose-lg prose-headings:px-2 prose-h5:mb-1 prose-h5:mt-10 prose-h5:text-sm prose-h5:font-medium prose-h5:leading-normal prose-h5:text-primary-800 prose-p:px-2 prose-code:before:content-none prose-code:after:content-none;
|
||||
}
|
||||
.richtext code:not(.hljs) {
|
||||
@apply inline whitespace-pre-wrap break-all rounded-md border border-mono-200 bg-white px-1.5 py-0.5 font-mono font-normal tracking-wide text-mono-950;
|
||||
}
|
||||
|
||||
.richtext td,
|
||||
.richtext th {
|
||||
@apply px-4 py-2;
|
||||
}
|
||||
|
||||
.richtext td {
|
||||
@apply min-w-max;
|
||||
}
|
||||
|
||||
/* HLJS */
|
||||
pre code.hljs {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
padding: 1em;
|
||||
}
|
||||
code.hljs {
|
||||
padding: 3px 5px;
|
||||
}
|
||||
/*
|
||||
Theme: GitHub
|
||||
Description: Light theme as seen on github.com
|
||||
Author: github.com
|
||||
Maintainer: @Hirse
|
||||
Updated: 2021-05-15
|
||||
|
||||
Outdated base version: https://github.com/primer/github-syntax-light
|
||||
Current colors taken from GitHub's CSS
|
||||
*/
|
||||
.hljs {
|
||||
color: #24292e;
|
||||
background: #fff;
|
||||
}
|
||||
.hljs-doctag,
|
||||
.hljs-keyword,
|
||||
.hljs-meta .hljs-keyword,
|
||||
.hljs-template-tag,
|
||||
.hljs-template-variable,
|
||||
.hljs-type,
|
||||
.hljs-variable.language_ {
|
||||
color: #d73a49;
|
||||
}
|
||||
.hljs-title,
|
||||
.hljs-title.class_,
|
||||
.hljs-title.class_.inherited__,
|
||||
.hljs-title.function_ {
|
||||
color: #6f42c1;
|
||||
}
|
||||
.hljs-attr,
|
||||
.hljs-attribute,
|
||||
.hljs-literal,
|
||||
.hljs-meta,
|
||||
.hljs-number,
|
||||
.hljs-operator,
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-class,
|
||||
.hljs-selector-id,
|
||||
.hljs-variable {
|
||||
color: #005cc5;
|
||||
}
|
||||
.hljs-meta .hljs-string,
|
||||
.hljs-regexp,
|
||||
.hljs-string {
|
||||
color: #032f62;
|
||||
}
|
||||
.hljs-built_in,
|
||||
.hljs-symbol {
|
||||
color: #e36209;
|
||||
}
|
||||
.hljs-code,
|
||||
.hljs-comment,
|
||||
.hljs-formula {
|
||||
color: #6a737d;
|
||||
}
|
||||
.hljs-name,
|
||||
.hljs-quote,
|
||||
.hljs-selector-pseudo,
|
||||
.hljs-selector-tag {
|
||||
color: #22863a;
|
||||
}
|
||||
.hljs-subst {
|
||||
color: #24292e;
|
||||
}
|
||||
.hljs-section {
|
||||
color: #005cc5;
|
||||
font-weight: 700;
|
||||
}
|
||||
.hljs-bullet {
|
||||
color: #735c0f;
|
||||
}
|
||||
.hljs-emphasis {
|
||||
color: #24292e;
|
||||
font-style: italic;
|
||||
}
|
||||
.hljs-strong {
|
||||
color: #24292e;
|
||||
font-weight: 700;
|
||||
}
|
||||
.hljs-addition {
|
||||
color: #22863a;
|
||||
background-color: #f0fff4;
|
||||
}
|
||||
.hljs-deletion {
|
||||
color: #b31d28;
|
||||
background-color: #ffeef0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
@@ -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} />
|
||||
@@ -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 +0,0 @@
|
||||
export { default as DocsPage } from './component.svelte';
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Utility functions module.
|
||||
* @module Core
|
||||
*/
|
||||
|
||||
export * from './audio-element';
|
||||
export * from './audio-metadata';
|
||||
export * from './audio-state';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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),
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { default as AudioPlayer } from './audio/player.svelte';
|
||||
|
||||
export * from './audio/actions';
|
||||
export * from './user';
|
||||
|
||||
export { AudioPlayer };
|
||||
export default AudioPlayer;
|
||||
@@ -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';
|
||||
@@ -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),
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* Utility functions module.
|
||||
* @module internal
|
||||
*/
|
||||
|
||||
export * from './announce';
|
||||
export * from './use-url';
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* An object representing the metadata for an audio source.
|
||||
*/
|
||||
export interface AudioMetadata {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
+219
@@ -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());
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,7 +0,0 @@
|
||||
/**
|
||||
* User storage module.
|
||||
* @module user
|
||||
*/
|
||||
|
||||
export * from './user-preferences';
|
||||
export * from './user-progress';
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
/**
|
||||
* Utility functions module.
|
||||
* @module utility
|
||||
*/
|
||||
|
||||
export * from './seconds-to-timestamp';
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Minimal client for the Funkwhale API of mizik.o-k-i.net,
|
||||
* restricted to the public radio "Radyobòkaz" (id 7).
|
||||
*/
|
||||
|
||||
import type { PlayableTrack } from './playable';
|
||||
|
||||
export const FUNKWHALE_BASE = 'https://mizik.o-k-i.net';
|
||||
export const RADIO_ID = 7;
|
||||
export const RADIO_PAGE_SIZE = 50; // max allowed by the API
|
||||
|
||||
/** A track from the radio, ready to be played. */
|
||||
export interface RadioTrack extends PlayableTrack {
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface ApiImage {
|
||||
urls: {
|
||||
original: string | null;
|
||||
medium_square_crop: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
interface ApiTrack {
|
||||
id: number;
|
||||
title: string;
|
||||
listen_url: string;
|
||||
artist: { name: string };
|
||||
album: { title: string; cover: ApiImage | null } | null;
|
||||
uploads: { duration: number | null }[];
|
||||
}
|
||||
|
||||
interface ApiPage {
|
||||
count: number;
|
||||
next: string | null;
|
||||
results: ApiTrack[];
|
||||
}
|
||||
|
||||
function to_radio_track(track: ApiTrack): RadioTrack {
|
||||
const cover = track.album?.cover?.urls;
|
||||
return {
|
||||
id: track.id,
|
||||
title: track.title,
|
||||
artist: track.artist.name,
|
||||
album: track.album?.title ?? null,
|
||||
cover: cover?.medium_square_crop ?? cover?.original ?? null,
|
||||
stream_url: `${FUNKWHALE_BASE}${track.listen_url}`,
|
||||
duration: track.uploads[0]?.duration ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches one page of the radio's track list.
|
||||
* @param page - 1-based page number
|
||||
*/
|
||||
export async function fetch_radio_page(
|
||||
page: number,
|
||||
): Promise<{ count: number; tracks: RadioTrack[] }> {
|
||||
const url = `${FUNKWHALE_BASE}/api/v1/radios/radios/${RADIO_ID}/tracks/?page=${page}&page_size=${RADIO_PAGE_SIZE}`;
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Funkwhale API error: ${response.status}`);
|
||||
}
|
||||
const data: ApiPage = await response.json();
|
||||
return { count: data.count, tracks: data.results.map(to_radio_track) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shuffles an array in place (Fisher–Yates) and returns it.
|
||||
*/
|
||||
export function shuffle<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;
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,232 +1,38 @@
|
||||
<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';
|
||||
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
{@html github}
|
||||
</svelte:head>
|
||||
|
||||
<!-- Off-canvas menu for mobile, show/hide based on off-canvas menu state. -->
|
||||
<div
|
||||
class="pointer-events-none relative z-50 lg:hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
<main
|
||||
class="mx-auto min-h-full max-w-4xl px-4 pb-[calc(2rem+env(safe-area-inset-bottom))] pt-8"
|
||||
>
|
||||
{#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 />
|
||||
</div>
|
||||
</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>
|
||||
|
||||
+39
-122
@@ -1,137 +1,54 @@
|
||||
<script>
|
||||
import { base } from '$app/paths';
|
||||
import { PlayerWidget } from '$src/components/example-player';
|
||||
import { episodes } from '$src/content/episodes';
|
||||
import { DocsPage } from '$src/layout/page';
|
||||
|
||||
/** @type { string | undefined} */
|
||||
let audio_src = episodes.knomii.src;
|
||||
import RadioIcon from '@inqling/svelte-icons/heroicon-24-solid/radio.svelte';
|
||||
import Seo from '$lib/Seo.svelte';
|
||||
import RadioPlayer from '$src/radio/radio-player.svelte';
|
||||
import TrackList from '$src/radio/track-list.svelte';
|
||||
import { radio_player } from '$src/radio/radio-store';
|
||||
</script>
|
||||
|
||||
<DocsPage title="Docs" let:Section>
|
||||
<header
|
||||
slot="header"
|
||||
class="relative isolate bg-white"
|
||||
aria-labelledby="hero"
|
||||
>
|
||||
<svg
|
||||
class="absolute inset-0 -z-10 h-full w-full stroke-mono-200 [mask-image:radial-gradient(100%_100%_at_top_right,white,transparent)]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="0787a7c5-978c-4f66-83c7-11c213f99cb7"
|
||||
width="200"
|
||||
height="200"
|
||||
x="50%"
|
||||
y="-1"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<path d="M.5 200V.5H200" fill="none" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect
|
||||
width="100%"
|
||||
height="100%"
|
||||
stroke-width="0"
|
||||
fill="url(#0787a7c5-978c-4f66-83c7-11c213f99cb7)"
|
||||
/>
|
||||
</svg>
|
||||
<Seo
|
||||
title="Lecteur radio"
|
||||
description="Écoute la radio Radyobòkaz de mizik.o-k-i.net : lecture aléatoire continue ou à la carte."
|
||||
path="/"
|
||||
/>
|
||||
|
||||
<header class="mb-6 flex flex-wrap items-center justify-between gap-4">
|
||||
<div>
|
||||
<div class="relative py-24">
|
||||
<div class="mx-auto max-w-7xl px-6 lg:px-8">
|
||||
<div class="mx-auto max-w-2xl text-center">
|
||||
<h1 id="hero">
|
||||
<span class="block text-xl font-medium text-primary-600">
|
||||
svelte-podcast<span class="sr-only">: </span>
|
||||
</span>
|
||||
<span
|
||||
class="text-4xl font-bold tracking-tight text-mono-900 sm:text-6xl"
|
||||
>
|
||||
The fastest way to build a podcast site with Svelte.
|
||||
</span>
|
||||
</h1>
|
||||
<p class="mt-6 text-xl leading-8 text-mono-600">
|
||||
A suite of tools and components to build your own podcast
|
||||
players, and work with RSS podcast data in SvelteKit.
|
||||
<span
|
||||
class="mt-3 block text-base leading-none text-primary-800"
|
||||
>
|
||||
<span
|
||||
class="inline-block rounded-full bg-primary-50 px-3 py-1.5 text-xs font-medium uppercase tracking-wider text-primary-600"
|
||||
>
|
||||
Coming Soon<span class="sr-only">:</span>
|
||||
</span>
|
||||
<span class="tracking-wide"
|
||||
>SSR utilities for consuming RSS podcast feeds</span
|
||||
>
|
||||
</span>
|
||||
</p>
|
||||
<div class="mt-10 flex items-center justify-center gap-x-3">
|
||||
<h1 class="text-3xl font-bold text-slate-900">Radyobòkaz</h1>
|
||||
<p class="text-slate-500">
|
||||
La radio de
|
||||
<a
|
||||
href="{base}/setup"
|
||||
class="rounded-md bg-primary-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-primary-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600"
|
||||
href="https://mizik.o-k-i.net/library/radios/7"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-orange-600 underline decoration-orange-300 underline-offset-2 hover:text-orange-500"
|
||||
>
|
||||
Get started
|
||||
mizik.o-k-i.net
|
||||
</a>
|
||||
·
|
||||
<a
|
||||
href="{base}/api"
|
||||
class="rounded-md bg-white px-3.5 py-2.5 text-sm font-semibold text-mono-600 hover:bg-primary-50 hover:text-primary-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600"
|
||||
href="{base}/podcast/"
|
||||
class="text-orange-600 underline decoration-orange-300 underline-offset-2 hover:text-orange-500"
|
||||
>
|
||||
Examples <span aria-hidden="true">→</span>
|
||||
Démo podcast RSS →
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-16 flow-root sm:mt-24">
|
||||
<div
|
||||
class="-m-2 grid grid-cols-1 grid-rows-1 rounded-xl border border-mono-100 bg-white p-1 shadow-2xl shadow-mono-200 sm:p-2 lg:-m-4 lg:rounded-2xl lg:p-4"
|
||||
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => radio_player.start_radio()}
|
||||
disabled={$radio_player.is_loading}
|
||||
class="flex items-center gap-2 rounded-full bg-orange-600 px-5 py-2.5 font-semibold text-white shadow hover:bg-orange-500 disabled:opacity-50"
|
||||
>
|
||||
<PlayerWidget
|
||||
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>
|
||||
<RadioIcon class="h-5 w-5" />
|
||||
{$radio_player.is_loading ? 'Chargement…' : 'Lecture radio aléatoire'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<Section title="Introduction">
|
||||
<p>
|
||||
Svelte-Podcast streamlines the creation of custom audio players and
|
||||
simplifies state management in Svelte apps.
|
||||
</p>
|
||||
<div class="sticky top-[max(1rem,env(safe-area-inset-top))] z-10 mb-8">
|
||||
<RadioPlayer />
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<b>Build custom Audio Player UI</b>
|
||||
<br />
|
||||
Simplify the creation of custom audio players with a set of headless components
|
||||
that keep out of your way and take care of core functionality.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>Easily manage Audio State</b>
|
||||
<br />
|
||||
Loading, controlling, and keeping track of multiple audio sources is a pain.
|
||||
svelte-podcast abstracts this away and provides a simple interface to manage
|
||||
audio state.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<b>Track user preferences</b>
|
||||
<br />
|
||||
Users expect a lot from media players. It should remember their preferences
|
||||
like playback speed, and it should remember where they were in an episode
|
||||
even after reloading the page. svelte-podcast takes care of this for you,
|
||||
and provides you with access to extend this with your own database.
|
||||
</p>
|
||||
</Section>
|
||||
</DocsPage>
|
||||
<TrackList />
|
||||
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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');
|
||||
@@ -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>
|
||||
@@ -1,4 +0,0 @@
|
||||
import { audio } from 'svelte-podcast';
|
||||
|
||||
// unload the current audio source
|
||||
audio.unload();
|
||||
@@ -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');
|
||||
@@ -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();
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,17 @@
|
||||
import { fetch_podcast_feed } from '$lib/rss';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
const FEED_URL = 'https://feed.syntax.fm/rss';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const feed = await fetch_podcast_feed(FEED_URL, fetch);
|
||||
return {
|
||||
feed: {
|
||||
...feed,
|
||||
// Les notes d'épisode (HTML complet) ne sont affichées nulle part :
|
||||
// on ne les sérialise pas dans la page (plusieurs Mo pour ~1000 épisodes).
|
||||
episodes: feed.episodes.map(({ description: _description, ...episode }) => episode),
|
||||
},
|
||||
feed_url: FEED_URL,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import MusicalNoteIcon from '@inqling/svelte-icons/heroicon-24-solid/musical-note.svelte';
|
||||
import PlayIcon from '@inqling/svelte-icons/heroicon-24-solid/play.svelte';
|
||||
|
||||
import { format_seconds, type PodcastEpisode } from '$lib/index';
|
||||
import Seo from '$lib/Seo.svelte';
|
||||
import RadioPlayer from '$src/radio/radio-player.svelte';
|
||||
import type { PlayableTrack } from '$src/radio/playable';
|
||||
import { radio_player } from '$src/radio/radio-store';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
export let data: PageData;
|
||||
|
||||
$: feed = data.feed;
|
||||
|
||||
/** Nombre d'épisodes affichés par page de la liste. */
|
||||
const PAGE_SIZE = 50;
|
||||
let shown = PAGE_SIZE;
|
||||
$: visible_episodes = feed.episodes.slice(0, shown);
|
||||
|
||||
/** Maps a podcast episode to a track playable by the queue. */
|
||||
function to_track(episode: Omit<PodcastEpisode, 'description'>): PlayableTrack {
|
||||
return {
|
||||
id: episode.id,
|
||||
title: episode.title,
|
||||
artist: feed.author ?? feed.title,
|
||||
album: feed.title,
|
||||
cover: episode.artwork ?? feed.artwork,
|
||||
stream_url: episode.src,
|
||||
duration: episode.duration,
|
||||
};
|
||||
}
|
||||
|
||||
$: tracks = feed.episodes.map(to_track);
|
||||
|
||||
function play_episode(index: number) {
|
||||
radio_player.play_tracks(tracks, index);
|
||||
}
|
||||
|
||||
function format_date(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<Seo
|
||||
title={feed.title}
|
||||
description={feed.description ?? feed.title}
|
||||
path="/podcast/"
|
||||
/>
|
||||
|
||||
<header class="mb-6 flex flex-wrap items-center gap-4">
|
||||
{#if feed.artwork}
|
||||
<img
|
||||
src={feed.artwork}
|
||||
alt="Pochette de {feed.title}"
|
||||
class="h-20 w-20 rounded-xl object-cover shadow"
|
||||
/>
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<h1 class="text-2xl font-bold text-slate-900">{feed.title}</h1>
|
||||
{#if feed.author}
|
||||
<p class="text-slate-500">{feed.author}</p>
|
||||
{/if}
|
||||
<p class="mt-1 text-sm text-slate-400">
|
||||
{feed.episodes.length} épisodes ·
|
||||
<a
|
||||
href={data.feed_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="text-orange-600 underline decoration-orange-300 underline-offset-2 hover:text-orange-500"
|
||||
>
|
||||
flux RSS
|
||||
</a>
|
||||
·
|
||||
<a
|
||||
href="{base}/"
|
||||
class="text-orange-600 underline decoration-orange-300 underline-offset-2 hover:text-orange-500"
|
||||
>
|
||||
← Radyobòkaz
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="sticky top-[max(1rem,env(safe-area-inset-top))] z-10 mb-8">
|
||||
<RadioPlayer />
|
||||
</div>
|
||||
|
||||
<ul
|
||||
class="divide-y divide-slate-200 rounded-xl border border-slate-200 bg-white"
|
||||
>
|
||||
{#each visible_episodes as episode, index (episode.id)}
|
||||
{@const is_current = $radio_player.current?.id === episode.id}
|
||||
<li class="episode-row">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => play_episode(index)}
|
||||
class="flex w-full items-center gap-3 px-3 py-2 text-left hover:bg-orange-50"
|
||||
class:bg-orange-100={is_current}
|
||||
>
|
||||
<span
|
||||
class="grid h-8 w-8 shrink-0 place-content-center rounded-full text-slate-400"
|
||||
class:bg-orange-500={is_current}
|
||||
class:text-white={is_current}
|
||||
>
|
||||
{#if is_current}
|
||||
<MusicalNoteIcon class="h-4 w-4" />
|
||||
{:else}
|
||||
<PlayIcon class="h-4 w-4" />
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-medium text-slate-900">
|
||||
{episode.title}
|
||||
</span>
|
||||
<span class="block truncate text-xs text-slate-500">
|
||||
{format_date(episode.published_at)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{#if episode.duration}
|
||||
<span class="shrink-0 text-xs tabular-nums text-slate-400">
|
||||
{format_seconds.to_timestamp(episode.duration)}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if shown < feed.episodes.length}
|
||||
<div class="mt-4 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
on:click={() => (shown += PAGE_SIZE)}
|
||||
class="min-h-12 rounded-full border border-orange-300 bg-white px-6 font-semibold text-orange-600 hover:bg-orange-50"
|
||||
>
|
||||
Charger plus d'épisodes ({feed.episodes.length - shown} restants)
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style lang="postcss">
|
||||
/* Le navigateur saute le rendu des lignes hors écran (gros gain
|
||||
mobile sur les longues listes), la taille intrinsèque évite les
|
||||
sauts de scrollbar. */
|
||||
.episode-row {
|
||||
content-visibility: auto;
|
||||
contain-intrinsic-size: auto 52px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,104 +0,0 @@
|
||||
<script>
|
||||
import { DocsPage } from '$src/layout/page';
|
||||
import { Highlight } from 'svelte-highlight';
|
||||
import lang_shell from 'svelte-highlight/languages/shell';
|
||||
import lang_ts from 'svelte-highlight/languages/typescript';
|
||||
import {
|
||||
install,
|
||||
load_audio_advanced,
|
||||
load_audio_local,
|
||||
load_audio_remote,
|
||||
override_episode_state,
|
||||
} from './code';
|
||||
</script>
|
||||
|
||||
<DocsPage title="Setup" let:Section>
|
||||
<Section title="Installation">
|
||||
<p>
|
||||
Install the latest version of svelte-podcast with your preferred
|
||||
package manager.
|
||||
</p>
|
||||
|
||||
<div class="codeblock not-prose flex flex-col items-stretch gap-2 py-1">
|
||||
<Highlight language={lang_shell} code={install} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Audio Sources">
|
||||
<p>
|
||||
To get started, you'll need an audio file. velte-podcast uses a html <code
|
||||
>{'<audio />'}</code
|
||||
> element under the hood, so any file compatible with the audio element
|
||||
will work.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
You can provide your audio files via absolute URL's - hosted online, or
|
||||
relative paths - hosted within your project.
|
||||
</p>
|
||||
|
||||
<h3>Using a remote file (URL)</h3>
|
||||
|
||||
<p>
|
||||
An <em>audio url</em> could be a URL to an MP3 file from an RSS feed,
|
||||
like this:
|
||||
<code>https://media.transistor.fm/27a058c9/27b595e2.mp3</code>. It
|
||||
could also be a path to a static file on your server.
|
||||
</p>
|
||||
|
||||
<div class="codeblock not-prose flex flex-col items-stretch gap-2 py-1">
|
||||
<Highlight code={load_audio_remote} language={lang_ts} />
|
||||
</div>
|
||||
|
||||
<h3>Using a local file (relative path)</h3>
|
||||
|
||||
<p>
|
||||
If you're using SvelteKit, you can store <em>static files</em> in the
|
||||
/static directory. When your site is built, everything in the static
|
||||
directory will be at the root of your site. If you have a file in
|
||||
<code>/static/episides/episode-01.mp3</code>
|
||||
you could load it as <code>/episides/episode-01.mp3</code>
|
||||
</p>
|
||||
|
||||
<div class="codeblock not-prose flex flex-col items-stretch gap-2 py-1">
|
||||
<Highlight code={load_audio_local} language={lang_ts} />
|
||||
</div>
|
||||
|
||||
<h3>Additional options</h3>
|
||||
|
||||
<p>
|
||||
Along side your audio source, you can optionally define arbitrary
|
||||
metadata for you to use in your player, and determine if the episode
|
||||
should autoplay once loaded. Learn more in the <a
|
||||
href="/api/#audio.load">api docs</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<div class="codeblock not-prose flex flex-col items-stretch gap-2 py-1">
|
||||
<Highlight code={load_audio_advanced} language={lang_ts} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section title="Type Safety">
|
||||
<p>
|
||||
Svelte podast makes it easy for you to associate arbitrary metadata
|
||||
with each audio source. We recommend defining the shape of your
|
||||
metadata in a <code>.d.ts</code> file.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
In this example, we'll assume that your metadata consists of an episode
|
||||
title, episode artwork, and an optional episode guest name.
|
||||
</p>
|
||||
|
||||
<div class="not-prose codeblock">
|
||||
<Highlight code={override_episode_state} language={lang_ts} />
|
||||
</div>
|
||||
|
||||
<p>
|
||||
If you're using SvelteKit, we recommend keeping this in <code
|
||||
>src/app.d.ts</code
|
||||
>.
|
||||
</p>
|
||||
</Section>
|
||||
</DocsPage>
|
||||
@@ -1,4 +0,0 @@
|
||||
<script>
|
||||
</script>
|
||||
|
||||
<slot />
|
||||
@@ -1,14 +0,0 @@
|
||||
import { format_code } from '$src/layout/helper';
|
||||
import { default as add_audio_loader_src } from './add-audio-loader.svelte?raw';
|
||||
import { default as install_src } from './install.sh?raw';
|
||||
import { default as load_audio_advanced_src } from './load-audio-advanced?raw';
|
||||
import { default as load_audio_local_src } from './load-audio-local?raw';
|
||||
import { default as load_audio_remote_src } from './load-audio-remote?raw';
|
||||
import { default as override_episode_state_src } from './override-episode-state?raw';
|
||||
|
||||
export const add_audio_loader = format_code(add_audio_loader_src);
|
||||
export const install = format_code(install_src);
|
||||
export const override_episode_state = format_code(override_episode_state_src);
|
||||
export const load_audio_local = format_code(load_audio_local_src);
|
||||
export const load_audio_remote = format_code(load_audio_remote_src);
|
||||
export const load_audio_advanced = format_code(load_audio_advanced_src);
|
||||
@@ -1,5 +0,0 @@
|
||||
npm install svelte-podcast@latest
|
||||
|
||||
yarn add svelte-podcast@latest
|
||||
|
||||
pnpm add svelte-podcast@latest
|
||||
@@ -1,14 +0,0 @@
|
||||
import { audio } from 'svelte-podcast';
|
||||
|
||||
audio.load('/episode-audio.mp3');
|
||||
|
||||
audio.load(
|
||||
'https://media.transistor.fm/27a058c9/27b595e2.mp3',
|
||||
{
|
||||
title: 'SvelteKit-superforms with Andreas Söderlund',
|
||||
artwork:
|
||||
'https://images.transistor.fm/file/transistor/images/show/12899/medium_1597678946-artwork.jpg',
|
||||
guest_name: 'Andreas Söderlund',
|
||||
},
|
||||
false,
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user