diff --git a/app/apps/common/widgets/crispy/daisyui.py b/app/apps/common/widgets/crispy/daisyui.py index 93d4b070..6b457d82 100644 --- a/app/apps/common/widgets/crispy/daisyui.py +++ b/app/apps/common/widgets/crispy/daisyui.py @@ -3,3 +3,20 @@ from crispy_forms.layout import Field class Switch(Field): template = "crispy-daisyui/layout/switch.html" + + +class Range(Field): + template = "crispy-daisyui/layout/range.html" + + def render(self, form, context, extra_context=None, **kwargs): + extra_context = extra_context or {} + + # Build the measure (ticks + labels) from the field's min/max/step + attrs = form.fields[self.fields[0]].widget.attrs + if "min" in attrs and "max" in attrs: + step = attrs.get("step") or 1 + extra_context["range_steps"] = list( + range(int(attrs["min"]), int(attrs["max"]) + 1, int(step)) + ) + + return super().render(form, context, extra_context=extra_context, **kwargs) diff --git a/app/apps/common/widgets/range.py b/app/apps/common/widgets/range.py new file mode 100644 index 00000000..655824b8 --- /dev/null +++ b/app/apps/common/widgets/range.py @@ -0,0 +1,5 @@ +from django import forms + + +class RangeInput(forms.NumberInput): + input_type = "range" diff --git a/app/apps/users/forms.py b/app/apps/users/forms.py index 84e4b129..cb8b4c1d 100644 --- a/app/apps/users/forms.py +++ b/app/apps/users/forms.py @@ -2,7 +2,9 @@ from datetime import timedelta from apps.common.middleware.thread_local import get_current_user from apps.users.models import APIToken +from apps.common.widgets.crispy.daisyui import Range from apps.common.widgets.crispy.submit import NoClassSubmit +from apps.common.widgets.range import RangeInput from apps.common.widgets.tom_select import TomSelect from apps.users.models import UserSettings from apps.accounts.models import Account @@ -131,6 +133,20 @@ class UserSettingsForm(forms.ModelForm): required=False, ) + volume = forms.IntegerField( + min_value=0, + max_value=10, + label=_("Volume"), + help_text=_("Set to 0 to mute all sounds"), + widget=RangeInput( + attrs={ + "step": 1, + # Preview the selected volume + "_": "on change call playSound('sparkle', my valueAsNumber)", + } + ), + ) + class Meta: model = UserSettings fields = [ @@ -141,6 +157,7 @@ class UserSettingsForm(forms.ModelForm): "datetime_format", "number_format", "default_account", + "volume", ] widgets = { "default_account": TomSelect(clear_button=False, group_by="group"), @@ -167,7 +184,7 @@ class UserSettingsForm(forms.ModelForm): "start_page", "default_account", HTML('
'), - "volume", + Range("volume"), FormActions( NoClassSubmit("submit", _("Save"), css_class="btn btn-primary"), ), diff --git a/app/apps/users/migrations/0028_alter_usersettings_volume.py b/app/apps/users/migrations/0028_alter_usersettings_volume.py new file mode 100644 index 00000000..d84302e8 --- /dev/null +++ b/app/apps/users/migrations/0028_alter_usersettings_volume.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.16 on 2026-09-12 21:53 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0027_alter_usersettings_timezone'), + ] + + operations = [ + migrations.AlterField( + model_name='usersettings', + name='volume', + field=models.PositiveIntegerField(default=10, validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(10)], verbose_name='Volume'), + ), + ] diff --git a/app/apps/users/models.py b/app/apps/users/models.py index 11826e99..7b21216e 100644 --- a/app/apps/users/models.py +++ b/app/apps/users/models.py @@ -481,7 +481,7 @@ class UserSettings(models.Model): mute_sounds = models.BooleanField(default=False) volume = models.PositiveIntegerField( default=10, - validators=[MinValueValidator(1), MaxValueValidator(10)], + validators=[MinValueValidator(0), MaxValueValidator(10)], verbose_name=_("Volume"), ) diff --git a/app/templates/crispy-daisyui/layout/range.html b/app/templates/crispy-daisyui/layout/range.html new file mode 100644 index 00000000..7be13f60 --- /dev/null +++ b/app/templates/crispy-daisyui/layout/range.html @@ -0,0 +1,29 @@ +{% load crispy_forms_field %} + +{% if field.is_hidden %} + {{ field }} +{% else %} + <{% if tag %}{{ tag }}{% else %}fieldset{% endif %} id="div_{{ field.auto_id }}" class="fieldset{% if wrapper_class %} {{ wrapper_class }}{% endif %}{% if field.css_classes %} {{ field.css_classes }}{% endif %}"> + {% if field.label and form_show_labels %} + + {% endif %} +
+ {% if field.errors %} + {% crispy_field field 'class' 'range range-error w-full' %} + {% else %} + {% crispy_field field 'class' 'range range-primary w-full' %} + {% endif %} + {% if range_steps %} +
+ {% for step in range_steps %}|{% endfor %} +
+
+ {% for step in range_steps %}{{ step|stringformat:"02d" }}{% endfor %} +
+ {% endif %} +
+ {% include 'crispy-daisyui/layout/help_text_and_errors.html' %} + +{% endif %} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ff8038e1..77974ec7 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -25,7 +25,7 @@ "chart.js": "^4.5.1", "chartjs-chart-sankey": "^0.14.3", "chartjs-plugin-zoom": "^2.2.0", - "cuelume": "^0.1.2", + "cuelume": "^0.2.2", "daisyui": "5.5.20", "htmx.org": "^2.0.10", "hyperscript.org": "^0.9.91", @@ -1319,9 +1319,9 @@ } }, "node_modules/cuelume": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/cuelume/-/cuelume-0.1.2.tgz", - "integrity": "sha512-VxL0k9l1fyKGot3VKM+wm5Xd2K75fVeBHdNTrR1EHiqKRtD6S5Dxy/vY7hwl9gXp3QT/TIywFXUos77n39YZVQ==", + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/cuelume/-/cuelume-0.2.2.tgz", + "integrity": "sha512-TiNzJRjhddjC4jy4M7sv63V/86VaFhdIs6iHUCrc4CKhqypaxRBv1aedCBGebrz/cO7nroPkEfqHe/P7fOE2GQ==", "license": "MIT" }, "node_modules/daisyui": { diff --git a/frontend/package.json b/frontend/package.json index 85cf5755..8ee82716 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -32,7 +32,7 @@ "chart.js": "^4.5.1", "chartjs-chart-sankey": "^0.14.3", "chartjs-plugin-zoom": "^2.2.0", - "cuelume": "^0.1.2", + "cuelume": "^0.2.2", "daisyui": "5.5.20", "htmx.org": "^2.0.10", "hyperscript.org": "^0.9.91", diff --git a/frontend/src/js/sounds.js b/frontend/src/js/sounds.js index df884445..c3914810 100644 --- a/frontend/src/js/sounds.js +++ b/frontend/src/js/sounds.js @@ -1,4 +1,11 @@ import { play } from "cuelume"; -window.playSound = play; +// User volume is stored as 0-10 (0 = muted); cuelume expects a 0-1 gain. +// Loudness is perceived logarithmically, so a linear mapping makes adjacent +// steps sound nearly identical. Squaring spreads them out (10 → 0dB, 5 → -12dB, 1 → -40dB). +// Pass `volume` to override the user's saved volume (e.g. to preview a new value). +window.playSound = (name, volume) => { + volume ??= JSON.parse(document.getElementById("volume")?.textContent ?? "10"); + play(name, { volume: (volume / 10) ** 2 }); +};