feat: allinea lo swipe e lo scroll con l'avanzamento karaoke, aggiungi frecce di navigazione canti nel titolo
This commit is contained in:
@@ -83,6 +83,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
public youtubePlayerService = inject(YoutubePlayerService);
|
||||
private channel = new BroadcastChannel('karaoke_sync');
|
||||
|
||||
// Rimossi stati di freeze fragili per garantire fluidità e stabilità 100% dell'avanzamento
|
||||
|
||||
private readonly MIN_FONT = 0.6;
|
||||
private readonly MAX_FONT = 5.0;
|
||||
private readonly FONT_STEP = 0.15;
|
||||
@@ -96,7 +98,8 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
private lastAdvanceTimestamp: number = 0; // For safety cooldown
|
||||
private readonly ADVANCE_COOLDOWN = 1500; // 1.5 seconds min
|
||||
|
||||
private wordsSpokenInCurrentLine: number = 0;
|
||||
public wordsSpokenInCurrentPage: number = 0;
|
||||
public lastScrollBlock = signal<'start' | 'center'>('start');
|
||||
private lastProcessedTranscript: string = '';
|
||||
private initialStartTime: number = 0;
|
||||
|
||||
@@ -132,11 +135,20 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.showChords.set(this.settingsService.showChordsDefault());
|
||||
}, { allowSignalWrites: true });
|
||||
|
||||
// Automatic advancement logic based on line detection
|
||||
// Avanzamento guidato dai picchi acustici con conteggio parole della riga in corso
|
||||
effect(() => {
|
||||
const count = this.audioEngine.linesDetected();
|
||||
if (count > 0) {
|
||||
this.next();
|
||||
if (count > 0 && this.audioEngine.isListening()) {
|
||||
this.handleAcousticPeak();
|
||||
}
|
||||
});
|
||||
|
||||
// Avanzamento in tempo reale tramite riconoscimento vocale di prossimità (driver primario veloce)
|
||||
effect(() => {
|
||||
const transcript = this.audioEngine.backgroundTranscript();
|
||||
const isSpeechActive = this.audioEngine.speechRecognitionActive();
|
||||
if (transcript && this.audioEngine.isListening() && isSpeechActive) {
|
||||
this.checkWordAdvancement();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -147,7 +159,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
setTimeout(() => {
|
||||
const activeElem = document.querySelector('.lyric-line.active');
|
||||
if (activeElem) {
|
||||
activeElem.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
// Usa l'allineamento calcolato dinamico (start per manuale a pagine, center per automatico e standard)
|
||||
// Usiamo behavior: 'auto' poiché il container CSS ha già 'scroll-behavior: smooth', evitando blocchi/conflitti nativi Chromium
|
||||
const scrollBlock = this.lastScrollBlock();
|
||||
activeElem.scrollIntoView({ behavior: 'auto', block: scrollBlock });
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
@@ -241,26 +256,45 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
const gesture = this.gestureCtrl.create({
|
||||
const gestureX = this.gestureCtrl.create({
|
||||
el: this.el.nativeElement,
|
||||
direction: 'x',
|
||||
gestureName: 'swipe-song',
|
||||
gestureName: 'swipe-song-x',
|
||||
canStart: (ev) => {
|
||||
// Prevent swipe when touching the bottom toolbar or other interactive elements
|
||||
const target = ev.event.target as HTMLElement;
|
||||
return !target.closest('ion-footer');
|
||||
return !target.closest('ion-footer') && !target.closest('ion-header');
|
||||
},
|
||||
onEnd: (ev) => {
|
||||
if (Math.abs(ev.deltaX) > 60) {
|
||||
if (ev.deltaX > 0) {
|
||||
this.prevSong();
|
||||
this.prev();
|
||||
} else {
|
||||
this.nextSong();
|
||||
this.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
gesture.enable();
|
||||
gestureX.enable();
|
||||
|
||||
const gestureY = this.gestureCtrl.create({
|
||||
el: this.el.nativeElement,
|
||||
direction: 'y',
|
||||
gestureName: 'swipe-song-y',
|
||||
canStart: (ev) => {
|
||||
const target = ev.event.target as HTMLElement;
|
||||
return !target.closest('ion-footer') && !target.closest('ion-header');
|
||||
},
|
||||
onEnd: (ev) => {
|
||||
if (Math.abs(ev.deltaY) > 50) {
|
||||
if (ev.deltaY > 0) {
|
||||
this.prev();
|
||||
} else {
|
||||
this.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
gestureY.enable();
|
||||
}
|
||||
|
||||
|
||||
@@ -327,6 +361,25 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.initialPinchDistance = null;
|
||||
}
|
||||
|
||||
private lastWheelTime = 0;
|
||||
private readonly WHEEL_COOLDOWN = 400; // ms to avoid too rapid advancement
|
||||
|
||||
onWheel(event: WheelEvent) {
|
||||
event.preventDefault();
|
||||
const now = Date.now();
|
||||
if (now - this.lastWheelTime < this.WHEEL_COOLDOWN) {
|
||||
return;
|
||||
}
|
||||
if (Math.abs(event.deltaY) > 5) {
|
||||
if (event.deltaY > 0) {
|
||||
this.next();
|
||||
} else {
|
||||
this.prev();
|
||||
}
|
||||
this.lastWheelTime = now;
|
||||
}
|
||||
}
|
||||
|
||||
private getDistance(t1: Touch, t2: Touch): number {
|
||||
return Math.sqrt(Math.pow(t1.clientX - t2.clientX, 2) + Math.pow(t1.clientY - t2.clientY, 2));
|
||||
}
|
||||
@@ -442,25 +495,356 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
this.audioEngine.resetWordCount();
|
||||
this.youtubePlayerService.seekTo(0);
|
||||
this.channel.postMessage({ type: 'SYNC_INDEX', index: 0 });
|
||||
this.wordsSpokenInCurrentPage = 0;
|
||||
this.lastScrollBlock.set('center');
|
||||
}
|
||||
|
||||
next() {
|
||||
this.lastAdvanceTimestamp = Date.now();
|
||||
this.audioEngine.resetWordCount();
|
||||
public calculatePageStepSize(): number {
|
||||
try {
|
||||
const container = document.querySelector('.lyrics-container') as HTMLElement;
|
||||
if (!container) return 3; // Ritorno generico se il contenitore non è pronto
|
||||
|
||||
const totalLines = this.getTotalLines();
|
||||
if (this.currentLineIndex() < totalLines - 1) {
|
||||
this.currentLineIndex.set(this.currentLineIndex() + 1);
|
||||
this.channel.postMessage({ type: 'SYNC_INDEX', index: this.currentLineIndex() });
|
||||
const containerHeight = container.clientHeight;
|
||||
|
||||
// Calcoliamo la reale altezza visibile sottraendo l'eventuale footer in sovraimpressione
|
||||
let visibleHeight = containerHeight;
|
||||
const footer = document.querySelector('ion-footer') as HTMLElement;
|
||||
if (footer && footer.offsetHeight > 0) {
|
||||
const computedStyle = window.getComputedStyle(footer);
|
||||
if (computedStyle.display !== 'none') {
|
||||
visibleHeight -= footer.offsetHeight;
|
||||
}
|
||||
}
|
||||
|
||||
const lineElems = document.querySelectorAll('.lyric-line');
|
||||
|
||||
if (lineElems.length === 0) return 3;
|
||||
|
||||
let totalHeight = 0;
|
||||
lineElems.forEach((el: any) => {
|
||||
totalHeight += el.clientHeight;
|
||||
});
|
||||
const avgLineHeight = totalHeight / lineElems.length;
|
||||
|
||||
if (avgLineHeight <= 0) return 3;
|
||||
|
||||
// Quante righe entrano effettivamente nella vista REALE non oscurata dello schermo
|
||||
const linesPerPage = Math.floor(visibleHeight / avgLineHeight);
|
||||
|
||||
// Passo di scorrimento: una pagina intera pulita (zero sovrapposizione) per far sparire il testo precedente
|
||||
const pageStep = Math.max(1, linesPerPage);
|
||||
|
||||
console.log('[PageScroll] Calcolo dinamico dello scorrimento a pagina (Area visibile depurata):', {
|
||||
containerHeight,
|
||||
visibleHeight,
|
||||
avgLineHeight,
|
||||
linesPerPage,
|
||||
pageStep
|
||||
});
|
||||
|
||||
return pageStep;
|
||||
} catch (e) {
|
||||
console.warn('[PageScroll] Impossibile calcolare dinamicamente lo step di pagina:', e);
|
||||
return 3; // Fallback di emergenza
|
||||
}
|
||||
}
|
||||
|
||||
private getLineWords(lineIndex: number): string[] {
|
||||
const allLines = this.getAllLines();
|
||||
const lineText = allLines[lineIndex]?.text || '';
|
||||
return lineText
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.map((w: string) => w.replace(/[^a-zA-Z0-9àèìòùáéíóú]/g, ''))
|
||||
.filter((w: string) => w.length >= 3);
|
||||
}
|
||||
|
||||
public getPageWords(startIndex: number, pageSize: number): string[] {
|
||||
const allWords: string[] = [];
|
||||
const totalLines = this.getTotalLines();
|
||||
const end = Math.min(totalLines, startIndex + pageSize);
|
||||
for (let i = startIndex; i < end; i++) {
|
||||
allWords.push(...this.getLineWords(i));
|
||||
}
|
||||
return allWords;
|
||||
}
|
||||
|
||||
public getVisibleLineIndices(): number[] {
|
||||
try {
|
||||
const container = document.querySelector('.lyrics-container') as HTMLElement;
|
||||
if (!container) return [];
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const visibleTop = containerRect.top;
|
||||
let visibleBottom = containerRect.bottom;
|
||||
|
||||
// Sottrai l'altezza dell'eventuale footer sovrapposto ad alto z-index
|
||||
const footer = document.querySelector('ion-footer') as HTMLElement;
|
||||
if (footer) {
|
||||
const footerRect = footer.getBoundingClientRect();
|
||||
if (footerRect.height > 0 && window.getComputedStyle(footer).display !== 'none') {
|
||||
visibleBottom = Math.min(visibleBottom, footerRect.top);
|
||||
}
|
||||
}
|
||||
|
||||
const lineElems = document.querySelectorAll('.lyric-line');
|
||||
const visibleIndices: number[] = [];
|
||||
|
||||
for (let i = 0; i < lineElems.length; i++) {
|
||||
const el = lineElems[i] as HTMLElement;
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
// Consideriamo la riga visibile se la sua metà verticale è all'interno dei limiti visibili reali
|
||||
const lineMiddle = (rect.top + rect.bottom) / 2;
|
||||
if (lineMiddle >= visibleTop && lineMiddle <= visibleBottom) {
|
||||
visibleIndices.push(i);
|
||||
}
|
||||
}
|
||||
return visibleIndices;
|
||||
} catch (e) {
|
||||
console.warn('[PageScroll] Errore nel recupero degli indici delle righe visibili:', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public getPageTargetWordCount(): number {
|
||||
const visibleIndices = this.getVisibleLineIndices();
|
||||
let totalRawWords = 0;
|
||||
const currentIdx = this.currentLineIndex();
|
||||
|
||||
if (visibleIndices.length > 0) {
|
||||
if (currentIdx === 0) {
|
||||
// Sulla prima pagina, contiamo solo la prima metà delle righe visibili
|
||||
// per far avanzare l'applicazione non appena si arriva a metà della prima schermata
|
||||
const halfLength = Math.max(1, Math.ceil(visibleIndices.length / 2));
|
||||
for (let i = 0; i < halfLength; i++) {
|
||||
const idx = visibleIndices[i];
|
||||
totalRawWords += this.getLineRawWordCount(idx);
|
||||
}
|
||||
} else {
|
||||
// Dalla seconda pagina in poi, contiamo solo le righe attive e future (escludendo il testo già cantato in alto)
|
||||
for (const idx of visibleIndices) {
|
||||
if (idx >= currentIdx) {
|
||||
totalRawWords += this.getLineRawWordCount(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: calcolo teorico basato sulla pagina corrente
|
||||
const pageSize = this.calculatePageStepSize();
|
||||
const totalLines = this.getTotalLines();
|
||||
|
||||
let end: number;
|
||||
if (currentIdx === 0) {
|
||||
// Fallback prima pagina: metà delle righe della pagina teorica
|
||||
const halfPageSize = Math.max(1, Math.ceil(pageSize / 2));
|
||||
end = Math.min(totalLines, currentIdx + halfPageSize);
|
||||
} else {
|
||||
end = Math.min(totalLines, currentIdx + pageSize);
|
||||
}
|
||||
|
||||
for (let i = currentIdx; i < end; i++) {
|
||||
totalRawWords += this.getLineRawWordCount(i);
|
||||
}
|
||||
}
|
||||
|
||||
// Soglia al 70% delle parole contate nella schermata visibile
|
||||
return Math.max(1, Math.ceil(totalRawWords * 0.70));
|
||||
}
|
||||
|
||||
private checkWordAdvancement() {
|
||||
const now = Date.now();
|
||||
|
||||
// Cooldown per prevenire avanzamenti multipli troppo veloci
|
||||
if (now - this.lastAdvanceTimestamp < this.ADVANCE_COOLDOWN) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentIdx = this.currentLineIndex();
|
||||
let nextPageIdx: number;
|
||||
const totalLines = this.getTotalLines();
|
||||
|
||||
if (currentIdx === 0) {
|
||||
// Sulla prima pagina, la "pagina successiva" è in realtà la seconda metà della prima schermata
|
||||
const visibleIndices = this.getVisibleLineIndices();
|
||||
if (visibleIndices.length > 0) {
|
||||
const halfLength = Math.max(1, Math.ceil(visibleIndices.length / 2));
|
||||
nextPageIdx = visibleIndices[halfLength] !== undefined ? visibleIndices[halfLength] : halfLength;
|
||||
} else {
|
||||
const pageSize = this.calculatePageStepSize();
|
||||
const halfPageSize = Math.max(1, Math.ceil(pageSize / 2));
|
||||
nextPageIdx = currentIdx + halfPageSize;
|
||||
}
|
||||
} else {
|
||||
nextPageIdx = this.calculateNextPageStartIndex();
|
||||
}
|
||||
|
||||
if (nextPageIdx >= totalLines) return;
|
||||
|
||||
const pageSize = this.calculatePageStepSize();
|
||||
|
||||
// Raccoglie le parole del blocco successivo (metà pagina se siamo all'inizio, altrimenti intera pagina)
|
||||
const nextPageWords = this.getPageWords(nextPageIdx, currentIdx === 0 ? Math.max(1, Math.ceil(pageSize / 2)) : pageSize);
|
||||
if (nextPageWords.length === 0) return;
|
||||
|
||||
// Parole rilevate di recente (con tolleranza di ritardo della Speech API)
|
||||
const recentWords = this.audioEngine.getRecentWordsSince(this.lastAdvanceTimestamp);
|
||||
|
||||
// Controlla se l'utente ha cantato parole della pagina successiva -> Avanzamento istantaneo immediato
|
||||
const matchNext = nextPageWords.some(word => recentWords.includes(word));
|
||||
if (matchNext) {
|
||||
console.log(`[SmartKaraoke] Parola chiave del blocco successivo rilevata! Avanzamento immediato a: ${nextPageIdx}`);
|
||||
this.lastScrollBlock.set('center');
|
||||
this.currentLineIndex.set(nextPageIdx);
|
||||
this.channel.postMessage({ type: 'SYNC_INDEX', index: nextPageIdx });
|
||||
this.lastAdvanceTimestamp = now;
|
||||
this.audioEngine.resetWordCount();
|
||||
this.wordsSpokenInCurrentPage = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public getLineRawWordCount(lineIndex: number): number {
|
||||
const allLines = this.getAllLines();
|
||||
const lineText = allLines[lineIndex]?.text || '';
|
||||
const words = lineText.trim().split(/\s+/).filter((w: string) => w.length > 0);
|
||||
return words.length;
|
||||
}
|
||||
|
||||
public getLineTargetWordCount(lineIndex: number): number {
|
||||
const rawCount = this.getLineRawWordCount(lineIndex);
|
||||
// Soglia proporzionale tollerante (65% del conteggio parole effettivo) per compensare legature melodiche
|
||||
return Math.max(1, Math.ceil(rawCount * 0.65));
|
||||
}
|
||||
|
||||
private handleAcousticPeak() {
|
||||
const targetWordCount = this.getPageTargetWordCount();
|
||||
|
||||
this.wordsSpokenInCurrentPage++;
|
||||
console.log(`[PeakAdvance] Peak detected. Spoken words on page: ${this.wordsSpokenInCurrentPage}/${targetWordCount}`);
|
||||
|
||||
if (this.wordsSpokenInCurrentPage >= targetWordCount) {
|
||||
console.log(`[PeakAdvance] Target page word count reached (${targetWordCount}). Advancing page.`);
|
||||
this.next(true);
|
||||
}
|
||||
}
|
||||
|
||||
private calculateNextPageStartIndex(): number {
|
||||
try {
|
||||
const container = document.querySelector('.lyrics-container') as HTMLElement;
|
||||
if (!container) return this.currentLineIndex() + 1;
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
let visibleBottom = containerRect.bottom;
|
||||
|
||||
// Trova la posizione del footer o di qualunque elemento sovrapposto in fondo
|
||||
const footer = document.querySelector('ion-footer') as HTMLElement;
|
||||
if (footer) {
|
||||
const footerRect = footer.getBoundingClientRect();
|
||||
if (footerRect.height > 0 && window.getComputedStyle(footer).display !== 'none') {
|
||||
visibleBottom = Math.min(visibleBottom, footerRect.top);
|
||||
}
|
||||
}
|
||||
|
||||
// Sottrai un piccolo margine di tolleranza di 8px
|
||||
const visibleLimitY = visibleBottom - 8;
|
||||
const lineElems = document.querySelectorAll('.lyric-line');
|
||||
|
||||
const totalLines = this.getTotalLines();
|
||||
const currentIdx = this.currentLineIndex();
|
||||
|
||||
// Scansiona le righe successive a quella attiva e trova la prima riga che non era COMPLETAMENTE visibile
|
||||
let foundNextIdx = -1;
|
||||
for (let i = currentIdx + 1; i < lineElems.length; i++) {
|
||||
const el = lineElems[i] as HTMLElement;
|
||||
const rect = el.getBoundingClientRect();
|
||||
|
||||
// Se la parte inferiore della riga ricade sotto l'area di visualizzazione utile (coperta da footer/log)
|
||||
if (rect.bottom > visibleLimitY) {
|
||||
foundNextIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundNextIdx !== -1) {
|
||||
console.log('[PageScroll] Riga coperta/tagliata in fondo rilevata. Diventerà la prima riga della nuova pagina:', foundNextIdx);
|
||||
return foundNextIdx;
|
||||
}
|
||||
|
||||
// Se tutto era perfettamente visibile, avanza dello step di pagina calcolato standard
|
||||
const step = this.calculatePageStepSize();
|
||||
return Math.min(totalLines - 1, currentIdx + step);
|
||||
} catch (e) {
|
||||
console.warn('[PageScroll] Errore nel calcolo dinamico dell\'indice della pagina successiva:', e);
|
||||
return this.currentLineIndex() + 1;
|
||||
}
|
||||
}
|
||||
|
||||
next(isAutomatic: boolean = false) {
|
||||
this.lastAdvanceTimestamp = Date.now();
|
||||
this.audioEngine.resetWordCount();
|
||||
this.wordsSpokenInCurrentPage = 0;
|
||||
|
||||
const totalLines = this.getTotalLines();
|
||||
const prevIdx = this.currentLineIndex();
|
||||
|
||||
if (prevIdx < totalLines - 1) {
|
||||
let nextIdx: number;
|
||||
if (isAutomatic) {
|
||||
if (prevIdx === 0) {
|
||||
// Sulla prima pagina, avanziamo alla metà della pagina (inizio della seconda metà)
|
||||
const visibleIndices = this.getVisibleLineIndices();
|
||||
if (visibleIndices.length > 0) {
|
||||
const halfLength = Math.max(1, Math.ceil(visibleIndices.length / 2));
|
||||
nextIdx = Math.min(totalLines - 1, visibleIndices[halfLength] !== undefined ? visibleIndices[halfLength] : halfLength);
|
||||
} else {
|
||||
const pageSize = this.calculatePageStepSize();
|
||||
const halfPageSize = Math.max(1, Math.ceil(pageSize / 2));
|
||||
nextIdx = Math.min(totalLines - 1, prevIdx + halfPageSize);
|
||||
}
|
||||
} else {
|
||||
// Avanzamento acustico automatico: avanza l'intera pagina lasciando centrale la riga di riferimento
|
||||
nextIdx = this.calculateNextPageStartIndex();
|
||||
}
|
||||
this.lastScrollBlock.set('center');
|
||||
} else if (this.settingsService.karaokePageScrollMode()) {
|
||||
// Modalità manuale a pagine: calcolo analitico preciso per non perdere righe coperte
|
||||
nextIdx = this.calculateNextPageStartIndex();
|
||||
this.lastScrollBlock.set('start');
|
||||
} else {
|
||||
// Avanzamento riga per riga standard manuale
|
||||
nextIdx = prevIdx + 1;
|
||||
this.lastScrollBlock.set('center');
|
||||
}
|
||||
|
||||
this.currentLineIndex.set(nextIdx);
|
||||
this.channel.postMessage({ type: 'SYNC_INDEX', index: nextIdx });
|
||||
}
|
||||
}
|
||||
|
||||
// Metodi di validazione e freeze rimossi per migliorare affidabilità e prevenire blocchi permanenti
|
||||
|
||||
prev() {
|
||||
this.lastAdvanceTimestamp = Date.now();
|
||||
this.audioEngine.resetWordCount();
|
||||
if (this.currentLineIndex() > 0) {
|
||||
this.currentLineIndex.set(this.currentLineIndex() - 1);
|
||||
this.channel.postMessage({ type: 'SYNC_INDEX', index: this.currentLineIndex() });
|
||||
this.wordsSpokenInCurrentPage = 0;
|
||||
|
||||
const prevIdx = this.currentLineIndex();
|
||||
if (prevIdx > 0) {
|
||||
const isPageMode = this.settingsService.karaokePageScrollMode();
|
||||
const stepSize = isPageMode ? this.calculatePageStepSize() : 1;
|
||||
const nextIdx = Math.max(0, prevIdx - stepSize);
|
||||
|
||||
if (nextIdx === 0) {
|
||||
// Se torniamo all'inizio, allineiamo in alto
|
||||
this.lastScrollBlock.set('start');
|
||||
} else if (isPageMode) {
|
||||
this.lastScrollBlock.set('start');
|
||||
} else {
|
||||
this.lastScrollBlock.set('center');
|
||||
}
|
||||
|
||||
this.currentLineIndex.set(nextIdx);
|
||||
this.channel.postMessage({ type: 'SYNC_INDEX', index: nextIdx });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user