diff --git a/docs/development-status.md b/docs/development-status.md index ff61d74..de7e714 100644 --- a/docs/development-status.md +++ b/docs/development-status.md @@ -6,7 +6,7 @@ Milestone 3 — SDL Video Viewport, HUD, and Wayland Compatibility ## Current Architecture Decisions -- **Language / UI**: Python 3.9+ with PySDL2 (ctypes wrapper around system SDL2) +- **Language / UI**: Python 3.9+ with PySDL2 (ctypes wrapper around system SDL2), with UI layout metrics now computed from the runtime window/display size instead of being fixed to 640x480 - **DLNA Discovery**: Custom SSDP M-SEARCH implementation using asyncio datagrams + aiohttp for device description XML - **Content Browsing**: Direct SOAP/XML ContentDirectory client with DIDL-Lite parser (no dependency on async-upnp-client browsing at runtime — only aiohttp) - **Playback**: integrated GStreamer backend via `PyGObject` / `GstPlayBin`, decoding video into `GstAppSink` frames that are uploaded to SDL textures and rendered in the main SDL renderer @@ -25,7 +25,7 @@ Milestone 3 — SDL Video Viewport, HUD, and Wayland Compatibility - Phase 1: Project bootstrap (`pyproject.toml`, `requirements.txt`, `README.md`, package layout under `src/`) - Phase 2: DLNA discovery (`dlna/discovery.py` — SSDP M-SEARCH, friendly-name fetch) and browsing (`dlna/client.py` — SOAP Browse, DIDL-Lite parser with relative-URL resolution + `dlna/models.py` domain models + `dlna/browser_state.py` navigation stack/cache) -- Phase 3: SDL2 UI (`ui/sdl_app.py` — window, event loop, input dispatch; `ui/screens.py` — server list, browse list, playback, error screens; `ui/theme.py` — 640×480 layout constants) +- Phase 3: SDL2 UI (`ui/sdl_app.py` — window, event loop, input dispatch; `ui/screens.py` — server list, browse list, playback, error screens; `ui/theme.py` — runtime-scaled layout helpers for 640x480 and 720x720-class displays) - Phase 4: Playback (`player/backend.py` abstract interface + `player/gstreamer_backend.py` integrated GStreamer backend) - Phase 5: Device integration (`platform/controls.py` — keyboard + gamecontroller mapping; `platform/runtime.py` — logging, R36S heuristic, SDL env hints) - Phase 7: Tests — 75 tests across 7 test files all passing (DIDL mapping, SOAP/XML parser, navigation state, playback backend, SDL redraw policy, input controls, runtime environment setup) @@ -39,6 +39,7 @@ Milestone 3 — SDL Video Viewport, HUD, and Wayland Compatibility - Playback HUD expanded: progress bar, elapsed/duration, volume, buffer, resolution, and control legends are rendered around the video area and updated from GStreamer bus/pipeline queries - Playback-page flashing root cause addressed by removing native overlay composition entirely: video and HUD are now rendered together by SDL in one pass, with redraws driven by decoded frame availability and HUD state changes - Playback HUD simplified: the border around the video area was removed, playback control/status icons were added as bundled SVG+PNG assets, the title/timer top bar no longer overlaps, and playback now supports `auto / fixed / hidden` HUD modes through a dedicated command while staying visible when paused +- UI scaling hardened for mixed small-display targets: list rows, HUD bands, icon sizes, viewport margins, and font sizes are now derived from the actual SDL window/display size so the app remains readable on both 640x480 and 720x720 screens - Deployment assets added: `.gitignore`, `environment.yml`, and a real `LICENSE` file so the project can be initialized and published as a clean git repository - Conda environment refreshed for current playback needs: runtime now includes GStreamer codec/plugin packages plus explicit Python build/test tooling, while editable install keeps the package code sourced from the repo checkout - Packaging fix: `pyproject.toml` now uses a valid TOML `[project.urls]` table so editable installs work with modern `pip` / `tomllib` @@ -52,7 +53,7 @@ Milestone 3 — SDL Video Viewport, HUD, and Wayland Compatibility - Validate real video playback on the physical R36S after adding the missing H.264 and AAC decoder plugins to the device conda env - Device deployment on the physical R36S is now wired through ArkOS `Ports -> MatHacks`, with the heavy runtime under `/home/ark` and only a lightweight stub launcher under `/roms/ports` - Device env bootstrap on the physical R36S reaches a clean `from r36s_dlna_browser.app import Application` inside `/home/ark/miniconda3/envs/r36s-dlna-browser` -- ArkOS launcher asset added at `deploy/arkos/MatHacks.sh` with the verified `LD_LIBRARY_PATH`, `GST_PLUGIN_SCANNER`, and `GST_PLUGIN_SYSTEM_PATH_1_0` exports needed by the conda runtime +- ArkOS launcher asset added at `deploy/arkos/MatHacks.sh`; current launcher uses the `/home/ark/R36SHack` checkout plus verified `LD_LIBRARY_PATH`, `GST_PLUGIN_PATH`, and `LD_PRELOAD` exports needed to load the system `gstreamer1.0-libav` plugins from the conda runtime ## Blockers Or Open Questions diff --git a/src/r36s_dlna_browser/ui/screens.py b/src/r36s_dlna_browser/ui/screens.py index f791a0a..5f29d93 100644 --- a/src/r36s_dlna_browser/ui/screens.py +++ b/src/r36s_dlna_browser/ui/screens.py @@ -93,20 +93,11 @@ def _draw_rect(renderer, x: int, y: int, w: int, h: int, color: tuple) -> None: sdl2.SDL_RenderDrawRect(renderer, rect) -def _render_icon(renderer, texture, x: int, y: int) -> None: - """Blit a pre-loaded SDL texture as an ICON_SIZE square.""" +def _render_icon(renderer, texture, x: int, y: int, size: int) -> None: + """Blit a pre-loaded SDL texture as a square icon.""" if not texture: return - s = theme.ICON_SIZE - dst = sdl2.SDL_Rect(x, y, s, s) - sdl2.SDL_RenderCopy(renderer, texture, None, dst) - - -def _render_icon_small(renderer, texture, x: int, y: int) -> None: - if not texture: - return - s = theme.PLAYBACK_HUD_ICON_SIZE - dst = sdl2.SDL_Rect(x, y, s, s) + dst = sdl2.SDL_Rect(x, y, size, size) sdl2.SDL_RenderCopy(renderer, texture, None, dst) @@ -129,71 +120,71 @@ def _draw_progress_bar(renderer, x: int, y: int, w: int, h: int, progress: float # ── Public drawing functions ───────────────────────────────────── -def draw_header(renderer, font, title: str) -> None: - _fill_rect(renderer, 0, 0, theme.SCREEN_W, theme.HEADER_H, theme.HEADER_BG) - _render_text(renderer, font, title, theme.LIST_PAD_LEFT, 8, - theme.TEXT_COLOR, theme.SCREEN_W - theme.LIST_PAD_LEFT * 2) +def draw_header(renderer, font, title: str, layout: theme.Layout) -> None: + _fill_rect(renderer, 0, 0, layout.width, layout.header_h, theme.HEADER_BG) + _render_text(renderer, font, title, layout.list_pad_left, max(6, layout.header_h // 5), + theme.TEXT_COLOR, layout.width - layout.list_pad_left * 2) -def draw_status_bar(renderer, font, text: str) -> None: - y = theme.SCREEN_H - theme.STATUS_H - _fill_rect(renderer, 0, y, theme.SCREEN_W, theme.STATUS_H, theme.STATUS_BG) - _render_text(renderer, font, text, theme.LIST_PAD_LEFT, y + 6, - theme.DIM_TEXT, theme.SCREEN_W - theme.LIST_PAD_LEFT * 2) +def draw_status_bar(renderer, font, text: str, layout: theme.Layout) -> None: + y = layout.height - layout.status_h + _fill_rect(renderer, 0, y, layout.width, layout.status_h, theme.STATUS_BG) + _render_text(renderer, font, text, layout.list_pad_left, y + max(4, layout.status_h // 5), + theme.DIM_TEXT, layout.width - layout.list_pad_left * 2) -def draw_server_list(renderer, font, state: "BrowserState", icons: dict | None = None) -> None: - draw_header(renderer, font, "DLNA Servers") +def draw_server_list(renderer, font, state: "BrowserState", layout: theme.Layout, icons: dict | None = None) -> None: + draw_header(renderer, font, "DLNA Servers", layout) if not state.servers: msg = "Searching..." if state.loading else "No servers found. Press A to refresh." - _render_text(renderer, font, msg, theme.LIST_PAD_LEFT, theme.LIST_TOP + 20, theme.DIM_TEXT) - draw_status_bar(renderer, font, "A=Select Start=Quit") + _render_text(renderer, font, msg, layout.list_pad_left, layout.list_top + max(20, layout.list_item_h // 2), theme.DIM_TEXT) + draw_status_bar(renderer, font, "A=Select Start=Quit", layout) return srv_icon = icons.get("server") if icons else None - icon_gap = theme.ICON_SIZE + 4 if srv_icon else 0 - text_x = theme.LIST_PAD_LEFT + icon_gap + icon_gap = layout.icon_size + 4 if srv_icon else 0 + text_x = layout.list_pad_left + icon_gap - vis = theme.VISIBLE_ITEMS + vis = layout.visible_items offset = state.server_scroll_offset for i in range(vis): idx = offset + i if idx >= len(state.servers): break srv = state.servers[idx] - y = theme.LIST_TOP + i * theme.LIST_ITEM_H + y = layout.list_top + i * layout.list_item_h if idx == state.server_cursor: - _fill_rect(renderer, 0, y, theme.SCREEN_W, theme.LIST_ITEM_H, theme.HIGHLIGHT_BG) + _fill_rect(renderer, 0, y, layout.width, layout.list_item_h, theme.HIGHLIGHT_BG) color = theme.HIGHLIGHT_TEXT else: color = theme.TEXT_COLOR - icon_y = y + (theme.LIST_ITEM_H - theme.ICON_SIZE) // 2 - _render_icon(renderer, srv_icon, theme.LIST_PAD_LEFT, icon_y) - _render_text(renderer, font, str(srv), text_x, y + 4, - color, theme.SCREEN_W - text_x - theme.LIST_PAD_RIGHT) + icon_y = y + (layout.list_item_h - layout.icon_size) // 2 + _render_icon(renderer, srv_icon, layout.list_pad_left, icon_y, layout.icon_size) + _render_text(renderer, font, str(srv), text_x, y + max(3, layout.list_item_h // 7), + color, layout.width - text_x - layout.list_pad_right) - draw_status_bar(renderer, font, f"A=Browse B=Refresh Start=Quit ({len(state.servers)} servers)") + draw_status_bar(renderer, font, f"A=Browse B=Refresh Start=Quit ({len(state.servers)} servers)", layout) -def draw_browse_list(renderer, font, state: "BrowserState", icons: dict | None = None) -> None: +def draw_browse_list(renderer, font, state: "BrowserState", layout: theme.Layout, icons: dict | None = None) -> None: lv = state.current_level title = "Browsing" if lv: title = f"/{lv.object_id}" if lv.object_id != "0" else "/" - draw_header(renderer, font, title) + draw_header(renderer, font, title, layout) items = state.current_items if not items: msg = "Loading..." if state.loading else "Empty folder." - _render_text(renderer, font, msg, theme.LIST_PAD_LEFT, theme.LIST_TOP + 20, theme.DIM_TEXT) - draw_status_bar(renderer, font, "B=Back") + _render_text(renderer, font, msg, layout.list_pad_left, layout.list_top + max(20, layout.list_item_h // 2), theme.DIM_TEXT) + draw_status_bar(renderer, font, "B=Back", layout) return has_icons = bool(icons) - icon_gap = theme.ICON_SIZE + 4 if has_icons else 0 - text_x = theme.LIST_PAD_LEFT + icon_gap + icon_gap = layout.icon_size + 4 if has_icons else 0 + text_x = layout.list_pad_left + icon_gap - vis = theme.VISIBLE_ITEMS + vis = layout.visible_items offset = state.scroll_offset cursor = state.cursor for i in range(vis): @@ -201,35 +192,36 @@ def draw_browse_list(renderer, font, state: "BrowserState", icons: dict | None = if idx >= len(items): break item = items[idx] - y = theme.LIST_TOP + i * theme.LIST_ITEM_H + y = layout.list_top + i * layout.list_item_h if idx == cursor: - _fill_rect(renderer, 0, y, theme.SCREEN_W, theme.LIST_ITEM_H, theme.HIGHLIGHT_BG) + _fill_rect(renderer, 0, y, layout.width, layout.list_item_h, theme.HIGHLIGHT_BG) color = theme.HIGHLIGHT_TEXT else: color = theme.TEXT_COLOR - icon_y = y + (theme.LIST_ITEM_H - theme.ICON_SIZE) // 2 + icon_y = y + (layout.list_item_h - layout.icon_size) // 2 if has_icons: ico = icons.get(item.item_type) # type: ignore[call-overload] - _render_icon(renderer, ico, theme.LIST_PAD_LEFT, icon_y) - _render_text(renderer, font, item.title, text_x, y + 4, - color, theme.SCREEN_W - text_x - theme.LIST_PAD_RIGHT) + _render_icon(renderer, ico, layout.list_pad_left, icon_y, layout.icon_size) + _render_text(renderer, font, item.title, text_x, y + max(3, layout.list_item_h // 7), + color, layout.width - text_x - layout.list_pad_right) draw_status_bar( renderer, font, - f"A=Open B=Back L/R=Page Start=Quit ({cursor+1}/{len(items)})" + f"A=Open B=Back L/R=Page Start=Quit ({cursor+1}/{len(items)})", + layout, ) -def draw_playback(renderer, font, state: "BrowserState", icons: dict | None = None) -> None: +def draw_playback(renderer, font, state: "BrowserState", layout: theme.Layout, icons: dict | None = None) -> None: if not state.playback_hud_visible: return - top_h = theme.PLAYBACK_HUD_TOP - bottom_h = theme.PLAYBACK_HUD_BOTTOM - width = theme.SCREEN_W - height = theme.SCREEN_H - left = theme.PLAYBACK_HUD_SIDE - right = theme.PLAYBACK_HUD_SIDE + top_h = layout.playback_hud_top + bottom_h = layout.playback_hud_bottom + width = layout.width + height = layout.height + left = layout.playback_hud_side + right = layout.playback_hud_side status = "Paused" if state.playback_paused else "Playing" total = max(0.0, state.playback_duration) current = max(0.0, min(state.playback_position, total if total else state.playback_position)) @@ -249,53 +241,53 @@ def draw_playback(renderer, font, state: "BrowserState", icons: dict | None = No theme.PLAYBACK_HUD_HIDDEN: "HIDDEN", }.get(state.playback_hud_mode, "AUTO") time_text = f"{_format_time(current)} / {_format_time(total)}" - time_w = 156 + time_w = layout.playback_time_w time_x = width - left - time_w - title_x = left + 98 + title_x = left + layout.playback_status_w + layout.playback_hud_icon_size + 8 title_w = max(0, time_x - title_x - 12) title_text = _fit_text(font, state.playback_title or "...", title_w) _fill_rect(renderer, 0, 0, width, top_h, theme.PLAYBACK_OVERLAY_BG) _fill_rect(renderer, 0, height - bottom_h, width, bottom_h, theme.PLAYBACK_OVERLAY_BG) - _render_icon_small(renderer, status_icon, left, 7) - _render_text(renderer, font, status, left + 22, 5, theme.TEXT_COLOR, 70) + _render_icon(renderer, status_icon, left, max(5, top_h // 5), layout.playback_hud_icon_size) + _render_text(renderer, font, status, left + layout.playback_hud_icon_size + 6, max(4, top_h // 6), theme.TEXT_COLOR, layout.playback_status_w) _render_text(renderer, font, title_text, title_x, 5, theme.TEXT_COLOR, title_w) _render_text(renderer, font, time_text, time_x, 5, theme.DIM_TEXT, time_w) progress_y = height - bottom_h + 10 - _draw_progress_bar(renderer, progress_x, progress_y, progress_w, theme.PLAYBACK_PROGRESS_H, progress) + _draw_progress_bar(renderer, progress_x, progress_y, progress_w, layout.playback_progress_h, progress) - info_y = height - bottom_h + 23 - _render_icon_small(renderer, volume_icon, left, info_y + 1) + info_y = height - bottom_h + max(20, bottom_h // 2 - layout.playback_hud_icon_size // 2) + _render_icon(renderer, volume_icon, left, info_y + 1, layout.playback_hud_icon_size) _render_text(renderer, font, f"{state.playback_volume}%", left + 22, info_y - 1, theme.TEXT_COLOR, 64) buffer_text = f"Buffer {state.playback_buffer_percent}%" if state.playback_buffer_percent < 100 else state.playback_resolution or "" - _render_text(renderer, font, buffer_text, left + 88, info_y - 1, theme.DIM_TEXT, 180) + _render_text(renderer, font, buffer_text, left + max(88, layout.playback_status_w + 18), info_y - 1, theme.DIM_TEXT, layout.playback_buffer_w) _render_text(renderer, font, hud_mode, width - 70, info_y - 1, theme.DIM_TEXT, 60) - controls_y = height - 20 + controls_y = height - layout.playback_bottom_margin cx = left - _render_icon_small(renderer, volume_icon, cx, controls_y - 1) + _render_icon(renderer, volume_icon, cx, controls_y - 1, layout.playback_hud_icon_size) _render_text(renderer, font, "U/D", cx + 20, controls_y - 2, theme.TEXT_COLOR, 38) cx += 70 - _render_icon_small(renderer, status_icon, cx, controls_y - 1) + _render_icon(renderer, status_icon, cx, controls_y - 1, layout.playback_hud_icon_size) _render_text(renderer, font, "A", cx + 20, controls_y - 2, theme.TEXT_COLOR, 18) cx += 44 - _render_icon_small(renderer, seek_icon, cx, controls_y - 1) + _render_icon(renderer, seek_icon, cx, controls_y - 1, layout.playback_hud_icon_size) _render_text(renderer, font, "L/R", cx + 20, controls_y - 2, theme.TEXT_COLOR, 34) cx += 62 - _render_icon_small(renderer, stop_icon, cx, controls_y - 1) + _render_icon(renderer, stop_icon, cx, controls_y - 1, layout.playback_hud_icon_size) _render_text(renderer, font, "B", cx + 20, controls_y - 2, theme.TEXT_COLOR, 18) cx += 42 - _render_icon_small(renderer, hud_icon, cx, controls_y - 1) + _render_icon(renderer, hud_icon, cx, controls_y - 1, layout.playback_hud_icon_size) _render_text(renderer, font, "Y", cx + 20, controls_y - 2, theme.TEXT_COLOR, 18) -def draw_error(renderer, font, state: "BrowserState") -> None: - draw_header(renderer, font, "Error") +def draw_error(renderer, font, state: "BrowserState", layout: theme.Layout) -> None: + draw_header(renderer, font, "Error", layout) _render_text(renderer, font, state.error or "Unknown error", - theme.LIST_PAD_LEFT, theme.SCREEN_H // 2 - 20, theme.ERROR_COLOR, - theme.SCREEN_W - theme.LIST_PAD_LEFT * 2) - draw_status_bar(renderer, font, "A=OK") + layout.list_pad_left, layout.height // 2 - 20, theme.ERROR_COLOR, + layout.width - layout.list_pad_left * 2) + draw_status_bar(renderer, font, "A=OK", layout) diff --git a/src/r36s_dlna_browser/ui/sdl_app.py b/src/r36s_dlna_browser/ui/sdl_app.py index 0765d40..ad02452 100644 --- a/src/r36s_dlna_browser/ui/sdl_app.py +++ b/src/r36s_dlna_browser/ui/sdl_app.py @@ -60,6 +60,7 @@ class SDLApp: self._font = None self._playback_font = None self._icons: dict = {} + self._layout = theme.DEFAULT_LAYOUT self._playback_clear_once = False self._needs_redraw = True self._last_playback_draw_at = 0.0 @@ -87,12 +88,15 @@ class SDLApp: 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, - theme.SCREEN_W, - theme.SCREEN_H, + window_w, + window_h, sdl2.SDL_WINDOW_SHOWN, ) self._renderer = sdl2.SDL_CreateRenderer( @@ -106,8 +110,7 @@ class SDLApp: sdl2.SDL_GameControllerOpen(i) break - self._font = self._load_font(theme.FONT_SIZE) - self._playback_font = self._load_font(theme.PLAYBACK_FONT_SIZE) + self._reload_fonts() if not self._font: log.error("Could not load any font – UI text will be missing.") self._icons = self._load_icons() @@ -159,6 +162,33 @@ class SDLApp: 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) + + 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: for tex in self._icons.values(): sdl2.SDL_DestroyTexture(tex) @@ -337,7 +367,7 @@ class SDLApp: self._mark_dirty() def _adjust_server_scroll(self) -> None: - vis = theme.VISIBLE_ITEMS + vis = self._layout.visible_items cur = self._state.server_cursor off = self._state.server_scroll_offset if cur < off: @@ -346,7 +376,7 @@ class SDLApp: self._state.server_scroll_offset = cur - vis + 1 def _adjust_browse_scroll(self) -> None: - vis = theme.VISIBLE_ITEMS + vis = self._layout.visible_items cur = self._state.cursor off = self._state.scroll_offset if cur < off: @@ -367,14 +397,14 @@ class SDLApp: f = self._font if self._screen == Screen.SERVERS: - screens.draw_server_list(r, f, self._state, self._icons) + 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._icons) + 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._icons) + screens.draw_playback(r, self._playback_font or f, self._state, self._layout, self._icons) elif self._screen == Screen.ERROR: - screens.draw_error(r, f, self._state) + screens.draw_error(r, f, self._state, self._layout) sdl2.SDL_RenderPresent(r) self._needs_redraw = False @@ -453,11 +483,13 @@ class SDLApp: 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, - theme.PLAYBACK_HUD_TOP, - theme.PLAYBACK_HUD_BOTTOM, - theme.PLAYBACK_VIDEO_LEFT, - width.value - theme.PLAYBACK_VIDEO_RIGHT, + 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), ) diff --git a/src/r36s_dlna_browser/ui/theme.py b/src/r36s_dlna_browser/ui/theme.py index 3afd69b..2998937 100644 --- a/src/r36s_dlna_browser/ui/theme.py +++ b/src/r36s_dlna_browser/ui/theme.py @@ -1,10 +1,12 @@ -"""UI layout constants for a 640×480 display.""" +"""UI layout helpers for small embedded displays such as 640x480 and 720x720.""" from __future__ import annotations +import math +from dataclasses import dataclass from pathlib import Path -# Screen +# Base screen SCREEN_W = 640 SCREEN_H = 480 @@ -24,19 +26,6 @@ FONT_SIZE = 20 HEADER_FONT_SIZE = 24 PLAYBACK_FONT_SIZE = 14 -# Layout -HEADER_H = 40 -STATUS_H = 32 -LIST_TOP = HEADER_H -LIST_BOTTOM = SCREEN_H - STATUS_H -LIST_ITEM_H = 28 -VISIBLE_ITEMS = (LIST_BOTTOM - LIST_TOP) // LIST_ITEM_H -LIST_PAD_LEFT = 12 -LIST_PAD_RIGHT = 12 - -# Page jump size (L/R shoulder) -PAGE_SIZE = VISIBLE_ITEMS - # Timing TARGET_FPS = 30 FRAME_DELAY_MS = 1000 // TARGET_FPS @@ -64,11 +53,6 @@ PLAYBACK_PILL_BG = (8, 12, 18, 210) PLAYBACK_HUD_ICON_SIZE = 16 -PLAYBACK_VIDEO_TOP = PLAYBACK_HUD_TOP -PLAYBACK_VIDEO_BOTTOM = SCREEN_H - PLAYBACK_HUD_BOTTOM -PLAYBACK_VIDEO_LEFT = 0 -PLAYBACK_VIDEO_RIGHT = SCREEN_W - # Font search paths (in order) FONT_SEARCH_PATHS = [ str(ASSETS_DIR / "NotoSans-Regular.ttf"), @@ -78,3 +62,111 @@ FONT_SEARCH_PATHS = [ "/usr/share/fonts/google-noto-vf/NotoSans[wght].ttf", "/usr/share/fonts/liberation-sans-fonts/LiberationSans-Regular.ttf", ] + + +@dataclass(frozen=True) +class Layout: + width: int + height: int + ui_scale: float + font_size: int + header_font_size: int + playback_font_size: int + header_h: int + status_h: int + list_top: int + list_bottom: int + list_item_h: int + visible_items: int + list_pad_left: int + list_pad_right: int + page_size: int + icon_size: int + playback_hud_top: int + playback_hud_bottom: int + playback_hud_side: int + playback_progress_h: int + playback_hud_icon_size: int + playback_video_top: int + playback_video_bottom: int + playback_video_left: int + playback_video_right: int + playback_status_w: int + playback_time_w: int + playback_buffer_w: int + playback_bottom_margin: int + + +def _scaled(value: int, scale: float, minimum: int = 1) -> int: + return max(minimum, int(round(value * scale))) + + +def _ui_scale(width: int, height: int) -> float: + width = max(1, width) + height = max(1, height) + return max(0.85, min(1.6, math.sqrt((width / SCREEN_W) * (height / SCREEN_H)))) + + +def get_layout(width: int, height: int) -> Layout: + width = max(1, width) + height = max(1, height) + scale = _ui_scale(width, height) + + font_size = _scaled(FONT_SIZE, scale) + header_font_size = _scaled(HEADER_FONT_SIZE, scale) + playback_font_size = _scaled(PLAYBACK_FONT_SIZE, scale) + header_h = _scaled(40, scale) + status_h = _scaled(32, scale) + list_item_h = _scaled(28, scale) + list_pad_left = _scaled(12, scale) + list_pad_right = _scaled(12, scale) + icon_size = _scaled(20, scale) + playback_hud_top = _scaled(30, scale) + playback_hud_bottom = _scaled(50, scale) + playback_hud_side = _scaled(10, scale) + playback_progress_h = _scaled(8, scale, minimum=4) + playback_hud_icon_size = _scaled(16, scale) + playback_bottom_margin = _scaled(20, scale) + + list_top = header_h + list_bottom = max(list_top + list_item_h, height - status_h) + visible_items = max(1, (list_bottom - list_top) // list_item_h) + + playback_status_w = max(_scaled(70, scale), min(width // 6, _scaled(110, scale))) + playback_time_w = max(_scaled(156, scale), min(width // 3, _scaled(220, scale))) + playback_buffer_w = max(_scaled(180, scale), min(width // 3, _scaled(240, scale))) + + return Layout( + width=width, + height=height, + ui_scale=scale, + font_size=font_size, + header_font_size=header_font_size, + playback_font_size=playback_font_size, + header_h=header_h, + status_h=status_h, + list_top=list_top, + list_bottom=list_bottom, + list_item_h=list_item_h, + visible_items=visible_items, + list_pad_left=list_pad_left, + list_pad_right=list_pad_right, + page_size=visible_items, + icon_size=icon_size, + playback_hud_top=playback_hud_top, + playback_hud_bottom=playback_hud_bottom, + playback_hud_side=playback_hud_side, + playback_progress_h=playback_progress_h, + playback_hud_icon_size=playback_hud_icon_size, + playback_video_top=playback_hud_top, + playback_video_bottom=height - playback_hud_bottom, + playback_video_left=0, + playback_video_right=width, + playback_status_w=playback_status_w, + playback_time_w=playback_time_w, + playback_buffer_w=playback_buffer_w, + playback_bottom_margin=playback_bottom_margin, + ) + + +DEFAULT_LAYOUT = get_layout(SCREEN_W, SCREEN_H) diff --git a/tests/test_sdl_app.py b/tests/test_sdl_app.py index 1974909..8609f43 100644 --- a/tests/test_sdl_app.py +++ b/tests/test_sdl_app.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ctypes from queue import Queue from r36s_dlna_browser.dlna.browser_state import BrowserState @@ -12,12 +13,13 @@ from r36s_dlna_browser.ui.sdl_app import SDLApp, Screen class DummyPlayer: def __init__(self) -> None: self._new_frame = False + self.viewport = None def attach_window(self, _window) -> None: pass - def set_viewport(self, *_args) -> None: - pass + def set_viewport(self, *args) -> None: + self.viewport = args def has_new_frame(self) -> bool: return self._new_frame @@ -119,4 +121,40 @@ def test_paused_playback_keeps_hud_visible_in_auto_mode(monkeypatch) -> None: app._refresh_playback_hud_visibility() - assert app._state.playback_hud_visible is True \ No newline at end of file + assert app._state.playback_hud_visible is True + + +def test_layout_scales_for_square_display() -> None: + compact = theme.get_layout(640, 480) + square = theme.get_layout(720, 720) + + assert square.visible_items > compact.visible_items + assert square.list_item_h > compact.list_item_h + assert square.playback_hud_top > compact.playback_hud_top + + +def test_sync_player_viewport_uses_runtime_layout(monkeypatch) -> None: + app = _make_app() + app._window = object() + + def fake_get_window_size(_window, width_ptr, height_ptr): + width = ctypes.cast(width_ptr, ctypes.POINTER(ctypes.c_int)) + height = ctypes.cast(height_ptr, ctypes.POINTER(ctypes.c_int)) + width.contents.value = 720 + height.contents.value = 720 + + monkeypatch.setattr(sdl_app_module.sdl2, "SDL_GetWindowSize", fake_get_window_size) + + app._sync_player_viewport() + + layout = app._layout + assert layout.width == 720 + assert layout.height == 720 + assert app._player.viewport == ( + 720, + 720, + layout.playback_hud_top, + 720 - layout.playback_video_bottom, + layout.playback_video_left, + 720 - layout.playback_video_right, + ) \ No newline at end of file