From 617699503212651ee14a934a0645695665fca05f Mon Sep 17 00:00:00 2001 From: valere Date: Sun, 7 Dec 2025 19:44:21 +0100 Subject: [PATCH] Platine etape 1 --- app/components/Box.vue | 17 +- app/components/Boxes.vue | 10 +- app/components/Card.vue | 8 +- app/components/CinemaScreen.vue | 2 +- app/components/Loader.vue | 2 +- app/components/Platine.vue | 206 +++++++++++++++++ app/layouts/default.vue | 3 +- app/platine-tools/controls.ts | 48 ++++ app/platine-tools/disc.ts | 379 ++++++++++++++++++++++++++++++++ app/platine-tools/sampler.ts | 97 ++++++++ app/store/card.ts | 12 + app/store/data.ts | 5 +- app/store/ui.ts | 2 - nuxt.config.ts | 1 + server/api/tracks/playlist.ts | 6 +- 15 files changed, 771 insertions(+), 27 deletions(-) create mode 100644 app/components/Platine.vue create mode 100644 app/platine-tools/controls.ts create mode 100644 app/platine-tools/disc.ts create mode 100644 app/platine-tools/sampler.ts diff --git a/app/components/Box.vue b/app/components/Box.vue index 67c8806..033160d 100644 --- a/app/components/Box.vue +++ b/app/components/Box.vue @@ -5,6 +5,7 @@
+
@@ -142,13 +143,6 @@ function rotateBox() { applyTransform(0.8) } -watch( - () => props.box.activeSide, - () => { - rotateBox() - } -) - // --- Inertie --- function tickInertia() { if (!enableInertia) return @@ -246,6 +240,10 @@ onBeforeUnmount(() => { }) // --- Watchers --- +watch( + () => props.box.activeSide, + () => rotateBox() +) watch( () => props.box.state, () => applyBoxState() @@ -255,7 +253,10 @@ watch( () => applyColor(), { deep: true } ) -watch(isDraggable, (enabled) => (enabled ? addListeners() : removeListeners())) +watch( + isDraggable, + (enabled) => (enabled ? addListeners() : removeListeners()) +) diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 6e5a156..1c1a9d1 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -3,7 +3,6 @@ - - +
diff --git a/app/platine-tools/controls.ts b/app/platine-tools/controls.ts new file mode 100644 index 0000000..74d4277 --- /dev/null +++ b/app/platine-tools/controls.ts @@ -0,0 +1,48 @@ +class Controls { + public toggleButton: HTMLButtonElement; + public rewindButton: HTMLButtonElement; + + public isPlaying: boolean = false; + + public callbacks = { + // @ts-expect-error: unused var + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onIsplayingChanged: (isPlaying: boolean) => {}, + onRewind: () => {}, + }; + + constructor({ + toggleButton, + rewindButton, + }: { + toggleButton: HTMLButtonElement; + rewindButton: HTMLButtonElement; + }) { + this.toggleButton = toggleButton; + this.rewindButton = rewindButton; + + this.toggleButton.addEventListener('click', () => this.toggle()); + this.rewindButton.addEventListener('click', () => this.rewind()); + + this.isDisabled = true; + } + + set isDisabled(disabled: boolean) { + this.toggleButton.disabled = disabled; + this.rewindButton.disabled = disabled; + } + + toggle() { + this.isPlaying = !this.isPlaying; + + this.toggleButton.classList.toggle('is-active', this.isPlaying); + + this.callbacks.onIsplayingChanged(this.isPlaying); + } + + rewind() { + this.callbacks.onRewind(); + } +} + +export default Controls; diff --git a/app/platine-tools/disc.ts b/app/platine-tools/disc.ts new file mode 100644 index 0000000..34c627c --- /dev/null +++ b/app/platine-tools/disc.ts @@ -0,0 +1,379 @@ +const TAU = Math.PI * 2 + +const targetFPS = 60 +const RPS = 0.75 +const RPM = RPS * 60 + +const RADIANS_PER_MINUTE = RPM * TAU +const RADIANS_PER_SECOND = RADIANS_PER_MINUTE / 60 +const RADIANS_PER_MILLISECOND = RADIANS_PER_SECOND * 0.001 + +const ROTATION_SPEED = (TAU * RPS) / targetFPS + +type Vector = { + x: number + y: number +} + +type NumberArray = Array + +const average = (arr: NumberArray) => arr.reduce((memo, val) => memo + val, 0) / arr.length + +// Limit array size by cutting off from the start +const limit = (arr: NumberArray, maxLength = 10) => { + const deleteCount = Math.max(0, arr.length - maxLength) + + return arr.slice(deleteCount) +} + +const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(value, max)) + +const distanceBetween = (vec1: Vector, vec2: Vector) => Math.hypot(vec2.x - vec1.x, vec2.y - vec1.y) + +const getElementCenter = (el: HTMLElement): Vector => { + const { left, top, width, height } = el.getBoundingClientRect() + + const x = left + width / 2 + const y = top + height / 2 + + return { x, y } +} + +const angleBetween = (vec1: Vector, vec2: Vector) => Math.atan2(vec2.y - vec1.y, vec2.x - vec1.x) + +const angleDifference = (x: number, y: number) => Math.atan2(Math.sin(x - y), Math.cos(x - y)) + +type DiscProgress = { + playbackSpeed: number + isReversed: boolean + secondsPlayed: number + progress: number +} +class Disc { + public el: HTMLDivElement + + private _playbackSpeed = 1 + private _duration = 0 + private _isDragging = false + private _isPoweredOn = false + + private _center: Vector + + private _currentAngle = 0 + private _previousAngle = 0 + private _maxAngle = TAU + + public rafId: number | null = null + + public previousTimestamp: number + + private _draggingSpeeds: Array = [] + private _draggingFrom: Vector = { x: 0, y: 0 } + + // Propriétés pour l'inertie + private _inertiaVelocity: number = 0 + private _isInertiaActive: boolean = false + private _basePlaybackSpeed: number = 1 // Vitesse de lecture normale + private _inertiaFriction: number = 0.93 // Coefficient de frottement pour l'inertie (plus proche de 1 = plus long) + private _lastDragVelocity: number = 0 // Dernière vitesse de drag + private _lastDragTime: number = 0 // Dernier temps de drag + private _inertiaAmplification: number = 25 // Facteur d'amplification de l'inertie + + public isReversed: boolean = false + + public callbacks = { + // @ts-expect-error: unused var + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onDragEnded: (secondsPlayed: number): void => {}, + onStop: () => {}, + + // @ts-expect-error: unused var + // eslint-disable-next-line @typescript-eslint/no-unused-vars + onLoop: (params: DiscProgress) => {} + } + + constructor(el: HTMLDivElement) { + this.el = el + + this._center = getElementCenter(this.el) + + this.previousTimestamp = performance.now() + + this.onDragStart = this.onDragStart.bind(this) + this.onDragProgress = this.onDragProgress.bind(this) + this.onDragEnd = this.onDragEnd.bind(this) + this.loop = this.loop.bind(this) + + this.init() + } + + init() { + // Ajout du style pour désactiver le comportement tactile par défaut + this.el.style.touchAction = 'none' + + // Écouteurs pour la souris et le tactile + this.el.addEventListener('pointerdown', this.onDragStart) + this.el.addEventListener( + 'touchstart', + (e) => { + // Empêcher le défilement de la page + e.preventDefault() + this.onDragStart(e) + }, + { passive: false } + ) + } + + get playbackSpeed() { + return this._playbackSpeed + } + + set playbackSpeed(s) { + this._draggingSpeeds.push(s) + this._draggingSpeeds = limit(this._draggingSpeeds, 10) + + this._playbackSpeed = average(this._draggingSpeeds) + this._playbackSpeed = clamp(this._playbackSpeed, -4, 4) + } + + get secondsPlayed() { + return this._currentAngle / TAU / RPS + } + + set isDragging(d) { + this._isDragging = d + this.el.classList.toggle('is-scratching', d) + } + + get isDragging() { + return this._isDragging + } + + powerOn() { + if (!this.rafId) { + this.start() + } + this._isPoweredOn = true + this._basePlaybackSpeed = 1 + this._playbackSpeed = 1 + } + + powerOff() { + this._isPoweredOn = false + this._basePlaybackSpeed = 0 + } + + public setDuration(duration: number) { + this._duration = duration + this._maxAngle = duration * RPS * TAU + } + + onDragStart(e: PointerEvent | TouchEvent) { + // Empêcher le comportement par défaut pour éviter le défilement + e.preventDefault() + + // Obtenir les coordonnées du toucher ou de la souris + const getCoords = (event: PointerEvent | TouchEvent): { x: number; y: number } => { + if ('touches' in event) { + return { + x: event.touches[0].clientX, + y: event.touches[0].clientY + } + } else { + return { + x: event.clientX, + y: event.clientY + } + } + } + + const startCoords = getCoords(e) + + const onMove = (moveEvent: Event) => { + if (!(moveEvent instanceof PointerEvent) && !(moveEvent instanceof TouchEvent)) return + + const coords = getCoords(moveEvent) + + this.onDragProgress({ + clientX: coords.x, + clientY: coords.y, + preventDefault: () => moveEvent.preventDefault(), + stopPropagation: () => moveEvent.stopPropagation() + } as MouseEvent) + } + + const onEnd = () => { + document.removeEventListener('pointermove', onMove) + document.removeEventListener('touchmove', onMove) + document.removeEventListener('pointerup', onEnd) + document.removeEventListener('touchend', onEnd) + this.onDragEnd() + } + + document.addEventListener('pointermove', onMove) + document.addEventListener('touchmove', onMove, { passive: false }) + document.addEventListener('pointerup', onEnd) + document.addEventListener('touchend', onEnd) + + this._center = getElementCenter(this.el) + this._draggingFrom = startCoords + this.isDragging = true + } + + onDragProgress(e: { + clientX: number + clientY: number + preventDefault: () => void + stopPropagation: () => void + }) { + const currentTime = performance.now() + const deltaTime = currentTime - this._lastDragTime + + const pointerPosition: Vector = { + x: e.clientX, + y: e.clientY + } + + const anglePointerToCenter = angleBetween(this._center, pointerPosition) + const angle_DraggingFromToCenter = angleBetween(this._center, this._draggingFrom) + const angleDragged = angleDifference(angle_DraggingFromToCenter, anglePointerToCenter) + + // Calcul de la vitesse de déplacement angulaire (radians par milliseconde) + // On inverse le signe pour que le sens de l'inertie soit naturel + if (deltaTime > 0) { + this._lastDragVelocity = -angleDragged / deltaTime + } + + this._lastDragTime = currentTime + this.setAngle(this._currentAngle - angleDragged) + this._draggingFrom = { ...pointerPosition } + } + + onDragEnd() { + document.body.removeEventListener('pointermove', this.onDragProgress) + document.body.removeEventListener('pointerup', this.onDragEnd) + + // Activer l'inertie avec la vitesse de drag actuelle + this._isInertiaActive = true + // Augmenter la sensibilité du drag avec le facteur d'amplification + this._inertiaVelocity = this._lastDragVelocity * this._inertiaAmplification + this.isDragging = false + + // Toujours conserver la vitesse de base actuelle (1 si allumé, 0 si éteint) + this._basePlaybackSpeed = this._isPoweredOn ? 1 : 0 + + // Si le lecteur est éteint, s'assurer que la vitesse de base est bien à 0 + if (!this._isPoweredOn) { + this._basePlaybackSpeed = 0 + } + + this.callbacks.onDragEnded(this.secondsPlayed) + } + + autoRotate(currentTimestamp: number) { + const timestampElapsed = currentTimestamp - this.previousTimestamp + + if (this._isInertiaActive) { + // Appliquer l'inertie + const inertiaRotation = this._inertiaVelocity * timestampElapsed + this.setAngle(this._currentAngle + inertiaRotation) + + // Si le lecteur est allumé, faire une transition fluide vers la vitesse de lecture + if (this._isPoweredOn) { + // Si on est proche de la vitesse de lecture normale, on désactive l'inertie + if ( + Math.abs(this._inertiaVelocity - RADIANS_PER_MILLISECOND * this._basePlaybackSpeed) < + 0.0001 + ) { + this._isInertiaActive = false + this._inertiaVelocity = RADIANS_PER_MILLISECOND * this._basePlaybackSpeed + } else { + // Réduire progressivement la vitesse d'inertie vers la vitesse de lecture + this._inertiaVelocity += + (RADIANS_PER_MILLISECOND * this._basePlaybackSpeed - this._inertiaVelocity) * 0.1 + } + } else { + // Si le lecteur est éteint, appliquer un frottement normal + this._inertiaVelocity *= this._inertiaFriction + + // Si la vitesse est très faible, on arrête l'inertie + if (Math.abs(this._inertiaVelocity) < 0.0001) { + this._isInertiaActive = false + this._inertiaVelocity = 0 + this._playbackSpeed = 0 // Mettre à jour la vitesse de lecture à 0 uniquement à la fin + } + } + } else { + // Rotation normale à la vitesse de lecture de base + const baseRotation = RADIANS_PER_MILLISECOND * this._basePlaybackSpeed * timestampElapsed + this.setAngle(this._currentAngle + baseRotation) + } + } + + setAngle(angle: number) { + this._currentAngle = clamp(angle, 0, this._maxAngle) + + return this._currentAngle + } + + start() { + this.previousTimestamp = performance.now() + + this.loop() + } + + stop() { + if (this.rafId) { + cancelAnimationFrame(this.rafId) + this.rafId = null + } + this.callbacks.onStop() + } + + rewind() { + this.setAngle(0) + } + + loop() { + const currentTimestamp = performance.now() + + if (!this.isDragging) { + if (this._isPoweredOn) { + this.autoRotate(currentTimestamp) + } else { + // Mettre à jour le timestamp même quand le lecteur est éteint + // pour éviter un saut lors de la reprise + this.previousTimestamp = currentTimestamp + } + } + + const timestampDifferenceMS = currentTimestamp - this.previousTimestamp + + const rotated = this._currentAngle - this._previousAngle + const rotationNormal = RADIANS_PER_MILLISECOND * timestampDifferenceMS + + this.playbackSpeed = rotated / rotationNormal || 0 + this.isReversed = this._currentAngle < this._previousAngle + + this._previousAngle = this._currentAngle + this.previousTimestamp = performance.now() + + this.el.style.transform = `rotate(${this._currentAngle}rad)` + + const { playbackSpeed, isReversed, secondsPlayed, _duration } = this + const progress = secondsPlayed / _duration + + this.callbacks.onLoop({ + playbackSpeed, + isReversed, + secondsPlayed, + progress + }) + + this._previousAngle = this._currentAngle + + this.rafId = requestAnimationFrame(this.loop) + } +} + +export default Disc diff --git a/app/platine-tools/sampler.ts b/app/platine-tools/sampler.ts new file mode 100644 index 0000000..7b08b61 --- /dev/null +++ b/app/platine-tools/sampler.ts @@ -0,0 +1,97 @@ +class Sampler { + public audioContext: AudioContext = new AudioContext(); + public gainNode: GainNode = new GainNode(this.audioContext); + + public audioBuffer: AudioBuffer | null = null; + public audioBufferReversed: AudioBuffer | null = null; + public audioSource: AudioBufferSourceNode | null = null; + + public duration: number = 0; + public isReversed: boolean = false; + + constructor() { + this.gainNode.connect(this.audioContext.destination); + } + + async getAudioBuffer(audioUrl: string) { + const response = await fetch(audioUrl); + const arrayBuffer = await response.arrayBuffer(); + + const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer); + + return audioBuffer; + } + + async loadTrack(audioUrl: string) { + this.audioBuffer = await this.getAudioBuffer(audioUrl); + this.audioBufferReversed = this.getReversedAudioBuffer(this.audioBuffer); + + this.duration = this.audioBuffer.duration; + } + + getReversedAudioBuffer(audioBuffer: AudioBuffer) { + const bufferArray = audioBuffer.getChannelData(0).slice().reverse(); + + const audioBufferReversed = this.audioContext.createBuffer( + 1, + audioBuffer.length, + audioBuffer.sampleRate, + ); + + audioBufferReversed.getChannelData(0).set(bufferArray); + + return audioBufferReversed; + } + + changeDirection(isReversed: boolean, secondsPlayed: number) { + this.isReversed = isReversed; + this.play(secondsPlayed); + } + + play(offset = 0) { + this.pause(); + + const buffer = this.isReversed + ? this.audioBufferReversed + : this.audioBuffer; + + const cueTime = this.isReversed ? this.duration - offset : offset; + + this.audioSource = this.audioContext.createBufferSource(); + this.audioSource.buffer = buffer; + this.audioSource.loop = false; + + this.audioSource.connect(this.gainNode); + + this.audioSource.start(0, cueTime); + } + + updateSpeed(speed: number, isReversed: boolean, secondsPlayed: number) { + if (!this.audioSource) { + return; + } + + if (isReversed !== this.isReversed) { + this.changeDirection(isReversed, secondsPlayed); + } + + const { currentTime } = this.audioContext; + const speedAbsolute = Math.abs(speed); + + this.audioSource.playbackRate.cancelScheduledValues(currentTime); + this.audioSource.playbackRate.linearRampToValueAtTime( + Math.max(0.001, speedAbsolute), + currentTime, + ); + } + + pause() { + if (!this.audioSource) { + return; + } + + this.audioSource.stop(); + } +} + +export default Sampler; diff --git a/app/store/card.ts b/app/store/card.ts index 59b49ba..9936aec 100644 --- a/app/store/card.ts +++ b/app/store/card.ts @@ -23,6 +23,11 @@ export const useCardStore = defineStore('card', { this.saveToLocalStorage() }, + hideCard(trackId: number) { + this.revealedCards.delete(trackId) + this.saveToLocalStorage() + }, + // Vérifier si une carte est révélée isCardRevealed(trackId: number): boolean { return this.revealedCards.has(trackId) @@ -50,6 +55,13 @@ export const useCardStore = defineStore('card', { this.saveToLocalStorage() }, + hideAllCards(tracks: Track[]) { + tracks.forEach((track) => { + this.hideCard(track.id) + }) + this.saveToLocalStorage() + }, + // Sauvegarder l'état dans le localStorage saveToLocalStorage() { if (typeof window !== 'undefined') { diff --git a/app/store/data.ts b/app/store/data.ts index d2db224..a5627cd 100644 --- a/app/store/data.ts +++ b/app/store/data.ts @@ -22,7 +22,10 @@ export const useDataStore = defineStore('data', { // Mapper les tracks pour remplacer l'artist avec un objet Artist cohérent const artistMap = new Map(this.artists.map((a) => [a.id, a])) - const allTracks = [...(compilationTracks ?? []), ...(playlistTracks ?? [])] + const allTracks = [ + ...(Array.isArray(compilationTracks) ? compilationTracks : []), + ...(Array.isArray(playlistTracks) ? playlistTracks : []) + ] this.tracks = allTracks.map((track) => { const a = track.artist as unknown diff --git a/app/store/ui.ts b/app/store/ui.ts index 2e54076..0b9eb78 100644 --- a/app/store/ui.ts +++ b/app/store/ui.ts @@ -46,8 +46,6 @@ export const useUiStore = defineStore('ui', { dataStore.boxes.forEach((box) => { box.state = 'box-list' }) - // Scroll back to the unselected box in the list - if (selectedBox) this.scrollToBox(selectedBox) }, scrollToBox(box: Box) { diff --git a/nuxt.config.ts b/nuxt.config.ts index de0d8a0..5635841 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -19,6 +19,7 @@ export default defineNuxtConfig({ { rel: 'apple-touch-icon', sizes: '180x180', href: '/favicon/apple-touch-icon.png' }, { rel: 'manifest', href: '/favicon/site.webmanifest' } ], + viewport: 'width=device-width, initial-scale=1.0, maximum-scale=1.0', script: isProd ? [ { diff --git a/server/api/tracks/playlist.ts b/server/api/tracks/playlist.ts index 9498826..18401f8 100644 --- a/server/api/tracks/playlist.ts +++ b/server/api/tracks/playlist.ts @@ -4,14 +4,12 @@ import { eventHandler } from 'h3' import { getCardFromDate } from '../../../utils/cards' export default eventHandler(async (event) => { - const basePath = path.join(process.cwd(), '/mnt/media/files/music') + const dirPath = path.join(process.cwd(), '/mnt/media/files/music') + const urlPrefix = `https://files.erudi.fr/music` try { let allTracks: any[] = [] - const dirPath = basePath - const urlPrefix = `https://files.erudi.fr/music` - let files = await fs.promises.readdir(dirPath) files = files.filter((f) => !f.startsWith('.') && !f.endsWith('.jpg'))