diff --git a/CHANGELOG.md b/CHANGELOG.md index e28a52688..ee2156519 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +3,11 @@ # Future Release * Web App * Fixed internet radio search functionality + * Upgraded volume calculations to preserve relative positions when hitting the min or max setting via source volume bar +* Streams: + * Upgraded Spotify to sync Spotify's volume with AmpliPi and vice-versa # 0.4.9 - * System * Update our spotify provider `go-librespot` to `0.5.2` to accomodate spotify's API update diff --git a/amplipi/ctrl.py b/amplipi/ctrl.py index e3665efdf..c07efbc45 100644 --- a/amplipi/ctrl.py +++ b/amplipi/ctrl.py @@ -847,14 +847,18 @@ def set_mute(): def set_vol(): """ Update the zone's volume. Could be triggered by a change in - vol, vol_f, vol_min, or vol_max. + vol, vol_f, vol_f_delta, vol_min, or vol_max. """ # Field precedence: vol (db) > vol_delta > vol (float) - # NOTE: checks happen in reverse precedence to cover default case of unchanged volume + # vol (db) is first in precedence yet last in the stack to cover the default case of a None volume change, but when it does have a value it overrides the other options if update.vol_delta_f is not None and update.vol is None: - applied_delta = utils.clamp((vol_delta_f + zone.vol_f), 0, 1) - vol_db = utils.vol_float_to_db(applied_delta, zone.vol_min, zone.vol_max) - vol_f_new = applied_delta + true_vol_f = zone.vol_f + zone.vol_f_overflow + expected_vol_total = update.vol_delta_f + true_vol_f + vol_f_new = utils.clamp(expected_vol_total, models.MIN_VOL_F, models.MAX_VOL_F) + + vol_db = utils.vol_float_to_db(vol_f_new, zone.vol_min, zone.vol_max) + zone.vol_f_overflow = 0 if models.MIN_VOL_F < expected_vol_total and expected_vol_total < models.MAX_VOL_F else utils.clamp((expected_vol_total - vol_f_new), models.MIN_VOL_F_OVERFLOW, models.MAX_VOL_F_OVERFLOW) # Clamp the remaining delta to be between -1 and 1 + elif update.vol_f is not None and update.vol is None: clamp_vol_f = utils.clamp(vol_f, 0, 1) vol_db = utils.vol_float_to_db(clamp_vol_f, zone.vol_min, zone.vol_max) @@ -866,9 +870,14 @@ def set_vol(): if self._rt.update_zone_vol(idx, vol_db): zone.vol = vol_db zone.vol_f = vol_f_new + else: raise Exception('unable to update zone volume') + # If the change made vol f be between the min and max, delete the overflow + # This is useful so that you can click wherever you want on the volume bar and expect it to end up there without rubberbanding back to whatever vol_f + vol_f_overflow value you'd otherwise be at + zone.vol_f_overflow = 0 if vol_f_new != models.MIN_VOL_F and vol_f_new != models.MAX_VOL_F else zone.vol_f_overflow + # To avoid potential unwanted loud output: # If muting, mute before setting volumes # If un-muting, set desired volume first diff --git a/amplipi/defaults.py b/amplipi/defaults.py index 236e53e4f..76f69eb3a 100644 --- a/amplipi/defaults.py +++ b/amplipi/defaults.py @@ -41,17 +41,17 @@ ], "zones": [ # this is an array of zones, array length depends on # of boxes connected {"id": 0, "name": "Zone 1", "source_id": 0, "mute": True, "disabled": False, - "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "vol_f": models.MIN_VOL_F, "vol_f_overflow": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, {"id": 1, "name": "Zone 2", "source_id": 0, "mute": True, "disabled": False, - "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "vol_f": models.MIN_VOL_F, "vol_f_overflow": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, {"id": 2, "name": "Zone 3", "source_id": 0, "mute": True, "disabled": False, - "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "vol_f": models.MIN_VOL_F, "vol_f_overflow": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, {"id": 3, "name": "Zone 4", "source_id": 0, "mute": True, "disabled": False, - "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "vol_f": models.MIN_VOL_F, "vol_f_overflow": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, {"id": 4, "name": "Zone 5", "source_id": 0, "mute": True, "disabled": False, - "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "vol_f": models.MIN_VOL_F, "vol_f_overflow": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, {"id": 5, "name": "Zone 6", "source_id": 0, "mute": True, "disabled": False, - "vol_f": models.MIN_VOL_F, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, + "vol_f": models.MIN_VOL_F, "vol_f_overflow": 0.0, "vol_min": models.MIN_VOL_DB, "vol_max": models.MAX_VOL_DB}, ], "groups": [ ], diff --git a/amplipi/models.py b/amplipi/models.py index e5ac7c2ac..11f1804af 100644 --- a/amplipi/models.py +++ b/amplipi/models.py @@ -38,6 +38,12 @@ MAX_VOL_F = 1.0 """ Max volume for slider bar. Will be mapped to dB. """ +MIN_VOL_F_OVERFLOW = MIN_VOL_F - MAX_VOL_F +"""Min overflow for volume sliders, set to be the full range of vol_f below zero""" + +MAX_VOL_F_OVERFLOW = MAX_VOL_F - MIN_VOL_F +"""Max overflow for volume sliders, set to be the full range of vol_f above zero""" + MIN_VOL_DB = -80 """ Min volume in dB. -80 is special and is actually -90 dB (mute). """ @@ -111,6 +117,8 @@ class fields_w_default(SimpleNamespace): Volume = Field(default=MIN_VOL_DB, ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Output volume in dB') VolumeF = Field(default=MIN_VOL_F, ge=MIN_VOL_F, le=MAX_VOL_F, description='Output volume as a floating-point scalar from 0.0 to 1.0 representing MIN_VOL_DB to MAX_VOL_DB') + VolumeFOverflow = Field(default=0.0, ge=MIN_VOL_F_OVERFLOW, le=MAX_VOL_F_OVERFLOW, + description='Output volume as a floating-point scalar that has a range equal to MIN_VOL_F - MAX_VOL_F in both directions from zero, and is used to keep track of the relative distance between two or more zone volumes when they would otherwise have to exceed their VOL_F range') VolumeMin = Field(default=MIN_VOL_DB, ge=MIN_VOL_DB, le=MAX_VOL_DB, description='Min output volume in dB') VolumeMax = Field(default=MAX_VOL_DB, ge=MIN_VOL_DB, le=MAX_VOL_DB, @@ -321,6 +329,7 @@ class Zone(Base): mute: bool = fields_w_default.Mute vol: int = fields_w_default.Volume vol_f: float = fields_w_default.VolumeF + vol_f_overflow: float = fields_w_default.VolumeFOverflow vol_min: int = fields_w_default.VolumeMin vol_max: int = fields_w_default.VolumeMax disabled: bool = fields_w_default.Disabled diff --git a/amplipi/streams/__init__.py b/amplipi/streams/__init__.py index f094c9bd8..d141999a6 100644 --- a/amplipi/streams/__init__.py +++ b/amplipi/streams/__init__.py @@ -65,7 +65,7 @@ def build_stream(stream: models.Stream, mock: bool = False, validate: bool = True) -> AnyStream: """ Build a stream from the generic arguments given in stream, discriminated by stream.type - we are waiting on Pydantic's implemenatation of discriminators to fully integrate streams into our model definitions + we are waiting on Pydantic's implementation of discriminators to fully integrate streams into our model definitions """ # pylint: disable=too-many-return-statements args = stream.dict(exclude_none=True) diff --git a/amplipi/streams/airplay.py b/amplipi/streams/airplay.py index a000ae097..cfa83eb34 100644 --- a/amplipi/streams/airplay.py +++ b/amplipi/streams/airplay.py @@ -7,6 +7,9 @@ import time import os import io +import sys +import threading +import json def write_sp_config_file(filename, config): @@ -42,6 +45,29 @@ def __init__(self, name: str, ap2: bool, disabled: bool = False, mock: bool = Fa self._connect_time = 0.0 self._coverart_dir = '' self._log_file: Optional[io.TextIOBase] = None + self.src_config_folder: Optional[str] = None + self.volume_watcher_process: Optional[threading.Thread] = None # Populates the fifo that the vol sync script depends on + self.volume_sync_process: Optional[subprocess.Popen] = None + self._volume_fifo: Optional[str] = None + + def watch_vol(self): + """Creates and supplies a FIFO with volume data for volume sync""" + while True: + try: + if self.src is not None: + if self._volume_fifo is None and self.src_config_folder is not None: + fifo_path = f"{self.src_config_folder}/vol" + if not os.path.isfile(fifo_path): + os.mkfifo(fifo_path) + self._volume_fifo = os.open(fifo_path, os.O_WRONLY, os.O_NONBLOCK) + data = json.dumps({ + 'zones': self.connected_zones, + 'volume': self.volume, + }) + os.write(self._volume_fifo, bytearray(f"{data}\r\n", encoding="utf8")) + except Exception as e: + logger.error(f"{self.name} volume thread ran into exception: {e}") + time.sleep(0.1) def reconfig(self, **kwargs): self.validate_stream(**kwargs) @@ -71,9 +97,9 @@ def _activate(self, vsrc: int): logger.info(f'Another Airplay 2 stream is already in use, unable to start {self.name}, mocking connection') return - src_config_folder = f'{utils.get_folder("config")}/srcs/v{vsrc}' + self.src_config_folder = f'{utils.get_folder("config")}/srcs/v{vsrc}' try: - os.remove(f'{src_config_folder}/currentSong') + os.remove(f'{self.src_config_folder}/currentSong') except FileNotFoundError: pass self._connect_time = time.time() @@ -86,9 +112,9 @@ def _activate(self, vsrc: int): 'name': self.name, 'port': 5100 + 100 * vsrc, # Listen for service requests on this port 'udp_port_base': 6101 + 100 * vsrc, # start allocating UDP ports from this port number when needed - 'drift': 2000, # allow this number of frames of drift away from exact synchronisation before attempting to correct it - 'resync_threshold': 0, # a synchronisation error greater than this will cause resynchronisation; 0 disables it - 'log_verbosity': 0, # "0" means no debug verbosity, "3" is most verbose. + 'drift_in_seconds': 2, # allow this number of frames of drift away from exact synchronisation before attempting to correct it + 'resync_threshold_in_seconds': 0, # a synchronisation error greater than this will cause resynchronisation; 0 disables it + 'log_verbosity': "diagnostics", # "none" means no debug verbosity, "diagnostics" is most verbose. 'mpris_service_bus': 'Session', }, 'metadata': { @@ -99,7 +125,7 @@ def _activate(self, vsrc: int): 'alsa': { 'output_device': utils.virtual_output_device(vsrc), # alsa output device # If set too small, buffer underflow occurs on low-powered machines. Too long and the response times with software mixer become annoying. - 'audio_backend_buffer_desired_length': 11025 + 'audio_backend_buffer_desired_length': 11025, }, } @@ -109,10 +135,10 @@ def _activate(self, vsrc: int): except FileNotFoundError: pass os.makedirs(self._coverart_dir, exist_ok=True) - os.makedirs(src_config_folder, exist_ok=True) - config_file = f'{src_config_folder}/shairport.conf' + os.makedirs(self.src_config_folder, exist_ok=True) + config_file = f'{self.src_config_folder}/shairport.conf' write_sp_config_file(config_file, config) - self._log_file = open(f'{src_config_folder}/log', mode='w') + self._log_file = open(f'{self.src_config_folder}/log', mode='w') shairport_args = f"{utils.get_folder('streams')}/shairport-sync{'-ap2' if self.ap2 else ''} -c {config_file}".split(' ') logger.info(f'shairport_args: {shairport_args}') @@ -125,7 +151,15 @@ def _activate(self, vsrc: int): # shairport sync only adds the pid to the mpris name if it cannot use the default name if len(os.popen("pgrep shairport-sync").read().strip().splitlines()) > 1: mpris_name += f".i{self.proc.pid}" - self.mpris = MPRIS(mpris_name, f'{src_config_folder}/metadata.txt') + self.mpris = MPRIS(mpris_name, f'{self.src_config_folder}/metadata.txt') + + vol_sync = f"{utils.get_folder('streams')}/shairport_volume_handler.py" + vol_args = [sys.executable, vol_sync, mpris_name, f"{utils.get_folder('config')}/srcs/v{self.vsrc}"] + + logger.info(f'{self.name}: starting vol synchronizer: {vol_args}') + self.volume_watcher_process = threading.Thread(target=self.watch_vol, daemon=True) + self.volume_watcher_process.start() + self.volume_sync_process = subprocess.Popen(args=vol_args, stdout=self._log_file, stderr=self._log_file) except Exception as exc: logger.exception(f'Error starting airplay MPRIS reader: {exc}') @@ -135,12 +169,22 @@ def _deactivate(self): self.mpris = None if self._is_running(): self.proc.stdin.close() + logger.info('stopping shairport-sync') self.proc.terminate() + if self.volume_sync_process is not None: + self.volume_sync_process.terminate() + if self.proc.wait(1) != 0: logger.info('killing shairport-sync') self.proc.kill() self.proc.communicate() + + if self.volume_sync_process is not None: + if self.volume_sync_process.wait(1) != 0: + logger.info('killing shairport vol sync') + self.volume_sync_process.kill() + if '_log_file' in self.__dir__() and self._log_file: self._log_file.close() if self.src: @@ -149,7 +193,11 @@ def _deactivate(self): except Exception as e: logger.exception(f'Error removing airplay config files: {e}') self._disconnect() + self.proc = None + self.volume_sync_process = None + self.volume_watcher_process = None + self._volume_fifo = None def info(self) -> models.SourceInfo: source = models.SourceInfo( diff --git a/amplipi/streams/base_streams.py b/amplipi/streams/base_streams.py index b8645d237..01afae4b7 100644 --- a/amplipi/streams/base_streams.py +++ b/amplipi/streams/base_streams.py @@ -5,6 +5,7 @@ import logging from amplipi import models from amplipi import utils +from amplipi import app logger = logging.getLogger(__name__) logger.level = logging.DEBUG @@ -50,6 +51,7 @@ class BaseStream: """ BaseStream class containing methods that all other streams inherit """ def __init__(self, stype: str, name: str, only_src=None, disabled: bool = False, mock: bool = False, validate: bool = True, **kwargs): + self.name = name self.disabled = disabled self.proc: Optional[subprocess.Popen] = None @@ -62,6 +64,24 @@ def __init__(self, stype: str, name: str, only_src=None, disabled: bool = False, if validate: self.validate_stream(name=name, mock=mock, **kwargs) + def get_zone_data(self): + if self.src is not None: + ctrl = app.get_ctrl() + state = ctrl.get_state() + return [zone for zone in state.zones if zone.source_id == self.src] + + @property + def connected_zones(self) -> List[int]: + connected_zones = self.get_zone_data() + return [zone.id for zone in connected_zones] + + @property + def volume(self) -> float: + connected_zones = self.get_zone_data() + if connected_zones: + return sum([zone.vol_f for zone in connected_zones]) / len(connected_zones) + return 0 + def __del__(self): self.disconnect() @@ -242,7 +262,7 @@ def deactivate(self): raise Exception(f'Failed to deactivate {self.name}: {e}') from e finally: self.state = "disconnected" # make this look like a normal stream for now - if 'vsrc' in self.__dir__() and self.vsrc: + if 'vsrc' in self.__dir__() and self.vsrc is not None: vsrc = self.vsrc self.vsrc = None vsources.free(vsrc) diff --git a/amplipi/streams/spotify_connect.py b/amplipi/streams/spotify_connect.py index 34e8b105d..460d8ed12 100644 --- a/amplipi/streams/spotify_connect.py +++ b/amplipi/streams/spotify_connect.py @@ -2,19 +2,27 @@ import io import os +import threading import re import sys import subprocess import time from typing import ClassVar, Optional import yaml +import logging +import json from amplipi import models, utils -from .base_streams import PersistentStream, InvalidStreamField, logger +from .base_streams import PersistentStream, InvalidStreamField from .. import tasks # Our subprocesses run behind the scenes, is there a more standard way to do this? # pylint: disable=consider-using-with +logger = logging.getLogger(__name__) +logger.level = logging.DEBUG +sh = logging.StreamHandler(sys.stdout) +logger.addHandler(sh) + class SpotifyConnect(PersistentStream): """ A SpotifyConnect Stream based off librespot-go """ @@ -33,9 +41,30 @@ def __init__(self, name: str, disabled: bool = False, mock: bool = False, valida self._log_file: Optional[io.TextIOBase] = None self._api_port: int self.proc2: Optional[subprocess.Popen] = None + self.volume_sync_process: Optional[subprocess.Popen] = None # Runs the actual vol sync script + self.volume_watcher_process: Optional[threading.Thread] = None # Populates the fifo that the vol sync script depends on + self.src_config_folder: Optional[str] = None self.meta_file: str = '' - self.max_volume: int = 100 # default configuration from 'volume_steps' - self.last_volume: float = 0 + self._volume_fifo = None + + def watch_vol(self): + """Creates and supplies a FIFO with volume data for volume sync""" + while True: + try: + if self.src is not None: + if self._volume_fifo is None and self.src_config_folder is not None: + fifo_path = f"{self.src_config_folder}/vol" + if not os.path.isfile(fifo_path): + os.mkfifo(fifo_path) + self._volume_fifo = os.open(fifo_path, os.O_WRONLY, os.O_NONBLOCK) + data = json.dumps({ + 'zones': self.connected_zones, + 'volume': self.volume, + }) + os.write(self._volume_fifo, bytearray(f"{data}\r\n", encoding="utf8")) + except Exception as e: + logger.error(f"{self.name} volume thread ran into exception: {e}") + time.sleep(0.1) def reconfig(self, **kwargs): self.validate_stream(**kwargs) @@ -52,9 +81,9 @@ def _activate(self, vsrc: int): """ Connect to a given audio source """ - src_config_folder = f'{utils.get_folder("config")}/srcs/v{vsrc}' + self.src_config_folder = f'{utils.get_folder("config")}/srcs/v{vsrc}' try: - os.remove(f'{src_config_folder}/currentSong') + os.remove(f'{self.src_config_folder}/currentSong') except FileNotFoundError: pass self._connect_time = time.time() @@ -78,16 +107,16 @@ def _activate(self, vsrc: int): } # make all of the necessary dir(s) & files - os.makedirs(src_config_folder, exist_ok=True) + os.makedirs(self.src_config_folder, exist_ok=True) - config_file = f'{src_config_folder}/config.yml' + config_file = f'{self.src_config_folder}/config.yml' with open(config_file, 'w', encoding='utf8') as f: f.write(yaml.dump(config)) - self.meta_file = f'{src_config_folder}/metadata.json' + self.meta_file = f'{self.src_config_folder}/metadata.json' - self._log_file = open(f'{src_config_folder}/log', mode='w', encoding='utf8') - player_args = f"{utils.get_folder('streams')}/go-librespot --config_dir {src_config_folder}".split(' ') + self._log_file = open(f'{self.src_config_folder}/log', mode='w', encoding='utf8') + player_args = f"{utils.get_folder('streams')}/go-librespot --config_dir {self.src_config_folder}".split(' ') logger.debug(f'spotify player args: {player_args}') self.proc = subprocess.Popen(args=player_args, stdin=subprocess.PIPE, @@ -99,20 +128,45 @@ def _activate(self, vsrc: int): logger.info(f'{self.name}: starting metadata reader: {meta_args}') self.proc2 = subprocess.Popen(args=meta_args, stdout=self._log_file, stderr=self._log_file) + vol_sync = f"{utils.get_folder('streams')}/spotify_volume_handler.py" + vol_args = [sys.executable, vol_sync, str(self._api_port), self.src_config_folder, "--debug"] + logger.info(f'{self.name}: starting vol synchronizer: {vol_args}') + self.volume_sync_process = subprocess.Popen(args=vol_args, stdout=self._log_file, stderr=self._log_file) + + self.volume_watcher_process = threading.Thread(target=self.watch_vol, daemon=True) + self.volume_watcher_process.start() + def _deactivate(self): if self._is_running(): self.proc.stdin.close() logger.info(f'{self.name}: stopping player') + + # Call terminate on all processes self.proc.terminate() self.proc2.terminate() + if self.volume_sync_process: + self.volume_sync_process.terminate() + + # Ensure the processes have closed, by force if necessary if self.proc.wait(1) != 0: logger.info(f'{self.name}: killing player') self.proc.kill() + if self.proc2.wait(1) != 0: logger.info(f'{self.name}: killing metadata reader') self.proc2.kill() + + if self.volume_sync_process: + if self.volume_sync_process.wait(1) != 0: + logger.info(f'{self.name}: killing volume synchronizer') + self.volume_sync_process.kill() + + # Validate on the way out self.proc.communicate() self.proc2.communicate() + if self.volume_sync_process: + self.volume_sync_process.communicate() + if self.proc and self._log_file: # prevent checking _log_file when it may not exist, thanks validation! self._log_file.close() if self.src: @@ -121,8 +175,12 @@ def _deactivate(self): except Exception as e: logger.exception(f'{self.name}: Error removing config files: {e}') self._disconnect() + self.proc = None self.proc2 = None + self.volume_sync_process = None + self.volume_watcher_process = None + self._volume_fifo = None def info(self) -> models.SourceInfo: source = models.SourceInfo( @@ -190,10 +248,3 @@ def validate_stream(self, **kwargs): NAME = r"[a-zA-Z0-9][A-Za-z0-9\- ]*[a-zA-Z0-9]" if 'name' in kwargs and not re.fullmatch(NAME, kwargs['name']): raise InvalidStreamField("name", "Invalid stream name") - - def sync_volume(self, volume: float) -> None: - """ Set the volume of amplipi to the Spotify Connect stream""" - if volume != self.last_volume: - url = f"http://localhost:{self._api_port}/" - self.last_volume = volume # update last_volume for future syncs - tasks.post.delay(url + 'volume', data={'volume': int(volume * self.max_volume)}) diff --git a/streams/shairport_volume_handler.py b/streams/shairport_volume_handler.py new file mode 100644 index 000000000..3ef564ae1 --- /dev/null +++ b/streams/shairport_volume_handler.py @@ -0,0 +1,80 @@ +"""Script for synchronizing AmpliPi and Spotify volumes""" +import argparse +from time import sleep +from dasbus.connection import SessionMessageBus +from dasbus.typing import Variant +from volume_synchronizer import VolSyncDispatcher, StreamWatcher, VolEvents + + +class ShairportWatcher(StreamWatcher): + """A class that watches and tracks changes to airplay-side volume""" + + def __init__(self, service_suffix: str): + super().__init__() + self.mpris = SessionMessageBus().get_proxy( + service_name=f"org.mpris.MediaPlayer2.{service_suffix}", + object_path="/org/mpris/MediaPlayer2", + interface_name="org.mpris.MediaPlayer2.Player" + ) + + self.dbus = SessionMessageBus().get_proxy( + service_name=f"org.mpris.MediaPlayer2.{service_suffix}", + object_path="/org/mpris/MediaPlayer2", + interface_name="org.freedesktop.DBus.Properties" + ) + + async def watch_vol(self): + """Watch the shairport mpris stream for volume changes and update amplipi volume info accordingly""" + while True: + try: + if self.volume != self.mpris.Volume: + self.logger.debug(f"Airplay volume changed from {self.volume} to {self.mpris.Volume}") + self.volume = float(self.mpris.Volume) + self.schedule_event(VolEvents.CHANGE_AMPLIPI) + # self.delta = self.mpris.Volume - self.volume + + except Exception as e: + self.logger.exception(f"Error: {e}") + return + sleep(0.1) + + def set_vol(self, amplipi_volume: float, vol_set_point: float) -> float: # This has unused variable vol_set_point to keep up with the underlying StreamData.set_vol function schema + """Update Airplay's volume slider to match AmpliPi""" + try: + # Airplay does not allow external devices to set the volume of a users system + + # Airplay is a fully authoritative volume source, meaning it forces amplipi volume to equal its volume now. If that ever changes, this will be relevant: + # There are two values this could realistically be returned and become the new vol_set_point, and they each have their own drawbacks: + + # amplipi_volume: If amplipi_volume is the new set point, any changes to airplay volume will send the volume to an odd + # spot as it just sets the vol average of amplipi to be the same as the value of airplay's vol + + # vol_set_point: if vol_set_point is retained as the set point, any changes to amplipi will reflect for 1-2 seconds at most and then + # bounce back to where it had been, resulting in a glitchy front end interface + + # In any future MPRIS based volume synchronizers, you can check if self.mpris.CanControl is true and then potentially directly set self.mpris.Volume + # Note that we cannot do this due to this line: + # That exists in the MPRIS config xml at https://github.com/mikebrady/shairport-sync/blob/master/org.mpris.MediaPlayer2.xml + + # self.dbus.Set( + # 'org.mpris.MediaPlayer2', + # 'Volume', + # Variant("d", amplipi_volume) + # ) + + return amplipi_volume + except Exception as e: + self.logger.exception(f"Exception: {e}") + + +if __name__ == "__main__": + + parser = argparse.ArgumentParser(description="Read metadata from a given URL and write it to a file.") + + parser.add_argument("service_suffix", help="Name of mpris instance", type=str) + parser.add_argument("config_dir", help="The directory of the vsrc config", type=str) + parser.add_argument("--debug", action="store_true", help="Change log level from WARNING to DEBUG") + + args = parser.parse_args() + + handler = VolSyncDispatcher(ShairportWatcher(service_suffix=args.service_suffix), args.config_dir, args.debug) diff --git a/streams/spotify_volume_handler.py b/streams/spotify_volume_handler.py new file mode 100644 index 000000000..4a7ffff8d --- /dev/null +++ b/streams/spotify_volume_handler.py @@ -0,0 +1,85 @@ +"""Script for synchronizing AmpliPi and Spotify volumes""" +import argparse +import json +import logging +import sys + +import websockets +import requests + +from volume_synchronizer import VolSyncDispatcher, StreamWatcher, VolEvents +from spot_connect_meta import Event + + +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) +sh = logging.StreamHandler(sys.stdout) +logger.addHandler(sh) + + +class SpotifyWatcher(StreamWatcher): + """A class that watches and tracks changes to spotify-side volume""" + + def __init__(self, api_port: int): + super().__init__() + self.api_port: int = api_port + """What port is go-librespot running on? Typically set to 3678 + vsrc.""" + + async def watch_vol(self): + """Watch the go-librespot websocket endpoint for volume change events and update AmpliPi volume info accordingly""" + try: + # Connect to the websocket and listen for state changes + # pylint: disable=E1101 + # E1101: Module 'websockets' has no 'connect' member (no-member) + async with websockets.connect(f"ws://localhost:{self.api_port}/events", open_timeout=5) as websocket: + while True: + try: + msg = await websocket.recv() + event = Event.from_json(json.loads(msg)) + if event.event_type == "volume": + last_volume = float(self.volume) if self.volume is not None else None + self.volume = event.data.value / 100 # Translate spotify volume (0 - 100) to amplipi volume (0 - 1) + + self.logger.debug(f"Spotify volume changed from {last_volume} to {self.volume}") + if last_volume is not None and self.volume != last_volume: + self.schedule_event(VolEvents.CHANGE_AMPLIPI) + elif event.event_type == "will_play" and self.volume is None: + self.schedule_event(VolEvents.CHANGE_STREAM) # Intercept the event that occurs when a song starts playing and use that as a trigger for the initial state sync + + except Exception as e: + self.logger.exception(f"Error: {e}") + return + except Exception as e: + self.logger.exception(f"Error: {e}") + return + + def set_vol(self, new_vol: float, vol_set_point: float) -> float: + """Update Spotify's volume slider""" + try: + if new_vol is None: + return vol_set_point + + if abs(new_vol - vol_set_point) <= 0.005 and self.volume is not None: + self.logger.debug("Ignored minor AmpliPi -> Spotify change") + return vol_set_point + + url = f"http://localhost:{self.api_port}/player/volume" + spot_vol = int(new_vol * 100) + self.logger.debug(f"Setting Spotify volume to {new_vol} from {self.volume}") + requests.post(url, json={"volume": spot_vol}, timeout=5) + return new_vol + except Exception as e: + self.logger.exception(f"Exception: {e}") + + +if __name__ == "__main__": + + parser = argparse.ArgumentParser(description="Read metadata from a given URL and write it to a file.") + + parser.add_argument("port", help="port that go-librespot is running on", type=int) + parser.add_argument("config_dir", help="The directory of the vsrc config", type=str) + parser.add_argument("--debug", action="store_true", help="Change log level from WARNING to DEBUG") + + args = parser.parse_args() + + handler = VolSyncDispatcher(SpotifyWatcher(api_port=args.port), args.config_dir, args.debug) diff --git a/streams/volume_synchronizer.py b/streams/volume_synchronizer.py new file mode 100644 index 000000000..0ba5e52bd --- /dev/null +++ b/streams/volume_synchronizer.py @@ -0,0 +1,215 @@ +"""Classes for synchronizing AmpliPi volume with the internal volume of a given stream""" +import json +import asyncio +import threading +import queue +import logging +import sys +from typing import Callable, List, Optional +from enum import Enum +import requests + + +class VolEvents(Enum): + CHANGE_STREAM = "change_stream" + CHANGE_AMPLIPI = "change_amplipi" + + +class StreamWatcher: + """ + A class that is used as a blueprint for stream volume watchers + Child classes must provide the following functions. Both of these functions are automatically used by the VolSyncDispatcher, so there's no need to do anything with them: + + watch_vol: a function that contains a while True loop that collects the remote volume, sets self.volume, and calls self.schedule_event(VolEvents.CHANGE_AMPLIPI) when the volume changes + + set_vol: a function that takes the new_volume as well as the previous volume_set_point (both floats) and returns the new set point volume (either the previous set point if the change was unsuccessful or the newly recognized volume) + """ + + def __init__(self): + self.schedule_event: Callable[[VolEvents]] + """Event scheduler function provided by VolSyncDispatcher, has limited valid inputs that can be seen in the VolEvents enum""" + + self._volume: float = None + """Value between 0 and 1, or None if not yet initialized by the upstream""" + + self.delta: Optional[float] = None + self.logger: logging.Logger + """logging.Logger instance provided by VolSyncDispatcher""" + + self.thread: threading.Thread = threading.Thread(target=self.run_async_watch, daemon=True) + self.thread.start() + + @property + def volume(self) -> Optional[float]: + """Value between 0 and 1, or None if not yet initialized by the upstream""" + return self._volume + + @volume.setter + def volume(self, value: float) -> None: + if 0 > value or value > 1: + raise ValueError("Volume must be between 0 and 1") + self._volume = value + + def run_async_watch(self): + """Middleman function for creating an asyncio run inside of a new threading.Thread""" + asyncio.run(self.watch_vol()) + + async def watch_vol(self): + """A function to be implemented by child classes that must contain a while True loop and do self.schedule_event(VolEvents.CHANGE_AMPLIPI) when new_vol != old_vol""" + raise NotImplementedError("Function must be implemented by child classes") + + def set_vol(self, new_vol: float, vol_set_point: float) -> float: + """A function to be implemented by child classes to update the stream's volume and returns the new set point volume""" + raise NotImplementedError("Function must be implemented by child classes") + + +class AmpliPiWatcher: + """ + A class to watch changes to a streams vol fifo and change the volume of connected zones + Already fully handled by VolSyncDispatcher and should not be used by itself + """ + + def __init__(self, config_dir: str, schedule_event: Callable, logger: logging.Logger): + self.schedule_event: Callable[[VolEvents]] = schedule_event + """Event scheduler function provided by VolSyncDispatcher, has limited valid inputs that can be seen in the VolEvents enum""" + + self.logger: logging.Logger = logger + self.volume: Optional[float] = None + self.config_dir: str = config_dir + + self.connected_zones: List[int] = [] + """List of zone ids, used to send volume change requests to these connected zones""" + + self.thread = threading.Thread(target=self.get_vol, daemon=True) + self.thread.start() + + def get_vol(self): + """ + Read the volume FIFO from .config/amplipi/srcs/v{vsrc}/vol to load the currently connected zones and the averaged volume of them + If the read volume is different than the previous volume, send a volume change event to the stream + """ + try: + with open(f'{self.config_dir}/vol', 'r') as fifo: + while True: + data = json.loads(fifo.readline().strip()) + if self.volume != data["volume"]: + self.volume = data["volume"] + self.schedule_event(VolEvents.CHANGE_STREAM) + self.connected_zones = data["zones"] + except Exception as e: + self.logger.exception(f"Error while getting writing to {self.config_dir}/vol fifo: {e}") + + def set_vol(self, stream_volume: float, vol_set_point: float): + """Update AmpliPi's volume to match the stream volume""" + try: + if stream_volume is None: + return vol_set_point + + if abs(stream_volume - self.volume) <= 0.005: + self.logger.debug("Ignored minor Stream -> AmpliPi change") + return vol_set_point + + delta = float(stream_volume - self.volume) + return self.set_vol_delta(delta) + except Exception as e: + self.logger.exception(f"Exception: {e}") + + def set_vol_delta(self, delta: float): + """Update AmpliPi's volume by delta""" + try: + expected_volume = self.volume + delta + self.logger.debug(f"Setting AmpliPi volume to {expected_volume} from {self.volume}") + requests.patch( + "http://localhost/api/zones", + json={ + "zones": self.connected_zones, + "update": {"vol_delta_f": delta, "mute": False}, + }, + timeout=5, + ) + return expected_volume + except Exception as e: + self.logger.exception(f"Exception: {e}") + + +class VolSyncDispatcher: + """ + Volume synchronizer for AmpliPi and another volume-providing stream. + + stream: A fully constructed instance of a class that extends StreamWatcher + + config_dir: the path to the .config/amplipi/srcs/v{vsrc} folder for this persistent stream + + debug: bool that decides whether log level is DEBUG (if True) or WARNING (if False). False by default. + + Example Usage: + + class SomeStreamWatcher(StreamWatcher): + __init__(**kwargs): + super().__init__() + ... + + async def get_vol(self) -> None: + ... + self.volume = new_volume + + def set_vol(self, new_vol: float, vol_set_point: float) -> float: + ... + return new_vol if change_successful else vol_set_point + + {build standard argparse flow here, containing args for your constructor as well as config_dir and --debug} + + handler = VolSyncDispatcher(SomeStreamWatcher(**kwargs), args.config_dir, args.debug) + """ + + # All you need to do to use this class is build a StreamWatcher extension and then follow the above example with a simple argsparse flow, everything else is handled automatically + + def __init__(self, stream: StreamWatcher, config_dir: str, debug=False): + + self.logger = logging.getLogger(__name__) + self.logger.setLevel(logging.DEBUG if debug else logging.WARNING) + sh = logging.StreamHandler(sys.stdout) + self.logger.addHandler(sh) + + self.event_queue = queue.Queue() + self.amplipi = AmpliPiWatcher(config_dir, self.schedule_event, self.logger) + + self.stream: StreamWatcher = stream + + # Set these directly so children don't need to add them to their constructors + self.stream.logger = self.logger + self.stream.schedule_event = self.schedule_event + + self.vol_set_point = self.amplipi.volume + self.event_loop() + + def schedule_event(self, event_type: VolEvents): + """When an event occurs in a child, that child can use this callback function to schedule the response to said event in the event queue""" + self.event_queue.put(event_type) + + def event_loop(self): + """Watch for events coming from amplipi and the stream to then change the volume of the other""" + while True: + try: + if self.vol_set_point is None: + self.vol_set_point = self.amplipi.volume + + event = self.event_queue.get() + if event == VolEvents.CHANGE_AMPLIPI: + if self.stream.delta is not None: + # Reduce race condition potential by decoupling the value from the variable + delta = float(self.stream.delta) + self.vol_set_point = self.amplipi.set_vol_delta(delta) + self.stream.delta -= delta + else: + self.vol_set_point = self.amplipi.set_vol(self.stream.volume, self.vol_set_point) + elif event == VolEvents.CHANGE_STREAM: + self.vol_set_point = self.stream.set_vol(self.amplipi.volume, self.vol_set_point) + except queue.Empty: + continue + except (KeyboardInterrupt, SystemExit): + self.logger.exception("Exiting...") + break + except Exception as e: + self.logger.exception(f"Exception: {e}") + continue diff --git a/tests/test_rest.py b/tests/test_rest.py index a474aeda5..867c003bd 100644 --- a/tests/test_rest.py +++ b/tests/test_rest.py @@ -605,10 +605,10 @@ def test_patch_zones_vol_delta(client): # check that each update worked as expected for z in jrv['zones']: if z['id'] in range(6): - assert z['vol_f'] - (zones[z['id']]['vol_f'] + 0.1) < 0.0001 + assert z['vol_f'] - (zones[z['id']]['vol_f'] + 0.1) < 0.0001 and z["vol_f_overflow"] == 0 - # test oversized deltas - rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_delta_f': -10.0}}) + # test overflowing deltas + rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_delta_f': -1.0}}) assert rv.status_code == HTTPStatus.OK jrv = rv.json() assert len(jrv['zones']) >= 6 @@ -616,9 +616,33 @@ def test_patch_zones_vol_delta(client): for z in jrv['zones']: if z['id'] in range(6): assert z['vol_f'] == amplipi.models.MIN_VOL_F + assert z["vol_f_overflow"] == zones[z['id']]['vol_f'] + 0.1 - 1 + + # test oversized overflowing deltas + rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_delta_f': 10.0}}) + assert rv.status_code == HTTPStatus.OK + jrv = rv.json() + assert len(jrv['zones']) >= 6 + # check that each update worked as expected + for z in jrv['zones']: + if z['id'] in range(6): + assert z['vol_f'] == amplipi.models.MAX_VOL_F + assert z["vol_f_overflow"] == amplipi.models.MAX_VOL_F_OVERFLOW + + # test overflow reset + mid_vol_f = (amplipi.models.MIN_VOL_F + amplipi.models.MAX_VOL_F) / 2 + rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_f': mid_vol_f}}) + assert rv.status_code == HTTPStatus.OK + jrv = rv.json() + assert len(jrv['zones']) >= 6 + # check that each update worked as expected + for z in jrv['zones']: + if z['id'] in range(6): + assert z['vol_f'] == mid_vol_f + assert z["vol_f_overflow"] == 0 # test precedence - rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_delta_f': 10.0, "vol": amplipi.models.MIN_VOL_DB}}) + rv = client.patch('/api/zones', json={'zones': [z['id'] for z in zones], 'update': {'vol_delta_f': 1.0, "vol": amplipi.models.MIN_VOL_DB}}) assert rv.status_code == HTTPStatus.OK jrv = rv.json() assert len(jrv['zones']) >= 6 diff --git a/web/src/App.jsx b/web/src/App.jsx index 4e28a7d3c..0b82939ca 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -50,7 +50,11 @@ export const useStatusStore = create((set, get) => ({ applyPlayerVol(vol, zones, sourceId, (zone_id, new_vol) => { for (const i in s.status.zones) { if (s.status.zones[i].id === zone_id) { - s.status.zones[i].vol_f = new_vol; + let true_vol = Math.round((new_vol + s.status.zones[i].vol_f_overflow) * 100) / 100; + let clamped = Math.min(Math.max(true_vol, 0), 1); + + s.status.zones[i].vol_f = clamped; + s.status.zones[i].vol_f_overflow = true_vol - clamped; } } }); @@ -61,11 +65,17 @@ export const useStatusStore = create((set, get) => ({ setZonesMute: (mute, zones, source_id) => { set( produce((s) => { - for (const i of getSourceZones(source_id, zones)) { - for (const j of s.status.zones) { - if (j.id === i.id) { - j.mute = mute; - } + const affectedZones = getSourceZones(source_id, zones).map(z => z.id); + for (const j of s.status.zones) { + if (affectedZones.includes(j.id)) { + j.mute = mute; + } + } + + // Also update groups that consist entirely of affected zones + for (const g of s.status.groups) { + if (g.zones.every(zid => affectedZones.includes(zid))) { + g.mute = mute; } } }) @@ -164,7 +174,7 @@ export const useStatusStore = create((set, get) => ({ const g = s.status.groups.filter((g) => g.id === groupId)[0]; for (const i of g.zones) { s.skipUpdate = true; - s.status.zones[i].vol_f = new_vol; + s.status.zones[i].vol_f = new_vol + s.status.zones[i].vol_f_overflow; } updateGroupVols(s); @@ -198,7 +208,7 @@ export const useStatusStore = create((set, get) => ({ const updateGroupVols = (s) => { s.status.groups.forEach((g) => { if (g.zones.length > 1) { - const vols = g.zones.map((id) => s.status.zones[id].vol_f); + const vols = g.zones.map((id) => s.status.zones[id].vol_f + s.status.zones[id].vol_f_overflow); let calculated_vol = Math.min(...vols) * 0.5 + Math.max(...vols) * 0.5; g.vol_f = calculated_vol; } else if (g.zones.length == 1) { @@ -226,14 +236,14 @@ Page.propTypes = { const App = ({ selectedPage }) => { return ( -
+
{/* Used to make sure the background doesn't stretch or stop prematurely on scrollable pages */} -
- -
- +
+
+ +
); }; App.propTypes = { diff --git a/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx b/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx index fdb1c0d3c..227a7e537 100644 --- a/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx +++ b/web/src/components/CardVolumeSlider/CardVolumeSlider.jsx @@ -11,7 +11,7 @@ const getPlayerVol = (sourceId, zones) => { let n = 0; for (const i of getSourceZones(sourceId, zones)) { n += 1; - vol += i.vol_f; + vol += i.vol_f + i.vol_f_overflow; // Add buffer to retain proper relative space when doing an action that would un-overload the slider } const avg = vol / n; @@ -27,13 +27,12 @@ export const applyPlayerVol = (vol, zones, sourceId, apply) => { let delta = vol - getPlayerVol(sourceId, zones); for (let i of getSourceZones(sourceId, zones)) { - let set_pt = Math.max(0, Math.min(1, i.vol_f + delta)); - apply(i.id, set_pt); + apply(i.id, i.vol_f + delta); } }; -// cumulativeDelta reflects the amount of movement that the -let cumulativeDelta = 0; +// cumulativeDelta reflects the amount of movement that the volume bar has had that has gone unreflected in the backend +let cumulativeDelta = 0.0; let sendingPacketCount = 0; // main volume slider on player and volume slider on player card @@ -41,8 +40,9 @@ const CardVolumeSlider = ({ sourceId }) => { const zones = useStatusStore((s) => s.status.zones); const setZonesVol = useStatusStore((s) => s.setZonesVol); const setZonesMute = useStatusStore((s) => s.setZonesMute); + const setSystemState = useStatusStore((s) => s.setSystemState); - // needed to ensure that polling doesn't cause the delta volume to be made inacurrate during volume slider interactions + // needed to ensure that polling doesn't cause the delta volume to be made inaccurate during volume slider interactions const skipNextUpdate = useStatusStore((s) => s.skipNextUpdate); const value = getPlayerVol(sourceId, zones); @@ -69,15 +69,16 @@ const CardVolumeSlider = ({ sourceId }) => { zones: getSourceZones(sourceId, zones).map((z) => z.id), update: { vol_delta_f: cumulativeDelta, mute: false }, }), - }).then(() => { + }).then(res => { // NOTE: This used to just set cumulativeDelta to 0 // that would skip all accumulated delta from fetch start to backend response time // causing jittering issues cumulativeDelta -= delta; sendingPacketCount -= 1; + if(res.ok){res.json().then(s => setSystemState(s));} }); } - }; + } const mute = getSourceZones(sourceId, zones) .map((z) => z.mute) @@ -95,6 +96,8 @@ const CardVolumeSlider = ({ sourceId }) => { zones: getSourceZones(sourceId, zones).map((z) => z.id), update: { mute: mute }, }), + }).then(res => { + if(res.ok){res.json().then(s => setSystemState(s));} }); }; diff --git a/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx b/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx index f1edf40cb..aeda0c234 100644 --- a/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx +++ b/web/src/components/GroupVolumeSlider/GroupVolumeSlider.jsx @@ -15,12 +15,22 @@ let sendingRequestCount = 0; // volume slider for a group in the volumes drawer const GroupVolumeSlider = ({ groupId, sourceId, groupsLeft }) => { + const setSystemState = useStatusStore((s) => s.setSystemState); const group = useStatusStore(s => s.status.groups.filter(g => g.id === groupId)[0]); - const volume = group.vol_f; + const zones = useStatusStore(s => s.status.zones); const setGroupVol = useStatusStore(s => s.setGroupVol); const setGroupMute = useStatusStore(s => s.setGroupMute); const [slidersOpen, setSlidersOpen] = React.useState(false); + const getVolume = () => { // Make sure group sliders account for vol_f_overflow + let v = 0; + for(let i = 0; i < group.zones.length; i++){ + v += (zones[group.zones[i]].vol_f + zones[group.zones[i]].vol_f_overflow); + } + + return v / group.zones.length; + }; + const volume = getVolume(); // get zones for this group const groupZones = getSourceZones(sourceId, useStatusStore(s => s.status.zones)).filter(z => group.zones.includes(z.id)); @@ -68,6 +78,8 @@ const GroupVolumeSlider = ({ groupId, sourceId, groupsLeft }) => { "Content-type": "application/json", }, body: JSON.stringify({ mute: mute }), + }).then(res => { + if(res.ok){res.json().then(s => setSystemState(s));} }); }; diff --git a/web/src/components/StreamsModal/StreamsModal.jsx b/web/src/components/StreamsModal/StreamsModal.jsx index 86c6bfba0..9ee40021c 100644 --- a/web/src/components/StreamsModal/StreamsModal.jsx +++ b/web/src/components/StreamsModal/StreamsModal.jsx @@ -27,7 +27,7 @@ export const executeApplyAction = async (customSourceId) => { let ret = undefined; while(ret == undefined){ ret = await temp(customSourceId); - }; + } let sliced = parseInt(String(ret.url).slice(-1)); setSelectedSource(sliced); setAutoselectSource(false); diff --git a/web/src/components/VolumeSlider/VolumeSlider.jsx b/web/src/components/VolumeSlider/VolumeSlider.jsx index 8fa967f32..73660fc3f 100644 --- a/web/src/components/VolumeSlider/VolumeSlider.jsx +++ b/web/src/components/VolumeSlider/VolumeSlider.jsx @@ -71,31 +71,31 @@ const VolumeSlider = ({ vol, mute, setVol, setMute, disabled }) => { >
- { - if (isIOS() && e.type === "mousedown") { - return; - } - handleVolChange(val); - }} - onChangeCommitted={(e, val) => { - if (isIOS() && e.type === "mouseup") { - return; - } - handleVolChange(val, true); - }} - /> - + { + if (isIOS() && e.type === "mousedown") { + return; + } + handleVolChange(val); + }} + onChangeCommitted={(e, val) => { + if (isIOS() && e.type === "mouseup") { + return; + } + handleVolChange(val, true); + }} + /> + ); }; diff --git a/web/src/components/VolumeZones/VolumeZones.jsx b/web/src/components/VolumeZones/VolumeZones.jsx index 46f28907f..036382756 100644 --- a/web/src/components/VolumeZones/VolumeZones.jsx +++ b/web/src/components/VolumeZones/VolumeZones.jsx @@ -31,12 +31,12 @@ const VolumeZones = ({ sourceId, open, zones, groups, groupsLeft, alone }) => { }); if(open){ - return ( -
- {groupVolumeSliders} - {zoneVolumeSliders} -
- ); + return ( +
+ {groupVolumeSliders} + {zoneVolumeSliders} +
+ ); } }; VolumeZones.propTypes = { diff --git a/web/src/components/ZoneVolumeSlider/ZoneVolumeSlider.jsx b/web/src/components/ZoneVolumeSlider/ZoneVolumeSlider.jsx index 710b215f7..9367c0755 100644 --- a/web/src/components/ZoneVolumeSlider/ZoneVolumeSlider.jsx +++ b/web/src/components/ZoneVolumeSlider/ZoneVolumeSlider.jsx @@ -9,6 +9,7 @@ let sendingRequestCount = 0; // Volume slider for individual zone in volume drawer const ZoneVolumeSlider = ({ zoneId, alone }) => { + const setSystemState = useStatusStore((s) => s.setSystemState); const zoneName = useStatusStore((s) => s.status.zones[zoneId].name); const volume = useStatusStore((s) => s.status.zones[zoneId].vol_f); const mute = useStatusStore((s) => s.status.zones[zoneId].mute); @@ -42,6 +43,8 @@ const ZoneVolumeSlider = ({ zoneId, alone }) => { "Content-type": "application/json", }, body: JSON.stringify({ mute: mute }), + }).then(res => { + if(res.ok){res.json().then(s => setSystemState(s));} }); }; diff --git a/web/src/pages/Home/Home.jsx b/web/src/pages/Home/Home.jsx index 0f28f48bc..b80b0a72b 100644 --- a/web/src/pages/Home/Home.jsx +++ b/web/src/pages/Home/Home.jsx @@ -119,7 +119,7 @@ const Home = () => { // on apply, we want to call onApply={async (customSourceId) => { const ret = await executeApplyAction(customSourceId); - if(ret.ok){ret.json().then(s => setSystemState(s))}; + if(ret.ok){ret.json().then(s => setSystemState(s));} }} onClose={() => setZonesModalOpen(false)} /> @@ -129,7 +129,7 @@ const Home = () => { sourceId={nextAvailableSource} onApply={async (customSourceId) => { const ret = await executeApplyAction(customSourceId); - if(ret.ok){ret.json().then(s => setSystemState(s))}; + if(ret.ok){ret.json().then(s => setSystemState(s));} }} onClose={() => setStreamerOutputModalOpen(false)} />