fix: risoluzione importazione playlist tramite link di condivisione e fix installabilità PWA su Android

This commit is contained in:
David Frassi
2026-05-22 01:22:26 +02:00
parent d82d56a3cc
commit adc3d510ad
39 changed files with 842 additions and 299 deletions
+42 -1
View File
@@ -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>
+320
View File
@@ -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;
}
}
}
}
+154 -77
View File
@@ -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) {