multi cards
All checks were successful
Deploy App / build (push) Successful in 2m4s
Deploy App / deploy (push) Successful in 21s

This commit is contained in:
valere
2026-02-11 16:49:34 +01:00
parent 620112d9ba
commit 399519d1d4
8 changed files with 276 additions and 105 deletions

View File

@@ -6,10 +6,13 @@
<b v-else>normal</b> <b v-else>normal</b>
</button> </button>
<button @click="Rewind"> <button @click="Rewind">
rewind Rewind
</button>
<button @click="Wind">
Wind
</button> </button>
<!-- <div>{{ progressPercentage }}</div> --> <!-- <div>{{ progressPercentage }}</div> -->
<div>{{ currentSpeed }}</div> <div>{{ (currentSpeed).toFixed(2) }}</div>
</div> </div>
<div class="disc" ref="discRef" id="disc"> <div class="disc" ref="discRef" id="disc">
<div class="bobine" :style="{ <div class="bobine" :style="{
@@ -31,6 +34,7 @@ import type { Card } from '~~/types/types'
import { ref, onMounted, onUnmounted, watch, computed, nextTick } 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'
import { duration } from 'drizzle-orm/gel-core';
const props = defineProps<{ card?: Card, autoplay?: boolean }>() const props = defineProps<{ card?: Card, autoplay?: boolean }>()
const autoplay = props.autoplay ?? false const autoplay = props.autoplay ?? false
@@ -184,9 +188,97 @@ const Reverse = () => {
const Rewind = async () => { const Rewind = async () => {
if (!disc.value || !sampler.value) return if (!disc.value || !sampler.value) return
Reverse()
play()
// Sauvegarder l'état actuel
disc.value.isStopped() ? play() : null
const wasPlaying = !disc.value.isStopped()
const currentSpeed = disc.value.playbackSpeed
// Fonction pour l'effet de scratch/pull up
const scratchEffect = async () => {
// Ralentir progressivement
const slowDownDuration = 300 // ms
const startTime = performance.now()
const startSpeed = wasPlaying ? currentSpeed : 0.1
const slowDown = (timestamp: number) => {
const elapsed = timestamp - startTime
const progress = Math.min(elapsed / slowDownDuration, 1)
// Courbe d'accélération pour un effet plus naturel
const easeOut = 1 - Math.pow(1 - progress, 2)
// Ralentir progressivement jusqu'à presque l'arrêt
const newSpeed = startSpeed * (1 - easeOut) + 0.05
sampler.value?.setPlaybackRate(newSpeed)
if (progress < 1) {
requestAnimationFrame(slowDown)
} else {
// Une fois ralenti, effectuer le rembobinage
performRewind()
}
}
requestAnimationFrame(slowDown)
}
const performRewind = () => {
// Mettre en pause la lecture actuelle
if (wasPlaying) {
sampler.value?.pause()
}
// Remettre à zéro la position
sampler.value?.play(0)
disc.value?.setAngle(0)
// Effet de scratch rapide
const scratchDuration = 200 // ms
const scratchStart = performance.now()
const scratch = (timestamp: number) => {
const elapsed = timestamp - scratchStart
const progress = Math.min(elapsed / scratchDuration, 1)
// Créer un effet de scratch en variant la vitesse
if (progress < 0.5) {
// Phase de scratch vers l'arrière
const scratchProgress = progress * 2
const scratchSpeed = 1 - (scratchProgress * 1.8) // Ralenti jusqu'à -0.8
sampler.value?.setPlaybackRate(scratchSpeed)
} else {
// Phase de pull up
const pullProgress = (progress - 0.5) * 2
const pullSpeed = -0.8 + (pullProgress * 1.8) // Accélère de -0.8 à 1.0
sampler.value?.setPlaybackRate(pullSpeed)
}
if (progress < 1) {
requestAnimationFrame(scratch)
} else {
// Remettre la vitesse normale à la fin
sampler.value?.setPlaybackRate(1)
// Si c'était en lecture avant, relancer la lecture
if (wasPlaying) {
setTimeout(() => {
sampler.value?.play(0)
}, 50)
}
}
}
requestAnimationFrame(scratch)
}
// Démarrer l'effet
scratchEffect()
}
const Wind = () => {
disc.value.secondsPlayed = duration
sampler.value.secondsPlayed = duration
} }
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -237,7 +329,7 @@ watch(() => props.card, (newCard) => {
.disc { .disc {
pointer-events: auto; pointer-events: auto;
position: fixed; position: absolute;
background-color: transparent; background-color: transparent;
position: relative; position: relative;
aspect-ratio: 1; aspect-ratio: 1;

View File

@@ -0,0 +1,85 @@
<template>
<section class="playing-card" :class="{ 'playing-card--platine-open': platineOpen }">
<Card :card="props.card!" :is-face-up="props.isFaceUp" @click="clickOnCard" :role="platineOpen ? 'img' : 'button'"
showPlayButtonFaceUp />
<Platine v-if="platineOpen && card" :card="card" autoplay />
<CloseButton v-if="platineOpen" @click="platineOpen = false" />
</section>
</template>
<script setup lang="ts">
import type { Card } from '~~/types/types'
const props = defineProps<{ card: Card, isFaceUp: boolean }>()
const platineOpen = ref(false)
const clickOnCard = () => {
platineOpen.value = !platineOpen.value
}
</script>
<style scoped lang="scss">
$open-speed: 0.4s;
.playing-card {
display: flex;
justify-content: center;
align-items: center;
: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,
.play-button {
opacity: 0;
}
}
.play-button,
.card-body,
.face-down {
display: none;
opacity: 0;
}
}
}
}
</style>

View File

@@ -1,15 +1,13 @@
<template> <template>
<section class="screen-centered"> <PlayingCard :card :isFaceUp />
<Card :card="card" :isFaceUp="isFaceUp" showPlayButtonFaceUp @click="clickOnSlugCard" @image-loaded="imageLoaded" />
</section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Card } from '~~/types/types' import type { Card } from '~~/types/types'
const isFaceUp = ref(false)
const route = useRoute() const route = useRoute()
const slug = route.params.slug as string const slug = route.params.slug as string
const isFaceUp = ref(false)
const { data: card, pending, error } = await useFetch<Card>(`/api/card/${slug}`) const { data: card, pending, error } = await useFetch<Card>(`/api/card/${slug}`)
useHead({ useHead({
@@ -18,14 +16,6 @@ useHead({
) )
}) })
const clickOnSlugCard = () => {
//
}
const imageLoaded = () => {
console.log('imageLoaded')
}
onMounted(() => { onMounted(() => {
setTimeout(() => { setTimeout(() => {
isFaceUp.value = true isFaceUp.value = true

View File

@@ -1,95 +1,58 @@
<template> <template>
<section class="platine-card" :class="{ 'platine-card--platine-open': platineOpen }"> <section class="deck">
<Card :card="card!" :is-face-up @click="clickOnCard" :role="platineOpen ? 'img' : 'button'" showPlayButtonFaceUp /> <div v-for="(card, index) in cards" :key="index">
<Platine v-if="platineOpen && card" :card="card" autoplay /> <PlayingCard :card="card" :isFaceUp="isFaceUp[index] || false" />
<CloseButton v-if="platineOpen" @click="platineOpen = false" /> </div>
</section> </section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Card } from '~~/types/types' import type { Card } from '~~/types/types'
const { data: card, pending, error } = await useFetch<Card>('/api/card/random') const nbCards = 1 // Nombre de cartes à afficher
const cards = ref<Card[]>([])
const isFaceUp = ref<boolean[]>([])
const isFaceUp = ref(false) // Chargement des cartes
const platineOpen = ref(false) const loadCards = async () => {
try {
const promises = Array(nbCards).fill(0).map(() =>
$fetch<Card>('/api/card/random')
)
const results = await Promise.all(promises)
cards.value = results
isFaceUp.value = new Array(nbCards).fill(false)
} catch (error) {
console.error('Error loading cards:', error)
}
}
// Animation de retournement des cartes
const flipCards = () => {
isFaceUp.value.forEach((_, index) => {
setTimeout(() => {
isFaceUp.value[index] = true
}, 400 * (index + 1))
})
}
onMounted(async () => {
await loadCards()
setTimeout(flipCards, 600)
})
useHead({ useHead({
title: computed(() => title: computed(() =>
card.value ? `${card.value.artist} - ${card.value.title}` : 'Loading...' cards.value[0] ? `${cards.value[0].artist} - ${cards.value[0].title}` : 'Loading...'
) )
}) })
onMounted(() => {
setTimeout(() => {
isFaceUp.value = true
}, 700)
})
const clickOnCard = () => {
platineOpen.value = !platineOpen.value
}
</script> </script>
<style scoped lang="scss"> <style lang="scss" scoped>
$open-speed: 0.4s; .deck {
.platine-card {
@apply screen-centered h-screen; @apply screen-centered h-screen;
display: grid;
:deep(.card) { grid-template-columns: 1fr 1fr 1fr;
transition: width $open-speed, height $open-speed, transform 0.1s; grid-template-rows: 1fr 1fr 1fr;
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,
.play-button {
opacity: 0;
}
}
.play-button,
.card-body,
.face-down {
display: none;
opacity: 0;
}
}
}
} }
</style> </style>

23
app/pages/random.vue Normal file
View File

@@ -0,0 +1,23 @@
<template>
<PlayingCard :card :isFaceUp />
</template>
<script setup lang="ts">
import type { Card } from '~~/types/types'
const { data: card, pending, error } = await useFetch<Card>('/api/card/random')
const isFaceUp = ref(false)
useHead({
title: computed(() =>
card.value ? `${card.value.artist} - ${card.value.title}` : 'Loading...'
)
})
onMounted(() => {
setTimeout(() => {
isFaceUp.value = true
}, 700)
})
</script>

View File

@@ -77,7 +77,7 @@ class Disc {
private _inertiaFriction: number = 1 // 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 = 45 // Facteur d'amplification de l'inertie private _inertiaAmplification: number = 25 // 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
@@ -131,7 +131,7 @@ class Disc {
this._draggingSpeeds = limit(this._draggingSpeeds, 10) this._draggingSpeeds = limit(this._draggingSpeeds, 10)
this._playbackSpeed = average(this._draggingSpeeds) this._playbackSpeed = average(this._draggingSpeeds)
this._playbackSpeed = clamp(this._playbackSpeed, -4, 4) // this._playbackSpeed = clamp(this._playbackSpeed, -4, 4)
} }
get secondsPlayed() { get secondsPlayed() {
@@ -354,11 +354,11 @@ class Disc {
this._inertiaVelocity = 0 this._inertiaVelocity = 0
this._isInertiaActive = false this._isInertiaActive = false
this._playbackSpeed = 0 this._playbackSpeed = 0
this._basePlaybackSpeed = 0 this._basePlaybackSpeed = 1
this.el.style.transform = 'rotate(0rad)' this.el.style.transform = 'rotate(0rad)'
this.callbacks.onStop() // this.callbacks.onStop()
this._isPoweredOn = false // this._isPoweredOn = false
this.stop() // this.stop()
return 0 return 0
} }
// Arrêt à la fin (angle >= _maxAngle) // Arrêt à la fin (angle >= _maxAngle)

View File

@@ -132,6 +132,24 @@ class Sampler {
unmute() { unmute() {
this.gainNode.gain.value = 1 this.gainNode.gain.value = 1
} }
/**
* Définit le taux de lecture (vitesse de lecture)
* @param rate Taux de lecture (1.0 = vitesse normale, 0.5 = moitié de vitesse, 2.0 = double vitesse, etc.)
*/
setPlaybackRate(rate: number) {
if (!this.audioSource) return
const currentTime = this.audioContext.currentTime
this.audioSource.playbackRate.cancelScheduledValues(currentTime)
this.audioSource.playbackRate.linearRampToValueAtTime(
Math.max(0.001, Math.abs(rate)), // Éviter les valeurs négatives ou nulles
currentTime + 0.05 // Petit délai pour éviter les clics
)
// Mettre à jour la vitesse actuelle
this.currentSpeed = rate
}
} }
export default Sampler export default Sampler

View File

@@ -42,8 +42,8 @@ module.exports = {
'.debug': { '.debug': {
position: 'fixed', position: 'fixed',
'z-index': '1000', 'z-index': '1000',
top: '16px', bottom: '16px',
right: '16px', right: '25%',
background: '#9CA3AF', background: '#9CA3AF',
'border-radius': '16px', 'border-radius': '16px',
padding: '16px' padding: '16px'