Platine etape 1

This commit is contained in:
valere
2025-12-07 19:44:21 +01:00
parent 9f70419ea5
commit 6176995032
15 changed files with 771 additions and 27 deletions

View File

@@ -5,6 +5,7 @@
<img v-if="isCompilation" class="cover absolute" :src="`/${box.id}/${box.activeSide}/cover.jpg`" alt="" />
<div v-else class="size-full flex flex-col justify-center items-center text-7xl text-black"
v-html="box.description" />
<CinemaScreen />
</div>
<div class="face back flex flex-row flex-wrap items-start p-4 overflow-hidden"
:class="{ 'overflow-y-scroll': !isCompilation }" ref="backFace">
@@ -142,13 +143,6 @@ function rotateBox() {
applyTransform(0.8)
}
watch(
() => props.box.activeSide,
() => {
rotateBox()
}
)
// --- Inertie ---
function tickInertia() {
if (!enableInertia) return
@@ -246,6 +240,10 @@ onBeforeUnmount(() => {
})
// --- Watchers ---
watch(
() => props.box.activeSide,
() => rotateBox()
)
watch(
() => props.box.state,
() => applyBoxState()
@@ -255,7 +253,10 @@ watch(
() => applyColor(),
{ deep: true }
)
watch(isDraggable, (enabled) => (enabled ? addListeners() : removeListeners()))
watch(
isDraggable,
(enabled) => (enabled ? addListeners() : removeListeners())
)
</script>
<style lang="scss" scoped>

View File

@@ -2,11 +2,13 @@
<div class="boxes" :class="{ 'box-selected': uiStore.isBoxSelected }">
<box v-for="(box, i) in dataStore.boxes" :key="box.id" :tabindex="dataStore.boxes.length - i"
:box="getBoxToDisplay(box)" @click="openBox(box)" class="text-center" :class="box.state" :id="box.id">
<playButton @click.stop="playSelectedBox(box)" :objectToPlay="box" class="relative z-40 m-auto" />
<template v-if="box.state === 'box-selected'">
<deckCompilation :box="getBoxToDisplay(box)" class="box-page" v-if="box.type === 'compilation'"
:key="`${box.id}-${box.activeSide}`" @click.stop />
<deckPlaylist :box="box" class="box-page" v-if="box.type === 'playlist'" @click.stop />
<template v-if="box.type === 'compilation'">
<playButton @click.stop="playSelectedBox(box)" :objectToPlay="box" class="relative z-40 m-auto" />
<deckCompilation :box="getBoxToDisplay(box)" class="box-page" :key="`${box.id}-${box.activeSide}`"
@click.stop="" />
</template>
<deckPlaylist :box="box" class="box-page" v-if="box.type === 'playlist'" @click.stop="" />
</template>
</box>
</div>

View File

@@ -26,7 +26,7 @@
<!-- Cover -->
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-t-xl cursor-pointer"
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer"
@click="playerStore.playTrack(track)">
<playButton :objectToPlay="track" />
<img v-if="isFaceUp" :src="coverUrl" alt="Pochette de l'album" loading="lazy"
@@ -35,7 +35,7 @@
<!-- Body -->
<div class="p-3 text-center bg-white rounded-b-xl">
<!-- <div class="p-3 text-center bg-white rounded-b-xl">
<div v-if="isOrder" class="label">
{{ props.track.order }}
</div>
@@ -48,7 +48,7 @@
<p class="select-text">
{{ props.track.url.split('/')[4]?.split('__')[0] }}
</p>
</div>
</div> -->
</main>
<!-- Face-Down -->
@@ -154,7 +154,7 @@ const coverUrl = computed(() => {
@apply z-50;
.face-up {
@apply shadow-none;
@apply shadow-2xl;
transition:
box-shadow 0.6s,
transform 0.6s;

View File

@@ -1,3 +1,3 @@
<template>
<video class="fixed h-full w-full object-cover" ref="video" muted autoplay src=""></video>
<video class="h-full w-full object-cover" ref="video" muted autoplay src=""></video>
</template>

View File

@@ -2,7 +2,7 @@
<transition name="fade">
<div v-if="data.isLoading" class="fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/60 backdrop-blur-md" />
<img src="/loader.svg" alt="Loading" class="border-esyellow/30 border-2 relative h-40 w-40 p-6 rounded-full">
<img src="/loader.svg" alt="Loading" class="border-esyellow/30 border-4 relative h-40 w-40 p-6 rounded-full">
</div>
</transition>
</template>

206
app/components/Platine.vue Normal file
View File

@@ -0,0 +1,206 @@
<template>
<div class="layout player fixed z-40"
:class="playerStore.currentTrack ? '-bottom-1/4 opacity-100' : '-bottom-1/2 opacity-0'">
<div class="disc bg-slate-900" id="disc">
<div class="disc__que">
</div>
<div class="disc__label rounded-full bg-cover bg-center" :style="{
backgroundImage: playerStore.currentTrack?.coverId
? `url(${playerStore.currentTrack.coverId})`
: 'none'
}">
</div>
<div class="disc__middle">
</div>
</div>
</div>
<div class="control fixed bottom-0 z-50">
<button class="control button rewind" id="rewind">&lt;&lt;</button>
<button class="control button toggle" id="playToggle">power</button>
</div>
</template>
<script setup lang="ts">
import Disc from '@/platine-tools/disc';
import Sampler from '@/platine-tools/sampler';
import Controls from '@/platine-tools/controls';
import { usePlayerStore } from '~/store/player'
const playerStore = usePlayerStore()
onMounted(async () => {
const disc = new Disc(document.querySelector('#disc')!)
const sampler = new Sampler()
const controls = new Controls({
toggleButton: document.querySelector('#playToggle') as HTMLButtonElement,
rewindButton: document.querySelector('#rewind') as HTMLButtonElement,
})
await sampler.loadTrack('/jet.mp3')
controls.isDisabled = false
disc.setDuration(sampler.duration)
disc.start()
disc.callbacks.onStop = () => sampler.pause()
disc.callbacks.onDragEnded = () => {
if (!controls.isPlaying) {
return
}
sampler.play(disc.secondsPlayed)
}
disc.callbacks.onLoop = ({ playbackSpeed, isReversed, secondsPlayed }) => {
sampler.updateSpeed(playbackSpeed, isReversed, secondsPlayed)
}
controls.callbacks.onIsplayingChanged = (isPlaying) => {
if (isPlaying) {
disc.powerOn()
sampler.play(disc.secondsPlayed)
} else {
disc.powerOff()
}
}
controls.callbacks.onRewind = () => {
disc.rewind()
}
})
</script>
<style>
.player {
transition: all 1s ease-in-out;
}
.layout {
width: 100vw;
height: 100vw;
max-width: 500px;
max-height: 500px;
display: flex;
justify-content: center;
align-items: center;
}
.disc-container {
width: 100%;
aspect-ratio: 1;
display: flex;
justify-content: center;
align-items: center;
border-radius: 50%;
border: 2px solid #000;
background: linear-gradient(45deg, #f0f0f0, #424242);
}
.disc {
position: relative;
aspect-ratio: 1;
width: 100%;
overflow: hidden;
border-radius: 50%;
cursor: grab;
}
.disc.is-scratching {
cursor: grabbing;
}
.disc__que {
--dim: 20px;
position: absolute;
top: 50%;
right: 30px;
width: var(--dim);
height: var(--dim);
background: var(--color-theme);
border-radius: 50%;
}
.disc__label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
justify-content: center;
align-items: center;
text-align: center;
background-size: cover;
width: 45%;
aspect-ratio: 1/1;
/* background: no-repeat url(/logo.svg) center center; */
background-size: cover;
border-radius: 50%;
pointer-events: none;
}
.disc__middle {
--dim: 10px;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: var(--dim);
height: var(--dim);
background: rgb(26, 26, 26);
border-radius: 50%;
}
.button {
border-radius: 0;
border: none;
background: rgb(69, 69, 69);
font-size: 0.75rem;
padding: 0.4rem;
color: #fff;
line-height: 1.3;
cursor: pointer;
box-shadow:
1px 1px 0px 1px rgb(0 0 0),
0px 0px 0px 0px var(--color-theme);
will-change: box-shadow;
transition:
box-shadow 0.2s ease-out,
transform 0.05s ease-in;
}
.button.is-active {
transform: translate(1px, 2px);
box-shadow: 0px 0px 5px 1px var(--color-theme);
}
.button[disabled] {
opacity: 0.5;
}
</style>

View File

@@ -3,7 +3,6 @@
<slot />
<searchModal />
<loader />
<Player class="w-full border-t border-gray-200" />
<CinemaScreen />
<Platine />
</div>
</template>

View File

@@ -0,0 +1,48 @@
class Controls {
public toggleButton: HTMLButtonElement;
public rewindButton: HTMLButtonElement;
public isPlaying: boolean = false;
public callbacks = {
// @ts-expect-error: unused var
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onIsplayingChanged: (isPlaying: boolean) => {},
onRewind: () => {},
};
constructor({
toggleButton,
rewindButton,
}: {
toggleButton: HTMLButtonElement;
rewindButton: HTMLButtonElement;
}) {
this.toggleButton = toggleButton;
this.rewindButton = rewindButton;
this.toggleButton.addEventListener('click', () => this.toggle());
this.rewindButton.addEventListener('click', () => this.rewind());
this.isDisabled = true;
}
set isDisabled(disabled: boolean) {
this.toggleButton.disabled = disabled;
this.rewindButton.disabled = disabled;
}
toggle() {
this.isPlaying = !this.isPlaying;
this.toggleButton.classList.toggle('is-active', this.isPlaying);
this.callbacks.onIsplayingChanged(this.isPlaying);
}
rewind() {
this.callbacks.onRewind();
}
}
export default Controls;

379
app/platine-tools/disc.ts Normal file
View File

@@ -0,0 +1,379 @@
const TAU = Math.PI * 2
const targetFPS = 60
const RPS = 0.75
const RPM = RPS * 60
const RADIANS_PER_MINUTE = RPM * TAU
const RADIANS_PER_SECOND = RADIANS_PER_MINUTE / 60
const RADIANS_PER_MILLISECOND = RADIANS_PER_SECOND * 0.001
const ROTATION_SPEED = (TAU * RPS) / targetFPS
type Vector = {
x: number
y: number
}
type NumberArray = Array<number>
const average = (arr: NumberArray) => arr.reduce((memo, val) => memo + val, 0) / arr.length
// Limit array size by cutting off from the start
const limit = (arr: NumberArray, maxLength = 10) => {
const deleteCount = Math.max(0, arr.length - maxLength)
return arr.slice(deleteCount)
}
const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max))
const distanceBetween = (vec1: Vector, vec2: Vector) => Math.hypot(vec2.x - vec1.x, vec2.y - vec1.y)
const getElementCenter = (el: HTMLElement): Vector => {
const { left, top, width, height } = el.getBoundingClientRect()
const x = left + width / 2
const y = top + height / 2
return { x, y }
}
const angleBetween = (vec1: Vector, vec2: Vector) => Math.atan2(vec2.y - vec1.y, vec2.x - vec1.x)
const angleDifference = (x: number, y: number) => Math.atan2(Math.sin(x - y), Math.cos(x - y))
type DiscProgress = {
playbackSpeed: number
isReversed: boolean
secondsPlayed: number
progress: number
}
class Disc {
public el: HTMLDivElement
private _playbackSpeed = 1
private _duration = 0
private _isDragging = false
private _isPoweredOn = false
private _center: Vector
private _currentAngle = 0
private _previousAngle = 0
private _maxAngle = TAU
public rafId: number | null = null
public previousTimestamp: number
private _draggingSpeeds: Array<number> = []
private _draggingFrom: Vector = { x: 0, y: 0 }
// Propriétés pour l'inertie
private _inertiaVelocity: number = 0
private _isInertiaActive: boolean = false
private _basePlaybackSpeed: number = 1 // Vitesse de lecture normale
private _inertiaFriction: number = 0.93 // Coefficient de frottement pour l'inertie (plus proche de 1 = plus long)
private _lastDragVelocity: number = 0 // Dernière vitesse de drag
private _lastDragTime: number = 0 // Dernier temps de drag
private _inertiaAmplification: number = 25 // Facteur d'amplification de l'inertie
public isReversed: boolean = false
public callbacks = {
// @ts-expect-error: unused var
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onDragEnded: (secondsPlayed: number): void => {},
onStop: () => {},
// @ts-expect-error: unused var
// eslint-disable-next-line @typescript-eslint/no-unused-vars
onLoop: (params: DiscProgress) => {}
}
constructor(el: HTMLDivElement) {
this.el = el
this._center = getElementCenter(this.el)
this.previousTimestamp = performance.now()
this.onDragStart = this.onDragStart.bind(this)
this.onDragProgress = this.onDragProgress.bind(this)
this.onDragEnd = this.onDragEnd.bind(this)
this.loop = this.loop.bind(this)
this.init()
}
init() {
// Ajout du style pour désactiver le comportement tactile par défaut
this.el.style.touchAction = 'none'
// Écouteurs pour la souris et le tactile
this.el.addEventListener('pointerdown', this.onDragStart)
this.el.addEventListener(
'touchstart',
(e) => {
// Empêcher le défilement de la page
e.preventDefault()
this.onDragStart(e)
},
{ passive: false }
)
}
get playbackSpeed() {
return this._playbackSpeed
}
set playbackSpeed(s) {
this._draggingSpeeds.push(s)
this._draggingSpeeds = limit(this._draggingSpeeds, 10)
this._playbackSpeed = average(this._draggingSpeeds)
this._playbackSpeed = clamp(this._playbackSpeed, -4, 4)
}
get secondsPlayed() {
return this._currentAngle / TAU / RPS
}
set isDragging(d) {
this._isDragging = d
this.el.classList.toggle('is-scratching', d)
}
get isDragging() {
return this._isDragging
}
powerOn() {
if (!this.rafId) {
this.start()
}
this._isPoweredOn = true
this._basePlaybackSpeed = 1
this._playbackSpeed = 1
}
powerOff() {
this._isPoweredOn = false
this._basePlaybackSpeed = 0
}
public setDuration(duration: number) {
this._duration = duration
this._maxAngle = duration * RPS * TAU
}
onDragStart(e: PointerEvent | TouchEvent) {
// Empêcher le comportement par défaut pour éviter le défilement
e.preventDefault()
// Obtenir les coordonnées du toucher ou de la souris
const getCoords = (event: PointerEvent | TouchEvent): { x: number; y: number } => {
if ('touches' in event) {
return {
x: event.touches[0].clientX,
y: event.touches[0].clientY
}
} else {
return {
x: event.clientX,
y: event.clientY
}
}
}
const startCoords = getCoords(e)
const onMove = (moveEvent: Event) => {
if (!(moveEvent instanceof PointerEvent) && !(moveEvent instanceof TouchEvent)) return
const coords = getCoords(moveEvent)
this.onDragProgress({
clientX: coords.x,
clientY: coords.y,
preventDefault: () => moveEvent.preventDefault(),
stopPropagation: () => moveEvent.stopPropagation()
} as MouseEvent)
}
const onEnd = () => {
document.removeEventListener('pointermove', onMove)
document.removeEventListener('touchmove', onMove)
document.removeEventListener('pointerup', onEnd)
document.removeEventListener('touchend', onEnd)
this.onDragEnd()
}
document.addEventListener('pointermove', onMove)
document.addEventListener('touchmove', onMove, { passive: false })
document.addEventListener('pointerup', onEnd)
document.addEventListener('touchend', onEnd)
this._center = getElementCenter(this.el)
this._draggingFrom = startCoords
this.isDragging = true
}
onDragProgress(e: {
clientX: number
clientY: number
preventDefault: () => void
stopPropagation: () => void
}) {
const currentTime = performance.now()
const deltaTime = currentTime - this._lastDragTime
const pointerPosition: Vector = {
x: e.clientX,
y: e.clientY
}
const anglePointerToCenter = angleBetween(this._center, pointerPosition)
const angle_DraggingFromToCenter = angleBetween(this._center, this._draggingFrom)
const angleDragged = angleDifference(angle_DraggingFromToCenter, anglePointerToCenter)
// Calcul de la vitesse de déplacement angulaire (radians par milliseconde)
// On inverse le signe pour que le sens de l'inertie soit naturel
if (deltaTime > 0) {
this._lastDragVelocity = -angleDragged / deltaTime
}
this._lastDragTime = currentTime
this.setAngle(this._currentAngle - angleDragged)
this._draggingFrom = { ...pointerPosition }
}
onDragEnd() {
document.body.removeEventListener('pointermove', this.onDragProgress)
document.body.removeEventListener('pointerup', this.onDragEnd)
// Activer l'inertie avec la vitesse de drag actuelle
this._isInertiaActive = true
// Augmenter la sensibilité du drag avec le facteur d'amplification
this._inertiaVelocity = this._lastDragVelocity * this._inertiaAmplification
this.isDragging = false
// Toujours conserver la vitesse de base actuelle (1 si allumé, 0 si éteint)
this._basePlaybackSpeed = this._isPoweredOn ? 1 : 0
// Si le lecteur est éteint, s'assurer que la vitesse de base est bien à 0
if (!this._isPoweredOn) {
this._basePlaybackSpeed = 0
}
this.callbacks.onDragEnded(this.secondsPlayed)
}
autoRotate(currentTimestamp: number) {
const timestampElapsed = currentTimestamp - this.previousTimestamp
if (this._isInertiaActive) {
// Appliquer l'inertie
const inertiaRotation = this._inertiaVelocity * timestampElapsed
this.setAngle(this._currentAngle + inertiaRotation)
// Si le lecteur est allumé, faire une transition fluide vers la vitesse de lecture
if (this._isPoweredOn) {
// Si on est proche de la vitesse de lecture normale, on désactive l'inertie
if (
Math.abs(this._inertiaVelocity - RADIANS_PER_MILLISECOND * this._basePlaybackSpeed) <
0.0001
) {
this._isInertiaActive = false
this._inertiaVelocity = RADIANS_PER_MILLISECOND * this._basePlaybackSpeed
} else {
// Réduire progressivement la vitesse d'inertie vers la vitesse de lecture
this._inertiaVelocity +=
(RADIANS_PER_MILLISECOND * this._basePlaybackSpeed - this._inertiaVelocity) * 0.1
}
} else {
// Si le lecteur est éteint, appliquer un frottement normal
this._inertiaVelocity *= this._inertiaFriction
// Si la vitesse est très faible, on arrête l'inertie
if (Math.abs(this._inertiaVelocity) < 0.0001) {
this._isInertiaActive = false
this._inertiaVelocity = 0
this._playbackSpeed = 0 // Mettre à jour la vitesse de lecture à 0 uniquement à la fin
}
}
} else {
// Rotation normale à la vitesse de lecture de base
const baseRotation = RADIANS_PER_MILLISECOND * this._basePlaybackSpeed * timestampElapsed
this.setAngle(this._currentAngle + baseRotation)
}
}
setAngle(angle: number) {
this._currentAngle = clamp(angle, 0, this._maxAngle)
return this._currentAngle
}
start() {
this.previousTimestamp = performance.now()
this.loop()
}
stop() {
if (this.rafId) {
cancelAnimationFrame(this.rafId)
this.rafId = null
}
this.callbacks.onStop()
}
rewind() {
this.setAngle(0)
}
loop() {
const currentTimestamp = performance.now()
if (!this.isDragging) {
if (this._isPoweredOn) {
this.autoRotate(currentTimestamp)
} else {
// Mettre à jour le timestamp même quand le lecteur est éteint
// pour éviter un saut lors de la reprise
this.previousTimestamp = currentTimestamp
}
}
const timestampDifferenceMS = currentTimestamp - this.previousTimestamp
const rotated = this._currentAngle - this._previousAngle
const rotationNormal = RADIANS_PER_MILLISECOND * timestampDifferenceMS
this.playbackSpeed = rotated / rotationNormal || 0
this.isReversed = this._currentAngle < this._previousAngle
this._previousAngle = this._currentAngle
this.previousTimestamp = performance.now()
this.el.style.transform = `rotate(${this._currentAngle}rad)`
const { playbackSpeed, isReversed, secondsPlayed, _duration } = this
const progress = secondsPlayed / _duration
this.callbacks.onLoop({
playbackSpeed,
isReversed,
secondsPlayed,
progress
})
this._previousAngle = this._currentAngle
this.rafId = requestAnimationFrame(this.loop)
}
}
export default Disc

View File

@@ -0,0 +1,97 @@
class Sampler {
public audioContext: AudioContext = new AudioContext();
public gainNode: GainNode = new GainNode(this.audioContext);
public audioBuffer: AudioBuffer | null = null;
public audioBufferReversed: AudioBuffer | null = null;
public audioSource: AudioBufferSourceNode | null = null;
public duration: number = 0;
public isReversed: boolean = false;
constructor() {
this.gainNode.connect(this.audioContext.destination);
}
async getAudioBuffer(audioUrl: string) {
const response = await fetch(audioUrl);
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer);
return audioBuffer;
}
async loadTrack(audioUrl: string) {
this.audioBuffer = await this.getAudioBuffer(audioUrl);
this.audioBufferReversed = this.getReversedAudioBuffer(this.audioBuffer);
this.duration = this.audioBuffer.duration;
}
getReversedAudioBuffer(audioBuffer: AudioBuffer) {
const bufferArray = audioBuffer.getChannelData(0).slice().reverse();
const audioBufferReversed = this.audioContext.createBuffer(
1,
audioBuffer.length,
audioBuffer.sampleRate,
);
audioBufferReversed.getChannelData(0).set(bufferArray);
return audioBufferReversed;
}
changeDirection(isReversed: boolean, secondsPlayed: number) {
this.isReversed = isReversed;
this.play(secondsPlayed);
}
play(offset = 0) {
this.pause();
const buffer = this.isReversed
? this.audioBufferReversed
: this.audioBuffer;
const cueTime = this.isReversed ? this.duration - offset : offset;
this.audioSource = this.audioContext.createBufferSource();
this.audioSource.buffer = buffer;
this.audioSource.loop = false;
this.audioSource.connect(this.gainNode);
this.audioSource.start(0, cueTime);
}
updateSpeed(speed: number, isReversed: boolean, secondsPlayed: number) {
if (!this.audioSource) {
return;
}
if (isReversed !== this.isReversed) {
this.changeDirection(isReversed, secondsPlayed);
}
const { currentTime } = this.audioContext;
const speedAbsolute = Math.abs(speed);
this.audioSource.playbackRate.cancelScheduledValues(currentTime);
this.audioSource.playbackRate.linearRampToValueAtTime(
Math.max(0.001, speedAbsolute),
currentTime,
);
}
pause() {
if (!this.audioSource) {
return;
}
this.audioSource.stop();
}
}
export default Sampler;

View File

@@ -23,6 +23,11 @@ export const useCardStore = defineStore('card', {
this.saveToLocalStorage()
},
hideCard(trackId: number) {
this.revealedCards.delete(trackId)
this.saveToLocalStorage()
},
// Vérifier si une carte est révélée
isCardRevealed(trackId: number): boolean {
return this.revealedCards.has(trackId)
@@ -50,6 +55,13 @@ export const useCardStore = defineStore('card', {
this.saveToLocalStorage()
},
hideAllCards(tracks: Track[]) {
tracks.forEach((track) => {
this.hideCard(track.id)
})
this.saveToLocalStorage()
},
// Sauvegarder l'état dans le localStorage
saveToLocalStorage() {
if (typeof window !== 'undefined') {

View File

@@ -22,7 +22,10 @@ export const useDataStore = defineStore('data', {
// Mapper les tracks pour remplacer l'artist avec un objet Artist cohérent
const artistMap = new Map(this.artists.map((a) => [a.id, a]))
const allTracks = [...(compilationTracks ?? []), ...(playlistTracks ?? [])]
const allTracks = [
...(Array.isArray(compilationTracks) ? compilationTracks : []),
...(Array.isArray(playlistTracks) ? playlistTracks : [])
]
this.tracks = allTracks.map((track) => {
const a = track.artist as unknown

View File

@@ -46,8 +46,6 @@ export const useUiStore = defineStore('ui', {
dataStore.boxes.forEach((box) => {
box.state = 'box-list'
})
// Scroll back to the unselected box in the list
if (selectedBox) this.scrollToBox(selectedBox)
},
scrollToBox(box: Box) {

View File

@@ -19,6 +19,7 @@ export default defineNuxtConfig({
{ rel: 'apple-touch-icon', sizes: '180x180', href: '/favicon/apple-touch-icon.png' },
{ rel: 'manifest', href: '/favicon/site.webmanifest' }
],
viewport: 'width=device-width, initial-scale=1.0, maximum-scale=1.0',
script: isProd
? [
{

View File

@@ -4,14 +4,12 @@ import { eventHandler } from 'h3'
import { getCardFromDate } from '../../../utils/cards'
export default eventHandler(async (event) => {
const basePath = path.join(process.cwd(), '/mnt/media/files/music')
const dirPath = path.join(process.cwd(), '/mnt/media/files/music')
const urlPrefix = `https://files.erudi.fr/music`
try {
let allTracks: any[] = []
const dirPath = basePath
const urlPrefix = `https://files.erudi.fr/music`
let files = await fs.promises.readdir(dirPath)
files = files.filter((f) => !f.startsWith('.') && !f.endsWith('.jpg'))