diff --git a/.gitignore b/.gitignore index 15faa2d..6851fb0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ virtualEnv/ __pycache__/ +repomix-output.xml +.venv diff --git a/README.md b/README.md index 361ea67..8810b14 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,64 @@ -# scrcpyMediaController -![Screenshot of scrcpyMediaController in swaync](Screenshots/Screenshot_02-Jun_10-38-55_26599.png) -Control your phone/emulator's media playback from your notification panel through MPRIS.
-This script works independently from scrcpy and does not require it to be installed or running for use.
-Take note that this "simple" script only works on GNU/Linux with MPRIS and only controls media playback. -It does not forward audio. Use scrcpy or sndcpy to do that.
-**Credits:** Default album art icon (`icon.png`) from [scrcpy repository](https://github.com/Genymobile/scrcpy/blob/master/app/data/icon.png).
-Tested on Ubnutu Mantic 23.10 running Hyprland with `swaync`. +# audiocpy (scrcpyMediaController) +![showcase](./screenshots/showcase-2026-04-22.png) +This program does two things: +1. Shares audio from connected device to the pc, supressing it on the original device. +2. Exposes Android media playback over MPRIS so desktop notification panels (swaync, dunst, waybar, etc.) can display and control it. + +I built it so that I can listen to a podcast from my phone while playing a game on my pc, and mix the audio on pc. +An alternative sync is an A2DP sink but that introduces latency, lag & quality loss if you have multiple devices connected over bluetooth e.g. Xbox Controller, Wireless headphones (connected to PC) and Phone (streaming audio to PC). If you only connect your phone (e.g. have wired headphones and use mouse&keyboard for that game), you may not need this/scrcpy at all and A2DP might be sufficient. + +For now, this only works on linux. It will **not** work on Windows or MacOS, since they don't use MPRIS, but their own thing. On those OSes you can just do `scrcpy --no-window --no-video` and you get the audio part without the media controls. +Windows support or MacOS support is possible, but since I don't use either of those (for now), I will not implement it (for now). + +You need to install [scrcpy](https://scrcpy.org) ([github](https://github.com/genymobile/scrcpy), [repology](https://repology.org/project/scrcpy/versions)). +Requires GNU/Linux with D-Bus, `adb` connected to a device, and `scrcpy` in PATH. + +audiocpy starts `scrcpy --no-window --no-video` alongside the controller and tears it down on exit; you can pass `--detach` to manage scrcpy yourself. +Run `audiocpy --help` for all options. + +Album art is resolved offline via Android MediaStore only, with cached images stored in `~/.cache/scrcpyMediaController`. + +## System dependencies -## Setup -Clone this repo, install Python3.12 and create a virtual environment +**Fedora:** ```bash -git clone https://github.com/AzlanCoding/scrcpyMediaController -sudo apt install python3.12 python3.12-venv python3.12-dev libgirepository1.0-dev libcairo2-dev -cd scrcpyMediaController -python3.12 -m venv virtualEnv -source ./virtualEnv/bin/activate -pip install mpris_server -exit +sudo dnf install android-tools scrcpy python3-devel gobject-introspection-devel cairo-gobject-devel gcc pkg-config ``` -Once done can remove build packages: + +**Debian/Ubuntu:** ```bash -sudo apt remove python3.12-dev libgirepository1.0-dev libcairo2-dev -sudo apt autoremove +sudo apt install adb scrcpy python3.12-dev libgirepository1.0-dev libcairo2-dev pkg-config gcc ``` +The compiler and dev headers are only needed at install time to build `pydbus`/`PyGObject`. They can be removed afterwards. -## Running -Connect your device to your laptop via `adb` and run the command below. -```bash -./start_scrcpyMediaController.sh -``` -Alternatively, you can manually activate the environment and run `main.py` using the following commands: -```bash -cd scrcpyMediaPlayer -source ./virtualEnv/bin/activate -python main.py -``` +## Install +With the system dependencies above in place: -## Running in background -### Setting up ```bash -nohup ./start_scrcpyMediaController.sh 0 & +uv tool install git+https://github.com/KraXen72/scrcpyMediaController +audiocpy ``` -**DO NOT RUN `./start_scrcpyMediaController.sh & disown`.** Process will hang when `print()` or any standard output is called in the program. -### Killing -use `Btop++` or something to send signal 15 (SIGTERM) and terminate the process with the program named `python`. [Don't use SIGKILL!!!](https://turnoff.us/geek/dont-sigkill/?ref=linuxhandbook.com) +## Local development -## Customizing -In `main.py` you can change the 3 variables in lines 10-12 -```python -artUrl = "file://"+os.path.join(os.path.dirname(__file__), 'icon.png') -playerName = "scrcpy" -updateFreq = 1 +```bash +git clone https://github.com/KraXen72/scrcpyMediaController +cd scrcpyMediaController +uv sync +uv run audiocpy ``` -`artUrl` holds the location of the album art icon (player icon).
-`playerName` defines the name of the player.
-`updateFreq` specifies how frequent the player checks for updates in seconds. -## To Do -- Convert the variables above to flags you can pass -- Windows support using `winrt.windows.media.control.GlobalSystemMediaTransportControlsSessionManager` as suggested by Bing Chat -- First Release +## Intentionally unsupported functionality: +- **Fetching album art for AntennaPod and Spotify** + - the only reliable found method is fetching through Android MediaStore (+ caching), which does not work for these apps +- **Shuffle/Loop controls** + - at one point they were implemented, but they didn't work. Removed for now, may be reintroduced later. + +## Credits +Thanks to the [scrcpy repository](https://github.com/Genymobile/scrcpy/blob/master/app/data/icon.png) for the icon (`icon.png`). +Thanks to the [original project](https://github.com/AzlanCoding/scrcpyMediaController) for the initial code. diff --git a/Screenshots/Screenshot_02-Jun_10-38-55_26599.png b/Screenshots/Screenshot_02-Jun_10-38-55_26599.png deleted file mode 100644 index 0519262..0000000 Binary files a/Screenshots/Screenshot_02-Jun_10-38-55_26599.png and /dev/null differ diff --git a/Screenshots/Screenshot_02-Jun_10-58-03_1099.png b/Screenshots/Screenshot_02-Jun_10-58-03_1099.png deleted file mode 100644 index e72c141..0000000 Binary files a/Screenshots/Screenshot_02-Jun_10-58-03_1099.png and /dev/null differ diff --git a/Screenshots/Screenshot_30-May_20-25-30_20743.png b/Screenshots/Screenshot_30-May_20-25-30_20743.png deleted file mode 100644 index 125c5fb..0000000 Binary files a/Screenshots/Screenshot_30-May_20-25-30_20743.png and /dev/null differ diff --git a/album_art.py b/album_art.py new file mode 100644 index 0000000..b0b03d0 --- /dev/null +++ b/album_art.py @@ -0,0 +1,168 @@ +""" +album_art.py — Offline album art fetching for scrcpy-media-controller. + +Public API +---------- +art_cache_key(title, artist, album) -> str + Stable string key identifying a track for cache lookups. + +request_art(key, title, artist, album, package, on_ready) -> str + Returns a cached file:// URI immediately if one exists, otherwise + starts a background fetch and calls on_ready(key, uri) when done. + Returns '' when the result is not yet known. +""" + +import hashlib +import os +import re +import shlex +import subprocess +import tempfile +import threading +from collections.abc import Callable +from pathlib import Path +from threading import Thread + +_CACHE_DIR = Path.home() / ".cache" / "scrcpyMediaController" +_CACHE_DIR.mkdir(parents=True, exist_ok=True) +_BLACKLISTED_PACKAGES: set[str] = { + "com.spotify.music", + "de.danoeh.antennapod", + "de.danoeh.antennapod.debug", +} + +_cache: dict[str, str] = {} +_album_cache: dict[str, str] = {} +_cache_lock = threading.Lock() +_in_flight: set[str] = set() + + +def _cache_path(identifier: str, ext: str) -> Path: + safe_ext = ext if ext.startswith(".") else f".{ext}" + digest = hashlib.sha256(identifier.encode("utf-8")).hexdigest() + return _CACHE_DIR / f"{digest}{safe_ext}" + + +def _is_supported_image(path: Path) -> bool: + header = path.read_bytes()[:12] + is_jpeg = header[:3] == b"\xff\xd8\xff" + is_png = header[:4] == b"\x89PNG" + is_webp = header[:4] == b"RIFF" and header[8:12] == b"WEBP" + return is_jpeg or is_png or is_webp + + +def _uri_exists(uri: str) -> bool: + return uri.startswith("file://") and Path(uri[7:]).exists() + + +def _album_identity(package: str, artist: list[str], album: str) -> str | None: + if not album: + return None + return f"{package}\x00{album}\x00{''.join(sorted(artist))}" + + +def _query_album_id(title: str) -> str | None: + sql_safe = title.replace("'", "''").replace("\n", " ").replace("\r", " ") + like_safe = sql_safe.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + clauses = ( + f"title='{sql_safe}'", + f"title LIKE '%{like_safe}%' ESCAPE '\\\\'", + ) + + for clause in clauses: + shell_clause = shlex.quote(clause) + query = subprocess.run( + [ + "adb", + "shell", + f"content query --uri content://media/external/audio/media --projection album_id --where {shell_clause}", + ], + capture_output=True, + text=True, + timeout=5, + ) + match = re.search(r"album_id=(\d+)", query.stdout or "") + if match: + return match.group(1) + return None + + +def _read_album_art(album_id: str) -> str: + album_uri = f"content://media/external/audio/albumart/{album_id}" + dest = _cache_path(album_uri, ".jpg") + if not dest.exists(): + pull = subprocess.run( + ["adb", "exec-out", "content", "read", "--uri", album_uri], + capture_output=True, + timeout=8, + ) + if pull.returncode != 0 or not pull.stdout: + return "" + fd, tmp_path = tempfile.mkstemp(dir=_CACHE_DIR, suffix=".tmp") + try: + with os.fdopen(fd, "wb") as tmp_file: + tmp_file.write(pull.stdout) + os.replace(tmp_path, dest) + except Exception: + if os.path.exists(tmp_path): + os.unlink(tmp_path) + raise + + if not _is_supported_image(dest): + dest.unlink(missing_ok=True) + return "" + return f"file://{dest}" + + +def _fetch(title: str, package: str) -> str: + if package in _BLACKLISTED_PACKAGES: + return "" + try: + album_id = _query_album_id(title) + return _read_album_art(album_id) if album_id else "" + except Exception: + return "" + + +def art_cache_key(title: str, artist: list[str], album: str) -> str: + return f"{title}\x00{album}\x00{''.join(sorted(artist))}" + + +def request_art( + key: str, + title: str, + artist: list[str], + album: str, + package: str, + on_ready: Callable[[str, str], None], +) -> str: + if package in _BLACKLISTED_PACKAGES: + with _cache_lock: + _cache[key] = "" + return "" + + album_key = _album_identity(package, artist, album) + with _cache_lock: + if key in _cache: + return _cache[key] + if album_key: + album_uri = _album_cache.get(album_key) + if album_uri and _uri_exists(album_uri): + _cache[key] = album_uri + return album_uri + _album_cache.pop(album_key, None) + if key in _in_flight: + return "" + _in_flight.add(key) + + def _worker() -> None: + uri = _fetch(title, package) + with _cache_lock: + _cache[key] = uri + if uri and album_key: + _album_cache[album_key] = uri + _in_flight.discard(key) + on_ready(key, uri) + + Thread(target=_worker, daemon=True).start() + return "" diff --git a/main.py b/main.py index 9969305..33ae1c5 100644 --- a/main.py +++ b/main.py @@ -1,231 +1,421 @@ -import os +#!/usr/bin/env python3 +""" +scrcpy MPRIS media controller. + +Exposes Android media playback over MPRIS so desktop notification panels +(swaync, dunst, waybar, …) can display and control it. +Requires an ADB-connected Android device. +""" + +import os # at the top of the file +import re +import signal import subprocess -from threading import Timer, Thread, Event -from mpris_server.adapters import PlayState, PlayerAdapter +from threading import Event, Thread + +import click +from mpris_server.adapters import PlayerAdapter, PlayState from mpris_server.events import EventAdapter from mpris_server.server import Server -from player import CustomPlayer -#Modify these variables to customise the player -artUrl = "file://"+os.path.join(os.path.dirname(__file__), 'icon.png') -playerName = "scrcpy" -updateFreq = 1 - -class app: - def __init__(self): - self.title = "No Media" - self.album = "Unknown" - self.artist = [] - self.currentPosition = 0 - self.playbackState = PlayState.PLAYING - self.media_adapter = None - self.oldDevice = False - - def update(self) -> None: - media_session = subprocess.run(["adb","shell","dumpsys","media_session"], capture_output=True, text=True).stdout - - try: - desc = media_session.split("description=")[1].split("\n")[0] - assert desc != "null" - descList = desc.split(", ") - assert descList != ["null","null","null"] #Media is Buffering - - self.title = descList[0] - self.artist = descList[1:-1]#Song may have multiple artists - self.album = descList[-1] - - playback_state = media_session.split("state=PlaybackState {")[1].split("}")[0].split(", ") - playbackStatus = playback_state[0] - - if len(playbackStatus) <= 8: - #Support for older versions of Android - self.oldDevice = True - if playbackStatus == "state=0" or playbackStatus == "state=1" or \ - playbackStatus == "state=7" or playbackStatus == "state=8": - self.playackState = PlayState.STOPPED - elif playbackStatus == "state=2" or playbackStatus == "state=6": - self.playbackState = PlayState.PAUSED - elif playbackStatus == "state=3" or playbackStatus == "state=4" or \ - playbackStatus == "state=5" or playbackStatus == "state=9" or \ - playbackStatus == "state=10" or playbackStatus == "state=11": - self.playbackState = PlayState.PLAYING - else: - print("Error: unknown playback status\n" + str(playback_state)) - else: - self.oldDevice = False - if playbackStatus == "state=NONE(0)" or playbackStatus == "state=STOPPED(1)" or \ - playbackStatus == "state=ERROR(7)" or playbackStatus == "state=CONNECTING(8)": - self.playackState = PlayState.STOPPED - elif playbackStatus == "state=PAUSED(2)" or playbackStatus == "state=BUFFERING(6)": - self.playbackState = PlayState.PAUSED - elif playbackStatus == "state=PLAYING(3)" or playbackStatus == "state=FAST_FORWARDING(4)" or \ - playbackStatus == "state=REWINDING(5)" or playbackStatus == "state=SKIPPING_TO_PREVIOUS(9)" or \ - playbackStatus == "state=SKIPPING_TO_NEXT(10)" or playbackStatus == "state=SKIPPING_TO_QUEUE_ITEM(11)": - self.playbackState = PlayState.PLAYING - else: - print("Error: unknown playback status\n" + str(playback_state)) - - except (IndexError, AssertionError): - self.title = "No Media" - self.artist = [] - self.album = "Unknown" - self.playbackState = PlayState.PLAYING - - if self.media_adapter: - EventAdapter.emit_changes(self.media_adapter.player,["Metadata","PlaybackStatus"]) - - def sendKeyCode(self, keyCode: str) -> subprocess.CompletedProcess: - return subprocess.run(["adb", "shell", "input", "keyevent", keyCode]) - - def dispatchMediaKey(self, key: str) -> subprocess.CompletedProcess: - if self.oldDevice == True: - return subprocess.run(["adb", "shell", "media", "dispatch", key]) - else: - return subprocess.run(["adb", "shell", "cmd", "media_session", "dispatch", key]) - -App = app() -App.update() +from album_art import art_cache_key, request_art +from player import CustomPlayer +# --------------------------------------------------------------------------- +# App state +# --------------------------------------------------------------------------- + + +class AppState: + def __init__(self) -> None: + self.title: str = "No Media" + self.album: str = "" + self.artist: list[str] = [] + self.package: str = "" + self.art_url: str = "" + self.playbackState: PlayState = PlayState.PLAYING + self.media_adapter = None + self.oldDevice: bool = False + self._art_key: str = "" + + @staticmethod + def _denull(val: str) -> str: + """Coerce the literal string 'null' (sent by some Android versions) to ''.""" + return "" if val == "null" else val + + @staticmethod + def _best_session(media_session: str) -> tuple[str, str, str] | None: + """ + Return (package, description, playback-state-token) for the best media session. + """ + pattern = re.compile(r"package=([A-Za-z0-9._]+)(?P(?:(?!\n\s+package=).)*)", re.S) + candidates: list[tuple[int, str, str, str]] = [] + for m in pattern.finditer(media_session): + package = m.group(1) + body = m.group("body") + state_match = re.search(r"state=PlaybackState\s*\{([^}]*)\}", body) + desc_match = re.search(r"metadata:\s*size=\d+,\s*description=([^\n]+)", body) + if not state_match or not desc_match: + continue + state_token = state_match.group(1).split(", ")[0] + desc = desc_match.group(1).strip() + active = "active=true" in body + + score = 0 + if active: + score += 4 + if state_token in ("state=PLAYING(3)", "state=3"): + score += 4 + elif state_token in ("state=PAUSED(2)", "state=2", "state=BUFFERING(6)", "state=6"): + score += 2 + if desc != "null, null, null" and desc != "null": + score += 2 + candidates.append((score, package, desc, state_token)) + if not candidates: + return None + _, package, desc, state_token = max(candidates, key=lambda x: x[0]) + return package, desc, state_token + + def update(self, emit: bool = True) -> bool: + result = subprocess.run( + ["adb", "shell", "dumpsys", "media_session"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return False + media_session = result.stdout + + try: + session = self._best_session(media_session) + if session is None: + raise ValueError("No valid media session found") + self.package, desc, status = session + if desc == "null": + raise ValueError("Session description is null") + desc_list = desc.split(", ") + if desc_list == ["null", "null", "null"]: + raise ValueError("Session description has no metadata") + + if len(desc_list) >= 3: + self.title = self._denull(", ".join(desc_list[:-2])) or "Unknown" + artist_field = self._denull(desc_list[-2]) + self.artist = [artist_field] if artist_field else [] + self.album = self._denull(desc_list[-1]) + else: + self.title = self._denull(desc_list[0]) or "Unknown" + self.artist = [a for a in desc_list[1:-1] if a and a != "null"] + self.album = self._denull(desc_list[-1]) if len(desc_list) > 1 else "" + + # -- playback state ------------------------------------------- + if len(status) <= 8: + self.oldDevice = True + if status in ("state=0", "state=1", "state=7", "state=8"): + self.playbackState = PlayState.STOPPED + elif status in ("state=2", "state=6"): + self.playbackState = PlayState.PAUSED + elif status in ("state=3", "state=4", "state=5", "state=9", "state=10", "state=11"): + self.playbackState = PlayState.PLAYING + else: + print(f"[mediactl] unknown playback status: {status}") + else: + self.oldDevice = False + if status in ("state=NONE(0)", "state=STOPPED(1)", "state=ERROR(7)", "state=CONNECTING(8)"): + self.playbackState = PlayState.STOPPED + elif status in ("state=PAUSED(2)", "state=BUFFERING(6)"): + self.playbackState = PlayState.PAUSED + elif status in ( + "state=PLAYING(3)", + "state=FAST_FORWARDING(4)", + "state=REWINDING(5)", + "state=SKIPPING_TO_PREVIOUS(9)", + "state=SKIPPING_TO_NEXT(10)", + "state=SKIPPING_TO_QUEUE_ITEM(11)", + ): + self.playbackState = PlayState.PLAYING + else: + print(f"[mediactl] unknown playback status: {status}") + + # -- album art (background fetch, cached by track identity) ---- + key = art_cache_key(self.title, self.artist, self.album) + if key != self._art_key: + self._art_key = key + self.art_url = request_art( + key, + self.title, + self.artist, + self.album, + self.package, + self._on_art_ready, + ) + + except (IndexError, ValueError): + self.title = "No Media" + self.artist = [] + self.album = "" + self.package = "" + self.art_url = "" + self.playbackState = PlayState.PLAYING + + if self.media_adapter and emit: + EventAdapter.emit_changes( + self.media_adapter.player, + ["Metadata", "PlaybackStatus"], + ) + return True + + def _on_art_ready(self, key: str, uri: str) -> None: + if uri and self._art_key == key: + self.art_url = uri + if self.media_adapter: + EventAdapter.emit_changes(self.media_adapter.player, ["Metadata"]) + + def dispatch_media_key(self, key: str) -> subprocess.CompletedProcess: + if self.oldDevice: + return subprocess.run(["adb", "shell", "media", "dispatch", key]) + return subprocess.run(["adb", "shell", "cmd", "media_session", "dispatch", key]) + + +# --------------------------------------------------------------------------- +# Update thread +# --------------------------------------------------------------------------- class UpdateThread(Thread): - def __init__(self, event): - Thread.__init__(self) - self.exit = event + def __init__(self, app: AppState, update_freq: float, exit_event: Event) -> None: + super().__init__(daemon=True, name="update-thread") + self.app = app + self.update_freq = update_freq + self.exit = exit_event + + def run(self) -> None: + while not self.exit.wait(self.update_freq): + if not self.app.update(): + print("[mediactl] device disconnected — exiting") + os.kill(os.getpid(), signal.SIGTERM) + break - def run(self): - while not self.exit.wait(updateFreq): - App.update() -exitEvent = Event() -thread = UpdateThread(exitEvent) -thread.start() +# --------------------------------------------------------------------------- +# MPRIS adapter +# --------------------------------------------------------------------------- class MediaAdapter(PlayerAdapter): - def next(self) -> None: - #adb shell input keyevent KEYCODE_MEDIA_NEXT - #adb shell cmd media_session dispatch next - print("next") - #App.sendKeyCode("KEYCODE_MEDIA_NEXT") - App.dispatchMediaKey("next") - App.update() - - def previous(self) -> None: - #adb shell input keyevent KEYCODE_MEDIA_PREVIOUS - #adb shell cmd media_session dispatch previous - print("prev") - #App.sendKeyCode("KEYCODE_MEDIA_PREVIOUS") - App.dispatchMediaKey("previous") - App.update() - - def pause(self) -> None: - #adb shell input keyevent KEYCODE_MEDIA_PAUSE - #adb shell cmd media_session dispatch pause - print('pause') - #App.sendKeyCode("KEYCODE_MEDIA_PAUSE") - App.dispatchMediaKey("pause") - App.update() - - def resume(self) -> None: - #adb shell input keyevent KEYCODE_MEDIA_PLAY - #adb shell cmd media_session dispatch play - print("resume") - #App.sendKeyCode("KEYCODE_MEDIA_PLAY") - App.dispatchMediaKey("play") - App.update() - - def stop(self) -> None: - #adb shell input keyevent KEYCODE_MEDIA_STOP - #adb shell cmd media_session dispatch stop - print("stop") - #App.sendKeyCode("KEYCODE_MEDIA_STOP") - App.dispatchMediaKey("stop") - App.update() - - def play(self) -> None: - #adb shell input keyevent KEYCODE_MEDIA_PLAY - #adb shell cmd media_session dispatch play - print("play") - #App.sendKeyCode("KEYCODE_MEDIA_PLAY") - App.dispatchMediaKey("play") - App.update() - - def get_playstate(self) -> PlayState: - return App.playbackState - - def get_art_url(self, track): - return artUrl - - def can_go_next(self) -> bool: - return True - - def can_go_previous(self) -> bool: - return True - - def can_play(self) -> bool: - return True - - def can_pause(self) -> bool: - return True - - def can_seek(self) -> bool: - return False - - def can_control(self) -> bool: - return True - - def can_quit(self) -> bool: - return False - - def can_raise(self) -> bool: - return False - - def has_tracklist(self) -> bool: - return False - - def can_fullscreen(self) -> bool: - return False - - def get_fullscreen(self) -> None: - return None - - def get_stream_title(self) -> str: - return App.title - - def get_desktop_entry(self) -> str: - return "scrcpy" - - def get_mime_types(self) -> list[str]: - return ["audio/mpeg", "application/ogg", "video/mpeg"] - - def get_uri_schemes(self) -> list[str]: - return ["file"] - - def metadata(self) -> dict: - metadata = { - "mpris:artUrl": artUrl, - "mpris:trackid": '/org/mpris/MediaPlayer2/scrcpy', - "xesam:title": App.title, - "xesam:artist": App.artist, - "xesam:album": App.album - } - - return metadata - -media_adapter = MediaAdapter() -mpris = Server(name=playerName, adapter=media_adapter) -mpris.player = CustomPlayer(name=playerName, adapter=media_adapter) -mpris.interfaces = mpris.root, mpris.player -App.media_adapter = mpris - -try: - mpris.loop() -except KeyboardInterrupt: - pass -except RuntimeError: - print(playerName + " Media Controller already running!") -finally: - print("quitting...") - exitEvent.set() - thread.join() + def __init__(self, app: AppState) -> None: + super().__init__() + self.app = app + + def _cmd(self, label: str, key: str) -> None: + print(f"[mediactl] {label}") + self.app.dispatch_media_key(key) + self.app.update() + + # -- commands ----------------------------------------------------------- + + def next(self) -> None: + self._cmd("next", "next") + + def previous(self) -> None: + self._cmd("prev", "previous") + + def pause(self) -> None: + self._cmd("pause", "pause") + + def resume(self) -> None: + self._cmd("resume", "play") + + def stop(self) -> None: + self._cmd("stop", "stop") + + def play(self) -> None: + self._cmd("play", "play") + + # -- state -------------------------------------------------------------- + + def get_playstate(self) -> PlayState: + return self.app.playbackState + + def get_art_url(self, track) -> str: + return self.app.art_url + + # -- capabilities ------------------------------------------------------- + + def can_go_next(self) -> bool: + return True + + def can_go_previous(self) -> bool: + return True + + def can_play(self) -> bool: + return True + + def can_pause(self) -> bool: + return True + + def can_seek(self) -> bool: + return False + + def can_control(self) -> bool: + return True + + def can_quit(self) -> bool: + return False + + def can_raise(self) -> bool: + return False + + def has_tracklist(self) -> bool: + return False + + def can_fullscreen(self) -> bool: + return False + + def get_fullscreen(self) -> None: + return None + + # -- info --------------------------------------------------------------- + + def get_stream_title(self) -> str: + return self.app.title + + def get_desktop_entry(self) -> str: + return "scrcpy" + + def get_mime_types(self) -> list[str]: + return ["audio/mpeg", "application/ogg", "video/mpeg"] + + def get_uri_schemes(self) -> list[str]: + return ["file"] + + def metadata(self) -> dict: + meta: dict = { + "mpris:trackid": "/org/mpris/MediaPlayer2/scrcpy", + "xesam:title": self.app.title, + "xesam:artist": self.app.artist, + } + if self.app.album: + meta["xesam:album"] = self.app.album + if self.app.art_url: + meta["mpris:artUrl"] = self.app.art_url + return meta + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +@click.command() +@click.option( + "--player-name", + default="scrcpy", + show_default=True, + help="MPRIS player name exposed on D-Bus.", +) +@click.option( + "--update-freq", + default=1.0, + show_default=True, + type=float, + help="How often (in seconds) to poll ADB for media state.", +) +@click.option( + "-v", + "--video", + is_flag=True, + default=False, + help="Enable scrcpy video output (starts scrcpy with a window).", +) +@click.option( + "--detach", + is_flag=True, + default=False, + help="Do not start or manage scrcpy. Lets you handle the scrcpy lifecycle yourself.", +) +def cli( + player_name: str, + update_freq: float, + video: bool, + detach: bool, +) -> None: + """scrcpy MPRIS media controller. + + Exposes Android media playback over MPRIS so desktop notification panels + (swaync, dunst, waybar, ...) can display and control it. + Requires an ADB-connected Android device. + + By default this starts `scrcpy --no-window --no-video` and tears it down on + exit. Pass -v/--video to launch scrcpy with a video window, or --detach to + manage scrcpy yourself. + """ + exit_event = Event() + + # -- optionally launch scrcpy ----------------------------------------- + scrcpy_proc: subprocess.Popen | None = None + if not detach: + try: + scrcpy_cmd = ["scrcpy"] if video else ["scrcpy", "--no-window", "--no-video"] + scrcpy_proc = subprocess.Popen( + scrcpy_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + + def _drain(proc: subprocess.Popen) -> None: + for raw in proc.stdout: + print(f"[scrcpy] {raw.decode(errors='replace').rstrip()}") + + def _watch_scrcpy(proc: subprocess.Popen) -> None: + proc.wait() + if not exit_event.is_set(): + print("[mediactl] scrcpy exited — exiting") + os.kill(os.getpid(), signal.SIGTERM) + + Thread(target=_drain, args=(scrcpy_proc,), daemon=True).start() + Thread(target=_watch_scrcpy, args=(scrcpy_proc,), daemon=True).start() + print("[mediactl] scrcpy started") + except FileNotFoundError: + print("[mediactl] scrcpy not found in PATH — continuing without it") + + # -- MPRIS setup ------------------------------------------------------ + app = AppState() + app.update(emit=False) + + update_thread = UpdateThread(app, update_freq, exit_event) + update_thread.start() + + media_adapter = MediaAdapter(app) + mpris = Server(name=player_name, adapter=media_adapter) + mpris.player = CustomPlayer(name=player_name, adapter=media_adapter) + mpris.interfaces = mpris.root, mpris.player + app.media_adapter = mpris + + try: + mpris.loop() + except KeyboardInterrupt: + pass + except RuntimeError: + print(f"[mediactl] {player_name} media controller is already running!") + finally: + print("[mediactl] quitting...") + exit_event.set() + update_thread.join() + if scrcpy_proc is not None: + try: + pgid = os.getpgid(scrcpy_proc.pid) + os.killpg(pgid, signal.SIGTERM) + scrcpy_proc.wait(timeout=3) + except (ProcessLookupError, PermissionError): + pass + except subprocess.TimeoutExpired: + try: + pgid = os.getpgid(scrcpy_proc.pid) + os.killpg(pgid, signal.SIGKILL) + except (ProcessLookupError, PermissionError): + pass + + +if __name__ == "__main__": + cli() diff --git a/player.py b/player.py index b85f734..cd7c7ca 100644 --- a/player.py +++ b/player.py @@ -1,205 +1,193 @@ from __future__ import annotations -#NOTE: THIS IS A COPY OF mpris-server.interfaces.player but without `Position`, `Shuffle`, `loop`, `OpenUri`, `Seek`, `SetPosition`, `Rate` and `Volume` +# NOTE: THIS IS A COPY OF mpris-server.interfaces.player but without +# `Position`, `LoopStatus`, `Shuffle`, `OpenUri`, `Seek`, `SetPosition`, +# `Rate` and `Volume`. import logging -from fractions import Fraction from typing import ClassVar, Final -from pydbus.generic import signal - +from mpris_server.base import ( + DbusObj, + DbusTypes, + Interface, + PlayState, + Track, +) +from mpris_server.enums import Access, Arg, Method, Property, Signal from mpris_server.interfaces.interface import MprisInterface, log_trace -from mpris_server.base import BEGINNING, DbusObj, DbusTypes, Interface, MAX_RATE, MAX_VOLUME, MIN_RATE, MUTE_VOLUME, \ - PAUSE_RATE, PlayState, Position, Rate, Track, Volume -from mpris_server.enums import Access, Arg, Direction, LoopStatus, Method, Property, Signal from mpris_server.mpris.metadata import Metadata, MetadataEntries, create_metadata_from_track, get_dbus_metadata, update_metadata - +from pydbus.generic import signal log = logging.getLogger(__name__) -ERR_NOT_ENOUGH_METADATA: Final[str] = \ - "Couldn't find enough metadata, please implement metadata() or get_stream_title() and get_current_track() methods.`" +ERR_NOT_ENOUGH_METADATA: Final[str] = ( + "Couldn't find enough metadata, please implement metadata() or get_stream_title() and get_current_track() methods.`" +) class CustomPlayer(MprisInterface): - INTERFACE: ClassVar[Interface] = Interface.Player - - __doc__: Final[str] = f""" - - - - - - - - - - - - - - - - - - - - - - - - """ - - Seeked: Final[signal] = signal() - - def _get_metadata(self) -> Metadata | None: - if metadata := self.adapter.metadata(): - return get_dbus_metadata(metadata) - - return None - - def _get_basic_metadata(self, track: Track) -> Metadata: - metadata: Metadata = Metadata() - - if name := self.adapter.get_stream_title(): - update_metadata(metadata, MetadataEntries.TITLE, name) - - if art_url := self._get_art_url(track): - update_metadata(metadata, MetadataEntries.ART_URL, art_url) - - return metadata - - def _get_art_url(self, track: DbusObj | Track | None) -> str: - return self.adapter.get_art_url(track) - - @property - @log_trace - def CanControl(self) -> bool: - return self.adapter.can_control() - - @property - @log_trace - def CanGoNext(self) -> bool: - # if not self.CanControl: - # return False - - return self.adapter.can_go_next() - - @property - @log_trace - def CanGoPrevious(self) -> bool: - # if not self.CanControl: - # return False - - return self.adapter.can_go_previous() - - @property - @log_trace - def CanPause(self) -> bool: - return self.adapter.can_pause() - # if not self.CanControl: - # return False - - # return True - - @property - @log_trace - def CanPlay(self) -> bool: - # if not self.CanControl: - # return False - - return self.adapter.can_play() - - @property - @log_trace - def CanSeek(self) -> bool: - return self.adapter.can_seek() - # if not self.CanControl: - # return False - - # return True - - @property - @log_trace - def Metadata(self) -> Metadata: - # prefer adapter's metadata to building our own - if metadata := self._get_metadata(): - return metadata - - # build metadata if no metadata supplied by adapter - log.debug(f"Building {self.INTERFACE}.{Property.Metadata}") - - track = self.adapter.get_current_track() - metadata: Metadata = self._get_basic_metadata(track) - - if not track: - log.warning(ERR_NOT_ENOUGH_METADATA) - return metadata - - return create_metadata_from_track(track, metadata) - - @property - @log_trace - def PlaybackStatus(self) -> PlayState: - state = self.adapter.get_playstate() - return state.value.title() - - @log_trace - def Next(self): - if not self.CanGoNext: - log.debug(f"{self.INTERFACE}.{Method.Next} not allowed") - return + INTERFACE: ClassVar[Interface] = Interface.Player + + __doc__: str = f""" + + + + + + + + + + + + + + + + + + + + + + + + """ + + Seeked: Final[signal] = signal() + + def _get_metadata(self) -> Metadata | None: + if metadata := self.adapter.metadata(): + return get_dbus_metadata(metadata) + + return None + + def _get_basic_metadata(self, track: Track) -> Metadata: + metadata: Metadata = Metadata() + + if name := self.adapter.get_stream_title(): + update_metadata(metadata, MetadataEntries.TITLE, name) + + if art_url := self._get_art_url(track): + update_metadata(metadata, MetadataEntries.ART_URL, art_url) + + return metadata + + def _get_art_url(self, track: DbusObj | Track | None) -> str: + return self.adapter.get_art_url(track) + + @property + @log_trace + def CanControl(self) -> bool: + return self.adapter.can_control() + + @property + @log_trace + def CanGoNext(self) -> bool: + return self.adapter.can_go_next() + + @property + @log_trace + def CanGoPrevious(self) -> bool: + return self.adapter.can_go_previous() + + @property + @log_trace + def CanPause(self) -> bool: + return self.adapter.can_pause() + + @property + @log_trace + def CanPlay(self) -> bool: + return self.adapter.can_play() + + @property + @log_trace + def CanSeek(self) -> bool: + return self.adapter.can_seek() + + @property + @log_trace + def Metadata(self) -> Metadata: + # prefer adapter's metadata to building our own + if metadata := self._get_metadata(): + return metadata + + # build metadata if no metadata supplied by adapter + log.debug(f"Building {self.INTERFACE}.{Property.Metadata}") + + track = self.adapter.get_current_track() + metadata: Metadata = self._get_basic_metadata(track) + + if not track: + log.warning(ERR_NOT_ENOUGH_METADATA) + return metadata + + return create_metadata_from_track(track, metadata) + + @property + @log_trace + def PlaybackStatus(self) -> PlayState: + state = self.adapter.get_playstate() + return state.value.title() + + @log_trace + def Next(self): + if not self.CanGoNext: + log.debug(f"{self.INTERFACE}.{Method.Next} not allowed") + return + + self.adapter.next() + + @log_trace + def Previous(self): + if not self.CanGoPrevious: + log.debug(f"{self.INTERFACE}.{Method.Previous} not allowed") + return - self.adapter.next() + self.adapter.previous() - @log_trace - def Previous(self): - if not self.CanGoPrevious: - log.debug(f"{self.INTERFACE}.{Method.Previous} not allowed") - return + @log_trace + def Pause(self): + if not self.CanPause: + log.debug(f"{self.INTERFACE}.{Method.Pause} not allowed") + return - self.adapter.previous() + self.adapter.pause() - @log_trace - def Pause(self): - if not self.CanPause: - log.debug(f"{self.INTERFACE}.{Method.Pause} not allowed") - return + @log_trace + def Play(self): + if not self.CanPlay: + log.debug(f"{self.INTERFACE}.{Method.Play} not allowed") + return - self.adapter.pause() - - @log_trace - def Play(self): - if not self.CanPlay: - log.debug(f"{self.INTERFACE}.{Method.Play} not allowed") - return - - match self.adapter.get_playstate(): - case PlayState.PAUSED: - self.adapter.resume() - - case _: - self.adapter.play() - - @log_trace - def PlayPause(self): - if not self.CanPause: - log.debug(f"{self.INTERFACE}.{Method.PlayPause} not allowed") - return - - match self.adapter.get_playstate(): - case PlayState.PLAYING: - self.adapter.pause() + match self.adapter.get_playstate(): + case PlayState.PAUSED: + self.adapter.resume() - case PlayState.PAUSED: - self.adapter.resume() - - case PlayState.STOPPED: - self.adapter.play() - - @log_trace - def Stop(self): - if not self.CanControl: - log.debug(f"{self.INTERFACE}.{Method.Stop} not allowed") - return - - self.adapter.stop() + case _: + self.adapter.play() + + @log_trace + def PlayPause(self): + if not self.CanPause: + log.debug(f"{self.INTERFACE}.{Method.PlayPause} not allowed") + return + + match self.adapter.get_playstate(): + case PlayState.PLAYING: + self.adapter.pause() + + case PlayState.PAUSED: + self.adapter.resume() + + case PlayState.STOPPED: + self.adapter.play() + + @log_trace + def Stop(self): + if not self.CanControl: + log.debug(f"{self.INTERFACE}.{Method.Stop} not allowed") + return + + self.adapter.stop() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..696785e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "scrcpy-media-controller" +version = "0.1.0" +description = "Control Android media playback via MPRIS over ADB" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "mpris-server", + "click", +] + +[project.scripts] +audiocpy = "main:cli" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +include = ["main.py", "player.py", "album_art.py", "icon.png"] diff --git a/screenshots/showcase-2026-04-22.png b/screenshots/showcase-2026-04-22.png new file mode 100644 index 0000000..89bf1a7 Binary files /dev/null and b/screenshots/showcase-2026-04-22.png differ diff --git a/start_scrcpyMediaController.sh b/start_scrcpyMediaController.sh deleted file mode 100755 index 3d57634..0000000 --- a/start_scrcpyMediaController.sh +++ /dev/null @@ -1,3 +0,0 @@ -BASEDIR=$(dirname "$0") -. $BASEDIR/virtualEnv/bin/activate -python $BASEDIR/main.py diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..0c0295a --- /dev/null +++ b/uv.lock @@ -0,0 +1,119 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "click" +version = "8.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "emoji" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/78/0d2db9382c92a163d7095fc08efff7800880f830a152cfced40161e7638d/emoji-2.15.0.tar.gz", hash = "sha256:eae4ab7d86456a70a00a985125a03263a5eac54cd55e51d7e184b1ed3b6757e4", size = 615483, upload-time = "2025-09-21T12:13:02.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/5e/4b5aaaabddfacfe36ba7768817bd1f71a7a810a43705e531f3ae4c690767/emoji-2.15.0-py3-none-any.whl", hash = "sha256:205296793d66a89d88af4688fa57fd6496732eb48917a87175a023c8138995eb", size = 608433, upload-time = "2025-09-21T12:13:01.197Z" }, +] + +[[package]] +name = "mpris-server" +version = "0.9.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "emoji" }, + { name = "pydbus" }, + { name = "pygobject" }, + { name = "strenum" }, + { name = "unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/f1/9397b8b697b7bb15f848dc9fd68cefa663a85e95f4a0c028fcb65e5e107a/mpris_server-0.9.6.tar.gz", hash = "sha256:4f465e0d089820084a47c6b0de2bf7aedc3373e4743d342e221ebe1e2e2b2074", size = 27722, upload-time = "2024-09-02T11:06:14.514Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/79/15590e73b935eb1a1bbc6adb5e2a3165d522b84b29d6c9b8ad74f56bf521/mpris_server-0.9.6-py2.py3-none-any.whl", hash = "sha256:13e8ffaa3b6b907c743b6ba76e9a3dfe4c2274e0a8c99ca03f66d6073c39a7fe", size = 26529, upload-time = "2024-09-02T11:06:12.874Z" }, +] + +[[package]] +name = "pycairo" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/d9/1728840a22a4ef8a8f479b9156aa2943cd98c3907accd3849fb0d5f82bfd/pycairo-1.29.0.tar.gz", hash = "sha256:f3f7fde97325cae80224c09f12564ef58d0d0f655da0e3b040f5807bd5bd3142", size = 665871, upload-time = "2025-11-11T19:13:01.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/28/6363087b9e60af031398a6ee5c248639eefc6cc742884fa2789411b1f73b/pycairo-1.29.0-cp312-cp312-win32.whl", hash = "sha256:91bcd7b5835764c616a615d9948a9afea29237b34d2ed013526807c3d79bb1d0", size = 751486, upload-time = "2025-11-11T19:11:54.451Z" }, + { url = "https://files.pythonhosted.org/packages/3a/d2/d146f1dd4ef81007686ac52231dd8f15ad54cf0aa432adaefc825475f286/pycairo-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:3f01c3b5e49ef9411fff6bc7db1e765f542dc1c9cfed4542958a5afa3a8b8e76", size = 845383, upload-time = "2025-11-11T19:12:01.551Z" }, + { url = "https://files.pythonhosted.org/packages/01/16/6e6f33bb79ec4a527c9e633915c16dc55a60be26b31118dbd0d5859e8c51/pycairo-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:eafe3d2076f3533535ad4a361fa0754e0ee66b90e548a3a0f558fed00b1248f2", size = 694518, upload-time = "2025-11-11T19:12:06.561Z" }, + { url = "https://files.pythonhosted.org/packages/f0/21/3f477dc318dd4e84a5ae6301e67284199d7e5a2384f3063714041086b65d/pycairo-1.29.0-cp313-cp313-win32.whl", hash = "sha256:3eb382a4141591807073274522f7aecab9e8fa2f14feafd11ac03a13a58141d7", size = 750949, upload-time = "2025-11-11T19:12:12.198Z" }, + { url = "https://files.pythonhosted.org/packages/43/34/7d27a333c558d6ac16dbc12a35061d389735e99e494ee4effa4ec6d99bed/pycairo-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:91114e4b3fbf4287c2b0788f83e1f566ce031bda49cf1c3c3c19c3e986e95c38", size = 844149, upload-time = "2025-11-11T19:12:19.171Z" }, + { url = "https://files.pythonhosted.org/packages/15/43/e782131e23df69e5c8e631a016ed84f94bbc4981bf6411079f57af730a23/pycairo-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:09b7f69a5ff6881e151354ea092137b97b0b1f0b2ab4eb81c92a02cc4a08e335", size = 693595, upload-time = "2025-11-11T19:12:23.445Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fa/87eaeeb9d53344c769839d7b2854db7ff2cd596211e00dd1b702eeb1838f/pycairo-1.29.0-cp314-cp314-win32.whl", hash = "sha256:69e2a7968a3fbb839736257bae153f547bca787113cc8d21e9e08ca4526e0b6b", size = 767198, upload-time = "2025-11-11T19:12:42.336Z" }, + { url = "https://files.pythonhosted.org/packages/3c/90/3564d0f64d0a00926ab863dc3c4a129b1065133128e96900772e1c4421f8/pycairo-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:e91243437a21cc4c67c401eff4433eadc45745275fa3ade1a0d877e50ffb90da", size = 871579, upload-time = "2025-11-11T19:12:48.982Z" }, + { url = "https://files.pythonhosted.org/packages/5e/91/93632b6ba12ad69c61991e3208bde88486fdfc152be8cfdd13444e9bc650/pycairo-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:b72200ea0e5f73ae4c788cd2028a750062221385eb0e6d8f1ecc714d0b4fdf82", size = 719537, upload-time = "2025-11-11T19:12:55.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/23/37053c039f8d3b9b5017af9bc64d27b680c48a898d48b72e6d6583cf0155/pycairo-1.29.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5e45fce6185f553e79e4ef1722b8e98e6cde9900dbc48cb2637a9ccba86f627a", size = 874015, upload-time = "2025-11-11T19:12:28.47Z" }, + { url = "https://files.pythonhosted.org/packages/d7/54/123f6239685f5f3f2edc123f1e38d2eefacebee18cf3c532d2f4bd51d0ef/pycairo-1.29.0-cp314-cp314t-win_arm64.whl", hash = "sha256:caba0837a4b40d47c8dfb0f24cccc12c7831e3dd450837f2a356c75f21ce5a15", size = 721404, upload-time = "2025-11-11T19:12:36.919Z" }, +] + +[[package]] +name = "pydbus" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/56/3e84f2c1f2e39b9ea132460183f123af41e3b9c8befe222a35636baa6a5a/pydbus-0.6.0.tar.gz", hash = "sha256:4207162eff54223822c185da06c1ba8a34137a9602f3da5a528eedf3f78d0f2c", size = 22079, upload-time = "2016-12-18T16:44:31.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/56/27148014c2f85ce70332f18612f921f682395c7d4e91ec103783be4fce00/pydbus-0.6.0-py2.py3-none-any.whl", hash = "sha256:66b80106352a718d80d6c681dc2a82588048e30b75aab933e4020eb0660bf85e", size = 19580, upload-time = "2016-12-18T16:44:19.565Z" }, +] + +[[package]] +name = "pygobject" +version = "3.56.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycairo" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a2/80/09247a2be28af2c2240132a0af6c1005a2b1d089242b13a2cd782d2de8d7/pygobject-3.56.2.tar.gz", hash = "sha256:b816098969544081de9eecedb94ad6ac59c77e4d571fe7051f18bebcec074313", size = 1409059, upload-time = "2026-03-25T16:14:04.008Z" } + +[[package]] +name = "scrcpy-media-controller" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "click" }, + { name = "mpris-server" }, +] + +[package.metadata] +requires-dist = [ + { name = "click" }, + { name = "mpris-server" }, +] + +[[package]] +name = "strenum" +version = "0.4.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/ad/430fb60d90e1d112a62ff57bdd1f286ec73a2a0331272febfddd21f330e1/StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff", size = 23384, upload-time = "2023-06-29T22:02:58.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659", size = 8851, upload-time = "2023-06-29T22:02:56.947Z" }, +] + +[[package]] +name = "unidecode" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/7d/a8a765761bbc0c836e397a2e48d498305a865b70a8600fd7a942e85dcf63/Unidecode-1.4.0.tar.gz", hash = "sha256:ce35985008338b676573023acc382d62c264f307c8f7963733405add37ea2b23", size = 200149, upload-time = "2025-04-24T08:45:03.798Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/b7/559f59d57d18b44c6d1250d2eeaa676e028b9c527431f5d0736478a73ba1/Unidecode-1.4.0-py3-none-any.whl", hash = "sha256:c3c7606c27503ad8d501270406e345ddb480a7b5f38827eafe4fa82a137f0021", size = 235837, upload-time = "2025-04-24T08:45:01.609Z" }, +]