Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce new PlayContentHandler to abstract Second Swipe #2452

Open
wants to merge 9 commits into
base: future3/develop
Choose a base branch
from
18 changes: 9 additions & 9 deletions documentation/developers/docstring/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -761,11 +761,11 @@ Use just as a regular print function, but with first parameter as color

# components

<a id="components.playermpd.playcontentcallback"></a>
<a id="components.playermpd.play_content_callback"></a>

# components.playermpd.playcontentcallback
# components.playermpd.play_content_callback

<a id="components.playermpd.playcontentcallback.PlayContentCallbacks"></a>
<a id="components.playermpd.play_content_callback.PlayContentCallbacks"></a>

## PlayContentCallbacks Objects

Expand All @@ -776,7 +776,7 @@ class PlayContentCallbacks(Generic[STATE], CallbackHandler)
Callbacks are executed in various play functions


<a id="components.playermpd.playcontentcallback.PlayContentCallbacks.register"></a>
<a id="components.playermpd.play_content_callback.PlayContentCallbacks.register"></a>

#### register

Expand All @@ -796,7 +796,7 @@ Callback signature is
- `folder`: relativ path to folder to play
- `state`: indicator of the state inside the calling

<a id="components.playermpd.playcontentcallback.PlayContentCallbacks.run_callbacks"></a>
<a id="components.playermpd.play_content_callback.PlayContentCallbacks.run_callbacks"></a>

#### run\_callbacks

Expand Down
288 changes: 175 additions & 113 deletions src/jukebox/components/playermpd/__init__.py

Large diffs are not rendered by default.

97 changes: 97 additions & 0 deletions src/jukebox/components/playermpd/play_content_handler.py
Original file line number Diff line number Diff line change
@@ -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
46 changes: 26 additions & 20 deletions src/jukebox/components/rfid/cards/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())


Expand Down
6 changes: 3 additions & 3 deletions src/jukebox/components/rpc_command_alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
35 changes: 32 additions & 3 deletions src/jukebox/run_rpc_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <TAB> for auto-completion of commands!\n")
scr.addstr("Use <UP>/<DOWN> for command history!\n")
Expand Down
17 changes: 9 additions & 8 deletions src/webapp/public/locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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": {
Expand Down
17 changes: 9 additions & 8 deletions src/webapp/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"next_song": "Next song",
"pause": "Pause",
"play": "Play",
"play_content": "Play content",
"prev_song": "Previous song",
"shuffle": "Shuffle",
"repeat": "Repeat",
Expand All @@ -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",
Expand All @@ -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": {
Expand Down
18 changes: 3 additions & 15 deletions src/webapp/src/commands/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading
Loading