Source code for aionowplaying.interface.macos

import asyncio
import os
from typing import Any

from Foundation import NSMutableDictionary, NSURL
from AppKit import NSImage
from MediaPlayer import MPRemoteCommandCenter, MPNowPlayingInfoCenter
from MediaPlayer import (
    MPMediaItemPropertyTitle, MPMediaItemPropertyArtist, MPMediaItemPropertyAlbumTitle,
    MPMusicPlaybackStatePlaying, MPMusicPlaybackStatePaused,
    MPMusicPlaybackStateStopped, MPMediaItemPropertyPlaybackDuration,
    MPNowPlayingInfoPropertyPlaybackRate, MPNowPlayingInfoPropertyElapsedPlaybackTime,
    MPRemoteCommandHandlerStatusSuccess, MPNowPlayingInfoPropertyDefaultPlaybackRate,
    MPMediaItemPropertyAlbumArtist, MPMediaItemPropertyComposer, MPMediaItemPropertyGenre,
    MPMediaItemPropertyArtwork,
)

try:
    from MediaPlayer import MPMediaItemPropertyAlbumTrackNumber
except Exception:  # pragma: no cover - depends on OS/framework availability
    MPMediaItemPropertyAlbumTrackNumber = None

try:
    from MediaPlayer import MPChangePlaybackPositionCommandEvent, MPChangePlaybackRateCommandEvent
    from MediaPlayer import MPChangeRepeatModeCommandEvent, MPChangeShuffleModeCommandEvent
    from MediaPlayer import MPRepeatTypeOff, MPRepeatTypeOne, MPRepeatTypeAll
    from MediaPlayer import MPShuffleTypeOff, MPShuffleTypeItems
    from MediaPlayer import MPMediaItemArtwork
except Exception:  # pragma: no cover - depends on OS/framework availability
    MPChangePlaybackPositionCommandEvent = object
    MPChangePlaybackRateCommandEvent = object
    MPChangeRepeatModeCommandEvent = object
    MPChangeShuffleModeCommandEvent = object
    MPRepeatTypeOff = None
    MPRepeatTypeOne = None
    MPRepeatTypeAll = None
    MPShuffleTypeOff = None
    MPShuffleTypeItems = None
    MPMediaItemArtwork = None

from aionowplaying import (
    BaseInterface, PlaybackPropertyName, PlaybackProperties, PlaybackStatus
)
from aionowplaying.interface.base import LoopStatus

PlaybackStatusStateMapping = {
    PlaybackStatus.Playing: MPMusicPlaybackStatePlaying,
    PlaybackStatus.Paused: MPMusicPlaybackStatePaused,
    PlaybackStatus.Stopped: MPMusicPlaybackStateStopped,
}


