Miglioramenti generali layout player landscape/portrait, traduzione pulsanti navigazione, gestione aggiornamenti PWA e modifiche al posizionamento del pulsante aggiungi canto

This commit is contained in:
David Frassi
2026-06-14 22:14:41 +02:00
parent d023bb7d3f
commit b4b7e10c5f
20 changed files with 1554 additions and 535 deletions
+3 -3
View File
@@ -8,7 +8,7 @@
<!-- Previous line -->
<div *ngIf="displayLines().prev" class="prev-line">
<ng-container *ngIf="showChords(); else prevText">
<span *ngFor="let seg of displayLines().prev!.segments" class="chord-segment">
<span *ngFor="let seg of displayLines().prev!.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(displayLines().prev!.segments, i)">
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="seg-text">{{ seg.text }}</span>
</span>
@@ -19,7 +19,7 @@
<!-- Active line -->
<div *ngIf="displayLines().current" class="active-line outfit-font">
<ng-container *ngIf="showChords(); else currentText">
<span *ngFor="let seg of displayLines().current!.segments" class="chord-segment">
<span *ngFor="let seg of displayLines().current!.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(displayLines().current!.segments, i)">
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="seg-text">{{ seg.text }}</span>
</span>
@@ -30,7 +30,7 @@
<!-- Next line -->
<div *ngIf="displayLines().next" class="next-line">
<ng-container *ngIf="showChords(); else nextText">
<span *ngFor="let seg of displayLines().next!.segments" class="chord-segment">
<span *ngFor="let seg of displayLines().next!.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(displayLines().next!.segments, i)">
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="seg-text">{{ seg.text }}</span>
</span>
+1 -1
View File
@@ -82,7 +82,7 @@ export class DisplayPage implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private cantiService = inject(CantiService);
private lyricsParser = inject(LyricsParserService);
public lyricsParser = inject(LyricsParserService);
private comunitaService = inject(ComunitaService);
constructor() {
+94 -60
View File
@@ -11,14 +11,6 @@
<span class="title-text" style="display: inline-flex; align-items: center; flex-wrap: wrap; gap: 8px;">
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
<span>{{ canto()?.titolo || 'Player' }}</span>
<span style="display: inline-flex; align-items: center; gap: 4px; margin-left: 8px;">
<ion-button fill="clear" (click)="prevSong()" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 28px; width: 28px; min-height: 28px;">
<ion-icon name="chevron-back" color="secondary" style="font-size: 1.1rem;"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="nextSong()" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 28px; width: 28px; min-height: 28px;">
<ion-icon name="chevron-forward" color="secondary" style="font-size: 1.1rem;"></ion-icon>
</ion-button>
</span>
</span>
</div>
</ion-title>
@@ -26,10 +18,10 @@
<div class="offline-badge-header" *ngIf="!connectivityService.isOnline()">
<ion-icon name="cloud-offline-outline"></ion-icon>
</div>
<ion-button fill="clear" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
<ion-button fill="clear" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor() && !isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
<ion-icon slot="icon-only" name="create-outline" color="secondary" style="font-size: 1.3rem;"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="toggleChords()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
<ion-button fill="clear" (click)="toggleChords()" *ngIf="!isLandscapeActive()" style="margin: 0; --padding-start: 6px; --padding-end: 6px;">
<ion-icon slot="icon-only"
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
[color]="showChords() ? 'secondary' : 'medium'"
@@ -46,24 +38,59 @@
<div class="lyrics-container"
[style.fontSize.rem]="fontSize()"
[class.full-screen-container]="settingsService.fullscreenMode()"
[class.has-landscape-audio]="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5"
(touchstart)="onTouchStart($event)"
(touchmove)="onTouchMove($event)"
(touchend)="onTouchEnd()">
<!-- Landscape Side Controls (Scrollable) -->
<div class="landscape-side-controls" [class.active-fullscreen]="settingsService.fullscreenMode()">
<div class="side-scroll-container">
<!-- Karaoke Line Advancer (Avanzatore di riga) -->
<div class="side-group" style="margin: 0 auto; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px;">
<ion-button fill="clear" (click)="restart()" style="height: 48px; margin: 0;">
<ion-icon name="arrow-up-circle" color="secondary" style="font-size: 1.8rem;"></ion-icon>
<!-- Navigation Group -->
<div class="side-group">
<ion-button fill="clear" color="secondary" (click)="prevSong()" class="landscape-playlist-btn prev-btn">
prv
</ion-button>
<ion-button fill="clear" (click)="prev()" [disabled]="currentLineIndex() === 0" style="height: 48px; margin: 0;">
<ion-icon name="chevron-up" color="secondary" style="font-size: 1.8rem;"></ion-icon>
</div>
<div class="side-group">
<ion-button fill="clear" (click)="prev()" [disabled]="currentLineIndex() === 0">
<ion-icon slot="icon-only" name="chevron-up" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1" style="height: 48px; margin: 0;">
<ion-icon name="chevron-down" color="secondary" style="font-size: 1.8rem;"></ion-icon>
</div>
<div class="side-group">
<ion-button fill="clear" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1">
<ion-icon slot="icon-only" name="chevron-down" color="secondary"></ion-icon>
</ion-button>
</div>
<div class="side-group">
<ion-button fill="clear" color="secondary" (click)="nextSong()" class="landscape-playlist-btn next-btn">
nxt
</ion-button>
</div>
<!-- Zoom Group -->
<div class="side-group">
<ion-button fill="clear" (click)="zoomIn()" [disabled]="fontSize() >= maxZoom()">
<ion-icon slot="icon-only" name="add-circle-outline" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="zoomOut()" [disabled]="fontSize() <= minZoom()">
<ion-icon slot="icon-only" name="remove-circle-outline" color="secondary"></ion-icon>
</ion-button>
</div>
<!-- Autoscroll Standard Group -->
<div class="side-group" *ngIf="settingsService.enableStandardAutoscroll()">
<ion-button fill="clear" (click)="decreaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() <= 1">
<ion-icon slot="icon-only" name="remove"></ion-icon>
</ion-button>
<div class="side-indicator" (click)="toggleAutoscroll()">
<ion-icon [name]="isAutoscrolling() ? 'pause' : 'play'" [color]="isAutoscrolling() ? 'danger' : 'secondary'"></ion-icon>
<span class="side-val">V{{ autoscrollSpeed() }}</span>
</div>
<ion-button fill="clear" (click)="increaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() >= 10">
<ion-icon slot="icon-only" name="add"></ion-icon>
</ion-button>
</div>
@@ -86,8 +113,8 @@
[class.active]="isActiveLine(si, li)">
<!-- Chord mode: show chords above text -->
<ng-container *ngIf="showChords(); else textOnly">
<span *ngFor="let seg of line.segments" class="chord-segment">
<ng-container *ngIf="showChords() && !isLandscapeActive(); else textOnly">
<span *ngFor="let seg of line.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(line.segments, i)">
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
<span class="seg-text">{{ seg.text }}</span>
</span>
@@ -99,7 +126,7 @@
</ng-template>
</div>
</div>
<!-- Spazio bianco finale per consentire lo scorrimento a pagine perfetto dell'ultima riga in cima allo schermo -->
<!-- Spazio bianco finale per consentire lo scorrimento a pagine perfetto dell'ultima riga in cima sullo schermo -->
<div class="bottom-spacer" *ngIf="settingsService.karaokePageScrollMode()" style="height: 75vh; width: 100%; pointer-events: none;"></div>
</div>
@@ -107,33 +134,36 @@
</ion-content>
<ion-footer class="ion-no-border">
<!-- Audio Player Toolbar -->
<ion-toolbar class="global-player-toolbar glass" *ngIf="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5 && !settingsService.fullscreenMode()">
<!-- Audio Player / Playlist Navigation Toolbar -->
<ion-toolbar class="global-player-toolbar glass">
<div class="player-content">
<div class="controls-row">
<ion-button fill="clear" color="secondary" (click)="prevSong()" class="skip-btn">
<ion-icon slot="icon-only" name="play-skip-back-sharp"></ion-icon>
<ion-icon slot="icon-only" name="chevron-back"></ion-icon>
</ion-button>
<ion-button fill="clear" color="secondary" (click)="toggleAudio()" class="play-btn">
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
</ion-button>
<ion-range
[min]="0"
[max]="youtubePlayerService.videoDuration()"
[value]="youtubePlayerService.videoProgress()"
(ionChange)="onSeek($event)"
color="secondary"
class="global-range">
</ion-range>
<ng-container *ngIf="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5; else noAudioMsg">
<ion-button fill="clear" color="secondary" (click)="toggleAudio()" class="play-btn">
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
</ion-button>
<ion-range
[min]="0"
[max]="youtubePlayerService.videoDuration()"
[value]="youtubePlayerService.videoProgress()"
(ionChange)="onSeek($event)"
color="secondary"
class="global-range">
</ion-range>
</ng-container>
<ng-template #noAudioMsg>
<div class="no-audio-msg outfit-font" style="flex: 1; text-align: center; font-size: 0.85rem; opacity: 0.6; color: var(--ion-color-medium);">
Nessun audio disponibile
</div>
</ng-template>
<ion-button fill="clear" color="secondary" (click)="nextSong()" class="skip-btn">
<ion-icon slot="icon-only" name="play-skip-forward-sharp"></ion-icon>
</ion-button>
<ion-button fill="clear" color="medium" (click)="stopVideo($event)" class="close-btn">
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
<ion-icon slot="icon-only" name="chevron-forward"></ion-icon>
</ion-button>
</div>
</div>
@@ -167,6 +197,18 @@
</div>
</div>
<!-- Navigation -->
<div class="group">
<ion-button fill="clear" size="small" (click)="restart()">
<ion-icon slot="icon-only" name="arrow-up-circle" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="prev()" [disabled]="currentLineIndex() === 0">
<ion-icon slot="icon-only" name="chevron-back" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1">
<ion-icon slot="icon-only" name="chevron-forward" color="secondary"></ion-icon>
</ion-button>
</div>
<!-- Transposition (Only in chords mode) -->
<div class="group" *ngIf="showChords()">
@@ -182,38 +224,30 @@
</ion-button>
</div>
<!-- Navigation -->
<div class="group">
<ion-button fill="clear" size="small" (click)="restart()">
<ion-icon slot="icon-only" name="arrow-up-circle" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="prev()" [disabled]="currentLineIndex() === 0">
<ion-icon slot="icon-only" name="chevron-back" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1">
<ion-icon slot="icon-only" name="chevron-forward" color="secondary"></ion-icon>
</ion-button>
</div>
<!-- Zoom -->
<div class="group">
<ion-button fill="clear" size="small" (click)="zoomOut()" [disabled]="fontSize() <= 0.6">
<ion-button fill="clear" size="small" (click)="zoomOut()" [disabled]="fontSize() <= minZoom()">
<ion-icon slot="icon-only" name="remove-circle-outline" color="secondary"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="zoomIn()" [disabled]="fontSize() >= 5.0">
<ion-button fill="clear" size="small" (click)="zoomIn()" [disabled]="fontSize() >= maxZoom()">
<ion-icon slot="icon-only" name="add-circle-outline" color="secondary"></ion-icon>
</ion-button>
</div>
<!-- Actions -->
<div class="group" *ngIf="connectivityService.isOnline()">
<ion-button *ngIf="canto()?.link_youtube && canto()?.link_youtube!.length > 5" fill="clear" size="small" (click)="openYoutube()">
<!-- YouTube Link in Portrait Footer -->
<div class="group" *ngIf="connectivityService.isOnline() && canto()?.link_youtube && canto()?.link_youtube!.length > 5">
<ion-button fill="clear" size="small" (click)="openYoutube()">
<ion-icon slot="icon-only" name="logo-youtube" color="danger"></ion-icon>
</ion-button>
</div>
</div>
</ion-toolbar>
</ion-footer>
<!-- Black Screen Overlay -->
<div class="black-screen-overlay" *ngIf="isBlackScreen()" (click)="deactivateBlackScreen()"></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>
+119 -17
View File
@@ -172,6 +172,9 @@
color: rgba(255, 255, 255, 0.9);
transition: all 0.3s ease;
min-height: 1.5em;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
&.active {
color: var(--ion-color-secondary);
@@ -187,6 +190,10 @@
vertical-align: bottom;
margin-right: 0.2em;
&.contiguous-next {
margin-right: 0 !important;
}
.chord {
font-size: 0.75em;
font-weight: 700;
@@ -299,7 +306,6 @@
100% { transform: scale(0.9); opacity: 0.6; }
}
// Fullscreen and Orientation
@media (orientation: landscape) {
// Always hide footer in landscape as we have side controls
ion-footer {
@@ -309,22 +315,19 @@
.lyrics-container {
height: 100%;
padding-top: 4px; // Minimized
padding-bottom: 8px !important;
padding-right: 90px !important; // More room for side controls and zoom
}
.lyrics-view {
max-width: 100% !important;
}
ion-content {
--offset-bottom: 0px !important;
}
}
ion-content.full-screen-content {
--offset-bottom: 0px !important;
@media (orientation: portrait) {
--offset-bottom: 48px !important; // Footer height
}
}
.landscape-side-controls {
display: none !important; // Strict hidden in portrait
@@ -333,9 +336,9 @@ ion-content.full-screen-content {
flex-direction: column;
position: fixed;
right: 0;
top: 44px !important; // Align with header
top: 56px !important; // Align below header
bottom: 0;
width: 60px;
width: 50px;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
border-left: 1px solid rgba(255, 255, 255, 0.1);
@@ -347,8 +350,10 @@ ion-content.full-screen-content {
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 12px 0;
gap: 16px;
justify-content: flex-start;
align-items: center;
padding: 8px 0;
gap: 12px;
&::-webkit-scrollbar { display: none; }
}
@@ -356,22 +361,81 @@ ion-content.full-screen-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px; // Reduced for a more compact layout
padding-bottom: 20px;
gap: 8px; // Reduced for a more compact layout
padding-bottom: 12px;
background: rgba(255, 255, 255, 0.05);
margin: 0 2px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
padding-top: 6px;
ion-button {
--padding-start: 0;
--padding-end: 0;
margin: 0;
height: 48px;
ion-icon { font-size: 1.8rem; }
height: 38px;
width: 38px;
ion-icon { font-size: 1.5rem; }
}
}
.vertical-range-container {
height: 120px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
margin: 8px 0;
}
.vertical-range {
transform: rotate(-90deg);
width: 120px;
--bar-height: 4px;
--knob-size: 14px;
padding: 0;
margin: 0;
}
.side-indicator {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
font-size: 0.7rem;
font-weight: 700;
color: var(--ion-color-secondary);
padding: 4px 0;
cursor: pointer;
ion-icon {
font-size: 1.0rem;
}
&.tilted {
color: var(--ion-color-success, #2ed573);
text-shadow: 0 0 5px rgba(46, 213, 115, 0.6);
}
}
.side-val {
font-size: 0.7rem;
font-weight: 700;
}
.side-divider {
display: none;
}
}
ion-content.full-screen-content {
--offset-bottom: 0px !important;
@media (orientation: portrait) {
--offset-bottom: 48px !important; // Footer height
}
}
.mic-sensitivity-overlay {
position: fixed;
@@ -673,6 +737,13 @@ ion-content.full-screen-content {
backdrop-filter: none !important;
}
.landscape-audio-player {
--background: #ffffff !important;
background: #ffffff !important;
border-left: 1px solid rgba(0, 0, 0, 0.2) !important;
backdrop-filter: none !important;
}
.slim-controls .group {
background: rgba(0, 0, 0, 0.05) !important;
border: 1px solid rgba(0, 0, 0, 0.15) !important;
@@ -807,3 +878,34 @@ ion-content.full-screen-content {
}
}
}
.black-screen-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background-color: #000000;
z-index: 99999;
cursor: pointer;
}
.landscape-playlist-btn {
writing-mode: vertical-rl;
text-transform: uppercase;
font-weight: 700;
font-size: 0.65rem;
letter-spacing: 1px;
height: auto;
min-height: 75px;
width: 30px;
margin: 0;
--padding-start: 2px;
--padding-end: 2px;
color: var(--ion-color-secondary);
display: flex;
align-items: center;
justify-content: center;
transform: rotate(180deg);
}
+226 -20
View File
@@ -1,4 +1,4 @@
import { Component, OnInit, OnDestroy, signal, computed, effect, inject, ElementRef, AfterViewInit, untracked } from '@angular/core';
import { Component, OnInit, OnDestroy, signal, computed, effect, inject, ElementRef, AfterViewInit, untracked, HostListener } from '@angular/core';
import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { AlertController, ToastController, GestureController } from '@ionic/angular';
@@ -24,6 +24,24 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
public canto = signal<Canto | null>(null);
public activeSongId = signal<string | null>(null);
@HostListener('window:keydown', ['$event'])
handleKeyDown(event: KeyboardEvent) {
const target = event.target as HTMLElement;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
if (event.key === 'ArrowUp') {
this.prevSong();
event.preventDefault();
} else if (event.key === 'ArrowDown') {
this.nextSong();
event.preventDefault();
}
}
public isLandscapeActive = signal<boolean>(window.innerWidth > window.innerHeight);
/** true = show chords (accordi mode), false = text only */
public showChords = signal<boolean>(false);
@@ -44,7 +62,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
// For local songs, if accordi is missing but testo has chords, use parseAccordi on testo
const hasChordsInText = !c.accordi && c.testo?.includes('[');
if ((this.showChords() && c.accordi) || (this.showChords() && hasChordsInText)) {
// Force text-only mode in landscape
const activeShowChords = this.showChords() && !this.isLandscapeActive();
if ((activeShowChords && c.accordi) || (activeShowChords && hasChordsInText)) {
sections = this.lyricsParser.parseAccordi(c.accordi || c.testo);
} else {
sections = this.lyricsParser.parseText(c.testo);
@@ -68,7 +89,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
}
// Apply transposition if in chords mode
if (this.showChords()) {
if (activeShowChords) {
return this.lyricsParser.transposeSections(sections, this.transposeAmount());
}
@@ -88,6 +109,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
private readonly MAX_FONT = 5.0;
private readonly FONT_STEP = 0.15;
public minZoom = computed(() => this.isLandscapeActive() ? 2.0 : this.MIN_FONT);
public maxZoom = computed(() => this.isLandscapeActive() ? 3.0 : this.MAX_FONT);
private initialPinchDistance: number | null = null;
private initialFontSize: number = 1.0;
@@ -104,7 +128,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
private route = inject(ActivatedRoute);
public router = inject(Router);
public cantiService = inject(CantiService);
private lyricsParser = inject(LyricsParserService);
public lyricsParser = inject(LyricsParserService);
private alertCtrl = inject(AlertController);
private toastCtrl = inject(ToastController);
public themeService = inject(ThemeService);
@@ -210,6 +234,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
if (scrollEl) {
scrollEl.scrollTop = 0;
}
this.checkLandscapeZoom();
}, 100);
}
}
@@ -269,9 +294,147 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.autoscrollSpeed.set(2);
}
}, { allowSignalWrites: true });
// Fullscreen control in landscape mode
effect(() => {
const isLandscape = this.isLandscapeActive();
if (isLandscape) {
this.enterFullscreen();
} else {
this.exitFullscreen();
}
});
}
public isBlackScreen = signal<boolean>(false);
@HostListener('window:resize', ['$event'])
onResize(event: any) {
this.isLandscapeActive.set(window.innerWidth > window.innerHeight);
this.checkLandscapeZoom();
}
private isLandscape(): boolean {
return window.innerWidth > window.innerHeight;
}
private checkAndLimitFontSize(targetFont: number): number {
const titleMain = this.el.nativeElement.querySelector('.title-main');
if (!titleMain) return targetFont;
const originalStyle = titleMain.style.fontSize;
let bestFont = targetFont;
// We want to find the largest font <= targetFont where the title stays on one line.
// Let's iterate down from targetFont to 0.6 in steps of 0.05.
for (let f = targetFont; f >= 0.6; f -= 0.05) {
titleMain.style.fontSize = `${f * 1.1}rem`;
// Force layout calculation
const height = titleMain.clientHeight;
// Get computed line height
const computedStyle = window.getComputedStyle(titleMain);
const lineHeightVal = computedStyle.lineHeight;
let lh = parseFloat(lineHeightVal);
// If line-height is 'normal', fallback to a reasonable estimate based on font-size
if (isNaN(lh) || lineHeightVal === 'normal') {
const fs = parseFloat(computedStyle.fontSize) || (f * 1.1 * 16);
lh = fs * 1.25;
}
// If the actual height is less than 1.5 * line-height, it fits on one line!
if (height <= lh * 1.5) {
bestFont = f;
break;
}
bestFont = f; // Fallback
}
// Restore original style
titleMain.style.fontSize = originalStyle;
return Math.max(0.6, parseFloat(bestFont.toFixed(2)));
}
public checkLandscapeZoom() {
if (this.isLandscape()) {
setTimeout(() => {
const container = this.el.nativeElement.querySelector('.lyrics-container');
if (!container) return;
const visibleHeight = container.clientHeight;
const lineElems = this.el.nativeElement.querySelectorAll('.lyric-line');
if (lineElems.length > 0 && visibleHeight > 0) {
let totalHeight = 0;
lineElems.forEach((el: any) => {
totalHeight += el.getBoundingClientRect().height;
});
const avgLineHeight = totalHeight / lineElems.length;
if (avgLineHeight > 0) {
const currentFont = this.fontSize();
const margin = 16; // 1rem in pixels
const targetLineHeight = Math.max(10, (visibleHeight / 3.1) - margin);
const avgLineHeightAtFont1 = avgLineHeight / currentFont;
const newFont = targetLineHeight / avgLineHeightAtFont1;
const targetFont = Math.max(2.0, Math.min(3.0, newFont));
let limitedFont = this.checkAndLimitFontSize(targetFont);
if (limitedFont < 2.0) {
limitedFont = 2.0;
}
this.fontSize.set(limitedFont);
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
}
}
}, 150);
} else {
const limitedFont = this.checkAndLimitFontSize(1.0);
this.fontSize.set(limitedFont);
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: limitedFont });
}
}
deactivateBlackScreen() {
this.isBlackScreen.set(false);
}
private async enterFullscreen() {
try {
const docEl = document.documentElement;
if (docEl.requestFullscreen) {
await docEl.requestFullscreen();
} else if ((docEl as any).webkitRequestFullscreen) {
await (docEl as any).webkitRequestFullscreen();
} else if ((docEl as any).msRequestFullscreen) {
await (docEl as any).msRequestFullscreen();
}
} catch (e) {
console.warn('[PlayerPage] Failed to enter fullscreen:', e);
}
}
private async exitFullscreen() {
try {
if (document.exitFullscreen) {
if (document.fullscreenElement) {
await document.exitFullscreen();
}
} else if ((document as any).webkitExitFullscreen) {
if ((document as any).webkitFullscreenElement) {
await (document as any).webkitExitFullscreen();
}
} else if ((document as any).msExitFullscreen) {
if ((document as any).msFullscreenElement) {
await (document as any).msExitFullscreen();
}
}
} catch (e) {
console.warn('[PlayerPage] Failed to exit fullscreen:', e);
}
}
ngAfterViewInit() {
this.checkLandscapeZoom();
const gestureX = this.gestureCtrl.create({
el: this.el.nativeElement,
direction: 'x',
@@ -291,6 +454,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
}
});
gestureX.enable();
}
@@ -345,9 +509,15 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
const currentDistance = this.getDistance(event.touches[0], event.touches[1]);
const ratio = currentDistance / this.initialPinchDistance;
let newSize = this.initialFontSize * ratio;
newSize = Math.max(this.MIN_FONT, Math.min(this.MAX_FONT, newSize));
if (Math.abs(newSize - this.fontSize()) > 0.01) {
this.fontSize.set(newSize);
const minZ = this.minZoom();
const maxZ = this.maxZoom();
newSize = Math.max(minZ, Math.min(maxZ, newSize));
let limited = this.checkAndLimitFontSize(newSize);
if (this.isLandscape() && limited < 2.0) {
limited = 2.0;
}
if (Math.abs(limited - this.fontSize()) > 0.01) {
this.fontSize.set(limited);
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
}
}
@@ -358,6 +528,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.updatePlaylistSettings();
}
private getDistance(t1: Touch, t2: Touch): number {
return Math.sqrt(Math.pow(t1.clientX - t2.clientX, 2) + Math.pow(t1.clientY - t2.clientY, 2));
}
@@ -366,6 +537,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.showChords.update(v => !v);
this.channel.postMessage({ type: 'SYNC_CHORDS', showChords: this.showChords() });
this.transposeAmount.set(0);
this.checkLandscapeZoom();
}
private updatePlaylistSettings() {
@@ -401,16 +573,26 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
}
zoomIn() {
if (this.fontSize() < this.MAX_FONT) {
this.fontSize.update(v => Math.min(v + this.FONT_STEP, this.MAX_FONT));
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
this.updatePlaylistSettings();
const maxZ = this.maxZoom();
if (this.fontSize() < maxZ) {
const target = Math.min(this.fontSize() + this.FONT_STEP, maxZ);
let limited = this.checkAndLimitFontSize(target);
if (this.isLandscape() && limited < 2.0) {
limited = 2.0;
}
if (limited !== this.fontSize()) {
this.fontSize.set(limited);
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
this.updatePlaylistSettings();
}
}
}
zoomOut() {
if (this.fontSize() > this.MIN_FONT) {
this.fontSize.update(v => Math.max(v - this.FONT_STEP, this.MIN_FONT));
const minZ = this.minZoom();
if (this.fontSize() > minZ) {
const target = Math.max(this.fontSize() - this.FONT_STEP, minZ);
this.fontSize.set(target);
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
this.updatePlaylistSettings();
}
@@ -442,7 +624,13 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
this.currentLineIndex.set(0);
this.youtubePlayerService.seekTo(0);
this.channel.postMessage({ type: 'SYNC_INDEX', index: 0 });
this.lastScrollBlock.set('center');
this.lastScrollBlock.set('start');
setTimeout(() => {
const scrollEl = this.el.nativeElement.querySelector('.lyrics-container');
if (scrollEl) {
scrollEl.scrollTop = 0;
}
}, 100);
}
public calculatePageStepSize(): number {
@@ -600,13 +788,16 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
}
if (foundNextIdx !== -1) {
console.log('[PageScroll] Riga coperta/tagliata in fondo rilevata. Diventerà la prima riga della nuova pagina:', foundNextIdx);
return foundNextIdx;
// Overlap by 1 line to ensure the last visible line is repeated at the top of the next page
const targetIdx = Math.max(currentIdx + 1, foundNextIdx - 1);
console.log('[PageScroll] Riga coperta/tagliata in fondo rilevata. Nuova pagina inizierà con overlap a:', targetIdx);
return targetIdx;
}
// Se tutto era perfettamente visibile, avanza dello step di pagina calcolato standard
// Se tutto era perfettamente visibile, avanza dello step di pagina calcolato standard con 1 riga di overlap
const step = this.calculatePageStepSize();
return Math.min(totalLines - 1, currentIdx + step);
const targetIdx = Math.max(currentIdx + 1, currentIdx + step - 1);
return Math.min(totalLines - 1, targetIdx);
} catch (e) {
console.warn('[PageScroll] Errore nel calcolo dinamico dell\'indice della pagina successiva:', e);
return this.currentLineIndex() + 1;
@@ -614,6 +805,10 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
}
next(isAutomatic: boolean = false, isVisual: boolean = false) {
if (this.isBlackScreen()) {
this.deactivateBlackScreen();
return;
}
this.lastAdvanceTimestamp = Date.now();
const totalLines = this.getTotalLines();
@@ -638,7 +833,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
nextIdx = this.calculateNextPageStartIndex();
}
this.lastScrollBlock.set('center');
} else if (this.settingsService.karaokePageScrollMode() || isVisual) {
} else if ((this.settingsService.karaokePageScrollMode() || isVisual) && !this.isLandscapeActive()) {
// Modalità manuale a pagine (o trigger visuale): calcolo analitico preciso per non perdere righe coperte
nextIdx = this.calculateNextPageStartIndex();
this.lastScrollBlock.set('start');
@@ -656,11 +851,15 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
// Metodi di validazione e freeze rimossi per migliorare affidabilità e prevenire blocchi permanenti
prev(isVisual: boolean = false) {
if (this.isBlackScreen()) {
this.deactivateBlackScreen();
return;
}
this.lastAdvanceTimestamp = Date.now();
const prevIdx = this.currentLineIndex();
if (prevIdx > 0) {
const isPageMode = this.settingsService.karaokePageScrollMode() || isVisual;
const isPageMode = (this.settingsService.karaokePageScrollMode() || isVisual) && !this.isLandscapeActive();
const stepSize = isPageMode ? this.calculatePageStepSize() : 1;
const nextIdx = Math.max(0, prevIdx - stepSize);
@@ -690,6 +889,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
const index = list.indexOf(currentId);
if (index >= 0 && index < list.length - 1) {
const nextId = list[index + 1];
if (this.isLandscapeActive()) {
this.isBlackScreen.set(true);
}
this.router.navigate(['/player'], { queryParams: { id: nextId } });
} else {
this.playlistService.autoPlayPlaylist.set(false);
@@ -708,6 +910,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
const index = list.indexOf(currentId);
if (index > 0) {
const prevId = list[index - 1];
if (this.isLandscapeActive()) {
this.isBlackScreen.set(true);
}
this.router.navigate(['/player'], { queryParams: { id: prevId } });
}
}
@@ -949,6 +1154,7 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
}
ngOnDestroy() {
this.exitFullscreen();
this.stopAutoscroll();
this.logPreviousSongTime();
this.stopCameraNavigation();
@@ -31,107 +31,159 @@
</div>
</div>
<ion-list lines="none" class="input-list">
<ion-item class="custom-input-item">
<ion-label position="stacked">Titolo del Canto</ion-label>
<ion-input [(ngModel)]="title" placeholder="Es: Il Signore è la mia salvezza"></ion-input>
</ion-item>
<div class="editor-preview-split">
<!-- Left Column: Inputs & Editor -->
<div class="editor-column">
<ion-list lines="none" class="input-list">
<ion-item class="custom-input-item">
<ion-label position="stacked">Titolo del Canto</ion-label>
<ion-input [(ngModel)]="title" placeholder="Es: Il Signore è la mia salvezza"></ion-input>
</ion-item>
<div class="category-selectors">
<ion-item class="custom-input-item select-item">
<ion-label position="stacked">Momento Liturgico</ion-label>
<ion-select [(ngModel)]="selectedLiturgico" placeholder="Scegli momento" multiple="true" interface="popover">
<ion-select-option *ngFor="let lit of cantiService.indiceLiturgico()" [value]="lit.id">
{{ lit.tag_name }}
</ion-select-option>
</ion-select>
</ion-item>
<div class="category-selectors">
<ion-item class="custom-input-item select-item">
<ion-label position="stacked">Momento Liturgico</ion-label>
<ion-select [(ngModel)]="selectedLiturgico" placeholder="Scegli momento" multiple="true" interface="popover">
<ion-select-option *ngFor="let lit of cantiService.indiceLiturgico()" [value]="lit.id">
{{ lit.tag_name }}
</ion-select-option>
</ion-select>
</ion-item>
<ion-item class="custom-input-item select-item">
<ion-label position="stacked">Periodo / Tema</ion-label>
<ion-select [(ngModel)]="selectedTematico" placeholder="Scegli tema" multiple="true" interface="popover">
<ion-select-option *ngFor="let tem of cantiService.indiceTematico()" [value]="tem.id">
{{ tem.tag_name }}
</ion-select-option>
</ion-select>
</ion-item>
</div>
<!-- TOOLBARS -->
<div class="toolbar-section">
<div class="horizontal-toolbar">
<ion-button *ngFor="let tag of commonTags" size="small" fill="outline" (click)="insertText(tag.start)">
{{ tag.label }}
</ion-button>
</div>
</div>
<!-- Main Chords Selector Toolbar -->
<div class="toolbar-section">
<div class="horizontal-toolbar main-chords-toolbar">
<ion-button
*ngFor="let group of groupedChords"
size="small"
[fill]="selectedRootChord === group.root ? 'solid' : 'outline'"
[color]="selectedRootChord === group.root ? 'secondary' : 'light'"
(click)="selectRoot(group.root)">
{{ group.root }}
</ion-button>
</div>
</div>
<!-- Variations Toolbar (only visible if a root chord is selected) -->
<div class="toolbar-section variations-container" *ngIf="selectedRootChord">
<div class="horizontal-toolbar variations-toolbar">
<span class="variations-label">Variazioni {{ selectedRootChord }}:</span>
<ion-button
size="small"
*ngFor="let chord of getVariations()"
(click)="insertChord(chord)">
{{ chord }}
</ion-button>
</div>
</div>
<!-- EDITOR AREA -->
<div class="editor-wrapper" [class.hc]="isHighContrast">
<div class="editor-header">
<div class="editor-title-group">
<span>Editor Testo</span>
<ion-item class="custom-input-item select-item">
<ion-label position="stacked">Periodo / Tema</ion-label>
<ion-select [(ngModel)]="selectedTematico" placeholder="Scegli tema" multiple="true" interface="popover">
<ion-select-option *ngFor="let tem of cantiService.indiceTematico()" [value]="tem.id">
{{ tem.tag_name }}
</ion-select-option>
</ion-select>
</ion-item>
</div>
<div class="editor-actions">
<ion-button fill="clear" size="small" (click)="undo()" [disabled]="undoStack.length === 0">
<ion-icon name="undo-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="takePhoto()">
<ion-icon name="camera-outline"></ion-icon>
<!-- TOOLBARS -->
<div class="toolbar-section">
<div class="horizontal-toolbar">
<ion-button *ngFor="let tag of commonTags" size="small" fill="outline" (click)="insertText(tag.start)">
{{ tag.label }}
</ion-button>
</div>
</div>
<!-- Main Chords Selector Toolbar -->
<div class="toolbar-section">
<div class="horizontal-toolbar main-chords-toolbar">
<ion-button
*ngFor="let group of groupedChords"
size="small"
[fill]="selectedRootChord === group.root ? 'solid' : 'outline'"
[color]="selectedRootChord === group.root ? 'secondary' : 'light'"
(click)="selectRoot(group.root)">
{{ group.root }}
</ion-button>
</div>
</div>
<!-- Variations Toolbar (only visible if a root chord is selected) -->
<div class="toolbar-section variations-container" *ngIf="selectedRootChord">
<div class="horizontal-toolbar variations-toolbar">
<span class="variations-label">Variazioni {{ selectedRootChord }}:</span>
<ion-button
size="small"
*ngFor="let chord of getVariations()"
(click)="insertChord(chord)">
{{ chord }}
</ion-button>
</div>
</div>
<!-- EDITOR AREA -->
<div class="editor-wrapper" [class.hc]="isHighContrast">
<div class="editor-header">
<div class="editor-title-group">
<span>Editor Testo</span>
</div>
<div class="editor-actions">
<ion-button fill="clear" size="small" (click)="undo()" [disabled]="undoStack.length === 0">
<ion-icon name="undo-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" size="small" (click)="takePhoto()">
<ion-icon name="camera-outline"></ion-icon>
</ion-button>
</div>
</div>
<ion-item class="custom-input-item textarea-item">
<ion-textarea
#contentTextarea
[(ngModel)]="content"
placeholder="Scrivi o scansiona..."
rows="18"
class="content-textarea"
(paste)="onPaste($event)">
</ion-textarea>
</ion-item>
</div>
<ion-item class="custom-input-item">
<ion-label position="stacked">Autore / Link YouTube</ion-label>
<ion-input [(ngModel)]="author" placeholder="Autore"></ion-input>
<ion-input [(ngModel)]="youtubeLink" placeholder="URL YouTube"></ion-input>
</ion-item>
</ion-list>
<div class="action-buttons">
<ion-button expand="block" (click)="saveToMyCanti()" class="send-btn" [disabled]="!title || !content || isProcessingOCR">
<ion-icon slot="start" name="save-outline"></ion-icon>
Salva nei Miei Canti
</ion-button>
</div>
</div>
<!-- Right Column: Active Preview Pane -->
<div class="preview-column">
<div class="preview-card" [class.hc]="isHighContrast">
<div class="preview-header">
<span class="preview-title">Anteprima Attiva</span>
<ion-button fill="clear" size="small" (click)="toggleChordsPreview()" class="preview-toggle-btn">
<ion-icon slot="start" [name]="showChordsPreview ? 'musical-notes-outline' : 'text-outline'"></ion-icon>
{{ showChordsPreview ? 'Con Accordi' : 'Solo Testo' }}
</ion-button>
</div>
<div class="preview-body">
<div class="preview-song-header">
<h2 class="preview-song-title">{{ title || 'Titolo del Canto' }}</h2>
<p class="preview-song-author" *ngIf="author">{{ author }}</p>
</div>
<div class="preview-lyrics-container">
<div *ngFor="let section of parsedSections"
class="preview-section"
[class.chorus]="section.type === 'chorus'"
[class.verse-num]="section.type === 'verse_num'">
<div *ngIf="section.type === 'chorus'" class="preview-section-label">Rit.</div>
<div *ngIf="section.type === 'verse_num' && section.verseNumber" class="preview-section-label preview-verse-num-label">{{ section.verseNumber }}.</div>
<div *ngFor="let line of section.lines" class="preview-lyric-line">
<!-- Chord mode -->
<ng-container *ngIf="showChordsPreview; else textOnly">
<span *ngFor="let seg of line.segments; let i = index" class="preview-chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(line.segments, i)">
<span *ngIf="seg.chord" class="preview-chord">{{ seg.chord }}</span>
<span class="preview-seg-text">{{ seg.text }}</span>
</span>
</ng-container>
<!-- Text only mode -->
<ng-template #textOnly>
{{ line.text }}
</ng-template>
</div>
</div>
<div class="preview-empty" *ngIf="!content">
Il testo formattato apparirà qui mentre scrivi...
</div>
</div>
</div>
</div>
<ion-item class="custom-input-item textarea-item">
<ion-textarea
#contentTextarea
[(ngModel)]="content"
placeholder="Scrivi o scansiona..."
rows="18"
class="content-textarea"
(paste)="onPaste($event)">
</ion-textarea>
</ion-item>
</div>
<ion-item class="custom-input-item">
<ion-label position="stacked">Autore / Link YouTube</ion-label>
<ion-input [(ngModel)]="author" placeholder="Autore"></ion-input>
<ion-input [(ngModel)]="youtubeLink" placeholder="URL YouTube"></ion-input>
</ion-item>
</ion-list>
<div class="action-buttons">
<ion-button expand="block" (click)="saveToMyCanti()" class="send-btn" [disabled]="!title || !content || isProcessingOCR">
<ion-icon slot="start" name="save-outline"></ion-icon>
Salva nei Miei Canti
</ion-button>
</div>
</div>
@@ -113,7 +113,8 @@ body.high-contrast :host ::ng-deep {
}
.propose-container {
max-width: 800px;
max-width: 1400px;
width: 100%;
margin: 0 auto;
}
@@ -470,3 +471,233 @@ body.high-contrast :host ::ng-deep {
}
}
}
/* RESPONSIVE LAYOUT & ACTIVE PREVIEW PANE */
.editor-preview-split {
display: grid;
grid-template-columns: 1fr;
gap: 24px;
align-items: flex-start;
@media (min-width: 992px) {
grid-template-columns: 1fr 1fr;
}
}
.preview-column {
@media (min-width: 992px) {
position: sticky;
top: 16px;
}
}
.preview-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
backdrop-filter: blur(10px);
.preview-header {
background: rgba(255, 255, 255, 0.04);
padding: 10px 16px;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
.preview-title {
font-size: 11px;
font-weight: 800;
color: var(--ion-color-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.preview-toggle-btn {
--color: rgba(255, 255, 255, 0.7);
margin: 0;
font-size: 11px;
font-weight: 600;
ion-icon {
font-size: 14px;
}
}
}
.preview-body {
padding: 24px;
min-height: 400px;
background: rgba(0, 0, 0, 0.2);
.preview-song-header {
margin-bottom: 24px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
padding-bottom: 14px;
.preview-song-title {
font-size: 1.6rem;
font-weight: 700;
color: #ffffff;
margin: 0 0 6px 0;
font-family: 'Outfit', sans-serif;
}
.preview-song-author {
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.5);
margin: 0;
font-weight: 500;
}
}
.preview-lyrics-container {
font-size: 1.1rem;
font-family: 'Outfit', sans-serif;
overflow-y: auto;
max-height: 60vh;
padding-right: 8px;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 2px;
}
.preview-section {
margin-bottom: 1.8rem;
position: relative;
&.chorus {
background: rgba(var(--ion-color-secondary-rgb), 0.03);
border-left: 3px solid var(--ion-color-secondary);
padding-left: 1rem;
margin-left: -1rem;
border-radius: 0 8px 8px 0;
}
.preview-section-label {
font-size: 0.7rem;
text-transform: uppercase;
font-weight: 700;
color: var(--ion-color-secondary);
margin-bottom: 0.4rem;
opacity: 0.7;
letter-spacing: 1px;
}
}
.preview-lyric-line {
margin-bottom: 0.8rem;
line-height: 1.6;
color: rgba(255, 255, 255, 0.9);
min-height: 1.5em;
}
.preview-chord-segment {
display: inline-flex;
flex-direction: column;
vertical-align: bottom;
margin-right: 0.25em;
&.contiguous-next {
margin-right: 0 !important;
}
.preview-chord {
font-size: 0.78em;
font-weight: 700;
color: var(--ion-color-secondary);
height: 1.25em;
margin-bottom: -0.2em;
}
.preview-seg-text {
white-space: pre;
&::after {
content: '\200b';
}
}
}
.preview-empty {
color: rgba(255, 255, 255, 0.3);
text-align: center;
padding-top: 80px;
font-style: italic;
font-size: 0.95rem;
}
}
}
/* High Contrast mode overrides */
&.hc {
background: #ffffff;
border: 2px solid #000000;
box-shadow: none;
.preview-header {
background: #f0f0f0;
border-bottom: 2px solid #000000;
.preview-title {
color: #000000;
}
.preview-toggle-btn {
--color: #000000;
font-weight: 700;
}
}
.preview-body {
background: #ffffff;
.preview-song-header {
border-bottom: 2px solid #000000;
.preview-song-title {
color: #000000;
}
.preview-song-author {
color: #333333;
}
}
.preview-lyrics-container {
.preview-section {
&.chorus {
background: #f5f5f5;
border-left: 3px solid #000000;
}
.preview-section-label {
color: #000000;
}
}
.preview-lyric-line {
color: #000000;
}
.preview-chord-segment {
.preview-chord {
color: #000000;
text-decoration: underline;
font-weight: 800;
}
}
.preview-empty {
color: #666666;
}
}
}
}
}
@@ -8,6 +8,7 @@ import { MyCantiService } from '../../services/my-canti.service';
import { PlaylistService } from '../../services/playlist.service';
import { ThemeService } from '../../services/theme.service';
import { ActivatedRoute, RouterModule, Router } from '@angular/router';
import { LyricsParserService, ParsedSection } from '../../services/lyrics-parser.service';
@Component({
selector: 'app-propose-canto',
@@ -27,6 +28,17 @@ export class ProposeCantoPage implements OnInit {
public themeService = inject(ThemeService);
private route = inject(ActivatedRoute);
private router = inject(Router);
public lyricsParser = inject(LyricsParserService);
showChordsPreview: boolean = true;
get parsedSections(): ParsedSection[] {
return this.lyricsParser.parseAccordi(this.content);
}
toggleChordsPreview() {
this.showChordsPreview = !this.showChordsPreview;
}
title: string = '';
author: string = '';
+1 -8
View File
@@ -62,14 +62,7 @@
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.browserFullscreen()" (ionChange)="settingsService.toggleBrowserFullscreen()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="expand-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
<h2 class="settings-item-title">Nascondi Barre (Player)</h2>
<p class="settings-item-subtitle">Modalità immersiva nel dettaglio canto</p>
</ion-label>
<ion-toggle slot="end" [checked]="settingsService.fullscreenMode()" (ionChange)="settingsService.toggleFullscreenMode()" color="secondary"></ion-toggle>
</ion-item>
<ion-item class="transparent-item" lines="none">
<ion-icon name="contrast-outline" slot="start" color="secondary"></ion-icon>
<ion-label class="outfit-font">
+32 -19
View File
@@ -347,15 +347,33 @@ export class SettingsPage {
await registration.update();
}
let sub: any = null;
let readyPromise: Promise<void> | undefined = undefined;
if (this.swUpdate.isEnabled) {
readyPromise = new Promise<void>((resolve) => {
sub = this.swUpdate.versionUpdates
.pipe(
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'),
first()
)
.subscribe(() => {
resolve();
});
});
}
// Layer 2: Ask Angular SW to check
if (this.swUpdate.isEnabled) {
const swFoundUpdate = await this.swUpdate.checkForUpdate();
if (swFoundUpdate) {
await this.applyUpdateAndReload();
await this.applyUpdateAndReload(readyPromise, sub);
return true;
}
}
if (sub) sub.unsubscribe();
// Layer 3: Fallback — check version.json
const versionMismatch = await this.checkVersionJson();
if (versionMismatch) {
@@ -377,7 +395,7 @@ export class SettingsPage {
}
}
private async applyUpdateAndReload() {
private async applyUpdateAndReload(readyPromise?: Promise<void>, subscription?: any) {
const overlay = showFullscreenUpdateOverlay();
let activated = false;
@@ -397,24 +415,19 @@ export class SettingsPage {
}, 600);
};
// Listen for VERSION_READY + activate + reload
if (this.swUpdate.isEnabled) {
this.swUpdate.versionUpdates
.pipe(
filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'),
first()
)
.subscribe(() => {
console.log('[PWA-Update] Settings: version ready, activating...');
activateAndReload();
});
if (readyPromise) {
Promise.race([
readyPromise,
new Promise((resolve) => setTimeout(resolve, 25000))
]).then(() => {
if (subscription) subscription.unsubscribe();
activateAndReload();
});
} else {
setTimeout(() => {
activateAndReload();
}, 1000);
}
// Safety timeout: reload after 6s regardless
setTimeout(() => {
console.log('[PWA-Update] Settings: safety timeout reached, activating...');
activateAndReload();
}, 6000);
}
private async checkVersionJson(): Promise<boolean> {