WIP starbook demo
This commit is contained in:
32
app/app.vue
32
app/app.vue
@@ -1,8 +1,36 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100">
|
||||
<div class="min-h-screen" @keydown.esc="resetFocus">
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
<NuxtPage ref="pageContent" />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -4,27 +4,19 @@
|
||||
isFaceUp ? 'face-up' : 'face-down',
|
||||
showPlayButtonFaceUp ? 'show-play-button-face-up' : ''
|
||||
]" :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">
|
||||
<!-- Face-Up -->
|
||||
<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">
|
||||
<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>
|
||||
<Rank :card="props.card" />
|
||||
|
||||
<!-- Cover -->
|
||||
<figure class="pochette flex-1 flex justify-center items-center overflow-hidden rounded-xl cursor-pointer">
|
||||
<playButton />
|
||||
<figure class="flex-1 flex justify-center items-center overflow-hidden cursor-pointer">
|
||||
<PlayButton />
|
||||
<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>
|
||||
|
||||
<!-- 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">
|
||||
<figure class="h-full flex text-center rounded-xl justify-center items-center"
|
||||
:style="{ backgroundColor: cardColor }">
|
||||
<playButton />
|
||||
<PlayButton />
|
||||
<img src="/face-down.svg" />
|
||||
</figure>
|
||||
</footer>
|
||||
@@ -75,7 +67,6 @@ const props = withDefaults(defineProps<{
|
||||
import { getYearColor } from '~/utils/colors'
|
||||
|
||||
const cardColor = computed(() => getYearColor(props.card.year || 0))
|
||||
const isRedCard = computed(() => (props.card?.suit === '♥' || props.card?.suit === '♦'))
|
||||
|
||||
/* loading states of the card */
|
||||
const isApiLoaded = ref(false)
|
||||
@@ -103,6 +94,10 @@ const isTrackLoaded = ref(false)
|
||||
perspective: 1000px;
|
||||
@apply transition-all scale-100 w-56 h-80 min-w-56 min-h-80;
|
||||
|
||||
.pochette {
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.flip-inner {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
@@ -123,6 +118,7 @@ const isTrackLoaded = ref(false)
|
||||
}
|
||||
|
||||
.face-up {
|
||||
border-radius: 1rem;
|
||||
transform: rotateY(0deg);
|
||||
transition: box-shadow 0.6s;
|
||||
}
|
||||
@@ -158,7 +154,7 @@ const isTrackLoaded = ref(false)
|
||||
@apply shadow-2xl;
|
||||
transition:
|
||||
box-shadow 0.6s,
|
||||
transform 0.6s;
|
||||
transform 6s;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,49 +1,43 @@
|
||||
<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="bobine" :style="{
|
||||
height: progressPercentage + '%',
|
||||
width: progressPercentage + '%'
|
||||
}"></div>
|
||||
<div class="power-button" @mousedown.stop @click.stop="handlePowerButtonClick">
|
||||
<img class="power-logo" src="/favicon.svg">
|
||||
<div class="power-loading" v-if="isLoadingCard">
|
||||
<div class="spinner"></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 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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 Sampler from '~/utils/platine/sampler'
|
||||
|
||||
const props = defineProps<{ card?: Card }>()
|
||||
const props = defineProps<{ card?: Card, autoplay?: boolean }>()
|
||||
const autoplay = props.autoplay ?? false
|
||||
|
||||
// State
|
||||
const isLoadingCard = ref(false)
|
||||
const isFirstDrag = ref(true)
|
||||
const isLoadingTrack = ref(false)
|
||||
const isFirstPlay = ref(true)
|
||||
const progressPercentage = ref(0)
|
||||
const currentTurns = ref(0)
|
||||
const totalTurns = ref(0)// Refs pour les instances
|
||||
@@ -56,6 +50,8 @@ 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) => {
|
||||
@@ -72,22 +68,12 @@ const initPlatine = (element: HTMLElement) => {
|
||||
}
|
||||
|
||||
disc.value.callbacks.onDragStart = () => {
|
||||
// console.log('[DISC] On Drag Start')
|
||||
if (isFirstDrag.value) {
|
||||
isFirstDrag.value = false
|
||||
// togglePlay()
|
||||
// Activer le son à chaque fois qu'on glisse, pas seulement au premier play
|
||||
if (sampler.value && disc.value) {
|
||||
sampler.value.play(disc.value.secondsPlayed)
|
||||
disc.value.powerOn()
|
||||
// console.log('[DISC] Power ON')
|
||||
// On joue toujours le son quand on glisse, même après une pause
|
||||
sampler.value.play(disc.value.secondsPlayed || 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disc.value.callbacks.onDragEnded = () => {
|
||||
// console.log('[DISC] On Drag END')
|
||||
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
|
||||
@@ -106,10 +92,11 @@ const updateTurns = () => {
|
||||
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 (22% à 100%)
|
||||
const minPercentage = 22
|
||||
// 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 * (100 - minPercentage))
|
||||
const newProgressPercentage = minPercentage + (progressRatio * (maxPercentage - minPercentage))
|
||||
|
||||
// Ne mettre à jour que si les valeurs ont changé de manière significative
|
||||
if (
|
||||
@@ -127,7 +114,7 @@ const loadCard = async (card: Card) => {
|
||||
// console.log('[LOAD CARD]', card)
|
||||
if (!sampler.value || !card) return
|
||||
|
||||
isLoadingCard.value = true
|
||||
isLoadingTrack.value = true
|
||||
// console.log(disc.value)
|
||||
try {
|
||||
await sampler.value.loadTrack(card.url_audio)
|
||||
@@ -137,12 +124,12 @@ const loadCard = async (card: Card) => {
|
||||
updateTurns()
|
||||
}
|
||||
} finally {
|
||||
isLoadingCard.value = false
|
||||
isLoadingTrack.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const play = (position = 0) => {
|
||||
// console.log('[PLAY]')
|
||||
isFirstPlay.value = false
|
||||
if (!disc.value || !sampler.value || !props.card) return
|
||||
|
||||
sampler.value.play(position)
|
||||
@@ -179,11 +166,24 @@ const cleanup = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePowerButtonClick = (e: MouseEvent) => {
|
||||
e.stopPropagation() // Empêcher la propagation de l'événement
|
||||
e.preventDefault() // Empêcher tout comportement par défaut
|
||||
const Power = (e: MouseEvent) => {
|
||||
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) => {
|
||||
@@ -194,14 +194,17 @@ const handleKeyDown = (e: KeyboardEvent) => {
|
||||
}
|
||||
|
||||
// Initialisation du lecteur
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
isMounted.value = true
|
||||
if (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)
|
||||
})
|
||||
|
||||
@@ -224,15 +227,12 @@ watch(() => props.card, (newCard) => {
|
||||
|
||||
<style lang="scss">
|
||||
.platine {
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.disc {
|
||||
.disc {
|
||||
pointer-events: auto;
|
||||
position: fixed;
|
||||
background-color: transparent;
|
||||
@@ -244,60 +244,60 @@ watch(() => props.card, (newCard) => {
|
||||
cursor: grab;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.4);
|
||||
padding: 14px;
|
||||
|
||||
.loading & {
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.turn-point {
|
||||
.turn-point {
|
||||
@apply absolute top-1/2 right-8 size-1/12 rounded-full bg-esyellow;
|
||||
}
|
||||
}
|
||||
|
||||
.disc.is-scratching {
|
||||
.disc.is-scratching {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 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 {
|
||||
@apply size-1/2 bg-black rounded-full p-5;
|
||||
}
|
||||
|
||||
.power-loading {
|
||||
.macaron {
|
||||
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%;
|
||||
filter: grayscale(1);
|
||||
transition: transform 0.1s, filter 0.8s;
|
||||
@apply size-2/3;
|
||||
}
|
||||
}
|
||||
|
||||
.disc-middle {
|
||||
.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%;
|
||||
@@ -306,52 +306,32 @@ watch(() => props.card, (newCard) => {
|
||||
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;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
.spinner {
|
||||
width: 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-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
// @apply md:border-8;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
@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;
|
||||
}
|
||||
|
||||
.debug {
|
||||
@apply fixed top-4 right-4 bg-slate-200 rounded-md p-2;
|
||||
.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>
|
||||
|
||||
24
app/components/Rank.vue
Normal file
24
app/components/Rank.vue
Normal 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>
|
||||
39
app/components/UI/CloseButton.vue
Normal file
39
app/components/UI/CloseButton.vue
Normal 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>
|
||||
@@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<button tabindex="-1"
|
||||
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">
|
||||
<button tabindex="-1" class="play-button" :class="{ loading: isLoading }" :disabled="isLoading">
|
||||
<template v-if="props.isLoading">
|
||||
<img src="/loader.svg" alt="Chargement" class="size-16" />
|
||||
</template>
|
||||
@@ -21,7 +19,11 @@ const props = withDefaults(defineProps<{
|
||||
})
|
||||
</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,
|
||||
.play-button-changed {
|
||||
opacity: 1 !important;
|
||||
@@ -19,7 +19,7 @@ useHead({
|
||||
})
|
||||
|
||||
const clickOnSlugCard = () => {
|
||||
isFaceUp.value = true
|
||||
//
|
||||
}
|
||||
|
||||
const imageLoaded = () => {
|
||||
@@ -28,7 +28,7 @@ const imageLoaded = () => {
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
clickOnSlugCard()
|
||||
isFaceUp.value = true
|
||||
}, 700)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<section class="flex justify-center items-center h-screen">
|
||||
<div class="aspect-square size-[100vmin] max-h-screen max-w-screen overflow-hidden">
|
||||
<Platine :card="card" />
|
||||
</div>
|
||||
<section class="platine-card" :class="{ 'platine-card--platine-open': platineOpen }">
|
||||
<Card :card="card!" :is-face-up @click="clickOnCard" :role="platineOpen ? 'img' : 'button'" showPlayButtonFaceUp />
|
||||
<Platine v-if="platineOpen && card" :card="card" autoplay />
|
||||
<CloseButton v-if="platineOpen" @click="platineOpen = false" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -11,9 +11,84 @@ import type { Card } from '~~/types/types'
|
||||
|
||||
const { data: card, pending, error } = await useFetch<Card>('/api/card/random')
|
||||
|
||||
const isFaceUp = ref(false)
|
||||
const platineOpen = ref(false)
|
||||
|
||||
useHead({
|
||||
title: computed(() =>
|
||||
card.value ? `${card.value.artist} - ${card.value.title}` : 'Loading...'
|
||||
)
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(() => {
|
||||
isFaceUp.value = true
|
||||
}, 700)
|
||||
})
|
||||
|
||||
const clickOnCard = () => {
|
||||
platineOpen.value = !platineOpen.value
|
||||
}
|
||||
</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>
|
||||
@@ -74,16 +74,17 @@ class Disc {
|
||||
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 _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 _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
|
||||
|
||||
public isReversed: boolean = false
|
||||
|
||||
public callbacks = {
|
||||
onDragStart: (): void => {},
|
||||
onDragProgress: (): void => {},
|
||||
onDragEnded: (secondsPlayed: number): void => {},
|
||||
onStop: (): void => {},
|
||||
onLoop: (params: DiscProgress): void => {}
|
||||
@@ -147,12 +148,9 @@ class Disc {
|
||||
}
|
||||
|
||||
powerOn() {
|
||||
if (!this.rafId) {
|
||||
this.start()
|
||||
}
|
||||
this._isPoweredOn = true
|
||||
this._basePlaybackSpeed = 1
|
||||
this._playbackSpeed = 1
|
||||
this._basePlaybackSpeed = this.isReversed ? -1 : 1
|
||||
this.start()
|
||||
}
|
||||
|
||||
powerOff() {
|
||||
@@ -250,15 +248,18 @@ class Disc {
|
||||
|
||||
const anglePointerToCenter = angleBetween(this._center, pointerPosition)
|
||||
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)
|
||||
// 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) {
|
||||
this._lastDragVelocity = -angleDragged / deltaTime
|
||||
}
|
||||
|
||||
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._draggingFrom = { ...pointerPosition }
|
||||
}
|
||||
@@ -269,12 +270,15 @@ class Disc {
|
||||
|
||||
// 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
|
||||
|
||||
// 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
|
||||
|
||||
// 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
|
||||
if (!this._isPoweredOn) {
|
||||
@@ -286,25 +290,41 @@ class Disc {
|
||||
|
||||
autoRotate(currentTimestamp: number) {
|
||||
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) {
|
||||
// Appliquer l'inertie
|
||||
const inertiaRotation = this._inertiaVelocity * timestampElapsed
|
||||
this.setAngle(this._currentAngle + inertiaRotation)
|
||||
// Appliquer l'inertie en tenant compte de la direction
|
||||
const inertiaRotation = this._inertiaVelocity * timestampElapsed * direction
|
||||
this.setAngle(this._currentAngle + inertiaRotation, true)
|
||||
|
||||
// Si le lecteur est allumé, faire une transition fluide vers la vitesse de lecture
|
||||
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
|
||||
if (
|
||||
Math.abs(this._inertiaVelocity - RADIANS_PER_MILLISECOND * this._basePlaybackSpeed) <
|
||||
0.0001
|
||||
) {
|
||||
if (Math.abs(this._inertiaVelocity - targetVelocity) < 0.0001) {
|
||||
this._isInertiaActive = false
|
||||
this._inertiaVelocity = RADIANS_PER_MILLISECOND * this._basePlaybackSpeed
|
||||
this._inertiaVelocity = targetVelocity
|
||||
} else {
|
||||
// Réduire progressivement la vitesse d'inertie vers la vitesse de lecture
|
||||
this._inertiaVelocity +=
|
||||
(RADIANS_PER_MILLISECOND * this._basePlaybackSpeed - this._inertiaVelocity) * 0.1
|
||||
this._inertiaVelocity += (targetVelocity - this._inertiaVelocity) * 0.1
|
||||
}
|
||||
} else {
|
||||
// Si le lecteur est éteint, appliquer un frottement normal
|
||||
@@ -314,18 +334,61 @@ class Disc {
|
||||
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
|
||||
this._playbackSpeed = 0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Rotation normale à la vitesse de lecture de base
|
||||
const baseRotation = RADIANS_PER_MILLISECOND * this._basePlaybackSpeed * timestampElapsed
|
||||
this.setAngle(this._currentAngle + baseRotation)
|
||||
// Rotation normale à la vitesse de lecture de base, dans la direction actuelle
|
||||
const baseRotation =
|
||||
RADIANS_PER_MILLISECOND * Math.abs(this._basePlaybackSpeed) * timestampElapsed * direction
|
||||
this.setAngle(this._currentAngle + baseRotation, true)
|
||||
}
|
||||
}
|
||||
|
||||
setAngle(angle: number) {
|
||||
this._currentAngle = clamp(angle, 0, this._maxAngle)
|
||||
setAngle(angle: number, checkBounds = false) {
|
||||
// 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
|
||||
}
|
||||
@@ -337,15 +400,43 @@ class Disc {
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId)
|
||||
this._isInertiaActive = false
|
||||
this._inertiaVelocity = 0
|
||||
cancelAnimationFrame(this.rafId!)
|
||||
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() {
|
||||
@@ -357,11 +448,18 @@ class Disc {
|
||||
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 rotationNormal = RADIANS_PER_MILLISECOND * timestampDifferenceMS
|
||||
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
|
||||
this.el.style.transform = `rotate(${this._currentAngle}rad)`
|
||||
@@ -373,7 +471,7 @@ class Disc {
|
||||
// Ne pas appeler onLoop si rien n'a changé
|
||||
if (this._previousAngle !== this._currentAngle || this._previousDuration !== this._duration) {
|
||||
this.callbacks.onLoop({
|
||||
playbackSpeed: this.playbackSpeed,
|
||||
playbackSpeed: this._playbackSpeed, // Utiliser _playbackSpeed directement
|
||||
isReversed: this.isReversed,
|
||||
secondsPlayed,
|
||||
progress
|
||||
|
||||
@@ -105,6 +105,26 @@ class Sampler {
|
||||
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() {
|
||||
this.gainNode.gain.value = 0
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,3 +0,0 @@
|
||||
body {
|
||||
background-color: red !important;
|
||||
}
|
||||
BIN
data/music.db
BIN
data/music.db
Binary file not shown.
@@ -19,6 +19,12 @@ export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
modules: ['@nuxt/eslint', '@nuxtjs/tailwindcss', '@pinia/nuxt'],
|
||||
components: [
|
||||
{
|
||||
path: '~/components',
|
||||
pathPrefix: false
|
||||
}
|
||||
],
|
||||
typescript: {
|
||||
tsConfig: {
|
||||
include: ['types/**/*.ts']
|
||||
|
||||
@@ -38,6 +38,18 @@ module.exports = {
|
||||
width: '100dvw',
|
||||
display: 'grid',
|
||||
'place-items': 'center'
|
||||
},
|
||||
'.debug': {
|
||||
position: 'fixed',
|
||||
'z-index': '1000',
|
||||
top: '16px',
|
||||
right: '16px',
|
||||
background: '#9CA3AF',
|
||||
'border-radius': '16px',
|
||||
padding: '16px'
|
||||
},
|
||||
'.debug > *': {
|
||||
display: 'block'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user