Ottimizzazione face tracking: calibrazione istantanea, eliminazione loop di stream e reattività migliorata
This commit is contained in:
@@ -171,6 +171,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden Camera Video for Head Navigation (needed for face detection API to run) -->
|
||||||
|
<video #faceVideo id="face-preview-video" muted playsinline autoplay style="position: fixed; width: 1px; height: 1px; opacity: 0; pointer-events: none; z-index: -1;"></video>
|
||||||
</ion-content>
|
</ion-content>
|
||||||
|
|
||||||
<ion-footer class="ion-no-border">
|
<ion-footer class="ion-no-border">
|
||||||
@@ -233,12 +236,27 @@
|
|||||||
</ion-button>
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Camera Head Gestures Toggle in Portrait -->
|
<!-- Camera Gestures Toggle in Portrait (Tilt or Distance) -->
|
||||||
<div class="group" *ngIf="settingsService.enableVisualAutoscroll()">
|
<div class="group" *ngIf="settingsService.enableVisualAutoscroll()">
|
||||||
<ion-button fill="clear" size="small" (click)="toggleCameraNavigation()" [color]="enableCameraNavigation() ? 'success' : 'secondary'" style="margin: 0;">
|
<ion-button fill="clear" size="small" (click)="toggleCameraNavigation()" [color]="enableCameraNavigation() ? 'success' : 'secondary'" style="margin: 0;">
|
||||||
<ion-icon slot="icon-only" [name]="enableCameraNavigation() ? 'videocam' : 'videocam-off-outline'"></ion-icon>
|
<ion-icon slot="icon-only" [name]="enableCameraNavigation() ? 'videocam' : 'videocam-off-outline'"></ion-icon>
|
||||||
</ion-button>
|
</ion-button>
|
||||||
<div class="camera-indicator" *ngIf="enableCameraNavigation()" [class.tilted]="faceDetector.isTilted()">
|
|
||||||
|
<!-- Indicator in Distance Mode -->
|
||||||
|
<div class="camera-indicator distance-mode"
|
||||||
|
*ngIf="enableCameraNavigation() && settingsService.cameraTrackingMode() === 'distance'"
|
||||||
|
[class.approaching]="faceDetector.distanceState() === 'approaching'"
|
||||||
|
[class.receding]="faceDetector.distanceState() === 'receding'"
|
||||||
|
(click)="recalibrateDistance($event)"
|
||||||
|
title="Tocca per ricalibrare e impostare la tua distanza attuale come 100%">
|
||||||
|
<ion-icon [name]="faceDetector.distanceState() === 'approaching' ? 'arrow-down-circle' : (faceDetector.distanceState() === 'receding' ? 'arrow-up-circle' : 'scan-outline')"></ion-icon>
|
||||||
|
<span class="val">{{ (faceDetector.currentDistanceRatio() * 100).toFixed(0) }}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Indicator in Tilt Mode -->
|
||||||
|
<div class="camera-indicator"
|
||||||
|
*ngIf="enableCameraNavigation() && settingsService.cameraTrackingMode() === 'tilt'"
|
||||||
|
[class.tilted]="faceDetector.isTilted()">
|
||||||
<ion-icon name="person-outline"
|
<ion-icon name="person-outline"
|
||||||
[style.transform]="'rotate(' + (-faceDetector.currentTiltAngle()) + 'deg)'"></ion-icon>
|
[style.transform]="'rotate(' + (-faceDetector.currentTiltAngle()) + 'deg)'"></ion-icon>
|
||||||
<span class="val">{{ faceDetector.currentTiltAngle() }}°</span>
|
<span class="val">{{ faceDetector.currentTiltAngle() }}°</span>
|
||||||
@@ -349,9 +367,3 @@
|
|||||||
</ion-button>
|
</ion-button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="car-mode-subtext outfit-font" (click)="deactivateBlackScreen()">Tocca lo sfondo o l'icona per sbloccare</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hidden Camera Video for Head Navigation (needed for face detection API to run) -->
|
|
||||||
<video *ngIf="enableCameraNavigation()" id="face-preview-video" muted playsinline autoplay style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;"></video>
|
|
||||||
|
|||||||
@@ -409,7 +409,7 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--ion-color-secondary);
|
color: var(--ion-color-secondary);
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
transition: color 0.15s ease-out;
|
transition: all 0.15s ease-out;
|
||||||
|
|
||||||
ion-icon {
|
ion-icon {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
@@ -417,9 +417,21 @@
|
|||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.tilted {
|
&.tilted, &.approaching {
|
||||||
color: var(--ion-color-success, #2ed573);
|
color: var(--ion-color-success, #2ed573);
|
||||||
text-shadow: 0 0 5px rgba(46, 213, 115, 0.6);
|
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);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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 { DomSanitizer, SafeResourceUrl, Meta } from '@angular/platform-browser';
|
||||||
import { ActivatedRoute, Router } from '@angular/router';
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
import { AlertController, ToastController, GestureController } from '@ionic/angular';
|
import { AlertController, ToastController, GestureController } from '@ionic/angular';
|
||||||
@@ -24,6 +24,8 @@ import { FaceDetectorService } from '../../services/face-detector.service';
|
|||||||
standalone: false
|
standalone: false
|
||||||
})
|
})
|
||||||
export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
@ViewChild('faceVideo', { static: false }) faceVideoRef!: ElementRef<HTMLVideoElement>;
|
||||||
|
|
||||||
public canto = signal<Canto | null>(null);
|
public canto = signal<Canto | null>(null);
|
||||||
public activeSongId = signal<string | null>(null);
|
public activeSongId = signal<string | null>(null);
|
||||||
public isTitleWrapped = signal<boolean>(false);
|
public isTitleWrapped = signal<boolean>(false);
|
||||||
@@ -52,6 +54,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private clickTimeout: any = null;
|
||||||
|
|
||||||
handleLyricsClick(event: MouseEvent) {
|
handleLyricsClick(event: MouseEvent) {
|
||||||
const target = event.target as HTMLElement;
|
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'))) {
|
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) {
|
if (selection && selection.toString().length > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const clickX = event.clientX;
|
|
||||||
const width = window.innerWidth;
|
// Se c'è già un click in attesa, questo è il secondo tocco (Doppio Tap) -> Torna indietro
|
||||||
if (clickX > width / 2) {
|
if (this.clickTimeout) {
|
||||||
this.next(false, true);
|
clearTimeout(this.clickTimeout);
|
||||||
} else {
|
this.clickTimeout = null;
|
||||||
this.prev(true);
|
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) => {
|
onEnd: (ev) => {
|
||||||
if (Math.abs(ev.deltaX) > 60) {
|
if (Math.abs(ev.deltaX) > 60) {
|
||||||
if (ev.deltaX > 0) {
|
if (ev.deltaX > 0) {
|
||||||
this.prev();
|
// Swipe/trascinamento verso destra -> Brano precedente
|
||||||
|
this.prevSong();
|
||||||
} else {
|
} 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() {
|
toggleCameraNavigation() {
|
||||||
if (this.settingsService.cameraNavigationActive()) {
|
if (this.settingsService.cameraNavigationActive()) {
|
||||||
this.stopCameraNavigation();
|
this.stopCameraNavigation();
|
||||||
@@ -1382,12 +1407,17 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
this.initCameraTracking();
|
this.initCameraTracking();
|
||||||
}
|
}
|
||||||
|
|
||||||
initCameraTracking() {
|
async initCameraTracking() {
|
||||||
if (!this.settingsService.cameraNavigationActive()) return;
|
if (!this.settingsService.cameraNavigationActive()) return;
|
||||||
|
|
||||||
setTimeout(async () => {
|
// Usa ViewChild per trovare il video element in modo sicuro
|
||||||
const videoEl = document.querySelector('#face-preview-video') as HTMLVideoElement;
|
const videoEl = this.faceVideoRef?.nativeElement || document.querySelector('#face-preview-video') as HTMLVideoElement;
|
||||||
if (videoEl) {
|
if (!videoEl) {
|
||||||
|
console.error('[PlayerPage] Video element #face-preview-video not found in DOM');
|
||||||
|
this.settingsService.cameraNavigationActive.set(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.faceDetector.start(videoEl, (direction) => {
|
await this.faceDetector.start(videoEl, (direction) => {
|
||||||
console.log(`[PlayerPage] Head tilt trigger received: ${direction}`);
|
console.log(`[PlayerPage] Head tilt trigger received: ${direction}`);
|
||||||
@@ -1403,8 +1433,6 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
alert(`Impossibile accedere alla fotocamera. Assicurati di aver concesso i permessi e di usare HTTPS.\n\nDettagli errore: ${errorMsg}`);
|
alert(`Impossibile accedere alla fotocamera. Assicurati di aver concesso i permessi e di usare HTTPS.\n\nDettagli errore: ${errorMsg}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
stopCameraNavigation() {
|
stopCameraNavigation() {
|
||||||
this.settingsService.cameraNavigationActive.set(false);
|
this.settingsService.cameraNavigationActive.set(false);
|
||||||
@@ -1412,6 +1440,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
|
if (this.clickTimeout) {
|
||||||
|
clearTimeout(this.clickTimeout);
|
||||||
|
this.clickTimeout = null;
|
||||||
|
}
|
||||||
this.exitFullscreen();
|
this.exitFullscreen();
|
||||||
this.stopAutoscroll();
|
this.stopAutoscroll();
|
||||||
this.logPreviousSongTime();
|
this.logPreviousSongTime();
|
||||||
|
|||||||
@@ -354,10 +354,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Camera Tracking Mode Section -->
|
||||||
|
<div class="settings-group glass ion-margin-bottom" *ngIf="settingsService.enableVisualAutoscroll()">
|
||||||
|
<div class="group-header ion-padding-start ion-padding-top">
|
||||||
|
<h2 class="outfit-font settings-group-title">
|
||||||
|
Modalità Tracciamento Fotocamera (Karaoke)
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="segment-wrapper ion-padding-horizontal ion-padding-bottom">
|
||||||
|
<div class="filter-buttons compact-mode ion-padding-horizontal ion-padding-bottom">
|
||||||
|
<div class="filter-btn glass"
|
||||||
|
[class.active-btn]="settingsService.cameraTrackingMode() === 'distance'"
|
||||||
|
(click)="settingsService.setCameraTrackingMode('distance')">
|
||||||
|
<span>Distanza Viso (Avanti / Dietro)</span>
|
||||||
|
</div>
|
||||||
|
<div class="filter-btn glass"
|
||||||
|
[class.active-btn]="settingsService.cameraTrackingMode() === 'tilt'"
|
||||||
|
(click)="settingsService.setCameraTrackingMode('tilt')">
|
||||||
|
<span>Inclinazione Testa</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="settings-item-subtitle outfit-font" style="margin: 8px 12px 0 12px; font-size: 0.78rem; opacity: 0.8; line-height: 1.4;">
|
||||||
|
<span *ngIf="settingsService.cameraTrackingMode() === 'distance'">
|
||||||
|
💡 <strong>Distanza Viso:</strong> 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%.
|
||||||
|
</span>
|
||||||
|
<span *ngIf="settingsService.cameraTrackingMode() === 'tilt'">
|
||||||
|
💡 <strong>Inclinazione:</strong> piega la testa a destra o a sinistra per avanzare o tornare indietro.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Legenda Pulsanti Canto -->
|
<!-- Legenda Pulsanti Canto -->
|
||||||
<div class="settings-group glass ion-margin-top">
|
<div class="settings-group glass ion-margin-top">
|
||||||
@@ -380,7 +407,7 @@
|
|||||||
<div class="legend-text">
|
<div class="legend-text">
|
||||||
<h4 class="outfit-font">Scroll Visuale (Karaoke)</h4>
|
<h4 class="outfit-font">Scroll Visuale (Karaoke)</h4>
|
||||||
<p class="outfit-font">
|
<p class="outfit-font">
|
||||||
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).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,92 +1,98 @@
|
|||||||
import { Injectable, signal } from '@angular/core';
|
import { Injectable, inject, signal } from '@angular/core';
|
||||||
|
import { SettingsService } from './settings.service';
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root'
|
providedIn: 'root'
|
||||||
})
|
})
|
||||||
export class FaceDetectorService {
|
export class FaceDetectorService {
|
||||||
|
private settingsService = inject(SettingsService);
|
||||||
|
|
||||||
public isCameraActive = signal<boolean>(false);
|
public isCameraActive = signal<boolean>(false);
|
||||||
public currentTiltAngle = signal<number>(0);
|
public currentTiltAngle = signal<number>(0);
|
||||||
public isTilted = signal<boolean>(false);
|
public isTilted = signal<boolean>(false);
|
||||||
|
|
||||||
private stream: MediaStream | null = null;
|
// Segnali di stato per la modalità distanza viso
|
||||||
private animFrameId: number | null = null;
|
public currentDistanceRatio = signal<number>(1.0); // Rapporto rispetto alla distanza di riposo (es. 1.25 = 25% più vicino, 0.75 = 25% più lontano)
|
||||||
private faceMesh: any = null;
|
public distanceState = signal<'idle' | 'approaching' | 'receding'>('idle'); // Stato dinamico rilevato
|
||||||
private onTiltCallback: ((direction: 'next' | 'prev') => void) | null = null;
|
|
||||||
|
|
||||||
// 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 tiltStartTime: number = 0;
|
||||||
private inCooldown: boolean = false;
|
private tiltInCooldown: boolean = false;
|
||||||
private lastTriggerTime: number = 0;
|
private lastTiltTriggerTime: number = 0;
|
||||||
private readonly TILT_THRESHOLD = 30; // Degrees to trigger next/prev page (30° for stability)
|
private readonly TILT_THRESHOLD = 15; // Gradi per scattare avanti/dietro (originale: 15°)
|
||||||
private readonly TILT_HOLD_MS = 300; // How long to hold the tilt
|
private readonly TILT_HOLD_MS = 250; // Durata mantenimento inclinazione (ms)
|
||||||
private readonly RETURN_THRESHOLD = 15; // Degrees to reset cooldown
|
private readonly RETURN_THRESHOLD = 6; // Gradi per reimpostare il cooldown (originale: 6°)
|
||||||
private readonly TRIGGER_COOLDOWN_MS = 3000; // Minimum time between consecutive gestures in ms
|
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() {}
|
constructor() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loads MediaPipe script dynamically if not already loaded.
|
* Loads MediaPipe scripts dynamically if not already loaded.
|
||||||
*/
|
*/
|
||||||
private loadScripts(): Promise<void> {
|
private loadScripts(): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if ((window as any).FaceMesh) {
|
if ((window as any).FaceMesh && (window as any).Camera) {
|
||||||
resolve();
|
resolve();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cameraScript = document.createElement('script');
|
||||||
|
cameraScript.src = 'assets/mediapipe/camera_utils.js';
|
||||||
|
|
||||||
const faceMeshScript = document.createElement('script');
|
const faceMeshScript = document.createElement('script');
|
||||||
faceMeshScript.src = 'assets/mediapipe/face_mesh.js';
|
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);
|
faceMeshScript.onerror = (err) => reject(err);
|
||||||
|
|
||||||
document.head.appendChild(faceMeshScript);
|
document.head.appendChild(cameraScript);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts or re-binds camera capture and face mesh tracking.
|
* Starts 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.
|
|
||||||
*/
|
*/
|
||||||
async start(videoElement: HTMLVideoElement, onTilt: (direction: 'next' | 'prev') => void): Promise<void> {
|
async start(videoElement: HTMLVideoElement, onTrigger: (direction: 'next' | 'prev') => void): Promise<void> {
|
||||||
this.onTiltCallback = onTilt;
|
this.onTriggerCallback = onTrigger;
|
||||||
|
|
||||||
|
if (this.isCameraActive()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.loadScripts();
|
await this.loadScripts();
|
||||||
|
|
||||||
const isStreamActive = this.stream &&
|
const FaceMeshLib = (window as any).FaceMesh;
|
||||||
this.stream.active &&
|
const CameraLib = (window as any).Camera;
|
||||||
this.stream.getVideoTracks().some(track => track.readyState === 'live');
|
|
||||||
|
|
||||||
if (!isStreamActive) {
|
if (!FaceMeshLib || !CameraLib) {
|
||||||
// Request camera permissions and stream ONCE
|
throw new Error('MediaPipe libraries failed to initialize.');
|
||||||
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 (!this.faceMesh) {
|
if (!this.faceMesh) {
|
||||||
const FaceMeshLib = (window as any).FaceMesh;
|
|
||||||
if (!FaceMeshLib) {
|
|
||||||
throw new Error('MediaPipe FaceMesh library failed to initialize.');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.faceMesh = new FaceMeshLib({
|
this.faceMesh = new FaceMeshLib({
|
||||||
locateFile: (file: string) => `assets/mediapipe/${file}`
|
locateFile: (file: string) => `assets/mediapipe/${file}`
|
||||||
});
|
});
|
||||||
@@ -94,8 +100,8 @@ export class FaceDetectorService {
|
|||||||
this.faceMesh.setOptions({
|
this.faceMesh.setOptions({
|
||||||
maxNumFaces: 1,
|
maxNumFaces: 1,
|
||||||
refineLandmarks: false,
|
refineLandmarks: false,
|
||||||
minDetectionConfidence: 0.6,
|
minDetectionConfidence: 0.5,
|
||||||
minTrackingConfidence: 0.6
|
minTrackingConfidence: 0.5
|
||||||
});
|
});
|
||||||
|
|
||||||
this.faceMesh.onResults((results: any) => {
|
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);
|
this.isCameraActive.set(true);
|
||||||
|
|
||||||
// Start custom frame processing loop
|
// Usiamo CameraLib direttamente per gestire lo stream ed evitare doppi stream / loop
|
||||||
this.stopFrameLoop();
|
this.isProcessingFrame = false;
|
||||||
this.startFrameLoop(videoElement);
|
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.');
|
console.log('[FaceDetector] Face tracking started successfully.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[FaceDetector] Failed to start face tracking:', err);
|
console.error('[FaceDetector] Failed to start face tracking:', err);
|
||||||
@@ -117,84 +144,189 @@ export class FaceDetectorService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private stopFrameLoop() {
|
/**
|
||||||
if (this.animFrameId !== null) {
|
* Resetta la dimensione di riferimento (baseline) della distanza del viso
|
||||||
cancelAnimationFrame(this.animFrameId);
|
*/
|
||||||
this.animFrameId = null;
|
/**
|
||||||
}
|
* Resetta la dimensione di riferimento (baseline) della distanza del viso per calibrazione istantanea
|
||||||
}
|
*/
|
||||||
|
public resetDistanceBaseline() {
|
||||||
private startFrameLoop(videoElement: HTMLVideoElement) {
|
this.baselineFaceSize = 0;
|
||||||
let lastFrameTime = 0;
|
this.smoothedFaceSize = 0;
|
||||||
const FRAME_INTERVAL_MS = 100; // Analizza massimo 10 fotogrammi al secondo per risparmiare CPU/RAM
|
this.currentDistanceRatio.set(1.0);
|
||||||
|
this.distanceState.set('idle');
|
||||||
const processFrame = async () => {
|
this.distanceStartTime = 0;
|
||||||
if (!this.isCameraActive()) return;
|
this.distanceInCooldown = false;
|
||||||
|
this.activeDistanceDirection = null;
|
||||||
const now = Date.now();
|
this.lastDistanceTriggerTime = 0;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes landmarks to calculate head tilt angle.
|
* Processes landmarks to calculate head tilt angle or face distance.
|
||||||
*/
|
*/
|
||||||
private processLandmarks(results: any) {
|
private processLandmarks(results: any) {
|
||||||
if (!results.multiFaceLandmarks || results.multiFaceLandmarks.length === 0) {
|
if (!results.multiFaceLandmarks || results.multiFaceLandmarks.length === 0) {
|
||||||
this.currentTiltAngle.set(0);
|
this.currentTiltAngle.set(0);
|
||||||
this.isTilted.set(false);
|
this.isTilted.set(false);
|
||||||
|
this.distanceState.set('idle');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const landmarks = results.multiFaceLandmarks[0];
|
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 leftEye = landmarks[33];
|
||||||
const rightEye = landmarks[263];
|
const rightEye = landmarks[263];
|
||||||
|
const topForehead = landmarks[10];
|
||||||
|
const chin = landmarks[152];
|
||||||
|
|
||||||
if (!leftEye || !rightEye) return;
|
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 dy = rightEye.y - leftEye.y;
|
||||||
const dx = rightEye.x - leftEye.x;
|
const dx = rightEye.x - leftEye.x;
|
||||||
|
const angle = Math.atan2(dy, dx) * (180 / Math.PI);
|
||||||
// Normalize angle (roll)
|
|
||||||
let angle = Math.atan2(dy, dx) * (180 / Math.PI);
|
|
||||||
|
|
||||||
// Smooth angle updates
|
|
||||||
this.currentTiltAngle.set(Math.round(angle));
|
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 absAngle = Math.abs(angle);
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
if (absAngle > this.TILT_THRESHOLD) {
|
if (absAngle > this.TILT_THRESHOLD) {
|
||||||
this.isTilted.set(true);
|
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) {
|
if (this.tiltStartTime === 0) {
|
||||||
this.tiltStartTime = Date.now();
|
this.tiltStartTime = now;
|
||||||
} else if (Date.now() - this.tiltStartTime > this.TILT_HOLD_MS) {
|
} else if (now - this.tiltStartTime >= this.TILT_HOLD_MS) {
|
||||||
// Trigger the tilt gesture!
|
// Trigger the tilt gesture!
|
||||||
const direction = angle > 0 ? 'prev' : 'next';
|
const direction = angle > 0 ? 'prev' : 'next';
|
||||||
console.log(`[FaceDetector] Head tilt gesture detected! Angle: ${angle.toFixed(1)}°, Direction: ${direction}`);
|
console.log(`[FaceDetector] Head tilt gesture detected! Angle: ${angle.toFixed(1)}°, Direction: ${direction}`);
|
||||||
if (this.onTiltCallback) {
|
if (this.onTriggerCallback) {
|
||||||
this.onTiltCallback(direction);
|
this.onTriggerCallback(direction);
|
||||||
}
|
}
|
||||||
this.inCooldown = true;
|
this.tiltInCooldown = true;
|
||||||
this.tiltStartTime = 0;
|
this.tiltStartTime = 0;
|
||||||
this.lastTriggerTime = Date.now();
|
this.lastTiltTriggerTime = now;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -203,7 +335,7 @@ export class FaceDetectorService {
|
|||||||
|
|
||||||
// Reset cooldown when the head returns near the center
|
// Reset cooldown when the head returns near the center
|
||||||
if (absAngle < this.RETURN_THRESHOLD) {
|
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.
|
* Pauses the frame tracking loop without killing the underlying media stream hardware.
|
||||||
*/
|
*/
|
||||||
pause() {
|
pause() {
|
||||||
this.stopFrameLoop();
|
|
||||||
this.isCameraActive.set(false);
|
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.pause();
|
||||||
this.currentTiltAngle.set(0);
|
this.currentTiltAngle.set(0);
|
||||||
this.isTilted.set(false);
|
this.isTilted.set(false);
|
||||||
this.inCooldown = false;
|
this.tiltInCooldown = false;
|
||||||
this.tiltStartTime = 0;
|
this.tiltStartTime = 0;
|
||||||
this.lastTriggerTime = 0;
|
this.lastTiltTriggerTime = 0;
|
||||||
|
this.resetDistanceBaseline();
|
||||||
|
|
||||||
if (this.stream) {
|
if (this.stream) {
|
||||||
|
try {
|
||||||
this.stream.getTracks().forEach(track => track.stop());
|
this.stream.getTracks().forEach(track => track.stop());
|
||||||
|
} catch (e) {}
|
||||||
this.stream = null;
|
this.stream = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +380,8 @@ export class FaceDetectorService {
|
|||||||
this.faceMesh = null;
|
this.faceMesh = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.onTiltCallback = null;
|
this.onTriggerCallback = null;
|
||||||
console.log('[FaceDetector] Face tracking fully stopped.');
|
console.log('[FaceDetector] Face tracking fully stopped.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ export class SettingsService {
|
|||||||
/** Navigazione con fotocamera (tracciamento testa) attiva nel player: true = attivo */
|
/** Navigazione con fotocamera (tracciamento testa) attiva nel player: true = attivo */
|
||||||
public cameraNavigationActive = signal<boolean>(false);
|
public cameraNavigationActive = signal<boolean>(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 */
|
/** Preferenza notazione accordi: diesis o bemolle */
|
||||||
public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis');
|
public chordNotationPreference = signal<'diesis' | 'bemolle'>('diesis');
|
||||||
|
|
||||||
@@ -360,17 +366,35 @@ export class SettingsService {
|
|||||||
localStorage.setItem('global-zoom', this.globalZoom().toString());
|
localStorage.setItem('global-zoom', this.globalZoom().toString());
|
||||||
});
|
});
|
||||||
|
|
||||||
const savedCameraNavActive = localStorage.getItem('camera-navigation-active');
|
// La navigazione con fotocamera deve SEMPRE partire disattivata all'avvio dell'app
|
||||||
if (savedCameraNavActive !== null) {
|
|
||||||
this.cameraNavigationActive.set(savedCameraNavActive === 'true');
|
|
||||||
} else {
|
|
||||||
this.cameraNavigationActive.set(false);
|
this.cameraNavigationActive.set(false);
|
||||||
|
|
||||||
|
const savedCameraTrackingMode = localStorage.getItem('camera-tracking-mode');
|
||||||
|
if (savedCameraTrackingMode === 'tilt' || savedCameraTrackingMode === 'distance') {
|
||||||
|
this.cameraTrackingMode.set(savedCameraTrackingMode);
|
||||||
|
} else {
|
||||||
|
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(() => {
|
effect(() => {
|
||||||
localStorage.setItem('camera-navigation-active', this.cameraNavigationActive().toString());
|
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(() => {
|
effect(() => {
|
||||||
const active = this.keepScreenOn();
|
const active = this.keepScreenOn();
|
||||||
localStorage.setItem('keep-screen-on', active.toString());
|
localStorage.setItem('keep-screen-on', active.toString());
|
||||||
@@ -522,6 +546,14 @@ export class SettingsService {
|
|||||||
this.chordNotationPreference.set(val);
|
this.chordNotationPreference.set(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setCameraTrackingMode(val: 'tilt' | 'distance') {
|
||||||
|
this.cameraTrackingMode.set(val);
|
||||||
|
}
|
||||||
|
|
||||||
|
setCameraDistanceRange(val: 'close' | 'medium' | 'far') {
|
||||||
|
this.cameraDistanceRange.set(val);
|
||||||
|
}
|
||||||
|
|
||||||
setUserUuid(uuid: string) {
|
setUserUuid(uuid: string) {
|
||||||
const trimmed = uuid.trim();
|
const trimmed = uuid.trim();
|
||||||
if (trimmed) {
|
if (trimmed) {
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
export const VERSION = '2026.08.09.0826';
|
export const VERSION = '2026.08.24.0054';
|
||||||
|
|||||||
Reference in New Issue
Block a user