initiation librodrome

This commit is contained in:
Yvv
2026-02-20 12:55:10 +01:00
commit 35e2897a73
208 changed files with 18951 additions and 0 deletions

View File

@@ -0,0 +1,153 @@
import type { Song } from '~/types/song'
let audio: HTMLAudioElement | null = null
let animationFrameId: number | null = null
export function useAudioPlayer() {
const store = usePlayerStore()
function getAudio(): HTMLAudioElement {
if (!audio) {
audio = new Audio()
audio.preload = 'metadata'
audio.volume = store.volume
audio.addEventListener('loadedmetadata', () => {
store.setDuration(audio!.duration)
})
audio.addEventListener('ended', () => {
const next = store.nextSong()
if (next) {
loadAndPlay(next)
}
})
audio.addEventListener('error', (e) => {
console.error('Audio error:', e)
store.pause()
})
}
return audio
}
function startTimeUpdate() {
if (animationFrameId) return
const update = () => {
if (audio && !audio.paused) {
store.setCurrentTime(audio.currentTime)
}
animationFrameId = requestAnimationFrame(update)
}
animationFrameId = requestAnimationFrame(update)
}
function stopTimeUpdate() {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId)
animationFrameId = null
}
}
async function loadAndPlay(song: Song) {
const el = getAudio()
store.setSong(song)
// Try OGG first, fall back to MP3
const oggPath = song.file.replace(/\.mp3$/, '.ogg')
const canOgg = el.canPlayType('audio/ogg; codecs=vorbis')
el.src = canOgg ? oggPath : song.file
el.volume = store.volume
try {
await el.play()
store.play()
startTimeUpdate()
}
catch {
// If OGG failed, try MP3
if (el.src !== song.file) {
el.src = song.file
try {
await el.play()
store.play()
startTimeUpdate()
}
catch (err) {
console.error('Playback failed:', err)
}
}
}
}
function pause() {
getAudio().pause()
store.pause()
stopTimeUpdate()
}
function resume() {
const el = getAudio()
if (el.src) {
el.play()
store.play()
startTimeUpdate()
}
}
function togglePlayPause() {
if (store.isPlaying) {
pause()
}
else {
resume()
}
}
function seek(time: number) {
const el = getAudio()
el.currentTime = time
store.setCurrentTime(time)
}
function setVolume(vol: number) {
store.setVolume(vol)
getAudio().volume = store.volume
}
function playNext() {
const song = store.nextSong()
if (song) loadAndPlay(song)
}
function playPrev() {
const song = store.prevSong()
if (song) {
if (song === store.currentSong && store.currentTime <= 3) {
// prevSong already reset time
seek(0)
}
else {
loadAndPlay(song)
}
}
}
// Watch volume changes from store
watch(() => store.volume, (vol) => {
if (audio) audio.volume = vol
})
return {
loadAndPlay,
pause,
resume,
togglePlayPause,
seek,
setVolume,
playNext,
playPrev,
getAudio,
}
}

View File

@@ -0,0 +1,95 @@
import yaml from 'yaml'
import type { Song } from '~/types/song'
import type { ChapterSongLink, BookConfig } from '~/types/book'
let _configCache: BookConfig | null = null
async function loadConfig(): Promise<BookConfig> {
if (_configCache) return _configCache
const raw = await import('~/data/librodrome.config.yml?raw').then(m => m.default)
const parsed = yaml.parse(raw)
_configCache = {
title: parsed.book.title,
author: parsed.book.author,
description: parsed.book.description,
coverImage: parsed.book.coverImage,
chapters: [],
songs: parsed.songs as Song[],
chapterSongs: parsed.chapterSongs as ChapterSongLink[],
defaultPlaylistOrder: parsed.defaultPlaylistOrder as string[],
}
return _configCache
}
export function useBookData() {
const config = ref<BookConfig | null>(null)
const isLoaded = ref(false)
async function init() {
if (isLoaded.value) return
config.value = await loadConfig()
isLoaded.value = true
}
function getSongs(): Song[] {
return config.value?.songs ?? []
}
function getSongById(id: string): Song | undefined {
return config.value?.songs.find(s => s.id === id)
}
function getChapterSongs(chapterSlug: string): Song[] {
if (!config.value) return []
const links = config.value.chapterSongs.filter(cs => cs.chapterSlug === chapterSlug)
return links
.map(link => config.value!.songs.find(s => s.id === link.songId))
.filter((s): s is Song => !!s)
}
function getPrimarySong(chapterSlug: string): Song | undefined {
if (!config.value) return undefined
const link = config.value.chapterSongs.find(
cs => cs.chapterSlug === chapterSlug && cs.primary,
)
if (!link) return undefined
return config.value.songs.find(s => s.id === link.songId)
}
function getChapterSongLinks(chapterSlug: string): ChapterSongLink[] {
return config.value?.chapterSongs.filter(cs => cs.chapterSlug === chapterSlug) ?? []
}
function getPlaylistOrder(): Song[] {
if (!config.value) return []
return config.value.defaultPlaylistOrder
.map(id => config.value!.songs.find(s => s.id === id))
.filter((s): s is Song => !!s)
}
function getBookMeta() {
if (!config.value) return null
return {
title: config.value.title,
author: config.value.author,
description: config.value.description,
coverImage: config.value.coverImage,
}
}
return {
config,
isLoaded,
init,
getSongs,
getSongById,
getChapterSongs,
getPrimarySong,
getChapterSongLinks,
getPlaylistOrder,
getBookMeta,
}
}

View File

