WIP starbook demo
All checks were successful
Deploy App / build (push) Successful in 34s
Deploy App / deploy (push) Successful in 25s

This commit is contained in:
valere
2026-02-10 07:31:31 +01:00
parent 7fa6f6ccc8
commit 7be09dd12d
17 changed files with 516 additions and 914 deletions

View File

@@ -1,8 +1,36 @@
<template> <template>
<div class="min-h-screen bg-gray-100"> <div class="min-h-screen" @keydown.esc="resetFocus">
<NuxtRouteAnnouncer /> <NuxtRouteAnnouncer />
<NuxtLayout> <NuxtLayout>
<NuxtPage /> <NuxtPage ref="pageContent" />
</NuxtLayout> </NuxtLayout>
</div> </div>
</template> </template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const pageContent = ref < HTMLElement | null > (null)
const resetFocus = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
// On déplace le focus sur l'élément racine
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
// On force le focus sur le body
document.body.focus()
}
}
// Ajout du gestionnaire d'événement au montage du composant
onMounted(() => {
window.addEventListener('keydown', resetFocus)
})
// Nettoyage du gestionnaire d'événement lors du démontage du composant
onUnmounted(() => {
window.removeEventListener('keydown', resetFocus)
})
</script>

View File

@@ -4,27 +4,19 @@
isFaceUp ? 'face-up' : 'face-down', isFaceUp ? 'face-up' : 'face-down',
showPlayButtonFaceUp ? 'show-play-button-face-up' : '' showPlayButtonFaceUp ? 'show-play-button-face-up' : ''
]" :tabindex="props.tabindex" :aria-disabled="false" @click="$emit('click', $event)" ]" :tabindex="props.tabindex" :aria-disabled="false" @click="$emit('click', $event)"
@keydown.enter="$emit('click', $event)" @keydown.space.prevent="$emit('click', $event)"> @keydown.enter="$emit('click', $event)">
<div class="flip-inner" ref="cardElement"> <div class="flip-inner" ref="cardElement">
<!-- Face-Up --> <!-- Face-Up -->
<main <main
class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 hover:shadow-xl hover:scale-110 transition-all rounded-2xl shadow-lg flex flex-col overflow-hidden"> class="face-up backdrop-blur-sm border-2 z-10 card w-56 h-80 p-3 hover:shadow-xl hover:scale-110 transition-all shadow-lg flex flex-col overflow-hidden">
<div class="flex items-center justify-center size-7 absolute top-7 right-7"> <Rank :card="props.card" />
<div class="suit text-7xl absolute"
:class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.card?.suit]">
<img :src="`/${props.card?.suit}.svg`" />
</div>
<div class="rank text-white font-bold absolute -mt-1">
{{ props.card?.rank }}
</div>
</div>
<!-- Cover --> <!-- Cover -->
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer"> <figure class="flex-1 flex justify-center items-center overflow-hidden cursor-pointer">
<playButton /> <PlayButton />
<img :src="props.card.url_image" alt="Pochette de l'album" :loading="props.imageLoadingType" <img :src="props.card.url_image" alt="Pochette de l'album" :loading="props.imageLoadingType"
@load="$emit('image-loaded', $event)" class="w-full h-full object-cover object-center" /> @load="$emit('image-loaded', $event)" class="pochette w-full h-full object-cover object-center" />
</figure> </figure>
<!-- Body --> <!-- Body -->
@@ -44,7 +36,7 @@
class="face-down backdrop-blur-sm z-10 card w-56 h-80 p-3 bg-opacity-10 bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden select-none"> class="face-down backdrop-blur-sm z-10 card w-56 h-80 p-3 bg-opacity-10 bg-white rounded-2xl shadow-lg flex flex-col overflow-hidden select-none">
<figure class="h-full flex text-center rounded-xl justify-center items-center" <figure class="h-full flex text-center rounded-xl justify-center items-center"
:style="{ backgroundColor: cardColor }"> :style="{ backgroundColor: cardColor }">
<playButton /> <PlayButton />
<img src="/face-down.svg" /> <img src="/face-down.svg" />
</figure> </figure>
</footer> </footer>
@@ -75,7 +67,6 @@ const props = withDefaults(defineProps<{
import { getYearColor } from '~/utils/colors' import { getYearColor } from '~/utils/colors'
const cardColor = computed(() => getYearColor(props.card.year || 0)) const cardColor = computed(() => getYearColor(props.card.year || 0))
const isRedCard = computed(() => (props.card?.suit === '♥' || props.card?.suit === '♦'))
/* loading states of the card */ /* loading states of the card */
const isApiLoaded = ref(false) const isApiLoaded = ref(false)
@@ -103,6 +94,10 @@ const isTrackLoaded = ref(false)
perspective: 1000px; perspective: 1000px;
@apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80; @apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80;
.pochette {
border-radius: 0.75rem;
}
.flip-inner { .flip-inner {
position: relative; position: relative;
width: 100%; width: 100%;
@@ -123,6 +118,7 @@ const isTrackLoaded = ref(false)
} }
.face-up { .face-up {
border-radius: 1rem;
transform: rotateY(0deg); transform: rotateY(0deg);
transition: box-shadow 0.6s; transition: box-shadow 0.6s;
} }
@@ -158,7 +154,7 @@ const isTrackLoaded = ref(false)
@apply shadow-2xl; @apply shadow-2xl;
transition: transition:
box-shadow 0.6s, box-shadow 0.6s,
transform 0.6s; transform 6s;
} }
} }

