diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0666d1a..ce57c13 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,12 @@ jobs: run: choco install openssl innosetup -y --no-progress - name: Build - run: powershell -ExecutionPolicy Bypass -File scripts\build.ps1 + shell: pwsh + run: ./scripts/build.ps1 + + - name: Test + shell: pwsh + run: ctest --test-dir build -C Release --output-on-failure - name: Build installer shell: pwsh diff --git a/CMakeLists.txt b/CMakeLists.txt index 75da960..9439b21 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.16) -project(AirScreen VERSION 1.0.0 LANGUAGES C CXX) +project(AirScreen VERSION 1.2.0 LANGUAGES C CXX) set(CMAKE_C_STANDARD 11) set(CMAKE_C_STANDARD_REQUIRED ON) @@ -111,9 +111,9 @@ target_link_libraries(airplay PUBLIC bcrypt ) if (MSVC) - target_compile_options(airplay PRIVATE /wd4244 /wd4267 /wd4018 /wd4996 /FI"${COMPAT_DIR}/airscreen_types.h") - target_compile_options(plist PRIVATE /wd4244 /wd4267 /wd4996 /wd4018 /FI"${COMPAT_DIR}/airscreen_types.h") - target_compile_options(dnssd PRIVATE /wd4244 /wd4267 /FI"${COMPAT_DIR}/airscreen_types.h") + target_compile_options(airplay PRIVATE /wd4244 /wd4267 /wd4018 /wd4996 "/FI${COMPAT_DIR}/airscreen_types.h") + target_compile_options(plist PRIVATE /wd4244 /wd4267 /wd4996 /wd4018 "/FI${COMPAT_DIR}/airscreen_types.h") + target_compile_options(dnssd PRIVATE /wd4244 /wd4267 "/FI${COMPAT_DIR}/airscreen_types.h") endif() if (MSVC) @@ -190,3 +190,14 @@ install(TARGETS AirScreen RUNTIME DESTINATION .) if (FFMPEG_BIN_DIR) install(DIRECTORY "${FFMPEG_BIN_DIR}/" DESTINATION . FILES_MATCHING PATTERN "*.dll") endif() + +include(CTest) +if (BUILD_TESTING) + add_executable(MediaTimingTests tests/media_timing_test.cpp) + target_include_directories(MediaTimingTests PRIVATE src) + add_test(NAME MediaTiming COMMAND MediaTimingTests) + + add_executable(WindowSizeTests tests/window_size_test.cpp) + target_include_directories(WindowSizeTests PRIVATE src) + add_test(NAME WindowSize COMMAND WindowSizeTests) +endif() diff --git a/README.md b/README.md index 8f5884a..ec397de 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ AirScreen turns a Windows PC into a wireless AirPlay receiver. Your iPhone lists - Audio plays through your default Windows output device - Rename the receiver, require a pairing PIN, keep the window on top, fill the screen or letterbox - Light and dark idle screen +- Native-resolution window sizes (0.5x, 1x, 1.5x, 2x), optional fixed aspect ratio, and automatic portrait/landscape sizing - Closing the window quits AirScreen; settings are also in the tray icon menu - Optional Start with Windows: waits in the system tray, ready to mirror to @@ -52,16 +53,23 @@ The installer isn't code-signed yet, so Windows SmartScreen may show "Windows pr 2. On your iPhone, open **Control Center → Screen Mirroring** and pick **AirScreen**. 3. If a PIN appears on the PC, enter it on the iPhone. -To stop, end mirroring on the iPhone. Close the window to quit AirScreen. Right-click the window or the tray icon for settings. +To stop mirroring, end it on the iPhone or choose **Stop mirroring** above **Exit** in the window or tray menu. AirScreen stays available for another connection. Close the window to quit AirScreen. Right-click the window or the tray icon for settings. + +**Streaming quality** defaults to 1080p at 30 FPS and also offers 1080p at 60 or 24 FPS, and 720p at 30 FPS (Smooth). Saved quality settings are preserved. Lower settings request a smaller stream from the iPhone, reducing network and decoding load. Changing quality restarts the receiver; reconnect the iPhone afterward. The iPhone chooses the actual stream dimensions and frame rate. For persistent playback delay, try 720p at 30 FPS before reducing audio buffering. + +**Size** sets the picture area to **0.5x**, **1x**, **1.5x**, or **2x** the resolution reported by the mirrored device. Use **Ctrl+1**, **Ctrl+2**, **Ctrl+3**, or **Ctrl+4**, respectively. A size selected before connecting applies when the stream starts. Dragging an edge returns to a custom size. Enable **Fixed Ratio** to keep the original picture proportions while resizing. **Auto Rotate Window** is enabled by default and adjusts the window when the mirrored device switches between portrait and landscape; in full screen, it updates the window size restored on exit. Both options are saved. + +In full screen, portrait video fits the screen height with its original proportions. **Fill screen (landscape only)** controls whether landscape video fills the display or keeps the complete picture visible. ## Troubleshooting **AirScreen doesn't appear on the iPhone** +- On Windows, discovery uses a connected physical Ethernet or Wi-Fi adapter and its current IPv4 address. VPN and Hyper-V virtual adapters are excluded. - Make sure both devices are on the same Wi-Fi network (not a guest network). - Turn off any VPN on the PC or the iPhone. - Right-click the tray icon → **Allow through firewall**. -**Stuttering or delay.** Use 5 GHz Wi-Fi or connect the PC by Ethernet. Other devices streaming on the same network can add delay. +**Stuttering or delay.** Use 5 GHz Wi-Fi or connect the PC by Ethernet. Other devices streaming on the same network can add delay. Decoding and presentation run independently; overdue decoded frames are skipped to follow audio playback. With no audio, the newest decoded frame is shown immediately. The log includes the decoder format and periodic video timing statistics. **No sound.** Check the default playback device in Windows sound settings. Apps that take exclusive control of the device can block AirScreen. @@ -76,7 +84,7 @@ Requires Visual Studio 2022 with the *Desktop development with C++* workload. ```powershell winget install Kitware.CMake winget install ShiningLight.OpenSSL.Dev -powershell -ExecutionPolicy Bypass -File scripts\build.ps1 +pwsh -File scripts\build.ps1 ``` `build.ps1` downloads FFmpeg into `third_party\prebuilt`, configures CMake and builds `build\Release\AirScreen.exe` with the required DLLs next to it. OpenSSL is found through `OPENSSL_ROOT_DIR`, the Shining Light install location, or vcpkg. diff --git a/scripts/airscreen.iss b/scripts/airscreen.iss index ba600d1..9290d9d 100644 --- a/scripts/airscreen.iss +++ b/scripts/airscreen.iss @@ -2,7 +2,7 @@ ; iscc /DCrtDir=\VC\Redist\MSVC\\x64\Microsoft.VC143.CRT scripts\airscreen.iss #define MyAppName "AirScreen" #ifndef MyAppVersion - #define MyAppVersion "1.0.0" + #define MyAppVersion "1.2.0" #endif #define MyAppPublisher "AirScreen" #define MyAppURL "https://github.com/donerfolk/airscreen" diff --git a/src/app/resource.h b/src/app/resource.h index 51e0bc2..e77e63a 100644 --- a/src/app/resource.h +++ b/src/app/resource.h @@ -15,3 +15,16 @@ #define IDM_TRAY_FILL 40009 #define IDC_SETTINGS 40010 #define IDM_TRAY_STARTUP 40011 + +#define IDM_QUALITY_1080_60 40012 +#define IDM_QUALITY_1080_30 40013 +#define IDM_QUALITY_1080_24 40014 +#define IDM_QUALITY_720_30 40015 + +#define IDM_SIZE_HALF 40016 +#define IDM_SIZE_NATIVE 40017 +#define IDM_SIZE_ONE_HALF 40018 +#define IDM_SIZE_DOUBLE 40019 +#define IDM_FIXED_RATIO 40020 +#define IDM_AUTO_ROTATE 40021 +#define IDM_TRAY_STOP 40022 diff --git a/src/audio.cpp b/src/audio.cpp index df8ca34..2c38fea 100644 --- a/src/audio.cpp +++ b/src/audio.cpp @@ -88,6 +88,17 @@ size_t ms_to_samples(int rate, int ch, int ms) { return (size_t) rate * (size_t) ch * (size_t) ms / 1000; } +int64_t monotonic_ns() { + static const int64_t frequency = [] { + LARGE_INTEGER value; + QueryPerformanceFrequency(&value); + return value.QuadPart; + }(); + LARGE_INTEGER value; + QueryPerformanceCounter(&value); + return value.QuadPart / frequency * 1000000000 + value.QuadPart % frequency * 1000000000 / frequency; +} + DWORD WINAPI audio_thread_proc(LPVOID p) { auto *self = static_cast(p); self->render_thread(); @@ -185,18 +196,26 @@ void AudioPipeline::stop_locked() { buffering_ = true; ring_.clear(); rpos_ = wpos_ = count_ = 0; + timeline_.clear(); } void AudioPipeline::flush() { std::lock_guard dlock(dec_mu_); std::lock_guard lock(mu_); rpos_ = wpos_ = count_ = 0; + timeline_.clear(); buffering_ = true; if (av_codec_) { avcodec_flush_buffers((AVCodecContext *) av_codec_); } } +int64_t AudioPipeline::playback_time_ns() { + std::lock_guard lock(mu_); + return running_ && out_rate_ > 0 && out_ch_ > 0 + ? timeline_.position(monotonic_ns(), out_rate_ * out_ch_) : 0; +} + void AudioPipeline::set_volume(float linear) { if (linear < 0) { linear = 0; @@ -229,6 +248,7 @@ bool AudioPipeline::init_decoder(unsigned char codec_type) { av_channel_layout_default(&ctx->ch_layout, src_ch_); ctx->request_sample_fmt = AV_SAMPLE_FMT_S16; ctx->flags |= AV_CODEC_FLAG_LOW_DELAY; + ctx->pkt_timebase = AVRational{1, 1000000000}; if (id == AV_CODEC_ID_ALAC) { set_extra(ctx, kAlacMagic, (int) sizeof(kAlacMagic)); } else if (codec_type == 4) { @@ -380,6 +400,7 @@ bool AudioPipeline::init_wasapi() { std::lock_guard lock(mu_); ring_.assign(cap, 0); rpos_ = wpos_ = count_ = 0; + timeline_.clear(); return true; } @@ -429,7 +450,7 @@ bool AudioPipeline::ensure_swr(int in_fmt, int in_rate, int in_ch) { return true; } -void AudioPipeline::convert_and_push(const uint8_t **src, int in_samples) { +void AudioPipeline::convert_and_push(const uint8_t **src, int in_samples, int64_t timestamp_ns) { if (!swr_ || in_samples <= 0) { return; } @@ -439,19 +460,26 @@ void AudioPipeline::convert_and_push(const uint8_t **src, int in_samples) { } std::vector pcm((size_t) out_samples * (size_t) out_ch_ + 32); uint8_t *dst = (uint8_t *) pcm.data(); + int64_t delay_ns = swr_get_delay((SwrContext *) swr_, 1000000000); int got = swr_convert((SwrContext *) swr_, &dst, out_samples, src, in_samples); if (got > 0) { - push_pcm(pcm.data(), got, out_ch_); + push_pcm(pcm.data(), got, out_ch_, timestamp_ns > 0 ? timestamp_ns - delay_ns : 0); } } -void AudioPipeline::push_pcm(const int16_t *pcm, int frames, int channels) { +void AudioPipeline::push_pcm(const int16_t *pcm, int frames, int channels, int64_t timestamp_ns) { std::lock_guard lock(mu_); if (ring_.empty() || frames <= 0 || channels <= 0) { return; } size_t samples = (size_t) frames * (size_t) channels; size_t cap = ring_.size(); + if (samples > cap) { + size_t trim = samples - cap; + pcm += trim; + samples = cap; + timestamp_ns += AudioTimeline::duration_ns(trim, out_rate_ * out_ch_); + } size_t max_keep = ms_to_samples(out_rate_, out_ch_, kMaxMs); if (max_keep < samples) { max_keep = samples; @@ -468,7 +496,9 @@ void AudioPipeline::push_pcm(const int16_t *pcm, int frames, int channels) { rpos_ = (rpos_ + drop) % cap; count_ -= drop; dropped_ += drop; + timeline_.discard(drop, out_rate_ * out_ch_); } + timeline_.append(timestamp_ns, samples); float vol = volume_.load(); for (size_t i = 0; i < samples; ++i) { int v = (int) (pcm[i] * vol); @@ -489,7 +519,6 @@ void AudioPipeline::push_pcm(const int16_t *pcm, int frames, int channels) { } void AudioPipeline::submit(const uint8_t *data, int len, uint64_t ntp_ns) { - (void) ntp_ns; if (!data || len <= 0) { return; } @@ -504,7 +533,7 @@ void AudioPipeline::submit(const uint8_t *data, int len, uint64_t ntp_ns) { return; } const uint8_t *src = data; - convert_and_push(&src, frames); + convert_and_push(&src, frames, (int64_t) ntp_ns); return; } if (!av_codec_ || !av_pkt_ || !av_frame_) { @@ -512,12 +541,16 @@ void AudioPipeline::submit(const uint8_t *data, int len, uint64_t ntp_ns) { } auto *ctx = (AVCodecContext *) av_codec_; auto *pkt = (AVPacket *) av_pkt_; - auto *frame = (AVFrame *) av_frame_; if (av_new_packet(pkt, len) < 0) { return; } memcpy(pkt->data, data, (size_t) len); + pkt->pts = ntp_ns ? (int64_t) ntp_ns : AV_NOPTS_VALUE; int serr = avcodec_send_packet(ctx, pkt); + if (serr == AVERROR(EAGAIN)) { + receive_frames(); + serr = avcodec_send_packet(ctx, pkt); + } av_packet_unref(pkt); if (serr < 0 && serr != AVERROR(EAGAIN)) { static int decode_errs = 0; @@ -529,6 +562,12 @@ void AudioPipeline::submit(const uint8_t *data, int len, uint64_t ntp_ns) { } return; } + receive_frames(); +} + +void AudioPipeline::receive_frames() { + auto *ctx = (AVCodecContext *) av_codec_; + auto *frame = (AVFrame *) av_frame_; while (avcodec_receive_frame(ctx, frame) == 0) { int in_rate = frame->sample_rate > 0 ? frame->sample_rate : src_rate_; int in_ch = frame->ch_layout.nb_channels > 0 ? frame->ch_layout.nb_channels : src_ch_; @@ -536,7 +575,8 @@ void AudioPipeline::submit(const uint8_t *data, int len, uint64_t ntp_ns) { av_frame_unref(frame); continue; } - convert_and_push((const uint8_t **) frame->extended_data, frame->nb_samples); + convert_and_push((const uint8_t **) frame->extended_data, frame->nb_samples, + frame->pts == AV_NOPTS_VALUE ? 0 : frame->pts); av_frame_unref(frame); } } @@ -565,6 +605,7 @@ void AudioPipeline::render_thread() { if (!running_) { break; } + int64_t output_now = monotonic_ns(); UINT32 padding = 0; if (FAILED(client->GetCurrentPadding(&padding))) { continue; @@ -590,6 +631,7 @@ void AudioPipeline::render_thread() { rpos_ = (rpos_ + drop) % cap; count_ -= drop; dropped_ += drop; + timeline_.discard(drop, out_rate_ * out_ch_); buffering_ = false; } if (!buffering_) { @@ -608,21 +650,27 @@ void AudioPipeline::render_thread() { if (n < need) { memset(tmp.data() + n, 0, (need - n) * sizeof(int16_t)); } - } - if (out_float_) { - float *out = (float *) dest; - for (size_t i = 0; i < need; ++i) { - out[i] = tmp[i] * (1.0f / 32768.0f); + if (out_float_) { + float *out = (float *) dest; + for (size_t i = 0; i < need; ++i) { + out[i] = tmp[i] * (1.0f / 32768.0f); + } + } else if (out_bits_ >= 24) { + int32_t *out = (int32_t *) dest; + for (size_t i = 0; i < need; ++i) { + out[i] = (int32_t) tmp[i] << 16; + } + } else { + memcpy(dest, tmp.data(), need * sizeof(int16_t)); } - } else if (out_bits_ >= 24) { - int32_t *out = (int32_t *) dest; - for (size_t i = 0; i < need; ++i) { - out[i] = (int32_t) tmp[i] << 16; + HRESULT released = render->ReleaseBuffer(avail, 0); + if (SUCCEEDED(released)) { + timeline_.schedule(n, output_now + AudioTimeline::duration_ns( + (size_t) padding * out_ch_, out_rate_ * out_ch_), out_rate_ * out_ch_, output_now); + } else { + timeline_.discard(n, out_rate_ * out_ch_); } - } else { - memcpy(dest, tmp.data(), need * sizeof(int16_t)); } - render->ReleaseBuffer(avail, 0); } if (mmcss) { AvRevertMmThreadCharacteristics(mmcss); diff --git a/src/audio.h b/src/audio.h index 496729a..eff0014 100644 --- a/src/audio.h +++ b/src/audio.h @@ -1,5 +1,7 @@ #pragma once +#include "media_timing.h" + #include #include #include @@ -19,15 +21,17 @@ class AudioPipeline { void submit(const uint8_t *data, int len, uint64_t ntp_ns); bool is_running() const { return running_.load(); } void render_thread(); + int64_t playback_time_ns(); private: bool init_decoder(unsigned char codec_type); bool init_wasapi(); + void receive_frames(); void teardown_wasapi(); void stop_locked(); - void push_pcm(const int16_t *pcm, int frames, int channels); + void push_pcm(const int16_t *pcm, int frames, int channels, int64_t timestamp_ns); bool ensure_swr(int in_fmt, int in_rate, int in_ch); - void convert_and_push(const uint8_t **src, int in_samples); + void convert_and_push(const uint8_t **src, int in_samples, int64_t timestamp_ns); void *av_codec_ = nullptr; void *av_frame_ = nullptr; @@ -58,6 +62,7 @@ class AudioPipeline { size_t rpos_ = 0; size_t wpos_ = 0; size_t count_ = 0; + AudioTimeline timeline_; bool buffering_ = true; // refilling cushion before playing; guarded by mu_ unsigned rebuffers_ = 0; size_t dropped_ = 0; diff --git a/src/main.cpp b/src/main.cpp index fcddc03..07a682d 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,7 @@ #include "receiver.h" #include "settings.h" #include "ui.h" +#include "window_size.h" #include "app/resource.h" #include "raop.h" @@ -34,6 +35,34 @@ constexpr UINT kOverlayHideMs = 2200; constexpr UINT kDisconnectMs = 400; UINT g_taskbar_created = 0; +struct VideoQuality { + UINT command; + int width; + int height; + int max_fps; + const wchar_t *label; +}; + +constexpr VideoQuality kVideoQualities[] = { + {IDM_QUALITY_1080_60, 1920, 1080, 60, L"1080p · 60 FPS"}, + {IDM_QUALITY_1080_30, 1920, 1080, 30, L"1080p · 30 FPS"}, + {IDM_QUALITY_1080_24, 1920, 1080, 24, L"1080p · 24 FPS"}, + {IDM_QUALITY_720_30, 1280, 720, 30, L"720p · 30 FPS (Smooth)"}, +}; + +struct WindowSize { + UINT command; + double scale; + const wchar_t *label; +}; + +constexpr WindowSize kWindowSizes[] = { + {IDM_SIZE_HALF, 0.5, L"0.5x\tCtrl+1"}, + {IDM_SIZE_NATIVE, 1.0, L"1x\tCtrl+2"}, + {IDM_SIZE_ONE_HALF, 1.5, L"1.5x\tCtrl+3"}, + {IDM_SIZE_DOUBLE, 2.0, L"2x\tCtrl+4"}, +}; + struct NameDlgState { std::wstring initial; std::wstring result; @@ -58,6 +87,9 @@ struct App { LONG windowed_style = 0; LONG windowed_ex = 0; RECT windowed_rc = {}; + RECT sizing_start_rc = {}; + SIZE source_size = {}; + double window_scale = 0; std::wstring overlay_status = L"Waiting for iPhone"; std::wstring overlay_pin; std::wstring overlay_detail; @@ -109,7 +141,8 @@ void update_title(App *app) { } void apply_video_fill(App *app) { - app->receiver.video().set_cover(app->fullscreen && app->settings.fill_screen); + bool landscape = app->source_size.cx >= app->source_size.cy && app->source_size.cx > 0; + app->receiver.video().set_cover(app->fullscreen && app->settings.fill_screen && landscape); } void layout_chrome(App *app) { @@ -198,6 +231,85 @@ void toggle_fullscreen(App *app) { set_fullscreen(app, !app->fullscreen); } +SIZE window_frame(App *app) { + LONG style = app->fullscreen ? app->windowed_style : GetWindowLongW(app->hwnd, GWL_STYLE); + LONG ex = app->fullscreen ? app->windowed_ex : GetWindowLongW(app->hwnd, GWL_EXSTYLE); + RECT frame = {}; + AdjustWindowRectExForDpi(&frame, style & ~(WS_MAXIMIZE | WS_MINIMIZE), FALSE, ex, window_dpi(app->hwnd)); + return {frame.right - frame.left, frame.bottom - frame.top}; +} + +RECT normal_window_rect(App *app) { + if (app->fullscreen) return app->windowed_rc; + RECT rect = {}; + if (IsZoomed(app->hwnd) || IsIconic(app->hwnd)) { + WINDOWPLACEMENT placement = {sizeof(placement)}; + GetWindowPlacement(app->hwnd, &placement); + rect = placement.rcNormalPosition; + MONITORINFO monitor = {sizeof(monitor)}; + GetMonitorInfoW(MonitorFromWindow(app->hwnd, MONITOR_DEFAULTTONEAREST), &monitor); + OffsetRect(&rect, monitor.rcWork.left - monitor.rcMonitor.left, + monitor.rcWork.top - monitor.rcMonitor.top); + } else { + GetWindowRect(app->hwnd, &rect); + } + return rect; +} + +void resize_video_window(App *app, double scale) { + if (app->source_size.cx <= 0 || app->source_size.cy <= 0) return; + RECT current = normal_window_rect(app); + SIZE frame = window_frame(app); + MONITORINFO monitor = {sizeof(monitor)}; + GetMonitorInfoW(MonitorFromRect(¤t, MONITOR_DEFAULTTONEAREST), &monitor); + RECT work = monitor.rcWork; + if (scale <= 0) { + scale = (double) std::max(current.right - current.left - frame.cx, + current.bottom - current.top - frame.cy) / + std::max(app->source_size.cx, app->source_size.cy); + scale = std::min(scale, std::min((double) (work.right - work.left - frame.cx) / app->source_size.cx, + (double) (work.bottom - work.top - frame.cy) / app->source_size.cy)); + } + SIZE size = airscreen::scaled_video_size(app->source_size, scale); + size.cx += frame.cx; + size.cy += frame.cy; + RECT target = airscreen::sized_window_rect(current, size, work); + if (app->fullscreen) { + app->windowed_rc = target; + } else if (IsZoomed(app->hwnd) || IsIconic(app->hwnd)) { + WINDOWPLACEMENT placement = {sizeof(placement)}; + GetWindowPlacement(app->hwnd, &placement); + placement.rcNormalPosition = target; + OffsetRect(&placement.rcNormalPosition, monitor.rcMonitor.left - work.left, + monitor.rcMonitor.top - work.top); + SetWindowPlacement(app->hwnd, &placement); + } else { + SetWindowPos(app->hwnd, nullptr, target.left, target.top, size.cx, size.cy, + SWP_NOZORDER | SWP_NOACTIVATE); + } +} + +void select_window_size(App *app, const WindowSize &size) { + set_fullscreen(app, false); + if (IsZoomed(app->hwnd) || IsIconic(app->hwnd)) ShowWindow(app->hwnd, SW_RESTORE); + app->window_scale = size.scale; + resize_video_window(app, size.scale); +} + +void update_source_size(App *app, int width, int height) { + if (width <= 0 || height <= 0) return; + SIZE previous = app->source_size; + if (previous.cx == width && previous.cy == height) return; + app->source_size = {width, height}; + apply_video_fill(app); + bool first = previous.cx <= 0 || previous.cy <= 0; + bool rotated = (previous.cx > previous.cy) != (width > height); + if ((first && (app->window_scale > 0 || app->settings.fixed_ratio)) || + (app->settings.auto_rotate && (first || rotated || app->window_scale > 0))) { + resize_video_window(app, app->window_scale); + } +} + void set_boost(bool on) { static bool boosted = false; if (on == boosted) { @@ -225,7 +337,8 @@ void return_to_menu(App *app) { set_boost(false); app->connected = false; app->mirroring = false; - app->overlay_status = L"Waiting for iPhone"; + app->source_size = {}; + app->overlay_status = app->start_failed ? L"Could not start. Check the log from Settings" : L"Waiting for iPhone"; app->overlay_pin.clear(); if (app->video_hwnd) { ShowWindow(app->video_hwnd, SW_HIDE); @@ -285,23 +398,58 @@ void do_rename(App *app) { } } +void start_receiver(App *app) { + auto ui = [app](airscreen::UiEvent ev, const std::string &msg) { app->post_ui(ev, msg); }; + app->overlay_status = L"Starting AirPlay…"; + InvalidateRect(app->hwnd, nullptr, FALSE); + app->start_failed = !app->receiver.start(app->video_hwnd, app->settings, ui); + if (app->start_failed) { + app->overlay_status = L"Could not start. Check the log from Settings"; + InvalidateRect(app->hwnd, nullptr, FALSE); + MessageBoxW(app->hwnd, + L"Could not start the AirPlay receiver.\n\nAllow AirScreen through the firewall (tray menu) and check %APPDATA%\\AirScreen\\airscreen.log", + L"AirScreen", MB_OK | MB_ICONERROR); + } else { + app->overlay_status = L"Waiting for iPhone"; + apply_video_fill(app); + InvalidateRect(app->hwnd, nullptr, FALSE); + } +} + void popup_settings_menu(App *app, POINT screen_pt, bool tray) { HMENU m = CreatePopupMenu(); if (tray) { AppendMenuW(m, MF_STRING, IDM_TRAY_SHOW, L"Show AirScreen"); } AppendMenuW(m, MF_STRING, IDM_TRAY_RENAME, L"Rename receiver…"); + HMENU quality = CreatePopupMenu(); + for (const auto &preset : kVideoQualities) { + UINT flags = MF_STRING; + if (app->settings.width == preset.width && app->settings.height == preset.height && + app->settings.max_fps == preset.max_fps) flags |= MF_CHECKED; + AppendMenuW(quality, flags, preset.command, preset.label); + } + AppendMenuW(m, MF_POPUP, (UINT_PTR) quality, L"Streaming quality"); + HMENU size = CreatePopupMenu(); + for (const auto &preset : kWindowSizes) { + AppendMenuW(size, MF_STRING | (app->window_scale == preset.scale ? MF_CHECKED : 0), + preset.command, preset.label); + } + AppendMenuW(m, MF_POPUP, (UINT_PTR) size, L"Size"); + AppendMenuW(m, MF_STRING | (app->settings.fixed_ratio ? MF_CHECKED : 0), IDM_FIXED_RATIO, L"Fixed Ratio"); + AppendMenuW(m, MF_STRING | (app->settings.auto_rotate ? MF_CHECKED : 0), IDM_AUTO_ROTATE, L"Auto Rotate Window"); AppendMenuW(m, MF_STRING | (app->settings.always_on_top ? MF_CHECKED : 0), IDM_TRAY_TOP, L"Always on top"); AppendMenuW(m, MF_STRING | (app->settings.require_pin ? MF_CHECKED : 0), IDM_TRAY_PIN, L"Require pairing PIN"); AppendMenuW(m, MF_STRING | (app->fullscreen ? MF_CHECKED : 0), IDM_TRAY_FULL, L"Fullscreen"); AppendMenuW(m, MF_STRING | (app->settings.fill_screen ? MF_CHECKED : 0), IDM_TRAY_FILL, - L"Fill screen (no black bars)"); + L"Fill screen (landscape only)"); AppendMenuW(m, MF_STRING | (airscreen::start_with_windows() ? MF_CHECKED : 0), IDM_TRAY_STARTUP, L"Start with Windows"); AppendMenuW(m, MF_SEPARATOR, 0, nullptr); AppendMenuW(m, MF_STRING, IDM_TRAY_FIREWALL, L"Allow through firewall"); AppendMenuW(m, MF_STRING, IDM_TRAY_LOG, L"Open log file"); AppendMenuW(m, MF_SEPARATOR, 0, nullptr); + AppendMenuW(m, MF_STRING | (app->connected ? 0 : MF_GRAYED), IDM_TRAY_STOP, L"Stop mirroring"); AppendMenuW(m, MF_STRING, IDM_TRAY_EXIT, L"Exit"); SetForegroundWindow(app->hwnd); KillTimer(app->hwnd, kTimerOverlay); @@ -339,6 +487,28 @@ void paint_idle(App *app, HDC hdc, RECT rc) { } void handle_tray_cmd(App *app, UINT id) { + for (const auto &size : kWindowSizes) { + if (size.command == id) { + select_window_size(app, size); + return; + } + } + for (const auto &preset : kVideoQualities) { + if (preset.command != id) continue; + if (app->settings.width == preset.width && app->settings.height == preset.height && + app->settings.max_fps == preset.max_fps) return; + bool reconnect = app->connected; + app->settings.width = preset.width; + app->settings.height = preset.height; + app->settings.max_fps = preset.max_fps; + airscreen::save_settings(app->settings); + start_receiver(app); + if (reconnect && !app->start_failed) { + MessageBoxW(app->hwnd, L"Streaming quality changed. Reconnect your iPhone to continue mirroring.", + L"AirScreen", MB_OK | MB_ICONINFORMATION); + } + return; + } switch (id) { case IDM_TRAY_SHOW: ShowWindow(app->hwnd, SW_RESTORE); @@ -367,6 +537,16 @@ void handle_tray_cmd(App *app, UINT id) { airscreen::save_settings(app->settings); apply_video_fill(app); break; + case IDM_FIXED_RATIO: + app->settings.fixed_ratio = !app->settings.fixed_ratio; + airscreen::save_settings(app->settings); + if (app->settings.fixed_ratio) resize_video_window(app, app->window_scale); + break; + case IDM_AUTO_ROTATE: + app->settings.auto_rotate = !app->settings.auto_rotate; + airscreen::save_settings(app->settings); + if (app->settings.auto_rotate) resize_video_window(app, app->window_scale); + break; case IDM_TRAY_STARTUP: airscreen::set_start_with_windows(!airscreen::start_with_windows()); break; @@ -383,6 +563,12 @@ void handle_tray_cmd(App *app, UINT id) { ShellExecuteW(nullptr, L"open", p.c_str(), nullptr, nullptr, SW_SHOWNORMAL); break; } + case IDM_TRAY_STOP: + if (app->connected) { + app->start_failed = !app->receiver.stop_mirroring(); + return_to_menu(app); + } + break; case IDM_TRAY_EXIT: DestroyWindow(app->hwnd); return; @@ -475,7 +661,9 @@ LRESULT CALLBACK video_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) { return 1; case WM_MOUSEACTIVATE: SetFocus(parent); - return MA_NOACTIVATE; + return MA_ACTIVATE; + case WM_KEYDOWN: + return SendMessageW(parent, msg, wparam, lparam); case WM_MOUSEMOVE: case WM_LBUTTONDOWN: case WM_LBUTTONDBLCLK: @@ -502,6 +690,8 @@ LRESULT CALLBACK gear_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) { return 1; case WM_MOUSEACTIVATE: return MA_NOACTIVATE; + case WM_KEYDOWN: + return SendMessageW(GetParent(hwnd), msg, wparam, lparam); case WM_MOUSEMOVE: { TRACKMOUSEEVENT tme = {sizeof(tme), TME_LEAVE, hwnd, 0}; TrackMouseEvent(&tme); @@ -560,16 +750,45 @@ LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) { SetWindowPos(hwnd, nullptr, sugg->left, sugg->top, sugg->right - sugg->left, sugg->bottom - sugg->top, SWP_NOZORDER | SWP_NOACTIVATE); if (app) { + if (!app->fullscreen && (app->window_scale > 0 || app->settings.fixed_ratio)) { + resize_video_window(app, app->window_scale); + } layout_chrome(app); } return 0; } case WM_GETMINMAXINFO: { auto *mm = (MINMAXINFO *) lparam; - mm->ptMinTrackSize.x = 720; - mm->ptMinTrackSize.y = 480; + if (app && app->source_size.cx > 0 && app->source_size.cy > 0) { + SIZE frame = window_frame(app); + SIZE minimum = {64, 64}; + if (app->settings.fixed_ratio) { + minimum = airscreen::scaled_video_size(app->source_size, + std::max(64.0 / app->source_size.cx, 64.0 / app->source_size.cy)); + } + mm->ptMinTrackSize = {minimum.cx + frame.cx, minimum.cy + frame.cy}; + SIZE maximum = airscreen::scaled_video_size(app->source_size, 2.0); + mm->ptMaxTrackSize.x = std::max(mm->ptMaxTrackSize.x, maximum.cx + frame.cx); + mm->ptMaxTrackSize.y = std::max(mm->ptMaxTrackSize.y, maximum.cy + frame.cy); + } else { + mm->ptMinTrackSize.x = 720; + mm->ptMinTrackSize.y = 480; + } return 0; } + case WM_ENTERSIZEMOVE: + if (app) GetWindowRect(hwnd, &app->sizing_start_rc); + return 0; + case WM_SIZING: + if (app && !app->fullscreen && app->source_size.cx > 0 && app->source_size.cy > 0) { + app->window_scale = 0; + if (app->settings.fixed_ratio) { + auto *rect = (RECT *) lparam; + *rect = airscreen::fixed_ratio_rect(*rect, app->sizing_start_rc, (UINT) wparam, + app->source_size, window_frame(app)); + } + } + return TRUE; case WM_MOUSEMOVE: { if (app) { note_activity(app); @@ -648,7 +867,13 @@ LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) { } return 0; case WM_KEYDOWN: - if (wparam == VK_F11) { + if (lparam & (1LL << 30)) { + return 0; + } + if ((GetKeyState(VK_CONTROL) & 0x8000) && !(GetKeyState(VK_SHIFT) & 0x8000) && + !(GetKeyState(VK_MENU) & 0x8000) && wparam >= '1' && wparam <= '4') { + select_window_size(app, kWindowSizes[wparam - '1']); + } else if (wparam == VK_F11) { toggle_fullscreen(app); } else if (wparam == VK_F2) { do_rename(app); @@ -689,9 +914,15 @@ LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) { case airscreen::UiEvent::Log: app->overlay_detail = app->utf16(msg); break; - case airscreen::UiEvent::VideoSize: + case airscreen::UiEvent::VideoSize: { + int width = 0, height = 0; + if (sscanf(msg.c_str(), "%dx%d", &width, &height) == 2) { + update_source_size(app, width, height); + } + break; + } case airscreen::UiEvent::ClientName: - if (ev == airscreen::UiEvent::ClientName && !msg.empty()) { + if (!msg.empty()) { app->overlay_status = L"Connected: " + app->utf16(msg); update_title(app); } @@ -921,22 +1152,7 @@ int WINAPI wWinMain(HINSTANCE inst, HINSTANCE, PWSTR cmdline, int show) { DispatchMessageW(&pump); } - auto ui = [&app](airscreen::UiEvent ev, const std::string &msg) { app.post_ui(ev, msg); }; - ntp_global_init(); - app.overlay_status = L"Starting AirPlay…"; - InvalidateRect(app.hwnd, nullptr, FALSE); - if (!app.receiver.start(app.video_hwnd, app.settings, ui)) { - app.start_failed = true; - app.overlay_status = L"Could not start. Check the log from Settings"; - InvalidateRect(app.hwnd, nullptr, FALSE); - MessageBoxW(app.hwnd, - L"Could not start the AirPlay receiver.\n\nAllow AirScreen through the firewall (tray menu) and check %APPDATA%\\AirScreen\\airscreen.log", - L"AirScreen", MB_OK | MB_ICONERROR); - } else { - app.overlay_status = L"Waiting for iPhone"; - apply_video_fill(&app); - InvalidateRect(app.hwnd, nullptr, FALSE); - } + start_receiver(&app); MSG msg; while (GetMessageW(&msg, nullptr, 0, 0)) { diff --git a/src/media_timing.h b/src/media_timing.h new file mode 100644 index 0000000..060c262 --- /dev/null +++ b/src/media_timing.h @@ -0,0 +1,120 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +struct AVFrame; + +namespace airscreen { + +class AudioTimeline { +public: + static int64_t duration_ns(size_t samples, int samples_per_second) { + return (int64_t) samples * 1000000000 / samples_per_second; + } + + void append(int64_t timestamp_ns, size_t samples) { + if (samples) queued_.push_back({timestamp_ns, samples, 0}); + } + + void discard(size_t samples, int samples_per_second) { + while (samples && !queued_.empty()) { + auto &span = queued_.front(); + size_t n = std::min(samples, span.samples); + span.timestamp_ns += duration_ns(n, samples_per_second); + span.samples -= n; + samples -= n; + if (!span.samples) queued_.pop_front(); + } + } + + void schedule(size_t samples, int64_t due_ns, int samples_per_second, int64_t now_ns) { + prune(now_ns, samples_per_second); + while (samples && !queued_.empty()) { + auto span = queued_.front(); + size_t n = std::min(samples, span.samples); + playing_.push_back({span.timestamp_ns, n, due_ns}); + due_ns += duration_ns(n, samples_per_second); + discard(n, samples_per_second); + samples -= n; + } + } + + int64_t position(int64_t now_ns, int samples_per_second) { + prune(now_ns, samples_per_second); + if (playing_.empty() || now_ns < playing_.front().due_ns || playing_.front().timestamp_ns <= 0) return 0; + const auto &span = playing_.front(); + return span.timestamp_ns + now_ns - span.due_ns; + } + + void clear() { + queued_.clear(); + playing_.clear(); + } + +private: + struct Span { + int64_t timestamp_ns; + size_t samples; + int64_t due_ns; + }; + + void prune(int64_t now_ns, int samples_per_second) { + while (!playing_.empty() && + now_ns >= playing_.front().due_ns + duration_ns(playing_.front().samples, samples_per_second)) { + playing_.pop_front(); + } + } + + std::deque queued_; + std::deque playing_; +}; + +struct VideoFrame { + std::shared_ptr data; + int64_t timestamp_ns = 0; + uint64_t generation = 0; +}; + +class VideoFrameQueue { +public: + static constexpr size_t capacity = 16; + + void push(VideoFrame frame) { + if (frames_.size() == capacity) { + frames_.pop_front(); + ++discarded_; + } + frames_.push_back(std::move(frame)); + } + + std::optional take(int64_t audio_time_ns) { + if (frames_.empty()) return std::nullopt; + size_t ready = 0; + while (ready < frames_.size() && + (!audio_time_ns || !frames_[ready].timestamp_ns || frames_[ready].timestamp_ns <= audio_time_ns)) { + ++ready; + } + if (!ready) return std::nullopt; + discarded_ += ready - 1; + VideoFrame frame = std::move(frames_[ready - 1]); + frames_.erase(frames_.begin(), frames_.begin() + ready); + return frame; + } + + bool empty() const { return frames_.empty(); } + size_t size() const { return frames_.size(); } + uint64_t discarded() const { return discarded_; } + void clear() { frames_.clear(); } + +private: + std::deque frames_; + uint64_t discarded_ = 0; +}; + +} // namespace airscreen diff --git a/src/receiver.cpp b/src/receiver.cpp index 05f4869..3ee62ce 100644 --- a/src/receiver.cpp +++ b/src/receiver.cpp @@ -161,6 +161,7 @@ bool Receiver::start(HWND hwnd, const Settings &settings, UiFn ui) { } parse_mac(mac_, hw_bytes_); ntp_global_init(); + video_.set_playback_clock([this] { return audio_.playback_time_ns(); }); on_log(LOGGER_INFO, "starting D3D11"); if (!video_.init(hwnd_)) { on_log(LOGGER_ERR, "D3D11 init failed"); @@ -208,6 +209,20 @@ void Receiver::stop() { connected_ = false; } +bool Receiver::stop_mirroring() { + if (!raop_) return false; + auto *raop = (raop_t *) raop_; + raop_stop_httpd(raop); + open_conns_ = 0; + teardown_session(); + unsigned short port = raop_port_; + if (raop_start_httpd(raop, &port) < 0) { + on_log(LOGGER_ERR, "could not restart the AirPlay listener after stopping mirroring"); + return false; + } + return true; +} + bool Receiver::rename(const std::string &name) { if (name.empty()) { return false; diff --git a/src/receiver.h b/src/receiver.h index 625fb8e..ac3c4a6 100644 --- a/src/receiver.h +++ b/src/receiver.h @@ -34,6 +34,7 @@ class Receiver { bool start(HWND hwnd, const Settings &settings, UiFn ui); void stop(); + bool stop_mirroring(); bool rename(const std::string &name); bool set_require_pin(bool on); void set_always_on_top_flag(bool on) { (void) on; } diff --git a/src/settings.cpp b/src/settings.cpp index 1d6d804..85114d9 100644 --- a/src/settings.cpp +++ b/src/settings.cpp @@ -128,6 +128,10 @@ Settings load_settings() { s.require_pin = (val == "1" || val == "true"); } else if (key == "FillScreen") { s.fill_screen = (val == "1" || val == "true"); + } else if (key == "FixedRatio") { + s.fixed_ratio = (val == "1" || val == "true"); + } else if (key == "AutoRotate") { + s.auto_rotate = (val == "1" || val == "true"); } else if (key == "DarkMode") { s.dark_mode = (val == "1" || val == "true"); } else if (key == "Width") { @@ -150,6 +154,8 @@ void save_settings(const Settings &s) { out << "AlwaysOnTop=" << (s.always_on_top ? 1 : 0) << "\n"; out << "RequirePin=" << (s.require_pin ? 1 : 0) << "\n"; out << "FillScreen=" << (s.fill_screen ? 1 : 0) << "\n"; + out << "FixedRatio=" << (s.fixed_ratio ? 1 : 0) << "\n"; + out << "AutoRotate=" << (s.auto_rotate ? 1 : 0) << "\n"; out << "DarkMode=" << (s.dark_mode ? 1 : 0) << "\n"; out << "Width=" << s.width << "\n"; out << "Height=" << s.height << "\n"; diff --git a/src/settings.h b/src/settings.h index 6f73f8a..3598804 100644 --- a/src/settings.h +++ b/src/settings.h @@ -10,12 +10,14 @@ struct Settings { bool always_on_top = false; bool require_pin = false; bool fill_screen = true; + bool fixed_ratio = false; + bool auto_rotate = true; bool dark_mode = false; int pin = 0; // 0 = random each time UxPlay assigns int width = 1920; int height = 1080; int refresh_hz = 60; - int max_fps = 60; + int max_fps = 30; int win_x = 0, win_y = 0, win_w = 0, win_h = 0; // 0 size = default placement }; diff --git a/src/video.cpp b/src/video.cpp index 0b57e74..644e6c8 100644 --- a/src/video.cpp +++ b/src/video.cpp @@ -17,6 +17,8 @@ #include #include #include +#include +#include #pragma comment(lib, "avrt.lib") @@ -120,11 +122,17 @@ bool VideoPipeline::init(HWND hwnd) { void VideoPipeline::stop_worker() { worker_run_ = false; q_cv_.notify_all(); + frame_cv_.notify_all(); if (worker_.joinable()) { worker_.join(); } + if (renderer_.joinable()) { + renderer_.join(); + } std::lock_guard qlock(q_mu_); q_.clear(); + std::lock_guard flock(frame_mu_); + frames_.clear(); } void VideoPipeline::start_worker() { @@ -133,6 +141,7 @@ void VideoPipeline::start_worker() { } worker_run_ = true; worker_ = std::thread([this] { decode_loop(); }); + renderer_ = std::thread([this] { render_loop(); }); } void VideoPipeline::shutdown() { @@ -231,31 +240,40 @@ void VideoPipeline::resize() { } void VideoPipeline::flush() { + std::scoped_lock lock(decode_mu_, mu_); + ++generation_; { std::lock_guard qlock(q_mu_); q_.clear(); } - std::lock_guard lock(mu_); + { + std::lock_guard flock(frame_mu_); + frames_.clear(); + } if (av_codec_) { avcodec_flush_buffers((AVCodecContext *) av_codec_); } + q_cv_.notify_all(); + frame_cv_.notify_all(); } void VideoPipeline::clear() { - { - std::lock_guard qlock(q_mu_); - q_.clear(); - } + flush(); std::lock_guard lock(mu_); has_frame_ = false; if (rtv_ && ctx_ && swapchain_) { float black[4] = {0.07f, 0.08f, 0.10f, 1}; ctx_->OMSetRenderTargets(1, &rtv_, nullptr); ctx_->ClearRenderTargetView(rtv_, black); - swapchain_->Present(0, tearing_ ? DXGI_PRESENT_ALLOW_TEARING : 0); + swapchain_->Present(0, DXGI_PRESENT_DO_NOT_WAIT | (tearing_ ? DXGI_PRESENT_ALLOW_TEARING : 0)); } } +void VideoPipeline::set_playback_clock(std::function fn) { + std::lock_guard lock(mu_); + playback_clock_ = std::move(fn); +} + void VideoPipeline::set_cover(bool cover) { cover_.store(cover); } @@ -271,7 +289,7 @@ void VideoPipeline::set_source_size(int width, int height) { } bool VideoPipeline::create_device() { - UINT flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT; + UINT flags = D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_VIDEO_SUPPORT; #ifdef _DEBUG flags |= D3D11_CREATE_DEVICE_DEBUG; #endif @@ -280,14 +298,14 @@ bool VideoPipeline::create_device() { HRESULT hr = D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, flags, levels, 3, D3D11_SDK_VERSION, &device_, &got, &ctx_); if (FAILED(hr)) { - hr = D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, D3D11_CREATE_DEVICE_BGRA_SUPPORT, + hr = D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_VIDEO_SUPPORT, levels, 3, D3D11_SDK_VERSION, &device_, &got, &ctx_); } if (FAILED(hr)) { return false; } ComPtr mt; - if (SUCCEEDED(device_->QueryInterface(IID_PPV_ARGS(&mt)))) { + if (SUCCEEDED(ctx_->QueryInterface(IID_PPV_ARGS(&mt)))) { mt->SetMultithreadProtected(TRUE); } return true; @@ -334,9 +352,12 @@ bool VideoPipeline::create_swapchain() { } factory->MakeWindowAssociation(hwnd_, DXGI_MWA_NO_ALT_ENTER); - ComPtr sc2; - if (SUCCEEDED(swapchain_->QueryInterface(IID_PPV_ARGS(&sc2)))) { - sc2->SetMaximumFrameLatency(1); + ComPtr dxgi_dev1; + if (SUCCEEDED(dxgi_dev.As(&dxgi_dev1))) { + hr = dxgi_dev1->SetMaximumFrameLatency(1); + if (FAILED(hr)) { + return false; + } } ID3D11Texture2D *back = nullptr; @@ -478,9 +499,11 @@ bool VideoPipeline::decode_init(bool hevc) { AVCodecContext *ctx = avcodec_alloc_context3(codec); ctx->flags |= AV_CODEC_FLAG_LOW_DELAY; ctx->flags2 |= AV_CODEC_FLAG2_FAST | AV_CODEC_FLAG2_SHOW_ALL; - ctx->thread_count = 1; - ctx->pkt_timebase = AVRational{1, 1000000}; - ctx->extra_hw_frames = 8; + ctx->thread_count = 0; + ctx->thread_type = FF_THREAD_SLICE; + ctx->pkt_timebase = AVRational{1, 1000000000}; + ctx->extra_hw_frames = (int) VideoFrameQueue::capacity + 2; + decoder_reported_ = false; AVBufferRef *hw = av_hwdevice_ctx_alloc(AV_HWDEVICE_TYPE_D3D11VA); if (hw) { @@ -532,18 +555,27 @@ void VideoPipeline::submit(const uint8_t *data, int len, uint64_t ntp_ns, bool h if (!data || len <= 0 || !worker_run_) { return; } + ++received_count_; + received_bytes_ += (uint64_t) len; EncodedPacket pkt; pkt.data.assign(data, data + len); pkt.ntp_ns = ntp_ns; + pkt.generation = generation_.load(); pkt.hevc = hevc; + auto wait_start = std::chrono::steady_clock::now(); { - std::lock_guard qlock(q_mu_); - if (q_.size() >= kMaxQueued) { - q_.pop_front(); + std::unique_lock qlock(q_mu_); + q_cv_.wait(qlock, [this, &pkt] { return q_.size() < kMaxQueued || !worker_run_ || pkt.generation != generation_.load(); }); + if (!worker_run_ || pkt.generation != generation_.load()) { + return; } q_.push_back(std::move(pkt)); } - q_cv_.notify_one(); + uint64_t wait_us = (uint64_t) std::chrono::duration_cast( + std::chrono::steady_clock::now() - wait_start).count(); + uint64_t previous = max_submit_wait_us_.load(); + while (previous < wait_us && !max_submit_wait_us_.compare_exchange_weak(previous, wait_us)) {} + q_cv_.notify_all(); } void VideoPipeline::decode_loop() { @@ -564,7 +596,13 @@ void VideoPipeline::decode_loop() { pkt = std::move(q_.front()); q_.pop_front(); } + q_cv_.notify_all(); + auto decode_start = std::chrono::steady_clock::now(); decode_packet(pkt); + uint64_t decode_us = (uint64_t) std::chrono::duration_cast( + std::chrono::steady_clock::now() - decode_start).count(); + uint64_t previous = max_decode_us_.load(); + while (previous < decode_us && !max_decode_us_.compare_exchange_weak(previous, decode_us)) {} } if (mmcss) { AvRevertMmThreadCharacteristics(mmcss); @@ -572,108 +610,226 @@ void VideoPipeline::decode_loop() { } void VideoPipeline::decode_packet(const EncodedPacket &pkt) { - std::lock_guard lock(mu_); - if (!device_ || pkt.data.empty()) { + std::lock_guard lock(decode_mu_); + if (!device_ || pkt.data.empty() || pkt.generation != generation_.load()) { return; } - const uint8_t *data = pkt.data.data(); - int len = (int) pkt.data.size(); - bool hevc = pkt.hevc; - if (!av_codec_ || hevc_ != hevc) { - if (!decode_init(hevc)) { - return; - } + if (!av_codec_ || hevc_ != pkt.hevc) { + if (!decode_init(pkt.hevc)) return; } auto *ctx = (AVCodecContext *) av_codec_; auto *avpkt = (AVPacket *) av_pkt_; - auto *frame = (AVFrame *) av_frame_; av_packet_unref(avpkt); - if (av_new_packet(avpkt, len) < 0) { - return; - } - memcpy(avpkt->data, data, (size_t) len); + if (av_new_packet(avpkt, (int) pkt.data.size()) < 0) return; + memcpy(avpkt->data, pkt.data.data(), pkt.data.size()); + avpkt->pts = pkt.ntp_ns ? (int64_t) pkt.ntp_ns : AV_NOPTS_VALUE; int send = avcodec_send_packet(ctx, avpkt); + if (send == AVERROR(EAGAIN)) { + receive_frames(pkt.generation); + send = avcodec_send_packet(ctx, avpkt); + } + av_packet_unref(avpkt); if (send < 0 && send != AVERROR(EAGAIN)) { - decode_init(hevc); - ctx = (AVCodecContext *) av_codec_; - avpkt = (AVPacket *) av_pkt_; - frame = (AVFrame *) av_frame_; - if (!ctx || av_new_packet(avpkt, len) < 0) { - return; + char error[64]; + av_strerror(send, error, sizeof(error)); + char line[128]; + snprintf(line, sizeof(line), "video decode send failed: %s", error); + append_log(line); + if (decode_init(pkt.hevc)) { + ctx = (AVCodecContext *) av_codec_; + avpkt = (AVPacket *) av_pkt_; + if (av_new_packet(avpkt, (int) pkt.data.size()) >= 0) { + memcpy(avpkt->data, pkt.data.data(), pkt.data.size()); + avpkt->pts = pkt.ntp_ns ? (int64_t) pkt.ntp_ns : AV_NOPTS_VALUE; + send = avcodec_send_packet(ctx, avpkt); + av_packet_unref(avpkt); + if (send >= 0) receive_frames(pkt.generation); + } } - memcpy(avpkt->data, data, (size_t) len); - send = avcodec_send_packet(ctx, avpkt); - if (send < 0 && send != AVERROR(EAGAIN)) { - char es[64]; - av_strerror(send, es, sizeof(es)); - char line[96]; - snprintf(line, sizeof(line), "decode send failed: %s", es); + return; + } + receive_frames(pkt.generation); +} + +void VideoPipeline::receive_frames(uint64_t generation) { + auto *ctx = (AVCodecContext *) av_codec_; + auto *frame = (AVFrame *) av_frame_; + int result; + while ((result = avcodec_receive_frame(ctx, frame)) == 0) { + ++decoded_count_; + if (!decoder_reported_) { + char line[128]; + snprintf(line, sizeof(line), "video decoder: %s %dx%d threads=%d", + av_get_pix_fmt_name((AVPixelFormat) frame->format), frame->width, frame->height, ctx->thread_count); append_log(line); - av_packet_unref(avpkt); - return; + decoder_reported_ = true; + } + AVFrame *copy = av_frame_clone(frame); + if (copy) { + VideoFrame ready; + ready.data = std::shared_ptr(copy, [](AVFrame *p) { av_frame_free(&p); }); + ready.timestamp_ns = frame->pts == AV_NOPTS_VALUE ? 0 : frame->pts; + ready.generation = generation; + std::lock_guard lock(frame_mu_); + if (generation == generation_.load()) frames_.push(std::move(ready)); } + av_frame_unref(frame); + frame_cv_.notify_one(); } - av_packet_unref(avpkt); - if (!ctx) { - return; + if (result != AVERROR(EAGAIN) && result != AVERROR_EOF) { + char error[64]; + av_strerror(result, error, sizeof(error)); + char line[128]; + snprintf(line, sizeof(line), "video decode receive failed: %s", error); + append_log(line); + } +} + +bool VideoPipeline::upload_frame(AVFrame *frame) { + int w = frame->width & ~1; + int h = frame->height & ~1; + if (w <= 0 || h <= 0) { + return false; + } + width_.store(w); + height_.store(h); + if (frame->format == AV_PIX_FMT_YUVJ420P || frame->color_range == AVCOL_RANGE_JPEG) { + if (!full_range_) { + full_range_ = true; + color_cbuf_dirty_ = true; + } + } else if (frame->color_range == AVCOL_RANGE_MPEG) { + if (full_range_) { + full_range_ = false; + color_cbuf_dirty_ = true; + } + } + if (!ensure_nv12(w, h)) { + return false; } - while (avcodec_receive_frame(ctx, frame) == 0) { - int w = frame->width & ~1; - int h = frame->height & ~1; - if (w <= 0 || h <= 0) { - av_frame_unref(frame); - continue; + if (frame->format == AV_PIX_FMT_D3D11) { + auto *tex = (ID3D11Texture2D *) frame->data[0]; + intptr_t index = (intptr_t) frame->data[1]; + if (!tex) { + return false; } - width_.store(w); - height_.store(h); - if (frame->format == AV_PIX_FMT_YUVJ420P || frame->color_range == AVCOL_RANGE_JPEG) { - if (!full_range_) { - full_range_ = true; - color_cbuf_dirty_ = true; + D3D11_BOX box = {0, 0, 0, (UINT) w, (UINT) h, 1}; + ctx_->CopySubresourceRegion(nv12_, 0, 0, 0, 0, tex, (UINT) index, &box); + } else if (frame->format == AV_PIX_FMT_NV12) { + if (ensure_staging(w, h)) { + D3D11_MAPPED_SUBRESOURCE map = {}; + if (SUCCEEDED(ctx_->Map(staging_, 0, D3D11_MAP_WRITE, 0, &map))) { + for (int y = 0; y < h; ++y) { + memcpy((uint8_t *) map.pData + y * map.RowPitch, frame->data[0] + y * frame->linesize[0], w); + } + uint8_t *dst_uv = (uint8_t *) map.pData + map.RowPitch * h; + for (int y = 0; y < h / 2; ++y) { + memcpy(dst_uv + y * map.RowPitch, frame->data[1] + y * frame->linesize[1], w); + } + ctx_->Unmap(staging_, 0); + ctx_->CopyResource(nv12_, staging_); } - } else if (frame->color_range == AVCOL_RANGE_MPEG) { - if (full_range_) { - full_range_ = false; - color_cbuf_dirty_ = true; + } + } else if (frame->format == AV_PIX_FMT_YUV420P || frame->format == AV_PIX_FMT_YUVJ420P) { + upload_yuv420(frame->data[0], frame->linesize[0], frame->data[1], frame->data[2], frame->linesize[1], w, h); + } else { + return false; + } + return true; +} + +void VideoPipeline::render_loop() { + DWORD mmcss_idx = 0; + HANDLE mmcss = AvSetMmThreadCharacteristicsW(L"Playback", &mmcss_idx); + std::optional current; + bool uploaded = false; + auto last_stats = std::chrono::steady_clock::now(); + uint64_t last_received = received_count_.load(); + uint64_t last_bytes = received_bytes_.load(); + uint64_t last_decoded = decoded_count_.load(); + while (worker_run_) { + { + std::unique_lock lock(frame_mu_); + if (!current && frames_.empty()) { + frame_cv_.wait(lock, [this] { return !worker_run_ || !frames_.empty(); }); } } - if (!ensure_nv12(w, h)) { - av_frame_unref(frame); - continue; + if (!worker_run_) break; + int64_t audio_time; + { + std::lock_guard lock(mu_); + audio_time = playback_clock_ ? playback_clock_() : 0; } - if (frame->format == AV_PIX_FMT_D3D11) { - auto *tex = (ID3D11Texture2D *) frame->data[0]; - intptr_t index = (intptr_t) frame->data[1]; - if (!tex) { - av_frame_unref(frame); + { + std::unique_lock lock(frame_mu_); + auto next = frames_.take(audio_time); + if (next) { + current = std::move(next); + uploaded = false; + } + if (!current) { + frame_cv_.wait_for(lock, std::chrono::milliseconds(2)); continue; } - ctx_->CopySubresourceRegion(nv12_, 0, 0, 0, 0, tex, (UINT) index, nullptr); - } else if (frame->format == AV_PIX_FMT_NV12) { - if (ensure_staging(w, h)) { - D3D11_MAPPED_SUBRESOURCE map = {}; - if (SUCCEEDED(ctx_->Map(staging_, 0, D3D11_MAP_WRITE, 0, &map))) { - for (int y = 0; y < h; ++y) { - memcpy((uint8_t *) map.pData + y * map.RowPitch, frame->data[0] + y * frame->linesize[0], w); - } - uint8_t *dst_uv = (uint8_t *) map.pData + map.RowPitch * h; - for (int y = 0; y < h / 2; ++y) { - memcpy(dst_uv + y * map.RowPitch, frame->data[1] + y * frame->linesize[1], w); - } - ctx_->Unmap(staging_, 0); - ctx_->CopyResource(nv12_, staging_); - } + } + HRESULT hr = E_FAIL; + { + std::lock_guard lock(mu_); + if (current->generation != generation_.load()) { + current.reset(); + continue; + } + if (!uploaded) uploaded = upload_frame(current->data.get()); + if (uploaded) { + if (!has_frame_.exchange(true) && first_frame_fn_) first_frame_fn_(); + hr = present_nv12(); + } + if (hr == S_OK) { + ++presented_count_; } - } else if (frame->format == AV_PIX_FMT_YUV420P || frame->format == AV_PIX_FMT_YUVJ420P) { - upload_yuv420(frame->data[0], frame->linesize[0], frame->data[1], frame->data[2], frame->linesize[1], w, h); } - bool first = !has_frame_.exchange(true); - present_nv12(); - av_frame_unref(frame); - if (first && first_frame_fn_) { - first_frame_fn_(); + int64_t video_time = current->timestamp_ns; + if (hr == S_OK || (FAILED(hr) && hr != DXGI_ERROR_WAS_STILL_DRAWING)) { + current.reset(); + } else { + if (hr == DXGI_ERROR_WAS_STILL_DRAWING) ++busy_count_; + std::unique_lock lock(frame_mu_); + frame_cv_.wait_for(lock, std::chrono::milliseconds(hr == DXGI_STATUS_OCCLUDED ? 16 : 1)); + } + auto now = std::chrono::steady_clock::now(); + if (now - last_stats >= std::chrono::seconds(5)) { + size_t encoded, pending; + uint64_t discarded; + { + std::lock_guard lock(q_mu_); + encoded = q_.size(); + } + { + std::lock_guard lock(frame_mu_); + pending = frames_.size(); + discarded = frames_.discarded(); + } + double seconds = std::chrono::duration(now - last_stats).count(); + uint64_t received = received_count_.load(); + uint64_t bytes = received_bytes_.load(); + uint64_t decoded = decoded_count_.load(); + char line[448]; + snprintf(line, sizeof(line), + "video stats: size=%dx%d input_fps=%.1f decode_fps=%.1f input_mbps=%.2f decoded=%llu presented=%llu skipped=%llu busy=%llu encoded=%zu pending=%zu max_decode_us=%llu max_submit_wait_us=%llu av_delta_ms=%lld", + width_.load(), height_.load(), (received - last_received) / seconds, + (decoded - last_decoded) / seconds, (bytes - last_bytes) * 8.0 / seconds / 1000000, + (unsigned long long) decoded, (unsigned long long) presented_count_.load(), + (unsigned long long) discarded, (unsigned long long) busy_count_.load(), encoded, pending, + (unsigned long long) max_decode_us_.exchange(0), (unsigned long long) max_submit_wait_us_.exchange(0), + (long long) (audio_time && video_time ? (video_time - audio_time) / 1000000 : 0)); + append_log(line); + last_stats = now; + last_received = received; + last_bytes = bytes; + last_decoded = decoded; } } + if (mmcss) AvRevertMmThreadCharacteristics(mmcss); } void VideoPipeline::upload_yuv420(const uint8_t *y, int y_stride, const uint8_t *u, const uint8_t *v, int uv_stride, int w, int h) { @@ -700,16 +856,16 @@ void VideoPipeline::upload_yuv420(const uint8_t *y, int y_stride, const uint8_t } } -void VideoPipeline::present_nv12() { +HRESULT VideoPipeline::present_nv12() { if (!rtv_ || !swapchain_) { - return; + return E_FAIL; } RECT rc = {}; GetClientRect(hwnd_, &rc); int cw = rc.right - rc.left; int ch = rc.bottom - rc.top; if (cw <= 0 || ch <= 0) { - return; + return DXGI_STATUS_OCCLUDED; } D3D11_VIEWPORT vp = {}; @@ -788,14 +944,15 @@ void VideoPipeline::present_nv12() { ID3D11ShaderResourceView *none[2] = {}; ctx_->PSSetShaderResources(0, 2, none); - HRESULT hr = swapchain_->Present(0, tearing_ ? DXGI_PRESENT_ALLOW_TEARING : 0); + HRESULT hr = swapchain_->Present(0, DXGI_PRESENT_DO_NOT_WAIT | (tearing_ ? DXGI_PRESENT_ALLOW_TEARING : 0)); if (hr == DXGI_ERROR_INVALID_CALL && tearing_) { tearing_ = false; - hr = swapchain_->Present(0, 0); + hr = swapchain_->Present(0, DXGI_PRESENT_DO_NOT_WAIT); } if (hr == DXGI_ERROR_DEVICE_REMOVED || hr == DXGI_ERROR_DEVICE_RESET) { append_log("DXGI device lost during present"); } + return hr; } } // namespace airscreen diff --git a/src/video.h b/src/video.h index f612016..ab5ea3d 100644 --- a/src/video.h +++ b/src/video.h @@ -1,5 +1,7 @@ #pragma once +#include "media_timing.h" + #include #include #include @@ -27,6 +29,7 @@ class VideoPipeline { void flush(); void submit(const uint8_t *data, int len, uint64_t ntp_ns, bool hevc); + void set_playback_clock(std::function fn); void set_source_size(int width, int height); void set_cover(bool cover); void set_first_frame_fn(std::function fn); @@ -42,6 +45,7 @@ class VideoPipeline { struct EncodedPacket { std::vector data; uint64_t ntp_ns = 0; + uint64_t generation = 0; bool hevc = false; }; @@ -54,9 +58,12 @@ class VideoPipeline { void decode_close(); void decode_packet(const EncodedPacket &pkt); void decode_loop(); + void receive_frames(uint64_t generation); + void render_loop(); + bool upload_frame(AVFrame *frame); void start_worker(); void stop_worker(); - void present_nv12(); + HRESULT present_nv12(); void upload_yuv420(const uint8_t *y, int y_stride, const uint8_t *u, const uint8_t *v, int uv_stride, int w, int h); HWND hwnd_ = nullptr; @@ -84,6 +91,7 @@ class VideoPipeline { void *av_frame_ = nullptr; void *av_pkt_ = nullptr; bool hevc_ = false; + bool decoder_reported_ = false; bool tearing_ = false; std::atomic width_{0}; @@ -100,6 +108,19 @@ class VideoPipeline { std::atomic cover_{false}; std::mutex mu_; std::function first_frame_fn_; + std::function playback_clock_; + std::mutex decode_mu_; + std::mutex frame_mu_; + std::condition_variable frame_cv_; + VideoFrameQueue frames_; + std::atomic generation_{0}; + std::atomic received_count_{0}; + std::atomic received_bytes_{0}; + std::atomic decoded_count_{0}; + std::atomic presented_count_{0}; + std::atomic busy_count_{0}; + std::atomic max_decode_us_{0}; + std::atomic max_submit_wait_us_{0}; static constexpr size_t kMaxQueued = 6; std::mutex q_mu_; @@ -107,6 +128,7 @@ class VideoPipeline { std::deque q_; std::atomic worker_run_{false}; std::thread worker_; + std::thread renderer_; }; } // namespace airscreen diff --git a/src/window_size.h b/src/window_size.h new file mode 100644 index 0000000..d1b2194 --- /dev/null +++ b/src/window_size.h @@ -0,0 +1,49 @@ +#pragma once + +#include +#include +#include + +namespace airscreen { + +inline SIZE scaled_video_size(SIZE source, double scale) { + return {std::max(1L, std::lround(source.cx * scale)), + std::max(1L, std::lround(source.cy * scale))}; +} + +inline RECT sized_window_rect(RECT current, SIZE size, RECT work) { + LONG x = current.left + (current.right - current.left - size.cx) / 2; + LONG y = current.top + (current.bottom - current.top - size.cy) / 2; + x = std::clamp(x, work.left, std::max(work.left, work.right - size.cx)); + y = std::clamp(y, work.top, std::max(work.top, work.bottom - size.cy)); + return {x, y, x + size.cx, y + size.cy}; +} + +inline RECT fixed_ratio_rect(RECT requested, RECT initial, UINT edge, SIZE source, SIZE frame) { + LONG width = std::max(1L, requested.right - requested.left - frame.cx); + LONG height = std::max(1L, requested.bottom - requested.top - frame.cy); + bool horizontal = edge == WMSZ_LEFT || edge == WMSZ_RIGHT; + if (edge != WMSZ_TOP && edge != WMSZ_BOTTOM && !horizontal) { + LONG dx = (requested.right - requested.left) - (initial.right - initial.left); + LONG dy = (requested.bottom - requested.top) - (initial.bottom - initial.top); + horizontal = std::abs(dx) * (double) source.cy >= std::abs(dy) * (double) source.cx; + } + double scale = horizontal ? (double) width / source.cx : (double) height / source.cy; + scale = std::max(scale, std::max(64.0 / source.cx, 64.0 / source.cy)); + SIZE size = scaled_video_size(source, scale); + size.cx += frame.cx; + size.cy += frame.cy; + if (edge == WMSZ_LEFT || edge == WMSZ_TOPLEFT || edge == WMSZ_BOTTOMLEFT) { + requested.left = requested.right - size.cx; + } else { + requested.right = requested.left + size.cx; + } + if (edge == WMSZ_TOP || edge == WMSZ_TOPLEFT || edge == WMSZ_TOPRIGHT) { + requested.top = requested.bottom - size.cy; + } else { + requested.bottom = requested.top + size.cy; + } + return requested; +} + +} // namespace airscreen diff --git a/tests/media_timing_test.cpp b/tests/media_timing_test.cpp new file mode 100644 index 0000000..50a76ee --- /dev/null +++ b/tests/media_timing_test.cpp @@ -0,0 +1,67 @@ +#include "media_timing.h" + +#include +#include + +using namespace airscreen; + +void require(bool condition, const char *message) { + if (!condition) { + std::fprintf(stderr, "FAIL: %s\n", message); + std::exit(1); + } +} + +int main() { + constexpr int rate = 96000; + constexpr int64_t ms = 1000000; + AudioTimeline audio; + audio.append(1000 * ms, 960); + audio.append(1300 * ms, 960); + audio.schedule(1920, 100 * ms, rate, 80 * ms); + require(audio.position(99 * ms, rate) == 0, "audio must not start before endpoint padding drains"); + require(audio.position(105 * ms, rate) == 1005 * ms, "first audio packet follows the output clock"); + require(audio.position(115 * ms, rate) == 1305 * ms, "a packet timestamp gap must survive playback"); + require(audio.position(120 * ms, rate) == 0, "silence must not advance an expired media clock"); + + audio.clear(); + audio.append(1000 * ms, 9600); + audio.discard(7680, rate); + audio.schedule(1920, 100 * ms, rate, 100 * ms); + require(audio.position(105 * ms, rate) == 1085 * ms, "audio catch-up must advance its media timestamp"); + audio.clear(); + require(audio.position(105 * ms, rate) == 0, "flush must invalidate the old audio clock"); + + VideoFrameQueue video; + video.push({{}, 100 * ms, 1}); + video.push({{}, 116 * ms, 1}); + video.push({{}, 133 * ms, 1}); + require(!video.take(99 * ms), "video must wait for audio without consuming future frames"); + auto frame = video.take(120 * ms); + require(frame && frame->timestamp_ns == 116 * ms, "select the newest due decoded frame"); + require(video.size() == 1 && video.discarded() == 1, "discard stale display frames but retain the future frame"); + frame = video.take(0); + require(frame && frame->timestamp_ns == 133 * ms, "a silent stream must display its final frame immediately"); + video.clear(); + require(video.empty(), "session reset must remove pending display frames"); + + VideoFrameQueue overloaded; + uint64_t rendered = 0; + int64_t last_lag = 0; + for (int i = 1; i <= 36000; ++i) { + int64_t now = (int64_t) i * 1000000000 / 60; + overloaded.push({{}, now, 2}); + require(overloaded.size() <= VideoFrameQueue::capacity, "display storage must remain bounded"); + if (i % 15 == 0) { + auto latest = overloaded.take(now - 80 * ms); + require(latest.has_value(), "slow display must still produce synchronized frames"); + last_lag = now - 80 * ms - latest->timestamp_ns; + require(last_lag >= 0 && last_lag < 17 * ms, "display lag must not accumulate during ten minutes of overload"); + ++rendered; + } + } + require(overloaded.discarded() > 30000, "overload must replace decoded frames rather than build a backlog"); + std::printf("PASS: audio timestamps, queue catch-up, silence, flush, and 600s at 60 fps / 4 fps display; rendered=%llu max_lag<17ms\n", + (unsigned long long) rendered); + return 0; +} diff --git a/tests/window_size_test.cpp b/tests/window_size_test.cpp new file mode 100644 index 0000000..91f2324 --- /dev/null +++ b/tests/window_size_test.cpp @@ -0,0 +1,82 @@ +#include "window_size.h" +#include + +namespace { +int failures = 0; + +void check(bool condition, const char *name) { + if (!condition) { + std::printf("FAIL: %s\n", name); + ++failures; + } +} + +bool same_size(SIZE size, LONG width, LONG height) { + return size.cx == width && size.cy == height; +} +} + +int main() { + SIZE landscape = {1920, 1080}; + SIZE portrait = {1080, 1920}; + const double scales[] = {0.5, 1, 1.5, 2}; + const LONG widths[] = {960, 1920, 2880, 3840}; + const LONG heights[] = {540, 1080, 1620, 2160}; + for (int i = 0; i < 4; ++i) { + check(same_size(airscreen::scaled_video_size(landscape, scales[i]), widths[i], heights[i]), + "landscape native resolution"); + check(same_size(airscreen::scaled_video_size(portrait, scales[i]), heights[i], widths[i]), + "portrait native resolution"); + } + + SIZE frame = {16, 16}; + RECT current = {100, 100, 1076, 656}; + for (UINT edge = WMSZ_LEFT; edge <= WMSZ_BOTTOMRIGHT; ++edge) { + bool left = edge == WMSZ_LEFT || edge == WMSZ_TOPLEFT || edge == WMSZ_BOTTOMLEFT; + bool right = edge == WMSZ_RIGHT || edge == WMSZ_TOPRIGHT || edge == WMSZ_BOTTOMRIGHT; + bool top = edge == WMSZ_TOP || edge == WMSZ_TOPLEFT || edge == WMSZ_TOPRIGHT; + bool bottom = edge == WMSZ_BOTTOM || edge == WMSZ_BOTTOMLEFT || edge == WMSZ_BOTTOMRIGHT; + RECT requested = current; + if (left) requested.left -= 192; + if (right) requested.right += 192; + if (top) requested.top -= 108; + if (bottom) requested.bottom += 108; + RECT actual = airscreen::fixed_ratio_rect(requested, current, edge, landscape, frame); + check(actual.right - actual.left - frame.cx == 1152 && + actual.bottom - actual.top - frame.cy == 648, "all edges keep client aspect ratio"); + check(left ? actual.right == current.right : actual.left == current.left, + "horizontal opposite edge stays anchored"); + check(top ? actual.bottom == current.bottom : actual.top == current.top, + "vertical opposite edge stays anchored"); + } + + RECT vertical_drag = current; + vertical_drag.bottom += 108; + RECT actual = airscreen::fixed_ratio_rect(vertical_drag, current, WMSZ_BOTTOMRIGHT, landscape, frame); + check(actual.right - actual.left == 1168 && actual.bottom - actual.top == 664, + "corner follows vertical dragging"); + + RECT portrait_rect = {100, 100, 656, 1076}; + vertical_drag = portrait_rect; + vertical_drag.bottom += 192; + actual = airscreen::fixed_ratio_rect(vertical_drag, portrait_rect, WMSZ_BOTTOM, portrait, frame); + check(actual.right - actual.left == 664 && actual.bottom - actual.top == 1168, + "portrait resizing uses original ratio"); + + RECT tiny = {100, 100, 117, 117}; + actual = airscreen::fixed_ratio_rect(tiny, current, WMSZ_RIGHT, landscape, frame); + check(actual.right - actual.left == 130 && actual.bottom - actual.top == 80, + "minimum size preserves ratio"); + + RECT work = {-1920, 40, 0, 1080}; + RECT old = {-1200, 200, -224, 756}; + RECT rotated = airscreen::sized_window_rect(old, {556, 976}, work); + check(rotated.left == -990 && rotated.top == 40 && rotated.right == -434 && rotated.bottom == 1016, + "rotation preserves center and stays in the monitor work area"); + RECT large = airscreen::sized_window_rect(old, {3856, 2176}, work); + check(large.left == -1920 && large.top == 40 && large.right - large.left == 3856 && + large.bottom - large.top == 2176, "large explicit sizes remain exact"); + + std::printf("Window sizing: %s\n", failures ? "FAILED" : "PASS"); + return failures ? 1 : 0; +} diff --git a/third_party/uxplay/lib/mdnsd/mdnsd.c b/third_party/uxplay/lib/mdnsd/mdnsd.c index 597fc4c..3ea407d 100644 --- a/third_party/uxplay/lib/mdnsd/mdnsd.c +++ b/third_party/uxplay/lib/mdnsd/mdnsd.c @@ -13,6 +13,7 @@ */ #include +#include #include #include #include @@ -25,6 +26,10 @@ #include "../compat.h" #include "mdnsd.h" +#ifdef WIN32 +#include +#endif + #define MDNS_ADDR4 "224.0.0.251" #define MDNS_ADDR6 "ff02::fb" #define MDNS_PORT 5353 @@ -347,6 +352,69 @@ static int mdns_query_type_matches(uint16_t query_type, uint16_t record_type) static uint32_t mdns_get_default_ipv4(void) { +#ifdef WIN32 + ULONG size = 15000; + ULONG flags = GAA_FLAG_SKIP_ANYCAST | GAA_FLAG_SKIP_MULTICAST | + GAA_FLAG_SKIP_DNS_SERVER; + IP_ADAPTER_ADDRESSES *adapters = malloc(size); + uint32_t addr = 0; + ULONG metric = ULONG_MAX; + ULONG index = ULONG_MAX; + ULONG result; + + if (!adapters) { + return 0; + } + result = GetAdaptersAddresses(AF_INET, flags, NULL, adapters, &size); + if (result == ERROR_BUFFER_OVERFLOW) { + free(adapters); + adapters = malloc(size); + if (!adapters) { + return 0; + } + result = GetAdaptersAddresses(AF_INET, flags, NULL, adapters, &size); + } + + if (result == NO_ERROR) { + for (IP_ADAPTER_ADDRESSES *a = adapters; a; a = a->Next) { + MIB_IF_ROW2 iface = {0}; + if (a->OperStatus != IfOperStatusUp || + (a->IfType != IF_TYPE_ETHERNET_CSMACD && + a->IfType != IF_TYPE_IEEE80211) || + (a->Flags & IP_ADAPTER_NO_MULTICAST)) { + continue; + } + iface.InterfaceLuid = a->Luid; + if (GetIfEntry2(&iface) != NO_ERROR || + !iface.InterfaceAndOperStatusFlags.HardwareInterface) { + continue; + } + for (IP_ADAPTER_UNICAST_ADDRESS *u = a->FirstUnicastAddress; u; u = u->Next) { + struct sockaddr_in *local; + uint32_t host; + if (!u->Address.lpSockaddr || + u->Address.lpSockaddr->sa_family != AF_INET || + u->DadState != IpDadStatePreferred) { + continue; + } + local = (struct sockaddr_in *) u->Address.lpSockaddr; + host = ntohl(local->sin_addr.s_addr); + if (!host || (host >> 24) == 127) { + continue; + } + if (!addr || a->Ipv4Metric < metric || + (a->Ipv4Metric == metric && a->IfIndex < index)) { + addr = local->sin_addr.s_addr; + metric = a->Ipv4Metric; + index = a->IfIndex; + } + break; + } + } + } + free(adapters); + return addr; +#else int fd; uint32_t addr = 0; struct sockaddr_in remote; @@ -370,6 +438,7 @@ static uint32_t mdns_get_default_ipv4(void) CLOSESOCKET(fd); return addr; +#endif } static int mdns_get_default_ipv6(unsigned char addr[16], unsigned int *scope_id) @@ -454,7 +523,12 @@ static int mdns_open_socket4(uint32_t iface_addr) if (iface_addr) { struct in_addr iface; iface.s_addr = iface_addr; - setsockopt(fd, IPPROTO_IP, IP_MULTICAST_IF, (const char *) &iface, sizeof(iface)); + if (setsockopt(fd, IPPROTO_IP, IP_MULTICAST_IF, + (const char *) &iface, sizeof(iface)) == -1) { + int error = SOCKET_GET_ERROR(); + CLOSESOCKET(fd); + return -error; + } } memset(&addr, 0, sizeof(addr)); @@ -474,6 +548,7 @@ static int mdns_open_socket4(uint32_t iface_addr) if (setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, (const char *) &mreq, sizeof(mreq)) == -1) { int error = SOCKET_GET_ERROR(); +#ifndef WIN32 if (iface_addr) { mreq.imr_interface.s_addr = htonl(INADDR_ANY); if (setsockopt(fd, IPPROTO_IP, IP_ADD_MEMBERSHIP, @@ -481,6 +556,7 @@ static int mdns_open_socket4(uint32_t iface_addr) return fd; } } +#endif CLOSESOCKET(fd); return -error; } @@ -990,6 +1066,12 @@ int mdnsd_start(mdnsd_t *mdnsd) int error6 = 0; mdnsd->ipv4_addr = mdns_get_default_ipv4(); +#ifdef WIN32 + if (!mdnsd->ipv4_addr) { + MUTEX_UNLOCK(mdnsd->mutex); + return -WSAEADDRNOTAVAIL; + } +#endif mdns_get_default_ipv6(mdnsd->ipv6_addr, &mdnsd->ipv6_scope_id); mdnsd->sock_fd4 = mdns_open_socket4(mdnsd->ipv4_addr); diff --git a/third_party/uxplay/lib/raop_rtp_mirror.c b/third_party/uxplay/lib/raop_rtp_mirror.c index a5186a6..a86f6e1 100644 --- a/third_party/uxplay/lib/raop_rtp_mirror.c +++ b/third_party/uxplay/lib/raop_rtp_mirror.c @@ -251,14 +251,13 @@ raop_rtp_mirror_thread(void *arg) break; } - // Windows SO_RCVTIMEO is a DWORD in milliseconds, not struct timeval. #ifdef _WIN32 { - DWORD rcv_ms = 5; - if (setsockopt(stream_fd, SOL_SOCKET, SO_RCVTIMEO, CAST &rcv_ms, sizeof(rcv_ms)) < 0) { + u_long nonblocking = 1; + if (ioctlsocket(stream_fd, FIONBIO, &nonblocking) == SOCKET_ERROR) { int sock_err = SOCKET_GET_ERROR(); logger_log(raop_rtp_mirror->logger, LOGGER_ERR, - "raop_rtp_mirror could not set stream socket timeout %d %s", sock_err, SOCKET_ERROR_STRING(sock_err)); + "raop_rtp_mirror could not set stream socket nonblocking %d %s", sock_err, SOCKET_ERROR_STRING(sock_err)); break; } }