Initial import
@@ -0,0 +1 @@
|
||||
"""R36S DLNA Browser – lightweight DLNA media browser for R36S handhelds."""
|
||||
@@ -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()
|
||||
@@ -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])))
|
||||
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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 |
|
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>
|
||||
|
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 |
|
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 |
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
]
|
||||