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.
@@ -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,11 @@
1
+ # Vaux CLI
2
+
3
+ A terminal client for vaux listening rooms.
4
+
5
+ ## Installation
6
+
7
+ `pip install vaux-cli`
8
+
9
+ ## Usage
10
+
11
+ `vaux join <room-id> -u <your-name>`
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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
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,2 @@
1
+ [console_scripts]
2
+ vaux = main:cli
@@ -0,0 +1,6 @@
1
+ click
2
+ textual
3
+ python-socketio
4
+ httpx
5
+ aiohttp
6
+ websockets
@@ -0,0 +1,2 @@
1
+ main
2
+ vaux