Refactor: Restringimento drag & drop, legenda impostazioni, modalità offline e rimozione sezione liturgia
This commit is contained in:
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
|
||||
# --- Caricamento variabili d'ambiente ---
|
||||
if [ -f .env ]; then
|
||||
export $(grep -v '^#' .env | xargs)
|
||||
else
|
||||
echo "❌ Errore: File .env non trovato."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Configurazione FTP ---
|
||||
FTP_HOST="ftp.canticristiani.it"
|
||||
FTP_USER="canticristiani.it"
|
||||
FTP_PASS="$FTP_PASSWORD"
|
||||
SOURCE_URL="http://185.193.67.105:3000/cantiletture.json"
|
||||
REMOTE_PATH="api/cantiletture.json"
|
||||
|
||||
if [ -z "$FTP_PASS" ]; then
|
||||
echo "❌ Errore: FTP_PASSWORD non definita nel file .env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Download del file ---
|
||||
echo "⬇️ Scaricamento dati da $SOURCE_URL..."
|
||||
TEMP_FILE=$(mktemp)
|
||||
if ! curl -s -L "$SOURCE_URL" -o "$TEMP_FILE"; then
|
||||
echo "❌ Errore nel download dei dati."
|
||||
rm "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Upload via FTP ---
|
||||
echo "🚀 Caricamento nuovo file su $FTP_HOST/$REMOTE_PATH..."
|
||||
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
|
||||
echo "✅ Allineamento completato con successo!"
|
||||
else
|
||||
echo "❌ Errore durante il caricamento FTP."
|
||||
rm "$TEMP_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Cleanup
|
||||
rm "$TEMP_FILE"
|
||||
@@ -27,10 +27,6 @@ const routes: Routes = [
|
||||
path: 'propose-canto',
|
||||
loadComponent: () => import('./pages/propose-canto/propose-canto.page').then( m => m.ProposeCantoPage)
|
||||
},
|
||||
{
|
||||
path: 'liturgia',
|
||||
loadChildren: () => import('./pages/liturgia/liturgia.module').then( m => m.LiturgiaPageModule)
|
||||
},
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -10,10 +10,6 @@
|
||||
</div>
|
||||
</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<div class="offline-badge-header" *ngIf="!connectivityService.isOnline()">
|
||||
<ion-icon name="cloud-offline-outline"></ion-icon>
|
||||
</div>
|
||||
|
||||
|
||||
<ion-button (click)="importPlaylist()" class="add-btn">
|
||||
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
|
||||
@@ -84,6 +80,17 @@
|
||||
</span>
|
||||
</ion-button>
|
||||
|
||||
<ion-button
|
||||
[fill]="showSuggeriti() ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleSuggeriti()"
|
||||
class="filter-chip">
|
||||
Suggeriti
|
||||
<span class="close-icon-wrapper" *ngIf="showSuggeriti()" (click)="clearSuggeriti($event)">
|
||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</ion-button>
|
||||
|
||||
<ion-button
|
||||
[fill]="activeFilterType() === 'playlist' || playlistService.activeListName() !== null ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
@@ -96,6 +103,17 @@
|
||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</ion-button>
|
||||
|
||||
<ion-button
|
||||
[fill]="showTopTen() ? 'solid' : 'outline'"
|
||||
size="small"
|
||||
(click)="toggleTopTen()"
|
||||
class="filter-chip">
|
||||
Top Ten
|
||||
<span class="close-icon-wrapper" *ngIf="showTopTen()" (click)="clearTopTen($event)">
|
||||
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||
</span>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -108,6 +126,9 @@
|
||||
<!-- Selection Mode Actions -->
|
||||
<div class="selection-pill glass" *ngIf="playlistService.selectionMode()">
|
||||
<span class="selection-count">{{ playlistService.selectedIds().size }}</span>
|
||||
<ion-button fill="clear" color="secondary" (click)="toggleAddingSongs()" class="mini-action-btn">
|
||||
<ion-icon slot="icon-only" [name]="isAddingSongs() ? 'list-outline' : 'add-circle-outline'"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" color="secondary" (click)="finishSelection()" [disabled]="playlistService.selectedIds().size === 0" class="mini-action-btn">
|
||||
<ion-icon slot="icon-only" name="save-outline"></ion-icon>
|
||||
</ion-button>
|
||||
@@ -162,6 +183,32 @@
|
||||
(cdkDropListDropped)="drop($event)"
|
||||
class="transparent-list">
|
||||
|
||||
<!-- Premium Suggested Mass Card (Collapsible) -->
|
||||
<div class="suggeriti-header-card glass ion-margin-bottom"
|
||||
*ngIf="showSuggeriti() && cantiLettureService.data()"
|
||||
(click)="isMassCardExpanded.set(!isMassCardExpanded())"
|
||||
[class.expanded]="isMassCardExpanded()">
|
||||
<div class="card-inner ion-padding">
|
||||
<div class="card-header-row">
|
||||
<div class="card-header-left">
|
||||
<span class="card-label">SINTESI MESSA</span>
|
||||
<span class="card-date-compact" *ngIf="!isMassCardExpanded()">{{ getSelectedMassFormattedDate() }}</span>
|
||||
</div>
|
||||
<ion-icon [name]="isMassCardExpanded() ? 'chevron-up-outline' : 'chevron-down-outline'" class="expand-icon"></ion-icon>
|
||||
</div>
|
||||
|
||||
<div class="expandable-content" [class.show]="isMassCardExpanded()">
|
||||
<div class="card-date-row" *ngIf="isMassCardExpanded()">
|
||||
<span class="card-date">{{ getSelectedMassFormattedDate() }}</span>
|
||||
</div>
|
||||
<h1 class="card-title outfit-font">{{ getSelectedMassTitle() }}</h1>
|
||||
<p class="card-summary outfit-font" *ngIf="getSelectedMassSummary()">
|
||||
{{ getSelectedMassSummary() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ion-item *ngFor="let canto of visibleCanti()"
|
||||
[id]="'canto-' + canto.id"
|
||||
class="glass ion-margin-bottom custom-item"
|
||||
@@ -189,13 +236,23 @@
|
||||
<h2 class="outfit-font" style="font-weight: 500; color: var(--ion-color-secondary)">
|
||||
{{ canto.titolo }}
|
||||
</h2>
|
||||
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px;">{{ canto.autore || 'Autore sconosciuto' }}</p>
|
||||
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 8px;">
|
||||
<span>{{ canto.autore || 'Autore sconosciuto' }}</span>
|
||||
<span *ngIf="showTopTen() && getEsecuzioniCount(canto.id) !== null" class="esecuzioni-badge" style="color: var(--ion-color-secondary); font-size: 0.75rem; background: rgba(var(--ion-color-secondary-rgb), 0.12); padding: 2px 6px; border-radius: 6px; display: inline-flex; align-items: center; gap: 4px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.2);">
|
||||
<ion-icon name="stats-chart-outline" style="font-size: 0.75rem; color: var(--ion-color-secondary);"></ion-icon>
|
||||
{{ getEsecuzioniCount(canto.id) }} esecuzioni
|
||||
</span>
|
||||
<span *ngIf="showSuggeriti() && getMassSuggestionWeight(canto.id_canti) !== null" class="suggerito-badge" style="color: var(--ion-color-secondary); font-size: 0.75rem; background: rgba(var(--ion-color-secondary-rgb), 0.12); padding: 2px 6px; border-radius: 6px; display: inline-flex; align-items: center; gap: 4px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.3);">
|
||||
<ion-icon name="sparkles-outline" style="font-size: 0.75rem; color: var(--ion-color-secondary);"></ion-icon>
|
||||
Attinenza {{ getMassSuggestionWeight(canto.id_canti) }}%
|
||||
</span>
|
||||
</p>
|
||||
</ion-label>
|
||||
|
||||
<!-- Video Thumbnail Section -->
|
||||
<div *ngIf="canto.link_youtube && canto.link_youtube.length > 5" class="thumb-section" (click)="$event.stopPropagation()">
|
||||
<div class="thumb-container compact">
|
||||
<ng-container *ngIf="connectivityService.isOnline(); else offlineThumb">
|
||||
<ng-container *ngIf="youtubePlayerService.isPlayerSupported(); else noPlayerThumb">
|
||||
<div class="thumb-wrapper" (click)="playVideo($event, canto.id)">
|
||||
<img [src]="cantiService.getYoutubeThumb(canto.link_youtube)"
|
||||
class="thumb-img loaded"
|
||||
@@ -205,11 +262,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #offlineThumb>
|
||||
<ng-template #noPlayerThumb>
|
||||
<div class="thumb-wrapper">
|
||||
<img [src]="cantiService.getYoutubeThumb(canto.link_youtube)"
|
||||
class="thumb-img loaded"
|
||||
loading="lazy"
|
||||
*ngIf="connectivityService.isOnline(); else offlineIcon">
|
||||
<ng-template #offlineIcon>
|
||||
<div class="offline-thumb">
|
||||
<ion-icon name="cloud-offline-outline"></ion-icon>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
@@ -249,7 +312,7 @@
|
||||
|
||||
</ion-content>
|
||||
|
||||
<ion-footer *ngIf="youtubePlayerService.currentCantoId()" class="ion-no-border">
|
||||
<ion-footer *ngIf="youtubePlayerService.isPlayerSupported() && youtubePlayerService.currentCantoId()" class="ion-no-border">
|
||||
<ion-toolbar class="global-player-toolbar glass">
|
||||
<div class="player-content">
|
||||
<div class="controls-row">
|
||||
|
||||
+143
-3
@@ -31,7 +31,7 @@
|
||||
.search-wrapper-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px 16px 12px 16px; // Added top padding for extra clearance
|
||||
padding: 2px 16px 12px 16px; // Reduced top padding
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ ion-title {
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 12px;
|
||||
padding: 20px 0 16px 24px; // Increased padding for mobile spacing
|
||||
padding: 16px 0 4px 24px; // Reduced padding to bring search bar closer
|
||||
}
|
||||
|
||||
.header-logo {
|
||||
@@ -661,7 +661,7 @@ ion-title {
|
||||
justify-content: center;
|
||||
padding: 0 12px 0 0;
|
||||
cursor: grab;
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.8rem;
|
||||
@@ -748,3 +748,143 @@ ion-title {
|
||||
.transparent-list {
|
||||
padding-bottom: 100px !important;
|
||||
}
|
||||
|
||||
.suggeriti-header-card {
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
overflow: hidden;
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
margin-bottom: 12px;
|
||||
backdrop-filter: blur(15px);
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2);
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease, border-color 0.3s ease;
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.card-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.card-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: padding-bottom 0.3s ease, border-bottom-color 0.3s ease;
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
.card-header-row {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 1.25rem;
|
||||
color: var(--ion-color-secondary);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--ion-color-secondary);
|
||||
}
|
||||
|
||||
.card-date-compact {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.expandable-content {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.35s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.35s ease, margin-top 0.35s ease;
|
||||
|
||||
&.show {
|
||||
max-height: 400px; /* High enough to fit summary */
|
||||
opacity: 1;
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.card-date-row {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.card-date {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 4px 0 0 0;
|
||||
}
|
||||
|
||||
.card-summary {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
margin: 6px 0 0 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(body.high-contrast) .suggeriti-header-card {
|
||||
background: #ffffff !important;
|
||||
border-color: #000000 !important;
|
||||
box-shadow: none !important;
|
||||
|
||||
.card-date-compact {
|
||||
color: black !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
&.expanded {
|
||||
.card-header-row {
|
||||
border-bottom-color: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
.card-label {
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.card-date {
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
.card-summary {
|
||||
color: black !important;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(body.high-contrast) {
|
||||
.drag-handle {
|
||||
color: rgba(0, 0, 0, 0.6) !important;
|
||||
}
|
||||
}
|
||||
|
||||
+135
-11
@@ -13,6 +13,7 @@ import { ModalController, AlertController, ToastController } from '@ionic/angula
|
||||
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||
import { MyCantiService } from '../services/my-canti.service';
|
||||
import { QrScannerComponent } from '../components/qr-scanner/qr-scanner.component';
|
||||
import { CantiLettureService } from '../services/canti-letture.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
@@ -25,6 +26,9 @@ export class HomePage implements OnDestroy {
|
||||
public selectedLiturgico = signal<number | null>(null);
|
||||
public selectedTematico = signal<number | null>(null);
|
||||
public showOnlyMine = signal<boolean>(false);
|
||||
public showTopTen = signal<boolean>(false);
|
||||
public showSuggeriti = signal<boolean>(false);
|
||||
public isMassCardExpanded = signal<boolean>(false);
|
||||
public activeFilterType = signal<'liturgico' | 'tematico' | 'playlist' | null>(null);
|
||||
public loadedThumbs = new Set<string>();
|
||||
public version = VERSION;
|
||||
@@ -49,6 +53,7 @@ export class HomePage implements OnDestroy {
|
||||
public myCantiService = inject(MyCantiService);
|
||||
public playlistService = inject(PlaylistService);
|
||||
public settingsService = inject(SettingsService);
|
||||
public cantiLettureService = inject(CantiLettureService);
|
||||
private router = inject(Router);
|
||||
private route = inject(ActivatedRoute);
|
||||
private modalCtrl = inject(ModalController);
|
||||
@@ -93,6 +98,8 @@ export class HomePage implements OnDestroy {
|
||||
const litId = this.selectedLiturgico();
|
||||
const temId = this.selectedTematico();
|
||||
const onlyMine = this.showOnlyMine();
|
||||
const topTen = this.showTopTen();
|
||||
const suggeriti = this.showSuggeriti();
|
||||
|
||||
let list = [...this.cantiService.canti(), ...this.myCantiService.myCanti()];
|
||||
|
||||
@@ -107,12 +114,17 @@ export class HomePage implements OnDestroy {
|
||||
list = list.filter(c => c.id_momenti?.includes(temId));
|
||||
}
|
||||
|
||||
if (suggeriti) {
|
||||
const suggMap = this.cantiLettureService.suggestionsMap();
|
||||
list = list.filter(c => suggMap.has(c.id_canti));
|
||||
}
|
||||
|
||||
// Special List handling (from QR Code or Saved Playlists)
|
||||
const activeIds = this.playlistService.activeListIds();
|
||||
const selectionMode = this.playlistService.selectionMode();
|
||||
|
||||
// If we are in selection mode, only show restricted list if NOT adding songs AND we have a base list to reorder
|
||||
if (selectionMode && !this.isAddingSongs() && activeIds.length > 0) {
|
||||
// If we are in selection mode, only show restricted list if NOT adding songs
|
||||
if (selectionMode && !this.isAddingSongs()) {
|
||||
return this.reorderList();
|
||||
}
|
||||
|
||||
@@ -124,6 +136,25 @@ export class HomePage implements OnDestroy {
|
||||
.filter((c): c is any => !!c);
|
||||
}
|
||||
|
||||
if (suggeriti) {
|
||||
const suggMap = this.cantiLettureService.suggestionsMap();
|
||||
list = list.sort((a, b) => {
|
||||
const pesoA = suggMap.get(a.id_canti) || 0;
|
||||
const pesoB = suggMap.get(b.id_canti) || 0;
|
||||
return pesoB - pesoA;
|
||||
});
|
||||
} else if (topTen) {
|
||||
const eseguiti = this.cantiService.cantiEseguiti();
|
||||
const eseguitiMap = new Map<number, number>();
|
||||
eseguiti.forEach(x => eseguitiMap.set(x.id_canti, x.num));
|
||||
|
||||
list = list.sort((a, b) => {
|
||||
const numA = eseguitiMap.get(a.id_canti) || 0;
|
||||
const numB = eseguitiMap.get(b.id_canti) || 0;
|
||||
return numB - numA;
|
||||
});
|
||||
}
|
||||
|
||||
if (!query) return list;
|
||||
|
||||
// Converte "uno" in "1" per facilitare la ricerca vocale (es. "numero uno")
|
||||
@@ -170,15 +201,22 @@ export class HomePage implements OnDestroy {
|
||||
}
|
||||
});
|
||||
|
||||
let prevSelectionMode = false;
|
||||
|
||||
effect(() => {
|
||||
const selectionMode = this.playlistService.selectionMode();
|
||||
const selectedIds = this.playlistService.selectedIds();
|
||||
const activeIds = this.playlistService.activeListIds();
|
||||
|
||||
if (selectionMode) {
|
||||
if (activeIds.length === 0 && !this.isAddingSongs()) {
|
||||
if (!prevSelectionMode) {
|
||||
if (activeIds.length === 0) {
|
||||
this.isAddingSongs.set(true);
|
||||
} else {
|
||||
this.isAddingSongs.set(false);
|
||||
}
|
||||
}
|
||||
prevSelectionMode = true;
|
||||
|
||||
const currentIds = this.reorderList().map(c => c.id);
|
||||
const newIds = Array.from(selectedIds);
|
||||
@@ -208,6 +246,7 @@ export class HomePage implements OnDestroy {
|
||||
this.reorderList.set(updatedList);
|
||||
}
|
||||
} else {
|
||||
prevSelectionMode = false;
|
||||
this.isAddingSongs.set(false);
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
@@ -267,11 +306,6 @@ export class HomePage implements OnDestroy {
|
||||
}
|
||||
|
||||
goToCanto(id: string) {
|
||||
if (this.playlistService.selectionMode()) {
|
||||
this.playlistService.toggleSongSelection(id);
|
||||
return;
|
||||
}
|
||||
|
||||
const params: any = { id };
|
||||
const isPlaying = this.youtubePlayerService.isPlaying();
|
||||
|
||||
@@ -379,6 +413,8 @@ export class HomePage implements OnDestroy {
|
||||
return this.selectedLiturgico() !== null ||
|
||||
this.selectedTematico() !== null ||
|
||||
this.showOnlyMine() ||
|
||||
this.showTopTen() ||
|
||||
this.showSuggeriti() ||
|
||||
this.playlistService.activeListName() !== null ||
|
||||
this.searchQuery() !== '';
|
||||
}
|
||||
@@ -387,6 +423,8 @@ export class HomePage implements OnDestroy {
|
||||
this.selectedLiturgico.set(null);
|
||||
this.selectedTematico.set(null);
|
||||
this.showOnlyMine.set(false);
|
||||
this.showTopTen.set(false);
|
||||
this.showSuggeriti.set(false);
|
||||
this.playlistService.activeListIds.set([]);
|
||||
this.playlistService.activeListName.set(null);
|
||||
this.playlistService.activePlaylistId.set(null);
|
||||
@@ -415,6 +453,73 @@ export class HomePage implements OnDestroy {
|
||||
this.showOnlyMine.set(false);
|
||||
}
|
||||
|
||||
toggleTopTen() {
|
||||
this.showTopTen.update(v => !v);
|
||||
this.limit.set(30);
|
||||
}
|
||||
|
||||
clearTopTen(event?: Event) {
|
||||
if (event) event.stopPropagation();
|
||||
this.showTopTen.set(false);
|
||||
this.limit.set(30);
|
||||
}
|
||||
|
||||
toggleSuggeriti() {
|
||||
this.showSuggeriti.update(v => !v);
|
||||
this.limit.set(30);
|
||||
}
|
||||
|
||||
clearSuggeriti(event?: Event) {
|
||||
if (event) event.stopPropagation();
|
||||
this.showSuggeriti.set(false);
|
||||
this.limit.set(30);
|
||||
}
|
||||
|
||||
getMassSuggestionWeight(id_canti: number): number | null {
|
||||
const weight = this.cantiLettureService.suggestionsMap().get(id_canti);
|
||||
return weight !== undefined ? weight : null;
|
||||
}
|
||||
|
||||
getMassSuggestionMoment(id_canti: number): string | null {
|
||||
const moments = this.cantiLettureService.suggestionsMomentsMap().get(id_canti);
|
||||
return moments && moments.length > 0 ? moments.join(', ') : null;
|
||||
}
|
||||
|
||||
getSelectedMassFormattedDate(): string | null {
|
||||
const dateStr = this.cantiLettureService.selectedMassDate();
|
||||
if (!dateStr) return null;
|
||||
const parts = dateStr.split('-');
|
||||
if (parts.length === 3) {
|
||||
return `${parts[2]}/${parts[1]}/${parts[0]}`;
|
||||
}
|
||||
return dateStr;
|
||||
}
|
||||
|
||||
getSelectedMassTitle(): string | null {
|
||||
const dateStr = this.cantiLettureService.selectedMassDate();
|
||||
const data = this.cantiLettureService.data();
|
||||
if (dateStr && data && data.masses[dateStr]) {
|
||||
return data.masses[dateStr].title;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getSelectedMassSummary(): string | null {
|
||||
const dateStr = this.cantiLettureService.selectedMassDate();
|
||||
const data = this.cantiLettureService.data();
|
||||
if (dateStr && data && data.masses[dateStr]) {
|
||||
return data.masses[dateStr].summary || null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getEsecuzioniCount(id: string): number | null {
|
||||
const numId = parseInt(id, 10);
|
||||
if (isNaN(numId)) return null;
|
||||
const es = this.cantiService.cantiEseguiti().find(x => x.id_canti === numId);
|
||||
return es ? es.num : null;
|
||||
}
|
||||
|
||||
onImgLoad(id: string) {
|
||||
this.loadedThumbs.add(id);
|
||||
}
|
||||
@@ -429,6 +534,9 @@ export class HomePage implements OnDestroy {
|
||||
|
||||
playVideo(event: Event, id: string) {
|
||||
event.stopPropagation();
|
||||
if (!this.youtubePlayerService.isPlayerSupported()) {
|
||||
return;
|
||||
}
|
||||
this.youtubePlayerService.setMediaSessionCallbacks(
|
||||
() => this.playNextPreview(),
|
||||
() => this.playPrevPreview()
|
||||
@@ -577,14 +685,30 @@ export class HomePage implements OnDestroy {
|
||||
handleScannedData(data: string) {
|
||||
if (!data) return;
|
||||
|
||||
if (data.includes('?import=')) {
|
||||
if (data.includes('import=')) {
|
||||
try {
|
||||
const base64 = data.split('?import=')[1];
|
||||
let base64 = '';
|
||||
if (data.includes('?import=')) {
|
||||
base64 = data.split('?import=')[1];
|
||||
} else if (data.includes('&import=')) {
|
||||
base64 = data.split('&import=')[1];
|
||||
}
|
||||
|
||||
if (base64.includes('&')) {
|
||||
base64 = base64.split('&')[0];
|
||||
}
|
||||
if (base64.includes('#')) {
|
||||
base64 = base64.split('#')[0];
|
||||
}
|
||||
|
||||
this.handleImport(base64);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.error('Failed to parse scanned link', e);
|
||||
}
|
||||
} else if (data.startsWith('canti:')) {
|
||||
}
|
||||
|
||||
if (data.startsWith('canti:')) {
|
||||
const parts = data.replace('canti:', '').split(':');
|
||||
let idsStr = '';
|
||||
if (parts.length > 1) {
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { LiturgiaPage } from './liturgia.page';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild([
|
||||
{
|
||||
path: '',
|
||||
component: LiturgiaPage
|
||||
}
|
||||
])
|
||||
],
|
||||
declarations: [LiturgiaPage]
|
||||
})
|
||||
export class LiturgiaPageModule {}
|
||||
@@ -1,71 +0,0 @@
|
||||
<ion-header [translucent]="true" class="ion-no-border">
|
||||
<ion-toolbar class="bg-gradient">
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="/settings" color="secondary"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title class="outfit-font">Liturgia del Giorno</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content [fullscreen]="true" class="bg-gradient">
|
||||
<div class="liturgia-container ion-padding">
|
||||
|
||||
<div class="date-header-wrapper ion-margin-bottom">
|
||||
<div class="date-header glass">
|
||||
<div class="date-navigation">
|
||||
<ion-button fill="clear" color="secondary" (click)="liturgyService.prevDay()">
|
||||
<ion-icon name="chevron-back-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<h2 class="outfit-font">{{ getTodayString() }}</h2>
|
||||
<ion-button fill="clear" color="secondary" (click)="liturgyService.nextDay()">
|
||||
<ion-icon name="chevron-forward-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="liturgyService.loading()" class="ion-text-center ion-padding">
|
||||
<ion-spinner name="crescent" color="secondary"></ion-spinner>
|
||||
<p class="outfit-font">Caricamento letture...</p>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!liturgyService.loading() && liturgyService.readings() as r" class="readings-content">
|
||||
|
||||
<ion-accordion-group class="glass-accordion ion-margin-bottom">
|
||||
|
||||
<!-- Prima Lettura -->
|
||||
<ion-accordion value="prima" *ngIf="r.primaLettura">
|
||||
<ion-item slot="header" class="transparent-item">
|
||||
<ion-label class="outfit-font reading-title">Prima Lettura</ion-label>
|
||||
</ion-item>
|
||||
<div class="reading-text ion-padding" slot="content" [innerHTML]="r.primaLettura"></div>
|
||||
</ion-accordion>
|
||||
|
||||
<!-- Seconda Lettura -->
|
||||
<ion-accordion value="seconda" *ngIf="r.secondaLettura">
|
||||
<ion-item slot="header" class="transparent-item">
|
||||
<ion-label class="outfit-font reading-title">Seconda Lettura</ion-label>
|
||||
</ion-item>
|
||||
<div class="reading-text ion-padding" slot="content" [innerHTML]="r.secondaLettura"></div>
|
||||
</ion-accordion>
|
||||
|
||||
<!-- Vangelo -->
|
||||
<ion-accordion value="vangelo" *ngIf="r.vangelo">
|
||||
<ion-item slot="header" class="transparent-item">
|
||||
<ion-label class="outfit-font reading-title">Vangelo</ion-label>
|
||||
</ion-item>
|
||||
<div class="reading-text ion-padding" slot="content" [innerHTML]="r.vangelo"></div>
|
||||
</ion-accordion>
|
||||
|
||||
</ion-accordion-group>
|
||||
|
||||
<!-- Le sezioni dei suggerimenti sono state rimosse temporaneamente in attesa dell'implementazione AI lato server -->
|
||||
</div>
|
||||
|
||||
<div *ngIf="!liturgyService.loading() && !liturgyService.readings()" class="ion-text-center ion-padding glass">
|
||||
<p class="outfit-font">Non è stato possibile caricare le letture per oggi.</p>
|
||||
<ion-button fill="clear" color="secondary" (click)="liturgyService.fetchTodayLiturgy()">Riprova</ion-button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -1,144 +0,0 @@
|
||||
.bg-gradient {
|
||||
--background: transparent;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
}
|
||||
|
||||
.liturgia-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.date-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.date-navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--ion-color-secondary);
|
||||
text-transform: capitalize;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.keywords-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.moment-group {
|
||||
.moment-header {
|
||||
margin-bottom: 8px;
|
||||
ion-badge {
|
||||
font-size: 0.9rem;
|
||||
padding: 6px 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.glass-accordion {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
|
||||
ion-accordion {
|
||||
--background: transparent;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.reading-title {
|
||||
color: var(--ion-color-secondary);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.reading-text {
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
line-height: 1.6;
|
||||
font-size: 1rem;
|
||||
white-space: pre-wrap;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
|
||||
::ng-deep br {
|
||||
display: block;
|
||||
content: "";
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
color: #fff;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.song-card {
|
||||
padding: 0;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.song-title {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.song-author {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
}
|
||||
|
||||
.transparent-item {
|
||||
--background: transparent;
|
||||
--color: #fff;
|
||||
--padding-start: 16px;
|
||||
--padding-end: 16px;
|
||||
}
|
||||
|
||||
/* Alto Contrasto per Liturgia */
|
||||
:host-context(body.high-contrast) {
|
||||
.glass-accordion {
|
||||
// Mantengo il look glass per gli header come richiesto
|
||||
|
||||
ion-accordion {
|
||||
.reading-text {
|
||||
color: #000 !important; // Testo nero
|
||||
background: #fff !important; // Sfondo bianco solido per massimo contrasto
|
||||
padding: 20px !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Component, OnInit, inject, signal } from '@angular/core';
|
||||
import { LiturgyService } from '../../services/liturgy.service';
|
||||
import { Router } from '@angular/router';
|
||||
import { Canto } from '../../services/canti.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-liturgia',
|
||||
templateUrl: './liturgia.page.html',
|
||||
styleUrls: ['./liturgia.page.scss'],
|
||||
standalone: false
|
||||
})
|
||||
export class LiturgiaPage implements OnInit {
|
||||
public liturgyService = inject(LiturgyService);
|
||||
private router = inject(Router);
|
||||
|
||||
ngOnInit() {
|
||||
this.liturgyService.fetchTodayLiturgy();
|
||||
}
|
||||
|
||||
suggest() {
|
||||
this.liturgyService.suggestSongs();
|
||||
}
|
||||
|
||||
onDateChange(event: any) {
|
||||
const newDate = event.detail.value.split('T')[0];
|
||||
this.liturgyService.fetchTodayLiturgy(newDate);
|
||||
}
|
||||
|
||||
openSong(song: Canto) {
|
||||
this.router.navigate(['/player'], { queryParams: { id: song.id } });
|
||||
}
|
||||
|
||||
getTodayString(): string {
|
||||
const date = new Date(this.liturgyService.selectedDate());
|
||||
return date.toLocaleDateString('it-IT', {
|
||||
weekday: 'long',
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
<ion-header [translucent]="true" class="ion-no-border">
|
||||
<ion-toolbar class="bg-gradient top-toolbar">
|
||||
<ion-buttons slot="start">
|
||||
<ion-buttons slot="end">
|
||||
<div class="offline-badge-header" *ngIf="!connectivityService.isOnline()">
|
||||
<ion-icon name="cloud-offline-outline"></ion-icon>
|
||||
</div>
|
||||
@@ -57,7 +57,7 @@
|
||||
</ion-button>
|
||||
|
||||
<!-- Youtube Link -->
|
||||
<ion-button *ngIf="canto()?.link_youtube && canto()?.link_youtube!.length > 5" fill="clear" (click)="openYoutube()">
|
||||
<ion-button *ngIf="connectivityService.isOnline() && canto()?.link_youtube && canto()?.link_youtube!.length > 5" fill="clear" (click)="openYoutube()">
|
||||
<ion-icon name="logo-youtube" color="danger"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
@@ -100,7 +100,7 @@
|
||||
|
||||
<ion-footer class="ion-no-border">
|
||||
<!-- Audio Player Toolbar -->
|
||||
<ion-toolbar class="global-player-toolbar glass" *ngIf="canto()?.link_youtube && canto()?.link_youtube!.length > 5 && !settingsService.fullscreenMode()">
|
||||
<ion-toolbar class="global-player-toolbar glass" *ngIf="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5 && !settingsService.fullscreenMode()">
|
||||
<div class="player-content">
|
||||
<div class="controls-row">
|
||||
<ion-button fill="clear" color="secondary" (click)="prevSong()" class="skip-btn">
|
||||
@@ -182,7 +182,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="group">
|
||||
<div class="group" *ngIf="connectivityService.isOnline()">
|
||||
<ion-button *ngIf="canto()?.link_youtube && canto()?.link_youtube!.length > 5" fill="clear" size="small" (click)="openYoutube()">
|
||||
<ion-icon slot="icon-only" name="logo-youtube" color="danger"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
@@ -208,19 +208,31 @@
|
||||
|
||||
.slim-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0 8px;
|
||||
padding: 4px 12px;
|
||||
overflow-x: auto;
|
||||
gap: 16px;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mic-btn-portrait {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 2px 6px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.page-indicator {
|
||||
|
||||
@@ -447,6 +447,9 @@ export class PlayerPage implements OnInit, AfterViewInit, OnDestroy {
|
||||
}
|
||||
|
||||
private initPlayer(id: string) {
|
||||
if (!this.youtubePlayerService.isPlayerSupported()) {
|
||||
return;
|
||||
}
|
||||
const startT = this.initialStartTime;
|
||||
this.initialStartTime = 0; // Reset
|
||||
|
||||
|
||||
@@ -59,13 +59,37 @@
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [checked]="settingsService.showEditor()" (ionChange)="settingsService.toggleEditor()" color="secondary" [disabled]="true"></ion-toggle>
|
||||
</ion-item>
|
||||
<ion-item class="transparent-item" lines="none" routerLink="/liturgia" detail="true" button>
|
||||
<ion-icon name="book-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
<h2 class="settings-item-title">Liturgia del Giorno</h2>
|
||||
<p class="settings-item-subtitle">Letture e suggerimenti canti</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</div>
|
||||
|
||||
<!-- Messa di Riferimento Selection -->
|
||||
<div class="settings-group glass ion-margin-bottom">
|
||||
<div class="group-header ion-padding-start ion-padding-top">
|
||||
<h2 class="outfit-font settings-group-title">
|
||||
Messa di Riferimento (Suggeriti)
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="select-item-container ion-padding-horizontal ion-padding-bottom">
|
||||
<div class="select-label-row">
|
||||
<ion-icon name="calendar-outline" color="secondary" class="select-icon"></ion-icon>
|
||||
<div class="select-text-group">
|
||||
<h2 class="settings-item-title outfit-font">Seleziona Messa</h2>
|
||||
<p class="settings-item-subtitle outfit-font">Scegli la messa per i canti consigliati</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="select-box-wrapper">
|
||||
<ion-select [value]="cantiLettureService.selectedMassDate()"
|
||||
(ionChange)="onMassChange($event)"
|
||||
interface="action-sheet"
|
||||
class="custom-select-block"
|
||||
placeholder="Seleziona una messa...">
|
||||
<ion-select-option *ngFor="let mass of cantiLettureService.availableMasses()" [value]="mass.date">
|
||||
{{ mass.formattedLabel }}
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Display Mode Section -->
|
||||
@@ -123,6 +147,95 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legenda Pulsanti Canto -->
|
||||
<div class="settings-group glass ion-margin-top">
|
||||
<div class="group-header ion-padding-start ion-padding-top">
|
||||
<h2 class="outfit-font settings-group-title">
|
||||
Legenda Pulsanti del Canto
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="legend-content ion-padding-horizontal ion-padding-bottom">
|
||||
<p class="legend-intro outfit-font">
|
||||
All'interno della schermata di lettura del canto trovi vari pulsanti per controllare la visualizzazione e lo scorrimento:
|
||||
</p>
|
||||
|
||||
<div class="legend-list">
|
||||
<div class="legend-item">
|
||||
<div class="legend-icon-wrapper">
|
||||
<ion-icon name="mic" color="danger"></ion-icon>
|
||||
</div>
|
||||
<div class="legend-text">
|
||||
<h4 class="outfit-font">Scroll Acustico (Karaoke)</h4>
|
||||
<p class="outfit-font">
|
||||
Attiva lo scorrimento vocale intelligente. L'app ascolta il canto o lo strumento e fa scorrere testo e accordi a tempo di musica, senza bisogno di toccare lo schermo. Uno slider verticale permette di regolare la sensibilità.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend-item">
|
||||
<div class="legend-icon-wrapper">
|
||||
<ion-icon name="musical-note" color="secondary"></ion-icon>
|
||||
</div>
|
||||
<div class="legend-text">
|
||||
<h4 class="outfit-font">Trasposizione Tonalità (+ / -)</h4>
|
||||
<p class="outfit-font">
|
||||
Disponibile in modalità "Accordi". Alza o abbassa la tonalità del canto di semitono in semitono, ricalcolando gli accordi all'istante.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend-item">
|
||||
<div class="legend-icon-wrapper">
|
||||
<ion-icon name="arrow-up-circle" color="secondary"></ion-icon>
|
||||
</div>
|
||||
<div class="legend-text">
|
||||
<h4 class="outfit-font">Riavvia Canto</h4>
|
||||
<p class="outfit-font">
|
||||
Riporta la visualizzazione all'inizio del testo e azzera il tracciamento vocale dello scroll acustico.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend-item">
|
||||
<div class="legend-icon-wrapper">
|
||||
<ion-icon name="chevron-back" color="secondary"></ion-icon>
|
||||
</div>
|
||||
<div class="legend-text">
|
||||
<h4 class="outfit-font">Navigazione Righe</h4>
|
||||
<p class="outfit-font">
|
||||
Permette di avanzare o tornare indietro manualmente tra le righe del testo del canto.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend-item">
|
||||
<div class="legend-icon-wrapper">
|
||||
<ion-icon name="add-circle-outline" color="secondary"></ion-icon>
|
||||
</div>
|
||||
<div class="legend-text">
|
||||
<h4 class="outfit-font">Zoom Caratteri</h4>
|
||||
<p class="outfit-font">
|
||||
Ingrandisce o rimpicciolisce il testo e gli accordi (puoi usare anche il gesto "pinch-to-zoom" sullo schermo).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="legend-item">
|
||||
<div class="legend-icon-wrapper">
|
||||
<ion-icon name="logo-youtube" color="danger"></ion-icon>
|
||||
</div>
|
||||
<div class="legend-text">
|
||||
<h4 class="outfit-font">YouTube Esterno</h4>
|
||||
<p class="outfit-font">
|
||||
Apre la traccia o il video ufficiale del canto direttamente sull'applicazione esterna di YouTube (quando online).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="credits-footer ion-padding-top ion-text-center">
|
||||
<p class="outfit-font email-text">
|
||||
info@canticristiani.it
|
||||
|
||||
@@ -176,3 +176,151 @@ ion-radio {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.select-item-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.select-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.select-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.select-text-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.settings-item-title {
|
||||
font-size: 1rem !important;
|
||||
font-weight: 600 !important;
|
||||
line-height: 1.2;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-item-subtitle {
|
||||
font-size: 0.85rem !important;
|
||||
margin-top: 2px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.select-box-wrapper {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 12px;
|
||||
padding: 4px 12px;
|
||||
transition: background 0.2s ease, border-color 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.custom-select-block {
|
||||
--placeholder-color: rgba(255, 255, 255, 0.4);
|
||||
--placeholder-opacity: 1;
|
||||
--color: var(--ion-color-secondary);
|
||||
color: var(--ion-color-secondary);
|
||||
font-family: 'Outfit', sans-serif;
|
||||
font-weight: 500;
|
||||
width: 100%;
|
||||
font-size: 0.95rem;
|
||||
|
||||
&::part(placeholder), &::part(text) {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(body.high-contrast) {
|
||||
.select-box-wrapper {
|
||||
background: rgba(0, 0, 0, 0.05) !important;
|
||||
border-color: rgba(0, 0, 0, 0.3) !important;
|
||||
}
|
||||
.custom-select-block {
|
||||
--color: black !important;
|
||||
color: black !important;
|
||||
}
|
||||
.legend-content {
|
||||
.legend-intro {
|
||||
color: #333333 !important;
|
||||
}
|
||||
.legend-item {
|
||||
.legend-icon-wrapper {
|
||||
background: rgba(0, 0, 0, 0.05) !important;
|
||||
border: 1px solid #cccccc;
|
||||
}
|
||||
.legend-text {
|
||||
h4 {
|
||||
color: #000000 !important;
|
||||
}
|
||||
p {
|
||||
color: #222222 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.legend-content {
|
||||
.legend-intro {
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: 16px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.legend-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
|
||||
.legend-icon-wrapper {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.legend-text {
|
||||
flex: 1;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 4px 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--ion-color-secondary);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
font-size: 0.82rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PlaylistService } from '../../services/playlist.service';
|
||||
import { Router } from '@angular/router';
|
||||
|
||||
import { MyCantiService } from '../../services/my-canti.service';
|
||||
import { CantiLettureService } from '../../services/canti-letture.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings',
|
||||
@@ -24,6 +25,7 @@ export class SettingsPage {
|
||||
public cantiService = inject(CantiService);
|
||||
public connectivityService = inject(ConnectivityService);
|
||||
public playlistService = inject(PlaylistService);
|
||||
public cantiLettureService = inject(CantiLettureService);
|
||||
private swUpdate = inject(SwUpdate);
|
||||
private toastCtrl = inject(ToastController);
|
||||
private modalCtrl = inject(ModalController);
|
||||
@@ -38,6 +40,10 @@ export class SettingsPage {
|
||||
this.settingsService.setShowChordsDefault(event.detail.value === 'chords');
|
||||
}
|
||||
|
||||
onMassChange(event: any) {
|
||||
this.cantiLettureService.setSelectedMass(event.detail.value);
|
||||
}
|
||||
|
||||
async fullRefresh() {
|
||||
// 1. Refresh JSON data
|
||||
this.cantiService.refresh();
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Injectable, signal, inject, effect } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Storage } from '@ionic/storage-angular';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
export interface Suggestion {
|
||||
id_canto: number;
|
||||
peso: number;
|
||||
}
|
||||
|
||||
export interface MassDetails {
|
||||
title: string;
|
||||
day: string;
|
||||
summary?: string;
|
||||
suggestions: { [moment: string]: Suggestion[] } | Suggestion[];
|
||||
}
|
||||
|
||||
export interface CantiLettureData {
|
||||
generated_at: string;
|
||||
week_start: string;
|
||||
week_end: string;
|
||||
masses: { [date: string]: MassDetails };
|
||||
}
|
||||
|
||||
export interface AvailableMass {
|
||||
date: string;
|
||||
title: string;
|
||||
day: string;
|
||||
formattedLabel: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class CantiLettureService {
|
||||
private http = inject(HttpClient);
|
||||
private storage = inject(Storage);
|
||||
|
||||
private _storage: Storage | null = null;
|
||||
public data = signal<CantiLettureData | null>(null);
|
||||
public selectedMassDate = signal<string | null>(null);
|
||||
public availableMasses = signal<AvailableMass[]>([]);
|
||||
public suggestionsMap = signal<Map<number, number>>(new Map()); // id_canto -> peso
|
||||
public suggestionsMomentsMap = signal<Map<number, string[]>>(new Map()); // id_canto -> moments[]
|
||||
|
||||
private JSON_URL = 'http://185.193.67.105:3000/cantiletture.json';
|
||||
private SECURE_JSON_URL = '/api/cantiletture.json';
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
|
||||
// Effect to auto-update suggestions maps when selectedMassDate or data changes
|
||||
effect(() => {
|
||||
const activeDate = this.selectedMassDate();
|
||||
const currentData = this.data();
|
||||
const newMap = new Map<number, number>();
|
||||
const newMomentsMap = new Map<number, string[]>();
|
||||
|
||||
if (activeDate && currentData && currentData.masses[activeDate]) {
|
||||
const mass = currentData.masses[activeDate];
|
||||
if (mass.suggestions) {
|
||||
if (Array.isArray(mass.suggestions)) {
|
||||
// Legacy flat suggestions format
|
||||
mass.suggestions.forEach(s => {
|
||||
if (s && s.id_canto) {
|
||||
newMap.set(s.id_canto, s.peso);
|
||||
newMomentsMap.set(s.id_canto, ['Generale']);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// New moment-grouped suggestions format
|
||||
Object.keys(mass.suggestions).forEach(moment => {
|
||||
const momentSuggestions = (mass.suggestions as any)[moment];
|
||||
if (Array.isArray(momentSuggestions)) {
|
||||
momentSuggestions.forEach(s => {
|
||||
if (s && s.id_canto) {
|
||||
// Save max weight
|
||||
const existingWeight = newMap.get(s.id_canto) || 0;
|
||||
if (s.peso > existingWeight) {
|
||||
newMap.set(s.id_canto, s.peso);
|
||||
}
|
||||
|
||||
// Save liturgical moment
|
||||
const existingMoments = newMomentsMap.get(s.id_canto) || [];
|
||||
if (!existingMoments.includes(moment)) {
|
||||
newMomentsMap.set(s.id_canto, [...existingMoments, moment]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
this.suggestionsMap.set(newMap);
|
||||
this.suggestionsMomentsMap.set(newMomentsMap);
|
||||
});
|
||||
|
||||
// Effect to persist selectedMassDate to local storage
|
||||
effect(() => {
|
||||
const activeDate = this.selectedMassDate();
|
||||
if (activeDate) {
|
||||
localStorage.setItem('selected-mass-date', activeDate);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async init() {
|
||||
// We assume CantiService has already created the storage, but to be safe:
|
||||
this._storage = await this.storage.create();
|
||||
|
||||
// Load cached values
|
||||
const cachedData = await this._storage?.get('cantiletture_data');
|
||||
if (cachedData) {
|
||||
this.processData(cachedData);
|
||||
}
|
||||
|
||||
const savedDate = localStorage.getItem('selected-mass-date');
|
||||
if (savedDate) {
|
||||
this.selectedMassDate.set(savedDate);
|
||||
}
|
||||
|
||||
// Fetch fresh data
|
||||
await this.fetchData();
|
||||
}
|
||||
|
||||
async fetchData() {
|
||||
try {
|
||||
let fetchedData: CantiLettureData | null = null;
|
||||
|
||||
const isProduction = window.location.hostname.includes('canticristiani.it');
|
||||
const primaryUrl = isProduction ? `${window.location.origin}${this.SECURE_JSON_URL}` : this.JSON_URL;
|
||||
const fallbackUrl = isProduction ? this.JSON_URL : `${window.location.origin}${this.SECURE_JSON_URL}`;
|
||||
|
||||
try {
|
||||
console.log('Fetching mass data from primary URL:', primaryUrl);
|
||||
fetchedData = await firstValueFrom(this.http.get<CantiLettureData>(`${primaryUrl}?t=${Date.now()}`));
|
||||
} catch (err) {
|
||||
console.warn('Primary fetch failed, trying fallback URL...', fallbackUrl, err);
|
||||
try {
|
||||
fetchedData = await firstValueFrom(this.http.get<CantiLettureData>(`${fallbackUrl}?t=${Date.now()}`));
|
||||
} catch (fallbackErr) {
|
||||
console.error('Fallback fetch also failed:', fallbackErr);
|
||||
}
|
||||
}
|
||||
|
||||
if (fetchedData && fetchedData.masses) {
|
||||
this.processData(fetchedData);
|
||||
await this._storage?.set('cantiletture_data', fetchedData);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch cantiletture.json from both primary and fallback URLs', err);
|
||||
}
|
||||
}
|
||||
|
||||
private processData(json: CantiLettureData) {
|
||||
this.data.set(json);
|
||||
|
||||
// Build available masses array
|
||||
const massesList: AvailableMass[] = [];
|
||||
const keys = Object.keys(json.masses).sort(); // Sort chronological
|
||||
|
||||
keys.forEach(key => {
|
||||
const mass = json.masses[key];
|
||||
const parts = key.split('-');
|
||||
let formattedLabel = key;
|
||||
if (parts.length === 3) {
|
||||
formattedLabel = `${parts[2]}/${parts[1]}`;
|
||||
}
|
||||
const day = mass.day || '';
|
||||
const title = mass.title || '';
|
||||
|
||||
massesList.push({
|
||||
date: key,
|
||||
title: title,
|
||||
day: day,
|
||||
formattedLabel: `${day} ${formattedLabel}`
|
||||
});
|
||||
});
|
||||
|
||||
this.availableMasses.set(massesList);
|
||||
|
||||
// If no mass is currently selected, select the first one (or today's mass if exists)
|
||||
if (!this.selectedMassDate() && massesList.length > 0) {
|
||||
// Try to find today's date
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
const match = massesList.find(m => m.date === todayStr);
|
||||
if (match) {
|
||||
this.selectedMassDate.set(match.date);
|
||||
} else {
|
||||
// Find the first date that is today or in the future
|
||||
const futureMatch = massesList.find(m => m.date >= todayStr);
|
||||
if (futureMatch) {
|
||||
this.selectedMassDate.set(futureMatch.date);
|
||||
} else {
|
||||
this.selectedMassDate.set(massesList[0].date);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setSelectedMass(date: string) {
|
||||
this.selectedMassDate.set(date);
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,11 @@ export interface Indice {
|
||||
type: 'liturgico' | 'tematico';
|
||||
}
|
||||
|
||||
export interface CantoEseguito {
|
||||
id_canti: number;
|
||||
num: number;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@@ -31,6 +36,7 @@ export class CantiService {
|
||||
public canti = signal<Canto[]>([]);
|
||||
public indiceLiturgico = signal<Indice[]>([]);
|
||||
public indiceTematico = signal<Indice[]>([]);
|
||||
public cantiEseguiti = signal<CantoEseguito[]>([]);
|
||||
// Keep momenti for backward compatibility or temporary usage
|
||||
public momenti = signal<Indice[]>([]);
|
||||
public loading = signal<boolean>(false);
|
||||
@@ -53,9 +59,11 @@ export class CantiService {
|
||||
const cachedCanti = await this._storage?.get('canti');
|
||||
const cachedLit = await this._storage?.get('indiceLiturgico');
|
||||
const cachedTem = await this._storage?.get('indiceTematico');
|
||||
const cachedEseguiti = await this._storage?.get('cantiEseguiti');
|
||||
if (cachedCanti) this.canti.set(cachedCanti);
|
||||
if (cachedLit) this.indiceLiturgico.set(cachedLit);
|
||||
if (cachedTem) this.indiceTematico.set(cachedTem);
|
||||
if (cachedEseguiti) this.cantiEseguiti.set(cachedEseguiti);
|
||||
// Sync momenti
|
||||
if (cachedLit) this.momenti.set(cachedLit);
|
||||
}
|
||||
@@ -106,14 +114,18 @@ export class CantiService {
|
||||
id_momenti: pivotMap.get(c.id_canti) || []
|
||||
}));
|
||||
|
||||
const eseguitiData = response.canti_eseguiti?.data || [];
|
||||
|
||||
this.canti.set(linkedCanti);
|
||||
this.indiceLiturgico.set(litData);
|
||||
this.indiceTematico.set(temData);
|
||||
this.momenti.set(litData); // Fallback
|
||||
this.cantiEseguiti.set(eseguitiData);
|
||||
|
||||
await this._storage?.set('canti', linkedCanti);
|
||||
await this._storage?.set('indiceLiturgico', litData);
|
||||
await this._storage?.set('indiceTematico', temData);
|
||||
await this._storage?.set('cantiEseguiti', eseguitiData);
|
||||
}
|
||||
this.progress.set(100);
|
||||
this.loading.set(false);
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
import { Injectable, inject, signal } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { CantiService, Canto } from './canti.service';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
export interface LiturgyReadings {
|
||||
date: string;
|
||||
primaLettura?: string;
|
||||
salmo?: string;
|
||||
secondaLettura?: string;
|
||||
vangelo?: string;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class LiturgyService {
|
||||
private http = inject(HttpClient);
|
||||
private cantiService = inject(CantiService);
|
||||
|
||||
public readings = signal<LiturgyReadings | null>(null);
|
||||
public suggestions = signal<Canto[]>([]);
|
||||
public loading = signal<boolean>(false);
|
||||
|
||||
public selectedDate = signal<string>(this.getNextSunday());
|
||||
public keywords = signal<string[]>([]);
|
||||
public groupedSuggestions = signal<{moment: string, canti: Canto[]}[]>([]);
|
||||
|
||||
private PROXY_URL = 'https://corsproxy.io/?';
|
||||
private RSS_URL = 'https://liturgia.silvestrini.org/rss';
|
||||
|
||||
private getNextSunday(): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + (7 - d.getDay()) % 7);
|
||||
if (d.getDay() === 0 && new Date().getDay() === 0) {
|
||||
// Keep today if today is Sunday
|
||||
} else if (d.getDay() === 0) {
|
||||
// already sunday
|
||||
} else {
|
||||
d.setDate(d.getDate() + (7 - d.getDay()));
|
||||
}
|
||||
return d.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
prevDay() {
|
||||
const d = new Date(this.selectedDate());
|
||||
d.setDate(d.getDate() - 1);
|
||||
this.fetchTodayLiturgy(d.toISOString().split('T')[0]);
|
||||
}
|
||||
|
||||
nextDay() {
|
||||
const d = new Date(this.selectedDate());
|
||||
d.setDate(d.getDate() + 1);
|
||||
this.fetchTodayLiturgy(d.toISOString().split('T')[0]);
|
||||
}
|
||||
|
||||
async fetchTodayLiturgy(date?: string) {
|
||||
if (date) this.selectedDate.set(date);
|
||||
const targetDate = this.selectedDate();
|
||||
|
||||
this.loading.set(true);
|
||||
this.suggestions.set([]);
|
||||
this.groupedSuggestions.set([]);
|
||||
this.keywords.set([]);
|
||||
|
||||
try {
|
||||
let xmlText = '';
|
||||
try {
|
||||
xmlText = await firstValueFrom(this.http.get(this.PROXY_URL + encodeURIComponent(this.RSS_URL), { responseType: 'text' }));
|
||||
} catch (e) {
|
||||
const fallbackProxy = 'https://api.allorigins.win/raw?url=';
|
||||
xmlText = await firstValueFrom(this.http.get(fallbackProxy + encodeURIComponent(this.RSS_URL), { responseType: 'text' }));
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(xmlText, 'text/xml');
|
||||
const items = Array.from(xmlDoc.querySelectorAll('item'));
|
||||
|
||||
const lettureItem = items.find(item => {
|
||||
const title = item.querySelector('title')?.textContent || '';
|
||||
const category = item.querySelector('category')?.textContent || '';
|
||||
return title.startsWith(targetDate) && category === 'letture';
|
||||
});
|
||||
|
||||
if (lettureItem) {
|
||||
const description = lettureItem.querySelector('description')?.textContent || '';
|
||||
this.readings.set(this.parseDescription(description, targetDate));
|
||||
} else {
|
||||
this.readings.set(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch liturgy', err);
|
||||
this.readings.set(null);
|
||||
} finally {
|
||||
this.loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
private parseDescription(html: string, date: string): LiturgyReadings {
|
||||
const readings: LiturgyReadings = { date };
|
||||
|
||||
const parts = html.split(/([A-Z\s]+:)/);
|
||||
|
||||
let currentLabel = '';
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed.endsWith(':')) {
|
||||
currentLabel = trimmed.toUpperCase();
|
||||
} else if (trimmed) {
|
||||
if (currentLabel.includes('PRIMA LETTURA')) readings.primaLettura = trimmed;
|
||||
else if (currentLabel.includes('SALMO')) readings.salmo = trimmed;
|
||||
else if (currentLabel.includes('SECONDA LETTURA')) readings.secondaLettura = trimmed;
|
||||
else if (currentLabel.includes('VANGELO')) readings.vangelo = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return readings;
|
||||
}
|
||||
|
||||
suggestSongs() {
|
||||
const currentReadings = this.readings();
|
||||
if (!currentReadings) return;
|
||||
|
||||
const allText = [
|
||||
currentReadings.primaLettura,
|
||||
currentReadings.salmo,
|
||||
currentReadings.secondaLettura,
|
||||
currentReadings.vangelo
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
const stopWords = new Set(['il', 'lo', 'la', 'i', 'gli', 'le', 'di', 'a', 'da', 'in', 'con', 'su', 'per', 'tra', 'fra', 'e', 'o', 'che', 'non', 'si', 'del', 'al', 'dal', 'nel', 'col', 'sul', 'mio', 'tuo', 'suo', 'noi', 'voi', 'loro', 'mio', 'mia', 'miei', 'mie']);
|
||||
const words = allText.match(/\b(\w+)\b/g) || [];
|
||||
const keywords = Array.from(new Set(words.filter(w => w.length > 3 && !stopWords.has(w))));
|
||||
this.keywords.set(keywords.slice(0, 20));
|
||||
|
||||
const songs = this.cantiService.canti();
|
||||
const scoredSongs = songs.map(song => {
|
||||
let score = 0;
|
||||
const titleLower = song.titolo.toLowerCase();
|
||||
const lyricsLower = (song.testo || '').toLowerCase();
|
||||
|
||||
keywords.forEach(kw => {
|
||||
if (titleLower.includes(kw)) score += 10;
|
||||
const regex = new RegExp(`\\b${kw}\\b`, 'g');
|
||||
const matches = lyricsLower.match(regex);
|
||||
if (matches) score += matches.length;
|
||||
});
|
||||
|
||||
return { song, score };
|
||||
});
|
||||
|
||||
const results = scoredSongs
|
||||
.filter(s => s.score > 0)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
const moments = this.cantiService.indiceLiturgico();
|
||||
const grouped: {moment: string, canti: Canto[]}[] = [];
|
||||
|
||||
moments.forEach(m => {
|
||||
const cantiInMoment = results
|
||||
.filter(s => s.song.id_momenti?.includes(m.id))
|
||||
.slice(0, 3)
|
||||
.map(s => s.song);
|
||||
|
||||
if (cantiInMoment.length > 0) {
|
||||
grouped.push({ moment: m.tag_name, canti: cantiInMoment });
|
||||
}
|
||||
});
|
||||
|
||||
this.groupedSuggestions.set(grouped);
|
||||
this.suggestions.set(results.slice(0, 20).map(s => s.song));
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import { Injectable, signal, effect } from '@angular/core';
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ThemeService {
|
||||
public highContrast = signal<boolean>(false);
|
||||
public highContrast = signal<boolean>(true);
|
||||
|
||||
constructor() {
|
||||
// Load from localStorage
|
||||
const saved = localStorage.getItem('high-contrast');
|
||||
if (saved === 'true') {
|
||||
if (saved === 'false') {
|
||||
this.highContrast.set(false);
|
||||
} else {
|
||||
this.highContrast.set(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Injectable, signal, effect, inject } from '@angular/core';
|
||||
import { Injectable, signal, effect, inject, computed } from '@angular/core';
|
||||
import { CantiService } from './canti.service';
|
||||
import { SettingsService } from './settings.service';
|
||||
import { MediaSessionService } from './media-session.service';
|
||||
import { MyCantiService } from './my-canti.service';
|
||||
import { ConnectivityService } from './connectivity.service';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -12,6 +13,21 @@ export class YoutubePlayerService {
|
||||
private settingsService = inject(SettingsService);
|
||||
private mediaSessionService = inject(MediaSessionService);
|
||||
private myCantiService = inject(MyCantiService);
|
||||
private connectivityService = inject(ConnectivityService);
|
||||
|
||||
public isPlayerSupported = computed<boolean>(() => {
|
||||
// Check if offline
|
||||
if (!this.connectivityService.isOnline()) {
|
||||
return false;
|
||||
}
|
||||
// Check if iPhone/iPad/iPod
|
||||
const userAgent = window.navigator.userAgent.toLowerCase();
|
||||
const isIOS = /iphone|ipad|ipod/.test(userAgent);
|
||||
if (isIOS) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
public currentCantoId = signal<string | null>(null);
|
||||
public isPlaying = signal<boolean>(false);
|
||||
@@ -32,6 +48,14 @@ export class YoutubePlayerService {
|
||||
this.setupMediaSession();
|
||||
this.initSilentAudio();
|
||||
this.setupBackgroundPersistence();
|
||||
|
||||
// Auto-stop player when not supported (e.g. going offline)
|
||||
effect(() => {
|
||||
const supported = this.isPlayerSupported();
|
||||
if (!supported && this.currentCantoId()) {
|
||||
this.stop();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private initSilentAudio() {
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const VERSION = '2026.05.16.1840';
|
||||
export const VERSION = '2026.05.18.0125';
|
||||
|
||||
@@ -354,3 +354,52 @@ body.high-contrast .offline-thumb {
|
||||
.outfit-font {
|
||||
font-family: 'Outfit', sans-serif;
|
||||
}
|
||||
|
||||
/* Ensure high visibility of Action Sheet options in Dark Mode and High Contrast */
|
||||
ion-action-sheet {
|
||||
--background: #1e1e1e !important;
|
||||
--color: #ffffff !important;
|
||||
|
||||
button {
|
||||
color: #ffffff !important;
|
||||
--color: #ffffff !important;
|
||||
}
|
||||
|
||||
.action-sheet-button {
|
||||
color: #ffffff !important;
|
||||
--color: #ffffff !important;
|
||||
|
||||
&.action-sheet-selected {
|
||||
color: var(--ion-color-secondary) !important;
|
||||
--color: var(--ion-color-secondary) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.action-sheet-title {
|
||||
color: rgba(255, 255, 255, 0.7) !important;
|
||||
}
|
||||
}
|
||||
|
||||
body.high-contrast ion-action-sheet {
|
||||
--background: #ffffff !important;
|
||||
--color: #000000 !important;
|
||||
|
||||
button {
|
||||
color: #000000 !important;
|
||||
--color: #000000 !important;
|
||||
}
|
||||
|
||||
.action-sheet-button {
|
||||
color: #000000 !important;
|
||||
--color: #000000 !important;
|
||||
|
||||
&.action-sheet-selected {
|
||||
color: #e67e22 !important;
|
||||
--color: #e67e22 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.action-sheet-title {
|
||||
color: rgba(0, 0, 0, 0.7) !important;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user