View File

@@ -1,49 +1,43 @@
<template> <template>
<div class="platine" :class="{ 'loading': isLoadingCard, 'mounted': isMounted }" ref="platine"> <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>
<div>{{ progressPercentage }}</div>
<div>{{ currentSpeed }}</div>
</div>
<div class="disc" ref="discRef" id="disc"> <div class="disc" ref="discRef" id="disc">
<div class="bobine" :style="{ <div class="bobine" :style="{
height: progressPercentage + '%', height: progressPercentage + '%',
width: progressPercentage + '%' width: progressPercentage + '%'
}"></div> }"></div>
<div class="power-button" @mousedown.stop @click.stop="handlePowerButtonClick"> <button class="power-button" @click="Power" @touchstart="Power" :disabled="isLoadingTrack">
<img class="power-logo" src="/favicon.svg"> <img class="macaron" src="/favicon.svg">
<div class="power-loading" v-if="isLoadingCard"> <div class="spinner" v-if="isLoadingTrack" />
<div class="spinner"></div> </button>
<div class="turn-point" v-if="!isLoadingTrack">
</div> </div>
</div> </div>
<div class="turn-point" v-if="!isLoadingCard">
</div>
</div>
<div class="debug">
<b>progressPercentage</b>: <br>
</br>{{ Math.round(progressPercentage) }}%
<br>
<b>Disc</b>: <br>
</br>{{ Math.round(disc?.secondsPlayed || 0) }}sec
<br>
</br>{{ Math.round(sampler?.duration || 0) }}sec
<br>
<!-- <pre>{{ sampler }}</pre> -->
isPlaying:
</br>{{ isPlaying }}
<br>
<b>isFirstDrag</b>: <br>
</br>{{ isFirstDrag }}
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Card } from '~~/types/types' import type { Card } from '~~/types/types'
import { ref, onMounted, onUnmounted, watch, computed } from 'vue' import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue'
import Disc from '~/utils/platine/disc' import Disc from '~/utils/platine/disc'
import Sampler from '~/utils/platine/sampler' import Sampler from '~/utils/platine/sampler'
const props = defineProps<{ card?: Card }>() const props = defineProps<{ card?: Card, autoplay?: boolean }>()
const autoplay = props.autoplay ?? false
// State // State
const isLoadingCard = ref(false) const isLoadingTrack = ref(false)
const isFirstDrag = ref(true) const isFirstPlay = ref(true)
const progressPercentage = ref(0) const progressPercentage = ref(0)
const currentTurns = ref(0) const currentTurns = ref(0)
const totalTurns = ref(0)// Refs pour les instances const totalTurns = ref(0)// Refs pour les instances
@@ -56,6 +50,8 @@ const platine = ref<HTMLElement>()
const isMounted = ref(false) const isMounted = ref(false)
const isPlaying = computed(() => Math.abs(Math.round(sampler.value?.currentSpeed || 0)) !== 0) 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 // Actions
const initPlatine = (element: HTMLElement) => { const initPlatine = (element: HTMLElement) => {
@@ -72,22 +68,12 @@ const initPlatine = (element: HTMLElement) => {
} }
disc.value.callbacks.onDragStart = () => { disc.value.callbacks.onDragStart = () => {
// console.log('[DISC] On Drag Start') // Activer le son à chaque fois qu'on glisse, pas seulement au premier play
if (isFirstDrag.value) {
isFirstDrag.value = false
// togglePlay()
if (sampler.value && disc.value) { if (sampler.value && disc.value) {
sampler.value.play(disc.value.secondsPlayed) // On joue toujours le son quand on glisse, même après une pause
disc.value.powerOn() sampler.value.play(disc.value.secondsPlayed || 0)
// console.log('[DISC] Power ON')
} }
} }
}
disc.value.callbacks.onDragEnded = () => {
// console.log('[DISC] On Drag END')
sampler.value?.play(disc.value?.secondsPlayed || 0)
}
disc.value.callbacks.onLoop = ({ playbackSpeed, isReversed, secondsPlayed }) => { disc.value.callbacks.onLoop = ({ playbackSpeed, isReversed, secondsPlayed }) => {
// Ne mettre à jour que si nécessaire et s'assurer que la position est valide // Ne mettre à jour que si nécessaire et s'assurer que la position est valide
@@ -106,10 +92,11 @@ const updateTurns = () => {
const newTurns = disc.value.secondsPlayed * 0.75 const newTurns = disc.value.secondsPlayed * 0.75
const newTotalTurns = (disc.value as any)._duration * 0.75 const newTotalTurns = (disc.value as any)._duration * 0.75
// Calcul du pourcentage de progression pour l'affichage visuel (22% à 100%) // Calcul du pourcentage de progression pour l'affichage visuel (17% à 100%)
const minPercentage = 22 const minPercentage = 17
const maxPercentage = 100
const progressRatio = disc.value.secondsPlayed / (disc.value as any)._duration const progressRatio = disc.value.secondsPlayed / (disc.value as any)._duration
const newProgressPercentage = minPercentage + (progressRatio * (100 - minPercentage)) const newProgressPercentage = minPercentage + (progressRatio * (maxPercentage - minPercentage))
// Ne mettre à jour que si les valeurs ont changé de manière significative // Ne mettre à jour que si les valeurs ont changé de manière significative
if ( if (
@@ -127,7 +114,7 @@ const loadCard = async (card: Card) => {
// console.log('[LOAD CARD]', card) // console.log('[LOAD CARD]', card)
if (!sampler.value || !card) return if (!sampler.value || !card) return
isLoadingCard.value = true isLoadingTrack.value = true
// console.log(disc.value) // console.log(disc.value)
try { try {
await sampler.value.loadTrack(card.url_audio) await sampler.value.loadTrack(card.url_audio)
@@ -137,12 +124,12 @@ const loadCard = async (card: Card) => {
updateTurns() updateTurns()
} }
} finally { } finally {
isLoadingCard.value = false isLoadingTrack.value = false
} }
} }
const play = (position = 0) => { const play = (position = 0) => {
// console.log('[PLAY]') isFirstPlay.value = false
if (!disc.value || !sampler.value || !props.card) return if (!disc.value || !sampler.value || !props.card) return
sampler.value.play(position) sampler.value.play(position)
@@ -179,11 +166,24 @@ const cleanup = () => {
} }
} }
const handlePowerButtonClick = (e: MouseEvent) => { const Power = (e: MouseEvent) => {
e.stopPropagation() // Empêcher la propagation de l'événement
e.preventDefault() // Empêcher tout comportement par défaut
togglePlay() togglePlay()
return false // Empêcher tout autre comportement }
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 () => {
// ...
} }
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -194,14 +194,17 @@ const handleKeyDown = (e: KeyboardEvent) => {
} }
// Initialisation du lecteur // Initialisation du lecteur
onMounted(() => { onMounted(async () => {
isMounted.value = true isMounted.value = true
if (discRef.value) { if (discRef.value) {
initPlatine(discRef.value) initPlatine(discRef.value)
loadCard(props.card!) await loadCard(props.card!)
if (autoplay) {
await nextTick()
play()
}
} }
// Ajouter l'écouteur d'événement clavier
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
}) })
@@ -224,13 +227,10 @@ watch(() => props.card, (newCard) => {
<style lang="scss"> <style lang="scss">
.platine { .platine {
pointer-events: none;
overflow: hidden; overflow: hidden;
position: relative; position: relative;
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 20px;
}
.disc { .disc {
pointer-events: auto; pointer-events: auto;
@@ -244,7 +244,7 @@ watch(() => props.card, (newCard) => {
cursor: grab; cursor: grab;
background-position: center; background-position: center;
background-size: cover; background-size: cover;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.4); padding: 14px;
.loading & { .loading & {
box-shadow: none; box-shadow: none;
@@ -260,41 +260,41 @@ watch(() => props.card, (newCard) => {
} }
.power-button { .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; border-radius: 999px;
background-size: cover;
background-position: center;
position: absolute;
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
display: flex;
justify-content: center;
align-items: center;
text-align: center;
width: 45%;
aspect-ratio: 1/1;
// background: no-repeat url(/favicon.svg) center center;
background-size: 30%;
border-radius: 50%;
cursor: pointer !important;
.power-logo { .macaron {
@apply size-1/2 bg-black rounded-full p-5;
}
.power-loading {
position: absolute; position: absolute;
top: 50%; filter: grayscale(1);
left: 50%; transition: transform 0.1s, filter 0.8s;
transform: translate(-50%, -50%); @apply size-2/3;
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
} }
.spinner {
@apply size-1/2;
}
&:active {
.macaron {
transform: scale(0.8);
}
}
}
&.playing .power-button .macaron {
filter: grayscale(0);
} }
.disc-middle { .disc-middle {
@@ -308,37 +308,14 @@ watch(() => props.card, (newCard) => {
border-radius: 50%; 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;
will-change: box-shadow;
transition:
box-shadow 0.2s ease-out,
transform 0.05s ease-in;
}
.power.is-active {
transform: translate(1px, 2px);
color: red;
}
.button[disabled] {
opacity: 0.5;
}
.spinner { .spinner {
width: 40px; width: 40px;
height: 40px; height: 40px;
border: 4px solid rgba(255, 255, 255, 0.3); border: 1px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff; border-top-color: #fff;
border-radius: 50%; border-radius: 50%;
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
// @apply md:border-8;
} }
@keyframes spin { @keyframes spin {
@@ -348,10 +325,13 @@ watch(() => props.card, (newCard) => {
} }
.bobine { .bobine {
@apply bg-slate-900 bg-opacity-50 backdrop-blur absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 rounded-full; 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;
} }
.debug {
@apply fixed top-4 right-4 bg-slate-200 rounded-md p-2;
} }
</style> </style>

24
app/components/Rank.vue Normal file
View File

@@ -0,0 +1,24 @@
<template>
<div class="rank flex items-center justify-center size-7 absolute top-7 right-7">
<div class="suit text-7xl absolute" :class="[isRedCard ? 'text-red-600' : 'text-slate-800', props.card?.suit]">
<img :src="`/${props.card?.suit}.svg`" />
</div>
<div class="rank text-white font-bold absolute -mt-1">
{{ props.card?.rank }}
</div>
</div>
</template>
<script setup lang="ts">
import type { Card } from '~~/types/types';
const props = defineProps<{ card?: Card }>()
const isRedCard = computed(() => (props.card?.suit === '♥' || props.card?.suit === '♦'))
</script>
<style>
.rank {
transition: opacity 0.5s ease-in-out;
}
</style>

View File

@@ -0,0 +1,39 @@
<template>
<button ref="buttonRef">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="SVGRepo_bgCarrier" stroke-width="0"></g>
<g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g>
<g id="SVGRepo_iconCarrier">
<path fill-rule="evenodd" clip-rule="evenodd"
d="M15.7071 5.29289C16.0976 5.68342 16.0976 6.31658 15.7071 6.70711L10.4142 12L15.7071 17.2929C16.0976 17.6834 16.0976 18.3166 15.7071 18.7071C15.3165 19.0976 14.6834 19.0976 14.2929 18.7071L8.46963 12.8839C7.98148 12.3957 7.98148 11.6043 8.46963 11.1161L14.2929 5.29289C14.6834 4.90237 15.3165 4.90237 15.7071 5.29289Z"
fill="#0F1729"></path>
</g>
</svg>
</button>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
const buttonRef = ref<HTMLButtonElement | null>(null)
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && buttonRef.value) {
buttonRef.value.click()
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
window.removeEventListener('keydown', handleKeyDown)
})
</script>
<style scoped lang="scss">
button {
@apply fixed bottom-4 md:top-4 left-4 size-20;
}
</style>

View File

@@ -1,7 +1,5 @@
<template> <template>
<button tabindex="-1" <button tabindex="-1" class="play-button" :class="{ loading: isLoading }" :disabled="isLoading">
class="play-button pointer-events-none rounded-full size-24 flex items-center justify-center text-esyellow backdrop-blur-sm bg-black/25 transition-all duration-200 ease-in-out transform active:scale-90 scale-110 text-4xl font-bold"
:class="{ loading: isLoading }" :disabled="isLoading">
<template v-if="props.isLoading"> <template v-if="props.isLoading">
<img src="/loader.svg" alt="Chargement" class="size-16" /> <img src="/loader.svg" alt="Chargement" class="size-16" />
</template> </template>
@@ -21,7 +19,11 @@ const props = withDefaults(defineProps<{
}) })
</script> </script>
<style> <style scoped lang="scss">
.play-button {
@apply pointer-events-none rounded-full size-24 flex items-center justify-center text-esyellow backdrop-blur-sm bg-black/25 transition-all duration-100 ease-in-out transform active:scale-90 scale-110 text-4xl font-bold;
}
.loading, .loading,
.play-button-changed { .play-button-changed {
opacity: 1 !important; opacity: 1 !important;

View File

@@ -19,7 +19,7 @@ useHead({
}) })
const clickOnSlugCard = () => { const clickOnSlugCard = () => {
isFaceUp.value = true //
} }
const imageLoaded = () => { const imageLoaded = () => {
@@ -28,7 +28,7 @@ const imageLoaded = () => {
onMounted(() => { onMounted(() => {
setTimeout(() => { setTimeout(() => {
clickOnSlugCard() isFaceUp.value = true
}, 700) }, 700)
}) })
</script> </script>

View File

@@ -1,8 +1,8 @@
<template> <template>
<section class="flex justify-center items-center h-screen"> <section class="platine-card" :class="{ 'platine-card--platine-open': platineOpen }">
<div class="aspect-square size-[100vmin] max-h-screen max-w-screen overflow-hidden"> <Card :card="card!" :is-face-up @click="clickOnCard" :role="platineOpen ? 'img' : 'button'" showPlayButtonFaceUp />
<Platine :card="card" /> <Platine v-if="platineOpen && card" :card="card" autoplay />
</div> <CloseButton v-if="platineOpen" @click="platineOpen = false" />
</section> </section>
</template> </template>
@@ -11,9 +11,84 @@ import type { Card } from '~~/types/types'
const { data: card, pending, error } = await useFetch<Card>('/api/card/random') const { data: card, pending, error } = await useFetch<Card>('/api/card/random')
const isFaceUp = ref(false)
const platineOpen = ref(false)
useHead({ useHead({
title: computed(() => title: computed(() =>
card.value ? `${card.value.artist} - ${card.value.title}` : 'Loading...' card.value ? `${card.value.artist} - ${card.value.title}` : 'Loading...'
) )
}) })
onMounted(() => {
setTimeout(() => {
isFaceUp.value = true
}, 700)
})
const clickOnCard = () => {
platineOpen.value = !platineOpen.value
}
</script> </script>
<style scoped lang="scss">
$open-speed: 0.4s;
.platine-card {
@apply screen-centered h-screen;
:deep(.card) {
transition: width $open-speed, height $open-speed, transform 0.1s;
position: absolute;
.face-up,
.pochette {
transition: border-radius $open-speed, box-shadow $open-speed;
&:active {
.play-button {
@apply scale-90;
}
}
}
}
&--platine-open {
.card {
pointer-events: none;
&:focus {
@apply z-auto scale-100;
}
}
.card,
.platine {
width: 100vmin;
height: 100vmin;
}
&:deep(.card) {
.face-up {
border-radius: 999px;
@apply shadow-xl;
.pochette {
border-radius: 999px;
}
.rank {
opacity: 0;
}
}
.play-button,
.card-body,
.face-down {
display: none;
opacity: 0;
}
}
}
}
</style>

View File

@@ -74,16 +74,17 @@ class Disc {
private _inertiaVelocity: number = 0 private _inertiaVelocity: number = 0
private _isInertiaActive: boolean = false private _isInertiaActive: boolean = false
private _basePlaybackSpeed: number = 1 // Vitesse de lecture normale 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 _inertiaFriction: number = 1 // Coefficient de frottement pour l'inertie (plus proche de 1 = plus long)
private _lastDragVelocity: number = 0 // Dernière vitesse de drag private _lastDragVelocity: number = 0 // Dernière vitesse de drag
private _lastDragTime: number = 0 // Dernier temps de drag private _lastDragTime: number = 0 // Dernier temps de drag
private _inertiaAmplification: number = 25 // Facteur d'amplification de l'inertie private _inertiaAmplification: number = 45 // Facteur d'amplification de l'inertie
private _previousDuration: number = 0 // Pour suivre les changements de durée private _previousDuration: number = 0 // Pour suivre les changements de durée
public isReversed: boolean = false public isReversed: boolean = false
public callbacks = { public callbacks = {
onDragStart: (): void => {}, onDragStart: (): void => {},
onDragProgress: (): void => {},
onDragEnded: (secondsPlayed: number): void => {}, onDragEnded: (secondsPlayed: number): void => {},
onStop: (): void => {}, onStop: (): void => {},
onLoop: (params: DiscProgress): void => {} onLoop: (params: DiscProgress): void => {}
@@ -147,12 +148,9 @@ class Disc {
} }
powerOn() { powerOn() {
if (!this.rafId) {
this.start()
}
this._isPoweredOn = true this._isPoweredOn = true
this._basePlaybackSpeed = 1 this._basePlaybackSpeed = this.isReversed ? -1 : 1
this._playbackSpeed = 1 this.start()
} }
powerOff() { powerOff() {
@@ -250,15 +248,18 @@ class Disc {
const anglePointerToCenter = angleBetween(this._center, pointerPosition) const anglePointerToCenter = angleBetween(this._center, pointerPosition)
const angle_DraggingFromToCenter = angleBetween(this._center, this._draggingFrom) const angle_DraggingFromToCenter = angleBetween(this._center, this._draggingFrom)
const angleDragged = angleDifference(angle_DraggingFromToCenter, anglePointerToCenter) let angleDragged = angleDifference(angle_DraggingFromToCenter, anglePointerToCenter)
// Calcul de la vitesse de déplacement angulaire (radians par milliseconde) // Calcul de la vitesse de déplacement angulaire (radians par milliseconde)
// On inverse le signe pour que le sens de l'inertie soit naturel // Le signe est inversé pour que le glissement vers la droite fasse tourner vers la droite
if (deltaTime > 0) { if (deltaTime > 0) {
this._lastDragVelocity = -angleDragged / deltaTime this._lastDragVelocity = -angleDragged / deltaTime
} }
this._lastDragTime = currentTime this._lastDragTime = currentTime
// Appliquer la rotation au disque
// Le signe est inversé pour que le glissement vers la droite fasse tourner vers la droite
this.setAngle(this._currentAngle - angleDragged) this.setAngle(this._currentAngle - angleDragged)
this._draggingFrom = { ...pointerPosition } this._draggingFrom = { ...pointerPosition }
} }
@@ -269,12 +270,15 @@ class Disc {
// Activer l'inertie avec la vitesse de drag actuelle // Activer l'inertie avec la vitesse de drag actuelle
this._isInertiaActive = true this._isInertiaActive = true
// Augmenter la sensibilité du drag avec le facteur d'amplification
this._inertiaVelocity = this._lastDragVelocity * this._inertiaAmplification // Ajuster la direction de l'inertie en fonction du mode reverse
const direction = this.isReversed ? -1 : 1
this._inertiaVelocity = this._lastDragVelocity * this._inertiaAmplification * direction
this.isDragging = false this.isDragging = false
// Toujours conserver la vitesse de base actuelle (1 si allumé, 0 si éteint) // Toujours conserver la vitesse de base actuelle (1 si allumé, 0 si éteint)
this._basePlaybackSpeed = this._isPoweredOn ? 1 : 0 this._basePlaybackSpeed = this._isPoweredOn ? (this.isReversed ? -1 : 1) : 0
// Si le lecteur est éteint, s'assurer que la vitesse de base est bien à 0 // Si le lecteur est éteint, s'assurer que la vitesse de base est bien à 0
if (!this._isPoweredOn) { if (!this._isPoweredOn) {
@@ -286,25 +290,41 @@ class Disc {
autoRotate(currentTimestamp: number) { autoRotate(currentTimestamp: number) {
const timestampElapsed = currentTimestamp - this.previousTimestamp const timestampElapsed = currentTimestamp - this.previousTimestamp
const direction = this.isReversed ? -1 : 1
// Vérifier si on est au début du morceau en mode reverse
if (this.isReversed && this.secondsPlayed <= 0) {
this._currentAngle = 0
this._inertiaVelocity = 0
this._isInertiaActive = false
this._playbackSpeed = 0
this._basePlaybackSpeed = 0 // Arrêter complètement la lecture
this.el.style.transform = 'rotate(0rad)'
this.callbacks.onStop()
// Éteindre le lecteur pour éviter toute reprise automatique
this._isPoweredOn = false
this.stop()
return
}
if (this._isInertiaActive) { if (this._isInertiaActive) {
// Appliquer l'inertie // Appliquer l'inertie en tenant compte de la direction
const inertiaRotation = this._inertiaVelocity * timestampElapsed const inertiaRotation = this._inertiaVelocity * timestampElapsed * direction
this.setAngle(this._currentAngle + inertiaRotation) this.setAngle(this._currentAngle + inertiaRotation, true)
// Si le lecteur est allumé, faire une transition fluide vers la vitesse de lecture // Si le lecteur est allumé, faire une transition fluide vers la vitesse de lecture
if (this._isPoweredOn) { if (this._isPoweredOn) {
const targetVelocity =
RADIANS_PER_MILLISECOND * Math.abs(this._basePlaybackSpeed) * direction
// Si on est proche de la vitesse de lecture normale, on désactive l'inertie // Si on est proche de la vitesse de lecture normale, on désactive l'inertie
if ( if (Math.abs(this._inertiaVelocity - targetVelocity) < 0.0001) {
Math.abs(this._inertiaVelocity - RADIANS_PER_MILLISECOND * this._basePlaybackSpeed) <
0.0001
) {
this._isInertiaActive = false this._isInertiaActive = false
this._inertiaVelocity = RADIANS_PER_MILLISECOND * this._basePlaybackSpeed this._inertiaVelocity = targetVelocity
} else { } else {
// Réduire progressivement la vitesse d'inertie vers la vitesse de lecture // Réduire progressivement la vitesse d'inertie vers la vitesse de lecture
this._inertiaVelocity += this._inertiaVelocity += (targetVelocity - this._inertiaVelocity) * 0.1
(RADIANS_PER_MILLISECOND * this._basePlaybackSpeed - this._inertiaVelocity) * 0.1
} }
} else { } else {
// Si le lecteur est éteint, appliquer un frottement normal // Si le lecteur est éteint, appliquer un frottement normal
@@ -314,18 +334,61 @@ class Disc {
if (Math.abs(this._inertiaVelocity) < 0.0001) { if (Math.abs(this._inertiaVelocity) < 0.0001) {
this._isInertiaActive = false this._isInertiaActive = false
this._inertiaVelocity = 0 this._inertiaVelocity = 0
this._playbackSpeed = 0 // Mettre à jour la vitesse de lecture à 0 uniquement à la fin this._playbackSpeed = 0
} }
} }
} else { } else {
// Rotation normale à la vitesse de lecture de base // Rotation normale à la vitesse de lecture de base, dans la direction actuelle
const baseRotation = RADIANS_PER_MILLISECOND * this._basePlaybackSpeed * timestampElapsed const baseRotation =
this.setAngle(this._currentAngle + baseRotation) RADIANS_PER_MILLISECOND * Math.abs(this._basePlaybackSpeed) * timestampElapsed * direction
this.setAngle(this._currentAngle + baseRotation, true)
} }
} }
setAngle(angle: number) { setAngle(angle: number, checkBounds = false) {
this._currentAngle = clamp(angle, 0, this._maxAngle) // Vérifier les limites si demandé
if (checkBounds) {
// Arrêt au début (angle < 0)
if (angle < 0) {
this._currentAngle = 0
this._inertiaVelocity = 0
this._isInertiaActive = false
this._playbackSpeed = 0
this._basePlaybackSpeed = 0
this.el.style.transform = 'rotate(0rad)'
this.callbacks.onStop()
this._isPoweredOn = false
this.stop()
return 0
}
// Arrêt à la fin (angle >= _maxAngle)
else if (angle >= this._maxAngle) {
this._currentAngle = this._maxAngle
this._inertiaVelocity = 0
this._isInertiaActive = false
this._playbackSpeed = 0
this._basePlaybackSpeed = 0
this.el.style.transform = `rotate(${this._maxAngle}rad)`
this.callbacks.onStop()
this._isPoweredOn = false
this.stop()
return this._maxAngle
}
}
// Si on dépasse les limites, on reste aux bornes
if (angle < 0) {
this._currentAngle = 0
} else if (angle > this._maxAngle) {
this._currentAngle = this._maxAngle
} else {
this._currentAngle = angle
}
// Appliquer la rotation à l'élément
if (this.el) {
this.el.style.transform = `rotate(${this._currentAngle}rad)`
}
return this._currentAngle return this._currentAngle
} }
@@ -337,15 +400,43 @@ class Disc {
} }
stop() { stop() {
if (this.rafId) { this._isInertiaActive = false
cancelAnimationFrame(this.rafId) this._inertiaVelocity = 0
cancelAnimationFrame(this.rafId!)
this.rafId = null this.rafId = null
} }
this.callbacks.onStop()
/**
* Vérifie si le disque est à l'arrêt
*/
isStopped(): boolean {
return this.rafId === null && !this._isInertiaActive
} }
rewind() { /**
this.setAngle(0) * Inverse la direction de rotation du disque
* @returns true si l'inversion a réussi, false sinon
*/
reverse(): boolean {
if (!this.el) return false
// Inverser la direction
this.isReversed = !this.isReversed
// Inverser la vitesse de base si nécessaire
if (this._isPoweredOn) {
this._basePlaybackSpeed = this.isReversed ? -1 : 1
}
// Mettre à jour la direction de l'animation
this.el.style.animationDirection = this.isReversed ? 'reverse' : 'normal'
// Inverser la vitesse d'inertie si elle est active
if (this._isInertiaActive) {
this._inertiaVelocity = -this._inertiaVelocity
}
return true
} }
loop() { loop() {
@@ -357,11 +448,18 @@ class Disc {
this.autoRotate(currentTimestamp) this.autoRotate(currentTimestamp)
} }
// Calculer la vitesse de lecture // Calculer la vitesse de lecture uniquement pendant le glissement ou l'inertie
if (this.isDragging || this._isInertiaActive) {
const rotated = this._currentAngle - this._previousAngle const rotated = this._currentAngle - this._previousAngle
const rotationNormal = RADIANS_PER_MILLISECOND * timestampDifferenceMS const rotationNormal = RADIANS_PER_MILLISECOND * timestampDifferenceMS
this.playbackSpeed = rotated / rotationNormal || 0 this.playbackSpeed = rotated / rotationNormal || 0
this.isReversed = this._currentAngle < this._previousAngle } else if (this._isPoweredOn) {
// En mode lecture normale, utiliser la vitesse de base
this._playbackSpeed = this._basePlaybackSpeed
} else {
// Si le lecteur est éteint, vitesse à 0
this._playbackSpeed = 0
}
// Mettre à jour la rotation visuelle // Mettre à jour la rotation visuelle
this.el.style.transform = `rotate(${this._currentAngle}rad)` this.el.style.transform = `rotate(${this._currentAngle}rad)`
@@ -373,7 +471,7 @@ class Disc {
// Ne pas appeler onLoop si rien n'a changé // Ne pas appeler onLoop si rien n'a changé
if (this._previousAngle !== this._currentAngle || this._previousDuration !== this._duration) { if (this._previousAngle !== this._currentAngle || this._previousDuration !== this._duration) {
this.callbacks.onLoop({ this.callbacks.onLoop({
playbackSpeed: this.playbackSpeed, playbackSpeed: this._playbackSpeed, // Utiliser _playbackSpeed directement
isReversed: this.isReversed, isReversed: this.isReversed,
secondsPlayed, secondsPlayed,
progress progress

View File

@@ -105,6 +105,26 @@ class Sampler {
this.audioSource.stop() this.audioSource.stop()
} }
/**
* Inverse la direction de lecture
* @returns La nouvelle direction (true = inversé, false = normal)
*/
reverse(secondsPlayed: number = 0): boolean {
if (!this.audioBuffer) return false
// Inverser la direction
this.isReversed = !this.isReversed
// Si on a une position, on relance la lecture à cette position
if (secondsPlayed > 0) {
// S'assurer que la position est dans les limites
const safePosition = Math.max(0, Math.min(secondsPlayed, this.duration))
this.play(safePosition)
}
return this.isReversed
}
mute() { mute() {
this.gainNode.gain.value = 0 this.gainNode.gain.value = 0
} }

View File

@@ -1,198 +0,0 @@
<template>
<div class="platine pointer-events-none" :class="{ 'loading': platineStore.isLoadingTrack, 'mounted': isMounted }"
ref="platine">
<img class="cover" :src="platineStore.currentTrack?.coverId" />
<div class="disc pointer-events-auto fixed bg-transparent" ref="discRef" id="disc">
<div class="bobine"
:style="{ height: platineStore.progressPercentage + '%', width: platineStore.progressPercentage + '%' }"></div>
<div class="disc-label rounded-full bg-cover bg-center">
<img src="/favicon.svg" class="size-1/2 bg-black rounded-full p-5">
<div v-if="platineStore.isLoadingTrack" class="loading-indicator">
<div class="spinner"></div>
</div>
</div>
<div v-if="!platineStore.isLoadingTrack" class="absolute top-1/2 right-8 size-1/12 rounded-full bg-esyellow">
</div>
</div>
<!-- <div class="w-full h-1/5 text-base">
{{ platineStore.currentTrack?.title }}
<br>
{{ platineStore.currentTrack?.artist?.name }}
</div> -->
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { usePlatineStore } from '~/store/platine'
import type { Track } from '~~/types'
const props = defineProps<{ track?: Track }>()
const platineStore = usePlatineStore()
const discRef = ref<HTMLElement>()
const platine = ref<HTMLElement>()
const isMounted = ref(false)
// Initialisation du lecteur
onMounted(() => {
isMounted.value = true
if (discRef.value) {
platineStore.initPlatine(discRef.value)
}
})
// Nettoyage
onUnmounted(() => {
isMounted.value = false
platineStore.cleanup()
})
// Surveillance des changements de piste
watch(() => props.track, (newTrack) => {
if (newTrack) {
platineStore.loadTrack(newTrack)
}
})
</script>
<style lang="scss">
.platine {
overflow: hidden;
position: relative;
width: 100%;
height: 100%;
.card {
position: absolute !important;
top: -20%;
left: 50%;
bottom: 0;
transform: translate(-50%, 50%);
}
}
.disc {
position: relative;
aspect-ratio: 1;
width: 100%;
overflow: hidden;
border-radius: 50%;
cursor: grab;
background-position: center;
background-size: cover;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.4);
.loading & {
box-shadow: none;
}
}
.disc.is-scratching {
cursor: grabbing;
}
.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(/favicon.svg) center center;
background-size: 30%;
border-radius: 50%;
cursor: pointer !important;
}
.disc-middle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 20px;
height: 20px;
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;
will-change: box-shadow;
transition:
box-shadow 0.2s ease-out,
transform 0.05s ease-in;
}
.power.is-active {
transform: translate(1px, 2px);
color: red;
}
.button[disabled] {
opacity: 0.5;
}
.loading-indicator {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
}
.cover {
position: absolute;
top: 0;
left: 0;
border-radius: 100%;
object-fit: cover;
width: 100%;
height: 100%;
transition: opacity 3s ease;
.loading & {
opacity: 0;
transition: opacity 0.3s ease;
}
}
.spinner {
width: 40px;
height: 40px;
border: 4px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.bobine {
@apply bg-slate-900 bg-opacity-50 backdrop-blur absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 rounded-full;
}
</style>

View File

@@ -1,382 +0,0 @@
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: HTMLElement
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 = {
onDragStart: (): void => {},
onDragEnded: (secondsPlayed: number): void => {},
onStop: (): void => {},
onLoop: (params: DiscProgress): void => {}
}
constructor(el: HTMLElement) {
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()
// Appeler le callback onDragStart
this.callbacks.onDragStart()
// Obtenir les coordonnées du toucher ou de la souris
const getCoords = (event: PointerEvent | TouchEvent): { x: number; y: number } => {
// Gestion des événements tactiles
const touchEvent = event as TouchEvent
if (touchEvent.touches?.[0]) {
return {
x: touchEvent.touches[0].clientX,
y: touchEvent.touches[0].clientY
}
}
// Gestion des événements de souris
const mouseEvent = event as PointerEvent
return {
x: mouseEvent.clientX ?? this._center.x,
y: mouseEvent.clientY ?? this._center.y
}
}
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

@@ -1,95 +0,0 @@
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

@@ -1,3 +0,0 @@
body {
background-color: red !important;
}

Binary file not shown.

View File

@@ -19,6 +19,12 @@ export default defineNuxtConfig({
compatibilityDate: '2025-07-15', compatibilityDate: '2025-07-15',
devtools: { enabled: true }, devtools: { enabled: true },
modules: ['@nuxt/eslint', '@nuxtjs/tailwindcss', '@pinia/nuxt'], modules: ['@nuxt/eslint', '@nuxtjs/tailwindcss', '@pinia/nuxt'],
components: [
{
path: '~/components',
pathPrefix: false
}
],
typescript: { typescript: {
tsConfig: { tsConfig: {
include: ['types/**/*.ts'] include: ['types/**/*.ts']

View File

@@ -38,6 +38,18 @@ module.exports = {
width: '100dvw', width: '100dvw',
display: 'grid', display: 'grid',
'place-items': 'center' 'place-items': 'center'
},
'.debug': {
position: 'fixed',
'z-index': '1000',
top: '16px',
right: '16px',
background: '#9CA3AF',
'border-radius': '16px',
padding: '16px'
},
'.debug > *': {
display: 'block'
} }
}) })
} }