@ -0,0 +1,104 @@
|
||||
# Copilot Instructions |
||||
|
||||
## Product Goal |
||||
|
||||
Build a balanced, production-oriented first milestone for an R36S DLNA browser with Wi-Fi support. |
||||
|
||||
The application must: |
||||
|
||||
- run on R36S-class Linux firmware derived from ArkOS |
||||
- use Python for the application logic |
||||
- use SDL2 for the user interface and input handling |
||||
- discover DLNA/UPnP media servers on the local LAN |
||||
- allow hierarchical browsing of remote media content |
||||
- support local playback on the device |
||||
- stay lightweight enough for RK3326-class hardware |
||||
|
||||
## Balanced Development Plan |
||||
|
||||
Copilot should follow this plan unless the user explicitly changes priorities. |
||||
|
||||
### Phase 1 - Project Bootstrap |
||||
|
||||
- Create a clean Python project layout with a package entrypoint. |
||||
- Keep dependencies minimal and explicit. |
||||
- Prefer simple run scripts and documentation that also make sense on-device. |
||||
|
||||
### Phase 2 - DLNA Discovery And Browsing |
||||
|
||||
- Implement SSDP discovery for media servers in the LAN. |
||||
- Filter and normalize DLNA MediaServer devices. |
||||
- Build a ContentDirectory client for browsing containers and media items. |
||||
- Support pagination, caching, and safe handling of incomplete metadata. |
||||
|
||||
### Phase 3 - SDL2 User Interface |
||||
|
||||
- Design the UI for 640x480 with readable typography and low visual overhead. |
||||
- Optimize navigation for D-pad controls with A/B and shoulder buttons. |
||||
- Keep the UI responsive while network operations run asynchronously. |
||||
|
||||
### Phase 4 - Milestone 2: Integrated GStreamer Playback |
||||
|
||||
- Implement playback through GStreamer inside the application process instead of launching an external player subprocess. |
||||
- Prefer the system GStreamer stack via `PyGObject` and `GstPlayBin` rather than custom decode loops. |
||||
- Prefer decoding video to `GstAppSink` and rendering frames through SDL2 so playback does not depend on a window manager, native overlay sinks, or X11 embedding. |
||||
- Support play, pause/resume, stop, relative seek, automatic return to browser on end-of-stream, and explicit error reporting when decode or frame upload fails. |
||||
- Keep the architecture ready for follow-up work on on-screen playback HUD, resume state, and device-specific video backend tuning. |
||||
|
||||
### Phase 5 - Milestone 3: SDL Video Viewport, HUD, and Wayland Compatibility |
||||
|
||||
- Reserve an explicit video viewport inside the SDL window and scale decoded video to that viewport instead of using the full window surface. |
||||
- Render a full playback HUD in SDL around the video area, including title, state, elapsed/duration, progress, volume, cache/buffer hints, resolution, and visible controls. |
||||
- Make the playback HUD controllable with the same device inputs used elsewhere in the app, at minimum covering pause/resume, stop, seek, and volume. |
||||
- Prefer SDL backends that work without a window manager on target hardware, especially `kmsdrm` on R36S-class devices. |
||||
- If the chosen frame upload path is too slow on target hardware, optimize within the SDL-rendered path rather than falling back to external overlay windows. |
||||
|
||||
### Phase 6 - Device Integration And Hardening |
||||
|
||||
- Handle Wi-Fi instability, timeouts, malformed XML, missing thumbnails, and unsupported media. |
||||
- Keep memory use and redraw frequency conservative. |
||||
- Document firmware assumptions, runtime prerequisites, and launch steps. |
||||
|
||||
### Phase 7 - Validation |
||||
|
||||
- Add focused tests for parsing, navigation state, and non-UI logic. |
||||
- Verify manually on a real LAN and, when possible, on the target device. |
||||
- Prefer incremental, testable milestones over broad unfinished features. |
||||
|
||||
## State Tracking Requirements |
||||
|
||||
Copilot must treat development state as a living artifact, not as implicit context. |
||||
|
||||
- Track the current milestone, completed work, in-progress work, blockers, and next steps. |
||||
- Keep a repository status file updated during development at `docs/development-status.md`. |
||||
- Update that status file whenever a meaningful implementation step is completed, re-scoped, or blocked. |
||||
- Record only verified state, not guesses. |
||||
- Keep status entries concise, factual, and ordered by relevance to current work. |
||||
- If the plan changes, update both the active implementation and the tracked status. |
||||
|
||||
The status file should normally include: |
||||
|
||||
- current milestone |
||||
- current architecture decisions |
||||
- completed tasks |
||||
- tasks in progress |
||||
- blockers or open questions |
||||
- next recommended actions |
||||
|
||||
## Implementation Preferences |
||||
|
||||
- Favor simple, explicit modules over premature abstraction. |
||||
- Separate SDL2 UI concerns from networking and playback concerns. |
||||
- Keep async/network code isolated behind small adapters. |
||||
- Avoid features that increase complexity before the core browsing flow is stable. |
||||
- Prefer integrated GStreamer playback over subprocess control for Milestone 2. |
||||
- Prefer SDL-rendered playback from decoded frames over platform-specific window embedding on R36S-class targets. |
||||
- For Milestone 3, keep video and HUD in the same SDL render pass so the design remains compatible with DRM/KMS-only environments. |
||||
|
||||
## Quality Bar |
||||
|
||||
- Fix root causes when practical. |
||||
- Avoid unrelated refactors. |
||||
- Preserve performance on low-end ARM hardware. |
||||
- Make every milestone runnable, not just partially scaffolded. |
||||
- Treat documentation and development-state tracking as part of the deliverable. |
||||
@ -0,0 +1,9 @@
|
||||
__pycache__/ |
||||
*.py[cod] |
||||
.pytest_cache/ |
||||
.venv/ |
||||
.mypy_cache/ |
||||
.ruff_cache/ |
||||
build/ |
||||
dist/ |
||||
*.egg-info/ |
||||
@ -0,0 +1,21 @@
|
||||
MIT License |
||||
|
||||
Copyright (c) 2026 Matteo Benedetto |
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy |
||||
of this software and associated documentation files (the "Software"), to deal |
||||
in the Software without restriction, including without limitation the rights |
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell |
||||
copies of the Software, and to permit persons to whom the Software is |
||||
furnished to do so, subject to the following conditions: |
||||
|
||||
The above copyright notice and this permission notice shall be included in all |
||||
copies or substantial portions of the Software. |
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR |
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, |
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE |
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER |
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, |
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE |
||||
SOFTWARE. |
||||
@ -0,0 +1,144 @@
|
||||
# R36S DLNA Browser |
||||
|
||||
A lightweight DLNA media browser for the R36S handheld, built with Python, SDL2, and GStreamer. |
||||
|
||||
Author: Matteo Benedetto |
||||
|
||||
## Features |
||||
|
||||
- Discover DLNA/UPnP media servers on local LAN via SSDP |
||||
- Browse media libraries hierarchically with D-pad navigation |
||||
- Play audio and video locally via integrated GStreamer playback inside the SDL window |
||||
- Optimised for 640×480 screen and low-power RK3326 hardware |
||||
|
||||
## System Prerequisites |
||||
|
||||
Install these system packages before running (Ubuntu/Debian-based): |
||||
|
||||
```bash |
||||
sudo apt install libsdl2-2.0-0 libsdl2-ttf-2.0-0 python3 python3-pip python3-gi gir1.2-gstreamer-1.0 gstreamer1.0-tools gstreamer1.0-plugins-base gstreamer1.0-plugins-good |
||||
``` |
||||
|
||||
On Fedora the equivalent runtime packages are: |
||||
|
||||
```bash |
||||
sudo dnf install SDL2_ttf python3-gobject gstreamer1 gstreamer1-plugins-base gstreamer1-plugins-good python3 python3-pip |
||||
``` |
||||
|
||||
The current playback path is SDL-only: GStreamer decodes into frames and the app |
||||
renders them inside the SDL renderer. This avoids any dependency on X11, |
||||
Wayland embedding, or a window manager and is aligned with DRM/KMS-only targets. |
||||
|
||||
On ArkOS-derived firmware the SDL2 libraries are typically pre-installed. |
||||
|
||||
A TrueType font is bundled inside the package at `src/r36s_dlna_browser/assets/` |
||||
so packaged builds do not depend on a system font path. System font fallbacks are |
||||
still kept for development machines. |
||||
|
||||
## Quick Start |
||||
|
||||
```bash |
||||
cd /path/to/R36SHack |
||||
python3 -m pip install -e ".[dev]" |
||||
python3 -m r36s_dlna_browser |
||||
``` |
||||
|
||||
## Miniforge / Conda Deploy |
||||
|
||||
Use Miniforge or Miniconda when you want a reproducible Linux development or |
||||
packaging environment with GStreamer and SDL dependencies managed together. |
||||
|
||||
```bash |
||||
cd /path/to/R36SHack |
||||
conda env create -f environment.yml |
||||
conda activate r36s-dlna-browser |
||||
python -m r36s_dlna_browser |
||||
``` |
||||
|
||||
To update an existing environment after dependency changes: |
||||
|
||||
```bash |
||||
conda env update -f environment.yml --prune |
||||
conda activate r36s-dlna-browser |
||||
``` |
||||
|
||||
Or without installing: |
||||
|
||||
```bash |
||||
cd /path/to/R36SHack |
||||
pip install -r requirements.txt |
||||
PYTHONPATH=src python3 -m r36s_dlna_browser |
||||
``` |
||||
|
||||
## Controls |
||||
|
||||
| Key / Button | Action | |
||||
|-------------|----------------------| |
||||
| Up / Down | Navigate items | |
||||
| Left / Right | Page up / down | |
||||
| A (Return) | Select / Enter | |
||||
| B (Escape) | Back | |
||||
| Start | Quit | |
||||
|
||||
During playback, the same controls are remapped to transport actions: |
||||
|
||||
- `A`: pause / resume |
||||
- `B`: stop and return to browser |
||||
- `Left / Right`: seek `-10s / +10s` |
||||
- `Up / Down`: volume `+5 / -5` |
||||
- `H` / `Y`: cycle HUD mode (`auto / fixed / hidden`) |
||||
|
||||
## On-Device (R36S / ArkOS) |
||||
|
||||
1. Copy the project to `/roms/ports/` or another writable location. |
||||
2. Ensure Wi-Fi is connected. |
||||
3. Run via PortMaster or a launch script: |
||||
|
||||
```bash |
||||
cd /roms/ports/R36SHack |
||||
PYTHONPATH=src python3 -m r36s_dlna_browser |
||||
``` |
||||
|
||||
## Git / Release Workflow |
||||
|
||||
For publication on a Gitea instance with `tea`, initialize git locally, create a |
||||
remote repository, then push the current branch. |
||||
|
||||
```bash |
||||
git init |
||||
git add . |
||||
git commit -m "Initial import" |
||||
tea repos create --name R36SHack --description "SDL2/GStreamer DLNA browser for R36S" --owner enne2 |
||||
git remote add origin git@git.enne2.net:enne2/R36SHack.git |
||||
git push -u origin main |
||||
``` |
||||
|
||||
## Project Structure |
||||
|
||||
``` |
||||
src/r36s_dlna_browser/ |
||||
├── __main__.py # Entrypoint |
||||
├── app.py # Application lifecycle |
||||
├── assets/ |
||||
│ └── NotoSans-Regular.ttf |
||||
├── dlna/ |
||||
│ ├── discovery.py # SSDP media-server discovery |
||||
│ ├── client.py # ContentDirectory browsing via DmsDevice |
||||
│ ├── models.py # Domain models for servers/items |
||||
│ └── browser_state.py # Navigation stack and cache |
||||
├── ui/ |
||||
│ ├── sdl_app.py # SDL2 window, event loop, rendering |
||||
│ ├── screens.py # Screen definitions |
||||
│ └── theme.py # Layout constants for 640×480 |
||||
├── player/ |
||||
│ ├── backend.py # Player interface |
||||
│ └── gstreamer_backend.py # integrated GStreamer playback backend |
||||
└── platform/ |
||||
├── controls.py # Input mapping |
||||
└── runtime.py # Runtime detection and logging |
||||
``` |
||||
|
||||
## License |
||||
|
||||
MIT |
||||
|
||||
@ -0,0 +1,61 @@
|
||||
# Development Status |
||||
|
||||
## Current Milestone |
||||
|
||||
Milestone 3 — SDL Video Viewport, HUD, and Wayland Compatibility |
||||
|
||||
## Current Architecture Decisions |
||||
|
||||
- **Language / UI**: Python 3.9+ with PySDL2 (ctypes wrapper around system SDL2) |
||||
- **DLNA Discovery**: Custom SSDP M-SEARCH implementation using asyncio datagrams + aiohttp for device description XML |
||||
- **Content Browsing**: Direct SOAP/XML ContentDirectory client with DIDL-Lite parser (no dependency on async-upnp-client browsing at runtime — only aiohttp) |
||||
- **Playback**: integrated GStreamer backend via `PyGObject` / `GstPlayBin`, decoding video into `GstAppSink` frames that are uploaded to SDL textures and rendered in the main SDL renderer |
||||
- **Playback viewport**: SDL scales decoded video into a dedicated playback viewport in the same render pass as the HUD, with full-width video bounds and a black playback backdrop outside the viewport |
||||
- **Concurrency**: Dedicated asyncio event loop in a daemon thread; thread-safe queues bridge it to the SDL2 main loop |
||||
- **Input**: Keyboard mapping for desktop testing + SDL2 GameController for R36S D-pad/buttons |
||||
- **Font**: Bundled package font preferred first; system font fallback kept only for development hosts |
||||
- **UI icons**: prefer bundled monochrome glyphs or a bundled icon font subset instead of depending on OS emoji fonts |
||||
- **Playback HUD**: SDL-rendered overlay is now simplified and compact, uses bundled playback icons, uses a smaller dedicated playback font with title ellipsis for 640x480 readability, supports auto-hide or fixed visibility, stays visible while playback is paused, and remains in the same SDL render pass as video |
||||
- **Wayland / DRM strategy**: playback no longer depends on native overlay sinks or X11 window handles; R36S-class targets continue to prefer `kmsdrm` when no display server is present |
||||
- **Deploy packaging**: a `conda-forge`-oriented `environment.yml` now defines a reproducible Miniforge/Miniconda environment for local development and release preparation |
||||
|
||||
## Completed Tasks |
||||
|
||||
- Phase 1: Project bootstrap (`pyproject.toml`, `requirements.txt`, `README.md`, package layout under `src/`) |
||||
- Phase 2: DLNA discovery (`dlna/discovery.py` — SSDP M-SEARCH, friendly-name fetch) and browsing (`dlna/client.py` — SOAP Browse, DIDL-Lite parser with relative-URL resolution + `dlna/models.py` domain models + `dlna/browser_state.py` navigation stack/cache) |
||||
- Phase 3: SDL2 UI (`ui/sdl_app.py` — window, event loop, input dispatch; `ui/screens.py` — server list, browse list, playback, error screens; `ui/theme.py` — 640×480 layout constants) |
||||
- Phase 4: Playback (`player/backend.py` abstract interface + `player/gstreamer_backend.py` integrated GStreamer backend) |
||||
- Phase 5: Device integration (`platform/controls.py` — keyboard + gamecontroller mapping; `platform/runtime.py` — logging, R36S heuristic, SDL env hints) |
||||
- Phase 7: Tests — 75 tests across 7 test files all passing (DIDL mapping, SOAP/XML parser, navigation state, playback backend, SDL redraw policy, input controls, runtime environment setup) |
||||
- Desktop runtime verification completed: fixed SSDP discovery socket setup for IPv4 and removed pending-task shutdown noise from the async worker thread |
||||
- Packaging hardening: bundled a local UI font asset and configured setuptools to ship it with the package |
||||
- Real LAN regression fixed: Browse SOAP parser now handles `Result` elements both with and without a namespace, matching responses from the discovered MiniDLNA/Jellyfin servers |
||||
- Playback backend pivoted to GStreamer because libmpv continued to create a separate native window on the desktop host instead of remaining embedded in the SDL UI |
||||
- Milestone 2 is now implemented with GStreamer: playback uses `GstPlayBin` plus `GstAppSink` instead of an external player, libmpv, or native overlay sinks |
||||
- SDL playback flow updated: decoded GStreamer frames are uploaded into SDL textures, playback end-of-stream returns automatically to the browser, and playback controls support pause/resume, relative seek, and volume |
||||
- Milestone 3 implemented in code: SDL scales video into a dedicated viewport inside the SDL window, with reserved HUD margins instead of using the whole window area for video |
||||
- Playback HUD expanded: progress bar, elapsed/duration, volume, buffer, resolution, and control legends are rendered around the video area and updated from GStreamer bus/pipeline queries |
||||
- Playback-page flashing root cause addressed by removing native overlay composition entirely: video and HUD are now rendered together by SDL in one pass, with redraws driven by decoded frame availability and HUD state changes |
||||
- Playback HUD simplified: the border around the video area was removed, playback control/status icons were added as bundled SVG+PNG assets, the title/timer top bar no longer overlaps, and playback now supports `auto / fixed / hidden` HUD modes through a dedicated command while staying visible when paused |
||||
- Deployment assets added: `.gitignore`, `environment.yml`, and a real `LICENSE` file so the project can be initialized and published as a clean git repository |
||||
- Copilot instructions and this status file |
||||
|
||||
## Tasks In Progress |
||||
|
||||
- Verify that the SDL-texture playback path is smooth enough on real host playback and on R36S hardware |
||||
- Measure whether BGRA frame upload is acceptable on RK3326 or whether a future YUV texture path is needed |
||||
|
||||
## Blockers Or Open Questions |
||||
|
||||
- `SDL2_ttf` system library needed for text rendering (`sudo dnf install SDL2_ttf` on Fedora, `sudo apt install libsdl2-ttf-2.0-0` on Debian/Ubuntu). The app handles its absence gracefully but will show no text. |
||||
- Integrated playback requires system GStreamer plus Python GI bindings (for Fedora: `python3-gobject gstreamer1 gstreamer1-plugins-base gstreamer1-plugins-good`; add codec/plugin packages as needed for target media). |
||||
- Root browse verified against two real DLNA servers on the LAN. |
||||
- On-device testing on R36S hardware is pending. |
||||
- The current SDL-texture path avoids window-manager dependencies but may still need optimization on low-end hardware if BGRA upload cost is too high. |
||||
|
||||
## Next Recommended Actions |
||||
|
||||
1. Run a visual playback smoke test and confirm SDL-rendered video plus HUD eliminates flashing on host playback. |
||||
2. Validate the SDL-texture playback path on the target R36S `kmsdrm` backend. |
||||
3. Measure CPU/load on RK3326 hardware during audio and video playback. |
||||
4. If RGBA upload cost is too high, add a follow-up YUV texture upload path using `SDL_UpdateYUVTexture`. |
||||
@ -0,0 +1,14 @@
|
||||
name: r36s-dlna-browser |
||||
channels: |
||||
- conda-forge |
||||
dependencies: |
||||
- python=3.11 |
||||
- pip |
||||
- pygobject |
||||
- gstreamer |
||||
- gst-plugins-base |
||||
- gst-plugins-good |
||||
- sdl2 |
||||
- sdl2_ttf |
||||
- pip: |
||||
- -e .[dev] |
||||
@ -0,0 +1,38 @@
|
||||
[build-system] |
||||
requires = ["setuptools>=68.0", "wheel"] |
||||
build-backend = "setuptools.build_meta" |
||||
|
||||
[project] |
||||
name = "r36s-dlna-browser" |
||||
version = "0.1.0" |
||||
description = "DLNA media browser for R36S handheld (ArkOS-derived Linux)" |
||||
readme = "README.md" |
||||
requires-python = ">=3.9" |
||||
license = "MIT" |
||||
authors = [ |
||||
{name = "Matteo Benedetto"}, |
||||
] |
||||
dependencies = [ |
||||
"PySDL2>=0.9.16", |
||||
"async-upnp-client>=0.38.0", |
||||
] |
||||
|
||||
[project.optional-dependencies] |
||||
dev = [ |
||||
"pytest>=7.0", |
||||
"pytest-asyncio>=0.21", |
||||
] |
||||
|
||||
[project.scripts] |
||||
r36s-dlna = "r36s_dlna_browser.__main__:main" |
||||
|
||||
[tool.setuptools.packages.find] |
||||
where = ["src"] |
||||
|
||||
[tool.setuptools.package-data] |
||||
"r36s_dlna_browser" = ["assets/*.ttf", "assets/icons/*.png"] |
||||
|
||||
[tool.pytest.ini_options] |
||||
testpaths = ["tests"] |
||||
asyncio_mode = "auto" |
||||
|
||||
@ -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 |
|
After Width: | Height: | Size: 364 B |
|
After Width: | Height: | Size: 532 B |
|
After Width: | Height: | Size: 387 B |
|
After Width: | Height: | Size: 636 B |
|
After Width: | Height: | Size: 326 B |
|
After Width: | Height: | Size: 322 B |
|
After Width: | Height: | Size: 228 B |
|
After Width: | Height: | Size: 410 B |
|
After Width: | Height: | Size: 145 B |
|
After Width: | Height: | Size: 610 B |
|
After Width: | Height: | Size: 269 B |
|
After Width: | Height: | Size: 338 B |
|
After Width: | Height: | Size: 159 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 385 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 447 B |
|
After Width: | Height: | Size: 378 B |
|
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 |
|
After Width: | Height: | Size: 598 B |
|
After Width: | Height: | Size: 1.0 KiB |
|
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", |
||||
] |
||||
@ -0,0 +1,131 @@
|
||||
"""Tests for the DLNAClient SOAP and DIDL-Lite parsers.""" |
||||
|
||||
from r36s_dlna_browser.dlna.client import _extract_browse_result, _parse_didl |
||||
from r36s_dlna_browser.dlna.models import ItemType |
||||
|
||||
|
||||
_SAMPLE_DIDL = """\ |
||||
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" |
||||
xmlns:dc="http://purl.org/dc/elements/1.1/" |
||||
xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"> |
||||
<container id="1" parentID="0" childCount="3"> |
||||
<dc:title>Music</dc:title> |
||||
<upnp:class>object.container.storageFolder</upnp:class> |
||||
</container> |
||||
<item id="100" parentID="1"> |
||||
<dc:title>Song.mp3</dc:title> |
||||
<upnp:class>object.item.audioItem.musicTrack</upnp:class> |
||||
<upnp:albumArtURI>http://srv/art.jpg</upnp:albumArtURI> |
||||
<res protocolInfo="http-get:*:audio/mpeg:*" size="4000000" duration="0:03:21">http://srv/song.mp3</res> |
||||
</item> |
||||
<item id="200" parentID="1"> |
||||
<dc:title>Video.mkv</dc:title> |
||||
<upnp:class>object.item.videoItem</upnp:class> |
||||
<res protocolInfo="http-get:*:video/x-matroska:*">http://srv/video.mkv</res> |
||||
</item> |
||||
</DIDL-Lite> |
||||
""" |
||||
|
||||
|
||||
class TestParseDIDL: |
||||
def test_parses_container(self): |
||||
items = _parse_didl(_SAMPLE_DIDL, "http://srv") |
||||
containers = [i for i in items if i.is_container] |
||||
assert len(containers) == 1 |
||||
assert containers[0].title == "Music" |
||||
assert containers[0].child_count == 3 |
||||
|
||||
def test_parses_audio_item(self): |
||||
items = _parse_didl(_SAMPLE_DIDL, "http://srv") |
||||
audio = [i for i in items if i.item_type == ItemType.AUDIO] |
||||
assert len(audio) == 1 |
||||
assert audio[0].title == "Song.mp3" |
||||
assert audio[0].resource_url == "http://srv/song.mp3" |
||||
assert audio[0].mime_type == "audio/mpeg" |
||||
assert audio[0].size == 4000000 |
||||
assert audio[0].duration == "0:03:21" |
||||
assert audio[0].album_art_url == "http://srv/art.jpg" |
||||
|
||||
def test_parses_video_item(self): |
||||
items = _parse_didl(_SAMPLE_DIDL, "http://srv") |
||||
video = [i for i in items if i.item_type == ItemType.VIDEO] |
||||
assert len(video) == 1 |
||||
assert video[0].title == "Video.mkv" |
||||
assert "video/x-matroska" in video[0].mime_type |
||||
|
||||
def test_total_count(self): |
||||
items = _parse_didl(_SAMPLE_DIDL, "http://srv") |
||||
assert len(items) == 3 |
||||
|
||||
def test_empty_didl(self): |
||||
empty = '<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"></DIDL-Lite>' |
||||
assert _parse_didl(empty, "http://srv") == [] |
||||
|
||||
def test_malformed_xml(self): |
||||
assert _parse_didl("<<<not xml>>>", "http://srv") == [] |
||||
|
||||
def test_relative_url_resolution(self): |
||||
didl = """\ |
||||
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/" |
||||
xmlns:dc="http://purl.org/dc/elements/1.1/" |
||||
xmlns:upnp="urn:schemas-upnp-org:metadata-1-0/upnp/"> |
||||
<item id="300" parentID="1"> |
||||
<dc:title>Relative</dc:title> |
||||
<upnp:class>object.item.audioItem</upnp:class> |
||||
<res protocolInfo="http-get:*:audio/flac:*">media/track.flac</res> |
||||
</item> |
||||
</DIDL-Lite> |
||||
""" |
||||
items = _parse_didl(didl, "http://myserver:8200") |
||||
assert items[0].resource_url == "http://myserver:8200/media/track.flac" |
||||
|
||||
|
||||
class TestExtractBrowseResult: |
||||
def test_extracts_unnamespaced_result(self): |
||||
soap = """\ |
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"> |
||||
<s:Body> |
||||
<u:BrowseResponse xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1"> |
||||
<Result><DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"><container id="1" parentID="0" /></DIDL-Lite></Result> |
||||
</u:BrowseResponse> |
||||
</s:Body> |
||||
</s:Envelope> |
||||
""" |
||||
result = _extract_browse_result(soap) |
||||
assert result is not None |
||||
assert "DIDL-Lite" in result |
||||
assert 'container id="1"' in result |
||||
|
||||
def test_extracts_namespaced_result(self): |
||||
soap = """\ |
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" xmlns:u="urn:schemas-upnp-org:service:ContentDirectory:1"> |
||||
<s:Body> |
||||
<u:BrowseResponse> |
||||
<u:Result><DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"></DIDL-Lite></u:Result> |
||||
</u:BrowseResponse> |
||||
</s:Body> |
||||
</s:Envelope> |
||||
""" |
||||
assert _extract_browse_result(soap) is not None |
||||
|
||||
def test_extracts_embedded_xml_result(self): |
||||
soap = """\ |
||||
<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/"> |
||||
<s:Body> |
||||
<BrowseResponse> |
||||
<Result> |
||||
<DIDL-Lite xmlns="urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/"> |
||||
<container id="1" parentID="0" /> |
||||
</DIDL-Lite> |
||||
</Result> |
||||
</BrowseResponse> |
||||
</s:Body> |
||||
</s:Envelope> |
||||
""" |
||||
result = _extract_browse_result(soap) |
||||
assert result is not None |
||||
assert "DIDL-Lite" in result |
||||
|
||||
def test_returns_none_when_missing(self): |
||||
soap = "<Envelope><Body><BrowseResponse /></Body></Envelope>" |
||||
assert _extract_browse_result(soap) is None |
||||
@ -0,0 +1,79 @@
|
||||
"""Tests for platform/controls input mapping.""" |
||||
|
||||
import ctypes |
||||
from unittest.mock import MagicMock |
||||
|
||||
import sdl2 |
||||
|
||||
from r36s_dlna_browser.platform.controls import Action, map_key |
||||
|
||||
|
||||
def _make_key_event(sym: int) -> sdl2.SDL_Event: |
||||
event = sdl2.SDL_Event() |
||||
event.type = sdl2.SDL_KEYDOWN |
||||
event.key.keysym.sym = sym |
||||
return event |
||||
|
||||
|
||||
def _make_button_event(button: int) -> sdl2.SDL_Event: |
||||
event = sdl2.SDL_Event() |
||||
event.type = sdl2.SDL_CONTROLLERBUTTONDOWN |
||||
event.cbutton.button = button |
||||
return event |
||||
|
||||
|
||||
class TestKeyMapping: |
||||
def test_up(self): |
||||
assert map_key(_make_key_event(sdl2.SDLK_UP)) == Action.UP |
||||
|
||||
def test_down(self): |
||||
assert map_key(_make_key_event(sdl2.SDLK_DOWN)) == Action.DOWN |
||||
|
||||
def test_confirm_return(self): |
||||
assert map_key(_make_key_event(sdl2.SDLK_RETURN)) == Action.CONFIRM |
||||
|
||||
def test_confirm_space(self): |
||||
assert map_key(_make_key_event(sdl2.SDLK_SPACE)) == Action.CONFIRM |
||||
|
||||
def test_back_escape(self): |
||||
assert map_key(_make_key_event(sdl2.SDLK_ESCAPE)) == Action.BACK |
||||
|
||||
def test_quit(self): |
||||
assert map_key(_make_key_event(sdl2.SDLK_q)) == Action.QUIT |
||||
|
||||
def test_hud_mode(self): |
||||
assert map_key(_make_key_event(sdl2.SDLK_h)) == Action.HUD_MODE |
||||
|
||||
def test_page_up(self): |
||||
assert map_key(_make_key_event(sdl2.SDLK_PAGEUP)) == Action.PAGE_UP |
||||
|
||||
def test_page_down(self): |
||||
assert map_key(_make_key_event(sdl2.SDLK_PAGEDOWN)) == Action.PAGE_DOWN |
||||
|
||||
def test_unmapped_key(self): |
||||
assert map_key(_make_key_event(sdl2.SDLK_z)) is None |
||||
|
||||
def test_wrong_event_type(self): |
||||
event = sdl2.SDL_Event() |
||||
event.type = sdl2.SDL_MOUSEMOTION |
||||
assert map_key(event) is None |
||||
|
||||
|
||||
class TestButtonMapping: |
||||
def test_dpad_up(self): |
||||
assert map_key(_make_button_event(sdl2.SDL_CONTROLLER_BUTTON_DPAD_UP)) == Action.UP |
||||
|
||||
def test_button_a(self): |
||||
assert map_key(_make_button_event(sdl2.SDL_CONTROLLER_BUTTON_A)) == Action.CONFIRM |
||||
|
||||
def test_button_b(self): |
||||
assert map_key(_make_button_event(sdl2.SDL_CONTROLLER_BUTTON_B)) == Action.BACK |
||||
|
||||
def test_start(self): |
||||
assert map_key(_make_button_event(sdl2.SDL_CONTROLLER_BUTTON_START)) == Action.QUIT |
||||
|
||||
def test_left_shoulder(self): |
||||
assert map_key(_make_button_event(sdl2.SDL_CONTROLLER_BUTTON_LEFTSHOULDER)) == Action.PAGE_UP |
||||
|
||||
def test_button_y(self): |
||||
assert map_key(_make_button_event(sdl2.SDL_CONTROLLER_BUTTON_Y)) == Action.HUD_MODE |
||||
@ -0,0 +1,120 @@
|
||||
"""Tests for DIDL-Lite parsing and domain model mapping.""" |
||||
|
||||
from r36s_dlna_browser.dlna.models import ( |
||||
ItemType, |
||||
MediaItem, |
||||
classify_upnp_class, |
||||
parse_didl_item, |
||||
) |
||||
|
||||
|
||||
class TestClassifyUpnpClass: |
||||
def test_container(self): |
||||
assert classify_upnp_class("object.container.storageFolder") == ItemType.CONTAINER |
||||
|
||||
def test_audio(self): |
||||
assert classify_upnp_class("object.item.audioItem.musicTrack") == ItemType.AUDIO |
||||
|
||||
def test_video(self): |
||||
assert classify_upnp_class("object.item.videoItem") == ItemType.VIDEO |
||||
|
||||
def test_image(self): |
||||
assert classify_upnp_class("object.item.imageItem.photo") == ItemType.IMAGE |
||||
|
||||
def test_unknown(self): |
||||
assert classify_upnp_class("object.item.something") == ItemType.UNKNOWN |
||||
|
||||
def test_empty(self): |
||||
assert classify_upnp_class("") == ItemType.UNKNOWN |
||||
|
||||
|
||||
class TestParseDIDLItem: |
||||
def test_audio_item(self): |
||||
didl = { |
||||
"id": "42", |
||||
"title": "My Song", |
||||
"parent_id": "10", |
||||
"upnp_class": "object.item.audioItem.musicTrack", |
||||
"resources": [ |
||||
{ |
||||
"url": "http://server/song.mp3", |
||||
"protocol_info": "http-get:*:audio/mpeg:*", |
||||
"size": "5000000", |
||||
"duration": "0:03:45", |
||||
} |
||||
], |
||||
"album_art_uri": "http://server/art.jpg", |
||||
} |
||||
item = parse_didl_item(didl) |
||||
assert item.object_id == "42" |
||||
assert item.title == "My Song" |
||||
assert item.item_type == ItemType.AUDIO |
||||
assert item.resource_url == "http://server/song.mp3" |
||||
assert item.mime_type == "http-get:*:audio/mpeg:*" |
||||
assert item.size == 5000000 |
||||
assert item.duration == "0:03:45" |
||||
assert item.album_art_url == "http://server/art.jpg" |
||||
assert not item.is_container |
||||
|
||||
def test_container_item(self): |
||||
didl = { |
||||
"id": "5", |
||||
"title": "Music", |
||||
"parent_id": "0", |
||||
"upnp_class": "object.container.storageFolder", |
||||
"child_count": "12", |
||||
} |
||||
item = parse_didl_item(didl) |
||||
assert item.object_id == "5" |
||||
assert item.title == "Music" |
||||
assert item.item_type == ItemType.CONTAINER |
||||
assert item.child_count == 12 |
||||
assert item.is_container |
||||
|
||||
def test_missing_resources(self): |
||||
didl = {"id": "99", "title": "NoRes", "upnp_class": "object.item.audioItem"} |
||||
item = parse_didl_item(didl) |
||||
assert item.resource_url == "" |
||||
assert item.size == 0 |
||||
|
||||
def test_single_resource_dict(self): |
||||
didl = { |
||||
"id": "1", |
||||
"title": "Track", |
||||
"upnp_class": "object.item.audioItem", |
||||
"resources": {"url": "http://srv/track.flac", "size": "100"}, |
||||
} |
||||
item = parse_didl_item(didl) |
||||
assert item.resource_url == "http://srv/track.flac" |
||||
assert item.size == 100 |
||||
|
||||
def test_resource_string(self): |
||||
didl = { |
||||
"id": "2", |
||||
"title": "Track2", |
||||
"upnp_class": "object.item.videoItem", |
||||
"resources": ["http://srv/vid.mp4"], |
||||
} |
||||
item = parse_didl_item(didl) |
||||
assert item.resource_url == "http://srv/vid.mp4" |
||||
|
||||
def test_alt_key_names(self): |
||||
"""Handles alternative key formats (@id, @parentID, class, etc.).""" |
||||
didl = { |
||||
"@id": "7", |
||||
"title": "Alt", |
||||
"@parentID": "3", |
||||
"class": "object.container", |
||||
"@childCount": "5", |
||||
} |
||||
item = parse_didl_item(didl) |
||||
assert item.object_id == "7" |
||||
assert item.parent_id == "3" |
||||
assert item.item_type == ItemType.CONTAINER |
||||
assert item.child_count == 5 |
||||
|
||||
def test_empty_dict(self): |
||||
item = parse_didl_item({}) |
||||
assert item.object_id == "" |
||||
assert item.title == "" |
||||
assert item.item_type == ItemType.UNKNOWN |
||||
@ -0,0 +1,134 @@
|
||||
"""Tests for BrowserState navigation stack and pagination.""" |
||||
|
||||
from r36s_dlna_browser.dlna.browser_state import BrowserState |
||||
from r36s_dlna_browser.dlna.models import MediaServer, MediaItem, ItemType |
||||
|
||||
|
||||
def _server(name: str = "TestServer") -> MediaServer: |
||||
return MediaServer(friendly_name=name, location=f"http://{name}:8080/desc.xml") |
||||
|
||||
|
||||
def _items(n: int, container: bool = False) -> list[MediaItem]: |
||||
t = ItemType.CONTAINER if container else ItemType.AUDIO |
||||
return [ |
||||
MediaItem( |
||||
object_id=str(i), |
||||
title=f"Item {i}", |
||||
item_type=t, |
||||
resource_url="" if container else f"http://srv/file{i}.mp3", |
||||
) |
||||
for i in range(n) |
||||
] |
||||
|
||||
|
||||
class TestServerList: |
||||
def test_empty_initial(self): |
||||
s = BrowserState() |
||||
assert s.servers == [] |
||||
assert s.selected_server is None |
||||
|
||||
def test_set_servers(self): |
||||
s = BrowserState() |
||||
servers = [_server("A"), _server("B")] |
||||
s.set_servers(servers) |
||||
assert len(s.servers) == 2 |
||||
assert s.server_cursor == 0 |
||||
assert s.selected_server.friendly_name == "A" |
||||
|
||||
def test_cursor_bounds(self): |
||||
s = BrowserState() |
||||
s.set_servers([_server("A"), _server("B"), _server("C")]) |
||||
s.server_cursor = 2 |
||||
assert s.selected_server.friendly_name == "C" |
||||
s.server_cursor = 5 # out of bounds |
||||
assert s.selected_server is None |
||||
|
||||
|
||||
class TestBrowseStack: |
||||
def test_not_in_browse_initially(self): |
||||
s = BrowserState() |
||||
assert not s.in_browse_mode |
||||
|
||||
def test_enter_server(self): |
||||
s = BrowserState() |
||||
s.set_servers([_server()]) |
||||
obj_id = s.enter_server(s.selected_server) |
||||
assert obj_id == "0" |
||||
assert s.in_browse_mode |
||||
assert s.current_level.object_id == "0" |
||||
|
||||
def test_set_items_and_cursor(self): |
||||
s = BrowserState() |
||||
s.set_servers([_server()]) |
||||
s.enter_server(s.selected_server) |
||||
items = _items(10) |
||||
s.set_items(items, "0") |
||||
assert len(s.current_items) == 10 |
||||
assert s.cursor == 0 |
||||
s.cursor = 3 |
||||
assert s.cursor == 3 |
||||
assert s.selected_item().title == "Item 3" |
||||
|
||||
def test_cursor_clamps(self): |
||||
s = BrowserState() |
||||
s.set_servers([_server()]) |
||||
s.enter_server(s.selected_server) |
||||
s.set_items(_items(5), "0") |
||||
s.cursor = 100 |
||||
assert s.cursor == 4 # clamped to last |
||||
s.cursor = -5 |
||||
assert s.cursor == 0 |
||||
|
||||
def test_push_and_pop(self): |
||||
s = BrowserState() |
||||
s.set_servers([_server()]) |
||||
s.enter_server(s.selected_server) |
||||
s.set_items(_items(3, container=True), "0") |
||||
|
||||
# Drill into child |
||||
s.set_items(_items(5), "1") |
||||
assert len(s.current_items) == 5 |
||||
|
||||
# Go back |
||||
assert s.go_back() |
||||
assert len(s.current_items) == 3 |
||||
assert s.current_level.object_id == "0" |
||||
|
||||
# Go back again exits browse |
||||
assert s.go_back() |
||||
assert not s.in_browse_mode |
||||
|
||||
def test_go_back_at_root(self): |
||||
s = BrowserState() |
||||
assert not s.go_back() |
||||
|
||||
def test_reset(self): |
||||
s = BrowserState() |
||||
s.set_servers([_server()]) |
||||
s.enter_server(s.selected_server) |
||||
s.set_items(_items(5), "0") |
||||
s.error = "fail" |
||||
s.playback_title = "song" |
||||
s.playback_paused = True |
||||
s.reset() |
||||
assert not s.in_browse_mode |
||||
assert s.error == "" |
||||
assert s.playback_title == "" |
||||
assert not s.playback_paused |
||||
|
||||
|
||||
class TestScrollOffset: |
||||
def test_scroll_offset_default(self): |
||||
s = BrowserState() |
||||
s.set_servers([_server()]) |
||||
s.enter_server(s.selected_server) |
||||
s.set_items(_items(3), "0") |
||||
assert s.scroll_offset == 0 |
||||
|
||||
def test_scroll_offset_set(self): |
||||
s = BrowserState() |
||||
s.set_servers([_server()]) |
||||
s.enter_server(s.selected_server) |
||||
s.set_items(_items(30), "0") |
||||
s.scroll_offset = 10 |
||||
assert s.scroll_offset == 10 |
||||
@ -0,0 +1,357 @@
|
||||
"""Tests for the SDL-rendered GStreamer backend without using a real pipeline.""" |
||||
|
||||
import ctypes |
||||
from types import SimpleNamespace |
||||
|
||||
import sdl2 |
||||
|
||||
from r36s_dlna_browser.player.gstreamer_backend import GStreamerBackend |
||||
|
||||
|
||||
class FakeMessageType: |
||||
ERROR = 1 |
||||
EOS = 2 |
||||
BUFFERING = 4 |
||||
STATE_CHANGED = 8 |
||||
|
||||
|
||||
class FakeFlowReturn: |
||||
OK = 0 |
||||
|
||||
|
||||
class FakeState: |
||||
NULL = 0 |
||||
READY = 1 |
||||
PAUSED = 2 |
||||
PLAYING = 3 |
||||
|
||||
|
||||
class FakeStateChangeReturn: |
||||
FAILURE = -1 |
||||
SUCCESS = 0 |
||||
|
||||
|
||||
class FakeFormat: |
||||
TIME = 1 |
||||
|
||||
|
||||
class FakeSeekFlags: |
||||
FLUSH = 1 |
||||
KEY_UNIT = 2 |
||||
|
||||
|
||||
class FakeCaps: |
||||
def __init__(self, width, height): |
||||
self._width = width |
||||
self._height = height |
||||
|
||||
def get_size(self): |
||||
return 1 |
||||
|
||||
def get_structure(self, _index): |
||||
return FakeStructure(self._width, self._height) |
||||
|
||||
|
||||
class FakeStructure: |
||||
def __init__(self, width, height): |
||||
self._width = width |
||||
self._height = height |
||||
|
||||
def has_field(self, name): |
||||
return name in {"width", "height"} |
||||
|
||||
def get_value(self, name): |
||||
return self._width if name == "width" else self._height |
||||
|
||||
|
||||
class FakeBuffer: |
||||
def __init__(self, payload: bytes): |
||||
self._payload = payload |
||||
|
||||
def get_size(self): |
||||
return len(self._payload) |
||||
|
||||
def extract_dup(self, _offset, _size): |
||||
return self._payload |
||||
|
||||
|
||||
class FakeSample: |
||||
def __init__(self, width=1280, height=720, payload: bytes | None = None): |
||||
self._caps = FakeCaps(width, height) |
||||
self._buffer = FakeBuffer(payload or (b"\x00" * (width * height * 4))) |
||||
|
||||
def get_caps(self): |
||||
return self._caps |
||||
|
||||
def get_buffer(self): |
||||
return self._buffer |
||||
|
||||
|
||||
class FakeAppSink: |
||||
def __init__(self): |
||||
self.props = {} |
||||
self.connected = [] |
||||
self.sample = None |
||||
|
||||
def set_property(self, name, value): |
||||
self.props[name] = value |
||||
|
||||
def connect(self, signal_name, callback): |
||||
self.connected.append((signal_name, callback)) |
||||
|
||||
def emit(self, signal_name): |
||||
assert signal_name == "pull-sample" |
||||
return self.sample |
||||
|
||||
|
||||
class FakeBus: |
||||
def __init__(self): |
||||
self.message = None |
||||
|
||||
def timed_pop_filtered(self, *_args): |
||||
msg, self.message = self.message, None |
||||
return msg |
||||
|
||||
|
||||
class FakePipeline: |
||||
def __init__(self): |
||||
self.props = {} |
||||
self.state = FakeState.NULL |
||||
self.uri = None |
||||
self.position = 12 * 1_000_000_000 |
||||
self.duration = 93 * 1_000_000_000 |
||||
self.bus = FakeBus() |
||||
self.seek_calls = [] |
||||
|
||||
def set_state(self, state): |
||||
self.state = state |
||||
return FakeStateChangeReturn.SUCCESS |
||||
|
||||
def set_property(self, name, value): |
||||
self.props[name] = value |
||||
if name == "uri": |
||||
self.uri = value |
||||
|
||||
def get_property(self, name): |
||||
if name == "volume": |
||||
return self.props.get("volume", 1.0) |
||||
return self.props.get(name) |
||||
|
||||
def get_bus(self): |
||||
return self.bus |
||||
|
||||
def query_position(self, _format): |
||||
return True, self.position |
||||
|
||||
def query_duration(self, _format): |
||||
return True, self.duration |
||||
|
||||
def seek_simple(self, _format, flags, target): |
||||
self.seek_calls.append((flags, target)) |
||||
self.position = target |
||||
return True |
||||
|
||||
|
||||
class FakeMessage: |
||||
def __init__(self, msg_type, src=None, buffering=0, state=None, error_text="boom", structure_name=None): |
||||
self.type = msg_type |
||||
self.src = src |
||||
self._buffering = buffering |
||||
self._state = state or (FakeState.NULL, FakeState.PLAYING, FakeState.NULL) |
||||
self._error_text = error_text |
||||
self._structure_name = structure_name |
||||
|
||||
def parse_buffering(self): |
||||
return self._buffering |
||||
|
||||
def parse_state_changed(self): |
||||
return self._state |
||||
|
||||
def parse_error(self): |
||||
return SimpleNamespace(message=self._error_text), None |
||||
|
||||
def get_structure(self): |
||||
if not self._structure_name: |
||||
return None |
||||
return SimpleNamespace(get_name=lambda: self._structure_name) |
||||
|
||||
|
||||
class FakeGst: |
||||
State = FakeState |
||||
StateChangeReturn = FakeStateChangeReturn |
||||
Format = FakeFormat |
||||
SeekFlags = FakeSeekFlags |
||||
MessageType = FakeMessageType |
||||
FlowReturn = FakeFlowReturn |
||||
SECOND = 1_000_000_000 |
||||
MSECOND = 1_000_000 |
||||
Caps = SimpleNamespace(from_string=lambda value: value) |
||||
|
||||
|
||||
class FakeVideoInfoValue: |
||||
def __init__(self, width, height, stride): |
||||
self.width = width |
||||
self.height = height |
||||
self.stride = [stride] |
||||
|
||||
|
||||
class FakeVideoInfo: |
||||
@staticmethod |
||||
def new_from_caps(caps): |
||||
structure = caps.get_structure(0) |
||||
width = structure.get_value("width") |
||||
height = structure.get_value("height") |
||||
return FakeVideoInfoValue(width, height, width * 4) |
||||
|
||||
|
||||
class FakeGstVideo: |
||||
VideoInfo = FakeVideoInfo |
||||
|
||||
|
||||
class TestGStreamerBackend: |
||||
def _make_backend(self): |
||||
pipeline = FakePipeline() |
||||
sink = FakeAppSink() |
||||
backend = GStreamerBackend( |
||||
gst_module=FakeGst, |
||||
gst_video_module=FakeGstVideo, |
||||
appsink_factory=lambda: sink, |
||||
playbin_factory=lambda: pipeline, |
||||
subsystem="x11", |
||||
) |
||||
backend._start_bus_thread = lambda: None |
||||
backend._stop_bus_thread = lambda: None |
||||
return backend, pipeline, sink |
||||
|
||||
def test_not_playing_initially(self): |
||||
backend, _pipeline, _sink = self._make_backend() |
||||
assert not backend.is_playing() |
||||
|
||||
def test_play_sets_uri_and_configures_appsink(self): |
||||
backend, pipeline, sink = self._make_backend() |
||||
|
||||
backend.play("http://example.com/video.mp4") |
||||
|
||||
assert backend.is_playing() |
||||
assert pipeline.uri == "http://example.com/video.mp4" |
||||
assert pipeline.props["video-sink"] is sink |
||||
assert sink.props["emit-signals"] is True |
||||
assert sink.props["caps"] == "video/x-raw,format=BGRA" |
||||
|
||||
def test_new_sample_marks_frame_dirty_and_updates_resolution(self): |
||||
events = [] |
||||
backend, _pipeline, sink = self._make_backend() |
||||
backend.set_event_callback(lambda event, *args: events.append((event, *args))) |
||||
sink.sample = FakeSample(width=640, height=360) |
||||
|
||||
assert backend._on_new_sample(sink) == FakeFlowReturn.OK |
||||
|
||||
assert backend.has_new_frame() is True |
||||
assert ("resolution", "640x360") in events |
||||
|
||||
def test_toggle_pause_switches_pipeline_state(self): |
||||
backend, pipeline, _sink = self._make_backend() |
||||
backend.play("http://example.com/video.mp4") |
||||
|
||||
assert backend.toggle_pause() is True |
||||
assert pipeline.state == FakeState.PAUSED |
||||
assert backend.toggle_pause() is False |
||||
assert pipeline.state == FakeState.PLAYING |
||||
|
||||
def test_seek_uses_relative_position(self): |
||||
backend, pipeline, _sink = self._make_backend() |
||||
backend.play("http://example.com/video.mp4") |
||||
|
||||
backend.seek(10) |
||||
|
||||
assert pipeline.seek_calls == [(FakeSeekFlags.FLUSH | FakeSeekFlags.KEY_UNIT, 22 * 1_000_000_000)] |
||||
|
||||
def test_change_volume_clamps_to_supported_range(self): |
||||
backend, pipeline, _sink = self._make_backend() |
||||
backend.play("http://example.com/video.mp4") |
||||
|
||||
assert backend.change_volume(15) == 115 |
||||
assert pipeline.props["volume"] == 1.15 |
||||
assert backend.change_volume(50) == 130 |
||||
assert pipeline.props["volume"] == 1.3 |
||||
|
||||
def test_eos_notifies_stopped(self): |
||||
events = [] |
||||
backend, pipeline, _sink = self._make_backend() |
||||
backend.set_event_callback(lambda event, *args: events.append((event, *args))) |
||||
backend.play("http://example.com/video.mp4") |
||||
|
||||
backend._handle_bus_message(FakeMessage(FakeMessageType.EOS, src=pipeline)) |
||||
|
||||
assert ("stopped",) in events |
||||
assert not backend.is_playing() |
||||
assert pipeline.state == FakeState.NULL |
||||
|
||||
def test_buffering_and_metrics_feed_hud(self): |
||||
events = [] |
||||
backend, pipeline, _sink = self._make_backend() |
||||
backend.set_event_callback(lambda event, *args: events.append((event, *args))) |
||||
backend.play("http://example.com/video.mp4") |
||||
backend._resolution = "1280x720" |
||||
|
||||
backend._handle_bus_message(FakeMessage(FakeMessageType.BUFFERING, buffering=76)) |
||||
backend._emit_playback_metrics() |
||||
|
||||
assert ("buffering", 76) in events |
||||
assert ("position", 12.0) in events |
||||
assert ("duration", 93.0) in events |
||||
assert ("volume", 100) in events |
||||
assert ("resolution", "1280x720") in events |
||||
|
||||
def test_state_changed_updates_pause_event(self): |
||||
events = [] |
||||
backend, pipeline, _sink = self._make_backend() |
||||
backend.set_event_callback(lambda event, *args: events.append((event, *args))) |
||||
backend.play("http://example.com/video.mp4") |
||||
|
||||
backend._handle_bus_message( |
||||
FakeMessage( |
||||
FakeMessageType.STATE_CHANGED, |
||||
src=pipeline, |
||||
state=(FakeState.PLAYING, FakeState.PAUSED, FakeState.NULL), |
||||
) |
||||
) |
||||
|
||||
assert ("paused", True) in events |
||||
|
||||
def test_error_message_propagates(self): |
||||
events = [] |
||||
backend, pipeline, _sink = self._make_backend() |
||||
backend.set_event_callback(lambda event, *args: events.append((event, *args))) |
||||
backend.play("http://example.com/video.mp4") |
||||
|
||||
backend._handle_bus_message(FakeMessage(FakeMessageType.ERROR, src=pipeline, error_text="decoder fail")) |
||||
|
||||
assert ("error", "decoder fail") in events |
||||
assert not backend.is_playing() |
||||
|
||||
def test_stop_sets_pipeline_to_null(self): |
||||
backend, pipeline, _sink = self._make_backend() |
||||
backend.play("http://example.com/video.mp4") |
||||
|
||||
backend.stop() |
||||
|
||||
assert pipeline.state == FakeState.NULL |
||||
assert not backend.is_playing() |
||||
|
||||
def test_render_uploads_latest_frame_and_clears_dirty_flag(self, monkeypatch): |
||||
backend, _pipeline, _sink = self._make_backend() |
||||
backend._latest_frame = SimpleNamespace(width=320, height=180, pitch=1280, pixels=b"\x00" * (320 * 180 * 4)) |
||||
backend._frame_dirty = True |
||||
|
||||
calls = [] |
||||
|
||||
monkeypatch.setattr(sdl2, "SDL_CreateTexture", lambda *_args: object()) |
||||
monkeypatch.setattr(sdl2, "SDL_UpdateTexture", lambda *_args: 0) |
||||
monkeypatch.setattr(sdl2, "SDL_RenderCopy", lambda _renderer, _texture, _src, dst: calls.append((dst.x, dst.y, dst.w, dst.h))) |
||||
|
||||
backend.set_viewport(640, 480, 48, 80, 12, 12) |
||||
|
||||
assert backend.render(object()) is True |
||||
assert backend.has_new_frame() is False |
||||
assert calls == [(12, 51, 616, 346)] |
||||
@ -0,0 +1,40 @@
|
||||
"""Tests for runtime environment setup helpers.""" |
||||
|
||||
import os |
||||
|
||||
from r36s_dlna_browser.platform import runtime |
||||
|
||||
|
||||
def test_wayland_does_not_override_video_driver(monkeypatch): |
||||
monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0") |
||||
monkeypatch.setenv("DISPLAY", ":0") |
||||
monkeypatch.delenv("SDL_VIDEODRIVER", raising=False) |
||||
monkeypatch.setattr(runtime, "is_r36s", lambda: False) |
||||
|
||||
runtime.sdl_env_setup() |
||||
|
||||
assert "SDL_VIDEODRIVER" not in os.environ |
||||
|
||||
|
||||
def test_respects_existing_sdl_videodriver(monkeypatch): |
||||
monkeypatch.setenv("WAYLAND_DISPLAY", "wayland-0") |
||||
monkeypatch.setenv("DISPLAY", ":0") |
||||
monkeypatch.setenv("SDL_VIDEODRIVER", "wayland") |
||||
monkeypatch.setattr(runtime, "is_r36s", lambda: False) |
||||
|
||||
runtime.sdl_env_setup() |
||||
|
||||
assert os.environ["SDL_VIDEODRIVER"] == "wayland" |
||||
|
||||
|
||||
def test_r36s_sets_alsa_audio_driver(monkeypatch): |
||||
monkeypatch.delenv("SDL_AUDIODRIVER", raising=False) |
||||
monkeypatch.delenv("SDL_VIDEODRIVER", raising=False) |
||||
monkeypatch.delenv("DISPLAY", raising=False) |
||||
monkeypatch.delenv("WAYLAND_DISPLAY", raising=False) |
||||
monkeypatch.setattr(runtime, "is_r36s", lambda: True) |
||||
|
||||
runtime.sdl_env_setup() |
||||
|
||||
assert os.environ["SDL_AUDIODRIVER"] == "alsa" |
||||
assert os.environ["SDL_VIDEODRIVER"] == "kmsdrm" |
||||
@ -0,0 +1,122 @@
|
||||
from __future__ import annotations |
||||
|
||||
from queue import Queue |
||||
|
||||
from r36s_dlna_browser.dlna.browser_state import BrowserState |
||||
from r36s_dlna_browser.platform.controls import Action |
||||
from r36s_dlna_browser.ui import theme |
||||
from r36s_dlna_browser.ui import sdl_app as sdl_app_module |
||||
from r36s_dlna_browser.ui.sdl_app import SDLApp, Screen |
||||
|
||||
|
||||
class DummyPlayer: |
||||
def __init__(self) -> None: |
||||
self._new_frame = False |
||||
|
||||
def attach_window(self, _window) -> None: |
||||
pass |
||||
|
||||
def set_viewport(self, *_args) -> None: |
||||
pass |
||||
|
||||
def has_new_frame(self) -> bool: |
||||
return self._new_frame |
||||
|
||||
def render(self, _renderer) -> bool: |
||||
self._new_frame = False |
||||
return True |
||||
|
||||
|
||||
def _make_app() -> SDLApp: |
||||
return SDLApp(Queue(), Queue(), BrowserState(), DummyPlayer()) |
||||
|
||||
|
||||
def test_non_playback_draws_only_when_dirty() -> None: |
||||
app = _make_app() |
||||
app._screen = Screen.SERVERS |
||||
app._needs_redraw = False |
||||
|
||||
assert app._should_draw() is False |
||||
|
||||
app._mark_dirty() |
||||
|
||||
assert app._should_draw() is True |
||||
|
||||
|
||||
def test_playback_snapshot_change_waits_for_refresh_interval(monkeypatch) -> None: |
||||
app = _make_app() |
||||
app._screen = Screen.PLAYBACK |
||||
app._needs_redraw = False |
||||
app._last_playback_snapshot = app._playback_snapshot() |
||||
app._last_playback_draw_at = 10.0 |
||||
app._state.playback_position = 1.0 |
||||
|
||||
monkeypatch.setattr(sdl_app_module.time, "monotonic", lambda: 10.1) |
||||
|
||||
assert app._should_draw() is False |
||||
|
||||
|
||||
def test_playback_snapshot_change_redraws_after_refresh_interval(monkeypatch) -> None: |
||||
app = _make_app() |
||||
app._screen = Screen.PLAYBACK |
||||
app._needs_redraw = False |
||||
app._last_playback_snapshot = app._playback_snapshot() |
||||
app._last_playback_draw_at = 10.0 |
||||
app._state.playback_position = 1.0 |
||||
|
||||
monkeypatch.setattr(sdl_app_module.time, "monotonic", lambda: 10.3) |
||||
|
||||
assert app._should_draw() is True |
||||
|
||||
|
||||
def test_playback_snapshot_without_changes_does_not_redraw(monkeypatch) -> None: |
||||
app = _make_app() |
||||
app._screen = Screen.PLAYBACK |
||||
app._needs_redraw = False |
||||
app._last_playback_snapshot = app._playback_snapshot() |
||||
app._last_playback_draw_at = 10.0 |
||||
|
||||
monkeypatch.setattr(sdl_app_module.time, "monotonic", lambda: 10.5) |
||||
|
||||
assert app._should_draw() is False |
||||
|
||||
|
||||
def test_playback_draws_immediately_for_new_video_frame() -> None: |
||||
app = _make_app() |
||||
app._screen = Screen.PLAYBACK |
||||
app._needs_redraw = False |
||||
app._player._new_frame = True |
||||
|
||||
assert app._should_draw() is True |
||||
|
||||
|
||||
def test_hud_mode_cycles_auto_fixed_hidden(monkeypatch) -> None: |
||||
app = _make_app() |
||||
app._screen = Screen.PLAYBACK |
||||
monkeypatch.setattr(sdl_app_module.time, "monotonic", lambda: 12.0) |
||||
|
||||
app._handle_action(Action.HUD_MODE) |
||||
assert app._state.playback_hud_mode == theme.PLAYBACK_HUD_PINNED |
||||
assert app._state.playback_hud_visible is True |
||||
|
||||
app._handle_action(Action.HUD_MODE) |
||||
assert app._state.playback_hud_mode == theme.PLAYBACK_HUD_HIDDEN |
||||
assert app._state.playback_hud_visible is False |
||||
|
||||
app._handle_action(Action.HUD_MODE) |
||||
assert app._state.playback_hud_mode == theme.PLAYBACK_HUD_AUTO |
||||
assert app._state.playback_hud_visible is True |
||||
|
||||
|
||||
def test_paused_playback_keeps_hud_visible_in_auto_mode(monkeypatch) -> None: |
||||
app = _make_app() |
||||
app._screen = Screen.PLAYBACK |
||||
app._state.playback_hud_mode = theme.PLAYBACK_HUD_AUTO |
||||
app._state.playback_hud_visible = False |
||||
app._state.playback_paused = True |
||||
|
||||
monkeypatch.setattr(sdl_app_module.time, "monotonic", lambda: 99.0) |
||||
|
||||
app._refresh_playback_hud_visibility() |
||||
|
||||
assert app._state.playback_hud_visible is True |
||||