diff --git a/src/r36s_dlna_browser/ui/screens.py b/src/r36s_dlna_browser/ui/screens.py index 5f29d93..47a306c 100644 --- a/src/r36s_dlna_browser/ui/screens.py +++ b/src/r36s_dlna_browser/ui/screens.py @@ -18,6 +18,66 @@ if TYPE_CHECKING: from r36s_dlna_browser.dlna.browser_state import BrowserState +class HUDTextCache: + """Cache SDL text textures so TTF_RenderUTF8_Blended is not called every frame. + + The cache maps ``(text, color_tuple)`` to ``(SDL_Texture*, full_w, full_h)``. + Call ``invalidate()`` whenever the renderer or font changes (e.g. on resize + or cleanup) so stale texture pointers are not used. + """ + + def __init__(self) -> None: + self._tex_cache: dict[tuple, tuple] = {} + self._fit_cache: dict[tuple, str] = {} + + # ------------------------------------------------------------------ + def render_text( + self, + renderer: ctypes.c_void_p, + font: ctypes.c_void_p, + text: str, + x: int, + y: int, + color: tuple, + max_w: int = 0, + ) -> None: + """Render *text* at (x, y), reusing a cached texture when possible.""" + if not text or not font or not ttf: + return + key = (text, color) + entry = self._tex_cache.get(key) + if entry is None: + encoded = text.encode("utf-8") + surf = ttf.TTF_RenderUTF8_Blended(font, encoded, _color(color)) + if not surf: + return + tex = sdl2.SDL_CreateTextureFromSurface(renderer, surf) + w, h = surf.contents.w, surf.contents.h + sdl2.SDL_FreeSurface(surf) + if not tex: + return + entry = (tex, w, h) + self._tex_cache[key] = entry + tex, w, h = entry + if max_w and w > max_w: + w = max_w + sdl2.SDL_RenderCopy(renderer, tex, sdl2.SDL_Rect(0, 0, w, h), sdl2.SDL_Rect(x, y, w, h)) + + def fit_text(self, font: ctypes.c_void_p, text: str, max_w: int) -> str: + """Cache the result of _fit_text so TTF_SizeUTF8 is not called every frame.""" + key = (text, max_w) + if key not in self._fit_cache: + self._fit_cache[key] = _fit_text(font, text, max_w) + return self._fit_cache[key] + + def invalidate(self) -> None: + """Destroy all cached textures. Call when renderer or font changes.""" + for tex, _w, _h in self._tex_cache.values(): + sdl2.SDL_DestroyTexture(tex) + self._tex_cache.clear() + self._fit_cache.clear() + + def _color(rgba: tuple) -> sdl2.SDL_Color: return sdl2.SDL_Color(rgba[0], rgba[1], rgba[2], rgba[3] if len(rgba) > 3 else 255) @@ -212,10 +272,25 @@ def draw_browse_list(renderer, font, state: "BrowserState", layout: theme.Layout ) -def draw_playback(renderer, font, state: "BrowserState", layout: theme.Layout, icons: dict | None = None) -> None: +def draw_playback( + renderer, + font, + state: "BrowserState", + layout: theme.Layout, + icons: dict | None = None, + *, + cache: "HUDTextCache | None" = None, +) -> None: if not state.playback_hud_visible: return + # Local alias: reuse cached texture when a cache is provided, else create/destroy per call. + def _rt(text: str, x: int, y: int, color: tuple, max_w: int = 0) -> None: + if cache is not None: + cache.render_text(renderer, font, text, x, y, color, max_w) + else: + _render_text(renderer, font, text, x, y, color, max_w) + top_h = layout.playback_hud_top bottom_h = layout.playback_hud_bottom width = layout.width @@ -245,44 +320,42 @@ def draw_playback(renderer, font, state: "BrowserState", layout: theme.Layout, i time_x = width - left - time_w 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) + title_text = cache.fit_text(font, state.playback_title or "...", title_w) if cache else _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(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) + _rt(status, left + layout.playback_hud_icon_size + 6, max(4, top_h // 6), theme.TEXT_COLOR, layout.playback_status_w) + _rt(title_text, title_x, 5, theme.TEXT_COLOR, title_w) + _rt(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, layout.playback_progress_h, progress) 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) + _rt(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 + 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) + _rt(buffer_text, left + max(88, layout.playback_status_w + 18), info_y - 1, theme.DIM_TEXT, layout.playback_buffer_w) + _rt(hud_mode, width - 70, info_y - 1, theme.DIM_TEXT, 60) controls_y = height - layout.playback_bottom_margin cx = left _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) + _rt("U/D", cx + 20, controls_y - 2, theme.TEXT_COLOR, 38) cx += 70 _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) + _rt("A", cx + 20, controls_y - 2, theme.TEXT_COLOR, 18) cx += 44 _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) + _rt("L/R", cx + 20, controls_y - 2, theme.TEXT_COLOR, 34) cx += 62 _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) + _rt("B", cx + 20, controls_y - 2, theme.TEXT_COLOR, 18) cx += 42 _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) + _rt("Y", cx + 20, controls_y - 2, theme.TEXT_COLOR, 18) def draw_error(renderer, font, state: "BrowserState", layout: theme.Layout) -> None: diff --git a/src/r36s_dlna_browser/ui/sdl_app.py b/src/r36s_dlna_browser/ui/sdl_app.py index 82aa266..562caa8 100644 --- a/src/r36s_dlna_browser/ui/sdl_app.py +++ b/src/r36s_dlna_browser/ui/sdl_app.py @@ -61,6 +61,7 @@ class SDLApp: 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 @@ -113,6 +114,7 @@ class SDLApp: 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() @@ -169,6 +171,8 @@ class SDLApp: 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() @@ -190,6 +194,9 @@ class SDLApp: 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 = {} @@ -402,7 +409,7 @@ class SDLApp: 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) + 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) diff --git a/tests/test_video_playback_device.py b/tests/test_video_playback_device.py index b5cfdde..8142645 100644 --- a/tests/test_video_playback_device.py +++ b/tests/test_video_playback_device.py @@ -775,6 +775,7 @@ else: import sdl2.sdlttf as _ttf9 from r36s_dlna_browser.ui import screens as _screens9, theme as _theme9 + _cache9 = _screens9.HUDTextCache() # ── SDL + TTF init (no vsync so phases are timed cleanly) ──────────── sdl2.SDL_SetHint(b"SDL_VIDEODRIVER", b"kmsdrm,offscreen") @@ -906,9 +907,9 @@ else: sdl2.SDL_RenderCopy(_ren9, _tex9, None, _dst9) _t_video = (time.monotonic() - _t0) * 1e6 - # Phase 3 — draw_playback() — all TTF_RenderUTF8_Blended + fill-rects. + # Phase 3 — draw_playback() with texture cache (warm after first frame). _t0 = time.monotonic() - _screens9.draw_playback(_ren9, _font9, _state9, _layout9, _icons9 or None) + _screens9.draw_playback(_ren9, _font9, _state9, _layout9, _icons9 or None, cache=_cache9) _t_hud = (time.monotonic() - _t0) * 1e6 # Phase 4 — Present (no vsync → should be near-zero on HW renderer). @@ -928,6 +929,7 @@ else: pass # Cleanup. + _cache9.invalidate() for _itex_v in _icons9.values(): sdl2.SDL_DestroyTexture(_itex_v) if _tex9: @@ -955,9 +957,9 @@ else: print(" --- Section 9 HUD Timing Report ---") print(f" Frames measured (excl warmup) : {len(_hud9)}") _stat9("SDL_UpdateNVTexture (synthetic NV12)", _upload9) - _stat9("SDL_RenderClear+RenderCopy (video)", _video9) - _stat9("draw_playback() — TTF+fills+icons", _hud9) - _stat9("SDL_RenderPresent (no vsync)", _pres9) + _stat9("SDL_RenderClear+RenderCopy (video)", _video9) + _stat9("draw_playback() — cached TTF+fills+icons", _hud9) + _stat9("SDL_RenderPresent (no vsync)", _pres9) _hud_mean = statistics.mean(_hud9) if _hud9 else 0 _total9_mean = (statistics.mean(_upload9) + statistics.mean(_video9) +