feat: réparation audio, mobile et PWA + charte OKI légère

- Volume propagé en direct sur l'élément audio (le slider ne faisait
  que persister) ; volume 0 respecté (?? 1) ; slider masqué sur iOS
  (can_set_volume) ; mute synchronisé store/élément, persistant entre pistes
- Race condition loadedmetadata, USER_PROGRESS borné à 200 entrées,
  erreurs radio affichées avec bouton Réessayer
- Mobile : barre de progression avec zone tactile 44 px (tap + scrub au doigt),
  boutons 48 px, contrôles wrap, listes paginées 50×50 + content-visibility
- PWA : manifest + icônes 192/512 (any+maskable), service worker manuel
  (shell + pochettes, streams exclus), Media Session API (metadata + 4 handlers)
- SEO : Seo.svelte (canonical, OG, Twitter), footer fédéré OKI + flag-bar
- Mesuré puppeteer 390x844 : seek tactile OK, scrollWidth OK, SW actif
This commit is contained in:
sucupira
2026-07-21 18:57:26 -04:00
parent 4320258468
commit 67c701f85a
22 changed files with 488 additions and 74 deletions
+6 -1
View File
@@ -2,9 +2,14 @@
<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="#ea580c" />
<meta name="color-scheme" content="light" />
+33 -2
View File
@@ -36,6 +36,25 @@
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}
@@ -49,7 +68,15 @@
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: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')}
@@ -110,7 +137,11 @@
--borders: calc(var(--thumb-border-size) * 2);
height: calc(var(--thumb-shape-height) + var(--borders));
/* 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%;
+27
View File
@@ -0,0 +1,27 @@
<script>
import { SITE_NAME, SITE_URL } from '$lib/site.js';
export let title;
export let description;
export let path = '/';
$: full_title = title ? `${title}${SITE_NAME}` : SITE_NAME;
$: url = `${SITE_URL}${path}`;
</script>
<svelte:head>
<title>{full_title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={url} />
<meta property="og:type" content="website" />
<meta property="og:url" content={url} />
<meta property="og:title" content={full_title} />
<meta property="og:description" content={description} />
<meta property="og:locale" content="fr_FR" />
<meta property="og:site_name" content={SITE_NAME} />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={full_title} />
<meta name="twitter:description" content={description} />
</svelte:head>
+16 -3
View File
@@ -1,5 +1,7 @@
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';
@@ -26,17 +28,25 @@ export const audio_element: Readable<HTMLAudioElement | null> = derived(
// Set the HTMLAudioElement's attributes.
el.id = ELEMENT_ID;
el.setAttribute('preload', 'metadata');
el.muted = false;
el.autoplay = false;
el.controls = false;
if ($audio_state) {
el.src = $audio_state.src;
el.currentTime = $audio_state.start_at;
el.playbackRate = $audio_state.playback_rate || 1;
el.volume = $audio_state.volume || 1;
}
// 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);
@@ -56,6 +66,9 @@ export const audio_element: Readable<HTMLAudioElement | null> = derived(
// 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);
+7 -5
View File
@@ -75,16 +75,18 @@ const play = (playing: boolean | 'toggle' = 'toggle') => {
/**
* 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');
if (typeof muted === 'boolean') {
el.muted = muted;
} else {
el.muted = !el.muted;
}
const next_muted = typeof muted === 'boolean' ? muted : !el.muted;
el.muted = next_muted;
user_preferences.set_muted(next_muted);
};
/**
+1
View File
@@ -7,5 +7,6 @@ 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';
+3
View File
@@ -0,0 +1,3 @@
// URL publique du déploiement — à ajuster si le domaine change.
export const SITE_URL = 'https://radyobokaz.o-k-i.net';
export const SITE_NAME = 'Radyobòkaz Player';
+12
View File
@@ -4,6 +4,7 @@ import { persisted } from 'svelte-local-storage-store';
export interface UserPreferences {
playback_rate: number;
volume: number;
muted: boolean;
}
/**
@@ -12,6 +13,7 @@ export interface UserPreferences {
const DEFAULT_USER_PREFERENCES: UserPreferences = {
playback_rate: 1,
volume: 1,
muted: false,
};
/**
@@ -43,6 +45,15 @@ const set_volume = (value: number) => {
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
*/
@@ -55,5 +66,6 @@ export const user_preferences = {
subscribe: USER_PREFERENCES_STORE.subscribe,
set_playback_rate,
set_volume,
set_muted,
clear: clear_store,
};
+24 -4
View File
@@ -10,6 +10,13 @@ export interface UserProgress {
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
*/
@@ -29,10 +36,23 @@ const save_user_progress = () => {
const pathname = use_url(audio.src).pathname;
const current_time = audio.currentTime;
USER_PROGRESS_STORE.update((prev) => ({
...prev,
[pathname]: current_time,
}));
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;
});
};
/**
+24
View File
@@ -0,0 +1,24 @@
import { BROWSER } from 'esm-env';
let cached: boolean | null = null;
/**
* Whether the current environment allows writing to
* `HTMLMediaElement.volume`. On iOS Safari the property is read-only
* (assignment is ignored or throws), so volume must be controlled
* through the system UI instead.
*/
export const can_set_volume = (): boolean => {
if (cached !== null) return cached;
if (!BROWSER) return false;
try {
const probe = document.createElement('audio');
probe.volume = 0.5;
cached = probe.volume === 0.5;
} catch {
cached = false;
}
return cached;
};
+44 -13
View File
@@ -7,12 +7,26 @@
import SpeakerWaveIcon from '@inqling/svelte-icons/heroicon-24-solid/speaker-wave.svelte';
import SpeakerXMarkIcon from '@inqling/svelte-icons/heroicon-24-solid/speaker-x-mark.svelte';
import { AudioPlayer, user_preferences } from '$lib/index';
import { AudioPlayer, can_set_volume, user_preferences } from '$lib/index';
import { onMount } from 'svelte';
import { radio_player } from './radio-store';
let is_muted = false;
// 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>
@@ -65,12 +79,12 @@
</div>
<!-- Controls -->
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
on:click={() => radio_player.previous()}
disabled={!current}
class="rounded-full p-2 hover:bg-slate-700 disabled:opacity-30"
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" />
@@ -80,7 +94,7 @@
type="button"
on:click={() => play('toggle')}
disabled={!attributes.is_loaded}
class="rounded-full bg-orange-500 p-3 text-white hover:bg-orange-400 disabled:opacity-30"
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}
@@ -94,7 +108,7 @@
type="button"
on:click={() => radio_player.next()}
disabled={!current || $radio_player.is_loading}
class="rounded-full p-2 hover:bg-slate-700 disabled:opacity-30"
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" />
@@ -102,20 +116,18 @@
<button
type="button"
on:click={() => {
mute('toggle');
is_muted = !is_muted;
}}
class="rounded-full p-2 hover:bg-slate-700"
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 is_muted || $user_preferences.volume === 0}
{#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"
@@ -124,9 +136,10 @@
value={$user_preferences.volume}
on:input={(e) =>
user_preferences.set_volume(e.currentTarget.valueAsNumber)}
class="h-1 w-20 accent-orange-500"
class="h-10 w-24 accent-orange-500"
aria-label="Volume"
/>
{/if}
</div>
</div>
@@ -140,6 +153,24 @@
</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>
+64 -18
View File
@@ -19,6 +19,8 @@ export interface RadioPlayerState {
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 = {
@@ -27,6 +29,7 @@ const initial_state: RadioPlayerState = {
index: -1,
current: null,
is_loading: false,
error: null,
};
const state = writable<RadioPlayerState>(initial_state);
@@ -37,6 +40,24 @@ const state = writable<RadioPlayerState>(initial_state);
* (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,
@@ -44,20 +65,22 @@ function load_and_play(track: PlayableTrack): void {
artwork: track.cover,
});
const element = get(audio_element);
if (element) {
element.addEventListener(
'loadedmetadata',
() => {
if (
Number.isFinite(element.duration) &&
element.currentTime > element.duration - 5
) {
element.currentTime = 0;
// 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 }] : [],
});
}
},
{ once: true },
);
// 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);
@@ -68,7 +91,7 @@ function play_at(index: number): void {
const track = current_state.queue[index];
if (!track) return;
state.set({ ...current_state, index, current: track });
state.set({ ...current_state, index, current: track, error: null });
load_and_play(track);
}
@@ -92,7 +115,7 @@ export const radio_player = {
const current_state = get(state);
if (current_state.is_loading) return;
state.set({ ...current_state, mode: 'radio', is_loading: true });
state.set({ ...current_state, mode: 'radio', is_loading: true, error: null });
try {
const queue = await fetch_random_queue();
const first_track = queue[0];
@@ -103,11 +126,16 @@ export const radio_player = {
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 });
state.set({
...get(state),
is_loading: false,
error: 'Impossible de charger la radio. Vérifie ta connexion et réessaie.',
});
}
},
@@ -124,6 +152,7 @@ export const radio_player = {
index,
current: track,
is_loading: false,
error: null,
});
load_and_play(track);
},
@@ -139,7 +168,7 @@ export const radio_player = {
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 });
state.set({ ...current_state, is_loading: true, error: null });
try {
const queue = await fetch_random_queue();
const first_track = queue[0];
@@ -153,11 +182,16 @@ export const radio_player = {
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 });
state.set({
...get(state),
is_loading: false,
error: 'Impossible de charger la suite. Vérifie ta connexion et réessaie.',
});
}
}
},
@@ -194,4 +228,16 @@ if (BROWSER) {
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(),
);
}
}
+9 -1
View File
@@ -79,7 +79,7 @@
>
{#each visible as track (track.id)}
{@const is_current = $radio_player.current?.id === track.id}
<li>
<li class="track-row">
<button
type="button"
on:click={() => play_track(track)}
@@ -138,3 +138,11 @@
{/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>
+32 -1
View File
@@ -2,6 +2,37 @@
import '../app.postcss';
</script>
<main class="mx-auto min-h-full max-w-4xl px-4 py-8">
<main
class="mx-auto min-h-full max-w-4xl px-4 pb-[calc(2rem+env(safe-area-inset-bottom))] pt-8"
>
<slot />
</main>
<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>
+7 -8
View File
@@ -1,18 +1,17 @@
<script>
import { base } from '$app/paths';
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>
<svelte:head>
<title>Radyobòkaz — Lecteur radio</title>
<meta
name="description"
content="Écoute la radio Radyobòkaz de mizik.o-k-i.net : lecture aléatoire continue ou à la carte."
/>
</svelte:head>
<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>
@@ -48,7 +47,7 @@
</button>
</header>
<div class="sticky top-4 z-10 mb-8">
<div class="sticky top-[max(1rem,env(safe-area-inset-top))] z-10 mb-8">
<RadioPlayer />
</div>
+36 -7
View File
@@ -4,6 +4,7 @@
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';
@@ -13,6 +14,11 @@
$: 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 {
@@ -42,10 +48,11 @@
}
</script>
<svelte:head>
<title>{feed.title} — Lecteur podcast</title>
<meta name="description" content={feed.description ?? feed.title} />
</svelte:head>
<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}
@@ -81,16 +88,16 @@
</div>
</header>
<div class="sticky top-4 z-10 mb-8">
<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 feed.episodes as episode, index (episode.id)}
{#each visible_episodes as episode, index (episode.id)}
{@const is_current = $radio_player.current?.id === episode.id}
<li>
<li class="episode-row">
<button
type="button"
on:click={() => play_episode(index)}
@@ -127,3 +134,25 @@
</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>
+95
View File
@@ -0,0 +1,95 @@
/// <reference types="@sveltejs/kit" />
/// <reference no-default-lib="true"/>
/// <reference lib="esnext" />
/// <reference lib="webworker" />
import { build, files, version } from '$service-worker';
const sw = /** @type {ServiceWorkerGlobalScope} */ (
/** @type {unknown} */ (self)
);
const CACHE = `radyobokaz-${version}`;
// Shell applicatif : bundles + fichiers statiques + page d'accueil.
const ASSETS = [...build, ...files, './'];
sw.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(CACHE)
.then((cache) => cache.addAll(ASSETS))
.then(() => sw.skipWaiting()),
);
});
sw.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)),
),
)
.then(() => sw.clients.claim()),
);
});
sw.addEventListener('fetch', (event) => {
const { request } = event;
if (request.method !== 'GET') return;
// Jamais les flux audio : les requêtes Range et les fichiers médias
// partent directement sur le réseau.
if (request.headers.has('range')) return;
const url = new URL(request.url);
if (/\.(mp3|ogg|opus|m4a|flac|wav|aac)(\?|$)/i.test(url.pathname)) return;
// Navigations : réseau d'abord (contenu frais), repli cache hors-ligne.
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then((response) => {
if (response.ok) {
const copy = response.clone();
caches.open(CACHE).then((cache) => cache.put(request, copy));
}
return response;
})
.catch(() =>
caches
.match(request)
.then((cached) => cached || caches.match('./'))
.then((cached) => cached || Response.error()),
),
);
return;
}
// Assets same-origin (bundles _app, icônes…) : cache d'abord.
if (url.origin === sw.location.origin) {
event.respondWith(
caches
.match(request)
.then((cached) => cached || fetch(request)),
);
return;
}
// Pochettes distantes (Funkwhale, flux RSS) : cache d'abord,
// remplissage au premier accès. Réponses opaques acceptées.
if (request.destination === 'image') {
event.respondWith(
caches.open(CACHE).then(async (cache) => {
const cached = await cache.match(request);
if (cached) return cached;
const response = await fetch(request);
if (response.ok || response.type === 'opaque') {
cache.put(request, response.clone());
}
return response;
}),
);
}
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

+37
View File
@@ -0,0 +1,37 @@
{
"name": "Radyobòkaz Player",
"short_name": "Radyobòkaz",
"description": "Écoute la radio Radyobòkaz de mizik.o-k-i.net : lecture aléatoire continue ou à la carte.",
"lang": "fr",
"start_url": "./",
"scope": "./",
"display": "standalone",
"background_color": "#f1f5f9",
"theme_color": "#ea580c",
"icons": [
{
"src": "icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}