From b9039b2ad4185e81a596df8604891af94d478bb1 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Thu, 23 Jan 2025 18:56:43 +1100 Subject: [PATCH 1/6] Fix surprise incompatibility with websockets 14 :/ --- src/mpd_now_playable/config/model.py | 2 +- .../receivers/websockets/receiver.py | 17 ++++++----------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/src/mpd_now_playable/config/model.py b/src/mpd_now_playable/config/model.py index 0fde2d1..a112042 100644 --- a/src/mpd_now_playable/config/model.py +++ b/src/mpd_now_playable/config/model.py @@ -35,7 +35,7 @@ class WebsocketsReceiverConfig(BaseReceiverConfig): #: The hostname you'd like your WebSockets server to listen on. In most #: cases the default behaviour, which binds to all network interfaces, will #: be fine. - host: Optional[Host | tuple[Host, ...]] = None + host: Optional[Host] = None ReceiverConfig = Annotated[ diff --git a/src/mpd_now_playable/receivers/websockets/receiver.py b/src/mpd_now_playable/receivers/websockets/receiver.py index a125b6b..d218f35 100644 --- a/src/mpd_now_playable/receivers/websockets/receiver.py +++ b/src/mpd_now_playable/receivers/websockets/receiver.py @@ -2,7 +2,7 @@ from pathlib import Path import ormsgpack from websockets import broadcast -from websockets.server import WebSocketServerProtocol, serve +from websockets.asyncio.server import Server, ServerConnection, serve from yarl import URL from ...config.model import WebsocketsReceiverConfig @@ -24,12 +24,11 @@ def default(value: object) -> object: class WebsocketsReceiver(Receiver): config: WebsocketsReceiverConfig player: Player - connections: set[WebSocketServerProtocol] + server: Server last_status: bytes = MSGPACK_NULL def __init__(self, config: WebsocketsReceiverConfig): self.config = config - self.connections = set() @classmethod def loop_factory(cls) -> DefaultLoopFactory: @@ -37,18 +36,14 @@ class WebsocketsReceiver(Receiver): async def start(self, player: Player) -> None: self.player = player - await serve( + self.server = await serve( self.handle, host=self.config.host, port=self.config.port, reuse_port=True ) - async def handle(self, conn: WebSocketServerProtocol) -> None: - self.connections.add(conn) + async def handle(self, conn: ServerConnection) -> None: await conn.send(self.last_status) - try: - await conn.wait_closed() - finally: - self.connections.remove(conn) + await conn.wait_closed() async def update(self, playback: Playback) -> None: self.last_status = ormsgpack.packb(playback, default=default) - broadcast(self.connections, self.last_status) + broadcast(self.server.connections, self.last_status) From b41339a8c5d548da18142ec4cd9991cff8550a69 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Thu, 23 Jan 2025 18:57:11 +1100 Subject: [PATCH 2/6] Update schemata to accommodate changes to WebSockets --- schemata/config-v1.json | 23 +++-------------------- schemata/playback-v1.json | 9 --------- 2 files changed, 3 insertions(+), 29 deletions(-) diff --git a/schemata/config-v1.json b/schemata/config-v1.json index 68eff9f..bf5b358 100644 --- a/schemata/config-v1.json +++ b/schemata/config-v1.json @@ -5,9 +5,6 @@ "kind": { "const": "cocoa", "default": "cocoa", - "enum": [ - "cocoa" - ], "title": "Kind", "type": "string" } @@ -52,28 +49,14 @@ "WebsocketsReceiverConfig": { "properties": { "host": { - "anyOf": [ - { - "format": "hostname", - "type": "string" - }, - { - "items": { - "format": "hostname", - "type": "string" - }, - "type": "array" - } - ], "description": "The hostname you'd like your WebSockets server to listen on. In most cases the default behaviour, which binds to all network interfaces, will be fine.", - "title": "Host" + "format": "hostname", + "title": "Host", + "type": "string" }, "kind": { "const": "websockets", "default": "websockets", - "enum": [ - "websockets" - ], "title": "Kind", "type": "string" }, diff --git a/schemata/playback-v1.json b/schemata/playback-v1.json index 58b7244..29c2300 100644 --- a/schemata/playback-v1.json +++ b/schemata/playback-v1.json @@ -136,9 +136,6 @@ }, { "const": "oneshot", - "enum": [ - "oneshot" - ], "type": "string" } ], @@ -172,9 +169,6 @@ }, { "const": "oneshot", - "enum": [ - "oneshot" - ], "type": "string" } ], @@ -328,9 +322,6 @@ "state": { "const": "stop", "default": "stop", - "enum": [ - "stop" - ], "title": "State", "type": "string" } From 7dfd3f85e471d12a0ab44ded9ed9aba43b470116 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Wed, 5 Mar 2025 13:16:48 +1100 Subject: [PATCH 3/6] Make Queue.current nullable, since MPD may be stopped --- schemata/playback-v1.json | 2 +- src/mpd_now_playable/mpd/convert/to_playback.py | 4 ++-- src/mpd_now_playable/playback/queue.py | 6 ++++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/schemata/playback-v1.json b/schemata/playback-v1.json index 29c2300..7c97aa4 100644 --- a/schemata/playback-v1.json +++ b/schemata/playback-v1.json @@ -104,7 +104,7 @@ "Queue": { "properties": { "current": { - "description": "The zero-based index of the current song in MPD's queue.", + "description": "The zero-based index of the current song in MPD's queue. If MPD is currently stopped, then there is no current song in the queue, indicated by None.", "title": "Current", "type": "integer" }, diff --git a/src/mpd_now_playable/mpd/convert/to_playback.py b/src/mpd_now_playable/mpd/convert/to_playback.py index 29b2223..77adfa2 100644 --- a/src/mpd_now_playable/mpd/convert/to_playback.py +++ b/src/mpd_now_playable/mpd/convert/to_playback.py @@ -9,8 +9,8 @@ from .to_song import to_song def to_queue(mpd: MpdState) -> Queue: return Queue( - current=int(mpd.current["pos"]), - next=int(mpd.status["nextsong"]), + current=option_fmap(int, mpd.current.get("pos")), + next=int(mpd.status.get("nextsong", 0)), length=int(mpd.status["playlistlength"]), ) diff --git a/src/mpd_now_playable/playback/queue.py b/src/mpd_now_playable/playback/queue.py index 7c93741..df5e76c 100644 --- a/src/mpd_now_playable/playback/queue.py +++ b/src/mpd_now_playable/playback/queue.py @@ -3,8 +3,10 @@ from dataclasses import dataclass @dataclass(slots=True) class Queue: - #: The zero-based index of the current song in MPD's queue. - current: int + #: The zero-based index of the current song in MPD's queue. If MPD is + #: currently stopped, then there is no current song in the queue, indicated + #: by None. + current: int | None #: The index of the next song to be played, taking into account random and #: repeat playback settings. next: int From 413df0979de46466a2eca60b4df4f8f838c559d3 Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Wed, 5 Mar 2025 13:17:36 +1100 Subject: [PATCH 4/6] Convert MPD_LOGO to the right type for ObjC --- src/mpd_now_playable/receivers/cocoa/convert/to_nsimage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mpd_now_playable/receivers/cocoa/convert/to_nsimage.py b/src/mpd_now_playable/receivers/cocoa/convert/to_nsimage.py index 213914d..e8544ed 100644 --- a/src/mpd_now_playable/receivers/cocoa/convert/to_nsimage.py +++ b/src/mpd_now_playable/receivers/cocoa/convert/to_nsimage.py @@ -37,4 +37,4 @@ def ns_image_to_media_item_artwork(img: NSImage) -> MPMediaItemArtwork: ) -MPD_LOGO = logo_to_ns_image() +MPD_LOGO = ns_image_to_media_item_artwork(logo_to_ns_image()) From c2f67c47815a5abe1041dd2bcab093533379c58b Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Wed, 30 Apr 2025 12:46:17 +1000 Subject: [PATCH 5/6] style: apply formatting fixes to AppKit code --- .../receivers/cocoa/now_playing.py | 3 +- stubs/AppKit/__init__.pyi | 38 +++++++++---------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/src/mpd_now_playable/receivers/cocoa/now_playing.py b/src/mpd_now_playable/receivers/cocoa/now_playing.py index 602006a..4e60ff3 100644 --- a/src/mpd_now_playable/receivers/cocoa/now_playing.py +++ b/src/mpd_now_playable/receivers/cocoa/now_playing.py @@ -1,7 +1,6 @@ from collections.abc import Callable, Coroutine from typing import Literal -from corefoundationasyncio import CoreFoundationEventLoop from MediaPlayer import ( MPChangePlaybackPositionCommandEvent, MPMusicPlaybackStatePlaying, @@ -12,6 +11,8 @@ from MediaPlayer import ( MPRemoteCommandHandlerStatusSuccess, ) +from corefoundationasyncio import CoreFoundationEventLoop + from ...config.model import CocoaReceiverConfig from ...playback import Playback from ...playback.state import PlaybackState diff --git a/stubs/AppKit/__init__.pyi b/stubs/AppKit/__init__.pyi index 30512c2..e21476e 100644 --- a/stubs/AppKit/__init__.pyi +++ b/stubs/AppKit/__init__.pyi @@ -8,26 +8,26 @@ NSCompositingOperationCopy: Final = 1 NSCompositingOperation = Literal[0, 1] class NSRect: - pass + pass def NSMakeRect(x: float, y: float, w: float, h: float) -> NSRect: ... class NSImage: - @staticmethod - def alloc() -> type[NSImage]: ... - - @staticmethod - def initByReferencingFile_(file: str) -> NSImage: ... - - @staticmethod - def initWithData_(data: bytes) -> NSImage: ... - - @staticmethod - def initWithSize_(size: CGSize) -> NSImage: ... - - def size(self) -> CGSize: ... - - def lockFocus(self) -> None: ... - def unlockFocus(self) -> None: ... - - def drawInRect_fromRect_operation_fraction_(self, inRect: NSRect, fromRect: NSRect, operation: NSCompositingOperation, fraction: float) -> None: ... + @staticmethod + def alloc() -> type[NSImage]: ... + @staticmethod + def initByReferencingFile_(file: str) -> NSImage: ... + @staticmethod + def initWithData_(data: bytes) -> NSImage: ... + @staticmethod + def initWithSize_(size: CGSize) -> NSImage: ... + def size(self) -> CGSize: ... + def lockFocus(self) -> None: ... + def unlockFocus(self) -> None: ... + def drawInRect_fromRect_operation_fraction_( + self, + inRect: NSRect, + fromRect: NSRect, + operation: NSCompositingOperation, + fraction: float, + ) -> None: ... From 28748df3c16bd7361be6b334840191cbeebbb8ff Mon Sep 17 00:00:00 2001 From: Danielle McLean Date: Wed, 30 Apr 2025 12:46:47 +1000 Subject: [PATCH 6/6] feat: hide running Cocoa receiver from the Dock --- src/mpd_now_playable/receivers/cocoa/now_playing.py | 4 ++++ stubs/AppKit/__init__.pyi | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/mpd_now_playable/receivers/cocoa/now_playing.py b/src/mpd_now_playable/receivers/cocoa/now_playing.py index 4e60ff3..83ba472 100644 --- a/src/mpd_now_playable/receivers/cocoa/now_playing.py +++ b/src/mpd_now_playable/receivers/cocoa/now_playing.py @@ -1,6 +1,7 @@ from collections.abc import Callable, Coroutine from typing import Literal +from AppKit import NSApplication, NSApplicationActivationPolicyAccessory from MediaPlayer import ( MPChangePlaybackPositionCommandEvent, MPMusicPlaybackStatePlaying, @@ -42,6 +43,9 @@ class CocoaNowPlayingReceiver(Receiver): pass async def start(self, player: Player) -> None: + NSApplication.sharedApplication().setActivationPolicy_( + NSApplicationActivationPolicyAccessory + ) self.cmd_center = MPRemoteCommandCenter.sharedCommandCenter() self.info_center = MPNowPlayingInfoCenter.defaultCenter() diff --git a/stubs/AppKit/__init__.pyi b/stubs/AppKit/__init__.pyi index e21476e..50b8fcc 100644 --- a/stubs/AppKit/__init__.pyi +++ b/stubs/AppKit/__init__.pyi @@ -2,6 +2,16 @@ from typing import Final, Literal from Foundation import CGSize +NSApplicationActivationPolicyRegular: Final = 0 +NSApplicationActivationPolicyAccessory: Final = 1 +NSApplicationActivationPolicyProhibited: Final = 2 +NSApplicationActivationPolicy = Literal[0, 1, 2] + +class NSApplication: + @staticmethod + def sharedApplication() -> NSApplication: ... + def setActivationPolicy_(self, policy: NSApplicationActivationPolicy) -> bool: ... + # There are many other operations available but we only actually use copy, so we don't need all of them here. NSCompositingOperationClear: Final = 0 NSCompositingOperationCopy: Final = 1