diff --git a/documentation/developers/docstring/README.md b/documentation/developers/docstring/README.md index c48c34a41..b1aea3623 100644 --- a/documentation/developers/docstring/README.md +++ b/documentation/developers/docstring/README.md @@ -27,10 +27,10 @@ * [resolve](#misc.simplecolors.resolve) * [print](#misc.simplecolors.print) * [components](#components) -* [components.playermpd.playcontentcallback](#components.playermpd.playcontentcallback) - * [PlayContentCallbacks](#components.playermpd.playcontentcallback.PlayContentCallbacks) - * [register](#components.playermpd.playcontentcallback.PlayContentCallbacks.register) - * [run\_callbacks](#components.playermpd.playcontentcallback.PlayContentCallbacks.run_callbacks) +* [components.playermpd.play_content_callback](#components.playermpd.play_content_callback) + * [PlayContentCallbacks](#components.playermpd.play_content_callback.PlayContentCallbacks) + * [register](#components.playermpd.play_content_callback.PlayContentCallbacks.register) + * [run\_callbacks](#components.playermpd.play_content_callback.PlayContentCallbacks.run_callbacks) * [components.playermpd](#components.playermpd) * [PlayerMPD](#components.playermpd.PlayerMPD) * [mpd\_retry\_with\_mutex](#components.playermpd.PlayerMPD.mpd_retry_with_mutex) @@ -761,11 +761,11 @@ Use just as a regular print function, but with first parameter as color # components - + -# components.playermpd.playcontentcallback +# components.playermpd.play_content_callback - + ## PlayContentCallbacks Objects @@ -776,7 +776,7 @@ class PlayContentCallbacks(Generic[STATE], CallbackHandler) Callbacks are executed in various play functions - + #### register @@ -796,7 +796,7 @@ Callback signature is - `folder`: relativ path to folder to play - `state`: indicator of the state inside the calling - + #### run\_callbacks diff --git a/src/jukebox/components/playermpd/__init__.py b/src/jukebox/components/playermpd/__init__.py index 86dbc60ab..161b81679 100644 --- a/src/jukebox/components/playermpd/__init__.py +++ b/src/jukebox/components/playermpd/__init__.py @@ -88,6 +88,8 @@ import time import functools from pathlib import Path +from typing import Union, Dict, Any, Optional + import components.player import jukebox.cfghandler import jukebox.utils as utils @@ -98,8 +100,9 @@ import misc from jukebox.NvManager import nv_manager -from .playcontentcallback import PlayContentCallbacks, PlayCardState +from .play_content_callback import PlayContentCallbacks, PlayCardState from .coverart_cache_manager import CoverartCacheManager +from .play_content_handler import PlayContentHandler, PlayContent, PlayContentType logger = logging.getLogger('jb.PlayerMPD') cfg = jukebox.cfghandler.get_handler('jukebox') @@ -148,38 +151,38 @@ def __init__(self): self.music_player_status = self.nvm.load(cfg.getn('playermpd', 'status_file')) self.second_swipe_action_dict = {'toggle': self.toggle, - 'play': self.play, - 'skip': self.next, - 'rewind': self.rewind, - 'replay': self.replay, - 'replay_if_stopped': self.replay_if_stopped} + 'play': self.play, + 'skip': self.next, + 'rewind': self.rewind, + 'replay': self.replay, + 'replay_if_stopped': self.replay_if_stopped} self.second_swipe_action = None self.decode_2nd_swipe_option() self.end_of_playlist_next_action = utils.get_config_action(cfg, - 'playermpd', - 'end_of_playlist_next_action', - 'none', - {'rewind': self.rewind, + 'playermpd', + 'end_of_playlist_next_action', + 'none', + {'rewind': self.rewind, 'stop': self.stop, 'none': lambda: None}, - logger) + logger) self.stopped_prev_action = utils.get_config_action(cfg, - 'playermpd', - 'stopped_prev_action', - 'prev', - {'rewind': self.rewind, + 'playermpd', + 'stopped_prev_action', + 'prev', + {'rewind': self.rewind, 'prev': self._prev_in_stopped_state, 'none': lambda: None}, - logger) + logger) self.stopped_next_action = utils.get_config_action(cfg, - 'playermpd', - 'stopped_next_action', - 'next', - {'rewind': self.rewind, - 'next': self._next_in_stopped_state, - 'none': lambda: None}, - logger) + 'playermpd', + 'stopped_next_action', + 'next', + {'rewind': self.rewind, + 'next': self._next_in_stopped_state, + 'none': lambda: None}, + logger) self.mpd_client = mpd.MPDClient() self.coverart_cache_manager = CoverartCacheManager() @@ -222,11 +225,18 @@ def __init__(self): self.mpd_status = {} self.mpd_status_poll_interval = 0.25 self.mpd_lock = MpdLock(self.mpd_client, self.mpd_host, 6600) + + global play_card_callbacks + play_card_callbacks = PlayContentCallbacks[PlayCardState]('play_card_callbacks', logger, context=self.mpd_lock) + self.play_card_callbacks = play_card_callbacks + self.play_content_handler = PlayContentHandler(self) + self.play_content_handler.set_second_swipe_action(self.second_swipe_action) + self.status_is_closing = False # self.status_thread = threading.Timer(self.mpd_status_poll_interval, self._mpd_status_poll).start() self.status_thread = multitimer.GenericEndlessTimerClass('mpd.timer_status', - self.mpd_status_poll_interval, self._mpd_status_poll) + self.mpd_status_poll_interval, self._mpd_status_poll) self.status_thread.start() def exit(self): @@ -522,60 +532,163 @@ def move(self): # MPDClient.swapid(song1, song2) raise NotImplementedError - @plugs.tag - def play_single(self, song_url): + def _play_single_internal(self, song_url: str) -> None: with self.mpd_lock: self.mpd_client.clear() self.mpd_client.addid(song_url) self.mpd_client.play() - @plugs.tag - def resume(self): + def _play_album_internal(self, artist: str, album: str) -> None: with self.mpd_lock: - songpos = self.current_folder_status["CURRENTSONGPOS"] - elapsed = self.current_folder_status["ELAPSED"] - self.mpd_client.seek(songpos, elapsed) + logger.info(f"Play album: '{album}' by '{artist}") + self.mpd_client.clear() + self.mpd_retry_with_mutex(self.mpd_client.findadd, 'albumartist', artist, 'album', album) + self.mpd_client.play() + + def _play_folder_internal(self, folder: str, recursive: bool) -> None: + with self.mpd_lock: + logger.info(f"Play folder: '{folder}'") + self.mpd_client.clear() + + plc = playlistgenerator.PlaylistCollector(components.player.get_music_library_path()) + plc.parse(folder, recursive) + uri = '--unset--' + try: + for uri in plc: + self.mpd_client.addid(uri) + except mpd.base.CommandError as e: + logger.error(f"{e.__class__.__qualname__}: {e} at uri {uri}") + except Exception as e: + logger.error(f"{e.__class__.__qualname__}: {e} at uri {uri}") + + self.music_player_status['player_status']['last_played_folder'] = folder + + self.current_folder_status = self.music_player_status['audio_folder_status'].get(folder) + if self.current_folder_status is None: + self.current_folder_status = self.music_player_status['audio_folder_status'][folder] = {} + self.mpd_client.play() @plugs.tag - def play_card(self, folder: str, recursive: bool = False): + def play_content(self, content: Union[str, Dict[str, Any]], content_type: str = 'folder', recursive: bool = False): + """ + Main entry point for trigger music playing from any source (RFID reader, web UI, etc.). + Does NOT support second swipe - use play_from_reader() for that. + + :param content: Content identifier: + - For singles/folders: file/folder path as string + - For albums: dict with 'albumartist' and 'album' keys + :param content_type: Type of content ('single', 'album', 'folder') + :param recursive: Add folder recursively (only used for folder type) """ - Main entry point for trigger music playing from RFID reader. Decodes second swipe options before playing folder content + try: + content_type = content_type.lower() + if content_type == 'album': + if isinstance(content, dict): + albumartist = content.get('albumartist') + album = content.get('album') + if not albumartist or not album: + raise ValueError("Album content must contain both 'albumartist' and 'album' keys") + else: + raise ValueError("Album content must be a dictionary with 'albumartist' and 'album' keys") + + play_content = PlayContent( + type=PlayContentType.ALBUM, + content=(albumartist, album) + ) + elif content_type == 'single': + if isinstance(content, dict): + raise ValueError("Single track content should be a direct file path, not a dictionary") + play_content = PlayContent( + type=PlayContentType.SINGLE, + content=content + ) + else: # folder is default + if isinstance(content, dict): + raise ValueError("Folder content should be a direct folder path, not a dictionary") + play_content = PlayContent( + type=PlayContentType.FOLDER, + content=content, + recursive=recursive + ) + + # Ensure no second swipe for regular content playback + old_action = self.play_content_handler._second_swipe_action + self.play_content_handler._second_swipe_action = None - Checks for second (or multiple) trigger of the same folder and calls first swipe / second swipe action - accordingly. + try: + self.play_content_handler.play_content(play_content) + finally: + # Restore previous second swipe action + self.play_content_handler._second_swipe_action = old_action - :param folder: Folder path relative to music library path - :param recursive: Add folder recursively + except Exception as e: + logger.error(f"Error playing content: {e}") + raise + + @plugs.tag + def play_from_reader(self, content: Union[str, Dict[str, str]], content_type: str = 'folder', + recursive: bool = False, second_swipe: Optional[str] = None): """ - # Developers notes: - # - # * 2nd swipe trigger may also happen, if playlist has already stopped playing - # --> Generally, treat as first swipe - # * 2nd swipe of same Card ID may also happen if a different song has been played in between from WebUI - # --> Treat as first swipe - # * With place-not-swipe: Card is placed on reader until playlist expieres. Music stop. Card is removed and - # placed again on the reader: Should be like first swipe - # * TODO: last_played_folder is restored after box start, so first swipe of last played card may look like - # second swipe - # - logger.debug(f"last_played_folder = {self.music_player_status['player_status']['last_played_folder']}") - with self.mpd_lock: - is_second_swipe = self.music_player_status['player_status']['last_played_folder'] == folder - if self.second_swipe_action is not None and is_second_swipe: - logger.debug('Calling second swipe action') + Special entry point for reader-triggered playback with second swipe support. + Used when content is identified via RFID, barcode, or other physical readers. + + :param content: Content identifier, either: + - string path for single/folder types + - dict with 'albumartist' and 'album' keys for album type + :param content_type: Type of content ('single', 'album', 'folder') + :param recursive: Add folder recursively (only used for folder type) + :param second_swipe: Override default second swipe action for this reader: + - None/not specified: use default from config + - 'none': disable second swipe + - One of: 'toggle', 'play', 'skip', 'rewind', 'replay', 'replay_if_stopped' + """ + # Determine second swipe action + if second_swipe is None: + action = self.second_swipe_action + elif second_swipe.lower() == 'none': + action = None + else: + action = self.second_swipe_action_dict.get(second_swipe.lower()) + if action is None: + logger.error(f"Unknown second swipe action '{second_swipe}', using default") + action = self.second_swipe_action - # run callbacks before second_swipe_action is invoked - play_card_callbacks.run_callbacks(folder, PlayCardState.secondSwipe) + # Temporarily set the chosen second swipe action + old_action = self.play_content_handler._second_swipe_action + self.play_content_handler.set_second_swipe_action(action) - self.second_swipe_action() - else: - logger.debug('Calling first swipe action') + try: + self.play_content(content, content_type, recursive) + finally: + # Restore previous second swipe action + self.play_content_handler.set_second_swipe_action(old_action) + + # The following methods are kept for backward compatibility but now use play_content internally + + @plugs.tag + def play_single(self, song_url): + """Deprecated: Use play_content with content_type='single' instead""" + self.play_content(song_url, content_type='single') + + @plugs.tag + def play_album(self, albumartist: str, album: str): + """Deprecated: Use play_content with content_type='album' instead""" + content = {'artist': albumartist, 'album': album} + self.play_content(content, content_type='album') - # run callbacks before play_folder is invoked - play_card_callbacks.run_callbacks(folder, PlayCardState.firstSwipe) + @plugs.tag + def play_folder(self, folder: str, recursive: bool = False): + """Deprecated: Use play_content with content_type='folder' instead""" + self.play_content(folder, content_type='folder', recursive=recursive) - self.play_folder(folder, recursive) + @plugs.tag + def resume(self): + with self.mpd_lock: + songpos = self.current_folder_status["CURRENTSONGPOS"] + elapsed = self.current_folder_status["ELAPSED"] + self.mpd_client.seek(songpos, elapsed) + self.mpd_client.play() @plugs.tag def get_single_coverart(self, song_url): @@ -611,58 +724,6 @@ def get_folder_content(self, folder: str): plc.get_directory_content(folder) return plc.playlist - @plugs.tag - def play_folder(self, folder: str, recursive: bool = False) -> None: - """ - Playback a music folder. - - Folder content is added to the playlist as described by :mod:`jukebox.playlistgenerator`. - The playlist is cleared first. - - :param folder: Folder path relative to music library path - :param recursive: Add folder recursively - """ - # TODO: This changes the current state -> Need to save last state - with self.mpd_lock: - logger.info(f"Play folder: '{folder}'") - self.mpd_client.clear() - - plc = playlistgenerator.PlaylistCollector(components.player.get_music_library_path()) - plc.parse(folder, recursive) - uri = '--unset--' - try: - for uri in plc: - self.mpd_client.addid(uri) - except mpd.base.CommandError as e: - logger.error(f"{e.__class__.__qualname__}: {e} at uri {uri}") - except Exception as e: - logger.error(f"{e.__class__.__qualname__}: {e} at uri {uri}") - - self.music_player_status['player_status']['last_played_folder'] = folder - - self.current_folder_status = self.music_player_status['audio_folder_status'].get(folder) - if self.current_folder_status is None: - self.current_folder_status = self.music_player_status['audio_folder_status'][folder] = {} - - self.mpd_client.play() - - @plugs.tag - def play_album(self, albumartist: str, album: str): - """ - Playback a album found in MPD database. - - All album songs are added to the playlist - The playlist is cleared first. - - :param albumartist: Artist of the Album provided by MPD database - :param album: Album name provided by MPD database - """ - with self.mpd_lock: - logger.info(f"Play album: '{album}' by '{albumartist}") - self.mpd_client.clear() - self.mpd_retry_with_mutex(self.mpd_client.findadd, 'albumartist', albumartist, 'album', album) - self.mpd_client.play() - @plugs.tag def queue_load(self, folder): # There was something playing before -> stop and save state @@ -762,6 +823,7 @@ def _db_is_updating(self, update_id: int): #: States: #: - See :class:`PlayCardState` #: See :class:`PlayContentCallbacks` +player_ctrl: PlayerMPD play_card_callbacks: PlayContentCallbacks[PlayCardState] diff --git a/src/jukebox/components/playermpd/playcontentcallback.py b/src/jukebox/components/playermpd/play_content_callback.py similarity index 100% rename from src/jukebox/components/playermpd/playcontentcallback.py rename to src/jukebox/components/playermpd/play_content_callback.py diff --git a/src/jukebox/components/playermpd/play_content_handler.py b/src/jukebox/components/playermpd/play_content_handler.py new file mode 100644 index 000000000..660b82c28 --- /dev/null +++ b/src/jukebox/components/playermpd/play_content_handler.py @@ -0,0 +1,97 @@ +from enum import Enum, auto +from dataclasses import dataclass +from typing import Union, Optional, Callable, Protocol +import logging + +from .play_content_callback import PlayCardState # Add this import + +logger = logging.getLogger('jb.PlayerMPD') + + +class PlayContentType(Enum): + SINGLE = auto() + ALBUM = auto() + FOLDER = auto() + + +@dataclass +class PlayContent: + """Represents playable content with its type and metadata""" + type: PlayContentType + content: Union[str, tuple[str, str]] # str for SINGLE/FOLDER, tuple(artist, album) for ALBUM + recursive: bool = False + + +class PlayerProtocol(Protocol): + """Protocol defining required player methods""" + def _play_single_internal(self, song_url: str) -> None: + """Play a single track""" + + def _play_album_internal(self, artist: str, album: str) -> None: + """Play an album""" + + def _play_folder_internal(self, folder: str, recursive: bool) -> None: + """Play a folder""" + + @property + def play_card_callbacks(self) -> any: + """Access to callbacks""" + + +class PlayContentHandler: + """Handles different types of playback content with second swipe support""" + + def __init__(self, player: PlayerProtocol): + self.player = player + self.last_played_content: Optional[PlayContent] = None + self._second_swipe_action = None + + def set_second_swipe_action(self, action: Optional[Callable]) -> None: + """Set the action to be performed on second swipe""" + self._second_swipe_action = action + + def _play_content(self, content: PlayContent) -> None: + """Internal method to play content based on its type""" + if content.type == PlayContentType.SINGLE: + logger.debug(f"Playing single track: {content.content}") + self.player._play_single_internal(content.content) + elif content.type == PlayContentType.ALBUM: + artist, album = content.content + logger.debug(f"Playing album: {album} by {artist}") + self.player._play_album_internal(artist, album) + elif content.type == PlayContentType.FOLDER: + logger.debug(f"Playing folder: {content.content} (recursive={content.recursive})") + self.player._play_folder_internal(content.content, content.recursive) + + def play_content(self, content: PlayContent) -> None: + """ + Main entry point for playing content with second swipe support + + Checks for second trigger of the same content and calls first/second swipe + action accordingly. + """ + is_second_swipe = False + + if self.last_played_content is not None: + if (content.type == self.last_played_content.type + and content.content == self.last_played_content.content): + is_second_swipe = True + + if self._second_swipe_action is not None and is_second_swipe: + logger.debug('Calling second swipe action') + # run callbacks before second_swipe_action is invoked + self.player.play_card_callbacks.run_callbacks( + str(content.content), + PlayCardState.secondSwipe # Use imported PlayCardState directly + ) + self._second_swipe_action() + else: + logger.debug('Calling first swipe action') + # run callbacks before play_content is invoked + self.player.play_card_callbacks.run_callbacks( + str(content.content), + PlayCardState.firstSwipe # Use imported PlayCardState directly + ) + self._play_content(content) + + self.last_played_content = content diff --git a/src/jukebox/components/rfid/cards/__init__.py b/src/jukebox/components/rfid/cards/__init__.py index 65e3ff8b9..1413f791e 100644 --- a/src/jukebox/components/rfid/cards/__init__.py +++ b/src/jukebox/components/rfid/cards/__init__.py @@ -17,7 +17,7 @@ import logging import time -from typing import (List, Dict, Optional) +from typing import List, Dict, Optional, Union import jukebox.utils as utils import jukebox.cfghandler import jukebox.plugs as plugs @@ -89,42 +89,48 @@ def delete_card(card_id: str, auto_save: bool = True): @plugs.register def register_card(card_id: str, cmd_alias: str, - args: Optional[List] = None, kwargs: Optional[Dict] = None, - ignore_card_removal_action: Optional[bool] = None, ignore_same_id_delay: Optional[bool] = None, - overwrite: bool = False, - auto_save: bool = True): - """Register a new card based on quick-selection - - If you are going to call this through the RPC it will get a little verbose - - **Example:** Registering a new card with ID *0009* for increment volume with a custom argument to inc_volume - (*here: 15*) and custom *ignore_same_id_delay value*:: - - plugin.call_ignore_errors('cards', 'register_card', - args=['0009', 'inc_volume'], - kwargs={'args': [15], 'ignore_same_id_delay': True, 'overwrite': True}) - - """ + args: Optional[Union[List, Dict]] = None, + kwargs: Optional[Dict] = None, + ignore_card_removal_action: Optional[bool] = None, + ignore_same_id_delay: Optional[bool] = None, + overwrite: bool = False, + auto_save: bool = True): + """Register a new card based on quick-selection""" if cmd_alias not in cmd_alias_definitions.keys(): msg = f"Unknown RPC command alias: '{cmd_alias}'" log.error(msg) raise KeyError(msg) + with cfg_cards: if not overwrite and card_id in cfg_cards.keys(): msg = f"Card already registered: '{card_id}'. Abort. (use overwrite=True to overrule)" log.error(msg) raise KeyError(msg) + cfg_cards[card_id] = {'alias': cmd_alias} - if args is not None: + + # For play_from_reader, expect a single dict of args + if cmd_alias == 'play_from_reader': + # Use either kwargs or args if it's a dict + if kwargs is not None: + cfg_cards[card_id]['args'] = kwargs + elif isinstance(args, dict): + cfg_cards[card_id]['args'] = args + else: + log.error(f"play_from_reader requires dict arguments, got: {type(args)}") + raise ValueError("play_from_reader requires dict arguments") + # For other commands, maintain list args support + elif args is not None: cfg_cards[card_id]['args'] = args - if kwargs is not None: - cfg_cards[card_id]['kwargs'] = args + if ignore_same_id_delay is not None: cfg_cards[card_id]['ignore_same_id_delay'] = ignore_same_id_delay if ignore_card_removal_action is not None: cfg_cards[card_id]['ignore_card_removal_action'] = ignore_card_removal_action + if auto_save: cfg_cards.save() + publishing.get_publisher().send(f'{plugs.loaded_as(__name__)}.database.has_changed', time.ctime()) diff --git a/src/jukebox/components/rpc_command_alias.py b/src/jukebox/components/rpc_command_alias.py index 5a7820733..48b76ea7c 100644 --- a/src/jukebox/components/rpc_command_alias.py +++ b/src/jukebox/components/rpc_command_alias.py @@ -12,12 +12,12 @@ # -------------------------------------------------------------- cmd_alias_definitions = { # Player - 'play_card': { - 'title': 'Play music folder triggered by card swipe', + 'play_from_reader': { + 'title': 'Play content triggered by card swipe, supports second swipe', 'note': "This function you'll want to use most often", 'package': 'player', 'plugin': 'ctrl', - 'method': 'play_card'}, + 'method': 'play_from_reader'}, 'play_album': { 'title': 'Play Album triggered by card swipe', 'note': "This function plays the content of a given album", diff --git a/src/jukebox/components/synchronisation/rfidcards/__init__.py b/src/jukebox/components/synchronisation/rfidcards/__init__.py index 0fa0969a9..10f58541c 100644 --- a/src/jukebox/components/synchronisation/rfidcards/__init__.py +++ b/src/jukebox/components/synchronisation/rfidcards/__init__.py @@ -29,7 +29,7 @@ import shutil from components.rfid.reader import RfidCardDetectState -from components.playermpd.playcontentcallback import PlayCardState +from components.playermpd.play_content_callback import PlayCardState logger = logging.getLogger('jb.sync_rfidcards') diff --git a/src/jukebox/run_rpc_tool.py b/src/jukebox/run_rpc_tool.py index 4bd834e12..47f861af5 100644 --- a/src/jukebox/run_rpc_tool.py +++ b/src/jukebox/run_rpc_tool.py @@ -107,10 +107,39 @@ def format_usage(scr): scr.addstr("\n\nUsage:\n") scr.addstr(" > cmd [arg1] [arg2] [arg3]\n") scr.addstr("e.g.\n") - scr.addstr(" > volume.ctrl.set_volume 50\n") - scr.addstr("Note: NOT yet supported: kwargs, quoting!\n") + scr.addstr(' > volume.ctrl.set_volume 50\n') + + # General content playback examples (for webapp/CLI) + scr.addstr("\nPlaying content (using play_content):\n") + scr.addstr(' > player.ctrl.play_content \'{"artist":"Pink Floyd","album":"The Wall"}\' album\n') + scr.addstr(' > player.ctrl.play_content "/music/classical" folder true\n') + scr.addstr(' > player.ctrl.play_content "/music/favorites/track.mp3" single\n') + + # Update the reader examples section + scr.addstr("\nPlaying content from physical readers (using play_from_reader):\n") + scr.addstr(' > player.ctrl.play_from_reader \'{"artist":"Pink Floyd","album":"The Wall"}\' album false toggle\n') + scr.addstr(' > player.ctrl.play_from_reader "/music/classical" folder true replay\n') + scr.addstr(' > player.ctrl.play_from_reader "/music/stories" folder false none\n') + + scr.addstr("\n") + scr.addstr("Quoting:\n") + scr.addstr(" - Use single quotes (\') for JSON content\n") + scr.addstr(' - Use double quotes (") for simple string arguments containing spaces\n') + scr.addstr(' - Escape quotes within quoted strings with \\\n') + scr.addstr("\n") + scr.addstr("Content Types:\n") + scr.addstr(" - album: requires JSON with artist and album\n") + scr.addstr(" - single: direct path to audio file\n") + scr.addstr(" - folder: path to folder (optional recursive flag)\n") scr.addstr("\n") - scr.addstr("Numbers are supported in decimal and hexadecimal format when prefixed with '0x'") + scr.addstr("Second Swipe Actions (for play_card):\n") + scr.addstr(" - none: disable second swipe\n") + scr.addstr(" - toggle: toggle play/pause\n") + scr.addstr(" - play: start playing\n") + scr.addstr(" - skip: next track\n") + scr.addstr(" - rewind: restart playlist\n") + scr.addstr(" - replay: restart folder\n") + scr.addstr(" - replay_if_stopped: restart if stopped\n") scr.addstr("\n") scr.addstr("Use for auto-completion of commands!\n") scr.addstr("Use / for command history!\n") diff --git a/src/webapp/public/locales/de/translation.json b/src/webapp/public/locales/de/translation.json index 1c6729415..42046e7b6 100644 --- a/src/webapp/public/locales/de/translation.json +++ b/src/webapp/public/locales/de/translation.json @@ -27,6 +27,7 @@ "next_song": "Nächster Song", "pause": "Pause", "play": "Abspielen", + "play_content": "Inhalte abspielen", "prev_song": "Vorheriger Song", "shuffle": "Zufallswiedergabe", "repeat": "Wiedergabe wiederholen", @@ -40,7 +41,7 @@ "label": "Aktionen", "placeholder": "Wähle eine Aktion aus", "actions": { - "play_music": "Musik abspielen", + "play_content": "Inhalte abspielen", "audio": "Audio & Lautstärke", "host": "System", "timers": "Timer", @@ -53,15 +54,15 @@ "label-full": "Gesamte Addresse (z.B. 192.168.1.53)", "label-short": "Letzter Quadrant (z.B. 53)" }, - "play-music": { + "play-content": { "commands": { - "play_album": "Ausgewähltes Album", - "play_folder": "Ausgewählter Ordner", - "play_single": "Ausgewählter Song" + "album": "Ausgewähltes Album", + "folder": "Ausgewählter Ordner", + "single": "Ausgewählter Song" }, - "button-label": "Musik auswählen", - "no-music-selected": "Es ist keine Musik ausgewählt.", - "loading-song-error": "Während des Ladens des Songs ist ein Fehler aufgetreten." + "button-label": "Inhalt auswählen", + "no-music-selected": "Es sind keine Inhalte ausgewählt.", + "loading-song-error": "Während des Ladens des Inhalts ist ein Fehler aufgetreten." }, "audio": { "repeat": { diff --git a/src/webapp/public/locales/en/translation.json b/src/webapp/public/locales/en/translation.json index 20a28bdac..358b6b1ec 100644 --- a/src/webapp/public/locales/en/translation.json +++ b/src/webapp/public/locales/en/translation.json @@ -27,6 +27,7 @@ "next_song": "Next song", "pause": "Pause", "play": "Play", + "play_content": "Play content", "prev_song": "Previous song", "shuffle": "Shuffle", "repeat": "Repeat", @@ -40,7 +41,7 @@ "label": "Actions", "placeholder": "Select an action", "actions": { - "play_music": "Play music", + "play_content": "Play content", "audio": "Audio & Volume", "host": "System", "timers": "Timers", @@ -53,15 +54,15 @@ "label-full": "Full address (e.g. 192.168.1.53)", "label-short": "Last quadrant (e.g. 53)" }, - "play-music": { + "play-content": { "commands": { - "play_album": "Selected album", - "play_folder": "Selected folder", - "play_single": "Selected song" + "album": "Selected album", + "folder": "Selected folder", + "single": "Selected song" }, - "button-label": "Select music", - "no-music-selected": "No music selected", - "loading-song-error": "An error occurred while loading song." + "button-label": "Select content", + "no-music-selected": "No content selected", + "loading-song-error": "An error occurred while loading the content." }, "audio": { "repeat": { diff --git a/src/webapp/src/commands/index.js b/src/webapp/src/commands/index.js index 1e984997e..ffbc01f03 100644 --- a/src/webapp/src/commands/index.js +++ b/src/webapp/src/commands/index.js @@ -59,23 +59,11 @@ const commands = { plugin: 'ctrl', method: 'play', }, - play_single: { + play_content: { _package: 'player', plugin: 'ctrl', - method: 'play_single', - argKeys: ['song_url'] - }, - play_folder: { - _package: 'player', - plugin: 'ctrl', - method: 'play_folder', - argKeys: ['folder'] - }, - play_album: { - _package: 'player', - plugin: 'ctrl', - method: 'play_album', - argKeys: ['albumartist', 'album'] + method: 'play_content', + argKeys: ['content', 'content_type', 'recursive'] }, pause: { _package: 'player', diff --git a/src/webapp/src/components/Cards/controls/actions-controls.js b/src/webapp/src/components/Cards/controls/actions-controls.js index 90afb56ad..b1577f25b 100644 --- a/src/webapp/src/components/Cards/controls/actions-controls.js +++ b/src/webapp/src/components/Cards/controls/actions-controls.js @@ -10,8 +10,8 @@ import { import CardsDeleteDialog from '../dialogs/delete'; import request from '../../../utils/request'; import { + cleanObject, getActionAndCommand, - getArgsValues } from '../utils'; const ActionsControls = ({ @@ -24,14 +24,14 @@ const ActionsControls = ({ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const handleRegisterCard = async () => { - const args = getArgsValues(actionData); + const { args } = actionData.command || {}; const { command: cmd_alias } = getActionAndCommand(actionData); const kwargs = { card_id: cardId.toString(), - cmd_alias, + cmd_alias: cmd_alias === 'play_content' ? 'play_from_reader' : cmd_alias, overwrite: true, - ...(args.length && { args }), + args: cleanObject(args), }; const { error } = await request('registerCard', kwargs); diff --git a/src/webapp/src/components/Cards/controls/actions/play-music/index.js b/src/webapp/src/components/Cards/controls/actions/play-content/index.js similarity index 69% rename from src/webapp/src/components/Cards/controls/actions/play-music/index.js rename to src/webapp/src/components/Cards/controls/actions/play-content/index.js index 2e8d7332c..0e8fe8eae 100644 --- a/src/webapp/src/components/Cards/controls/actions/play-music/index.js +++ b/src/webapp/src/components/Cards/controls/actions/play-content/index.js @@ -20,17 +20,18 @@ import SelectedAlbum from './selected-album'; import SelectedFolder from './selected-folder'; import SelectedSingle from './selected-single'; -const SelectPlayMusic = ({ +const SelectPlayContent = ({ actionData, cardId, }) => { const { t } = useTranslation(); const navigate = useNavigate(); - const { command } = getActionAndCommand(actionData); + const { content_type } = actionData.command.args || {}; + const values = getArgsValues(actionData); - const selectMusic = () => { + const selectContent = () => { const searchParams = createSearchParams({ isSelecting: true, cardId @@ -44,30 +45,30 @@ const SelectPlayMusic = ({ return ( - {command && + {content_type && - {t(`cards.controls.actions.play-music.commands.${command}`)} + {t(`cards.controls.actions.play-content.commands.${content_type}`)} } - {command === 'play_album' && } - {command === 'play_folder' && } - {command === 'play_single' && } + {content_type === 'album' && } + {content_type === 'folder' && } + {content_type === 'single' && } ); }; -export default SelectPlayMusic; +export default SelectPlayContent; diff --git a/src/webapp/src/components/Cards/controls/actions/play-music/no-music-selected.js b/src/webapp/src/components/Cards/controls/actions/play-content/no-music-selected.js similarity index 80% rename from src/webapp/src/components/Cards/controls/actions/play-music/no-music-selected.js rename to src/webapp/src/components/Cards/controls/actions/play-content/no-music-selected.js index 90821d151..ce9478f14 100644 --- a/src/webapp/src/components/Cards/controls/actions/play-music/no-music-selected.js +++ b/src/webapp/src/components/Cards/controls/actions/play-content/no-music-selected.js @@ -8,7 +8,7 @@ const NoMusicSelected = () => { return ( - {t('cards.controls.actions.play-music.no-music-selected')} + {t('cards.controls.actions.play-content.no-music-selected')} ); } diff --git a/src/webapp/src/components/Cards/controls/actions/play-music/selected-album.js b/src/webapp/src/components/Cards/controls/actions/play-content/selected-album.js similarity index 88% rename from src/webapp/src/components/Cards/controls/actions/play-music/selected-album.js rename to src/webapp/src/components/Cards/controls/actions/play-content/selected-album.js index b0f5d2bc9..f7f323245 100644 --- a/src/webapp/src/components/Cards/controls/actions/play-music/selected-album.js +++ b/src/webapp/src/components/Cards/controls/actions/play-content/selected-album.js @@ -4,7 +4,7 @@ import { List } from '@mui/material'; import AlbumListItem from '../../../../Library/lists/albums/album-list/album-list-item' import NoMusicSelected from './no-music-selected'; -const SelectedAlbum = ({ values: [albumartist, album] }) => { +const SelectedAlbum = ({ values: [{ albumartist, album }] }) => { if (albumartist && album) { return ( diff --git a/src/webapp/src/components/Cards/controls/actions/play-music/selected-folder.js b/src/webapp/src/components/Cards/controls/actions/play-content/selected-folder.js similarity index 100% rename from src/webapp/src/components/Cards/controls/actions/play-music/selected-folder.js rename to src/webapp/src/components/Cards/controls/actions/play-content/selected-folder.js diff --git a/src/webapp/src/components/Cards/controls/actions/play-music/selected-single.js b/src/webapp/src/components/Cards/controls/actions/play-content/selected-single.js similarity index 95% rename from src/webapp/src/components/Cards/controls/actions/play-music/selected-single.js rename to src/webapp/src/components/Cards/controls/actions/play-content/selected-single.js index a7280c11e..a369e93b2 100644 --- a/src/webapp/src/components/Cards/controls/actions/play-music/selected-single.js +++ b/src/webapp/src/components/Cards/controls/actions/play-content/selected-single.js @@ -32,7 +32,7 @@ const SelectecSingle = ({ values: [song_url] }) => { if (error) { return ( - {t('cards.controls.actions.play-music.loading-song-error')} + {t('cards.controls.actions.play-content.loading-song-error')} ); } diff --git a/src/webapp/src/components/Cards/controls/controls-selector.js b/src/webapp/src/components/Cards/controls/controls-selector.js index eea2d5c67..1295bcfe7 100644 --- a/src/webapp/src/components/Cards/controls/controls-selector.js +++ b/src/webapp/src/components/Cards/controls/controls-selector.js @@ -7,7 +7,7 @@ import { } from '@mui/material'; import SelectCommandAliases from './select-command-aliases'; -import SelectPlayMusic from './actions/play-music'; +import SelectPlayContent from './actions/play-content'; import SelectTimers from './actions/timers'; import SelectAudio from './actions/audio'; import { buildActionData } from '../utils'; @@ -61,8 +61,8 @@ const ControlsSelector = ({ /> } - {actionData.action === 'play_music' && - diff --git a/src/webapp/src/components/Cards/edit.js b/src/webapp/src/components/Cards/edit.js index 6bcf11012..7ca5f803c 100644 --- a/src/webapp/src/components/Cards/edit.js +++ b/src/webapp/src/components/Cards/edit.js @@ -22,9 +22,11 @@ const CardsEdit = () => { if (result && result[cardId]) { const { action: { args }, - from_alias: command + from_alias, } = result[cardId]; + const command = from_alias === 'play_from_reader' ? 'play_content' : from_alias; + const action = findActionByCommand(command); const actionData = buildActionData(action, command, args); diff --git a/src/webapp/src/components/Cards/list.js b/src/webapp/src/components/Cards/list.js index 4e8211a27..020389b7f 100644 --- a/src/webapp/src/components/Cards/list.js +++ b/src/webapp/src/components/Cards/list.js @@ -13,6 +13,7 @@ import { } from '@mui/material'; import BookmarkIcon from '@mui/icons-material/Bookmark'; +import { printObject } from '../../utils/utils'; const CardsList = ({ cardsList }) => { const { t } = useTranslation(); @@ -28,10 +29,15 @@ const CardsList = ({ cardsList }) => { return }); - const description = cardsList[cardId].from_alias + const command = cardsList[cardId].from_alias === 'play_from_reader' ? 'play_content' : cardsList[cardId].from_alias; + + const description = command ? reject( isNil, - [cardsList[cardId].from_alias, cardsList[cardId].action.args] + [ + t(`cards.controls.command-selector.commands.${command}`), + printObject(cardsList[cardId].action.args) + ] ).join(', ') : cardsList[cardId].func diff --git a/src/webapp/src/components/Cards/register.js b/src/webapp/src/components/Cards/register.js index c4d3d32f0..848acb9db 100644 --- a/src/webapp/src/components/Cards/register.js +++ b/src/webapp/src/components/Cards/register.js @@ -17,6 +17,7 @@ const CardsRegister = () => { const [cardId, setCardId] = useState(undefined); const [actionData, setActionData] = useState(registerCard?.actionData || {}); + const [args, setArgs] = useState(registerCard?.args || {}); useEffect(() => { setState(state => (omit(['rfid.card_id'], state))); diff --git a/src/webapp/src/components/Cards/utils.js b/src/webapp/src/components/Cards/utils.js index 17eaede6f..0202b783e 100644 --- a/src/webapp/src/components/Cards/utils.js +++ b/src/webapp/src/components/Cards/utils.js @@ -1,6 +1,8 @@ import { isEmpty, + isNil, has, + reject, } from 'ramda'; import commands from '../../commands'; @@ -67,8 +69,11 @@ const getArgsValues = (actionData) => { ); }; +const cleanObject = reject(isNil); + export { buildActionData, + cleanObject, findActionByCommand, getActionAndCommand, getArgsValues, diff --git a/src/webapp/src/components/Library/lists/albums/song-list/index.js b/src/webapp/src/components/Library/lists/albums/song-list/index.js index 006ab791b..9fb87a841 100644 --- a/src/webapp/src/components/Library/lists/albums/song-list/index.js +++ b/src/webapp/src/components/Library/lists/albums/song-list/index.js @@ -18,7 +18,7 @@ import SongListItem from './song-list-item'; const SongList = ({ isSelecting, - registerMusicToCard, + registerContentToCard, }) => { const { t } = useTranslation(); const { artist, album } = useParams(); @@ -59,7 +59,7 @@ const SongList = ({ albumartist={decodeURIComponent(artist)} disabled={songs.length === 0} isSelecting={isSelecting} - registerMusicToCard={registerMusicToCard} + registerContentToCard={registerContentToCard} /> )} diff --git a/src/webapp/src/components/Library/lists/albums/song-list/song-list-controls.js b/src/webapp/src/components/Library/lists/albums/song-list/song-list-controls.js index b2391819e..a3db788e0 100644 --- a/src/webapp/src/components/Library/lists/albums/song-list/song-list-controls.js +++ b/src/webapp/src/components/Library/lists/albums/song-list/song-list-controls.js @@ -14,18 +14,17 @@ const SongListControls = ({ albumartist, album, disabled, - registerMusicToCard, + registerContentToCard, isSelecting }) => { const { t } = useTranslation(); - const command = 'play_album'; const playAlbum = () => ( - request(command, { albumartist, album }) + request('play_content', { content: { albumartist, album }, content_type: 'album' }) ); const registerAlbumToCard = () => ( - registerMusicToCard(command, { albumartist, album }) + registerContentToCard('play_content', { content: { albumartist, album }, content_type: 'album' }) ); return ( diff --git a/src/webapp/src/components/Library/lists/albums/song-list/song-list-item.js b/src/webapp/src/components/Library/lists/albums/song-list/song-list-item.js index 0f22d2df3..e9edea8eb 100644 --- a/src/webapp/src/components/Library/lists/albums/song-list/song-list-item.js +++ b/src/webapp/src/components/Library/lists/albums/song-list/song-list-item.js @@ -12,12 +12,11 @@ import request from '../../../../../utils/request' const SongListItem = ({ isSelecting, - registerMusicToCard, + registerContentToCard, song, }) => { const { t } = useTranslation(); - const command = 'play_single'; const { artist, duration, @@ -26,18 +25,18 @@ const SongListItem = ({ } = song; const playSingle = () => { - request(command, { song_url: file }) + request('play_content', { content: file, content_type: 'single' }) } - const registerSongToCard = () => ( - registerMusicToCard(command, { song_url: file }) + const registerSingleToCard = () => ( + registerContentToCard('play_content', { content: file, content_type: 'single' }) ); return ( (isSelecting ? registerSongToCard() : playSingle())} + onClick={() => (isSelecting ? registerSingleToCard() : playSingle())} > { const { t } = useTranslation(); const { type, name, relpath } = folder; const playItem = () => { switch(type) { - case 'directory': return request('play_folder', { folder: relpath, recursive: true }); - case 'file': return request('play_single', { song_url: relpath }); + case 'directory': return request('play_content', { content: relpath, content_type: 'folder', recursive: true }); + case 'file': request('play_content', { content: relpath, content_type: 'single' }); // TODO: Add missing Podcast // TODO: Add missing Stream default: return; @@ -34,8 +34,8 @@ const FolderListItem = ({ const registerItemToCard = () => { switch(type) { - case 'directory': return registerMusicToCard('play_folder', { folder: relpath, recursive: true }); - case 'file': return registerMusicToCard('play_single', { song_url: relpath }); + case 'directory': return registerContentToCard('play_content', { content: relpath, content_type: 'folder', recursive: true }); + case 'file': return registerContentToCard('play_content', { content: relpath, content_type: 'single' }); // TODO: Add missing Podcast // TODO: Add missing Stream default: return; diff --git a/src/webapp/src/components/Library/lists/folders/folder-list.js b/src/webapp/src/components/Library/lists/folders/folder-list.js index 3222e4234..2c2f90086 100644 --- a/src/webapp/src/components/Library/lists/folders/folder-list.js +++ b/src/webapp/src/components/Library/lists/folders/folder-list.js @@ -17,7 +17,7 @@ const FolderList = ({ dir, folders, isSelecting, - registerMusicToCard, + registerContentToCard, }) => { const { t } = useTranslation(); @@ -47,7 +47,7 @@ const FolderList = ({ key={key} folder={folder} isSelecting={isSelecting} - registerMusicToCard={registerMusicToCard} + registerContentToCard={registerContentToCard} /> )} diff --git a/src/webapp/src/components/Library/lists/folders/index.js b/src/webapp/src/components/Library/lists/folders/index.js index fa0532589..ce279cebd 100644 --- a/src/webapp/src/components/Library/lists/folders/index.js +++ b/src/webapp/src/components/Library/lists/folders/index.js @@ -15,7 +15,7 @@ import { ROOT_DIR } from '../../../../config'; const Folders = ({ musicFilter, isSelecting, - registerMusicToCard, + registerContentToCard, }) => { const { t } = useTranslation(); const { dir = ROOT_DIR } = useParams(); @@ -60,7 +60,7 @@ const Folders = ({ dir={dir} folders={filteredFolders} isSelecting={isSelecting} - registerMusicToCard={registerMusicToCard} + registerContentToCard={registerContentToCard} /> ); }; diff --git a/src/webapp/src/components/Library/lists/index.js b/src/webapp/src/components/Library/lists/index.js index e2b7a2d46..7390eb451 100644 --- a/src/webapp/src/components/Library/lists/index.js +++ b/src/webapp/src/components/Library/lists/index.js @@ -32,8 +32,8 @@ const LibraryLists = () => { setMusicFilter(event.target.value); }; - const registerMusicToCard = (command, args) => { - const actionData = buildActionData('play_music', command, args); + const registerContentToCard = (command, args) => { + const actionData = buildActionData('play_content', command, args); const state = { registerCard: { actionData, @@ -71,7 +71,7 @@ const LibraryLists = () => { element={ } exact @@ -86,7 +86,7 @@ const LibraryLists = () => { } /> diff --git a/src/webapp/src/config.js b/src/webapp/src/config.js index 46a6ec1df..9d5f5d372 100644 --- a/src/webapp/src/config.js +++ b/src/webapp/src/config.js @@ -28,11 +28,9 @@ const ROOT_DIR = './'; const JUKEBOX_ACTIONS_MAP = { // Command Aliases // Player - play_music: { + play_content: { commands: { - play_album: {}, - play_folder: {}, - play_single: {}, + play_content: {}, } }, diff --git a/src/webapp/src/utils/utils.js b/src/webapp/src/utils/utils.js index 170a8994f..5b217e9fb 100644 --- a/src/webapp/src/utils/utils.js +++ b/src/webapp/src/utils/utils.js @@ -24,10 +24,21 @@ const flatByAlbum = (albumList, { albumartist, album }) => { return [...albumList, ...list]; }; +const printObject = (obj) => { + return Object.entries(obj) + .map(([key, value]) => { + if (value && typeof value === 'object') { + return `${key}: ${printObject(value)}`; + } + return `${key}: ${value}`; + }) + .join(', '); +}; export { flatByAlbum, pluginIsLoaded, + printObject, progressToTime, timeToProgress, toHHMMSS,