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:
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user