Refactor: Restringimento drag & drop, legenda impostazioni, modalità offline e rimozione sezione liturgia

This commit is contained in:
David Frassi
2026-05-18 01:36:18 +02:00
parent 9982441c3c
commit 961b7ba65c
22 changed files with 988 additions and 500 deletions
+43
View File
@@ -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"
-4
View 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({
+74 -11
View File
@@ -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">
<div class="offline-thumb">
<ion-icon name="cloud-offline-outline"></ion-icon>
</div>
<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
View File
@@ -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;
}
}
+136 -12
View File
@@ -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()) {
this.isAddingSongs.set(true);
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) {
-22
View File
@@ -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 {}
-71
View File
@@ -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>
-144
View File
@@ -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;
}
}
}
}
-42
View File
@@ -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'
});
}
}
+4 -4
View File
@@ -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>
+15 -3
View File
@@ -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 {
+3
View File
@@ -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
+120 -7
View File
@@ -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
+148
View File
@@ -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;
}
}
}
}
+6
View File
@@ -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();
+205
View File
@@ -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);
}
}
+12
View File
@@ -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);
-173
View File
@@ -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 -2
View File
@@ -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);
}
+25 -1
View File
@@ -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
View File
@@ -1 +1 @@
export const VERSION = '2026.05.16.1840';
export const VERSION = '2026.05.18.0125';
+49
View File
@@ -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;
}
}