diff --git a/README.md b/README.md index 0d5ab36..2dac819 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,17 @@ ![Apps Screen](./docs/images/screenshots/03-apps.png) +## Video codec selection + +The streaming menu's **Video Codec** setting offers Auto, H.264, MPEG-2, and H.263+. +Auto prefers H.264, then MPEG-2, then H.263+, using only codecs advertised by the host. +An explicit choice requests only that codec and fails negotiation when it is unavailable. +Sunshine must enable `mpeg2_mode = 2` or `h263p_mode = 2` to advertise the corresponding legacy codec. +These Sunshine protocol extensions are available to any client implementing their negotiation and decoding. + +The preference is saved as `streaming.video_codec` in the settings file. Existing +`streaming.video_decoder` preferences are read for compatibility; `video_codec` takes precedence when both exist. + ## Streaming controls and statistics To quit a stream, press **Back/Select + Black + White (LB + RB)** together on any connected controller. diff --git a/cmake/moonlight-dependencies.cmake b/cmake/moonlight-dependencies.cmake index 734e221..ccd92f6 100644 --- a/cmake/moonlight-dependencies.cmake +++ b/cmake/moonlight-dependencies.cmake @@ -108,7 +108,7 @@ function(_moonlight_compute_ffmpeg_signature out_var nxdk_dir ffmpeg_source_dir) "NXDK_DIR=${nxdk_dir}" "NXDK_REVISION=${nxdk_revision}" "COMPAT_SHA256=${compat_hashes}" - "FFMPEG_PROFILE=h264-opus-xbox" + "FFMPEG_PROFILE=h264-mpeg2-h263-opus-xbox" "FFMPEG_TARGET_OS=none" "FFMPEG_ARCH=x86" "FFMPEG_CC_WRAPPER_SHA256=${ffmpeg_cc_wrapper_hash}" @@ -238,7 +238,13 @@ function(_moonlight_get_ffmpeg_configure_args out_var) --enable-swscale --enable-swresample --enable-parser=h264 + --enable-parser=h263 + --enable-parser=mpegvideo --enable-decoder=h264 + --enable-decoder=h263 + --enable-decoder=h263i + --enable-decoder=h263p + --enable-decoder=mpeg2video --enable-decoder=opus) set(${out_var} "${ffmpeg_configure_args}" PARENT_SCOPE) diff --git a/src/app/client_state.cpp b/src/app/client_state.cpp index c0e2020..627f269 100644 --- a/src/app/client_state.cpp +++ b/src/app/client_state.cpp @@ -32,6 +32,12 @@ namespace { constexpr int DEFAULT_STREAM_BITRATE_KBPS = 1000; constexpr std::array STREAM_FRAMERATE_OPTIONS {15, 20, 24, 25, 30, 60}; constexpr std::array STREAM_BITRATE_OPTIONS {500, 750, 1000, 1500, 2000, 2500, 3000, 4000, 5000}; + constexpr std::array VIDEO_CODEC_OPTIONS { + app::VideoCodecSelection::autoDetect, + app::VideoCodecSelection::h264, + app::VideoCodecSelection::mpeg2, + app::VideoCodecSelection::h263p, + }; /** * @brief Describes the keypad characters available for the active add-host field. @@ -144,6 +150,27 @@ namespace { return std::to_string(videoMode.width) + "x" + std::to_string(videoMode.height); } + /** + * @brief Return the settings label for one video codec preference. + * + * @param selection Codec preference to describe. + * @return User-facing codec preference label. + */ + const char *video_codec_selection_label(app::VideoCodecSelection selection) { + switch (selection) { + case app::VideoCodecSelection::autoDetect: + return "Auto"; + case app::VideoCodecSelection::h264: + return "H.264"; + case app::VideoCodecSelection::mpeg2: + return "MPEG-2/H.262"; + case app::VideoCodecSelection::h263p: + return "H.263+"; + } + + return "Auto"; + } + /** * @brief Return the selected stream-resolution index inside the detected mode list. * @@ -213,6 +240,22 @@ namespace { state.settings.streamBitrateKbps = STREAM_BITRATE_OPTIONS[nextIndex]; } + /** + * @brief Advance the preferred video codec to the next supported option. + * + * @param state Current client state containing the preferred decoder. + */ + void cycle_video_codec(app::ClientState &state) { + const auto current = std::find(VIDEO_CODEC_OPTIONS.begin(), VIDEO_CODEC_OPTIONS.end(), state.settings.videoCodec); + if (current == VIDEO_CODEC_OPTIONS.end()) { + state.settings.videoCodec = app::VideoCodecSelection::autoDetect; + return; + } + + const std::size_t nextIndex = (static_cast(std::distance(VIDEO_CODEC_OPTIONS.begin(), current)) + 1U) % VIDEO_CODEC_OPTIONS.size(); + state.settings.videoCodec = VIDEO_CODEC_OPTIONS[nextIndex]; + } + std::string pairing_reset_endpoint_key(std::string_view address, uint16_t port) { return app::normalize_ipv4_address(address) + ":" + std::to_string(app::effective_host_port(port)); } @@ -544,6 +587,12 @@ namespace { "Cycle through the preferred video bitrate. Lower bitrates reduce bandwidth use and can help when running Sunshine and xemu on the same NATed host.", true, }, + { + "cycle-video-codec", + std::string("Video Codec: ") + video_codec_selection_label(state.settings.videoCodec), + "Choose automatic codec negotiation or request a specific video codec for new streams.", + true, + }, { "toggle-play-audio-on-pc", std::string("Play Audio on PC: ") + (state.settings.playAudioOnPc ? "On" : "Off"), @@ -1405,6 +1454,7 @@ namespace app { state.settings.xemuConsoleLoggingLevel = logging::LogLevel::none; state.settings.streamFramerate = DEFAULT_STREAM_FRAMERATE; state.settings.streamBitrateKbps = DEFAULT_STREAM_BITRATE_KBPS; + state.settings.videoCodec = VideoCodecSelection::autoDetect; state.settings.playAudioOnPc = false; state.settings.showPerformanceStats = false; state.settings.playAudioOnXbox = true; @@ -1996,6 +2046,14 @@ namespace app { rebuild_menu(state, "cycle-stream-bitrate"); return; } + if (detailUpdate.activatedItemId == "cycle-video-codec") { + cycle_video_codec(state); + state.settings.dirty = true; + update->persistence.settingsChanged = true; + state.shell.statusMessage = std::string("Video codec set to ") + video_codec_selection_label(state.settings.videoCodec); + rebuild_menu(state, "cycle-video-codec"); + return; + } if (detailUpdate.activatedItemId == "toggle-play-audio-on-pc") { state.settings.playAudioOnPc = !state.settings.playAudioOnPc; state.settings.dirty = true; diff --git a/src/app/client_state.h b/src/app/client_state.h index e3e87cf..79f6118 100644 --- a/src/app/client_state.h +++ b/src/app/client_state.h @@ -65,6 +65,16 @@ namespace app { right, ///< Dock the log viewer on the right side of the split layout. }; + /** + * @brief Video codec preference applied to new streaming sessions. + */ + enum class VideoCodecSelection { + autoDetect, ///< Negotiate supported codecs using the shared modern-first preference order. + h264, ///< Request only H.264 video. + mpeg2, ///< Request only MPEG-2/H.262 video. + h263p, ///< Request only H.263+ video. + }; + /** * @brief Focus areas used by the two-pane settings screen. */ @@ -321,6 +331,7 @@ namespace app { bool preferredVideoModeSet = false; ///< True when preferredVideoMode contains a user-selected or default mode. int streamFramerate = 30; ///< Preferred stream frame rate in frames per second. int streamBitrateKbps = 1000; ///< Preferred stream bitrate in kilobits per second. + VideoCodecSelection videoCodec = VideoCodecSelection::autoDetect; ///< Preferred video codec for new streams. bool playAudioOnPc = false; ///< True when the host PC should continue local audio playback during streaming. bool showPerformanceStats = false; ///< Display live performance telemetry and include it in the stream summary. bool playAudioOnXbox = true; ///< True when the Xbox should decode and play streamed audio locally. diff --git a/src/app/settings_storage.cpp b/src/app/settings_storage.cpp index ffa244c..f70abf8 100644 --- a/src/app/settings_storage.cpp +++ b/src/app/settings_storage.cpp @@ -127,6 +127,27 @@ namespace { return "full"; } + /** + * @brief Convert a codec preference to the persisted TOML token. + * + * @param selection Codec preference to serialize. + * @return Stable lowercase settings value. + */ + const char *video_codec_selection_text(app::VideoCodecSelection selection) { + switch (selection) { + case app::VideoCodecSelection::autoDetect: + return "auto"; + case app::VideoCodecSelection::h264: + return "h264"; + case app::VideoCodecSelection::mpeg2: + return "mpeg2"; + case app::VideoCodecSelection::h263p: + return "h263p"; + } + + return "auto"; + } + bool try_parse_logging_level(std::string_view text, logging::LogLevel *level) { const std::string normalized = ascii_lowercase(text); if (normalized == "trace") { @@ -193,6 +214,43 @@ namespace { return false; } + /** + * @brief Parse a codec preference from the persisted TOML token. + * + * @param text Settings value to parse. + * @param selection Receives the parsed codec preference on success. + * @return True when @p text is a supported codec preference. + */ + bool try_parse_video_codec_selection(std::string_view text, app::VideoCodecSelection *selection) { + const std::string normalized = ascii_lowercase(text); + if (normalized == "auto") { + if (selection != nullptr) { + *selection = app::VideoCodecSelection::autoDetect; + } + return true; + } + if (normalized == "h264" || normalized == "h.264") { + if (selection != nullptr) { + *selection = app::VideoCodecSelection::h264; + } + return true; + } + if (normalized == "mpeg2" || normalized == "mpeg-2" || normalized == "h262" || normalized == "h.262") { + if (selection != nullptr) { + *selection = app::VideoCodecSelection::mpeg2; + } + return true; + } + if (normalized == "h263p" || normalized == "h.263p" || normalized == "h263+" || normalized == "h.263+") { + if (selection != nullptr) { + *selection = app::VideoCodecSelection::h263p; + } + return true; + } + + return false; + } + void append_invalid_value_warning(std::vector *warnings, const std::string &filePath, std::string_view keyPath, std::string_view valueText) { if (warnings == nullptr) { return; @@ -263,6 +321,36 @@ namespace { append_invalid_value_warning(warnings, filePath, "ui.log_viewer_placement", ""); } + /** + * @brief Load one codec preference setting when present. + * + * @param settingNode TOML node to parse. + * @param filePath Settings file path used in warnings. + * @param keyPath Canonical or legacy setting key reported in validation warnings. + * @param selection Receives the parsed codec preference on success. + * @param warnings Warning collection updated for invalid values. + */ + void load_video_codec_selection_setting( + toml::node_view settingNode, + const std::string &filePath, + std::string_view keyPath, + app::VideoCodecSelection *selection, + std::vector *warnings + ) { + if (!settingNode) { + return; + } + + if (const auto videoCodecText = settingNode.value(); videoCodecText) { + if (!try_parse_video_codec_selection(*videoCodecText, selection)) { + append_invalid_value_warning(warnings, filePath, keyPath, *videoCodecText); + } + return; + } + + append_invalid_value_warning(warnings, filePath, keyPath, ""); + } + /** * @brief Load one integer settings value when present. * @@ -346,6 +434,8 @@ namespace { content += "# Preferred streaming parameters.\n"; content += std::string("fps = ") + std::to_string(settings.streamFramerate) + "\n"; content += std::string("bitrate_kbps = ") + std::to_string(settings.streamBitrateKbps) + "\n"; + content += "# Video codec preference. Use auto to keep normal host negotiation.\n"; + content += std::string("video_codec = \"") + video_codec_selection_text(settings.videoCodec) + "\"\n"; content += std::string("play_audio_on_pc = ") + (settings.playAudioOnPc ? "true" : "false") + "\n"; content += std::string("play_audio_on_xbox = ") + (settings.playAudioOnXbox ? "true" : "false") + "\n"; content += "# Show stream telemetry after streaming ends.\n"; @@ -392,7 +482,7 @@ namespace { void inspect_streaming_keys(const toml::table &streamingTable, const std::string &filePath, app::LoadAppSettingsResult *result) { for (const auto &[rawKey, node] : streamingTable) { const std::string key(rawKey.str()); - if (key == "video_width" || key == "video_height" || key == "video_bpp" || key == "video_refresh" || key == "video_mode_selected" || key == "fps" || key == "bitrate_kbps" || key == "play_audio_on_pc" || key == "play_audio_on_xbox" || key == "show_performance_stats") { + if (key == "video_width" || key == "video_height" || key == "video_bpp" || key == "video_refresh" || key == "video_mode_selected" || key == "fps" || key == "bitrate_kbps" || key == "video_codec" || key == "video_decoder" || key == "play_audio_on_pc" || key == "play_audio_on_xbox" || key == "show_performance_stats") { continue; } @@ -490,6 +580,12 @@ namespace app { load_boolean_setting(settingsTable["streaming"]["video_mode_selected"], filePath, "streaming.video_mode_selected", &result.settings.preferredVideoModeSet, &result.warnings); load_integer_setting(settingsTable["streaming"]["fps"], filePath, "streaming.fps", &result.settings.streamFramerate, &result.warnings); load_integer_setting(settingsTable["streaming"]["bitrate_kbps"], filePath, "streaming.bitrate_kbps", &result.settings.streamBitrateKbps, &result.warnings); + const auto codecNode = settingsTable["streaming"]["video_codec"]; + if (codecNode) { + load_video_codec_selection_setting(codecNode, filePath, "streaming.video_codec", &result.settings.videoCodec, &result.warnings); + } else { + load_video_codec_selection_setting(settingsTable["streaming"]["video_decoder"], filePath, "streaming.video_decoder", &result.settings.videoCodec, &result.warnings); + } load_boolean_setting(settingsTable["streaming"]["play_audio_on_pc"], filePath, "streaming.play_audio_on_pc", &result.settings.playAudioOnPc, &result.warnings); load_boolean_setting(settingsTable["streaming"]["play_audio_on_xbox"], filePath, "streaming.play_audio_on_xbox", &result.settings.playAudioOnXbox, &result.warnings); load_boolean_setting(settingsTable["streaming"]["show_performance_stats"], filePath, "streaming.show_performance_stats", &result.settings.showPerformanceStats, &result.warnings); diff --git a/src/app/settings_storage.h b/src/app/settings_storage.h index e0a5334..e9a9816 100644 --- a/src/app/settings_storage.h +++ b/src/app/settings_storage.h @@ -24,6 +24,7 @@ namespace app { bool preferredVideoModeSet = false; ///< True when preferredVideoMode contains a saved user preference. int streamFramerate = 30; ///< Preferred stream frame rate in frames per second. int streamBitrateKbps = 1000; ///< Preferred stream bitrate in kilobits per second. + app::VideoCodecSelection videoCodec = app::VideoCodecSelection::autoDetect; ///< Preferred video codec for new streams. bool playAudioOnPc = false; ///< True when the host PC should continue local audio playback during streaming. bool showPerformanceStats = false; ///< Display live performance telemetry and include it in the stream summary. bool playAudioOnXbox = true; ///< True when the Xbox should decode and play streamed audio locally. diff --git a/src/main.cpp b/src/main.cpp index deabc1b..c8c3334 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -44,6 +44,7 @@ namespace { state.settings.preferredVideoModeSet = settings.preferredVideoModeSet; state.settings.streamFramerate = settings.streamFramerate; state.settings.streamBitrateKbps = settings.streamBitrateKbps; + state.settings.videoCodec = settings.videoCodec; state.settings.playAudioOnPc = settings.playAudioOnPc; state.settings.showPerformanceStats = settings.showPerformanceStats; state.settings.playAudioOnXbox = settings.playAudioOnXbox; diff --git a/src/startup/video_mode.cpp b/src/startup/video_mode.cpp index 7d0548b..6f3edc1 100644 --- a/src/startup/video_mode.cpp +++ b/src/startup/video_mode.cpp @@ -39,9 +39,13 @@ namespace startup { return left.width == right.width && left.height == right.height; } + int stream_video_mode_area(const VIDEO_MODE &videoMode) { + return videoMode.width * videoMode.height; + } + bool is_smaller_video_mode(const VIDEO_MODE &left, const VIDEO_MODE &right) { - const int leftArea = left.width * left.height; - if (const int rightArea = right.width * right.height; leftArea != rightArea) { + const int leftArea = stream_video_mode_area(left); + if (const int rightArea = stream_video_mode_area(right); leftArea != rightArea) { return leftArea < rightArea; } return left.width < right.width; diff --git a/src/streaming/ffmpeg_stream_backend.cpp b/src/streaming/ffmpeg_stream_backend.cpp index c4c68af..ba9f499 100644 --- a/src/streaming/ffmpeg_stream_backend.cpp +++ b/src/streaming/ffmpeg_stream_backend.cpp @@ -57,6 +57,14 @@ namespace { constexpr std::uint64_t STREAM_VIDEO_DROP_LOG_INTERVAL = 30; constexpr Uint32 STREAM_VIDEO_DECODE_YIELD_MILLISECONDS = 1U; + /** + * @brief FFmpeg codec metadata selected from one negotiated Moonlight format. + */ + struct VideoCodecSelection { + AVCodecID codecId = AV_CODEC_ID_NONE; ///< FFmpeg codec identifier. + const char *displayName = "video"; ///< Human-readable codec name for logs. + }; + /** * @brief Return the backend currently receiving video callbacks. * @@ -180,6 +188,37 @@ namespace { }); } + /** + * @brief Resolve the FFmpeg decoder to use for one negotiated video format. + * + * @param videoFormat Moonlight negotiated video format mask. + * @param selection Output codec selection populated on success. + * @return True when the video format is supported by this backend. + */ + bool select_video_codec(int videoFormat, VideoCodecSelection *selection) { + if (selection == nullptr) { + return false; + } + + if ((videoFormat & VIDEO_FORMAT_MASK_MPEG2) != 0) { + selection->codecId = AV_CODEC_ID_MPEG2VIDEO; + selection->displayName = "MPEG-2/H.262"; + return true; + } + if ((videoFormat & VIDEO_FORMAT_H263P) != 0) { + selection->codecId = AV_CODEC_ID_H263P; + selection->displayName = "H.263+"; + return true; + } + if ((videoFormat & VIDEO_FORMAT_MASK_H264) != 0) { + selection->codecId = AV_CODEC_ID_H264; + selection->displayName = "H.264"; + return true; + } + + return false; + } + /** * @brief Return the number of bytes generated per second for the SDL audio format. * @@ -632,18 +671,19 @@ namespace streaming { acceleration_.cpuFeatures = (cpuFlags & AV_CPU_FLAG_SSE) != 0 ? "MMX/SSE" : ((cpuFlags & AV_CPU_FLAG_MMX) != 0 ? "MMX" : "Scalar"); acceleration_.threaded = std::string(avcodec_configuration()).find("--enable-w32threads") != std::string::npos; - if ((videoFormat & VIDEO_FORMAT_MASK_H264) == 0) { - logging::error("stream", "The FFmpeg backend currently supports only H.264 video streams"); + VideoCodecSelection codecSelection; + if (!select_video_codec(videoFormat, &codecSelection)) { + logging::error("stream", "The FFmpeg backend supports H.264, MPEG-2/H.262, and H.263+ video streams"); return -1; } - const AVCodec *codec = avcodec_find_decoder(AV_CODEC_ID_H264); + const AVCodec *codec = avcodec_find_decoder(codecSelection.codecId); if (codec == nullptr) { - logging::error("stream", "FFmpeg did not provide an H.264 decoder"); + logging::error("stream", std::string("FFmpeg did not provide a ") + codecSelection.displayName + " decoder"); return -1; } - video_.codecName = "H.264"; + video_.codecName = codecSelection.displayName; video_.codecContext = avcodec_alloc_context3(codec); video_.decodedFrame = av_frame_alloc(); video_.convertedFrame = av_frame_alloc(); diff --git a/src/streaming/session.cpp b/src/streaming/session.cpp index 6c94ca8..a3204df 100644 --- a/src/streaming/session.cpp +++ b/src/streaming/session.cpp @@ -10,6 +10,7 @@ #include "src/startup/memory_stats.h" #include "src/streaming/ffmpeg_stream_backend.h" #include "src/streaming/stats_overlay.h" +#include "src/streaming/video_codec_selection.h" #include "third-party/moonlight-common-c/src/Limelight.h" #include @@ -311,9 +312,11 @@ namespace { }; struct ResolvedStreamParameters { - VIDEO_MODE videoMode {}; + VIDEO_MODE presentationVideoMode {}; ///< Xbox presentation mode selected by user settings. + VIDEO_MODE videoMode {}; ///< Stream mode requested from the host. int fps = DEFAULT_STREAM_FPS; int bitrateKbps = DEFAULT_STREAM_BITRATE_KBPS; + int supportedVideoFormats = VIDEO_FORMAT_H264; int packetSize = DEFAULT_PACKET_SIZE; int streamingRemotely = STREAM_CFG_AUTO; }; @@ -901,6 +904,27 @@ namespace { return std::clamp(settings.streamBitrateKbps, MIN_STREAM_BITRATE_KBPS, MAX_STREAM_BITRATE_KBPS); } + /** + * @brief Describe a codec preference for logs and waiting screens. + * + * @param selection Codec preference selected in settings. + * @return Human-readable codec preference. + */ + const char *describe_video_codec_selection(app::VideoCodecSelection selection) { + switch (selection) { + case app::VideoCodecSelection::autoDetect: + return "Auto"; + case app::VideoCodecSelection::h264: + return "H.264"; + case app::VideoCodecSelection::mpeg2: + return "MPEG-2/H.262"; + case app::VideoCodecSelection::h263p: + return "H.263+"; + } + + return "Auto"; + } + /** * @brief Resolve the effective stream parameters for the next session. * @@ -910,9 +934,11 @@ namespace { */ ResolvedStreamParameters resolve_stream_parameters(const VIDEO_MODE &fallbackVideoMode, const app::SettingsState &settings) { ResolvedStreamParameters resolved {}; - resolved.videoMode = select_effective_stream_video_mode(fallbackVideoMode, settings); + resolved.presentationVideoMode = select_effective_stream_video_mode(fallbackVideoMode, settings); + resolved.videoMode = resolved.presentationVideoMode; resolved.fps = select_stream_fps(settings); resolved.bitrateKbps = select_stream_bitrate_kbps(settings); + resolved.supportedVideoFormats = streaming::supported_video_formats(settings.videoCodec); return resolved; } @@ -956,7 +982,7 @@ namespace { context->streamConfiguration.packetSize = streamParameters.packetSize; context->streamConfiguration.streamingRemotely = streamParameters.streamingRemotely; context->streamConfiguration.audioConfiguration = AUDIO_CONFIGURATION_STEREO; - context->streamConfiguration.supportedVideoFormats = VIDEO_FORMAT_H264; + context->streamConfiguration.supportedVideoFormats = streamParameters.supportedVideoFormats; context->streamConfiguration.clientRefreshRateX100 = select_client_refresh_rate_x100(outputVideoMode); context->streamConfiguration.colorSpace = COLORSPACE_REC_601; context->streamConfiguration.colorRange = COLOR_RANGE_LIMITED; @@ -1968,7 +1994,7 @@ namespace streaming { *returnController = nullptr; } const ResolvedStreamParameters streamParameters = resolve_stream_parameters(videoMode, settings); - const VIDEO_MODE streamPresentationVideoMode = select_stream_presentation_video_mode(videoMode, streamParameters.videoMode); + const VIDEO_MODE streamPresentationVideoMode = select_stream_presentation_video_mode(videoMode, streamParameters.presentationVideoMode); ScopedStreamVideoMode scopedStreamVideoMode(window, videoMode, streamPresentationVideoMode); const VIDEO_MODE &activeStreamVideoMode = scopedStreamVideoMode.active_video_mode(); @@ -2031,7 +2057,7 @@ namespace streaming { ); logging::info( "stream", - std::string("Requested stream configuration | resolution=") + std::to_string(startContext.streamConfiguration.width) + "x" + std::to_string(startContext.streamConfiguration.height) + " | fps=" + std::to_string(startContext.streamConfiguration.fps) + " | bitrate=" + std::to_string(startContext.streamConfiguration.bitrate) + " kbps" + " | packetSize=" + std::to_string(startContext.streamConfiguration.packetSize) + " | networkProfile=" + describe_streaming_remotely_mode(startContext.streamConfiguration.streamingRemotely) + " | clientRefreshX100=" + std::to_string(startContext.streamConfiguration.clientRefreshRateX100) + std::string("Requested stream configuration | resolution=") + std::to_string(startContext.streamConfiguration.width) + "x" + std::to_string(startContext.streamConfiguration.height) + " | fps=" + std::to_string(startContext.streamConfiguration.fps) + " | bitrate=" + std::to_string(startContext.streamConfiguration.bitrate) + " kbps | decoder=" + describe_video_codec_selection(settings.videoCodec) + " | packetSize=" + std::to_string(startContext.streamConfiguration.packetSize) + " | networkProfile=" + describe_streaming_remotely_mode(startContext.streamConfiguration.streamingRemotely) + " | clientRefreshX100=" + std::to_string(startContext.streamConfiguration.clientRefreshRateX100) ); resources.mediaBackend.set_audio_playback_enabled(settings.playAudioOnXbox); logging::info("stream", std::string("Xbox audio playback ") + (settings.playAudioOnXbox ? "enabled" : "disabled for lower decode latency")); diff --git a/src/streaming/session.h b/src/streaming/session.h index 389aaee..e71c2ab 100644 --- a/src/streaming/session.h +++ b/src/streaming/session.h @@ -32,7 +32,7 @@ namespace streaming { * * @param window Shared SDL window reused from the shell. * @param videoMode Active Xbox video mode. - * @param settings Active shell settings that control stream resolution, bitrate, frame rate, host audio playback, and optional performance metrics in the end-of-stream summary. + * @param settings Active shell settings that control stream resolution, bitrate, frame rate, codec selection, host audio playback, and optional performance metrics in the end-of-stream summary. * @param host Selected paired host. * @param app Selected host app. * @param clientIdentity Paired client identity used for authenticated launch requests. diff --git a/src/streaming/video_codec_selection.cpp b/src/streaming/video_codec_selection.cpp new file mode 100644 index 0000000..dd94c02 --- /dev/null +++ b/src/streaming/video_codec_selection.cpp @@ -0,0 +1,23 @@ +/** + * @file src/streaming/video_codec_selection.cpp + * @brief Maps client codec preferences to shared decoder capability bits. + */ +#include "src/streaming/video_codec_selection.h" + +#include "third-party/moonlight-common-c/src/Limelight.h" + +namespace streaming { + int supported_video_formats(app::VideoCodecSelection selection) { + switch (selection) { + case app::VideoCodecSelection::h264: + return VIDEO_FORMAT_H264; + case app::VideoCodecSelection::mpeg2: + return VIDEO_FORMAT_MPEG2; + case app::VideoCodecSelection::h263p: + return VIDEO_FORMAT_H263P; + case app::VideoCodecSelection::autoDetect: + default: + return VIDEO_FORMAT_H264 | VIDEO_FORMAT_MPEG2 | VIDEO_FORMAT_H263P; + } + } +} // namespace streaming diff --git a/src/streaming/video_codec_selection.h b/src/streaming/video_codec_selection.h new file mode 100644 index 0000000..3775f42 --- /dev/null +++ b/src/streaming/video_codec_selection.h @@ -0,0 +1,17 @@ +/** + * @file src/streaming/video_codec_selection.h + * @brief Declares the codec capabilities associated with client preferences. + */ +#pragma once + +#include "src/app/client_state.h" + +namespace streaming { + /** + * @brief Advertise available decoders or strictly request the selected codec. + * + * @param selection Codec preference selected by the user. + * @return Moonlight video format mask used for negotiation. + */ + int supported_video_formats(app::VideoCodecSelection selection); +} // namespace streaming diff --git a/src/ui/shell_screen.cpp b/src/ui/shell_screen.cpp index f877d6d..8733d48 100644 --- a/src/ui/shell_screen.cpp +++ b/src/ui/shell_screen.cpp @@ -2165,6 +2165,7 @@ namespace { state.settings.preferredVideoModeSet, state.settings.streamFramerate, state.settings.streamBitrateKbps, + state.settings.videoCodec, state.settings.playAudioOnPc, state.settings.showPerformanceStats, state.settings.playAudioOnXbox, diff --git a/src/ui/shell_view.cpp b/src/ui/shell_view.cpp index e5cfb34..0cb9fe0 100644 --- a/src/ui/shell_view.cpp +++ b/src/ui/shell_view.cpp @@ -193,6 +193,27 @@ namespace { return "Logging"; } + /** + * @brief Return a compact settings label for one codec preference. + * + * @param selection Codec preference to describe. + * @return User-facing codec preference label. + */ + const char *video_codec_selection_label(app::VideoCodecSelection selection) { + switch (selection) { + case app::VideoCodecSelection::autoDetect: + return "Auto"; + case app::VideoCodecSelection::h264: + return "H.264"; + case app::VideoCodecSelection::mpeg2: + return "MPEG-2/H.262"; + case app::VideoCodecSelection::h263p: + return "H.263+"; + } + + return "Auto"; + } + std::vector keypad_buttons(const app::ClientState &state) { const std::vector labels = state.addHostDraft.activeField == app::AddHostField::address ? std::vector {"1", "2", "3", "4", "5", "6", "7", "8", "9", ".", "0"} : std::vector {"1", "2", "3", "4", "5", "6", "7", "8", "9", "0"}; @@ -325,7 +346,9 @@ namespace { return lines; } if (state.settings.selectedCategory == app::SettingsCategory::display) { - lines.emplace_back("Display options will be added here."); + lines.push_back(std::string("Stream frame rate: ") + std::to_string(state.settings.streamFramerate) + " FPS"); + lines.push_back(std::string("Stream bitrate: ") + std::to_string(state.settings.streamBitrateKbps) + " kbps"); + lines.push_back(std::string("Video codec: ") + video_codec_selection_label(state.settings.videoCodec)); return lines; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d344033..5102d4f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -80,6 +80,9 @@ target_link_options(${PROJECT_NAME} add_dependencies(${PROJECT_NAME} gtest gtest_main gmock gmock_main moonlight-common-c) +target_sources(${PROJECT_NAME} PRIVATE + "${CMAKE_SOURCE_DIR}/third-party/moonlight-common-c/src/VideoFormats.c") + include(GoogleTest) set(TEST_RUNTIME_PROPERTIES) if(WIN32 OR MINGW OR CMAKE_HOST_WIN32) diff --git a/tests/unit/app/client_state_test.cpp b/tests/unit/app/client_state_test.cpp index a7acd27..8dd5b0d 100644 --- a/tests/unit/app/client_state_test.cpp +++ b/tests/unit/app/client_state_test.cpp @@ -42,6 +42,7 @@ namespace { EXPECT_FALSE(state.hosts.dirty); EXPECT_EQ(state.settings.loggingLevel, logging::LogLevel::none); EXPECT_EQ(state.settings.streamFramerate, 30); + EXPECT_EQ(state.settings.videoCodec, app::VideoCodecSelection::autoDetect); } TEST(ClientStateTest, ReplacesHostsFromPersistenceWithoutMarkingThemDirty) { @@ -1071,6 +1072,30 @@ namespace { EXPECT_EQ(state.shell.statusMessage, "Stream frame rate set to 30 FPS"); } + TEST(ClientStateTest, DisplaySettingsCanForceVideoDecoder) { + app::ClientState state = app::create_initial_state(); + + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::move_left); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.shell.activeScreen, app::ScreenId::settings); + + app::handle_command(state, input::UiCommand::move_down); + app::handle_command(state, input::UiCommand::activate); + ASSERT_EQ(state.settings.selectedCategory, app::SettingsCategory::display); + ASSERT_EQ(state.settings.focusArea, app::SettingsFocusArea::options); + + ASSERT_TRUE(state.detailMenu.select_item_by_id("cycle-video-codec")); + const app::AppUpdate update = app::handle_command(state, input::UiCommand::activate); + + EXPECT_EQ(update.navigation.activatedItemId, "cycle-video-codec"); + EXPECT_TRUE(update.persistence.settingsChanged); + EXPECT_EQ(state.settings.videoCodec, app::VideoCodecSelection::h264); + EXPECT_EQ(state.shell.statusMessage, "Video codec set to H.264"); + ASSERT_NE(state.detailMenu.selected_item(), nullptr); + EXPECT_EQ(state.detailMenu.selected_item()->label, "Video Codec: H.264"); + } + TEST(ClientStateTest, DisplaySettingsCanToggleXboxAudioAndPerformanceStats) { app::ClientState state = app::create_initial_state(); ASSERT_TRUE(state.settings.playAudioOnXbox); diff --git a/tests/unit/app/settings_storage_test.cpp b/tests/unit/app/settings_storage_test.cpp index c0b15c9..937bbed 100644 --- a/tests/unit/app/settings_storage_test.cpp +++ b/tests/unit/app/settings_storage_test.cpp @@ -42,6 +42,18 @@ namespace { } }; + /** + * @brief The canonical codec key takes precedence over the development-era decoder key. + */ + TEST_F(SettingsStorageTest, LoadsCanonicalAndLegacyCodecKeysWithoutLosingPreferences) { + write_text_file(settingsPath, "[streaming]\nvideo_decoder = \"mpeg2\"\n"); + EXPECT_EQ(app::load_app_settings(settingsPath).settings.videoCodec, app::VideoCodecSelection::mpeg2); + write_text_file(settingsPath, "[streaming]\nvideo_codec = \"h263p\"\nvideo_decoder = \"h264\"\n"); + const auto loaded = app::load_app_settings(settingsPath); + EXPECT_EQ(loaded.settings.videoCodec, app::VideoCodecSelection::h263p); + EXPECT_TRUE(loaded.warnings.empty()); + } + TEST_F(SettingsStorageTest, SavesAndLoadsTomlSettings) { const app::AppSettings savedSettings { logging::LogLevel::debug, @@ -51,6 +63,7 @@ namespace { true, 24, 2500, + app::VideoCodecSelection::mpeg2, true, true, true, @@ -71,6 +84,7 @@ namespace { EXPECT_EQ(loadResult.settings.preferredVideoMode.refresh, 60); EXPECT_EQ(loadResult.settings.streamFramerate, 24); EXPECT_EQ(loadResult.settings.streamBitrateKbps, 2500); + EXPECT_EQ(loadResult.settings.videoCodec, app::VideoCodecSelection::mpeg2); EXPECT_TRUE(loadResult.settings.playAudioOnPc); EXPECT_TRUE(loadResult.settings.showPerformanceStats); EXPECT_TRUE(loadResult.settings.playAudioOnXbox); @@ -86,6 +100,7 @@ namespace { true, 15, 500, + app::VideoCodecSelection::h264, false, false, false, @@ -104,6 +119,7 @@ namespace { EXPECT_EQ(loadResult.settings.preferredVideoMode.refresh, 60); EXPECT_EQ(loadResult.settings.streamFramerate, 15); EXPECT_EQ(loadResult.settings.streamBitrateKbps, 500); + EXPECT_EQ(loadResult.settings.videoCodec, app::VideoCodecSelection::h264); EXPECT_FALSE(loadResult.settings.playAudioOnXbox); } @@ -119,6 +135,7 @@ namespace { EXPECT_FALSE(loadResult.settings.preferredVideoModeSet); EXPECT_EQ(loadResult.settings.streamFramerate, 30); EXPECT_EQ(loadResult.settings.streamBitrateKbps, 1000); + EXPECT_EQ(loadResult.settings.videoCodec, app::VideoCodecSelection::autoDetect); EXPECT_FALSE(loadResult.settings.playAudioOnPc); EXPECT_FALSE(loadResult.settings.showPerformanceStats); EXPECT_TRUE(loadResult.settings.playAudioOnXbox); @@ -137,6 +154,7 @@ namespace { "[streaming]\n" "video_width = \"wide\"\n" "fps = \"fast\"\n" + "video_decoder = \"vp9\"\n" "play_audio_on_pc = \"sometimes\"\n" "play_audio_on_xbox = \"sometimes\"\n" ); @@ -150,6 +168,7 @@ namespace { EXPECT_EQ(loadResult.settings.logViewerPlacement, app::LogViewerPlacement::full); EXPECT_FALSE(loadResult.settings.preferredVideoModeSet); EXPECT_EQ(loadResult.settings.streamFramerate, 30); + EXPECT_EQ(loadResult.settings.videoCodec, app::VideoCodecSelection::autoDetect); EXPECT_FALSE(loadResult.settings.playAudioOnPc); EXPECT_TRUE(loadResult.settings.playAudioOnXbox); } diff --git a/tests/unit/streaming/video_codec_selection_test.cpp b/tests/unit/streaming/video_codec_selection_test.cpp new file mode 100644 index 0000000..fff6071 --- /dev/null +++ b/tests/unit/streaming/video_codec_selection_test.cpp @@ -0,0 +1,26 @@ +/** + * @file tests/unit/streaming/video_codec_selection_test.cpp + * @brief Checks automatic capabilities and strict codec requests. + */ +#include "src/streaming/video_codec_selection.h" +#include "third-party/moonlight-common-c/src/Limelight.h" + +#include + +/** + * @brief Automatic mode advertises every decoder implemented by this client. + */ +TEST(VideoCodecSelectionTest, AutomaticModeAdvertisesSupportedCodecs) { + const int formats = VIDEO_FORMAT_H264 | VIDEO_FORMAT_MPEG2 | VIDEO_FORMAT_H263P; + EXPECT_EQ(streaming::supported_video_formats(app::VideoCodecSelection::autoDetect), formats); + EXPECT_EQ(streaming::supported_video_formats(static_cast(-1)), formats); +} + +/** + * @brief Explicit choices cannot negotiate a different codec as a fallback. + */ +TEST(VideoCodecSelectionTest, ExplicitChoicesAdvertiseOnlyTheRequestedCodec) { + EXPECT_EQ(streaming::supported_video_formats(app::VideoCodecSelection::h264), VIDEO_FORMAT_H264); + EXPECT_EQ(streaming::supported_video_formats(app::VideoCodecSelection::mpeg2), VIDEO_FORMAT_MPEG2); + EXPECT_EQ(streaming::supported_video_formats(app::VideoCodecSelection::h263p), VIDEO_FORMAT_H263P); +} diff --git a/tests/unit/streaming/video_formats_test.cpp b/tests/unit/streaming/video_formats_test.cpp new file mode 100644 index 0000000..dbebcdc --- /dev/null +++ b/tests/unit/streaming/video_formats_test.cpp @@ -0,0 +1,104 @@ +/** + * @file tests/unit/streaming/video_formats_test.cpp + * @brief Regression coverage for shared codec negotiation and wire identifiers. + */ +#include +#include +#include + +extern "C" { +#include "third-party/moonlight-common-c/src/VideoFormats.h" +} + +namespace { + constexpr char ALL_CODECS[] = "AV1/90000\nsprop-parameter-sets=AAAAAU\nMPV/90000\nH263-1998/90000\n"; ///< Host SDP advertising every codec. + constexpr int ALL_FORMATS = VIDEO_FORMAT_MASK_AV1 | VIDEO_FORMAT_MASK_H265 | VIDEO_FORMAT_MASK_H264 | VIDEO_FORMAT_MPEG2 | VIDEO_FORMAT_H263P; ///< All known decoder formats. + constexpr int ALL_MODES = SCM_MASK_AV1 | SCM_MASK_HEVC | SCM_MASK_H264 | SCM_MPEG2 | SCM_H263P; ///< All known host codec capabilities. +} // namespace + +/** + * @brief Prefer each modern codec profile before either legacy codec. + */ +TEST(VideoFormatNegotiation, PreservesModernCodecPriorityWhenLegacyCodecsAreAvailable) { + const std::array order = {VIDEO_FORMAT_AV1_HIGH10_444, VIDEO_FORMAT_AV1_MAIN10, VIDEO_FORMAT_AV1_HIGH8_444, VIDEO_FORMAT_AV1_MAIN8, VIDEO_FORMAT_H265_REXT10_444, VIDEO_FORMAT_H265_MAIN10, VIDEO_FORMAT_H265_REXT8_444, VIDEO_FORMAT_H265, VIDEO_FORMAT_H264_HIGH8_444, VIDEO_FORMAT_H264, VIDEO_FORMAT_MPEG2, VIDEO_FORMAT_H263P}; + int supported = ALL_FORMATS; + for (int expected : order) { + EXPECT_EQ(chooseVideoFormat(supported, ALL_MODES, ALL_CODECS), expected); + supported &= ~expected; + } + EXPECT_EQ(chooseVideoFormat(supported, ALL_MODES, ALL_CODECS), 0); +} + +/** + * @brief Select only mutually supported profiles from the client's strict request mask. + */ +TEST(VideoFormatNegotiation, ExplicitRequestsNeverFallBackToAnUnsupportedCodec) { + for (int format : {VIDEO_FORMAT_H264, VIDEO_FORMAT_H265, VIDEO_FORMAT_AV1_MAIN8, VIDEO_FORMAT_MPEG2, VIDEO_FORMAT_H263P}) { + EXPECT_EQ(chooseVideoFormat(format, ALL_MODES, ALL_CODECS), format); + } + EXPECT_EQ(chooseVideoFormat(VIDEO_FORMAT_MPEG2, SCM_H264, ALL_CODECS), 0); + EXPECT_EQ(chooseVideoFormat(VIDEO_FORMAT_H263P, SCM_H264, ALL_CODECS), 0); + EXPECT_EQ(chooseVideoFormat(VIDEO_FORMAT_AV1_MAIN10, SCM_AV1_MAIN8, ALL_CODECS), 0); + EXPECT_EQ(chooseVideoFormat(VIDEO_FORMAT_H265_MAIN10, SCM_HEVC, ALL_CODECS), 0); + EXPECT_EQ(chooseVideoFormat(VIDEO_FORMAT_H264_HIGH8_444, SCM_H264, ALL_CODECS), 0); +} + +/** + * @brief Require matching host capabilities and SDP markers for legacy codecs. + */ +TEST(VideoFormatNegotiation, RequiresHostMetadataAndSdpForLegacyCodecs) { + const std::array aliases = { + std::pair {VIDEO_FORMAT_MPEG2, "MPV/90000"}, + std::pair {VIDEO_FORMAT_MPEG2, "MPEG2/90000"}, + std::pair {VIDEO_FORMAT_MPEG2, "MPEG-2/90000"}, + std::pair {VIDEO_FORMAT_H263P, "H263-1998/90000"}, + std::pair {VIDEO_FORMAT_H263P, "H263-2000/90000"}, + }; + for (const auto &[format, sdp] : aliases) { + EXPECT_EQ(chooseVideoFormat(format, ALL_MODES, sdp), format); + EXPECT_EQ(chooseVideoFormat(format, 0, sdp), 0); + EXPECT_EQ(chooseVideoFormat(format, ALL_MODES, ""), 0); + } + EXPECT_EQ(chooseVideoFormat(ALL_FORMATS, ALL_MODES, nullptr), 0); +} + +/** + * @brief Preserve H.264 for old hosts while respecting explicit legacy-only advertisements. + */ +TEST(VideoFormatNegotiation, SupportsLegacyHostsAndLegacyOnlyHosts) { + EXPECT_EQ(chooseVideoFormat(ALL_FORMATS, 0, ""), VIDEO_FORMAT_H264); + EXPECT_EQ(chooseVideoFormat(ALL_FORMATS, 0, "sprop-parameter-sets=AAAAAU"), VIDEO_FORMAT_H265); + EXPECT_EQ(chooseVideoFormat(ALL_FORMATS, 0, ALL_CODECS), VIDEO_FORMAT_AV1_MAIN8); + EXPECT_EQ(chooseVideoFormat(VIDEO_FORMAT_H265_MAIN10, 0, ALL_CODECS), 0); + EXPECT_EQ(chooseVideoFormat(VIDEO_FORMAT_AV1_MAIN10, 0, ALL_CODECS), 0); + EXPECT_EQ(chooseVideoFormat(ALL_FORMATS, SCM_MPEG2, ALL_CODECS), VIDEO_FORMAT_MPEG2); + EXPECT_EQ(chooseVideoFormat(ALL_FORMATS, SCM_H263P, ALL_CODECS), VIDEO_FORMAT_H263P); + EXPECT_EQ(chooseVideoFormat(ALL_FORMATS, SCM_HEVC, ""), 0); + EXPECT_EQ(chooseVideoFormat(ALL_FORMATS, SCM_AV1_MAIN8, ""), 0); +} + +/** + * @brief Map every known codec profile and reject combinations and reserved bits. + */ +TEST(VideoFormatNegotiation, EncodesOnlyValidSingleNegotiatedFormats) { + const std::array mappings = { + std::pair {VIDEO_FORMAT_H264, 0}, + std::pair {VIDEO_FORMAT_H264_HIGH8_444, 0}, + std::pair {VIDEO_FORMAT_H265, 1}, + std::pair {VIDEO_FORMAT_H265_MAIN10, 1}, + std::pair {VIDEO_FORMAT_H265_REXT8_444, 1}, + std::pair {VIDEO_FORMAT_H265_REXT10_444, 1}, + std::pair {VIDEO_FORMAT_AV1_MAIN8, 2}, + std::pair {VIDEO_FORMAT_AV1_MAIN10, 2}, + std::pair {VIDEO_FORMAT_AV1_HIGH8_444, 2}, + std::pair {VIDEO_FORMAT_AV1_HIGH10_444, 2}, + std::pair {VIDEO_FORMAT_MPEG2, 3}, + std::pair {VIDEO_FORMAT_H263P, 4}, + }; + for (const auto &[format, wire] : mappings) { + EXPECT_EQ(getVideoBitStreamFormat(format), wire); + } + for (int invalid : {0, -1, 0x2, 0x8, 0x40000000, VIDEO_FORMAT_H264 | VIDEO_FORMAT_MPEG2}) { + EXPECT_EQ(getVideoBitStreamFormat(invalid), -1); + } +} diff --git a/third-party/moonlight-common-c b/third-party/moonlight-common-c index 94c65fd..ed6b2da 160000 --- a/third-party/moonlight-common-c +++ b/third-party/moonlight-common-c @@ -1 +1 @@ -Subproject commit 94c65fd364d17b223f00a8763c9a5d94a66cb8dd +Subproject commit ed6b2da643e5c16c315c4e4a93f0ac601c5cb32c