From c36b0482c5295a00f6f30ca661659a9afbb3a27e Mon Sep 17 00:00:00 2001 From: David Frassi Date: Mon, 24 Aug 2026 01:12:50 +0200 Subject: [PATCH] =?UTF-8?q?Ottimizzazione=20face=20tracking:=20calibrazion?= =?UTF-8?q?e=20istantanea,=20eliminazione=20loop=20di=20stream=20e=20reatt?= =?UTF-8?q?ivit=C3=A0=20migliorata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/pages/player/player.page.html | 28 +- src/app/pages/player/player.page.scss | 17 +- src/app/pages/player/player.page.ts | 86 ++++-- src/app/pages/settings/settings.page.html | 37 ++- src/app/services/face-detector.service.ts | 354 +++++++++++++++------- src/app/services/settings.service.ts | 40 ++- src/app/version.ts | 2 +- 7 files changed, 411 insertions(+), 153 deletions(-) diff --git a/src/app/pages/player/player.page.html b/src/app/pages/player/player.page.html index c24124b..58e0419 100644 --- a/src/app/pages/player/player.page.html +++ b/src/app/pages/player/player.page.html @@ -171,6 +171,9 @@ + + + @@ -233,12 +236,27 @@ - +
-
+ + +
+ + {{ (faceDetector.currentDistanceRatio() * 100).toFixed(0) }}% +
+ + +
{{ faceDetector.currentTiltAngle() }}° @@ -349,9 +367,3 @@
-

Tocca lo sfondo o l'icona per sbloccare

