ui: runtime-scaled layout for 640x480 and 720x720 displays
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user