"""SDL2 window, event loop, rendering and input handling.""" from __future__ import annotations import ctypes import logging import os import queue import time from enum import Enum, auto from typing import Any import sdl2 try: import sdl2.sdlttf as ttf except ImportError: ttf = None # type: ignore[assignment] try: import sdl2.sdlimage as sdl_img except ImportError: sdl_img = None # type: ignore[assignment] from r36s_dlna_browser.dlna.browser_state import BrowserState from r36s_dlna_browser.dlna.models import ItemType, MediaItem from r36s_dlna_browser.player.backend import PlayerBackend from r36s_dlna_browser.platform.controls import Action, map_key from r36s_dlna_browser.ui import theme from r36s_dlna_browser.ui import screens log = logging.getLogger(__name__) class Screen(Enum): SERVERS = auto() BROWSE = auto() PLAYBACK = auto() ERROR = auto() class SDLApp: """Owns the SDL2 window and drives the main loop.""" def __init__( self, ui_queue: queue.Queue[Any], cmd_queue: queue.Queue[Any], browser_state: BrowserState, player: PlayerBackend, ) -> None: self._ui_q = ui_queue self._cmd_q = cmd_queue self._state = browser_state self._player = player self._screen = Screen.SERVERS self._running = False self._window = None self._renderer = None self._font = None self._playback_font = None self._icons: dict = {} self._layout = theme.DEFAULT_LAYOUT self._hud_cache: screens.HUDTextCache | None = None self._playback_clear_once = False self._needs_redraw = True self._last_playback_draw_at = 0.0 self._last_playback_snapshot: tuple[Any, ...] | None = None self._last_playback_interaction_at = 0.0 # ── lifecycle ──────────────────────────────────────────────── def run(self) -> None: self._init_sdl() self._running = True try: while self._running: self._poll_events() self._process_ui_queue() self._refresh_playback_hud_visibility() if self._should_draw(): self._draw() sdl2.SDL_Delay(theme.FRAME_DELAY_MS) finally: self._cleanup() def _init_sdl(self) -> None: sdl2.SDL_Init(sdl2.SDL_INIT_VIDEO | sdl2.SDL_INIT_GAMECONTROLLER) if ttf: ttf.TTF_Init() window_w, window_h = self._preferred_window_size() self._layout = theme.get_layout(window_w, window_h) self._window = sdl2.SDL_CreateWindow( b"R36S DLNA Browser", sdl2.SDL_WINDOWPOS_CENTERED, sdl2.SDL_WINDOWPOS_CENTERED, window_w, window_h, sdl2.SDL_WINDOW_SHOWN, ) self._renderer = sdl2.SDL_CreateRenderer( self._window, -1, sdl2.SDL_RENDERER_ACCELERATED | sdl2.SDL_RENDERER_PRESENTVSYNC, ) # Open first available gamecontroller for i in range(sdl2.SDL_NumJoysticks()): if sdl2.SDL_IsGameController(i): sdl2.SDL_GameControllerOpen(i) break self._reload_fonts() if not self._font: log.error("Could not load any font – UI text will be missing.") self._hud_cache = screens.HUDTextCache() self._icons = self._load_icons() self._player.attach_window(self._window) self._sync_player_viewport() def _load_icons(self) -> dict: icons: dict = {} if not sdl_img: return icons sdl_img.IMG_Init(sdl_img.IMG_INIT_PNG) mapping = [ ("folder", ItemType.CONTAINER), ("audio", ItemType.AUDIO), ("video", ItemType.VIDEO), ("image", ItemType.IMAGE), ("playing", "playing"), ("server", "server"), ("hud-play", "hud-play"), ("hud-pause", "hud-pause"), ("hud-stop", "hud-stop"), ("hud-seek", "hud-seek"), ("hud-volume", "hud-volume"), ("hud-display", "hud-display"), ] for fname, key in mapping: path = theme.ICONS_DIR / f"{fname}.png" if path.exists(): surf = sdl_img.IMG_Load(str(path).encode()) if surf: tex = sdl2.SDL_CreateTextureFromSurface(self._renderer, surf) sdl2.SDL_FreeSurface(surf) if tex: icons[key] = tex log.debug("Loaded icon: %s", fname) log.info("Loaded %d icon(s).", len(icons)) return icons def _load_font(self, size: int): if not ttf: log.warning("SDL2_ttf not available – text rendering disabled.") return None for path in theme.FONT_SEARCH_PATHS: expanded = os.path.expanduser(path) if os.path.isfile(expanded): f = ttf.TTF_OpenFont(expanded.encode(), size) if f: log.info("Loaded font: %s", expanded) return f return None def _reload_fonts(self) -> None: if self._font and ttf: ttf.TTF_CloseFont(self._font) if self._playback_font and ttf: ttf.TTF_CloseFont(self._playback_font) self._font = self._load_font(self._layout.font_size) self._playback_font = self._load_font(self._layout.playback_font_size) if self._hud_cache is not None: self._hud_cache.invalidate() def _preferred_window_size(self) -> tuple[int, int]: mode = sdl2.SDL_DisplayMode() if sdl2.SDL_GetCurrentDisplayMode(0, ctypes.byref(mode)) == 0: width = int(mode.w) height = int(mode.h) if 320 <= width <= 960 and 320 <= height <= 960: return width, height return theme.SCREEN_W, theme.SCREEN_H def _update_layout(self, width: int, height: int) -> None: prev_font_size = self._layout.font_size prev_playback_font_size = self._layout.playback_font_size self._layout = theme.get_layout(width, height) if ( self._layout.font_size != prev_font_size or self._layout.playback_font_size != prev_playback_font_size ): self._reload_fonts() def _cleanup(self) -> None: if self._hud_cache is not None: self._hud_cache.invalidate() self._hud_cache = None for tex in self._icons.values(): sdl2.SDL_DestroyTexture(tex) self._icons = {} if sdl_img: sdl_img.IMG_Quit() if self._font and ttf: ttf.TTF_CloseFont(self._font) if self._playback_font and ttf: ttf.TTF_CloseFont(self._playback_font) if self._renderer: sdl2.SDL_DestroyRenderer(self._renderer) if self._window: sdl2.SDL_DestroyWindow(self._window) if ttf: ttf.TTF_Quit() sdl2.SDL_Quit() # ── events ─────────────────────────────────────────────────── def _poll_events(self) -> None: event = sdl2.SDL_Event() while sdl2.SDL_PollEvent(ctypes.byref(event)): if event.type == sdl2.SDL_QUIT: self._running = False return if event.type == sdl2.SDL_WINDOWEVENT and event.window.event in ( sdl2.SDL_WINDOWEVENT_MOVED, sdl2.SDL_WINDOWEVENT_SIZE_CHANGED, sdl2.SDL_WINDOWEVENT_RESIZED, ): if self._window and event.window.windowID == sdl2.SDL_GetWindowID(self._window): self._sync_player_viewport() self._mark_dirty() action = map_key(event) if action is not None: self._handle_action(action) def _process_ui_queue(self) -> None: while True: try: msg = self._ui_q.get_nowait() except queue.Empty: break self._handle_ui_message(msg) def _handle_ui_message(self, msg: tuple) -> None: kind = msg[0] if kind == "servers_updated": self._state.loading = False self._screen = Screen.SERVERS elif kind == "items_updated": self._state.loading = False self._screen = Screen.BROWSE elif kind == "playback_started": self._state.playback_title = msg[1] if len(msg) > 1 else "" self._state.playback_paused = False self._show_playback_hud(force=True) self._playback_clear_once = True self._screen = Screen.PLAYBACK elif kind == "playback_paused": self._state.playback_paused = True self._show_playback_hud(force=True) elif kind == "playback_resumed": self._state.playback_paused = False self._show_playback_hud(force=True) elif kind == "playback_stopped": self._state.playback_paused = False self._screen = Screen.BROWSE if self._state.in_browse_mode else Screen.SERVERS elif kind == "error": self._state.error = msg[1] if len(msg) > 1 else "Unknown error" self._state.loading = False self._screen = Screen.ERROR self._mark_dirty() # ── input dispatch ─────────────────────────────────────────── def _handle_action(self, action: Action) -> None: if action == Action.QUIT: self._running = False return if self._screen == Screen.ERROR: if action in (Action.CONFIRM, Action.BACK): self._state.error = "" self._screen = Screen.BROWSE if self._state.in_browse_mode else Screen.SERVERS self._mark_dirty() return if self._screen == Screen.PLAYBACK: if action == Action.BACK: self._cmd_q.put(("stop",)) elif action == Action.CONFIRM: self._cmd_q.put(("toggle_pause",)) elif action == Action.UP: self._cmd_q.put(("volume", 5)) elif action == Action.DOWN: self._cmd_q.put(("volume", -5)) elif action == Action.PAGE_UP: self._cmd_q.put(("seek", -10)) elif action == Action.PAGE_DOWN: self._cmd_q.put(("seek", 10)) elif action == Action.HUD_MODE: self._cycle_playback_hud_mode() self._mark_dirty() self._show_playback_hud(force=action != Action.HUD_MODE) return if self._screen == Screen.SERVERS: self._handle_server_action(action) elif self._screen == Screen.BROWSE: self._handle_browse_action(action) def _handle_server_action(self, action: Action) -> None: n = len(self._state.servers) if action == Action.UP and n: self._state.server_cursor = max(0, self._state.server_cursor - 1) self._adjust_server_scroll() self._mark_dirty() elif action == Action.DOWN and n: self._state.server_cursor = min(n - 1, self._state.server_cursor + 1) self._adjust_server_scroll() self._mark_dirty() elif action == Action.CONFIRM: srv = self._state.selected_server if srv: obj_id = self._state.enter_server(srv) self._state.loading = True self._cmd_q.put(("browse", srv.location, obj_id)) else: # No server selected – refresh self._state.loading = True self._cmd_q.put(("discover",)) self._mark_dirty() elif action == Action.BACK: # Refresh server list self._state.loading = True self._cmd_q.put(("discover",)) self._mark_dirty() def _handle_browse_action(self, action: Action) -> None: items = self._state.current_items n = len(items) if action == Action.UP and n: self._state.cursor -= 1 self._adjust_browse_scroll() self._mark_dirty() elif action == Action.DOWN and n: self._state.cursor += 1 self._adjust_browse_scroll() self._mark_dirty() elif action == Action.PAGE_UP and n: self._state.cursor = max(0, self._state.cursor - self._layout.page_size) self._adjust_browse_scroll() self._mark_dirty() elif action == Action.PAGE_DOWN and n: self._state.cursor = min(n - 1, self._state.cursor + self._layout.page_size) self._adjust_browse_scroll() self._mark_dirty() elif action == Action.CONFIRM: item = self._state.selected_item() if item: if item.is_container: self._state.loading = True lv = self._state.current_level loc = lv.server_location if lv else "" self._cmd_q.put(("browse", loc, item.object_id)) elif item.resource_url: self._cmd_q.put(("play", item.resource_url, item.title)) self._mark_dirty() elif action == Action.BACK: if not self._state.go_back(): self._screen = Screen.SERVERS else: self._screen = Screen.BROWSE self._mark_dirty() def _adjust_server_scroll(self) -> None: vis = self._layout.visible_items cur = self._state.server_cursor off = self._state.server_scroll_offset if cur < off: self._state.server_scroll_offset = cur elif cur >= off + vis: self._state.server_scroll_offset = cur - vis + 1 def _adjust_browse_scroll(self) -> None: vis = self._layout.visible_items cur = self._state.cursor off = self._state.scroll_offset if cur < off: self._state.scroll_offset = cur elif cur >= off + vis: self._state.scroll_offset = cur - vis + 1 # ── drawing ────────────────────────────────────────────────── def _draw(self) -> None: r = self._renderer if self._screen == Screen.PLAYBACK: sdl2.SDL_SetRenderDrawColor(r, *theme.PLAYBACK_BG_COLOR) else: sdl2.SDL_SetRenderDrawColor(r, *theme.BG_COLOR) sdl2.SDL_RenderClear(r) self._playback_clear_once = False f = self._font if self._screen == Screen.SERVERS: screens.draw_server_list(r, f, self._state, self._layout, self._icons) elif self._screen == Screen.BROWSE: screens.draw_browse_list(r, f, self._state, self._layout, self._icons) elif self._screen == Screen.PLAYBACK: self._player.render(r) screens.draw_playback(r, self._playback_font or f, self._state, self._layout, self._icons, cache=self._hud_cache) elif self._screen == Screen.ERROR: screens.draw_error(r, f, self._state, self._layout) sdl2.SDL_RenderPresent(r) self._needs_redraw = False if self._screen == Screen.PLAYBACK: self._last_playback_draw_at = time.monotonic() self._last_playback_snapshot = self._playback_snapshot() def _mark_dirty(self) -> None: self._needs_redraw = True def _playback_snapshot(self) -> tuple[Any, ...]: return ( self._state.playback_title, self._state.playback_paused, round(self._state.playback_position, 1), round(self._state.playback_duration, 1), self._state.playback_volume, self._state.playback_buffer_percent, self._state.playback_resolution, self._state.playback_backend, self._state.playback_hud_visible, self._state.playback_hud_mode, ) def _refresh_playback_hud_visibility(self) -> None: if self._screen != Screen.PLAYBACK: return if self._state.playback_hud_mode == theme.PLAYBACK_HUD_HIDDEN: return if self._state.playback_hud_mode == theme.PLAYBACK_HUD_PINNED or self._state.playback_paused: if not self._state.playback_hud_visible: self._state.playback_hud_visible = True self._mark_dirty() return visible = (time.monotonic() - self._last_playback_interaction_at) < (theme.PLAYBACK_HUD_AUTOHIDE_MS / 1000.0) if visible != self._state.playback_hud_visible: self._state.playback_hud_visible = visible self._mark_dirty() def _show_playback_hud(self, force: bool = False) -> None: self._last_playback_interaction_at = time.monotonic() if force or self._state.playback_hud_mode != theme.PLAYBACK_HUD_HIDDEN: if not self._state.playback_hud_visible: self._state.playback_hud_visible = True self._mark_dirty() def _cycle_playback_hud_mode(self) -> None: next_mode = { theme.PLAYBACK_HUD_AUTO: theme.PLAYBACK_HUD_PINNED, theme.PLAYBACK_HUD_PINNED: theme.PLAYBACK_HUD_HIDDEN, theme.PLAYBACK_HUD_HIDDEN: theme.PLAYBACK_HUD_AUTO, } self._state.playback_hud_mode = next_mode[self._state.playback_hud_mode] self._state.playback_hud_visible = self._state.playback_hud_mode != theme.PLAYBACK_HUD_HIDDEN self._last_playback_interaction_at = time.monotonic() def _should_draw(self) -> bool: if self._needs_redraw: return True if self._screen != Screen.PLAYBACK: return False if self._player.has_new_frame(): return True now = time.monotonic() if now - self._last_playback_draw_at < (theme.PLAYBACK_HUD_REFRESH_MS / 1000.0): return False return self._playback_snapshot() != self._last_playback_snapshot def _sync_player_viewport(self) -> None: width = ctypes.c_int() height = ctypes.c_int() if self._window: sdl2.SDL_GetWindowSize(self._window, ctypes.byref(width), ctypes.byref(height)) if width.value <= 0 or height.value <= 0: width.value = theme.SCREEN_W height.value = theme.SCREEN_H self._update_layout(width.value, height.value) self._player.set_viewport( width.value, height.value, self._layout.playback_hud_top, height.value - self._layout.playback_video_bottom, self._layout.playback_video_left, max(0, width.value - self._layout.playback_video_right), )