-
-
- - - diff --git a/src/app/pages/player/player.page.scss b/src/app/pages/player/player.page.scss index b4fd117..91f3972 100644 --- a/src/app/pages/player/player.page.scss +++ b/src/app/pages/player/player.page.scss @@ -409,7 +409,7 @@ font-weight: 700; color: var(--ion-color-secondary); padding: 0 4px; - transition: color 0.15s ease-out; + transition: all 0.15s ease-out; ion-icon { font-size: 1.1rem; @@ -417,9 +417,21 @@ display: inline-block; } - &.tilted { + &.tilted, &.approaching { color: var(--ion-color-success, #2ed573); text-shadow: 0 0 5px rgba(46, 213, 115, 0.6); + transform: scale(1.05); + } + + &.receding { + color: var(--ion-color-warning, #ffa502); + text-shadow: 0 0 5px rgba(255, 165, 2, 0.6); + transform: scale(1.05); + } + + &.distance-mode { + cursor: pointer; + user-select: none; } } } @@ -1131,3 +1143,4 @@ ion-content.full-screen-content { transform: rotate(180deg); } + diff --git a/src/app/pages/player/player.page.ts b/src/app/pages/player/player.page.ts index 168b60f..a7f52aa 100644 --- a/src/app/pages/player/player.page.ts +++ b/src/app/pages/player/player.page.ts @@ -1,4 +1,4 @@ -import { Component, OnInit, OnDestroy, signal, computed, effect, inject, ElementRef, AfterViewInit, untracked, HostListener } from '@angular/core'; +import { Component, OnInit, OnDestroy, signal, computed, effect, inject, ElementRef, AfterViewInit, untracked, HostListener, ViewChild } from '@angular/core'; import { DomSanitizer, SafeResourceUrl, Meta } from '@angular/platform-browser'; import { ActivatedRoute, Router } from '@angular/router'; import { AlertController, ToastController, GestureController } from '@ionic/angular'; @@ -24,6 +24,8 @@ import { FaceDetectorService } from '../../services/face-detector.service'; standalone: false }) export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { + @ViewChild('faceVideo', { static: false }) faceVideoRef!: ElementRef; + public canto = signal(null); public activeSongId = signal(null); public isTitleWrapped = signal(false); @@ -52,6 +54,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } } + private clickTimeout: any = null; + handleLyricsClick(event: MouseEvent) { const target = event.target as HTMLElement; if (target && (target.closest('button') || target.closest('ion-button') || target.closest('.side-indicator') || target.closest('.autoscroll-indicator') || target.closest('ion-icon'))) { @@ -61,13 +65,20 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { if (selection && selection.toString().length > 0) { return; } - const clickX = event.clientX; - const width = window.innerWidth; - if (clickX > width / 2) { - this.next(false, true); - } else { + + // Se c'è già un click in attesa, questo è il secondo tocco (Doppio Tap) -> Torna indietro + if (this.clickTimeout) { + clearTimeout(this.clickTimeout); + this.clickTimeout = null; this.prev(true); + return; } + + // Altrimenti imposta un timer per il singolo tocco -> Avanza + this.clickTimeout = setTimeout(() => { + this.clickTimeout = null; + this.next(false, true); + }, 250); } @@ -534,9 +545,11 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { onEnd: (ev) => { if (Math.abs(ev.deltaX) > 60) { if (ev.deltaX > 0) { - this.prev(); + // Swipe/trascinamento verso destra -> Brano precedente + this.prevSong(); } else { - this.next(); + // Swipe/trascinamento verso sinistra -> Brano successivo + this.nextSong(); } } } @@ -1369,6 +1382,18 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } } + async recalibrateDistance(event?: Event) { + if (event) event.stopPropagation(); + this.faceDetector.resetDistanceBaseline(); + const toast = await this.toastCtrl.create({ + message: 'Distanza ricalibrata al 100%', + duration: 1500, + position: 'bottom', + color: 'secondary' + }); + await toast.present(); + } + toggleCameraNavigation() { if (this.settingsService.cameraNavigationActive()) { this.stopCameraNavigation(); @@ -1382,28 +1407,31 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { this.initCameraTracking(); } - initCameraTracking() { + async initCameraTracking() { if (!this.settingsService.cameraNavigationActive()) return; - setTimeout(async () => { - const videoEl = document.querySelector('#face-preview-video') as HTMLVideoElement; - if (videoEl) { - try { - await this.faceDetector.start(videoEl, (direction) => { - console.log(`[PlayerPage] Head tilt trigger received: ${direction}`); - if (direction === 'next') { - this.next(false, true); - } else { - this.prev(true); - } - }); - } catch (e: any) { - this.settingsService.cameraNavigationActive.set(false); - const errorMsg = e?.message || e?.name || (typeof e === 'object' ? JSON.stringify(e) : String(e)) || 'Errore sconosciuto'; - alert(`Impossibile accedere alla fotocamera. Assicurati di aver concesso i permessi e di usare HTTPS.\n\nDettagli errore: ${errorMsg}`); + // Usa ViewChild per trovare il video element in modo sicuro + const videoEl = this.faceVideoRef?.nativeElement || document.querySelector('#face-preview-video') as HTMLVideoElement; + if (!videoEl) { + console.error('[PlayerPage] Video element #face-preview-video not found in DOM'); + this.settingsService.cameraNavigationActive.set(false); + return; + } + + try { + await this.faceDetector.start(videoEl, (direction) => { + console.log(`[PlayerPage] Head tilt trigger received: ${direction}`); + if (direction === 'next') { + this.next(false, true); + } else { + this.prev(true); } - } - }, 300); + }); + } catch (e: any) { + this.settingsService.cameraNavigationActive.set(false); + const errorMsg = e?.message || e?.name || (typeof e === 'object' ? JSON.stringify(e) : String(e)) || 'Errore sconosciuto'; + alert(`Impossibile accedere alla fotocamera. Assicurati di aver concesso i permessi e di usare HTTPS.\n\nDettagli errore: ${errorMsg}`); + } } stopCameraNavigation() { @@ -1412,6 +1440,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy { } ngOnDestroy() { + if (this.clickTimeout) { + clearTimeout(this.clickTimeout); + this.clickTimeout = null; + } this.exitFullscreen(); this.stopAutoscroll(); this.logPreviousSongTime(); diff --git a/src/app/pages/settings/settings.page.html b/src/app/pages/settings/settings.page.html index e79851f..c68bb0e 100644 --- a/src/app/pages/settings/settings.page.html +++ b/src/app/pages/settings/settings.page.html @@ -354,10 +354,37 @@ - - - - + +
+
+

+ Modalità Tracciamento Fotocamera (Karaoke) +

+
+ +
+
+
+ Distanza Viso (Avanti / Dietro) +
+
+ Inclinazione Testa +
+
+

+ + 💡 Distanza Viso: avvicinati allo schermo per avanzare nel testo, allontanati per tornare indietro. Tocca la percentuale nel player per calibrare all'istante la tua distanza attuale al 100%. + + + 💡 Inclinazione: piega la testa a destra o a sinistra per avanzare o tornare indietro. + +

+
+
@@ -380,7 +407,7 @@

Scroll Visuale (Karaoke)

- Attiva lo scorrimento visuale intelligente tramite movimenti del capo rilevati dalla fotocamera frontale. Inclinando la testa è possibile scorrere il testo senza toccare lo schermo. + Attiva lo scorrimento visuale intelligente tramite fotocamera frontale (avvicinandoti/allontanandoti o inclinando il capo in base alla modalità selezionata).

diff --git a/src/app/services/face-detector.service.ts b/src/app/services/face-detector.service.ts index 43b92a9..61e64ee 100644 --- a/src/app/services/face-detector.service.ts +++ b/src/app/services/face-detector.service.ts @@ -1,92 +1,98 @@ -import { Injectable, signal } from '@angular/core'; +import { Injectable, inject, signal } from '@angular/core'; +import { SettingsService } from './settings.service'; @Injectable({ providedIn: 'root' }) export class FaceDetectorService { + private settingsService = inject(SettingsService); + public isCameraActive = signal(false); public currentTiltAngle = signal(0); public isTilted = signal(false); - private stream: MediaStream | null = null; - private animFrameId: number | null = null; - private faceMesh: any = null; - private onTiltCallback: ((direction: 'next' | 'prev') => void) | null = null; + // Segnali di stato per la modalità distanza viso + public currentDistanceRatio = signal(1.0); // Rapporto rispetto alla distanza di riposo (es. 1.25 = 25% più vicino, 0.75 = 25% più lontano) + public distanceState = signal<'idle' | 'approaching' | 'receding'>('idle'); // Stato dinamico rilevato - // Gesture state machine + private stream: MediaStream | null = null; + private camera: any = null; + private faceMesh: any = null; + private onTriggerCallback: ((direction: 'next' | 'prev') => void) | null = null; + private isProcessingFrame: boolean = false; + + // Gesture state machine - Tilt (inclinazione testa) private tiltStartTime: number = 0; - private inCooldown: boolean = false; - private lastTriggerTime: number = 0; - private readonly TILT_THRESHOLD = 30; // Degrees to trigger next/prev page (30° for stability) - private readonly TILT_HOLD_MS = 300; // How long to hold the tilt - private readonly RETURN_THRESHOLD = 15; // Degrees to reset cooldown - private readonly TRIGGER_COOLDOWN_MS = 3000; // Minimum time between consecutive gestures in ms + private tiltInCooldown: boolean = false; + private lastTiltTriggerTime: number = 0; + private readonly TILT_THRESHOLD = 15; // Gradi per scattare avanti/dietro (originale: 15°) + private readonly TILT_HOLD_MS = 250; // Durata mantenimento inclinazione (ms) + private readonly RETURN_THRESHOLD = 6; // Gradi per reimpostare il cooldown (originale: 6°) + private readonly TRIGGER_COOLDOWN_MS = 2000; // Tempo minimo tra due scatti consecutivi (ms) + + // Gesture state machine - Distance (avvicinamento / allontanamento viso) + // Utilizziamo la distanza geometrica normalizzata tra gli angoli esterni degli occhi (33 e 263) e tra fronte (10) e mento (152) + private baselineFaceSize: number = 0; // Dimensione di riferimento del viso in posizione neutra + private smoothedFaceSize: number = 0; // Media mobile per evitare jitter/rumore + private distanceStartTime: number = 0; // Timestamp di inizio superamento soglia + private distanceInCooldown: boolean = false; // Flag di attesa ritorno a neutro + private lastDistanceTriggerTime: number = 0; // Timestamp ultimo scatto + private activeDistanceDirection: 'next' | 'prev' | null = null; constructor() {} /** - * Loads MediaPipe script dynamically if not already loaded. + * Loads MediaPipe scripts dynamically if not already loaded. */ private loadScripts(): Promise { return new Promise((resolve, reject) => { - if ((window as any).FaceMesh) { + if ((window as any).FaceMesh && (window as any).Camera) { resolve(); return; } + const cameraScript = document.createElement('script'); + cameraScript.src = 'assets/mediapipe/camera_utils.js'; + const faceMeshScript = document.createElement('script'); faceMeshScript.src = 'assets/mediapipe/face_mesh.js'; - faceMeshScript.onload = () => resolve(); + + cameraScript.onload = () => { + document.head.appendChild(faceMeshScript); + }; + + faceMeshScript.onload = () => { + resolve(); + }; + + cameraScript.onerror = (err) => reject(err); faceMeshScript.onerror = (err) => reject(err); - document.head.appendChild(faceMeshScript); + document.head.appendChild(cameraScript); }); } /** - * Starts or re-binds camera capture and face mesh tracking. - * If a stream is already active (e.g. navigating between songs on iPad), reuses it - * to avoid triggering repeated permission prompts on iOS/Safari. + * Starts camera capture and face mesh tracking. */ - async start(videoElement: HTMLVideoElement, onTilt: (direction: 'next' | 'prev') => void): Promise { - this.onTiltCallback = onTilt; + async start(videoElement: HTMLVideoElement, onTrigger: (direction: 'next' | 'prev') => void): Promise { + this.onTriggerCallback = onTrigger; + + if (this.isCameraActive()) { + return; + } try { await this.loadScripts(); - const isStreamActive = this.stream && - this.stream.active && - this.stream.getVideoTracks().some(track => track.readyState === 'live'); + const FaceMeshLib = (window as any).FaceMesh; + const CameraLib = (window as any).Camera; - if (!isStreamActive) { - // Request camera permissions and stream ONCE - this.stream = await navigator.mediaDevices.getUserMedia({ - video: { - width: { ideal: 320 }, - height: { ideal: 240 }, - facingMode: 'user' - }, - audio: false - }); - } - - videoElement.srcObject = this.stream; - videoElement.setAttribute('playsinline', 'true'); - (videoElement as any).playsInline = true; - videoElement.muted = true; - - try { - await videoElement.play(); - } catch (playErr) { - console.warn('[FaceDetector] Video play warning:', playErr); + if (!FaceMeshLib || !CameraLib) { + throw new Error('MediaPipe libraries failed to initialize.'); } if (!this.faceMesh) { - const FaceMeshLib = (window as any).FaceMesh; - if (!FaceMeshLib) { - throw new Error('MediaPipe FaceMesh library failed to initialize.'); - } - this.faceMesh = new FaceMeshLib({ locateFile: (file: string) => `assets/mediapipe/${file}` }); @@ -94,8 +100,8 @@ export class FaceDetectorService { this.faceMesh.setOptions({ maxNumFaces: 1, refineLandmarks: false, - minDetectionConfidence: 0.6, - minTrackingConfidence: 0.6 + minDetectionConfidence: 0.5, + minTrackingConfidence: 0.5 }); this.faceMesh.onResults((results: any) => { @@ -103,12 +109,33 @@ export class FaceDetectorService { }); } + // Reset immediato per calibrazione istantanea al primo frame utile + this.resetDistanceBaseline(); + this.isCameraActive.set(true); - // Start custom frame processing loop - this.stopFrameLoop(); - this.startFrameLoop(videoElement); + // Usiamo CameraLib direttamente per gestire lo stream ed evitare doppi stream / loop + this.isProcessingFrame = false; + this.camera = new CameraLib(videoElement, { + onFrame: async () => { + if (!this.isCameraActive() || !this.faceMesh) return; + if (this.isProcessingFrame) return; // Salta il frame se il precedente è ancora in elaborazione (anti-lag & memory leak) + + this.isProcessingFrame = true; + try { + await this.faceMesh.send({ image: videoElement }); + } catch (e) { + console.warn('[FaceDetector] Error sending frame to FaceMesh:', e); + } finally { + this.isProcessingFrame = false; + } + }, + width: 320, + height: 240, + facingMode: 'user' + }); + await this.camera.start(); console.log('[FaceDetector] Face tracking started successfully.'); } catch (err) { console.error('[FaceDetector] Failed to start face tracking:', err); @@ -117,84 +144,189 @@ export class FaceDetectorService { } } - private stopFrameLoop() { - if (this.animFrameId !== null) { - cancelAnimationFrame(this.animFrameId); - this.animFrameId = null; - } - } - - private startFrameLoop(videoElement: HTMLVideoElement) { - let lastFrameTime = 0; - const FRAME_INTERVAL_MS = 100; // Analizza massimo 10 fotogrammi al secondo per risparmiare CPU/RAM - - const processFrame = async () => { - if (!this.isCameraActive()) return; - - const now = Date.now(); - if (now - lastFrameTime >= FRAME_INTERVAL_MS && this.faceMesh && videoElement && videoElement.readyState >= 2) { - lastFrameTime = now; - try { - await this.faceMesh.send({ image: videoElement }); - } catch (err) { - console.warn('[FaceDetector] Error processing frame:', err); - } - } - - if (this.isCameraActive()) { - this.animFrameId = requestAnimationFrame(processFrame); - } - }; - - this.animFrameId = requestAnimationFrame(processFrame); + /** + * Resetta la dimensione di riferimento (baseline) della distanza del viso + */ + /** + * Resetta la dimensione di riferimento (baseline) della distanza del viso per calibrazione istantanea + */ + public resetDistanceBaseline() { + this.baselineFaceSize = 0; + this.smoothedFaceSize = 0; + this.currentDistanceRatio.set(1.0); + this.distanceState.set('idle'); + this.distanceStartTime = 0; + this.distanceInCooldown = false; + this.activeDistanceDirection = null; + this.lastDistanceTriggerTime = 0; } /** - * Processes landmarks to calculate head tilt angle. + * Processes landmarks to calculate head tilt angle or face distance. */ private processLandmarks(results: any) { if (!results.multiFaceLandmarks || results.multiFaceLandmarks.length === 0) { this.currentTiltAngle.set(0); this.isTilted.set(false); + this.distanceState.set('idle'); return; } const landmarks = results.multiFaceLandmarks[0]; - // Left eye corner (landmark 33) and right eye corner (landmark 263) + // Landmark essenziali: + // 33: occhio sinistro esterno, 263: occhio destro esterno + // 10: fronte / attaccatura capelli superiore, 152: mento inferiore const leftEye = landmarks[33]; const rightEye = landmarks[263]; + const topForehead = landmarks[10]; + const chin = landmarks[152]; if (!leftEye || !rightEye) return; - // Calculate angle in degrees + // Calcolo roll/inclinazione testa (sempre aggiornato per visualizzazione e modalità tilt) const dy = rightEye.y - leftEye.y; const dx = rightEye.x - leftEye.x; - - // Normalize angle (roll) - let angle = Math.atan2(dy, dx) * (180 / Math.PI); - - // Smooth angle updates + const angle = Math.atan2(dy, dx) * (180 / Math.PI); this.currentTiltAngle.set(Math.round(angle)); + const trackingMode = this.settingsService.cameraTrackingMode(); + + if (trackingMode === 'distance') { + this.processDistanceGesture(leftEye, rightEye, topForehead, chin); + } else { + this.processTiltGesture(angle); + } + } + + /** + * Gestione del gesto basato sulla distanza del viso dalla fotocamera + */ + private processDistanceGesture(leftEye: any, rightEye: any, topForehead: any, chin: any) { + // Calcoliamo la larghezza occhi (inter-ocular distance) e l'altezza del viso + const eyeDistX = rightEye.x - leftEye.x; + const eyeDistY = rightEye.y - leftEye.y; + const eyeDist = Math.sqrt(eyeDistX * eyeDistX + eyeDistY * eyeDistY); + + let faceHeight = eyeDist; + if (topForehead && chin) { + const faceHeightX = chin.x - topForehead.x; + const faceHeightY = chin.y - topForehead.y; + faceHeight = Math.sqrt(faceHeightX * faceHeightX + faceHeightY * faceHeightY); + } + + // Media armonica combinata di larghezza occhi e altezza viso per massima accuratezza invariante da rotazioni + const currentMeasuredSize = (eyeDist * 1.5 + faceHeight) / 2.5; + + // Calibrazione ISTANTANEA al primo frame utile (funziona subito senza attendere) + if (this.smoothedFaceSize === 0 || this.baselineFaceSize === 0) { + this.smoothedFaceSize = currentMeasuredSize; + this.baselineFaceSize = currentMeasuredSize; + this.currentDistanceRatio.set(1.0); + this.distanceState.set('idle'); + return; + } + + // Filtro a media mobile esponenziale (EMA) rapido per evitare jitter + this.smoothedFaceSize = this.smoothedFaceSize * 0.65 + currentMeasuredSize * 0.35; + + const ratio = this.baselineFaceSize > 0 ? (this.smoothedFaceSize / this.baselineFaceSize) : 1.0; + this.currentDistanceRatio.set(Math.round(ratio * 100) / 100); + + const now = Date.now(); + + // Soglie per scatto immediato ed ergonomico: + // +12% per avanzare, -12% per retrocedere + const approachThreshold = 1.12; + const recedeThreshold = 0.88; + const returnHigh = 1.05; + const returnLow = 0.95; + const holdMs = 120; // Reattività immediata (120ms) + const distanceCooldownMs = 900; // Cooldown fluido tra scatti consecutivi (0.9s) + + // Adattamento graduale della baseline SOLO quando l'utente è stabile e rilassato al centro + if (!this.distanceInCooldown && ratio >= returnLow && ratio <= returnHigh && this.activeDistanceDirection === null) { + this.baselineFaceSize = this.baselineFaceSize * 0.985 + this.smoothedFaceSize * 0.015; + } + + // Riconoscimento avvicinamento (Next) + if (ratio >= approachThreshold) { + this.distanceState.set('approaching'); + + if (!this.distanceInCooldown && (now - this.lastDistanceTriggerTime > distanceCooldownMs)) { + if (this.activeDistanceDirection !== 'next') { + this.activeDistanceDirection = 'next'; + this.distanceStartTime = now; + } else if (now - this.distanceStartTime >= holdMs) { + // Trigger avvicinamento -> AVANTI + console.log(`[FaceDetector] Face approach gesture detected! Ratio: ${ratio.toFixed(2)} -> NEXT`); + if (this.onTriggerCallback) { + this.onTriggerCallback('next'); + } + this.distanceInCooldown = true; + this.activeDistanceDirection = null; + this.distanceStartTime = 0; + this.lastDistanceTriggerTime = now; + } + } + } + // Riconoscimento allontanamento (Prev) + else if (ratio <= recedeThreshold) { + this.distanceState.set('receding'); + + if (!this.distanceInCooldown && (now - this.lastDistanceTriggerTime > distanceCooldownMs)) { + if (this.activeDistanceDirection !== 'prev') { + this.activeDistanceDirection = 'prev'; + this.distanceStartTime = now; + } else if (now - this.distanceStartTime >= holdMs) { + // Trigger allontanamento -> INDIETRO + console.log(`[FaceDetector] Face recede gesture detected! Ratio: ${ratio.toFixed(2)} -> PREV`); + if (this.onTriggerCallback) { + this.onTriggerCallback('prev'); + } + this.distanceInCooldown = true; + this.activeDistanceDirection = null; + this.distanceStartTime = 0; + this.lastDistanceTriggerTime = now; + } + } + } + // Posizione neutra + else { + this.distanceState.set('idle'); + this.activeDistanceDirection = null; + this.distanceStartTime = 0; + + // Reset cooldown quando l'utente torna alla distanza neutra + if (ratio < returnHigh && ratio > returnLow) { + this.distanceInCooldown = false; + } + } + } + + /** + * Gestione del gesto basato sull'inclinazione del collo (modalità tilt) + */ + private processTiltGesture(angle: number) { const absAngle = Math.abs(angle); + const now = Date.now(); if (absAngle > this.TILT_THRESHOLD) { this.isTilted.set(true); - if (!this.inCooldown && (Date.now() - this.lastTriggerTime > this.TRIGGER_COOLDOWN_MS)) { + if (!this.tiltInCooldown && (now - this.lastTiltTriggerTime > this.TRIGGER_COOLDOWN_MS)) { if (this.tiltStartTime === 0) { - this.tiltStartTime = Date.now(); - } else if (Date.now() - this.tiltStartTime > this.TILT_HOLD_MS) { + this.tiltStartTime = now; + } else if (now - this.tiltStartTime >= this.TILT_HOLD_MS) { // Trigger the tilt gesture! const direction = angle > 0 ? 'prev' : 'next'; console.log(`[FaceDetector] Head tilt gesture detected! Angle: ${angle.toFixed(1)}°, Direction: ${direction}`); - if (this.onTiltCallback) { - this.onTiltCallback(direction); + if (this.onTriggerCallback) { + this.onTriggerCallback(direction); } - this.inCooldown = true; + this.tiltInCooldown = true; this.tiltStartTime = 0; - this.lastTriggerTime = Date.now(); + this.lastTiltTriggerTime = now; } } } else { @@ -203,7 +335,7 @@ export class FaceDetectorService { // Reset cooldown when the head returns near the center if (absAngle < this.RETURN_THRESHOLD) { - this.inCooldown = false; + this.tiltInCooldown = false; } } } @@ -212,8 +344,14 @@ export class FaceDetectorService { * Pauses the frame tracking loop without killing the underlying media stream hardware. */ pause() { - this.stopFrameLoop(); this.isCameraActive.set(false); + this.isProcessingFrame = false; + if (this.camera) { + try { + this.camera.stop(); + } catch (e) {} + this.camera = null; + } } /** @@ -223,12 +361,15 @@ export class FaceDetectorService { this.pause(); this.currentTiltAngle.set(0); this.isTilted.set(false); - this.inCooldown = false; + this.tiltInCooldown = false; this.tiltStartTime = 0; - this.lastTriggerTime = 0; + this.lastTiltTriggerTime = 0; + this.resetDistanceBaseline(); if (this.stream) { - this.stream.getTracks().forEach(track => track.stop()); + try { + this.stream.getTracks().forEach(track => track.stop()); + } catch (e) {} this.stream = null; } @@ -239,7 +380,8 @@ export class FaceDetectorService { this.faceMesh = null; } - this.onTiltCallback = null; + this.onTriggerCallback = null; console.log('[FaceDetector] Face tracking fully stopped.'); } } + diff --git a/src/app/services/settings.service.ts b/src/app/services/settings.service.ts index 1d49ff4..e533d1b 100644 --- a/src/app/services/settings.service.ts +++ b/src/app/services/settings.service.ts @@ -53,6 +53,12 @@ export class SettingsService { /** Navigazione con fotocamera (tracciamento testa) attiva nel player: true = attivo */ public cameraNavigationActive = signal(false); + /** Modalità tracciamento fotocamera: 'tilt' (inclinazione collo) o 'distance' (avvicinamento/allontanamento viso) */ + public cameraTrackingMode = signal<'tilt' | 'distance'>('tilt'); + + /** Fascia di distanza utente-fotocamera per taratura sensibilità: 'close' (0-30 cm), 'medium' (30-60 cm), 'far' (1m e oltre) */ + public cameraDistanceRange = signal<'close' | 'medium' | 'far'>('medium'); + /** Preferenza notazione accordi: diesis o bemolle */ public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis'); @@ -360,17 +366,35 @@ export class SettingsService { localStorage.setItem('global-zoom', this.globalZoom().toString()); }); - const savedCameraNavActive = localStorage.getItem('camera-navigation-active'); - if (savedCameraNavActive !== null) { - this.cameraNavigationActive.set(savedCameraNavActive === 'true'); + // La navigazione con fotocamera deve SEMPRE partire disattivata all'avvio dell'app + this.cameraNavigationActive.set(false); + + const savedCameraTrackingMode = localStorage.getItem('camera-tracking-mode'); + if (savedCameraTrackingMode === 'tilt' || savedCameraTrackingMode === 'distance') { + this.cameraTrackingMode.set(savedCameraTrackingMode); } else { - this.cameraNavigationActive.set(false); + this.cameraTrackingMode.set('tilt'); + } + + const savedCameraDistanceRange = localStorage.getItem('camera-distance-range'); + if (savedCameraDistanceRange === 'close' || savedCameraDistanceRange === 'medium' || savedCameraDistanceRange === 'far') { + this.cameraDistanceRange.set(savedCameraDistanceRange); + } else { + this.cameraDistanceRange.set('medium'); } effect(() => { localStorage.setItem('camera-navigation-active', this.cameraNavigationActive().toString()); }); + effect(() => { + localStorage.setItem('camera-tracking-mode', this.cameraTrackingMode()); + }); + + effect(() => { + localStorage.setItem('camera-distance-range', this.cameraDistanceRange()); + }); + effect(() => { const active = this.keepScreenOn(); localStorage.setItem('keep-screen-on', active.toString()); @@ -522,6 +546,14 @@ export class SettingsService { this.chordNotationPreference.set(val); } + setCameraTrackingMode(val: 'tilt' | 'distance') { + this.cameraTrackingMode.set(val); + } + + setCameraDistanceRange(val: 'close' | 'medium' | 'far') { + this.cameraDistanceRange.set(val); + } + setUserUuid(uuid: string) { const trimmed = uuid.trim(); if (trimmed) { diff --git a/src/app/version.ts b/src/app/version.ts index 22fdb00..aac83a0 100644 --- a/src/app/version.ts +++ b/src/app/version.ts @@ -1 +1 @@ -export const VERSION = '2026.08.09.0826'; +export const VERSION = '2026.08.24.0054';