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' && }
}
>
- {t('cards.controls.actions.play-music.button-label')}
+ {t('cards.controls.actions.play-content.button-label')}
);
};
-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,