fix: risoluzione importazione playlist tramite link di condivisione e fix installabilità PWA su Android
@@ -19,21 +19,31 @@ export class AppComponent {
|
||||
|
||||
private setupUpdates() {
|
||||
if (this.swUpdate.isEnabled) {
|
||||
// 1. Check for updates immediately as soon as the application stabilizes
|
||||
const appIsStable$ = this.appRef.isStable.pipe(
|
||||
first(isStable => isStable === true)
|
||||
);
|
||||
// Wait for the application to stabilize before running update checks or starting intervals
|
||||
this.appRef.isStable.pipe(
|
||||
filter(stable => stable),
|
||||
first()
|
||||
).subscribe(() => {
|
||||
console.log('[PWA-Update] App is stable. Initializing update checks...');
|
||||
|
||||
// 1. Check for updates immediately
|
||||
this.swUpdate.checkForUpdate().catch(err => {
|
||||
console.warn('[PWA-Update] Failed immediate startup update check:', err);
|
||||
});
|
||||
|
||||
appIsStable$.subscribe(async () => {
|
||||
console.log('[PWA-Update] App is stable, checking for PWA updates immediately...');
|
||||
try {
|
||||
await this.swUpdate.checkForUpdate();
|
||||
} catch (err) {
|
||||
console.warn('[PWA-Update] Failed startup update check:', err);
|
||||
}
|
||||
// 2. Periodic check in background every 30 seconds
|
||||
const every30Seconds$ = interval(30 * 1000);
|
||||
every30Seconds$.subscribe(async () => {
|
||||
console.log('[PWA-Update] Periodic check for updates (every 30s)...');
|
||||
try {
|
||||
await this.swUpdate.checkForUpdate();
|
||||
} catch (err) {
|
||||
console.warn('[PWA-Update] Failed periodic update check:', err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 2. Check for updates when the app is resumed/focused
|
||||
// 3. Check for updates when the app is resumed/focused
|
||||
fromEvent(document, 'visibilitychange')
|
||||
.pipe(filter(() => document.visibilityState === 'visible'))
|
||||
.subscribe(async () => {
|
||||
@@ -45,17 +55,6 @@ export class AppComponent {
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Periodic check in background every 5 minutes
|
||||
const everyFiveMinutes$ = interval(5 * 60 * 1000);
|
||||
everyFiveMinutes$.subscribe(async () => {
|
||||
console.log('[PWA-Update] Periodic check for updates...');
|
||||
try {
|
||||
await this.swUpdate.checkForUpdate();
|
||||
} catch (err) {
|
||||
console.warn('[PWA-Update] Failed periodic update check:', err);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Activate update and reload when a new version is ready
|
||||
this.swUpdate.versionUpdates
|
||||
.pipe(filter((evt): evt is VersionReadyEvent => evt.type === 'VERSION_READY'))
|
||||
|
||||
@@ -20,8 +20,7 @@ import { ServiceWorkerModule } from '@angular/service-worker';
|
||||
IonicStorageModule.forRoot(),
|
||||
ServiceWorkerModule.register('ngsw-worker.js', {
|
||||
enabled: !isDevMode(),
|
||||
// Register the ServiceWorker as soon as the application is stable
|
||||
// or after 30 seconds (whichever comes first).
|
||||
// Register the ServiceWorker as soon as possible
|
||||
registrationStrategy: 'registerImmediately'
|
||||
})
|
||||
],
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="header-logo-wrapper">
|
||||
<img src="assets/icon/favicon.png" class="header-logo">
|
||||
<div class="header-text-group">
|
||||
<span class="app-name">CantiCristiani</span>
|
||||
<span class="app-name">{{ appName }}</span>
|
||||
<span class="version-badge">v{{ version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,6 +192,29 @@
|
||||
(click)="onInteraction()">
|
||||
|
||||
<div class="ion-padding no-padding-top">
|
||||
<!-- Premium PWA Android Install Banner -->
|
||||
<div class="pwa-android-banner glass ion-margin-bottom" *ngIf="showAndroidBanner()">
|
||||
<div class="banner-inner">
|
||||
<div class="banner-icon-wrapper">
|
||||
<ion-icon name="cloud-download-outline" class="banner-icon"></ion-icon>
|
||||
</div>
|
||||
<div class="banner-text-wrapper">
|
||||
<h3 class="banner-title outfit-font">Installa la nostra App!</h3>
|
||||
<p class="banner-subtitle outfit-font">
|
||||
Accedi a tutti i canti all'istante, anche offline, direttamente dalla tua home screen.
|
||||
</p>
|
||||
</div>
|
||||
<div class="banner-actions">
|
||||
<ion-button fill="solid" size="small" color="secondary" class="install-action-btn outfit-font" (click)="installAndroidPwa()">
|
||||
Installa
|
||||
</ion-button>
|
||||
<ion-button fill="clear" size="small" class="dismiss-action-btn" (click)="dismissAndroidBanner($event)">
|
||||
<ion-icon name="close-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="cantiService.loading() && filteredCanti().length === 0" class="ion-text-center ion-padding loading-container">
|
||||
<div class="loading-wrapper">
|
||||
<ion-spinner name="crescent" color="secondary"></ion-spinner>
|
||||
@@ -394,3 +417,21 @@
|
||||
</div>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
|
||||
<!-- Premium PWA iOS Tooltip -->
|
||||
<div class="pwa-ios-tooltip-wrapper" [class.has-player]="youtubePlayerService.currentCantoId()" *ngIf="showIosTooltip()">
|
||||
<div class="pwa-ios-tooltip glass">
|
||||
<div class="tooltip-header">
|
||||
<span class="tooltip-title outfit-font">Installa su iPhone</span>
|
||||
<ion-button fill="clear" size="small" class="dismiss-btn" (click)="dismissIosTooltip($event)">
|
||||
<ion-icon name="close-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
<div class="tooltip-body">
|
||||
<p class="tooltip-text outfit-font">
|
||||
Tocca il pulsante Condividi <span class="safari-icon-inline"><ion-icon name="share-outline"></ion-icon></span> in basso e seleziona <strong>"Aggiungi a schermata Home"</strong>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="tooltip-arrow"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -953,3 +953,323 @@ ion-title {
|
||||
color: #c0392b !important;
|
||||
border-color: #c0392b !important;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
PWA INSTALLATION PROMOTIONS (Android & iOS)
|
||||
========================================================================== */
|
||||
|
||||
/* 1. Android Installation Banner */
|
||||
.pwa-android-banner {
|
||||
--background: rgba(255, 255, 255, 0.04);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-left: 4px solid var(--ion-color-secondary);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
animation: slideInBanner 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.banner-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.banner-icon-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(var(--ion-color-secondary-rgb), 0.12);
|
||||
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.2);
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.banner-icon {
|
||||
font-size: 1.5rem;
|
||||
color: var(--ion-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.banner-text-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
overflow: hidden;
|
||||
|
||||
.banner-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
margin: 0;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.banner-subtitle {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.35;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
|
||||
.banner-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
|
||||
.install-action-btn {
|
||||
--border-radius: 10px;
|
||||
--box-shadow: 0 4px 10px rgba(var(--ion-color-secondary-rgb), 0.2);
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
text-transform: none;
|
||||
letter-spacing: 0.2px;
|
||||
transition: transform 0.2s ease;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
.dismiss-action-btn {
|
||||
--color: rgba(255, 255, 255, 0.5);
|
||||
--padding-start: 4px;
|
||||
--padding-end: 4px;
|
||||
margin: 0;
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 2. iOS Safari Contextual Tooltip */
|
||||
.pwa-ios-tooltip-wrapper {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 10000;
|
||||
width: 90%;
|
||||
max-width: 385px;
|
||||
pointer-events: none;
|
||||
filter: drop-shadow(0 12px 36px rgba(0, 0, 0, 0.45));
|
||||
transition: bottom 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&.has-player {
|
||||
bottom: 84px; /* Float above active bottom player footer */
|
||||
}
|
||||
|
||||
.pwa-ios-tooltip {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: rgba(26, 26, 46, 0.96) !important;
|
||||
backdrop-filter: blur(25px);
|
||||
-webkit-backdrop-filter: blur(25px);
|
||||
border: 1.5px solid rgba(var(--ion-color-secondary-rgb), 0.25);
|
||||
border-radius: 20px;
|
||||
padding: 14px 16px;
|
||||
position: relative;
|
||||
box-shadow: inset 0 0 20px rgba(255, 255, 255, 0.02);
|
||||
animation: floatTooltip 0.45s cubic-bezier(0.175, 0.885, 0.32, 1.275), bounceGently 3s ease-in-out infinite;
|
||||
|
||||
.tooltip-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
|
||||
.tooltip-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: var(--ion-color-secondary);
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.dismiss-btn {
|
||||
--color: rgba(255, 255, 255, 0.45);
|
||||
--padding-start: 4px;
|
||||
--padding-end: 4px;
|
||||
margin: 0;
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip-body {
|
||||
.tooltip-text {
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
margin: 0;
|
||||
font-weight: 400;
|
||||
|
||||
strong {
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.safari-icon-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
padding: 3px;
|
||||
border-radius: 6px;
|
||||
vertical-align: middle;
|
||||
margin: 0 3px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
|
||||
ion-icon {
|
||||
font-size: 0.95rem;
|
||||
color: #007aff; /* Apple System Blue */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tooltip-arrow {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 12px solid transparent;
|
||||
border-right: 12px solid transparent;
|
||||
border-top: 12px solid rgba(26, 26, 46, 0.96);
|
||||
position: absolute;
|
||||
bottom: -11px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 12px solid transparent;
|
||||
border-right: 12px solid transparent;
|
||||
border-top: 12px solid rgba(var(--ion-color-secondary-rgb), 0.25);
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: -12px;
|
||||
z-index: -1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes slideInBanner {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes floatTooltip {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 20px) scale(0.95);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounceGently {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
}
|
||||
|
||||
/* High Contrast mode adaptations */
|
||||
:host-context(body.high-contrast) {
|
||||
.pwa-android-banner {
|
||||
background: #ffffff !important;
|
||||
border: 2px solid #000000 !important;
|
||||
border-left-width: 6px !important;
|
||||
|
||||
.banner-title, .banner-subtitle {
|
||||
color: #000000 !important;
|
||||
}
|
||||
.banner-icon {
|
||||
color: #000000 !important;
|
||||
}
|
||||
.banner-icon-wrapper {
|
||||
background: rgba(0, 0, 0, 0.05) !important;
|
||||
border-color: #000000 !important;
|
||||
}
|
||||
.install-action-btn {
|
||||
--background: #000000 !important;
|
||||
--color: #ffffff !important;
|
||||
}
|
||||
.dismiss-action-btn {
|
||||
--color: #000000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.pwa-ios-tooltip {
|
||||
background: #ffffff !important;
|
||||
border: 2px solid #000000 !important;
|
||||
|
||||
.tooltip-title, .tooltip-text, .tooltip-text strong {
|
||||
color: #000000 !important;
|
||||
}
|
||||
.dismiss-btn {
|
||||
--color: #000000 !important;
|
||||
}
|
||||
.tooltip-arrow {
|
||||
border-top-color: #ffffff !important;
|
||||
&::after {
|
||||
border-top-color: #000000 !important;
|
||||
}
|
||||
}
|
||||
.safari-icon-inline {
|
||||
background: rgba(0, 0, 0, 0.05) !important;
|
||||
border-color: #000000 !important;
|
||||
ion-icon {
|
||||
color: #000000 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import { MyCantiService } from '../services/my-canti.service';
|
||||
import { QrScannerComponent } from '../components/qr-scanner/qr-scanner.component';
|
||||
import { CantiLettureService } from '../services/canti-letture.service';
|
||||
import { ComunitaService } from '../services/comunita.service';
|
||||
import { environment } from '../../environments/environment';
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
@@ -33,10 +34,14 @@ export class HomePage implements OnDestroy {
|
||||
public activeFilterType = signal<'liturgico' | 'tematico' | 'playlist' | null>(null);
|
||||
public loadedThumbs = new Set<string>();
|
||||
public version = VERSION;
|
||||
public appName = environment.appName;
|
||||
public isAddingSongs = signal<boolean>(false);
|
||||
public reorderList = signal<any[]>([]);
|
||||
public limit = signal<number>(30);
|
||||
|
||||
public showAndroidBanner = signal<boolean>(false);
|
||||
public showIosTooltip = signal<boolean>(false);
|
||||
|
||||
public fontSize = signal<number>(1.0);
|
||||
public youtubePlayerService = inject(YoutubePlayerService);
|
||||
public comunitaService = inject(ComunitaService);
|
||||
@@ -110,23 +115,28 @@ export class HomePage implements OnDestroy {
|
||||
const onlyMine = this.showOnlyMine();
|
||||
const topTen = this.showTopTen();
|
||||
const suggeriti = this.showSuggeriti();
|
||||
|
||||
let list = [...this.cantiService.canti(), ...this.myCantiService.myCanti()];
|
||||
const activeIds = this.playlistService.activeListIds();
|
||||
const selectionMode = this.playlistService.selectionMode();
|
||||
|
||||
// Filter by Community if code is set and filter is active
|
||||
// 1. Get base list depending on community filter
|
||||
let list: any[] = [];
|
||||
const comunitaCode = this.comunitaService.comunitaCode();
|
||||
const comunitaIds = this.comunitaService.comunitaCantiIds();
|
||||
|
||||
if (comunitaCode && this.comunitaService.isFilterActive()) {
|
||||
// Include parish custom/unvalidated canti
|
||||
list = [...list, ...this.comunitaService.comunitaCantiPersonali()];
|
||||
|
||||
// Base is standard canti + my canti + community custom canti
|
||||
list = [
|
||||
...this.cantiService.canti(),
|
||||
...this.myCantiService.myCanti(),
|
||||
...this.comunitaService.comunitaCantiPersonali()
|
||||
];
|
||||
|
||||
// Filter to only community canti
|
||||
list = list.filter(c => {
|
||||
return comunitaIds.includes(c.id_canti) || comunitaIds.includes(c.id);
|
||||
});
|
||||
|
||||
// Deduplicate: if a song exists in both validated and unvalidated lists,
|
||||
// the NON-VALIDATED (parish-customized) version always takes precedence,
|
||||
// so parishes can always personalize their songs even if never validated.
|
||||
// Deduplicate: parish-customized versions take precedence
|
||||
const seen = new Map<number | string, any>();
|
||||
for (const canto of list) {
|
||||
const key = canto.id_canti || canto.id;
|
||||
@@ -134,16 +144,14 @@ export class HomePage implements OnDestroy {
|
||||
if (!existing) {
|
||||
seen.set(key, canto);
|
||||
} else {
|
||||
// Precedence to non-validated (parish custom) version:
|
||||
// overwrite only if current entry is validated and new one is non-validated
|
||||
if (!existing.nonValidato && canto.nonValidato) {
|
||||
seen.set(key, canto);
|
||||
}
|
||||
}
|
||||
}
|
||||
list = Array.from(seen.values());
|
||||
|
||||
// Sort list by community song number (num_canto) ASC!
|
||||
|
||||
// Sort by community song number (num_canto)
|
||||
const cantiInfo = this.comunitaService.comunitaCantiInfo();
|
||||
list.sort((a, b) => {
|
||||
const infoA = cantiInfo.find(x => x.id_canti === a.id_canti || x.id_canti === Number(a.id));
|
||||
@@ -152,49 +160,77 @@ export class HomePage implements OnDestroy {
|
||||
const numB = infoB ? Number(infoB.num_canto) : 999999;
|
||||
return numA - numB;
|
||||
});
|
||||
} else {
|
||||
// General context: only standard canti + my canti
|
||||
list = [...this.cantiService.canti(), ...this.myCantiService.myCanti()];
|
||||
}
|
||||
|
||||
if (onlyMine) {
|
||||
list = this.myCantiService.myCanti();
|
||||
// 2. Cumulative filtering
|
||||
|
||||
// Filter by selectionMode / reorderList if in selection mode and not adding songs
|
||||
if (selectionMode && !this.isAddingSongs()) {
|
||||
return this.reorderList();
|
||||
}
|
||||
|
||||
|
||||
// A. Filter by Playlist (if activeIds is present)
|
||||
if (activeIds.length > 0 && !selectionMode) {
|
||||
list = activeIds
|
||||
.map(id => list.find(c => c.id === id))
|
||||
.filter((c): c is any => !!c);
|
||||
}
|
||||
|
||||
// B. Filter by "Only Mine" (Miei)
|
||||
if (onlyMine) {
|
||||
const myIds = new Set(this.myCantiService.myCanti().map(c => c.id));
|
||||
list = list.filter(c => myIds.has(c.id));
|
||||
}
|
||||
|
||||
// C. Filter by Liturgical Moment
|
||||
if (litId !== null) {
|
||||
list = list.filter(c => c.id_momenti?.includes(litId));
|
||||
}
|
||||
|
||||
// D. Filter by Thematic Moment
|
||||
if (temId !== null) {
|
||||
list = list.filter(c => c.id_momenti?.includes(temId));
|
||||
}
|
||||
|
||||
// E. Filter by Suggested (Suggeriti)
|
||||
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
|
||||
if (selectionMode && !this.isAddingSongs()) {
|
||||
return this.reorderList();
|
||||
// F. Filter by Search Query
|
||||
if (query) {
|
||||
const processedQuery = query.replace(/\buno\b/g, '1');
|
||||
const numberMatchPattern = processedQuery.match(/^(?:numero|nr\.?)\s*(\d+)$/);
|
||||
|
||||
if (numberMatchPattern) {
|
||||
const targetId = numberMatchPattern[1];
|
||||
list = list.filter(c => {
|
||||
const commNum = this.getCommunitySongNumber(c);
|
||||
return commNum ? commNum === targetId : c.id_canti.toString() === targetId;
|
||||
});
|
||||
} else {
|
||||
list = list.filter(c => {
|
||||
const titleMatch = this.normalize(c.titolo).includes(query);
|
||||
const authorMatch = this.normalize(c.autore).includes(query);
|
||||
const commNum = this.getCommunitySongNumber(c);
|
||||
const numberMatch = commNum ? commNum.includes(query) : c.id_canti.toString().includes(query);
|
||||
|
||||
const lyricsPlain = (c.testo || '')
|
||||
.replace(/{.*?}/g, '')
|
||||
.replace(/\n/g, ' ');
|
||||
const lyricsMatch = this.normalize(lyricsPlain).includes(query);
|
||||
|
||||
return titleMatch || authorMatch || lyricsMatch || numberMatch;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If we have an active playlist and NOT in selection mode, show ONLY those songs
|
||||
if (activeIds.length > 0 && !selectionMode) {
|
||||
// Filter only songs in the special list and KEEP THE ORDER
|
||||
return activeIds
|
||||
.map(id => list.find(c => c.id === id))
|
||||
.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) {
|
||||
// G. Sorting/Ordering
|
||||
if (topTen) {
|
||||
const eseguiti = this.cantiService.cantiEseguiti();
|
||||
const eseguitiMap = new Map<number, number>();
|
||||
eseguiti.forEach(x => eseguitiMap.set(x.id_canti, x.num));
|
||||
@@ -204,37 +240,16 @@ export class HomePage implements OnDestroy {
|
||||
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")
|
||||
const processedQuery = query.replace(/\buno\b/g, '1');
|
||||
|
||||
// Se la ricerca inizia con "numero" o "nr", filtra esattamente per id_canti/comunita number
|
||||
const numberMatchPattern = processedQuery.match(/^(?:numero|nr\.?)\s*(\d+)$/);
|
||||
if (numberMatchPattern) {
|
||||
const targetId = numberMatchPattern[1];
|
||||
return list.filter(c => {
|
||||
const commNum = this.getCommunitySongNumber(c);
|
||||
return commNum ? commNum === targetId : c.id_canti.toString() === targetId;
|
||||
} else 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;
|
||||
});
|
||||
}
|
||||
|
||||
return list.filter(c => {
|
||||
const titleMatch = this.normalize(c.titolo).includes(query);
|
||||
const authorMatch = this.normalize(c.autore).includes(query);
|
||||
const commNum = this.getCommunitySongNumber(c);
|
||||
const numberMatch = commNum ? commNum.includes(query) : c.id_canti.toString().includes(query);
|
||||
|
||||
// Strip tags like {Chorus} and newlines from lyrics before searching
|
||||
const lyricsPlain = (c.testo || '')
|
||||
.replace(/{.*?}/g, '')
|
||||
.replace(/\n/g, ' ');
|
||||
const lyricsMatch = this.normalize(lyricsPlain).includes(query);
|
||||
|
||||
return titleMatch || authorMatch || lyricsMatch || numberMatch;
|
||||
});
|
||||
return list;
|
||||
});
|
||||
|
||||
public visibleCanti = computed(() => {
|
||||
@@ -242,6 +257,49 @@ export class HomePage implements OnDestroy {
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// Check if install prompts should be visible
|
||||
const androidDismissed = localStorage.getItem('pwa-android-dismissed') === 'true';
|
||||
const iosDismissed = localStorage.getItem('pwa-ios-dismissed') === 'true';
|
||||
|
||||
this.showAndroidBanner.set(
|
||||
this.settingsService.isAndroid() &&
|
||||
!this.settingsService.isStandalone() &&
|
||||
!androidDismissed
|
||||
);
|
||||
|
||||
this.showIosTooltip.set(
|
||||
this.settingsService.isIos() &&
|
||||
!this.settingsService.isStandalone() &&
|
||||
!iosDismissed
|
||||
);
|
||||
|
||||
// Track initial community filter state to avoid clearing during the initial run of the effect
|
||||
let prevFilterActive = this.comunitaService.isFilterActive();
|
||||
let prevComunitaCode = this.comunitaService.comunitaCode();
|
||||
|
||||
// Automatically reset all other filters and active playlist when toggling the community filter or changing community
|
||||
effect(() => {
|
||||
const active = this.comunitaService.isFilterActive();
|
||||
const code = this.comunitaService.comunitaCode();
|
||||
|
||||
if (active !== prevFilterActive || code !== prevComunitaCode) {
|
||||
prevFilterActive = active;
|
||||
prevComunitaCode = code;
|
||||
|
||||
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);
|
||||
this.searchQuery.set('');
|
||||
this.activeFilterType.set(null);
|
||||
this.limit.set(10);
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
|
||||
// Sync speech recognition results to search query
|
||||
effect(() => {
|
||||
const transcript = this.audioEngine.searchTranscript();
|
||||
@@ -307,6 +365,34 @@ export class HomePage implements OnDestroy {
|
||||
}, { allowSignalWrites: true });
|
||||
}
|
||||
|
||||
dismissAndroidBanner(event?: Event) {
|
||||
if (event) event.stopPropagation();
|
||||
this.showAndroidBanner.set(false);
|
||||
localStorage.setItem('pwa-android-dismissed', 'true');
|
||||
}
|
||||
|
||||
dismissIosTooltip(event?: Event) {
|
||||
if (event) event.stopPropagation();
|
||||
this.showIosTooltip.set(false);
|
||||
localStorage.setItem('pwa-ios-dismissed', 'true');
|
||||
}
|
||||
|
||||
async installAndroidPwa() {
|
||||
if (this.settingsService.deferredPrompt()) {
|
||||
await this.settingsService.installPwa();
|
||||
this.dismissAndroidBanner();
|
||||
} else {
|
||||
const toast = await this.toastCtrl.create({
|
||||
message: 'Tocca il menu del browser (i tre puntini in alto a destra) e seleziona "Aggiungi a schermata Home" o "Installa app".',
|
||||
duration: 6000,
|
||||
position: 'bottom',
|
||||
color: 'secondary',
|
||||
buttons: [{ text: 'OK', role: 'cancel' }]
|
||||
});
|
||||
await toast.present();
|
||||
}
|
||||
}
|
||||
|
||||
handleImport(base64: string) {
|
||||
try {
|
||||
const decoded = decodeURIComponent(escape(atob(base64)));
|
||||
@@ -382,10 +468,6 @@ export class HomePage implements OnDestroy {
|
||||
|
||||
toggleOnlyMine() {
|
||||
this.showOnlyMine.update(v => !v);
|
||||
if (this.showOnlyMine()) {
|
||||
this.selectedLiturgico.set(null);
|
||||
this.selectedTematico.set(null);
|
||||
}
|
||||
this.limit.set(10);
|
||||
}
|
||||
|
||||
@@ -401,7 +483,6 @@ export class HomePage implements OnDestroy {
|
||||
} else if (type === 'tematico') {
|
||||
this.selectedTematico.update(cur => cur === id ? null : id);
|
||||
}
|
||||
this.showOnlyMine.set(false);
|
||||
this.activeFilterType.set(null);
|
||||
this.limit.set(10);
|
||||
}
|
||||
@@ -510,10 +591,6 @@ export class HomePage implements OnDestroy {
|
||||
this.playlistService.activePlaylistId.set(pl.id);
|
||||
this.activeFilterType.set(null);
|
||||
this.limit.set(50);
|
||||
// Clear other filters
|
||||
this.selectedLiturgico.set(null);
|
||||
this.selectedTematico.set(null);
|
||||
this.showOnlyMine.set(false);
|
||||
}
|
||||
|
||||
toggleTopTen() {
|
||||
@@ -580,7 +657,7 @@ export class HomePage implements OnDestroy {
|
||||
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;
|
||||
return es ? es.num : 0;
|
||||
}
|
||||
|
||||
onImgLoad(id: string) {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<ion-icon name="download-outline" slot="start" color="secondary"></ion-icon>
|
||||
<ion-label class="outfit-font">
|
||||
<h2 class="settings-item-title" style="color: var(--ion-color-secondary); font-weight: bold;">Installa Applicazione</h2>
|
||||
<p class="settings-item-subtitle">Aggiungi CantiCristiani alla schermata home</p>
|
||||
<p class="settings-item-subtitle">Aggiungi {{ appName }} alla schermata home</p>
|
||||
</ion-label>
|
||||
<ion-button fill="solid" size="small" color="secondary" slot="end" class="outfit-font" style="--border-radius: 12px; font-weight: bold; text-transform: none; margin: 0;">
|
||||
Installa
|
||||
|
||||
@@ -37,6 +37,7 @@ export class SettingsPage {
|
||||
|
||||
public version = VERSION;
|
||||
public contactEmail = environment.contactEmail;
|
||||
public appName = environment.appName;
|
||||
public showIosInstructions = false;
|
||||
|
||||
constructor() {}
|
||||
|
||||
@@ -4,8 +4,10 @@ import { Storage } from '@ionic/storage-angular';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
export interface Suggestion {
|
||||
id_canto: number;
|
||||
peso: number;
|
||||
id_canto?: number;
|
||||
id_canti?: number;
|
||||
peso?: number;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
export interface MassDetails {
|
||||
@@ -62,9 +64,13 @@ export class CantiLettureService {
|
||||
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']);
|
||||
if (s) {
|
||||
const id = s.id_canto || (s as any).id_canti;
|
||||
const weight = s.peso !== undefined ? s.peso : (s as any).score;
|
||||
if (id) {
|
||||
newMap.set(id, weight);
|
||||
newMomentsMap.set(id, ['Generale']);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
@@ -73,17 +79,21 @@ export class CantiLettureService {
|
||||
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);
|
||||
}
|
||||
if (s) {
|
||||
const id = s.id_canto || (s as any).id_canti;
|
||||
const weight = s.peso !== undefined ? s.peso : (s as any).score;
|
||||
if (id) {
|
||||
// Save max weight
|
||||
const existingWeight = newMap.get(id) || 0;
|
||||
if (weight > existingWeight) {
|
||||
newMap.set(id, weight);
|
||||
}
|
||||
|
||||
// Save liturgical moment
|
||||
const existingMoments = newMomentsMap.get(s.id_canto) || [];
|
||||
if (!existingMoments.includes(moment)) {
|
||||
newMomentsMap.set(s.id_canto, [...existingMoments, moment]);
|
||||
// Save liturgical moment
|
||||
const existingMoments = newMomentsMap.get(id) || [];
|
||||
if (!existingMoments.includes(moment)) {
|
||||
newMomentsMap.set(id, [...existingMoments, moment]);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -129,8 +139,12 @@ export class CantiLettureService {
|
||||
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}`;
|
||||
const primaryUrl = isProduction
|
||||
? `${window.location.origin}${this.SECURE_JSON_URL}`
|
||||
: 'https://www.canticristiani.it/api/cantiletture.json';
|
||||
const fallbackUrl = isProduction
|
||||
? 'https://www.canticristiani.it/api/cantiletture.json'
|
||||
: this.JSON_URL;
|
||||
|
||||
try {
|
||||
console.log('Fetching mass data from primary URL:', primaryUrl);
|
||||
@@ -182,7 +196,11 @@ export class CantiLettureService {
|
||||
|
||||
// Always select today's mass if available on load, else find closest future date
|
||||
if (massesList.length > 0) {
|
||||
const todayStr = new Date().toISOString().split('T')[0];
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(now.getDate()).padStart(2, '0');
|
||||
const todayStr = `${year}-${month}-${day}`;
|
||||
const match = massesList.find(m => m.date === todayStr);
|
||||
if (match) {
|
||||
this.selectedMassDate.set(match.date);
|
||||
|
||||
@@ -1,13 +1,37 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MyCantiService } from './my-canti.service';
|
||||
import { Storage } from '@ionic/storage-angular';
|
||||
import { CantiService } from './canti.service';
|
||||
import { ToastController } from '@ionic/angular';
|
||||
|
||||
import { MyCanti } from './my-canti';
|
||||
|
||||
describe('MyCanti', () => {
|
||||
let service: MyCanti;
|
||||
describe('MyCantiService', () => {
|
||||
let service: MyCantiService;
|
||||
let mockStorage: any;
|
||||
let mockCantiService: any;
|
||||
let mockToastCtrl: any;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(MyCanti);
|
||||
mockStorage = jasmine.createSpyObj('Storage', ['create', 'get', 'set']);
|
||||
mockStorage.get.and.returnValue(Promise.resolve([]));
|
||||
mockStorage.set.and.returnValue(Promise.resolve());
|
||||
|
||||
mockCantiService = jasmine.createSpyObj('CantiService', ['getStorage']);
|
||||
mockCantiService.getStorage.and.returnValue(mockStorage);
|
||||
|
||||
mockToastCtrl = jasmine.createSpyObj('ToastController', ['create']);
|
||||
mockToastCtrl.create.and.returnValue(Promise.resolve({
|
||||
present: jasmine.createSpy('present').and.returnValue(Promise.resolve())
|
||||
}));
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
MyCantiService,
|
||||
{ provide: Storage, useValue: mockStorage },
|
||||
{ provide: CantiService, useValue: mockCantiService },
|
||||
{ provide: ToastController, useValue: mockToastCtrl }
|
||||
]
|
||||
});
|
||||
service = TestBed.inject(MyCantiService);
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { PlaylistService } from './playlist.service';
|
||||
import { Storage } from '@ionic/storage-angular';
|
||||
import { CantiService } from './canti.service';
|
||||
import { ComunitaService } from './comunita.service';
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
describe('PlaylistService', () => {
|
||||
let service: PlaylistService;
|
||||
let mockStorage: any;
|
||||
let mockCantiService: any;
|
||||
let mockComunitaService: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockStorage = jasmine.createSpyObj('Storage', ['create', 'get', 'set']);
|
||||
mockStorage.create.and.returnValue(Promise.resolve(mockStorage));
|
||||
mockStorage.get.and.callFake((key: string) => {
|
||||
if (key === 'playlists') {
|
||||
return Promise.resolve([{ id: '1', name: 'Playlist Generale', ids: ['101', '102'] }]);
|
||||
}
|
||||
if (key === 'playlists_comunita_123456') {
|
||||
return Promise.resolve([{ id: '2', name: 'Playlist Comunita 123', ids: ['103'] }]);
|
||||
}
|
||||
return Promise.resolve(null);
|
||||
});
|
||||
mockStorage.set.and.returnValue(Promise.resolve());
|
||||
|
||||
mockCantiService = jasmine.createSpyObj('CantiService', ['canti']);
|
||||
mockCantiService.canti.and.returnValue([]);
|
||||
|
||||
mockComunitaService = {
|
||||
comunitaCode: signal(''),
|
||||
isFilterActive: signal(false),
|
||||
comunitaScalette: signal([]),
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
PlaylistService,
|
||||
{ provide: Storage, useValue: mockStorage },
|
||||
{ provide: CantiService, useValue: mockCantiService },
|
||||
{ provide: ComunitaService, useValue: mockComunitaService }
|
||||
]
|
||||
});
|
||||
|
||||
service = TestBed.inject(PlaylistService);
|
||||
// Simulate init() completion
|
||||
await service.init();
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load general playlists when community is not active', async () => {
|
||||
mockComunitaService.isFilterActive.set(false);
|
||||
mockComunitaService.comunitaCode.set('');
|
||||
|
||||
await service.loadPlaylistsForCurrentContext();
|
||||
|
||||
expect(service.playlists().length).toBe(1);
|
||||
expect(service.playlists()[0].name).toBe('Playlist Generale');
|
||||
});
|
||||
|
||||
it('should load community-specific playlists when community is active', async () => {
|
||||
mockComunitaService.comunitaCode.set('123456');
|
||||
mockComunitaService.isFilterActive.set(true);
|
||||
|
||||
await service.loadPlaylistsForCurrentContext();
|
||||
|
||||
expect(service.playlists().length).toBe(1);
|
||||
expect(service.playlists()[0].name).toBe('Playlist Comunita 123');
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, signal, inject, computed } from '@angular/core';
|
||||
import { Injectable, signal, inject, computed, effect } from '@angular/core';
|
||||
import { Storage } from '@ionic/storage-angular';
|
||||
import { Canto, CantiService } from './canti.service';
|
||||
import { ComunitaService } from './comunita.service';
|
||||
@@ -50,19 +50,42 @@ export class PlaylistService {
|
||||
|
||||
constructor() {
|
||||
this.init();
|
||||
|
||||
// Watch for context changes to dynamically reload the correct playlists
|
||||
effect(() => {
|
||||
// Trigger effect on changes to these signals
|
||||
this.comunitaService.comunitaCode();
|
||||
this.comunitaService.isFilterActive();
|
||||
|
||||
// Load the playlists
|
||||
this.loadPlaylistsForCurrentContext();
|
||||
}, { allowSignalWrites: true });
|
||||
}
|
||||
|
||||
private getPlaylistsStorageKey(): string {
|
||||
const code = this.comunitaService.comunitaCode();
|
||||
const isCommunityActive = this.comunitaService.isFilterActive();
|
||||
if (code && isCommunityActive) {
|
||||
return `playlists_comunita_${code}`;
|
||||
}
|
||||
return 'playlists';
|
||||
}
|
||||
|
||||
async init() {
|
||||
const storage = await this.storage.create();
|
||||
this._storage = storage;
|
||||
const saved = await this._storage.get('playlists');
|
||||
if (saved) {
|
||||
this.playlists.set(saved);
|
||||
}
|
||||
const last = await this._storage?.get('lastPlaylist');
|
||||
if (last) {
|
||||
this.lastPlaylist.set(last);
|
||||
}
|
||||
await this.loadPlaylistsForCurrentContext();
|
||||
}
|
||||
|
||||
async loadPlaylistsForCurrentContext() {
|
||||
if (!this._storage) return;
|
||||
const key = this.getPlaylistsStorageKey();
|
||||
const saved = await this._storage.get(key);
|
||||
this.playlists.set(saved || []);
|
||||
|
||||
const lastKey = `lastPlaylist_${key}`;
|
||||
const last = await this._storage.get(lastKey);
|
||||
this.lastPlaylist.set(last || null);
|
||||
}
|
||||
|
||||
toggleSelectionMode() {
|
||||
@@ -92,6 +115,8 @@ export class PlaylistService {
|
||||
}
|
||||
|
||||
async savePlaylist(name: string, ids: string[]) {
|
||||
const key = this.getPlaylistsStorageKey();
|
||||
const lastKey = `lastPlaylist_${key}`;
|
||||
const editId = this.activePlaylistId();
|
||||
let newPlaylist: any;
|
||||
|
||||
@@ -116,8 +141,8 @@ export class PlaylistService {
|
||||
this.lastPlaylist.set(newPlaylist);
|
||||
this.activeListIds.set(ids);
|
||||
this.activeListName.set(name);
|
||||
await this._storage?.set('playlists', this.playlists());
|
||||
await this._storage?.set('lastPlaylist', newPlaylist);
|
||||
await this._storage?.set(key, this.playlists());
|
||||
await this._storage?.set(lastKey, newPlaylist);
|
||||
|
||||
// Reset selection mode, IDs and editing state after saving
|
||||
this.selectedIds.set(new Set());
|
||||
@@ -126,8 +151,9 @@ export class PlaylistService {
|
||||
}
|
||||
|
||||
async deletePlaylist(id: string) {
|
||||
const key = this.getPlaylistsStorageKey();
|
||||
this.playlists.update(p => p.filter(pl => pl.id !== id));
|
||||
await this._storage?.set('playlists', this.playlists());
|
||||
await this._storage?.set(key, this.playlists());
|
||||
}
|
||||
|
||||
async generateQR(ids: string[], name: string): Promise<string> {
|
||||
@@ -148,7 +174,7 @@ export class PlaylistService {
|
||||
const base64 = btoa(unescape(encodeURIComponent(data)));
|
||||
|
||||
// Always use the production URL for sharing links as requested
|
||||
const productionUrl = 'https://www.canticristiani.it/ionic';
|
||||
const productionUrl = 'https://www.canticristiani.it';
|
||||
return `${productionUrl}/?import=${base64}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,14 +5,14 @@ import { Injectable, signal, effect } from '@angular/core';
|
||||
})
|
||||
export class SettingsService {
|
||||
/** Default mode for viewing songs: true = chords, false = text only */
|
||||
public showChordsDefault = signal<boolean>(false);
|
||||
public showChordsDefault = signal<boolean>(true);
|
||||
|
||||
/** UI Fullscreen mode (lyrics only): true = active */
|
||||
public fullscreenMode = signal<boolean>(false);
|
||||
|
||||
/** Browser Fullscreen state: true = fullscreen active */
|
||||
public browserFullscreen = signal<boolean>(
|
||||
!!(
|
||||
typeof document !== 'undefined' && !!(
|
||||
document.fullscreenElement ||
|
||||
(document as any).webkitFullscreenElement ||
|
||||
(document as any).mozFullScreenElement ||
|
||||
@@ -27,25 +27,25 @@ export class SettingsService {
|
||||
public showEditor = signal<boolean>(false);
|
||||
|
||||
/** Schermo sempre acceso: true = attiva Screen Wake Lock */
|
||||
public keepScreenOn = signal<boolean>(false);
|
||||
public keepScreenOn = signal<boolean>(true);
|
||||
|
||||
/** Funzionalità Comunità: true = il chip comunità è visibile nella home */
|
||||
public comunitaEnabled = signal<boolean>(true);
|
||||
public comunitaEnabled = signal<boolean>(false);
|
||||
|
||||
/** Invio dati statistici: true = invia pacchetto dati statistici */
|
||||
public invioDatiStatistici = signal<boolean>(false);
|
||||
|
||||
/** Visualizza tag sotto autore nella lista canti: true = attivo */
|
||||
public showTagsInList = signal<boolean>(false);
|
||||
public showTagsInList = signal<boolean>(true);
|
||||
|
||||
/** Visualizza data update sotto autore nella lista canti: true = attivo */
|
||||
public showUpdateDate = signal<boolean>(false);
|
||||
public showUpdateDate = signal<boolean>(true);
|
||||
|
||||
/** Attiva autoscroll standard nel dettaglio canto: true = attivo */
|
||||
public enableStandardAutoscroll = signal<boolean>(false);
|
||||
public enableStandardAutoscroll = signal<boolean>(true);
|
||||
|
||||
/** Attiva autoscroll acustico nel dettaglio canto: true = attivo */
|
||||
public enableAcousticAutoscroll = signal<boolean>(true);
|
||||
public enableAcousticAutoscroll = signal<boolean>(false);
|
||||
|
||||
private wakeLock: any = null;
|
||||
|
||||
@@ -54,6 +54,7 @@ export class SettingsService {
|
||||
public showInstallButton = signal<boolean>(false);
|
||||
public isStandalone = signal<boolean>(false);
|
||||
public isIos = signal<boolean>(false);
|
||||
public isAndroid = signal<boolean>(false);
|
||||
|
||||
constructor() {
|
||||
// Detect PWA status
|
||||
@@ -65,6 +66,9 @@ export class SettingsService {
|
||||
this.isIos.set(
|
||||
/iPad|iPhone|iPod/.test(navigator.userAgent) && !(window as any).MSStream
|
||||
);
|
||||
this.isAndroid.set(
|
||||
/Android/i.test(navigator.userAgent)
|
||||
);
|
||||
} catch (e) {
|
||||
console.warn('Browser environment does not support display-mode media query');
|
||||
}
|
||||
@@ -84,59 +88,103 @@ export class SettingsService {
|
||||
this.isStandalone.set(true);
|
||||
console.log('PWA was installed');
|
||||
});
|
||||
|
||||
// Migration: force settings defaults once for existing users to match the new rules
|
||||
const migrationKey = 'defaults-migrated-20260521';
|
||||
if (localStorage.getItem(migrationKey) !== 'true') {
|
||||
localStorage.setItem('show-chords-default', 'true');
|
||||
localStorage.setItem('fullscreen-mode', this.isIos().toString());
|
||||
localStorage.setItem('show-editor', 'false');
|
||||
localStorage.setItem('auto-advance', 'true');
|
||||
localStorage.setItem('keep-screen-on', 'true');
|
||||
localStorage.setItem('comunita-enabled', 'false');
|
||||
localStorage.setItem('invio-dati-statistici', 'false');
|
||||
localStorage.setItem('show-tags-in-list', 'true');
|
||||
localStorage.setItem('show-update-date', 'true');
|
||||
localStorage.setItem('enable-standard-autoscroll', 'true');
|
||||
localStorage.setItem('enable-acoustic-autoscroll', 'false');
|
||||
|
||||
// ThemeService high contrast default
|
||||
localStorage.setItem('high-contrast', 'true');
|
||||
|
||||
localStorage.setItem(migrationKey, 'true');
|
||||
}
|
||||
|
||||
const savedChords = localStorage.getItem('show-chords-default');
|
||||
if (savedChords !== null) {
|
||||
this.showChordsDefault.set(savedChords === 'true');
|
||||
} else {
|
||||
this.showChordsDefault.set(true);
|
||||
}
|
||||
|
||||
const savedFullscreen = localStorage.getItem('fullscreen-mode');
|
||||
if (savedFullscreen !== null) {
|
||||
this.fullscreenMode.set(savedFullscreen === 'true');
|
||||
} else {
|
||||
this.fullscreenMode.set(this.isIos());
|
||||
}
|
||||
|
||||
const savedEditor = localStorage.getItem('show-editor');
|
||||
if (savedEditor !== null) {
|
||||
this.showEditor.set(savedEditor === 'true');
|
||||
} else {
|
||||
this.showEditor.set(false);
|
||||
}
|
||||
|
||||
const savedAutoAdvance = localStorage.getItem('auto-advance');
|
||||
if (savedAutoAdvance !== null) {
|
||||
this.autoAdvance.set(savedAutoAdvance === 'true');
|
||||
} else {
|
||||
this.autoAdvance.set(true);
|
||||
}
|
||||
|
||||
const savedKeepScreenOn = localStorage.getItem('keep-screen-on');
|
||||
if (savedKeepScreenOn !== null) {
|
||||
this.keepScreenOn.set(savedKeepScreenOn === 'true');
|
||||
} else {
|
||||
this.keepScreenOn.set(true);
|
||||
}
|
||||
|
||||
const savedComunitaEnabled = localStorage.getItem('comunita-enabled');
|
||||
if (savedComunitaEnabled !== null) {
|
||||
this.comunitaEnabled.set(savedComunitaEnabled === 'true');
|
||||
} else {
|
||||
this.comunitaEnabled.set(false);
|
||||
}
|
||||
|
||||
const savedStats = localStorage.getItem('invio-dati-statistici');
|
||||
if (savedStats !== null) {
|
||||
this.invioDatiStatistici.set(savedStats === 'true');
|
||||
} else {
|
||||
this.invioDatiStatistici.set(false);
|
||||
}
|
||||
|
||||
const savedShowTags = localStorage.getItem('show-tags-in-list');
|
||||
if (savedShowTags !== null) {
|
||||
this.showTagsInList.set(savedShowTags === 'true');
|
||||
} else {
|
||||
this.showTagsInList.set(true);
|
||||
}
|
||||
|
||||
const savedShowUpdateDate = localStorage.getItem('show-update-date');
|
||||
if (savedShowUpdateDate !== null) {
|
||||
this.showUpdateDate.set(savedShowUpdateDate === 'true');
|
||||
} else {
|
||||
this.showUpdateDate.set(true);
|
||||
}
|
||||
|
||||
const savedStandardAutoscroll = localStorage.getItem('enable-standard-autoscroll');
|
||||
if (savedStandardAutoscroll !== null) {
|
||||
this.enableStandardAutoscroll.set(savedStandardAutoscroll === 'true');
|
||||
} else {
|
||||
this.enableStandardAutoscroll.set(true);
|
||||
}
|
||||
|
||||
const savedAcousticAutoscroll = localStorage.getItem('enable-acoustic-autoscroll');
|
||||
if (savedAcousticAutoscroll !== null) {
|
||||
this.enableAcousticAutoscroll.set(savedAcousticAutoscroll === 'true');
|
||||
} else {
|
||||
this.enableAcousticAutoscroll.set(false);
|
||||
}
|
||||
|
||||
// Sync browser fullscreen state with listeners (supporting vendor prefixes)
|
||||
|
||||
@@ -4,24 +4,26 @@ 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') {
|
||||
this.highContrast.set(true);
|
||||
if (saved !== null) {
|
||||
this.highContrast.set(saved === 'true');
|
||||
} else {
|
||||
this.highContrast.set(false);
|
||||
this.highContrast.set(true);
|
||||
}
|
||||
|
||||
// Effect to apply class to body
|
||||
effect(() => {
|
||||
const isHigh = this.highContrast();
|
||||
if (isHigh) {
|
||||
document.body.classList.add('high-contrast');
|
||||
} else {
|
||||
document.body.classList.remove('high-contrast');
|
||||
if (typeof document !== 'undefined' && document.body) {
|
||||
if (isHigh) {
|
||||
document.body.classList.add('high-contrast');
|
||||
} else {
|
||||
document.body.classList.remove('high-contrast');
|
||||
}
|
||||
}
|
||||
localStorage.setItem('high-contrast', isHigh.toString());
|
||||
});
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const VERSION = '2026.05.20.1624';
|
||||
export const VERSION = '2026.05.22.0110';
|
||||
|
||||
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 252 KiB After Width: | Height: | Size: 9.8 KiB |
@@ -1,4 +1,5 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
contactEmail: 'info@canticristiani.it'
|
||||
contactEmail: 'info@canticristiani.it',
|
||||
appName: 'CantiCristiani'
|
||||
};
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
export const environment = {
|
||||
production: false,
|
||||
contactEmail: 'info@canticristiani.it'
|
||||
contactEmail: 'info@canticristiani.it',
|
||||
appName: 'CantiCristiani'
|
||||
};
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { enableProdMode } from '@angular/core';
|
||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
import { environment } from './environments/environment';
|
||||
|
||||
if (environment.production) {
|
||||
enableProdMode();
|
||||
}
|
||||
|
||||
platformBrowserDynamic().bootstrapModule(AppModule)
|
||||
.catch(err => console.log(err));
|
||||
|
||||