Files
evilspins/app/components/Platine.vue
valere 399519d1d4
All checks were successful
Deploy App / build (push) Successful in 2m4s
Deploy App / deploy (push) Successful in 21s
multi cards
2026-02-11 16:49:34 +01:00

433 lines
11 KiB
Vue

<template>
<div class="platine" :class="{ 'loading': isLoadingTrack, 'mounted': isMounted, 'playing': isPlaying }" ref="platine">
<div v-if="true" class="debug">
<button @click="Reverse">
<b v-if="isReversed">reversed</b>
<b v-else>normal</b>
</button>
<button @click="Rewind">
Rewind
</button>
<button @click="Wind">
Wind
</button>
<!-- <div>{{ progressPercentage }}</div> -->
<div>{{ (currentSpeed).toFixed(2) }}</div>
</div>
<div class="disc" ref="discRef" id="disc">
<div class="bobine" :style="{
height: progressPercentage + '%',
width: progressPercentage + '%'
}"></div>
<button class="power-button" @click="Power" @touchstart="Power" :disabled="isLoadingTrack">
<img class="macaron" src="/favicon.svg">
<div class="spinner" v-if="isLoadingTrack" />
</button>
<div class="turn-point" v-if="!isLoadingTrack">
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { Card } from '~~/types/types'
import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue'
import Disc from '~/utils/platine/disc'
import Sampler from '~/utils/platine/sampler'
import { duration } from 'drizzle-orm/gel-core';
const props = defineProps<{ card?: Card, autoplay?: boolean }>()
const autoplay = props.autoplay ?? false
// State
const isLoadingTrack = ref(false)
const isFirstPlay = ref(true)
const progressPercentage = ref(0)
const currentTurns = ref(0)
const totalTurns = ref(0)// Refs pour les instances
// Refs pour les instances
const disc = ref<Disc | null>(null)
const sampler = ref<Sampler | null>(null)
const discRef = ref<HTMLElement>()
const platine = ref<HTMLElement>()
const isMounted = ref(false)
const isPlaying = computed(() => Math.abs(Math.round(sampler.value?.currentSpeed || 0)) !== 0)
const isReversed = computed(() => disc.value?.isReversed || false)
const currentSpeed = computed(() => sampler.value?.currentSpeed || 0)
// Actions
const initPlatine = (element: HTMLElement) => {
// console.log('[INIT] Platine')
discRef.value = element
disc.value = new Disc(element)
sampler.value = new Sampler()
// Configurer les callbacks du disque
if (disc.value) {
disc.value.callbacks.onStop = () => {
// console.log('[DISC] On Stop')
sampler.value?.pause()
}
disc.value.callbacks.onDragStart = () => {
// Activer le son à chaque fois qu'on glisse, pas seulement au premier play
if (sampler.value && disc.value) {
// On joue toujours le son quand on glisse, même après une pause
sampler.value.play(disc.value.secondsPlayed || 0)
}
}
disc.value.callbacks.onLoop = ({ playbackSpeed, isReversed, secondsPlayed }) => {
// Ne mettre à jour que si nécessaire et s'assurer que la position est valide
if (Math.abs((sampler.value?.currentSpeed || 0) - playbackSpeed) > 0.01) {
const safeSecondsPlayed = Math.max(0, secondsPlayed)
sampler.value?.updateSpeed(playbackSpeed, isReversed, safeSecondsPlayed)
}
updateTurns()
}
}
}
const updateTurns = () => {
if (!disc.value) return
const newTurns = disc.value.secondsPlayed * 0.75
const newTotalTurns = (disc.value as any)._duration * 0.75
// Calcul du pourcentage de progression pour l'affichage visuel (17% à 100%)
const minPercentage = 17
const maxPercentage = 100
const progressRatio = disc.value.secondsPlayed / (disc.value as any)._duration
const newProgressPercentage = minPercentage + (progressRatio * (maxPercentage - minPercentage))
// Ne mettre à jour que si les valeurs ont changé de manière significative
if (
Math.abs(currentTurns.value - newTurns) > 0.01 ||
Math.abs(totalTurns.value - newTotalTurns) > 0.01 ||
Math.abs(progressPercentage.value - newProgressPercentage) > 0.1
) {
currentTurns.value = newTurns
totalTurns.value = newTotalTurns
progressPercentage.value = newProgressPercentage
}
}
const loadCard = async (card: Card) => {
// console.log('[LOAD CARD]', card)
if (!sampler.value || !card) return
isLoadingTrack.value = true
// console.log(disc.value)
try {
await sampler.value.loadTrack(card.url_audio)
if (disc.value) {
// console.log('[DISC] Set Duration', sampler.value.duration)
disc.value.setDuration(sampler.value.duration)
updateTurns()
}
} finally {
isLoadingTrack.value = false
}
}
const play = (position = 0) => {
isFirstPlay.value = false
if (!disc.value || !sampler.value || !props.card) return
sampler.value.play(position)
disc.value.powerOn()
}
const pause = () => {
// console.log('[PAUSE]')
if (!disc.value || !sampler.value) return
sampler.value.pause()
disc.value.powerOff()
}
const togglePlay = () => {
if (isPlaying.value) {
pause()
} else {
// Reprendre la lecture à la position actuelle
const currentPosition = disc.value?.secondsPlayed || 0
play(currentPosition)
}
}
// Nettoyage
const cleanup = () => {
// console.log('[CLEANUP] Platine')
if (disc.value) {
disc.value.stop()
disc.value.powerOff()
}
if (sampler.value) {
sampler.value.pause()
}
}
const Power = (e: MouseEvent) => {
togglePlay()
}
const Reverse = () => {
if (!disc.value || !sampler.value) return
// Sauvegarder la position actuelle
const currentPosition = disc.value.secondsPlayed || 0
const wasPlaying = !disc.value.isStopped()
// Inverser la direction du disque et du sampler
disc.value.reverse()
sampler.value.reverse(wasPlaying ? currentPosition : 0)
}
const Rewind = async () => {
if (!disc.value || !sampler.value) return
// Sauvegarder l'état actuel
disc.value.isStopped() ? play() : null
const wasPlaying = !disc.value.isStopped()
const currentSpeed = disc.value.playbackSpeed
// Fonction pour l'effet de scratch/pull up
const scratchEffect = async () => {
// Ralentir progressivement
const slowDownDuration = 300 // ms
const startTime = performance.now()
const startSpeed = wasPlaying ? currentSpeed : 0.1
const slowDown = (timestamp: number) => {
const elapsed = timestamp - startTime
const progress = Math.min(elapsed / slowDownDuration, 1)
// Courbe d'accélération pour un effet plus naturel
const easeOut = 1 - Math.pow(1 - progress, 2)
// Ralentir progressivement jusqu'à presque l'arrêt
const newSpeed = startSpeed * (1 - easeOut) + 0.05
sampler.value?.setPlaybackRate(newSpeed)
if (progress < 1) {
requestAnimationFrame(slowDown)
} else {
// Une fois ralenti, effectuer le rembobinage
performRewind()
}
}
requestAnimationFrame(slowDown)
}
const performRewind = () => {
// Mettre en pause la lecture actuelle
if (wasPlaying) {
sampler.value?.pause()
}
// Remettre à zéro la position
sampler.value?.play(0)
disc.value?.setAngle(0)
// Effet de scratch rapide
const scratchDuration = 200 // ms
const scratchStart = performance.now()
const scratch = (timestamp: number) => {
const elapsed = timestamp - scratchStart
const progress = Math.min(elapsed / scratchDuration, 1)
// Créer un effet de scratch en variant la vitesse
if (progress < 0.5) {
// Phase de scratch vers l'arrière
const scratchProgress = progress * 2
const scratchSpeed = 1 - (scratchProgress * 1.8) // Ralenti jusqu'à -0.8
sampler.value?.setPlaybackRate(scratchSpeed)
} else {
// Phase de pull up
const pullProgress = (progress - 0.5) * 2
const pullSpeed = -0.8 + (pullProgress * 1.8) // Accélère de -0.8 à 1.0
sampler.value?.setPlaybackRate(pullSpeed)
}
if (progress < 1) {
requestAnimationFrame(scratch)
} else {
// Remettre la vitesse normale à la fin
sampler.value?.setPlaybackRate(1)
// Si c'était en lecture avant, relancer la lecture
if (wasPlaying) {
setTimeout(() => {
sampler.value?.play(0)
}, 50)
}
}
}
requestAnimationFrame(scratch)
}
// Démarrer l'effet
scratchEffect()
}
const Wind = () => {
disc.value.secondsPlayed = duration
sampler.value.secondsPlayed = duration
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.code === 'Space' || e.key === ' ' || e.keyCode === 32) {
e.preventDefault() // Empêcher le défilement de la page
togglePlay()
}
}
// Initialisation du lecteur
onMounted(async () => {
isMounted.value = true
if (discRef.value) {
initPlatine(discRef.value)
await loadCard(props.card!)
if (autoplay) {
await nextTick()
play()
}
}
window.addEventListener('keydown', handleKeyDown)
})
// Nettoyage de l'écouteur d'événement
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
isMounted.value = false
cleanup()
})
// Surveillance des changements de piste
watch(() => props.card, (newCard) => {
// console.log('[WATCH] Card', newCard)
if (newCard) {
loadCard(newCard)
}
})
</script>
<style lang="scss">
.platine {
overflow: hidden;
position: relative;
width: 100%;
height: 100%;
.disc {
pointer-events: auto;
position: absolute;
background-color: transparent;
position: relative;
aspect-ratio: 1;
width: 100%;
overflow: hidden;
border-radius: 50%;
cursor: grab;
background-position: center;
background-size: cover;
padding: 14px;
.loading & {
box-shadow: none;
}
}
.turn-point {
@apply absolute top-1/2 right-8 size-1/12 rounded-full bg-esyellow;
}
.disc.is-scratching {
cursor: grabbing;
}
.power-button {
position: absolute;
z-index: 100;
cursor: pointer;
transition: all 0.1s;
display: flex;
justify-content: center;
align-items: center;
transform-origin: center;
width: 33%;
height: 33%;
border-radius: 999px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
.macaron {
position: absolute;
filter: grayscale(1);
transition: transform 0.1s, filter 0.8s;
@apply size-2/3;
}
.spinner {
@apply size-1/2;
}
&:active {
.macaron {
transform: scale(0.8);
}
}
}
&.playing .power-button .macaron {
filter: grayscale(0);
}
.disc-middle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 20px;
height: 20px;
background: rgb(26, 26, 26);
border-radius: 50%;
}
.spinner {
width: 40px;
height: 40px;
border: 1px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
// @apply md:border-8;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.bobine {
width: 17%;
height: 17%;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
@apply relative bg-black bg-opacity-30 backdrop-blur-sm rounded-full;
}
}
</style>