ui: runtime-scaled layout for 640x480 and 720x720 displays

This commit is contained in:
Matteo Benedetto
2026-03-22 16:57:24 +01:00
parent 527e63a769
commit 544ed8bc6d
5 changed files with 270 additions and 115 deletions
+4 -3
View File
@@ -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
+67 -75
View File
@@ -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)
+46 -14
View File
@@ -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),
)
+112 -20
View File
@@ -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)
+41 -3
View File
@@ -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
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,
)