fluxcast 0.1.0__py3-none-any.whl

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,9 @@
1
+ [Desktop Entry]
2
+ Name=FluxCast
3
+ Comment=Stream your desktop to a Smart TV
4
+ Exec=fluxcast --tray
5
+ Icon=fluxcast
6
+ Terminal=false
7
+ Type=Application
8
+ Categories=AudioVideo;Video;Network;
9
+ Keywords=cast;stream;miracast;screen;tv;wireless;dlna;chromecast;
@@ -0,0 +1,14 @@
1
+ <!DOCTYPE busconfig PUBLIC
2
+ "-//freedesktop//DTD D-BUS Bus Configuration 1.0//EN"
3
+ "http://www.freedesktop.org/standards/dbus/1.0/busconfig.dtd">
4
+ <busconfig>
5
+ <policy context="default">
6
+ <allow send_destination="fi.w1.wpa_supplicant1"
7
+ send_interface="org.freedesktop.DBus.Properties"
8
+ send_member="Get"/>
9
+ <allow send_destination="fi.w1.wpa_supplicant1"
10
+ send_interface="org.freedesktop.DBus.Properties"
11
+ send_member="Set"/>
12
+ <allow receive_sender="fi.w1.wpa_supplicant1"/>
13
+ </policy>
14
+ </busconfig>
Binary file
capture.py ADDED
@@ -0,0 +1,564 @@
1
+ import glob
2
+ import os
3
+ import re
4
+ import subprocess
5
+ import shutil
6
+ import sys
7
+ import time
8
+ from typing import Any, Optional, NamedTuple
9
+
10
+ FFMPEG_PATH = shutil.which("ffmpeg") or "/usr/sbin/ffmpeg"
11
+
12
+
13
+ class Monitor(NamedTuple):
14
+ display: str
15
+ name: str # e.g. 'eDP-1'
16
+ width: int
17
+ height: int
18
+ x: int # x offset within the combined framebuffer
19
+ y: int
20
+ refresh: float # Hz
21
+
22
+
23
+ class SessionInfo(NamedTuple):
24
+ session_type: str
25
+ desktop: str
26
+ wm: str
27
+ is_hyprland: bool
28
+ is_wayland: bool
29
+ is_x11: bool
30
+
31
+
32
+ class CaptureStartError(RuntimeError):
33
+ pass
34
+
35
+
36
+ class CaptureResult(NamedTuple):
37
+ process: "subprocess.Popen[bytes]"
38
+ aux_process: "Optional[subprocess.Popen[bytes]]" = None
39
+ portal_session: Any = None
40
+
41
+
42
+ def detect_session() -> SessionInfo:
43
+ session_type = (os.environ.get("XDG_SESSION_TYPE") or "").strip().lower()
44
+ desktop = (os.environ.get("XDG_CURRENT_DESKTOP") or "").strip()
45
+ wm = (os.environ.get("XDG_SESSION_DESKTOP") or "").strip()
46
+ hypr_sig = os.environ.get("HYPRLAND_INSTANCE_SIGNATURE")
47
+ is_hyprland = bool(hypr_sig) or "hyprland" in desktop.lower() or "hyprland" in wm.lower()
48
+ is_wayland = bool(os.environ.get("WAYLAND_DISPLAY")) or session_type == "wayland"
49
+ is_x11 = bool(os.environ.get("DISPLAY")) and (session_type == "x11" or not is_wayland)
50
+ if not session_type:
51
+ session_type = "wayland" if is_wayland else ("x11" if is_x11 else "unknown")
52
+ return SessionInfo(
53
+ session_type=session_type,
54
+ desktop=desktop or "unknown",
55
+ wm=wm or "unknown",
56
+ is_hyprland=is_hyprland,
57
+ is_wayland=is_wayland,
58
+ is_x11=is_x11,
59
+ )
60
+
61
+
62
+ def _default_audio_monitor() -> str:
63
+ pactl = shutil.which("pactl")
64
+ if not pactl:
65
+ return "default"
66
+ try:
67
+ result = subprocess.run(
68
+ [pactl, "get-default-sink"],
69
+ capture_output=True,
70
+ text=True,
71
+ timeout=2.0,
72
+ )
73
+ except (subprocess.TimeoutExpired, OSError):
74
+ return "default"
75
+ sink = result.stdout.strip()
76
+ if sink:
77
+ return sink + ".monitor"
78
+ return "default"
79
+
80
+
81
+ def _gst_has_element(name: str) -> bool:
82
+ try:
83
+ result = subprocess.run(
84
+ ["gst-inspect-1.0", name],
85
+ capture_output=True,
86
+ timeout=10.0,
87
+ )
88
+ return result.returncode == 0
89
+ except (subprocess.TimeoutExpired, FileNotFoundError):
90
+ return False
91
+
92
+
93
+ def _auto_capture_backend_order() -> list[str]:
94
+ session = detect_session()
95
+ if session.is_hyprland:
96
+ return ["wf-recorder", "x11grab"]
97
+ if session.is_x11:
98
+ return ["x11grab", "wf-recorder"]
99
+ if session.is_wayland:
100
+ return ["portal", "wf-recorder"]
101
+ return ["x11grab", "wf-recorder"]
102
+
103
+
104
+ def choose_capture_backend(preferred: str = "auto") -> str:
105
+ if preferred != "auto":
106
+ return preferred
107
+ return _auto_capture_backend_order()[0]
108
+
109
+
110
+ def describe_capture_selection(backend: str) -> None:
111
+ session = detect_session()
112
+ print(
113
+ "[FluxCast] Session detected: "
114
+ f"type={session.session_type}, desktop={session.desktop}, wm={session.wm}"
115
+ )
116
+ if backend == "portal":
117
+ print("[FluxCast] Capture backend: XDG portal (KDE/GNOME Wayland)")
118
+ elif backend == "wf-recorder" and session.is_wayland and not session.is_hyprland:
119
+ print(
120
+ "[FluxCast] Capture backend: wf-recorder (best-effort on this Wayland session). "
121
+ "If capture fails, use an X11 session or install portal stack for KDE/GNOME."
122
+ )
123
+ elif backend == "x11grab" and session.is_wayland:
124
+ print(
125
+ "[FluxCast] Capture backend: x11grab (forced). "
126
+ "On Wayland this may produce a black stream."
127
+ )
128
+ else:
129
+ print(f"[FluxCast] Capture backend: {backend}")
130
+
131
+
132
+ def _detect_live_displays() -> list:
133
+ locks = glob.glob("/tmp/.X*-lock")
134
+ displays = []
135
+ for lock in sorted(locks):
136
+ m = re.search(r"/\.X(\d+)-lock$", lock)
137
+ if m:
138
+ displays.append(f":{m.group(1)}")
139
+ return displays or [":0"]
140
+
141
+
142
+ def _parse_xrandr(display: str) -> list:
143
+ xrandr = shutil.which("xrandr")
144
+ if not xrandr:
145
+ return []
146
+
147
+ env = os.environ.copy()
148
+ env["DISPLAY"] = display
149
+ try:
150
+ result = subprocess.run(
151
+ [xrandr, "--query"],
152
+ capture_output=True, text=True, timeout=4, env=env,
153
+ )
154
+ except (subprocess.TimeoutExpired, FileNotFoundError):
155
+ return []
156
+
157
+ if result.returncode != 0:
158
+ return []
159
+
160
+ monitors: list = []
161
+ lines = result.stdout.splitlines()
162
+ for i, line in enumerate(lines):
163
+ m = re.match(
164
+ r"^(\S+)\s+connected\s+(?:primary\s+)?(\d+)x(\d+)\+(\d+)\+(\d+)",
165
+ line,
166
+ )
167
+ if not m:
168
+ continue
169
+ name, w, h, x, y = m.group(1), int(m.group(2)), int(m.group(3)), int(m.group(4)), int(m.group(5))
170
+ refresh = 0.0
171
+ if i + 1 < len(lines):
172
+ rm = re.search(r"([\d.]+)\*", lines[i + 1])
173
+ if rm:
174
+ refresh = float(rm.group(1))
175
+ monitors.append(Monitor(
176
+ display=display, name=name,
177
+ width=w, height=h, x=x, y=y, refresh=refresh,
178
+ ))
179
+ return monitors
180
+
181
+
182
+ def gather_monitors() -> list:
183
+ all_monitors: list = []
184
+ for disp in _detect_live_displays():
185
+ all_monitors.extend(_parse_xrandr(disp))
186
+ return all_monitors
187
+
188
+
189
+ def prompt_monitor() -> Monitor:
190
+ monitors = gather_monitors()
191
+
192
+ if not monitors:
193
+ print("[FluxCast] WARNING: xrandr returned no monitors. Falling back to :0 1920x1080.")
194
+ return Monitor(display=":0", name="(unknown)", width=1920, height=1080,
195
+ x=0, y=0, refresh=60.0)
196
+
197
+ current_display = os.environ.get("DISPLAY", "")
198
+
199
+ print("\n[FluxCast] Available monitors to capture:")
200
+ print(f" {'#':<4} {'Monitor':<12} {'Display':<8} "
201
+ f"{'Resolution':<14} {'Position':<14} {'Refresh'}")
202
+ print(f" {'-'*4} {'-'*12} {'-'*8} {'-'*14} {'-'*14} {'-'*8}")
203
+
204
+ default_idx = 0
205
+ for i, mon in enumerate(monitors):
206
+ pos = f"{mon.x},{mon.y}"
207
+ hz = f"{mon.refresh:.1f} Hz" if mon.refresh else "—"
208
+ current = " ← active" if mon.display == current_display and i == 0 else ""
209
+ print(f" [{i}] {mon.name:<12} {mon.display:<8} "
210
+ f"{mon.width}x{mon.height:<8} {pos:<14} {hz}{current}")
211
+ if mon.display == current_display:
212
+ default_idx = i
213
+
214
+ print()
215
+ raw = input(f"Select monitor [{default_idx}]: ").strip()
216
+ if raw == "":
217
+ return monitors[default_idx]
218
+ try:
219
+ idx = int(raw)
220
+ return monitors[idx]
221
+ except (ValueError, IndexError):
222
+ print(f"[FluxCast] Invalid choice, using monitor {default_idx}.")
223
+ return monitors[default_idx]
224
+
225
+
226
+ def start_capture(
227
+ monitor: Monitor,
228
+ fps: int = 30,
229
+ bitrate: str = "4M",
230
+ output_resolution: Optional[str] = None,
231
+ backend: str = "auto",
232
+ ) -> "CaptureResult":
233
+ backends = [backend] if backend != "auto" else _auto_capture_backend_order()
234
+ errors: list[str] = []
235
+
236
+ for idx, candidate in enumerate(backends):
237
+ describe_capture_selection(candidate)
238
+ try:
239
+ if candidate == "portal":
240
+ return _start_capture_portal(monitor, fps, bitrate, output_resolution)
241
+ if candidate == "x11grab":
242
+ proc = _start_capture_x11grab(monitor, fps, bitrate, output_resolution)
243
+ else:
244
+ proc = _start_capture_wf_recorder(monitor, fps, bitrate, output_resolution)
245
+ return CaptureResult(process=proc)
246
+ except CaptureStartError as exc:
247
+ errors.append(f"{candidate}: {exc}")
248
+ if idx < len(backends) - 1:
249
+ print(f"[FluxCast] Capture backend {candidate} failed, trying fallback...")
250
+
251
+ detail = "; ".join(errors) if errors else "unknown capture error"
252
+ print(f"[FluxCast] ERROR: Could not start capture backend ({detail})")
253
+ sys.exit(1)
254
+
255
+
256
+ def _start_capture_wf_recorder(
257
+ monitor: Monitor,
258
+ fps: int = 30,
259
+ bitrate: str = "4M",
260
+ output_resolution: Optional[str] = None,
261
+ ) -> "subprocess.Popen[bytes]":
262
+ if not shutil.which("wf-recorder"):
263
+ raise CaptureStartError("wf-recorder not found in PATH")
264
+ src_res = f"{monitor.width}x{monitor.height}"
265
+
266
+ if output_resolution:
267
+ ow, oh = output_resolution.lower().split("x")
268
+ out_w, out_h = int(ow), int(oh)
269
+ else:
270
+ out_w, out_h = 1920, 1080
271
+
272
+ audio_monitor = _default_audio_monitor()
273
+
274
+ cmd = [
275
+ "wf-recorder",
276
+ "-y",
277
+ "-D",
278
+ "-r", str(fps),
279
+ f"--audio={audio_monitor}",
280
+ "-C", "aac",
281
+ "-P", "b:a=128k",
282
+ "-c", "libx264",
283
+ "-m", "hls",
284
+ # scale to target res and force SAR=1:1 so the TV sees 16:9, not 16:10
285
+ "-F", f"scale={out_w}:{out_h},setsar=1",
286
+ "-p", "preset=ultrafast",
287
+ "-p", "tune=zerolatency",
288
+ "-p", "hls_time=1",
289
+ "-p", "hls_list_size=3",
290
+ "-p", "hls_flags=append_list",
291
+ "-p", "pix_fmt=yuv420p",
292
+ "-p", "profile=main",
293
+ "-f", "/tmp/fluxcast/stream.m3u8",
294
+ "-o", monitor.name,
295
+ ]
296
+
297
+ print(f"[FluxCast] Capturing Wayland monitor : {monitor.name} ({src_res})")
298
+ print(f"[FluxCast] Output: {out_w}x{out_h}, audio: {audio_monitor}")
299
+
300
+ hls_dir = "/tmp/fluxcast"
301
+ if os.path.exists(hls_dir):
302
+ shutil.rmtree(hls_dir)
303
+ os.makedirs(hls_dir, exist_ok=True)
304
+
305
+ try:
306
+ process = subprocess.Popen(
307
+ cmd,
308
+ stdout=subprocess.DEVNULL,
309
+ stderr=subprocess.DEVNULL,
310
+ )
311
+ except FileNotFoundError as e:
312
+ raise CaptureStartError(f"required executable not found: {e}") from e
313
+
314
+ time.sleep(1.5)
315
+ if process.poll() is not None:
316
+ raise CaptureStartError("wf-recorder exited immediately")
317
+
318
+ return process
319
+
320
+
321
+ def stop_capture(result: "Optional[CaptureResult]") -> None:
322
+ if result is None:
323
+ return
324
+ for proc in (result.process, result.aux_process):
325
+ if proc is None or proc.poll() is not None:
326
+ continue
327
+ proc.terminate()
328
+ try:
329
+ proc.wait(timeout=5)
330
+ except subprocess.TimeoutExpired:
331
+ proc.kill()
332
+ if result.portal_session is not None:
333
+ from portal_capture import close_portal_capture
334
+ close_portal_capture(result.portal_session)
335
+
336
+
337
+ def _start_capture_portal(
338
+ monitor: Monitor,
339
+ fps: int = 30,
340
+ bitrate: str = "4M",
341
+ output_resolution: Optional[str] = None,
342
+ ) -> "CaptureResult":
343
+ if not shutil.which("gst-launch-1.0"):
344
+ raise CaptureStartError("portal backend requires gst-launch-1.0")
345
+
346
+ required = ("pipewiresrc", "videoconvert", "videoscale", "videorate",
347
+ "x264enc", "h264parse", "hlssink2")
348
+ missing = [e for e in required if not _gst_has_element(e)]
349
+ if missing:
350
+ raise CaptureStartError(
351
+ "portal backend requires missing GStreamer elements: " + ", ".join(missing)
352
+ )
353
+
354
+ from portal_capture import (
355
+ PortalCaptureError,
356
+ close_portal_capture,
357
+ start_portal_capture,
358
+ )
359
+
360
+ b = bitrate.upper().rstrip("B")
361
+ if b.endswith("M"):
362
+ bitrate_kbits = int(float(b[:-1]) * 1000)
363
+ elif b.endswith("K"):
364
+ bitrate_kbits = int(float(b[:-1]))
365
+ else:
366
+ bitrate_kbits = max(1, int(b) // 1000)
367
+
368
+ out_w, out_h = monitor.width, monitor.height
369
+ if output_resolution:
370
+ try:
371
+ ow, oh = output_resolution.lower().split("x")
372
+ out_w, out_h = int(ow), int(oh)
373
+ except (ValueError, AttributeError):
374
+ pass
375
+
376
+ hls_dir = "/tmp/fluxcast"
377
+ if os.path.exists(hls_dir):
378
+ shutil.rmtree(hls_dir)
379
+ os.makedirs(hls_dir, exist_ok=True)
380
+
381
+ audio_monitor = _default_audio_monitor()
382
+ audio_enc = (
383
+ "avenc_aac" if _gst_has_element("avenc_aac") else
384
+ "faac" if _gst_has_element("faac") else None
385
+ )
386
+ has_audio = audio_enc is not None and _gst_has_element("pulsesrc")
387
+
388
+ print("[FluxCast] Opening portal screen-share dialog (KDE/GNOME Wayland)...")
389
+ try:
390
+ session = start_portal_capture(
391
+ timeout=120.0,
392
+ preferred_position=(monitor.x, monitor.y),
393
+ preferred_size=(monitor.width, monitor.height),
394
+ )
395
+ except PortalCaptureError as exc:
396
+ raise CaptureStartError(f"portal capture setup failed: {exc}") from exc
397
+
398
+ if session.size is not None and not output_resolution:
399
+ out_w, out_h = session.size[0], session.size[1]
400
+
401
+ # Normalize to 16:9
402
+ if not output_resolution:
403
+ out_w, out_h = 1920, 1080
404
+
405
+ video_chain = [
406
+ "pipewiresrc",
407
+ f"fd={session.pw_fd}",
408
+ f"path={session.pw_node_id}",
409
+ "do-timestamp=true",
410
+ # always-copy=true!! immediately copy PipeWire buffer to CPU memory so
411
+ # x264enc doesn't stall waiting for DMA-buf download mid-pipeline
412
+ "always-copy=true",
413
+ "!", "queue", "max-size-buffers=8", "max-size-time=0", "leaky=downstream",
414
+ "!", "videorate", "skip-to-first=true",
415
+ "!", f"video/x-raw,framerate={fps}/1",
416
+ "!", "videoconvert",
417
+ "!", "videoscale", "add-borders=false",
418
+ "!", f"video/x-raw,width={out_w},height={out_h},format=I420,pixel-aspect-ratio=1/1",
419
+ "!", "x264enc",
420
+ "tune=zerolatency",
421
+ "speed-preset=ultrafast",
422
+ f"bitrate={bitrate_kbits}",
423
+ f"key-int-max={fps}",
424
+ "threads=0",
425
+ "bframes=0",
426
+ "byte-stream=true",
427
+ "aud=true",
428
+ "!", "h264parse", "config-interval=-1",
429
+ "!", "video/x-h264,stream-format=byte-stream,alignment=au,profile=baseline",
430
+ "!", "sink.video",
431
+ ]
432
+
433
+ audio_chain: list[str] = []
434
+ if has_audio:
435
+ audio_chain = [
436
+ "pulsesrc", f"device={audio_monitor}", "do-timestamp=true",
437
+ "!", "audioconvert",
438
+ "!", "audioresample",
439
+ "!", "audio/x-raw,rate=48000,channels=2",
440
+ "!", audio_enc, "bitrate=128000",
441
+ "!", "aacparse",
442
+ "!", "sink.audio",
443
+ ]
444
+
445
+ gst_cmd = [
446
+ "gst-launch-1.0", "-e", "-q",
447
+ "hlssink2", "name=sink",
448
+ f"location={hls_dir}/stream%05d.ts",
449
+ f"playlist-location={hls_dir}/stream.m3u8",
450
+ "target-duration=1",
451
+ "playlist-length=3",
452
+ "max-files=4",
453
+ *video_chain,
454
+ *audio_chain,
455
+ ]
456
+
457
+ print(f"[FluxCast] Portal capture: node={session.pw_node_id}, output={out_w}x{out_h}@{fps}")
458
+ if session.position and session.size:
459
+ print(
460
+ f"[FluxCast] Portal source: "
461
+ f"pos={session.position[0]},{session.position[1]} "
462
+ f"size={session.size[0]}x{session.size[1]}"
463
+ )
464
+ if has_audio:
465
+ print(f"[FluxCast] Capturing audio: {audio_monitor}")
466
+
467
+ try:
468
+ gst_proc = subprocess.Popen(
469
+ gst_cmd,
470
+ stdout=subprocess.DEVNULL,
471
+ stderr=subprocess.DEVNULL,
472
+ pass_fds=(session.pw_fd,),
473
+ )
474
+ except FileNotFoundError as exc:
475
+ close_portal_capture(session)
476
+ raise CaptureStartError(f"gst-launch-1.0 not found: {exc}") from exc
477
+
478
+ time.sleep(2.5)
479
+ if gst_proc.poll() is not None:
480
+ close_portal_capture(session)
481
+ raise CaptureStartError("portal GStreamer HLS pipeline exited immediately")
482
+
483
+ return CaptureResult(process=gst_proc, portal_session=session)
484
+
485
+
486
+ def _start_capture_x11grab(
487
+ monitor: Monitor,
488
+ fps: int = 30,
489
+ bitrate: str = "4M",
490
+ output_resolution: Optional[str] = None,
491
+ ) -> "subprocess.Popen[bytes]":
492
+ ffmpeg = shutil.which("ffmpeg")
493
+ if not ffmpeg:
494
+ raise CaptureStartError("ffmpeg is required for x11grab backend")
495
+ if not os.environ.get("DISPLAY"):
496
+ raise CaptureStartError("DISPLAY is not set for x11grab")
497
+
498
+ display = os.environ.get("DISPLAY", monitor.display or ":0")
499
+ src_res = f"{monitor.width}x{monitor.height}"
500
+ out_res = output_resolution or src_res
501
+ audio_monitor = _default_audio_monitor()
502
+ hls_dir = "/tmp/fluxcast"
503
+
504
+ if os.path.exists(hls_dir):
505
+ shutil.rmtree(hls_dir)
506
+ os.makedirs(hls_dir, exist_ok=True)
507
+
508
+ cmd = [
509
+ ffmpeg,
510
+ "-hide_banner",
511
+ "-loglevel", "warning",
512
+ "-y",
513
+ "-thread_queue_size", "1024",
514
+ "-f", "x11grab",
515
+ "-framerate", str(fps),
516
+ "-video_size", src_res,
517
+ "-i", f"{display}+{monitor.x},{monitor.y}",
518
+ "-thread_queue_size", "1024",
519
+ "-f", "pulse",
520
+ "-i", audio_monitor,
521
+ "-map", "0:v:0",
522
+ "-map", "1:a:0",
523
+ ]
524
+
525
+ if out_res != src_res:
526
+ cmd += ["-vf", f"scale={out_res.replace('x', ':')}:flags=bilinear,format=yuv420p"]
527
+ else:
528
+ cmd += ["-vf", "format=yuv420p"]
529
+
530
+ cmd += [
531
+ "-c:v", "libx264",
532
+ "-preset", "veryfast",
533
+ "-tune", "zerolatency",
534
+ "-pix_fmt", "yuv420p",
535
+ "-r", str(fps),
536
+ "-g", str(max(1, fps)),
537
+ "-b:v", bitrate,
538
+ "-maxrate", bitrate,
539
+ "-bufsize", "2M",
540
+ "-c:a", "aac",
541
+ "-b:a", "128k",
542
+ "-ac", "2",
543
+ "-ar", "48000",
544
+ "-f", "hls",
545
+ "-hls_time", "2",
546
+ "-hls_list_size", "6",
547
+ "-hls_flags", "append_list",
548
+ "-hls_segment_filename", os.path.join(hls_dir, "stream%d.ts"),
549
+ os.path.join(hls_dir, "stream.m3u8"),
550
+ ]
551
+
552
+ print(f"[FluxCast] Capturing X11 region : {display}+{monitor.x},{monitor.y} ({src_res})")
553
+ if out_res != src_res:
554
+ print(f"[FluxCast] Scaling output to : {out_res}")
555
+
556
+ try:
557
+ process = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
558
+ except FileNotFoundError as exc:
559
+ raise CaptureStartError(f"required executable not found: {exc}") from exc
560
+
561
+ time.sleep(1.5)
562
+ if process.poll() is not None:
563
+ raise CaptureStartError("x11grab capture exited immediately")
564
+ return process
cast.py ADDED
@@ -0,0 +1,93 @@
1
+ import sys
2
+ import time
3
+ from typing import Optional
4
+
5
+ try:
6
+ import pychromecast
7
+ except ImportError:
8
+ print("[FluxCast] ERROR: pychromecast is not installed. "
9
+ "Run: pip install pychromecast")
10
+ sys.exit(1)
11
+
12
+
13
+ def discover_devices(timeout: int = 10) -> list:
14
+ print(f"[FluxCast] Searching for Cast devices (timeout={timeout}s)…")
15
+ chromecasts, browser = pychromecast.get_chromecasts(timeout=timeout)
16
+ pychromecast.discovery.stop_discovery(browser)
17
+ return chromecasts
18
+
19
+
20
+ def connect_by_ip(ip: str, port: int = 8009):
21
+ print(f"[FluxCast] Connecting directly to Cast device at {ip}:{port}…")
22
+ try:
23
+ known = [f"{ip}:{port}"] if port != 8009 else [ip]
24
+ chromecasts, browser = pychromecast.get_listed_chromecasts(known_hosts=known)
25
+ pychromecast.discovery.stop_discovery(browser)
26
+ except Exception as exc:
27
+ print(f"[FluxCast] ERROR: Discovery failed — {exc}")
28
+ sys.exit(1)
29
+
30
+ if not chromecasts:
31
+ print(f"[FluxCast] ERROR: No Cast device responded at {ip}:{port}.")
32
+ print("[FluxCast] Make sure the TV is ON, on the same network, "
33
+ "and Cast is enabled in Settings → General → External Device Manager.")
34
+ sys.exit(1)
35
+
36
+ cast = chromecasts[0]
37
+ cast.wait(timeout=10)
38
+ print(f"[FluxCast] Connected: {cast.cast_info.friendly_name}")
39
+ return cast
40
+
41
+
42
+ def prompt_device(devices: list, device_name: Optional[str] = None):
43
+ if not devices:
44
+ print("[FluxCast] ERROR: No Cast devices found on the network.")
45
+ print("[FluxCast] TIP: Use --tv-ip <IP> to connect directly, e.g.:")
46
+ print("[FluxCast] python main.py --protocol cast --tv-ip 192.168.100.XXX")
47
+ sys.exit(1)
48
+
49
+ if device_name is not None:
50
+ needle = device_name.lower()
51
+ for cc in devices:
52
+ if cc.cast_info.friendly_name.lower() == needle:
53
+ return cc
54
+ print(f"[FluxCast] WARNING: Device '{device_name}' not found; falling back to picker.")
55
+
56
+ print("\n[FluxCast] Found Cast device(s):")
57
+ for i, cc in enumerate(devices):
58
+ name = cc.cast_info.friendly_name
59
+ model = cc.cast_info.model_name
60
+ host = cc.cast_info.host
61
+ print(f" [{i}] {name} ({model}) — {host}")
62
+
63
+ default_idx = 0
64
+ for i, cc in enumerate(devices):
65
+ if "samsung" in cc.cast_info.model_name.lower():
66
+ default_idx = i
67
+ break
68
+
69
+ raw = input(f"Select device [{default_idx}]: ").strip()
70
+ try:
71
+ idx = int(raw) if raw else default_idx
72
+ return devices[idx]
73
+ except (ValueError, IndexError):
74
+ print(f"[FluxCast] Invalid choice, using device {default_idx}.")
75
+ return devices[default_idx]
76
+
77
+
78
+ def start_cast(device, stream_url: str) -> None:
79
+ device.wait()
80
+ mc = device.media_controller
81
+ mc.play_media(stream_url, "video/mp2t")
82
+ mc.block_until_active(timeout=15)
83
+ print(f"[FluxCast] Cast started → {device.cast_info.friendly_name}")
84
+
85
+
86
+ def stop_cast(device: Optional[object]) -> None:
87
+ if device is None:
88
+ return
89
+ try:
90
+ device.media_controller.stop()
91
+ device.disconnect(timeout=5)
92
+ except Exception:
93
+ pass