webplayercontrol 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: webplayercontrol
3
+ Version: 1.0.0
4
+ Summary: An ASGI application that exposes Media Player Remote Interfacing Specification (MPRIS)-compliant media players over the web.
5
+ Author: Christopher Head
6
+ License-Expression: GPL-3.0-only
7
+ Classifier: Development Status :: 5 - Production/Stable
8
+ Classifier: Environment :: Web Environment
9
+ Classifier: Intended Audience :: End Users/Desktop
10
+ Classifier: Operating System :: POSIX :: Linux
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
13
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Players
14
+ Requires-Dist: aioscgi>=2.5.1
15
+ Requires-Dist: dbus-fast>=5.0.22
16
+ Requires-Python: >=3.14
17
+ Project-URL: Repository, https://gitlab.com/Hawk777/webplayercontrol
18
+ Description-Content-Type: text/markdown
19
+
20
+ What is MPRIS?
21
+ ==============
22
+
23
+ The [Media Player Remote Interfacing
24
+ Specification](https://specifications.freedesktop.org/mpris/latest/), or MPRIS,
25
+ is a standard for remotely controlling media players via D-Bus in a Linux
26
+ system. Media players can expose MPRIS interfaces via D-Bus, and any
27
+ MPRIS-compliant client can control those players (pause, play, advance through
28
+ the playlist, adjust volume, seek, etc.).
29
+
30
+
31
+ What is webplayercontrol?
32
+ =========================
33
+
34
+ Webplayercontrol is a web application (implemented using
35
+ [ASGI](https://asgi.readthedocs.io/en/stable/) that provides a web interface
36
+ for controlling MPRIS-compliant media players. The web interface is optimized
37
+ for cell phone usage. The intention is to use a cell phone to control a media
38
+ player running on a desktop, for example while sitting too far away from the
39
+ desktop to use its own mouse and keyboard.
40
+
41
+
42
+ How do I set it up?
43
+ ===================
44
+
45
+ You’ll need an ASGI server. There are lots available to choose from, some which
46
+ are usable on their own and some which expect to have a traditional Web server
47
+ (like Apache, Lighttpd, or Nginx) placed in front of them. Then, once
48
+ everything is installed, run your ASGI server and tell it the application
49
+ callable is `webplayercontrol:main`.
50
+
51
+
52
+ What options can I configure?
53
+ =============================
54
+
55
+ If your server supports [the pathsend
56
+ extension](https://asgi.readthedocs.io/en/stable/extensions.html#path-send),
57
+ you can set the `WEBPLAYERCONTROL_ASSETS` environment variable to the name of a
58
+ directory. The directory will be created, static assets will be extracted into
59
+ it, and the pathsend extension will be used to send those assets.
@@ -0,0 +1,40 @@
1
+ What is MPRIS?
2
+ ==============
3
+
4
+ The [Media Player Remote Interfacing
5
+ Specification](https://specifications.freedesktop.org/mpris/latest/), or MPRIS,
6
+ is a standard for remotely controlling media players via D-Bus in a Linux
7
+ system. Media players can expose MPRIS interfaces via D-Bus, and any
8
+ MPRIS-compliant client can control those players (pause, play, advance through
9
+ the playlist, adjust volume, seek, etc.).
10
+
11
+
12
+ What is webplayercontrol?
13
+ =========================
14
+
15
+ Webplayercontrol is a web application (implemented using
16
+ [ASGI](https://asgi.readthedocs.io/en/stable/) that provides a web interface
17
+ for controlling MPRIS-compliant media players. The web interface is optimized
18
+ for cell phone usage. The intention is to use a cell phone to control a media
19
+ player running on a desktop, for example while sitting too far away from the
20
+ desktop to use its own mouse and keyboard.
21
+
22
+
23
+ How do I set it up?
24
+ ===================
25
+
26
+ You’ll need an ASGI server. There are lots available to choose from, some which
27
+ are usable on their own and some which expect to have a traditional Web server
28
+ (like Apache, Lighttpd, or Nginx) placed in front of them. Then, once
29
+ everything is installed, run your ASGI server and tell it the application
30
+ callable is `webplayercontrol:main`.
31
+
32
+
33
+ What options can I configure?
34
+ =============================
35
+
36
+ If your server supports [the pathsend
37
+ extension](https://asgi.readthedocs.io/en/stable/extensions.html#path-send),
38
+ you can set the `WEBPLAYERCONTROL_ASSETS` environment variable to the name of a
39
+ directory. The directory will be created, static assets will be extracted into
40
+ it, and the pathsend extension will be used to send those assets.
@@ -0,0 +1,88 @@
1
+ [build-system]
2
+ requires = ["uv_build ~= 0.13.4"]
3
+ build-backend = "uv_build"
4
+
5
+ [dependency-groups]
6
+ ci = ["pylint-gitlab"]
7
+ dev = [
8
+ "asgi-types >= 0.2.0",
9
+ "pylint ~= 4.1.3",
10
+ "pyright ~= 1.1.414",
11
+ "pytest ~= 9.2.0",
12
+ "pytest-asyncio ~= 1.4.1",
13
+ "ruff ~= 0.17.3",
14
+ ]
15
+
16
+ [project]
17
+ name = "webplayercontrol"
18
+ version = "1.0.0"
19
+ dependencies = [
20
+ "aioscgi >= 2.5.1",
21
+ "dbus-fast >= 5.0.22",
22
+ ]
23
+ requires-python = ">= 3.14"
24
+ license = "GPL-3.0-only"
25
+ description = "An ASGI application that exposes Media Player Remote Interfacing Specification (MPRIS)-compliant media players over the web."
26
+ readme = "README.md"
27
+ classifiers = [
28
+ "Development Status :: 5 - Production/Stable",
29
+ "Environment :: Web Environment",
30
+ "Intended Audience :: End Users/Desktop",
31
+ "Operating System :: POSIX :: Linux",
32
+ "Programming Language :: Python :: 3",
33
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
34
+ "Topic :: Multimedia :: Sound/Audio :: Players",
35
+ ]
36
+
37
+ [[project.authors]]
38
+ name = "Christopher Head"
39
+
40
+ [project.urls]
41
+ Repository = "https://gitlab.com/Hawk777/webplayercontrol"
42
+
43
+ [tool.pylint.imports]
44
+ allow-reexport-from-package = true
45
+
46
+ [tool.pylint.messages_control]
47
+ enable = [
48
+ "deprecated-pragma",
49
+ "useless-suppression",
50
+ "use-symbolic-message-instead",
51
+ ]
52
+ disable = [
53
+ "broad-exception-caught",
54
+ "line-too-long",
55
+ "too-few-public-methods",
56
+ "too-many-instance-attributes",
57
+ "too-many-locals",
58
+ ]
59
+
60
+ [tool.pyright]
61
+ exclude = [".ruff_cache"]
62
+ pythonVersion = "3.14"
63
+ typeCheckingMode = "strict"
64
+
65
+ [tool.pytest]
66
+ addopts = ["--import-mode=importlib"]
67
+ asyncio_mode = "strict"
68
+ asyncio_default_fixture_loop_scope = "function"
69
+ strict = true
70
+ testpaths = ["tests"]
71
+
72
+ [tool.ruff]
73
+ src = ["src"]
74
+ target-version = "py314"
75
+
76
+ [tool.ruff.lint]
77
+ allowed-confusables = ["’"]
78
+ ignore = [
79
+ "COM812",
80
+ "CPY001",
81
+ "D203",
82
+ "D212",
83
+ "S101",
84
+ ]
85
+ select = ["ALL"]
86
+
87
+ [tool.uv.build-backend]
88
+ source-include = ["tests/**"]
@@ -0,0 +1,88 @@
1
+ [build-system]
2
+ requires = ["uv_build ~= 0.13.4"]
3
+ build-backend = "uv_build"
4
+
5
+ [dependency-groups]
6
+ ci = [
7
+ "pylint-gitlab",
8
+ ]
9
+ dev = [
10
+ "asgi-types >= 0.2.0",
11
+ "pylint ~= 4.1.3",
12
+ "pyright ~= 1.1.414",
13
+ "pytest ~= 9.2.0",
14
+ "pytest-asyncio ~= 1.4.1",
15
+ "ruff ~= 0.17.3",
16
+ ]
17
+
18
+ [project]
19
+ name = "webplayercontrol"
20
+ version = "1.0.0"
21
+ dependencies = [
22
+ "aioscgi >= 2.5.1",
23
+ "dbus-fast >= 5.0.22",
24
+ ]
25
+ requires-python = ">= 3.14"
26
+ license = "GPL-3.0-only"
27
+ authors = [
28
+ { name = "Christopher Head" },
29
+ ]
30
+ description = "An ASGI application that exposes Media Player Remote Interfacing Specification (MPRIS)-compliant media players over the web."
31
+ readme = "README.md"
32
+ classifiers = [
33
+ "Development Status :: 5 - Production/Stable",
34
+ "Environment :: Web Environment",
35
+ "Intended Audience :: End Users/Desktop",
36
+ "Operating System :: POSIX :: Linux",
37
+ "Programming Language :: Python :: 3",
38
+ "Topic :: Internet :: WWW/HTTP :: Dynamic Content",
39
+ "Topic :: Multimedia :: Sound/Audio :: Players",
40
+ ]
41
+ urls.Repository = "https://gitlab.com/Hawk777/webplayercontrol"
42
+
43
+ [tool.pylint.imports]
44
+ allow-reexport-from-package = true
45
+
46
+ [tool.pylint.messages_control]
47
+ enable = [
48
+ "deprecated-pragma",
49
+ "useless-suppression",
50
+ "use-symbolic-message-instead",
51
+ ]
52
+ disable = [
53
+ "broad-exception-caught",
54
+ "line-too-long",
55
+ "too-few-public-methods",
56
+ "too-many-instance-attributes",
57
+ "too-many-locals",
58
+ ]
59
+
60
+ [tool.pyright]
61
+ exclude = [".ruff_cache"]
62
+ pythonVersion = "3.14"
63
+ typeCheckingMode = "strict"
64
+
65
+ [tool.pytest]
66
+ addopts = ["--import-mode=importlib"]
67
+ asyncio_mode = "strict"
68
+ asyncio_default_fixture_loop_scope = "function"
69
+ strict = true
70
+ testpaths = ["tests"]
71
+
72
+ [tool.ruff]
73
+ src = ["src"]
74
+ target-version = "py314"
75
+
76
+ [tool.ruff.lint]
77
+ allowed-confusables = ["’"]
78
+ ignore = [
79
+ "COM812", # missing-trailing-comma
80
+ "CPY001", # missing-copyright-notice
81
+ "D203", # one-blank-line-before-class
82
+ "D212", # multi-line-summary-first-line
83
+ "S101", # assert
84
+ ]
85
+ select = ["ALL"]
86
+
87
+ [tool.uv.build-backend]
88
+ source-include = ["tests/**"]
@@ -0,0 +1,3 @@
1
+ """An ASGI application for controlling MPRIS-compliant media players."""
2
+
3
+ from .main import main as main
@@ -0,0 +1,12 @@
1
+ html {
2
+ color: white;
3
+ background: black;
4
+ }
5
+
6
+ a, a:active, a:link, a:visited {
7
+ color: white;
8
+ }
9
+
10
+ .error-message {
11
+ color: red;
12
+ }
@@ -0,0 +1,14 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>WebPlayerControl</title>
6
+ <link href="{{hash-urlsafe:common.css}}" integrity="sha256-{{hash:common.css}}" rel="stylesheet">
7
+ <script type="module" integrity="sha256-{{hash:index.js}}" src="{{hash-urlsafe:index.js}}"></script>
8
+ </head>
9
+ <body>
10
+ <h1>Select a player to control</h1>
11
+ <ul id="player-list">
12
+ </ul>
13
+ </body>
14
+ </html>
@@ -0,0 +1,26 @@
1
+ const onLoad = async function () {
2
+ const playerList = document.getElementById("player-list");
3
+ try {
4
+ // Fetch the list of players from the server.
5
+ const listingResp = await fetch("players/", { headers: { Accept: "application/json" } });
6
+ if (!listingResp.ok) {
7
+ throw new Error(`Fetching player list failed: HTTP error ${listingResp.status}`);
8
+ }
9
+ const listing = await listingResp.json();
10
+ for (const [name, display] of listing) {
11
+ const item = document.createElement("li");
12
+ const link = document.createElement("a");
13
+ link.href = `players/${name}/`;
14
+ link.textContent = display;
15
+ item.append(link);
16
+ playerList.append(item);
17
+ }
18
+ } catch (exp) {
19
+ const para = document.createElement("p");
20
+ para.textContent = exp.toString();
21
+ para.classList.add("error-message");
22
+ playerList.parentElement.replaceChild(para, playerList);
23
+ }
24
+ };
25
+
26
+ window.addEventListener("load", onLoad);
@@ -0,0 +1,43 @@
1
+ html {
2
+ text-align: center;
3
+ }
4
+
5
+ button {
6
+ background: black;
7
+ border: 0;
8
+ font-size: min(10vw, 35vh);
9
+ }
10
+
11
+ button:disabled {
12
+ filter: grayscale(100%);
13
+ }
14
+
15
+ #status {
16
+ font-size: 2rem;
17
+ margin-bottom: 2rem;
18
+ }
19
+
20
+ #spinner-wrapper {
21
+ display: inline-block;
22
+ min-height: 2em;
23
+ vertical-align: middle;
24
+ }
25
+
26
+ #spinner {
27
+ animation: spin 2s linear infinite;
28
+ border: 0.2em solid #333;
29
+ border-top: 0.2em solid #888;
30
+ border-radius: 50%;
31
+ display: inline-block;
32
+ width: 1em;
33
+ height: 1em;
34
+ }
35
+
36
+ #spinner.off {
37
+ display: none;
38
+ }
39
+
40
+ @keyframes spin {
41
+ 0% { transform: rotate(0deg); }
42
+ 100% { transform: rotate(360deg); }
43
+ }
@@ -0,0 +1,29 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <title>WebPlayerControl</title>
6
+ <link href="../../{{hash-urlsafe:common.css}}" integrity="sha256-{{hash:common.css}}" rel="stylesheet">
7
+ <link href="../../{{hash-urlsafe:player.css}}" integrity="sha256-{{hash:player.css}}" rel="stylesheet">
8
+ <script type="module" integrity="sha256-{{hash:player.js}}" src="../../{{hash-urlsafe:player.js}}"></script>
9
+ </head>
10
+ <body>
11
+ <h1>WebPlayerControl</h1>
12
+ <a href="../..">← Back to player list</a>
13
+ <div id="status">
14
+ <div id="spinner-wrapper">
15
+ <div id="spinner"></div>
16
+ </div>
17
+ <span id="status-text">Initializing</span>
18
+ </div>
19
+ <div id="buttons">
20
+ <button id="goPrevious" type="button" disabled>⏮️</button>
21
+ <button id="seekBack" type="button" disabled>⏪️</button>
22
+ <button id="stop" type="button" disabled>⏹️</button>
23
+ <button id="pause" type="button" disabled>⏸️</button>
24
+ <button id="play" type="button" disabled>▶️</button>
25
+ <button id="seekForward" type="button" disabled>⏩️</button>
26
+ <button id="goNext" type="button" disabled>⏭️</button>
27
+ </div>
28
+ </body>
29
+ </html>
@@ -0,0 +1,114 @@
1
+ /*
2
+ * Capitalizes the first character of a string.
3
+ */
4
+ const capFirst = function (string) {
5
+ return string.charAt(0).toUpperCase() + string.slice(1);
6
+ };
7
+
8
+ /*
9
+ * Lowercases the first character of a string.
10
+ */
11
+ const lowerFirst = function (string) {
12
+ return string.charAt(0).toLowerCase() + string.slice(1);
13
+ };
14
+
15
+ /*
16
+ * Display a status message for an error or reconnection.
17
+ *
18
+ * All controls are disabled.
19
+ */
20
+ const showStatus = function (msg, error) {
21
+ for (const button of document.getElementsByTagName("button")) {
22
+ button.disabled = true;
23
+ }
24
+ const spinner = document.getElementById("spinner");
25
+ const statusText = document.getElementById("status-text");
26
+ statusText.textContent = msg;
27
+ if (error) {
28
+ spinner.classList.add("off");
29
+ statusText.classList.add("error-message");
30
+ } else {
31
+ spinner.classList.remove("off");
32
+ statusText.classList.remove("error-message");
33
+ }
34
+ };
35
+
36
+ /*
37
+ * Handles a server-sent event starting with “can”.
38
+ *
39
+ * Such an event enables or disables the indicated button.
40
+ */
41
+ const onCan = function (evt) {
42
+ const buttonName = lowerFirst(evt.type.slice(3));
43
+ const button = document.getElementById(buttonName);
44
+ button.disabled = evt.data === "0";
45
+ };
46
+
47
+ /*
48
+ * Handles a button being clicked.
49
+ */
50
+ const onClick = async function (evt, source) {
51
+ const buttonName = evt.target.id;
52
+ const result = await (async () => {
53
+ try {
54
+ const resp = await fetch(buttonName, { body: "", method: "POST", mode: "same-origin" });
55
+ return [resp, null];
56
+ } catch (err) {
57
+ if (err instanceof TypeError) {
58
+ return [null, err];
59
+ }
60
+ throw err;
61
+ }
62
+ })();
63
+ const [resp, err] = result;
64
+ if (resp === null) {
65
+ source.close();
66
+ showStatus(`POST to “${buttonName}” failed with ${err.message}`, true);
67
+ } else if (!resp.ok) {
68
+ source.close();
69
+ showStatus(`POST to “${resp.url}” failed with HTTP ${resp.status}`, true);
70
+ }
71
+ };
72
+
73
+ /*
74
+ * Handles an error in the server-sent event connection.
75
+ *
76
+ * The problem is displayed and all controls are disabled until the event
77
+ * stream reconnects (if it is going to do so).
78
+ */
79
+ const onError = function (evt) {
80
+ if (evt.target.readyState === EventSource.CONNECTING) {
81
+ showStatus("SSE reconnecting", false);
82
+ } else {
83
+ showStatus("SSE connection failed", true);
84
+ }
85
+ };
86
+
87
+ /*
88
+ * Handles a server-sent “title” event.
89
+ *
90
+ * The title is displayed for the user. The loading spinner is also hidden,
91
+ * since “title” is always the last event in the initial burst on a new
92
+ * connection.
93
+ */
94
+ const onTitle = function (evt) {
95
+ const statusText = document.getElementById("status-text");
96
+ statusText.textContent = evt.data;
97
+ const spinner = document.getElementById("spinner");
98
+ spinner.classList.add("off");
99
+ };
100
+
101
+ /*
102
+ * Starts the application.
103
+ */
104
+ const main = function () {
105
+ const source = new EventSource("status");
106
+ source.addEventListener("error", onError);
107
+ for (const button of document.getElementsByTagName("button")) {
108
+ source.addEventListener(`can${capFirst(button.id)}`, onCan);
109
+ button.addEventListener("click", (evt) => onClick(evt, source));
110
+ }
111
+ source.addEventListener("title", onTitle);
112
+ };
113
+
114
+ main();