Initial commit: fork from canti
@@ -0,0 +1,3 @@
|
|||||||
|
# Regole del Progetto
|
||||||
|
|
||||||
|
Quando trovi dei trattini verticali `|` tra gli accordi, ricordati di inserirli all'interno della riga degli accordi (cioè racchiusi tra parentesi quadre, ad esempio come accordo `[|]`), non come testo normale.
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# This file is used by the build system to adjust CSS and JS output to support the specified browsers below.
|
||||||
|
# For additional information regarding the format and rule options, please see:
|
||||||
|
# https://github.com/browserslist/browserslist#queries
|
||||||
|
|
||||||
|
# For the full list of supported browsers by the Angular framework, please see:
|
||||||
|
# https://angular.dev/reference/versions#browser-support
|
||||||
|
|
||||||
|
# You can see what browsers were selected by your queries by running:
|
||||||
|
# npx browserslist
|
||||||
|
|
||||||
|
Chrome >=107
|
||||||
|
Firefox >=106
|
||||||
|
Edge >=107
|
||||||
|
Safari >=16.1
|
||||||
|
iOS >=16.1
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
FTP_PASSWORD=cantiDavid@72
|
||||||
|
FTP_THREADS=100
|
||||||
|
BASE_HREF=/ionic/
|
||||||
|
CONTACT_EMAIL=info@canticristiani.it
|
||||||
|
API_AUTH_USER=canti
|
||||||
|
API_AUTH_PASS=antani2026
|
||||||
|
VPS_HOST=185.193.67.105
|
||||||
|
VPS_USER=root
|
||||||
|
VPS_PATH=/var/docker/canticristiani-pwa/html
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"ignorePatterns": ["projects/**/*"],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.ts"],
|
||||||
|
"parserOptions": {
|
||||||
|
"project": ["tsconfig.json"],
|
||||||
|
"createDefaultProgram": true
|
||||||
|
},
|
||||||
|
"extends": [
|
||||||
|
"plugin:@angular-eslint/recommended",
|
||||||
|
"plugin:@angular-eslint/template/process-inline-templates"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"@angular-eslint/prefer-standalone": "off",
|
||||||
|
"@angular-eslint/component-class-suffix": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"suffixes": ["Page", "Component"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@angular-eslint/component-selector": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"type": "element",
|
||||||
|
"prefix": "app",
|
||||||
|
"style": "kebab-case"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"@angular-eslint/directive-selector": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"type": "attribute",
|
||||||
|
"prefix": "app",
|
||||||
|
"style": "camelCase"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["*.html"],
|
||||||
|
"extends": ["plugin:@angular-eslint/template/recommended"],
|
||||||
|
"rules": {}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# Specifies intentionally untracked files to ignore when using Git
|
||||||
|
# http://git-scm.com/docs/gitignore
|
||||||
|
|
||||||
|
*~
|
||||||
|
*.sw[mnpcod]
|
||||||
|
.tmp
|
||||||
|
*.tmp
|
||||||
|
*.tmp.*
|
||||||
|
UserInterfaceState.xcuserstate
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
*.log
|
||||||
|
log.txt
|
||||||
|
|
||||||
|
|
||||||
|
/.sourcemaps
|
||||||
|
/.versions
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# Ionic
|
||||||
|
/.ionic
|
||||||
|
/www
|
||||||
|
/platforms
|
||||||
|
/plugins
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-project
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/.nx
|
||||||
|
/.nx/cache
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"Webnative.webnative"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"typescript.preferences.autoImportFileExcludePatterns": ["@ionic/angular/common", "@ionic/angular/standalone"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
FROM node:22-bullseye
|
||||||
|
|
||||||
|
# Install Ionic CLI
|
||||||
|
RUN npm install -g @ionic/cli @angular/cli
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Expose ports for Ionic dev server and live reload
|
||||||
|
EXPOSE 8100 8101 35729
|
||||||
|
|
||||||
|
# Start command (will be overridden by docker-compose for initial setup)
|
||||||
|
CMD ["ionic", "serve", "--host", "0.0.0.0", "--port", "8100"]
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# CantiCristiani PWA
|
||||||
|
|
||||||
|
Applicazione PWA moderna per la gestione di canti, accordi e karaoke integrato. Progettata per musicisti e cantanti per un uso fluido durante le celebrazioni o i momenti di preghiera.
|
||||||
|
|
||||||
|
## Caratteristiche Principali
|
||||||
|
|
||||||
|
- 🎤 **Karaoke Intelligente**: Avanzamento automatico del testo basato sul rilevamento vocale (microfono).
|
||||||
|
- 🎚️ **Sensibilità Dinamica**: Regolazione in tempo reale della sensibilità del microfono tramite slider verticale custom (50% - 100%).
|
||||||
|
- 🎼 **Accordi e Trasporto**: Visualizzazione accordi con possibilità di trasporto tonale istantaneo.
|
||||||
|
- 📋 **Gestione Playlist**: Creazione, modifica e condivisione di playlist tramite QR Code.
|
||||||
|
- 📱 **PWA Ready**: Installabile su Android e iOS come app nativa, con supporto offline.
|
||||||
|
- 🔆 **Schermo Sempre Acceso**: Opzione dedicata nelle impostazioni per evitare lo spegnimento dello schermo durante l'uso.
|
||||||
|
- 🔍 **Ricerca Rapida**: Filtri avanzati per categorie, tonalità e playlist.
|
||||||
|
|
||||||
|
## Tecnologie Utilizzate
|
||||||
|
|
||||||
|
- **Framework**: Ionic + Angular
|
||||||
|
- **State Management**: Angular Signals
|
||||||
|
- **PWA**: @angular/pwa
|
||||||
|
- **API**: Screen Wake Lock API, Media Session API
|
||||||
|
|
||||||
|
## Installazione e Sviluppo
|
||||||
|
|
||||||
|
### Prerequisiti
|
||||||
|
- Node.js (v18+)
|
||||||
|
- Ionic CLI
|
||||||
|
|
||||||
|
### Comandi Locali
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
ionic serve
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
Per distribuire l'applicazione in produzione (ambiente locale o server FTP):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy_pwa.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Lo script si occupa di:
|
||||||
|
1. Incrementare la versione dell'app.
|
||||||
|
2. Eseguire la build di produzione.
|
||||||
|
3. Distribuire i file nella cartella di distribuzione locale.
|
||||||
|
|
||||||
|
---
|
||||||
|
© 2026 CantiCristiani.it
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#!/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="https://libretto.mmcinet.eu/canti/api/get_all_app_tables"
|
||||||
|
REMOTE_PATH="api/canti.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
|
||||||
|
|
||||||
|
# --- Rotazione e Caricamento file (FTP o VPS) ---
|
||||||
|
if [ -n "$VPS_HOST" ]; then
|
||||||
|
echo "🔄 Rotazione file su VPS ($VPS_HOST)..."
|
||||||
|
ssh "$VPS_USER@$VPS_HOST" "mkdir -p $VPS_PATH/api && mv $VPS_PATH/api/canti.json $VPS_PATH/api/canti_ex.json 2>/dev/null || true"
|
||||||
|
|
||||||
|
echo "🚀 Caricamento nuovo file su VPS via SCP..."
|
||||||
|
if scp "$TEMP_FILE" "$VPS_USER@$VPS_HOST:$VPS_PATH/$REMOTE_PATH" && ssh "$VPS_USER@$VPS_HOST" "chmod 644 $VPS_PATH/$REMOTE_PATH"; then
|
||||||
|
echo "✅ Allineamento su VPS completato con successo!"
|
||||||
|
else
|
||||||
|
echo "❌ Errore durante il caricamento via SCP su VPS."
|
||||||
|
rm "$TEMP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "🔄 Rotazione file su FTP (canti.json -> canti_ex.json)..."
|
||||||
|
# Rinominiamo canti.json in canti_ex.json.
|
||||||
|
# Usiamo i percorsi assoluti per sicurezza.
|
||||||
|
curl -s -u "$FTP_USER:$FTP_PASS" \
|
||||||
|
--ftp-pasv \
|
||||||
|
-Q "*DELE /htdocs/api/canti_ex.json" \
|
||||||
|
-Q "*RNFR /htdocs/api/canti.json" \
|
||||||
|
-Q "*RNTO /htdocs/api/canti_ex.json" \
|
||||||
|
"ftp://$FTP_HOST/" > /dev/null
|
||||||
|
|
||||||
|
echo "🚀 Caricamento nuovo file su FTP ($FTP_HOST) via FTP..."
|
||||||
|
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
|
||||||
|
echo "✅ Allineamento su FTP completato con successo!"
|
||||||
|
else
|
||||||
|
echo "❌ Errore durante il caricamento FTP."
|
||||||
|
rm "$TEMP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
rm "$TEMP_FILE"
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
#!/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="https://api.canticristiani.it/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 -u "$API_AUTH_USER:$API_AUTH_PASS" -L "$SOURCE_URL" -o "$TEMP_FILE"; then
|
||||||
|
echo "❌ Errore nel download dei dati."
|
||||||
|
rm "$TEMP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Caricamento file (FTP o VPS) ---
|
||||||
|
if [ -n "$VPS_HOST" ]; then
|
||||||
|
echo "🚀 Caricamento nuovo file su VPS ($VPS_HOST) via SCP..."
|
||||||
|
ssh "$VPS_USER@$VPS_HOST" "mkdir -p $VPS_PATH/api"
|
||||||
|
if scp "$TEMP_FILE" "$VPS_USER@$VPS_HOST:$VPS_PATH/$REMOTE_PATH" && ssh "$VPS_USER@$VPS_HOST" "chmod 644 $VPS_PATH/$REMOTE_PATH"; then
|
||||||
|
echo "✅ Allineamento su VPS completato con successo!"
|
||||||
|
else
|
||||||
|
echo "❌ Errore durante il caricamento via SCP su VPS."
|
||||||
|
rm "$TEMP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "🚀 Caricamento nuovo file su FTP ($FTP_HOST) via FTP..."
|
||||||
|
if curl -s -u "$FTP_USER:$FTP_PASS" --ftp-pasv --ftp-create-dirs -T "$TEMP_FILE" "ftp://$FTP_HOST/$REMOTE_PATH"; then
|
||||||
|
echo "✅ Allineamento su FTP completato con successo!"
|
||||||
|
else
|
||||||
|
echo "❌ Errore durante il caricamento FTP."
|
||||||
|
rm "$TEMP_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
rm "$TEMP_FILE"
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"app": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:browser",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "www",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"main": "src/main.ts",
|
||||||
|
"polyfills": "src/polyfills.ts",
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "src/assets",
|
||||||
|
"output": "assets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public",
|
||||||
|
"output": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*.svg",
|
||||||
|
"input": "node_modules/ionicons/dist/ionicons/svg",
|
||||||
|
"output": "./svg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": ["src/global.scss", "src/theme/variables.scss"],
|
||||||
|
"scripts": [],
|
||||||
|
"serviceWorker": true,
|
||||||
|
"ngswConfigPath": "ngsw-config.json",
|
||||||
|
"allowedCommonJsDependencies": [
|
||||||
|
"localforage",
|
||||||
|
"qrcode",
|
||||||
|
"mammoth",
|
||||||
|
"tesseract.js"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "2mb",
|
||||||
|
"maximumError": "5mb"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "32kb",
|
||||||
|
"maximumError": "48kb"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildOptimizer": false,
|
||||||
|
"optimization": false,
|
||||||
|
"vendorChunk": true,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true,
|
||||||
|
"namedChunks": true
|
||||||
|
},
|
||||||
|
"ci": {
|
||||||
|
"progress": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "app:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "app:build:development"
|
||||||
|
},
|
||||||
|
"ci": {
|
||||||
|
"progress": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"extract-i18n": {
|
||||||
|
"builder": "@angular-devkit/build-angular:extract-i18n",
|
||||||
|
"options": {
|
||||||
|
"buildTarget": "app:build"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
|
"options": {
|
||||||
|
"main": "src/test.ts",
|
||||||
|
"polyfills": "src/polyfills.ts",
|
||||||
|
"tsConfig": "tsconfig.spec.json",
|
||||||
|
"karmaConfig": "karma.conf.js",
|
||||||
|
"inlineStyleLanguage": "scss",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "src/assets",
|
||||||
|
"output": "assets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public",
|
||||||
|
"output": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*.svg",
|
||||||
|
"input": "node_modules/ionicons/dist/ionicons/svg",
|
||||||
|
"output": "./svg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": ["src/global.scss", "src/theme/variables.scss"],
|
||||||
|
"scripts": []
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"ci": {
|
||||||
|
"progress": false,
|
||||||
|
"watch": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lint": {
|
||||||
|
"builder": "@angular-eslint/builder:lint",
|
||||||
|
"options": {
|
||||||
|
"lintFilePatterns": [
|
||||||
|
"src/**/*.ts",
|
||||||
|
"src/**/*.html"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cli": {
|
||||||
|
"schematicCollections": [
|
||||||
|
"@ionic/angular-toolkit"
|
||||||
|
],
|
||||||
|
"analytics": false
|
||||||
|
},
|
||||||
|
"schematics": {
|
||||||
|
"@ionic/angular-toolkit:component": {
|
||||||
|
"styleext": "scss"
|
||||||
|
},
|
||||||
|
"@ionic/angular-toolkit:page": {
|
||||||
|
"styleext": "scss"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
echo "Creazione del backup del repository Git locale..."
|
||||||
|
zip -r git_backup.zip . -x "node_modules/*" ".angular/*" "www/*" "platforms/*" "plugins/*" ".ionic/*" "dist/*" ".nx/*" "*/.DS_Store" "git_backup.zip" "backup.sh"
|
||||||
|
echo "Backup completato! Creato il file git_backup.zip."
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# Piano di Azione: Suggerimento Canti con AI (cantiletture.json)
|
||||||
|
|
||||||
|
## Obiettivo
|
||||||
|
|
||||||
|
Generare automaticamente, una volta a settimana, un file JSON con i canti suggeriti per ogni messa della settimana, analizzati da Gemini Flash a partire dalle letture liturgiche. La PWA scarica questo file statico e mostra i suggerimenti istantaneamente, senza alcun calcolo lato client.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architettura
|
||||||
|
|
||||||
|
```
|
||||||
|
CRON Settimanale (Lunedì ore 6:00)
|
||||||
|
│
|
||||||
|
├─ 1. Fetch RSS liturgia.silvestrini.org/rss
|
||||||
|
│ → Estrae le letture di tutta la settimana (7 giorni)
|
||||||
|
│
|
||||||
|
├─ 2. Fetch canti.json dal server
|
||||||
|
│ → Carica il database completo dei canti con testi e momenti liturgici
|
||||||
|
│
|
||||||
|
├─ 3. Chiamata API Gemini Flash
|
||||||
|
│ → Invia prompt strutturato con letture + lista canti
|
||||||
|
│ → Riceve suggerimenti ragionati con score e motivazione
|
||||||
|
│
|
||||||
|
└─ 4. Salva cantiletture.json
|
||||||
|
→ Upload sul server web (stessa posizione di canti.json)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Struttura del File cantiletture.json
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"generated_at": "2026-05-19T06:00:00Z",
|
||||||
|
"week_start": "2026-05-17",
|
||||||
|
"week_end": "2026-05-24",
|
||||||
|
"masses": {
|
||||||
|
"2026-05-17": {
|
||||||
|
"title": "Ascensione del Signore",
|
||||||
|
"day": "Domenica",
|
||||||
|
"moments": {
|
||||||
|
"Ingresso": [
|
||||||
|
{
|
||||||
|
"id_canti": 42,
|
||||||
|
"titolo": "Cristo è risorto veramente",
|
||||||
|
"score": 95,
|
||||||
|
"motivo": "Il tema dell'ascensione e della gloria di Cristo risorto è centrale in questo canto."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id_canti": 118,
|
||||||
|
"titolo": "Alleluia, è risorto",
|
||||||
|
"score": 88,
|
||||||
|
"motivo": "Il tono gioioso e la proclamazione pasquale si collegano al mistero celebrato."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id_canti": 205,
|
||||||
|
"titolo": "Canto di lode al Signore",
|
||||||
|
"score": 82,
|
||||||
|
"motivo": "Richiama il tema della lode che accompagna l'ascensione di Gesù."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"Offertorio": [
|
||||||
|
{ "...": "3 canti suggeriti" }
|
||||||
|
],
|
||||||
|
"Comunione": [
|
||||||
|
{ "...": "3 canti suggeriti" }
|
||||||
|
],
|
||||||
|
"Finale": [
|
||||||
|
{ "...": "3 canti suggeriti" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"2026-05-18": {
|
||||||
|
"title": "Lunedì della VII settimana di Pasqua",
|
||||||
|
"day": "Lunedì",
|
||||||
|
"moments": { "...": "stessa struttura" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Campi per ogni canto suggerito:
|
||||||
|
| Campo | Tipo | Descrizione |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `id_canti` | number | ID del canto nel database (per collegamento diretto al player) |
|
||||||
|
| `titolo` | string | Titolo del canto (per visualizzazione rapida) |
|
||||||
|
| `score` | number (0-100) | Grado di affinità/affidabilità del suggerimento |
|
||||||
|
| `motivo` | string | Spiegazione testuale del perché il canto è adatto |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Componenti da Sviluppare
|
||||||
|
|
||||||
|
### 1. Script di Generazione (`scripts/generate-cantiletture.ts`)
|
||||||
|
|
||||||
|
Script Node.js/TypeScript che:
|
||||||
|
|
||||||
|
- **Input**: RSS feed + canti.json
|
||||||
|
- **Elaborazione**:
|
||||||
|
1. Parsing XML del feed RSS (estrazione letture per data)
|
||||||
|
2. Preparazione del prompt per Gemini con: testo letture + lista canti (id, titolo, testo, momenti liturgici)
|
||||||
|
3. Chiamata API Gemini Flash con output JSON strutturato
|
||||||
|
4. Validazione della risposta (verifica che gli ID canti esistano, score nel range)
|
||||||
|
- **Output**: File `cantiletture.json`
|
||||||
|
|
||||||
|
**Prompt di esempio per Gemini:**
|
||||||
|
```
|
||||||
|
Sei un esperto liturgista cattolico. Ti fornisco le letture della messa
|
||||||
|
e un database di canti liturgici con i relativi momenti (Ingresso,
|
||||||
|
Offertorio, Comunione, Finale, ecc.).
|
||||||
|
|
||||||
|
Per ogni giorno della settimana e per ogni momento liturgico, suggerisci
|
||||||
|
i 3 canti più adatti. Per ogni suggerimento indica:
|
||||||
|
- L'ID del canto (id_canti)
|
||||||
|
- Uno score da 0 a 100 che indica il grado di attinenza
|
||||||
|
- Una breve motivazione (max 1 frase)
|
||||||
|
|
||||||
|
Considera: temi teologici, periodo liturgico, tono emotivo,
|
||||||
|
corrispondenze tra letture e testi dei canti.
|
||||||
|
|
||||||
|
LETTURE DELLA SETTIMANA:
|
||||||
|
[...testo letture...]
|
||||||
|
|
||||||
|
DATABASE CANTI:
|
||||||
|
[...lista canti con id, titolo, testo, momenti...]
|
||||||
|
|
||||||
|
Rispondi SOLO con JSON valido nel formato specificato.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configurazione API Key
|
||||||
|
|
||||||
|
- Ottenere API Key gratuita da [Google AI Studio](https://aistudio.google.com/apikey)
|
||||||
|
- Salvarla come variabile d'ambiente `GEMINI_API_KEY`
|
||||||
|
- Non committarla nel repository (usare `.env` in `.gitignore`)
|
||||||
|
|
||||||
|
### 3. Aggiornamento PWA
|
||||||
|
|
||||||
|
#### 3.1 Nuovo Service: `CantiLettureService`
|
||||||
|
```typescript
|
||||||
|
// Logica semplificata:
|
||||||
|
// - Scarica cantiletture.json dal server (con cache)
|
||||||
|
// - Espone i suggerimenti per data selezionata
|
||||||
|
// - Fallback al keyword matching se il JSON non è disponibile
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2 Aggiornamento LiturgiaPage
|
||||||
|
- Rimuovere il tasto "Analizza e Suggerisci Canti" (non serve più)
|
||||||
|
- Rimuovere la sezione "Parole Chiave Rilevate" (non serve più)
|
||||||
|
- Mostrare automaticamente i suggerimenti raggruppati per momento liturgico
|
||||||
|
- Ogni card mostra: titolo, score (barra o badge), motivazione dell'AI
|
||||||
|
- Click sulla card → apre il canto nel player
|
||||||
|
|
||||||
|
#### 3.3 Fallback
|
||||||
|
Se `cantiletture.json` non è disponibile o la data non è coperta:
|
||||||
|
- Mostrare un messaggio "Suggerimenti AI non disponibili per questa data"
|
||||||
|
- Opzionalmente, offrire il keyword matching come alternativa
|
||||||
|
|
||||||
|
### 4. Automazione (CRON)
|
||||||
|
|
||||||
|
#### Opzione A: Script locale con crontab
|
||||||
|
```bash
|
||||||
|
# Ogni lunedì alle 6:00
|
||||||
|
0 6 * * 1 cd /path/to/canti && node scripts/generate-cantiletture.js
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Opzione B: GitHub Action (se il repo viene messo su GitHub)
|
||||||
|
```yaml
|
||||||
|
# .github/workflows/generate-suggestions.yml
|
||||||
|
name: Generate Canti Suggestions
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 6 * * 1' # Ogni lunedì alle 6:00 UTC
|
||||||
|
workflow_dispatch: # Esecuzione manuale
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Opzione C: Script manuale
|
||||||
|
```bash
|
||||||
|
# Eseguibile a mano quando si vuole aggiornare
|
||||||
|
./scripts/generate-cantiletture.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stima Costi
|
||||||
|
|
||||||
|
| Voce | Valore |
|
||||||
|
|------|--------|
|
||||||
|
| Chiamate API/settimana | 1 (una sola chiamata copre tutta la settimana) |
|
||||||
|
| Token input (letture + ~500 canti) | ~15.000 token |
|
||||||
|
| Token output (JSON suggerimenti) | ~3.000 token |
|
||||||
|
| Costo per chiamata (Gemini Flash) | ~0.001€ |
|
||||||
|
| **Costo mensile stimato** | **< 0.01€** |
|
||||||
|
| Rientra nell'abbonamento Google One | ✅ Sì |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Piano di Esecuzione (Step by Step)
|
||||||
|
|
||||||
|
### Fase 1: Script di Generazione
|
||||||
|
1. [ ] Creare `scripts/generate-cantiletture.ts`
|
||||||
|
2. [ ] Implementare il parsing RSS
|
||||||
|
3. [ ] Implementare la chiamata Gemini con prompt ottimizzato
|
||||||
|
4. [ ] Testare con le letture della settimana corrente
|
||||||
|
5. [ ] Validare il JSON generato
|
||||||
|
|
||||||
|
### Fase 2: Integrazione PWA
|
||||||
|
6. [ ] Creare `CantiLettureService` che scarica e gestisce il JSON
|
||||||
|
7. [ ] Aggiornare `LiturgiaPage` per mostrare i suggerimenti AI
|
||||||
|
8. [ ] Implementare il fallback al keyword matching
|
||||||
|
9. [ ] Testare l'interfaccia completa
|
||||||
|
|
||||||
|
### Fase 3: Automazione
|
||||||
|
10. [ ] Configurare il cron/script di esecuzione automatica
|
||||||
|
11. [ ] Testare il ciclo completo (generazione → upload → visualizzazione)
|
||||||
|
12. [ ] Documentare la procedura nel README
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Note Tecniche
|
||||||
|
|
||||||
|
- **Dimensione stimata del JSON**: ~50-100 KB per settimana (7 giorni × 4-5 momenti × 3 canti)
|
||||||
|
- **Cache**: Il JSON viene cachato in localStorage con TTL di 24 ore
|
||||||
|
- **Compatibilità**: Il file viene servito dallo stesso server HTTP della PWA, nessun problema CORS
|
||||||
|
- **Retrocompatibilità**: Se il file non esiste, la PWA continua a funzionare con il keyword matching
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"id_comunita": "123456",
|
||||||
|
"nome_comunita": "Comunità Test 123456",
|
||||||
|
"canti": [378, 379, 380]
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# --- Configurazione ---
|
||||||
|
DIST_PATH="www" # Solitamente Ionic usa 'www' o 'dist/app-name'
|
||||||
|
APP_NAME="CantiCristiani"
|
||||||
|
|
||||||
|
echo "🚀 Inizio procedura di deploy PWA per: $APP_NAME"
|
||||||
|
|
||||||
|
# 1. Verifica dipendenze
|
||||||
|
if ! [ -x "$(command -v npm)" ]; then
|
||||||
|
echo "❌ Errore: npm non è installato." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 1.5 Aggiornamento Versione
|
||||||
|
VERSION=$(date +'%Y.%m.%d.%H%M')
|
||||||
|
echo "export const VERSION = '$VERSION';" > src/app/version.ts
|
||||||
|
echo "🏷️ Versione aggiornata a: $VERSION"
|
||||||
|
|
||||||
|
# --- Caricamento variabili d'ambiente ---
|
||||||
|
if [ -f .env ]; then
|
||||||
|
export $(grep -v '^#' .env | xargs)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Configurazione Email e API Parametriche ---
|
||||||
|
node -e "
|
||||||
|
const fs = require('fs');
|
||||||
|
const envEmail = process.env.CONTACT_EMAIL || 'info@canticristiani.it';
|
||||||
|
const apiUser = process.env.API_AUTH_USER || 'canti';
|
||||||
|
const apiPass = process.env.API_AUTH_PASS || 'antani2026';
|
||||||
|
['src/environments/environment.ts', 'src/environments/environment.prod.ts'].forEach(file => {
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
let content = fs.readFileSync(file, 'utf8');
|
||||||
|
content = content.replace(/contactEmail:\s*'[^']*'/g, \`contactEmail: '\${envEmail}'\`);
|
||||||
|
content = content.replace(/apiAuthUser:\s*'[^']*'/g, \`apiAuthUser: '\${apiUser}'\`);
|
||||||
|
content = content.replace(/apiAuthPass:\s*'[^']*'/g, \`apiAuthPass: '\${apiPass}'\`);
|
||||||
|
fs.writeFileSync(file, content, 'utf8');
|
||||||
|
console.log(\`📧 Aggiornate variabili di ambiente in \${file}\`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
"
|
||||||
|
|
||||||
|
# 2. Build dell'applicazione
|
||||||
|
echo "📦 Compilazione in corso (Production Build)..."
|
||||||
|
if ! npm run build -- --configuration=production --base-href=/; then
|
||||||
|
echo "❌ Errore: La compilazione è fallita. Controlla gli errori sopra."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Verifico se la cartella www esiste (Ionic/Angular)
|
||||||
|
if [ ! -d "www" ]; then
|
||||||
|
# Se non c'è www, cerco in dist/
|
||||||
|
DIST_PATH=$(ls -d dist/*/ 2>/dev/null | head -n 1)
|
||||||
|
if [ -z "$DIST_PATH" ]; then
|
||||||
|
echo "❌ Errore: Cartella di build non trovata. Controlla il comando 'npm run build'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Build completata con successo in: $DIST_PATH"
|
||||||
|
|
||||||
|
# --- Genera version.json per il polling PWA ---
|
||||||
|
BUILD_TIMESTAMP=$(date +%s)000
|
||||||
|
echo "{\"version\":\"$VERSION\",\"buildTime\":$BUILD_TIMESTAMP}" > www/version.json
|
||||||
|
echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)"
|
||||||
|
|
||||||
|
# 2.5 In locale usiamo la root
|
||||||
|
echo "📁 Build pronta nella root..."
|
||||||
|
# Nessuna sottocartella ionic necessaria in locale
|
||||||
|
|
||||||
|
# Nessun redirect necessario in locale
|
||||||
|
|
||||||
|
# 3. Recupero IP locale per connessione da cellulare
|
||||||
|
IP_ADDRESS=$(ipconfig getifaddr en0 || ipconfig getifaddr en1 || hostname -I | awk '{print $1}')
|
||||||
|
|
||||||
|
if [ -z "$IP_ADDRESS" ]; then
|
||||||
|
IP_ADDRESS="localhost"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "--------------------------------------------------------"
|
||||||
|
echo "📱 INSTALLAZIONE SU CELLULARE:"
|
||||||
|
echo "1. Assicurati che il cellulare sia sulla stessa rete Wi-Fi."
|
||||||
|
echo "2. Apri il browser (Chrome su Android, Safari su iOS)."
|
||||||
|
echo "3. Vai all'indirizzo: http://\$IP_ADDRESS:8080/"
|
||||||
|
echo " ⚠️ NOTA: Per usare il MICROFONO su cellulare, i browser richiedono HTTPS."
|
||||||
|
echo " Puoi usare uno strumento come 'ngrok' o 'local-ssl-proxy' per testarlo con HTTPS."
|
||||||
|
echo "4. Una volta caricata l'app:"
|
||||||
|
echo " - Android: Clicca sui 3 puntini -> 'Installa app' o 'Aggiungi a home'."
|
||||||
|
echo " - iOS: Clicca l'icona 'Condividi' (quadrato con freccia) -> 'Aggiungi a schermata Home'."
|
||||||
|
echo "--------------------------------------------------------"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 4. Avvio server locale per test immediato
|
||||||
|
echo "🌐 Liberazione porta 8080 (se occupata)..."
|
||||||
|
lsof -ti:8080 | xargs kill -9 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "🌐 Avvio server di distribuzione su http://$IP_ADDRESS:8080/ ..."
|
||||||
|
echo "premi CTRL+C per fermare il server."
|
||||||
|
|
||||||
|
npx http-server "$DIST_PATH" -p 8080 -c-1 --proxy "http://localhost:8080?"
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
#!/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 Target ---
|
||||||
|
TARGET="contabo"
|
||||||
|
echo "🎯 Target di deploy: $TARGET"
|
||||||
|
|
||||||
|
# --- Validazione configurazione ---
|
||||||
|
if [ -z "$VPS_HOST" ] || [ -z "$VPS_USER" ] || [ -z "$VPS_PATH" ]; then
|
||||||
|
echo "❌ Errore: VPS_HOST, VPS_USER o VPS_PATH non definiti nel file .env"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Aggiornamento Versione ---
|
||||||
|
VERSION=$(date +'%Y.%m.%d.%H%M')
|
||||||
|
echo "export const VERSION = '$VERSION';" > src/app/version.ts
|
||||||
|
echo "🏷️ Versione aggiornata a: $VERSION"
|
||||||
|
|
||||||
|
# --- Configurazione Email e API Parametriche ---
|
||||||
|
node -e "
|
||||||
|
const fs = require('fs');
|
||||||
|
const envEmail = process.env.CONTACT_EMAIL || 'info@canticristiani.it';
|
||||||
|
const apiUser = process.env.API_AUTH_USER || 'canti';
|
||||||
|
const apiPass = process.env.API_AUTH_PASS || 'antani2026';
|
||||||
|
['src/environments/environment.ts', 'src/environments/environment.prod.ts'].forEach(file => {
|
||||||
|
if (fs.existsSync(file)) {
|
||||||
|
let content = fs.readFileSync(file, 'utf8');
|
||||||
|
content = content.replace(/contactEmail:\s*'[^']*'/g, \`contactEmail: '\${envEmail}'\`);
|
||||||
|
content = content.replace(/apiAuthUser:\s*'[^']*'/g, \`apiAuthUser: '\${apiUser}'\`);
|
||||||
|
content = content.replace(/apiAuthPass:\s*'[^']*'/g, \`apiAuthPass: '\${apiPass}'\`);
|
||||||
|
fs.writeFileSync(file, content, 'utf8');
|
||||||
|
console.log(\`📧 Aggiornate variabili di ambiente in \${file}\`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
"
|
||||||
|
|
||||||
|
|
||||||
|
# --- Build della PWA (base-href=/ per installare nella root) ---
|
||||||
|
echo "📦 1/2 Build della PWA (Production) per ROOT (www.canticristiani.it) in corso..."
|
||||||
|
npm run build -- --configuration=production --base-href=/ || { echo "❌ Errore nella build"; exit 1; }
|
||||||
|
|
||||||
|
if [ ! -d "www" ]; then
|
||||||
|
echo "❌ Errore: Cartella 'www' non trovata."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Genera version.json per il polling PWA ---
|
||||||
|
BUILD_TIMESTAMP=$(date +%s)000
|
||||||
|
echo "{\"version\":\"$VERSION\",\"buildTime\":$BUILD_TIMESTAMP}" > www/version.json
|
||||||
|
echo "📄 Generato version.json: v$VERSION (timestamp: $BUILD_TIMESTAMP)"
|
||||||
|
|
||||||
|
# --- Upload ---
|
||||||
|
echo "🚀 Upload via SSH/rsync su Contabo ($VPS_HOST) in corso..."
|
||||||
|
rsync -avz --delete --exclude 'api' www/ "$VPS_USER@$VPS_HOST:$VPS_PATH"
|
||||||
|
|
||||||
|
# --- Allineamento Cantiletture JSON ---
|
||||||
|
if [ -f "./allinealetture.sh" ]; then
|
||||||
|
echo "🔄 Sincronizzazione cantiletture.json su server..."
|
||||||
|
./allinealetture.sh || echo "⚠️ Warning: Allineamento cantiletture.json fallito"
|
||||||
|
fi
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- node_modules:/app/node_modules
|
||||||
|
ports:
|
||||||
|
- "8100:8100"
|
||||||
|
- "8101:8101"
|
||||||
|
- "35729:35729"
|
||||||
|
environment:
|
||||||
|
- CHOKIDAR_USEPOLLING=true
|
||||||
|
command: ionic serve --external --port 8100 --address 0.0.0.0
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
node_modules:
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
# Studio di Dettaglio: Opzione 1 - White-Label / Multi-Configuration
|
||||||
|
|
||||||
|
Questo documento descrive in dettaglio la strategia, la configurazione e i passaggi necessari per implementare la clonazione del progetto **canti** per la variante **stereocomics** attraverso l'**Opzione 1: White-Label**.
|
||||||
|
|
||||||
|
Con questo approccio, il codice sorgente rimane **unico al 100%**. La differenziazione tra l'applicazione originale (Canti) e il clone (Stereocomics) avviene esclusivamente a tempo di compilazione (build-time) o di esecuzione (run-time) tramite file di configurazione ambientale di Angular (`environment`), fogli di stile dedicati, e sostituzione degli asset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architettura e Flusso di Configurazione
|
||||||
|
|
||||||
|
Il concetto chiave è l'utilizzo delle funzionalità native di Angular CLI (`angular.json`) per iniettare le configurazioni specifiche del brand.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Codice Sorgente Comune] --> B{Build Command}
|
||||||
|
B -- "ng build" --> C[Canti App]
|
||||||
|
B -- "ng build --configuration=stereocomics" --> D[Stereocomics App]
|
||||||
|
|
||||||
|
subgraph Sostituzioni Stereocomics
|
||||||
|
E[environment.ts -> environment.stereocomics.ts]
|
||||||
|
F[variables.scss -> variables.stereocomics.scss]
|
||||||
|
G[Asset Generici -> Asset Stereocomics]
|
||||||
|
end
|
||||||
|
|
||||||
|
D -.-> Sostituzioni Stereocomics
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Passaggi Operativi per l'Implementazione
|
||||||
|
|
||||||
|
### Passo 2.1: Creazione dei File Ambientali (Environments)
|
||||||
|
Attualmente in `src/environments/` abbiamo `environment.ts` e `environment.prod.ts`. Creeremo le varianti per Stereocomics:
|
||||||
|
|
||||||
|
#### [NEW] [environment.stereocomics.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/environments/environment.stereocomics.ts)
|
||||||
|
```typescript
|
||||||
|
export const environment = {
|
||||||
|
production: false,
|
||||||
|
contactEmail: 'info@stereocomics.it',
|
||||||
|
appName: 'Stereocomics',
|
||||||
|
apiAuthUser: 'stereocomics',
|
||||||
|
apiAuthPass: 'stereo2026',
|
||||||
|
brand: 'stereocomics' // Flag utile per abilitare o disabilitare funzionalità specifiche run-time
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#### [NEW] [environment.stereocomics.prod.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/environments/environment.stereocomics.prod.ts)
|
||||||
|
```typescript
|
||||||
|
export const environment = {
|
||||||
|
production: true,
|
||||||
|
contactEmail: 'info@stereocomics.it',
|
||||||
|
appName: 'Stereocomics',
|
||||||
|
apiAuthUser: 'stereocomics',
|
||||||
|
apiAuthPass: 'stereo2026_prod',
|
||||||
|
brand: 'stereocomics'
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Passo 2.2: Configurazione di `angular.json`
|
||||||
|
Per permettere ad Angular di caricare la configurazione corretta, dobbiamo aggiungere una nuova build configuration in [angular.json](file:///Users/davidfrassi/SRC/agenti/canti/angular.json).
|
||||||
|
|
||||||
|
Sotto `projects -> app -> architect -> build -> configurations`, aggiungeremo il blocco `stereocomics`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"stereocomics": {
|
||||||
|
"buildOptimizer": true,
|
||||||
|
"optimization": true,
|
||||||
|
"vendorChunk": false,
|
||||||
|
"extractLicenses": true,
|
||||||
|
"sourceMap": false,
|
||||||
|
"namedChunks": false,
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.stereocomics.prod.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"replace": "src/theme/variables.scss",
|
||||||
|
"with": "src/theme/variables.stereocomics.scss"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "src/assets/stereocomics",
|
||||||
|
"output": "assets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public/stereocomics",
|
||||||
|
"output": "."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
E sotto `projects -> app -> architect -> serve -> configurations`:
|
||||||
|
```json
|
||||||
|
"stereocomics": {
|
||||||
|
"buildTarget": "app:build:development",
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.stereocomics.ts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"replace": "src/theme/variables.scss",
|
||||||
|
"with": "src/theme/variables.stereocomics.scss"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "src/assets/stereocomics",
|
||||||
|
"output": "assets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public/stereocomics",
|
||||||
|
"output": "."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Passo 2.3: Stile e Theming
|
||||||
|
Creeremo un file di variabili SCSS specifico per ridefinire i colori primari di Ionic in chiave "Stereocomics" (es. arancione/viola invece del blu/verde tipico dei Canti).
|
||||||
|
|
||||||
|
#### [NEW] [variables.stereocomics.scss](file:///Users/davidfrassi/SRC/agenti/canti/src/theme/variables.stereocomics.scss)
|
||||||
|
Questo file conterrà le medesime variabili CSS di `src/theme/variables.scss` ma con la palette colori e i font scelti per Stereocomics:
|
||||||
|
```scss
|
||||||
|
// Stereocomics Palette
|
||||||
|
:root {
|
||||||
|
--ion-color-primary: #ff5722;
|
||||||
|
--ion-color-primary-rgb: 255,87,34;
|
||||||
|
--ion-color-primary-contrast: #ffffff;
|
||||||
|
--ion-color-primary-contrast-rgb: 255,255,255;
|
||||||
|
--ion-color-primary-shade: #e04d1d;
|
||||||
|
--ion-color-primary-tint: #ff6838;
|
||||||
|
|
||||||
|
// Font personalizzati
|
||||||
|
--app-font-family: 'Outfit', sans-serif;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nel file [global.scss](file:///Users/davidfrassi/SRC/agenti/canti/src/global.scss) o nei componenti useremo la variabile `--app-font-family` per rendere dinamico il font.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Passo 2.4: Gestione Asset e Icone PWA
|
||||||
|
Per evitare di caricare icone e manifest di CantiCristiani su Stereocomics:
|
||||||
|
1. Creeremo due sottocartelle in `src/assets/`:
|
||||||
|
- `src/assets/canti/` (per i loghi e immagini originali)
|
||||||
|
- `src/assets/stereocomics/` (per i loghi e immagini di Stereocomics)
|
||||||
|
2. Durante la build di `stereocomics`, mapperemo la cartella `src/assets/stereocomics` direttamente sull'output `assets/` (come specificato in `angular.json`), garantendo che i percorsi `/assets/logo.png` rimangano identici nel codice HTML, ma cambino fisicamente nel pacchetto generato.
|
||||||
|
3. Creeremo un file `manifest.stereocomics.webmanifest` in `public/stereocomics/manifest.webmanifest` contenente il nome "Stereocomics" e i riferimenti alle icone corrette per la PWA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Passo 2.5: Script in `package.json`
|
||||||
|
Aggiungeremo comandi dedicati in [package.json](file:///Users/davidfrassi/SRC/agenti/canti/package.json) per facilitare lo sviluppo e la pubblicazione:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"scripts": {
|
||||||
|
"start:canti": "ng serve --configuration=development",
|
||||||
|
"start:stereocomics": "ng serve --configuration=stereocomics",
|
||||||
|
"build:canti": "ng build --configuration=production",
|
||||||
|
"build:stereocomics": "ng build --configuration=stereocomics"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Passo 2.6: Gestione Capacitor (Mobile Native App)
|
||||||
|
Se l'app deve essere compilata per iOS/Android tramite Capacitor:
|
||||||
|
- Possiamo creare un file di configurazione dinamico `capacitor.config.ts` che esporta la configurazione a seconda di una variabile d'ambiente (es. `process.env['BRAND']`).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CapacitorConfig } from '@capacitor/cli';
|
||||||
|
|
||||||
|
const brand = process.env['BRAND'] || 'canti';
|
||||||
|
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: brand === 'stereocomics' ? 'it.stereocomics.app' : 'it.canticristiani.app',
|
||||||
|
appName: brand === 'stereocomics' ? 'Stereocomics' : 'Canti Cristiani',
|
||||||
|
webDir: 'www',
|
||||||
|
bundledWebRuntime: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Vantaggi e Svantaggi dell'Opzione 1
|
||||||
|
|
||||||
|
### Vantaggi:
|
||||||
|
1. **Zero Duplicazione di Codice**: Se viene corretto un bug nella pagina di riproduzione audio o nel parser dei testi, la modifica è istantaneamente attiva per entrambi i brand.
|
||||||
|
2. **Semplicità di Manutenzione**: Un'unica pipeline di CI/CD che esegue i test unitari una sola volta per la codebase comune.
|
||||||
|
3. **Flessibilità Controllata**: È comunque possibile introdurre comportamenti run-time personalizzati leggendo `environment.brand` nel codice TypeScript (es. `if (environment.brand === 'stereocomics') { ... }`).
|
||||||
|
|
||||||
|
### Svantaggi:
|
||||||
|
1. **Complessità Condizionale**: Se a lungo andare Stereocomics necessita di pagine con layout o logiche radicalmente diversi da Canti, il codice si riempirà di blocchi `if/else` o direttive condizionali (`*ngIf="isStereocomics"`), riducendo la leggibilità del codice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Criteri di Accettazione e Verifica (Verification Plan)
|
||||||
|
|
||||||
|
### Verifica dello Sviluppo Locale
|
||||||
|
1. Eseguire `npm run start:canti` -> Verificare che il logo sia quello di CantiCristiani e il colore dominante sia il blu/verde originale.
|
||||||
|
2. Eseguire `npm run start:stereocomics` -> Verificare che l'interfaccia risponda con il branding Stereocomics (colore primario cambiato, nome app cambiato in testata, logo corretto).
|
||||||
|
|
||||||
|
### Verifica PWA e Build di Produzione
|
||||||
|
1. Eseguire `npm run build:stereocomics`.
|
||||||
|
2. Controllare che la cartella `/www` contenga il file `manifest.webmanifest` con i riferimenti a Stereocomics.
|
||||||
|
3. Verificare che l'email di contatto visualizzata sia `info@stereocomics.it`.
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# Studio di Dettaglio: Opzione 2 - Angular Workspace Monorepo
|
||||||
|
|
||||||
|
Questo documento descrive in dettaglio la strategia, la configurazione e i passaggi necessari per implementare la clonazione del progetto **canti** per la variante **stereocomics** attraverso l'**Opzione 2: Angular Workspace Monorepo**.
|
||||||
|
|
||||||
|
Con questo approccio, il repository viene strutturato per ospitare **due applicazioni distinte** (`canti` e `stereocomics`) e **una o più librerie condivise** (`shared-core`) che conterranno la logica di business comune (servizi audio, parser di testi, database locale, integrazioni API).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architettura e Struttura delle Cartelle
|
||||||
|
|
||||||
|
Nel modello Monorepo, la struttura dei file viene riorganizzata per isolare l'interfaccia utente (pagine, componenti, temi) e condividere i servizi fondamentali.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
subgraph Monorepo Workspace
|
||||||
|
Shared[Libreria Condivisa: @shared/core]
|
||||||
|
AppCanti[App 1: Canti]
|
||||||
|
AppStereo[App 2: Stereocomics]
|
||||||
|
end
|
||||||
|
|
||||||
|
AppCanti --> Shared
|
||||||
|
AppStereo --> Shared
|
||||||
|
```
|
||||||
|
|
||||||
|
La struttura delle cartelle diventerà simile alla seguente:
|
||||||
|
```text
|
||||||
|
canti/
|
||||||
|
├── angular.json
|
||||||
|
├── package.json
|
||||||
|
├── tsconfig.json
|
||||||
|
├── src/ <-- Diventa l'applicazione Canti originale
|
||||||
|
│ ├── app/
|
||||||
|
│ │ ├── pages/ <-- Pagine specifiche di Canti
|
||||||
|
│ │ └── app.module.ts
|
||||||
|
│ └── assets/ <-- Asset di Canti
|
||||||
|
├── projects/
|
||||||
|
│ ├── shared-core/ <-- Libreria condivisa generata
|
||||||
|
│ │ ├── src/
|
||||||
|
│ │ │ ├── public-api.ts
|
||||||
|
│ │ │ └── lib/
|
||||||
|
│ │ │ └── services/ <-- AudioEngine, LyricsParser, CantiService, ecc.
|
||||||
|
│ └── stereocomics/ <-- Nuova applicazione Stereocomics
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── app/
|
||||||
|
│ │ │ ├── pages/ <-- Pagine e UI customizzate di Stereocomics
|
||||||
|
│ │ │ └── app.module.ts
|
||||||
|
│ │ ├── assets/ <-- Asset di Stereocomics
|
||||||
|
│ │ └── theme/ <-- Fogli di stile di Stereocomics
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Passaggi Operativi per l'Implementazione
|
||||||
|
|
||||||
|
### Passo 2.1: Inizializzazione della Libreria Condivisa
|
||||||
|
Generiamo una libreria Angular per ospitare i servizi condivisi.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate library shared-core --prefix=shared
|
||||||
|
```
|
||||||
|
|
||||||
|
Questo comando creerà la cartella `projects/shared-core/` e aggiornerà [angular.json](file:///Users/davidfrassi/SRC/agenti/canti/angular.json) e [tsconfig.json](file:///Users/davidfrassi/SRC/agenti/canti/tsconfig.json) inserendo il path alias (es. `@shared/core`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Passo 2.2: Migrazione dei Servizi Core
|
||||||
|
Sposteremo tutti i servizi non legati direttamente alla UI da `src/app/services/` a `projects/shared-core/src/lib/services/`.
|
||||||
|
|
||||||
|
Servizi da migrare:
|
||||||
|
* [audio-engine.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/audio-engine.service.ts)
|
||||||
|
* [canti.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/canti.service.ts)
|
||||||
|
* [lyrics-parser.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/lyrics-parser.service.ts)
|
||||||
|
* Eventuali altri helper o interfacce dati comuni.
|
||||||
|
|
||||||
|
Esponiamo i servizi nel file `projects/shared-core/src/public-api.ts`:
|
||||||
|
```typescript
|
||||||
|
export * from './lib/services/audio-engine.service';
|
||||||
|
export * from './lib/services/canti.service';
|
||||||
|
export * from './lib/services/lyrics-parser.service';
|
||||||
|
```
|
||||||
|
|
||||||
|
Aggiorneremo quindi gli import all'interno dell'applicazione principale `canti` (e successivamente `stereocomics`):
|
||||||
|
```typescript
|
||||||
|
// Da:
|
||||||
|
import { CantiService } from '../services/canti.service';
|
||||||
|
// A:
|
||||||
|
import { CantiService } from '@shared/core';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Passo 2.3: Generazione della Nuova Applicazione Stereocomics
|
||||||
|
Creiamo la seconda applicazione all'interno del workspace:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng generate application stereocomics --routing --style=scss
|
||||||
|
```
|
||||||
|
|
||||||
|
In [angular.json](file:///Users/davidfrassi/SRC/agenti/canti/angular.json) verrà aggiunto un nuovo progetto denominato `stereocomics`.
|
||||||
|
|
||||||
|
#### Integrazione Ionic
|
||||||
|
Per abilitare le funzionalità Ionic (componenti UI, gesture, ecc.) nella nuova applicazione, occorre:
|
||||||
|
1. Importare `IonicModule.forRoot()` nel file `projects/stereocomics/src/app/app.module.ts`.
|
||||||
|
2. Copiare o adattare la struttura di theming da `src/theme/` a `projects/stereocomics/src/theme/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Passo 2.4: Personalizzazione delle Pagine e dei Componenti
|
||||||
|
A differenza dell'Opzione 1 (dove le pagine HTML sono identiche), nell'Opzione 2 `stereocomics` ha le sue pagine indipendenti in `projects/stereocomics/src/app/pages/`.
|
||||||
|
|
||||||
|
È possibile:
|
||||||
|
* Creare layout completamente differenti.
|
||||||
|
* Aggiungere nuove feature o pagine esclusive (es. una sezione "Comics" o "Store") senza intaccare minimamente l'applicazione `canti`.
|
||||||
|
* Importare i servizi core da `@shared/core` in ciascuna pagina per gestire la logica dei canti e dell'audio.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Passo 2.5: Script in `package.json`
|
||||||
|
Modificheremo gli script di avvio e build per differenziare i target:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"scripts": {
|
||||||
|
"start:canti": "ng serve app --port=4200",
|
||||||
|
"start:stereocomics": "ng serve stereocomics --port=4300",
|
||||||
|
"build:shared": "ng build shared-core",
|
||||||
|
"build:canti": "npm run build:shared && ng build app --configuration=production",
|
||||||
|
"build:stereocomics": "npm run build:shared && ng build stereocomics --configuration=production"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Passo 2.6: Gestione di Capacitor (Mobile Apps Separate)
|
||||||
|
Per compilare le due app per iOS/Android in modo totalmente indipendente:
|
||||||
|
1. L'app `canti` continuerà ad usare la configurazione di Capacitor a livello di root, oppure verrà spostata in una sottocartella.
|
||||||
|
2. Inizializziamo Capacitor specificamente per `stereocomics` posizionando un file `capacitor.config.ts` all'interno di `projects/stereocomics/`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { CapacitorConfig } from '@capacitor/cli';
|
||||||
|
|
||||||
|
const config: CapacitorConfig = {
|
||||||
|
appId: 'it.stereocomics.app',
|
||||||
|
appName: 'Stereocomics',
|
||||||
|
webDir: '../../dist/stereocomics', // Punterà alla cartella di build di Angular
|
||||||
|
bundledWebRuntime: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Vantaggi e Svantaggi dell'Opzione 2
|
||||||
|
|
||||||
|
### Vantaggi:
|
||||||
|
1. **Massima Libertà di Design e UX**: L'applicazione `stereocomics` può avere una struttura di navigazione, pagine e flussi utente completamente diversi da `canti`.
|
||||||
|
2. **Isolamento del Codice UI**: I cambiamenti estetici o le nuove feature di interfaccia introdotte su Stereocomics non rischiano di rompere l'applicazione Canti originale.
|
||||||
|
3. **Riutilizzo della Logica di Business**: Tutta la logica complessa dei database locali, del lettore audio e dei parser rimane centralizzata e testata una volta sola nella libreria `@shared/core`.
|
||||||
|
|
||||||
|
### Svantaggi:
|
||||||
|
1. **Sforzo Iniziale Elevato**: Richiede un refactoring iniziale importante per estrarre tutti i servizi e ridefinire i path di importazione in tutto il progetto.
|
||||||
|
2. **Build più Lente**: La compilazione richiede prima la build delle librerie condivise e poi dell'applicazione specifica.
|
||||||
|
3. **Gestione delle Dipendenze**: Sebbene condividano lo stesso `package.json`, l'aggiornamento di una libreria esterna (es. Ionic o Angular) deve essere testato su entrambe le applicazioni per evitare regressioni.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Criteri di Accettazione e Verifica (Verification Plan)
|
||||||
|
|
||||||
|
### Verifica dello Sviluppo Locale
|
||||||
|
1. Eseguire `npm run start:canti` -> Verificare il funzionamento completo dell'app principale sulla porta `4200`.
|
||||||
|
2. Eseguire `npm run start:stereocomics` -> Verificare che la nuova app risponda sulla porta `4300` con il layout personalizzato.
|
||||||
|
|
||||||
|
### Verifica della Compilazione Condivisa
|
||||||
|
1. Apportare una modifica al servizio `CantiService` (es. aggiungere un log).
|
||||||
|
2. Verificare che la modifica sia visibile ed efficace sia su `canti` che su `stereocomics` dopo aver compilato la libreria condivisa.
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Guida alla Creazione di "stereocomics" tramite Angular Workspace Monorepo
|
||||||
|
|
||||||
|
Questo documento descrive dettagliatamente la strategia dell'**Opzione 2 (Monorepo)**. L'obiettivo è trasformare l'attuale struttura del progetto in un Workspace Angular multi-applicazione, dove le logiche funzionali (servizi, parser, gestione audio) risiedono in una libreria condivisa, mentre le applicazioni `canti` e `stereocomics` rimangono indipendenti per quanto riguarda interfacce grafiche, stili, asset e configurazioni.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Struttura Finale del Workspace Monorepo
|
||||||
|
|
||||||
|
Al termine del processo, la struttura delle cartelle del progetto si presenterà così:
|
||||||
|
|
||||||
|
```text
|
||||||
|
canti/ (Root del Workspace)
|
||||||
|
├── angular.json # Configurazione di build per entrambi i progetti
|
||||||
|
├── package.json # Dipendenze condivise
|
||||||
|
├── tsconfig.json # Configurazione TypeScript con path alias per il core
|
||||||
|
├── src/ # Codice sorgente dell'applicazione originale "canti"
|
||||||
|
│ ├── app/ # Componenti, pagine e routing specifici di canti
|
||||||
|
│ └── assets/ # Immagini, loghi e risorse di canti
|
||||||
|
└── projects/
|
||||||
|
├── core/ # LIBRERIA CONDIVISA (TypeScript puro)
|
||||||
|
│ └── src/
|
||||||
|
│ ├── public-api.ts # Esporta i servizi core
|
||||||
|
│ └── lib/
|
||||||
|
│ └── services/ # I servizi estratti (audio, parser, canti, settings, ecc.)
|
||||||
|
└── stereocomics/ # NUOVA APPLICAZIONE "stereocomics"
|
||||||
|
├── src/
|
||||||
|
│ ├── app/ # Pagine, componenti e routing specifici di stereocomics
|
||||||
|
│ ├── assets/ # Loghi, immagini e splash screen di stereocomics
|
||||||
|
│ └── theme/ # CSS/SCSS personalizzato (variabili di colore diverse)
|
||||||
|
└── capacitor.config.ts # Configurazione Capacitor specifica (es. per iOS/Android)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fasi di Implementazione Dettagliate
|
||||||
|
|
||||||
|
### Fase 1: Creazione della Libreria Condivisa (`core`)
|
||||||
|
Il primo passo consiste nel creare un modulo di libreria all'interno del workspace.
|
||||||
|
|
||||||
|
1. **Generazione della libreria**:
|
||||||
|
Utilizzando l'Angular CLI dalla root del progetto:
|
||||||
|
```bash
|
||||||
|
ng generate library core --prefix=core
|
||||||
|
```
|
||||||
|
Questo comando creerà la cartella `projects/core` e configurerà automaticamente i path alias nel file `tsconfig.json` (es. `"@core/*"` o `"core"`).
|
||||||
|
|
||||||
|
2. **Migrazione dei Servizi**:
|
||||||
|
Sposteremo i file dei servizi core da `src/app/services/` a `projects/core/src/lib/services/`. I file principali da migrare includono:
|
||||||
|
- [lyrics-parser.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/lyrics-parser.service.ts)
|
||||||
|
- [audio-engine.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/audio-engine.service.ts)
|
||||||
|
- [canti.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/canti.service.ts)
|
||||||
|
- [playlist.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/playlist.service.ts)
|
||||||
|
- [settings.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/settings.service.ts)
|
||||||
|
- [comunita.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/comunita.service.ts)
|
||||||
|
- E gli altri servizi correlati.
|
||||||
|
|
||||||
|
3. **Esportazione delle API**:
|
||||||
|
Nel file `projects/core/src/public-api.ts`, esporteremo tutti i servizi migrati in modo che siano importabili dalle applicazioni esterne:
|
||||||
|
```typescript
|
||||||
|
export * from './lib/services/canti.service';
|
||||||
|
export * from './lib/services/lyrics-parser.service';
|
||||||
|
// ... altre esportazioni
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Compilazione iniziale**:
|
||||||
|
Si compila la libreria core affinché sia disponibile per i progetti:
|
||||||
|
```bash
|
||||||
|
ng build core
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 2: Adeguamento dell'Applicazione `canti`
|
||||||
|
Dopo aver spostato i servizi nella libreria condivisa, dobbiamo aggiornare l'applicazione originale affinché consumi la libreria anziché i vecchi file locali.
|
||||||
|
|
||||||
|
1. **Aggiornamento degli Import**:
|
||||||
|
In tutti i componenti e pagine di `canti` (es. `src/app/home/home.page.ts`), modificheremo gli import dei servizi:
|
||||||
|
*Prima:*
|
||||||
|
```typescript
|
||||||
|
import { CantiService } from '../services/canti.service';
|
||||||
|
```
|
||||||
|
*Dopo:*
|
||||||
|
```typescript
|
||||||
|
import { CantiService } from 'core';
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Rimozione dei Vecchi Servizi**:
|
||||||
|
Elimineremo la cartella `src/app/services/` ormai vuota.
|
||||||
|
|
||||||
|
3. **Test di Verifica**:
|
||||||
|
Avvieremo `canti` con `npm run start` (o `ng serve`) per assicurarci che l'applicazione funzioni correttamente importando i servizi dalla libreria.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 3: Generazione dell'Applicazione `stereocomics`
|
||||||
|
Ora che le fondamenta condivise sono pronte, creiamo la nuova applicazione `stereocomics`.
|
||||||
|
|
||||||
|
1. **Generazione**:
|
||||||
|
Sempre tramite Angular CLI:
|
||||||
|
```bash
|
||||||
|
ng generate application stereocomics --style=scss --routing=true
|
||||||
|
```
|
||||||
|
Questo configurerà un nuovo blocco chiamato `stereocomics` in `angular.json` e creerà la cartella `projects/stereocomics`.
|
||||||
|
|
||||||
|
2. **Integrazione con Ionic**:
|
||||||
|
Aggiungeremo il supporto a Ionic nella nuova applicazione importando `IonicModule.forRoot()` nel file `projects/stereocomics/src/app/app.module.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 4: Sviluppo e Personalizzazione di `stereocomics`
|
||||||
|
A questo punto abbiamo un'applicazione vergine che possiamo strutturare come vogliamo, riutilizzando però i servizi core.
|
||||||
|
|
||||||
|
1. **Struttura delle Pagine**:
|
||||||
|
Possiamo decidere di copiare le pagine esistenti di `canti` (se vogliamo che `stereocomics` parta con lo stesso layout per poi essere modificato) oppure creare pagine del tutto nuove.
|
||||||
|
Ad esempio, per generare una pagina specifica in stereocomics:
|
||||||
|
```bash
|
||||||
|
ng generate page pages/home --project=stereocomics
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Consumo dei Servizi Shared**:
|
||||||
|
Nel codice di `stereocomics`, per caricare i dati useremo la libreria condivisa:
|
||||||
|
```typescript
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { CantiService } from 'core';
|
||||||
|
|
||||||
|
@Component({ ... })
|
||||||
|
export class HomePage implements OnInit {
|
||||||
|
constructor(private cantiService: CantiService) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
// Possiamo accedere a tutte le funzioni storiche
|
||||||
|
this.cantiService.loadCanti();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Personalizzazione Visiva (Branding)**:
|
||||||
|
- Modificheremo `projects/stereocomics/src/theme/variables.scss` per definire la palette di colori di `stereocomics` (ad es. tonalità arancioni o viola, differenziandosi dal blu di `canti`).
|
||||||
|
- Sostituiremo gli asset in `projects/stereocomics/src/assets/` con loghi, icone e immagini dedicati a stereocomics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 5: Configurazione dei Comandi di Build
|
||||||
|
Aggiungeremo o modificheremo gli script nel file `package.json` per facilitare lo sviluppo parallelo:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"scripts": {
|
||||||
|
"start:canti": "ng serve app",
|
||||||
|
"start:stereocomics": "ng serve stereocomics",
|
||||||
|
"build:canti": "ng build app --configuration production",
|
||||||
|
"build:stereocomics": "ng build stereocomics --configuration production",
|
||||||
|
"watch:core": "ng build core --watch"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Durante lo sviluppo, se modifichi un servizio core, il comando `watch:core` ricompilerà automaticamente la libreria in background, aggiornando istantaneamente l'app che stai servendo localmente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vantaggi di questo Approccio
|
||||||
|
|
||||||
|
* **Sorgente di Verità Unica (Single Source of Truth)**: La complessa logica di business (algoritmi di parsing, logica audio per i player, caching, sync) viene scritta, testata e manutenuta in un solo posto (`projects/core`).
|
||||||
|
* **Autonomia Grafica ed Esperienziale**: `stereocomics` ha le sue pagine HTML e i suoi fogli di stile CSS/SCSS. Può avere una navigazione a tab, mentre `canti` usa un menu laterale (sidemenu), senza alcun conflitto.
|
||||||
|
* **Semplicità di Aggiornamento delle Dipendenze**: Entrambe le applicazioni utilizzano gli stessi pacchetti npm (configurati nel `package.json` globale nella root), riducendo il disallineamento delle versioni delle librerie terze (es. Capacitor, Ionic, Angular).
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# Piano Operativo: Clonazione e Personalizzazione in "stereocomics"
|
||||||
|
|
||||||
|
Questo piano descrive le opzioni e le fasi operative per creare un clone del progetto **canti** denominato **stereocomics**. Il clone condividerà le stesse librerie funzionali di base (servizi, parser, logica audio, ecc.) offrendo al contempo completa libertà di personalizzazione (grafica, asset, configurazioni, ed eventuali pagine specifiche).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Opzioni Architetturali Proposte
|
||||||
|
|
||||||
|
Prima di procedere con l'implementazione, è fondamentale scegliere l'approccio strutturale più adatto:
|
||||||
|
|
||||||
|
### Opzione 1: White-Label / Multi-Configuration (Consigliata per Manutenibilità)
|
||||||
|
*Se l'applicazione stereocomics differisce principalmente per branding, colori, logo, configurazioni e alcuni testi, ma condivide la quasi totalità delle pagine e dei flussi.*
|
||||||
|
- **Come funziona**: Si mantiene un unico codice sorgente. Si usano i file di configurazione ambientale di Angular (`src/environments/`) e file CSS personalizzati per caricare dinamicamente loghi, stili (tramite variabili CSS/SCSS), e comportamenti in base alla build (es. `ng build --configuration=stereocomics`).
|
||||||
|
- **Pro**: Semplicità assoluta di manutenzione. Qualsiasi bug fix o nuova funzionalità su un servizio o una pagina si riflette istantaneamente su entrambi i brand senza duplicazione di codice.
|
||||||
|
- **Contro**: Meno flessibilità se le pagine di `stereocomics` dovranno divergere drasticamente a livello di layout HTML o logica di navigazione rispetto a `canti`.
|
||||||
|
|
||||||
|
### Opzione 2: Angular Workspace Monorepo (Consigliata per Massima Personalizzazione)
|
||||||
|
*Se stereocomics deve avere pagine, componenti e flussi di navigazione diversi da canti, pur riutilizzando gli stessi servizi (audio, parser, database local, ecc.).*
|
||||||
|
- **Come funziona**: Si trasforma il progetto in un workspace Angular multi-applicazione.
|
||||||
|
1. Si crea una libreria condivisa (es. `projects/shared-core`) dove vengono spostati tutti i servizi di base (`src/app/services/*`).
|
||||||
|
2. L'applicazione attuale viene configurata come progetto `canti`.
|
||||||
|
3. Viene generata una nuova applicazione Angular/Ionic nello stesso workspace (`projects/stereocomics`) che importa i servizi da `shared-core` ma ha le sue pagine, i suoi componenti e la sua veste grafica indipendenti.
|
||||||
|
- **Pro**: Massimo controllo. Ciascuna app ha la sua struttura di pagine, ma condividono al 100% la logica complessa dei servizi.
|
||||||
|
- **Contro**: Richiede una ristrutturazione iniziale dei path di importazione dei servizi nel progetto attuale.
|
||||||
|
|
||||||
|
### Opzione 3: Repository / Cartella Indipendente
|
||||||
|
*Se si desidera un progetto completamente separato in una nuova cartella `/Users/davidfrassi/SRC/agenti/stereocomics`.*
|
||||||
|
- **Come funziona**: Si clona il progetto in una nuova cartella e si personalizza in modo indipendente. Per condividere le librerie, si può creare un package locale (`npm link`) o importare i servizi come sottomodulo git.
|
||||||
|
- **Pro**: Isolamento totale.
|
||||||
|
- **Contro**: Rischio elevato di divergenza del codice. I bug fix sui servizi in un progetto dovranno essere riportati manualmente o gestiti tramite rilasci di pacchetti.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Review Required
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Si prega di verificare quale delle tre opzioni si adatta meglio alle esigenze di sviluppo a lungo termine di **stereocomics**.
|
||||||
|
>
|
||||||
|
> - Se il clone differisce solo per loghi, colori e piccoli dettagli, l'**Opzione 1 (White-Label)** è la più rapida ed efficiente.
|
||||||
|
> - Se il clone deve avere un'interfaccia utente o funzionalità molto diverse pur usando la stessa logica di lettura/parsing, l'**Opzione 2 (Monorepo)** è la scelta ideale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fasi del Piano Operativo (Esempio basato sull'Opzione 2 - Monorepo)
|
||||||
|
|
||||||
|
Se si sceglie l'approccio Monorepo, i passi saranno i seguenti:
|
||||||
|
|
||||||
|
### Fase 1: Preparazione e Ristrutturazione (Refactoring dei Servizi)
|
||||||
|
1. Spostare i servizi core (ad es. [lyrics-parser.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/lyrics-parser.service.ts), [audio-engine.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/audio-engine.service.ts), [canti.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/canti.service.ts)) in una libreria condivisa o in una cartella core dedicata configurata con path alias in `tsconfig.json` (es. `@shared/services`).
|
||||||
|
2. Aggiornare gli import in tutto il progetto `canti` per utilizzare il nuovo path alias.
|
||||||
|
|
||||||
|
### Fase 2: Creazione del Progetto Stereocomics
|
||||||
|
1. Generare la nuova applicazione all'interno del workspace o duplicare la struttura configurando il nuovo target in [angular.json](file:///Users/davidfrassi/SRC/agenti/canti/angular.json).
|
||||||
|
2. Configurare gli asset (immagini, loghi, splash screen) per `stereocomics` in una cartella dedicata.
|
||||||
|
3. Creare il file di configurazione specifico per stereocomics (`environment.stereocomics.ts`).
|
||||||
|
|
||||||
|
### Fase 3: Personalizzazione e Stile
|
||||||
|
1. Creare un tema CSS/SCSS personalizzato per `stereocomics` modificando le variabili di colore Ionic/CSS.
|
||||||
|
2. Sviluppare eventuali componenti o pagine specifiche per `stereocomics`.
|
||||||
|
|
||||||
|
### Fase 4: Configurazione della Build e Deploy
|
||||||
|
1. Configurare gli script npm in `package.json` per avviare e buildare specificamente il nuovo target (es. `npm run start:stereocomics`, `npm run build:stereocomics`).
|
||||||
|
2. Configurare Capacitor/PWA per il nuovo brand (nuovo package ID, nome dell'app, icone).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification Plan
|
||||||
|
|
||||||
|
### Manual Verification
|
||||||
|
- Avvio di `canti` in modalità sviluppo per verificare che il refactoring dei servizi non abbia introdotto regressioni.
|
||||||
|
- Avvio di `stereocomics` per verificare il caricamento del nuovo tema, logo e impostazioni personalizzate.
|
||||||
|
- Test delle funzionalità core (riproduzione, parsing testi) in entrambe le applicazioni.
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
# Specifiche e Considerazioni di Design: Editor Visuale Intelligente
|
||||||
|
|
||||||
|
Questo documento raccoglie l'analisi e le specifiche di design per l'implementazione del sistema di **Scansione Fotocamera & AI** per la digitalizzazione rapida dei canti nella PWA.
|
||||||
|
L'obiettivo è progettare un'esperienza utente a "sfregamento zero" (zero friction), robusta anche in ambienti ostili (es. chiese senza campo) e capace di gestire le imperfezioni storiche dei libretti parrocchiali.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Architettura della Pipeline Visiva
|
||||||
|
|
||||||
|
Il processo si basa su un'interazione fluida tra client (PWA), proxy server ed il motore multimodale di visione artificiale (Google Gemini 2.5 Flash).
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
A[Inquadratura Fotocamera PWA] --> B[Compressione Client Canvas JPEG ~200KB]
|
||||||
|
B --> C{Connessione Rete?}
|
||||||
|
C -- Online --> D[Invia POST a /scanSong]
|
||||||
|
C -- Offline --> E[Salva in IndexedDB localmente]
|
||||||
|
E --> F[In attesa di Connessione...]
|
||||||
|
F --> C
|
||||||
|
D --> G[Chiamata a Gemini 2.5 Flash]
|
||||||
|
G --> H[Analisi Spaziale ed Estrazione JSON]
|
||||||
|
H --> I[Popolamento Automatico dell'Editor PWA]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Casi d'Uso Avanzati e Soluzioni Specifiche
|
||||||
|
|
||||||
|
### A. Gestione Multi-Pagina (Canti Lunghi)
|
||||||
|
* **Problema**: Molti canti liturgici (es. Salmi o canti d'ingresso lunghi) si sviluppano su due pagine adiacenti o su foglietti fronte-retro separati.
|
||||||
|
* **Soluzione**: L'interfaccia dell'editor consentirà all'utente di aggiungere più scatti all'interno della stessa sessione di scansione prima dell'invio.
|
||||||
|
* **Gestione AI**: Le immagini verranno inviate in blocco al modello di visione (sfruttando l'enorme finestra di contesto di Gemini). Il prompt istruirà l'IA a considerare le foto come sequenziali, unendo il testo in un unico flusso coerente senza duplicare il titolo ed ordinando le strofe in modo corretto.
|
||||||
|
|
||||||
|
### B. Correzione dei Refusi Storici (Libretti Anni '90)
|
||||||
|
* **Problema**: I libretti parrocchiali stampati decenni fa o digitati a macchina presentano spesso refusi tipografici, lettere scambiate o scorrettezze sugli accenti (es. `perche'` al posto di `perché`, `piu'` per `più`, `Gesu'` per `Gesù`).
|
||||||
|
* **Soluzione**: Sfruttando la comprensione semantica del modello di linguaggio (LLM), l'IA non effettua una mera trascrizione pixel-per-pixel, ma analizza il senso delle parole nel contesto dei canti liturgici italiani (molti dei quali fanno già parte della sua conoscenza pregressa).
|
||||||
|
* **Risultato**: Restituzione di un testo grammaticalmente perfetto e pulito, correggendo automaticamente i vecchi accenti e gli errori di battitura della carta originale.
|
||||||
|
|
||||||
|
### C. Traduzione ed Uniformazione degli Accordi
|
||||||
|
* **Problema**: Gli spartiti o canzonieri cartacei possono utilizzare la notazione internazionale (`C, D, E, F, G, A, B`) anziché la notazione latina standard italiana (`Do, Re, Mi, Fa, Sol, La, Si`).
|
||||||
|
* **Soluzione**: Nel prompt di sistema, l'IA viene istruita ad uniformare tutti gli accordi alla notazione italiana standard.
|
||||||
|
* **Esempio**: Se l'utente inquadra un canzonierino scout o internazionale scritto in notazione inglese (es. `[C] [G] [Am]`), il sistema lo tradurrà istantaneamente in `[Do] [Sol] [Lam]` prima di inviarlo all'editor della PWA, garantendo la compatibilità al 100% con il modulo di trasposizione interna dei toni.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Strategia Offline (Resilienza in Chiesa)
|
||||||
|
|
||||||
|
Le chiese sono notoriamente ambienti difficili per la connettività di rete (mura in pietra spesse, sotterranei, assenza di ripetitori vicini). Il sistema non deve dipendere da una connessione sempre attiva.
|
||||||
|
|
||||||
|
### Flusso Offline con IndexedDB
|
||||||
|
1. L'utente scatta la foto (o seleziona l'immagine) dall'editor mentre si trova offline.
|
||||||
|
2. L'app converte l'immagine in `Blob` e la salva localmente all'interno di **IndexedDB** associandola a una coda di scansioni pendenti (`scan-offline-queue`).
|
||||||
|
3. L'interfaccia mostra un avviso rassicurante:
|
||||||
|
> 💾 *Fogli acquisiti offline! L'app elaborerà automaticamente la scansione del canto non appena rileverai una connessione internet stabile.*
|
||||||
|
4. Il `ConnectivityService` della PWA monitora lo stato della rete. Non appena il dispositivo torna online (es. all'uscita dalla chiesa o sotto WiFi):
|
||||||
|
* L'app estrae l'immagine da IndexedDB.
|
||||||
|
* Effettua la chiamata POST a `/scanSong`.
|
||||||
|
* Riceve il JSON tradotto e notifica l'utente (o precompila la scheda dei canti proposti).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Specifiche del Prompt di Sistema (System Prompt)
|
||||||
|
|
||||||
|
L'output del backend deve essere rigidamente controllato e tipizzato per consentire al client Angular di digerirlo senza eccezioni di parsing. Il prompt di sistema da implementare sul proxy API sarà il seguente:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Sei un OCR musicale multimodale specializzato in canti liturgici e religiosi cristiani.
|
||||||
|
Analizza l'immagine fornita (o il set di immagini sequenziali) e restituisci ESCLUSIVAMENTE un oggetto JSON valido. Non aggiungere spiegazioni o markdown.
|
||||||
|
|
||||||
|
Schema JSON di risposta:
|
||||||
|
{
|
||||||
|
"titolo": "String (titolo del canto)",
|
||||||
|
"autore": "String o null",
|
||||||
|
"sezioni": [
|
||||||
|
{
|
||||||
|
"tipo": "ritornello" | "strofa",
|
||||||
|
"numero": Number o null (solo per le strofe, es. 1, 2, 3),
|
||||||
|
"righe": [
|
||||||
|
"Testo completo della riga con accordi musicali inseriti in linea tra parentesi quadre prima della sillaba corretta. Es: [Do]Tu sei la mia [Sol]vita"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
Regole di formattazione:
|
||||||
|
1. Traduci tutti gli accordi in notazione italiana (Do, Re, Mi, Fa, Sol, La, Si, con 'm' per i minori, '7' per le settime, ecc.).
|
||||||
|
2. Inserisci gli accordi esattamente nel punto in cui cadono spazialmente sopra le parole dell'immagine.
|
||||||
|
3. Correggi accenti storici errati (es. trasformando 'perche'' in 'perché') e refusi evidenti.
|
||||||
|
```
|
||||||
|
|
||||||
|
Questo approccio garantisce che la PWA riceva un formato perfettamente strutturato e pronto per essere inserito direttamente nelle righe del database locale.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Approccio 100% Locale e Gratuito (Client-Side)
|
||||||
|
|
||||||
|
Se il vincolo fondamentale è **non utilizzare alcun servizio cloud a pagamento o esterno**, possiamo spostare l'intero carico di elaborazione (OCR ed allineamento) **direttamente all'interno del browser dell'utente (client-side)** in modo 100% gratuito ed autonomo.
|
||||||
|
|
||||||
|
Ciò è realizzabile integrando due componenti open-source nella PWA:
|
||||||
|
|
||||||
|
1. **Tesseract.js** (compilato in WebAssembly per il browser): si occupa di estrarre il testo e, soprattutto, di fornirci i metadati spaziali di ogni singola parola.
|
||||||
|
2. **Algoritmo Euristico di Allineamento Spaziale**: un algoritmo scritto da noi in TypeScript che mappa la posizione fisica degli accordi rispetto al testo.
|
||||||
|
|
||||||
|
### Come funziona l'algoritmo di allineamento locale?
|
||||||
|
|
||||||
|
Quando Tesseract.js scansiona l'immagine, non restituisce solo una stringa, ma un array di parole in cui ogni parola contiene il testo ed il suo **Bounding Box (rettangolo di coordinate fisiche sull'immagine: `x0`, `y0`, `x1`, `y1`)**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface OCRWord {
|
||||||
|
text: string;
|
||||||
|
bbox: { x0: number; y0: number; x1: number; y1: number };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
L'algoritmo elabora questi dati in 4 fasi:
|
||||||
|
|
||||||
|
#### Fase 1: Raggruppamento in Righe Fisiche
|
||||||
|
Le parole vengono ordinate per coordinata `y0`. Le parole che hanno valori di `y0` simili (con una tolleranza basata sull'altezza dei caratteri, es. ±15 pixel) vengono raggruppate nella stessa **riga orizzontale**.
|
||||||
|
|
||||||
|
#### Fase 2: Classificazione delle Righe (Accordi vs. Testo)
|
||||||
|
Ogni riga viene analizzata per determinare la sua natura:
|
||||||
|
* **Riga di Accordi**: Se più del 70% delle parole nella riga corrisponde a pattern di accordi noti (es. parole corte come `Do`, `Sol`, `Re7`, `Lam`, `C`, `G`, `D7`), la riga viene marcata come riga di accordi.
|
||||||
|
* **Riga di Testo**: In tutti gli altri casi, viene considerata una riga di testo normale.
|
||||||
|
|
||||||
|
#### Fase 3: Mappatura e Iniezione degli Accordi
|
||||||
|
Per ogni **Riga di Accordi**, individuiamo la **Riga di Testo** immediatamente sottostante (quella con coordinata `y` successiva più vicina).
|
||||||
|
Per ciascun accordo presente nella riga:
|
||||||
|
1. Calcoliamo la sua coordinata orizzontale centrale: `x_accordo = (bbox.x0 + bbox.x1) / 2`.
|
||||||
|
2. Scorriamo le parole della riga di testo sottostante per trovare quale parola (o lettera) ha la coordinata `x` più vicina a `x_accordo`.
|
||||||
|
3. Inseriamo la stringa dell'accordo tra parentesi quadre (es. `[Re]`) esattamente prima di quella parola nel testo finale.
|
||||||
|
|
||||||
|
```text
|
||||||
|
Spaziatura fisica sull'immagine:
|
||||||
|
Riga 1 (Accordi): [Do] [Sol]
|
||||||
|
Riga 2 (Testo): Tu sei la mia vita, altro io non ho.
|
||||||
|
↑ ↑
|
||||||
|
x=120 x=480
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Fase 4: Segmentazione Strutturale delle Sezioni
|
||||||
|
L'algoritmo identifica i cambi di sezione (es. il passaggio da Ritornello a Strofa) misurando lo spazio verticale (`gap y`) tra le righe di testo. Un distanziamento verticale significativamente maggiore della media indica una separazione di strofa, consentendo di dividere il canto in blocchi strutturati.
|
||||||
|
|
||||||
|
### Pro e Contro dell'Approccio Locale
|
||||||
|
|
||||||
|
| Vantaggi | Svantaggi |
|
||||||
|
| :--- | :--- |
|
||||||
|
| 🟢 **100% Gratuito**: Nessun costo di licenza o API esterne. | 🔴 **Uso Risorse**: L'elaborazione OCR avviene sul telefono dell'utente (richiede 3-5 secondi). |
|
||||||
|
| 🟢 **Totale Privacy**: Le immagini non lasciano mai il dispositivo. | 🔴 **Meno tolleranza alle pieghe**: Rispetto a Gemini, un OCR locale soffre di più se la foto è storta o con ombre. |
|
||||||
|
| 🟢 **Funzionamento Offline**: Funziona anche nel deserto, senza alcuna rete. | 🔴 **Nessuna correzione lessicale**: Non corregge gli errori grammaticali storici del foglio cartaceo. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Il Compromesso Ideale: Gemini API Free Tier
|
||||||
|
|
||||||
|
Esiste una terza via che unisce il meglio dei due mondi (la precisione assoluta dell'AI e il costo zero): **il Free Tier delle API di Google Gemini 2.5 Flash**.
|
||||||
|
|
||||||
|
Google offre tramite **Google AI Studio** un piano d'uso completamente gratuito con i seguenti limiti:
|
||||||
|
* **15 Richieste al minuto (RPM)**
|
||||||
|
* **1500 Richieste al giorno (RPD)**
|
||||||
|
|
||||||
|
Per un'applicazione di canti religiosi dove l'aggiunta di nuovi brani è un'operazione sporadica (eseguita da pochi amministratori/chitarristi solo quando si impara un canto nuovo), **questi limiti gratuiti non verranno mai superati**.
|
||||||
|
|
||||||
|
* **Implementazione sicura**: Per evitare di inserire la chiave API gratuita all'interno del codice frontend (dove chiunque potrebbe rubarla), si fa passare la richiesta attraverso una semplice funzione serverless gratuita (es. su *Vercel Free Tier* o *Cloudflare Workers*), che funge da proxy sicuro e vi applica la chiave API prima di chiamare Google.
|
||||||
|
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Specifiche Tecniche: Integrazione Servizio `/miei` con PWA Canti
|
||||||
|
|
||||||
|
Questo documento descrive le specifiche dell'endpoint `/miei` implementato sul server Node.js e fornisce le linee guida per implementare la sincronizzazione e l'invio delle playlist locali e dei canti personalizzati ("canti miei") dalla PWA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 Dettagli dell'Endpoint `/miei`
|
||||||
|
|
||||||
|
- **Metodo HTTP**: `POST`
|
||||||
|
- **URL di Sviluppo**: `http://localhost:3000/miei` (o l'IP/porta del tuo server in produzione)
|
||||||
|
- **Content-Type**: `application/json`
|
||||||
|
- **Limite Dimensione Body**: `10MB` (per supportare liste e testi completi di canti)
|
||||||
|
|
||||||
|
### 🔑 Identificazione Utente (`uid`)
|
||||||
|
L'endpoint richiede l'identificativo unico dell'utente (`uid`). Può essere trasmesso in tre modi (in ordine di priorità):
|
||||||
|
1. **Header HTTP**: `x-user-uid` (scelta consigliata) o `uid`
|
||||||
|
2. **Query Parameter**: `?uid=<user_uid>`
|
||||||
|
3. **Wrapper nel Body**: Se il corpo è un oggetto del tipo `{ "uid": "...", "canti": [...] }`
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> L'UID dell'utente deve essere una stringa valida e sicura. Sono accettati solo caratteri alfanumerici, trattini e trattini bassi (`^[a-zA-Z0-9_\-]+$`). Qualsiasi tentativo di path traversal (es. contenente `.` o `/`) restituirà un errore `400 Bad Request`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Formato dei Dati Richiesto (Payload)
|
||||||
|
|
||||||
|
L'endpoint si aspetta di ricevere un **array JSON** contenente la lista dei canti dell'utente (formato analogo a `canti.json`).
|
||||||
|
|
||||||
|
### Esempio di Payload (Array di Canti)
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id_canti": 9001,
|
||||||
|
"titolo": "Mio Canto Personalizzato 1",
|
||||||
|
"momenti": ["Ingresso"],
|
||||||
|
"periodi": ["Lode"],
|
||||||
|
"testo": "Testo del mio canto personalizzato...\nRit: Alleluia!"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id_canti": 9002,
|
||||||
|
"titolo": "Mio Canto Personalizzato 2",
|
||||||
|
"momenti": ["Comunione"],
|
||||||
|
"periodi": [],
|
||||||
|
"testo": "Testo del secondo canto..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Comportamento del Server
|
||||||
|
|
||||||
|
1. **Ricezione e Validazione**:
|
||||||
|
- Estrae l'UID ed il payload dei canti.
|
||||||
|
- Valida l'UID (sicurezza path traversal) e verifica che il payload sia un array JSON. In caso contrario, risponde con `400 Bad Request`.
|
||||||
|
2. **Logica di Backup**:
|
||||||
|
- Se nella cartella `data/` del server esiste già un file associato all'utente (`data/<uid>.json`), ne crea automaticamente una copia di backup rinominandola o copiandola in `data/<uid>.bak.json`.
|
||||||
|
3. **Salvataggio**:
|
||||||
|
- Scrive il nuovo JSON in `data/<uid>.json`.
|
||||||
|
- Risponde con `200 OK` e un JSON di conferma:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "File salvato con successo.",
|
||||||
|
"uid": "utente_test_123",
|
||||||
|
"backupCreated": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Requisiti per l'Implementazione nella PWA (Client Locale Mac M1)
|
||||||
|
|
||||||
|
L'istanza di Antigravity che lavora sul codice della PWA locale dovrà implementare una funzione di sincronizzazione (es. `syncLocalDataToServer`) che esegua i seguenti passi:
|
||||||
|
|
||||||
|
1. **Recupero dei dati locali**:
|
||||||
|
- Estrarre le playlist locali e i canti personalizzati creati dall'utente (solitamente salvati in `localStorage`, `IndexedDB`, o altro database locale).
|
||||||
|
2. **Normalizzazione e Formattazione**:
|
||||||
|
- Formattare e unire queste informazioni in un unico array JSON strutturato con i campi chiave per ciascun canto (`id_canti`, `titolo`, `momenti`, `periodi`, `testo`).
|
||||||
|
3. **Invio al Server**:
|
||||||
|
- Effettuare una chiamata `fetch` (POST) verso l'endpoint `/miei`.
|
||||||
|
- Passare l'UID dell'utente autenticato (es. da Firebase Auth o altro sistema di sessione) tramite l'header `x-user-uid`.
|
||||||
|
4. **Gestione del Feedback**:
|
||||||
|
- Mostrare all'utente una notifica di successo o gestire eventuali errori di rete o validazione.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "tmp-app",
|
||||||
|
"integrations": {},
|
||||||
|
"type": "angular"
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Karma configuration file, see link for more information
|
||||||
|
// https://karma-runner.github.io/1.0/config/configuration-file.html
|
||||||
|
|
||||||
|
module.exports = function (config) {
|
||||||
|
config.set({
|
||||||
|
basePath: '',
|
||||||
|
frameworks: ['jasmine', '@angular-devkit/build-angular'],
|
||||||
|
plugins: [
|
||||||
|
require('karma-jasmine'),
|
||||||
|
require('karma-chrome-launcher'),
|
||||||
|
require('karma-jasmine-html-reporter'),
|
||||||
|
require('karma-coverage'),
|
||||||
|
require('@angular-devkit/build-angular/plugins/karma')
|
||||||
|
],
|
||||||
|
client: {
|
||||||
|
jasmine: {
|
||||||
|
// you can add configuration options for Jasmine here
|
||||||
|
// the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html
|
||||||
|
// for example, you can disable the random execution with `random: false`
|
||||||
|
// or set a specific seed with `seed: 4321`
|
||||||
|
},
|
||||||
|
clearContext: false // leave Jasmine Spec Runner output visible in browser
|
||||||
|
},
|
||||||
|
jasmineHtmlReporter: {
|
||||||
|
suppressAll: true // removes the duplicated traces
|
||||||
|
},
|
||||||
|
coverageReporter: {
|
||||||
|
dir: require('path').join(__dirname, './coverage/app'),
|
||||||
|
subdir: '.',
|
||||||
|
reporters: [
|
||||||
|
{ type: 'html' },
|
||||||
|
{ type: 'text-summary' }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
reporters: ['progress', 'kjhtml'],
|
||||||
|
port: 9876,
|
||||||
|
colors: true,
|
||||||
|
logLevel: config.LOG_INFO,
|
||||||
|
autoWatch: true,
|
||||||
|
browsers: ['Chrome'],
|
||||||
|
singleRun: false,
|
||||||
|
restartOnFileChange: true
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# Strategia di Suggerimento Canti Liturgici
|
||||||
|
|
||||||
|
Questo documento descrive l'approccio tecnico utilizzato per suggerire i canti attinenti alle letture del giorno nella PWA "CantiCristiani".
|
||||||
|
|
||||||
|
## 1. Algoritmo di Analisi (Keyword Matching)
|
||||||
|
|
||||||
|
Al momento **non viene utilizzata un'Intelligenza Artificiale generativa (LLM)** per due motivi principali:
|
||||||
|
- **Performance**: L'analisi avviene istantaneamente sul dispositivo dell'utente senza chiamate API esterne lente.
|
||||||
|
- **Privacy e Offline**: Il sistema può funzionare anche con una connettività limitata una volta scaricato il testo delle letture.
|
||||||
|
|
||||||
|
### Fasi dell'Algoritmo:
|
||||||
|
|
||||||
|
1. **Keyword Extraction**:
|
||||||
|
- Il testo di tutte le letture (Prima, Salmo, Seconda, Vangelo) viene normalizzato in minuscolo.
|
||||||
|
- Vengono rimosse le "stop words" (parole comuni come articoli, preposizioni, congiunzioni).
|
||||||
|
- Vengono estratte solo le parole con lunghezza superiore a 3 caratteri per evitare rumore.
|
||||||
|
2. **Ponderazione e Scoring**:
|
||||||
|
- Ogni canto nel database viene analizzato cercando le keyword estratte.
|
||||||
|
- **Titolo (Peso 10)**: Se una parola chiave appare nel titolo, il punteggio del canto aumenta drasticamente.
|
||||||
|
- **Testo/Lyrics (Peso 1)**: Ogni occorrenza di una parola chiave nel testo del canto aggiunge 1 punto al punteggio totale.
|
||||||
|
3. **Classificazione per Momento Liturgico**:
|
||||||
|
- I canti vengono raggruppati in base alle categorie liturgiche (Ingresso, Gloria, Offertorio, Comunione, ecc.) definite nel database.
|
||||||
|
- Il sistema seleziona i migliori 3 canti per ogni categoria per offrire una scelta bilanciata.
|
||||||
|
|
||||||
|
## 2. Selezione della Data Predefinita
|
||||||
|
|
||||||
|
L'applicazione calcola automaticamente la **prossima domenica** come data di default. Questo perché la maggior parte degli utenti utilizza la funzione per preparare la liturgia festiva imminente.
|
||||||
|
|
||||||
|
## 3. Sviluppi Futuri: Ricerca Semantica (Embeddings)
|
||||||
|
|
||||||
|
Per superare i limiti del matching testuale, la strategia futura prevede l'integrazione di tecniche di **Semantic Search** che possono essere eseguite direttamente nella PWA:
|
||||||
|
|
||||||
|
### Architettura Proposta:
|
||||||
|
- **Pre-calcolo dei Vettori**: Generazione dei vettori (embeddings) per l'intero database dei canti. Questi dati verrebbero inclusi in una versione estesa del file `canti.json`.
|
||||||
|
- **Analisi On-Device**: Utilizzo di librerie matematiche leggere (come `similarity-js`) per calcolare la "distanza del coseno" tra il vettore della lettura e quelli dei canti.
|
||||||
|
- **Vantaggi**: Il sistema identificherebbe correlazioni concettuali (es. "Luce" -> "Mondo", "Pane" -> "Eucarestia") anche in assenza di corrispondenze testuali esatte.
|
||||||
|
|
||||||
|
### Implementazione Tecnica:
|
||||||
|
1. Integrazione di un modello di embedding (es. `all-MiniLM-L6-v2`) via Web Workers per non bloccare l'interfaccia.
|
||||||
|
2. Memorizzazione locale dei vettori tramite IndexedDB per garantire performance elevate dopo il primo caricamento.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/service-worker/config/schema.json",
|
||||||
|
"index": "/index.html",
|
||||||
|
"assetGroups": [
|
||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"installMode": "prefetch",
|
||||||
|
"resources": {
|
||||||
|
"files": [
|
||||||
|
"/index.html",
|
||||||
|
"/manifest.webmanifest",
|
||||||
|
"/*.css",
|
||||||
|
"/*.js"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "assets",
|
||||||
|
"installMode": "prefetch",
|
||||||
|
"updateMode": "prefetch",
|
||||||
|
"resources": {
|
||||||
|
"files": [
|
||||||
|
"/assets/**/*",
|
||||||
|
"/**/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dataGroups": [
|
||||||
|
{
|
||||||
|
"name": "canti-api",
|
||||||
|
"urls": [
|
||||||
|
"https://www.canticristiani.it/it/api/canti.json"
|
||||||
|
],
|
||||||
|
"cacheConfig": {
|
||||||
|
"maxSize": 1,
|
||||||
|
"maxAge": "7d",
|
||||||
|
"timeout": "10s",
|
||||||
|
"strategy": "performance"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Piano di Implementazione: Importazione Playlist e Canti della Comunità
|
||||||
|
|
||||||
|
Questo piano descrive i dettagli tecnici per l'aggiunta di una funzione che consente di importare localmente tutte le playlist e i canti personalizzati di una comunità attiva.
|
||||||
|
|
||||||
|
## User Review Required
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> L'importazione comporterà il salvataggio dei canti personalizzati della comunità direttamente nei "Miei Canti" dell'utente (evitando duplicati) e la creazione di playlist locali. Le playlist locali create includeranno:
|
||||||
|
> 1. Una playlist principale con il nome della comunità stessa contenente tutti i canti della comunità con le rispettive tonalità, velocità di scorrimento e testi personalizzati/aggiuntivi.
|
||||||
|
> 2. Tutte le singole scalette/playlist della comunità importate come playlist locali indipendenti e modificabili.
|
||||||
|
|
||||||
|
## Proposed Changes
|
||||||
|
|
||||||
|
### Servizi e Logica di Business
|
||||||
|
|
||||||
|
#### [MODIFY] [playlist.service.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/services/playlist.service.ts)
|
||||||
|
Aggiungeremo un metodo helper o esporremo le funzioni di salvataggio di massa per le playlist importate, garantendo che le impostazioni dei brani (`songSettings` contenenti tonalità e velocità di scorrimento) siano preservate correttamente.
|
||||||
|
|
||||||
|
### Pagina Impostazioni (Settings Page)
|
||||||
|
|
||||||
|
#### [MODIFY] [settings.page.html](file:///Users/davidfrassi/SRC/agenti/canti/src/app/pages/settings/settings.page.html)
|
||||||
|
Nel gruppo "Comunità", quando una comunità è attiva, aggiungeremo un nuovo pulsante sotto i dettagli della comunità:
|
||||||
|
- **Pulsante**: "Importa Playlist e Canti" con icona `cloud-download-outline`.
|
||||||
|
|
||||||
|
#### [MODIFY] [settings.page.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/pages/settings/settings.page.ts)
|
||||||
|
Implementeremo il metodo `importComunitaPlaylists()` che svolgerà i seguenti passaggi:
|
||||||
|
1. **Verifica dello Stato**: Si assicura che una comunità sia attiva e che i dati siano caricati.
|
||||||
|
2. **Importazione dei Canti Personalizzati**:
|
||||||
|
- Scorrerà i canti di `comunitaService.comunitaCantiPersonali()`.
|
||||||
|
- Per ciascun canto, verificherà se è già presente nei "Miei Canti" (tramite `myCantiService.myCanti()`).
|
||||||
|
- Se assente, lo aggiungerà con prefisso ID `my_` ed effettuerà il salvataggio in locale su `MyCantiService`.
|
||||||
|
3. **Creazione della Playlist della Comunità**:
|
||||||
|
- Creerà una playlist avente come nome il nome della comunità stessa.
|
||||||
|
- Popolerà i canti associando sia i canti standard sia i canti personali importati.
|
||||||
|
- Assocerà le impostazioni di tonalità e velocità di scorrimento da `comunitaCantiSettings()`. Per la velocità, si utilizzerà la formula di conversione standard (es. `Math.round(speed / 100)`).
|
||||||
|
4. **Importazione delle Scalette (Playlist) Aggiuntive**:
|
||||||
|
- Per ciascuna scaletta in `comunitaService.comunitaScalette()`, creerà una playlist locale corrispondente.
|
||||||
|
- Per ciascun canto della scaletta, cercherà se esiste un'impostazione in `comunitaCantiSettings()` per impostare tonalità e velocità specifiche all'interno della playlist.
|
||||||
|
5. **Salvataggio e Feedback**:
|
||||||
|
- Salverà tutte le nuove playlist tramite `playlistService.savePlaylist()`.
|
||||||
|
- Mostrerà un alert di conferma iniziale e un toast di successo finale.
|
||||||
|
|
||||||
|
## Verification Plan
|
||||||
|
|
||||||
|
### Manual Verification
|
||||||
|
- Attivare una comunità di prova (es. con codice noto o inserendone uno valido).
|
||||||
|
- Andare nelle impostazioni e cliccare sul nuovo pulsante "Importa Playlist e Canti".
|
||||||
|
- Verificare la comparsa dell'alert di conferma.
|
||||||
|
- Confermare l'importazione.
|
||||||
|
- Verificare tramite Toast e navigando nella home che:
|
||||||
|
- Sia presente una playlist locale con il nome della comunità contenente tutti i canti ordinati e configurati.
|
||||||
|
- Siano presenti le playlist secondarie della comunità convertite in playlist locali.
|
||||||
|
- Le tonalità modificate e le velocità di scorrimento siano state importate correttamente per ciascun canto all'interno delle playlist.
|
||||||
|
- I canti personali/aggiuntivi siano stati importati nella sezione "Miei Canti" e siano visualizzabili.
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{
|
||||||
|
"name": "canticristiani",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"author": "Ionic Framework",
|
||||||
|
"homepage": "https://ionicframework.com/",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test",
|
||||||
|
"lint": "ng lint"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/animations": "^20.0.0",
|
||||||
|
"@angular/cdk": "^20.2.14",
|
||||||
|
"@angular/common": "^20.0.0",
|
||||||
|
"@angular/compiler": "^20.0.0",
|
||||||
|
"@angular/core": "^20.0.0",
|
||||||
|
"@angular/forms": "^20.0.0",
|
||||||
|
"@angular/platform-browser": "^20.0.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^20.0.0",
|
||||||
|
"@angular/router": "^20.0.0",
|
||||||
|
"@angular/service-worker": "^20.0.0",
|
||||||
|
"@capacitor/app": "8.1.0",
|
||||||
|
"@capacitor/core": "8.3.1",
|
||||||
|
"@capacitor/haptics": "8.0.2",
|
||||||
|
"@capacitor/keyboard": "8.0.3",
|
||||||
|
"@capacitor/preferences": "^8.0.1",
|
||||||
|
"@capacitor/status-bar": "8.0.2",
|
||||||
|
"@ionic/angular": "^8.0.0",
|
||||||
|
"@ionic/storage-angular": "^4.0.0",
|
||||||
|
"@zxing/browser": "^0.2.0",
|
||||||
|
"@zxing/library": "^0.23.0",
|
||||||
|
"@zxing/ngx-scanner": "^21.0.0",
|
||||||
|
"ionicons": "^7.0.0",
|
||||||
|
"mammoth": "^1.12.0",
|
||||||
|
"pdfjs-dist": "^5.7.284",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tesseract.js": "^7.0.0",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "~0.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^20.0.0",
|
||||||
|
"@angular-eslint/builder": "^20.0.0",
|
||||||
|
"@angular-eslint/eslint-plugin": "^20.0.0",
|
||||||
|
"@angular-eslint/eslint-plugin-template": "^20.0.0",
|
||||||
|
"@angular-eslint/schematics": "^20.0.0",
|
||||||
|
"@angular-eslint/template-parser": "^20.0.0",
|
||||||
|
"@angular/cli": "^20.0.0",
|
||||||
|
"@angular/compiler-cli": "^20.0.0",
|
||||||
|
"@angular/language-service": "^20.0.0",
|
||||||
|
"@capacitor/cli": "8.3.1",
|
||||||
|
"@ionic/angular-toolkit": "^12.0.0",
|
||||||
|
"@types/jasmine": "~5.1.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
||||||
|
"@typescript-eslint/parser": "^8.18.0",
|
||||||
|
"eslint": "^9.16.0",
|
||||||
|
"eslint-plugin-import": "^2.29.1",
|
||||||
|
"eslint-plugin-jsdoc": "^48.2.1",
|
||||||
|
"eslint-plugin-prefer-arrow": "1.2.2",
|
||||||
|
"jasmine-core": "~5.1.0",
|
||||||
|
"jasmine-spec-reporter": "~5.0.0",
|
||||||
|
"karma": "~6.4.0",
|
||||||
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
"karma-coverage": "~2.2.0",
|
||||||
|
"karma-jasmine": "~5.1.0",
|
||||||
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
|
"typescript": "~5.9.0"
|
||||||
|
},
|
||||||
|
"description": "An Ionic project"
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
# Parametri letti da canti.json e da Comunità
|
||||||
|
|
||||||
|
Questo documento elenca tutti i parametri e i campi strutturati che vengono letti, elaborati e salvati dall'applicazione a partire dal file globale `canti.json` e dalle API/file di configurazione delle **Comunità**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Parametri da `canti.json`
|
||||||
|
|
||||||
|
Il file `canti.json` rappresenta il database globale dei canti dell'applicazione. Viene scaricato dall'endpoint configurato in `CantiService` (es. `https://www.canticristiani.it/api/canti.json`).
|
||||||
|
|
||||||
|
### Struttura Principale del File
|
||||||
|
|
||||||
|
Il JSON restituito contiene diversi nodi chiave, ciascuno contenente un array `data`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"canti": { "data": [...] },
|
||||||
|
"indice_liturgico": { "data": [...] },
|
||||||
|
"indice_tematico": { "data": [...] },
|
||||||
|
"tema": { "data": [...] },
|
||||||
|
"canti_eseguiti": { "data": [...] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Nodo `canti.data` (Lista dei Canti)
|
||||||
|
Ciascun elemento rappresenta un canto e viene mappato nell'interfaccia `Canto`:
|
||||||
|
|
||||||
|
| Parametro Originale | Tipo | Descrizione |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id_canti` | `number` | Identificativo univoco del canto (usato internamente anche come stringa `id`). |
|
||||||
|
| `titolo` | `string` | Titolo del canto. |
|
||||||
|
| `testo` | `string` | Testo del canto (può includere indicazioni di accordi). |
|
||||||
|
| `accordi` | `string` (opzionale) | Accordi musicali associati al canto. |
|
||||||
|
| `autore` | `string` (opzionale) | Autore o autori del canto. |
|
||||||
|
| `link_youtube` | `string` (opzionale) | URL o ID del video di YouTube associato al canto. |
|
||||||
|
| `data_update` | `string` (opzionale) | Data dell'ultimo aggiornamento (formato `YYYY-MM-DD HH:mm:ss`, formattata a schermo in `DD/MM/YYYY`). |
|
||||||
|
| `nonValidato` | `boolean` (opzionale) | Indica se il canto è in attesa di validazione. |
|
||||||
|
| `isPersonal` | `boolean` (opzionale) | Flag locale per identificare se si tratta di un canto personale dell'utente. |
|
||||||
|
|
||||||
|
*Nota: Durante l'importazione, viene aggiunto un array `id_momenti: number[]` ricavato dalla tabella pivot `tema.data`.*
|
||||||
|
|
||||||
|
#### Nodo `indice_liturgico.data` e `indice_tematico.data` (Indici/Tag)
|
||||||
|
Mappati nell'interfaccia `Indice`:
|
||||||
|
|
||||||
|
| Parametro Originale | Tipo | Descrizione |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id_indice_liturgico` / `id_indice_tematico` | `number` | Identificativo dell'indice/momento. |
|
||||||
|
| `tag_name` | `string` | Nome visualizzato del tag (es. "Ingresso", "Offertorio"). |
|
||||||
|
| `slug` | `string` | Versione ottimizzata per URL del tag. |
|
||||||
|
|
||||||
|
*Nota: A livello applicativo viene aggiunto il campo `type` con valore `'liturgico'` o `'tematico'`.*
|
||||||
|
|
||||||
|
#### Nodo `tema.data` (Relazione Pivot Canti-Indici)
|
||||||
|
Utilizzato per associare a ogni canto i rispettivi momenti liturgici o tematici:
|
||||||
|
|
||||||
|
| Parametro Originale | Tipo | Descrizione |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id_canti` | `number` | ID del canto associato. |
|
||||||
|
| `id_momento` | `number` | ID del momento liturgico/tematico. |
|
||||||
|
|
||||||
|
#### Nodo `canti_eseguiti.data`
|
||||||
|
Informazioni sull'esecuzione dei canti:
|
||||||
|
|
||||||
|
| Parametro Originale | Tipo | Descrizione |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id_canti` | `number` | ID del canto eseguito. |
|
||||||
|
| `num` | `number` | Numero di esecuzioni o indicatore di frequenza. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Parametri dalle Comunità
|
||||||
|
|
||||||
|
I dati di una comunità vengono letti in due modi: tramite l'API di produzione (v3) oppure tramite un file JSON statico di fallback.
|
||||||
|
|
||||||
|
### Opzione A: API di Produzione (`get_all_app_tables`)
|
||||||
|
Endpoint: `https://libretto.mmcinet.eu/canti/api/v3/get_all_app_tables`
|
||||||
|
|
||||||
|
L'API risponde con un oggetto contenente diverse tabelle relazionali:
|
||||||
|
|
||||||
|
#### 1. `parrocchia.data` (Dettagli della Comunità)
|
||||||
|
| Parametro Originale | Tipo | Descrizione |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id_parrocchia` | `number` | ID interno della parrocchia/comunità. |
|
||||||
|
| `nome` | `string` | Nome della comunità (es. "Parrocchia S. Maria"). |
|
||||||
|
| `codice` | `string` | Codice alfanumerico della comunità. |
|
||||||
|
| `mail` | `string` | Email di riferimento della comunità. |
|
||||||
|
| `guid_parrocchia` | `string` | GUID univoco. |
|
||||||
|
|
||||||
|
#### 2. `parrocchia_canti.data` (Associazione Canti-Comunità)
|
||||||
|
Indica quali canti del database generale appartengono al repertorio della comunità:
|
||||||
|
|
||||||
|
| Parametro Originale | Tipo | Descrizione |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id_parrocchia` | `number` | ID della parrocchia. |
|
||||||
|
| `id_canti` | `number` | ID del canto associato. |
|
||||||
|
| `num_canto` | `number` | Numero progressivo o di classificazione del canto all'interno della comunità. |
|
||||||
|
|
||||||
|
#### 3. `canti_settings.data` (Impostazioni di Esecuzione personalizzate)
|
||||||
|
Configurazioni specifiche per l'esecuzione del canto in comunità:
|
||||||
|
|
||||||
|
| Parametro Originale | Tipo | Descrizione |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id_canti` | `number` | ID del canto. |
|
||||||
|
| `speed` | `number` | Velocità di scorrimento (auto-scroll) consigliata. |
|
||||||
|
| `tonalita` | `number` | Semitoni di trasposizione (trasporto tonalità) consigliati. |
|
||||||
|
|
||||||
|
#### 4. `canti_personali.data` (Canti personalizzati/inediti della Comunità)
|
||||||
|
Canti inseriti direttamente dalla comunità e non presenti nel database globale:
|
||||||
|
|
||||||
|
| Parametro Originale | Tipo | Descrizione / Mapping Applicativo |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id_canti` | `number` | ID del canto personale. |
|
||||||
|
| `titolo` | `string` | Titolo del canto. |
|
||||||
|
| `accordi` | `string` | Testo con gli accordi del canto. |
|
||||||
|
| `autore` | `string` | Autore del canto. |
|
||||||
|
| `link_youtube` | `string` | Link YouTube. |
|
||||||
|
| `data_update` | `string` | Data dell'ultimo aggiornamento. |
|
||||||
|
| `stato` | `number` | Se uguale a `10`, il canto viene contrassegnato come `nonValidato = true`. |
|
||||||
|
|
||||||
|
*Nota: Vengono impostati automaticamente `isPersonal = true` e `id_momenti = []`.*
|
||||||
|
|
||||||
|
#### 5. Scalette della Comunità (`lista_nome` + `lista_esecuzione`)
|
||||||
|
L'applicazione ricostruisce l'elenco delle scalette (`ComunitaScaletta`):
|
||||||
|
* **`lista_nome.data`** (Testate delle scalette):
|
||||||
|
* `id_lista` (`string`/`number`): ID della scaletta.
|
||||||
|
* `nome` (`string`): Nome della scaletta (es. "Domenica delle Palme").
|
||||||
|
* `progr` (`string`): Data o stringa di ordinamento (mappata in `date`).
|
||||||
|
* **`lista_esecuzione.data`** (Canti contenuti nelle scalette):
|
||||||
|
* `id_lista` (`string`/`number`): Associazione alla scaletta.
|
||||||
|
* `id_canti` (`number`): ID del canto.
|
||||||
|
* `progr` (`number`): Ordine progressivo del canto all'interno della scaletta (usato per l'ordinamento).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Opzione B: Fallback Statico (`comunita_[codice].json`)
|
||||||
|
Se l'API di produzione non è raggiungibile, viene tentato il download di un file statico (es. `comunita_123456.json`) dall'origine del sito.
|
||||||
|
|
||||||
|
La struttura attesa per questo file è molto più semplice:
|
||||||
|
|
||||||
|
| Parametro JSON | Tipo | Descrizione |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `id_comunita` | `string` | Codice identificativo della comunità. |
|
||||||
|
| `nome_comunita` | `string` | Nome leggibile della comunità. |
|
||||||
|
| `canti` | `(number \| string)[]` | Array contenente gli ID di tutti i canti associati a questa comunità. |
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Piano delle Azioni per la Generazione di una Batteria di Test (`.spec.ts`)
|
||||||
|
|
||||||
|
Questo documento descrive in dettaglio la strategia, l'analisi dello stato attuale e l'elenco delle azioni sequenziali necessarie per implementare una suite completa di test unitari e di integrazione per tutte le classi (servizi, pagine e componenti) dell'applicazione Angular/Ionic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Analisi dello Stato Attuale dei Test
|
||||||
|
|
||||||
|
L'applicazione utilizza **Angular v20**, **Ionic v8**, e il framework di test standard **Jasmine** abbinato al test runner **Karma**.
|
||||||
|
|
||||||
|
Un'analisi del codice ha rilevato la seguente situazione per ciascun componente chiave:
|
||||||
|
|
||||||
|
### 1. Servizi (`src/app/services`)
|
||||||
|
I servizi gestiscono la logica di business fondamentale (audio, sincronizzazione, storage, stato).
|
||||||
|
* ❌ `audio-engine.service.ts` — **Nessun test**
|
||||||
|
* ❌ `canti-letture.service.ts` — **Nessun test**
|
||||||
|
* ❌ `canti.service.ts` — **Nessun test**
|
||||||
|
* ❌ `comunita.service.ts` — **Nessun test**
|
||||||
|
* ❌ `connectivity.service.ts` — **Nessun test**
|
||||||
|
* ❌ `lyrics-parser.service.ts` — **Nessun test**
|
||||||
|
* ❌ `media-session.service.ts` — **Nessun test**
|
||||||
|
* ⚠️ `my-canti.service.ts` — **Spec esistente ma corrotto/obsoleto** (`my-canti.spec.ts` fa riferimento a una classe `MyCanti` che non esiste, la classe reale è `MyCantiService`).
|
||||||
|
* ❌ `playlist.service.ts` — **Nessun test**
|
||||||
|
* ❌ `settings.service.ts` — **Nessun test**
|
||||||
|
* ❌ `stats.service.ts` — **Nessun test**
|
||||||
|
* ❌ `theme.service.ts` — **Nessun test**
|
||||||
|
* ❌ `youtube-player.service.ts` — **Nessun test**
|
||||||
|
|
||||||
|
### 2. Pagine (`src/app/pages` & `src/app/home`)
|
||||||
|
Le pagine gestiscono la visualizzazione e l'interazione con l'utente.
|
||||||
|
* ⚠️ `home.page.ts` — Spec esistente (`home.page.spec.ts`) ma limitato al boilerplate base (verifica solo la creazione).
|
||||||
|
* ⚠️ `display.page.ts` — Spec esistente (`display.page.spec.ts`) solo boilerplate base.
|
||||||
|
* ⚠️ `player.page.ts` — Spec esistente (`player.page.spec.ts`) solo boilerplate base.
|
||||||
|
* ❌ `playlist.page.ts` — **Nessun test**
|
||||||
|
* ⚠️ `propose-canto.page.ts` — Spec esistente (`propose-canto.page.spec.ts`) solo boilerplate base.
|
||||||
|
* ❌ `settings.page.ts` — **Nessun test**
|
||||||
|
|
||||||
|
### 3. Componenti (`src/app/components`)
|
||||||
|
* ❌ `qr-scanner/qr-scanner.component.ts` — **Nessun test**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Strategia di Mocking e Test in Angular/Ionic
|
||||||
|
|
||||||
|
Per testare efficacemente queste classi in isolamento, definiremo una strategia di mocking standard:
|
||||||
|
|
||||||
|
1. **Storage di Ionic (`@ionic/storage-angular`)**:
|
||||||
|
Molti servizi (`MyCantiService`, `SettingsService`, `ComunitaService`) dipendono dallo Storage. Creeremo un mock per simulare i metodi `get`, `set` e `remove` usando una mappa in memoria.
|
||||||
|
2. **Controller di Ionic (`ToastController`, `ModalController`, `AlertController`)**:
|
||||||
|
Utilizzeremo `jasmine.createSpyObj` per simulare la creazione e la presentazione dei componenti UI di Ionic senza istanziarli realmente nel DOM durante i test dei servizi.
|
||||||
|
3. **Ambiente HTML5 (Audio, Window)**:
|
||||||
|
Servizi come `AudioEngineService` e `YoutubePlayerService` interagiscono con le API audio del browser e l'oggetto `window`. Useremo dei mock o spy su `Audio`, `window.location`, ecc.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Elenco Dettagliato delle Azioni da Compiere
|
||||||
|
|
||||||
|
Le azioni sono suddivise in 6 fasi logiche per garantire un approccio sistematico e sicuro.
|
||||||
|
|
||||||
|
### Fase 1: Verifica dell'Ambiente e Correzione dei Test Esistenti
|
||||||
|
Prima di scrivere nuovi test, dobbiamo assicurarci che l'infrastruttura di test esistente sia stabile e funzionante.
|
||||||
|
|
||||||
|
- [ ] **Azione 1.1: Esecuzione iniziale dei test**
|
||||||
|
* Eseguire `npm run test` (o `ng test --watch=false`) per verificare se la suite iniziale compila ed è verde.
|
||||||
|
- [ ] **Azione 1.2: Correzione di `my-canti.spec.ts`**
|
||||||
|
* Rinominare il file in `my-canti.service.spec.ts` per uniformità.
|
||||||
|
* Correggere gli import: importare `MyCantiService` anziché `MyCanti`.
|
||||||
|
* Configurare il `TestBed` fornendo il mock per `Storage`, `CantiService` e `ToastController`.
|
||||||
|
* Verificare che il test passi con successo.
|
||||||
|
- [ ] **Azione 1.3: Aggiornamento dei boilerplate delle pagine esistenti**
|
||||||
|
* Risolvere eventuali errori nei test pregenerati (`home.page.spec.ts`, `display.page.spec.ts`, `player.page.spec.ts`, `propose-canto.page.spec.ts`) fornendo i moduli minimi (`IonicModule`, `RouterTestingModule`) e i mock dei servizi iniettati nei loro costruttori.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 2: Implementazione dei Test sui Servizi (Core Logico)
|
||||||
|
I servizi sono la priorità in quanto non hanno dipendenze grafiche complesse e contengono la logica di business principale.
|
||||||
|
|
||||||
|
- [ ] **Azione 2.1: Test per `connectivity.service.ts` e `theme.service.ts`**
|
||||||
|
* *Perché:* Sono i più semplici e privi di dipendenze esterne pesanti.
|
||||||
|
* *Test:* Cambiamenti di stato della rete online/offline, applicazione corretta delle classi CSS per i temi (scuro/chiaro).
|
||||||
|
- [ ] **Azione 2.2: Test per `lyrics-parser.service.ts`**
|
||||||
|
* *Casi di test:* Parsing corretto dei testi dei canti con accordi tra parentesi (es. `[Do]`), gestione delle righe vuote, estrazione del testo pulito, formattazione.
|
||||||
|
- [ ] **Azione 2.3: Test per `settings.service.ts`**
|
||||||
|
* *Casi di test:* Caricamento delle impostazioni di default da storage, salvataggio di nuove impostazioni, gestione del cambio di dimensione del testo, export/import delle impostazioni.
|
||||||
|
- [ ] **Azione 2.4: Test per `stats.service.ts`**
|
||||||
|
* *Casi di test:* Incremento delle statistiche di visualizzazione di un canto, persistenza su storage, generazione dei report dei canti più cantati.
|
||||||
|
- [ ] **Azione 2.5: Test per `canti.service.ts` e `canti-letture.service.ts`**
|
||||||
|
* *Casi di test:* Lettura del database dei canti, filtri di ricerca per titolo/testo/momento liturgico, associazione tra letture del giorno e canti consigliati (sulla base delle strategie liturgiche descritte in `liturgia_strategy.md`).
|
||||||
|
- [ ] **Azione 2.6: Test per `comunita.service.ts`**
|
||||||
|
* *Casi di test:* Gestione del codice comunità, sincronizzazione dei canti della comunità tramite chiamate HTTP (mocking di `HttpClient`), salvataggio in storage locale.
|
||||||
|
- [ ] **Azione 2.7: Test per `playlist.service.ts`**
|
||||||
|
* *Casi di test:* Creazione, modifica e cancellazione di playlist, riordino dei canti all'interno di una playlist, salvataggio e ripristino da storage.
|
||||||
|
- [ ] **Azione 2.8: Test per `media-session.service.ts`**
|
||||||
|
* *Casi di test:* Integrazione con l'API `navigator.mediaSession` del browser, aggiornamento dei metadati audio (titolo, autore, copertina), gestione degli eventi di riproduzione/pausa del sistema operativo.
|
||||||
|
- [ ] **Azione 2.9: Test per `audio-engine.service.ts` e `youtube-player.service.ts`**
|
||||||
|
* *Casi di test:* Avvio, pausa, stop e seek delle tracce audio locali e dei video YouTube, gestione degli eventi di fine riproduzione, aggiornamento dello stato tramite Signals/RxJS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 3: Implementazione dei Test sui Componenti Condivisi
|
||||||
|
- [ ] **Azione 3.1: Test per `qr-scanner.component.ts`**
|
||||||
|
* *Casi di test:* Inizializzazione della fotocamera, gestione dei permessi negati, decodifica del codice QR (simulando l'evento del decoder), emissione del codice scansionato tramite `@Output()`, interruzione dello streaming video alla distruzione del componente.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 4: Implementazione e Arricchimento dei Test sulle Pagine (UI & Controller)
|
||||||
|
Qui testeremo l'integrazione tra i servizi (mockati) e l'interfaccia utente di Ionic.
|
||||||
|
|
||||||
|
- [ ] **Azione 4.1: Test approfonditi per `home.page.ts`**
|
||||||
|
* *Casi di test:* Visualizzazione dell'elenco dei canti, funzionamento della barra di ricerca (filtro istantaneo), apertura dei dettagli del canto, gestione della navigazione verso le altre pagine.
|
||||||
|
- [ ] **Azione 4.2: Test approfonditi per `player.page.ts`**
|
||||||
|
* *Casi di test:* Interazione con i pulsanti di play/pause, scorrimento del testo a tempo, trasposizione degli accordi (es. +1 semitono, -1 semitono) con aggiornamento dinamico del testo a schermo.
|
||||||
|
- [ ] **Azione 4.3: Test approfonditi per `display.page.ts`**
|
||||||
|
* *Casi di test:* Rendering corretto del testo del canto, applicazione del tema visivo e della dimensione del font dalle impostazioni, gestione del blocco dello spegnimento dello schermo (se implementato).
|
||||||
|
- [ ] **Azione 4.4: Creazione e test per `playlist.page.ts`**
|
||||||
|
* *Casi di test:* Visualizzazione dell'elenco delle playlist dell'utente, creazione di una nuova playlist tramite popup, aggiunta di canti, eliminazione di canti con swipe.
|
||||||
|
- [ ] **Azione 4.5: Test approfonditi per `propose-canto.page.ts`**
|
||||||
|
* *Casi di test:* Validazione del form di proposta (titolo obbligatorio, testo minimo), generazione della mailto URL corretta con il body in formato JSON, comportamento del pulsante di invio.
|
||||||
|
- [ ] **Azione 4.6: Creazione e test per `settings.page.ts`**
|
||||||
|
* *Casi di test:* Toggle del tema scuro (con verifica dell'applicazione al DOM), selezione della dimensione del font, reset dei dati locali con richiesta di conferma tramite `AlertController`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 5: Raccolta delle Metriche di Coverage e Rifinitura
|
||||||
|
- [ ] **Azione 5.1: Configurazione del report di coverage**
|
||||||
|
* Assicurarsi che `karma.conf.js` sia configurato per esportare i dati in formato `lcov` o `html` (tramite `karma-coverage`).
|
||||||
|
- [ ] **Azione 5.2: Generazione del report**
|
||||||
|
* Eseguire `ng test --code-coverage --watch=false`.
|
||||||
|
* Esaminare la cartella `coverage/` generata per individuare eventuali rami logici (branches) o righe non coperte nei servizi core.
|
||||||
|
- [ ] **Azione 5.3: Incremento mirato del coverage**
|
||||||
|
* Aggiungere test specifici per coprire i casi limite (*edge cases*) e la gestione degli errori (es. fallimento delle chiamate HTTP, storage corrotto).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 6: Automazione in CI/CD (Opzionale ma Consigliato)
|
||||||
|
- [ ] **Azione 6.1: Configurazione test headless**
|
||||||
|
* Configurare Karma per eseguire i test in modalità headless usando `ChromeHeadless` su sistemi di Continuous Integration (es. GitHub Actions).
|
||||||
|
* Aggiungere uno script npm `test:ci`: `"test:ci": "ng test --watch=false --browsers=ChromeHeadless"`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 Tabella di Marcia Consigliata
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
gantt
|
||||||
|
title Roadmap per la Copertura dei Test
|
||||||
|
dateFormat YYYY-MM-DD
|
||||||
|
section Fase 1
|
||||||
|
Verifica & Fix Esistenti :active, 2026-05-21, 2d
|
||||||
|
section Fase 2
|
||||||
|
Test Servizi Core : 2026-05-23, 5d
|
||||||
|
section Fase 3 & 4
|
||||||
|
Test Componenti & Pagine : 2026-05-28, 5d
|
||||||
|
section Fase 5 & 6
|
||||||
|
Coverage & CI/CD : 2026-06-02, 2d
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!TIP]
|
||||||
|
> **Consiglio per l'efficienza**: Si raccomanda di iniziare ad implementare i test partendo dai servizi a più basso livello (come `ConnectivityService`, `ThemeService`, `LyricsParserService`) perché sono privi di dipendenze e consentono di stabilire rapidamente dei pattern di test solidi prima di affrontare servizi più complessi o interfacce grafiche.
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<IfModule mod_rewrite.c>
|
||||||
|
RewriteEngine On
|
||||||
|
|
||||||
|
# Se il file o la directory richiesti esistono, li serve direttamente
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-d
|
||||||
|
|
||||||
|
# Altrimenti reindirizza tutto a index.html (gestito da Angular)
|
||||||
|
RewriteRule ^ index.html [L]
|
||||||
|
</IfModule>
|
||||||
|
|
||||||
|
<IfModule mod_headers.c>
|
||||||
|
# Disabilita il caching per l'index.html, il manifesto e i file di configurazione del Service Worker
|
||||||
|
<FilesMatch "index\.html|ngsw\.json|ngsw-worker\.js|safety-worker\.js|manifest\.webmanifest|version\.json">
|
||||||
|
Header set Cache-Control "no-cache, no-store, must-revalidate"
|
||||||
|
Header set Pragma "no-cache"
|
||||||
|
Header set Expires 0
|
||||||
|
</FilesMatch>
|
||||||
|
</IfModule>
|
||||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
@@ -0,0 +1,80 @@
|
|||||||
|
{
|
||||||
|
"name": "CantiCristiani",
|
||||||
|
"short_name": "CantiCristiani",
|
||||||
|
"display": "standalone",
|
||||||
|
"scope": "/",
|
||||||
|
"start_url": "/",
|
||||||
|
"theme_color": "#3880ff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"launch_handler": {
|
||||||
|
"client_mode": "focus-existing"
|
||||||
|
},
|
||||||
|
"protocol_handlers": [
|
||||||
|
{
|
||||||
|
"protocol": "web+canti",
|
||||||
|
"url": "/?url=%s"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-72x72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-96x96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-128x128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-144x144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-152x152.png",
|
||||||
|
"sizes": "152x152",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-384x384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "assets/icons/icon-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 1.6 KiB |
@@ -0,0 +1,13 @@
|
|||||||
|
import urllib.request
|
||||||
|
import json
|
||||||
|
|
||||||
|
url = "https://libretto.mmcinet.eu/canti/api/v3/get_all_app_tables?uuid=pwa-cc-uuid&email=&platform=browser&version=1.0&gruppo=123456&all_song=false"
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||||
|
print("Fetching...")
|
||||||
|
with urllib.request.urlopen(req) as res:
|
||||||
|
data = json.loads(res.read().decode("utf-8"))
|
||||||
|
|
||||||
|
settings = data.get("canti_settings", {}).get("data", [])
|
||||||
|
print(f"Total settings: {len(settings)}")
|
||||||
|
for s in settings[:10]:
|
||||||
|
print(s)
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
from ftplib import FTP, error_perm
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
|
||||||
|
thread_local = threading.local()
|
||||||
|
|
||||||
|
def load_env():
|
||||||
|
env = {}
|
||||||
|
if os.path.exists('.env'):
|
||||||
|
with open('.env', 'r', encoding='utf-8') as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if line and not line.startswith('#'):
|
||||||
|
if '=' in line:
|
||||||
|
key, val = line.split('=', 1)
|
||||||
|
env[key.strip()] = val.strip()
|
||||||
|
return env
|
||||||
|
|
||||||
|
def get_ftp_connection(ftp_host, ftp_user, ftp_pass):
|
||||||
|
if not hasattr(thread_local, "ftp") or thread_local.ftp is None:
|
||||||
|
ftp = FTP(ftp_host, timeout=30)
|
||||||
|
ftp.login(ftp_user, ftp_pass)
|
||||||
|
ftp.passive = True
|
||||||
|
thread_local.ftp = ftp
|
||||||
|
return thread_local.ftp
|
||||||
|
|
||||||
|
def close_thread_connection():
|
||||||
|
if hasattr(thread_local, "ftp") and thread_local.ftp is not None:
|
||||||
|
try:
|
||||||
|
thread_local.ftp.quit()
|
||||||
|
except:
|
||||||
|
try:
|
||||||
|
thread_local.ftp.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
thread_local.ftp = None
|
||||||
|
|
||||||
|
def ensure_remote_dir(ftp, path):
|
||||||
|
parts = path.replace('\\', '/').strip('/').split('/')
|
||||||
|
current = ""
|
||||||
|
for part in parts:
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
current += "/" + part
|
||||||
|
try:
|
||||||
|
ftp.mkd(current)
|
||||||
|
print(f"\nCreated remote dir: {current}")
|
||||||
|
except error_perm:
|
||||||
|
# Directory already exists or permission error (which is normal if it exists)
|
||||||
|
pass
|
||||||
|
|
||||||
|
def upload_file_task(local_path, rel_path, ftp_host, ftp_user, ftp_pass, created_dirs, created_dirs_lock):
|
||||||
|
ftp_path = rel_path.replace('\\', '/')
|
||||||
|
remote_dir = os.path.dirname(ftp_path)
|
||||||
|
|
||||||
|
# Ensure remote directory exists
|
||||||
|
if remote_dir:
|
||||||
|
with created_dirs_lock:
|
||||||
|
if remote_dir not in created_dirs:
|
||||||
|
try:
|
||||||
|
ftp = get_ftp_connection(ftp_host, ftp_user, ftp_pass)
|
||||||
|
ensure_remote_dir(ftp, remote_dir)
|
||||||
|
created_dirs.add(remote_dir)
|
||||||
|
except Exception as e:
|
||||||
|
thread_local.ftp = None
|
||||||
|
try:
|
||||||
|
ftp = get_ftp_connection(ftp_host, ftp_user, ftp_pass)
|
||||||
|
ensure_remote_dir(ftp, remote_dir)
|
||||||
|
created_dirs.add(remote_dir)
|
||||||
|
except Exception as err2:
|
||||||
|
return False, ftp_path, f"Failed directory creation: {err2}"
|
||||||
|
|
||||||
|
# Upload the file
|
||||||
|
for attempt in range(3):
|
||||||
|
try:
|
||||||
|
ftp = get_ftp_connection(ftp_host, ftp_user, ftp_pass)
|
||||||
|
with open(local_path, 'rb') as f:
|
||||||
|
ftp.storbinary(f"STOR {ftp_path}", f)
|
||||||
|
return True, ftp_path, None
|
||||||
|
except Exception as e:
|
||||||
|
thread_local.ftp = None # Force reconnect on next attempt
|
||||||
|
if attempt == 2:
|
||||||
|
return False, ftp_path, str(e)
|
||||||
|
return False, ftp_path, "Unknown error"
|
||||||
|
|
||||||
|
def main():
|
||||||
|
env = load_env()
|
||||||
|
ftp_host = "ftp.canticristiani.it"
|
||||||
|
ftp_user = "canticristiani.it"
|
||||||
|
ftp_pass = env.get("FTP_PASSWORD")
|
||||||
|
|
||||||
|
if not ftp_pass:
|
||||||
|
print("❌ Error: FTP_PASSWORD not found in .env")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
local_dir = "www"
|
||||||
|
if not os.path.isdir(local_dir):
|
||||||
|
print(f"❌ Error: Local dir '{local_dir}' not found.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
files_to_upload = []
|
||||||
|
for root, dirs, files in os.walk(local_dir):
|
||||||
|
for file in files:
|
||||||
|
local_path = os.path.join(root, file)
|
||||||
|
rel_path = os.path.relpath(local_path, local_dir)
|
||||||
|
files_to_upload.append((local_path, rel_path))
|
||||||
|
|
||||||
|
total = len(files_to_upload)
|
||||||
|
uploaded = 0
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
created_dirs = set()
|
||||||
|
created_dirs_lock = threading.Lock()
|
||||||
|
progress_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Thread limit: default to 100 as requested
|
||||||
|
num_threads = int(os.environ.get("FTP_THREADS", env.get("FTP_THREADS", "100")))
|
||||||
|
print(f"🚀 Starting parallel upload of {total} files using {num_threads} threads...")
|
||||||
|
|
||||||
|
def run_task(item):
|
||||||
|
nonlocal uploaded
|
||||||
|
local_path, rel_path = item
|
||||||
|
success, ftp_path, err_msg = upload_file_task(
|
||||||
|
local_path, rel_path, ftp_host, ftp_user, ftp_pass, created_dirs, created_dirs_lock
|
||||||
|
)
|
||||||
|
with progress_lock:
|
||||||
|
uploaded += 1
|
||||||
|
print(f"\rUploading [{uploaded}/{total}] {ftp_path}...", end="", flush=True)
|
||||||
|
if not success:
|
||||||
|
errors.append((ftp_path, err_msg))
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=num_threads) as executor:
|
||||||
|
futures = [executor.submit(run_task, item) for item in files_to_upload]
|
||||||
|
for future in as_completed(futures):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Cleanup connections
|
||||||
|
with ThreadPoolExecutor(max_workers=num_threads) as cleanup_executor:
|
||||||
|
cleanups = [cleanup_executor.submit(close_thread_connection) for _ in range(num_threads)]
|
||||||
|
for future in as_completed(cleanups):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print(f"\n⚠️ Deploy completed with {len(errors)} errors:")
|
||||||
|
for path, err in errors:
|
||||||
|
print(f" {path}: {err}")
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print("\n\n✅ Deploy completed successfully with 100% files uploaded without errors!")
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
function walkDir(dir, callback) {
|
||||||
|
fs.readdirSync(dir).forEach(f => {
|
||||||
|
let dirPath = path.join(dir, f);
|
||||||
|
let isDirectory = fs.statSync(dirPath).isDirectory();
|
||||||
|
if (isDirectory) {
|
||||||
|
walkDir(dirPath, callback);
|
||||||
|
} else {
|
||||||
|
callback(dirPath);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = new Set();
|
||||||
|
|
||||||
|
walkDir(path.join(__dirname, '../src/app'), (filePath) => {
|
||||||
|
if (filePath.endsWith('.html') || filePath.endsWith('.ts')) {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
// Match name="icon-name"
|
||||||
|
const nameMatches = content.matchAll(/name=["']([a-zA-Z0-9-]+)["']/g);
|
||||||
|
for (const match of nameMatches) {
|
||||||
|
icons.add(match[1]);
|
||||||
|
}
|
||||||
|
// Match [name]="... ? 'icon-a' : 'icon-b'"
|
||||||
|
const ternaryMatches = content.matchAll(/'([a-zA-Z0-9-]+-outline|[a-zA-Z0-9-]+-sharp|[a-zA-Z0-9-]+)'/g);
|
||||||
|
for (const match of ternaryMatches) {
|
||||||
|
icons.add(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(JSON.stringify(Array.from(icons).sort(), null, 2));
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import urllib.request
|
||||||
|
import json
|
||||||
|
|
||||||
|
url = "https://libretto.mmcinet.eu/canti/api/v3/get_all_app_tables?uuid=pwa-cc-uuid&email=&platform=browser&version=1.0&gruppo=123456&all_song=false"
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||||
|
print("Fetching...")
|
||||||
|
with urllib.request.urlopen(req) as res:
|
||||||
|
data = json.loads(res.read().decode("utf-8"))
|
||||||
|
|
||||||
|
canti_pers = data.get("canti_personali", {}).get("data", [])
|
||||||
|
states = {}
|
||||||
|
for cp in canti_pers:
|
||||||
|
state = cp.get("stato")
|
||||||
|
states[state] = states.get(state, 0) + 1
|
||||||
|
|
||||||
|
print("States found in canti_personali:")
|
||||||
|
for state, count in states.items():
|
||||||
|
print(f"- State {state}: {count} songs")
|
||||||
|
|
||||||
|
# Let's also check if there are any duplicate IDs between canti and canti_personali
|
||||||
|
canti_ids = {c["id_canti"] for c in data.get("canti", {}).get("data", [])}
|
||||||
|
canti_pers_ids = {cp["id_canti"] for cp in canti_pers}
|
||||||
|
intersection = canti_ids.intersection(canti_pers_ids)
|
||||||
|
print(f"Number of duplicate song IDs between global catalog and canti_personali: {len(intersection)}")
|
||||||
|
if len(intersection) > 0:
|
||||||
|
print("First 5 duplicate IDs:")
|
||||||
|
print(list(intersection)[:5])
|
||||||
|
# Find one example of duplicate
|
||||||
|
dup_id = list(intersection)[0]
|
||||||
|
global_dup = next(c for c in data["canti"]["data"] if c["id_canti"] == dup_id)
|
||||||
|
pers_dup = next(cp for cp in canti_pers if cp["id_canti"] == dup_id)
|
||||||
|
print(f"\nExample Duplicate (ID {dup_id}):")
|
||||||
|
print(f"Global: Title='{global_dup.get('titolo')}', Stato={global_dup.get('stato')}")
|
||||||
|
print(f"Personal: Title='{pers_dup.get('titolo')}', Stato={pers_dup.get('stato')}")
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import urllib.request
|
||||||
|
|
||||||
|
url = "https://app.canticristiani.it/js/db.js"
|
||||||
|
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
||||||
|
with urllib.request.urlopen(req) as res:
|
||||||
|
content = res.read().decode("utf-8")
|
||||||
|
lines = content.split("\n")
|
||||||
|
|
||||||
|
print("================== js/db.js lines 1260 to 1310 ==================")
|
||||||
|
for i in range(1259, min(1310, len(lines))):
|
||||||
|
print(f"{i+1}: {lines[i]}")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<style>
|
||||||
|
.chord-segment {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
vertical-align: bottom;
|
||||||
|
margin-right: 0.2em;
|
||||||
|
border: 1px solid blue;
|
||||||
|
}
|
||||||
|
.chord {
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: red;
|
||||||
|
height: 1.2em;
|
||||||
|
margin-bottom: -0.2em;
|
||||||
|
border: 1px solid red;
|
||||||
|
}
|
||||||
|
.seg-text {
|
||||||
|
white-space: pre;
|
||||||
|
border: 1px solid green;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div>
|
||||||
|
ti amerò come
|
||||||
|
<span class="chord-segment"><span class="chord">LA</span><span class="seg-text"> </span></span>
|
||||||
|
<span class="chord-segment"><span class="chord">MI</span><span class="seg-text"></span></span>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
const textWords = [
|
||||||
|
{ text: 'come', bbox: { x0: 40, x1: 60 } },
|
||||||
|
{ text: 'sei', bbox: { x0: 70, x1: 90 } }
|
||||||
|
];
|
||||||
|
|
||||||
|
const chordWords = [
|
||||||
|
{ text: 'la', bbox: { x0: 65, x1: 75 } },
|
||||||
|
{ text: 'mi', bbox: { x0: 95, x1: 105 } }
|
||||||
|
];
|
||||||
|
|
||||||
|
function mergeChordsAndLyrics(chordWords: any[], textWords: any[]): string {
|
||||||
|
let result = '';
|
||||||
|
const chordAssignments = new Map<any, any[]>();
|
||||||
|
|
||||||
|
chordWords.forEach(chord => {
|
||||||
|
const chordX = (chord.bbox.x0 + chord.bbox.x1) / 2;
|
||||||
|
let closestWord: any = null;
|
||||||
|
let minDistance = Infinity;
|
||||||
|
|
||||||
|
textWords.forEach(textWord => {
|
||||||
|
const wordXCenter = (textWord.bbox.x0 + textWord.bbox.x1) / 2;
|
||||||
|
const dist = Math.abs(chordX - wordXCenter);
|
||||||
|
if (dist < minDistance) {
|
||||||
|
minDistance = dist;
|
||||||
|
closestWord = textWord;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (closestWord) {
|
||||||
|
if (!chordAssignments.has(closestWord)) {
|
||||||
|
chordAssignments.set(closestWord, []);
|
||||||
|
}
|
||||||
|
chordAssignments.get(closestWord)!.push(chord);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
textWords.forEach((textWord, index) => {
|
||||||
|
const assignedChords = chordAssignments.get(textWord) || [];
|
||||||
|
|
||||||
|
// Split chords into before and after the word
|
||||||
|
const chordsBefore = assignedChords.filter(c => (c.bbox.x0 + c.bbox.x1)/2 <= textWord.bbox.x1);
|
||||||
|
const chordsAfter = assignedChords.filter(c => (c.bbox.x0 + c.bbox.x1)/2 > textWord.bbox.x1);
|
||||||
|
|
||||||
|
chordsBefore.sort((a, b) => a.bbox.x0 - b.bbox.x0);
|
||||||
|
chordsAfter.sort((a, b) => a.bbox.x0 - b.bbox.x0);
|
||||||
|
|
||||||
|
chordsBefore.forEach(chord => {
|
||||||
|
const cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
|
||||||
|
result += `[${cleanChord}]`;
|
||||||
|
});
|
||||||
|
|
||||||
|
result += textWord.text;
|
||||||
|
|
||||||
|
chordsAfter.forEach(chord => {
|
||||||
|
const cleanChord = chord.text.replace(/[\(\)\[\]]/g, '').toUpperCase();
|
||||||
|
result += `[${cleanChord}]`;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (index < textWords.length - 1) {
|
||||||
|
result += ' ';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(mergeChordsAndLyrics(chordWords, textWords));
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
|
||||||
|
import { LyricsParserService } from './src/app/services/lyrics-parser.service';
|
||||||
|
|
||||||
|
const parser = new LyricsParserService();
|
||||||
|
|
||||||
|
function testTranspose(chord, semitones) {
|
||||||
|
const result = parser.transposeChord(chord, semitones);
|
||||||
|
console.log(`${chord} + ${semitones} -> ${result}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Testing Slash Chords:');
|
||||||
|
testTranspose('DO/SOL', 2);
|
||||||
|
testTranspose('REm/LA', 1);
|
||||||
|
testTranspose('MIb/SIb', -1);
|
||||||
|
testTranspose('FA#/DO#', 2);
|
||||||
|
|
||||||
|
console.log('\nTesting Single Chords:');
|
||||||
|
testTranspose('DO', 2);
|
||||||
|
testTranspose('REm', 1);
|
||||||
|
testTranspose('SIb', -1);
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# Strategia SEO e Indicizzazione per la PWA dei Canti
|
||||||
|
|
||||||
|
Questo documento definisce la strategia tecnica per consentire l'indicizzazione dei testi dei canti da parte dei motori di ricerca (in particolare Googlebot) e la corretta generazione delle anteprime (social cards) su piattaforme di messaggistica e social media (WhatsApp, Telegram, Facebook, ecc.), mantenendo intatta l'architettura PWA (Progressive Web App) offline-ready.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Il Contesto Tecnologico
|
||||||
|
L'applicazione è sviluppata con:
|
||||||
|
- **Angular 20** (Framework Core)
|
||||||
|
- **Ionic 8** (UI & Routing integration)
|
||||||
|
- **Angular Service Worker (`@angular/service-worker`)** per le funzionalità offline della PWA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. La Strategia Selezionata: Prerendering / Static Site Generation (SSG)
|
||||||
|
|
||||||
|
Dato che i testi dei canti sono **dati statici** (non cambiano in base all'utente connesso e variano molto raramente), la soluzione ottimale è la **Static Site Generation (SSG)**, nota anche come **Prerendering**.
|
||||||
|
|
||||||
|
### Come Funziona la Sinergia SSG + PWA
|
||||||
|
1. **Fase di Build:** Durante la compilazione dell'app (`ng build`), Angular genera un file `index.html` statico e pre-renderizzato per ogni singolo canto (es. `/canti/tu-sei-sorgente/index.html`).
|
||||||
|
2. **Scansione dello Spider (SEO):** Quando Googlebot o i crawler dei social richiedono l'URL di un canto, il server o la CDN distribuiscono immediatamente il file HTML statico già popolato con il testo del canto e con i meta tag corretti.
|
||||||
|
3. **Idratazione e PWA (Client):** Quando un utente apre la pagina sul browser, Angular scarica i bundle JavaScript ed esegue l'**hydration** in background. L'applicazione "prende vita" come Single Page Application (SPA), attiva il Service Worker e abilita la navigazione offline e l'installabilità come PWA.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Fasi e Dettagli di Implementazione
|
||||||
|
|
||||||
|
### Fase 1: Struttura degli URL e Routing Semantico
|
||||||
|
Per facilitare la SEO, gli URL devono essere parlanti e privi di simboli di hash (`#`). Attualmente, l'applicazione utilizza già il routing basato su percorsi standard (PathLocationStrategy) in [app-routing.module.ts](file:///Users/davidfrassi/SRC/agenti/canti/src/app/app-routing.module.ts).
|
||||||
|
|
||||||
|
È necessario definire una rotta dedicata per i singoli canti che accetti un parametro semantico (detto *slug* o *alias*), ad esempio:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/app/app-routing.module.ts
|
||||||
|
const routes: Routes = [
|
||||||
|
// ... altre rotte
|
||||||
|
{
|
||||||
|
path: 'canti/:slug',
|
||||||
|
loadChildren: () => import('./pages/canto-detail/canto-detail.module').then(m => m.CantoDetailPageModule)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
I collegamenti all'interno dell'applicazione per navigare verso i canti devono utilizzare il tag semantico `<a>` con la direttiva `routerLink`, per consentire a Googlebot di scoprire autonomamente tutte le pagine:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- EVITARE: pulsanti generici con eventi click gestiti in JS -->
|
||||||
|
<ion-item (click)="navigaAlCanto(canto.slug)">...</ion-item>
|
||||||
|
|
||||||
|
<!-- CONSIGLIATO: vero tag link HTML -->
|
||||||
|
<a [routerLink]="['/canti', canto.slug]" class="canto-link">
|
||||||
|
<ion-label>{{ canto.titolo }}</ion-label>
|
||||||
|
</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 2: Gestione dei Meta Tag Dinamici
|
||||||
|
Ogni canto deve avere un titolo e una descrizione univoci e ottimizzati per la SEO. In Angular si utilizzano i servizi `Title` e `Meta` di `@angular/platform-browser` per aggiornare i metadati all'inizializzazione del componente:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/app/pages/canto-detail/canto-detail.page.ts
|
||||||
|
import { Component, OnInit } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { Title, Meta } from '@angular/platform-browser';
|
||||||
|
import { CantiService } from '../../services/canti.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-canto-detail',
|
||||||
|
templateUrl: './canto-detail.page.html',
|
||||||
|
styleUrls: ['./canto-detail.page.scss'],
|
||||||
|
})
|
||||||
|
export class CantoDetailPage implements OnInit {
|
||||||
|
canto: any;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private route: ActivatedRoute,
|
||||||
|
private cantiService: CantiService,
|
||||||
|
private titleService: Title,
|
||||||
|
private metaService: Meta
|
||||||
|
) {}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
const slug = this.route.snapshot.paramMap.get('slug');
|
||||||
|
if (slug) {
|
||||||
|
this.canto = this.cantiService.getCantoBySlug(slug);
|
||||||
|
this.updateSEOMetadata();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSEOMetadata() {
|
||||||
|
const titoloCompleto = `${this.canto.titolo} - Canti Cristiani`;
|
||||||
|
const descrizione = `Testo e accordi del canto "${this.canto.titolo}". ${this.canto.testo.substring(0, 150)}...`;
|
||||||
|
|
||||||
|
// Imposta il titolo della pagina
|
||||||
|
this.titleService.setTitle(titoloCompleto);
|
||||||
|
|
||||||
|
// Imposta i meta tag standard per la SEO
|
||||||
|
this.metaService.updateTag({ name: 'description', content: descrizione });
|
||||||
|
|
||||||
|
// Imposta i tag OpenGraph per la condivisione sui social (Facebook, WhatsApp, Telegram)
|
||||||
|
this.metaService.updateTag({ property: 'og:title', content: titoloCompleto });
|
||||||
|
this.metaService.updateTag({ property: 'og:description', content: descrizione });
|
||||||
|
this.metaService.updateTag({ property: 'og:type', content: 'article' });
|
||||||
|
this.metaService.updateTag({ property: 'og:url', content: `https://canti.cristiani.it/canti/${this.canto.slug}` });
|
||||||
|
|
||||||
|
// Se c'è un'immagine associata o una copertina di default
|
||||||
|
this.metaService.updateTag({ property: 'og:image', content: 'https://canti.cristiani.it/assets/og-cover.png' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 3: Configurazione del Prerendering (SSG) in Angular 20
|
||||||
|
In Angular 20, l'abilitazione del server-side rendering e del prerendering statico durante la compilazione avviene aggiungendo il pacchetto SSR ufficiale:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ng add @angular/ssr
|
||||||
|
```
|
||||||
|
|
||||||
|
Questo comando configura automaticamente l'applicazione modificando `angular.json` e creando i file necessari per la compilazione lato server.
|
||||||
|
|
||||||
|
#### Configurazione delle rotte da pre-renderizzare
|
||||||
|
Poiché l'elenco dei canti è dinamico (es. risiede in file JSON o database), dobbiamo indicare ad Angular quali rotte generare staticamente durante il comando `ng build`.
|
||||||
|
|
||||||
|
Si crea un file di configurazione per definire le rotte o si utilizza uno script per estrarle dinamicamente:
|
||||||
|
|
||||||
|
1. **Creare un file delle rotte statiche** `prerender-routes.txt`:
|
||||||
|
```txt
|
||||||
|
/home
|
||||||
|
/settings
|
||||||
|
/canti/tu-sei-sorgente
|
||||||
|
/canti/re-dei-re
|
||||||
|
/canti/lodi-al-altissimo
|
||||||
|
```
|
||||||
|
2. **Automatizzare la generazione di questo file** inserendo uno script (es. `generate-routes.js`) da eseguire prima della build che legge l'elenco dei canti dal file JSON locale e scrive l'elenco dei percorsi in `prerender-routes.txt`.
|
||||||
|
3. **Configurare `angular.json`** per consumare questo file:
|
||||||
|
```json
|
||||||
|
"prerender": {
|
||||||
|
"discoverRoutes": false,
|
||||||
|
"routesFile": "prerender-routes.txt"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Al termine della build (`npm run build`), nella cartella di distribuzione (es. `dist/canticristiani/browser`) verranno generate cartelle fisiche con i file `index.html` pronti all'uso per ciascuna rotta definita.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Fase 4: Sitemap.xml e Robots.txt
|
||||||
|
Per garantire che Googlebot trovi tempestivamente tutti i canti, è fondamentale generare un file `sitemap.xml` da posizionare nella radice del server web.
|
||||||
|
|
||||||
|
#### Esempio di `sitemap.xml`:
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://canti.cristiani.it/home</loc>
|
||||||
|
<changefreq>weekly</changefreq>
|
||||||
|
<priority>1.0</priority>
|
||||||
|
</url>
|
||||||
|
<!-- Generato dinamicamente per ogni canto -->
|
||||||
|
<url>
|
||||||
|
<loc>https://canti.cristiani.it/canti/tu-sei-sorgente</loc>
|
||||||
|
<changefreq>monthly</changefreq>
|
||||||
|
<priority>0.8</priority>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Esempio di `robots.txt`:
|
||||||
|
```txt
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://canti.cristiani.it/sitemap.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Vantaggi e Risultati Attesi
|
||||||
|
- **Indicizzazione immediata:** Googlebot indicizzerà i testi dei canti all'istante, consentendo agli utenti di trovare la PWA cercando frammenti di testo o il titolo del canto direttamente su Google.
|
||||||
|
- **Anteprime nei Social Perfette:** La condivisione dei link sui canali di comunicazione mostrerà anteprime ricche (titolo corretto, frammento del testo del canto e logo dell'app).
|
||||||
|
- **Integrità PWA:** L'utente beneficerà di un caricamento iniziale ultra-veloce (grazie all'HTML pre-renderizzato) seguito dall'installazione offline e dall'esperienza fluida tipica dell'applicazione mobile.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { PreloadAllModules, RouterModule, Routes } from '@angular/router';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: 'home',
|
||||||
|
loadChildren: () => import('./home/home.module').then( m => m.HomePageModule)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
redirectTo: 'home',
|
||||||
|
pathMatch: 'full'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'player',
|
||||||
|
loadChildren: () => import('./pages/player/player.module').then( m => m.PlayerPageModule)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'display',
|
||||||
|
loadChildren: () => import('./pages/display/display.module').then( m => m.DisplayPageModule)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'settings',
|
||||||
|
loadChildren: () => import('./pages/settings/settings.module').then( m => m.SettingsPageModule)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'propose-canto',
|
||||||
|
loadComponent: () => import('./pages/propose-canto/propose-canto.page').then( m => m.ProposeCantoPage)
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
RouterModule.forRoot(routes, { preloadingStrategy: PreloadAllModules })
|
||||||
|
],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class AppRoutingModule { }
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<ion-app>
|
||||||
|
<ion-router-outlet></ion-router-outlet>
|
||||||
|
<div id="global-yt-player-container" style="position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; top: -100px;"></div>
|
||||||
|
|
||||||
|
<!-- PWA Redirect Overlay -->
|
||||||
|
<div *ngIf="showRedirectOverlay()"
|
||||||
|
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;">
|
||||||
|
<div style="text-align: center; padding: 30px; max-width: 420px; width: 100%; background: rgba(30, 30, 30, 0.75); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);">
|
||||||
|
<div style="margin-bottom: 25px; display: inline-block;">
|
||||||
|
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.4); border: 2px solid rgba(230, 126, 34, 0.2);">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Se la PWA è rilevata come installata -->
|
||||||
|
<ng-container *ngIf="isPwaInstalled()">
|
||||||
|
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 15px; color: #ffffff; -webkit-font-smoothing: antialiased;">Applicazione Installata</h2>
|
||||||
|
<div style="font-size: 1.05rem; color: rgba(255, 255, 255, 0.9); line-height: 1.6; -webkit-font-smoothing: antialiased; text-align: center; padding: 0 10px; margin-bottom: 25px;">
|
||||||
|
La app risulta già installata sul dispositivo, chiudi il browser ed usa quella oppure disinstallala se preferisci utilizzarla da qui.
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
|
||||||
|
<button (click)="stayInBrowserForceUninstallCheck()"
|
||||||
|
style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; width: 100%;">
|
||||||
|
Continua nel browser (L'ho disinstallata)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Se l'applicazione NON è installata (flusso normale di redirect / installazione guidata) -->
|
||||||
|
<ng-container *ngIf="!isPwaInstalled()">
|
||||||
|
<!-- Fase di redirect normale (in attesa di apertura) -->
|
||||||
|
<ng-container *ngIf="!redirectFailed()">
|
||||||
|
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Apertura App in corso</h2>
|
||||||
|
<p style="font-size: 0.95rem; color: rgba(255, 255, 255, 0.65); margin-bottom: 25px; line-height: 1.5; -webkit-font-smoothing: antialiased;">
|
||||||
|
Stiamo aprendo la PWA installata per caricare la playlist ed evitare cache del browser obsoleta.
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
|
||||||
|
<button (click)="openPwaManual()"
|
||||||
|
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25);">
|
||||||
|
Apri Manualmente
|
||||||
|
</button>
|
||||||
|
<div style="margin-top: 15px; font-size: 0.85rem; color: rgba(255, 255, 255, 0.5); line-height: 1.5; -webkit-font-smoothing: antialiased; padding: 0 10px; text-align: center;">
|
||||||
|
Per evitare problemi di cache, la navigazione da browser è disattivata se la PWA è installata.<br>
|
||||||
|
Se preferisci usare il browser, disinstalla l'app dal dispositivo.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Se l'apertura automatica/manuale fallisce (PWA probabilmente non installata) -->
|
||||||
|
<ng-container *ngIf="redirectFailed()">
|
||||||
|
<!-- iOS Guide -->
|
||||||
|
<div *ngIf="settingsService.isIos()" style="text-align: left;">
|
||||||
|
<h2 style="font-size: 1.5rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; text-align: center; -webkit-font-smoothing: antialiased;">Aggiungi a Home (iOS)</h2>
|
||||||
|
<p style="font-size: 0.9rem; color: rgba(255, 255, 255, 0.65); line-height: 1.4; text-align: center; margin-bottom: 20px; -webkit-font-smoothing: antialiased;">
|
||||||
|
Per aggiornamenti istantanei e uso offline, aggiungi l'app alla schermata Home di iOS:
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 16px; margin-bottom: 25px; color: rgba(255, 255, 255, 0.85); font-size: 0.95rem;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 15px;">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; background: rgba(230, 126, 34, 0.15); border-radius: 50%; color: #e67e22; font-weight: bold; flex-shrink: 0;">1</div>
|
||||||
|
<div style="flex-grow: 1; -webkit-font-smoothing: antialiased;">
|
||||||
|
Tocca il pulsante <strong>Condividi</strong> <span style="display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); border-radius: 6px; padding: 4px; font-size: 1.1rem; vertical-align: middle;">📤</span> in Safari.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 15px;">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; background: rgba(230, 126, 34, 0.15); border-radius: 50%; color: #e67e22; font-weight: bold; flex-shrink: 0;">2</div>
|
||||||
|
<div style="flex-grow: 1; -webkit-font-smoothing: antialiased;">
|
||||||
|
Scorri il menu e seleziona <strong>Aggiungi alla schermata Home</strong> ➕.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button (click)="stayInBrowser()"
|
||||||
|
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25); width: 100%; text-align: center;">
|
||||||
|
Continua nel browser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Android / Desktop -->
|
||||||
|
<div *ngIf="!settingsService.isIos()" style="text-align: center;">
|
||||||
|
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Installa CantiCristiani</h2>
|
||||||
|
<p style="font-size: 0.95rem; color: rgba(255, 255, 255, 0.65); margin-bottom: 25px; line-height: 1.5; -webkit-font-smoothing: antialiased;">
|
||||||
|
Installa l'applicazione sul tuo dispositivo per evitare problemi di cache del browser ed usarla offline!
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
|
||||||
|
<button *ngIf="settingsService.showInstallButton() || settingsService.deferredPrompt()" (click)="triggerInstallFromRedirect()"
|
||||||
|
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25);">
|
||||||
|
INSTALLA APP
|
||||||
|
</button>
|
||||||
|
<button (click)="stayInBrowser()"
|
||||||
|
style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease; width: 100%;">
|
||||||
|
Continua nel browser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PWA Install Overlay -->
|
||||||
|
<div *ngIf="showInstallOverlay()"
|
||||||
|
style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%); color: #ffffff; display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100000; font-family: 'Outfit', sans-serif; padding: 20px;">
|
||||||
|
|
||||||
|
<!-- Android Install Prompt -->
|
||||||
|
<div *ngIf="settingsService.isAndroid()"
|
||||||
|
style="text-align: center; padding: 30px; max-width: 420px; width: 100%; background: rgba(30, 30, 30, 0.75); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);">
|
||||||
|
<div style="margin-bottom: 25px; display: inline-block;">
|
||||||
|
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.4); border: 2px solid rgba(230, 126, 34, 0.2);">
|
||||||
|
</div>
|
||||||
|
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Installa CantiCristiani</h2>
|
||||||
|
<p style="font-size: 0.95rem; color: rgba(255, 255, 255, 0.65); margin-bottom: 25px; line-height: 1.5; -webkit-font-smoothing: antialiased;">
|
||||||
|
Installa l'applicazione sul tuo dispositivo per evitare problemi di cache del browser, aprirla all'istante ed usarla anche offline in chiesa!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
|
||||||
|
<button (click)="triggerInstall()"
|
||||||
|
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25);">
|
||||||
|
INSTALLA APP
|
||||||
|
</button>
|
||||||
|
<button (click)="closeInstallOverlay()"
|
||||||
|
style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease;">
|
||||||
|
Continua nel browser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- iOS Install Guide -->
|
||||||
|
<div *ngIf="settingsService.isIos()"
|
||||||
|
style="padding: 30px; max-width: 420px; width: 100%; background: rgba(30, 30, 30, 0.75); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);">
|
||||||
|
<div style="text-align: center; margin-bottom: 20px;">
|
||||||
|
<div style="margin-bottom: 15px; display: inline-block;">
|
||||||
|
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 70px; height: 70px; border-radius: 18px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.4); border: 2px solid rgba(230, 126, 34, 0.2);">
|
||||||
|
</div>
|
||||||
|
<h2 style="font-size: 1.5rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Aggiungi a Home (iOS)</h2>
|
||||||
|
<p style="font-size: 0.9rem; color: rgba(255, 255, 255, 0.65); line-height: 1.4; -webkit-font-smoothing: antialiased;">
|
||||||
|
Per aggiornamenti istantanei e uso offline, aggiungi l'app alla schermata Home di iOS:
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 16px; margin-bottom: 25px; color: rgba(255, 255, 255, 0.85); font-size: 0.95rem;">
|
||||||
|
<div style="display: flex; align-items: center; gap: 15px;">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; background: rgba(230, 126, 34, 0.15); border-radius: 50%; color: #e67e22; font-weight: bold; flex-shrink: 0;">1</div>
|
||||||
|
<div style="flex-grow: 1; -webkit-font-smoothing: antialiased;">
|
||||||
|
Tocca il pulsante <strong>Condividi</strong> <span style="display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); border-radius: 6px; padding: 4px; font-size: 1.1rem; vertical-align: middle;">📤</span> in Safari.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; gap: 15px;">
|
||||||
|
<div style="display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; background: rgba(230, 126, 34, 0.15); border-radius: 50%; color: #e67e22; font-weight: bold; flex-shrink: 0;">2</div>
|
||||||
|
<div style="flex-grow: 1; -webkit-font-smoothing: antialiased;">
|
||||||
|
Scorri il menu e seleziona <strong>Aggiungi alla schermata Home</strong> ➕.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: flex; flex-direction: column; width: 100%;">
|
||||||
|
<button (click)="closeInstallOverlay()"
|
||||||
|
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25); text-align: center;">
|
||||||
|
Ho capito, continua
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop / Other Install Prompt -->
|
||||||
|
<div *ngIf="!settingsService.isAndroid() && !settingsService.isIos()"
|
||||||
|
style="text-align: center; padding: 30px; max-width: 420px; width: 100%; background: rgba(30, 30, 30, 0.75); border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 24px; box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45);">
|
||||||
|
<div style="margin-bottom: 25px; display: inline-block;">
|
||||||
|
<img src="assets/icon/favicon.png" alt="CantiCristiani" style="width: 80px; height: 80px; border-radius: 20px; box-shadow: 0 8px 24px rgba(230, 126, 34, 0.4); border: 2px solid rgba(230, 126, 34, 0.2);">
|
||||||
|
</div>
|
||||||
|
<h2 style="font-size: 1.6rem; font-weight: 600; margin-bottom: 8px; color: #ffffff; -webkit-font-smoothing: antialiased;">Installa CantiCristiani</h2>
|
||||||
|
<p style="font-size: 0.95rem; color: rgba(255, 255, 255, 0.65); margin-bottom: 25px; line-height: 1.5; -webkit-font-smoothing: antialiased;">
|
||||||
|
Installa l'applicazione sul tuo computer per evitare problemi di cache del browser ed usarla offline!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 12px; width: 100%;">
|
||||||
|
<button (click)="triggerInstall()"
|
||||||
|
style="background: linear-gradient(135deg, #e67e22 0%, #d35400 100%); color: #ffffff; border: none; padding: 14px 20px; border-radius: 12px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.2s ease; box-shadow: 0 4px 15px rgba(230, 126, 34, 0.25);">
|
||||||
|
INSTALLA APP
|
||||||
|
</button>
|
||||||
|
<button (click)="closeInstallOverlay()"
|
||||||
|
style="background: transparent; color: rgba(255, 255, 255, 0.7); border: 1px solid rgba(255, 255, 255, 0.15); padding: 12px 20px; border-radius: 12px; font-size: 0.95rem; font-weight: 500; cursor: pointer; transition: all 0.2s ease;">
|
||||||
|
Continua nel browser
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ion-app>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { AppComponent } from './app.component';
|
||||||
|
|
||||||
|
describe('AppComponent', () => {
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [AppComponent],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||||
|
}).compileComponents();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create the app', () => {
|
||||||
|
const fixture = TestBed.createComponent(AppComponent);
|
||||||
|
const app = fixture.componentInstance;
|
||||||
|
expect(app).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -0,0 +1,756 @@
|
|||||||
|
import { Component, inject, NgZone, OnInit, signal, effect } from '@angular/core';
|
||||||
|
import { ThemeService } from './services/theme.service';
|
||||||
|
import { CantiService } from './services/canti.service';
|
||||||
|
import { SettingsService } from './services/settings.service';
|
||||||
|
import { VERSION } from './version';
|
||||||
|
import { Router, ActivatedRoute, NavigationStart } from '@angular/router';
|
||||||
|
import { ToastController, Platform, AlertController, NavController } from '@ionic/angular';
|
||||||
|
import { Location } from '@angular/common';
|
||||||
|
import { SwUpdate, VersionReadyEvent } from '@angular/service-worker';
|
||||||
|
import { filter, first } from 'rxjs/operators';
|
||||||
|
import { App } from '@capacitor/app';
|
||||||
|
import { addIcons } from 'ionicons';
|
||||||
|
import {
|
||||||
|
add,
|
||||||
|
addCircleOutline,
|
||||||
|
addOutline,
|
||||||
|
analyticsOutline,
|
||||||
|
arrowUpCircle,
|
||||||
|
bookOutline,
|
||||||
|
calendarOutline,
|
||||||
|
cameraOutline,
|
||||||
|
cameraReverseOutline,
|
||||||
|
carOutline,
|
||||||
|
chevronBack,
|
||||||
|
chevronBackOutline,
|
||||||
|
chevronDown,
|
||||||
|
chevronDownOutline,
|
||||||
|
chevronForward,
|
||||||
|
chevronForwardOutline,
|
||||||
|
chevronUp,
|
||||||
|
chevronUpOutline,
|
||||||
|
closeCircle,
|
||||||
|
closeCircleOutline,
|
||||||
|
closeOutline,
|
||||||
|
cloudDownloadOutline,
|
||||||
|
cloudOfflineOutline,
|
||||||
|
cloudUploadOutline,
|
||||||
|
contrastOutline,
|
||||||
|
copyOutline,
|
||||||
|
createOutline,
|
||||||
|
documentAttachOutline,
|
||||||
|
documentTextOutline,
|
||||||
|
downloadOutline,
|
||||||
|
eyeOutline,
|
||||||
|
informationCircleOutline,
|
||||||
|
keypadOutline,
|
||||||
|
listOutline,
|
||||||
|
logoApple,
|
||||||
|
logoYoutube,
|
||||||
|
mic,
|
||||||
|
micOutline,
|
||||||
|
musicalNote,
|
||||||
|
musicalNotesOutline,
|
||||||
|
pause,
|
||||||
|
pauseSharp,
|
||||||
|
peopleOutline,
|
||||||
|
personOutline,
|
||||||
|
phoneLandscapeOutline,
|
||||||
|
play,
|
||||||
|
playForwardOutline,
|
||||||
|
playSharp,
|
||||||
|
playSkipBackSharp,
|
||||||
|
playSkipForwardSharp,
|
||||||
|
pricetagsOutline,
|
||||||
|
qrCodeOutline,
|
||||||
|
refreshOutline,
|
||||||
|
remove,
|
||||||
|
removeCircleOutline,
|
||||||
|
removeOutline,
|
||||||
|
reorderTwoOutline,
|
||||||
|
saveOutline,
|
||||||
|
scanOutline,
|
||||||
|
searchOutline,
|
||||||
|
settingsOutline,
|
||||||
|
shareOutline,
|
||||||
|
shareSocialOutline,
|
||||||
|
sparklesOutline,
|
||||||
|
statsChartOutline,
|
||||||
|
swapVerticalOutline,
|
||||||
|
trashOutline,
|
||||||
|
arrowUndoOutline,
|
||||||
|
videocam,
|
||||||
|
videocamOffOutline
|
||||||
|
} from 'ionicons/icons';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
templateUrl: 'app.component.html',
|
||||||
|
styleUrls: ['app.component.scss'],
|
||||||
|
standalone: false,
|
||||||
|
})
|
||||||
|
export class AppComponent implements OnInit {
|
||||||
|
private themeService = inject(ThemeService); // Ensures theme is initialized at boot
|
||||||
|
public cantiService = inject(CantiService);
|
||||||
|
public settingsService = inject(SettingsService);
|
||||||
|
public version = VERSION;
|
||||||
|
private platform = inject(Platform);
|
||||||
|
private router = inject(Router);
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private location = inject(Location);
|
||||||
|
private navCtrl = inject(NavController);
|
||||||
|
private toastCtrl = inject(ToastController);
|
||||||
|
private alertCtrl = inject(AlertController);
|
||||||
|
private swUpdate = inject(SwUpdate);
|
||||||
|
private ngZone = inject(NgZone);
|
||||||
|
|
||||||
|
public showRedirectOverlay = signal<boolean>(false);
|
||||||
|
public showInstallOverlay = signal<boolean>(false);
|
||||||
|
public isInstalling = signal<boolean>(false);
|
||||||
|
public isRedirecting = signal<boolean>(false);
|
||||||
|
public isPwaInstalled = signal<boolean>(false);
|
||||||
|
public redirectFailed = signal<boolean>(false);
|
||||||
|
public protocolLink = '';
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Gli aggiornamenti automatici e periodici sono stati rimossi.
|
||||||
|
// L'aggiornamento viene gestito esclusivamente in modo manuale
|
||||||
|
// tramite il pulsante "Verifica Aggiornamenti App" in SettingsPage.
|
||||||
|
|
||||||
|
addIcons({
|
||||||
|
add,
|
||||||
|
'add-circle-outline': addCircleOutline,
|
||||||
|
'add-outline': addOutline,
|
||||||
|
'analytics-outline': analyticsOutline,
|
||||||
|
'arrow-up-circle': arrowUpCircle,
|
||||||
|
'book-outline': bookOutline,
|
||||||
|
'calendar-outline': calendarOutline,
|
||||||
|
'camera-outline': cameraOutline,
|
||||||
|
'camera-reverse-outline': cameraReverseOutline,
|
||||||
|
'car-outline': carOutline,
|
||||||
|
'chevron-back': chevronBack,
|
||||||
|
'chevron-back-outline': chevronBackOutline,
|
||||||
|
'chevron-down': chevronDown,
|
||||||
|
'chevron-down-outline': chevronDownOutline,
|
||||||
|
'chevron-forward': chevronForward,
|
||||||
|
'chevron-forward-outline': chevronForwardOutline,
|
||||||
|
'chevron-up': chevronUp,
|
||||||
|
'chevron-up-outline': chevronUpOutline,
|
||||||
|
'close-circle': closeCircle,
|
||||||
|
'close-circle-outline': closeCircleOutline,
|
||||||
|
'close-outline': closeOutline,
|
||||||
|
'cloud-download-outline': cloudDownloadOutline,
|
||||||
|
'cloud-offline-outline': cloudOfflineOutline,
|
||||||
|
'cloud-upload-outline': cloudUploadOutline,
|
||||||
|
'contrast-outline': contrastOutline,
|
||||||
|
'copy-outline': copyOutline,
|
||||||
|
'create-outline': createOutline,
|
||||||
|
'document-attach-outline': documentAttachOutline,
|
||||||
|
'document-text-outline': documentTextOutline,
|
||||||
|
'download-outline': downloadOutline,
|
||||||
|
'eye-outline': eyeOutline,
|
||||||
|
'information-circle-outline': informationCircleOutline,
|
||||||
|
'keypad-outline': keypadOutline,
|
||||||
|
'list-outline': listOutline,
|
||||||
|
'logo-apple': logoApple,
|
||||||
|
'logo-youtube': logoYoutube,
|
||||||
|
mic,
|
||||||
|
'mic-outline': micOutline,
|
||||||
|
'musical-note': musicalNote,
|
||||||
|
'musical-notes-outline': musicalNotesOutline,
|
||||||
|
pause,
|
||||||
|
'pause-sharp': pauseSharp,
|
||||||
|
'people-outline': peopleOutline,
|
||||||
|
'person-outline': personOutline,
|
||||||
|
'phone-landscape-outline': phoneLandscapeOutline,
|
||||||
|
play,
|
||||||
|
'play-forward-outline': playForwardOutline,
|
||||||
|
'play-sharp': playSharp,
|
||||||
|
'play-skip-back-sharp': playSkipBackSharp,
|
||||||
|
'play-skip-forward-sharp': playSkipForwardSharp,
|
||||||
|
'pricetags-outline': pricetagsOutline,
|
||||||
|
'qr-code-outline': qrCodeOutline,
|
||||||
|
'refresh-outline': refreshOutline,
|
||||||
|
remove,
|
||||||
|
'remove-circle-outline': removeCircleOutline,
|
||||||
|
'remove-outline': removeOutline,
|
||||||
|
'reorder-two-outline': reorderTwoOutline,
|
||||||
|
'save-outline': saveOutline,
|
||||||
|
'scan-outline': scanOutline,
|
||||||
|
'search-outline': searchOutline,
|
||||||
|
'settings-outline': settingsOutline,
|
||||||
|
'share-outline': shareOutline,
|
||||||
|
'share-social-outline': shareSocialOutline,
|
||||||
|
'sparkles-outline': sparklesOutline,
|
||||||
|
'stats-chart-outline': statsChartOutline,
|
||||||
|
'swap-vertical-outline': swapVerticalOutline,
|
||||||
|
'trash-outline': trashOutline,
|
||||||
|
'arrow-undo-outline': arrowUndoOutline,
|
||||||
|
videocam,
|
||||||
|
'videocam-off-outline': videocamOffOutline
|
||||||
|
});
|
||||||
|
|
||||||
|
effect(() => {
|
||||||
|
this.checkLoaderDismissal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
checkLoaderDismissal() {
|
||||||
|
const ready = this.settingsService.isVersionCheckComplete() && this.cantiService.firstLoadCompleted();
|
||||||
|
if (!ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se stiamo attivamente installando o reindirizzando, NON nascondiamo il loader
|
||||||
|
if (this.isInstalling() || this.isRedirecting()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Altrimenti, nascondiamo il loader per far entrare l'utente
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
// Gestione del tasto back (PWA/Browser e Nativo/Hardware) secondo le 3 specifiche:
|
||||||
|
// 1- Se siamo su un canto (/player o /display), premendo back andiamo sempre sulla home.
|
||||||
|
// 2- Se siamo sulla home (/home o /), premendo back l'app deve uscire.
|
||||||
|
// 3- Per tutto il resto, segue la logica standard andando alla pagina precedente nello storico.
|
||||||
|
|
||||||
|
// Gestione popstate (tasto indietro browser / gesture PWA)
|
||||||
|
this.router.events.pipe(
|
||||||
|
filter((e): e is NavigationStart => e instanceof NavigationStart),
|
||||||
|
filter(e => e.navigationTrigger === 'popstate')
|
||||||
|
).subscribe(() => {
|
||||||
|
const currentPath = this.router.url.split('?')[0];
|
||||||
|
if (currentPath === '/home' || currentPath === '/') {
|
||||||
|
// Spec 2: Sulla home, usciamo dall'app
|
||||||
|
this.router.navigate(['/home'], { replaceUrl: true });
|
||||||
|
this.exitApp();
|
||||||
|
} else if (currentPath === '/player' || currentPath === '/display') {
|
||||||
|
// Spec 1: Su un canto, andiamo alla home azzerando lo stack
|
||||||
|
this.navCtrl.navigateRoot('/home', { animationDirection: 'back' });
|
||||||
|
}
|
||||||
|
// Spec 3: Per il resto (es. /settings), popstate prosegue normalmente verso la pagina precedente
|
||||||
|
});
|
||||||
|
|
||||||
|
// Gestione tasto indietro hardware (es. Android / Capacitor / PWA)
|
||||||
|
this.platform.backButton.subscribeWithPriority(10, async () => {
|
||||||
|
const currentPath = this.router.url.split('?')[0];
|
||||||
|
if (currentPath === '/home' || currentPath === '/') {
|
||||||
|
// Spec 2: Sulla home, usciamo dall'app
|
||||||
|
this.exitApp();
|
||||||
|
} else if (currentPath === '/player' || currentPath === '/display') {
|
||||||
|
// Spec 1: Su un canto, andiamo alla home azzerando lo stack
|
||||||
|
this.navCtrl.navigateRoot('/home', { animationDirection: 'back' });
|
||||||
|
} else {
|
||||||
|
// Spec 3: Per il resto, logica standard (pagina precedente)
|
||||||
|
if (window.history.length > 1) {
|
||||||
|
this.location.back();
|
||||||
|
} else {
|
||||||
|
this.navCtrl.navigateRoot('/home', { animationDirection: 'back' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add global horizontal scroll support for wheel on horizontal containers
|
||||||
|
window.addEventListener('wheel', (event: WheelEvent) => {
|
||||||
|
if (Math.abs(event.deltaY) > 0 && Math.abs(event.deltaX) === 0) {
|
||||||
|
const path = event.composedPath();
|
||||||
|
for (const target of path) {
|
||||||
|
if (target instanceof HTMLElement) {
|
||||||
|
const style = window.getComputedStyle(target);
|
||||||
|
const isHorizontalScroll =
|
||||||
|
(style.overflowX === 'auto' || style.overflowX === 'scroll') &&
|
||||||
|
target.scrollWidth > target.clientWidth;
|
||||||
|
|
||||||
|
if (isHorizontalScroll) {
|
||||||
|
target.scrollLeft += event.deltaY;
|
||||||
|
event.preventDefault();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
// 1. Allineamento istantaneo alla versione remota
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.update({
|
||||||
|
phase: 'Fase: Verifica Versione',
|
||||||
|
desc: 'Verifica della versione più recente in corso...'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.checkVersionSync();
|
||||||
|
if (updated) {
|
||||||
|
return; // Reloading, skip further setup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set signal indicating startup version check is complete
|
||||||
|
this.settingsService.isVersionCheckComplete.set(true);
|
||||||
|
|
||||||
|
// Handle PWA Launch Queue if supported (focus-existing launch behavior)
|
||||||
|
if ('launchQueue' in window) {
|
||||||
|
(window as any).launchQueue.setConsumer((launchParams: any) => {
|
||||||
|
if (launchParams.targetURL) {
|
||||||
|
this.handleLaunchUrl(launchParams.targetURL);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.route.queryParams.subscribe(params => {
|
||||||
|
const protocolUrl = params['url'];
|
||||||
|
if (protocolUrl && protocolUrl.startsWith('web+canti:')) {
|
||||||
|
try {
|
||||||
|
const cleanUrl = protocolUrl.replace('web+canti://', 'http://localhost/');
|
||||||
|
const urlObj = new URL(cleanUrl);
|
||||||
|
|
||||||
|
let targetPath = urlObj.pathname;
|
||||||
|
if (targetPath === '/open' || targetPath === '//open') {
|
||||||
|
targetPath = '/';
|
||||||
|
} else if (targetPath.startsWith('/open/')) {
|
||||||
|
targetPath = targetPath.substring(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParams: any = {};
|
||||||
|
urlObj.searchParams.forEach((value, key) => {
|
||||||
|
queryParams[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.router.navigate([targetPath], { queryParams, replaceUrl: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse protocol url:', protocolUrl, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.addEventListener('appinstalled', () => {
|
||||||
|
console.log('[AppComponent] PWA appinstalled event caught.');
|
||||||
|
localStorage.setItem('pwa-installed', 'true');
|
||||||
|
this.isPwaInstalled.set(true);
|
||||||
|
this.showInstallOverlay.set(false);
|
||||||
|
this.showRedirectOverlay.set(false);
|
||||||
|
|
||||||
|
// Se la finestra è GIÀ stata trasformata in PWA standalone (es. Desktop Mac/Windows)
|
||||||
|
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone;
|
||||||
|
if (isStandalone) {
|
||||||
|
this.isInstalling.set(false);
|
||||||
|
this.isRedirecting.set(false);
|
||||||
|
this.checkLoaderDismissal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Su Android, l'evento 'appinstalled' scatta in 2-3 secondi, MA l'OS impiega 10-12s
|
||||||
|
// per pacchettizzare e registrare il WebAPK nella schermata Home.
|
||||||
|
// Calibriamo la progress bar spalmata su ~11 secondi reali prima del reindirizzamento.
|
||||||
|
this.isInstalling.set(true);
|
||||||
|
this.isRedirecting.set(false);
|
||||||
|
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.show();
|
||||||
|
(window as any).PwaLoader.update({
|
||||||
|
title: 'Installazione applicazione',
|
||||||
|
phase: 'Fase: Registrazione',
|
||||||
|
desc: 'Generazione e registrazione dell\'applicazione sul dispositivo in corso...',
|
||||||
|
percent: 15
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const TARGET_DURATION_MS = 11000; // 11 secondi reali per completare l'installazione WebAPK
|
||||||
|
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
const elapsed = Date.now() - startTime;
|
||||||
|
let pct = Math.min(100, Math.round(15 + (elapsed / TARGET_DURATION_MS) * 85));
|
||||||
|
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.update({ percent: pct });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pct >= 100) {
|
||||||
|
clearInterval(timer);
|
||||||
|
this.isInstalling.set(false);
|
||||||
|
this.isRedirecting.set(true);
|
||||||
|
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.update({
|
||||||
|
title: 'Chiudi il browser',
|
||||||
|
desc: 'Applicazione installata con successo! Chiudi il browser e continua sulla PWA.',
|
||||||
|
isRedirect: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||||
|
console.log('[AppComponent] Running on localhost - skipping protocol link redirect on appinstalled.');
|
||||||
|
this.isRedirecting.set(false);
|
||||||
|
this.checkLoaderDismissal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = this.protocolLink;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.checkAndRedirectToPwa();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLaunchUrl(urlStr: string) {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(urlStr);
|
||||||
|
|
||||||
|
// 1. Check if it's a protocol link inside query params (e.g. /?url=web+canti://...)
|
||||||
|
const protocolUrl = urlObj.searchParams.get('url');
|
||||||
|
if (protocolUrl && protocolUrl.startsWith('web+canti:')) {
|
||||||
|
const cleanUrl = protocolUrl.replace('web+canti://', 'http://localhost/');
|
||||||
|
const innerUrlObj = new URL(cleanUrl);
|
||||||
|
|
||||||
|
let targetPath = innerUrlObj.pathname;
|
||||||
|
if (targetPath === '/open' || targetPath === '//open') {
|
||||||
|
targetPath = '/';
|
||||||
|
} else if (targetPath.startsWith('/open/')) {
|
||||||
|
targetPath = targetPath.substring(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryParams: any = {};
|
||||||
|
innerUrlObj.searchParams.forEach((value, key) => {
|
||||||
|
queryParams[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[AppComponent] Launch queue routing (protocol) to:', targetPath, queryParams);
|
||||||
|
this.router.navigate([targetPath], { queryParams, replaceUrl: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Otherwise, route directly to the pathname and query params of the URL
|
||||||
|
let targetPath = urlObj.pathname;
|
||||||
|
const queryParams: any = {};
|
||||||
|
urlObj.searchParams.forEach((value, key) => {
|
||||||
|
queryParams[key] = value;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[AppComponent] Launch queue routing (direct) to:', targetPath, queryParams);
|
||||||
|
this.router.navigate([targetPath], { queryParams, replaceUrl: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse launch url:', urlStr, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkVersionSync(): Promise<boolean> {
|
||||||
|
// Verifica all'avvio se è disponibile una nuova versione remota (sia tramite version.json che SwUpdate)
|
||||||
|
// Se disponibile, viene aggiornata e attivata automaticamente senza richiedere l'intervento dell'utente.
|
||||||
|
try {
|
||||||
|
console.log(`[PWA-Update] Verifica version.json all'avvio (locale=${VERSION})...`);
|
||||||
|
const response = await Promise.race([
|
||||||
|
fetch(`/version.json?cb=${Date.now()}`, { cache: 'no-store' }),
|
||||||
|
new Promise<Response | null>((resolve) => setTimeout(() => resolve(null), 5000))
|
||||||
|
]);
|
||||||
|
if (response && response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
if (data && data.version && data.version !== VERSION) {
|
||||||
|
console.log(`[PWA-Update] Mismatch rilevato: locale=${VERSION}, remota=${data.version}. Avvio aggiornamento automatico...`);
|
||||||
|
|
||||||
|
// Scarica e attiva il nuovo Service Worker se abilitato
|
||||||
|
if (this.swUpdate.isEnabled) {
|
||||||
|
try {
|
||||||
|
const hasSwUpdate = await Promise.race([
|
||||||
|
this.swUpdate.checkForUpdate(),
|
||||||
|
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 8000))
|
||||||
|
]);
|
||||||
|
if (hasSwUpdate) {
|
||||||
|
console.log('[PWA-Update] SW aggiornamento disponibile, attivazione automatica...');
|
||||||
|
await Promise.race([
|
||||||
|
this.swUpdate.activateUpdate(),
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, 5000))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[PWA-Update] SwUpdate durante mismatch fallito:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forziamo il controllo di aggiornamento della registrazione Service Worker
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
try {
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
await registration.update();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[PWA-Update] SW registration.update fallito:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deregistra i vecchi Service Worker per applicare la nuova versione pulita
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
for (const registration of registrations) {
|
||||||
|
await registration.unregister();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pulisci le cache del browser
|
||||||
|
if ('caches' in window) {
|
||||||
|
const keys = await caches.keys();
|
||||||
|
for (const key of keys) {
|
||||||
|
await caches.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ricarica la pagina in modo trasparente
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.set('update_cb', Date.now().toString());
|
||||||
|
window.location.replace(url.toString());
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
console.log('[PWA-Update] Versione allineata, nessun aggiornamento necessario.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[PWA-Update] version.json check fallito:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkAndRedirectToPwa() {
|
||||||
|
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
|
||||||
|
console.log('[AppComponent] Running on localhost - skipping PWA redirect and install overlays.');
|
||||||
|
this.checkLoaderDismissal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStandalone = window.matchMedia('(display-mode: standalone)').matches || (navigator as any).standalone;
|
||||||
|
if (isStandalone) {
|
||||||
|
localStorage.setItem('pwa-installed', 'true');
|
||||||
|
this.checkLoaderDismissal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const search = window.location.search;
|
||||||
|
const path = window.location.pathname;
|
||||||
|
this.protocolLink = `web+canti://open${path}${search}`;
|
||||||
|
|
||||||
|
let isInstalled = false;
|
||||||
|
if ('getInstalledRelatedApps' in navigator) {
|
||||||
|
try {
|
||||||
|
const relatedApps = await (navigator as any).getInstalledRelatedApps();
|
||||||
|
isInstalled = relatedApps.length > 0;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to check installed apps:', e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isInstalled = localStorage.getItem('pwa-installed') === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se prima era salvata come installata ma l'utente riceve il prima possibile un beforeinstallprompt,
|
||||||
|
// significa che l'app è stata disinstallata!
|
||||||
|
if (this.settingsService.deferredPrompt() || this.settingsService.showInstallButton()) {
|
||||||
|
isInstalled = false;
|
||||||
|
localStorage.setItem('pwa-installed', 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isPwaInstalled.set(isInstalled);
|
||||||
|
|
||||||
|
if (isInstalled) {
|
||||||
|
if (sessionStorage.getItem('skip-pwa-redirect') === 'true') {
|
||||||
|
this.showRedirectOverlay.set(false);
|
||||||
|
this.checkLoaderDismissal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.showRedirectOverlay.set(true);
|
||||||
|
this.redirectFailed.set(false);
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.update({ isRedirect: true });
|
||||||
|
}
|
||||||
|
// Tentiamo il reindirizzamento automatico
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = this.protocolLink;
|
||||||
|
|
||||||
|
// Se dopo 2 secondi l'utente è ancora qui, mostriamo lo stato fallito per aprire manualmente o indicare la disinstallazione
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.showRedirectOverlay()) {
|
||||||
|
this.redirectFailed.set(true);
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}, 800);
|
||||||
|
} else {
|
||||||
|
// Se non è installata, proponiamo l'installazione immediata per evitare la cache del browser e avere un'esperienza ottimale
|
||||||
|
const skipInstall = sessionStorage.getItem('skip-pwa-install') === 'true';
|
||||||
|
const isMobile = this.settingsService.isAndroid() || this.settingsService.isIos();
|
||||||
|
const hasDesktopPrompt = this.settingsService.showInstallButton() || this.settingsService.deferredPrompt();
|
||||||
|
if (!skipInstall && (isMobile || hasDesktopPrompt)) {
|
||||||
|
this.showInstallOverlay.set(true);
|
||||||
|
} else {
|
||||||
|
this.checkLoaderDismissal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openPwaManual() {
|
||||||
|
window.location.href = this.protocolLink;
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.show();
|
||||||
|
(window as any).PwaLoader.update({ isRedirect: true });
|
||||||
|
}
|
||||||
|
// Se dopo 2 secondi l'utente è ancora qui, probabilmente l'app non è installata
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.showRedirectOverlay()) {
|
||||||
|
this.redirectFailed.set(true);
|
||||||
|
localStorage.setItem('pwa-installed', 'false');
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
stayInBrowser() {
|
||||||
|
sessionStorage.setItem('skip-pwa-redirect', 'true');
|
||||||
|
this.showRedirectOverlay.set(false);
|
||||||
|
this.checkLoaderDismissal();
|
||||||
|
}
|
||||||
|
|
||||||
|
stayInBrowserForceUninstallCheck() {
|
||||||
|
localStorage.setItem('pwa-installed', 'false');
|
||||||
|
this.isPwaInstalled.set(false);
|
||||||
|
this.stayInBrowser();
|
||||||
|
}
|
||||||
|
|
||||||
|
closeInstallOverlay() {
|
||||||
|
sessionStorage.setItem('skip-pwa-install', 'true');
|
||||||
|
this.showInstallOverlay.set(false);
|
||||||
|
this.checkLoaderDismissal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async triggerInstall() {
|
||||||
|
this.isInstalling.set(true);
|
||||||
|
this.showInstallOverlay.set(false);
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.show();
|
||||||
|
(window as any).PwaLoader.update({
|
||||||
|
title: 'Installazione in corso...',
|
||||||
|
desc: 'Completa l\'installazione tramite la finestra del browser.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = await this.settingsService.installPwa();
|
||||||
|
if (outcome === 'accepted') {
|
||||||
|
this.isInstalling.set(false);
|
||||||
|
this.isRedirecting.set(true);
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.update({
|
||||||
|
title: 'Apertura Applicazione...',
|
||||||
|
desc: 'Installazione completata! Chiudi il browser e continua sulla PWA.',
|
||||||
|
isRedirect: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.isInstalling.set(false);
|
||||||
|
this.isRedirecting.set(false);
|
||||||
|
this.showInstallOverlay.set(true);
|
||||||
|
this.checkLoaderDismissal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async triggerInstallFromRedirect() {
|
||||||
|
this.isInstalling.set(true);
|
||||||
|
this.showRedirectOverlay.set(false);
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.show();
|
||||||
|
(window as any).PwaLoader.update({
|
||||||
|
title: 'Installazione in corso...',
|
||||||
|
desc: 'Completa l\'installazione tramite la finestra del browser.'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const outcome = await this.settingsService.installPwa();
|
||||||
|
if (outcome === 'accepted') {
|
||||||
|
sessionStorage.setItem('skip-pwa-redirect', 'true');
|
||||||
|
this.isInstalling.set(false);
|
||||||
|
this.isRedirecting.set(true);
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.update({
|
||||||
|
title: 'Apertura Applicazione...',
|
||||||
|
desc: 'Installazione completata! Chiudi il browser e continua sulla PWA.',
|
||||||
|
isRedirect: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.isInstalling.set(false);
|
||||||
|
this.isRedirecting.set(false);
|
||||||
|
this.showRedirectOverlay.set(true);
|
||||||
|
this.checkLoaderDismissal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private exitApp() {
|
||||||
|
try {
|
||||||
|
App.exitApp();
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
if ((navigator as any)?.app?.exitApp) {
|
||||||
|
(navigator as any).app.exitApp();
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
try {
|
||||||
|
window.close();
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showFullscreenUpdateOverlay() {
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.show();
|
||||||
|
(window as any).PwaLoader.update({
|
||||||
|
title: 'Download aggiornamento',
|
||||||
|
phase: 'Fase: Download',
|
||||||
|
desc: 'Scaricamento della nuova versione...',
|
||||||
|
percent: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch remote version to display the version being downloaded
|
||||||
|
fetch(`/version.json?cb=${Date.now()}`)
|
||||||
|
.then(res => {
|
||||||
|
if (res.ok) return res.json();
|
||||||
|
throw new Error('Fallback');
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
if (data && data.version && (window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.update({
|
||||||
|
version: 'Versione ' + data.version
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
|
||||||
|
let percent = 0;
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (percent < 95) {
|
||||||
|
percent += Math.floor(Math.random() * 5) + 2;
|
||||||
|
if (percent > 95) percent = 95;
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.update({ percent });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
return {
|
||||||
|
finish: () => {
|
||||||
|
clearInterval(interval);
|
||||||
|
if ((window as any).PwaLoader) {
|
||||||
|
(window as any).PwaLoader.update({ percent: 100 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { NgModule, isDevMode } from '@angular/core';
|
||||||
|
import { BrowserModule } from '@angular/platform-browser';
|
||||||
|
import { RouteReuseStrategy } from '@angular/router';
|
||||||
|
import { HttpClientModule, HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||||
|
import { IonicStorageModule } from '@ionic/storage-angular';
|
||||||
|
|
||||||
|
import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
|
||||||
|
|
||||||
|
import { AppComponent } from './app.component';
|
||||||
|
import { AppRoutingModule } from './app-routing.module';
|
||||||
|
import { ServiceWorkerModule } from '@angular/service-worker';
|
||||||
|
import { ApiAuthInterceptor } from './interceptors/api-auth.interceptor';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [AppComponent],
|
||||||
|
imports: [
|
||||||
|
BrowserModule,
|
||||||
|
IonicModule.forRoot(),
|
||||||
|
AppRoutingModule,
|
||||||
|
HttpClientModule,
|
||||||
|
IonicStorageModule.forRoot(),
|
||||||
|
ServiceWorkerModule.register('ngsw-worker.js', {
|
||||||
|
enabled: !isDevMode(),
|
||||||
|
// Register the ServiceWorker as soon as possible
|
||||||
|
registrationStrategy: 'registerImmediately'
|
||||||
|
})
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
||||||
|
{ provide: HTTP_INTERCEPTORS, useClass: ApiAuthInterceptor, multi: true }
|
||||||
|
],
|
||||||
|
bootstrap: [AppComponent],
|
||||||
|
})
|
||||||
|
export class AppModule {}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<ion-header class="ion-no-border">
|
||||||
|
<ion-toolbar class="bg-gradient">
|
||||||
|
<ion-title class="outfit-font">QR Code di Ripristino</ion-title>
|
||||||
|
<ion-buttons slot="end">
|
||||||
|
<ion-button (click)="dismiss()">
|
||||||
|
<ion-icon name="close-outline" slot="icon-only" color="secondary"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</ion-buttons>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
|
||||||
|
<ion-content class="bg-gradient ion-padding">
|
||||||
|
<div class="qr-container">
|
||||||
|
<p class="description outfit-font">
|
||||||
|
Salva questo QR Code (fai uno screenshot o scaricalo) per ripristinare il tuo account e le tue comunità se cambi dispositivo o reinstalli l'applicazione.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="qr-card glass">
|
||||||
|
<div class="qr-wrapper" *ngIf="qrCodeUrl">
|
||||||
|
<img [src]="qrCodeUrl" alt="QR Code di Ripristino" class="qr-image" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="uuid-box">
|
||||||
|
<span class="uuid-label outfit-font">Codice Identificativo:</span>
|
||||||
|
<span class="uuid-text select-all outfit-font">{{ userUuid }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions-wrapper">
|
||||||
|
<ion-button expand="block" fill="solid" color="secondary" class="outfit-font action-btn" (click)="copyToClipboard()">
|
||||||
|
<ion-icon name="copy-outline" slot="start"></ion-icon>
|
||||||
|
Copia Codice Testuale
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
|
<a *ngIf="qrCodeUrl" [href]="qrCodeUrl" download="ripristino-identita-canti.png" class="download-btn outfit-font">
|
||||||
|
<ion-icon name="download-outline"></ion-icon>
|
||||||
|
Scarica Immagine QR
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ion-content>
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
.qr-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px 8px;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ion-text-color);
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-card {
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 340px;
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-wrapper {
|
||||||
|
background: white;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 220px;
|
||||||
|
height: 220px;
|
||||||
|
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.qr-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.uuid-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.uuid-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uuid-text {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ion-text-color);
|
||||||
|
word-break: break-all;
|
||||||
|
opacity: 0.85;
|
||||||
|
font-family: monospace;
|
||||||
|
user-select: all;
|
||||||
|
background: rgba(var(--ion-text-color-rgb, 255, 255, 255), 0.04);
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
margin: 0;
|
||||||
|
--border-radius: 14px;
|
||||||
|
--box-shadow: 0 4px 16px rgba(var(--ion-color-secondary-rgb), 0.2);
|
||||||
|
font-weight: 600;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
text-decoration: none;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
color: var(--ion-text-color);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 14px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
ion-icon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { Component, OnInit, inject, Input } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { IonicModule, ModalController, ToastController } from '@ionic/angular';
|
||||||
|
import * as QRCode from 'qrcode';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-identity-qr-modal',
|
||||||
|
templateUrl: './identity-qr-modal.component.html',
|
||||||
|
styleUrls: ['./identity-qr-modal.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, IonicModule]
|
||||||
|
})
|
||||||
|
export class IdentityQrModalComponent implements OnInit {
|
||||||
|
private modalCtrl = inject(ModalController);
|
||||||
|
private toastCtrl = inject(ToastController);
|
||||||
|
|
||||||
|
@Input() userUuid!: string;
|
||||||
|
public qrCodeUrl: string = '';
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.generateQr();
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateQr() {
|
||||||
|
try {
|
||||||
|
this.qrCodeUrl = await QRCode.toDataURL(this.userUuid, {
|
||||||
|
errorCorrectionLevel: 'H',
|
||||||
|
margin: 2,
|
||||||
|
width: 400,
|
||||||
|
color: {
|
||||||
|
dark: '#1e293b', // Slate 800 for premium dark aesthetic contrast
|
||||||
|
light: '#ffffff'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to generate QR Code:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyToClipboard() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(this.userUuid);
|
||||||
|
const toast = await this.toastCtrl.create({
|
||||||
|
message: 'Codice copiato negli appunti!',
|
||||||
|
duration: 2000,
|
||||||
|
color: 'success',
|
||||||
|
position: 'bottom'
|
||||||
|
});
|
||||||
|
await toast.present();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy text:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss() {
|
||||||
|
this.modalCtrl.dismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<ion-header class="ion-no-border">
|
||||||
|
<ion-toolbar class="bg-gradient">
|
||||||
|
<ion-title class="outfit-font">Scansiona QR</ion-title>
|
||||||
|
<ion-buttons slot="end">
|
||||||
|
<ion-button (click)="cancel()" color="secondary" class="outfit-font">Chiudi</ion-button>
|
||||||
|
</ion-buttons>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
|
||||||
|
<ion-content class="ion-no-padding">
|
||||||
|
<div class="scanner-container">
|
||||||
|
<zxing-scanner
|
||||||
|
[formats]="allowedFormats"
|
||||||
|
[device]="currentDevice"
|
||||||
|
[autostart]="true"
|
||||||
|
[tryHarder]="false"
|
||||||
|
(camerasFound)="onCamerasFound($event)"
|
||||||
|
(scanSuccess)="onCodeResult($event)"
|
||||||
|
(scanError)="onScanError($event)"
|
||||||
|
(scanFailure)="onScanFailure($event)">
|
||||||
|
</zxing-scanner>
|
||||||
|
|
||||||
|
<div class="scan-overlay">
|
||||||
|
<div class="scan-frame"></div>
|
||||||
|
<p class="scan-text">Inquadra il QR Code della tua parrocchia</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pulsante premium per cambiare fotocamera -->
|
||||||
|
<div class="camera-toggle-container" *ngIf="availableDevices.length > 1">
|
||||||
|
<button (click)="toggleCamera()" class="camera-toggle-btn">
|
||||||
|
<ion-icon name="camera-reverse-outline"></ion-icon>
|
||||||
|
<span>Cambia fotocamera</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messaggio di aiuto per utenti iPad in modalità desktop -->
|
||||||
|
<div class="ipad-warning-container" *ngIf="showIpadWarning">
|
||||||
|
<ion-icon name="information-circle-outline"></ion-icon>
|
||||||
|
<p>
|
||||||
|
Su iPad, se non vedi lo switch fotocamera, tocca l'icona <strong>"aA"</strong> in alto nella barra di Safari e seleziona <strong>"Richiedi sito mobile"</strong>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ion-content>
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
.scanner-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: black;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
zxing-scanner {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.scan-frame {
|
||||||
|
width: 250px;
|
||||||
|
height: 250px;
|
||||||
|
border: 2px solid white;
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 0 0 4000px rgba(0, 0, 0, 0.5);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--ion-color-secondary);
|
||||||
|
box-shadow: 0 0 10px var(--ion-color-secondary);
|
||||||
|
animation: scan 2s linear infinite;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.scan-text {
|
||||||
|
color: white;
|
||||||
|
margin-top: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-toggle-container {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 50px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-toggle-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 30px;
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, transform 0.2s, box-shadow 0.2s;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: rgba(255, 255, 255, 0.35);
|
||||||
|
transform: scale(0.96);
|
||||||
|
box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.37);
|
||||||
|
}
|
||||||
|
|
||||||
|
ion-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ipad-warning-container {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 40px;
|
||||||
|
left: 24px;
|
||||||
|
right: 24px;
|
||||||
|
background: rgba(0, 0, 0, 0.75);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
color: white;
|
||||||
|
z-index: 10;
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5);
|
||||||
|
|
||||||
|
ion-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
font-weight: 500;
|
||||||
|
text-align: left;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scan {
|
||||||
|
0% { top: 0; }
|
||||||
|
100% { top: 100%; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Component, inject } from '@angular/core';
|
||||||
|
import { ModalController } from '@ionic/angular';
|
||||||
|
import { ZXingScannerModule } from '@zxing/ngx-scanner';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { IonicModule } from '@ionic/angular';
|
||||||
|
import { BarcodeFormat } from '@zxing/library';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-qr-scanner',
|
||||||
|
templateUrl: './qr-scanner.component.html',
|
||||||
|
styleUrls: ['./qr-scanner.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, IonicModule, ZXingScannerModule]
|
||||||
|
})
|
||||||
|
export class QrScannerComponent {
|
||||||
|
private modalCtrl = inject(ModalController);
|
||||||
|
public allowedFormats = [BarcodeFormat.QR_CODE];
|
||||||
|
|
||||||
|
public availableDevices: MediaDeviceInfo[] = [];
|
||||||
|
public currentDevice: MediaDeviceInfo | undefined = undefined;
|
||||||
|
|
||||||
|
onCamerasFound(devices: MediaDeviceInfo[]) {
|
||||||
|
this.availableDevices = devices;
|
||||||
|
if (devices && devices.length > 0) {
|
||||||
|
// Cerca la fotocamera posteriore (etichette contenenti 'back', 'rear', 'environment', 'posteriore')
|
||||||
|
const backCamera = devices.find(d => {
|
||||||
|
const label = d.label.toLowerCase();
|
||||||
|
return label.includes('back') ||
|
||||||
|
label.includes('rear') ||
|
||||||
|
label.includes('environment') ||
|
||||||
|
label.includes('posteriore');
|
||||||
|
});
|
||||||
|
this.currentDevice = backCamera || devices[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleCamera() {
|
||||||
|
if (this.availableDevices.length <= 1) return;
|
||||||
|
const currentIndex = this.availableDevices.findIndex(d => d.deviceId === this.currentDevice?.deviceId);
|
||||||
|
const nextIndex = (currentIndex + 1) % this.availableDevices.length;
|
||||||
|
this.currentDevice = this.availableDevices[nextIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
get showIpadWarning(): boolean {
|
||||||
|
const isIPadDesktop =
|
||||||
|
/Macintosh/.test(navigator.userAgent) &&
|
||||||
|
navigator.maxTouchPoints !== undefined &&
|
||||||
|
navigator.maxTouchPoints > 1;
|
||||||
|
return isIPadDesktop && this.availableDevices.length <= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
onCodeResult(result: string) {
|
||||||
|
if (result) {
|
||||||
|
this.modalCtrl.dismiss(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onScanError(error: any) {
|
||||||
|
// Silenzia e ignora gli errori interni della libreria zxing
|
||||||
|
try {
|
||||||
|
if (error) {
|
||||||
|
// Previeni la propagazione a livello di window/console
|
||||||
|
if (typeof error.preventDefault === 'function') error.preventDefault();
|
||||||
|
if (typeof error.stopPropagation === 'function') error.stopPropagation();
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onScanFailure(failure: any) {
|
||||||
|
// Ignora i tentativi falliti di decodifica frame per frame
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel() {
|
||||||
|
this.modalCtrl.dismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<ion-header class="ion-no-border">
|
||||||
|
<ion-toolbar class="bg-gradient">
|
||||||
|
<ion-title class="outfit-font">Condividi Playlist</ion-title>
|
||||||
|
<ion-buttons slot="end">
|
||||||
|
<ion-button (click)="dismiss()">
|
||||||
|
<ion-icon name="close-outline" slot="icon-only" color="secondary"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</ion-buttons>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
|
||||||
|
<ion-content class="bg-gradient ion-padding">
|
||||||
|
<div class="qr-container">
|
||||||
|
<p class="description outfit-font">
|
||||||
|
Fai scansionare questo QR Code da un altro dispositivo per condividere all'istante la playlist <strong>{{ playlistName }}</strong>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="qr-card glass">
|
||||||
|
<div class="qr-wrapper" *ngIf="qrCodeUrl">
|
||||||
|
<img [src]="qrCodeUrl" alt="QR Code della Playlist" class="qr-image" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="playlist-box">
|
||||||
|
<span class="playlist-label outfit-font">Nome Playlist:</span>
|
||||||
|
<span class="playlist-text outfit-font">{{ playlistName }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions-wrapper">
|
||||||
|
<ion-button expand="block" fill="solid" color="secondary" class="outfit-font action-btn" (click)="shareLinkNative()">
|
||||||
|
<ion-icon name="share-social-outline" slot="start"></ion-icon>
|
||||||
|
Invia Link / Condividi
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
|
<ion-button expand="block" fill="outline" color="secondary" class="outfit-font action-btn" (click)="copyToClipboard()">
|
||||||
|
<ion-icon name="copy-outline" slot="start"></ion-icon>
|
||||||
|
Copia Link
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ion-content>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
.qr-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px 8px;
|
||||||
|
text-align: center;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ion-text-color);
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-card {
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 340px;
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-wrapper {
|
||||||
|
background: white;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 220px;
|
||||||
|
height: 220px;
|
||||||
|
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
|
||||||
|
.qr-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-box {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-text {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--ion-text-color);
|
||||||
|
font-weight: bold;
|
||||||
|
word-break: break-word;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 340px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
margin: 0;
|
||||||
|
--border-radius: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Component, OnInit, inject, Input } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { IonicModule, ModalController, ToastController } from '@ionic/angular';
|
||||||
|
import * as QRCode from 'qrcode';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-share-playlist-qr-modal',
|
||||||
|
templateUrl: './share-playlist-qr-modal.component.html',
|
||||||
|
styleUrls: ['./share-playlist-qr-modal.component.scss'],
|
||||||
|
standalone: true,
|
||||||
|
imports: [CommonModule, IonicModule]
|
||||||
|
})
|
||||||
|
export class SharePlaylistQrModalComponent implements OnInit {
|
||||||
|
private modalCtrl = inject(ModalController);
|
||||||
|
private toastCtrl = inject(ToastController);
|
||||||
|
|
||||||
|
@Input() playlistName!: string;
|
||||||
|
@Input() shareLink!: string;
|
||||||
|
|
||||||
|
public qrCodeUrl: string = '';
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.generateQr();
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateQr() {
|
||||||
|
try {
|
||||||
|
this.qrCodeUrl = await QRCode.toDataURL(this.shareLink, {
|
||||||
|
errorCorrectionLevel: 'H',
|
||||||
|
margin: 2,
|
||||||
|
width: 400,
|
||||||
|
color: {
|
||||||
|
dark: '#1e293b',
|
||||||
|
light: '#ffffff'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to generate QR Code:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async shareLinkNative() {
|
||||||
|
const fileName = `${this.playlistName.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
|
||||||
|
try {
|
||||||
|
const res = await fetch(this.qrCodeUrl);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const file = new File([blob], fileName, { type: 'image/png' });
|
||||||
|
|
||||||
|
const isMac = /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent) ||
|
||||||
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
|
|
||||||
|
if (!isMac && navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||||
|
await navigator.share({
|
||||||
|
files: [file],
|
||||||
|
title: 'Playlist CantiCristiani',
|
||||||
|
text: `Ecco la playlist: ${this.playlistName}\n\nClicca qui per aprirla subito: ${this.shareLink}`
|
||||||
|
});
|
||||||
|
} else if (navigator.share) {
|
||||||
|
await navigator.share({
|
||||||
|
title: 'Playlist CantiCristiani',
|
||||||
|
text: `Ecco la playlist: ${this.playlistName}\n\nClicca qui per aprirla: ${this.shareLink}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.copyToClipboard();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Share failed', err);
|
||||||
|
await this.copyToClipboard();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyToClipboard() {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(this.shareLink);
|
||||||
|
const toast = await this.toastCtrl.create({
|
||||||
|
message: 'Link copiato negli appunti!',
|
||||||
|
duration: 2000,
|
||||||
|
color: 'success',
|
||||||
|
position: 'bottom'
|
||||||
|
});
|
||||||
|
await toast.present();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy text:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss() {
|
||||||
|
this.modalCtrl.dismiss();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
import { HomePage } from './home.page';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: HomePage,
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule]
|
||||||
|
})
|
||||||
|
export class HomePageRoutingModule {}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { IonicModule } from '@ionic/angular';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { HomePage } from './home.page';
|
||||||
|
import { HomePageRoutingModule } from './home-routing.module';
|
||||||
|
import { DragDropModule } from '@angular/cdk/drag-drop';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
IonicModule,
|
||||||
|
HomePageRoutingModule,
|
||||||
|
DragDropModule
|
||||||
|
],
|
||||||
|
declarations: [HomePage]
|
||||||
|
})
|
||||||
|
export class HomePageModule {}
|
||||||
@@ -0,0 +1,651 @@
|
|||||||
|
<ion-header class="ion-no-border">
|
||||||
|
<ion-toolbar class="bg-gradient">
|
||||||
|
<ion-title class="outfit-font">
|
||||||
|
<div class="header-logo-wrapper">
|
||||||
|
<img src="assets/icon/favicon.png" class="header-logo">
|
||||||
|
<div class="header-text-group">
|
||||||
|
<span class="app-name">{{ appName }}</span>
|
||||||
|
<span class="version-badge" style="cursor: default; display: inline-flex; align-items: center; gap: 4px;">
|
||||||
|
v{{ version }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ion-title>
|
||||||
|
<ion-buttons slot="end">
|
||||||
|
<ion-button routerLink="/propose-canto" class="add-btn" title="Crea un nuovo canto" *ngIf="settingsService.showEditor()">
|
||||||
|
<ion-icon slot="icon-only" name="add-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ng-container *ngIf="hasUpdateAvailable(); else showQrBtn">
|
||||||
|
<ion-button (click)="checkForAppUpdate($event)" class="add-btn" title="Aggiornamento disponibile">
|
||||||
|
<ion-icon slot="icon-only" name="refresh-outline" color="secondary"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #showQrBtn>
|
||||||
|
<ion-button (click)="importPlaylist()" class="add-btn">
|
||||||
|
<ion-icon slot="icon-only" name="qr-code-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</ng-template>
|
||||||
|
<ion-button routerLink="/settings" class="settings-btn">
|
||||||
|
<ion-icon slot="icon-only" name="settings-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</ion-buttons>
|
||||||
|
</ion-toolbar>
|
||||||
|
<ion-toolbar class="bg-gradient" *ngIf="!playlistService.selectionMode() || isAddingSongs()">
|
||||||
|
<div class="search-wrapper-group">
|
||||||
|
<div class="search-row">
|
||||||
|
<div class="search-wrapper glass">
|
||||||
|
<ion-searchbar
|
||||||
|
placeholder="Cerca un canto..."
|
||||||
|
[value]="searchQuery()"
|
||||||
|
(ionInput)="onSearch($event)"
|
||||||
|
[disabled]="isAdvancedSearchOpen()"
|
||||||
|
class="custom-searchbar">
|
||||||
|
</ion-searchbar>
|
||||||
|
<ion-button fill="clear" (click)="toggleAdvancedSearch()" class="adv-search-btn" [class.active]="isAdvancedSearchOpen()" title="Ricerca avanzata">
|
||||||
|
<ion-icon slot="icon-only" name="options-outline" [color]="isAdvancedSearchOpen() ? 'secondary' : 'medium'"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" (click)="toggleVoiceSearch('global')" class="voice-search-btn" [disabled]="isAdvancedSearchOpen()">
|
||||||
|
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'global') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'global') ? 'danger' : 'secondary'"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced Search & Filters Toggle Row -->
|
||||||
|
<div class="advanced-search-toggle-row">
|
||||||
|
<div class="toggle-row-left">
|
||||||
|
<div class="song-count-card outfit-font">
|
||||||
|
{{ filteredCanti().length }}
|
||||||
|
</div>
|
||||||
|
<button type="button" class="advanced-search-link outfit-font" (click)="toggleAdvancedSearch()">
|
||||||
|
<ion-icon [name]="isAdvancedSearchOpen() ? 'chevron-up-outline' : 'options-outline'"></ion-icon>
|
||||||
|
<span>Ricerche</span>
|
||||||
|
<span class="inline-clear-btn" *ngIf="hasAdvancedSearchParams()" (click)="clearAdvancedSearch($event)" title="Reset ricerche">
|
||||||
|
<ion-icon name="close-circle"></ion-icon>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="advanced-search-link outfit-font" (click)="toggleFilterCard()">
|
||||||
|
<ion-icon [name]="isFilterCardOpen() ? 'chevron-up-outline' : 'filter-outline'"></ion-icon>
|
||||||
|
<span>Filtri</span>
|
||||||
|
<span class="inline-clear-btn" *ngIf="hasFilterCardParams()" (click)="clearFilterCard($event)" title="Reset filtri">
|
||||||
|
<ion-icon name="close-circle"></ion-icon>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="advanced-search-link outfit-font" (click)="togglePlaylistCard()">
|
||||||
|
<ion-icon [name]="isPlaylistCardOpen() ? 'chevron-up-outline' : 'list-outline'"></ion-icon>
|
||||||
|
<span>Liste</span>
|
||||||
|
<span class="inline-clear-btn" *ngIf="hasPlaylistCardParams()" (click)="clearPlaylistCard($event)" title="Reset liste">
|
||||||
|
<ion-icon name="close-circle"></ion-icon>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced Search Panel -->
|
||||||
|
<div class="advanced-search-panel glass outfit-font"
|
||||||
|
*ngIf="isAdvancedSearchOpen()"
|
||||||
|
(touchstart)="onPanelTouchStart($event)"
|
||||||
|
(touchend)="onPanelTouchEnd($event, 'advancedSearch')">
|
||||||
|
<div class="adv-search-grid">
|
||||||
|
<div class="adv-input-group">
|
||||||
|
<label class="adv-label">Ricerca per titolo</label>
|
||||||
|
<div class="adv-input-wrapper">
|
||||||
|
<ion-icon name="text-outline" class="adv-input-icon"></ion-icon>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Titolo canto..."
|
||||||
|
[value]="searchTitle()"
|
||||||
|
(input)="onSearchTitleInput($event)"
|
||||||
|
class="adv-input" />
|
||||||
|
<ion-icon name="close-circle" *ngIf="searchTitle()" (click)="searchTitle.set('')" class="adv-clear-icon"></ion-icon>
|
||||||
|
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('title')" class="adv-mic-btn">
|
||||||
|
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'title') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'title') ? 'danger' : 'secondary'"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="adv-input-group">
|
||||||
|
<label class="adv-label">Ricerca per autore</label>
|
||||||
|
<div class="adv-input-wrapper">
|
||||||
|
<ion-icon name="person-outline" class="adv-input-icon"></ion-icon>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Nome autore..."
|
||||||
|
[value]="searchAuthor()"
|
||||||
|
(input)="onSearchAuthorInput($event)"
|
||||||
|
class="adv-input" />
|
||||||
|
<ion-icon name="close-circle" *ngIf="searchAuthor()" (click)="searchAuthor.set('')" class="adv-clear-icon"></ion-icon>
|
||||||
|
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('author')" class="adv-mic-btn">
|
||||||
|
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'author') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'author') ? 'danger' : 'secondary'"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="adv-input-group">
|
||||||
|
<label class="adv-label">Ricerca per testo</label>
|
||||||
|
<div class="adv-input-wrapper">
|
||||||
|
<ion-icon name="document-text-outline" class="adv-input-icon"></ion-icon>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Parole nel testo..."
|
||||||
|
[value]="searchText()"
|
||||||
|
(input)="onSearchTextInput($event)"
|
||||||
|
class="adv-input" />
|
||||||
|
<ion-icon name="close-circle" *ngIf="searchText()" (click)="searchText.set('')" class="adv-clear-icon"></ion-icon>
|
||||||
|
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('text')" class="adv-mic-btn">
|
||||||
|
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'text') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'text') ? 'danger' : 'secondary'"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="adv-input-group">
|
||||||
|
<label class="adv-label">Ricerca per nr canto</label>
|
||||||
|
<div class="adv-input-wrapper">
|
||||||
|
<ion-icon name="pricetag-outline" class="adv-input-icon"></ion-icon>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Numero canto..."
|
||||||
|
[value]="searchNumber()"
|
||||||
|
(input)="onSearchNumberInput($event)"
|
||||||
|
class="adv-input" />
|
||||||
|
<ion-icon name="close-circle" *ngIf="searchNumber()" (click)="searchNumber.set('')" class="adv-clear-icon"></ion-icon>
|
||||||
|
<ion-button fill="clear" size="small" (click)="toggleVoiceSearch('number')" class="adv-mic-btn">
|
||||||
|
<ion-icon slot="icon-only" [name]="(audioEngine.isSearching() && activeVoiceField() === 'number') ? 'mic' : 'mic-outline'" [color]="(audioEngine.isSearching() && activeVoiceField() === 'number') ? 'danger' : 'secondary'"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Playlist Card (default closed) -->
|
||||||
|
<div class="filter-card glass outfit-font"
|
||||||
|
*ngIf="isPlaylistCardOpen()"
|
||||||
|
(touchstart)="onPanelTouchStart($event)"
|
||||||
|
(touchend)="onPanelTouchEnd($event, 'playlistCard')">
|
||||||
|
<div class="filter-card-row">
|
||||||
|
<span class="filter-row-label">Playlist</span>
|
||||||
|
<div class="filter-row-items">
|
||||||
|
<!-- Comunità filter button if enabled -->
|
||||||
|
<div class="comunita-filter-wrapper" style="position: relative; display: inline-flex; align-items: center; z-index: 999;" *ngIf="settingsService.comunitaEnabled()">
|
||||||
|
<ion-button
|
||||||
|
[fill]="comunitaService.isFilterActive() ? 'solid' : 'outline'"
|
||||||
|
size="small"
|
||||||
|
(click)="toggleComunitaFilter()"
|
||||||
|
[color]="comunitaService.isFilterActive() ? 'secondary' : 'medium'"
|
||||||
|
class="filter-chip"
|
||||||
|
style="margin: 0;">
|
||||||
|
<ion-icon slot="start" name="people-outline" style="font-size: 1.1rem; margin-right: 4px;"></ion-icon>
|
||||||
|
{{ comunitaService.comunitaCode() ? comunitaService.comunitaNome() : 'Comunità' }}
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Playlist Chips -->
|
||||||
|
<div
|
||||||
|
*ngFor="let item of playlistService.allPlaylists()"
|
||||||
|
class="momento-chip glass"
|
||||||
|
[class.active]="playlistService.activePlaylistId() === item.id"
|
||||||
|
[class.comunita-chip]="item.isComunita"
|
||||||
|
(click)="playlistService.activePlaylistId() === item.id ? clearSpecialList($event) : selectPlaylist(item)">
|
||||||
|
<ion-icon *ngIf="item.isComunita" name="people-outline" style="font-size: 0.85rem; margin-right: 4px; vertical-align: middle;"></ion-icon>
|
||||||
|
{{ item.name }}
|
||||||
|
<span *ngIf="playlistService.activePlaylistId() === item.id && isRemotePlaylist() && playlistService.hasRemotePlaylistUpdate()" (click)="refreshActiveRemotePlaylist(); $event.stopPropagation();" style="display: inline-flex; align-items: center; justify-content: center; padding: 2px; margin-left: 6px; background: rgba(var(--ion-color-secondary-rgb), 0.25); border-radius: 50%; border: 1px solid var(--ion-color-secondary); width: 18px; height: 18px; vertical-align: middle;">
|
||||||
|
<ion-icon name="refresh-outline" style="font-size: 0.85rem; color: var(--ion-color-secondary); font-weight: bold;"></ion-icon>
|
||||||
|
</span>
|
||||||
|
<span class="close-icon-wrapper" *ngIf="playlistService.activePlaylistId() === item.id" (click)="clearSpecialList($event)">
|
||||||
|
<ion-icon name="close-circle"></ion-icon>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span *ngIf="playlistService.allPlaylists().length === 0 && !playlistService.selectionMode()" class="empty-playlist-text">
|
||||||
|
Nessuna playlist creata
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Card (default closed) -->
|
||||||
|
<div class="filter-card glass outfit-font"
|
||||||
|
*ngIf="isFilterCardOpen()"
|
||||||
|
(touchstart)="onPanelTouchStart($event)"
|
||||||
|
(touchend)="onPanelTouchEnd($event, 'filterCard')">
|
||||||
|
|
||||||
|
<!-- Row 2: Tipologia -->
|
||||||
|
<div class="filter-card-row">
|
||||||
|
<span class="filter-row-label">Tipologia</span>
|
||||||
|
<div class="filter-row-items">
|
||||||
|
<div
|
||||||
|
class="momento-chip glass"
|
||||||
|
[class.active]="showValidati()"
|
||||||
|
(click)="toggleValidati()">
|
||||||
|
Validati
|
||||||
|
<span class="close-icon-wrapper" *ngIf="showValidati()" (click)="clearListaCompleta($event)">
|
||||||
|
<ion-icon name="close-circle"></ion-icon>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="momento-chip glass"
|
||||||
|
[class.active]="showNonValidati()"
|
||||||
|
(click)="toggleNonValidati()"
|
||||||
|
style="display: inline-flex; align-items: center; gap: 6px;">
|
||||||
|
<span>Non Validati</span>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="showNonValidati() && !playlistService.selectionMode() && settingsService.showEditor()"
|
||||||
|
name="add-outline"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
routerLink="/propose-canto"
|
||||||
|
class="non-validati-add-icon">
|
||||||
|
</ion-icon>
|
||||||
|
<span class="close-icon-wrapper" *ngIf="showNonValidati()" (click)="clearListaCompleta($event)">
|
||||||
|
<ion-icon name="close-circle"></ion-icon>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 3: Liturgia -->
|
||||||
|
<div class="filter-card-row">
|
||||||
|
<span class="filter-row-label">Liturgia</span>
|
||||||
|
<div class="filter-row-items">
|
||||||
|
<div
|
||||||
|
*ngFor="let item of cantiService.indiceLiturgico()"
|
||||||
|
class="momento-chip glass"
|
||||||
|
[class.active]="selectedLiturgico() === item.id"
|
||||||
|
(click)="toggleIndex(item.id, 'liturgico')">
|
||||||
|
{{ item.tag_name }}
|
||||||
|
<span class="close-icon-wrapper" *ngIf="selectedLiturgico() === item.id" (click)="clearLiturgico($event)">
|
||||||
|
<ion-icon name="close-circle"></ion-icon>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 4: Periodo -->
|
||||||
|
<div class="filter-card-row">
|
||||||
|
<span class="filter-row-label">Periodo</span>
|
||||||
|
<div class="filter-row-items">
|
||||||
|
<div
|
||||||
|
*ngFor="let item of cantiService.indiceTematico()"
|
||||||
|
class="momento-chip glass"
|
||||||
|
[class.active]="selectedTematico() === item.id"
|
||||||
|
(click)="toggleIndex(item.id, 'tematico')">
|
||||||
|
{{ item.tag_name }}
|
||||||
|
<span class="close-icon-wrapper" *ngIf="selectedTematico() === item.id" (click)="clearTematico($event)">
|
||||||
|
<ion-icon name="close-circle"></ion-icon>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 5 (Footer): Suggeriti, Top Ten & Reset filtri -->
|
||||||
|
<div class="filter-card-footer">
|
||||||
|
<div class="footer-actions">
|
||||||
|
<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]="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>
|
||||||
|
|
||||||
|
<ion-button
|
||||||
|
[fill]="showOnlyMine() ? 'solid' : 'outline'"
|
||||||
|
size="small"
|
||||||
|
(click)="toggleOnlyMine()"
|
||||||
|
class="filter-chip">
|
||||||
|
Miei
|
||||||
|
<span class="close-icon-wrapper" *ngIf="showOnlyMine()" (click)="clearOnlyMine($event)">
|
||||||
|
<ion-icon slot="end" name="close-circle"></ion-icon>
|
||||||
|
</span>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ion-toolbar>
|
||||||
|
|
||||||
|
<!-- Fixed Actions Bar in header for active playlist or selection mode -->
|
||||||
|
<ion-toolbar class="bg-gradient playlist-toolbar" *ngIf="(playlistService.activeListName() !== null && !playlistService.selectionMode()) || (playlistService.selectionMode() && reorderList().length > 0)">
|
||||||
|
<div class="playlist-actions-bar">
|
||||||
|
<!-- Duration Badge on the left -->
|
||||||
|
<div class="playlist-duration-pill outfit-font" *ngIf="totalPlaylistDuration()">
|
||||||
|
<ion-icon name="time-outline"></ion-icon>
|
||||||
|
<span>{{ totalPlaylistDuration() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Playlist Name in middle -->
|
||||||
|
<div class="playlist-title-badge outfit-font" *ngIf="playlistService.activeListName() && !playlistService.selectionMode()">
|
||||||
|
<span class="playlist-title-text">{{ playlistService.activeListName() }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Playlist Actions -->
|
||||||
|
<div class="selection-pill glass" *ngIf="playlistService.activeListName() !== null && !playlistService.selectionMode()">
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="refreshActiveRemotePlaylist()" class="mini-action-btn" title="Aggiorna Playlist" *ngIf="isRemotePlaylist()">
|
||||||
|
<ion-icon slot="icon-only" name="refresh-outline" [style.color]="playlistService.hasRemotePlaylistUpdate() ? 'var(--ion-color-warning)' : 'inherit'"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="editPlaylist()" class="mini-action-btn" *ngIf="!isComunitaPlaylist() && isActivePlaylistSaved()">
|
||||||
|
<ion-icon slot="icon-only" name="create-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="cloneActivePlaylist()" class="mini-action-btn" title="Clona Playlist">
|
||||||
|
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="printActivePlaylist()" class="mini-action-btn" title="Esporta PDF">
|
||||||
|
<ion-icon slot="icon-only" name="print-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="shareActivePlaylist()" class="mini-action-btn">
|
||||||
|
<ion-icon slot="icon-only" name="share-social-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" color="danger" (click)="deleteActivePlaylist()" class="mini-action-btn" *ngIf="playlistService.activeListName() !== null && !isComunitaPlaylist()">
|
||||||
|
<ion-icon name="trash-outline" slot="icon-only"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Selection Mode Actions -->
|
||||||
|
<div class="selection-pill glass" *ngIf="playlistService.selectionMode() && reorderList().length > 0">
|
||||||
|
<span class="selection-count">{{ reorderList().length }}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
[value]="playlistService.activeListName() || ''"
|
||||||
|
(input)="playlistService.activeListName.set($any($event.target).value)"
|
||||||
|
placeholder="Nome playlist..."
|
||||||
|
class="playlist-name-input outfit-font"
|
||||||
|
/>
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="toggleAddingSongs()" class="mini-action-btn" *ngIf="reorderList().length > 0">
|
||||||
|
<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]="reorderList().length === 0" class="mini-action-btn" *ngIf="reorderList().length > 0">
|
||||||
|
<ion-icon slot="icon-only" name="save-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" color="danger" (click)="cancelSelection()" class="mini-action-btn" *ngIf="reorderList().length > 0">
|
||||||
|
<ion-icon slot="icon-only" name="close-circle-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
|
||||||
|
<ion-content class="bg-gradient"
|
||||||
|
(touchstart)="onTouchStart($event); onInteraction()"
|
||||||
|
(touchmove)="onTouchMove($event)"
|
||||||
|
(touchend)="onTouchEnd()"
|
||||||
|
(click)="onInteraction()">
|
||||||
|
|
||||||
|
<div class="ion-padding no-padding-top">
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<div class="percentage-label outfit-font">{{ cantiService.progress() }}%</div>
|
||||||
|
<ion-progress-bar [value]="cantiService.progress() / 100" color="secondary" style="width: 200px; border-radius: 10px; height: 8px; margin-top: 8px;"></ion-progress-bar>
|
||||||
|
<p class="ion-margin-top" style="color: var(--ion-color-secondary)">Caricamento canti...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Wrap in DragDrop only in Reorder mode -->
|
||||||
|
<div cdkDropList
|
||||||
|
[cdkDropListDisabled]="!playlistService.selectionMode() || isAddingSongs()"
|
||||||
|
(cdkDropListDropped)="drop($event)"
|
||||||
|
class="transparent-list">
|
||||||
|
|
||||||
|
<!-- Premium Suggested Mass Card (Collapsible with Swipe support) -->
|
||||||
|
<div class="suggeriti-header-card glass ion-margin-bottom"
|
||||||
|
*ngIf="showSuggeriti() && cantiLettureService.data()"
|
||||||
|
(click)="isMassCardExpanded.set(!isMassCardExpanded())"
|
||||||
|
(touchstart)="onMassCardTouchStart($event)"
|
||||||
|
(touchend)="onMassCardTouchEnd($event)"
|
||||||
|
[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>
|
||||||
|
<div class="card-header-right" style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<div class="mass-nav-buttons" style="display: flex; align-items: center; gap: 4px; z-index: 10;" (click)="$event.stopPropagation()">
|
||||||
|
<ion-button fill="clear" size="small" color="secondary" (click)="navigateMassDate(-1)" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 28px;">
|
||||||
|
<ion-icon slot="icon-only" name="chevron-back-outline" style="font-size: 1.1rem;"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" size="small" color="secondary" (click)="navigateMassDate(1)" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 28px;">
|
||||||
|
<ion-icon slot="icon-only" name="chevron-forward-outline" style="font-size: 1.1rem;"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
<ion-icon [name]="isMassCardExpanded() ? 'chevron-up-outline' : 'chevron-down-outline'" class="expand-icon" style="margin-left: 4px;"></ion-icon>
|
||||||
|
</div>
|
||||||
|
</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"
|
||||||
|
[class.is-playing]="youtubePlayerService.currentCantoId() === canto.id"
|
||||||
|
cdkDrag
|
||||||
|
[cdkDragDisabled]="!playlistService.selectionMode() || isAddingSongs()">
|
||||||
|
|
||||||
|
<!-- Drag Handle (visible only in reorder mode) -->
|
||||||
|
<div class="drag-handle" cdkDragHandle *ngIf="playlistService.selectionMode() && !isAddingSongs()">
|
||||||
|
<ion-icon name="reorder-two-outline"></ion-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-wrapper">
|
||||||
|
<!-- Selection Area -->
|
||||||
|
<div class="selection-column" (click)="toggleSongSelection(canto.id); $event.stopPropagation()">
|
||||||
|
<span class="canto-number" [class.selected-number]="playlistService.selectedIds().has(canto.id)">
|
||||||
|
{{ canto.id.startsWith('my_') ? getMySongNumber(canto) : canto.id_canti }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content Area -->
|
||||||
|
<div class="content-column" (click)="goToCanto(canto.id)">
|
||||||
|
<div class="top-row">
|
||||||
|
<ion-label class="info-section">
|
||||||
|
<h2 class="outfit-font" style="font-weight: 500; color: var(--ion-color-secondary); display: flex; align-items: center; flex-wrap: wrap; gap: 4px;">
|
||||||
|
<span *ngIf="getCommunitySongNumber(canto)" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 8px; opacity: 0.9;">{{ getCommunitySongNumber(canto) }}</span>
|
||||||
|
<span>{{ canto.titolo }}</span>
|
||||||
|
<span *ngIf="canto.nonValidato && !canto.id.startsWith('my_')" class="non-validato-badge remote-badge">Remoto</span>
|
||||||
|
<span *ngIf="canto.id.startsWith('my_')" class="non-validato-badge mio-badge">Mio</span>
|
||||||
|
</h2>
|
||||||
|
<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>
|
||||||
|
</p>
|
||||||
|
<p style="color: rgba(255,255,255,0.6); margin-bottom: 2px; display: flex; align-items: center; flex-wrap: wrap; gap: 6px;"
|
||||||
|
*ngIf="(settingsService.showDurationBpmTonality() && (canto.durata || canto.bpm || getSongTonality(canto))) || (settingsService.showUpdateDate() && canto.data_update) || (showTopTen() && getEsecuzioniCount(canto.id) !== null) || (showSuggeriti() && getMassSuggestionWeight(canto.id_canti) !== null)">
|
||||||
|
<ng-container *ngIf="settingsService.showDurationBpmTonality()">
|
||||||
|
<span *ngIf="canto.durata" style="font-size: 0.7rem; color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); padding: 1px 5px; border-radius: 4px; display: inline-flex; align-items: center; gap: 2px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15);">
|
||||||
|
<ion-icon name="time-outline" style="font-size: 0.75rem;"></ion-icon>
|
||||||
|
{{ canto.durata }}
|
||||||
|
</span>
|
||||||
|
<span *ngIf="canto.bpm" style="font-size: 0.7rem; color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); padding: 1px 5px; border-radius: 4px; display: inline-flex; align-items: center; gap: 2px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15);">
|
||||||
|
<ion-icon name="pulse-outline" style="font-size: 0.75rem;"></ion-icon>
|
||||||
|
{{ canto.bpm }} BPM
|
||||||
|
</span>
|
||||||
|
<span *ngIf="getSongTonality(canto)" style="font-size: 0.7rem; color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); padding: 1px 5px; border-radius: 4px; display: inline-flex; align-items: center; gap: 2px; border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15);">
|
||||||
|
<ion-icon name="musical-note-outline" style="font-size: 0.75rem;"></ion-icon>
|
||||||
|
{{ getSongTonality(canto) }}
|
||||||
|
</span>
|
||||||
|
</ng-container>
|
||||||
|
<span *ngIf="settingsService.showUpdateDate() && canto.data_update" class="update-date-badge" style="font-size: 0.7rem; color: rgba(255, 255, 255, 0.45); font-weight: 400; display: inline-flex; align-items: center; gap: 3px;">
|
||||||
|
<ion-icon name="calendar-outline" style="font-size: 0.75rem; color: rgba(255,255,255,0.45);"></ion-icon>
|
||||||
|
agg. {{ cantiService.formatUpdateDate(canto.data_update) }}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Visualizza tag sotto autore -->
|
||||||
|
<div *ngIf="settingsService.showTagsInList() && cantiService.getSongTags(canto).length > 0" class="song-tags-list">
|
||||||
|
<span *ngFor="let tag of cantiService.getSongTags(canto)" class="song-tag-badge">
|
||||||
|
{{ tag }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</ion-label>
|
||||||
|
|
||||||
|
<!-- Video Thumbnail Section -->
|
||||||
|
<div *ngIf="cantiService.getYoutubeId(canto.link_youtube)" class="thumb-section" (click)="$event.stopPropagation()">
|
||||||
|
<div class="thumb-container compact">
|
||||||
|
<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"
|
||||||
|
loading="lazy">
|
||||||
|
<div class="play-overlay youtube-overlay">
|
||||||
|
<ion-icon name="play-sharp"></ion-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #noPlayerThumb>
|
||||||
|
<div class="thumb-wrapper">
|
||||||
|
<img [src]="cantiService.getYoutubeThumb(canto.link_youtube)"
|
||||||
|
class="thumb-img loaded"
|
||||||
|
loading="lazy"
|
||||||
|
*ngIf="connectivityService.isOnline(); else offlineIcon">
|
||||||
|
<ng-template #offlineIcon>
|
||||||
|
<div class="offline-thumb">
|
||||||
|
<ion-icon name="cloud-offline-outline"></ion-icon>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Button (only for "mio" songs when "Miei" filter is active) -->
|
||||||
|
<div *ngIf="settingsService.showEditor() && canto.id.startsWith('my_') && showOnlyMine()" class="delete-section" (click)="$event.stopPropagation()">
|
||||||
|
<ion-button fill="clear" color="danger" (click)="deleteMyCanto(canto.id, $event)">
|
||||||
|
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player bars removed from here -->
|
||||||
|
</div>
|
||||||
|
</ion-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add songs / Toggle View Button -->
|
||||||
|
<div class="ion-text-center ion-margin-top" *ngIf="playlistService.selectionMode()">
|
||||||
|
<ion-button fill="solid" color="secondary" (click)="toggleAddingSongs()" class="action-mode-btn glass">
|
||||||
|
<ion-icon slot="start" [name]="isAddingSongs() ? 'list-outline' : 'add-circle-outline'"></ion-icon>
|
||||||
|
{{ isAddingSongs() ? 'Torna alla Playlist' : 'Aggiungi altri canti' }}
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="!cantiService.loading() && filteredCanti().length === 0" class="ion-text-center ion-padding">
|
||||||
|
<p style="color: rgba(255,255,255,0.5)">Nessun canto trovato.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ion-infinite-scroll (ionInfinite)="loadData($event)" threshold="150px" [disabled]="(comunitaService.isFilterActive() && comunitaService.comunitaCode()) || limit() >= filteredCanti().length || (playlistService.selectionMode() && !isAddingSongs())">
|
||||||
|
<ion-infinite-scroll-content loadingSpinner="bubbles" loadingText="Caricamento altri canti...">
|
||||||
|
</ion-infinite-scroll-content>
|
||||||
|
</ion-infinite-scroll>
|
||||||
|
|
||||||
|
</ion-content>
|
||||||
|
|
||||||
|
<ion-footer *ngIf="youtubePlayerService.currentCantoId()" class="ion-no-border">
|
||||||
|
<ion-toolbar class="global-player-toolbar glass">
|
||||||
|
<div class="player-content">
|
||||||
|
|
||||||
|
<div class="controls-row">
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="playPrevPreview($event)" class="skip-btn">
|
||||||
|
<ion-icon slot="icon-only" name="chevron-back"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
|
<ng-container *ngIf="youtubePlayerService.isPlayerSupported() && getPlayingCanto()?.link_youtube && getPlayingCanto()?.link_youtube!.length > 5; else noAudioMsg">
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="togglePlayPause()" class="play-btn">
|
||||||
|
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
|
<span class="player-time-label outfit-font" style="font-size: 0.85rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 38px; text-align: right; margin-left: 4px;">{{ formatSeconds(youtubePlayerService.videoProgress()) }}</span>
|
||||||
|
|
||||||
|
<ion-range
|
||||||
|
[min]="0"
|
||||||
|
[max]="youtubePlayerService.videoDuration()"
|
||||||
|
[value]="youtubePlayerService.videoProgress()"
|
||||||
|
(ionChange)="onSeek($event)"
|
||||||
|
(ionKnobMoveStart)="onSeekStart()"
|
||||||
|
(ionKnobMoveEnd)="onSeekEnd()"
|
||||||
|
color="secondary"
|
||||||
|
class="global-range"
|
||||||
|
style="margin: 0; padding: 0 4px;">
|
||||||
|
</ion-range>
|
||||||
|
|
||||||
|
<span class="player-time-label outfit-font" style="font-size: 0.85rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 38px; text-align: left; margin-right: 4px;">{{ formatSeconds(youtubePlayerService.videoDuration()) }}</span>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #noAudioMsg>
|
||||||
|
<div class="no-audio-msg outfit-font" style="flex: 1; text-align: center; font-size: 0.85rem; opacity: 0.6; color: var(--ion-color-medium);">
|
||||||
|
Audio non disponibile
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="playNextPreview($event)" class="skip-btn">
|
||||||
|
<ion-icon slot="icon-only" name="chevron-forward"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-footer>
|
||||||
|
|
||||||
|
<div class="black-screen-overlay" *ngIf="isBlackOverlayActive()" (click)="isBlackOverlayDismissed.set(true)">
|
||||||
|
<div class="black-screen-content" (click)="$event.stopPropagation()">
|
||||||
|
<div (click)="isBlackOverlayDismissed.set(true)" style="display: flex; flex-direction: column; align-items: center; gap: 8px; cursor: pointer; width: 100%;">
|
||||||
|
<p class="car-mode-text outfit-font">Schermo nero attivo</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="car-mode-canto-info" *ngIf="getPlayingCanto()">
|
||||||
|
<h2 class="car-mode-canto-title outfit-font">{{ getPlayingCanto()?.titolo }}</h2>
|
||||||
|
<p class="car-mode-canto-author outfit-font">{{ getPlayingCanto()?.autore }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="car-mode-progress outfit-font" *ngIf="youtubePlayerService.currentCantoId()">
|
||||||
|
<span class="car-mode-time">{{ formatSeconds(youtubePlayerService.videoProgress()) }} / {{ formatSeconds(youtubePlayerService.videoDuration()) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="car-mode-controls">
|
||||||
|
<ion-button fill="clear" (click)="playPrevPreview($event)" class="car-mode-btn">
|
||||||
|
<ion-icon slot="icon-only" name="play-skip-back-sharp"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" (click)="youtubePlayerService.isPlaying() ? pauseVideo($event) : playVideo($event, youtubePlayerService.currentCantoId()!)" class="car-mode-btn">
|
||||||
|
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" (click)="playNextPreview($event)" class="car-mode-btn">
|
||||||
|
<ion-icon slot="icon-only" name="play-skip-forward-sharp"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="car-mode-subtext outfit-font" (click)="isBlackOverlayDismissed.set(true)">Tocca lo sfondo o l'icona per sbloccare</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { IonicModule } from '@ionic/angular';
|
||||||
|
|
||||||
|
import { HomePage } from './home.page';
|
||||||
|
|
||||||
|
describe('HomePage', () => {
|
||||||
|
let component: HomePage;
|
||||||
|
let fixture: ComponentFixture<HomePage>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [HomePage],
|
||||||
|
imports: [IonicModule.forRoot()]
|
||||||
|
}).compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(HomePage);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
|
||||||
|
import { Observable } from 'rxjs';
|
||||||
|
import { environment } from '../../environments/environment';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ApiAuthInterceptor implements HttpInterceptor {
|
||||||
|
intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
|
||||||
|
if (req.url.includes('api.canticristiani.it')) {
|
||||||
|
const authHeader = 'Basic ' + btoa(`${environment.apiAuthUser}:${environment.apiAuthPass}`);
|
||||||
|
const authReq = req.clone({
|
||||||
|
setHeaders: {
|
||||||
|
Authorization: authHeader
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return next.handle(authReq);
|
||||||
|
}
|
||||||
|
return next.handle(req);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { Routes, RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
import { DisplayPage } from './display.page';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: DisplayPage
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule],
|
||||||
|
})
|
||||||
|
export class DisplayPageRoutingModule {}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
|
||||||
|
import { IonicModule } from '@ionic/angular';
|
||||||
|
|
||||||
|
import { DisplayPageRoutingModule } from './display-routing.module';
|
||||||
|
|
||||||
|
import { DisplayPage } from './display.page';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
IonicModule,
|
||||||
|
DisplayPageRoutingModule
|
||||||
|
],
|
||||||
|
declarations: [DisplayPage]
|
||||||
|
})
|
||||||
|
export class DisplayPageModule {}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<ion-content [fullscreen]="true" class="bg-gradient">
|
||||||
|
<div class="display-wrapper"
|
||||||
|
[style.fontSize.rem]="fontSize()"
|
||||||
|
(touchstart)="onTouchStart($event)"
|
||||||
|
(touchmove)="onTouchMove($event)"
|
||||||
|
(touchend)="onTouchEnd()">
|
||||||
|
<div class="lyrics-window">
|
||||||
|
<!-- Previous line -->
|
||||||
|
<div *ngIf="displayLines().prev" class="prev-line">
|
||||||
|
<ng-container *ngIf="showChords(); else prevText">
|
||||||
|
<span *ngFor="let seg of displayLines().prev!.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(displayLines().prev!.segments, i)">
|
||||||
|
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
|
||||||
|
<span class="seg-text">{{ seg.text }}</span>
|
||||||
|
</span>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #prevText>{{ displayLines().prev!.text }}</ng-template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active line -->
|
||||||
|
<div *ngIf="displayLines().current" class="active-line outfit-font">
|
||||||
|
<ng-container *ngIf="showChords(); else currentText">
|
||||||
|
<span *ngFor="let seg of displayLines().current!.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(displayLines().current!.segments, i)">
|
||||||
|
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
|
||||||
|
<span class="seg-text">{{ seg.text }}</span>
|
||||||
|
</span>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #currentText>{{ displayLines().current!.text }}</ng-template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Next line -->
|
||||||
|
<div *ngIf="displayLines().next" class="next-line">
|
||||||
|
<ng-container *ngIf="showChords(); else nextText">
|
||||||
|
<span *ngFor="let seg of displayLines().next!.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(displayLines().next!.segments, i)">
|
||||||
|
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
|
||||||
|
<span class="seg-text">{{ seg.text }}</span>
|
||||||
|
</span>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #nextText>{{ displayLines().next!.text }}</ng-template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer-info">
|
||||||
|
<div class="title-label outfit-font">{{ canto()?.titolo }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ion-content>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
.bg-gradient {
|
||||||
|
--background: radial-gradient(circle at top, #1e272e 0%, #000000 100%);
|
||||||
|
background: radial-gradient(circle at top, #1e272e 0%, #000000 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-wrapper {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5% 10%;
|
||||||
|
text-align: center;
|
||||||
|
transition: font-size 0.2s ease;
|
||||||
|
touch-action: none; /* Support pinch-to-zoom */
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-window {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-line {
|
||||||
|
font-size: 5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
text-shadow: 0 0 20px rgba(253, 203, 110, 0.4);
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prev-line, .next-line {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: rgba(255, 255, 255, 0.2);
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Chord segments for display ────────────────
|
||||||
|
.chord-segment {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
vertical-align: bottom;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chord {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.4em;
|
||||||
|
line-height: 1.2;
|
||||||
|
min-height: 0.5em;
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
padding-right: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-line .chord {
|
||||||
|
color: #e74c3c;
|
||||||
|
font-size: 0.35em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prev-line .chord, .next-line .chord {
|
||||||
|
color: rgba(231, 76, 60, 0.4);
|
||||||
|
font-size: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-line, .prev-line, .next-line {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-text {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
&::after {
|
||||||
|
content: '\200b';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer-info {
|
||||||
|
margin-top: 2rem;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-top: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.title-label {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { DisplayPage } from './display.page';
|
||||||
|
|
||||||
|
describe('DisplayPage', () => {
|
||||||
|
let component: DisplayPage;
|
||||||
|
let fixture: ComponentFixture<DisplayPage>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(DisplayPage);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { Component, OnInit, OnDestroy, signal, computed, effect, inject } from '@angular/core';
|
||||||
|
import { ActivatedRoute } from '@angular/router';
|
||||||
|
import { CantiService, Canto } from '../../services/canti.service';
|
||||||
|
import { LyricsParserService, ParsedSection } from '../../services/lyrics-parser.service';
|
||||||
|
import { ComunitaService } from '../../services/comunita.service';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-display',
|
||||||
|
templateUrl: './display.page.html',
|
||||||
|
styleUrls: ['./display.page.scss'],
|
||||||
|
standalone: false
|
||||||
|
})
|
||||||
|
export class DisplayPage implements OnInit, OnDestroy {
|
||||||
|
public canto = signal<Canto | null>(null);
|
||||||
|
public activeSongId = signal<string | null>(null);
|
||||||
|
public showChords = signal<boolean>(false);
|
||||||
|
public fontSize = signal<number>(1.0);
|
||||||
|
public currentLineIndex = signal<number>(0);
|
||||||
|
public transposeAmount = signal<number>(0);
|
||||||
|
|
||||||
|
public parsedSections = computed<ParsedSection[]>(() => {
|
||||||
|
const c = this.canto();
|
||||||
|
if (!c) return [];
|
||||||
|
|
||||||
|
let sections: ParsedSection[];
|
||||||
|
const hasChordsInText = !c.accordi && c.testo?.includes('[');
|
||||||
|
|
||||||
|
if ((this.showChords() && c.accordi) || (this.showChords() && hasChordsInText)) {
|
||||||
|
sections = this.lyricsParser.parseAccordi(c.accordi || c.testo);
|
||||||
|
} else {
|
||||||
|
sections = this.lyricsParser.parseText(c.testo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sections.length === 0 && c.testo) {
|
||||||
|
const fallbackLines = c.testo.split('\n')
|
||||||
|
.filter(l => l.trim().length > 0)
|
||||||
|
.map(l => ({
|
||||||
|
text: l.trim(),
|
||||||
|
segments: [{ text: l.trim() }]
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (fallbackLines.length > 0) {
|
||||||
|
sections = [{
|
||||||
|
type: 'verse',
|
||||||
|
lines: fallbackLines
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.showChords()) {
|
||||||
|
return this.lyricsParser.transposeSections(sections, this.transposeAmount());
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Get the current line text and surrounding lines from flat index */
|
||||||
|
public displayLines = computed(() => {
|
||||||
|
const sections = this.parsedSections();
|
||||||
|
const idx = this.currentLineIndex();
|
||||||
|
const allLines: { text: string; segments: any[]; sectionType: string }[] = [];
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
for (const line of section.lines) {
|
||||||
|
allLines.push({ text: line.text, segments: line.segments, sectionType: section.type });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
prev: idx > 0 ? allLines[idx - 1] : null,
|
||||||
|
current: allLines[idx] || null,
|
||||||
|
next: idx < allLines.length - 1 ? allLines[idx + 1] : null
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
private channel = new BroadcastChannel('karaoke_sync');
|
||||||
|
|
||||||
|
private readonly MIN_FONT = 0.6;
|
||||||
|
private readonly MAX_FONT = 5.0;
|
||||||
|
private initialPinchDistance: number | null = null;
|
||||||
|
private initialFontSize: number = 1.0;
|
||||||
|
|
||||||
|
private route = inject(ActivatedRoute);
|
||||||
|
private cantiService = inject(CantiService);
|
||||||
|
public lyricsParser = inject(LyricsParserService);
|
||||||
|
private comunitaService = inject(ComunitaService);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const id = this.activeSongId();
|
||||||
|
const allCanti = this.cantiService.canti();
|
||||||
|
const comunitaCantiPers = this.comunitaService.comunitaCantiPersonali();
|
||||||
|
if (id) {
|
||||||
|
let found: any = null;
|
||||||
|
if (this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive()) {
|
||||||
|
found = comunitaCantiPers.find(c => c.id === id);
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
found = allCanti.find(c => c.id === id);
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
found = comunitaCantiPers.find(c => c.id === id);
|
||||||
|
}
|
||||||
|
if (found) {
|
||||||
|
this.canto.set(found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, { allowSignalWrites: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.route.queryParams.subscribe(params => {
|
||||||
|
const id = params['id'];
|
||||||
|
if (id) {
|
||||||
|
this.activeSongId.set(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.channel.onmessage = (event) => {
|
||||||
|
if (event.data.type === 'SYNC_INDEX') {
|
||||||
|
this.currentLineIndex.set(event.data.index);
|
||||||
|
}
|
||||||
|
if (event.data.type === 'SYNC_CANTO') {
|
||||||
|
this.activeSongId.set(event.data.id);
|
||||||
|
this.transposeAmount.set(0);
|
||||||
|
}
|
||||||
|
if (event.data.type === 'SYNC_CHORDS') {
|
||||||
|
this.showChords.set(event.data.showChords);
|
||||||
|
}
|
||||||
|
if (event.data.type === 'SYNC_FONT') {
|
||||||
|
this.fontSize.set(event.data.fontSize);
|
||||||
|
}
|
||||||
|
if (event.data.type === 'SYNC_TRANSPOSE') {
|
||||||
|
this.transposeAmount.set(event.data.amount);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.channel.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Touch Gestures for Pinch-to-Zoom ---
|
||||||
|
onTouchStart(event: TouchEvent) {
|
||||||
|
if (event.touches.length === 2) {
|
||||||
|
this.initialPinchDistance = this.getDistance(event.touches[0], event.touches[1]);
|
||||||
|
this.initialFontSize = this.fontSize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchMove(event: TouchEvent) {
|
||||||
|
if (event.touches.length === 2 && this.initialPinchDistance !== null) {
|
||||||
|
event.preventDefault(); // Prevent browser zoom/scroll
|
||||||
|
const currentDistance = this.getDistance(event.touches[0], event.touches[1]);
|
||||||
|
const ratio = currentDistance / this.initialPinchDistance;
|
||||||
|
let newSize = this.initialFontSize * ratio;
|
||||||
|
|
||||||
|
// Clamp values
|
||||||
|
newSize = Math.max(this.MIN_FONT, Math.min(this.MAX_FONT, newSize));
|
||||||
|
|
||||||
|
if (Math.abs(newSize - this.fontSize()) > 0.01) {
|
||||||
|
this.fontSize.set(newSize);
|
||||||
|
// Sync back to player if display is touched
|
||||||
|
this.channel.postMessage({ type: 'SYNC_FONT', fontSize: this.fontSize() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onTouchEnd() {
|
||||||
|
this.initialPinchDistance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDistance(t1: Touch, t2: Touch): number {
|
||||||
|
return Math.sqrt(Math.pow(t1.clientX - t2.clientX, 2) + Math.pow(t1.clientY - t2.clientY, 2));
|
||||||
|
}
|
||||||
|
// ----------------------------------------
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { Routes, RouterModule } from '@angular/router';
|
||||||
|
|
||||||
|
import { PlayerPage } from './player.page';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: PlayerPage
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule],
|
||||||
|
})
|
||||||
|
export class PlayerPageRoutingModule {}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { DragDropModule } from '@angular/cdk/drag-drop';
|
||||||
|
import { IonicModule } from '@ionic/angular';
|
||||||
|
|
||||||
|
import { PlayerPageRoutingModule } from './player-routing.module';
|
||||||
|
|
||||||
|
import { PlayerPage } from './player.page';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
IonicModule,
|
||||||
|
PlayerPageRoutingModule,
|
||||||
|
DragDropModule
|
||||||
|
],
|
||||||
|
declarations: [PlayerPage]
|
||||||
|
})
|
||||||
|
export class PlayerPageModule {}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
<ion-header [translucent]="true" class="ion-no-border">
|
||||||
|
<ion-toolbar class="bg-gradient top-toolbar" style="--padding-top: 8px; --padding-bottom: 8px; --padding-start: 12px; --padding-end: 12px;">
|
||||||
|
<div class="outfit-font" style="display: flex; flex-direction: column; width: 100%; gap: 2px;">
|
||||||
|
|
||||||
|
<!-- Row 1: Number + Title + Settings -->
|
||||||
|
<div style="display: flex; align-items: center; gap: 8px; width: 100%; min-width: 0;">
|
||||||
|
<span class="canto-number" *ngIf="canto()?.id_canti" style="flex-shrink: 0;"
|
||||||
|
[style.fontSize.rem]="0.85 * fontSize()"
|
||||||
|
[style.width.px]="32 * fontSize()"
|
||||||
|
[style.height.px]="32 * fontSize()"
|
||||||
|
[style.lineHeight.px]="28 * fontSize()"
|
||||||
|
[style.borderRadius.px]="8 * fontSize()">
|
||||||
|
{{ canto()?.id?.startsWith('my_') ? getMySongNumber(canto()) : canto()?.id_canti }}
|
||||||
|
</span>
|
||||||
|
<span class="canto-title"
|
||||||
|
[style.fontSize.rem]="1.15 * fontSize()"
|
||||||
|
style="font-weight: 700; color: var(--ion-color-secondary); line-height: 1.2; white-space: normal; display: block; text-align: left; flex: 1; min-width: 0;">
|
||||||
|
<span *ngIf="getCommunitySongNumber(canto())" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 6px; opacity: 0.9;">{{ getCommunitySongNumber(canto()) }}</span>
|
||||||
|
<span>{{ canto()?.titolo || 'Player' }}</span>
|
||||||
|
</span>
|
||||||
|
<ion-button routerLink="/settings" class="settings-btn" fill="clear" style="flex-shrink: 0; margin: 0; --color: var(--ion-color-secondary);">
|
||||||
|
<ion-icon slot="icon-only" name="settings-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 2: Author -->
|
||||||
|
<div *ngIf="!isLandscapeActive()"
|
||||||
|
style="display: block; width: 100%; margin-top: 1px; margin-bottom: 2px;"
|
||||||
|
[style.paddingLeft.px]="canto()?.id_canti ? (32 * fontSize() + 8) : 0">
|
||||||
|
<span class="song-author"
|
||||||
|
[style.fontSize.rem]="0.95 * fontSize()"
|
||||||
|
style="color: var(--ion-text-color); opacity: 0.8; font-weight: 400; text-align: left; display: block;">
|
||||||
|
{{ canto()?.autore || 'Autore sconosciuto' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Row 3: Badges only -->
|
||||||
|
<div *ngIf="!isLandscapeActive()" class="scroll-horizontal"
|
||||||
|
style="margin-top: 2px; width: 100%;"
|
||||||
|
[style.paddingLeft.px]="canto()?.id_canti ? (32 * fontSize() + 8) : 0">
|
||||||
|
<div style="display: flex; align-items: center; gap: inherit;">
|
||||||
|
<ng-container *ngIf="settingsService.showDurationBpmTonality()">
|
||||||
|
<span *ngIf="canto()?.durata" class="song-duration-badge"
|
||||||
|
[style.fontSize.rem]="0.75 * fontSize()"
|
||||||
|
[style.height.px]="26 * fontSize()"
|
||||||
|
style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
|
||||||
|
<ion-icon name="time-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
|
||||||
|
{{ canto()?.durata }}
|
||||||
|
</span>
|
||||||
|
<span *ngIf="canto()?.bpm" class="song-bpm-badge"
|
||||||
|
[style.fontSize.rem]="0.75 * fontSize()"
|
||||||
|
[style.height.px]="26 * fontSize()"
|
||||||
|
style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
|
||||||
|
<ion-icon name="pulse-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
|
||||||
|
{{ canto()?.bpm }} BPM
|
||||||
|
</span>
|
||||||
|
<span *ngIf="tonality()" class="song-tonality-badge"
|
||||||
|
[style.fontSize.rem]="0.75 * fontSize()"
|
||||||
|
[style.height.px]="26 * fontSize()"
|
||||||
|
style="color: var(--ion-color-secondary); opacity: 0.85; background: rgba(var(--ion-color-secondary-rgb), 0.08); border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.15); font-weight: normal;">
|
||||||
|
<ion-icon name="musical-note-outline" [style.fontSize.rem]="1.05 * fontSize()"></ion-icon>
|
||||||
|
{{ tonality() }}
|
||||||
|
</span>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ion-toolbar>
|
||||||
|
|
||||||
|
<!-- Audio Toolbar Removed -->
|
||||||
|
</ion-header>
|
||||||
|
|
||||||
|
<ion-content class="bg-gradient" [class.full-screen-content]="settingsService.fullscreenMode()">
|
||||||
|
<div class="lyrics-container"
|
||||||
|
[style.fontSize.rem]="fontSize()"
|
||||||
|
[class.full-screen-container]="settingsService.fullscreenMode()"
|
||||||
|
[class.has-landscape-audio]="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5"
|
||||||
|
(touchstart)="onTouchStart($event)"
|
||||||
|
(touchmove)="onTouchMove($event)"
|
||||||
|
(touchend)="onTouchEnd()">
|
||||||
|
|
||||||
|
<!-- Landscape Side Controls (Scrollable) -->
|
||||||
|
<div class="landscape-side-controls" [class.active-fullscreen]="settingsService.fullscreenMode()">
|
||||||
|
<div class="side-scroll-container">
|
||||||
|
|
||||||
|
<!-- Navigation Group -->
|
||||||
|
<div class="side-group">
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="prevSong()" class="landscape-playlist-btn prev-btn">
|
||||||
|
prv
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
<div class="side-group">
|
||||||
|
<ion-button fill="clear" (click)="prev()" [disabled]="currentLineIndex() === 0">
|
||||||
|
<ion-icon slot="icon-only" name="chevron-up" color="secondary"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="side-group">
|
||||||
|
<ion-button fill="clear" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1">
|
||||||
|
<ion-icon slot="icon-only" name="chevron-down" color="secondary"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
<div class="side-group">
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="nextSong()" class="landscape-playlist-btn next-btn">
|
||||||
|
nxt
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zoom Group -->
|
||||||
|
<div class="side-group">
|
||||||
|
<ion-button fill="clear" (click)="zoomIn()" [disabled]="fontSize() >= maxZoom()">
|
||||||
|
<ion-icon slot="icon-only" name="add-circle-outline" color="secondary"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<div class="side-indicator" style="display: flex; flex-direction: column; align-items: center; gap: 2px;">
|
||||||
|
<ion-icon name="search-outline" color="secondary" style="font-size: 1.0rem;"></ion-icon>
|
||||||
|
<span class="side-val" style="font-size: 0.7rem; font-weight: 700; color: var(--ion-color-secondary);">{{ fontSize().toFixed(1) }}</span>
|
||||||
|
</div>
|
||||||
|
<ion-button fill="clear" (click)="zoomOut()" [disabled]="fontSize() <= minZoom()">
|
||||||
|
<ion-icon slot="icon-only" name="remove-circle-outline" color="secondary"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Autoscroll Standard Group -->
|
||||||
|
<div class="side-group" *ngIf="settingsService.enableStandardAutoscroll()">
|
||||||
|
<ion-button fill="clear" (click)="decreaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() <= 1">
|
||||||
|
<ion-icon slot="icon-only" name="remove"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<div class="side-indicator" (click)="toggleAutoscroll()">
|
||||||
|
<ion-icon [name]="isAutoscrolling() ? 'pause' : 'play'" [color]="isAutoscrolling() ? 'danger' : 'secondary'"></ion-icon>
|
||||||
|
<span class="side-val">V{{ autoscrollSpeed() }}</span>
|
||||||
|
</div>
|
||||||
|
<ion-button fill="clear" (click)="increaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() >= 10">
|
||||||
|
<ion-icon slot="icon-only" name="add"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sections rendering -->
|
||||||
|
<div class="lyrics-view" (click)="handleLyricsClick($event)">
|
||||||
|
<div *ngFor="let section of parsedSections(); let si = index"
|
||||||
|
class="section"
|
||||||
|
[class.chorus]="section.type === 'chorus'"
|
||||||
|
[class.verse-num]="section.type === 'verse_num'">
|
||||||
|
|
||||||
|
<!-- Section label for chorus -->
|
||||||
|
<div *ngIf="section.type === 'chorus'" class="section-label">Rit.</div>
|
||||||
|
<div *ngIf="section.type === 'verse_num' && section.verseNumber" class="section-label verse-num-label">{{ section.verseNumber }}.</div>
|
||||||
|
|
||||||
|
<div *ngFor="let line of section.lines; let li = index"
|
||||||
|
class="lyric-line"
|
||||||
|
[class.active]="isActiveLine(si, li)">
|
||||||
|
|
||||||
|
<!-- Chord mode: show chords above text -->
|
||||||
|
<ng-container *ngIf="showChords() && !isLandscapeActive(); else textOnly">
|
||||||
|
<span *ngFor="let seg of line.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(line.segments, i)">
|
||||||
|
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
|
||||||
|
<span class="seg-text">{{ seg.text }}</span>
|
||||||
|
</span>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<!-- Text only mode -->
|
||||||
|
<ng-template #textOnly>
|
||||||
|
{{ line.text }}
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Spazio bianco finale per consentire lo scorrimento a pagine perfetto dell'ultima riga in cima sullo schermo -->
|
||||||
|
<div class="bottom-spacer" *ngIf="settingsService.karaokePageScrollMode()" style="height: 75vh; width: 100%; pointer-events: none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden Camera Video for Head Navigation (needed for face detection API to run) -->
|
||||||
|
<video #faceVideo id="face-preview-video" muted playsinline autoplay style="position: fixed; width: 1px; height: 1px; opacity: 0; pointer-events: none; z-index: -1;"></video>
|
||||||
|
</ion-content>
|
||||||
|
|
||||||
|
<ion-footer class="ion-no-border">
|
||||||
|
<!-- Audio Player / Playlist Navigation Toolbar -->
|
||||||
|
<ion-toolbar class="global-player-toolbar glass">
|
||||||
|
<div class="player-content">
|
||||||
|
<div class="controls-row">
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="goHome()" class="skip-btn">
|
||||||
|
<ion-icon slot="icon-only" name="home-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="prevSong()" class="skip-btn">
|
||||||
|
<ion-icon slot="icon-only" name="chevron-back"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
|
<ng-container *ngIf="youtubePlayerService.isPlayerSupported() && canto()?.link_youtube && canto()?.link_youtube!.length > 5; else noAudioMsg">
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="toggleAudio()" class="play-btn">
|
||||||
|
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
|
<span class="player-time-label outfit-font" style="font-size: 0.85rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 38px; text-align: right; margin-left: 4px;">{{ formatSeconds(youtubePlayerService.videoProgress()) }}</span>
|
||||||
|
|
||||||
|
<ion-range
|
||||||
|
[min]="0"
|
||||||
|
[max]="youtubePlayerService.videoDuration()"
|
||||||
|
[value]="youtubePlayerService.videoProgress()"
|
||||||
|
(ionChange)="onSeek($event)"
|
||||||
|
color="secondary"
|
||||||
|
class="global-range"
|
||||||
|
style="margin: 0; padding: 0 4px;">
|
||||||
|
</ion-range>
|
||||||
|
|
||||||
|
<span class="player-time-label outfit-font" style="font-size: 0.85rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 38px; text-align: left; margin-right: 4px;">{{ formatSeconds(youtubePlayerService.videoDuration()) }}</span>
|
||||||
|
</ng-container>
|
||||||
|
<ng-template #noAudioMsg>
|
||||||
|
<div class="no-audio-msg outfit-font" style="flex: 1; text-align: center; font-size: 0.85rem; opacity: 0.6; color: var(--ion-color-medium);">
|
||||||
|
Nessun audio disponibile
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="nextSong()" class="skip-btn">
|
||||||
|
<ion-icon slot="icon-only" name="chevron-forward"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ion-toolbar>
|
||||||
|
|
||||||
|
<ion-toolbar class="bg-gradient slim-toolbar">
|
||||||
|
<div class="slim-controls">
|
||||||
|
<!-- Autoscroll Standard in Portrait Footer -->
|
||||||
|
<div class="group" *ngIf="settingsService.enableStandardAutoscroll()">
|
||||||
|
<ion-button fill="clear" size="small" (click)="decreaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() <= 1">
|
||||||
|
<ion-icon slot="icon-only" name="remove"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<div class="autoscroll-indicator" (click)="toggleAutoscroll()" style="display: flex; align-items: center; gap: 4px; padding: 0 4px; cursor: pointer;">
|
||||||
|
<ion-icon [name]="isAutoscrolling() ? 'pause' : 'play'" [color]="isAutoscrolling() ? 'danger' : 'secondary'"></ion-icon>
|
||||||
|
<span style="font-size: 0.75rem; font-weight: 700; color: var(--ion-color-secondary); min-width: 32px; text-align: center;">V{{ autoscrollSpeed() }}</span>
|
||||||
|
</div>
|
||||||
|
<ion-button fill="clear" size="small" (click)="increaseAutoscrollSpeed()" [disabled]="autoscrollSpeed() >= 10">
|
||||||
|
<ion-icon slot="icon-only" name="add"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Camera Gestures Toggle in Portrait (Tilt or Distance) -->
|
||||||
|
<div class="group" *ngIf="settingsService.enableVisualAutoscroll()">
|
||||||
|
<ion-button fill="clear" size="small" (click)="toggleCameraNavigation()" [color]="enableCameraNavigation() ? 'success' : 'secondary'" style="margin: 0;">
|
||||||
|
<ion-icon slot="icon-only" [name]="enableCameraNavigation() ? 'videocam' : 'videocam-off-outline'"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
|
<!-- Indicator in Distance Mode -->
|
||||||
|
<div class="camera-indicator distance-mode"
|
||||||
|
*ngIf="enableCameraNavigation() && settingsService.cameraTrackingMode() === 'distance'"
|
||||||
|
[class.approaching]="faceDetector.distanceState() === 'approaching'"
|
||||||
|
[class.receding]="faceDetector.distanceState() === 'receding'"
|
||||||
|
(click)="recalibrateDistance($event)"
|
||||||
|
title="Tocca per ricalibrare e impostare la tua distanza attuale come 100%">
|
||||||
|
<ion-icon [name]="faceDetector.distanceState() === 'approaching' ? 'arrow-down-circle' : (faceDetector.distanceState() === 'receding' ? 'arrow-up-circle' : 'scan-outline')"></ion-icon>
|
||||||
|
<span class="val">{{ (faceDetector.currentDistanceRatio() * 100).toFixed(0) }}%</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Indicator in Tilt Mode -->
|
||||||
|
<div class="camera-indicator"
|
||||||
|
*ngIf="enableCameraNavigation() && settingsService.cameraTrackingMode() === 'tilt'"
|
||||||
|
[class.tilted]="faceDetector.isTilted()">
|
||||||
|
<ion-icon name="person-outline"
|
||||||
|
[style.transform]="'rotate(' + (-faceDetector.currentTiltAngle()) + 'deg)'"></ion-icon>
|
||||||
|
<span class="val">{{ faceDetector.currentTiltAngle() }}°</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation (Avanzamento Karaoke e Riparti da inizio) -->
|
||||||
|
<div class="group">
|
||||||
|
<ion-button fill="clear" size="small" (click)="restart()">
|
||||||
|
<ion-icon slot="icon-only" name="arrow-up-circle" color="secondary"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" size="small" (click)="prev()" [disabled]="currentLineIndex() === 0">
|
||||||
|
<ion-icon slot="icon-only" name="chevron-back" color="secondary"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" size="small" (click)="next()" [disabled]="currentLineIndex() === getTotalLines() - 1">
|
||||||
|
<ion-icon slot="icon-only" name="chevron-forward" color="secondary"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons (edit, condividi, accordi) -->
|
||||||
|
<div class="group">
|
||||||
|
<ion-button fill="clear" size="small" (click)="editOrCloneCanto()" *ngIf="settingsService.showEditor()">
|
||||||
|
<ion-icon slot="icon-only" name="create-outline" color="secondary"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" size="small" (click)="deleteMyCanto()" *ngIf="settingsService.showEditor() && canto()?.id?.startsWith('my_')" color="danger">
|
||||||
|
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" size="small" (click)="shareCanto()">
|
||||||
|
<ion-icon slot="icon-only" name="share-social-outline" color="secondary"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" size="small" (click)="toggleChords()">
|
||||||
|
<ion-icon slot="icon-only"
|
||||||
|
[name]="showChords() ? 'musical-notes-outline' : 'text-outline'"
|
||||||
|
[color]="showChords() ? 'secondary' : 'medium'">
|
||||||
|
</ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" size="small" (click)="printCanto()" title="Esporta PDF">
|
||||||
|
<ion-icon slot="icon-only" name="print-outline" color="secondary"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transposition (Only in chords mode) -->
|
||||||
|
<div class="group" *ngIf="showChords()">
|
||||||
|
<ion-button fill="clear" size="small" (click)="transposeDown()">
|
||||||
|
<ion-icon slot="icon-only" name="remove"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<div class="transpose-indicator">
|
||||||
|
<ion-icon name="musical-note" color="secondary"></ion-icon>
|
||||||
|
<span class="val" *ngIf="tonality()">{{ tonality() }}</span>
|
||||||
|
<span class="val" *ngIf="transposeAmount() !== 0" style="opacity: 0.8; font-size: 0.75rem; margin-left: 2px;">({{ transposeAmount() > 0 ? '+' : '' }}{{ transposeAmount() }})</span>
|
||||||
|
</div>
|
||||||
|
<ion-button fill="clear" size="small" (click)="transposeUp()">
|
||||||
|
<ion-icon slot="icon-only" name="add"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Zoom -->
|
||||||
|
<div class="group">
|
||||||
|
<ion-button fill="clear" size="small" (click)="zoomOut()" [disabled]="fontSize() <= minZoom()">
|
||||||
|
<ion-icon slot="icon-only" name="remove-circle-outline" color="secondary"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<div class="zoom-indicator">
|
||||||
|
<ion-icon name="search-outline" color="secondary"></ion-icon>
|
||||||
|
<span class="val">{{ fontSize().toFixed(1) }}</span>
|
||||||
|
</div>
|
||||||
|
<ion-button fill="clear" size="small" (click)="zoomIn()" [disabled]="fontSize() >= maxZoom()">
|
||||||
|
<ion-icon slot="icon-only" name="add-circle-outline" color="secondary"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- YouTube Link in Portrait Footer -->
|
||||||
|
<div class="group" *ngIf="connectivityService.isOnline() && canto()?.link_youtube && canto()?.link_youtube!.length > 5">
|
||||||
|
<ion-button fill="clear" size="small" (click)="openYoutube()">
|
||||||
|
<ion-icon slot="icon-only" name="logo-youtube" color="danger"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-footer>
|
||||||
|
|
||||||
|
<!-- Black Screen Overlay -->
|
||||||
|
<div class="black-screen-overlay" *ngIf="isBlackScreen()" (click)="deactivateBlackScreen()">
|
||||||
|
<div class="black-screen-content" (click)="$event.stopPropagation()">
|
||||||
|
<div (click)="deactivateBlackScreen()" style="display: flex; flex-direction: column; align-items: center; gap: 8px; cursor: pointer; width: 100%;">
|
||||||
|
<p class="car-mode-text outfit-font">Schermo nero attivo</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="car-mode-canto-info" *ngIf="canto()">
|
||||||
|
<h2 class="car-mode-canto-title outfit-font">{{ canto()?.titolo }}</h2>
|
||||||
|
<p class="car-mode-canto-author outfit-font">{{ canto()?.autore }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="car-mode-progress outfit-font" *ngIf="youtubePlayerService.currentCantoId()">
|
||||||
|
<span class="car-mode-time">{{ formatSeconds(youtubePlayerService.videoProgress()) }} / {{ formatSeconds(youtubePlayerService.videoDuration()) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="car-mode-controls">
|
||||||
|
<ion-button fill="clear" (click)="prevSong()" class="car-mode-btn">
|
||||||
|
<ion-icon slot="icon-only" name="play-skip-back-sharp"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" (click)="toggleAudio()" class="car-mode-btn">
|
||||||
|
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" (click)="nextSong()" class="car-mode-btn">
|
||||||
|
<ion-icon slot="icon-only" name="play-skip-forward-sharp"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { PlayerPage } from './player.page';
|
||||||
|
|
||||||
|
describe('PlayerPage', () => {
|
||||||
|
let component: PlayerPage;
|
||||||
|
let fixture: ComponentFixture<PlayerPage>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(PlayerPage);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
fixture.detectChanges();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { IonicModule } from '@ionic/angular';
|
||||||
|
import { DragDropModule } from '@angular/cdk/drag-drop';
|
||||||
|
import { PlaylistPage } from './playlist.page';
|
||||||
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: PlaylistPage
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
IonicModule,
|
||||||
|
DragDropModule,
|
||||||
|
RouterModule.forChild(routes)
|
||||||
|
],
|
||||||
|
declarations: [PlaylistPage]
|
||||||
|
})
|
||||||
|
export class PlaylistPageModule {}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<ion-header class="ion-no-border">
|
||||||
|
<ion-toolbar class="bg-gradient">
|
||||||
|
<ion-buttons slot="start">
|
||||||
|
<ion-back-button defaultHref="/home" color="secondary"></ion-back-button>
|
||||||
|
</ion-buttons>
|
||||||
|
<ion-title class="outfit-font">Gestione Playlist</ion-title>
|
||||||
|
<ion-buttons slot="end">
|
||||||
|
<ion-button (click)="exportPlaylistPdf()" [disabled]="localSongs.length === 0" title="Esporta PDF">
|
||||||
|
<ion-icon slot="icon-only" name="print-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button (click)="savePlaylist()" [disabled]="localSongs.length === 0">
|
||||||
|
<ion-icon slot="icon-only" name="save-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</ion-buttons>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
|
||||||
|
<ion-content class="bg-gradient">
|
||||||
|
<div class="ion-padding">
|
||||||
|
<div class="header-info glass ion-margin-bottom" *ngIf="localSongs.length > 0" style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<p style="margin: 0;">Trascina i canti per riordinarli. Una volta finito puoi salvare la playlist.</p>
|
||||||
|
<div *ngIf="totalDuration" style="background: rgba(var(--ion-color-secondary-rgb), 0.25); border: 1px solid var(--ion-color-secondary); padding: 4px 10px; border-radius: 10px; font-weight: 800; color: var(--ion-color-secondary); display: flex; align-items: center; gap: 4px; font-size: 0.85rem; font-family: 'Outfit', sans-serif; white-space: nowrap; margin-left: 12px;">
|
||||||
|
<ion-icon name="time-outline" style="font-size: 1rem;"></ion-icon>
|
||||||
|
{{ totalDuration }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div cdkDropList class="song-list" (cdkDropListDropped)="drop($event)">
|
||||||
|
<div class="song-item glass ion-margin-bottom" *ngFor="let song of localSongs" cdkDrag>
|
||||||
|
<div class="drag-handle" cdkDragHandle>
|
||||||
|
<ion-icon name="reorder-two-outline"></ion-icon>
|
||||||
|
</div>
|
||||||
|
<div class="song-info">
|
||||||
|
<span class="canto-number">{{ song.id.startsWith('my_') ? getMySongNumber(song) : song.id_canti }}</span>
|
||||||
|
<span class="song-title">
|
||||||
|
<span *ngIf="getCommunitySongNumber(song)" style="color: var(--ion-color-secondary); font-weight: 600; margin-right: 6px;">{{ getCommunitySongNumber(song) }}</span>{{ song.titolo }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="example-custom-placeholder" *cdkDragPlaceholder></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="localSongs.length === 0" class="empty-state ion-text-center">
|
||||||
|
<ion-icon name="musical-notes-outline" class="large-icon"></ion-icon>
|
||||||
|
<p>Nessun canto selezionato.</p>
|
||||||
|
<ion-button routerLink="/home" fill="outline" color="secondary" class="ion-margin-top">
|
||||||
|
Torna alla Home
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ion-content>
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
.song-list {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: move;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
.drag-handle {
|
||||||
|
margin-right: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
font-size: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.canto-number {
|
||||||
|
background: rgba(var(--ion-color-secondary-rgb), 0.1);
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdk-drag-preview {
|
||||||
|
box-sizing: border-box;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 5px 5px -3px rgba(0, 0, 0, 0.2),
|
||||||
|
0 8px 10px 1px rgba(0, 0, 0, 0.14),
|
||||||
|
0 3px 14px 2px rgba(0, 0, 0, 0.12);
|
||||||
|
background: #1e1e1e;
|
||||||
|
padding: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdk-drag-placeholder {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cdk-drag-animating {
|
||||||
|
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-list.cdk-drop-list-dragging .song-item:not(.cdk-drag-placeholder) {
|
||||||
|
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-info {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
margin-top: 100px;
|
||||||
|
.large-icon {
|
||||||
|
font-size: 5rem;
|
||||||
|
color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(5px);
|
||||||
|
|
||||||
|
.qr-card {
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 20px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 350px;
|
||||||
|
text-align: center;
|
||||||
|
color: #000;
|
||||||
|
|
||||||
|
.qr-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
h3 { margin: 0; text-align: left; }
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-img-container {
|
||||||
|
background: #f8f8f8;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-export-fab {
|
||||||
|
--border-radius: 30px;
|
||||||
|
width: auto;
|
||||||
|
min-width: 160px;
|
||||||
|
|
||||||
|
.fab-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 16px;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
ion-icon {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fab-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(body.high-contrast) {
|
||||||
|
.song-item {
|
||||||
|
background: #ffffff !important;
|
||||||
|
border-color: #000000 !important;
|
||||||
|
.song-title { color: #000000 !important; }
|
||||||
|
.drag-handle { color: #000000 !important; }
|
||||||
|
}
|
||||||
|
.header-info p { color: #000000 !important; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import { Component, inject, computed } from '@angular/core';
|
||||||
|
import { PlaylistService } from '../../services/playlist.service';
|
||||||
|
import { CantiService } from '../../services/canti.service';
|
||||||
|
import { ComunitaService } from '../../services/comunita.service';
|
||||||
|
import { MyCantiService } from '../../services/my-canti.service';
|
||||||
|
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
|
||||||
|
import { AlertController, ToastController, ModalController } from '@ionic/angular';
|
||||||
|
import { Router } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-playlist',
|
||||||
|
templateUrl: './playlist.page.html',
|
||||||
|
styleUrls: ['./playlist.page.scss'],
|
||||||
|
standalone: false
|
||||||
|
})
|
||||||
|
export class PlaylistPage {
|
||||||
|
public playlistService = inject(PlaylistService);
|
||||||
|
public cantiService = inject(CantiService);
|
||||||
|
public comunitaService = inject(ComunitaService);
|
||||||
|
public myCantiService = inject(MyCantiService);
|
||||||
|
private alertCtrl = inject(AlertController);
|
||||||
|
private toastCtrl = inject(ToastController);
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
public selectedSongs = computed(() => {
|
||||||
|
const ids = Array.from(this.playlistService.selectedIds());
|
||||||
|
const allCanti = this.cantiService.canti();
|
||||||
|
const myCantiList = this.myCantiService.myCanti();
|
||||||
|
const comunitaCantiPers = this.comunitaService.comunitaCantiPersonali();
|
||||||
|
const communityActive = this.comunitaService.comunitaCode() && this.comunitaService.isFilterActive();
|
||||||
|
|
||||||
|
return ids.map(id => {
|
||||||
|
let found: any = null;
|
||||||
|
if (communityActive) {
|
||||||
|
found = comunitaCantiPers.find(c => c.id === id);
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
found = allCanti.find(c => c.id === id);
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
found = myCantiList.find(c => c.id === id);
|
||||||
|
}
|
||||||
|
if (!found) {
|
||||||
|
found = comunitaCantiPers.find(c => c.id === id);
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}).filter(c => !!c);
|
||||||
|
});
|
||||||
|
|
||||||
|
public localSongs: any[] = [];
|
||||||
|
public qrCodeImage: string | null = null;
|
||||||
|
public savedPlaylistName: string | null = null;
|
||||||
|
|
||||||
|
get totalDuration(): string {
|
||||||
|
const songs = this.localSongs;
|
||||||
|
if (songs.length === 0) return '';
|
||||||
|
let totalSeconds = 0;
|
||||||
|
for (const song of songs) {
|
||||||
|
if (song && song.durata) {
|
||||||
|
totalSeconds += this.parseDuration(song.durata);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (totalSeconds === 0) return '';
|
||||||
|
return this.formatTotalDuration(totalSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseDuration(dur: string): number {
|
||||||
|
if (!dur) return 0;
|
||||||
|
const parts = dur.split(':').map(Number);
|
||||||
|
if (parts.some(isNaN)) return 0;
|
||||||
|
if (parts.length === 2) {
|
||||||
|
return parts[0] * 60 + parts[1];
|
||||||
|
} else if (parts.length === 3) {
|
||||||
|
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatTotalDuration(seconds: number): string {
|
||||||
|
const hh = Math.floor(seconds / 3600);
|
||||||
|
const mm = Math.floor((seconds % 3600) / 60);
|
||||||
|
const ss = seconds % 60;
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||||
|
if (hh > 0) {
|
||||||
|
return `${pad(hh)}:${pad(mm)}:${pad(ss)}`;
|
||||||
|
}
|
||||||
|
return `${pad(mm)}:${pad(ss)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Initial copy to allow local reordering
|
||||||
|
this.localSongs = [...this.selectedSongs()];
|
||||||
|
}
|
||||||
|
|
||||||
|
drop(event: CdkDragDrop<string[]>) {
|
||||||
|
moveItemInArray(this.localSongs, event.previousIndex, event.currentIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlaylistSongSettings(): any {
|
||||||
|
const activeId = this.playlistService.activePlaylistId();
|
||||||
|
if (activeId) {
|
||||||
|
const pl = this.playlistService.playlists().find(p => p.id === activeId);
|
||||||
|
return pl ? pl.songSettings : undefined;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
async savePlaylist() {
|
||||||
|
const alert = await this.alertCtrl.create({
|
||||||
|
header: 'Salva Playlist',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Nome della playlist',
|
||||||
|
value: this.savedPlaylistName || ''
|
||||||
|
}
|
||||||
|
],
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: 'Annulla',
|
||||||
|
role: 'cancel'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Salva',
|
||||||
|
handler: (data) => {
|
||||||
|
if (data.name) {
|
||||||
|
this.savedPlaylistName = data.name;
|
||||||
|
this.playlistService.savePlaylist(data.name, this.localSongs.map(s => s.id), this.getPlaylistSongSettings());
|
||||||
|
this.showToast('Playlist salvata!');
|
||||||
|
this.router.navigate(['/settings']);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await alert.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveAndExport() {
|
||||||
|
const alert = await this.alertCtrl.create({
|
||||||
|
header: 'Salva ed Esporta QR',
|
||||||
|
message: 'Inserisci un nome per la playlist. Verrà salvata e generato il QR Code.',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
name: 'name',
|
||||||
|
type: 'text',
|
||||||
|
placeholder: 'Nome della playlist',
|
||||||
|
value: this.savedPlaylistName || ''
|
||||||
|
}
|
||||||
|
],
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: 'Annulla',
|
||||||
|
role: 'cancel'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Salva ed Esporta',
|
||||||
|
handler: async (data) => {
|
||||||
|
if (data.name) {
|
||||||
|
this.savedPlaylistName = data.name;
|
||||||
|
const ids = this.localSongs.map(s => s.id);
|
||||||
|
const songSettings = this.getPlaylistSongSettings();
|
||||||
|
await this.playlistService.savePlaylist(data.name, ids, songSettings);
|
||||||
|
this.qrCodeImage = await this.playlistService.generateQR(ids, data.name, songSettings);
|
||||||
|
this.showToast('Playlist salvata!');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await alert.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
async sharePlaylist() {
|
||||||
|
if (this.localSongs.length === 0) return;
|
||||||
|
|
||||||
|
const ids = this.localSongs.map(s => s.id);
|
||||||
|
const name = this.savedPlaylistName || 'Playlist Condivisa';
|
||||||
|
const songSettings = this.getPlaylistSongSettings();
|
||||||
|
this.qrCodeImage = await this.playlistService.generateQR(ids, name, songSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadQR() {
|
||||||
|
if (!this.qrCodeImage) return;
|
||||||
|
|
||||||
|
const name = this.savedPlaylistName || 'playlist';
|
||||||
|
const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = this.qrCodeImage;
|
||||||
|
link.download = fileName;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
|
||||||
|
this.showToast('Immagine scaricata!');
|
||||||
|
}
|
||||||
|
|
||||||
|
async shareQR() {
|
||||||
|
if (!this.qrCodeImage) return;
|
||||||
|
const name = this.savedPlaylistName || 'playlist';
|
||||||
|
const songSettings = this.getPlaylistSongSettings();
|
||||||
|
const shareLink = this.playlistService.getShareLink(this.localSongs.map(s => s.id), name, songSettings);
|
||||||
|
const fileName = `${name.toLowerCase().replace(/\s+/g, '_')}_qr.png`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert base64 to blob/file for sharing
|
||||||
|
const res = await fetch(this.qrCodeImage);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const file = new File([blob], fileName, { type: 'image/png' });
|
||||||
|
|
||||||
|
const isMac = /Macintosh|MacIntel|MacPPC|Mac68K/.test(navigator.userAgent) ||
|
||||||
|
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
|
|
||||||
|
if (!isMac && navigator.share && navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||||
|
await navigator.share({
|
||||||
|
files: [file],
|
||||||
|
title: 'Playlist CantiCristiani',
|
||||||
|
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla subito: ${shareLink}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback to simple share text or download
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share({
|
||||||
|
title: 'Playlist CantiCristiani',
|
||||||
|
text: `Ecco la playlist: ${name}\n\nClicca qui per aprirla: ${shareLink}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Copy to clipboard AND download QR!
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
await navigator.clipboard.writeText(shareLink);
|
||||||
|
this.showToast('Link copiato negli appunti! QR scaricato.');
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
this.downloadQR();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Share failed', err);
|
||||||
|
this.downloadQR();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeQR() {
|
||||||
|
this.qrCodeImage = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async showToast(message: string) {
|
||||||
|
const toast = await this.toastCtrl.create({
|
||||||
|
message,
|
||||||
|
duration: 2000,
|
||||||
|
color: 'success'
|
||||||
|
});
|
||||||
|
await toast.present();
|
||||||
|
}
|
||||||
|
|
||||||
|
getCommunitySongNumber(song: any): string | null {
|
||||||
|
if (!this.comunitaService.comunitaCode() || !this.comunitaService.isFilterActive()) return null;
|
||||||
|
const cantiInfo = this.comunitaService.comunitaCantiInfo();
|
||||||
|
const info = cantiInfo.find(x => x.id_canti === song.id_canti || x.id_canti === Number(song.id));
|
||||||
|
return info && info.num_canto ? info.num_canto.toString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getMySongNumber(song: any): number {
|
||||||
|
if (!song || !song.id) return 0;
|
||||||
|
const index = this.myCantiService.myCanti().findIndex(c => c.id === song.id);
|
||||||
|
return index !== -1 ? index + 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async exportPlaylistPdf() {
|
||||||
|
if (this.localSongs.length === 0) return;
|
||||||
|
|
||||||
|
const songSettings = this.getPlaylistSongSettings() || {};
|
||||||
|
const transpositions: { [songId: string]: number } = {};
|
||||||
|
for (const key of Object.keys(songSettings)) {
|
||||||
|
if (songSettings[key] && songSettings[key].tonalita !== undefined) {
|
||||||
|
transpositions[key] = songSettings[key].tonalita;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const alert = await this.alertCtrl.create({
|
||||||
|
header: 'Esporta in PDF',
|
||||||
|
message: 'Vuoi includere gli accordi nel PDF o esportare solo il testo?',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: 'Solo Testo',
|
||||||
|
handler: () => {
|
||||||
|
const name = this.savedPlaylistName || 'Playlist';
|
||||||
|
this.playlistService.printPlaylist(name, this.localSongs, false, transpositions);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Testo e Accordi',
|
||||||
|
handler: () => {
|
||||||
|
const name = this.savedPlaylistName || 'Playlist';
|
||||||
|
this.playlistService.printPlaylist(name, this.localSongs, true, transpositions);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Annulla',
|
||||||
|
role: 'cancel'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await alert.present();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
<ion-header class="ion-no-border">
|
||||||
|
<ion-toolbar class="bg-gradient">
|
||||||
|
<ion-buttons slot="start">
|
||||||
|
<ion-back-button defaultHref="/home" color="secondary"></ion-back-button>
|
||||||
|
</ion-buttons>
|
||||||
|
<ion-title class="outfit-font">Mio Canto</ion-title>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
|
||||||
|
<ion-content class="ion-padding bg-gradient"
|
||||||
|
[class.high-contrast-mode]="isHighContrast"
|
||||||
|
[class.drag-over]="isDraggingOver"
|
||||||
|
(dragover)="onDragOver($event)"
|
||||||
|
(dragleave)="onDragLeave($event)"
|
||||||
|
(drop)="onDrop($event)">
|
||||||
|
|
||||||
|
<div class="drag-overlay" *ngIf="isDraggingOver">
|
||||||
|
<div class="drag-message">
|
||||||
|
<ion-icon name="document-text-outline"></ion-icon>
|
||||||
|
<p>Rilascia l'immagine o il PDF qui per estrarre il testo</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="propose-container">
|
||||||
|
<!-- OCR Progress -->
|
||||||
|
<div class="ocr-progress-card" *ngIf="isProcessingOCR">
|
||||||
|
<ion-spinner name="crescent"></ion-spinner>
|
||||||
|
<div class="progress-info">
|
||||||
|
<p>Elaborazione in corso...</p>
|
||||||
|
<ion-progress-bar [value]="ocrProgress" color="secondary"></ion-progress-bar>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Segment Control for mobile layout -->
|
||||||
|
<ion-segment [(ngModel)]="activeTab" class="custom-mobile-segment ion-margin-bottom" mode="md">
|
||||||
|
<ion-segment-button value="editor">
|
||||||
|
<ion-label>Editor</ion-label>
|
||||||
|
</ion-segment-button>
|
||||||
|
<ion-segment-button value="preview">
|
||||||
|
<ion-label>Anteprima</ion-label>
|
||||||
|
</ion-segment-button>
|
||||||
|
</ion-segment>
|
||||||
|
|
||||||
|
<div class="editor-preview-split">
|
||||||
|
<!-- Left Column: Inputs & Editor -->
|
||||||
|
<div class="editor-column" [class.hide-on-mobile]="activeTab === 'preview'">
|
||||||
|
<ion-list lines="none" class="input-list">
|
||||||
|
<ion-item class="custom-input-item">
|
||||||
|
<ion-label position="stacked">Titolo del Canto</ion-label>
|
||||||
|
<ion-input [(ngModel)]="title" placeholder="Es: Il Signore è la mia salvezza"></ion-input>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<div class="category-selectors">
|
||||||
|
<ion-item class="custom-input-item select-item">
|
||||||
|
<ion-label position="stacked">Durata (mm:ss)</ion-label>
|
||||||
|
<ion-input [(ngModel)]="durata" placeholder="Es: 03:45"></ion-input>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<ion-item class="custom-input-item select-item">
|
||||||
|
<ion-label position="stacked">BPM (Tempo)</ion-label>
|
||||||
|
<ion-input type="number" [(ngModel)]="bpm" placeholder="Es: 120"></ion-input>
|
||||||
|
</ion-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="category-selectors">
|
||||||
|
<ion-item class="custom-input-item select-item">
|
||||||
|
<ion-label position="stacked">Momento Liturgico</ion-label>
|
||||||
|
<ion-select [(ngModel)]="selectedLiturgico" placeholder="Scegli momento" multiple="true" interface="popover">
|
||||||
|
<ion-select-option *ngFor="let lit of sortedLiturgico" [value]="lit.id">
|
||||||
|
{{ lit.tag_name }}
|
||||||
|
</ion-select-option>
|
||||||
|
</ion-select>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<ion-item class="custom-input-item select-item">
|
||||||
|
<ion-label position="stacked">Periodo / Tema</ion-label>
|
||||||
|
<ion-select [(ngModel)]="selectedTematico" placeholder="Scegli tema" multiple="true" interface="popover">
|
||||||
|
<ion-select-option *ngFor="let tem of sortedTematico" [value]="tem.id">
|
||||||
|
{{ tem.tag_name }}
|
||||||
|
</ion-select-option>
|
||||||
|
</ion-select>
|
||||||
|
</ion-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TOOLBARS -->
|
||||||
|
<div class="toolbar-section">
|
||||||
|
<div class="horizontal-toolbar">
|
||||||
|
<ng-container *ngFor="let tag of commonTags">
|
||||||
|
<ion-button size="small" fill="outline" (click)="insertText(tag.start)">
|
||||||
|
{{ tag.label }}
|
||||||
|
</ion-button>
|
||||||
|
<ion-button *ngIf="tag.end" size="small" fill="outline" color="medium" (click)="insertText(tag.end)">
|
||||||
|
Fine {{ tag.label }}
|
||||||
|
</ion-button>
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Chords Selector Toolbar -->
|
||||||
|
<div class="toolbar-section">
|
||||||
|
<div class="horizontal-toolbar main-chords-toolbar">
|
||||||
|
<ion-button
|
||||||
|
*ngFor="let group of groupedChords"
|
||||||
|
size="small"
|
||||||
|
[fill]="selectedRootChord === group.root ? 'solid' : 'outline'"
|
||||||
|
[color]="selectedRootChord === group.root ? 'secondary' : 'light'"
|
||||||
|
(click)="selectRoot(group.root)">
|
||||||
|
{{ group.root }}
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Variations Toolbar (only visible if a root chord is selected) -->
|
||||||
|
<div class="toolbar-section variations-container" *ngIf="selectedRootChord">
|
||||||
|
<div class="horizontal-toolbar variations-toolbar">
|
||||||
|
<span class="variations-label">Variazioni {{ selectedRootChord }}:</span>
|
||||||
|
<ion-button
|
||||||
|
size="small"
|
||||||
|
*ngFor="let chord of getVariations()"
|
||||||
|
(click)="insertChord(chord)">
|
||||||
|
{{ chord }}
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- EDITOR AREA -->
|
||||||
|
<div class="editor-wrapper" [class.hc]="isHighContrast">
|
||||||
|
<div class="editor-header">
|
||||||
|
<div class="editor-title-group" style="display: flex; align-items: center; gap: 8px;">
|
||||||
|
<span>Editor Testo</span>
|
||||||
|
|
||||||
|
<!-- Transpose buttons in left column, above textarea -->
|
||||||
|
<div class="editor-transpose-group" style="display: 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); margin-left: 12px;">
|
||||||
|
<ion-button fill="clear" size="small" (click)="transposeDown()" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 24px;">
|
||||||
|
<ion-icon slot="icon-only" name="remove-outline" style="font-size: 1.0rem; color: var(--ion-color-secondary);"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<span class="val outfit-font" style="font-size: 0.85rem; font-weight: 700; color: var(--ion-color-secondary); min-width: 24px; text-align: center;">T:{{ transposeAmount > 0 ? '+' : '' }}{{ transposeAmount }}</span>
|
||||||
|
<ion-button fill="clear" size="small" (click)="transposeUp()" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 24px;">
|
||||||
|
<ion-icon slot="icon-only" name="add-outline" style="font-size: 1.0rem; color: var(--ion-color-secondary);"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Set default key button -->
|
||||||
|
<ion-button *ngIf="transposeAmount !== 0" fill="outline" color="secondary" size="small" (click)="setDefaultTonalita()" style="margin: 0 0 0 8px; font-size: 10px; font-weight: 700; height: 24px; --border-radius: 6px;">
|
||||||
|
Imposta tonalità di default
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<ion-button fill="clear" size="small" (click)="undo()" [disabled]="undoStack.length === 0" title="Annulla ultima modifica">
|
||||||
|
<ion-icon name="arrow-undo-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" size="small" (click)="deduceChords()" [disabled]="!content" title="Deduci accordi per le altre strofe">
|
||||||
|
<ion-icon name="musical-notes-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" size="small" (click)="takePhoto()" title="Scatta foto">
|
||||||
|
<ion-icon name="camera-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" size="small" (click)="chooseFile()" title="Allega file o PDF">
|
||||||
|
<ion-icon name="document-attach-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" size="small" (click)="toggleRawPasteMode()" [color]="isRawPasteModeActive ? 'warning' : 'medium'" [title]="isRawPasteModeActive ? 'Incolla Raw: Attivo (il testo incollato non verrà filtrato)' : 'Attiva Incolla Raw (incolla senza filtri)'" style="font-size: 11px; font-weight: 800; font-family: 'Outfit', sans-serif;">
|
||||||
|
RAW
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="editor-textarea-container">
|
||||||
|
<!-- Input textarea -->
|
||||||
|
<textarea
|
||||||
|
#nativeTextarea
|
||||||
|
[(ngModel)]="content"
|
||||||
|
placeholder="Scrivi o scansiona..."
|
||||||
|
(paste)="onPaste($event)"
|
||||||
|
spellcheck="false"
|
||||||
|
autocapitalize="none"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off">
|
||||||
|
</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ion-item class="custom-input-item">
|
||||||
|
<ion-label position="stacked">Autore</ion-label>
|
||||||
|
<ion-input [(ngModel)]="author" placeholder="Autore"></ion-input>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<!-- YouTube Link and Player Section -->
|
||||||
|
<div class="custom-input-item youtube-section">
|
||||||
|
<ion-label position="stacked" style="color: #64ffda; font-family: 'Outfit', sans-serif; font-weight: 700; font-size: 12px; text-transform: uppercase; letter-spacing: 1px;">Link YouTube & Audio Player</ion-label>
|
||||||
|
<div class="youtube-input-row" style="display: flex; align-items: center; gap: 8px; margin-top: 6px;">
|
||||||
|
<ion-input [(ngModel)]="youtubeLink" placeholder="URL YouTube" style="flex: 1;"></ion-input>
|
||||||
|
<ion-button fill="solid" color="secondary" size="small" [disabled]="!youtubeLink" (click)="loadEditorAudio()" style="margin: 0; font-weight: 700;">
|
||||||
|
Carica
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Small Player controls when playing/loaded -->
|
||||||
|
<div class="editor-mini-player" *ngIf="isAudioLoaded()" style="display: flex; align-items: center; gap: 8px; margin-top: 10px; padding: 8px; background: rgba(255, 255, 255, 0.05); border-radius: 8px; border: 1px solid rgba(255, 255, 255, 0.1);">
|
||||||
|
<ion-button fill="clear" color="secondary" (click)="togglePlayPause()" class="play-btn" style="margin: 0; --padding-start: 4px; --padding-end: 4px; height: 36px; width: 36px;">
|
||||||
|
<ion-icon slot="icon-only" [name]="youtubePlayerService.isPlaying() ? 'pause-sharp' : 'play-sharp'" style="font-size: 1.6rem;"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
|
<span class="player-time outfit-font" style="font-size: 0.8rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 32px; text-align: right;">{{ formatSeconds(youtubePlayerService.videoProgress()) }}</span>
|
||||||
|
<ion-range
|
||||||
|
[min]="0"
|
||||||
|
[max]="youtubePlayerService.videoDuration()"
|
||||||
|
[value]="youtubePlayerService.videoProgress()"
|
||||||
|
(ionChange)="onSeek($event)"
|
||||||
|
color="secondary"
|
||||||
|
style="margin: 0; padding: 0 4px; flex: 1; --bar-height: 4px; --knob-size: 12px;">
|
||||||
|
</ion-range>
|
||||||
|
<span class="player-time outfit-font" style="font-size: 0.8rem; color: var(--ion-color-secondary); opacity: 0.8; min-width: 32px; text-align: left;">{{ formatSeconds(youtubePlayerService.videoDuration()) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ion-list>
|
||||||
|
|
||||||
|
<div class="action-buttons">
|
||||||
|
<ion-button expand="block" (click)="saveToMyCanti()" class="send-btn" [disabled]="!title || !content || isProcessingOCR">
|
||||||
|
<ion-icon slot="start" name="save-outline"></ion-icon>
|
||||||
|
Salva nei Miei Canti
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Column: Active Preview Pane -->
|
||||||
|
<div class="preview-column" [class.hide-on-mobile]="activeTab === 'editor'">
|
||||||
|
<div class="preview-card" [class.hc]="isHighContrast">
|
||||||
|
<div class="preview-header">
|
||||||
|
<span class="preview-title">Anteprima Attiva</span>
|
||||||
|
|
||||||
|
<ion-button fill="clear" size="small" (click)="toggleChordsPreview()" class="preview-toggle-btn" style="margin: 0;">
|
||||||
|
<ion-icon slot="start" [name]="showChordsPreview ? 'musical-notes-outline' : 'text-outline'"></ion-icon>
|
||||||
|
{{ showChordsPreview ? 'Con Accordi' : 'Solo Testo' }}
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
<div class="preview-body">
|
||||||
|
<div class="preview-song-header">
|
||||||
|
<h2 class="preview-song-title">{{ title || 'Titolo del Canto' }}</h2>
|
||||||
|
<p class="preview-song-author" *ngIf="author">{{ author }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="preview-lyrics-container">
|
||||||
|
<div class="lyrics-view">
|
||||||
|
<div *ngFor="let section of parsedSections"
|
||||||
|
class="section"
|
||||||
|
[class.chorus]="section.type === 'chorus'"
|
||||||
|
[class.verse-num]="section.type === 'verse_num'">
|
||||||
|
|
||||||
|
<div *ngIf="section.type === 'chorus'" class="section-label">Rit.</div>
|
||||||
|
<div *ngIf="section.type === 'verse_num' && section.verseNumber" class="section-label verse-num-label">{{ section.verseNumber }}.</div>
|
||||||
|
|
||||||
|
<div *ngFor="let line of section.lines" class="lyric-line">
|
||||||
|
<!-- Chord mode -->
|
||||||
|
<ng-container *ngIf="showChordsPreview; else textOnly">
|
||||||
|
<span *ngFor="let seg of line.segments; let i = index" class="chord-segment" [class.contiguous-next]="lyricsParser.isContiguousNext(line.segments, i)">
|
||||||
|
<span *ngIf="seg.chord" class="chord">{{ seg.chord }}</span>
|
||||||
|
<span class="seg-text">{{ seg.text }}</span>
|
||||||
|
</span>
|
||||||
|
</ng-container>
|
||||||
|
<!-- Text only mode -->
|
||||||
|
<ng-template #textOnly>
|
||||||
|
{{ line.text }}
|
||||||
|
</ng-template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="preview-empty" *ngIf="!content">
|
||||||
|
Il testo formattato apparirà qui mentre scrivi...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden inputs -->
|
||||||
|
<input type="file" #cameraInput (change)="onFileSelected($event, true)" accept="image/*" capture="camera" style="display: none;">
|
||||||
|
<input type="file" #fileInput (change)="onFileSelected($event, false)" accept="image/*,application/pdf" style="display: none;">
|
||||||
|
</ion-content>
|
||||||
@@ -0,0 +1,845 @@
|
|||||||
|
:host {
|
||||||
|
--ion-background-color: #1a1a2e !important;
|
||||||
|
--ion-text-color: #ffffff !important;
|
||||||
|
--ion-item-background: transparent !important;
|
||||||
|
|
||||||
|
ion-input, ion-textarea, ion-select {
|
||||||
|
--color: #ffffff !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
--placeholder-color: rgba(255, 255, 255, 0.5) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body.high-contrast :host {
|
||||||
|
--ion-background-color: #ffffff !important;
|
||||||
|
--ion-text-color: #000000 !important;
|
||||||
|
--ion-item-background: #ffffff !important;
|
||||||
|
|
||||||
|
ion-input, ion-textarea, ion-select {
|
||||||
|
--color: #000000 !important;
|
||||||
|
color: #000000 !important;
|
||||||
|
--placeholder-color: rgba(0, 0, 0, 0.6) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Labels must be black, not teal */
|
||||||
|
ion-label {
|
||||||
|
color: #000000 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Variations container layout in high contrast */
|
||||||
|
.variations-container {
|
||||||
|
background: #f5f5f5 !important;
|
||||||
|
border: 1px solid #555555 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body.high-contrast :host ::ng-deep {
|
||||||
|
/* Default styles for high contrast buttons (no border by default, e.g. for fill="clear") */
|
||||||
|
ion-button {
|
||||||
|
--background: transparent !important;
|
||||||
|
--color: #000000 !important;
|
||||||
|
--color-activated: #000000 !important;
|
||||||
|
--color-focused: #000000 !important;
|
||||||
|
--color-hover: #000000 !important;
|
||||||
|
--border-color: transparent !important;
|
||||||
|
--border-width: 0px !important;
|
||||||
|
border: none !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Outlined buttons (tags, chords, action buttons) in high contrast get single gray border */
|
||||||
|
ion-button[fill="outline"],
|
||||||
|
.main-chords-toolbar ion-button,
|
||||||
|
.variations-toolbar ion-button {
|
||||||
|
--background: #ffffff !important;
|
||||||
|
--border-color: #555555 !important;
|
||||||
|
--border-width: 1px !important;
|
||||||
|
--border-style: solid !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Target specific Light-colored outlined buttons (inactive main chords) to overpower Ionic classes */
|
||||||
|
ion-button.ion-color-light,
|
||||||
|
.main-chords-toolbar ion-button.ion-color-light,
|
||||||
|
.variations-toolbar ion-button.ion-color-light {
|
||||||
|
--background: #ffffff !important;
|
||||||
|
--color: #000000 !important;
|
||||||
|
--color-activated: #000000 !important;
|
||||||
|
--color-focused: #000000 !important;
|
||||||
|
--color-hover: #000000 !important;
|
||||||
|
--border-color: #555555 !important;
|
||||||
|
--border-width: 1px !important;
|
||||||
|
--border-style: solid !important;
|
||||||
|
color: #000000 !important;
|
||||||
|
border: none !important;
|
||||||
|
|
||||||
|
/* OVERRIDE IONIC COLOR BASE FOR COLOR="LIGHT" OUTLINE BUTTONS */
|
||||||
|
--ion-color-base: #000000 !important;
|
||||||
|
--ion-color-base-rgb: 0, 0, 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Solid buttons (active chords, save button) in high contrast */
|
||||||
|
ion-button.button-solid,
|
||||||
|
ion-button[fill="solid"],
|
||||||
|
.send-btn {
|
||||||
|
--background: var(--ion-color-secondary) !important;
|
||||||
|
--color: #ffffff !important;
|
||||||
|
--color-activated: #ffffff !important;
|
||||||
|
--color-focused: #ffffff !important;
|
||||||
|
--color-hover: #ffffff !important;
|
||||||
|
--border-color: #555555 !important;
|
||||||
|
--border-width: 1px !important;
|
||||||
|
--border-style: solid !important;
|
||||||
|
border: none !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Textarea inside high contrast must have white background and black text */
|
||||||
|
.editor-textarea-container {
|
||||||
|
background: #ffffff !important;
|
||||||
|
border: 2px solid #000000 !important;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
color: #000000 !important;
|
||||||
|
caret-color: #000000 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force background of inputs and items to be white with single dark gray border in high contrast */
|
||||||
|
.custom-input-item, .select-item {
|
||||||
|
--background: #ffffff !important;
|
||||||
|
border: 1px solid #555555 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gradient {
|
||||||
|
--background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.propose-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ocr-progress-card {
|
||||||
|
background: rgba(var(--ion-color-secondary-rgb), 0.15);
|
||||||
|
border: 1px solid var(--ion-color-secondary);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
box-shadow: 0 4px 15px rgba(0,0,0,0.3);
|
||||||
|
|
||||||
|
ion-spinner {
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-info {
|
||||||
|
flex: 1;
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
&.small {
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ion-progress-bar {
|
||||||
|
margin-top: 12px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-section {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.toolbar-label {
|
||||||
|
font-size: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-left: 4px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
height: 3px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ion-button {
|
||||||
|
--border-radius: 10px;
|
||||||
|
margin: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.chords-toolbar {
|
||||||
|
ion-button {
|
||||||
|
--background: rgba(255, 255, 255, 0.1);
|
||||||
|
--color: #ffffff;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
--background: var(--ion-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
background: #000000;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255,255,255,0.1);
|
||||||
|
box-shadow: inset 0 2px 10px rgba(0,0,0,0.5);
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
background: rgba(255,255,255,0.05);
|
||||||
|
padding: 4px 8px 4px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
|
|
||||||
|
.editor-title-group {
|
||||||
|
span {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0;
|
||||||
|
|
||||||
|
ion-button {
|
||||||
|
--color: rgba(255,255,255,0.6);
|
||||||
|
margin: 0;
|
||||||
|
height: 36px;
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
ion-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hc {
|
||||||
|
border: 2px solid #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-bottom: 2px solid #000000;
|
||||||
|
span { color: #000000; }
|
||||||
|
small { color: #333; font-weight: 600; }
|
||||||
|
|
||||||
|
ion-button {
|
||||||
|
--color: #000000 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-textarea-container {
|
||||||
|
background: #ffffff;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
color: #000000;
|
||||||
|
caret-color: #000000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep {
|
||||||
|
.editor-textarea-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 450px;
|
||||||
|
background: #111111;
|
||||||
|
border-radius: 0 0 14px 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 16px !important;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
|
||||||
|
font-size: 1.05rem !important;
|
||||||
|
line-height: 1.6 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
background: transparent !important;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
resize: none;
|
||||||
|
overflow-y: auto !important;
|
||||||
|
caret-color: #64ffda !important;
|
||||||
|
border: none !important;
|
||||||
|
outline: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-selectors {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.select-item {
|
||||||
|
--background: rgba(255, 255, 255, 0.08) !important;
|
||||||
|
--border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
ion-select {
|
||||||
|
width: 100%;
|
||||||
|
--padding-start: 0;
|
||||||
|
--color: #ffffff !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-input-item {
|
||||||
|
--background: rgba(255, 255, 255, 0.08) !important;
|
||||||
|
--border-radius: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
ion-label {
|
||||||
|
color: #64ffda !important; /* High-contrast teal label */
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 6px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
ion-input {
|
||||||
|
--color: #ffffff !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea-item {
|
||||||
|
--background: transparent;
|
||||||
|
--padding-start: 0;
|
||||||
|
--padding-end: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-buttons {
|
||||||
|
margin-top: 24px;
|
||||||
|
margin-bottom: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
--border-radius: 16px;
|
||||||
|
--background: var(--ion-color-secondary);
|
||||||
|
font-weight: 700;
|
||||||
|
height: 60px;
|
||||||
|
font-size: 16px;
|
||||||
|
box-shadow: 0 8px 20px rgba(var(--ion-color-secondary-rgb), 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* INTERACTIVE CHORDS TOOLBAR */
|
||||||
|
.main-chords-toolbar {
|
||||||
|
ion-button {
|
||||||
|
--border-radius: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
min-width: 44px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.variations-container {
|
||||||
|
background: rgba(var(--ion-color-secondary-rgb), 0.08);
|
||||||
|
border: 1px solid rgba(var(--ion-color-secondary-rgb), 0.25);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(-4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.variations-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.variations-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
ion-button {
|
||||||
|
--background: rgba(255, 255, 255, 0.12);
|
||||||
|
--color: #ffffff;
|
||||||
|
--border-radius: 6px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 28px;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
--background: var(--ion-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DRAG AND DROP OVERLAY */
|
||||||
|
.drag-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
z-index: 9999;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
|
||||||
|
.drag-message {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: 3px dashed var(--ion-color-secondary);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 40px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
|
||||||
|
|
||||||
|
ion-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body.high-contrast :host ::ng-deep {
|
||||||
|
.drag-overlay {
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
.drag-message {
|
||||||
|
color: #000000;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 3px dashed #000000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* RESPONSIVE LAYOUT & ACTIVE PREVIEW PANE */
|
||||||
|
.editor-preview-split {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-column {
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
position: sticky;
|
||||||
|
top: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-card {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
padding: 10px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
|
||||||
|
.preview-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-toggle-btn {
|
||||||
|
--color: rgba(255, 255, 255, 0.7);
|
||||||
|
margin: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
ion-icon {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-body {
|
||||||
|
padding: 24px;
|
||||||
|
min-height: 400px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
.preview-song-header {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
padding-bottom: 14px;
|
||||||
|
|
||||||
|
.preview-song-title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0 0 6px 0;
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-song-author {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-lyrics-container {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
overflow-y: auto;
|
||||||
|
max-height: 60vh;
|
||||||
|
padding-right: 8px;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyrics-view {
|
||||||
|
max-width: 900px;
|
||||||
|
margin-left: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.chorus {
|
||||||
|
background: rgba(var(--ion-color-secondary-rgb), 0.03);
|
||||||
|
border-left: 3px solid var(--ion-color-secondary);
|
||||||
|
padding-left: 1rem;
|
||||||
|
margin-left: -1rem;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyric-line {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
min-height: 1.5em;
|
||||||
|
white-space: normal;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chord-segment {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
vertical-align: bottom;
|
||||||
|
margin-right: 0.25em;
|
||||||
|
|
||||||
|
&.contiguous-next {
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chord {
|
||||||
|
font-size: 0.75em;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
height: 1.2em;
|
||||||
|
margin-bottom: -0.2em;
|
||||||
|
padding-right: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.seg-text {
|
||||||
|
white-space: pre;
|
||||||
|
&::after {
|
||||||
|
content: '\200b';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 80px;
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* High Contrast mode overrides */
|
||||||
|
&.hc {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 2px solid #000000;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-bottom: 2px solid #000000;
|
||||||
|
|
||||||
|
.preview-title {
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-toggle-btn {
|
||||||
|
--color: #000000;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-transpose-group {
|
||||||
|
border-color: #000000 !important;
|
||||||
|
span {
|
||||||
|
color: #000000 !important;
|
||||||
|
}
|
||||||
|
ion-button {
|
||||||
|
--color: #000000 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-body {
|
||||||
|
background: #ffffff;
|
||||||
|
|
||||||
|
.preview-song-header {
|
||||||
|
border-bottom: 2px solid #000000;
|
||||||
|
|
||||||
|
.preview-song-title {
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-song-author {
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-lyrics-container {
|
||||||
|
.section {
|
||||||
|
&.chorus {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-left: 3px solid #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lyric-line {
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chord-segment {
|
||||||
|
.chord {
|
||||||
|
color: #000000;
|
||||||
|
text-decoration: underline;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-empty {
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.youtube-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.youtube-input-row {
|
||||||
|
ion-button {
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CUSTOM STYLES FOR INTUITIVE MOBILE RESPONSIVENESS */
|
||||||
|
.custom-mobile-segment {
|
||||||
|
display: none;
|
||||||
|
--background: rgba(255, 255, 255, 0.05);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
|
||||||
|
ion-segment-button {
|
||||||
|
--color: rgba(255, 255, 255, 0.6);
|
||||||
|
--color-checked: #ffffff;
|
||||||
|
--indicator-color: var(--ion-color-secondary);
|
||||||
|
--border-radius: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body.high-contrast :host ::ng-deep {
|
||||||
|
.custom-mobile-segment {
|
||||||
|
--background: #f0f0f0 !important;
|
||||||
|
background: #f0f0f0 !important;
|
||||||
|
border: 2px solid #000000 !important;
|
||||||
|
|
||||||
|
ion-segment-button {
|
||||||
|
--color: #333333 !important;
|
||||||
|
--color-checked: #000000 !important;
|
||||||
|
--indicator-color: #000000 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 991px) {
|
||||||
|
.custom-mobile-segment {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hide-on-mobile {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-toolbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
overflow-x: visible;
|
||||||
|
padding-bottom: 0;
|
||||||
|
|
||||||
|
ion-button {
|
||||||
|
flex-shrink: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
:host ::ng-deep .category-selectors {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-wrapper {
|
||||||
|
.editor-header {
|
||||||
|
padding: 6px 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.editor-title-group {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-actions {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
|
ion-button {
|
||||||
|
flex: 1;
|
||||||
|
height: 40px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { ProposeCantoPage } from './propose-canto.page';
|
||||||
|
import { ToastController, PopoverController, NavController, AngularDelegate, AlertController } from '@ionic/angular';
|
||||||
|
import { CantiService } from '../../services/canti.service';
|
||||||
|
import { MyCantiService } from '../../services/my-canti.service';
|
||||||
|
import { PlaylistService } from '../../services/playlist.service';
|
||||||
|
import { ThemeService } from '../../services/theme.service';
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router';
|
||||||
|
import { LyricsParserService } from '../../services/lyrics-parser.service';
|
||||||
|
import { of } from 'rxjs';
|
||||||
|
|
||||||
|
describe('ProposeCantoPage', () => {
|
||||||
|
let component: ProposeCantoPage;
|
||||||
|
let fixture: ComponentFixture<ProposeCantoPage>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
const cantiServiceMock = {
|
||||||
|
indiceLiturgico: () => [],
|
||||||
|
indiceTematico: () => [],
|
||||||
|
canti: () => []
|
||||||
|
};
|
||||||
|
const myCantiServiceMock = {
|
||||||
|
myCanti: () => []
|
||||||
|
};
|
||||||
|
const playlistServiceMock = {
|
||||||
|
remoteCustomSongs: () => [],
|
||||||
|
activePlaylistId: () => null
|
||||||
|
};
|
||||||
|
const themeServiceMock = {
|
||||||
|
highContrast: () => false
|
||||||
|
};
|
||||||
|
const activatedRouteMock = {
|
||||||
|
queryParams: of({})
|
||||||
|
};
|
||||||
|
const routerMock = {
|
||||||
|
navigate: jasmine.createSpy('navigate')
|
||||||
|
};
|
||||||
|
const lyricsParserMock = {
|
||||||
|
parseAccordi: () => []
|
||||||
|
};
|
||||||
|
const navCtrlMock = {};
|
||||||
|
const toastControllerMock = {};
|
||||||
|
const popoverControllerMock = {};
|
||||||
|
const alertControllerMock = {};
|
||||||
|
const angularDelegateMock = {};
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
{ provide: CantiService, useValue: cantiServiceMock },
|
||||||
|
{ provide: MyCantiService, useValue: myCantiServiceMock },
|
||||||
|
{ provide: PlaylistService, useValue: playlistServiceMock },
|
||||||
|
{ provide: ThemeService, useValue: themeServiceMock },
|
||||||
|
{ provide: ActivatedRoute, useValue: activatedRouteMock },
|
||||||
|
{ provide: Router, useValue: routerMock },
|
||||||
|
{ provide: LyricsParserService, useValue: lyricsParserMock },
|
||||||
|
{ provide: NavController, useValue: navCtrlMock },
|
||||||
|
{ provide: ToastController, useValue: toastControllerMock },
|
||||||
|
{ provide: PopoverController, useValue: popoverControllerMock },
|
||||||
|
{ provide: AngularDelegate, useValue: angularDelegateMock },
|
||||||
|
{ provide: AlertController, useValue: alertControllerMock }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ProposeCantoPage);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('convertEnglishChordToItalian', () => {
|
||||||
|
it('should convert simple English chords to Italian', () => {
|
||||||
|
expect(component.convertEnglishChordToItalian('C')).toBe('DO');
|
||||||
|
expect(component.convertEnglishChordToItalian('D')).toBe('RE');
|
||||||
|
expect(component.convertEnglishChordToItalian('E')).toBe('MI');
|
||||||
|
expect(component.convertEnglishChordToItalian('F')).toBe('FA');
|
||||||
|
expect(component.convertEnglishChordToItalian('G')).toBe('SOL');
|
||||||
|
expect(component.convertEnglishChordToItalian('A')).toBe('LA');
|
||||||
|
expect(component.convertEnglishChordToItalian('B')).toBe('SI');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert English chords with accidentals and modifiers', () => {
|
||||||
|
expect(component.convertEnglishChordToItalian('C#m7')).toBe('DO#m7');
|
||||||
|
expect(component.convertEnglishChordToItalian('Bb')).toBe('SIb');
|
||||||
|
expect(component.convertEnglishChordToItalian('F#m')).toBe('FA#m');
|
||||||
|
expect(component.convertEnglishChordToItalian('Faug')).toBe('FAaug');
|
||||||
|
expect(component.convertEnglishChordToItalian('Fadd9')).toBe('FAadd9');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep Italian chords unchanged', () => {
|
||||||
|
expect(component.convertEnglishChordToItalian('DO')).toBe('DO');
|
||||||
|
expect(component.convertEnglishChordToItalian('RE#m7')).toBe('RE#m7');
|
||||||
|
expect(component.convertEnglishChordToItalian('FA#')).toBe('FA#');
|
||||||
|
expect(component.convertEnglishChordToItalian('SIb')).toBe('SIb');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle slash chords correctly', () => {
|
||||||
|
expect(component.convertEnglishChordToItalian('C/E')).toBe('DO/MI');
|
||||||
|
expect(component.convertEnglishChordToItalian('D/F#')).toBe('RE/FA#');
|
||||||
|
expect(component.convertEnglishChordToItalian('F#m7/A#')).toBe('FA#m7/LA#');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('sanitizeOcrChord', () => {
|
||||||
|
it('should convert H to # when following a chord root', () => {
|
||||||
|
expect(component.sanitizeOcrChord('CH')).toBe('C#');
|
||||||
|
expect(component.sanitizeOcrChord('DHm7')).toBe('D#m7');
|
||||||
|
expect(component.sanitizeOcrChord('FH#')).toBe('F#');
|
||||||
|
expect(component.sanitizeOcrChord('FAH')).toBe('FA#');
|
||||||
|
expect(component.sanitizeOcrChord('SOLH7')).toBe('SOL#7');
|
||||||
|
expect(component.sanitizeOcrChord('FH#H')).toBe('F#');
|
||||||
|
expect(component.sanitizeOcrChord('FH#H/AH#')).toBe('F#/A#');
|
||||||
|
expect(component.sanitizeOcrChord('C#H-')).toBe('C#-');
|
||||||
|
expect(component.sanitizeOcrChord('G#H#')).toBe('G#');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert 0 to O for DO and SOL roots', () => {
|
||||||
|
expect(component.sanitizeOcrChord('D0')).toBe('DO');
|
||||||
|
expect(component.sanitizeOcrChord('D0#')).toBe('DO#');
|
||||||
|
expect(component.sanitizeOcrChord('S0L')).toBe('SOL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle slash chords with sanitization', () => {
|
||||||
|
expect(component.sanitizeOcrChord('CH/EH')).toBe('C#/E#');
|
||||||
|
expect(component.sanitizeOcrChord('D0/FH#')).toBe('DO/F#');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isChordWord', () => {
|
||||||
|
it('should identify Italian and English chords correctly based on mode', () => {
|
||||||
|
expect(component.isChordWord('DO', true)).toBe(true);
|
||||||
|
expect(component.isChordWord('DO', false)).toBe(true);
|
||||||
|
expect(component.isChordWord('C', true)).toBe(false); // English chord in Italian notation -> false
|
||||||
|
expect(component.isChordWord('C', false)).toBe(true); // English chord in English notation -> true
|
||||||
|
expect(component.isChordWord('Lan', true)).toBe(true);
|
||||||
|
expect(component.isChordWord('Lan', false)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('isItalianNotation', () => {
|
||||||
|
it('should detect Italian notation if DO/RE/MI/SOL/SI are present', () => {
|
||||||
|
const words = [{ text: 'Lan' }, { text: 'FA' }, { text: 'DO' }, { text: 'SOL' }];
|
||||||
|
expect(component.isItalianNotation(words)).toBe(true);
|
||||||
|
});
|
||||||
|
it('should return false if only English-like or non-italian chords are present', () => {
|
||||||
|
const words = [{ text: 'C' }, { text: 'G' }, { text: 'D' }];
|
||||||
|
expect(component.isItalianNotation(words)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { Routes, RouterModule } from '@angular/router';
|
||||||
|
import { SettingsPage } from './settings.page';
|
||||||
|
|
||||||
|
const routes: Routes = [
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: SettingsPage
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [RouterModule.forChild(routes)],
|
||||||
|
exports: [RouterModule],
|
||||||
|
})
|
||||||
|
export class SettingsPageRoutingModule {}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NgModule } from '@angular/core';
|
||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { IonicModule } from '@ionic/angular';
|
||||||
|
import { SettingsPageRoutingModule } from './settings-routing.module';
|
||||||
|
import { SettingsPage } from './settings.page';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
IonicModule,
|
||||||
|
SettingsPageRoutingModule
|
||||||
|
],
|
||||||
|
declarations: [SettingsPage]
|
||||||
|
})
|
||||||
|
export class SettingsPageModule {}
|
||||||
@@ -0,0 +1,507 @@
|
|||||||
|
<ion-header [translucent]="true" class="ion-no-border">
|
||||||
|
<ion-toolbar class="bg-gradient">
|
||||||
|
<ion-buttons slot="start">
|
||||||
|
<ion-back-button defaultHref="/home" color="secondary"></ion-back-button>
|
||||||
|
</ion-buttons>
|
||||||
|
<ion-title class="outfit-font">Impostazioni</ion-title>
|
||||||
|
</ion-toolbar>
|
||||||
|
</ion-header>
|
||||||
|
|
||||||
|
<ion-content [fullscreen]="true" class="bg-gradient">
|
||||||
|
<div class="settings-container ion-padding">
|
||||||
|
|
||||||
|
<!-- PWA Install Group (Android / Chrome custom prompt) -->
|
||||||
|
<div class="settings-group glass ion-margin-bottom" *ngIf="settingsService.showInstallButton() && !settingsService.isStandalone()">
|
||||||
|
<ion-item class="transparent-item" lines="none" button (click)="installApp()">
|
||||||
|
<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 {{ 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
|
||||||
|
</ion-button>
|
||||||
|
</ion-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PWA Install Group (iOS Safari Instructions) -->
|
||||||
|
<div class="settings-group glass ion-margin-bottom" *ngIf="settingsService.isIos() && !settingsService.isStandalone()">
|
||||||
|
<ion-item class="transparent-item" lines="none" button (click)="toggleIosInstructions()">
|
||||||
|
<ion-icon name="logo-apple" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font">
|
||||||
|
<h2 class="settings-item-title">Installazione su iPhone / iPad</h2>
|
||||||
|
<p class="settings-item-subtitle">Scopri come aggiungere l'app sulla home</p>
|
||||||
|
</ion-label>
|
||||||
|
<ion-icon slot="end" [name]="showIosInstructions ? 'chevron-up' : 'chevron-down'" color="medium" style="font-size: 1.2rem;"></ion-icon>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<div class="ios-instructions-content ion-padding" *ngIf="showIosInstructions" style="border-top: 1px solid var(--ion-border-color, rgba(255,255,255,0.08)); background: rgba(var(--ion-text-color-rgb, 255,255,255), 0.02);">
|
||||||
|
<p class="outfit-font" style="font-size: 0.85rem; color: var(--ion-text-color); margin: 0 0 12px 0; line-height: 1.4; opacity: 0.9;">
|
||||||
|
Apple non consente l'installazione automatica dei siti web. Segui questi semplici passi da <strong>Safari</strong>:
|
||||||
|
</p>
|
||||||
|
<ol class="outfit-font" style="font-size: 0.85rem; color: var(--ion-text-color); margin: 0; padding-left: 20px; line-height: 1.6; opacity: 0.9;">
|
||||||
|
<li style="margin-bottom: 8px;">
|
||||||
|
Tocca il pulsante di <strong>Condivisione</strong> <ion-icon name="share-outline" style="font-size: 1.1rem; vertical-align: middle; margin: 0 2px; color: var(--ion-color-secondary);"></ion-icon> nella barra di navigazione inferiore di Safari.
|
||||||
|
</li>
|
||||||
|
<li style="margin-bottom: 8px;">
|
||||||
|
Scorri il menu verso il basso e seleziona <strong>Aggiungi alla schermata Home</strong> <ion-icon name="add-circle-outline" style="font-size: 1.1rem; vertical-align: middle; margin: 0 2px; color: var(--ion-color-secondary);"></ion-icon>.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Conferma toccando <strong>Aggiungi</strong> in alto a destra. L'icona dell'app apparirà sul tuo schermo!
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PWA Install Group (Desktop Fallback Instructions) -->
|
||||||
|
<div class="settings-group glass ion-margin-bottom" *ngIf="!settingsService.showInstallButton() && !settingsService.isStandalone() && !settingsService.isIos()">
|
||||||
|
<ion-item class="transparent-item" lines="none">
|
||||||
|
<ion-icon name="download-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font" style="white-space: normal;">
|
||||||
|
<h2 class="settings-item-title" style="color: var(--ion-color-secondary); font-weight: bold; margin-bottom: 6px;">Come installare l'applicazione</h2>
|
||||||
|
<p class="settings-item-subtitle" style="font-size: 0.85rem; line-height: 1.45; opacity: 0.9; color: var(--ion-text-color); margin: 0;">
|
||||||
|
Se usi <strong>Chrome / Edge / Brave</strong>: puoi installarla cliccando sull'icona di installazione <span style="display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); border-radius: 6px; padding: 2px 6px; font-size: 0.9rem; vertical-align: middle;">⊕</span> o <span style="display: inline-flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.1); border-radius: 6px; padding: 2px 6px; font-size: 0.9rem; vertical-align: middle;">📥</span> che appare a destra nella barra degli indirizzi del browser.<br><br>
|
||||||
|
Se usi <strong>Safari (su Mac)</strong>: clicca sul menu <strong>File</strong> in alto e seleziona <strong>Aggiungi al Dock...</strong>.
|
||||||
|
</p>
|
||||||
|
</ion-label>
|
||||||
|
</ion-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-group glass ion-margin-bottom">
|
||||||
|
<ion-item class="transparent-item" lines="none">
|
||||||
|
<ion-icon name="scan-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font">
|
||||||
|
<h2 class="settings-item-title">Schermo Intero Browser</h2>
|
||||||
|
<p class="settings-item-subtitle">Espande l'app a tutto schermo</p>
|
||||||
|
</ion-label>
|
||||||
|
<ion-toggle slot="end" [checked]="settingsService.browserFullscreen()" (ionChange)="settingsService.toggleBrowserFullscreen()" color="secondary"></ion-toggle>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<ion-item class="transparent-item" lines="none">
|
||||||
|
<ion-icon name="contrast-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font">
|
||||||
|
<h2 class="settings-item-title">Alto Contrasto</h2>
|
||||||
|
<p class="settings-item-subtitle">Colori più netti per il sole</p>
|
||||||
|
</ion-label>
|
||||||
|
<ion-toggle slot="end" [checked]="themeService.highContrast()" (ionChange)="themeService.toggleContrast()" color="secondary"></ion-toggle>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item class="transparent-item" lines="none">
|
||||||
|
<ion-icon name="play-forward-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font">
|
||||||
|
<h2 class="settings-item-title">Avanzamento Automatico</h2>
|
||||||
|
<p class="settings-item-subtitle">Passa al canto successivo</p>
|
||||||
|
</ion-label>
|
||||||
|
<ion-toggle slot="end" [checked]="settingsService.autoAdvance()" (ionChange)="settingsService.toggleAutoAdvance()" color="secondary"></ion-toggle>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<ion-item class="transparent-item" lines="none">
|
||||||
|
<ion-icon name="create-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font">
|
||||||
|
<h2 class="settings-item-title">Editor e Canti Personali</h2>
|
||||||
|
<p class="settings-item-subtitle">Abilita aggiunta e gestione "Miei"</p>
|
||||||
|
</ion-label>
|
||||||
|
<ion-toggle slot="end" [checked]="settingsService.showEditor()" (ionChange)="settingsService.toggleEditor()" color="secondary"></ion-toggle>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item class="transparent-item" lines="none">
|
||||||
|
<ion-icon name="pricetags-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font">
|
||||||
|
<h2 class="settings-item-title">Visualizza tag</h2>
|
||||||
|
<p class="settings-item-subtitle">Mostra filtri sotto autore nella lista canti</p>
|
||||||
|
</ion-label>
|
||||||
|
<ion-toggle slot="end" [checked]="settingsService.showTagsInList()" (ionChange)="settingsService.toggleShowTagsInList()" color="secondary"></ion-toggle>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item class="transparent-item" lines="none">
|
||||||
|
<ion-icon name="calendar-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font">
|
||||||
|
<h2 class="settings-item-title">Visualizza data update</h2>
|
||||||
|
<p class="settings-item-subtitle">Mostra data aggiornamento nella lista canti</p>
|
||||||
|
</ion-label>
|
||||||
|
<ion-toggle slot="end" [checked]="settingsService.showUpdateDate()" (ionChange)="settingsService.toggleShowUpdateDate()" color="secondary"></ion-toggle>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item class="transparent-item" lines="none">
|
||||||
|
<ion-icon name="musical-notes-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font">
|
||||||
|
<h2 class="settings-item-title">Visualizza durata, bpm e tonalità</h2>
|
||||||
|
<p class="settings-item-subtitle">Mostra queste info sotto autore / titolo</p>
|
||||||
|
</ion-label>
|
||||||
|
<ion-toggle slot="end" [checked]="settingsService.showDurationBpmTonality()" (ionChange)="settingsService.toggleShowDurationBpmTonality()" color="secondary"></ion-toggle>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item class="transparent-item" lines="none">
|
||||||
|
<ion-icon name="swap-vertical-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font">
|
||||||
|
<h2 class="settings-item-title">Autoscroll standard</h2>
|
||||||
|
<p class="settings-item-subtitle">Abilita 3 tasti di autoscroll nel canto</p>
|
||||||
|
</ion-label>
|
||||||
|
<ion-toggle slot="end" [checked]="settingsService.enableStandardAutoscroll()" (ionChange)="settingsService.toggleStandardAutoscroll()" color="secondary"></ion-toggle>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item class="transparent-item" lines="none">
|
||||||
|
<ion-icon name="eye-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font">
|
||||||
|
<h2 class="settings-item-title">Autoscroll visuale</h2>
|
||||||
|
<p class="settings-item-subtitle">Mostra fotocamera per scorrimento visuale</p>
|
||||||
|
</ion-label>
|
||||||
|
<ion-toggle slot="end" [checked]="settingsService.enableVisualAutoscroll()" (ionChange)="settingsService.toggleVisualAutoscroll()" color="secondary"></ion-toggle>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item class="transparent-item" lines="none" style="border-top: 1px solid rgba(255,255,255,0.03);">
|
||||||
|
<ion-icon name="book-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font">
|
||||||
|
<h2 class="settings-item-title">Avanzamento manuale a pagine</h2>
|
||||||
|
<p class="settings-item-subtitle">I tasti Avanti/Indietro (Karaoke) voltano la pagina intera anziché riga per riga</p>
|
||||||
|
</ion-label>
|
||||||
|
<ion-toggle slot="end" [checked]="settingsService.karaokePageScrollMode()" (ionChange)="settingsService.toggleKaraokePageScrollMode()" color="secondary"></ion-toggle>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item class="transparent-item" lines="none" style="border-top: 1px solid rgba(255,255,255,0.03);">
|
||||||
|
<ion-icon name="phone-landscape-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font">
|
||||||
|
<h2 class="settings-item-title">Vista orizzontale per proiezione</h2>
|
||||||
|
<p class="settings-item-subtitle">Adatta il layout in landscape per la proiezione (testo più grande, controlli dedicati)</p>
|
||||||
|
</ion-label>
|
||||||
|
<ion-toggle slot="end" [checked]="settingsService.landscapeProjectionEnabled()" (ionChange)="settingsService.toggleLandscapeProjectionEnabled()" color="secondary"></ion-toggle>
|
||||||
|
</ion-item>
|
||||||
|
<ion-item class="transparent-item" lines="none" style="border-top: 1px solid rgba(255,255,255,0.03);">
|
||||||
|
<ion-icon name="car-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font">
|
||||||
|
<h2 class="settings-item-title">Schermo nero durante l'ascolto</h2>
|
||||||
|
<p class="settings-item-subtitle">Se attiva la riproduzione in home, mostra uno schermo nero per risparmiare batteria. Tocca per disattivare.</p>
|
||||||
|
</ion-label>
|
||||||
|
<ion-toggle slot="end" [checked]="settingsService.carModeBlackScreen()" (ionChange)="settingsService.toggleCarModeBlackScreen()" color="secondary"></ion-toggle>
|
||||||
|
</ion-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comunità -->
|
||||||
|
<div class="settings-group glass ion-margin-bottom">
|
||||||
|
<ion-item class="transparent-item" lines="none">
|
||||||
|
<ion-icon name="people-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font">
|
||||||
|
<h2 class="settings-item-title">Comunità</h2>
|
||||||
|
<p class="settings-item-subtitle">Mostra il filtro Comunità nella home</p>
|
||||||
|
</ion-label>
|
||||||
|
<ion-toggle slot="end" [checked]="settingsService.comunitaEnabled()" (ionChange)="onComunitaToggleChange($event)" color="secondary"></ion-toggle>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
|
<!-- Community Details/Form when enabled -->
|
||||||
|
<div class="ion-padding-horizontal ion-padding-bottom" *ngIf="settingsService.comunitaEnabled()" style="border-top: 1px solid rgba(255,255,255,0.06); padding-top: 16px;">
|
||||||
|
<div *ngIf="comunitaService.comunitaCode()" class="glass" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 12px; border-radius: 12px; display: flex; align-items: center; justify-content: space-between;">
|
||||||
|
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 12px; flex-grow: 1;">
|
||||||
|
<div style="font-size: 0.72rem; text-transform: uppercase; color: var(--ion-color-secondary); font-weight: bold; letter-spacing: 0.05em; margin-bottom: 2px;">Comunità Attiva</div>
|
||||||
|
<div style="font-size: 0.9rem; font-weight: 500; color: var(--ion-text-color); overflow: hidden; text-overflow: ellipsis;">{{ comunitaService.comunitaNome() }}</div>
|
||||||
|
<div style="font-size: 0.75rem; opacity: 0.7; color: var(--ion-text-color); margin-top: 2px;">Codice: {{ comunitaService.comunitaCode() }}</div>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; gap: 8px; flex-shrink: 0;">
|
||||||
|
<ion-button fill="clear" size="small" color="secondary" (click)="editComunita()" style="margin: 0; --padding-start: 4px; --padding-end: 4px;">
|
||||||
|
<ion-icon name="create-outline" slot="icon-only" style="font-size: 1.25rem;"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
<ion-button fill="clear" size="small" color="danger" (click)="removeComunita()" style="margin: 0; --padding-start: 4px; --padding-end: 4px;">
|
||||||
|
<ion-icon name="trash-outline" slot="icon-only" style="font-size: 1.25rem;"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div *ngIf="!comunitaService.comunitaCode()" style="display: flex; flex-direction: column; gap: 12px;">
|
||||||
|
<p class="settings-item-subtitle outfit-font" style="margin: 0; font-size: 0.8rem; opacity: 0.85;">
|
||||||
|
Inserisci il codice parrocchiale/comunità per scaricare i canti e le scalette dedicate:
|
||||||
|
</p>
|
||||||
|
<div style="display: flex; gap: 10px; align-items: center;">
|
||||||
|
<div class="glass" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 8px 12px; border-radius: 10px; flex-grow: 1; display: flex; align-items: center;">
|
||||||
|
<input type="text"
|
||||||
|
#comunitaCodeInput
|
||||||
|
placeholder="il codice di 6 cifre"
|
||||||
|
style="background: transparent; border: none; color: var(--ion-text-color); font-size: 0.85rem; width: 100%; outline: none;"
|
||||||
|
class="outfit-font"
|
||||||
|
(keyup.enter)="saveComunitaCode(comunitaCodeInput.value)">
|
||||||
|
</div>
|
||||||
|
<ion-button fill="solid" color="secondary" size="small" (click)="saveComunitaCode(comunitaCodeInput.value)" class="outfit-font" style="--border-radius: 10px; font-weight: bold; text-transform: none; margin: 0; height: 36px;">
|
||||||
|
Attiva
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Identità Utente e Ripristino -->
|
||||||
|
<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" style="color: var(--ion-color-secondary); font-weight: bold; font-size: 1.05rem;">
|
||||||
|
Identità Utente e Ripristino
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ion-padding-horizontal ion-padding-bottom">
|
||||||
|
<p class="settings-item-subtitle outfit-font" style="margin: 0 0 16px 0; font-size: 0.85rem; line-height: 1.45; opacity: 0.85; color: var(--ion-text-color);">
|
||||||
|
Questo codice univoco identifica in modo anonimo il tuo dispositivo e ti consente di gestire comunità e canti. Salva il codice o il QR code per ripristinare il tuo profilo su un nuovo dispositivo.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Nome Associato all'Identità -->
|
||||||
|
<div class="glass" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 12px; border-radius: 12px; margin-bottom: 12px;">
|
||||||
|
<div style="font-size: 0.72rem; text-transform: uppercase; color: var(--ion-color-secondary); font-weight: bold; letter-spacing: 0.05em; margin-bottom: 4px;">Nome</div>
|
||||||
|
<input type="text"
|
||||||
|
[value]="settingsService.userName()"
|
||||||
|
(input)="onNameChange($event)"
|
||||||
|
placeholder="Inserisci il tuo nome..."
|
||||||
|
style="background: transparent; border: none; color: var(--ion-text-color); font-size: 0.85rem; width: 100%; outline: none; padding: 4px 0;"
|
||||||
|
class="outfit-font">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="glass" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); padding: 12px; border-radius: 12px; display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px;">
|
||||||
|
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 12px; flex-grow: 1;">
|
||||||
|
<div style="font-size: 0.72rem; text-transform: uppercase; color: var(--ion-color-secondary); font-weight: bold; letter-spacing: 0.05em; margin-bottom: 2px;">Codice ID</div>
|
||||||
|
<div style="font-family: monospace; font-size: 0.78rem; opacity: 0.9; color: var(--ion-text-color);" class="select-all">{{ settingsService.userUuid() }}</div>
|
||||||
|
</div>
|
||||||
|
<ion-button fill="clear" size="small" (click)="showBackupQrCode()" color="secondary" style="margin: 0; --padding-start: 4px; --padding-end: 4px;">
|
||||||
|
<ion-icon name="qr-code-outline" slot="icon-only" style="font-size: 1.35rem;"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
|
||||||
|
<ion-button expand="block" fill="outline" color="secondary" (click)="scanRestoreQrCode()" class="outfit-font" style="--border-radius: 10px; font-size: 0.8rem; font-weight: bold; text-transform: none; margin: 0; height: 38px;">
|
||||||
|
<ion-icon name="scan-outline" slot="start"></ion-icon>
|
||||||
|
Scansiona QR
|
||||||
|
</ion-button>
|
||||||
|
<ion-button expand="block" fill="outline" color="secondary" (click)="manualRestore()" class="outfit-font" style="--border-radius: 10px; font-size: 0.8rem; font-weight: bold; text-transform: none; margin: 0; height: 38px;">
|
||||||
|
<ion-icon name="keypad-outline" slot="start"></ion-icon>
|
||||||
|
Inserisci Codice
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ion-button *ngIf="settingsService.userUuid() !== settingsService.originalUserUuid()"
|
||||||
|
expand="block" fill="outline" color="warning" (click)="restoreOriginalIdentity()" class="outfit-font ion-margin-top" style="--border-radius: 10px; font-size: 0.85rem; font-weight: bold; text-transform: none; margin-left: 0; margin-right: 0; margin-bottom: 0; height: 42px;">
|
||||||
|
<ion-icon name="refresh-outline" slot="start"></ion-icon>
|
||||||
|
Ripristina ID Originario
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
|
<ion-button expand="block" fill="solid" color="secondary" (click)="syncLocalDataToServer()" class="outfit-font ion-margin-top" style="--border-radius: 10px; font-size: 0.85rem; font-weight: bold; text-transform: none; margin-left: 0; margin-right: 0; margin-bottom: 0; height: 42px;">
|
||||||
|
<ion-icon name="cloud-upload-outline" slot="start"></ion-icon>
|
||||||
|
Salva Backup sul Server
|
||||||
|
</ion-button>
|
||||||
|
|
||||||
|
<ion-button expand="block" fill="outline" color="secondary" (click)="restoreBackupFromServer()" class="outfit-font ion-margin-top" style="--border-radius: 10px; font-size: 0.85rem; font-weight: bold; text-transform: none; margin-left: 0; margin-right: 0; margin-bottom: 0; height: 42px;">
|
||||||
|
<ion-icon name="cloud-download-outline" slot="start"></ion-icon>
|
||||||
|
Ripristina Backup dal Server
|
||||||
|
</ion-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Importa Playlist -->
|
||||||
|
<div class="settings-group glass ion-margin-bottom">
|
||||||
|
<ion-item class="transparent-item" lines="none" button (click)="importPlaylistViaQr()">
|
||||||
|
<ion-icon name="qr-code-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font">
|
||||||
|
<h2 class="settings-item-title">Importa Playlist via QR</h2>
|
||||||
|
<p class="settings-item-subtitle">Inquadra il QR di una playlist condivisa per importarla</p>
|
||||||
|
</ion-label>
|
||||||
|
<ion-icon name="chevron-forward-outline" slot="end" color="medium" style="font-size: 1rem;"></ion-icon>
|
||||||
|
</ion-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Invio Dati Statistici -->
|
||||||
|
<div class="settings-group glass ion-margin-bottom">
|
||||||
|
<ion-item class="transparent-item" lines="none">
|
||||||
|
<ion-icon name="analytics-outline" slot="start" color="secondary"></ion-icon>
|
||||||
|
<ion-label class="outfit-font">
|
||||||
|
<h2 class="settings-item-title">Invio dati statistici</h2>
|
||||||
|
<p class="settings-item-subtitle">Invia statistiche di utilizzo e tonalità</p>
|
||||||
|
</ion-label>
|
||||||
|
<ion-toggle slot="end" [checked]="settingsService.invioDatiStatistici()" (ionChange)="settingsService.toggleInvioDatiStatistici()" color="secondary"></ion-toggle>
|
||||||
|
</ion-item>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- Display Mode Section -->
|
||||||
|
<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">
|
||||||
|
Modalità Visualizzazione
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="segment-wrapper ion-padding-horizontal ion-padding-bottom">
|
||||||
|
<div class="filter-buttons compact-mode ion-padding-horizontal ion-padding-bottom">
|
||||||
|
<div class="filter-btn glass"
|
||||||
|
[class.active-btn]="!settingsService.showChordsDefault()"
|
||||||
|
(click)="onModeChange({detail: {value: 'text'}})">
|
||||||
|
<span>Solo Testo</span>
|
||||||
|
</div>
|
||||||
|
<div class="filter-btn glass"
|
||||||
|
[class.active-btn]="settingsService.showChordsDefault()"
|
||||||
|
(click)="onModeChange({detail: {value: 'chords'}})">
|
||||||
|
<span>Accordi</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chord Notation Preference Section -->
|
||||||
|
<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">
|
||||||
|
Notazione Accordi Preferita
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="segment-wrapper ion-padding-horizontal ion-padding-bottom">
|
||||||
|
<div class="filter-buttons compact-mode ion-padding-horizontal ion-padding-bottom">
|
||||||
|
<div class="filter-btn glass"
|
||||||
|
[class.active-btn]="settingsService.chordNotationPreference() === 'diesis'"
|
||||||
|
(click)="settingsService.setChordNotationPreference('diesis')">
|
||||||
|
<span>Diesis (#)</span>
|
||||||
|
</div>
|
||||||
|
<div class="filter-btn glass"
|
||||||
|
[class.active-btn]="settingsService.chordNotationPreference() === 'bemolle'"
|
||||||
|
(click)="settingsService.setChordNotationPreference('bemolle')">
|
||||||
|
<span>Bemolle (b)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Camera Tracking Mode Section -->
|
||||||
|
<div class="settings-group glass ion-margin-bottom" *ngIf="settingsService.enableVisualAutoscroll()">
|
||||||
|
<div class="group-header ion-padding-start ion-padding-top">
|
||||||
|
<h2 class="outfit-font settings-group-title">
|
||||||
|
Modalità Tracciamento Fotocamera (Karaoke)
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="segment-wrapper ion-padding-horizontal ion-padding-bottom">
|
||||||
|
<div class="filter-buttons compact-mode ion-padding-horizontal ion-padding-bottom">
|
||||||
|
<div class="filter-btn glass"
|
||||||
|
[class.active-btn]="settingsService.cameraTrackingMode() === 'distance'"
|
||||||
|
(click)="settingsService.setCameraTrackingMode('distance')">
|
||||||
|
<span>Distanza Viso (Avanti / Dietro)</span>
|
||||||
|
</div>
|
||||||
|
<div class="filter-btn glass"
|
||||||
|
[class.active-btn]="settingsService.cameraTrackingMode() === 'tilt'"
|
||||||
|
(click)="settingsService.setCameraTrackingMode('tilt')">
|
||||||
|
<span>Inclinazione Testa</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="settings-item-subtitle outfit-font" style="margin: 8px 12px 0 12px; font-size: 0.78rem; opacity: 0.8; line-height: 1.4;">
|
||||||
|
<span *ngIf="settingsService.cameraTrackingMode() === 'distance'">
|
||||||
|
💡 <strong>Distanza Viso:</strong> avvicinati allo schermo per avanzare nel testo, allontanati per tornare indietro. Tocca la percentuale nel player per calibrare all'istante la tua distanza attuale al 100%.
|
||||||
|
</span>
|
||||||
|
<span *ngIf="settingsService.cameraTrackingMode() === 'tilt'">
|
||||||
|
💡 <strong>Inclinazione:</strong> piega la testa a destra o a sinistra per avanzare o tornare indietro.
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</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="videocam" color="success"></ion-icon>
|
||||||
|
</div>
|
||||||
|
<div class="legend-text">
|
||||||
|
<h4 class="outfit-font">Scroll Visuale (Karaoke)</h4>
|
||||||
|
<p class="outfit-font">
|
||||||
|
Attiva lo scorrimento visuale intelligente tramite fotocamera frontale (avvicinandoti/allontanandoti o inclinando il capo in base alla modalità selezionata).
|
||||||
|
</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 style="display: flex; flex-direction: column; gap: 4px; align-items: center;">
|
||||||
|
<div class="legend-icon-wrapper">
|
||||||
|
<ion-icon name="text-outline" color="medium"></ion-icon>
|
||||||
|
</div>
|
||||||
|
<div class="legend-icon-wrapper">
|
||||||
|
<ion-icon name="musical-notes-outline" color="secondary"></ion-icon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="legend-text" style="padding-top: 6px;">
|
||||||
|
<h4 class="outfit-font">Testo / Accordi (icona in alto a destra)</h4>
|
||||||
|
<p class="outfit-font">
|
||||||
|
Alterna la visualizzazione tra solo testo e testo con accordi. L'icona con la <strong>T</strong> (grigia) indica la modalità testo; l'icona con le note musicali (arancione) indica la modalità accordi. Un tocco cambia modalità 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 dello scroll visuale.
|
||||||
|
</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
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ion-padding-top ion-text-center">
|
||||||
|
<p class="outfit-font settings-footer-text">
|
||||||
|
Le preferenze vengono salvate automaticamente.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ion-content>
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
.bg-gradient {
|
||||||
|
--background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%);
|
||||||
|
background: radial-gradient(circle at top left, #2d3436 0%, #121212 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-font {
|
||||||
|
font-family: 'Outfit', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group {
|
||||||
|
border-radius: 20px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.transparent-item {
|
||||||
|
--background: transparent;
|
||||||
|
--color: white;
|
||||||
|
--padding-start: 16px;
|
||||||
|
--inner-padding-end: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-item-title {
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-item-subtitle {
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-group-title {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-group-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-item {
|
||||||
|
--min-height: 44px;
|
||||||
|
background: rgba(255, 255, 255, 0.03) !important;
|
||||||
|
margin: 4px 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
.settings-item-title {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ion-radio {
|
||||||
|
--color: rgba(255, 255, 255, 0.4);
|
||||||
|
--color-checked: var(--ion-color-secondary);
|
||||||
|
font-size: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
ion-label {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-mode {
|
||||||
|
.filter-btn {
|
||||||
|
flex: 1;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.group-header {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-info {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-left: 56px;
|
||||||
|
p {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.4);
|
||||||
|
strong {
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-progress {
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--ion-color-secondary);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-footer-text {
|
||||||
|
color: rgba(255, 255, 255, 0.3);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// High contrast overrides
|
||||||
|
:host-context(body.high-contrast) {
|
||||||
|
.settings-item-title {
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
.settings-item-subtitle {
|
||||||
|
color: rgba(0, 0, 0, 0.6) !important;
|
||||||
|
}
|
||||||
|
.settings-footer-text {
|
||||||
|
color: rgba(0, 0, 0, 0.4) !important;
|
||||||
|
}
|
||||||
|
.sub-group-title {
|
||||||
|
color: rgba(0, 0, 0, 0.7) !important;
|
||||||
|
}
|
||||||
|
.sync-info p {
|
||||||
|
color: rgba(0, 0, 0, 0.7) !important;
|
||||||
|
}
|
||||||
|
ion-label {
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
ion-radio {
|
||||||
|
--color: rgba(0, 0, 0, 0.3);
|
||||||
|
color: black !important;
|
||||||
|
|
||||||
|
ion-label {
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.credits-footer {
|
||||||
|
margin-top: 24px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
padding-top: 24px;
|
||||||
|
|
||||||
|
.credits-text {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ion-color-secondary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.email-text {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(body.high-contrast) {
|
||||||
|
.credits-footer {
|
||||||
|
border-top-color: rgba(0, 0, 0, 0.2);
|
||||||
|
.credits-text {
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
.email-text {
|
||||||
|
color: rgba(0, 0, 0, 0.6) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||