vaux-cli 0.1.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.
- vaux_cli-0.1.0/PKG-INFO +24 -0
- vaux_cli-0.1.0/README.md +11 -0
- vaux_cli-0.1.0/main.py +94 -0
- vaux_cli-0.1.0/pyproject.toml +27 -0
- vaux_cli-0.1.0/setup.cfg +4 -0
- vaux_cli-0.1.0/vaux/__init__.py +0 -0
- vaux_cli-0.1.0/vaux/api.py +46 -0
- vaux_cli-0.1.0/vaux/app.py +781 -0
- vaux_cli-0.1.0/vaux/playback.py +47 -0
- vaux_cli-0.1.0/vaux/socket_client.py +111 -0
- vaux_cli-0.1.0/vaux_cli.egg-info/PKG-INFO +24 -0
- vaux_cli-0.1.0/vaux_cli.egg-info/SOURCES.txt +14 -0
- vaux_cli-0.1.0/vaux_cli.egg-info/dependency_links.txt +1 -0
- vaux_cli-0.1.0/vaux_cli.egg-info/entry_points.txt +2 -0
- vaux_cli-0.1.0/vaux_cli.egg-info/requires.txt +6 -0
- vaux_cli-0.1.0/vaux_cli.egg-info/top_level.txt +2 -0
vaux_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vaux-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A terminal client for vaux listening rooms.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: click
|
|
8
|
+
Requires-Dist: textual
|
|
9
|
+
Requires-Dist: python-socketio
|
|
10
|
+
Requires-Dist: httpx
|
|
11
|
+
Requires-Dist: aiohttp
|
|
12
|
+
Requires-Dist: websockets
|
|
13
|
+
|
|
14
|
+
# Vaux CLI
|
|
15
|
+
|
|
16
|
+
A terminal client for vaux listening rooms.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
`pip install vaux-cli`
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
`vaux join <room-id> -u <your-name>`
|
vaux_cli-0.1.0/README.md
ADDED
vaux_cli-0.1.0/main.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
vaux CLI — terminal client for vaux listening rooms.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python main.py join <room-id> --username <name>
|
|
6
|
+
"""
|
|
7
|
+
import asyncio
|
|
8
|
+
import sys
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
|
|
12
|
+
if sys.platform == "win32":
|
|
13
|
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
|
14
|
+
|
|
15
|
+
# Ensure python-mpv can find mpv-1.dll or mpv-2.dll if it's placed in this folder
|
|
16
|
+
os.environ["PATH"] = os.path.dirname(os.path.abspath(__file__)) + os.pathsep + os.environ.get("PATH", "")
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
from vaux.app import VauxApp, LobbyApp
|
|
20
|
+
|
|
21
|
+
def ensure_mpv():
|
|
22
|
+
"""Checks for mpv and prompts Windows users to auto-download it if missing."""
|
|
23
|
+
mpv_exe = "mpv.exe" if sys.platform == "win32" else "mpv"
|
|
24
|
+
if shutil.which(mpv_exe):
|
|
25
|
+
return
|
|
26
|
+
|
|
27
|
+
base_dir = os.path.dirname(os.path.abspath(__file__))
|
|
28
|
+
vendor_dir = os.path.join(base_dir, "vendor", "mpv")
|
|
29
|
+
mpv_path = os.path.join(vendor_dir, mpv_exe)
|
|
30
|
+
|
|
31
|
+
if os.path.exists(mpv_path):
|
|
32
|
+
return
|
|
33
|
+
|
|
34
|
+
if sys.platform == "win32":
|
|
35
|
+
if click.confirm("mpv is required to play audio, but was not found. Download it now?"):
|
|
36
|
+
import urllib.request
|
|
37
|
+
import json
|
|
38
|
+
import zipfile
|
|
39
|
+
import io
|
|
40
|
+
|
|
41
|
+
click.echo("Downloading mpv for Windows (this may take a minute)...")
|
|
42
|
+
try:
|
|
43
|
+
api_url = "https://api.github.com/repos/shinchiro/mpv-winbuild-cmake/releases/latest"
|
|
44
|
+
req = urllib.request.Request(api_url, headers={"User-Agent": "vaux-cli"})
|
|
45
|
+
with urllib.request.urlopen(req) as response:
|
|
46
|
+
data = json.loads(response.read().decode())
|
|
47
|
+
zip_url = next((a["browser_download_url"] for a in data.get("assets", [])
|
|
48
|
+
if a["name"].startswith("mpv-x86_64-") and a["name"].endswith(".zip")), None)
|
|
49
|
+
|
|
50
|
+
if zip_url:
|
|
51
|
+
os.makedirs(vendor_dir, exist_ok=True)
|
|
52
|
+
req = urllib.request.Request(zip_url, headers={"User-Agent": "vaux-cli"})
|
|
53
|
+
with urllib.request.urlopen(req) as response:
|
|
54
|
+
with zipfile.ZipFile(io.BytesIO(response.read())) as z:
|
|
55
|
+
for file_info in z.infolist():
|
|
56
|
+
if file_info.filename.endswith("mpv.exe"):
|
|
57
|
+
source = z.open(file_info)
|
|
58
|
+
target_path = os.path.join(vendor_dir, "mpv.exe")
|
|
59
|
+
with open(target_path, "wb") as target:
|
|
60
|
+
shutil.copyfileobj(source, target)
|
|
61
|
+
break
|
|
62
|
+
except Exception as e:
|
|
63
|
+
click.echo(f"Failed to auto-download mpv: {e}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@click.command()
|
|
67
|
+
@click.argument("room_id", required=False)
|
|
68
|
+
@click.option("--username", "-u", help="Your display name.")
|
|
69
|
+
@click.option(
|
|
70
|
+
"--server",
|
|
71
|
+
default="http://localhost:4000",
|
|
72
|
+
envvar="VAUX_SERVER_URL",
|
|
73
|
+
show_default=True,
|
|
74
|
+
help="vaux server URL.",
|
|
75
|
+
)
|
|
76
|
+
def cli(room_id: str | None, username: str | None, server: str):
|
|
77
|
+
"""vaux — listen together, in sync. Run without arguments to open the interactive lobby."""
|
|
78
|
+
ensure_mpv()
|
|
79
|
+
|
|
80
|
+
if not room_id or not username:
|
|
81
|
+
lobby = LobbyApp(server_url=server)
|
|
82
|
+
lobby.run()
|
|
83
|
+
|
|
84
|
+
if lobby.result is None:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
room_id, username = lobby.result
|
|
88
|
+
|
|
89
|
+
app = VauxApp(room_id=room_id, username=username, server_url=server)
|
|
90
|
+
app.run()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
cli()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "vaux-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A terminal client for vaux listening rooms."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"click",
|
|
13
|
+
"textual",
|
|
14
|
+
"python-socketio",
|
|
15
|
+
"httpx",
|
|
16
|
+
"aiohttp",
|
|
17
|
+
"websockets"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
# This creates the global terminal command `vaux` and points it to cli() in main.py
|
|
22
|
+
vaux = "main:cli"
|
|
23
|
+
|
|
24
|
+
[tool.setuptools]
|
|
25
|
+
packages = ["vaux"]
|
|
26
|
+
# Packages main.py so the script entrypoint can find it
|
|
27
|
+
py-modules = ["main"]
|
vaux_cli-0.1.0/setup.cfg
ADDED
|
File without changes
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
REST API client for vaux server endpoints.
|
|
3
|
+
Currently covers YouTube search; extend as new endpoints are added.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class SearchResult:
|
|
12
|
+
video_id: str
|
|
13
|
+
title: str
|
|
14
|
+
channel: str
|
|
15
|
+
thumbnail: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def search_youtube(server_url: str, query: str) -> list[SearchResult]:
|
|
19
|
+
"""Hits the server-side YouTube search proxy and returns results."""
|
|
20
|
+
url = f"{server_url}/youtube/search"
|
|
21
|
+
async with httpx.AsyncClient() as client:
|
|
22
|
+
resp = await client.get(url, params={"q": query}, timeout=10.0)
|
|
23
|
+
resp.raise_for_status()
|
|
24
|
+
data = resp.json()
|
|
25
|
+
|
|
26
|
+
return [
|
|
27
|
+
SearchResult(
|
|
28
|
+
video_id=r["videoId"],
|
|
29
|
+
title=r["title"],
|
|
30
|
+
channel=r["channel"],
|
|
31
|
+
thumbnail=r["thumbnail"],
|
|
32
|
+
)
|
|
33
|
+
for r in data.get("results", [])
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def get_stream_url(server_url: str, video_id: str) -> str | None:
|
|
38
|
+
"""Hits the server-side yt-dlp proxy to get a direct audio stream URL."""
|
|
39
|
+
url = f"{server_url}/youtube/stream-url"
|
|
40
|
+
async with httpx.AsyncClient() as client:
|
|
41
|
+
try:
|
|
42
|
+
resp = await client.get(url, params={"videoId": video_id}, timeout=15.0)
|
|
43
|
+
resp.raise_for_status()
|
|
44
|
+
return resp.json().get("streamUrl")
|
|
45
|
+
except Exception:
|
|
46
|
+
return None
|
|
@@ -0,0 +1,781 @@
|
|
|
1
|
+
"""
|
|
2
|
+
VauxApp — the main Textual application.
|
|
3
|
+
|
|
4
|
+
Layout (single screen):
|
|
5
|
+
┌─────────────────────────────────────────────┐
|
|
6
|
+
│ header: vaux / room-id role·name │
|
|
7
|
+
├───────────────────────┬─────────────────────┤
|
|
8
|
+
│ │ now playing │
|
|
9
|
+
│ queue │ ───────────────── │
|
|
10
|
+
│ │ chat │
|
|
11
|
+
│ │ ───────────────── │
|
|
12
|
+
│ search results │ chat input │
|
|
13
|
+
├───────────────────────┴─────────────────────┤
|
|
14
|
+
│ search input [Search] │
|
|
15
|
+
└─────────────────────────────────────────────┘
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import webbrowser
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import shutil
|
|
23
|
+
import json
|
|
24
|
+
import socket
|
|
25
|
+
import secrets
|
|
26
|
+
from textual.app import App, ComposeResult
|
|
27
|
+
from textual.binding import Binding
|
|
28
|
+
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
|
29
|
+
from textual.widgets import (
|
|
30
|
+
Header, Footer, Input, Button, Label, ListView,
|
|
31
|
+
ListItem, Static, RichLog,
|
|
32
|
+
)
|
|
33
|
+
from textual.reactive import reactive
|
|
34
|
+
from rich.text import Text
|
|
35
|
+
|
|
36
|
+
from vaux.socket_client import VauxSocket
|
|
37
|
+
from vaux.playback import PlaybackState
|
|
38
|
+
from vaux.api import search_youtube, SearchResult, get_stream_url
|
|
39
|
+
|
|
40
|
+
import subprocess
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ── generateRoomSlug ──────────────────────────────────────────────────────
|
|
44
|
+
# Same word lists and logic as the web client so slugs look consistent
|
|
45
|
+
# across both interfaces. Uses secrets.randbelow for cryptographic quality.
|
|
46
|
+
|
|
47
|
+
_ADJECTIVES = [
|
|
48
|
+
"amber", "arctic", "azure", "blazing", "crimson", "crystal", "drifting",
|
|
49
|
+
"echoing", "electric", "emerald", "floating", "frozen", "golden", "hollow",
|
|
50
|
+
"indigo", "jade", "lunar", "mystic", "neon", "obsidian", "onyx", "opal",
|
|
51
|
+
"phantom", "radiant", "rusty", "sacred", "silent", "silver", "solar",
|
|
52
|
+
"spectral", "stellar", "sunken", "twilight", "velvet", "vibrant", "violet",
|
|
53
|
+
"wandering", "wild", "winter", "wooden",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
_NOUNS = [
|
|
57
|
+
"anchor", "bloom", "canyon", "circuit", "comet", "current", "dusk",
|
|
58
|
+
"ember", "forest", "harbor", "horizon", "lantern", "melody", "mirror",
|
|
59
|
+
"mosaic", "nebula", "ocean", "orbit", "petal", "prism", "pulse", "reef",
|
|
60
|
+
"relay", "ridge", "signal", "spark", "storm", "summit", "tide", "timber",
|
|
61
|
+
"tunnel", "valley", "vinyl", "vortex", "wave", "willow", "wind", "wraith",
|
|
62
|
+
"zenith", "zephyr",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
def generate_room_slug() -> str:
|
|
66
|
+
adj = _ADJECTIVES[secrets.randbelow(len(_ADJECTIVES))]
|
|
67
|
+
noun = _NOUNS[secrets.randbelow(len(_NOUNS))]
|
|
68
|
+
suffix = 10 + secrets.randbelow(90) # two-digit suffix, 10–99
|
|
69
|
+
return f"{adj}-{noun}-{suffix}"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class MPVPlayer:
|
|
73
|
+
def __init__(self, path: str):
|
|
74
|
+
self.path = path
|
|
75
|
+
self.proc = None
|
|
76
|
+
self.ipc_path = r"\\.\pipe\vaux_mpv_ipc" if sys.platform == "win32" else "/tmp/vaux_mpv_ipc"
|
|
77
|
+
|
|
78
|
+
def play(self, url: str, start: float = 0.0, volume: int = 100):
|
|
79
|
+
self.stop()
|
|
80
|
+
|
|
81
|
+
if sys.platform != "win32" and os.path.exists(self.ipc_path):
|
|
82
|
+
try: os.remove(self.ipc_path)
|
|
83
|
+
except OSError: pass
|
|
84
|
+
|
|
85
|
+
cmd = [
|
|
86
|
+
self.path,
|
|
87
|
+
"--no-video",
|
|
88
|
+
f"--start={int(start)}",
|
|
89
|
+
f"--volume={volume}",
|
|
90
|
+
f"--input-ipc-server={self.ipc_path}",
|
|
91
|
+
url,
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
kwargs = {
|
|
95
|
+
"stdout": subprocess.DEVNULL,
|
|
96
|
+
"stderr": subprocess.DEVNULL,
|
|
97
|
+
}
|
|
98
|
+
if sys.platform == "win32":
|
|
99
|
+
kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW
|
|
100
|
+
|
|
101
|
+
self.proc = subprocess.Popen(cmd, **kwargs)
|
|
102
|
+
|
|
103
|
+
def set_volume(self, volume: int):
|
|
104
|
+
command = {"command": ["set_property", "volume", volume]}
|
|
105
|
+
payload = json.dumps(command) + "\n"
|
|
106
|
+
try:
|
|
107
|
+
if sys.platform == "win32":
|
|
108
|
+
with open(self.ipc_path, "w") as f:
|
|
109
|
+
f.write(payload)
|
|
110
|
+
f.flush()
|
|
111
|
+
else:
|
|
112
|
+
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
113
|
+
s.connect(self.ipc_path)
|
|
114
|
+
s.sendall(payload.encode())
|
|
115
|
+
s.close()
|
|
116
|
+
except Exception:
|
|
117
|
+
pass
|
|
118
|
+
|
|
119
|
+
def stop(self):
|
|
120
|
+
if self.proc and self.proc.poll() is None:
|
|
121
|
+
self.proc.terminate()
|
|
122
|
+
self.proc = None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── LobbyApp ──────────────────────────────────────────────────────────────
|
|
126
|
+
# Shown before VauxApp. Lets the user choose create or join, pick a name,
|
|
127
|
+
# then hands off room_id + username to the caller via self.result.
|
|
128
|
+
|
|
129
|
+
class LobbyApp(App):
|
|
130
|
+
"""Pre-game lobby: create a room (slug) or join an existing one."""
|
|
131
|
+
|
|
132
|
+
theme = "dracula"
|
|
133
|
+
|
|
134
|
+
CSS = """
|
|
135
|
+
Screen {
|
|
136
|
+
align: center middle;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#card {
|
|
140
|
+
width: 52;
|
|
141
|
+
border: round $primary;
|
|
142
|
+
padding: 1 2;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#title {
|
|
146
|
+
text-align: center;
|
|
147
|
+
color: $success;
|
|
148
|
+
text-style: bold;
|
|
149
|
+
margin-bottom: 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
#slug-row {
|
|
153
|
+
layout: horizontal;
|
|
154
|
+
height: 3;
|
|
155
|
+
margin-bottom: 1;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
#slug-display {
|
|
159
|
+
width: 1fr;
|
|
160
|
+
color: $success;
|
|
161
|
+
content-align: left middle;
|
|
162
|
+
padding: 0 1;
|
|
163
|
+
border: tall $primary-darken-2;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#reroll-btn {
|
|
167
|
+
width: 10;
|
|
168
|
+
margin-left: 1;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
#mode-row {
|
|
172
|
+
layout: horizontal;
|
|
173
|
+
height: 3;
|
|
174
|
+
margin-bottom: 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
#create-btn, #join-btn {
|
|
178
|
+
width: 1fr;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#room-input {
|
|
182
|
+
margin-bottom: 1;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
#username-input {
|
|
186
|
+
margin-bottom: 1;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
#go-btn {
|
|
190
|
+
width: 100%;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
#hint {
|
|
194
|
+
text-align: center;
|
|
195
|
+
color: $text-muted;
|
|
196
|
+
margin-top: 1;
|
|
197
|
+
}
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
BINDINGS = [
|
|
201
|
+
Binding("ctrl+c", "quit", "Quit"),
|
|
202
|
+
Binding("tab", "focus_next", "Next field", show=False),
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
def __init__(self, server_url: str):
|
|
206
|
+
super().__init__()
|
|
207
|
+
self.server_url = server_url
|
|
208
|
+
self._mode = "create"
|
|
209
|
+
self._slug = generate_room_slug()
|
|
210
|
+
# result is set before exit so the caller can read it
|
|
211
|
+
self.result: tuple[str, str] | None = None
|
|
212
|
+
|
|
213
|
+
def compose(self) -> ComposeResult:
|
|
214
|
+
with Vertical(id="card"):
|
|
215
|
+
yield Label("v a u x", id="title")
|
|
216
|
+
|
|
217
|
+
# ── mode toggle ──
|
|
218
|
+
with Horizontal(id="mode-row"):
|
|
219
|
+
yield Button("create room", id="create-btn", variant="success")
|
|
220
|
+
yield Button("join room", id="join-btn", variant="default")
|
|
221
|
+
|
|
222
|
+
# ── create: slug display + re-roll ──
|
|
223
|
+
with Horizontal(id="slug-row"):
|
|
224
|
+
yield Label(self._slug, id="slug-display")
|
|
225
|
+
yield Button("↺ new", id="reroll-btn", variant="default")
|
|
226
|
+
|
|
227
|
+
# ── join: free-type input (hidden in create mode) ──
|
|
228
|
+
yield Input(
|
|
229
|
+
placeholder="room name (e.g. velvet-orbit-42)",
|
|
230
|
+
id="room-input",
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
yield Input(placeholder="your name", id="username-input")
|
|
234
|
+
yield Button("create & join →", id="go-btn", variant="success")
|
|
235
|
+
yield Label("", id="hint")
|
|
236
|
+
|
|
237
|
+
yield Footer()
|
|
238
|
+
|
|
239
|
+
def on_mount(self):
|
|
240
|
+
# Start in create mode — hide the join input
|
|
241
|
+
self._apply_mode()
|
|
242
|
+
|
|
243
|
+
def _apply_mode(self):
|
|
244
|
+
slug_row = self.query_one("#slug-row")
|
|
245
|
+
room_input = self.query_one("#room-input", Input)
|
|
246
|
+
go_btn = self.query_one("#go-btn", Button)
|
|
247
|
+
hint = self.query_one("#hint", Label)
|
|
248
|
+
create_btn = self.query_one("#create-btn", Button)
|
|
249
|
+
join_btn = self.query_one("#join-btn", Button)
|
|
250
|
+
|
|
251
|
+
if self._mode == "create":
|
|
252
|
+
slug_row.display = True
|
|
253
|
+
room_input.display = False
|
|
254
|
+
go_btn.label = "create & join →"
|
|
255
|
+
hint.update("")
|
|
256
|
+
create_btn.variant = "success"
|
|
257
|
+
join_btn.variant = "default"
|
|
258
|
+
else:
|
|
259
|
+
slug_row.display = False
|
|
260
|
+
room_input.display = True
|
|
261
|
+
go_btn.label = "join room →"
|
|
262
|
+
hint.update("ask the host for their room name")
|
|
263
|
+
create_btn.variant = "default"
|
|
264
|
+
join_btn.variant = "success"
|
|
265
|
+
room_input.focus()
|
|
266
|
+
|
|
267
|
+
def on_button_pressed(self, event: Button.Pressed):
|
|
268
|
+
btn_id = event.button.id
|
|
269
|
+
|
|
270
|
+
if btn_id == "create-btn":
|
|
271
|
+
self._mode = "create"
|
|
272
|
+
self._apply_mode()
|
|
273
|
+
|
|
274
|
+
elif btn_id == "join-btn":
|
|
275
|
+
self._mode = "join"
|
|
276
|
+
self._apply_mode()
|
|
277
|
+
|
|
278
|
+
elif btn_id == "reroll-btn":
|
|
279
|
+
# Generate a fresh slug and update the display
|
|
280
|
+
self._slug = generate_room_slug()
|
|
281
|
+
self.query_one("#slug-display", Label).update(self._slug)
|
|
282
|
+
|
|
283
|
+
elif btn_id == "go-btn":
|
|
284
|
+
self._submit()
|
|
285
|
+
|
|
286
|
+
def on_input_submitted(self, event: Input.Submitted):
|
|
287
|
+
# Enter in any field submits the form
|
|
288
|
+
self._submit()
|
|
289
|
+
|
|
290
|
+
def _submit(self):
|
|
291
|
+
username = self.query_one("#username-input", Input).value.strip()
|
|
292
|
+
|
|
293
|
+
if self._mode == "create":
|
|
294
|
+
room_id = self._slug
|
|
295
|
+
else:
|
|
296
|
+
room_id = self.query_one("#room-input", Input).value.strip()
|
|
297
|
+
|
|
298
|
+
hint = self.query_one("#hint", Label)
|
|
299
|
+
|
|
300
|
+
if not room_id:
|
|
301
|
+
hint.update("[red]enter a room name[/red]")
|
|
302
|
+
return
|
|
303
|
+
if not username:
|
|
304
|
+
hint.update("[red]enter your name[/red]")
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
self.result = (room_id, username)
|
|
308
|
+
self.exit()
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
# ── NowPlaying widget ──────────────────────────────────────────────────────
|
|
312
|
+
class NowPlaying(Static):
|
|
313
|
+
"""Displays current track info and synced position."""
|
|
314
|
+
|
|
315
|
+
state: reactive[PlaybackState] = reactive(PlaybackState, recompose=False)
|
|
316
|
+
|
|
317
|
+
def __init__(self, **kwargs):
|
|
318
|
+
super().__init__("", **kwargs)
|
|
319
|
+
self._state = PlaybackState()
|
|
320
|
+
|
|
321
|
+
def on_mount(self):
|
|
322
|
+
self.set_interval(1, self._render_state)
|
|
323
|
+
|
|
324
|
+
def update_state(self, state: PlaybackState):
|
|
325
|
+
old_id = self._state.video_id
|
|
326
|
+
self._state = state
|
|
327
|
+
# if state.is_playing and state.video_id and state.video_id != old_id:
|
|
328
|
+
# webbrowser.open(f"https://youtu.be/{state.video_id}?t={int(state.position_seconds)}")
|
|
329
|
+
self._render_state()
|
|
330
|
+
|
|
331
|
+
def _render_state(self):
|
|
332
|
+
s = self._state
|
|
333
|
+
if not s.video_id:
|
|
334
|
+
self.update("◼ no track playing")
|
|
335
|
+
return
|
|
336
|
+
icon = "⏸" if s.is_playing else "▶"
|
|
337
|
+
pos = s.formatted_position()
|
|
338
|
+
title = (s.title or "")[:50]
|
|
339
|
+
channel = s.channel or ""
|
|
340
|
+
self.update(f"{icon} {title}\n {channel} [{pos}]")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# ── QueueItem widget ───────────────────────────────────────────────────────
|
|
344
|
+
class QueueItem(ListItem):
|
|
345
|
+
def __init__(self, item: dict, is_host: bool):
|
|
346
|
+
super().__init__()
|
|
347
|
+
self._item = item
|
|
348
|
+
self._is_host = is_host
|
|
349
|
+
|
|
350
|
+
def compose(self) -> ComposeResult:
|
|
351
|
+
title = (self._item.get("title") or "")[:40]
|
|
352
|
+
votes = self._item.get("votes", 0)
|
|
353
|
+
added_by = self._item.get("addedBy", "")
|
|
354
|
+
vote_str = f"+{votes}" if votes >= 0 else str(votes)
|
|
355
|
+
host_marker = " [host ▶]" if self._is_host else ""
|
|
356
|
+
yield Label(f"{vote_str} {title} — {added_by}{host_marker}")
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
# ── SearchResultItem widget ────────────────────────────────────────────────
|
|
360
|
+
class SearchResultItem(ListItem):
|
|
361
|
+
def __init__(self, result: SearchResult):
|
|
362
|
+
super().__init__()
|
|
363
|
+
self.result = result
|
|
364
|
+
|
|
365
|
+
def compose(self) -> ComposeResult:
|
|
366
|
+
title = result.title[:50] if (result := self.result) else ""
|
|
367
|
+
channel = self.result.channel
|
|
368
|
+
yield Label(f" {title} [{channel}]")
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# ── VauxApp ────────────────────────────────────────────────────────────────
|
|
372
|
+
class VauxApp(App):
|
|
373
|
+
theme = "dracula" # Built-in themes: dracula, nord, monokai, tokyo-night, textual-dark
|
|
374
|
+
|
|
375
|
+
CSS = """
|
|
376
|
+
Screen {
|
|
377
|
+
layout: vertical;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
#main {
|
|
381
|
+
layout: horizontal;
|
|
382
|
+
height: 1fr;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
#left {
|
|
386
|
+
width: 1fr;
|
|
387
|
+
layout: vertical;
|
|
388
|
+
border-right: solid $primary-darken-2;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
#right {
|
|
392
|
+
width: 50;
|
|
393
|
+
layout: vertical;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
#queue-list {
|
|
397
|
+
height: 1fr;
|
|
398
|
+
border-bottom: solid $primary-darken-2;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#search-results {
|
|
402
|
+
height: 12;
|
|
403
|
+
border-bottom: solid $primary-darken-2;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
#search-bar {
|
|
407
|
+
layout: horizontal;
|
|
408
|
+
height: 3;
|
|
409
|
+
padding: 0 1;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
#search-input {
|
|
413
|
+
width: 1fr;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
#now-playing {
|
|
417
|
+
height: 5;
|
|
418
|
+
padding: 1;
|
|
419
|
+
border-bottom: solid $primary-darken-2;
|
|
420
|
+
color: $success;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
#chat-log {
|
|
424
|
+
height: 1fr;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
#chat-bar {
|
|
428
|
+
height: 3;
|
|
429
|
+
padding: 0 1;
|
|
430
|
+
layout: horizontal;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
#chat-input {
|
|
434
|
+
width: 1fr;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
NowPlaying {
|
|
438
|
+
padding: 0 1;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
Label {
|
|
442
|
+
padding: 0 1;
|
|
443
|
+
}
|
|
444
|
+
"""
|
|
445
|
+
|
|
446
|
+
BINDINGS = [
|
|
447
|
+
Binding("ctrl+c", "quit", "Quit"),
|
|
448
|
+
Binding("ctrl+s", "focus_search", "Search", show=True),
|
|
449
|
+
Binding("ctrl+t", "focus_chat", "Chat", show=True),
|
|
450
|
+
Binding("ctrl+u", "vote_up", "Vote ▲", show=False),
|
|
451
|
+
Binding("ctrl+d", "vote_down", "Vote ▼", show=False),
|
|
452
|
+
Binding("ctrl+o", "toggle_playback", "Play/Pause", show=True),
|
|
453
|
+
Binding("ctrl+n", "skip_track", "Skip ▶", show=True),
|
|
454
|
+
Binding("-", "volume_down", "Vol -", show=True),
|
|
455
|
+
Binding("=", "volume_up", "Vol +", show=True),
|
|
456
|
+
]
|
|
457
|
+
|
|
458
|
+
def __init__(self, room_id: str, username: str, server_url: str):
|
|
459
|
+
super().__init__()
|
|
460
|
+
self.room_id = room_id
|
|
461
|
+
self.username = username
|
|
462
|
+
self.server_url = server_url
|
|
463
|
+
|
|
464
|
+
self.socket = VauxSocket(server_url)
|
|
465
|
+
self.is_host = False
|
|
466
|
+
self.role = "listener"
|
|
467
|
+
self.members: list[dict] = []
|
|
468
|
+
self.queue: list[dict] = []
|
|
469
|
+
self.playback = PlaybackState()
|
|
470
|
+
self.search_results: list[SearchResult] = []
|
|
471
|
+
self.last_video_id = None
|
|
472
|
+
self.player_running = False
|
|
473
|
+
self.stream_cache: dict[str, str] = {}
|
|
474
|
+
self.volume = 100
|
|
475
|
+
|
|
476
|
+
mpv_exe = "mpv.exe" if sys.platform == "win32" else "mpv"
|
|
477
|
+
|
|
478
|
+
# 1. Try to find mpv installed globally on the user's system
|
|
479
|
+
if shutil.which(mpv_exe):
|
|
480
|
+
self.player = MPVPlayer(shutil.which(mpv_exe))
|
|
481
|
+
else:
|
|
482
|
+
# 2. Fallback to local dev vendor folder
|
|
483
|
+
base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
484
|
+
vendor_dir = os.path.join(base_dir, "vendor", "mpv")
|
|
485
|
+
mpv_path = os.path.join(vendor_dir, mpv_exe)
|
|
486
|
+
|
|
487
|
+
if os.path.exists(mpv_path):
|
|
488
|
+
self.player = MPVPlayer(mpv_path)
|
|
489
|
+
else:
|
|
490
|
+
self.player = None
|
|
491
|
+
|
|
492
|
+
# ── layout ─────────────────────────────────────────────────────────────
|
|
493
|
+
def compose(self) -> ComposeResult:
|
|
494
|
+
yield Header(show_clock=True)
|
|
495
|
+
|
|
496
|
+
with Horizontal(id="main"):
|
|
497
|
+
# left: queue + search
|
|
498
|
+
with Vertical(id="left"):
|
|
499
|
+
yield Label(" queue", id="queue-label")
|
|
500
|
+
yield ListView(id="queue-list")
|
|
501
|
+
yield Label(" results", id="results-label")
|
|
502
|
+
yield ListView(id="search-results")
|
|
503
|
+
with Horizontal(id="search-bar"):
|
|
504
|
+
yield Input(placeholder="search youtube...", id="search-input")
|
|
505
|
+
yield Button("Search", id="search-btn", variant="primary")
|
|
506
|
+
|
|
507
|
+
# right: now playing + chat
|
|
508
|
+
with Vertical(id="right"):
|
|
509
|
+
yield NowPlaying(id="now-playing")
|
|
510
|
+
yield RichLog(id="chat-log", highlight=True, markup=True)
|
|
511
|
+
with Horizontal(id="chat-bar"):
|
|
512
|
+
yield Input(placeholder="say something...", id="chat-input")
|
|
513
|
+
yield Button("→", id="chat-btn", variant="success")
|
|
514
|
+
|
|
515
|
+
yield Footer()
|
|
516
|
+
|
|
517
|
+
# ── lifecycle ──────────────────────────────────────────────────────────
|
|
518
|
+
async def on_mount(self):
|
|
519
|
+
self.title = f"vaux / {self.room_id}"
|
|
520
|
+
self._register_socket_events()
|
|
521
|
+
await self.socket.connect()
|
|
522
|
+
await self.socket.join_room(self.room_id, self.username, self.username)
|
|
523
|
+
self.set_interval(1.0, self._check_player_status)
|
|
524
|
+
|
|
525
|
+
async def on_unmount(self):
|
|
526
|
+
if getattr(self, "player", None):
|
|
527
|
+
self.player.stop()
|
|
528
|
+
await self.socket.disconnect()
|
|
529
|
+
|
|
530
|
+
# ── socket event wiring ────────────────────────────────────────────────
|
|
531
|
+
def _register_socket_events(self):
|
|
532
|
+
self.socket.on("room:joined", self._on_room_joined)
|
|
533
|
+
self.socket.on("room:member_joined", self._on_member_joined)
|
|
534
|
+
self.socket.on("room:member_left", self._on_member_left)
|
|
535
|
+
self.socket.on("host:changed", self._on_host_changed)
|
|
536
|
+
self.socket.on("queue:updated", self._on_queue_updated)
|
|
537
|
+
self.socket.on("playback:state", self._on_playback_state)
|
|
538
|
+
self.socket.on("chat:message", self._on_chat_message)
|
|
539
|
+
self.socket.on("reaction:broadcast", self._on_reaction)
|
|
540
|
+
|
|
541
|
+
async def _on_room_joined(self, data: dict):
|
|
542
|
+
self.role = data.get("role", "listener")
|
|
543
|
+
self.is_host = self.role == "host"
|
|
544
|
+
self.members = data.get("members", [])
|
|
545
|
+
self.queue = data.get("queue", [])
|
|
546
|
+
pb = data.get("playbackState") or {}
|
|
547
|
+
self.playback = PlaybackState.from_dict(pb)
|
|
548
|
+
await self._refresh_queue()
|
|
549
|
+
self._refresh_now_playing()
|
|
550
|
+
await self._apply_playback()
|
|
551
|
+
self._post_system(f"joined [{self.role}]")
|
|
552
|
+
if not getattr(self, "player", None):
|
|
553
|
+
self._post_system("mpv not found on system. Please install mpv to hear audio.")
|
|
554
|
+
|
|
555
|
+
async def _on_member_joined(self, data: dict):
|
|
556
|
+
uname = data.get("username", "?")
|
|
557
|
+
self._post_system(f"{uname} joined")
|
|
558
|
+
|
|
559
|
+
async def _on_member_left(self, data: dict):
|
|
560
|
+
uid = data.get("userId", "?")
|
|
561
|
+
self._post_system(f"{uid} left")
|
|
562
|
+
|
|
563
|
+
async def _on_host_changed(self, data: dict):
|
|
564
|
+
new_host_id = data.get("newHostId")
|
|
565
|
+
self.is_host = new_host_id == self.username
|
|
566
|
+
self.role = "host" if self.is_host else "listener"
|
|
567
|
+
new_name = data.get("newHostUsername", new_host_id)
|
|
568
|
+
self._post_system(f"⭐ {new_name} is now host")
|
|
569
|
+
await self._refresh_queue()
|
|
570
|
+
|
|
571
|
+
async def _on_queue_updated(self, data: dict):
|
|
572
|
+
self.queue = data.get("queue", [])
|
|
573
|
+
await self._refresh_queue()
|
|
574
|
+
|
|
575
|
+
async def _on_playback_state(self, data: dict):
|
|
576
|
+
self.playback = PlaybackState.from_dict(data)
|
|
577
|
+
self._refresh_now_playing()
|
|
578
|
+
await self._apply_playback()
|
|
579
|
+
|
|
580
|
+
async def _on_chat_message(self, data: dict):
|
|
581
|
+
uname = data.get("username", "?")
|
|
582
|
+
text = data.get("text", "")
|
|
583
|
+
self._post_chat(uname, text)
|
|
584
|
+
|
|
585
|
+
async def _on_reaction(self, data: dict):
|
|
586
|
+
emoji = data.get("emoji", "")
|
|
587
|
+
self._post_system(emoji)
|
|
588
|
+
|
|
589
|
+
# ── UI refresh helpers ─────────────────────────────────────────────────
|
|
590
|
+
async def _refresh_queue(self):
|
|
591
|
+
lv = self.query_one("#queue-list", ListView)
|
|
592
|
+
await lv.clear()
|
|
593
|
+
for item in self.queue:
|
|
594
|
+
await lv.append(QueueItem(item, self.is_host))
|
|
595
|
+
|
|
596
|
+
def _refresh_now_playing(self):
|
|
597
|
+
widget = self.query_one("#now-playing", NowPlaying)
|
|
598
|
+
widget.update_state(self.playback)
|
|
599
|
+
|
|
600
|
+
def _post_chat(self, username: str, text: str):
|
|
601
|
+
log = self.query_one("#chat-log", RichLog)
|
|
602
|
+
t = Text()
|
|
603
|
+
t.append(f"{username} ", style="bold green")
|
|
604
|
+
t.append(text)
|
|
605
|
+
log.write(t)
|
|
606
|
+
|
|
607
|
+
def _post_system(self, text: str):
|
|
608
|
+
log = self.query_one("#chat-log", RichLog)
|
|
609
|
+
t = Text(text, style="dim italic")
|
|
610
|
+
log.write(t)
|
|
611
|
+
|
|
612
|
+
def _check_player_status(self):
|
|
613
|
+
"""Polls the mpv process to auto-skip when a track ends naturally or crashes."""
|
|
614
|
+
# Only the host is responsible for advancing the queue
|
|
615
|
+
if not self.is_host or not getattr(self, "playback", None) or not self.playback.is_playing:
|
|
616
|
+
return
|
|
617
|
+
|
|
618
|
+
if getattr(self, "player", None) and self.player.proc:
|
|
619
|
+
if self.player.proc.poll() is not None:
|
|
620
|
+
self.player.proc = None
|
|
621
|
+
self.player_running = False
|
|
622
|
+
asyncio.create_task(self._trigger_ended())
|
|
623
|
+
|
|
624
|
+
async def _apply_playback(self):
|
|
625
|
+
"""Syncs the python-mpv player instance with the server playback state."""
|
|
626
|
+
if not getattr(self, "player", None):
|
|
627
|
+
return
|
|
628
|
+
|
|
629
|
+
s = self.playback
|
|
630
|
+
if not s.video_id:
|
|
631
|
+
self.player.stop()
|
|
632
|
+
self.last_video_id = None
|
|
633
|
+
self.player_running = False
|
|
634
|
+
return
|
|
635
|
+
|
|
636
|
+
# Needs to play/resume if it's a new track OR it was paused locally
|
|
637
|
+
needs_play = s.is_playing and (s.video_id != self.last_video_id or not self.player_running)
|
|
638
|
+
|
|
639
|
+
if needs_play:
|
|
640
|
+
stream_url = self.stream_cache.get(s.video_id)
|
|
641
|
+
if not stream_url:
|
|
642
|
+
stream_url = await get_stream_url(self.server_url, s.video_id)
|
|
643
|
+
if stream_url:
|
|
644
|
+
self.stream_cache[s.video_id] = stream_url
|
|
645
|
+
|
|
646
|
+
if stream_url:
|
|
647
|
+
target_pos = s.synced_position()
|
|
648
|
+
self.player.play(stream_url, start=target_pos, volume=self.volume)
|
|
649
|
+
self.last_video_id = s.video_id
|
|
650
|
+
self.player_running = True
|
|
651
|
+
else:
|
|
652
|
+
self._post_system("Failed to load stream for track.")
|
|
653
|
+
await self._trigger_ended()
|
|
654
|
+
|
|
655
|
+
elif not s.is_playing and self.player_running:
|
|
656
|
+
self.player.stop()
|
|
657
|
+
self.player_running = False
|
|
658
|
+
|
|
659
|
+
async def _trigger_ended(self):
|
|
660
|
+
"""Tells the server the track finished so it can auto-play the next queue item."""
|
|
661
|
+
if self.is_host:
|
|
662
|
+
await self.socket.ended(self.room_id)
|
|
663
|
+
|
|
664
|
+
# ── button handlers ────────────────────────────────────────────────────
|
|
665
|
+
async def on_button_pressed(self, event: Button.Pressed):
|
|
666
|
+
if event.button.id == "search-btn":
|
|
667
|
+
await self._do_search()
|
|
668
|
+
elif event.button.id == "chat-btn":
|
|
669
|
+
await self._do_send_chat()
|
|
670
|
+
|
|
671
|
+
async def on_input_submitted(self, event: Input.Submitted):
|
|
672
|
+
if event.input.id == "search-input":
|
|
673
|
+
await self._do_search()
|
|
674
|
+
elif event.input.id == "chat-input":
|
|
675
|
+
await self._do_send_chat()
|
|
676
|
+
|
|
677
|
+
async def _do_search(self):
|
|
678
|
+
inp = self.query_one("#search-input", Input)
|
|
679
|
+
query = inp.value.strip()
|
|
680
|
+
if not query:
|
|
681
|
+
return
|
|
682
|
+
results = await search_youtube(self.server_url, query)
|
|
683
|
+
self.search_results = results
|
|
684
|
+
lv = self.query_one("#search-results", ListView)
|
|
685
|
+
await lv.clear()
|
|
686
|
+
for r in results:
|
|
687
|
+
await lv.append(SearchResultItem(r))
|
|
688
|
+
inp.value = ""
|
|
689
|
+
|
|
690
|
+
async def _do_send_chat(self):
|
|
691
|
+
inp = self.query_one("#chat-input", Input)
|
|
692
|
+
text = inp.value.strip()
|
|
693
|
+
if not text:
|
|
694
|
+
return
|
|
695
|
+
|
|
696
|
+
# Intercept /host command to transfer host privileges
|
|
697
|
+
if text.startswith("/host "):
|
|
698
|
+
if not self.is_host:
|
|
699
|
+
self._post_system("Only the host can transfer privileges.")
|
|
700
|
+
else:
|
|
701
|
+
new_host = text[6:].strip()
|
|
702
|
+
await self.socket.transfer_host(self.room_id, new_host)
|
|
703
|
+
inp.value = ""
|
|
704
|
+
return
|
|
705
|
+
|
|
706
|
+
await self.socket.send_chat(self.room_id, self.username, self.username, text)
|
|
707
|
+
inp.value = ""
|
|
708
|
+
|
|
709
|
+
# ── list selection — queue and search results ──────────────────────────
|
|
710
|
+
async def on_list_view_selected(self, event: ListView.Selected):
|
|
711
|
+
lv_id = event.list_view.id
|
|
712
|
+
|
|
713
|
+
if lv_id == "search-results":
|
|
714
|
+
# add selected result to queue
|
|
715
|
+
idx = event.list_view.index
|
|
716
|
+
if idx is not None and idx < len(self.search_results):
|
|
717
|
+
r = self.search_results[idx]
|
|
718
|
+
await self.socket.add_to_queue(
|
|
719
|
+
self.room_id, r.video_id, r.title, r.channel, r.thumbnail
|
|
720
|
+
)
|
|
721
|
+
self._post_system(f"added: {r.title[:40]}")
|
|
722
|
+
|
|
723
|
+
elif lv_id == "queue-list" and self.is_host:
|
|
724
|
+
# host pressing enter on a queue item plays it immediately
|
|
725
|
+
idx = event.list_view.index
|
|
726
|
+
if idx is not None and idx < len(self.queue):
|
|
727
|
+
item = self.queue[idx]
|
|
728
|
+
await self.socket.play_track(self.room_id, item["id"])
|
|
729
|
+
|
|
730
|
+
# ── keybinding actions ─────────────────────────────────────────────────
|
|
731
|
+
def action_focus_search(self):
|
|
732
|
+
self.query_one("#search-input", Input).focus()
|
|
733
|
+
|
|
734
|
+
def action_focus_chat(self):
|
|
735
|
+
self.query_one("#chat-input", Input).focus()
|
|
736
|
+
|
|
737
|
+
async def action_vote_up(self):
|
|
738
|
+
lv = self.query_one("#queue-list", ListView)
|
|
739
|
+
idx = lv.index
|
|
740
|
+
if idx is not None and idx < len(self.queue):
|
|
741
|
+
await self.socket.vote(self.room_id, self.queue[idx]["id"], 1)
|
|
742
|
+
|
|
743
|
+
async def action_vote_down(self):
|
|
744
|
+
lv = self.query_one("#queue-list", ListView)
|
|
745
|
+
idx = lv.index
|
|
746
|
+
if idx is not None and idx < len(self.queue):
|
|
747
|
+
item = self.queue[idx]
|
|
748
|
+
if item.get("votes", 0) >= 1:
|
|
749
|
+
await self.socket.vote(self.room_id, item["id"], -1)
|
|
750
|
+
|
|
751
|
+
async def action_toggle_playback(self):
|
|
752
|
+
if not self.is_host or not self.playback.video_id:
|
|
753
|
+
return
|
|
754
|
+
|
|
755
|
+
current_pos = self.playback.synced_position()
|
|
756
|
+
if self.playback.is_playing:
|
|
757
|
+
await self.socket.pause(self.room_id, current_pos)
|
|
758
|
+
self._post_system("paused playback")
|
|
759
|
+
else:
|
|
760
|
+
await self.socket.play(self.room_id, current_pos)
|
|
761
|
+
self._post_system("resumed playback")
|
|
762
|
+
|
|
763
|
+
async def action_skip_track(self):
|
|
764
|
+
if not self.is_host:
|
|
765
|
+
self._post_system("Only the host can skip tracks.")
|
|
766
|
+
return
|
|
767
|
+
if self.playback.video_id:
|
|
768
|
+
self._post_system("Skipped track.")
|
|
769
|
+
await self._trigger_ended()
|
|
770
|
+
|
|
771
|
+
def action_volume_down(self):
|
|
772
|
+
self.volume = max(0, self.volume - 10)
|
|
773
|
+
if getattr(self, "player", None):
|
|
774
|
+
self.player.set_volume(self.volume)
|
|
775
|
+
self._post_system(f"Volume: {self.volume}%")
|
|
776
|
+
|
|
777
|
+
def action_volume_up(self):
|
|
778
|
+
self.volume = min(100, self.volume + 10)
|
|
779
|
+
if getattr(self, "player", None):
|
|
780
|
+
self.player.set_volume(self.volume)
|
|
781
|
+
self._post_system(f"Volume: {self.volume}%")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Playback sync — mirrors the web client's getSyncedPosition formula.
|
|
3
|
+
|
|
4
|
+
currentPosition = positionSeconds + (now - updatedAt) / 1000
|
|
5
|
+
|
|
6
|
+
Both clients implement this identically so everyone stays in sync.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class PlaybackState:
|
|
15
|
+
video_id: str | None = None
|
|
16
|
+
title: str | None = None
|
|
17
|
+
channel: str | None = None
|
|
18
|
+
thumbnail: str | None = None
|
|
19
|
+
track_id: str | None = None
|
|
20
|
+
position_seconds: float = 0.0
|
|
21
|
+
is_playing: bool = False
|
|
22
|
+
updated_at: float = field(default_factory=lambda: time.time() * 1000)
|
|
23
|
+
|
|
24
|
+
@classmethod
|
|
25
|
+
def from_dict(cls, data: dict) -> "PlaybackState":
|
|
26
|
+
return cls(
|
|
27
|
+
video_id=data.get("videoId"),
|
|
28
|
+
title=data.get("title"),
|
|
29
|
+
channel=data.get("channel"),
|
|
30
|
+
thumbnail=data.get("thumbnail"),
|
|
31
|
+
track_id=data.get("trackId"),
|
|
32
|
+
position_seconds=data.get("positionSeconds", 0.0),
|
|
33
|
+
is_playing=data.get("isPlaying", False),
|
|
34
|
+
updated_at=data.get("updatedAt", time.time() * 1000),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
def synced_position(self) -> float:
|
|
38
|
+
"""Returns the current playback position accounting for elapsed time."""
|
|
39
|
+
if not self.is_playing:
|
|
40
|
+
return self.position_seconds
|
|
41
|
+
elapsed = (time.time() * 1000 - self.updated_at) / 1000
|
|
42
|
+
return self.position_seconds + elapsed
|
|
43
|
+
|
|
44
|
+
def formatted_position(self) -> str:
|
|
45
|
+
secs = int(self.synced_position())
|
|
46
|
+
m, s = divmod(secs, 60)
|
|
47
|
+
return f"{m}:{s:02d}"
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""
|
|
2
|
+
vaux socket client.
|
|
3
|
+
|
|
4
|
+
Wraps python-socketio and exposes the same event contract as the web client.
|
|
5
|
+
All callbacks receive the raw payload dict from the server.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from typing import Callable
|
|
10
|
+
import socketio
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class VauxSocket:
|
|
14
|
+
def __init__(self, server_url: str):
|
|
15
|
+
self.server_url = server_url
|
|
16
|
+
self.sio = socketio.AsyncClient()
|
|
17
|
+
self._handlers: dict[str, list[Callable]] = {}
|
|
18
|
+
|
|
19
|
+
# ── public event registration ──────────────────────────────────────────
|
|
20
|
+
def on(self, event: str, handler: Callable):
|
|
21
|
+
self._handlers.setdefault(event, []).append(handler)
|
|
22
|
+
self.sio.on(event, self._make_dispatcher(event))
|
|
23
|
+
|
|
24
|
+
def _make_dispatcher(self, event: str):
|
|
25
|
+
async def dispatch(*args):
|
|
26
|
+
data = args[0] if args else {}
|
|
27
|
+
for h in self._handlers.get(event, []):
|
|
28
|
+
if asyncio.iscoroutinefunction(h):
|
|
29
|
+
await h(data)
|
|
30
|
+
else:
|
|
31
|
+
h(data)
|
|
32
|
+
return dispatch
|
|
33
|
+
|
|
34
|
+
# ── connection ─────────────────────────────────────────────────────────
|
|
35
|
+
async def connect(self):
|
|
36
|
+
await self.sio.connect(self.server_url, transports=["polling", "websocket"])
|
|
37
|
+
|
|
38
|
+
async def disconnect(self):
|
|
39
|
+
await self.sio.disconnect()
|
|
40
|
+
|
|
41
|
+
# ── emit helpers — mirrors web client emit calls ───────────────────────
|
|
42
|
+
async def join_room(self, room_id: str, user_id: str, username: str):
|
|
43
|
+
await self.sio.emit("room:join", {
|
|
44
|
+
"roomId": room_id,
|
|
45
|
+
"userId": user_id,
|
|
46
|
+
"username": username,
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
async def send_chat(self, room_id: str, user_id: str, username: str, text: str):
|
|
50
|
+
await self.sio.emit("chat:send", {
|
|
51
|
+
"roomId": room_id,
|
|
52
|
+
"userId": user_id,
|
|
53
|
+
"username": username,
|
|
54
|
+
"text": text,
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
async def add_to_queue(self, room_id: str, video_id: str, title: str,
|
|
58
|
+
channel: str, thumbnail: str):
|
|
59
|
+
await self.sio.emit("queue:add", {
|
|
60
|
+
"roomId": room_id,
|
|
61
|
+
"videoId": video_id,
|
|
62
|
+
"title": title,
|
|
63
|
+
"channel": channel,
|
|
64
|
+
"thumbnail": thumbnail,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
async def vote(self, room_id: str, item_id: str, value: int):
|
|
68
|
+
await self.sio.emit("queue:vote", {
|
|
69
|
+
"roomId": room_id,
|
|
70
|
+
"itemId": item_id,
|
|
71
|
+
"value": value,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
async def play(self, room_id: str, position_seconds: float):
|
|
75
|
+
await self.sio.emit("playback:play", {
|
|
76
|
+
"roomId": room_id,
|
|
77
|
+
"positionSeconds": position_seconds,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
async def pause(self, room_id: str, position_seconds: float):
|
|
81
|
+
await self.sio.emit("playback:pause", {
|
|
82
|
+
"roomId": room_id,
|
|
83
|
+
"positionSeconds": position_seconds,
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
async def seek(self, room_id: str, position_seconds: float):
|
|
87
|
+
await self.sio.emit("playback:seek", {
|
|
88
|
+
"roomId": room_id,
|
|
89
|
+
"positionSeconds": position_seconds,
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
async def play_track(self, room_id: str, item_id: str):
|
|
93
|
+
await self.sio.emit("playback:play_track", {
|
|
94
|
+
"roomId": room_id,
|
|
95
|
+
"itemId": item_id,
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
async def ended(self, room_id: str):
|
|
99
|
+
await self.sio.emit("playback:ended", {"roomId": room_id})
|
|
100
|
+
|
|
101
|
+
async def transfer_host(self, room_id: str, new_host_id: str):
|
|
102
|
+
await self.sio.emit("host:transfer", {
|
|
103
|
+
"roomId": room_id,
|
|
104
|
+
"newHostId": new_host_id,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
async def send_reaction(self, room_id: str, emoji: str):
|
|
108
|
+
await self.sio.emit("reaction:send", {
|
|
109
|
+
"roomId": room_id,
|
|
110
|
+
"emoji": emoji,
|
|
111
|
+
})
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vaux-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A terminal client for vaux listening rooms.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: click
|
|
8
|
+
Requires-Dist: textual
|
|
9
|
+
Requires-Dist: python-socketio
|
|
10
|
+
Requires-Dist: httpx
|
|
11
|
+
Requires-Dist: aiohttp
|
|
12
|
+
Requires-Dist: websockets
|
|
13
|
+
|
|
14
|
+
# Vaux CLI
|
|
15
|
+
|
|
16
|
+
A terminal client for vaux listening rooms.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
`pip install vaux-cli`
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
`vaux join <room-id> -u <your-name>`
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
main.py
|
|
3
|
+
pyproject.toml
|
|
4
|
+
vaux/__init__.py
|
|
5
|
+
vaux/api.py
|
|
6
|
+
vaux/app.py
|
|
7
|
+
vaux/playback.py
|
|
8
|
+
vaux/socket_client.py
|
|
9
|
+
vaux_cli.egg-info/PKG-INFO
|
|
10
|
+
vaux_cli.egg-info/SOURCES.txt
|
|
11
|
+
vaux_cli.egg-info/dependency_links.txt
|
|
12
|
+
vaux_cli.egg-info/entry_points.txt
|
|
13
|
+
vaux_cli.egg-info/requires.txt
|
|
14
|
+
vaux_cli.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|