initiation librodrome
This commit is contained in:
153
app/composables/useAudioPlayer.ts
Normal file
153
app/composables/useAudioPlayer.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
95
app/composables/useBookData.ts
Normal file
95
app/composables/useBookData.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
16
app/composables/useGrateWizard.ts
Normal file
16
app/composables/useGrateWizard.ts
Normal 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 }
|
||||
}
|
||||
36
app/composables/useGuidedMode.ts
Normal file
36
app/composables/useGuidedMode.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
63
app/composables/useMediaSession.ts
Normal file
63
app/composables/useMediaSession.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
5
app/composables/usePageContent.ts
Normal file
5
app/composables/usePageContent.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export function usePageContent<T = Record<string, unknown>>(name: string) {
|
||||
return useAsyncData<T>(`page-${name}`, () =>
|
||||
$fetch(`/api/content/pages/${name}`),
|
||||
)
|
||||
}
|
||||
51
app/composables/usePlaylist.ts
Normal file
51
app/composables/usePlaylist.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
40
app/composables/useScrollReveal.ts
Normal file
40
app/composables/useScrollReveal.ts
Normal 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 }
|
||||
}
|
||||
30
app/composables/useSiteContent.ts
Normal file
30
app/composables/useSiteContent.ts
Normal 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'),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user