Files
R36SHack/src/r36s_dlna_browser/app.py
T
2026-03-21 15:55:32 +01:00

174 lines
6.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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])))