278 files
This commit is contained in:
@@ -764,6 +764,15 @@
|
||||
"panel.reward_cost_label": "Kosten ({points_name})",
|
||||
"panel.reward_description_label": "Beschreibung (optional)",
|
||||
"panel.reward_expires_hint": "Gesammelte Punkte werden um Mitternacht zurückerstattet.",
|
||||
"panel.reward_timelock_section": "Erweitert — Zeitsperre",
|
||||
"panel.reward_timelock_label": "Einlösen zeitlich begrenzen",
|
||||
"panel.reward_timelock_hint": "Die Belohnung bleibt sichtbar, kann aber nur innerhalb des festgelegten Zeitfensters eingelöst werden. Punkte ansparen ist nie blockiert.",
|
||||
"panel.reward_timelock_days_label": "Tage",
|
||||
"panel.reward_timelock_days_hint": "Alle Tage aus lassen, wenn es keine Tagesbeschränkung geben soll.",
|
||||
"panel.reward_timelock_from_label": "Von",
|
||||
"panel.reward_timelock_until_label": "Bis",
|
||||
"panel.reward_timelock_times_hint": "Beide Felder leer lassen für jede Uhrzeit. Die Endzeit ist exklusiv, 07:00–19:00 endet also um 19:00 Uhr. Ist der Start später als das Ende, läuft das Fenster über Mitternacht.",
|
||||
"panel.reward_timelock_badge": "Zeitgesperrt",
|
||||
"panel.reward_expires_label": "Läuft ab (leer = nie)",
|
||||
"panel.reward_icon_label": "Symbol",
|
||||
"panel.reward_is_jackpot_hint": "Beiträge aller Kinder für eine gemeinsame Belohnung zusammenlegen.",
|
||||
@@ -799,6 +808,8 @@
|
||||
"panel.settings_parents_title": "Eltern (ohne Admin-Rechte)",
|
||||
"panel.settings_parents_hint": "Geben Sie einem Haushaltsmitglied ohne Admin-Rechte die tägliche Kontrolle — Aufgaben bestätigen, Punkte anpassen, Belohnungen freigeben — ohne Home-Assistant-Administratorrechte. Dieses Admin-Panel können sie weiterhin nicht öffnen und die Konfiguration nicht ändern.",
|
||||
"panel.settings_parents_empty": "Keine Home-Assistant-Benutzer ohne Admin-Rechte gefunden. Erstellen Sie zuerst einen normalen HA-Benutzer und wählen Sie ihn dann hier aus.",
|
||||
"panel.settings_require_linked_child_label": "Verknüpftes Konto pro Kind verlangen",
|
||||
"panel.settings_require_linked_child_hint": "Nur das eigene Home-Assistant-Konto eines Kindes darf für dieses Kind handeln. Für ein gemeinsames Familien-Tablet, auf dem ein Konto für alle genutzt wird, ausgeschaltet lassen. Eltern und Admins können immer für jedes Kind handeln.",
|
||||
"panel.settings_evening_hint": "Standard 17:00 – 21:00",
|
||||
"panel.settings_evening_label": "Abend",
|
||||
"panel.settings_history_title": "Verlauf & Serien",
|
||||
@@ -1217,6 +1228,8 @@
|
||||
"rewards.reward_count_plural": "{count} Belohnungen",
|
||||
"rewards.reward_count_singular": "{count} Belohnung",
|
||||
"rewards.sold_out": "Ausverkauft",
|
||||
"rewards.available_when": "Verfügbar {window}",
|
||||
"rewards.locked_now": "Derzeit nicht verfügbar",
|
||||
"rewards.spendable_balance": "Ausgebbar",
|
||||
"rewards.unavailable_hint": "Diese Belohnung kann nicht mehr beansprucht werden",
|
||||
"streak.achievement.fifty_desc": "Erledige 50 Aufgaben",
|
||||
@@ -1761,5 +1774,25 @@
|
||||
"child.editor.pre_reader_points": "Punktzahl statt Sterne im Bildmodus anzeigen",
|
||||
"common.design.accessible": "Barrierefrei",
|
||||
"child.editor.include_anytime_chores": "Jederzeit-Aufgaben einbeziehen",
|
||||
"child.editor.include_anytime_chores_helper": "Jederzeit-Aufgaben erscheinen normalerweise in jeder Zeitkategorie – deaktivieren, um sie auszublenden"
|
||||
"child.editor.include_anytime_chores_helper": "Jederzeit-Aufgaben erscheinen normalerweise in jeder Zeitkategorie – deaktivieren, um sie auszublenden",
|
||||
"panel.settings_sounds_title": "Abschlussgeräusche",
|
||||
"panel.settings_sounds_hint": "Eigene Soundclips hochladen und pro Aufgabe auswählen.",
|
||||
"panel.settings_sounds_empty": "Noch keine eigenen Geräusche.",
|
||||
"panel.settings_sounds_formats": "MP3, OGG, WAV oder M4A. Max. 1 MB pro Datei.",
|
||||
"panel.sound_upload": "Geräusch hochladen",
|
||||
"panel.sound_uploading": "Wird hochgeladen…",
|
||||
"panel.sound_uploaded": "Geräusch hinzugefügt",
|
||||
"panel.sound_upload_failed": "Upload fehlgeschlagen",
|
||||
"panel.sound_too_large": "Dieses Geräusch ist zu groß (max. 1 MB).",
|
||||
"panel.sound_bad_type": "Wähle eine MP3-, OGG-, WAV- oder M4A-Datei.",
|
||||
"panel.sound_storage_full": "Der Speicher für Geräusche ist voll. Lösche ein Geräusch und versuche es erneut.",
|
||||
"panel.sound_default_name": "Mein Geräusch",
|
||||
"panel.sound_preview": "Abspielen",
|
||||
"panel.sound_rename": "Umbenennen",
|
||||
"panel.sound_rename_prompt": "Name für dieses Geräusch:",
|
||||
"panel.sound_name_required": "Gib dem Geräusch einen Namen.",
|
||||
"panel.sound_delete": "Löschen",
|
||||
"panel.sound_delete_confirm": "Geräusch „{name}“ löschen? Aufgaben, die es nutzen, erhalten wieder das Standardgeräusch.",
|
||||
"panel.sound_deleted": "Geräusch gelöscht",
|
||||
"panel.sound_deleted_reset": "Geräusch gelöscht. {count} Aufgabe(n) auf das Standardgeräusch zurückgesetzt."
|
||||
}
|
||||
|
||||
@@ -802,6 +802,15 @@
|
||||
"panel.reward_cost_label": "Cost ({points_name})",
|
||||
"panel.reward_description_label": "Description (optional)",
|
||||
"panel.reward_expires_hint": "Pooled points are refunded at midnight.",
|
||||
"panel.reward_timelock_section": "Advanced — time lock",
|
||||
"panel.reward_timelock_label": "Limit when this can be claimed",
|
||||
"panel.reward_timelock_hint": "The reward stays visible, but children can only claim it inside the window you set. Saving points into a pool reward is never blocked.",
|
||||
"panel.reward_timelock_days_label": "Days",
|
||||
"panel.reward_timelock_days_hint": "Leave every day off for no day restriction.",
|
||||
"panel.reward_timelock_from_label": "From",
|
||||
"panel.reward_timelock_until_label": "Until",
|
||||
"panel.reward_timelock_times_hint": "Leave both blank for any time of day. The end time is exclusive, so 07:00–19:00 stops at 19:00. A start later than the end spans midnight.",
|
||||
"panel.reward_timelock_badge": "Time-locked",
|
||||
"panel.reward_expires_label": "Expires (blank = never)",
|
||||
"panel.reward_icon_label": "Icon",
|
||||
"panel.reward_is_jackpot_hint": "Combine all children's contributions toward one shared reward.",
|
||||
@@ -837,6 +846,8 @@
|
||||
"panel.settings_parents_title": "Parents (no admin rights)",
|
||||
"panel.settings_parents_hint": "Give a non-admin household member day-to-day control — approve chores, adjust points, confirm rewards — without Home Assistant admin rights. They still can't open this admin panel or change configuration.",
|
||||
"panel.settings_parents_empty": "No non-admin Home Assistant users found. Create a standard HA user first, then tick them here.",
|
||||
"panel.settings_require_linked_child_label": "Require a linked account per child",
|
||||
"panel.settings_require_linked_child_hint": "Only let a child's own Home Assistant account act as that child. Leave off for a shared family tablet where one account is used for everyone. Parents and admins can always act for any child.",
|
||||
"panel.settings_evening_hint": "Default 17:00 – 21:00",
|
||||
"panel.settings_evening_label": "Evening",
|
||||
"panel.settings_history_title": "History & streaks",
|
||||
@@ -1270,6 +1281,8 @@
|
||||
"rewards.reward_count_plural": "{count} rewards",
|
||||
"rewards.reward_count_singular": "{count} reward",
|
||||
"rewards.sold_out": "Sold Out",
|
||||
"rewards.available_when": "Available {window}",
|
||||
"rewards.locked_now": "Not available right now",
|
||||
"rewards.spendable_balance": "Spendable",
|
||||
"rewards.unavailable_hint": "This reward can no longer be claimed",
|
||||
"streak.achievement.fifty_desc": "Complete 50 chores",
|
||||
@@ -1761,5 +1774,25 @@
|
||||
"child.editor.pre_reader_points": "Show point values instead of stars in picture mode",
|
||||
"common.design.accessible": "Accessible",
|
||||
"child.editor.include_anytime_chores": "Include anytime chores",
|
||||
"child.editor.include_anytime_chores_helper": "Anytime chores normally appear under every time category — turn this off to hide them"
|
||||
"child.editor.include_anytime_chores_helper": "Anytime chores normally appear under every time category — turn this off to hide them",
|
||||
"panel.settings_sounds_title": "Completion sounds",
|
||||
"panel.settings_sounds_hint": "Upload your own sound clips and pick them per chore.",
|
||||
"panel.settings_sounds_empty": "No custom sounds yet.",
|
||||
"panel.settings_sounds_formats": "MP3, OGG, WAV or M4A. Max 1 MB each.",
|
||||
"panel.sound_upload": "Upload sound",
|
||||
"panel.sound_uploading": "Uploading…",
|
||||
"panel.sound_uploaded": "Sound added",
|
||||
"panel.sound_upload_failed": "Upload failed",
|
||||
"panel.sound_too_large": "That sound is too large (max 1 MB).",
|
||||
"panel.sound_bad_type": "Choose an MP3, OGG, WAV or M4A file.",
|
||||
"panel.sound_storage_full": "Sound storage is full. Delete a sound and try again.",
|
||||
"panel.sound_default_name": "My sound",
|
||||
"panel.sound_preview": "Play",
|
||||
"panel.sound_rename": "Rename",
|
||||
"panel.sound_rename_prompt": "Name for this sound:",
|
||||
"panel.sound_name_required": "Give the sound a name.",
|
||||
"panel.sound_delete": "Delete",
|
||||
"panel.sound_delete_confirm": "Delete the sound “{name}”? Any chore using it goes back to the default sound.",
|
||||
"panel.sound_deleted": "Sound deleted",
|
||||
"panel.sound_deleted_reset": "Sound deleted. {count} chore(s) reset to the default sound."
|
||||
}
|
||||
|
||||
@@ -802,6 +802,15 @@
|
||||
"panel.reward_cost_label": "Cost ({points_name})",
|
||||
"panel.reward_description_label": "Description (optional)",
|
||||
"panel.reward_expires_hint": "Pooled points are refunded at midnight.",
|
||||
"panel.reward_timelock_section": "Advanced — time lock",
|
||||
"panel.reward_timelock_label": "Limit when this can be claimed",
|
||||
"panel.reward_timelock_hint": "The reward stays visible, but children can only claim it inside the window you set. Saving points into a pool reward is never blocked.",
|
||||
"panel.reward_timelock_days_label": "Days",
|
||||
"panel.reward_timelock_days_hint": "Leave every day off for no day restriction.",
|
||||
"panel.reward_timelock_from_label": "From",
|
||||
"panel.reward_timelock_until_label": "Until",
|
||||
"panel.reward_timelock_times_hint": "Leave both blank for any time of day. The end time is exclusive, so 07:00–19:00 stops at 19:00. A start later than the end spans midnight.",
|
||||
"panel.reward_timelock_badge": "Time-locked",
|
||||
"panel.reward_expires_label": "Expires (blank = never)",
|
||||
"panel.reward_icon_label": "Icon",
|
||||
"panel.reward_is_jackpot_hint": "Combine all children's contributions toward one shared reward.",
|
||||
@@ -837,6 +846,8 @@
|
||||
"panel.settings_parents_title": "Parents (no admin rights)",
|
||||
"panel.settings_parents_hint": "Give a non-admin household member day-to-day control — approve chores, adjust points, confirm rewards — without Home Assistant admin rights. They still can't open this admin panel or change configuration.",
|
||||
"panel.settings_parents_empty": "No non-admin Home Assistant users found. Create a standard HA user first, then tick them here.",
|
||||
"panel.settings_require_linked_child_label": "Require a linked account per child",
|
||||
"panel.settings_require_linked_child_hint": "Only let a child's own Home Assistant account act as that child. Leave off for a shared family tablet where one account is used for everyone. Parents and admins can always act for any child.",
|
||||
"panel.settings_evening_hint": "Default 17:00 – 21:00",
|
||||
"panel.settings_evening_label": "Evening",
|
||||
"panel.settings_history_title": "History & streaks",
|
||||
@@ -1270,6 +1281,8 @@
|
||||
"rewards.reward_count_plural": "{count} rewards",
|
||||
"rewards.reward_count_singular": "{count} reward",
|
||||
"rewards.sold_out": "Sold Out",
|
||||
"rewards.available_when": "Available {window}",
|
||||
"rewards.locked_now": "Not available right now",
|
||||
"rewards.spendable_balance": "Spendable",
|
||||
"rewards.unavailable_hint": "This reward can no longer be claimed",
|
||||
"streak.achievement.fifty_desc": "Complete 50 chores",
|
||||
@@ -1761,5 +1774,25 @@
|
||||
"child.editor.pre_reader_points": "Show point values instead of stars in picture mode",
|
||||
"common.design.accessible": "Accessible",
|
||||
"child.editor.include_anytime_chores": "Include anytime chores",
|
||||
"child.editor.include_anytime_chores_helper": "Anytime chores normally appear under every time category — turn this off to hide them"
|
||||
"child.editor.include_anytime_chores_helper": "Anytime chores normally appear under every time category — turn this off to hide them",
|
||||
"panel.settings_sounds_title": "Completion sounds",
|
||||
"panel.settings_sounds_hint": "Upload your own sound clips and pick them per chore.",
|
||||
"panel.settings_sounds_empty": "No custom sounds yet.",
|
||||
"panel.settings_sounds_formats": "MP3, OGG, WAV or M4A. Max 1 MB each.",
|
||||
"panel.sound_upload": "Upload sound",
|
||||
"panel.sound_uploading": "Uploading…",
|
||||
"panel.sound_uploaded": "Sound added",
|
||||
"panel.sound_upload_failed": "Upload failed",
|
||||
"panel.sound_too_large": "That sound is too large (max 1 MB).",
|
||||
"panel.sound_bad_type": "Choose an MP3, OGG, WAV or M4A file.",
|
||||
"panel.sound_storage_full": "Sound storage is full. Delete a sound and try again.",
|
||||
"panel.sound_default_name": "My sound",
|
||||
"panel.sound_preview": "Play",
|
||||
"panel.sound_rename": "Rename",
|
||||
"panel.sound_rename_prompt": "Name for this sound:",
|
||||
"panel.sound_name_required": "Give the sound a name.",
|
||||
"panel.sound_delete": "Delete",
|
||||
"panel.sound_delete_confirm": "Delete the sound “{name}”? Any chore using it goes back to the default sound.",
|
||||
"panel.sound_deleted": "Sound deleted",
|
||||
"panel.sound_deleted_reset": "Sound deleted. {count} chore(s) reset to the default sound."
|
||||
}
|
||||
|
||||
@@ -764,6 +764,15 @@
|
||||
"panel.reward_cost_label": "Coût ({points_name})",
|
||||
"panel.reward_description_label": "Description (optionnel)",
|
||||
"panel.reward_expires_hint": "Les points en cagnotte sont remboursés à minuit.",
|
||||
"panel.reward_timelock_section": "Avancé — verrouillage horaire",
|
||||
"panel.reward_timelock_label": "Limiter quand elle peut être réclamée",
|
||||
"panel.reward_timelock_hint": "La récompense reste visible, mais ne peut être réclamée que pendant la plage définie. Mettre des points en cagnotte n'est jamais bloqué.",
|
||||
"panel.reward_timelock_days_label": "Jours",
|
||||
"panel.reward_timelock_days_hint": "Laissez tous les jours désactivés pour ne restreindre aucun jour.",
|
||||
"panel.reward_timelock_from_label": "De",
|
||||
"panel.reward_timelock_until_label": "À",
|
||||
"panel.reward_timelock_times_hint": "Laissez les deux champs vides pour autoriser toute heure. L'heure de fin est exclue : 07:00–19:00 s'arrête à 19:00. Si le début est après la fin, la plage passe minuit.",
|
||||
"panel.reward_timelock_badge": "Horaire limité",
|
||||
"panel.reward_expires_label": "Expire (vide = jamais)",
|
||||
"panel.reward_icon_label": "Icône",
|
||||
"panel.reward_is_jackpot_hint": "Combiner les contributions de tous les enfants vers une récompense partagée.",
|
||||
@@ -799,6 +808,8 @@
|
||||
"panel.settings_parents_title": "Parents (sans droits admin)",
|
||||
"panel.settings_parents_hint": "Donnez à un membre du foyer non-administrateur le contrôle quotidien — valider les tâches, ajuster les points, confirmer les récompenses — sans droits d’administrateur Home Assistant. Il ne pourra toujours pas ouvrir ce panneau d’administration ni modifier la configuration.",
|
||||
"panel.settings_parents_empty": "Aucun utilisateur Home Assistant non-administrateur trouvé. Créez d’abord un utilisateur HA standard, puis cochez-le ici.",
|
||||
"panel.settings_require_linked_child_label": "Exiger un compte lié par enfant",
|
||||
"panel.settings_require_linked_child_hint": "Seul le compte Home Assistant d'un enfant peut agir en son nom. Laissez désactivé pour une tablette familiale partagée où un seul compte sert à tous. Les parents et les administrateurs peuvent toujours agir pour n'importe quel enfant.",
|
||||
"panel.settings_evening_hint": "Par défaut 17:00 – 21:00",
|
||||
"panel.settings_evening_label": "Soir",
|
||||
"panel.settings_history_title": "Historique et séries",
|
||||
@@ -1217,6 +1228,8 @@
|
||||
"rewards.reward_count_plural": "{count} récompenses",
|
||||
"rewards.reward_count_singular": "{count} récompense",
|
||||
"rewards.sold_out": "Épuisée",
|
||||
"rewards.available_when": "Disponible {window}",
|
||||
"rewards.locked_now": "Indisponible pour le moment",
|
||||
"rewards.spendable_balance": "Disponible",
|
||||
"rewards.unavailable_hint": "Cette récompense ne peut plus être réclamée",
|
||||
"streak.achievement.fifty_desc": "Accomplis 50 tâches",
|
||||
@@ -1761,5 +1774,25 @@
|
||||
"child.editor.pre_reader_points": "Afficher les points au lieu des étoiles en mode images",
|
||||
"common.design.accessible": "Accessible",
|
||||
"child.editor.include_anytime_chores": "Inclure les tâches « n'importe quand »",
|
||||
"child.editor.include_anytime_chores_helper": "Les tâches « n'importe quand » apparaissent normalement dans toutes les catégories de temps — désactivez pour les masquer"
|
||||
"child.editor.include_anytime_chores_helper": "Les tâches « n'importe quand » apparaissent normalement dans toutes les catégories de temps — désactivez pour les masquer",
|
||||
"panel.settings_sounds_title": "Sons de fin de tâche",
|
||||
"panel.settings_sounds_hint": "Importez vos propres extraits sonores et choisissez-les par tâche.",
|
||||
"panel.settings_sounds_empty": "Aucun son personnalisé pour le moment.",
|
||||
"panel.settings_sounds_formats": "MP3, OGG, WAV ou M4A. 1 Mo maximum par fichier.",
|
||||
"panel.sound_upload": "Importer un son",
|
||||
"panel.sound_uploading": "Import en cours…",
|
||||
"panel.sound_uploaded": "Son ajouté",
|
||||
"panel.sound_upload_failed": "Échec de l’import",
|
||||
"panel.sound_too_large": "Ce son est trop volumineux (1 Mo maximum).",
|
||||
"panel.sound_bad_type": "Choisissez un fichier MP3, OGG, WAV ou M4A.",
|
||||
"panel.sound_storage_full": "L’espace de stockage des sons est plein. Supprimez un son et réessayez.",
|
||||
"panel.sound_default_name": "Mon son",
|
||||
"panel.sound_preview": "Écouter",
|
||||
"panel.sound_rename": "Renommer",
|
||||
"panel.sound_rename_prompt": "Nom de ce son :",
|
||||
"panel.sound_name_required": "Donnez un nom au son.",
|
||||
"panel.sound_delete": "Supprimer",
|
||||
"panel.sound_delete_confirm": "Supprimer le son « {name} » ? Les tâches qui l’utilisent reviendront au son par défaut.",
|
||||
"panel.sound_deleted": "Son supprimé",
|
||||
"panel.sound_deleted_reset": "Son supprimé. {count} tâche(s) remise(s) au son par défaut."
|
||||
}
|
||||
|
||||
@@ -764,6 +764,15 @@
|
||||
"panel.reward_cost_label": "Kostnad ({points_name})",
|
||||
"panel.reward_description_label": "Beskrivelse (valgfritt)",
|
||||
"panel.reward_expires_hint": "Sparede poeng tilbakebetales ved midnatt.",
|
||||
"panel.reward_timelock_section": "Avansert — tidslås",
|
||||
"panel.reward_timelock_label": "Begrens når den kan kreves",
|
||||
"panel.reward_timelock_hint": "Belønningen vises fortsatt, men kan bare kreves innenfor tidsrommet du angir. Å spare poeng blir aldri blokkert.",
|
||||
"panel.reward_timelock_days_label": "Dager",
|
||||
"panel.reward_timelock_days_hint": "La alle dagene være av for ingen dagsbegrensning.",
|
||||
"panel.reward_timelock_from_label": "Fra",
|
||||
"panel.reward_timelock_until_label": "Til",
|
||||
"panel.reward_timelock_times_hint": "La begge feltene stå tomme for når som helst på døgnet. Sluttidspunktet er ekskludert, så 07:00–19:00 stopper 19:00. Er starten senere enn slutten, går tidsrommet over midnatt.",
|
||||
"panel.reward_timelock_badge": "Tidslåst",
|
||||
"panel.reward_expires_label": "Utløper (tomt = aldri)",
|
||||
"panel.reward_icon_label": "Ikon",
|
||||
"panel.reward_is_jackpot_hint": "Kombiner alle barnas bidrag mot én felles belønning.",
|
||||
@@ -799,6 +808,8 @@
|
||||
"panel.settings_parents_title": "Foreldre (uten adminrettigheter)",
|
||||
"panel.settings_parents_hint": "Gi et husstandsmedlem uten adminrettigheter daglig kontroll — godkjenne oppgaver, justere poeng, bekrefte belønninger — uten Home Assistant-adminrettigheter. De kan fortsatt ikke åpne dette adminpanelet eller endre konfigurasjonen.",
|
||||
"panel.settings_parents_empty": "Fant ingen Home Assistant-brukere uten adminrettigheter. Opprett en vanlig HA-bruker først, og huk deretter av her.",
|
||||
"panel.settings_require_linked_child_label": "Krev en koblet konto per barn",
|
||||
"panel.settings_require_linked_child_hint": "La bare barnets egen Home Assistant-konto handle som det barnet. La stå av for et delt familienettbrett der én konto brukes av alle. Foreldre og administratorer kan alltid handle for alle barn.",
|
||||
"panel.settings_evening_hint": "Standard 17:00 – 21:00",
|
||||
"panel.settings_evening_label": "Kveld",
|
||||
"panel.settings_history_title": "Historikk og serier",
|
||||
@@ -1217,6 +1228,8 @@
|
||||
"rewards.reward_count_plural": "{count} belønninger",
|
||||
"rewards.reward_count_singular": "{count} belønning",
|
||||
"rewards.sold_out": "Utsolgt",
|
||||
"rewards.available_when": "Tilgjengelig {window}",
|
||||
"rewards.locked_now": "Ikke tilgjengelig akkurat nå",
|
||||
"rewards.spendable_balance": "Disponibelt",
|
||||
"rewards.unavailable_hint": "Denne belønningen kan ikke lenger kreves",
|
||||
"streak.achievement.fifty_desc": "Fullfør 50 oppgaver",
|
||||
@@ -1761,5 +1774,25 @@
|
||||
"child.editor.pre_reader_points": "Vis poengverdier i stedet for stjerner i bildemodus",
|
||||
"common.design.accessible": "Tilgjengelig",
|
||||
"child.editor.include_anytime_chores": "Ta med «når som helst»-oppgaver",
|
||||
"child.editor.include_anytime_chores_helper": "«Når som helst»-oppgaver vises normalt i alle tidskategorier — slå av for å skjule dem"
|
||||
"child.editor.include_anytime_chores_helper": "«Når som helst»-oppgaver vises normalt i alle tidskategorier — slå av for å skjule dem",
|
||||
"panel.settings_sounds_title": "Fullføringslyder",
|
||||
"panel.settings_sounds_hint": "Last opp egne lydklipp og velg dem per oppgave.",
|
||||
"panel.settings_sounds_empty": "Ingen egne lyder ennå.",
|
||||
"panel.settings_sounds_formats": "MP3, OGG, WAV eller M4A. Maks 1 MB per fil.",
|
||||
"panel.sound_upload": "Last opp lyd",
|
||||
"panel.sound_uploading": "Laster opp…",
|
||||
"panel.sound_uploaded": "Lyd lagt til",
|
||||
"panel.sound_upload_failed": "Opplasting mislyktes",
|
||||
"panel.sound_too_large": "Denne lyden er for stor (maks 1 MB).",
|
||||
"panel.sound_bad_type": "Velg en MP3-, OGG-, WAV- eller M4A-fil.",
|
||||
"panel.sound_storage_full": "Lagringsplassen for lyder er full. Slett en lyd og prøv igjen.",
|
||||
"panel.sound_default_name": "Min lyd",
|
||||
"panel.sound_preview": "Spill av",
|
||||
"panel.sound_rename": "Gi nytt navn",
|
||||
"panel.sound_rename_prompt": "Navn på denne lyden:",
|
||||
"panel.sound_name_required": "Gi lyden et navn.",
|
||||
"panel.sound_delete": "Slett",
|
||||
"panel.sound_delete_confirm": "Slette lyden «{name}»? Oppgaver som bruker den, går tilbake til standardlyden.",
|
||||
"panel.sound_deleted": "Lyd slettet",
|
||||
"panel.sound_deleted_reset": "Lyd slettet. {count} oppgave(r) tilbakestilt til standardlyden."
|
||||
}
|
||||
|
||||
@@ -764,6 +764,15 @@
|
||||
"panel.reward_cost_label": "Kostnad ({points_name})",
|
||||
"panel.reward_description_label": "Skildring (valfri)",
|
||||
"panel.reward_expires_hint": "Samla poeng vert refunderte ved midnatt.",
|
||||
"panel.reward_timelock_section": "Avansert — tidslås",
|
||||
"panel.reward_timelock_label": "Avgrens når ho kan krevjast",
|
||||
"panel.reward_timelock_hint": "Løninga er framleis synleg, men kan berre krevjast innanfor tidsrommet du set. Å spare poeng vert aldri blokkert.",
|
||||
"panel.reward_timelock_days_label": "Dagar",
|
||||
"panel.reward_timelock_days_hint": "Lat alle dagane vere av for inga dagsavgrensing.",
|
||||
"panel.reward_timelock_from_label": "Frå",
|
||||
"panel.reward_timelock_until_label": "Til",
|
||||
"panel.reward_timelock_times_hint": "Lat begge felta stå tomme for kva tid som helst på døgnet. Slutt-tidspunktet er ekskludert, så 07:00–19:00 stoppar 19:00. Er starten seinare enn slutten, går tidsrommet over midnatt.",
|
||||
"panel.reward_timelock_badge": "Tidslåst",
|
||||
"panel.reward_expires_label": "Går ut (tomt = aldri)",
|
||||
"panel.reward_icon_label": "Ikon",
|
||||
"panel.reward_is_jackpot_hint": "Slå saman bidrag frå alle born mot éin felles premie.",
|
||||
@@ -799,6 +808,8 @@
|
||||
"panel.settings_parents_title": "Foreldre (utan adminrettar)",
|
||||
"panel.settings_parents_hint": "Gi eit husstandsmedlem utan adminrettar dagleg kontroll — godkjenne oppgåver, justere poeng, stadfeste løningar — utan Home Assistant-adminrettar. Dei kan framleis ikkje opne dette adminpanelet eller endre konfigurasjonen.",
|
||||
"panel.settings_parents_empty": "Fann ingen Home Assistant-brukarar utan adminrettar. Opprett ein vanleg HA-brukar først, og hak deretter av her.",
|
||||
"panel.settings_require_linked_child_label": "Krev ein kopla konto per barn",
|
||||
"panel.settings_require_linked_child_hint": "Lat berre barnet sin eigen Home Assistant-konto handle som det barnet. Lat stå av for eit delt familienettbrett der éin konto vert brukt av alle. Foreldre og administratorar kan alltid handle for alle barn.",
|
||||
"panel.settings_evening_hint": "Standard 17:00 – 21:00",
|
||||
"panel.settings_evening_label": "Kveld",
|
||||
"panel.settings_history_title": "Historikk og rekkjer",
|
||||
@@ -1217,6 +1228,8 @@
|
||||
"rewards.reward_count_plural": "{count} løningar",
|
||||
"rewards.reward_count_singular": "{count} løning",
|
||||
"rewards.sold_out": "Utseld",
|
||||
"rewards.available_when": "Tilgjengeleg {window}",
|
||||
"rewards.locked_now": "Ikkje tilgjengeleg akkurat no",
|
||||
"rewards.spendable_balance": "Tilgjengeleg",
|
||||
"rewards.unavailable_hint": "Denne løninga kan ikkje lenger krevjast",
|
||||
"streak.achievement.fifty_desc": "Fullfør 50 oppgåver",
|
||||
@@ -1761,5 +1774,25 @@
|
||||
"child.editor.pre_reader_points": "Vis poengverdiar i staden for stjerner i biletemodus",
|
||||
"common.design.accessible": "Tilgjengeleg",
|
||||
"child.editor.include_anytime_chores": "Ta med «kva tid som helst»-oppgåver",
|
||||
"child.editor.include_anytime_chores_helper": "«Kva tid som helst»-oppgåver vert normalt viste i alle tidskategoriar — slå av for å skjule dei"
|
||||
"child.editor.include_anytime_chores_helper": "«Kva tid som helst»-oppgåver vert normalt viste i alle tidskategoriar — slå av for å skjule dei",
|
||||
"panel.settings_sounds_title": "Fullføringslydar",
|
||||
"panel.settings_sounds_hint": "Last opp eigne lydklipp og vel dei per oppgåve.",
|
||||
"panel.settings_sounds_empty": "Ingen eigne lydar ennå.",
|
||||
"panel.settings_sounds_formats": "MP3, OGG, WAV eller M4A. Maks 1 MB per fil.",
|
||||
"panel.sound_upload": "Last opp lyd",
|
||||
"panel.sound_uploading": "Lastar opp…",
|
||||
"panel.sound_uploaded": "Lyd lagt til",
|
||||
"panel.sound_upload_failed": "Opplasting mislukkast",
|
||||
"panel.sound_too_large": "Denne lyden er for stor (maks 1 MB).",
|
||||
"panel.sound_bad_type": "Vel ei MP3-, OGG-, WAV- eller M4A-fil.",
|
||||
"panel.sound_storage_full": "Lagringsplassen for lydar er full. Slett ein lyd og prøv igjen.",
|
||||
"panel.sound_default_name": "Lyden min",
|
||||
"panel.sound_preview": "Spel av",
|
||||
"panel.sound_rename": "Gi nytt namn",
|
||||
"panel.sound_rename_prompt": "Namn på denne lyden:",
|
||||
"panel.sound_name_required": "Gi lyden eit namn.",
|
||||
"panel.sound_delete": "Slett",
|
||||
"panel.sound_delete_confirm": "Slette lyden «{name}»? Oppgåver som brukar han, går tilbake til standardlyden.",
|
||||
"panel.sound_deleted": "Lyd sletta",
|
||||
"panel.sound_deleted_reset": "Lyd sletta. {count} oppgåve(r) tilbakestilt til standardlyden."
|
||||
}
|
||||
|
||||
@@ -764,6 +764,15 @@
|
||||
"panel.reward_cost_label": "Custo ({points_name})",
|
||||
"panel.reward_description_label": "Descrição (opcional)",
|
||||
"panel.reward_expires_hint": "Pontos acumulados são devolvidos à meia-noite.",
|
||||
"panel.reward_timelock_section": "Avançado — bloqueio por horário",
|
||||
"panel.reward_timelock_label": "Limitar quando pode ser resgatada",
|
||||
"panel.reward_timelock_hint": "A recompensa continua visível, mas só pode ser resgatada dentro do período definido. Acumular pontos nunca é bloqueado.",
|
||||
"panel.reward_timelock_days_label": "Dias",
|
||||
"panel.reward_timelock_days_hint": "Deixe todos os dias desligados para não restringir dias.",
|
||||
"panel.reward_timelock_from_label": "Das",
|
||||
"panel.reward_timelock_until_label": "Até",
|
||||
"panel.reward_timelock_times_hint": "Deixe ambos em branco para qualquer horário do dia. O horário final é exclusivo, então 07:00–19:00 termina às 19:00. Se o início for depois do fim, o período atravessa a meia-noite.",
|
||||
"panel.reward_timelock_badge": "Com bloqueio de horário",
|
||||
"panel.reward_expires_label": "Expira (vazio = nunca)",
|
||||
"panel.reward_icon_label": "Ícone",
|
||||
"panel.reward_is_jackpot_hint": "Combina contribuições de todas as crianças para uma recompensa compartilhada.",
|
||||
@@ -799,6 +808,8 @@
|
||||
"panel.settings_parents_title": "Pais (sem direitos de administrador)",
|
||||
"panel.settings_parents_hint": "Dê a um membro da família sem permissões de administrador o controle do dia a dia — aprovar tarefas, ajustar pontos, confirmar recompensas — sem direitos de administrador do Home Assistant. Ele ainda não poderá abrir este painel de administração nem alterar a configuração.",
|
||||
"panel.settings_parents_empty": "Nenhum usuário do Home Assistant sem permissões de administrador foi encontrado. Crie primeiro um usuário HA padrão e depois marque-o aqui.",
|
||||
"panel.settings_require_linked_child_label": "Exigir uma conta vinculada por criança",
|
||||
"panel.settings_require_linked_child_hint": "Só permite que a conta do Home Assistant da própria criança atue como essa criança. Deixe desligado em um tablet familiar compartilhado em que uma conta serve para todos. Pais e administradores sempre podem atuar por qualquer criança.",
|
||||
"panel.settings_evening_hint": "Padrão 17:00 – 21:00",
|
||||
"panel.settings_evening_label": "Noite",
|
||||
"panel.settings_history_title": "Histórico e sequências",
|
||||
@@ -1217,6 +1228,8 @@
|
||||
"rewards.reward_count_plural": "{count} recompensas",
|
||||
"rewards.reward_count_singular": "{count} recompensa",
|
||||
"rewards.sold_out": "Esgotada",
|
||||
"rewards.available_when": "Disponível {window}",
|
||||
"rewards.locked_now": "Indisponível no momento",
|
||||
"rewards.spendable_balance": "Disponível",
|
||||
"rewards.unavailable_hint": "Essa recompensa não pode mais ser reclamada",
|
||||
"streak.achievement.fifty_desc": "Conclui 50 tarefas",
|
||||
@@ -1761,5 +1774,25 @@
|
||||
"child.editor.pre_reader_points": "Mostrar pontos em vez de estrelas no modo imagens",
|
||||
"common.design.accessible": "Acessível",
|
||||
"child.editor.include_anytime_chores": "Incluir tarefas de qualquer hora",
|
||||
"child.editor.include_anytime_chores_helper": "As tarefas de qualquer hora aparecem normalmente em todas as categorias de hora — desative para ocultá-las"
|
||||
"child.editor.include_anytime_chores_helper": "As tarefas de qualquer hora aparecem normalmente em todas as categorias de hora — desative para ocultá-las",
|
||||
"panel.settings_sounds_title": "Sons de conclusão",
|
||||
"panel.settings_sounds_hint": "Envie seus próprios clipes de som e escolha-os por tarefa.",
|
||||
"panel.settings_sounds_empty": "Ainda não há sons personalizados.",
|
||||
"panel.settings_sounds_formats": "MP3, OGG, WAV ou M4A. Máximo de 1 MB cada.",
|
||||
"panel.sound_upload": "Enviar som",
|
||||
"panel.sound_uploading": "Enviando…",
|
||||
"panel.sound_uploaded": "Som adicionado",
|
||||
"panel.sound_upload_failed": "Falha no envio",
|
||||
"panel.sound_too_large": "Esse som é muito grande (máx. 1 MB).",
|
||||
"panel.sound_bad_type": "Escolha um arquivo MP3, OGG, WAV ou M4A.",
|
||||
"panel.sound_storage_full": "O armazenamento de sons está cheio. Exclua um som e tente novamente.",
|
||||
"panel.sound_default_name": "Meu som",
|
||||
"panel.sound_preview": "Reproduzir",
|
||||
"panel.sound_rename": "Renomear",
|
||||
"panel.sound_rename_prompt": "Nome deste som:",
|
||||
"panel.sound_name_required": "Dê um nome ao som.",
|
||||
"panel.sound_delete": "Excluir",
|
||||
"panel.sound_delete_confirm": "Excluir o som “{name}”? As tarefas que o usam voltam ao som padrão.",
|
||||
"panel.sound_deleted": "Som excluído",
|
||||
"panel.sound_deleted_reset": "Som excluído. {count} tarefa(s) redefinida(s) para o som padrão."
|
||||
}
|
||||
|
||||
@@ -769,6 +769,15 @@
|
||||
"panel.reward_cost_label": "Custo ({points_name})",
|
||||
"panel.reward_description_label": "Descrição (opcional)",
|
||||
"panel.reward_expires_hint": "Os pontos acumulados são devolvidos à meia-noite.",
|
||||
"panel.reward_timelock_section": "Avançado — bloqueio horário",
|
||||
"panel.reward_timelock_label": "Limitar quando pode ser reclamada",
|
||||
"panel.reward_timelock_hint": "A recompensa continua visível, mas só pode ser reclamada dentro do período definido. Acumular pontos nunca é bloqueado.",
|
||||
"panel.reward_timelock_days_label": "Dias",
|
||||
"panel.reward_timelock_days_hint": "Deixe todos os dias desligados para não restringir dias.",
|
||||
"panel.reward_timelock_from_label": "Das",
|
||||
"panel.reward_timelock_until_label": "Até",
|
||||
"panel.reward_timelock_times_hint": "Deixe ambos em branco para qualquer hora do dia. A hora final é exclusiva, por isso 07:00–19:00 termina às 19:00. Se o início for depois do fim, o período passa a meia-noite.",
|
||||
"panel.reward_timelock_badge": "Com bloqueio horário",
|
||||
"panel.reward_expires_label": "Expira (vazio = nunca)",
|
||||
"panel.reward_icon_label": "Ícone",
|
||||
"panel.reward_is_jackpot_hint": "Combinar as contribuições de todas as crianças para uma recompensa partilhada.",
|
||||
@@ -804,6 +813,8 @@
|
||||
"panel.settings_parents_title": "Pais (sem direitos de administrador)",
|
||||
"panel.settings_parents_hint": "Dê a um membro do agregado sem permissões de administrador o controlo do dia a dia — aprovar tarefas, ajustar pontos, confirmar recompensas — sem direitos de administrador do Home Assistant. Continuará sem poder abrir este painel de administração nem alterar a configuração.",
|
||||
"panel.settings_parents_empty": "Não foram encontrados utilizadores do Home Assistant sem permissões de administrador. Crie primeiro um utilizador HA normal e depois selecione-o aqui.",
|
||||
"panel.settings_require_linked_child_label": "Exigir uma conta associada por criança",
|
||||
"panel.settings_require_linked_child_hint": "Só permite que a conta do Home Assistant da própria criança atue como essa criança. Deixe desligado num tablet familiar partilhado em que uma conta serve todos. Os pais e administradores podem sempre atuar por qualquer criança.",
|
||||
"panel.settings_evening_hint": "Predefinição 17:00 – 21:00",
|
||||
"panel.settings_evening_label": "Noite",
|
||||
"panel.settings_history_title": "Histórico e sequências",
|
||||
@@ -1222,6 +1233,8 @@
|
||||
"rewards.reward_count_plural": "{count} recompensas",
|
||||
"rewards.reward_count_singular": "{count} recompensa",
|
||||
"rewards.sold_out": "Esgotada",
|
||||
"rewards.available_when": "Disponível {window}",
|
||||
"rewards.locked_now": "Indisponível neste momento",
|
||||
"rewards.spendable_balance": "Disponível",
|
||||
"rewards.unavailable_hint": "Esta recompensa já não pode ser reclamada",
|
||||
"streak.achievement.fifty_desc": "Conclui 50 tarefas",
|
||||
@@ -1761,5 +1774,25 @@
|
||||
"child.editor.pre_reader_points": "Mostrar pontos em vez de estrelas no modo imagens",
|
||||
"common.design.accessible": "Acessível",
|
||||
"child.editor.include_anytime_chores": "Incluir tarefas de qualquer hora",
|
||||
"child.editor.include_anytime_chores_helper": "As tarefas de qualquer hora aparecem normalmente em todas as categorias de hora — desative para as ocultar"
|
||||
"child.editor.include_anytime_chores_helper": "As tarefas de qualquer hora aparecem normalmente em todas as categorias de hora — desative para as ocultar",
|
||||
"panel.settings_sounds_title": "Sons de conclusão",
|
||||
"panel.settings_sounds_hint": "Carregue os seus próprios clipes de som e escolha-os por tarefa.",
|
||||
"panel.settings_sounds_empty": "Ainda não há sons personalizados.",
|
||||
"panel.settings_sounds_formats": "MP3, OGG, WAV ou M4A. Máximo de 1 MB cada.",
|
||||
"panel.sound_upload": "Carregar som",
|
||||
"panel.sound_uploading": "A carregar…",
|
||||
"panel.sound_uploaded": "Som adicionado",
|
||||
"panel.sound_upload_failed": "Falha ao carregar",
|
||||
"panel.sound_too_large": "Esse som é demasiado grande (máx. 1 MB).",
|
||||
"panel.sound_bad_type": "Escolha um ficheiro MP3, OGG, WAV ou M4A.",
|
||||
"panel.sound_storage_full": "O armazenamento de sons está cheio. Elimine um som e tente novamente.",
|
||||
"panel.sound_default_name": "O meu som",
|
||||
"panel.sound_preview": "Reproduzir",
|
||||
"panel.sound_rename": "Mudar o nome",
|
||||
"panel.sound_rename_prompt": "Nome deste som:",
|
||||
"panel.sound_name_required": "Dê um nome ao som.",
|
||||
"panel.sound_delete": "Eliminar",
|
||||
"panel.sound_delete_confirm": "Eliminar o som “{name}”? As tarefas que o usam voltam ao som predefinido.",
|
||||
"panel.sound_deleted": "Som eliminado",
|
||||
"panel.sound_deleted_reset": "Som eliminado. {count} tarefa(s) reposta(s) para o som predefinido."
|
||||
}
|
||||
|
||||
@@ -216,353 +216,26 @@ class TaskMateChildCard extends LitElement {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the AudioContext (lazy initialization)
|
||||
* Must be called after user interaction due to browser autoplay policies
|
||||
*/
|
||||
_getAudioContext() {
|
||||
if (!this._audioContext) {
|
||||
this._audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
// Resume if suspended (required after user interaction)
|
||||
if (this._audioContext.state === 'suspended') {
|
||||
this._audioContext.resume();
|
||||
}
|
||||
return this._audioContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a completion sound using Web Audio API
|
||||
* Generates fun synthesized sounds - no external files needed!
|
||||
* @param {string} soundName - Name of the sound to play (coin, levelup, fanfare, chime, powerup)
|
||||
* Play a completion sound.
|
||||
*
|
||||
* Delegates to the shared engine in taskmate-sounds.js — this card used to
|
||||
* carry its own copy of every synthesised sound, which drifted out of sync
|
||||
* with the other copy in taskmate-config-sounds.js.
|
||||
*
|
||||
* @param {string} soundName - built-in name, or "custom:<file>" (#856)
|
||||
*/
|
||||
_playSound(soundName) {
|
||||
// Don't play if sound is "none" or not specified
|
||||
if (!soundName || soundName === 'none') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ctx = this._getAudioContext();
|
||||
const now = ctx.currentTime;
|
||||
|
||||
switch (soundName) {
|
||||
case 'coin':
|
||||
this._playCoinSound(ctx, now);
|
||||
break;
|
||||
case 'levelup':
|
||||
this._playLevelUpSound(ctx, now);
|
||||
break;
|
||||
case 'fanfare':
|
||||
this._playFanfareSound(ctx, now);
|
||||
break;
|
||||
case 'chime':
|
||||
this._playChimeSound(ctx, now);
|
||||
break;
|
||||
case 'powerup':
|
||||
this._playPowerUpSound(ctx, now);
|
||||
break;
|
||||
case 'undo':
|
||||
this._playUndoSound(ctx, now);
|
||||
break;
|
||||
case 'fart1':
|
||||
this._playAudioFile('fart1.mp3');
|
||||
break;
|
||||
case 'fart2':
|
||||
this._playAudioFile('fart2.mp3');
|
||||
break;
|
||||
case 'fart3':
|
||||
this._playAudioFile('fart3.mp3');
|
||||
break;
|
||||
case 'fart4':
|
||||
this._playAudioFile('fart4.mp3');
|
||||
break;
|
||||
case 'fart5':
|
||||
this._playAudioFile('fart5.mp3');
|
||||
break;
|
||||
case 'fart6':
|
||||
this._playAudioFile('fart6.mp3');
|
||||
break;
|
||||
case 'fart7':
|
||||
this._playAudioFile('fart7.mp3');
|
||||
break;
|
||||
case 'fart8':
|
||||
this._playAudioFile('fart8.mp3');
|
||||
break;
|
||||
case 'fart9':
|
||||
this._playAudioFile('fart9.mp3');
|
||||
break;
|
||||
case 'fart10':
|
||||
this._playAudioFile('fart10.mp3');
|
||||
break;
|
||||
case 'fart_random': {
|
||||
// Pick a random fart sound (1-10)
|
||||
const randomFartNum = Math.floor(Math.random() * 10) + 1;
|
||||
this._playAudioFile(`fart${randomFartNum}.mp3`);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
this._playCoinSound(ctx, now);
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
const engine = window.__taskmate_sounds;
|
||||
if (!engine) return;
|
||||
engine.play(soundName, this._customSounds());
|
||||
}
|
||||
|
||||
/**
|
||||
* Coin collect sound - classic video game coin pickup
|
||||
* Two quick ascending tones
|
||||
*/
|
||||
_playCoinSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.3;
|
||||
|
||||
// First tone (E6)
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(masterGain);
|
||||
osc1.frequency.value = 1318.5; // E6
|
||||
osc1.type = 'square';
|
||||
gain1.gain.setValueAtTime(0.5, startTime);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.1);
|
||||
osc1.start(startTime);
|
||||
osc1.stop(startTime + 0.1);
|
||||
|
||||
// Second tone (B6) - higher
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain2 = ctx.createGain();
|
||||
osc2.connect(gain2);
|
||||
gain2.connect(masterGain);
|
||||
osc2.frequency.value = 1975.5; // B6
|
||||
osc2.type = 'square';
|
||||
gain2.gain.setValueAtTime(0.5, startTime + 0.08);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.25);
|
||||
osc2.start(startTime + 0.08);
|
||||
osc2.stop(startTime + 0.25);
|
||||
}
|
||||
|
||||
/**
|
||||
* Level up sound - triumphant ascending arpeggio
|
||||
*/
|
||||
_playLevelUpSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.25;
|
||||
|
||||
// C major arpeggio going up: C5, E5, G5, C6
|
||||
const notes = [523.25, 659.25, 783.99, 1046.5];
|
||||
const duration = 0.12;
|
||||
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'square';
|
||||
|
||||
const noteStart = startTime + i * duration;
|
||||
gain.gain.setValueAtTime(0.6, noteStart);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration + 0.1);
|
||||
osc.start(noteStart);
|
||||
osc.stop(noteStart + duration + 0.15);
|
||||
});
|
||||
|
||||
// Final sustained chord
|
||||
const chordNotes = [523.25, 659.25, 783.99]; // C major chord
|
||||
const chordStart = startTime + notes.length * duration;
|
||||
chordNotes.forEach((freq) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'triangle';
|
||||
gain.gain.setValueAtTime(0.3, chordStart);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, chordStart + 0.5);
|
||||
osc.start(chordStart);
|
||||
osc.stop(chordStart + 0.55);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fanfare sound - celebratory trumpet-like fanfare
|
||||
*/
|
||||
_playFanfareSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.2;
|
||||
|
||||
// Fanfare pattern: G4, G4, G4, E4, G4, C5 (classic celebration pattern)
|
||||
const pattern = [
|
||||
{ freq: 392.00, duration: 0.1, delay: 0 }, // G4
|
||||
{ freq: 392.00, duration: 0.1, delay: 0.12 }, // G4
|
||||
{ freq: 392.00, duration: 0.15, delay: 0.24 }, // G4
|
||||
{ freq: 329.63, duration: 0.15, delay: 0.42 }, // E4
|
||||
{ freq: 392.00, duration: 0.15, delay: 0.6 }, // G4
|
||||
{ freq: 523.25, duration: 0.4, delay: 0.78 }, // C5 (long final note)
|
||||
];
|
||||
|
||||
pattern.forEach(({ freq, duration, delay }) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'sawtooth';
|
||||
|
||||
const noteStart = startTime + delay;
|
||||
gain.gain.setValueAtTime(0.5, noteStart);
|
||||
gain.gain.setValueAtTime(0.5, noteStart + duration * 0.8);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration);
|
||||
|
||||
osc.start(noteStart);
|
||||
osc.stop(noteStart + duration + 0.05);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Chime sound - simple pleasant bell chime
|
||||
*/
|
||||
_playChimeSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.3;
|
||||
|
||||
// Bell-like sound using multiple harmonics
|
||||
const fundamental = 880; // A5
|
||||
const harmonics = [1, 2, 3, 4.2]; // Slight inharmonicity for bell-like quality
|
||||
|
||||
harmonics.forEach((harmonic, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
|
||||
osc.frequency.value = fundamental * harmonic;
|
||||
osc.type = 'sine';
|
||||
|
||||
// Higher harmonics decay faster
|
||||
const amplitude = 0.5 / (i + 1);
|
||||
const decayTime = 0.8 / (i + 1);
|
||||
|
||||
gain.gain.setValueAtTime(amplitude, startTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, startTime + decayTime);
|
||||
|
||||
osc.start(startTime);
|
||||
osc.stop(startTime + decayTime + 0.1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Power up sound - ascending sweep with sparkle
|
||||
*/
|
||||
_playPowerUpSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.25;
|
||||
|
||||
// Ascending sweep
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(masterGain);
|
||||
osc1.type = 'sawtooth';
|
||||
osc1.frequency.setValueAtTime(200, startTime);
|
||||
osc1.frequency.exponentialRampToValueAtTime(1200, startTime + 0.3);
|
||||
gain1.gain.setValueAtTime(0.4, startTime);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.35);
|
||||
osc1.start(startTime);
|
||||
osc1.stop(startTime + 0.4);
|
||||
|
||||
// Sparkle notes at the end
|
||||
const sparkleNotes = [1318.5, 1567.98, 1975.5]; // E6, G6, B6
|
||||
sparkleNotes.forEach((freq, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'sine';
|
||||
|
||||
const noteStart = startTime + 0.25 + i * 0.05;
|
||||
gain.gain.setValueAtTime(0.3, noteStart);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + 0.2);
|
||||
osc.start(noteStart);
|
||||
osc.stop(noteStart + 0.25);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo sound - sad descending "womp womp" style
|
||||
* Two descending tones that sound disappointed/sad
|
||||
*/
|
||||
_playUndoSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.25;
|
||||
|
||||
// First "womp" - descending tone
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(masterGain);
|
||||
osc1.type = 'triangle';
|
||||
osc1.frequency.setValueAtTime(311.13, startTime); // Eb4
|
||||
osc1.frequency.exponentialRampToValueAtTime(233.08, startTime + 0.25); // Bb3
|
||||
gain1.gain.setValueAtTime(0.6, startTime);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.3, startTime + 0.2);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.3);
|
||||
osc1.start(startTime);
|
||||
osc1.stop(startTime + 0.35);
|
||||
|
||||
// Second "womp" - even lower descending tone (the sad part)
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain2 = ctx.createGain();
|
||||
osc2.connect(gain2);
|
||||
gain2.connect(masterGain);
|
||||
osc2.type = 'triangle';
|
||||
osc2.frequency.setValueAtTime(233.08, startTime + 0.3); // Bb3
|
||||
osc2.frequency.exponentialRampToValueAtTime(155.56, startTime + 0.7); // Eb3
|
||||
gain2.gain.setValueAtTime(0.5, startTime + 0.3);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.25, startTime + 0.55);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.75);
|
||||
osc2.start(startTime + 0.3);
|
||||
osc2.stop(startTime + 0.8);
|
||||
|
||||
// Optional: add a subtle low vibrato for extra sadness
|
||||
const osc3 = ctx.createOscillator();
|
||||
const gain3 = ctx.createGain();
|
||||
osc3.connect(gain3);
|
||||
gain3.connect(masterGain);
|
||||
osc3.type = 'sine';
|
||||
osc3.frequency.setValueAtTime(116.54, startTime + 0.5); // Bb2 (sub bass)
|
||||
gain3.gain.setValueAtTime(0.15, startTime + 0.5);
|
||||
gain3.gain.exponentialRampToValueAtTime(0.01, startTime + 0.8);
|
||||
osc3.start(startTime + 0.5);
|
||||
osc3.stop(startTime + 0.85);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play an audio file from the www folder
|
||||
* Used for fart sounds (real audio files, not synthesized)
|
||||
* CC0 public domain from BigSoundBank.com
|
||||
* @param {string} filename - The audio file name (e.g., 'fart1.mp3')
|
||||
*/
|
||||
_playAudioFile(filename) {
|
||||
try {
|
||||
// Build the URL to the audio file in the www folder
|
||||
const audio = new Audio(`/local/taskmate/${filename}`);
|
||||
audio.volume = 1.0;
|
||||
audio.play().catch(e => {
|
||||
console.warn(`[TaskMate] Failed to play ${filename}:`, e);
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`[TaskMate] Error preparing audio ${filename}:`, e);
|
||||
}
|
||||
/** Uploaded custom sounds (#856). Lives on the chores sensor; the attr
|
||||
* resolver merges it in, so the card reads it off the primary entity. */
|
||||
_customSounds() {
|
||||
const attrs = (window.__taskmate_attrs && window.__taskmate_attrs(this.hass, this.config?.entity))
|
||||
|| this.hass?.states?.[this.config?.entity]?.attributes || {};
|
||||
return attrs.custom_sounds || [];
|
||||
}
|
||||
|
||||
static get styles() {
|
||||
|
||||
@@ -1,647 +1,35 @@
|
||||
/**
|
||||
* TaskMate Config Flow Sound Preview
|
||||
* Plays sound preview when the completion_sound dropdown value changes
|
||||
* in the config flow (add/edit chore screens).
|
||||
* TaskMate sound preview bridge.
|
||||
*
|
||||
* Loaded on every HA frontend page (see GLOBAL_MODULES in frontend.py) so the
|
||||
* `taskmate.preview_sound` service can make a browser actually play something.
|
||||
*
|
||||
* This file used to carry a second, hand-maintained copy of every synthesised
|
||||
* sound plus a MutationObserver that scanned the document for anything looking
|
||||
* like a sound dropdown. Both had rotted: the copy still only knew the
|
||||
* long-removed "fart"/"fart_long" names while the real list had moved on to
|
||||
* fart1..fart10, and the scan could never reach the admin panel's sound picker
|
||||
* because that lives inside a shadow root. The panel now previews sounds
|
||||
* directly through window.__taskmate_sounds, so all that is gone; playback
|
||||
* lives in taskmate-sounds.js and this file is just the event bridge.
|
||||
*/
|
||||
|
||||
(function() {
|
||||
'use strict';
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// Debug logging gate. Set window.__taskmate_config_sounds_debug = true in the
|
||||
// browser console to enable the chatty console.debug/warn output.
|
||||
const debugLog = (...args) => {
|
||||
if (window.__taskmate_config_sounds_debug) console.debug(...args);
|
||||
};
|
||||
const debugWarn = (...args) => {
|
||||
if (window.__taskmate_config_sounds_debug) console.warn(...args);
|
||||
};
|
||||
|
||||
// Audio context for sound preview (lazy initialized)
|
||||
let audioContext = null;
|
||||
|
||||
/**
|
||||
* Get or create AudioContext
|
||||
*/
|
||||
function getAudioContext() {
|
||||
if (!audioContext) {
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
if (audioContext.state === 'suspended') {
|
||||
audioContext.resume();
|
||||
}
|
||||
return audioContext;
|
||||
function play(sound) {
|
||||
const engine = window.__taskmate_sounds;
|
||||
if (engine && sound) engine.play(sound, window.__taskmate_custom_sounds || []);
|
||||
}
|
||||
|
||||
// ============== SOUND IMPLEMENTATIONS ==============
|
||||
// These are copies from taskmate-child-card.js to keep this file standalone
|
||||
|
||||
function playCoinSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.3;
|
||||
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(masterGain);
|
||||
osc1.frequency.value = 1318.5;
|
||||
osc1.type = 'square';
|
||||
gain1.gain.setValueAtTime(0.5, startTime);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.1);
|
||||
osc1.start(startTime);
|
||||
osc1.stop(startTime + 0.1);
|
||||
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain2 = ctx.createGain();
|
||||
osc2.connect(gain2);
|
||||
gain2.connect(masterGain);
|
||||
osc2.frequency.value = 1975.5;
|
||||
osc2.type = 'square';
|
||||
gain2.gain.setValueAtTime(0.5, startTime + 0.08);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.25);
|
||||
osc2.start(startTime + 0.08);
|
||||
osc2.stop(startTime + 0.25);
|
||||
function onPreview(e) {
|
||||
const sound = (e.detail && (e.detail.sound || (e.detail.data && e.detail.data.sound))) || null;
|
||||
if (sound) play(sound);
|
||||
}
|
||||
|
||||
function playLevelUpSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.25;
|
||||
|
||||
const notes = [523.25, 659.25, 783.99, 1046.5];
|
||||
const duration = 0.12;
|
||||
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'square';
|
||||
|
||||
const noteStart = startTime + i * duration;
|
||||
gain.gain.setValueAtTime(0.6, noteStart);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration + 0.1);
|
||||
osc.start(noteStart);
|
||||
osc.stop(noteStart + duration + 0.15);
|
||||
});
|
||||
|
||||
const chordNotes = [523.25, 659.25, 783.99];
|
||||
const chordStart = startTime + notes.length * duration;
|
||||
chordNotes.forEach((freq) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'triangle';
|
||||
gain.gain.setValueAtTime(0.3, chordStart);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, chordStart + 0.5);
|
||||
osc.start(chordStart);
|
||||
osc.stop(chordStart + 0.55);
|
||||
});
|
||||
}
|
||||
|
||||
function playFanfareSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.2;
|
||||
|
||||
const pattern = [
|
||||
{ freq: 392.00, duration: 0.1, delay: 0 },
|
||||
{ freq: 392.00, duration: 0.1, delay: 0.12 },
|
||||
{ freq: 392.00, duration: 0.15, delay: 0.24 },
|
||||
{ freq: 329.63, duration: 0.15, delay: 0.42 },
|
||||
{ freq: 392.00, duration: 0.15, delay: 0.6 },
|
||||
{ freq: 523.25, duration: 0.4, delay: 0.78 },
|
||||
];
|
||||
|
||||
pattern.forEach(({ freq, duration, delay }) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'sawtooth';
|
||||
|
||||
const noteStart = startTime + delay;
|
||||
gain.gain.setValueAtTime(0.5, noteStart);
|
||||
gain.gain.setValueAtTime(0.5, noteStart + duration * 0.8);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration);
|
||||
osc.start(noteStart);
|
||||
osc.stop(noteStart + duration + 0.05);
|
||||
});
|
||||
}
|
||||
|
||||
function playChimeSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.3;
|
||||
|
||||
const fundamental = 880;
|
||||
const harmonics = [1, 2, 3, 4.2];
|
||||
|
||||
harmonics.forEach((harmonic, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.frequency.value = fundamental * harmonic;
|
||||
osc.type = 'sine';
|
||||
|
||||
const amplitude = 0.5 / (i + 1);
|
||||
const decayTime = 0.8 / (i + 1);
|
||||
|
||||
gain.gain.setValueAtTime(amplitude, startTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, startTime + decayTime);
|
||||
osc.start(startTime);
|
||||
osc.stop(startTime + decayTime + 0.1);
|
||||
});
|
||||
}
|
||||
|
||||
function playPowerUpSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.25;
|
||||
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(masterGain);
|
||||
osc1.type = 'sawtooth';
|
||||
osc1.frequency.setValueAtTime(200, startTime);
|
||||
osc1.frequency.exponentialRampToValueAtTime(1200, startTime + 0.3);
|
||||
gain1.gain.setValueAtTime(0.4, startTime);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.35);
|
||||
osc1.start(startTime);
|
||||
osc1.stop(startTime + 0.4);
|
||||
|
||||
const sparkleNotes = [1318.5, 1567.98, 1975.5];
|
||||
sparkleNotes.forEach((freq, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'sine';
|
||||
|
||||
const noteStart = startTime + 0.25 + i * 0.05;
|
||||
gain.gain.setValueAtTime(0.3, noteStart);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + 0.2);
|
||||
osc.start(noteStart);
|
||||
osc.stop(noteStart + 0.25);
|
||||
});
|
||||
}
|
||||
|
||||
function playUndoSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.25;
|
||||
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(masterGain);
|
||||
osc1.type = 'triangle';
|
||||
osc1.frequency.setValueAtTime(311.13, startTime);
|
||||
osc1.frequency.exponentialRampToValueAtTime(233.08, startTime + 0.25);
|
||||
gain1.gain.setValueAtTime(0.6, startTime);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.3, startTime + 0.2);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.3);
|
||||
osc1.start(startTime);
|
||||
osc1.stop(startTime + 0.35);
|
||||
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain2 = ctx.createGain();
|
||||
osc2.connect(gain2);
|
||||
gain2.connect(masterGain);
|
||||
osc2.type = 'triangle';
|
||||
osc2.frequency.setValueAtTime(233.08, startTime + 0.3);
|
||||
osc2.frequency.exponentialRampToValueAtTime(155.56, startTime + 0.7);
|
||||
gain2.gain.setValueAtTime(0.5, startTime + 0.3);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.25, startTime + 0.55);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.75);
|
||||
osc2.start(startTime + 0.3);
|
||||
osc2.stop(startTime + 0.8);
|
||||
|
||||
const osc3 = ctx.createOscillator();
|
||||
const gain3 = ctx.createGain();
|
||||
osc3.connect(gain3);
|
||||
gain3.connect(masterGain);
|
||||
osc3.type = 'sine';
|
||||
osc3.frequency.setValueAtTime(116.54, startTime + 0.5);
|
||||
gain3.gain.setValueAtTime(0.15, startTime + 0.5);
|
||||
gain3.gain.exponentialRampToValueAtTime(0.01, startTime + 0.8);
|
||||
osc3.start(startTime + 0.5);
|
||||
osc3.stop(startTime + 0.85);
|
||||
}
|
||||
|
||||
function playFartSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.9;
|
||||
|
||||
const noiseLength = ctx.sampleRate * 0.5;
|
||||
const noiseBuffer = ctx.createBuffer(1, noiseLength, ctx.sampleRate);
|
||||
const noiseData = noiseBuffer.getChannelData(0);
|
||||
|
||||
let lastOut = 0;
|
||||
for (let i = 0; i < noiseLength; i++) {
|
||||
const white = Math.random() * 2 - 1;
|
||||
noiseData[i] = (lastOut + (0.04 * white)) / 1.04;
|
||||
lastOut = noiseData[i];
|
||||
noiseData[i] *= 8;
|
||||
}
|
||||
|
||||
const noiseSource = ctx.createBufferSource();
|
||||
noiseSource.buffer = noiseBuffer;
|
||||
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.setValueAtTime(200, startTime);
|
||||
filter.frequency.exponentialRampToValueAtTime(80, startTime + 0.4);
|
||||
filter.Q.value = 2;
|
||||
|
||||
const noiseGain = ctx.createGain();
|
||||
noiseGain.gain.setValueAtTime(0, startTime);
|
||||
noiseGain.gain.linearRampToValueAtTime(1.0, startTime + 0.01);
|
||||
noiseGain.gain.setValueAtTime(1.0, startTime + 0.15);
|
||||
noiseGain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.45);
|
||||
|
||||
noiseSource.connect(filter);
|
||||
filter.connect(noiseGain);
|
||||
noiseGain.connect(masterGain);
|
||||
noiseSource.start(startTime);
|
||||
noiseSource.stop(startTime + 0.5);
|
||||
|
||||
const bass = ctx.createOscillator();
|
||||
const bassGain = ctx.createGain();
|
||||
bass.type = 'sawtooth';
|
||||
bass.frequency.setValueAtTime(80, startTime);
|
||||
bass.frequency.exponentialRampToValueAtTime(40, startTime + 0.4);
|
||||
bassGain.gain.setValueAtTime(0, startTime);
|
||||
bassGain.gain.linearRampToValueAtTime(0.8, startTime + 0.02);
|
||||
bassGain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.45);
|
||||
bass.connect(bassGain);
|
||||
bassGain.connect(masterGain);
|
||||
bass.start(startTime);
|
||||
bass.stop(startTime + 0.5);
|
||||
|
||||
const flap = ctx.createOscillator();
|
||||
const flapGain = ctx.createGain();
|
||||
flap.type = 'square';
|
||||
flap.frequency.setValueAtTime(25, startTime);
|
||||
flap.frequency.exponentialRampToValueAtTime(12, startTime + 0.4);
|
||||
flapGain.gain.setValueAtTime(0.6, startTime);
|
||||
flapGain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.4);
|
||||
flap.connect(flapGain);
|
||||
flapGain.connect(masterGain);
|
||||
flap.start(startTime);
|
||||
flap.stop(startTime + 0.45);
|
||||
|
||||
const sub = ctx.createOscillator();
|
||||
const subGain = ctx.createGain();
|
||||
sub.type = 'sine';
|
||||
sub.frequency.setValueAtTime(50, startTime);
|
||||
sub.frequency.exponentialRampToValueAtTime(25, startTime + 0.3);
|
||||
subGain.gain.setValueAtTime(0.7, startTime);
|
||||
subGain.gain.exponentialRampToValueAtTime(0.01, startTime + 0.35);
|
||||
sub.connect(subGain);
|
||||
subGain.connect(masterGain);
|
||||
sub.start(startTime);
|
||||
sub.stop(startTime + 0.4);
|
||||
}
|
||||
|
||||
function playFartLongSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.85;
|
||||
|
||||
const noiseLength = ctx.sampleRate * 2.0;
|
||||
const noiseBuffer = ctx.createBuffer(1, noiseLength, ctx.sampleRate);
|
||||
const noiseData = noiseBuffer.getChannelData(0);
|
||||
|
||||
let lastOut = 0;
|
||||
for (let i = 0; i < noiseLength; i++) {
|
||||
const white = Math.random() * 2 - 1;
|
||||
noiseData[i] = (lastOut + (0.05 * white)) / 1.05;
|
||||
lastOut = noiseData[i];
|
||||
noiseData[i] *= 7;
|
||||
}
|
||||
|
||||
const noiseSource = ctx.createBufferSource();
|
||||
noiseSource.buffer = noiseBuffer;
|
||||
|
||||
const filter = ctx.createBiquadFilter();
|
||||
filter.type = 'bandpass';
|
||||
filter.frequency.setValueAtTime(250, startTime);
|
||||
filter.frequency.linearRampToValueAtTime(180, startTime + 0.3);
|
||||
filter.frequency.linearRampToValueAtTime(280, startTime + 0.5);
|
||||
filter.frequency.linearRampToValueAtTime(150, startTime + 0.9);
|
||||
filter.frequency.linearRampToValueAtTime(220, startTime + 1.2);
|
||||
filter.frequency.exponentialRampToValueAtTime(60, startTime + 1.8);
|
||||
filter.Q.value = 3;
|
||||
|
||||
const noiseGain = ctx.createGain();
|
||||
noiseGain.gain.setValueAtTime(0, startTime);
|
||||
noiseGain.gain.linearRampToValueAtTime(1.0, startTime + 0.02);
|
||||
noiseGain.gain.linearRampToValueAtTime(0.7, startTime + 0.4);
|
||||
noiseGain.gain.linearRampToValueAtTime(1.0, startTime + 0.6);
|
||||
noiseGain.gain.linearRampToValueAtTime(0.5, startTime + 1.0);
|
||||
noiseGain.gain.linearRampToValueAtTime(0.9, startTime + 1.3);
|
||||
noiseGain.gain.exponentialRampToValueAtTime(0.01, startTime + 1.9);
|
||||
|
||||
noiseSource.connect(filter);
|
||||
filter.connect(noiseGain);
|
||||
noiseGain.connect(masterGain);
|
||||
noiseSource.start(startTime);
|
||||
noiseSource.stop(startTime + 2.0);
|
||||
|
||||
const bass = ctx.createOscillator();
|
||||
const bassGain = ctx.createGain();
|
||||
bass.type = 'sawtooth';
|
||||
bass.frequency.setValueAtTime(90, startTime);
|
||||
bass.frequency.linearRampToValueAtTime(70, startTime + 0.5);
|
||||
bass.frequency.linearRampToValueAtTime(100, startTime + 0.8);
|
||||
bass.frequency.linearRampToValueAtTime(50, startTime + 1.3);
|
||||
bass.frequency.exponentialRampToValueAtTime(30, startTime + 1.8);
|
||||
bassGain.gain.setValueAtTime(0.8, startTime);
|
||||
bassGain.gain.exponentialRampToValueAtTime(0.01, startTime + 1.85);
|
||||
bass.connect(bassGain);
|
||||
bassGain.connect(masterGain);
|
||||
bass.start(startTime);
|
||||
bass.stop(startTime + 1.9);
|
||||
|
||||
const flutter = ctx.createOscillator();
|
||||
const flutterGain = ctx.createGain();
|
||||
flutter.type = 'square';
|
||||
flutter.frequency.setValueAtTime(35, startTime);
|
||||
flutter.frequency.linearRampToValueAtTime(20, startTime + 0.8);
|
||||
flutter.frequency.linearRampToValueAtTime(28, startTime + 1.0);
|
||||
flutter.frequency.exponentialRampToValueAtTime(6, startTime + 1.8);
|
||||
flutterGain.gain.setValueAtTime(0.7, startTime);
|
||||
flutterGain.gain.exponentialRampToValueAtTime(0.01, startTime + 1.8);
|
||||
flutter.connect(flutterGain);
|
||||
flutterGain.connect(masterGain);
|
||||
flutter.start(startTime);
|
||||
flutter.stop(startTime + 1.85);
|
||||
|
||||
const sub = ctx.createOscillator();
|
||||
const subGain = ctx.createGain();
|
||||
sub.type = 'sine';
|
||||
sub.frequency.setValueAtTime(45, startTime);
|
||||
sub.frequency.exponentialRampToValueAtTime(20, startTime + 1.7);
|
||||
subGain.gain.setValueAtTime(0.6, startTime);
|
||||
subGain.gain.exponentialRampToValueAtTime(0.01, startTime + 1.75);
|
||||
sub.connect(subGain);
|
||||
subGain.connect(masterGain);
|
||||
sub.start(startTime);
|
||||
sub.stop(startTime + 1.8);
|
||||
|
||||
const squeakTimes = [0.5, 1.2];
|
||||
squeakTimes.forEach((delay) => {
|
||||
const squeak = ctx.createOscillator();
|
||||
const squeakGain = ctx.createGain();
|
||||
squeak.type = 'triangle';
|
||||
squeak.frequency.setValueAtTime(180, startTime + delay);
|
||||
squeak.frequency.exponentialRampToValueAtTime(120, startTime + delay + 0.15);
|
||||
squeakGain.gain.setValueAtTime(0.4, startTime + delay);
|
||||
squeakGain.gain.exponentialRampToValueAtTime(0.01, startTime + delay + 0.15);
|
||||
squeak.connect(squeakGain);
|
||||
squeakGain.connect(masterGain);
|
||||
squeak.start(startTime + delay);
|
||||
squeak.stop(startTime + delay + 0.2);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a sound by name
|
||||
*/
|
||||
function playSound(soundName) {
|
||||
if (!soundName || soundName === 'none') {
|
||||
debugLog('[TaskMate Config] No sound to play');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
const now = ctx.currentTime;
|
||||
|
||||
switch (soundName) {
|
||||
case 'coin': playCoinSound(ctx, now); break;
|
||||
case 'levelup': playLevelUpSound(ctx, now); break;
|
||||
case 'fanfare': playFanfareSound(ctx, now); break;
|
||||
case 'chime': playChimeSound(ctx, now); break;
|
||||
case 'powerup': playPowerUpSound(ctx, now); break;
|
||||
case 'undo': playUndoSound(ctx, now); break;
|
||||
case 'fart': playFartSound(ctx, now); break;
|
||||
case 'fart_long': playFartLongSound(ctx, now); break;
|
||||
default:
|
||||
debugWarn(`[TaskMate Config] Unknown sound: ${soundName}`);
|
||||
playCoinSound(ctx, now);
|
||||
}
|
||||
} catch (e) {
|
||||
debugWarn('[TaskMate Config] Error playing sound:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Valid sound names
|
||||
const VALID_SOUNDS = ['coin', 'levelup', 'fanfare', 'chime', 'powerup', 'undo', 'fart', 'fart_long', 'none'];
|
||||
|
||||
/**
|
||||
* Map display text to sound value
|
||||
*/
|
||||
function textToSoundValue(text) {
|
||||
if (!text) return null;
|
||||
const normalized = text.toLowerCase().trim().replace(/\s+/g, '_');
|
||||
const map = {
|
||||
'no_sound': 'none',
|
||||
'no sound': 'none',
|
||||
'coin': 'coin',
|
||||
'levelup': 'levelup',
|
||||
'level_up': 'levelup',
|
||||
'fanfare': 'fanfare',
|
||||
'chime': 'chime',
|
||||
'powerup': 'powerup',
|
||||
'power_up': 'powerup',
|
||||
'undo': 'undo',
|
||||
'fart': 'fart',
|
||||
'fart_long': 'fart_long',
|
||||
};
|
||||
return map[normalized] || (VALID_SOUNDS.includes(normalized) ? normalized : null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach change listener to a select element
|
||||
*/
|
||||
function attachSoundChangeListener(element) {
|
||||
if (element.dataset.taskmateSoundListener) return;
|
||||
element.dataset.taskmateSoundListener = 'true';
|
||||
|
||||
// Listen for various change events
|
||||
const handleChange = (e) => {
|
||||
const value = e.target?.value || e.detail?.value;
|
||||
const soundValue = textToSoundValue(value) || value;
|
||||
|
||||
debugLog('[TaskMate Config] Sound changed to:', soundValue);
|
||||
|
||||
if (soundValue && soundValue !== 'none' && VALID_SOUNDS.includes(soundValue)) {
|
||||
playSound(soundValue);
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener('change', handleChange);
|
||||
element.addEventListener('value-changed', handleChange);
|
||||
element.addEventListener('selected', handleChange);
|
||||
element.addEventListener('click', (e) => {
|
||||
// For dropdown items being clicked
|
||||
const item = e.target.closest('mwc-list-item, ha-list-item, [role="option"]');
|
||||
if (item) {
|
||||
const value = item.value || item.getAttribute('value') || item.textContent;
|
||||
const soundValue = textToSoundValue(value);
|
||||
if (soundValue && soundValue !== 'none') {
|
||||
setTimeout(() => playSound(soundValue), 50);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
debugLog('[TaskMate Config] Attached sound change listener to element');
|
||||
}
|
||||
|
||||
/**
|
||||
* Find sound selectors and attach listeners
|
||||
*/
|
||||
function findAndEnhanceSoundSelectors() {
|
||||
// Look for any select/dropdown that might be for sounds
|
||||
const selectors = document.querySelectorAll('ha-select, mwc-select, ha-combo-box, select');
|
||||
|
||||
selectors.forEach(selector => {
|
||||
// Check if this selector has sound-related options
|
||||
const options = selector.querySelectorAll('mwc-list-item, ha-list-item, option, [role="option"]');
|
||||
let isSoundSelector = false;
|
||||
|
||||
options.forEach(opt => {
|
||||
const text = (opt.value || opt.getAttribute('value') || opt.textContent || '').toLowerCase();
|
||||
if (VALID_SOUNDS.some(s => text.includes(s))) {
|
||||
isSoundSelector = true;
|
||||
}
|
||||
});
|
||||
|
||||
// Also check the selector's current value
|
||||
const currentValue = (selector.value || '').toLowerCase();
|
||||
if (VALID_SOUNDS.some(s => currentValue.includes(s))) {
|
||||
isSoundSelector = true;
|
||||
}
|
||||
|
||||
if (isSoundSelector) {
|
||||
attachSoundChangeListener(selector);
|
||||
|
||||
// Also attach to inner elements if present
|
||||
const innerSelect = selector.shadowRoot?.querySelector('select, mwc-menu, mwc-list');
|
||||
if (innerSelect) {
|
||||
attachSoundChangeListener(innerSelect);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Also look for ha-selector elements (HA's custom form selectors)
|
||||
document.querySelectorAll('ha-selector').forEach(haSelector => {
|
||||
const innerSelect = haSelector.shadowRoot?.querySelector('ha-select, mwc-select, select') ||
|
||||
haSelector.querySelector('ha-select, mwc-select, select');
|
||||
if (innerSelect) {
|
||||
const options = innerSelect.querySelectorAll('mwc-list-item, [role="option"]');
|
||||
let isSoundSelector = false;
|
||||
options.forEach(opt => {
|
||||
const text = (opt.value || opt.textContent || '').toLowerCase();
|
||||
if (VALID_SOUNDS.some(s => text.includes(s))) {
|
||||
isSoundSelector = true;
|
||||
}
|
||||
});
|
||||
if (isSoundSelector) {
|
||||
attachSoundChangeListener(innerSelect);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for DOM changes and enhance new sound selectors
|
||||
*/
|
||||
function startObserver() {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
let shouldScan = false;
|
||||
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.addedNodes.length > 0) {
|
||||
shouldScan = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (shouldScan) {
|
||||
// Debounce scanning
|
||||
clearTimeout(window._taskmateScanTimeout);
|
||||
window._taskmateScanTimeout = setTimeout(findAndEnhanceSoundSelectors, 100);
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
window._taskmateSoundObserver = observer;
|
||||
|
||||
debugLog('[TaskMate Config] Sound change observer started');
|
||||
}
|
||||
|
||||
// Initialize
|
||||
function init() {
|
||||
if (window._taskmateConfigSoundsInit) return;
|
||||
if (!window._taskmateConfigSoundsInit) {
|
||||
window._taskmateConfigSoundsInit = true;
|
||||
findAndEnhanceSoundSelectors();
|
||||
startObserver();
|
||||
|
||||
// Slow periodic scan as a safety net for dynamically loaded content the
|
||||
// MutationObserver doesn't catch (e.g. dialogs rendered outside body's light DOM)
|
||||
window._taskmateConfigSoundsPoll = setInterval(findAndEnhanceSoundSelectors, 10000);
|
||||
|
||||
const cleanup = () => {
|
||||
if (window._taskmateConfigSoundsPoll) {
|
||||
clearInterval(window._taskmateConfigSoundsPoll);
|
||||
window._taskmateConfigSoundsPoll = null;
|
||||
}
|
||||
if (window._taskmateScanTimeout) {
|
||||
clearTimeout(window._taskmateScanTimeout);
|
||||
window._taskmateScanTimeout = null;
|
||||
}
|
||||
if (window._taskmateSoundObserver) {
|
||||
window._taskmateSoundObserver.disconnect();
|
||||
window._taskmateSoundObserver = null;
|
||||
}
|
||||
};
|
||||
// pagehide covers SPA navigation in HA; beforeunload covers full reloads.
|
||||
window.addEventListener('pagehide', cleanup, { once: true });
|
||||
window.addEventListener('beforeunload', cleanup, { once: true });
|
||||
document.addEventListener("taskmate_preview_sound", onPreview);
|
||||
window.addEventListener("taskmate_preview_sound", onPreview);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => setTimeout(init, 500));
|
||||
|
||||
// Listen for taskmate_preview_sound events fired by the preview_sound service
|
||||
document.addEventListener('taskmate_preview_sound', (e) => {
|
||||
const sound = e.detail?.sound || e.detail?.data?.sound;
|
||||
if (sound) playSound(sound);
|
||||
});
|
||||
|
||||
// Also listen via window for HA event bus forwarding
|
||||
window.addEventListener('taskmate_preview_sound', (e) => {
|
||||
const sound = e.detail?.sound || e.detail?.data?.sound;
|
||||
if (sound) playSound(sound);
|
||||
});
|
||||
|
||||
console.info('[TaskMate] Config sound preview module loaded - sounds will play on selection change');
|
||||
})();
|
||||
|
||||
@@ -145,7 +145,32 @@ function _refreshTaskMateCards() {
|
||||
}
|
||||
}
|
||||
|
||||
const _DAY_KEYS = [
|
||||
'weekly.day_mon', 'weekly.day_tue', 'weekly.day_wed', 'weekly.day_thu',
|
||||
'weekly.day_fri', 'weekly.day_sat', 'weekly.day_sun',
|
||||
];
|
||||
|
||||
/**
|
||||
* Human summary of a reward's time lock (#857), e.g. "Fri, Sat · 18:00–21:00".
|
||||
* Returns "" when nothing is restricted, so callers can skip the badge.
|
||||
*
|
||||
* @param {object} lock - { days: number[], from: string, until: string }
|
||||
* @param {function} t - the caller's translate function (key, params) => string
|
||||
*/
|
||||
function timeLockLabel(lock, t) {
|
||||
if (!lock) return '';
|
||||
const days = (lock.days || []).map(Number).filter(n => Number.isInteger(n) && n >= 0 && n <= 6).sort((a, b) => a - b);
|
||||
const parts = [];
|
||||
// All seven days selected is the same as no day restriction — don't spell it out.
|
||||
if (days.length && days.length < 7) parts.push(days.map(d => t(_DAY_KEYS[d])).join(', '));
|
||||
const from = lock.from || '';
|
||||
const until = lock.until || '';
|
||||
if (from && until && from !== until) parts.push(`${from}\u2013${until}`);
|
||||
return parts.join(' \u00b7 ');
|
||||
}
|
||||
|
||||
// Expose globally so cards can access without ES module imports
|
||||
window.__taskmate_time_lock_label = timeLockLabel;
|
||||
window.__taskmate_localize = localize;
|
||||
window.__taskmate_loadTranslations = loadTranslations;
|
||||
|
||||
|
||||
@@ -122,6 +122,8 @@ const WEATHER_CONDITIONS = [
|
||||
{ v: "exceptional", icon: "mdi:alert-outline" },
|
||||
];
|
||||
|
||||
// Built-in sounds, mirroring COMPLETION_SOUND_OPTIONS in const.py. User-uploaded
|
||||
// sounds are appended per render by _soundOptions() (#856).
|
||||
const COMPLETION_SOUNDS = [
|
||||
"none", "coin", "levelup", "fanfare", "chime", "powerup", "undo",
|
||||
"fart1", "fart2", "fart3", "fart4", "fart5", "fart6", "fart7",
|
||||
@@ -506,6 +508,15 @@ class TaskMatePanel extends HTMLElement {
|
||||
if (act === "save-gift") { this._doGiftPoints(); return; }
|
||||
if (act === "adjust-points") { this._doAdjustPoints(t.dataset.id, Number(t.dataset.delta)); return; }
|
||||
if (act === "adjust-points-custom") { this._openAdjustDialog(t.dataset.id); return; }
|
||||
if (act === "sound-preview-field") {
|
||||
const sel = t.closest(".tm-sound-row")?.querySelector('select[data-field="completion_sound"]');
|
||||
if (sel) this._previewSound(sel.value);
|
||||
return;
|
||||
}
|
||||
if (act === "sound-preview") { this._previewSound(t.dataset.value); return; }
|
||||
if (act === "sound-upload") { this._pickCustomSound(); return; }
|
||||
if (act === "sound-rename") { this._renameCustomSound(t.dataset.file, t.dataset.name); return; }
|
||||
if (act === "sound-delete") { this._deleteCustomSound(t.dataset.file, t.dataset.name); return; }
|
||||
if (act === "chore-image-pick") { this._pickChoreImage(); return; }
|
||||
if (act === "chore-image-remove") {
|
||||
// Clears the dialog value only — the file is deleted on save by the
|
||||
@@ -605,6 +616,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
if (act === "delete-reward") { this._confirmDelete("reward", t.dataset.id); return; }
|
||||
if (act === "save-reward") { this._doSaveReward(); return; }
|
||||
if (act === "toggle-reward-assigned") { this._toggleArrayField("assigned_to", t.dataset.id); return; }
|
||||
if (act === "toggle-reward-day") { this._toggleArrayField("available_days", Number(t.dataset.day)); return; }
|
||||
|
||||
// Quests (chore chains)
|
||||
if (act === "add-quest") { this._openQuestDialog(null); return; }
|
||||
@@ -1449,6 +1461,100 @@ class TaskMatePanel extends HTMLElement {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Custom completion sounds (#856) ------------------------------------
|
||||
|
||||
_pickCustomSound() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.accept = "audio/mpeg,audio/ogg,audio/wav,audio/mp4,.mp3,.ogg,.wav,.m4a";
|
||||
input.addEventListener("change", () => {
|
||||
const file = input.files && input.files[0];
|
||||
if (file) this._uploadCustomSound(file);
|
||||
});
|
||||
input.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload the file, then register it under a display name.
|
||||
*
|
||||
* Two steps because the bytes go over HTTP (multipart) while the registry
|
||||
* entry is a WebSocket command — same split as chore images. The default
|
||||
* name is the filename without its extension, which is nearly always what
|
||||
* the user wants.
|
||||
*/
|
||||
async _uploadCustomSound(file) {
|
||||
if (this._soundBusy) return;
|
||||
this._soundBusy = true;
|
||||
this._render();
|
||||
try {
|
||||
const post = () => {
|
||||
const token = this._hass && this._hass.auth && this._hass.auth.data
|
||||
&& this._hass.auth.data.access_token;
|
||||
const fd = new FormData();
|
||||
fd.append("file", file, file.name || "sound");
|
||||
return fetch("/api/taskmate/sound", {
|
||||
method: "POST",
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: fd,
|
||||
});
|
||||
};
|
||||
// The cached access token can go stale mid-session, so refresh and retry
|
||||
// once on a 401 before giving up. Mirrors the chore-image upload.
|
||||
if (this._hass && this._hass.auth && this._hass.auth.expired
|
||||
&& this._hass.auth.refreshAccessToken) {
|
||||
try { await this._hass.auth.refreshAccessToken(); } catch (e) { /* surfaced below */ }
|
||||
}
|
||||
let resp = await post();
|
||||
if (resp.status === 401 && this._hass && this._hass.auth
|
||||
&& this._hass.auth.refreshAccessToken) {
|
||||
await this._hass.auth.refreshAccessToken();
|
||||
resp = await post();
|
||||
}
|
||||
if (resp.status === 413) { this._showToast("err", this._t("panel.sound_too_large")); return; }
|
||||
if (resp.status === 400) { this._showToast("err", this._t("panel.sound_bad_type")); return; }
|
||||
if (resp.status === 507) { this._showToast("err", this._t("panel.sound_storage_full")); return; }
|
||||
if (!resp.ok) { this._showToast("err", this._t("panel.sound_upload_failed")); return; }
|
||||
|
||||
const body = await resp.json();
|
||||
const name = (file.name || "").replace(/\.[^.]+$/, "").slice(0, 40) || this._t("panel.sound_default_name");
|
||||
const { ok, err } = await this._callWS({
|
||||
type: "taskmate/add_custom_sound", file: body.file, name,
|
||||
});
|
||||
if (!ok) { this._showToast("err", this._t("panel.sound_upload_failed", { error: err })); return; }
|
||||
this._showToast("ok", this._t("panel.sound_uploaded"));
|
||||
await this._fetchState();
|
||||
} catch (err) {
|
||||
this._showToast("err", this._t("panel.sound_upload_failed"));
|
||||
} finally {
|
||||
this._soundBusy = false;
|
||||
this._render();
|
||||
}
|
||||
}
|
||||
|
||||
async _renameCustomSound(file, current) {
|
||||
const name = prompt(this._t("panel.sound_rename_prompt"), current || "");
|
||||
if (name === null) return;
|
||||
if (!name.trim()) { this._showToast("err", this._t("panel.sound_name_required")); return; }
|
||||
const { ok, err } = await this._callWS({
|
||||
type: "taskmate/rename_custom_sound", file, name: name.trim().slice(0, 40),
|
||||
});
|
||||
if (!ok) { this._showToast("err", err); return; }
|
||||
await this._fetchState();
|
||||
}
|
||||
|
||||
async _deleteCustomSound(file, name) {
|
||||
if (!confirm(this._t("panel.sound_delete_confirm", { name: name || file }))) return;
|
||||
const { ok, err, res } = await this._callWS({ type: "taskmate/remove_custom_sound", file });
|
||||
if (!ok) { this._showToast("err", err); return; }
|
||||
// Chores that used it were reset to the default sound server-side; say so
|
||||
// rather than letting the change be discovered later.
|
||||
const reset = (res && res.chores_reset) || 0;
|
||||
this._showToast("ok", reset
|
||||
? this._t("panel.sound_deleted_reset", { count: reset })
|
||||
: this._t("panel.sound_deleted"));
|
||||
await this._fetchState();
|
||||
}
|
||||
|
||||
_pickChoreImage() {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
@@ -1619,7 +1725,8 @@ class TaskMatePanel extends HTMLElement {
|
||||
assigned_to: [], is_jackpot: false, pool_enabled: false,
|
||||
quantity_str: "", expires_at: "",
|
||||
restock_enabled: false, restock_amount: 0, restock_period: "weekly",
|
||||
unlock_entity: "", unlock_minutes: 30 };
|
||||
unlock_entity: "", unlock_minutes: 30,
|
||||
time_lock_enabled: false, available_days: [], available_from: "", available_until: "" };
|
||||
if (id) {
|
||||
const r = (this._state.rewards || []).find(x => x.id === id);
|
||||
if (!r) return;
|
||||
@@ -1628,6 +1735,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
assigned_to: [...(r.assigned_to || [])],
|
||||
quantity_str: r.quantity == null ? "" : String(r.quantity),
|
||||
expires_at: r.expires_at || "",
|
||||
available_days: (r.available_days || []).map(Number),
|
||||
} });
|
||||
} else {
|
||||
this._openDialog({ kind: "reward", mode: "add", data: blank });
|
||||
@@ -1655,6 +1763,10 @@ class TaskMatePanel extends HTMLElement {
|
||||
unlock_minutes: Math.max(0, Number(d.unlock_minutes) || 0),
|
||||
restock_amount: Math.max(0, Number(d.restock_amount) || 0),
|
||||
restock_period: d.restock_period || "weekly",
|
||||
time_lock_enabled: !!d.time_lock_enabled,
|
||||
available_days: (d.available_days || []).map(Number).filter(n => Number.isInteger(n) && n >= 0 && n <= 6),
|
||||
available_from: d.available_from || "",
|
||||
available_until: d.available_until || "",
|
||||
};
|
||||
const payload = wasAdd ? { type: "taskmate/add_reward", ...base } : { type: "taskmate/update_reward", reward_id: d.id, ...base };
|
||||
const { ok, err } = await this._callWS(payload);
|
||||
@@ -2368,7 +2480,9 @@ class TaskMatePanel extends HTMLElement {
|
||||
_timeAgo(iso) {
|
||||
if (!iso) return "—";
|
||||
const then = new Date(iso);
|
||||
if (isNaN(then)) return iso;
|
||||
// Echoing an unparseable value straight back would put stored text into
|
||||
// markup, so escape it — the panel builds its HTML as strings.
|
||||
if (isNaN(then)) return this._esc(iso);
|
||||
const diffSec = Math.floor((Date.now() - then.getTime()) / 1000);
|
||||
if (diffSec < 60) return this._t("panel.time_ago_just_now");
|
||||
if (diffSec < 3600) return this._t("panel.time_ago_minutes", {count: Math.floor(diffSec / 60)});
|
||||
@@ -2832,7 +2946,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
<td><strong>${this._t("panel.day_" + d.weekday.slice(0, 3))}</strong> <span class="tm-proj-date">${d.date.slice(5)}</span></td>
|
||||
${kids.map(k => {
|
||||
const cell = d.children.find(c => c.id === k.id) || { points: 0, chores: 0 };
|
||||
return `<td>${cell.chores ? `${cell.points} <span class="tm-proj-count">(${cell.chores})</span>` : "—"}</td>`;
|
||||
return `<td>${cell.chores ? `${this._num(cell.points)} <span class="tm-proj-count">(${this._num(cell.chores)})</span>` : "—"}</td>`;
|
||||
}).join("")}
|
||||
</tr>
|
||||
`).join("")}
|
||||
@@ -2976,7 +3090,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
</div>
|
||||
<div class="tm-fair-figs">
|
||||
<strong>${c.completions}</strong> ${this._t(c.completions === 1 ? "panel.insights_chore" : "panel.insights_chores")}
|
||||
· ${c.points} ${this._esc(this._state.settings?.points_name || this._t("common.points"))}
|
||||
· ${this._num(c.points)} ${this._esc(this._state.settings?.points_name || this._t("common.points"))}
|
||||
· ${c.share_completions}%
|
||||
</div>
|
||||
<div class="tm-fair-status tm-fair-${c.status}">${this._t("panel.insights_status_" + c.status)}</div>
|
||||
@@ -3191,7 +3305,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
<div class="tm-approval-icon"><ha-icon icon="mdi:gift-outline"></ha-icon></div>
|
||||
<div class="tm-approval-body">
|
||||
<div class="tm-approval-line">${this._t("panel.activity_claimed_text", {child: this._esc((child && child.name) || "?"), reward: this._esc((reward && reward.name) || this._t("panel.activity_deleted_reward"))})}</div>
|
||||
<div class="tm-meta">${this._timeAgo(c.claimed_at)}${reward ? ` · ${reward.cost} ${this._t("panel.activity_points")}` : ""}</div>
|
||||
<div class="tm-meta">${this._timeAgo(c.claimed_at)}${reward ? ` · ${this._num(reward.cost)} ${this._t("panel.activity_points")}` : ""}</div>
|
||||
</div>
|
||||
<div class="tm-approval-actions">
|
||||
<button type="button" class="tm-btn tm-btn-sm" data-act="reject-reward" data-id="${this._esc(c.id)}">${this._t("panel.activity_reject")}</button>
|
||||
@@ -3234,7 +3348,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
<div class="tm-timeline-body">
|
||||
<div><strong>${this._esc(ev.child)}</strong> · ${this._esc(ev.label)}</div>
|
||||
</div>
|
||||
<div class="tm-timeline-points ${ev.points >= 0 ? 'tm-pos' : 'tm-neg'} tm-numeric">${ev.points >= 0 ? '+' : ''}${ev.points || 0}</div>
|
||||
<div class="tm-timeline-points ${ev.points >= 0 ? 'tm-pos' : 'tm-neg'} tm-numeric">${ev.points >= 0 ? '+' : ''}${this._num(ev.points)}</div>
|
||||
</div>
|
||||
`).join("")}
|
||||
</div>
|
||||
@@ -3258,7 +3372,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
<tr class="tm-row">
|
||||
<td class="tm-meta">${this._esc(this._timeAgo(t.created_at))}</td>
|
||||
<td>${this._esc((child && child.name) || "?")}</td>
|
||||
<td><strong class="tm-numeric ${t.points >= 0 ? 'tm-pos' : 'tm-neg'}">${t.points >= 0 ? '+' : ''}${t.points}</strong></td>
|
||||
<td><strong class="tm-numeric ${t.points >= 0 ? 'tm-pos' : 'tm-neg'}">${t.points >= 0 ? '+' : ''}${this._num(t.points)}</strong></td>
|
||||
<td>${this._esc(this._translateReason(t.reason) || "—")}</td>
|
||||
<td>${undoable ? `<button type="button" class="tm-icon-btn" data-act="undo-tx" data-id="${this._esc(t.id)}" title="${this._esc(this._t("panel.activity_undo"))}"><ha-icon icon="mdi:undo-variant"></ha-icon></button>` : ""}</td>
|
||||
</tr>
|
||||
@@ -3365,7 +3479,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
<button type="button" class="tm-icon-btn" data-act="toggle-row-menu" data-id="${this._esc(c.id)}" title="${this._t("panel.row_menu_more")}" aria-haspopup="menu" aria-label="${this._t("panel.row_menu_more")}">⋮</button>
|
||||
</span>`}
|
||||
</div></td>
|
||||
<td><strong class="tm-numeric">${c.task_type === "timed" ? `${c.timed_rate_points || 0}/${c.timed_rate_minutes || 1} min` : c.points}</strong></td>
|
||||
<td><strong class="tm-numeric">${c.task_type === "timed" ? `${this._num(c.timed_rate_points)}/${this._num(c.timed_rate_minutes, 1)} min` : this._num(c.points)}</strong></td>
|
||||
<td><span class="tm-pill">${this._esc(this._timeCategoryLabel(c.time_category))}</span></td>
|
||||
<td>${assignedNames} ${modeBadge}</td>
|
||||
<td>${currentName}</td>
|
||||
@@ -3396,6 +3510,18 @@ class TaskMatePanel extends HTMLElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** Reward-list pill summarising a time lock (#857), or "" when unrestricted. */
|
||||
_rewardTimeLockPill(r) {
|
||||
if (!r.time_lock_enabled) return "";
|
||||
const summary = window.__taskmate_time_lock_label
|
||||
? window.__taskmate_time_lock_label(
|
||||
{ days: r.available_days, from: r.available_from, until: r.available_until },
|
||||
(k, pr) => this._t(k, pr))
|
||||
: "";
|
||||
const label = summary || this._t("panel.reward_timelock_badge");
|
||||
return `<span class="tm-pill tm-pill-timelock">${this._mdi("mdi:clock-outline")} ${this._esc(label)}</span>`;
|
||||
}
|
||||
|
||||
_renderRewardCard(r, pointsName) {
|
||||
const totalPooled = (this._state.pool_allocations || []).filter(a => a.reward_id === r.id).reduce((s, a) => s + (a.allocated_points || 0), 0);
|
||||
const showProgress = (r.pool_enabled || r.is_jackpot) && r.cost > 0;
|
||||
@@ -3405,14 +3531,14 @@ class TaskMatePanel extends HTMLElement {
|
||||
<div class="tm-child-head">
|
||||
<div class="tm-avatar tm-avatar-reward">${this._mdi(r.icon || "mdi:gift")}</div>
|
||||
<div class="tm-child-name">
|
||||
<h3>${this._esc(r.name)} ${r.is_jackpot ? `<span class="tm-pill tm-pill-jackpot">🏆 ${this._t("panel.reward_badge_jackpot")}</span>` : ""} ${r.pool_enabled ? `<span class="tm-pill tm-pill-pool">${this._t("panel.reward_badge_pool")}</span>` : ""}</h3>
|
||||
<h3>${this._esc(r.name)} ${r.is_jackpot ? `<span class="tm-pill tm-pill-jackpot">🏆 ${this._t("panel.reward_badge_jackpot")}</span>` : ""} ${r.pool_enabled ? `<span class="tm-pill tm-pill-pool">${this._t("panel.reward_badge_pool")}</span>` : ""} ${this._rewardTimeLockPill(r)}</h3>
|
||||
${this._idBadge(r.id)}
|
||||
<div class="tm-meta">${this._esc(r.description || "")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tm-stats-row" style="grid-template-columns: 1fr 1fr ${r.expires_at ? "1fr" : ""};">
|
||||
<div class="tm-stat"><div class="tm-stat-value tm-numeric tm-cost">${this._fmtNum(r.cost)}</div><div class="tm-stat-label">${this._t("panel.reward_stat_cost", {points_name: pointsName})}</div></div>
|
||||
${r.quantity != null ? `<div class="tm-stat"><div class="tm-stat-value tm-numeric">${r.quantity}</div><div class="tm-stat-label">${this._t("panel.reward_stat_remaining")}</div></div>` : `<div class="tm-stat"><div class="tm-stat-value">∞</div><div class="tm-stat-label">${this._t("panel.reward_stat_unlimited")}</div></div>`}
|
||||
${r.quantity != null ? `<div class="tm-stat"><div class="tm-stat-value tm-numeric">${this._num(r.quantity)}</div><div class="tm-stat-label">${this._t("panel.reward_stat_remaining")}</div></div>` : `<div class="tm-stat"><div class="tm-stat-value">∞</div><div class="tm-stat-label">${this._t("panel.reward_stat_unlimited")}</div></div>`}
|
||||
${r.expires_at ? `<div class="tm-stat"><div class="tm-stat-value" style="font-size: 14px;">${this._esc(r.expires_at)}</div><div class="tm-stat-label">${this._t("panel.reward_stat_expires")}</div></div>` : ""}
|
||||
</div>
|
||||
${showProgress ? `
|
||||
@@ -3533,7 +3659,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
</div>
|
||||
</div>
|
||||
<div class="tm-stats-row" style="grid-template-columns: 1fr 1fr;">
|
||||
<div class="tm-stat"><div class="tm-stat-value tm-numeric">${c.target}</div><div class="tm-stat-label">${this._esc(metricLabel)}</div></div>
|
||||
<div class="tm-stat"><div class="tm-stat-value tm-numeric">${this._num(c.target)}</div><div class="tm-stat-label">${this._esc(metricLabel)}</div></div>
|
||||
<div class="tm-stat"><div class="tm-stat-value tm-numeric">${this._fmtNum(c.bonus_points || 0)}</div><div class="tm-stat-label">${this._t("panel.quest_stat_bonus", {points_name: pointsName})}</div></div>
|
||||
</div>
|
||||
<div class="tm-meta">${this._t("panel.quest_assigned_to")}: ${this._esc(assigned.join(", "))}</div>
|
||||
@@ -3577,7 +3703,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
_criteriaLabel(criteria, combinator = "AND") {
|
||||
if (!criteria || !criteria.length) return this._t("badge.manual_award_only");
|
||||
const joinKey = combinator === "OR" ? "badge.criteria_or" : "badge.criteria_and";
|
||||
return criteria.map(c => `${this._t("badge.criteria_" + c.metric)} ${c.operator} ${c.value}`).join(` ${this._t(joinKey)} `);
|
||||
return criteria.map(c => `${this._t("badge.criteria_" + c.metric)} ${this._esc(c.operator)} ${this._num(c.value)}`).join(` ${this._t(joinKey)} `);
|
||||
}
|
||||
|
||||
_badgeName(b) {
|
||||
@@ -3870,7 +3996,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
<select class="tm-select tm-badge-op-select" data-badge-criterion-field="operator" data-badge-criterion-idx="${idx}">
|
||||
${BADGE_OPERATORS.map(sym => { const v = BADGE_OP_VALUES[sym]; return `<option value="${v}"${(c.operator || ">=") === v ? " selected" : ""}>${sym}</option>`; }).join("")}
|
||||
</select>`}
|
||||
${isBool ? `<span class="tm-field-hint" style="flex:1">true</span>` : `<input class="tm-input" type="number" min="0" value="${c.value || 1}" data-badge-criterion-field="value" data-badge-criterion-idx="${idx}">`}
|
||||
${isBool ? `<span class="tm-field-hint" style="flex:1">true</span>` : `<input class="tm-input" type="number" min="0" value="${this._num(c.value, 1)}" data-badge-criterion-field="value" data-badge-criterion-idx="${idx}">`}
|
||||
<button type="button" class="tm-icon-btn" data-act="badge-remove-criterion" data-idx="${idx}" title="${this._t("panel.badge_remove_criterion_btn")}">✕</button>
|
||||
</div>
|
||||
`;
|
||||
@@ -3935,7 +4061,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
return `
|
||||
<tr class="tm-row">
|
||||
<td><span class="tm-row-icon">${this._mdi(item.icon)}</span><strong>${this._esc(item.name)}</strong>${this._idBadge(item.id)}${item.description ? `<div class="tm-meta">${this._esc(item.description)}</div>` : ""}</td>
|
||||
<td><strong class="tm-numeric ${kind === "penalty" ? "tm-neg" : "tm-pos"}">${kind === "penalty" ? "−" : "+"}${item.points}</strong></td>
|
||||
<td><strong class="tm-numeric ${kind === "penalty" ? "tm-neg" : "tm-pos"}">${kind === "penalty" ? "−" : "+"}${this._num(item.points)}</strong></td>
|
||||
<td>${assignedNames}</td>
|
||||
<td class="tm-row-actions"><div>
|
||||
<button type="button" class="tm-btn tm-btn-sm" data-act="apply-${kind}" data-id="${this._esc(item.id)}">${this._t("panel.btn_apply")}</button>
|
||||
@@ -4120,7 +4246,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
<div class="tm-tpl-preview-body">
|
||||
<div class="tm-field-row">
|
||||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_name")}</span><input class="tm-input" type="text" data-tpl-field="name" data-tpl-idx="${idx}" value="${this._esc(chore.name)}"></div>
|
||||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_points")}</span><input class="tm-input" type="number" data-tpl-field="points" data-tpl-idx="${idx}" value="${chore.points || 0}" min="0"></div>
|
||||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_points")}</span><input class="tm-input" type="number" data-tpl-field="points" data-tpl-idx="${idx}" value="${this._num(chore.points)}" min="0"></div>
|
||||
</div>
|
||||
<div class="tm-field-row">
|
||||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.chore_time_category_label")}</span>
|
||||
@@ -4147,7 +4273,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
</select>
|
||||
</div>
|
||||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.template_daily_limit_label")}</span>
|
||||
<input class="tm-input" type="number" data-tpl-field="daily_limit" data-tpl-idx="${idx}" value="${chore.daily_limit || 1}" min="1">
|
||||
<input class="tm-input" type="number" data-tpl-field="daily_limit" data-tpl-idx="${idx}" value="${this._num(chore.daily_limit, 1)}" min="1">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4336,6 +4462,50 @@ class TaskMatePanel extends HTMLElement {
|
||||
}
|
||||
|
||||
// -- Settings tab ------------------------------------------------------
|
||||
/**
|
||||
* Custom completion sounds (#856).
|
||||
*
|
||||
* Uploaded clips are listed with preview / rename / delete. Deleting one
|
||||
* resets any chore using it back to the default sound server-side, so the
|
||||
* confirm copy warns about that rather than leaving it to be discovered.
|
||||
*/
|
||||
_renderSoundsSection() {
|
||||
const list = this._customSounds();
|
||||
const busy = this._soundBusy;
|
||||
return `
|
||||
<div class="tm-section">
|
||||
<div class="tm-section-head"><div>
|
||||
<h3>${this._t("panel.settings_sounds_title")}</h3>
|
||||
<p class="tm-meta">${this._t("panel.settings_sounds_hint")}</p>
|
||||
</div></div>
|
||||
<div class="tm-section-body">
|
||||
${list.length ? `
|
||||
<div class="tm-sound-list">
|
||||
${list.map(s => `
|
||||
<div class="tm-sound-item">
|
||||
<span class="tm-sound-name">${this._esc(s.name)}</span>
|
||||
<div class="tm-sound-item-actions">
|
||||
<button type="button" class="tm-btn tm-btn-sm" data-act="sound-preview"
|
||||
data-value="${this._esc(s.value)}"
|
||||
aria-label="${this._esc(this._t("panel.sound_preview"))}"
|
||||
title="${this._esc(this._t("panel.sound_preview"))}">▶</button>
|
||||
<button type="button" class="tm-btn tm-btn-sm" data-act="sound-rename"
|
||||
data-file="${this._esc(s.file)}" data-name="${this._esc(s.name)}">${this._t("panel.sound_rename")}</button>
|
||||
<button type="button" class="tm-btn tm-btn-sm tm-btn-danger" data-act="sound-delete"
|
||||
data-file="${this._esc(s.file)}" data-name="${this._esc(s.name)}">${this._t("panel.sound_delete")}</button>
|
||||
</div>
|
||||
</div>`).join("")}
|
||||
</div>` : `<p class="tm-meta">${this._t("panel.settings_sounds_empty")}</p>`}
|
||||
<div class="tm-sound-upload">
|
||||
<button type="button" class="tm-btn tm-btn-raised" data-act="sound-upload" ${busy ? "disabled" : ""}>
|
||||
${busy ? this._t("panel.sound_uploading") : this._t("panel.sound_upload")}
|
||||
</button>
|
||||
<span class="tm-field-hint">${this._t("panel.settings_sounds_formats")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_renderSettingsTab() {
|
||||
const s = this._state.settings || {};
|
||||
const notifyServices = Object.keys((this._hass && this._hass.services && this._hass.services.notify) || {});
|
||||
@@ -4377,6 +4547,8 @@ class TaskMatePanel extends HTMLElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this._renderSoundsSection()}
|
||||
|
||||
<div class="tm-section">
|
||||
<div class="tm-section-head"><div>
|
||||
<h3>${this._t("panel.settings_parents_title")}</h3>
|
||||
@@ -4389,6 +4561,10 @@ class TaskMatePanel extends HTMLElement {
|
||||
<input type="checkbox" class="tm-role-check" data-parent-user="${this._esc(u.id)}" ${(s.parent_user_ids || []).includes(u.id) ? "checked" : ""}>
|
||||
</label>`).join("")
|
||||
|| `<p class="tm-meta">${this._t("panel.settings_parents_empty")}</p>`}
|
||||
<div class="tm-setting-row">
|
||||
<div class="tm-setting-label">${this._t("panel.settings_require_linked_child_label")}<small>${this._t("panel.settings_require_linked_child_hint")}</small></div>
|
||||
<ha-switch data-setting="require_linked_child" ${s.require_linked_child ? "checked" : ""}></ha-switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4397,7 +4573,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
<div class="tm-section-body">
|
||||
<div class="tm-setting-row">
|
||||
<div class="tm-setting-label">${this._t("panel.settings_retention_label")}<small>${this._t("panel.settings_retention_hint")}</small></div>
|
||||
<input type="number" class="tm-input" min="30" max="365" data-setting="history_days" value="${s.history_days || 90}">
|
||||
<input type="number" class="tm-input" min="30" max="365" data-setting="history_days" value="${this._num(s.history_days, 90)}">
|
||||
</div>
|
||||
<div class="tm-setting-row">
|
||||
<div class="tm-setting-label">${this._t("panel.settings_streak_reset_label")}<small>${this._t("panel.settings_streak_reset_hint")}</small></div>
|
||||
@@ -4411,19 +4587,19 @@ class TaskMatePanel extends HTMLElement {
|
||||
</div>
|
||||
<div class="tm-setting-row">
|
||||
<div class="tm-setting-label">${this._t("panel.settings_weekend_multiplier_label")}<small>${this._t("panel.settings_weekend_multiplier_hint")}</small></div>
|
||||
<input type="number" class="tm-input" step="0.1" min="1" max="5" data-setting="weekend_multiplier" value="${s.weekend_multiplier || 1.0}">
|
||||
<input type="number" class="tm-input" step="0.1" min="1" max="5" data-setting="weekend_multiplier" value="${this._num(s.weekend_multiplier, 1.0)}">
|
||||
</div>
|
||||
<div class="tm-setting-row">
|
||||
<div class="tm-setting-label">${this._t("panel.settings_difficulty_multipliers_label")}<small>${this._t("panel.settings_difficulty_multipliers_hint")}</small></div>
|
||||
<div class="tm-difficulty-mults">
|
||||
<label>${this._t("panel.difficulty_easy")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_easy" value="${s.difficulty_multiplier_easy ?? 0.5}"></label>
|
||||
<label>${this._t("panel.difficulty_medium")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_medium" value="${s.difficulty_multiplier_medium ?? 1.0}"></label>
|
||||
<label>${this._t("panel.difficulty_hard")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_hard" value="${s.difficulty_multiplier_hard ?? 2.0}"></label>
|
||||
<label>${this._t("panel.difficulty_easy")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_easy" value="${this._num(s.difficulty_multiplier_easy, 0.5)}"></label>
|
||||
<label>${this._t("panel.difficulty_medium")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_medium" value="${this._num(s.difficulty_multiplier_medium, 1.0)}"></label>
|
||||
<label>${this._t("panel.difficulty_hard")}<input type="number" class="tm-input" step="0.1" min="0" max="10" data-setting="difficulty_multiplier_hard" value="${this._num(s.difficulty_multiplier_hard, 2.0)}"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tm-setting-row">
|
||||
<div class="tm-setting-label">${this._t("panel.settings_calendar_projection_label")}<small>${this._t("panel.settings_calendar_projection_hint")}</small></div>
|
||||
<input type="number" class="tm-input" min="1" max="90" data-setting="calendar_projection_days" value="${s.calendar_projection_days || 14}">
|
||||
<input type="number" class="tm-input" min="1" max="90" data-setting="calendar_projection_days" value="${this._num(s.calendar_projection_days, 14)}">
|
||||
</div>
|
||||
<div class="tm-setting-row">
|
||||
<div class="tm-setting-label">${this._t("panel.settings_surprise_enabled_label")}<small>${this._t("panel.settings_surprise_enabled_hint")}</small></div>
|
||||
@@ -4432,9 +4608,9 @@ class TaskMatePanel extends HTMLElement {
|
||||
<div class="tm-setting-row">
|
||||
<div class="tm-setting-label">${this._t("panel.settings_surprise_params_label")}<small>${this._t("panel.settings_surprise_params_hint")}</small></div>
|
||||
<div class="tm-difficulty-mults">
|
||||
<label>${this._t("panel.settings_surprise_chance")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="surprise_bonus_chance" value="${s.surprise_bonus_chance ?? 15}"></label>
|
||||
<label>${this._t("panel.settings_surprise_min")}<input type="number" class="tm-input" step="1" min="0" data-setting="surprise_bonus_min" value="${s.surprise_bonus_min ?? 5}"></label>
|
||||
<label>${this._t("panel.settings_surprise_max")}<input type="number" class="tm-input" step="1" min="0" data-setting="surprise_bonus_max" value="${s.surprise_bonus_max ?? 20}"></label>
|
||||
<label>${this._t("panel.settings_surprise_chance")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="surprise_bonus_chance" value="${this._num(s.surprise_bonus_chance, 15)}"></label>
|
||||
<label>${this._t("panel.settings_surprise_min")}<input type="number" class="tm-input" step="1" min="0" data-setting="surprise_bonus_min" value="${this._num(s.surprise_bonus_min, 5)}"></label>
|
||||
<label>${this._t("panel.settings_surprise_max")}<input type="number" class="tm-input" step="1" min="0" data-setting="surprise_bonus_max" value="${this._num(s.surprise_bonus_max, 20)}"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tm-setting-row tm-setting-stack">
|
||||
@@ -4460,8 +4636,8 @@ class TaskMatePanel extends HTMLElement {
|
||||
<div class="tm-setting-row">
|
||||
<div class="tm-setting-label">${this._t("panel.settings_roulette_section")}</div>
|
||||
<div class="tm-difficulty-mults">
|
||||
<label>${this._t("panel.settings_roulette_multiplier")}<input type="number" class="tm-input" step="0.5" min="1" max="5" data-setting="roulette_multiplier" value="${s.roulette_multiplier ?? 2}"></label>
|
||||
<label>${this._t("panel.settings_roulette_spins")}<input type="number" class="tm-input" step="1" min="1" max="10" data-setting="roulette_daily_spins" value="${s.roulette_daily_spins ?? 1}"></label>
|
||||
<label>${this._t("panel.settings_roulette_multiplier")}<input type="number" class="tm-input" step="0.5" min="1" max="5" data-setting="roulette_multiplier" value="${this._num(s.roulette_multiplier, 2)}"></label>
|
||||
<label>${this._t("panel.settings_roulette_spins")}<input type="number" class="tm-input" step="1" min="1" max="10" data-setting="roulette_daily_spins" value="${this._num(s.roulette_daily_spins, 1)}"></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tm-setting-row">
|
||||
@@ -4471,7 +4647,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
<div class="tm-setting-row">
|
||||
<div class="tm-setting-label">${this._t("panel.settings_decay_params_label")}<small>${this._t("panel.settings_decay_params_hint")}</small></div>
|
||||
<div class="tm-difficulty-mults">
|
||||
<label>${this._t("panel.settings_decay_percent")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="points_decay_percent" value="${s.points_decay_percent ?? 10}"></label>
|
||||
<label>${this._t("panel.settings_decay_percent")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="points_decay_percent" value="${this._num(s.points_decay_percent, 10)}"></label>
|
||||
<label>${this._t("panel.settings_decay_period")}
|
||||
<select class="tm-select" data-setting="points_decay_period">
|
||||
<option value="weekly" ${s.points_decay_period === "weekly" ? "selected" : ""}>${this._t("panel.reward_restock_weekly")}</option>
|
||||
@@ -4486,7 +4662,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
</div>
|
||||
<div class="tm-setting-row">
|
||||
<div class="tm-setting-label">${this._t("panel.settings_level_step_label")}<small>${this._t("panel.settings_level_step_hint")}</small></div>
|
||||
<input type="number" class="tm-input" min="1" data-setting="level_xp_step" value="${s.level_xp_step || 100}">
|
||||
<input type="number" class="tm-input" min="1" data-setting="level_xp_step" value="${this._num(s.level_xp_step, 100)}">
|
||||
</div>
|
||||
<div class="tm-setting-row">
|
||||
<div class="tm-setting-label">${this._t("panel.settings_spendcap_enabled_label")}<small>${this._t("panel.settings_spendcap_enabled_hint")}</small></div>
|
||||
@@ -4495,7 +4671,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
<div class="tm-setting-row">
|
||||
<div class="tm-setting-label">${this._t("panel.settings_spendcap_params_label")}<small>${this._t("panel.settings_spendcap_params_hint")}</small></div>
|
||||
<div class="tm-difficulty-mults">
|
||||
<label>${this._t("panel.settings_spendcap_amount")}<input type="number" class="tm-input" step="1" min="0" data-setting="spend_cap_amount" value="${s.spend_cap_amount ?? 0}"></label>
|
||||
<label>${this._t("panel.settings_spendcap_amount")}<input type="number" class="tm-input" step="1" min="0" data-setting="spend_cap_amount" value="${this._num(s.spend_cap_amount, 0)}"></label>
|
||||
<label>${this._t("panel.settings_decay_period")}
|
||||
<select class="tm-select" data-setting="spend_cap_period">
|
||||
<option value="weekly" ${s.spend_cap_period === "weekly" ? "selected" : ""}>${this._t("panel.reward_restock_weekly")}</option>
|
||||
@@ -4511,7 +4687,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
<div class="tm-setting-row">
|
||||
<div class="tm-setting-label">${this._t("panel.settings_interest_params_label")}<small>${this._t("panel.settings_interest_params_hint")}</small></div>
|
||||
<div class="tm-difficulty-mults">
|
||||
<label>${this._t("panel.settings_interest_percent")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="interest_percent" value="${s.interest_percent ?? 5}"></label>
|
||||
<label>${this._t("panel.settings_interest_percent")}<input type="number" class="tm-input" step="1" min="0" max="100" data-setting="interest_percent" value="${this._num(s.interest_percent, 5)}"></label>
|
||||
<label>${this._t("panel.settings_decay_period")}
|
||||
<select class="tm-select" data-setting="interest_period">
|
||||
<option value="weekly" ${(s.interest_period || "weekly") === "weekly" ? "selected" : ""}>${this._t("panel.reward_restock_weekly")}</option>
|
||||
@@ -4544,7 +4720,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
</div>
|
||||
<div class="tm-setting-row">
|
||||
<div class="tm-setting-label">${this._t("panel.settings_perfect_week_bonus_label")}<small>${this._t("panel.settings_perfect_week_bonus_hint")}</small></div>
|
||||
<input type="number" class="tm-input" min="0" data-setting="perfect_week_bonus" value="${s.perfect_week_bonus || 50}">
|
||||
<input type="number" class="tm-input" min="0" data-setting="perfect_week_bonus" value="${this._num(s.perfect_week_bonus, 50)}">
|
||||
</div>
|
||||
<div class="tm-setting-row">
|
||||
<div class="tm-setting-label">${this._t("panel.settings_celebration_notify_label")}<small>${this._t("panel.settings_celebration_notify_hint")}</small></div>
|
||||
@@ -4658,7 +4834,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
<tr>
|
||||
<td>${this._esc((p.date || "").slice(0, 10))}</td>
|
||||
<td>${this._esc(p.child_name || "")}</td>
|
||||
<td style="text-align:right">${p.points} → ${this._esc(p.currency || "")}${this._esc(String(p.amount))}</td>
|
||||
<td style="text-align:right">${this._num(p.points)} → ${this._esc(p.currency || "")}${this._esc(String(p.amount))}</td>
|
||||
</tr>
|
||||
`).join("")}
|
||||
</tbody></table></div>
|
||||
@@ -5115,7 +5291,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
<div style="padding:0 14px 12px;border-top:1px solid var(--tm-border-soft)">
|
||||
<div class="tm-field-row" style="margin-top:10px">
|
||||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_name")}</span><input class="tm-input" type="text" data-tpl-dialog-field="name" data-tpl-dialog-idx="${i}" value="${this._esc(c.name)}"></div>
|
||||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_points")}</span><input class="tm-input" type="number" data-tpl-dialog-field="points" data-tpl-dialog-idx="${i}" value="${c.points || 0}" min="0"></div>
|
||||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.common_points")}</span><input class="tm-input" type="number" data-tpl-dialog-field="points" data-tpl-dialog-idx="${i}" value="${this._num(c.points)}" min="0"></div>
|
||||
</div>
|
||||
<div class="tm-field-row">
|
||||
<div class="tm-field"><span class="tm-field-label">${this._t("panel.chore_time_category_label")}</span>
|
||||
@@ -5266,7 +5442,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
${this._select(this._t("panel.chore_first_occurrence_label"), "first_occurrence_mode", d.first_occurrence_mode, FIRST_OCCURRENCE)}
|
||||
</div>
|
||||
` : "",
|
||||
this._select(this._t("panel.chore_completion_sound_label"), "completion_sound", d.completion_sound, COMPLETION_SOUNDS.map(s => ({ v: s, l: s }))),
|
||||
this._soundField(d.completion_sound),
|
||||
this._select(this._t("panel.chore_difficulty_label"), "difficulty", d.difficulty || "medium", [
|
||||
{ v: "easy", l: this._t("panel.difficulty_easy") },
|
||||
{ v: "medium", l: this._t("panel.difficulty_medium") },
|
||||
@@ -5299,7 +5475,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
${(d.bonus_subtasks || []).map((b, idx) => `
|
||||
<div class="tm-field-row" style="align-items:flex-end;gap:6px;margin-bottom:6px">
|
||||
<div class="tm-field" style="flex:2;margin:0"><input class="tm-input" placeholder="${this._t("panel.chore_subtask_name_placeholder")}" value="${this._esc(b.name)}" data-field="bonus_subtasks[${idx}].name"></div>
|
||||
<div class="tm-field" style="flex:0 0 70px;margin:0"><input class="tm-input" type="number" min="0" placeholder="${this._t("panel.chore_subtask_points_placeholder")}" value="${b.points}" data-field="bonus_subtasks[${idx}].points"></div>
|
||||
<div class="tm-field" style="flex:0 0 70px;margin:0"><input class="tm-input" type="number" min="0" placeholder="${this._t("panel.chore_subtask_points_placeholder")}" value="${this._num(b.points)}" data-field="bonus_subtasks[${idx}].points"></div>
|
||||
<button type="button" class="tm-btn tm-btn-icon" data-act="remove-bonus-subtask" data-idx="${idx}" title="${this._t("panel.tooltip_remove")}" style="padding:6px 10px">✕</button>
|
||||
</div>
|
||||
`).join("")}
|
||||
@@ -5595,6 +5771,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
{ v: "monthly", l: this._t("panel.reward_restock_monthly") },
|
||||
])}
|
||||
</div>`,
|
||||
this._renderRewardTimeLock(d),
|
||||
this._renderRewardUnlock(d),
|
||||
].join(""),
|
||||
`<button type="button" class="tm-btn" data-act="close-dialog">${this._t("panel.btn_cancel")}</button>
|
||||
@@ -5602,6 +5779,42 @@ class TaskMatePanel extends HTMLElement {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Time lock (#857). Restricts *claiming* to certain weekdays and/or a
|
||||
* time-of-day window — the reward stays visible the rest of the time, greyed
|
||||
* out with a "when it's available" badge, and saving points into a pool
|
||||
* reward is never blocked.
|
||||
*/
|
||||
_renderRewardTimeLock(d) {
|
||||
const open = this._dialog._openAdvanced?.has("timelock") || !!d.time_lock_enabled;
|
||||
const days = (d.available_days || []).map(Number);
|
||||
return `<details class="tm-advanced" data-section="timelock"${open ? " open" : ""}>
|
||||
<summary>${this._t("panel.reward_timelock_section")}</summary>
|
||||
<div>
|
||||
${this._switch(this._t("panel.reward_timelock_label"), "time_lock_enabled", d.time_lock_enabled,
|
||||
this._t("panel.reward_timelock_hint"), true)}
|
||||
${d.time_lock_enabled ? `
|
||||
<div class="tm-field">
|
||||
<span class="tm-field-label">${this._t("panel.reward_timelock_days_label")}</span>
|
||||
<div class="tm-chip-row">
|
||||
${DAYS.map((day, i) => `
|
||||
<button type="button" class="tm-chip-btn ${days.includes(i) ? "tm-chip-on" : ""}" data-act="toggle-reward-day" data-day="${i}">
|
||||
${this._t(day.lk)}
|
||||
</button>
|
||||
`).join("")}
|
||||
</div>
|
||||
<span class="tm-field-hint">${this._t("panel.reward_timelock_days_hint")}</span>
|
||||
</div>
|
||||
<div class="tm-field-row">
|
||||
${this._timeField(this._t("panel.reward_timelock_from_label"), "available_from", d.available_from)}
|
||||
${this._timeField(this._t("panel.reward_timelock_until_label"), "available_until", d.available_until)}
|
||||
</div>
|
||||
<span class="tm-field-hint">${this._t("panel.reward_timelock_times_hint")}</span>
|
||||
` : ""}
|
||||
</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timed unlock (#678). Only offers entities the parent has allowlisted in
|
||||
* Settings — a reward must not be able to reach arbitrary devices.
|
||||
@@ -5845,7 +6058,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
</div>
|
||||
</div>
|
||||
` : "",
|
||||
this._select(this._t("panel.chore_completion_sound_label"), "completion_sound", d.completion_sound, COMPLETION_SOUNDS.map(s => ({ v: s, l: s }))),
|
||||
this._soundField(d.completion_sound),
|
||||
this._select(this._t("panel.chore_difficulty_label"), "difficulty", d.difficulty || "medium", [
|
||||
{ v: "easy", l: this._t("panel.difficulty_easy") },
|
||||
{ v: "medium", l: this._t("panel.difficulty_medium") },
|
||||
@@ -5881,7 +6094,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
<div class="tm-reorder-item" draggable="true" data-drag-id="${this._esc(id)}">
|
||||
<div class="tm-reorder-handle">⠿</div>
|
||||
<div class="tm-reorder-name">${this._esc(c.name)}</div>
|
||||
<div class="tm-reorder-points tm-numeric">${c.points}</div>
|
||||
<div class="tm-reorder-points tm-numeric">${this._num(c.points)}</div>
|
||||
<div class="tm-reorder-moves">
|
||||
<button type="button" class="tm-reorder-move" data-act="reorder-move" data-dir="-1" data-drag-id="${this._esc(id)}" ${idx === 0 ? "disabled" : ""} title="${this._t("reorder.move_up")}" aria-label="${this._t("reorder.move_up")}">▲</button>
|
||||
<button type="button" class="tm-reorder-move" data-act="reorder-move" data-dir="1" data-drag-id="${this._esc(id)}" ${idx === arr.length - 1 ? "disabled" : ""} title="${this._t("reorder.move_down")}" aria-label="${this._t("reorder.move_down")}">▼</button>
|
||||
@@ -5910,7 +6123,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_field(label, name, value, type = "text", hint = "") {
|
||||
_field(label, name, value, type = "text", hintHtml = "") {
|
||||
return `
|
||||
<div class="tm-field">
|
||||
<span class="tm-field-label">${this._esc(label)}</span>
|
||||
@@ -5920,11 +6133,11 @@ class TaskMatePanel extends HTMLElement {
|
||||
value="${this._esc(value == null ? "" : value)}"
|
||||
${type === "number" ? 'type="number"' : 'type="text"'}
|
||||
>
|
||||
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
|
||||
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_dateField(label, name, value, hint = "") {
|
||||
_dateField(label, name, value, hintHtml = "") {
|
||||
return `
|
||||
<div class="tm-field">
|
||||
<span class="tm-field-label">${this._esc(label)}</span>
|
||||
@@ -5934,11 +6147,25 @@ class TaskMatePanel extends HTMLElement {
|
||||
value="${this._esc(value || "")}"
|
||||
type="date"
|
||||
>
|
||||
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
|
||||
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_select(label, name, value, options, hint = "", rerender = false) {
|
||||
_timeField(label, name, value, hintHtml = "") {
|
||||
return `
|
||||
<div class="tm-field">
|
||||
<span class="tm-field-label">${this._esc(label)}</span>
|
||||
<input
|
||||
class="tm-input"
|
||||
data-field="${name}"
|
||||
value="${this._esc(value || "")}"
|
||||
type="time"
|
||||
>
|
||||
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_select(label, name, value, options, hintHtml = "", rerender = false) {
|
||||
return `
|
||||
<div class="tm-field">
|
||||
<span class="tm-field-label">${this._esc(label)}</span>
|
||||
@@ -5949,17 +6176,17 @@ class TaskMatePanel extends HTMLElement {
|
||||
>
|
||||
${options.map(o => `<option value="${this._esc(o.v)}" ${String(o.v) === String(value) ? "selected" : ""}>${this._esc(o.lk ? this._t(o.lk) : o.l)}</option>`).join("")}
|
||||
</select>
|
||||
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
|
||||
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
_switch(label, name, checked, hint = "", rerender = false) {
|
||||
_switch(label, name, checked, hintHtml = "", rerender = false) {
|
||||
return `
|
||||
<div class="tm-check-row">
|
||||
<ha-switch data-field="${name}" ${checked ? "checked" : ""} ${rerender ? 'data-rerender="true"' : ""}></ha-switch>
|
||||
<div>
|
||||
<div class="tm-check-title">${this._esc(label)}</div>
|
||||
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
|
||||
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -5985,11 +6212,63 @@ class TaskMatePanel extends HTMLElement {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Uploaded custom sounds from the last get_state (#856). */
|
||||
_customSounds() {
|
||||
return (this._state && this._state.custom_sounds) || [];
|
||||
}
|
||||
|
||||
/** Built-in + uploaded sounds, as <select> options. */
|
||||
_soundOptions() {
|
||||
return [
|
||||
...COMPLETION_SOUNDS.map(v => ({ v, l: v })),
|
||||
...this._customSounds().map(s => ({ v: s.value, l: s.name })),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The chore editor's completion-sound picker, with a preview button.
|
||||
*
|
||||
* Picking a sound used to be silent here: the only preview code lived in
|
||||
* taskmate-config-sounds.js, which scans the document for sound dropdowns
|
||||
* and so could never see this one inside the panel's shadow root (#856).
|
||||
*/
|
||||
_soundField(value) {
|
||||
const current = value || "coin";
|
||||
return `
|
||||
<div class="tm-field">
|
||||
<span class="tm-field-label">${this._t("panel.chore_completion_sound_label")}</span>
|
||||
<div class="tm-sound-row">
|
||||
<select class="tm-select" data-field="completion_sound">
|
||||
${this._soundOptions().map(o => `<option value="${this._esc(o.v)}" ${String(o.v) === String(current) ? "selected" : ""}>${this._esc(o.l)}</option>`).join("")}
|
||||
</select>
|
||||
<button type="button" class="tm-btn tm-btn-sm" data-act="sound-preview-field"
|
||||
title="${this._esc(this._t("panel.sound_preview"))}"
|
||||
aria-label="${this._esc(this._t("panel.sound_preview"))}">▶</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** Play a sound through the shared engine (taskmate-sounds.js). */
|
||||
_previewSound(value) {
|
||||
const engine = window.__taskmate_sounds;
|
||||
if (engine) engine.play(value, this._customSounds());
|
||||
}
|
||||
|
||||
/**
|
||||
* Icon field. `ha-icon-picker` is HA's own component — we keep it for the
|
||||
* searchable MDI list, but its Material shell (grey fill, underline, 56px
|
||||
* box) reads as a foreign control beside the panel's own inputs. The
|
||||
* `.tm-icon-well` wrapper supplies the tm-input frame and the picker inside
|
||||
* it is made transparent; see the CSS for why the wrapper, not the picker,
|
||||
* carries the border.
|
||||
*/
|
||||
_iconPickerField(label, name, value) {
|
||||
return `
|
||||
<div class="tm-field">
|
||||
<span class="tm-field-label">${this._esc(label)}</span>
|
||||
<ha-icon-picker data-field="${name}" data-current="${this._esc(value || "")}"></ha-icon-picker>
|
||||
<div class="tm-icon-well">
|
||||
<ha-icon-picker data-field="${name}" data-current="${this._esc(value || "")}"></ha-icon-picker>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -6014,7 +6293,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
else { (this._settingsEntityDraft = this._settingsEntityDraft || {})[field] = value; }
|
||||
}
|
||||
|
||||
_entityPickerField(label, name, value, domains, hint = "") {
|
||||
_entityPickerField(label, name, value, domains, hintHtml = "") {
|
||||
const friendly = value && this._hass?.states?.[value]?.attributes?.friendly_name || "";
|
||||
const icon = value ? (this._hass?.states?.[value]?.attributes?.icon || this._entityDomainIcon(value)) : "";
|
||||
return `
|
||||
@@ -6033,7 +6312,7 @@ class TaskMatePanel extends HTMLElement {
|
||||
</div>`
|
||||
}
|
||||
</div>
|
||||
${hint ? `<span class="tm-field-hint">${hint}</span>` : ""}
|
||||
${hintHtml ? `<span class="tm-field-hint">${hintHtml}</span>` : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -6208,6 +6487,15 @@ class TaskMatePanel extends HTMLElement {
|
||||
return new Intl.NumberFormat().format(n);
|
||||
}
|
||||
|
||||
// A number bound for a markup or attribute slot. The panel assembles its
|
||||
// HTML as strings, so anything stored in a field we *assume* is numeric has
|
||||
// to be proven numeric before it is echoed back. Non-numbers, inf and NaN
|
||||
// fall back rather than reaching the DOM.
|
||||
_num(v, fallback = 0) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
}
|
||||
|
||||
// Quick point-adjust amounts (#746). Stored as a comma-separated string like
|
||||
// "5, 10, 20" — same shape as streak_milestones — so the generic settings
|
||||
// collector can round-trip it as a plain text input. Junk degrades to the
|
||||
@@ -6740,6 +7028,24 @@ class TaskMatePanel extends HTMLElement {
|
||||
}
|
||||
.tm-image-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
|
||||
/* Custom completion sounds (#856) */
|
||||
.tm-sound-row { display: flex; gap: 6px; align-items: center; }
|
||||
.tm-sound-row .tm-select { flex: 1; min-width: 0; }
|
||||
.tm-sound-list {
|
||||
display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px;
|
||||
}
|
||||
.tm-sound-item {
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
padding: 8px 10px; border: 1px solid var(--tm-border);
|
||||
border-radius: var(--tm-radius-sm); background: var(--tm-surface-2);
|
||||
}
|
||||
.tm-sound-name {
|
||||
flex: 1; min-width: 0; font-size: 14px; font-weight: 600;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.tm-sound-item-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.tm-sound-upload { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
.tm-points-adjust {
|
||||
display: flex; gap: 4px; flex-wrap: wrap;
|
||||
align-items: center;
|
||||
@@ -6911,6 +7217,8 @@ class TaskMatePanel extends HTMLElement {
|
||||
.tm-pill-pool { background: var(--tm-pool-soft); color: var(--tm-pool); border-color: color-mix(in srgb, var(--tm-pool), transparent 75%); }
|
||||
.tm-pill-sticky { background: var(--tm-sticky-soft); color: var(--tm-sticky); border-color: color-mix(in srgb, var(--tm-sticky), transparent 75%); }
|
||||
.tm-pill-spread { background: var(--tm-positive-soft); color: var(--tm-positive); border-color: var(--tm-positive-border); }
|
||||
.tm-pill-timelock { background: var(--tm-accent-soft); color: var(--tm-accent-text); border-color: var(--tm-accent-border); }
|
||||
.tm-pill-timelock ha-icon { --mdc-icon-size: 13px; vertical-align: -2px; }
|
||||
.tm-pill-jackpot { background: var(--tm-gold-soft); color: var(--tm-gold); border-color: color-mix(in srgb, var(--tm-gold), transparent 75%); }
|
||||
.tm-pill-muted { background: var(--tm-surface-2); color: var(--tm-text-muted); border-color: var(--tm-border); }
|
||||
.tm-quest-inactive { opacity: 0.6; }
|
||||
@@ -7335,6 +7643,42 @@ class TaskMatePanel extends HTMLElement {
|
||||
display: block; width: 100%;
|
||||
}
|
||||
|
||||
/* Icon field well: make ha-icon-picker sit in the dialogs like a
|
||||
tm-input. The picker's inner shell can't be reached from here (shadow
|
||||
DOM), so the wrapper carries the frame and the picker is stripped back:
|
||||
- the fill and the 1px "active indicator" are var() usages inside
|
||||
ha-picker-field, so overriding the vars here does reach them;
|
||||
- ha-combo-box-item hard-sets its own 56px row height and square
|
||||
bottom corners, which no variable overrides — so the well is
|
||||
tm-input height with overflow:hidden, and the picker is pulled up
|
||||
by half the difference to keep its content optically centred.
|
||||
The --mdc-* vars cover older HA, which still renders an mwc textfield. */
|
||||
.tm-icon-well {
|
||||
box-sizing: border-box;
|
||||
height: 37px;
|
||||
background: var(--tm-surface-0);
|
||||
border: 1px solid var(--tm-border);
|
||||
border-radius: var(--tm-radius-sm);
|
||||
box-shadow: var(--tm-shadow-xs);
|
||||
overflow: hidden;
|
||||
transition: all 0.1s var(--tm-easing);
|
||||
}
|
||||
.tm-icon-well:hover { border-color: var(--tm-border-strong); }
|
||||
.tm-icon-well:focus-within { border-color: var(--tm-accent); box-shadow: var(--tm-shadow-focus); }
|
||||
.tm-icon-well ha-icon-picker {
|
||||
display: block; width: 100%;
|
||||
margin-top: calc((37px - 56px) / 2);
|
||||
--ha-color-form-background: transparent;
|
||||
--ha-color-border-neutral-loud: transparent;
|
||||
--md-list-item-leading-space: 11px;
|
||||
--md-list-item-trailing-space: 4px;
|
||||
--md-list-item-label-text-size: 13px;
|
||||
--mdc-text-field-fill-color: transparent;
|
||||
--mdc-text-field-idle-line-color: transparent;
|
||||
--mdc-text-field-hover-line-color: transparent;
|
||||
--mdc-text-field-focused-line-color: transparent;
|
||||
}
|
||||
|
||||
/* Chip multi-select */
|
||||
/* Insight reports (#679) */
|
||||
.tm-verdict {
|
||||
|
||||
@@ -325,6 +325,13 @@ class TaskMateRewardProgressCard extends LitElement {
|
||||
background: color-mix(in srgb, var(--error-color, #db4437) 18%, transparent);
|
||||
color: var(--error-color, #db4437);
|
||||
}
|
||||
.availability-badge.badge-time-locked {
|
||||
background: color-mix(in srgb, var(--info-color, #3498db) 18%, transparent);
|
||||
color: var(--info-color, #2471a3);
|
||||
/* Longer than the other badges — uppercasing a whole window reads badly. */
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.availability-badge.badge-expiring-soon {
|
||||
background: color-mix(in srgb, var(--warning-color, #ff9800) 20%, transparent);
|
||||
color: var(--warning-color, #ff9800);
|
||||
@@ -503,6 +510,9 @@ class TaskMateRewardProgressCard extends LitElement {
|
||||
} else if (isSoldOut) {
|
||||
availabilityLabel = this._t('rewards.sold_out');
|
||||
availabilityClass = 'badge-sold-out';
|
||||
} else if (reward.is_time_locked === true) {
|
||||
availabilityLabel = this._timeLockLabel(reward);
|
||||
availabilityClass = 'badge-time-locked';
|
||||
} else if (typeof reward.quantity === 'number' && reward.quantity > 0 && reward.quantity <= 3) {
|
||||
availabilityLabel = this._t('rewards.only_n_left', { count: reward.quantity });
|
||||
availabilityClass = 'badge-low-stock';
|
||||
@@ -559,6 +569,14 @@ class TaskMateRewardProgressCard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** "Available Fri, Sat · 18:00–21:00" for a time-locked reward (#857). */
|
||||
_timeLockLabel(reward) {
|
||||
const summary = window.__taskmate_time_lock_label
|
||||
? window.__taskmate_time_lock_label(reward.time_lock, (k, pr) => this._t(k, pr))
|
||||
: '';
|
||||
return summary ? this._t('rewards.available_when', { window: summary }) : this._t('rewards.locked_now');
|
||||
}
|
||||
|
||||
_renderChildProgress(child, reward, pointsIcon, pointsName) {
|
||||
const cost = reward.calculated_costs?.[child.id] ?? reward.cost;
|
||||
const have = child.points || 0;
|
||||
@@ -760,6 +778,9 @@ class TaskMateRewardProgressCard extends LitElement {
|
||||
} else if (isSoldOut) {
|
||||
availabilityLabel = this._t('rewards.sold_out');
|
||||
availabilityTone = 'dim';
|
||||
} else if (reward.is_time_locked === true) {
|
||||
availabilityLabel = this._timeLockLabel(reward);
|
||||
availabilityTone = 'dim';
|
||||
} else if (typeof reward.quantity === 'number' && reward.quantity > 0 && reward.quantity <= 3) {
|
||||
availabilityLabel = this._t('rewards.only_n_left', { count: reward.quantity });
|
||||
availabilityTone = 'bad';
|
||||
|
||||
@@ -587,6 +587,12 @@ class TaskMateRewardsCard extends LitElement {
|
||||
background: rgba(231,76,60,0.15);
|
||||
color: #c0392b;
|
||||
}
|
||||
.availability-badge.badge-time-locked {
|
||||
gap: 3px;
|
||||
background: rgba(52,152,219,0.16);
|
||||
color: #2471a3;
|
||||
}
|
||||
.availability-badge.badge-time-locked ha-icon { --mdc-icon-size: 13px; }
|
||||
.availability-badge.badge-expiring-soon {
|
||||
background: rgba(243,156,18,0.18);
|
||||
color: #d35400;
|
||||
@@ -1393,10 +1399,12 @@ class TaskMateRewardsCard extends LitElement {
|
||||
const isLoading = this._loading[reward.id];
|
||||
const isAllocLoading = this._loading[`${reward.id}_alloc`];
|
||||
|
||||
// Availability (quantity / expiration)
|
||||
// Availability (quantity / expiration / time lock)
|
||||
const isSoldOut = reward.is_sold_out === true;
|
||||
const isExpired = reward.is_expired === true;
|
||||
const isUnavailable = isSoldOut || isExpired;
|
||||
const isTimeLocked = reward.is_time_locked === true;
|
||||
const canClaimNow = !isUnavailable && !isTimeLocked;
|
||||
const daysUntilExpiry = (typeof reward.days_until_expiry === 'number')
|
||||
? reward.days_until_expiry
|
||||
: null;
|
||||
@@ -1407,7 +1415,7 @@ class TaskMateRewardsCard extends LitElement {
|
||||
const showPoolControls = enablePoolMode && childId && !hasPendingClaim && !isUnavailable;
|
||||
|
||||
return html`
|
||||
<div class="reward-row ${isJackpot ? 'jackpot' : ''} ${hasPendingClaim ? 'pending-approval' : ''} ${enablePoolMode ? 'pool-mode' : ''} ${isUnavailable ? 'unavailable' : ''}">
|
||||
<div class="reward-row ${isJackpot ? 'jackpot' : ''} ${hasPendingClaim ? 'pending-approval' : ''} ${enablePoolMode ? 'pool-mode' : ''} ${isUnavailable || isTimeLocked ? 'unavailable' : ''}">
|
||||
<div class="cost-badge">
|
||||
<ha-icon icon="${pointsIcon}"></ha-icon>
|
||||
<span class="cost-value">${displayCost}</span>
|
||||
@@ -1450,7 +1458,7 @@ class TaskMateRewardsCard extends LitElement {
|
||||
: ""}
|
||||
</div>
|
||||
<div class="reward-right-col">
|
||||
${!enablePoolMode && !hasPendingClaim && childId && !isUnavailable ? html`
|
||||
${!enablePoolMode && !hasPendingClaim && childId && canClaimNow ? html`
|
||||
<button
|
||||
class="claim-btn ${!canAfford ? 'cant-afford' : ''}"
|
||||
?disabled="${!canAfford || isLoading}"
|
||||
@@ -1460,7 +1468,7 @@ class TaskMateRewardsCard extends LitElement {
|
||||
<ha-icon icon="${isLoading ? 'mdi:loading' : rewardIcon}"></ha-icon>
|
||||
</button>
|
||||
` : html`
|
||||
<div class="reward-icon-container" title="${isUnavailable ? this._t('rewards.unavailable_hint') : ''}">
|
||||
<div class="reward-icon-container" title="${isTimeLocked ? this._timeLockLabel(reward) : (isUnavailable ? this._t('rewards.unavailable_hint') : '')}">
|
||||
<ha-icon icon="${rewardIcon}"></ha-icon>
|
||||
</div>
|
||||
`}
|
||||
@@ -1469,6 +1477,14 @@ class TaskMateRewardsCard extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
/** "Available Fri, Sat · 18:00–21:00" for a time-locked reward (#857). */
|
||||
_timeLockLabel(reward) {
|
||||
const summary = window.__taskmate_time_lock_label
|
||||
? window.__taskmate_time_lock_label(reward.time_lock, (k, pr) => this._t(k, pr))
|
||||
: '';
|
||||
return summary ? this._t('rewards.available_when', { window: summary }) : this._t('rewards.locked_now');
|
||||
}
|
||||
|
||||
_renderAvailabilityBadge(reward, isSoldOut, isExpired, daysUntilExpiry) {
|
||||
if (isExpired) {
|
||||
return html`<div class="availability-badge badge-expired">${this._t('rewards.expired')}</div>`;
|
||||
@@ -1476,6 +1492,11 @@ class TaskMateRewardsCard extends LitElement {
|
||||
if (isSoldOut) {
|
||||
return html`<div class="availability-badge badge-sold-out">${this._t('rewards.sold_out')}</div>`;
|
||||
}
|
||||
if (reward.is_time_locked === true) {
|
||||
return html`<div class="availability-badge badge-time-locked">
|
||||
<ha-icon icon="mdi:clock-outline"></ha-icon>${this._timeLockLabel(reward)}
|
||||
</div>`;
|
||||
}
|
||||
const badges = [];
|
||||
if (typeof reward.quantity === 'number' && reward.quantity > 0 && reward.quantity <= 3) {
|
||||
badges.push(html`<div class="availability-badge badge-low-stock">${this._t('rewards.only_n_left', { count: reward.quantity })}</div>`);
|
||||
@@ -1872,14 +1893,17 @@ class TaskMateRewardsCard extends LitElement {
|
||||
const isSoldOut = reward.is_sold_out === true;
|
||||
const isExpired = reward.is_expired === true;
|
||||
const isUnavailable = isSoldOut || isExpired;
|
||||
// Time lock (#857) is temporary: it blocks claiming and greys the reward,
|
||||
// but leaves pool allocation alone — saving up is not spending.
|
||||
const isTimeLocked = reward.is_time_locked === true;
|
||||
const daysUntilExpiry = (typeof reward.days_until_expiry === 'number')
|
||||
? reward.days_until_expiry : null;
|
||||
|
||||
return {
|
||||
reward, isJackpot, enablePoolMode, displayCost, relevantChildren, relevantChild,
|
||||
childId, currentStars, childContributions, percentage, hasPendingClaim,
|
||||
spendable, poolFull, canAfford, isSoldOut, isExpired, isUnavailable, daysUntilExpiry,
|
||||
showClaim: !enablePoolMode && !hasPendingClaim && childId && !isUnavailable,
|
||||
spendable, poolFull, canAfford, isSoldOut, isExpired, isUnavailable, isTimeLocked, daysUntilExpiry,
|
||||
showClaim: !enablePoolMode && !hasPendingClaim && childId && !isUnavailable && !isTimeLocked,
|
||||
showPoolControls: enablePoolMode && childId && !hasPendingClaim && !isUnavailable,
|
||||
};
|
||||
}
|
||||
@@ -1888,6 +1912,7 @@ class TaskMateRewardsCard extends LitElement {
|
||||
_designAvail(d) {
|
||||
if (d.isExpired) return { label: this._t('rewards.expired'), tone: 'muted' };
|
||||
if (d.isSoldOut) return { label: this._t('rewards.sold_out'), tone: 'muted' };
|
||||
if (d.isTimeLocked) return { label: this._timeLockLabel(d.reward), tone: 'muted' };
|
||||
if (typeof d.reward.quantity === 'number' && d.reward.quantity > 0 && d.reward.quantity <= 3) {
|
||||
return { label: this._t('rewards.only_n_left', { count: d.reward.quantity }), tone: 'bad' };
|
||||
}
|
||||
@@ -2049,7 +2074,7 @@ class TaskMateRewardsCard extends LitElement {
|
||||
: html`<span class="muted rw-status">${d.currentStars} / ${d.displayCost}</span>`;
|
||||
|
||||
return html`
|
||||
<div class="rw-card ${d.isJackpot ? 'rw-jackpot' : ''} ${d.isUnavailable ? 'rw-unavail' : ''}"
|
||||
<div class="rw-card ${d.isJackpot ? 'rw-jackpot' : ''} ${d.isUnavailable || d.isTimeLocked ? 'rw-unavail' : ''}"
|
||||
style="--ac:${tone}">
|
||||
<div class="row rw-top">
|
||||
<span class="rw-emoji">${emoji}</span>
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
/**
|
||||
* TaskMate shared sound engine.
|
||||
*
|
||||
* Single home for every completion sound, so the child card, the admin panel
|
||||
* and the config-flow preview module all behave identically. Before this
|
||||
* existed the six synthesised sounds were copy-pasted into two files that had
|
||||
* already drifted apart (one still knew only about a long-gone "fart"/"fart_long"
|
||||
* pair while the real list had moved to fart1..fart10).
|
||||
*
|
||||
* Three kinds of sound:
|
||||
* synthesised — generated on the fly via Web Audio API, no files needed
|
||||
* built-in files — the CC0 fart mp3s shipped in this www folder
|
||||
* custom — user-uploaded, referenced as "custom:<file>" (#856)
|
||||
*
|
||||
* A custom sound is resolved through a lookup table the caller passes in (the
|
||||
* `custom_sounds` attribute of sensor.taskmate_chores), because its URL is
|
||||
* signed server-side — a bare Audio() carries no bearer token and would 401
|
||||
* against the auth-gated serve view.
|
||||
*
|
||||
* Mirrors window.__taskmate_design: exposed globally, no ES module imports.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
// Base URL for files shipped inside the integration's www folder. Registered
|
||||
// by frontend.py as a static path. NOT /local/, which maps to config/www and
|
||||
// is where these files have never lived — that mismatch silently 404'd every
|
||||
// fart sound until #856.
|
||||
const FILE_BASE = "/taskmate";
|
||||
|
||||
const CUSTOM_PREFIX = "custom:";
|
||||
const FART_COUNT = 10;
|
||||
|
||||
// Built-in names, mirroring COMPLETION_SOUND_OPTIONS in const.py.
|
||||
const BUILTIN = [
|
||||
"none", "coin", "levelup", "fanfare", "chime", "powerup", "undo",
|
||||
"fart1", "fart2", "fart3", "fart4", "fart5", "fart6", "fart7",
|
||||
"fart8", "fart9", "fart10", "fart_random",
|
||||
];
|
||||
|
||||
// One shared AudioContext per page. Browsers cap how many a page may create,
|
||||
// and each card minting its own used to leak one per card instance.
|
||||
let audioContext = null;
|
||||
|
||||
function getAudioContext() {
|
||||
if (!audioContext) {
|
||||
const Ctor = window.AudioContext || window.webkitAudioContext;
|
||||
if (!Ctor) return null;
|
||||
audioContext = new Ctor();
|
||||
}
|
||||
// Autoplay policy: a context created before any user gesture starts
|
||||
// suspended and stays silent until resumed.
|
||||
if (audioContext.state === "suspended") audioContext.resume();
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
function playCoinSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.3;
|
||||
|
||||
// First tone (E6)
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(masterGain);
|
||||
osc1.frequency.value = 1318.5; // E6
|
||||
osc1.type = 'square';
|
||||
gain1.gain.setValueAtTime(0.5, startTime);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.1);
|
||||
osc1.start(startTime);
|
||||
osc1.stop(startTime + 0.1);
|
||||
|
||||
// Second tone (B6) - higher
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain2 = ctx.createGain();
|
||||
osc2.connect(gain2);
|
||||
gain2.connect(masterGain);
|
||||
osc2.frequency.value = 1975.5; // B6
|
||||
osc2.type = 'square';
|
||||
gain2.gain.setValueAtTime(0.5, startTime + 0.08);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.25);
|
||||
osc2.start(startTime + 0.08);
|
||||
osc2.stop(startTime + 0.25);
|
||||
}
|
||||
|
||||
/**
|
||||
* Level up sound - triumphant ascending arpeggio
|
||||
*/
|
||||
function playLevelUpSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.25;
|
||||
|
||||
// C major arpeggio going up: C5, E5, G5, C6
|
||||
const notes = [523.25, 659.25, 783.99, 1046.5];
|
||||
const duration = 0.12;
|
||||
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'square';
|
||||
|
||||
const noteStart = startTime + i * duration;
|
||||
gain.gain.setValueAtTime(0.6, noteStart);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration + 0.1);
|
||||
osc.start(noteStart);
|
||||
osc.stop(noteStart + duration + 0.15);
|
||||
});
|
||||
|
||||
// Final sustained chord
|
||||
const chordNotes = [523.25, 659.25, 783.99]; // C major chord
|
||||
const chordStart = startTime + notes.length * duration;
|
||||
chordNotes.forEach((freq) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'triangle';
|
||||
gain.gain.setValueAtTime(0.3, chordStart);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, chordStart + 0.5);
|
||||
osc.start(chordStart);
|
||||
osc.stop(chordStart + 0.55);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fanfare sound - celebratory trumpet-like fanfare
|
||||
*/
|
||||
function playFanfareSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.2;
|
||||
|
||||
// Fanfare pattern: G4, G4, G4, E4, G4, C5 (classic celebration pattern)
|
||||
const pattern = [
|
||||
{ freq: 392.00, duration: 0.1, delay: 0 }, // G4
|
||||
{ freq: 392.00, duration: 0.1, delay: 0.12 }, // G4
|
||||
{ freq: 392.00, duration: 0.15, delay: 0.24 }, // G4
|
||||
{ freq: 329.63, duration: 0.15, delay: 0.42 }, // E4
|
||||
{ freq: 392.00, duration: 0.15, delay: 0.6 }, // G4
|
||||
{ freq: 523.25, duration: 0.4, delay: 0.78 }, // C5 (long final note)
|
||||
];
|
||||
|
||||
pattern.forEach(({ freq, duration, delay }) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'sawtooth';
|
||||
|
||||
const noteStart = startTime + delay;
|
||||
gain.gain.setValueAtTime(0.5, noteStart);
|
||||
gain.gain.setValueAtTime(0.5, noteStart + duration * 0.8);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + duration);
|
||||
|
||||
osc.start(noteStart);
|
||||
osc.stop(noteStart + duration + 0.05);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Chime sound - simple pleasant bell chime
|
||||
*/
|
||||
function playChimeSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.3;
|
||||
|
||||
// Bell-like sound using multiple harmonics
|
||||
const fundamental = 880; // A5
|
||||
const harmonics = [1, 2, 3, 4.2]; // Slight inharmonicity for bell-like quality
|
||||
|
||||
harmonics.forEach((harmonic, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
|
||||
osc.frequency.value = fundamental * harmonic;
|
||||
osc.type = 'sine';
|
||||
|
||||
// Higher harmonics decay faster
|
||||
const amplitude = 0.5 / (i + 1);
|
||||
const decayTime = 0.8 / (i + 1);
|
||||
|
||||
gain.gain.setValueAtTime(amplitude, startTime);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, startTime + decayTime);
|
||||
|
||||
osc.start(startTime);
|
||||
osc.stop(startTime + decayTime + 0.1);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Power up sound - ascending sweep with sparkle
|
||||
*/
|
||||
function playPowerUpSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.25;
|
||||
|
||||
// Ascending sweep
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(masterGain);
|
||||
osc1.type = 'sawtooth';
|
||||
osc1.frequency.setValueAtTime(200, startTime);
|
||||
osc1.frequency.exponentialRampToValueAtTime(1200, startTime + 0.3);
|
||||
gain1.gain.setValueAtTime(0.4, startTime);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.35);
|
||||
osc1.start(startTime);
|
||||
osc1.stop(startTime + 0.4);
|
||||
|
||||
// Sparkle notes at the end
|
||||
const sparkleNotes = [1318.5, 1567.98, 1975.5]; // E6, G6, B6
|
||||
sparkleNotes.forEach((freq, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.frequency.value = freq;
|
||||
osc.type = 'sine';
|
||||
|
||||
const noteStart = startTime + 0.25 + i * 0.05;
|
||||
gain.gain.setValueAtTime(0.3, noteStart);
|
||||
gain.gain.exponentialRampToValueAtTime(0.01, noteStart + 0.2);
|
||||
osc.start(noteStart);
|
||||
osc.stop(noteStart + 0.25);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Undo sound - sad descending "womp womp" style
|
||||
* Two descending tones that sound disappointed/sad
|
||||
*/
|
||||
function playUndoSound(ctx, startTime) {
|
||||
const masterGain = ctx.createGain();
|
||||
masterGain.connect(ctx.destination);
|
||||
masterGain.gain.value = 0.25;
|
||||
|
||||
// First "womp" - descending tone
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(masterGain);
|
||||
osc1.type = 'triangle';
|
||||
osc1.frequency.setValueAtTime(311.13, startTime); // Eb4
|
||||
osc1.frequency.exponentialRampToValueAtTime(233.08, startTime + 0.25); // Bb3
|
||||
gain1.gain.setValueAtTime(0.6, startTime);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.3, startTime + 0.2);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.01, startTime + 0.3);
|
||||
osc1.start(startTime);
|
||||
osc1.stop(startTime + 0.35);
|
||||
|
||||
// Second "womp" - even lower descending tone (the sad part)
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain2 = ctx.createGain();
|
||||
osc2.connect(gain2);
|
||||
gain2.connect(masterGain);
|
||||
osc2.type = 'triangle';
|
||||
osc2.frequency.setValueAtTime(233.08, startTime + 0.3); // Bb3
|
||||
osc2.frequency.exponentialRampToValueAtTime(155.56, startTime + 0.7); // Eb3
|
||||
gain2.gain.setValueAtTime(0.5, startTime + 0.3);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.25, startTime + 0.55);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.01, startTime + 0.75);
|
||||
osc2.start(startTime + 0.3);
|
||||
osc2.stop(startTime + 0.8);
|
||||
|
||||
// Optional: add a subtle low vibrato for extra sadness
|
||||
const osc3 = ctx.createOscillator();
|
||||
const gain3 = ctx.createGain();
|
||||
osc3.connect(gain3);
|
||||
gain3.connect(masterGain);
|
||||
osc3.type = 'sine';
|
||||
osc3.frequency.setValueAtTime(116.54, startTime + 0.5); // Bb2 (sub bass)
|
||||
gain3.gain.setValueAtTime(0.15, startTime + 0.5);
|
||||
gain3.gain.exponentialRampToValueAtTime(0.01, startTime + 0.8);
|
||||
osc3.start(startTime + 0.5);
|
||||
osc3.stop(startTime + 0.85);
|
||||
}
|
||||
/**
|
||||
* Play an audio file by URL. Used for the shipped fart mp3s and for
|
||||
* user-uploaded custom sounds.
|
||||
*/
|
||||
function playUrl(url) {
|
||||
try {
|
||||
const audio = new Audio(url);
|
||||
audio.volume = 1.0;
|
||||
audio.play().catch(() => { /* autoplay blocked until a user gesture */ });
|
||||
} catch (e) { /* no Audio support */ }
|
||||
}
|
||||
|
||||
function playFile(filename) {
|
||||
playUrl(`${FILE_BASE}/${filename}`);
|
||||
}
|
||||
|
||||
/** True for a well-formed "custom:<32hex>.<ext>" value. Mirrors sounds.is_custom_sound. */
|
||||
function isCustom(name) {
|
||||
return typeof name === "string"
|
||||
&& name.startsWith(CUSTOM_PREFIX)
|
||||
&& /^[0-9a-f]{32}\.(mp3|ogg|wav|m4a)$/.test(name.slice(CUSTOM_PREFIX.length));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a custom sound value to its signed URL via the lookup table.
|
||||
* `customSounds` is the list delivered on sensor.taskmate_chores.
|
||||
*/
|
||||
function customUrl(name, customSounds) {
|
||||
if (!isCustom(name) || !Array.isArray(customSounds)) return null;
|
||||
const hit = customSounds.find(s => s && s.value === name);
|
||||
return (hit && hit.url) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a completion sound by name.
|
||||
*
|
||||
* @param {string} name - built-in name, or "custom:<file>"
|
||||
* @param {Array} customSounds - optional lookup table for custom sounds
|
||||
*/
|
||||
function play(name, customSounds) {
|
||||
if (!name || name === "none") return;
|
||||
|
||||
if (isCustom(name)) {
|
||||
const url = customUrl(name, customSounds);
|
||||
// A dangling reference (sound deleted, or a card whose sensor data
|
||||
// predates the upload) stays silent rather than playing the wrong thing.
|
||||
if (url) playUrl(url);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === "fart_random") {
|
||||
playFile(`fart${Math.floor(Math.random() * FART_COUNT) + 1}.mp3`);
|
||||
return;
|
||||
}
|
||||
if (/^fart([1-9]|10)$/.test(name)) {
|
||||
playFile(`${name}.mp3`);
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx) return;
|
||||
const now = ctx.currentTime;
|
||||
try {
|
||||
switch (name) {
|
||||
case "coin": playCoinSound(ctx, now); break;
|
||||
case "levelup": playLevelUpSound(ctx, now); break;
|
||||
case "fanfare": playFanfareSound(ctx, now); break;
|
||||
case "chime": playChimeSound(ctx, now); break;
|
||||
case "powerup": playPowerUpSound(ctx, now); break;
|
||||
case "undo": playUndoSound(ctx, now); break;
|
||||
default: playCoinSound(ctx, now); break;
|
||||
}
|
||||
} catch (e) { /* a failed sound must never break a completion */ }
|
||||
}
|
||||
|
||||
window.__taskmate_sounds = { BUILTIN, CUSTOM_PREFIX, play, isCustom, customUrl, getAudioContext };
|
||||
})();
|
||||
Reference in New Issue
Block a user