Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
virtualEnv/
__pycache__/
repomix-output.xml
.venv
96 changes: 46 additions & 50 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.<br>
This script works independently from scrcpy and does not require it to be installed or running for use.<br>
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.<br>
**Credits:** Default album art icon (`icon.png`) from [scrcpy repository](https://github.com/Genymobile/scrcpy/blob/master/app/data/icon.png).<br>
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).<br>
`playerName` defines the name of the player.<br>
`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.
Binary file removed Screenshots/Screenshot_02-Jun_10-38-55_26599.png
Binary file not shown.
Binary file removed Screenshots/Screenshot_02-Jun_10-58-03_1099.png
Binary file not shown.
Binary file removed Screenshots/Screenshot_30-May_20-25-30_20743.png
Binary file not shown.
168 changes: 168 additions & 0 deletions album_art.py
Original file line number Diff line number Diff line change
@@ -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 ""
Loading