@@ -0,0 +1,16 @@
export function useGrateWizard() {
const appConfig = useAppConfig()
function launch() {
const { url, popup } = appConfig.gratewizard as { url: string; popup: { width: number; height: number } }
const left = Math.round((window.screen.width - popup.width) / 2)
const top = Math.round((window.screen.height - popup.height) / 2)
window.open(
url,
'GrateWizard',
`width=${popup.width},height=${popup.height},left=${left},top=${top},scrollbars=yes,resizable=yes`,
)
}
return { launch }
}

View File

@@ -0,0 +1,36 @@
export function useGuidedMode() {
const route = useRoute()
const store = usePlayerStore()
const bookData = useBookData()
const { loadAndPlay } = useAudioPlayer()
async function activateGuidedMode(chapterSlug: string) {
await bookData.init()
if (!store.isGuidedMode) return
const primarySong = bookData.getPrimarySong(chapterSlug)
if (primarySong && primarySong.id !== store.currentSong?.id) {
// Set the chapter's songs as the playlist
const chapterSongs = bookData.getChapterSongs(chapterSlug)
if (chapterSongs.length > 0) {
store.setPlaylist(chapterSongs)
}
loadAndPlay(primarySong)
}
}
// Watch route changes for guided mode
watch(
() => route.params.slug,
async (slug) => {
if (slug && typeof slug === 'string' && store.isGuidedMode) {
await activateGuidedMode(slug)
}
},
)
return {
activateGuidedMode,
}
}

View File

@@ -0,0 +1,63 @@
export function useMediaSession() {
const store = usePlayerStore()
const { togglePlayPause, playNext, playPrev, seek } = useAudioPlayer()
function updateMediaSession() {
if (!('mediaSession' in navigator)) return
if (!store.currentSong) return
navigator.mediaSession.metadata = new MediaMetadata({
title: store.currentSong.title,
artist: store.currentSong.artist,
album: 'Une économie du don — enfin concevable',
artwork: store.currentSong.coverImage
? [{ src: store.currentSong.coverImage, sizes: '512x512', type: 'image/jpeg' }]
: [],
})
navigator.mediaSession.setActionHandler('play', () => togglePlayPause())
navigator.mediaSession.setActionHandler('pause', () => togglePlayPause())
navigator.mediaSession.setActionHandler('previoustrack', () => playPrev())
navigator.mediaSession.setActionHandler('nexttrack', () => playNext())
navigator.mediaSession.setActionHandler('seekto', (details) => {
if (details.seekTime != null) seek(details.seekTime)
})
}
function updatePositionState() {
if (!('mediaSession' in navigator)) return
if (!store.currentSong || store.duration === 0) return
try {
navigator.mediaSession.setPositionState({
duration: store.duration,
playbackRate: 1,
position: Math.min(store.currentTime, store.duration),
})
}
catch {
// Ignore errors from invalid position state
}
}
// Watch for song changes
watch(() => store.currentSong, () => {
updateMediaSession()
})
// Update position periodically
watch(() => store.currentTime, () => {
updatePositionState()
})
// Update playback state
watch(() => store.isPlaying, (playing) => {
if ('mediaSession' in navigator) {
navigator.mediaSession.playbackState = playing ? 'playing' : 'paused'
}
})
return {
updateMediaSession,
}
}

View File

@@ -0,0 +1,5 @@
export function usePageContent<T = Record<string, unknown>>(name: string) {
return useAsyncData<T>(`page-${name}`, () =>
$fetch(`/api/content/pages/${name}`),
)
}

View File

@@ -0,0 +1,51 @@
import type { Song } from '~/types/song'
export function usePlaylist() {
const store = usePlayerStore()
const bookData = useBookData()
const { loadAndPlay } = useAudioPlayer()
async function loadFullPlaylist() {
await bookData.init()
const songs = bookData.getPlaylistOrder()
store.setPlaylist(songs)
}
function shufflePlaylist() {
const current = [...store.playlist]
// Fisher-Yates shuffle
for (let i = current.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[current[i], current[j]] = [current[j], current[i]]
}
store.setPlaylist(current)
store.toggleShuffle()
}
function unshuffle() {
const songs = bookData.getPlaylistOrder()
store.setPlaylist(songs)
store.toggleShuffle()
}
function playSongFromPlaylist(song: Song) {
loadAndPlay(song)
}
function toggleShuffle() {
if (store.isShuffled) {
unshuffle()
}
else {
shufflePlaylist()
}
}
return {
loadFullPlaylist,
shufflePlaylist,
unshuffle,
playSongFromPlaylist,
toggleShuffle,
}
}

View File

@@ -0,0 +1,40 @@
export function useScrollReveal() {
const observer = ref<IntersectionObserver | null>(null)
function init() {
if (typeof window === 'undefined') return
observer.value = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible')
observer.value?.unobserve(entry.target)
}
})
},
{
threshold: 0.1,
rootMargin: '0px 0px -50px 0px',
},
)
document.querySelectorAll('.scroll-reveal').forEach((el) => {
observer.value?.observe(el)
})
}
function destroy() {
observer.value?.disconnect()
}
onMounted(() => {
nextTick(() => init())
})
onUnmounted(() => {
destroy()
})
return { init, destroy }
}

View File

@@ -0,0 +1,30 @@
interface NavItem {
label: string
to: string
}
interface SiteContent {
identity: {
name: string
description: string
url: string
}
navigation: NavItem[]
footer: {
credits: string
links: NavItem[]
}
gratewizard: {
url: string
popup: {
width: number
height: number
}
}
}
export function useSiteContent() {
return useAsyncData<SiteContent>('site-content', () =>
$fetch('/api/content/site'),
)
}