Initial import

This commit is contained in:
Matteo Benedetto
2026-03-21 15:55:32 +01:00
commit 1d89c7fdc7
59 changed files with 3752 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""R36S DLNA Browser – lightweight DLNA media browser for R36S handhelds."""
+22
View File
@@ -0,0 +1,22 @@
"""Entrypoint for ``python -m r36s_dlna_browser``."""
from __future__ import annotations
import sys
def main() -> None:
from r36s_dlna_browser.app import Application
app = Application()
try:
app.run()
except KeyboardInterrupt:
pass
finally:
app.shutdown()
sys.exit(0)
if __name__ == "__main__":
main()
+173
View File
@@ -0,0 +1,173 @@
"""Application lifecycle – wires together UI, DLNA networking and playback."""
from __future__ import annotations
import asyncio
import logging
import queue
import threading
from typing import Any
from r36s_dlna_browser.dlna.browser_state import BrowserState
from r36s_dlna_browser.dlna.discovery import DLNADiscovery
from r36s_dlna_browser.dlna.client import DLNAClient
from r36s_dlna_browser.player.gstreamer_backend import GStreamerBackend
from r36s_dlna_browser.platform.runtime import configure_logging, sdl_env_setup
from r36s_dlna_browser.ui.sdl_app import SDLApp
log = logging.getLogger(__name__)
class Application:
"""Top-level application that owns the event loop, UI and networking."""
def __init__(self) -> None:
configure_logging()
sdl_env_setup()
self.ui_queue: queue.Queue[Any] = queue.Queue()
self.cmd_queue: queue.Queue[Any] = queue.Queue()
self.browser_state = BrowserState()
self.discovery = DLNADiscovery()
self.client = DLNAClient()
self.player = GStreamerBackend()
self.player.set_event_callback(self._handle_player_event)
self._async_loop: asyncio.AbstractEventLoop | None = None
self._net_thread: threading.Thread | None = None
self._shutting_down = False
# ── public API ───────────────────────────────────────────────
def run(self) -> None:
"""Start the networking thread and enter the SDL2 main loop."""
self._start_network_thread()
self.browser_state.loading = True
self._schedule(("discover",))
sdl = SDLApp(
ui_queue=self.ui_queue,
cmd_queue=self.cmd_queue,
browser_state=self.browser_state,
player=self.player,
)
sdl.run() # blocks until quit
def shutdown(self) -> None:
self._shutting_down = True
self._schedule(("shutdown",))
if self._net_thread and self._net_thread.is_alive():
self._net_thread.join(timeout=2)
self.player.shutdown()
log.info("Application shut down.")
# ── networking thread ────────────────────────────────────────
def _start_network_thread(self) -> None:
self._net_thread = threading.Thread(
target=self._run_async_loop, daemon=True, name="dlna-net"
)
self._net_thread.start()
def _run_async_loop(self) -> None:
self._async_loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._async_loop)
try:
self._async_loop.run_until_complete(self._command_dispatcher())
finally:
pending = [
task
for task in asyncio.all_tasks(self._async_loop)
if not task.done()
]
for task in pending:
task.cancel()
if pending:
self._async_loop.run_until_complete(
asyncio.gather(*pending, return_exceptions=True)
)
self._async_loop.close()
async def _command_dispatcher(self) -> None:
"""Poll cmd_queue for commands from the UI thread and dispatch them."""
loop = asyncio.get_running_loop()
while not self._shutting_down:
try:
cmd = await loop.run_in_executor(None, self.cmd_queue.get, True, 0.1)
except queue.Empty:
continue
try:
await self._handle_command(cmd)
except Exception as exc:
log.exception("Error handling command %s", cmd)
self.browser_state.loading = False
self.ui_queue.put(("error", str(exc)))
async def _handle_command(self, cmd: tuple) -> None:
action = cmd[0]
if action == "discover":
servers = await self.discovery.find_servers()
self.browser_state.set_servers(servers)
self.browser_state.loading = False
self.ui_queue.put(("servers_updated",))
elif action == "browse":
server_location = cmd[1]
object_id = cmd[2]
items = await self.client.browse(server_location, object_id)
self.browser_state.set_items(items, object_id)
self.browser_state.loading = False
self.ui_queue.put(("items_updated",))
elif action == "play":
url = cmd[1]
title = cmd[2] if len(cmd) > 2 else ""
self.player.play(url)
self.browser_state.playback_paused = False
self.ui_queue.put(("playback_started", title))
elif action == "stop":
self.player.stop()
elif action == "toggle_pause":
paused = self.player.toggle_pause()
self.browser_state.playback_paused = paused
self.ui_queue.put(("playback_paused" if paused else "playback_resumed",))
elif action == "seek":
self.player.seek(cmd[1])
elif action == "volume":
volume = self.player.change_volume(cmd[1])
self.browser_state.playback_volume = volume
elif action == "shutdown":
self._shutting_down = True
self.player.shutdown()
# ── helpers ──────────────────────────────────────────────────
def _schedule(self, cmd: tuple) -> None:
self.cmd_queue.put(cmd)
def _handle_player_event(self, event: str, *args: Any) -> None:
if event == "stopped":
self.browser_state.playback_paused = False
self.ui_queue.put(("playback_stopped",))
elif event == "position" and args:
self.browser_state.playback_position = float(args[0])
elif event == "duration" and args:
self.browser_state.playback_duration = float(args[0])
elif event == "paused" and args:
self.browser_state.playback_paused = bool(args[0])
elif event == "volume" and args:
self.browser_state.playback_volume = int(args[0])
elif event == "buffering" and args:
self.browser_state.playback_buffer_percent = int(args[0])
elif event == "cache" and args:
self.browser_state.playback_cache_seconds = float(args[0])
elif event == "resolution" and args:
self.browser_state.playback_resolution = str(args[0])
elif event == "error" and args:
self.browser_state.playback_paused = False
self.ui_queue.put(("error", str(args[0])))
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 998 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<!-- notehead -->
<ellipse cx="20" cy="50" rx="13" ry="9" transform="rotate(-18 20 50)" fill="#DCDCDC"/>
<!-- stem -->
<rect x="31" y="10" width="5" height="42" rx="2" fill="#DCDCDC"/>
<!-- flag -->
<path d="M36 10 Q54 16 52 32 Q44 24 36 28 Z" fill="#DCDCDC"/>
</svg>

After

Width:  |  Height:  |  Size: 364 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 532 B

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<!-- folder tab -->
<rect x="6" y="10" width="24" height="15" rx="4" fill="#DCDCDC"/>
<!-- separator so tab reads separately from body -->
<rect x="6" y="22" width="52" height="3" fill="#181820"/>
<!-- folder body -->
<rect x="6" y="22" width="52" height="34" rx="5" fill="#DCDCDC"/>
</svg>

After

Width:  |  Height:  |  Size: 387 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 636 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect x="10" y="14" width="44" height="28" rx="4" fill="none" stroke="#DCDCDC" stroke-width="4"/>
<rect x="26" y="46" width="12" height="4" rx="2" fill="#DCDCDC"/>
<rect x="20" y="52" width="24" height="4" rx="2" fill="#DCDCDC"/>
</svg>

After

Width:  |  Height:  |  Size: 326 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 B

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect x="18" y="14" width="10" height="36" rx="3" fill="#DCDCDC"/>
<rect x="36" y="14" width="10" height="36" rx="3" fill="#DCDCDC"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<polygon points="20,14 50,32 20,50" fill="#DCDCDC"/>
</svg>

After

Width:  |  Height:  |  Size: 145 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<polygon points="10,32 26,20 26,44" fill="#DCDCDC"/>
<polygon points="26,32 42,20 42,44" fill="#DCDCDC"/>
<polygon points="54,32 38,20 38,44" fill="#DCDCDC" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 338 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<rect x="18" y="18" width="28" height="28" rx="4" fill="#DCDCDC"/>
</svg>

After

Width:  |  Height:  |  Size: 159 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<polygon points="12,26 22,26 34,16 34,48 22,38 12,38" fill="#DCDCDC"/>
<path d="M41 24 C46 27, 46 37, 41 40" fill="none" stroke="#DCDCDC" stroke-width="4" stroke-linecap="round"/>
<path d="M46 18 C55 23, 55 41, 46 46" fill="none" stroke="#DCDCDC" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<!-- photo frame -->
<rect x="4" y="8" width="56" height="48" rx="6" fill="#DCDCDC"/>
<!-- photo area (dark inner) -->
<rect x="10" y="14" width="44" height="36" rx="3" fill="#181820"/>
<!-- mountain silhouette -->
<polygon points="10,50 22,30 34,42 44,28 54,50" fill="#DCDCDC"/>
<!-- sun -->
<circle cx="44" cy="22" r="6" fill="#DCDCDC"/>
</svg>

After

Width:  |  Height:  |  Size: 447 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<!-- equalizer bars: left short, center tall, right medium -->
<rect x="8" y="34" width="13" height="22" rx="3" fill="#DCDCDC"/>
<rect x="26" y="14" width="13" height="42" rx="3" fill="#DCDCDC"/>
<rect x="44" y="24" width="13" height="32" rx="3" fill="#DCDCDC"/>
</svg>

After

Width:  |  Height:  |  Size: 363 B

@@ -0,0 +1,100 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>R36S Icons Review</title>
<style>
body { background: #181820; margin: 0; padding: 40px; font-family: sans-serif; color: #dcdcdc; }
h1 { font-size: 18px; margin-bottom: 32px; }
.grid { display: flex; gap: 40px; flex-wrap: wrap; align-items: flex-start; }
.item { display: flex; flex-direction: column; align-items: center; gap: 12px; }
.item img { image-rendering: pixelated; }
.label { font-size: 13px; color: #888; }
.sizes { display: flex; gap: 16px; align-items: center; }
/* simulate a list row */
.row { background: #18182018; display: flex; align-items: center; gap: 8px;
padding: 4px 8px; border-radius: 3px; width: 300px; }
.row.hi { background: #3250a0; }
.row span { font-size: 14px; color: #dcdcdc; }
.rows { display: flex; flex-direction: column; gap: 0px; margin-top: 40px; }
</style>
</head>
<body>
<h1>R36S DLNA Browser — Icons Review (64×64)</h1>
<div class="grid">
<div class="item">
<div class="sizes">
<img src="folder.png" width="64" height="64" alt="folder">
<img src="folder.png" width="32" height="32" alt="folder">
<img src="folder.png" width="20" height="20" alt="folder">
</div>
<div class="label">folder</div>
</div>
<div class="item">
<div class="sizes">
<img src="audio.png" width="64" height="64" alt="audio">
<img src="audio.png" width="32" height="32" alt="audio">
<img src="audio.png" width="20" height="20" alt="audio">
</div>
<div class="label">audio</div>
</div>
<div class="item">
<div class="sizes">
<img src="video.png" width="64" height="64" alt="video">
<img src="video.png" width="32" height="32" alt="video">
<img src="video.png" width="20" height="20" alt="video">
</div>
<div class="label">video</div>
</div>
<div class="item">
<div class="sizes">
<img src="image.png" width="64" height="64" alt="image">
<img src="image.png" width="32" height="32" alt="image">
<img src="image.png" width="20" height="20" alt="image">
</div>
<div class="label">image</div>
</div>
<div class="item">
<div class="sizes">
<img src="playing.png" width="64" height="64" alt="playing">
<img src="playing.png" width="32" height="32" alt="playing">
<img src="playing.png" width="20" height="20" alt="playing">
</div>
<div class="label">playing</div>
</div>
<div class="item">
<div class="sizes">
<img src="server.png" width="64" height="64" alt="server">
<img src="server.png" width="32" height="32" alt="server">
<img src="server.png" width="20" height="20" alt="server">
</div>
<div class="label">server</div>
</div>
</div>
<div class="rows">
<h2 style="font-size:14px; margin-bottom:8px;">Simulated list rows (20px icon)</h2>
<div class="row hi">
<img src="folder.png" width="20" height="20"><span>Music</span>
</div>
<div class="row">
<img src="folder.png" width="20" height="20"><span>Videos</span>
</div>
<div class="row">
<img src="audio.png" width="20" height="20"><span>Song Title.flac</span>
</div>
<div class="row">
<img src="video.png" width="20" height="20"><span>Movie.mkv</span>
</div>
<div class="row">
<img src="image.png" width="20" height="20"><span>photo.jpg</span>
</div>
<div class="row">
<img src="playing.png" width="20" height="20"><span>Now Playing.mkv</span>
</div>
<div class="row">
<img src="server.png" width="20" height="20"><span>MiniDLNA [10.0.0.181]</span>
</div>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<!-- broadcast dot -->
<circle cx="32" cy="36" r="5" fill="#DCDCDC"/>
<!-- mast -->
<rect x="30" y="40" width="4" height="14" rx="2" fill="#DCDCDC"/>
<!-- base -->
<rect x="18" y="52" width="28" height="6" rx="3" fill="#DCDCDC"/>
<!-- inner signal arc -->
<path d="M20 30 Q32 20 44 30" stroke="#DCDCDC" stroke-width="4.5" fill="none" stroke-linecap="round"/>
<!-- outer signal arc -->
<path d="M10 22 Q32 8 54 22" stroke="#DCDCDC" stroke-width="4.5" fill="none" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 598 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<!-- rounded screen -->
<rect x="4" y="8" width="56" height="48" rx="7" fill="#DCDCDC"/>
<!-- play triangle cutout (dark) -->
<polygon points="22,17 22,47 50,32" fill="#181820"/>
</svg>

After

Width:  |  Height:  |  Size: 278 B

+150
View File
@@ -0,0 +1,150 @@
"""Navigation stack, pagination and in-memory cache for the browser."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import List, Optional
from r36s_dlna_browser.dlna.models import MediaServer, MediaItem
from r36s_dlna_browser.ui import theme
@dataclass
class _Level:
"""One level of the navigation stack."""
server_location: str
object_id: str
items: List[MediaItem] = field(default_factory=list)
cursor: int = 0
scroll_offset: int = 0
class BrowserState:
"""Tracks the server list, the current navigation stack and cursor position."""
def __init__(self) -> None:
self.servers: List[MediaServer] = []
self.server_cursor: int = 0
self.server_scroll_offset: int = 0
self._stack: List[_Level] = []
self.loading: bool = False
self.error: str = ""
self.playback_title: str = ""
self.playback_paused: bool = False
self.playback_position: float = 0.0
self.playback_duration: float = 0.0
self.playback_volume: int = 100
self.playback_cache_seconds: float = 0.0
self.playback_buffer_percent: int = 0
self.playback_resolution: str = ""
self.playback_hud_visible: bool = True
self.playback_hud_mode: int = theme.PLAYBACK_HUD_AUTO
self.playback_backend: str = "gstreamer-sdl"
self.playback_video_driver: str = ""
# ── server list ──────────────────────────────────────────────
def set_servers(self, servers: List[MediaServer]) -> None:
self.servers = servers
self.server_cursor = 0
self.server_scroll_offset = 0
@property
def selected_server(self) -> Optional[MediaServer]:
if 0 <= self.server_cursor < len(self.servers):
return self.servers[self.server_cursor]
return None
# ── browse stack ─────────────────────────────────────────────
@property
def in_browse_mode(self) -> bool:
return len(self._stack) > 0
@property
def current_level(self) -> Optional[_Level]:
return self._stack[-1] if self._stack else None
@property
def current_items(self) -> List[MediaItem]:
lv = self.current_level
return lv.items if lv else []
@property
def cursor(self) -> int:
lv = self.current_level
return lv.cursor if lv else 0
@cursor.setter
def cursor(self, value: int) -> None:
lv = self.current_level
if lv:
lv.cursor = max(0, min(value, len(lv.items) - 1)) if lv.items else 0
@property
def scroll_offset(self) -> int:
lv = self.current_level
return lv.scroll_offset if lv else 0
@scroll_offset.setter
def scroll_offset(self, value: int) -> None:
lv = self.current_level
if lv:
lv.scroll_offset = value
def selected_item(self) -> Optional[MediaItem]:
items = self.current_items
c = self.cursor
if 0 <= c < len(items):
return items[c]
return None
def set_items(self, items: List[MediaItem], object_id: str) -> None:
"""Push or update items for the given object_id."""
lv = self.current_level
if lv and lv.object_id == object_id:
lv.items = items
lv.cursor = 0
lv.scroll_offset = 0
else:
# Find server_location from the stack or current server
loc = ""
if self._stack:
loc = self._stack[-1].server_location
elif self.selected_server:
loc = self.selected_server.location
self._stack.append(_Level(
server_location=loc,
object_id=object_id,
items=items,
))
def go_back(self) -> bool:
"""Pop one level. Returns True if we went back, False if at root."""
if self._stack:
self._stack.pop()
return True
return False
def enter_server(self, server: MediaServer) -> str:
"""Start browsing a server. Returns the root object_id."""
self._stack.clear()
self._stack.append(_Level(
server_location=server.location, object_id="0"
))
return "0"
def reset(self) -> None:
self._stack.clear()
self.error = ""
self.playback_title = ""
self.playback_paused = False
self.playback_position = 0.0
self.playback_duration = 0.0
self.playback_cache_seconds = 0.0
self.playback_buffer_percent = 0
self.playback_resolution = ""
self.playback_hud_visible = True
self.playback_hud_mode = theme.PLAYBACK_HUD_AUTO
+221
View File
@@ -0,0 +1,221 @@
"""ContentDirectory client – browse containers and items via UPnP."""
from __future__ import annotations
import logging
from typing import List
from xml.etree import ElementTree
import aiohttp
from r36s_dlna_browser.dlna.models import MediaItem, ItemType, classify_upnp_class
log = logging.getLogger(__name__)
_NS = {
"s": "http://schemas.xmlsoap.org/soap/envelope/",
"u": "urn:schemas-upnp-org:service:ContentDirectory:1",
"didl": "urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/",
"dc": "http://purl.org/dc/elements/1.1/",
"upnp": "urn:schemas-upnp-org:metadata-1-0/upnp/",
}
_BROWSE_BODY = """<?xml version="1.0" encoding="utf-8"?>
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"
s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">
<s:Body>
<u:Browse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1">
<ObjectID>{object_id}</ObjectID>
<BrowseFlag>BrowseDirectChildren</BrowseFlag>
<Filter>*</Filter>
<StartingIndex>{start}</StartingIndex>
<RequestedCount>{count}</RequestedCount>
<SortCriteria></SortCriteria>
</u:Browse>
</s:Body>
</s:Envelope>"""
def _text(el: ElementTree.Element | None) -> str:
if el is not None and el.text:
return el.text.strip()
return ""
def _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
def _extract_browse_result(xml_text: str) -> str | None:
"""Extract the Browse Result payload from a SOAP response.
DLNA servers are inconsistent here: some return a namespaced Result element,
some return an unqualified Result element, and some may embed the DIDL as
children instead of escaped text. This helper accepts all three shapes.
"""
try:
soap = ElementTree.fromstring(xml_text)
except ElementTree.ParseError as exc:
log.warning("SOAP parse error: %s", exc)
return None
for element in soap.iter():
if _local_name(element.tag) != "Result":
continue
if element.text and element.text.strip():
return element.text.strip()
if len(element):
return "".join(
ElementTree.tostring(child, encoding="unicode")
for child in element
).strip()
return None
def _parse_didl(didl_xml: str, base_url: str) -> List[MediaItem]:
"""Parse a DIDL-Lite XML fragment into MediaItem objects."""
items: list[MediaItem] = []
try:
root = ElementTree.fromstring(didl_xml)
except ElementTree.ParseError as exc:
log.warning("DIDL parse error: %s", exc)
return items
for container in root.findall("didl:container", _NS):
title = _text(container.find("dc:title", _NS))
obj_id = container.get("id", "")
parent_id = container.get("parentID", "0")
child_count = int(container.get("childCount", "0") or "0")
items.append(MediaItem(
object_id=obj_id,
title=title,
item_type=ItemType.CONTAINER,
parent_id=parent_id,
child_count=child_count,
))
for item_el in root.findall("didl:item", _NS):
title = _text(item_el.find("dc:title", _NS))
obj_id = item_el.get("id", "")
parent_id = item_el.get("parentID", "0")
upnp_class = _text(item_el.find("upnp:class", _NS))
album_art = _text(item_el.find("upnp:albumArtURI", _NS))
resource_url = ""
mime_type = ""
duration = ""
size = 0
res_el = item_el.find("didl:res", _NS)
if res_el is not None:
resource_url = res_el.text.strip() if res_el.text else ""
protocol_info = res_el.get("protocolInfo", "")
# Extract mime from protocolInfo: "http-get:*:audio/mpeg:*"
parts = protocol_info.split(":")
if len(parts) >= 3:
mime_type = parts[2]
duration = res_el.get("duration", "")
size = int(res_el.get("size", "0") or "0")
# Resolve relative URLs
if resource_url and not resource_url.startswith(("http://", "https://")):
resource_url = base_url.rstrip("/") + "/" + resource_url.lstrip("/")
if album_art and not album_art.startswith(("http://", "https://")):
album_art = base_url.rstrip("/") + "/" + album_art.lstrip("/")
items.append(MediaItem(
object_id=obj_id,
title=title,
item_type=classify_upnp_class(upnp_class),
parent_id=parent_id,
resource_url=resource_url,
mime_type=mime_type,
size=size,
duration=duration,
album_art_url=album_art,
))
return items
async def _get_content_directory_url(
session: aiohttp.ClientSession, location: str
) -> str | None:
"""Fetch device XML and find the ContentDirectory control URL."""
try:
async with session.get(location, timeout=aiohttp.ClientTimeout(total=5)) as resp:
xml_text = await resp.text()
root = ElementTree.fromstring(xml_text)
ns = {"d": "urn:schemas-upnp-org:device-1-0"}
for svc in root.findall(".//d:service", ns):
svc_type = _text(svc.find("d:serviceType", ns))
if "ContentDirectory" in svc_type:
ctrl = _text(svc.find("d:controlURL", ns))
if ctrl:
# Make absolute
from urllib.parse import urljoin
return urljoin(location, ctrl)
except Exception as exc:
log.warning("Could not get ContentDirectory URL from %s: %s", location, exc)
return None
class DLNAClient:
"""Browses a DLNA server's ContentDirectory."""
def __init__(self) -> None:
self._control_urls: dict[str, str] = {} # location → controlURL cache
async def browse(
self,
server_location: str,
object_id: str = "0",
start: int = 0,
count: int = 200,
) -> List[MediaItem]:
async with aiohttp.ClientSession() as session:
ctrl_url = self._control_urls.get(server_location)
if not ctrl_url:
ctrl_url = await _get_content_directory_url(session, server_location)
if not ctrl_url:
log.error("No ContentDirectory for %s", server_location)
return []
self._control_urls[server_location] = ctrl_url
body = _BROWSE_BODY.format(
object_id=object_id, start=start, count=count
)
headers = {
"Content-Type": 'text/xml; charset="utf-8"',
"SOAPAction": '"urn:schemas-upnp-org:service:ContentDirectory:1#Browse"',
}
try:
async with session.post(
ctrl_url,
data=body,
headers=headers,
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
xml_text = await resp.text()
except Exception as exc:
log.error("Browse request failed: %s", exc)
return []
# Extract the DIDL-Lite Result from the SOAP response
didl_xml = _extract_browse_result(xml_text)
if not didl_xml:
log.warning(
"Empty browse result for object_id=%s via %s",
object_id,
ctrl_url,
)
return []
# Derive base URL from server location for relative URL resolution
from urllib.parse import urlparse
parsed = urlparse(server_location)
base_url = f"{parsed.scheme}://{parsed.netloc}"
return _parse_didl(didl_xml, base_url)
+105
View File
@@ -0,0 +1,105 @@
"""SSDP-based discovery of DLNA MediaServers on the local network."""
from __future__ import annotations
import asyncio
import logging
import socket
from typing import List
from xml.etree import ElementTree
import aiohttp
from r36s_dlna_browser.dlna.models import MediaServer
log = logging.getLogger(__name__)
SSDP_ADDR = "239.255.255.250"
SSDP_PORT = 1900
MEDIA_SERVER_TYPE = "urn:schemas-upnp-org:device:MediaServer:1"
SSDP_MX = 3
SEARCH_TIMEOUT = 5.0
_SSDP_SEARCH = (
"M-SEARCH * HTTP/1.1\r\n"
f"HOST: {SSDP_ADDR}:{SSDP_PORT}\r\n"
'MAN: "ssdp:discover"\r\n'
f"MX: {SSDP_MX}\r\n"
f"ST: {MEDIA_SERVER_TYPE}\r\n"
"\r\n"
).encode()
class _SSDPProtocol(asyncio.DatagramProtocol):
"""Collects M-SEARCH responses."""
def __init__(self) -> None:
self.responses: list[tuple[str, str]] = [] # (location, server)
def datagram_received(self, data: bytes, addr: tuple[str, int]) -> None:
try:
text = data.decode("utf-8", errors="replace")
except Exception:
return
headers: dict[str, str] = {}
for line in text.split("\r\n"):
if ":" in line:
key, _, val = line.partition(":")
headers[key.strip().upper()] = val.strip()
location = headers.get("LOCATION", "")
st = headers.get("ST", "")
if location and MEDIA_SERVER_TYPE in st:
self.responses.append((location, headers.get("SERVER", "")))
def error_received(self, exc: Exception) -> None:
log.debug("SSDP error: %s", exc)
def connection_lost(self, exc: Exception | None) -> None:
pass
async def _fetch_friendly_name(session: aiohttp.ClientSession, location: str) -> str:
"""Fetch the device description XML and extract friendlyName."""
try:
async with session.get(location, timeout=aiohttp.ClientTimeout(total=5)) as resp:
xml_text = await resp.text()
root = ElementTree.fromstring(xml_text)
ns = {"d": "urn:schemas-upnp-org:device-1-0"}
el = root.find(".//d:device/d:friendlyName", ns)
if el is not None and el.text:
return el.text.strip()
except Exception as exc:
log.debug("Could not fetch description from %s: %s", location, exc)
return location
class DLNADiscovery:
"""Find DLNA MediaServers via SSDP M-SEARCH."""
async def find_servers(self, timeout: float = SEARCH_TIMEOUT) -> List[MediaServer]:
loop = asyncio.get_running_loop()
transport, protocol = await loop.create_datagram_endpoint(
_SSDPProtocol,
local_addr=("0.0.0.0", 0),
family=socket.AF_INET,
)
assert isinstance(protocol, _SSDPProtocol)
try:
transport.sendto(_SSDP_SEARCH, (SSDP_ADDR, SSDP_PORT))
await asyncio.sleep(timeout)
finally:
transport.close()
seen: dict[str, str] = {}
for loc, srv in protocol.responses:
if loc not in seen:
seen[loc] = srv
servers: list[MediaServer] = []
if seen:
async with aiohttp.ClientSession() as session:
for loc in seen:
name = await _fetch_friendly_name(session, loc)
servers.append(MediaServer(friendly_name=name, location=loc))
log.info("Discovered %d DLNA server(s).", len(servers))
return servers
+105
View File
@@ -0,0 +1,105 @@
"""Domain models for DLNA servers, containers and media items."""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
class ItemType(Enum):
CONTAINER = "container"
AUDIO = "audio"
VIDEO = "video"
IMAGE = "image"
UNKNOWN = "unknown"
@dataclass
class MediaServer:
"""Represents a discovered DLNA MediaServer on the LAN."""
friendly_name: str
location: str # description URL
udn: str = ""
icon_url: str = ""
def __str__(self) -> str:
return self.friendly_name or self.location
@dataclass
class MediaItem:
"""A single content-directory entry (container or leaf item)."""
object_id: str
title: str
item_type: ItemType = ItemType.UNKNOWN
parent_id: str = "0"
resource_url: str = ""
mime_type: str = ""
size: int = 0
duration: str = ""
album_art_url: str = ""
child_count: int = 0
@property
def is_container(self) -> bool:
return self.item_type == ItemType.CONTAINER
def __str__(self) -> str:
return self.title or self.object_id
def classify_upnp_class(upnp_class: str) -> ItemType:
"""Map a UPnP class string to our ItemType enum."""
c = upnp_class.lower()
if "container" in c:
return ItemType.CONTAINER
if "audio" in c:
return ItemType.AUDIO
if "video" in c:
return ItemType.VIDEO
if "image" in c:
return ItemType.IMAGE
return ItemType.UNKNOWN
def parse_didl_item(didl_item: dict) -> MediaItem:
"""Build a MediaItem from a parsed DIDL-Lite dict (as returned by async-upnp-client)."""
title = didl_item.get("title", "")
object_id = didl_item.get("id", didl_item.get("@id", ""))
parent_id = didl_item.get("parent_id", didl_item.get("@parentID", "0"))
upnp_class = didl_item.get("upnp_class", didl_item.get("class", ""))
item_type = classify_upnp_class(upnp_class)
child_count = int(didl_item.get("child_count", didl_item.get("@childCount", 0)) or 0)
resource_url = ""
mime_type = ""
size = 0
duration = ""
resources = didl_item.get("resources", didl_item.get("res", []))
if isinstance(resources, dict):
resources = [resources]
if resources:
res = resources[0]
if isinstance(res, dict):
resource_url = res.get("url", res.get("uri", res.get("#text", "")))
mime_type = res.get("protocol_info", res.get("@protocolInfo", ""))
size = int(res.get("size", res.get("@size", 0)) or 0)
duration = res.get("duration", res.get("@duration", ""))
elif isinstance(res, str):
resource_url = res
album_art = didl_item.get("album_art_uri", didl_item.get("albumArtURI", ""))
return MediaItem(
object_id=str(object_id),
title=str(title),
item_type=item_type,
parent_id=str(parent_id),
resource_url=str(resource_url),
mime_type=str(mime_type),
size=size,
duration=str(duration),
album_art_url=str(album_art),
child_count=child_count,
)
@@ -0,0 +1,62 @@
"""Input mapping – translates SDL2 events to abstract actions."""
from __future__ import annotations
import ctypes
from enum import Enum, auto
from typing import Optional
import sdl2
class Action(Enum):
UP = auto()
DOWN = auto()
LEFT = auto()
RIGHT = auto()
CONFIRM = auto() # A / Return
BACK = auto() # B / Escape
PAGE_UP = auto() # L shoulder / PageUp
PAGE_DOWN = auto() # R shoulder / PageDown
HUD_MODE = auto() # Y / H
QUIT = auto() # Start / Q
# Keyboard mapping (for desktop testing)
_KEY_MAP: dict[int, Action] = {
sdl2.SDLK_UP: Action.UP,
sdl2.SDLK_DOWN: Action.DOWN,
sdl2.SDLK_LEFT: Action.PAGE_UP,
sdl2.SDLK_RIGHT: Action.PAGE_DOWN,
sdl2.SDLK_RETURN: Action.CONFIRM,
sdl2.SDLK_SPACE: Action.CONFIRM,
sdl2.SDLK_ESCAPE: Action.BACK,
sdl2.SDLK_BACKSPACE: Action.BACK,
sdl2.SDLK_q: Action.QUIT,
sdl2.SDLK_h: Action.HUD_MODE,
sdl2.SDLK_PAGEUP: Action.PAGE_UP,
sdl2.SDLK_PAGEDOWN: Action.PAGE_DOWN,
}
# GameController button mapping (R36S / generic controller)
_BUTTON_MAP: dict[int, Action] = {
sdl2.SDL_CONTROLLER_BUTTON_DPAD_UP: Action.UP,
sdl2.SDL_CONTROLLER_BUTTON_DPAD_DOWN: Action.DOWN,
sdl2.SDL_CONTROLLER_BUTTON_DPAD_LEFT: Action.PAGE_UP,
sdl2.SDL_CONTROLLER_BUTTON_DPAD_RIGHT: Action.PAGE_DOWN,
sdl2.SDL_CONTROLLER_BUTTON_A: Action.CONFIRM,
sdl2.SDL_CONTROLLER_BUTTON_B: Action.BACK,
sdl2.SDL_CONTROLLER_BUTTON_LEFTSHOULDER: Action.PAGE_UP,
sdl2.SDL_CONTROLLER_BUTTON_RIGHTSHOULDER: Action.PAGE_DOWN,
sdl2.SDL_CONTROLLER_BUTTON_Y: Action.HUD_MODE,
sdl2.SDL_CONTROLLER_BUTTON_START: Action.QUIT,
}
def map_key(event: sdl2.SDL_Event) -> Optional[Action]:
"""Map an SDL2 event to an abstract Action, or None."""
if event.type == sdl2.SDL_KEYDOWN:
return _KEY_MAP.get(event.key.keysym.sym)
if event.type == sdl2.SDL_CONTROLLERBUTTONDOWN:
return _BUTTON_MAP.get(event.cbutton.button)
return None
+32
View File
@@ -0,0 +1,32 @@
"""Runtime detection, logging setup and platform helpers."""
from __future__ import annotations
import logging
import os
import platform
import sys
log = logging.getLogger(__name__)
def is_r36s() -> bool:
"""Heuristic: running on aarch64/armv7l with a small screen."""
return platform.machine() in ("aarch64", "armv7l")
def configure_logging() -> None:
level_name = os.environ.get("R36S_LOG_LEVEL", "INFO").upper()
level = getattr(logging, level_name, logging.INFO)
fmt = "%(asctime)s %(name)s %(levelname)s %(message)s"
logging.basicConfig(level=level, format=fmt, stream=sys.stderr)
def sdl_env_setup() -> None:
"""Set SDL environment hints for the R36S if needed."""
if is_r36s():
# Force fbdev or kmsdrm if no display server
if not os.environ.get("DISPLAY") and not os.environ.get("WAYLAND_DISPLAY"):
os.environ.setdefault("SDL_VIDEODRIVER", "kmsdrm")
os.environ.setdefault("SDL_AUDIODRIVER", "alsa")
+46
View File
@@ -0,0 +1,46 @@
"""Abstract player interface."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import Callable
class PlayerBackend(ABC):
"""Minimal playback interface used by the application."""
@abstractmethod
def attach_window(self, window: object) -> None: ...
@abstractmethod
def set_event_callback(self, callback: Callable[..., None] | None) -> None: ...
@abstractmethod
def set_viewport(self, width: int, height: int, top: int, bottom: int, left: int, right: int) -> None: ...
@abstractmethod
def has_new_frame(self) -> bool: ...
@abstractmethod
def render(self, renderer: object) -> bool: ...
@abstractmethod
def play(self, url: str) -> None: ...
@abstractmethod
def stop(self) -> None: ...
@abstractmethod
def toggle_pause(self) -> bool: ...
@abstractmethod
def seek(self, seconds: int) -> None: ...
@abstractmethod
def change_volume(self, delta: int) -> int: ...
@abstractmethod
def shutdown(self) -> None: ...
@abstractmethod
def is_playing(self) -> bool: ...
@@ -0,0 +1,440 @@
"""Integrated GStreamer playback backend rendered through SDL textures."""
from __future__ import annotations
import ctypes
import logging
import os
import threading
import time
from dataclasses import dataclass
from typing import Any, Callable
import gi
import sdl2
from sdl2 import version as sdl_version
from r36s_dlna_browser.player.backend import PlayerBackend
gi.require_version("Gst", "1.0")
gi.require_version("GstApp", "1.0")
gi.require_version("GstVideo", "1.0")
from gi.repository import Gst, GstApp, GstVideo
log = logging.getLogger(__name__)
Gst.init(None)
def _wm_subsystem_name(subsystem: int) -> str:
mapping = {
sdl2.SDL_SYSWM_UNKNOWN: "unknown",
sdl2.SDL_SYSWM_WINDOWS: "windows",
sdl2.SDL_SYSWM_X11: "x11",
sdl2.SDL_SYSWM_DIRECTFB: "directfb",
sdl2.SDL_SYSWM_COCOA: "cocoa",
sdl2.SDL_SYSWM_UIKIT: "uikit",
sdl2.SDL_SYSWM_WAYLAND: "wayland",
sdl2.SDL_SYSWM_MIR: "mir",
sdl2.SDL_SYSWM_WINRT: "winrt",
sdl2.SDL_SYSWM_ANDROID: "android",
sdl2.SDL_SYSWM_VIVANTE: "vivante",
sdl2.SDL_SYSWM_OS2: "os2",
sdl2.SDL_SYSWM_HAIKU: "haiku",
sdl2.SDL_SYSWM_KMSDRM: "kmsdrm",
sdl2.SDL_SYSWM_RISCOS: "riscos",
}
return mapping.get(subsystem, f"wm-{subsystem}")
def _resolve_window_subsystem(window: object) -> str:
if not window:
return "unbound"
info = sdl2.SDL_SysWMinfo()
sdl_version.SDL_VERSION(info.version)
if not sdl2.SDL_GetWindowWMInfo(window, ctypes.byref(info)):
return "unknown"
return _wm_subsystem_name(info.subsystem)
def _as_uri(url: str) -> str:
if "://" in url:
return url
return Gst.filename_to_uri(os.path.abspath(url))
class _NoopCallback:
def __call__(self, *args: Any) -> None:
return None
@dataclass
class _Frame:
width: int
height: int
pitch: int
pixels: bytes
class GStreamerBackend(PlayerBackend):
"""Decode video with GStreamer and render frames through the SDL renderer."""
def __init__(
self,
gst_module=Gst,
gst_app_module=GstApp,
gst_video_module=GstVideo,
appsink_factory: Callable[[], Any] | None = None,
playbin_factory: Callable[[], Any] | None = None,
subsystem: str = "test",
) -> None:
self._gst = gst_module
self._gst_app = gst_app_module
self._gst_video = gst_video_module
self._appsink_factory = appsink_factory or self._default_appsink_factory
self._playbin_factory = playbin_factory or self._default_playbin_factory
self._event_callback: Callable[..., None] = _NoopCallback()
self._window = None
self._wm_subsystem = subsystem
self._viewport = (640, 480, 0, 0, 0, 0)
self._pipeline = None
self._video_sink = None
self._bus = None
self._bus_thread = None
self._bus_running = False
self._lock = threading.RLock()
self._frame_lock = threading.RLock()
self._is_playing = False
self._paused = False
self._volume = 100
self._buffer_percent = 0
self._last_query = 0.0
self._latest_frame: _Frame | None = None
self._frame_dirty = False
self._texture = None
self._texture_renderer = None
self._texture_size = (0, 0)
self._resolution = ""
def attach_window(self, window: object) -> None:
self._window = window
self._wm_subsystem = _resolve_window_subsystem(window)
log.info("Playback window subsystem: %s", self._wm_subsystem)
def set_event_callback(self, callback: Callable[..., None] | None) -> None:
self._event_callback = callback or _NoopCallback()
def set_viewport(self, width: int, height: int, top: int, bottom: int, left: int, right: int) -> None:
self._viewport = (width, height, top, bottom, left, right)
def has_new_frame(self) -> bool:
with self._frame_lock:
return self._frame_dirty
def render(self, renderer: object) -> bool:
with self._frame_lock:
frame = self._latest_frame
if frame is None:
return False
if self._texture is None or self._texture_renderer != renderer or self._texture_size != (frame.width, frame.height):
self._destroy_texture()
self._texture = sdl2.SDL_CreateTexture(
renderer,
sdl2.SDL_PIXELFORMAT_BGRA32,
sdl2.SDL_TEXTUREACCESS_STREAMING,
frame.width,
frame.height,
)
if not self._texture:
log.error("Could not create SDL texture for video frame.")
return False
self._texture_renderer = renderer
self._texture_size = (frame.width, frame.height)
if self._frame_dirty:
pixel_buffer = ctypes.create_string_buffer(frame.pixels)
result = sdl2.SDL_UpdateTexture(self._texture, None, pixel_buffer, frame.pitch)
if result != 0:
log.error("Could not upload SDL video texture: %s", sdl2.SDL_GetError())
return False
self._frame_dirty = False
dst = self._fit_frame_to_viewport(frame.width, frame.height)
sdl2.SDL_RenderCopy(renderer, self._texture, None, dst)
return True
def play(self, url: str) -> None:
if not url:
raise ValueError("Playback URL is empty.")
pipeline = self._ensure_pipeline()
uri = _as_uri(url)
with self._lock:
pipeline.set_state(self._gst.State.NULL)
pipeline.set_property("uri", uri)
self._paused = False
self._buffer_percent = 100
self._clear_frames()
result = pipeline.set_state(self._gst.State.PLAYING)
if result == self._gst.StateChangeReturn.FAILURE:
raise RuntimeError(f"Failed to start SDL-rendered GStreamer playback: {uri}")
self._set_playing(True, notify=False)
self._event_callback("buffering", 100)
log.info("Starting SDL-rendered GStreamer playback: %s", url)
def stop(self) -> None:
with self._lock:
if self._pipeline is None:
self._set_playing(False, notify=False)
return
self._pipeline.set_state(self._gst.State.NULL)
self._paused = False
self._clear_frames()
self._set_playing(False, notify=True)
def toggle_pause(self) -> bool:
with self._lock:
if self._pipeline is None:
return False
self._paused = not self._paused
target = self._gst.State.PAUSED if self._paused else self._gst.State.PLAYING
self._pipeline.set_state(target)
self._event_callback("paused", self._paused)
return self._paused
def seek(self, seconds: int) -> None:
with self._lock:
if self._pipeline is None or seconds == 0:
return
ok_pos, current = self._pipeline.query_position(self._gst.Format.TIME)
current_ns = current if ok_pos else 0
target_ns = max(0, current_ns + int(seconds * self._gst.SECOND))
self._pipeline.seek_simple(
self._gst.Format.TIME,
self._gst.SeekFlags.FLUSH | self._gst.SeekFlags.KEY_UNIT,
target_ns,
)
def change_volume(self, delta: int) -> int:
with self._lock:
if self._pipeline is None:
return self._volume
self._volume = max(0, min(130, self._volume + delta))
self._pipeline.set_property("volume", self._volume / 100.0)
self._event_callback("volume", self._volume)
return self._volume
def shutdown(self) -> None:
self._stop_bus_thread()
with self._lock:
if self._pipeline is not None:
self._pipeline.set_state(self._gst.State.NULL)
self._pipeline = None
self._video_sink = None
self._bus = None
self._clear_frames()
self._set_playing(False, notify=False)
def is_playing(self) -> bool:
return self._is_playing
def _default_playbin_factory(self):
pipeline = self._gst.ElementFactory.make("playbin", "player")
if pipeline is None:
raise RuntimeError("GStreamer playbin is not available.")
return pipeline
def _default_appsink_factory(self):
sink = self._gst.ElementFactory.make("appsink", "video-output")
if sink is None:
raise RuntimeError("GStreamer appsink is not available.")
return sink
def _ensure_pipeline(self):
with self._lock:
if self._pipeline is not None:
return self._pipeline
self._pipeline = self._playbin_factory()
self._video_sink = self._create_appsink()
self._pipeline.set_property("video-sink", self._video_sink)
self._pipeline.set_property("volume", self._volume / 100.0)
self._bus = self._pipeline.get_bus()
self._start_bus_thread()
return self._pipeline
def _create_appsink(self):
sink = self._appsink_factory()
sink.set_property("emit-signals", True)
sink.set_property("sync", True)
sink.set_property("max-buffers", 2)
sink.set_property("drop", True)
sink.set_property("caps", self._gst.Caps.from_string("video/x-raw,format=BGRA"))
sink.connect("new-sample", self._on_new_sample)
return sink
def _on_new_sample(self, sink) -> Any:
sample = sink.emit("pull-sample")
if sample is None:
return self._flow_ok()
buffer = sample.get_buffer()
caps = sample.get_caps()
if buffer is None or caps is None:
return self._flow_ok()
info = self._gst_video.VideoInfo.new_from_caps(caps)
if info is None:
return self._flow_ok()
width = int(info.width)
height = int(info.height)
pitch = int(info.stride[0]) if info.stride else width * 4
pixels = buffer.extract_dup(0, buffer.get_size())
resolution = f"{width}x{height}"
with self._frame_lock:
self._latest_frame = _Frame(width=width, height=height, pitch=pitch, pixels=pixels)
self._frame_dirty = True
if resolution != self._resolution:
self._resolution = resolution
self._event_callback("resolution", resolution)
return self._flow_ok()
def _flow_ok(self) -> Any:
flow_return = getattr(self._gst, "FlowReturn", None)
if flow_return is None:
return 0
return flow_return.OK
def _start_bus_thread(self) -> None:
if self._bus_thread is not None and self._bus_thread.is_alive():
return
self._bus_running = True
self._bus_thread = threading.Thread(target=self._poll_bus, name="gst-bus", daemon=True)
self._bus_thread.start()
def _stop_bus_thread(self) -> None:
self._bus_running = False
if self._bus_thread and self._bus_thread.is_alive():
self._bus_thread.join(timeout=1.0)
self._bus_thread = None
def _poll_bus(self) -> None:
while self._bus_running:
bus = self._bus
if bus is None:
time.sleep(0.05)
continue
message = bus.timed_pop_filtered(
100 * self._gst.MSECOND,
self._gst.MessageType.ERROR
| self._gst.MessageType.EOS
| self._gst.MessageType.BUFFERING
| self._gst.MessageType.STATE_CHANGED,
)
if message is not None:
self._handle_bus_message(message)
if time.monotonic() - self._last_query >= 0.25:
self._last_query = time.monotonic()
self._emit_playback_metrics()
def _handle_bus_message(self, message) -> None:
msg_type = message.type
if msg_type == self._gst.MessageType.EOS:
log.info("SDL-rendered GStreamer playback reached end-of-stream")
with self._lock:
if self._pipeline is not None:
self._pipeline.set_state(self._gst.State.NULL)
self._clear_frames()
self._set_playing(False, notify=True)
return
if msg_type == self._gst.MessageType.ERROR:
err, debug = message.parse_error()
text = err.message if hasattr(err, "message") else str(err)
if debug:
text = f"{text} ({debug})"
log.error("GStreamer playback error: %s", text)
with self._lock:
if self._pipeline is not None:
self._pipeline.set_state(self._gst.State.NULL)
self._clear_frames()
self._set_playing(False, notify=False)
self._event_callback("error", text)
return
if msg_type == self._gst.MessageType.BUFFERING:
percent = int(message.parse_buffering())
self._buffer_percent = percent
self._event_callback("buffering", percent)
return
if msg_type == self._gst.MessageType.STATE_CHANGED and message.src == self._pipeline:
_old, new, _pending = message.parse_state_changed()
paused = new == self._gst.State.PAUSED
if new == self._gst.State.PLAYING:
self._paused = False
self._event_callback("paused", False)
elif paused:
self._paused = True
self._event_callback("paused", True)
def _emit_playback_metrics(self) -> None:
with self._lock:
pipeline = self._pipeline
if pipeline is None or not self._is_playing:
return
ok_pos, position = pipeline.query_position(self._gst.Format.TIME)
ok_dur, duration = pipeline.query_duration(self._gst.Format.TIME)
volume = int(round(float(pipeline.get_property("volume") or 1.0) * 100))
if ok_pos:
self._event_callback("position", position / self._gst.SECOND)
if ok_dur and duration > 0:
self._event_callback("duration", duration / self._gst.SECOND)
self._event_callback("volume", volume)
self._event_callback("buffering", self._buffer_percent)
if self._resolution:
self._event_callback("resolution", self._resolution)
def _fit_frame_to_viewport(self, frame_width: int, frame_height: int) -> sdl2.SDL_Rect:
width, height, top, bottom, left, right = self._viewport
viewport_w = max(1, width - left - right)
viewport_h = max(1, height - top - bottom)
if frame_width <= 0 or frame_height <= 0:
return sdl2.SDL_Rect(left, top, viewport_w, viewport_h)
scale = min(viewport_w / frame_width, viewport_h / frame_height)
draw_w = max(1, int(frame_width * scale))
draw_h = max(1, int(frame_height * scale))
draw_x = left + (viewport_w - draw_w) // 2
draw_y = top + (viewport_h - draw_h) // 2
return sdl2.SDL_Rect(draw_x, draw_y, draw_w, draw_h)
def _destroy_texture(self) -> None:
if self._texture is not None:
sdl2.SDL_DestroyTexture(self._texture)
self._texture = None
self._texture_renderer = None
self._texture_size = (0, 0)
def _clear_frames(self) -> None:
with self._frame_lock:
self._latest_frame = None
self._frame_dirty = False
self._resolution = ""
self._destroy_texture()
def _set_playing(self, value: bool, notify: bool) -> None:
changed = self._is_playing != value
self._is_playing = value
if changed and notify:
self._event_callback("stopped" if not value else "started")
+301
View File
@@ -0,0 +1,301 @@
"""Screen rendering helpers for the SDL2 UI."""
from __future__ import annotations
import ctypes
from typing import TYPE_CHECKING
import sdl2
try:
import sdl2.sdlttf as ttf
except ImportError:
ttf = None # type: ignore[assignment]
from r36s_dlna_browser.ui import theme
if TYPE_CHECKING:
from r36s_dlna_browser.dlna.browser_state import BrowserState
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)
def _render_text(
renderer: ctypes.c_void_p,
font: ctypes.c_void_p,
text: str,
x: int,
y: int,
color: tuple,
max_w: int = 0,
) -> None:
if not text or not font or not ttf:
return
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
if max_w and w > max_w:
w = max_w
dst = sdl2.SDL_Rect(x, y, w, h)
src = sdl2.SDL_Rect(0, 0, w, h)
sdl2.SDL_RenderCopy(renderer, tex, src, dst)
sdl2.SDL_DestroyTexture(tex)
def _fit_text(font: ctypes.c_void_p, text: str, max_w: int) -> str:
if not text or not font or not ttf or max_w <= 0:
return text
width = ctypes.c_int()
height = ctypes.c_int()
encoded = text.encode("utf-8")
if ttf.TTF_SizeUTF8(font, encoded, ctypes.byref(width), ctypes.byref(height)) == 0 and width.value <= max_w:
return text
ellipsis = "..."
ellipsis_width = ctypes.c_int()
if ttf.TTF_SizeUTF8(font, ellipsis.encode("utf-8"), ctypes.byref(ellipsis_width), ctypes.byref(height)) != 0:
return text
if ellipsis_width.value >= max_w:
return ""
trimmed = text
while trimmed:
trimmed = trimmed[:-1]
candidate = (trimmed.rstrip() + ellipsis).encode("utf-8")
if ttf.TTF_SizeUTF8(font, candidate, ctypes.byref(width), ctypes.byref(height)) == 0 and width.value <= max_w:
return trimmed.rstrip() + ellipsis
return ellipsis if ellipsis_width.value <= max_w else ""
def _fill_rect(renderer, x: int, y: int, w: int, h: int, color: tuple) -> None:
sdl2.SDL_SetRenderDrawBlendMode(renderer, sdl2.SDL_BLENDMODE_BLEND)
sdl2.SDL_SetRenderDrawColor(renderer, color[0], color[1], color[2],
color[3] if len(color) > 3 else 255)
rect = sdl2.SDL_Rect(x, y, w, h)
sdl2.SDL_RenderFillRect(renderer, rect)
def _draw_rect(renderer, x: int, y: int, w: int, h: int, color: tuple) -> None:
sdl2.SDL_SetRenderDrawBlendMode(renderer, sdl2.SDL_BLENDMODE_BLEND)
sdl2.SDL_SetRenderDrawColor(renderer, color[0], color[1], color[2],
color[3] if len(color) > 3 else 255)
rect = sdl2.SDL_Rect(x, y, w, h)
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."""
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)
sdl2.SDL_RenderCopy(renderer, texture, None, dst)
def _format_time(seconds: float) -> str:
total = max(0, int(seconds))
mins, secs = divmod(total, 60)
hours, mins = divmod(mins, 60)
if hours:
return f"{hours}:{mins:02d}:{secs:02d}"
return f"{mins:02d}:{secs:02d}"
def _draw_progress_bar(renderer, x: int, y: int, w: int, h: int, progress: float) -> None:
_fill_rect(renderer, x, y, w, h, theme.PLAYBACK_OVERLAY_DIM)
fill_w = max(0, min(w, int(w * max(0.0, min(1.0, progress)))))
if fill_w:
_fill_rect(renderer, x, y, fill_w, h, theme.PLAYBACK_OVERLAY_ACCENT)
# ── 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_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_server_list(renderer, font, state: "BrowserState", icons: dict | None = None) -> None:
draw_header(renderer, font, "DLNA Servers")
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")
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
vis = theme.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
if idx == state.server_cursor:
_fill_rect(renderer, 0, y, theme.SCREEN_W, theme.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)
draw_status_bar(renderer, font, f"A=Browse B=Refresh Start=Quit ({len(state.servers)} servers)")
def draw_browse_list(renderer, font, state: "BrowserState", 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)
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")
return
has_icons = bool(icons)
icon_gap = theme.ICON_SIZE + 4 if has_icons else 0
text_x = theme.LIST_PAD_LEFT + icon_gap
vis = theme.VISIBLE_ITEMS
offset = state.scroll_offset
cursor = state.cursor
for i in range(vis):
idx = offset + i
if idx >= len(items):
break
item = items[idx]
y = theme.LIST_TOP + i * theme.LIST_ITEM_H
if idx == cursor:
_fill_rect(renderer, 0, y, theme.SCREEN_W, theme.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
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)
draw_status_bar(
renderer, font,
f"A=Open B=Back L/R=Page Start=Quit ({cursor+1}/{len(items)})"
)
def draw_playback(renderer, font, state: "BrowserState", 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
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))
progress = (current / total) if total > 0 else 0.0
progress_x = left
progress_w = width - left - right
status_icon = icons.get("hud-pause" if state.playback_paused else "hud-play") if icons else None
volume_icon = icons.get("hud-volume") if icons else None
seek_icon = icons.get("hud-seek") if icons else None
stop_icon = icons.get("hud-stop") if icons else None
hud_icon = icons.get("hud-display") if icons else None
hud_mode = {
theme.PLAYBACK_HUD_AUTO: "AUTO",
theme.PLAYBACK_HUD_PINNED: "FIXED",
theme.PLAYBACK_HUD_HIDDEN: "HIDDEN",
}.get(state.playback_hud_mode, "AUTO")
time_text = f"{_format_time(current)} / {_format_time(total)}"
time_w = 156
time_x = width - left - time_w
title_x = left + 98
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_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)
info_y = height - bottom_h + 23
_render_icon_small(renderer, volume_icon, left, info_y + 1)
_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, hud_mode, width - 70, info_y - 1, theme.DIM_TEXT, 60)
controls_y = height - 20
cx = left
_render_icon_small(renderer, volume_icon, cx, controls_y - 1)
_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_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_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_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_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")
_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")
+463
View File
@@ -0,0 +1,463 @@
"""SDL2 window, event loop, rendering and input handling."""
from __future__ import annotations
import ctypes
import logging
import os
import queue
import time
from enum import Enum, auto
from typing import Any
import sdl2
try:
import sdl2.sdlttf as ttf
except ImportError:
ttf = None # type: ignore[assignment]
try:
import sdl2.sdlimage as sdl_img
except ImportError:
sdl_img = None # type: ignore[assignment]
from r36s_dlna_browser.dlna.browser_state import BrowserState
from r36s_dlna_browser.dlna.models import ItemType, MediaItem
from r36s_dlna_browser.player.backend import PlayerBackend
from r36s_dlna_browser.platform.controls import Action, map_key
from r36s_dlna_browser.ui import theme
from r36s_dlna_browser.ui import screens
log = logging.getLogger(__name__)
class Screen(Enum):
SERVERS = auto()
BROWSE = auto()
PLAYBACK = auto()
ERROR = auto()
class SDLApp:
"""Owns the SDL2 window and drives the main loop."""
def __init__(
self,
ui_queue: queue.Queue[Any],
cmd_queue: queue.Queue[Any],
browser_state: BrowserState,
player: PlayerBackend,
) -> None:
self._ui_q = ui_queue
self._cmd_q = cmd_queue
self._state = browser_state
self._player = player
self._screen = Screen.SERVERS
self._running = False
self._window = None
self._renderer = None
self._font = None
self._playback_font = None
self._icons: dict = {}
self._playback_clear_once = False
self._needs_redraw = True
self._last_playback_draw_at = 0.0
self._last_playback_snapshot: tuple[Any, ...] | None = None
self._last_playback_interaction_at = 0.0
# ── lifecycle ────────────────────────────────────────────────
def run(self) -> None:
self._init_sdl()
self._running = True
try:
while self._running:
self._poll_events()
self._process_ui_queue()
self._refresh_playback_hud_visibility()
if self._should_draw():
self._draw()
sdl2.SDL_Delay(theme.FRAME_DELAY_MS)
finally:
self._cleanup()
def _init_sdl(self) -> None:
sdl2.SDL_Init(sdl2.SDL_INIT_VIDEO | sdl2.SDL_INIT_GAMECONTROLLER)
if ttf:
ttf.TTF_Init()
self._window = sdl2.SDL_CreateWindow(
b"R36S DLNA Browser",
sdl2.SDL_WINDOWPOS_CENTERED,
sdl2.SDL_WINDOWPOS_CENTERED,
theme.SCREEN_W,
theme.SCREEN_H,
sdl2.SDL_WINDOW_SHOWN,
)
self._renderer = sdl2.SDL_CreateRenderer(
self._window, -1,
sdl2.SDL_RENDERER_ACCELERATED | sdl2.SDL_RENDERER_PRESENTVSYNC,
)
# Open first available gamecontroller
for i in range(sdl2.SDL_NumJoysticks()):
if sdl2.SDL_IsGameController(i):
sdl2.SDL_GameControllerOpen(i)
break
self._font = self._load_font(theme.FONT_SIZE)
self._playback_font = self._load_font(theme.PLAYBACK_FONT_SIZE)
if not self._font:
log.error("Could not load any font – UI text will be missing.")
self._icons = self._load_icons()
self._player.attach_window(self._window)
self._sync_player_viewport()
def _load_icons(self) -> dict:
icons: dict = {}
if not sdl_img:
return icons
sdl_img.IMG_Init(sdl_img.IMG_INIT_PNG)
mapping = [
("folder", ItemType.CONTAINER),
("audio", ItemType.AUDIO),
("video", ItemType.VIDEO),
("image", ItemType.IMAGE),
("playing", "playing"),
("server", "server"),
("hud-play", "hud-play"),
("hud-pause", "hud-pause"),
("hud-stop", "hud-stop"),
("hud-seek", "hud-seek"),
("hud-volume", "hud-volume"),
("hud-display", "hud-display"),
]
for fname, key in mapping:
path = theme.ICONS_DIR / f"{fname}.png"
if path.exists():
surf = sdl_img.IMG_Load(str(path).encode())
if surf:
tex = sdl2.SDL_CreateTextureFromSurface(self._renderer, surf)
sdl2.SDL_FreeSurface(surf)
if tex:
icons[key] = tex
log.debug("Loaded icon: %s", fname)
log.info("Loaded %d icon(s).", len(icons))
return icons
def _load_font(self, size: int):
if not ttf:
log.warning("SDL2_ttf not available – text rendering disabled.")
return None
for path in theme.FONT_SEARCH_PATHS:
expanded = os.path.expanduser(path)
if os.path.isfile(expanded):
f = ttf.TTF_OpenFont(expanded.encode(), size)
if f:
log.info("Loaded font: %s", expanded)
return f
return None
def _cleanup(self) -> None:
for tex in self._icons.values():
sdl2.SDL_DestroyTexture(tex)
self._icons = {}
if sdl_img:
sdl_img.IMG_Quit()
if self._font and ttf:
ttf.TTF_CloseFont(self._font)
if self._playback_font and ttf:
ttf.TTF_CloseFont(self._playback_font)
if self._renderer:
sdl2.SDL_DestroyRenderer(self._renderer)
if self._window:
sdl2.SDL_DestroyWindow(self._window)
if ttf:
ttf.TTF_Quit()
sdl2.SDL_Quit()
# ── events ───────────────────────────────────────────────────
def _poll_events(self) -> None:
event = sdl2.SDL_Event()
while sdl2.SDL_PollEvent(ctypes.byref(event)):
if event.type == sdl2.SDL_QUIT:
self._running = False
return
if event.type == sdl2.SDL_WINDOWEVENT and event.window.event in (
sdl2.SDL_WINDOWEVENT_MOVED,
sdl2.SDL_WINDOWEVENT_SIZE_CHANGED,
sdl2.SDL_WINDOWEVENT_RESIZED,
):
if self._window and event.window.windowID == sdl2.SDL_GetWindowID(self._window):
self._sync_player_viewport()
self._mark_dirty()
action = map_key(event)
if action is not None:
self._handle_action(action)
def _process_ui_queue(self) -> None:
while True:
try:
msg = self._ui_q.get_nowait()
except queue.Empty:
break
self._handle_ui_message(msg)
def _handle_ui_message(self, msg: tuple) -> None:
kind = msg[0]
if kind == "servers_updated":
self._state.loading = False
self._screen = Screen.SERVERS
elif kind == "items_updated":
self._state.loading = False
self._screen = Screen.BROWSE
elif kind == "playback_started":
self._state.playback_title = msg[1] if len(msg) > 1 else ""
self._state.playback_paused = False
self._show_playback_hud(force=True)
self._playback_clear_once = True
self._screen = Screen.PLAYBACK
elif kind == "playback_paused":
self._state.playback_paused = True
self._show_playback_hud(force=True)
elif kind == "playback_resumed":
self._state.playback_paused = False
self._show_playback_hud(force=True)
elif kind == "playback_stopped":
self._state.playback_paused = False
self._screen = Screen.BROWSE if self._state.in_browse_mode else Screen.SERVERS
elif kind == "error":
self._state.error = msg[1] if len(msg) > 1 else "Unknown error"
self._state.loading = False
self._screen = Screen.ERROR
self._mark_dirty()
# ── input dispatch ───────────────────────────────────────────
def _handle_action(self, action: Action) -> None:
if action == Action.QUIT:
self._running = False
return
if self._screen == Screen.ERROR:
if action in (Action.CONFIRM, Action.BACK):
self._state.error = ""
self._screen = Screen.BROWSE if self._state.in_browse_mode else Screen.SERVERS
self._mark_dirty()
return
if self._screen == Screen.PLAYBACK:
if action == Action.BACK:
self._cmd_q.put(("stop",))
elif action == Action.CONFIRM:
self._cmd_q.put(("toggle_pause",))
elif action == Action.UP:
self._cmd_q.put(("volume", 5))
elif action == Action.DOWN:
self._cmd_q.put(("volume", -5))
elif action == Action.PAGE_UP:
self._cmd_q.put(("seek", -10))
elif action == Action.PAGE_DOWN:
self._cmd_q.put(("seek", 10))
elif action == Action.HUD_MODE:
self._cycle_playback_hud_mode()
self._mark_dirty()
self._show_playback_hud(force=action != Action.HUD_MODE)
return
if self._screen == Screen.SERVERS:
self._handle_server_action(action)
elif self._screen == Screen.BROWSE:
self._handle_browse_action(action)
def _handle_server_action(self, action: Action) -> None:
n = len(self._state.servers)
if action == Action.UP and n:
self._state.server_cursor = max(0, self._state.server_cursor - 1)
self._adjust_server_scroll()
self._mark_dirty()
elif action == Action.DOWN and n:
self._state.server_cursor = min(n - 1, self._state.server_cursor + 1)
self._adjust_server_scroll()
self._mark_dirty()
elif action == Action.CONFIRM:
srv = self._state.selected_server
if srv:
obj_id = self._state.enter_server(srv)
self._state.loading = True
self._cmd_q.put(("browse", srv.location, obj_id))
else:
# No server selected – refresh
self._state.loading = True
self._cmd_q.put(("discover",))
self._mark_dirty()
elif action == Action.BACK:
# Refresh server list
self._state.loading = True
self._cmd_q.put(("discover",))
self._mark_dirty()
def _handle_browse_action(self, action: Action) -> None:
items = self._state.current_items
n = len(items)
if action == Action.UP and n:
self._state.cursor -= 1
self._adjust_browse_scroll()
self._mark_dirty()
elif action == Action.DOWN and n:
self._state.cursor += 1
self._adjust_browse_scroll()
self._mark_dirty()
elif action == Action.PAGE_UP and n:
self._state.cursor = max(0, self._state.cursor - theme.PAGE_SIZE)
self._adjust_browse_scroll()
self._mark_dirty()
elif action == Action.PAGE_DOWN and n:
self._state.cursor = min(n - 1, self._state.cursor + theme.PAGE_SIZE)
self._adjust_browse_scroll()
self._mark_dirty()
elif action == Action.CONFIRM:
item = self._state.selected_item()
if item:
if item.is_container:
self._state.loading = True
lv = self._state.current_level
loc = lv.server_location if lv else ""
self._cmd_q.put(("browse", loc, item.object_id))
elif item.resource_url:
self._cmd_q.put(("play", item.resource_url, item.title))
self._mark_dirty()
elif action == Action.BACK:
if not self._state.go_back():
self._screen = Screen.SERVERS
else:
self._screen = Screen.BROWSE
self._mark_dirty()
def _adjust_server_scroll(self) -> None:
vis = theme.VISIBLE_ITEMS
cur = self._state.server_cursor
off = self._state.server_scroll_offset
if cur < off:
self._state.server_scroll_offset = cur
elif cur >= off + vis:
self._state.server_scroll_offset = cur - vis + 1
def _adjust_browse_scroll(self) -> None:
vis = theme.VISIBLE_ITEMS
cur = self._state.cursor
off = self._state.scroll_offset
if cur < off:
self._state.scroll_offset = cur
elif cur >= off + vis:
self._state.scroll_offset = cur - vis + 1
# ── drawing ──────────────────────────────────────────────────
def _draw(self) -> None:
r = self._renderer
if self._screen == Screen.PLAYBACK:
sdl2.SDL_SetRenderDrawColor(r, *theme.PLAYBACK_BG_COLOR)
else:
sdl2.SDL_SetRenderDrawColor(r, *theme.BG_COLOR)
sdl2.SDL_RenderClear(r)
self._playback_clear_once = False
f = self._font
if self._screen == Screen.SERVERS:
screens.draw_server_list(r, f, self._state, self._icons)
elif self._screen == Screen.BROWSE:
screens.draw_browse_list(r, f, self._state, self._icons)
elif self._screen == Screen.PLAYBACK:
self._player.render(r)
screens.draw_playback(r, self._playback_font or f, self._state, self._icons)
elif self._screen == Screen.ERROR:
screens.draw_error(r, f, self._state)
sdl2.SDL_RenderPresent(r)
self._needs_redraw = False
if self._screen == Screen.PLAYBACK:
self._last_playback_draw_at = time.monotonic()
self._last_playback_snapshot = self._playback_snapshot()
def _mark_dirty(self) -> None:
self._needs_redraw = True
def _playback_snapshot(self) -> tuple[Any, ...]:
return (
self._state.playback_title,
self._state.playback_paused,
round(self._state.playback_position, 1),
round(self._state.playback_duration, 1),
self._state.playback_volume,
self._state.playback_buffer_percent,
self._state.playback_resolution,
self._state.playback_backend,
self._state.playback_hud_visible,
self._state.playback_hud_mode,
)
def _refresh_playback_hud_visibility(self) -> None:
if self._screen != Screen.PLAYBACK:
return
if self._state.playback_hud_mode == theme.PLAYBACK_HUD_HIDDEN:
return
if self._state.playback_hud_mode == theme.PLAYBACK_HUD_PINNED or self._state.playback_paused:
if not self._state.playback_hud_visible:
self._state.playback_hud_visible = True
self._mark_dirty()
return
visible = (time.monotonic() - self._last_playback_interaction_at) < (theme.PLAYBACK_HUD_AUTOHIDE_MS / 1000.0)
if visible != self._state.playback_hud_visible:
self._state.playback_hud_visible = visible
self._mark_dirty()
def _show_playback_hud(self, force: bool = False) -> None:
self._last_playback_interaction_at = time.monotonic()
if force or self._state.playback_hud_mode != theme.PLAYBACK_HUD_HIDDEN:
if not self._state.playback_hud_visible:
self._state.playback_hud_visible = True
self._mark_dirty()
def _cycle_playback_hud_mode(self) -> None:
next_mode = {
theme.PLAYBACK_HUD_AUTO: theme.PLAYBACK_HUD_PINNED,
theme.PLAYBACK_HUD_PINNED: theme.PLAYBACK_HUD_HIDDEN,
theme.PLAYBACK_HUD_HIDDEN: theme.PLAYBACK_HUD_AUTO,
}
self._state.playback_hud_mode = next_mode[self._state.playback_hud_mode]
self._state.playback_hud_visible = self._state.playback_hud_mode != theme.PLAYBACK_HUD_HIDDEN
self._last_playback_interaction_at = time.monotonic()
def _should_draw(self) -> bool:
if self._needs_redraw:
return True
if self._screen != Screen.PLAYBACK:
return False
if self._player.has_new_frame():
return True
now = time.monotonic()
if now - self._last_playback_draw_at < (theme.PLAYBACK_HUD_REFRESH_MS / 1000.0):
return False
return self._playback_snapshot() != self._last_playback_snapshot
def _sync_player_viewport(self) -> None:
width = ctypes.c_int()
height = ctypes.c_int()
if self._window:
sdl2.SDL_GetWindowSize(self._window, ctypes.byref(width), ctypes.byref(height))
if width.value <= 0 or height.value <= 0:
width.value = theme.SCREEN_W
height.value = theme.SCREEN_H
self._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,
)
+80
View File
@@ -0,0 +1,80 @@
"""UI layout constants for a 640×480 display."""
from __future__ import annotations
from pathlib import Path
# Screen
SCREEN_W = 640
SCREEN_H = 480
# Colors (R, G, B, A)
BG_COLOR = (24, 24, 32, 255)
PLAYBACK_BG_COLOR = (0, 0, 0, 255)
TEXT_COLOR = (220, 220, 220, 255)
HIGHLIGHT_BG = (50, 80, 160, 255)
HIGHLIGHT_TEXT = (255, 255, 255, 255)
DIM_TEXT = (140, 140, 150, 255)
ERROR_COLOR = (220, 60, 60, 255)
HEADER_BG = (32, 32, 48, 255)
STATUS_BG = (20, 20, 28, 255)
# Typography
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
PLAYBACK_HUD_REFRESH_MS = 250
PLAYBACK_HUD_AUTOHIDE_MS = 2500
PLAYBACK_HUD_HIDDEN = 0
PLAYBACK_HUD_AUTO = 1
PLAYBACK_HUD_PINNED = 2
PACKAGE_ROOT = Path(__file__).resolve().parents[1]
ASSETS_DIR = PACKAGE_ROOT / "assets"
ICONS_DIR = ASSETS_DIR / "icons"
ICON_SIZE = 20
# Playback HUD / video viewport
PLAYBACK_HUD_TOP = 30
PLAYBACK_HUD_BOTTOM = 50
PLAYBACK_HUD_SIDE = 10
PLAYBACK_PROGRESS_H = 8
PLAYBACK_OVERLAY_BG = (10, 14, 22, 170)
PLAYBACK_OVERLAY_ACCENT = (84, 124, 224, 255)
PLAYBACK_OVERLAY_DIM = (220, 220, 220, 72)
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"),
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/TTF/DejaVuSans.ttf",
"/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf",
"/usr/share/fonts/google-noto-vf/NotoSans[wght].ttf",
"/usr/share/fonts/liberation-sans-fonts/LiberationSans-Regular.ttf",
]