diff --git a/src/app.html b/src/app.html index bc17aeb..31616a1 100644 --- a/src/app.html +++ b/src/app.html @@ -2,9 +2,14 @@ - + + + diff --git a/src/lib/Progress.svelte b/src/lib/Progress.svelte index 7a33f3b..46cd845 100644 --- a/src/lib/Progress.svelte +++ b/src/lib/Progress.svelte @@ -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); + }; {#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%; diff --git a/src/lib/Seo.svelte b/src/lib/Seo.svelte new file mode 100644 index 0000000..6001174 --- /dev/null +++ b/src/lib/Seo.svelte @@ -0,0 +1,27 @@ + + + + {full_title} + + + + + + + + + + + + + + diff --git a/src/lib/_internal_/audio-element.ts b/src/lib/_internal_/audio-element.ts index 84618d9..d247c67 100644 --- a/src/lib/_internal_/audio-element.ts +++ b/src/lib/_internal_/audio-element.ts @@ -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 = 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 = 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); diff --git a/src/lib/actions.ts b/src/lib/actions.ts index 437adac..ca8cc9c 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -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); }; /** diff --git a/src/lib/index.ts b/src/lib/index.ts index 9aa9943..2267635 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -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'; diff --git a/src/lib/site.js b/src/lib/site.js new file mode 100644 index 0000000..9ca3c2b --- /dev/null +++ b/src/lib/site.js @@ -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'; diff --git a/src/lib/user-preferences.ts b/src/lib/user-preferences.ts index cca1782..a6ed0ff 100644 --- a/src/lib/user-preferences.ts +++ b/src/lib/user-preferences.ts @@ -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, }; diff --git a/src/lib/user-progress.ts b/src/lib/user-progress.ts index c4adc50..5b21a20 100644 --- a/src/lib/user-progress.ts +++ b/src/lib/user-progress.ts @@ -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; + }); }; /** diff --git a/src/lib/volume-support.ts b/src/lib/volume-support.ts new file mode 100644 index 0000000..39e939c --- /dev/null +++ b/src/lib/volume-support.ts @@ -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; +}; diff --git a/src/radio/radio-player.svelte b/src/radio/radio-player.svelte index 54137a7..b7448bd 100644 --- a/src/radio/radio-player.svelte +++ b/src/radio/radio-player.svelte @@ -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(); + } + }; @@ -65,12 +79,12 @@ -
+
- - user_preferences.set_volume(e.currentTarget.valueAsNumber)} - class="h-1 w-20 accent-orange-500" - aria-label="Volume" - /> + {#if volume_control_supported} + + user_preferences.set_volume(e.currentTarget.valueAsNumber)} + class="h-10 w-24 accent-orange-500" + aria-label="Volume" + /> + {/if}
@@ -140,6 +153,24 @@ {attributes.timestamp_end} + + + {#if $radio_player.error} + + {/if}
diff --git a/src/radio/radio-store.ts b/src/radio/radio-store.ts index bceb883..5eab43e 100644 --- a/src/radio/radio-store.ts +++ b/src/radio/radio-store.ts @@ -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(initial_state); @@ -37,6 +40,24 @@ const state = writable(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; - } - }, - { once: true }, - ); + // 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); @@ -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(), + ); + } } diff --git a/src/radio/track-list.svelte b/src/radio/track-list.svelte index 0921ce8..09e8289 100644 --- a/src/radio/track-list.svelte +++ b/src/radio/track-list.svelte @@ -79,7 +79,7 @@ > {#each visible as track (track.id)} {@const is_current = $radio_player.current?.id === track.id} -
  • +
  • -
    +
    diff --git a/src/routes/podcast/+page.svelte b/src/routes/podcast/+page.svelte index f9d8408..042098b 100644 --- a/src/routes/podcast/+page.svelte +++ b/src/routes/podcast/+page.svelte @@ -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): PlayableTrack { return { @@ -42,10 +48,11 @@ } - - {feed.title} — Lecteur podcast - - +
    {#if feed.artwork} @@ -81,16 +88,16 @@
    -
    +
      - {#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} -
    • +
    • {/each}
    + +{#if shown < feed.episodes.length} +
    + +
    +{/if} + + diff --git a/src/service-worker.js b/src/service-worker.js new file mode 100644 index 0000000..a40867c --- /dev/null +++ b/src/service-worker.js @@ -0,0 +1,95 @@ +/// +/// +/// +/// + +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; + }), + ); + } +}); diff --git a/static/icon-192.png b/static/icon-192.png new file mode 100644 index 0000000..f810228 Binary files /dev/null and b/static/icon-192.png differ diff --git a/static/icon-512.png b/static/icon-512.png new file mode 100644 index 0000000..9394f30 Binary files /dev/null and b/static/icon-512.png differ diff --git a/static/icon-maskable-192.png b/static/icon-maskable-192.png new file mode 100644 index 0000000..f85f4d1 Binary files /dev/null and b/static/icon-maskable-192.png differ diff --git a/static/icon-maskable-512.png b/static/icon-maskable-512.png new file mode 100644 index 0000000..261d7cd Binary files /dev/null and b/static/icon-maskable-512.png differ diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest new file mode 100644 index 0000000..8a01b9a --- /dev/null +++ b/static/manifest.webmanifest @@ -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" + } + ] +}