SDL2/GStreamer DLNA browser for R36S by Matteo Benedetto
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

502 lines
19 KiB

"""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),
)