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