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:
+6
-1
@@ -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
@@ -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%;
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user