Initial import

This commit is contained in:
Matteo Benedetto
2026-03-21 15:55:32 +01:00
commit 1d89c7fdc7
59 changed files with 3752 additions and 0 deletions
+173
View File
@@ -0,0 +1,173 @@
"""Application lifecycle – wires together UI, DLNA networking and playback."""
from __future__ import annotations
import asyncio
import logging
import queue
import threading
from typing import Any
from r36s_dlna_browser.dlna.browser_state import BrowserState
from r36s_dlna_browser.dlna.discovery import DLNADiscovery
from r36s_dlna_browser.dlna.client import DLNAClient
from r36s_dlna_browser.player.gstreamer_backend import GStreamerBackend
from r36s_dlna_browser.platform.runtime import configure_logging, sdl_env_setup
from r36s_dlna_browser.ui.sdl_app import SDLApp
log = logging.getLogger(__name__)
class Application:
"""Top-level application that owns the event loop, UI and networking."""
def __init__(self) -> None:
configure_logging()
sdl_env_setup()
self.ui_queue: queue.Queue[Any] = queue.Queue()
self.cmd_queue: queue.Queue[Any] = queue.Queue()
self.browser_state = BrowserState()
self.discovery = DLNADiscovery()
self.client = DLNAClient()
self.player = GStreamerBackend()
self.player.set_event_callback(self._handle_player_event)
self._async_loop: asyncio.AbstractEventLoop | None = None
self._net_thread: threading.Thread | None = None
self._shutting_down = False
# ── public API ───────────────────────────────────────────────
def run(self) -> None:
"""Start the networking thread and enter the SDL2 main loop."""
self._start_network_thread()
self.browser_state.loading = True
self._schedule(("discover",))
sdl = SDLApp(
ui_queue=self.ui_queue,
cmd_queue=self.cmd_queue,
browser_state=self.browser_state,
player=self.player,
)
sdl.run() # blocks until quit
def shutdown(self) -> None:
self._shutting_down = True
self._schedule(("shutdown",))
if self._net_thread and self._net_thread.is_alive():
self._net_thread.join(timeout=2)
self.player.shutdown()
log.info("Application shut down.")
# ── networking thread ────────────────────────────────────────
def _start_network_thread(self) -> None:
self._net_thread = threading.Thread(
target=self._run_async_loop, daemon=True, name="dlna-net"
)
self._net_thread.start()
def _run_async_loop(self) -> None:
self._async_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._async_loop)
try:
self._async_loop.run_until_complete(self._command_dispatcher())
finally:
pending = [
task
for task in asyncio.all_tasks(self._async_loop)
if not task.done()
]
for task in pending:
task.cancel()
if pending:
self._async_loop.run_until_complete(
asyncio.gather(*pending, return_exceptions=True)
)
self._async_loop.close()
async def _command_dispatcher(self) -> None:
"""Poll cmd_queue for commands from the UI thread and dispatch them."""
loop = asyncio.get_running_loop()
while not self._shutting_down:
try:
cmd = await loop.run_in_executor(None, self.cmd_queue.get, True, 0.1)
except queue.Empty:
continue
try:
await self._handle_command(cmd)
except Exception as exc:
log.exception("Error handling command %s", cmd)
self.browser_state.loading = False
self.ui_queue.put(("error", str(exc)))
async def _handle_command(self, cmd: tuple) -> None:
action = cmd[0]
if action == "discover":
servers = await self.discovery.find_servers()
self.browser_state.set_servers(servers)
self.browser_state.loading = False
self.ui_queue.put(("servers_updated",))
elif action == "browse":
server_location = cmd[1]
object_id = cmd[2]
items = await self.client.browse(server_location, object_id)
self.browser_state.set_items(items, object_id)
self.browser_state.loading = False
self.ui_queue.put(("items_updated",))
elif action == "play":
url = cmd[1]
title = cmd[2] if len(cmd) > 2 else ""
self.player.play(url)
self.browser_state.playback_paused = False
self.ui_queue.put(("playback_started", title))
elif action == "stop":
self.player.stop()
elif action == "toggle_pause":
paused = self.player.toggle_pause()
self.browser_state.playback_paused = paused
self.ui_queue.put(("playback_paused" if paused else "playback_resumed",))
elif action == "seek":
self.player.seek(cmd[1])
elif action == "volume":
volume = self.player.change_volume(cmd[1])
self.browser_state.playback_volume = volume
elif action == "shutdown":
self._shutting_down = True
self.player.shutdown()
# ── helpers ──────────────────────────────────────────────────
def _schedule(self, cmd: tuple) -> None:
self.cmd_queue.put(cmd)
def _handle_player_event(self, event: str, *args: Any) -> None:
if event == "stopped":
self.browser_state.playback_paused = False
self.ui_queue.put(("playback_stopped",))
elif event == "position" and args:
self.browser_state.playback_position = float(args[0])
elif event == "duration" and args:
self.browser_state.playback_duration = float(args[0])
elif event == "paused" and args:
self.browser_state.playback_paused = bool(args[0])
elif event == "volume" and args:
self.browser_state.playback_volume = int(args[0])
elif event == "buffering" and args:
self.browser_state.playback_buffer_percent = int(args[0])
elif event == "cache" and args:
self.browser_state.playback_cache_seconds = float(args[0])
elif event == "resolution" and args:
self.browser_state.playback_resolution = str(args[0])
elif event == "error" and args:
self.browser_state.playback_paused = False
self.ui_queue.put(("error", str(args[0])))