diff --git a/.agents/MEMORY.md b/.agents/MEMORY.md index 24284fc..698cee7 100644 --- a/.agents/MEMORY.md +++ b/.agents/MEMORY.md @@ -34,9 +34,17 @@ loopback; current phase is Phase 7. - **systemd autostart**: `systemd/screencast-receiver.service` is a template (`__SC_RECEIVER_BIN__`/`__SC_RECEIVER_USER__`); the install script substitutes and enables it (opt out: `SC_RECEIVER_SERVICE=0`), - adding the run user to video/render/input for headless KMSDRM. - Lifecycle-validated with a user unit on this machine: SIGTERM stop → - clean exit (success), mDNS withdrawn, no restart. + adding the run user to video/render/input for headless KMSDRM. The unit + gets a controlling tty (StandardInput=tty, tty1) because SDL's KMSDRM + backend needs one for VT handling. +- **Fullscreen headless rendering**: under the KMSDRM video driver (no + window manager) the renderer goes fullscreen automatically; --fullscreen + forces it on desktops. Aspect is preserved via + SDL_SetRenderLogicalPresentation(LETTERBOX) (verified: 4:3 feed on a + 3440x1440 monitor rendered with black pillarbox bars; --fullscreen + window covered the full monitor). The service lifecycle was validated + with a user unit: SIGTERM stop → clean exit (success), mDNS withdrawn, + no restart. - **mDNS operational lesson (hit during validation)**: `kill -9` on a process holding an avahi registration leaves stale daemon records; the service then browses but never resolves (timeout for every peer) until diff --git a/README.md b/README.md index e5504ff..f50550b 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,9 @@ installs and enables a systemd service so the receiver starts at boot — Performance note: decoding is software H.264; on very small boards expect smooth playback for modest resolutions and reduced frame rates at high -resolutions. Hardware decode is planned for Phase 7. +resolutions. Hardware decode is planned for Phase 7. On a headless console +the receiver runs fullscreen automatically with aspect-preserving +letterboxing. ## Architecture diff --git a/docs/RUNBOOK.md b/docs/RUNBOOK.md index ca847b5..adfde88 100644 --- a/docs/RUNBOOK.md +++ b/docs/RUNBOOK.md @@ -102,6 +102,11 @@ the recommended setup for small boards (Raspberry Pi OS **Lite**): 3. Disable console blanking: append `consoleblank=0` to `/boot/firmware/cmdline.txt` and reboot. +Under KMSDRM the receiver goes **fullscreen automatically**, with +aspect-preserving letterboxing (a 4:3 desktop on a 16:9 TV shows black +bars, not a stretched picture) and no mouse cursor. On desktop sessions +`--fullscreen` forces the same behavior. + A window manager only comes with the desktop-session alternative, where the receiver runs inside it (uncomment the `Environment=` lines in the service file as described above). diff --git a/include/screencast/app/cli.h b/include/screencast/app/cli.h index aa18d50..959cb16 100644 --- a/include/screencast/app/cli.h +++ b/include/screencast/app/cli.h @@ -17,6 +17,7 @@ struct ReceiveCommand { std::string_view peer_address; // optional, informational int local_rtp_port = 5004; int signaling_port = 5005; + bool fullscreen = false; }; struct DiscoverCommand { diff --git a/include/screencast/render/renderer.h b/include/screencast/render/renderer.h index e78607b..0fd707f 100644 --- a/include/screencast/render/renderer.h +++ b/include/screencast/render/renderer.h @@ -12,6 +12,9 @@ struct RendererConfig { std::string window_title = "screencast receiver"; int initial_width = 1280; int initial_height = 720; + // Borderless fullscreen. On the headless KMSDRM backend (no window + // manager) fullscreen is enabled automatically. + bool fullscreen = false; }; class Renderer { diff --git a/src/app/cli.cpp b/src/app/cli.cpp index 1a3f7cf..50e03af 100644 --- a/src/app/cli.cpp +++ b/src/app/cli.cpp @@ -9,7 +9,7 @@ namespace { void print_usage() { std::fputs("usage: screencast --send [--target monitor|window] [--peer HOST[:PORT]] [--bitrate KBPS]\n" - " screencast --receive [--port PORT] [--signaling-port PORT]\n" + " screencast --receive [--port PORT] [--signaling-port PORT] [--fullscreen]\n" " screencast --discover [--timeout SECONDS]\n" "\n" "--send without --peer discovers a receiver on the LAN and requires\n" @@ -110,6 +110,8 @@ std::optional parse_cli(int argc, const char* const argv[]) { return std::nullopt; } receive.signaling_port = port; + } else if (argument == "--fullscreen") { + receive.fullscreen = true; } else if (argument == "--timeout") { std::string_view value; if (!next_argument(argc, argv, index, value) || !parse_int(value, discover.timeout_seconds) || diff --git a/src/app/main.cpp b/src/app/main.cpp index 89a571c..eb34a67 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -214,6 +214,9 @@ int run_receiver(const sc::ReceiveCommand& command) { sc::ReceiverPipelineConfig config; config.local_rtp_endpoint = sc::Endpoint{"0.0.0.0", static_cast(command.local_rtp_port)}; config.signaling_port = static_cast(command.signaling_port); + // Fullscreen is automatic under KMSDRM (headless); the flag forces it + // on desktop sessions. + config.renderer.fullscreen = command.fullscreen; sc::ReceiverPipeline pipeline{std::move(config)}; if (!pipeline.start()) { diff --git a/src/render/sdl_renderer.cpp b/src/render/sdl_renderer.cpp index 772cdfb..9118a1d 100644 --- a/src/render/sdl_renderer.cpp +++ b/src/render/sdl_renderer.cpp @@ -34,12 +34,23 @@ class SdlRenderer final : public Renderer { } sdl_inited_ = true; - window_ = SDL_CreateWindow(config_.window_title.c_str(), config_.initial_width, config_.initial_height, 0); + // Without a window manager (SDL KMSDRM backend) a windowed window is + // meaningless: go fullscreen automatically. --fullscreen forces it + // on desktop sessions too. + const char* video_driver = SDL_GetCurrentVideoDriver(); + fullscreen_ = config_.fullscreen || (video_driver != nullptr && std::string_view{video_driver} == "kmsdrm"); + + const SDL_WindowFlags window_flags = fullscreen_ ? SDL_WINDOW_FULLSCREEN : 0; + window_ = + SDL_CreateWindow(config_.window_title.c_str(), config_.initial_width, config_.initial_height, window_flags); if (window_ == nullptr) { last_error_ = std::string{"SDL_CreateWindow failed: "} + SDL_GetError(); shutdown(); return false; } + if (fullscreen_) { + (void)SDL_HideCursor(); + } renderer_ = SDL_CreateRenderer(window_, nullptr); if (renderer_ == nullptr) { @@ -76,6 +87,10 @@ class SdlRenderer final : public Renderer { if (!SDL_UpdateTexture(texture_, nullptr, frame.rgba_pixels.data(), pitch)) { return false; } + // Clear first so the letterbox bars stay black between frames. + if (!SDL_RenderClear(renderer_)) { + return false; + } if (!SDL_RenderTexture(renderer_, texture_, nullptr, nullptr)) { return false; } @@ -132,6 +147,10 @@ class SdlRenderer final : public Renderer { } texture_width_ = width; texture_height_ = height; + + // Present the video at its own aspect ratio: letterbox anything + // else (e.g. a 3:2 desktop on a 16:9 TV) instead of stretching. + (void)SDL_SetRenderLogicalPresentation(renderer_, width, height, SDL_LOGICAL_PRESENTATION_LETTERBOX); return true; } @@ -139,6 +158,7 @@ class SdlRenderer final : public Renderer { std::string last_error_; bool sdl_inited_ = false; bool closed_ = false; + bool fullscreen_ = false; SDL_Window* window_ = nullptr; SDL_Renderer* renderer_ = nullptr; SDL_Texture* texture_ = nullptr;