[docs] def create_handler(_, handler): def handle(_): asyncio.create_task(handler()) return MPRemoteCommandHandlerStatusSuccess return handle
[docs] def create_event_handler(handler): def handle(event): asyncio.create_task(handler(event)) return MPRemoteCommandHandlerStatusSuccess return handle
[docs] class MacOSInterface(BaseInterface): def __init__(self, name: str): super().__init__(name) self.cmd_center = MPRemoteCommandCenter.sharedCommandCenter() self.info_center = MPNowPlayingInfoCenter.defaultCenter() self._cmds = [ (self.cmd_center.togglePlayPauseCommand(), self.on_play_pause), (self.cmd_center.playCommand(), self.on_play), (self.cmd_center.pauseCommand(), self.on_pause), (self.cmd_center.nextTrackCommand(), self.on_next), (self.cmd_center.previousTrackCommand(), self.on_previous), ] for cmd, handler in self._cmds: cmd.addTargetWithHandler_(create_handler(cmd, handler)) # Optional commands based on MediaPlayer availability. self._cmd_stop = getattr(self.cmd_center, "stopCommand", lambda: None)() if self._cmd_stop is not None: self._cmd_stop.addTargetWithHandler_(create_handler(self._cmd_stop, self.on_stop)) self._cmd_change_position = getattr(self.cmd_center, "changePlaybackPositionCommand", lambda: None)() if self._cmd_change_position is not None: self._cmd_change_position.addTargetWithHandler_(create_event_handler(self._handle_change_playback_position)) self._cmd_change_rate = getattr(self.cmd_center, "changePlaybackRateCommand", lambda: None)() if self._cmd_change_rate is not None: self._cmd_change_rate.addTargetWithHandler_(create_event_handler(self._handle_change_playback_rate)) self._cmd_change_repeat = getattr(self.cmd_center, "changeRepeatModeCommand", lambda: None)() if self._cmd_change_repeat is not None: self._cmd_change_repeat.addTargetWithHandler_(create_event_handler(self._handle_change_repeat_mode)) self._cmd_change_shuffle = getattr(self.cmd_center, "changeShuffleModeCommand", lambda: None)() if self._cmd_change_shuffle is not None: self._cmd_change_shuffle.addTargetWithHandler_(create_event_handler(self._handle_change_shuffle_mode))
[docs] def get_playback_property(self, name: PlaybackPropertyName) -> Any: if name == PlaybackPropertyName.Position: return self._get_property(MPNowPlayingInfoPropertyElapsedPlaybackTime) if name == PlaybackPropertyName.Rate: return self._get_property(MPNowPlayingInfoPropertyPlaybackRate) return super().get_playback_property(name)
[docs] def set_property(self, name, value: Any): # MPNowPlayingInfoCenter does not map player properties like fullscreen/quit/raise. super().set_property(name, value)
[docs] def set_playback_property(self, name: PlaybackPropertyName, value: Any): if name == PlaybackPropertyName.Metadata: value: PlaybackProperties.MetadataBean nowplaying_info = self._get_or_create_nowplaying_info() nowplaying_info[MPMediaItemPropertyTitle] = value.title nowplaying_info[MPMediaItemPropertyArtist] = ', '.join(value.artist) nowplaying_info[MPMediaItemPropertyAlbumTitle] = value.album if value.albumArtist: nowplaying_info[MPMediaItemPropertyAlbumArtist] = ', '.join(value.albumArtist) if value.composer: nowplaying_info[MPMediaItemPropertyComposer] = ', '.join(value.composer) if value.genre: nowplaying_info[MPMediaItemPropertyGenre] = ', '.join(value.genre) if value.trackNumber and MPMediaItemPropertyAlbumTrackNumber is not None: nowplaying_info[MPMediaItemPropertyAlbumTrackNumber] = value.trackNumber if value.duration: nowplaying_info[MPMediaItemPropertyPlaybackDuration] = value.duration / 1000 / 1000 # Artwork is supported via MPMediaItemArtwork but requires image data. if value.cover: artwork = self._load_artwork(value.cover) if artwork is not None: nowplaying_info[MPMediaItemArtwork] = artwork self.info_center.setNowPlayingInfo_(nowplaying_info) elif name == PlaybackPropertyName.PlaybackStatus: value: PlaybackProperties.PlaybackStatus # Need to set the rate to 0 when it is paused or stopped, # otherwise, macos's position will be incorrect after the player resumes. if value in (PlaybackStatus.Paused, PlaybackStatus.Stopped): rate = 0 else: rate = self.get_playback_property(PlaybackPropertyName.Rate) # Must update the position to keep it accurate. Note that updating the # nowplaying info re-paint the UI. # HELP: Is there a better way to do this? nowplaying_info = self._get_or_create_nowplaying_info() nowplaying_info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = \ self.get_playback_property(PlaybackPropertyName.Position) / 1000 / 1000 nowplaying_info[MPNowPlayingInfoPropertyPlaybackRate] = rate self.info_center.setNowPlayingInfo_(nowplaying_info) self.info_center.setPlaybackState_(PlaybackStatusStateMapping[value]) elif name == PlaybackPropertyName.Position: self._update_property(MPNowPlayingInfoPropertyElapsedPlaybackTime, value / 1000 / 1000) elif name == PlaybackPropertyName.Rate: self._update_property(MPNowPlayingInfoPropertyDefaultPlaybackRate, 1) self._update_property(MPNowPlayingInfoPropertyPlaybackRate, value) elif name == PlaybackPropertyName.Duration: self._update_property(MPMediaItemPropertyPlaybackDuration, value / 1000 / 1000) elif name == PlaybackPropertyName.LoopStatus: if self._cmd_change_repeat is not None and MPRepeatTypeOff is not None: try: if value == LoopStatus.Track: self._cmd_change_repeat.currentRepeatType = MPRepeatTypeOne elif value == LoopStatus.Playlist: self._cmd_change_repeat.currentRepeatType = MPRepeatTypeAll else: self._cmd_change_repeat.currentRepeatType = MPRepeatTypeOff except Exception: # Some MediaPlayer projections expose currentRepeatType as read-only. pass elif name == PlaybackPropertyName.Shuffle: if self._cmd_change_shuffle is not None and MPShuffleTypeOff is not None: try: self._cmd_change_shuffle.currentShuffleType = ( MPShuffleTypeItems if value else MPShuffleTypeOff ) except Exception: # Some MediaPlayer projections expose currentShuffleType as read-only. pass elif name == PlaybackPropertyName.MinimumRate: self._update_supported_rates() elif name == PlaybackPropertyName.MaximumRate: self._update_supported_rates() elif name == PlaybackPropertyName.Volume: # MediaPlayer does not expose a now-playing volume control. pass else: # TODO: handle more properties changes pass super().set_playback_property(name, value) # Enable/disable commands based on capability flags. if name == PlaybackPropertyName.CanPlay: self._set_command_enabled(self.cmd_center.playCommand(), value) elif name == PlaybackPropertyName.CanPause: self._set_command_enabled(self.cmd_center.pauseCommand(), value) self._set_command_enabled(self.cmd_center.togglePlayPauseCommand(), value) elif name == PlaybackPropertyName.CanGoNext: self._set_command_enabled(self.cmd_center.nextTrackCommand(), value) elif name == PlaybackPropertyName.CanGoPrevious: self._set_command_enabled(self.cmd_center.previousTrackCommand(), value) elif name == PlaybackPropertyName.CanSeek: if self._cmd_change_position is not None: self._set_command_enabled(self._cmd_change_position, value) elif name == PlaybackPropertyName.CanControl: if self._cmd_stop is not None: self._set_command_enabled(self._cmd_stop, value) if self._cmd_change_rate is not None: self._set_command_enabled(self._cmd_change_rate, value) if self._cmd_change_repeat is not None: self._set_command_enabled(self._cmd_change_repeat, value) if self._cmd_change_shuffle is not None: self._set_command_enabled(self._cmd_change_shuffle, value)
[docs] def set_tracklist_property(self, name, value: Any): super().set_tracklist_property(name, value)
[docs] def get_property(self, name): return super().get_property(name)
[docs] def get_tracklist_property(self, name): return super().get_tracklist_property(name)
[docs] async def start(self): pass
async def _handle_change_playback_position(self, event: MPChangePlaybackPositionCommandEvent): if not self.get_playback_property(PlaybackPropertyName.CanSeek): return position = int(event.positionTime * 1000 * 1000) track_id = self.get_playback_property(PlaybackPropertyName.Metadata).id_ await self.on_set_position(track_id, position) await self.on_seek(position) async def _handle_change_playback_rate(self, event: MPChangePlaybackRateCommandEvent): await self.on_rate(event.playbackRate) async def _handle_change_repeat_mode(self, event: MPChangeRepeatModeCommandEvent): if not self.get_playback_property(PlaybackPropertyName.CanControl): return if MPRepeatTypeOff is None: return repeat_type = event.repeatType if repeat_type == MPRepeatTypeOne: await self.on_loop_status(LoopStatus.Track) elif repeat_type == MPRepeatTypeAll: await self.on_loop_status(LoopStatus.Playlist) else: await self.on_loop_status(LoopStatus.None_) async def _handle_change_shuffle_mode(self, event: MPChangeShuffleModeCommandEvent): if not self.get_playback_property(PlaybackPropertyName.CanControl): return if MPShuffleTypeOff is None: return shuffle_type = event.shuffleType await self.on_shuffle(shuffle_type == MPShuffleTypeItems) def _get_or_create_nowplaying_info(self): current = self.info_center.nowPlayingInfo() if current is not None: nowplaying_info = current.mutableCopy() else: nowplaying_info = NSMutableDictionary.dictionary() return nowplaying_info def _update_property(self, name, value): nowplaying_info = self._get_or_create_nowplaying_info() nowplaying_info[name] = value self.info_center.setNowPlayingInfo_(nowplaying_info) def _get_property(self, name): nowplaying_info = self._get_or_create_nowplaying_info() return nowplaying_info[name] def _set_command_enabled(self, command, enabled: bool): if command is None: return if hasattr(command, "enabled"): command.enabled = enabled return if hasattr(command, "isEnabled"): try: command.isEnabled = enabled except Exception: pass def _update_supported_rates(self): if self._cmd_change_rate is None: return min_rate = self.get_playback_property(PlaybackPropertyName.MinimumRate) max_rate = self.get_playback_property(PlaybackPropertyName.MaximumRate) if min_rate is None or max_rate is None or min_rate > max_rate: return rates = sorted(set([min_rate, 1.0, max_rate])) try: self._cmd_change_rate.supportedPlaybackRates = rates except Exception: # Some MediaPlayer projections expose supportedPlaybackRates as read-only. return def _load_artwork(self, cover: str): # MediaPlayer expects image data; remote URLs may block. Only load local files. if cover.startswith("file://"): url = NSURL.URLWithString_(cover) elif os.path.exists(cover): url = NSURL.fileURLWithPath_(cover) else: return None image = NSImage.alloc().initWithContentsOfURL_(url) if image is None: return None if MPMediaItemArtwork is None: return None return MPMediaItemArtwork.alloc().initWithBoundsSize_requestHandler_(image.size(), lambda _: image)