From 8d9de117b99268a169b0c428fe1085ee8d9c8dd5 Mon Sep 17 00:00:00 2001 From: Mario Hewardt Date: Tue, 3 Feb 2026 13:05:31 -0800 Subject: [PATCH] Adds a video trim dialog to ZoomIt (#45334) ## Summary of the Pull Request Adds a video trim dialog to ZoomIt ## PR Checklist Closes 45333 ## Validation Steps Performed Manual validation --------- Co-authored-by: Mark Russinovich Co-authored-by: foxmsft --- .github/actions/spell-check/allow/zoomit.txt | 63 + .../ZoomIt/ZoomIt/AudioSampleGenerator.cpp | 602 +- .../ZoomIt/ZoomIt/AudioSampleGenerator.h | 27 +- src/modules/ZoomIt/ZoomIt/DemoType.cpp | 1 - .../ZoomIt/ZoomIt/GifRecordingSession.cpp | 81 +- .../ZoomIt/ZoomIt/GifRecordingSession.h | 7 + src/modules/ZoomIt/ZoomIt/LoopbackCapture.cpp | 337 ++ src/modules/ZoomIt/ZoomIt/LoopbackCapture.h | 46 + src/modules/ZoomIt/ZoomIt/Utility.cpp | 747 +++ src/modules/ZoomIt/ZoomIt/Utility.h | 87 + .../ZoomIt/ZoomIt/VideoRecordingSession.cpp | 4962 ++++++++++++++++- .../ZoomIt/ZoomIt/VideoRecordingSession.h | 156 + src/modules/ZoomIt/ZoomIt/ZoomIt.rc | 167 +- src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj | 9 + .../ZoomIt/ZoomIt/ZoomIt.vcxproj.filters | 6 + src/modules/ZoomIt/ZoomIt/ZoomItSettings.h | 14 + src/modules/ZoomIt/ZoomIt/Zoomit.cpp | 2694 ++++++++- src/modules/ZoomIt/ZoomIt/pch.h | 8 + src/modules/ZoomIt/ZoomIt/resource.h | 18 +- .../Settings.UI.Library/ZoomItProperties.cs | 2 + .../SettingsXAML/Views/ZoomItPage.xaml | 3 + .../Settings.UI/Strings/en-us/Resources.resw | 97 +- .../Settings.UI/ViewModels/ZoomItViewModel.cs | 14 + 23 files changed, 9703 insertions(+), 445 deletions(-) create mode 100644 .github/actions/spell-check/allow/zoomit.txt create mode 100644 src/modules/ZoomIt/ZoomIt/LoopbackCapture.cpp create mode 100644 src/modules/ZoomIt/ZoomIt/LoopbackCapture.h diff --git a/.github/actions/spell-check/allow/zoomit.txt b/.github/actions/spell-check/allow/zoomit.txt new file mode 100644 index 0000000000..98f3b62ca1 --- /dev/null +++ b/.github/actions/spell-check/allow/zoomit.txt @@ -0,0 +1,63 @@ +acq +APPLYTOSUBMENUS +AUDCLNT +bitmaps +BUFFERFLAGS +centiseconds +Ctl +CTLCOLOR +CTLCOLORBTN +CTLCOLORDLG +CTLCOLOREDIT +CTLCOLORLISTBOX +CTrim +DFCS +dlg +dlu +DONTCARE +DRAWITEM +DRAWITEMSTRUCT +DWLP +EDITCONTROL +ENABLEHOOK +FDE +GETCHANNELRECT +GETCHECK +GETTHUMBRECT +GIFs +HTBOTTOMRIGHT +HTHEME +KSDATAFORMAT +LEFTNOWORDWRAP +letterbox +lld +logfont +lround +MENUINFO +mic +MMRESULT +OWNERDRAW +PBGRA +pfdc +playhead +pwfx +quantums +REFKNOWNFOLDERID +reposted +SCROLLSIZEGRIP +SETDEFID +SETRECT +SHAREMODE +SHAREVIOLATION +STREAMFLAGS +submix +tci +TEXTMETRIC +tme +TRACKMOUSEEVENT +Unadvise +WASAPI +WAVEFORMATEX +WAVEFORMATEXTENSIBLE +wil +WMU diff --git a/src/modules/ZoomIt/ZoomIt/AudioSampleGenerator.cpp b/src/modules/ZoomIt/ZoomIt/AudioSampleGenerator.cpp index 21e9883bb7..fecdca4356 100644 --- a/src/modules/ZoomIt/ZoomIt/AudioSampleGenerator.cpp +++ b/src/modules/ZoomIt/ZoomIt/AudioSampleGenerator.cpp @@ -1,9 +1,24 @@ #include "pch.h" #include "AudioSampleGenerator.h" #include "CaptureFrameWait.h" +#include "LoopbackCapture.h" +#include extern TCHAR g_MicrophoneDeviceId[]; +namespace +{ + // Declare the IMemoryBufferByteAccess interface for accessing raw buffer data + MIDL_INTERFACE("5b0d3235-4dba-4d44-8657-1f1d0f83e9a3") + IMemoryBufferByteAccess : public IUnknown + { + public: + virtual HRESULT STDMETHODCALLTYPE GetBuffer( + BYTE** value, + UINT32* capacity) = 0; + }; +} + namespace winrt { using namespace Windows::Foundation; @@ -19,17 +34,23 @@ namespace winrt using namespace Windows::Devices::Enumeration; } -AudioSampleGenerator::AudioSampleGenerator() +AudioSampleGenerator::AudioSampleGenerator(bool captureMicrophone, bool captureSystemAudio) + : m_captureMicrophone(captureMicrophone) + , m_captureSystemAudio(captureSystemAudio) { + OutputDebugStringA(("AudioSampleGenerator created, captureMicrophone=" + + std::string(captureMicrophone ? "true" : "false") + + ", captureSystemAudio=" + std::string(captureSystemAudio ? "true" : "false") + "\n").c_str()); m_audioEvent.create(wil::EventOptions::ManualReset); m_endEvent.create(wil::EventOptions::ManualReset); + m_startEvent.create(wil::EventOptions::ManualReset); m_asyncInitialized.create(wil::EventOptions::ManualReset); } AudioSampleGenerator::~AudioSampleGenerator() { Stop(); - if (m_started.load()) + if (m_audioGraph) { m_audioGraph.Close(); } @@ -40,6 +61,10 @@ winrt::IAsyncAction AudioSampleGenerator::InitializeAsync() auto expected = false; if (m_initialized.compare_exchange_strong(expected, true)) { + // Reset state in case this instance is reused. + m_endEvent.ResetEvent(); + m_startEvent.ResetEvent(); + // Initialize the audio graph auto audioGraphSettings = winrt::AudioGraphSettings(winrt::AudioRenderCategory::Media); auto audioGraphResult = co_await winrt::AudioGraph::CreateAsync(audioGraphSettings); @@ -49,28 +74,88 @@ winrt::IAsyncAction AudioSampleGenerator::InitializeAsync() } m_audioGraph = audioGraphResult.Graph(); - // Initialize the selected microphone - auto defaultMicrophoneId = winrt::MediaDevice::GetDefaultAudioCaptureId(winrt::AudioDeviceRole::Default); - auto microphoneId = (g_MicrophoneDeviceId[0] == 0) ? defaultMicrophoneId : winrt::to_hstring(g_MicrophoneDeviceId); - auto microphone = co_await winrt::DeviceInformation::CreateFromIdAsync(microphoneId); + // Get AudioGraph encoding properties for resampling + auto graphProps = m_audioGraph.EncodingProperties(); + m_graphSampleRate = graphProps.SampleRate(); + m_graphChannels = graphProps.ChannelCount(); - // Initialize audio input and output nodes - auto inputNodeResult = co_await m_audioGraph.CreateDeviceInputNodeAsync(winrt::MediaCategory::Media, m_audioGraph.EncodingProperties(), microphone); - if (inputNodeResult.Status() != winrt::AudioDeviceNodeCreationStatus::Success && microphoneId != defaultMicrophoneId) - { - // If the selected microphone failed, try again with the default - microphone = co_await winrt::DeviceInformation::CreateFromIdAsync(defaultMicrophoneId); - inputNodeResult = co_await m_audioGraph.CreateDeviceInputNodeAsync(winrt::MediaCategory::Media, m_audioGraph.EncodingProperties(), microphone); - } - if (inputNodeResult.Status() != winrt::AudioDeviceNodeCreationStatus::Success) - { - throw winrt::hresult_error(E_FAIL, L"Failed to initialize input audio node!"); - } - m_audioInputNode = inputNodeResult.DeviceInputNode(); + OutputDebugStringA(("AudioGraph initialized: " + std::to_string(m_graphSampleRate) + + " Hz, " + std::to_string(m_graphChannels) + " ch\n").c_str()); + + // Create submix node to mix microphone and loopback audio + m_submixNode = m_audioGraph.CreateSubmixNode(); m_audioOutputNode = m_audioGraph.CreateFrameOutputNode(); + m_submixNode.AddOutgoingConnection(m_audioOutputNode); + + // Initialize WASAPI loopback capture for system audio (if enabled) + if (m_captureSystemAudio) + { + m_loopbackCapture = std::make_unique(); + } + if (m_loopbackCapture && SUCCEEDED(m_loopbackCapture->Initialize())) + { + auto loopbackFormat = m_loopbackCapture->GetFormat(); + if (loopbackFormat) + { + m_loopbackChannels = loopbackFormat->nChannels; + m_loopbackSampleRate = loopbackFormat->nSamplesPerSec; + m_resampleRatio = static_cast(m_loopbackSampleRate) / static_cast(m_graphSampleRate); + + OutputDebugStringA(("Loopback initialized: " + std::to_string(m_loopbackSampleRate) + + " Hz, " + std::to_string(m_loopbackChannels) + " ch, resample ratio=" + + std::to_string(m_resampleRatio) + "\n").c_str()); + } + } + else if (m_captureSystemAudio) + { + OutputDebugStringA("WARNING: Failed to initialize loopback capture\n"); + m_loopbackCapture.reset(); + } + + // Always initialize a microphone input node to keep the AudioGraph running at real-time pace. + // When mic capture is disabled, we mute it so only loopback audio is captured. + { + auto defaultMicrophoneId = winrt::MediaDevice::GetDefaultAudioCaptureId(winrt::AudioDeviceRole::Default); + auto microphoneId = (m_captureMicrophone && g_MicrophoneDeviceId[0] != 0) + ? winrt::to_hstring(g_MicrophoneDeviceId) + : defaultMicrophoneId; + if (!microphoneId.empty()) + { + auto microphone = co_await winrt::DeviceInformation::CreateFromIdAsync(microphoneId); + + // Initialize audio input node + auto inputNodeResult = co_await m_audioGraph.CreateDeviceInputNodeAsync(winrt::MediaCategory::Media, m_audioGraph.EncodingProperties(), microphone); + if (inputNodeResult.Status() != winrt::AudioDeviceNodeCreationStatus::Success && microphoneId != defaultMicrophoneId) + { + // If the selected microphone failed, try again with the default + microphone = co_await winrt::DeviceInformation::CreateFromIdAsync(defaultMicrophoneId); + inputNodeResult = co_await m_audioGraph.CreateDeviceInputNodeAsync(winrt::MediaCategory::Media, m_audioGraph.EncodingProperties(), microphone); + } + if (inputNodeResult.Status() == winrt::AudioDeviceNodeCreationStatus::Success) + { + m_audioInputNode = inputNodeResult.DeviceInputNode(); + m_audioInputNode.AddOutgoingConnection(m_submixNode); + + // If mic capture is disabled, mute the input so only loopback is captured + if (!m_captureMicrophone) + { + m_audioInputNode.OutgoingGain(0.0); + OutputDebugStringA("Mic input created but muted (loopback-only mode)\n"); + } + else + { + OutputDebugStringA("Mic input created and active\n"); + } + } + } + } + + // Loopback capture is only required when system audio capture is enabled + if (m_captureSystemAudio && !m_loopbackCapture) + { + throw winrt::hresult_error(E_FAIL, L"Failed to initialize loopback audio capture!"); + } - // Hookup audio nodes - m_audioInputNode.AddOutgoingConnection(m_audioOutputNode); m_audioGraph.QuantumStarted({ this, &AudioSampleGenerator::OnAudioQuantumStarted }); m_asyncInitialized.SetEvent(); @@ -86,7 +171,37 @@ winrt::AudioEncodingProperties AudioSampleGenerator::GetEncodingProperties() std::optional AudioSampleGenerator::TryGetNextSample() { CheckInitialized(); - CheckStarted(); + + // The MediaStreamSource can request audio samples before we've started the audio graph. + // Instead of throwing (which crashes the app), wait until either Start() is called + // or Stop() signals end-of-stream. + if (!m_started.load()) + { + std::vector events = { m_endEvent.get(), m_startEvent.get() }; + auto waitResult = WaitForMultipleObjectsEx(static_cast(events.size()), events.data(), false, INFINITE, false); + auto eventIndex = -1; + switch (waitResult) + { + case WAIT_OBJECT_0: + case WAIT_OBJECT_0 + 1: + eventIndex = waitResult - WAIT_OBJECT_0; + break; + } + WINRT_VERIFY(eventIndex >= 0); + + if (events[eventIndex] == m_endEvent.get()) + { + // End event signaled, but check if there are any remaining samples in the queue + auto lock = m_lock.lock_exclusive(); + if (!m_samples.empty()) + { + std::optional result(m_samples.front()); + m_samples.pop_front(); + return result; + } + return std::nullopt; + } + } { auto lock = m_lock.lock_exclusive(); @@ -118,11 +233,25 @@ std::optional AudioSampleGenerator::TryGetNextSample() auto signaledEvent = events[eventIndex]; if (signaledEvent == m_endEvent.get()) { + // End was signaled, but check for any remaining samples before returning nullopt + auto lock = m_lock.lock_exclusive(); + if (!m_samples.empty()) + { + std::optional result(m_samples.front()); + m_samples.pop_front(); + return result; + } return std::nullopt; } else { auto lock = m_lock.lock_exclusive(); + if (m_samples.empty()) + { + // Spurious wake or race - no samples available + // If end is signaled, return nullopt + return m_endEvent.is_signaled() ? std::nullopt : std::optional{}; + } std::optional result(m_samples.front()); m_samples.pop_front(); return result; @@ -135,23 +264,349 @@ void AudioSampleGenerator::Start() auto expected = false; if (m_started.compare_exchange_strong(expected, true)) { + m_endEvent.ResetEvent(); + m_startEvent.SetEvent(); + + // Start loopback capture if available + if (m_loopbackCapture) + { + // Clear any stale samples + { + auto lock = m_loopbackBufferLock.lock_exclusive(); + m_loopbackBuffer.clear(); + } + + m_resampleInputBuffer.clear(); + m_resampleInputPos = 0.0; + + m_loopbackCapture->Start(); + } + m_audioGraph.Start(); } } void AudioSampleGenerator::Stop() { - CheckInitialized(); - if (m_started.load()) + // Stop may be called during teardown even if initialization hasn't completed. + // It must never throw. + + if (!m_initialized.load()) { - m_asyncInitialized.wait(); - m_audioGraph.Stop(); m_endEvent.SetEvent(); + return; } + + m_asyncInitialized.wait(); + + // Stop loopback capture first + if (m_loopbackCapture) + { + m_loopbackCapture->Stop(); + } + + // Flush any remaining samples from the loopback capture before stopping the audio graph + FlushRemainingAudio(); + + // Stop the audio graph - no more quantum callbacks will run + m_audioGraph.Stop(); + + // Mark as stopped + m_started.store(false); + + // Combine all remaining queued samples into one final sample so it can be + // returned immediately without waiting for additional TryGetNextSample calls + CombineQueuedSamples(); + + // NOW signal end event - this allows TryGetNextSample to return remaining + // queued samples and then return nullopt + m_endEvent.SetEvent(); + m_audioEvent.SetEvent(); // Also wake any waiting TryGetNextSample + + // DO NOT clear m_loopbackBuffer or m_samples here - allow MediaTranscoder to + // consume remaining queued audio samples to avoid audio cutoff at end of recording. + // TryGetNextSample() will return nullopt once m_samples is empty and + // m_endEvent is signaled. Buffers will be cleaned up on destruction. +} + +void AudioSampleGenerator::AppendResampledLoopbackSamples(std::vector const& rawLoopbackSamples, bool flushRemaining) +{ + if (rawLoopbackSamples.empty()) + { + return; + } + + m_resampleInputBuffer.insert(m_resampleInputBuffer.end(), rawLoopbackSamples.begin(), rawLoopbackSamples.end()); + + if (m_loopbackChannels == 0 || m_graphChannels == 0 || m_resampleRatio <= 0.0) + { + return; + } + + std::vector resampledSamples; + while (true) + { + const uint32_t inputFrames = static_cast(m_resampleInputBuffer.size() / m_loopbackChannels); + if (inputFrames == 0) + { + break; + } + + if (!flushRemaining) + { + if (inputFrames < 2 || (m_resampleInputPos + 1.0) >= inputFrames) + { + break; + } + } + else + { + if (m_resampleInputPos >= inputFrames) + { + break; + } + } + + uint32_t inputFrame = static_cast(m_resampleInputPos); + double frac = m_resampleInputPos - inputFrame; + uint32_t nextFrame = (inputFrame + 1 < inputFrames) ? (inputFrame + 1) : inputFrame; + + for (uint32_t outCh = 0; outCh < m_graphChannels; outCh++) + { + float sample = 0.0f; + + if (m_loopbackChannels == m_graphChannels) + { + uint32_t idx1 = inputFrame * m_loopbackChannels + outCh; + uint32_t idx2 = nextFrame * m_loopbackChannels + outCh; + float s1 = m_resampleInputBuffer[idx1]; + float s2 = m_resampleInputBuffer[idx2]; + sample = static_cast(s1 * (1.0 - frac) + s2 * frac); + } + else if (m_loopbackChannels > m_graphChannels) + { + float sum = 0.0f; + for (uint32_t inCh = 0; inCh < m_loopbackChannels; inCh++) + { + uint32_t idx1 = inputFrame * m_loopbackChannels + inCh; + uint32_t idx2 = nextFrame * m_loopbackChannels + inCh; + float s1 = m_resampleInputBuffer[idx1]; + float s2 = m_resampleInputBuffer[idx2]; + sum += static_cast(s1 * (1.0 - frac) + s2 * frac); + } + sample = sum / m_loopbackChannels; + } + else + { + uint32_t idx1 = inputFrame * m_loopbackChannels; + uint32_t idx2 = nextFrame * m_loopbackChannels; + float s1 = m_resampleInputBuffer[idx1]; + float s2 = m_resampleInputBuffer[idx2]; + sample = static_cast(s1 * (1.0 - frac) + s2 * frac); + } + + resampledSamples.push_back(sample); + } + + m_resampleInputPos += m_resampleRatio; + } + + uint32_t consumedFrames = static_cast(m_resampleInputPos); + if (consumedFrames > 0) + { + size_t samplesToErase = static_cast(consumedFrames) * m_loopbackChannels; + if (samplesToErase >= m_resampleInputBuffer.size()) + { + m_resampleInputBuffer.clear(); + m_resampleInputPos = 0.0; + } + else + { + m_resampleInputBuffer.erase(m_resampleInputBuffer.begin(), m_resampleInputBuffer.begin() + samplesToErase); + m_resampleInputPos -= consumedFrames; + } + } + + if (flushRemaining) + { + m_resampleInputBuffer.clear(); + m_resampleInputPos = 0.0; + } + + if (!resampledSamples.empty()) + { + auto loopbackLock = m_loopbackBufferLock.lock_exclusive(); + const size_t maxBufferSize = static_cast(m_graphSampleRate) * m_graphChannels; + + if (m_loopbackBuffer.size() + resampledSamples.size() > maxBufferSize) + { + size_t overflow = (m_loopbackBuffer.size() + resampledSamples.size()) - maxBufferSize; + if (overflow >= m_loopbackBuffer.size()) + { + m_loopbackBuffer.clear(); + } + else + { + m_loopbackBuffer.erase(m_loopbackBuffer.begin(), m_loopbackBuffer.begin() + overflow); + } + } + + m_loopbackBuffer.insert(m_loopbackBuffer.end(), resampledSamples.begin(), resampledSamples.end()); + } +} + +void AudioSampleGenerator::FlushRemainingAudio() +{ + // Called during stop to drain any remaining samples from loopback capture + // and convert them to MediaStreamSamples before the audio graph stops. + + if (!m_loopbackCapture) + { + return; + } + + auto lock = m_lock.lock_exclusive(); + + // Drain all remaining samples from the loopback capture client + std::vector rawLoopbackSamples; + { + std::vector tempSamples; + while (m_loopbackCapture->TryGetSamples(tempSamples)) + { + rawLoopbackSamples.insert(rawLoopbackSamples.end(), tempSamples.begin(), tempSamples.end()); + } + } + + // Resample and channel-convert the loopback audio to match AudioGraph format + if (!rawLoopbackSamples.empty()) + { + AppendResampledLoopbackSamples(rawLoopbackSamples, true); + } + + // Now convert everything in m_loopbackBuffer to MediaStreamSamples + auto loopbackLock = m_loopbackBufferLock.lock_exclusive(); + + if (!m_loopbackBuffer.empty()) + { + uint32_t outputSampleCount = static_cast(m_loopbackBuffer.size()); + std::vector outputData(outputSampleCount * sizeof(float), 0); + float* outputFloats = reinterpret_cast(outputData.data()); + + for (uint32_t i = 0; i < outputSampleCount; i++) + { + float sample = m_loopbackBuffer[i]; + if (sample > 1.0f) sample = 1.0f; + else if (sample < -1.0f) sample = -1.0f; + outputFloats[i] = sample; + } + + m_loopbackBuffer.clear(); + + // Create buffer and sample + winrt::Buffer sampleBuffer(outputSampleCount * sizeof(float)); + memcpy(sampleBuffer.data(), outputData.data(), outputData.size()); + sampleBuffer.Length(static_cast(outputData.size())); + + if (sampleBuffer.Length() > 0) + { + const uint32_t sampleCount = sampleBuffer.Length() / sizeof(float); + const uint32_t frames = (m_graphChannels > 0) ? (sampleCount / m_graphChannels) : 0; + const int64_t durationTicks = (m_graphSampleRate > 0) ? (static_cast(frames) * 10000000LL / m_graphSampleRate) : 0; + const winrt::TimeSpan duration{ durationTicks }; + + winrt::TimeSpan timestamp{ 0 }; + if (m_hasLastSampleTimestamp) + { + timestamp = winrt::TimeSpan{ m_lastSampleTimestamp.count() + m_lastSampleDuration.count() }; + } + + auto sample = winrt::MediaStreamSample::CreateFromBuffer(sampleBuffer, timestamp); + m_samples.push_back(sample); + m_audioEvent.SetEvent(); + + m_lastSampleTimestamp = timestamp; + m_lastSampleDuration = duration; + m_hasLastSampleTimestamp = true; + } + } +} + +void AudioSampleGenerator::CombineQueuedSamples() +{ + // Combine all queued samples into a single sample so it can be returned + // immediately in the next TryGetNextSample call. This is critical because + // once video ends, the MediaTranscoder may only request one more audio sample. + + auto lock = m_lock.lock_exclusive(); + + if (m_samples.size() <= 1) + { + return; + } + + // Calculate total size and collect all sample data + size_t totalBytes = 0; + std::vector> buffers; + winrt::Windows::Foundation::TimeSpan firstTimestamp{ 0 }; + bool hasFirstTimestamp = false; + + for (auto& sample : m_samples) + { + auto buffer = sample.Buffer(); + if (buffer) + { + totalBytes += buffer.Length(); + if (!hasFirstTimestamp) + { + firstTimestamp = sample.Timestamp(); + hasFirstTimestamp = true; + } + buffers.push_back({ buffer, sample.Timestamp() }); + } + } + + if (totalBytes == 0) + { + return; + } + + // Create combined buffer + winrt::Buffer combinedBuffer(static_cast(totalBytes)); + uint8_t* dest = combinedBuffer.data(); + uint32_t offset = 0; + + for (auto& [buffer, ts] : buffers) + { + uint32_t len = buffer.Length(); + memcpy(dest + offset, buffer.data(), len); + offset += len; + } + combinedBuffer.Length(static_cast(totalBytes)); + + // Create combined sample with first timestamp + auto combinedSample = winrt::Windows::Media::Core::MediaStreamSample::CreateFromBuffer(combinedBuffer, firstTimestamp); + + // Clear queue and add combined sample + m_samples.clear(); + m_samples.push_back(combinedSample); + + // Update timestamp tracking + const uint32_t sampleCount = static_cast(totalBytes) / sizeof(float); + const uint32_t frames = (m_graphChannels > 0) ? (sampleCount / m_graphChannels) : 0; + const int64_t durationTicks = (m_graphSampleRate > 0) ? (static_cast(frames) * 10000000LL / m_graphSampleRate) : 0; + m_lastSampleTimestamp = firstTimestamp; + m_lastSampleDuration = winrt::Windows::Foundation::TimeSpan{ durationTicks }; + m_hasLastSampleTimestamp = true; } void AudioSampleGenerator::OnAudioQuantumStarted(winrt::AudioGraph const& sender, winrt::IInspectable const& args) { + // Don't process if we're not actively recording + if (!m_started.load()) + { + return; + } + { auto lock = m_lock.lock_exclusive(); @@ -159,10 +614,101 @@ void AudioSampleGenerator::OnAudioQuantumStarted(winrt::AudioGraph const& sender std::optional timestamp = frame.RelativeTime(); auto audioBuffer = frame.LockBuffer(winrt::AudioBufferAccessMode::Read); + // Get mic audio as a buffer (may be empty if no microphone) auto sampleBuffer = winrt::Buffer::CreateCopyFromMemoryBuffer(audioBuffer); sampleBuffer.Length(audioBuffer.Length()); - auto sample = winrt::MediaStreamSample::CreateFromBuffer(sampleBuffer, timestamp.value()); - m_samples.push_back(sample); + + // Calculate expected samples per quantum (~10ms at graph sample rate) + // AudioGraph uses 10ms quantums by default + uint32_t expectedSamplesPerQuantum = (m_graphSampleRate / 100) * m_graphChannels; + uint32_t numMicSamples = audioBuffer.Length() / sizeof(float); + + // Drain loopback samples regardless of whether we have mic audio + if (m_loopbackCapture) + { + std::vector rawLoopbackSamples; + { + std::vector tempSamples; + while (m_loopbackCapture->TryGetSamples(tempSamples)) + { + rawLoopbackSamples.insert(rawLoopbackSamples.end(), tempSamples.begin(), tempSamples.end()); + } + } + + // Resample and channel-convert the loopback audio to match AudioGraph format + if (!rawLoopbackSamples.empty()) + { + AppendResampledLoopbackSamples(rawLoopbackSamples); + } + } + + // Determine the actual number of samples we'll output + // Use mic sample count if mic is enabled + uint32_t outputSampleCount = m_captureMicrophone ? numMicSamples : expectedSamplesPerQuantum; + + // If microphone is disabled, create a buffer with only loopback audio + if (!m_captureMicrophone && outputSampleCount > 0) + { + // Create a buffer filled with loopback audio or silence + std::vector outputData(outputSampleCount * sizeof(float), 0); + float* outputFloats = reinterpret_cast(outputData.data()); + + { + auto loopbackLock = m_loopbackBufferLock.lock_exclusive(); + uint32_t samplesToUse = min(outputSampleCount, static_cast(m_loopbackBuffer.size())); + + for (uint32_t i = 0; i < samplesToUse; i++) + { + float sample = m_loopbackBuffer[i]; + if (sample > 1.0f) sample = 1.0f; + else if (sample < -1.0f) sample = -1.0f; + outputFloats[i] = sample; + } + + if (samplesToUse > 0) + { + m_loopbackBuffer.erase(m_loopbackBuffer.begin(), m_loopbackBuffer.begin() + samplesToUse); + } + } + + // Create a new buffer with our loopback data + sampleBuffer = winrt::Buffer(outputSampleCount * sizeof(float)); + memcpy(sampleBuffer.data(), outputData.data(), outputData.size()); + sampleBuffer.Length(static_cast(outputData.size())); + } + else if (m_captureMicrophone && numMicSamples > 0) + { + // Mix loopback into mic samples + auto loopbackLock = m_loopbackBufferLock.lock_exclusive(); + float* bufferData = reinterpret_cast(sampleBuffer.data()); + uint32_t samplesToMix = min(numMicSamples, static_cast(m_loopbackBuffer.size())); + + for (uint32_t i = 0; i < samplesToMix; i++) + { + float mixed = bufferData[i] + m_loopbackBuffer[i]; + if (mixed > 1.0f) mixed = 1.0f; + else if (mixed < -1.0f) mixed = -1.0f; + bufferData[i] = mixed; + } + + if (samplesToMix > 0) + { + m_loopbackBuffer.erase(m_loopbackBuffer.begin(), m_loopbackBuffer.begin() + samplesToMix); + } + } + + if (sampleBuffer.Length() > 0) + { + auto sample = winrt::MediaStreamSample::CreateFromBuffer(sampleBuffer, timestamp.value()); + m_samples.push_back(sample); + + const uint32_t sampleCount = sampleBuffer.Length() / sizeof(float); + const uint32_t frames = (m_graphChannels > 0) ? (sampleCount / m_graphChannels) : 0; + const int64_t durationTicks = (m_graphSampleRate > 0) ? (static_cast(frames) * 10000000LL / m_graphSampleRate) : 0; + m_lastSampleTimestamp = timestamp.value(); + m_lastSampleDuration = winrt::TimeSpan{ durationTicks }; + m_hasLastSampleTimestamp = true; + } } m_audioEvent.SetEvent(); } diff --git a/src/modules/ZoomIt/ZoomIt/AudioSampleGenerator.h b/src/modules/ZoomIt/ZoomIt/AudioSampleGenerator.h index 8e279f3b58..7ffe1438b7 100644 --- a/src/modules/ZoomIt/ZoomIt/AudioSampleGenerator.h +++ b/src/modules/ZoomIt/ZoomIt/AudioSampleGenerator.h @@ -1,9 +1,11 @@ #pragma once +#include "LoopbackCapture.h" + class AudioSampleGenerator { public: - AudioSampleGenerator(); + AudioSampleGenerator(bool captureMicrophone = true, bool captureSystemAudio = true); ~AudioSampleGenerator(); winrt::Windows::Foundation::IAsyncAction InitializeAsync(); @@ -18,6 +20,10 @@ private: winrt::Windows::Media::Audio::AudioGraph const& sender, winrt::Windows::Foundation::IInspectable const& args); + void FlushRemainingAudio(); + void CombineQueuedSamples(); + void AppendResampledLoopbackSamples(std::vector const& rawLoopbackSamples, bool flushRemaining = false); + void CheckInitialized() { if (!m_initialized.load()) @@ -37,12 +43,31 @@ private: private: winrt::Windows::Media::Audio::AudioGraph m_audioGraph{ nullptr }; winrt::Windows::Media::Audio::AudioDeviceInputNode m_audioInputNode{ nullptr }; + winrt::Windows::Media::Audio::AudioSubmixNode m_submixNode{ nullptr }; winrt::Windows::Media::Audio::AudioFrameOutputNode m_audioOutputNode{ nullptr }; + + std::unique_ptr m_loopbackCapture; + std::vector m_loopbackBuffer; // Accumulated loopback samples (resampled to match AudioGraph) + wil::srwlock m_loopbackBufferLock; + uint32_t m_loopbackChannels = 2; + uint32_t m_loopbackSampleRate = 48000; + uint32_t m_graphSampleRate = 48000; + uint32_t m_graphChannels = 2; + double m_resampleRatio = 1.0; // loopbackSampleRate / graphSampleRate + winrt::Windows::Foundation::TimeSpan m_lastSampleTimestamp{}; + winrt::Windows::Foundation::TimeSpan m_lastSampleDuration{}; + bool m_hasLastSampleTimestamp = false; + std::vector m_resampleInputBuffer; // raw loopback samples buffered for resampling + double m_resampleInputPos = 0.0; // fractional input frame position for resampling + wil::srwlock m_lock; wil::unique_event m_audioEvent; wil::unique_event m_endEvent; + wil::unique_event m_startEvent; wil::unique_event m_asyncInitialized; std::deque m_samples; std::atomic m_initialized = false; std::atomic m_started = false; + bool m_captureMicrophone = true; + bool m_captureSystemAudio = true; }; \ No newline at end of file diff --git a/src/modules/ZoomIt/ZoomIt/DemoType.cpp b/src/modules/ZoomIt/ZoomIt/DemoType.cpp index 40284a795b..6aefbf40ca 100644 --- a/src/modules/ZoomIt/ZoomIt/DemoType.cpp +++ b/src/modules/ZoomIt/ZoomIt/DemoType.cpp @@ -846,7 +846,6 @@ LRESULT CALLBACK DemoTypeHookProc( int nCode, WPARAM wParam, LPARAM lParam ) if( g_UserDriven ) { // Set baseline indentation to a blocking flag - // Otherwise indentation seeking will trigger user-driven injection events g_BaselineIndentation = INDENT_SEEK_FLAG; // Initialize the injection handler diff --git a/src/modules/ZoomIt/ZoomIt/GifRecordingSession.cpp b/src/modules/ZoomIt/ZoomIt/GifRecordingSession.cpp index 22e2079f71..18b08b6cf5 100644 --- a/src/modules/ZoomIt/ZoomIt/GifRecordingSession.cpp +++ b/src/modules/ZoomIt/ZoomIt/GifRecordingSession.cpp @@ -242,6 +242,13 @@ std::shared_ptr GifRecordingSession::Create( //---------------------------------------------------------------------------- HRESULT GifRecordingSession::EncodeFrame(ID3D11Texture2D* frameTexture) { + std::lock_guard lock(m_encoderMutex); + if (m_encoderReleased) + { + OutputDebugStringW(L"EncodeFrame called after encoder released.\n"); + return E_FAIL; + } + try { // Create a staging texture for CPU access @@ -367,6 +374,7 @@ HRESULT GifRecordingSession::EncodeFrame(ID3D11Texture2D* frameTexture) // Increment and log frame count m_frameCount++; + m_hasAnyFrame.store(true); OutputDebugStringW((L"GIF Frame #" + std::to_wstring(m_frameCount) + L" fully encoded and committed\n").c_str()); return S_OK; @@ -405,6 +413,12 @@ winrt::IAsyncAction GifRecordingSession::StartAsync() { captureAttempts++; auto frame = m_frameWait->TryGetNextFrame(); + if (!frame && !m_isRecording) + { + // Recording was stopped while waiting for frame + OutputDebugStringW(L"[GIF] Recording stopped during frame wait\n"); + break; + } winrt::com_ptr croppedTexture; @@ -472,8 +486,17 @@ winrt::IAsyncAction GifRecordingSession::StartAsync() // Wait for the next frame interval co_await winrt::resume_after(std::chrono::milliseconds(1000 / m_frameRate)); + + // Check again after resuming from sleep + if (!m_isRecording || m_closed) + { + OutputDebugStringW(L"[GIF] Loop exiting after resume_after\n"); + break; + } } + OutputDebugStringW(L"[GIF] Capture loop exited\n"); + // Commit the GIF encoder if (m_gifEncoder) { @@ -511,6 +534,10 @@ winrt::IAsyncAction GifRecordingSession::StartAsync() CloseInternal(); } } + + // Ensure encoder resources are released in case caller forgets to Close explicitly. + ReleaseEncoderResources(); + OutputDebugStringW(L"[GIF] StartAsync completing, about to co_return\n"); co_return; } @@ -521,18 +548,18 @@ winrt::IAsyncAction GifRecordingSession::StartAsync() //---------------------------------------------------------------------------- void GifRecordingSession::Close() { + OutputDebugStringW(L"[GIF] Close() called\n"); auto expected = false; if (m_closed.compare_exchange_strong(expected, true)) { - expected = true; - if (!m_isRecording.compare_exchange_strong(expected, false)) - { - CloseInternal(); - } - else - { - m_frameWait->StopCapture(); - } + OutputDebugStringW(L"[GIF] Setting m_closed = true\n"); + // Signal the capture loop to stop + m_isRecording = false; + OutputDebugStringW(L"[GIF] Setting m_isRecording = false\n"); + + // Stop the frame wait to unblock any pending frame acquisition + m_frameWait->StopCapture(); + OutputDebugStringW(L"[GIF] StopCapture called\n"); } } @@ -543,6 +570,42 @@ void GifRecordingSession::Close() //---------------------------------------------------------------------------- void GifRecordingSession::CloseInternal() { + ReleaseEncoderResources(); + m_frameWait->StopCapture(); m_itemClosed.revoke(); } + +//---------------------------------------------------------------------------- +// +// GifRecordingSession::ReleaseEncoderResources +// Ensures encoder/stream COM objects release the temp file handle so trim can reopen it. +// +//---------------------------------------------------------------------------- +void GifRecordingSession::ReleaseEncoderResources() +{ + std::lock_guard lock(m_encoderMutex); + if (m_encoderReleased) + { + return; + } + + // Commit only if we still own the encoder and it has not been committed; swallow failures. + if (m_gifEncoder) + { + try + { + m_gifEncoder->Commit(); + } + catch (...) + { + } + } + + m_encoderMetadataWriter = nullptr; + m_gifEncoder = nullptr; + m_wicStream = nullptr; + m_wicFactory = nullptr; + m_stream = nullptr; + m_encoderReleased = true; +} diff --git a/src/modules/ZoomIt/ZoomIt/GifRecordingSession.h b/src/modules/ZoomIt/ZoomIt/GifRecordingSession.h index 90732f60f3..2bffb94fd8 100644 --- a/src/modules/ZoomIt/ZoomIt/GifRecordingSession.h +++ b/src/modules/ZoomIt/ZoomIt/GifRecordingSession.h @@ -11,6 +11,7 @@ #include "CaptureFrameWait.h" #include #include +#include class GifRecordingSession : public std::enable_shared_from_this { @@ -27,6 +28,8 @@ public: void EnableCursorCapture(bool enable = true) { m_frameWait->EnableCursorCapture(enable); } void Close(); + bool HasCapturedFrames() const { return m_hasAnyFrame.load(); } + private: GifRecordingSession( winrt::Direct3D11::IDirect3DDevice const& device, @@ -35,6 +38,7 @@ private: uint32_t frameRate, winrt::Streams::IRandomAccessStream const& stream); void CloseInternal(); + void ReleaseEncoderResources(); HRESULT EncodeFrame(ID3D11Texture2D* texture); private: @@ -58,6 +62,9 @@ private: std::atomic m_isRecording = false; std::atomic m_closed = false; + std::atomic m_encoderReleased = false; + std::atomic m_hasAnyFrame = false; + std::mutex m_encoderMutex; uint32_t m_frameWidth=0; uint32_t m_frameHeight=0; diff --git a/src/modules/ZoomIt/ZoomIt/LoopbackCapture.cpp b/src/modules/ZoomIt/ZoomIt/LoopbackCapture.cpp new file mode 100644 index 0000000000..fb29df3cef --- /dev/null +++ b/src/modules/ZoomIt/ZoomIt/LoopbackCapture.cpp @@ -0,0 +1,337 @@ +#include "pch.h" +#include "LoopbackCapture.h" +#include + +#pragma comment(lib, "ole32.lib") + +LoopbackCapture::LoopbackCapture() +{ + m_stopEvent.create(wil::EventOptions::ManualReset); + m_samplesReadyEvent.create(wil::EventOptions::ManualReset); +} + +LoopbackCapture::~LoopbackCapture() +{ + Stop(); + if (m_pwfx) + { + CoTaskMemFree(m_pwfx); + m_pwfx = nullptr; + } +} + +HRESULT LoopbackCapture::Initialize() +{ + if (m_initialized.load()) + { + return S_OK; + } + + HRESULT hr = CoCreateInstance( + __uuidof(MMDeviceEnumerator), + nullptr, + CLSCTX_ALL, + __uuidof(IMMDeviceEnumerator), + m_deviceEnumerator.put_void()); + if (FAILED(hr)) + { + return hr; + } + + // Get the default audio render device (speakers/headphones) + hr = m_deviceEnumerator->GetDefaultAudioEndpoint(eRender, eConsole, m_device.put()); + if (FAILED(hr)) + { + return hr; + } + + hr = m_device->Activate(__uuidof(IAudioClient), CLSCTX_ALL, nullptr, m_audioClient.put_void()); + if (FAILED(hr)) + { + return hr; + } + + // Get the mix format + hr = m_audioClient->GetMixFormat(&m_pwfx); + if (FAILED(hr)) + { + return hr; + } + + // Initialize audio client in loopback mode + // AUDCLNT_STREAMFLAGS_LOOPBACK enables capturing what's being played on the device + hr = m_audioClient->Initialize( + AUDCLNT_SHAREMODE_SHARED, + AUDCLNT_STREAMFLAGS_LOOPBACK, + 1000000, // 100ms buffer to reduce capture latency + 0, + m_pwfx, + nullptr); + if (FAILED(hr)) + { + return hr; + } + + hr = m_audioClient->GetService(__uuidof(IAudioCaptureClient), m_captureClient.put_void()); + if (FAILED(hr)) + { + return hr; + } + + m_initialized.store(true); + return S_OK; +} + +HRESULT LoopbackCapture::Start() +{ + if (!m_initialized.load()) + { + return E_NOT_VALID_STATE; + } + + if (m_started.load()) + { + return S_OK; + } + + m_stopEvent.ResetEvent(); + + HRESULT hr = m_audioClient->Start(); + if (FAILED(hr)) + { + return hr; + } + + m_started.store(true); + + // Start capture thread + m_captureThread = std::thread(&LoopbackCapture::CaptureThread, this); + + return S_OK; +} + +void LoopbackCapture::Stop() +{ + if (!m_started.load()) + { + return; + } + + m_stopEvent.SetEvent(); + + if (m_captureThread.joinable()) + { + m_captureThread.join(); + } + + DrainCaptureClient(); + + if (m_audioClient) + { + m_audioClient->Stop(); + } + + m_started.store(false); +} + +void LoopbackCapture::DrainCaptureClient() +{ + if (!m_captureClient) + { + return; + } + + while (true) + { + UINT32 packetLength = 0; + HRESULT hr = m_captureClient->GetNextPacketSize(&packetLength); + if (FAILED(hr) || packetLength == 0) + { + break; + } + + BYTE* pData = nullptr; + UINT32 numFramesAvailable = 0; + DWORD flags = 0; + hr = m_captureClient->GetBuffer(&pData, &numFramesAvailable, &flags, nullptr, nullptr); + if (FAILED(hr)) + { + break; + } + + if (numFramesAvailable > 0) + { + std::vector samples; + + if (m_pwfx->wFormatTag == WAVE_FORMAT_IEEE_FLOAT || + (m_pwfx->wFormatTag == WAVE_FORMAT_EXTENSIBLE && + reinterpret_cast(m_pwfx)->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT)) + { + if (flags & AUDCLNT_BUFFERFLAGS_SILENT) + { + samples.resize(static_cast(numFramesAvailable) * m_pwfx->nChannels, 0.0f); + } + else + { + float* floatData = reinterpret_cast(pData); + samples.assign(floatData, floatData + (static_cast(numFramesAvailable) * m_pwfx->nChannels)); + } + } + else if (m_pwfx->wFormatTag == WAVE_FORMAT_PCM || + (m_pwfx->wFormatTag == WAVE_FORMAT_EXTENSIBLE && + reinterpret_cast(m_pwfx)->SubFormat == KSDATAFORMAT_SUBTYPE_PCM)) + { + if (flags & AUDCLNT_BUFFERFLAGS_SILENT) + { + samples.resize(static_cast(numFramesAvailable) * m_pwfx->nChannels, 0.0f); + } + else if (m_pwfx->wBitsPerSample == 16) + { + int16_t* pcmData = reinterpret_cast(pData); + samples.resize(static_cast(numFramesAvailable) * m_pwfx->nChannels); + for (size_t i = 0; i < samples.size(); i++) + { + samples[i] = static_cast(pcmData[i]) / 32768.0f; + } + } + else if (m_pwfx->wBitsPerSample == 32) + { + int32_t* pcmData = reinterpret_cast(pData); + samples.resize(static_cast(numFramesAvailable) * m_pwfx->nChannels); + for (size_t i = 0; i < samples.size(); i++) + { + samples[i] = static_cast(pcmData[i]) / 2147483648.0f; + } + } + } + + if (!samples.empty()) + { + auto lock = m_lock.lock_exclusive(); + m_sampleQueue.push_back(std::move(samples)); + m_samplesReadyEvent.SetEvent(); + } + } + + hr = m_captureClient->ReleaseBuffer(numFramesAvailable); + if (FAILED(hr)) + { + break; + } + } +} + +void LoopbackCapture::CaptureThread() +{ + while (WaitForSingleObject(m_stopEvent.get(), 10) == WAIT_TIMEOUT) + { + UINT32 packetLength = 0; + HRESULT hr = m_captureClient->GetNextPacketSize(&packetLength); + if (FAILED(hr)) + { + break; + } + + while (packetLength != 0) + { + BYTE* pData = nullptr; + UINT32 numFramesAvailable = 0; + DWORD flags = 0; + + hr = m_captureClient->GetBuffer(&pData, &numFramesAvailable, &flags, nullptr, nullptr); + if (FAILED(hr)) + { + break; + } + + if (numFramesAvailable > 0) + { + std::vector samples; + + // Convert to float samples + if (m_pwfx->wFormatTag == WAVE_FORMAT_IEEE_FLOAT || + (m_pwfx->wFormatTag == WAVE_FORMAT_EXTENSIBLE && + reinterpret_cast(m_pwfx)->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT)) + { + // Already float format + if (flags & AUDCLNT_BUFFERFLAGS_SILENT) + { + // Insert silence + samples.resize(static_cast(numFramesAvailable) * m_pwfx->nChannels, 0.0f); + } + else + { + float* floatData = reinterpret_cast(pData); + samples.assign(floatData, floatData + (static_cast(numFramesAvailable) * m_pwfx->nChannels)); + } + } + else if (m_pwfx->wFormatTag == WAVE_FORMAT_PCM || + (m_pwfx->wFormatTag == WAVE_FORMAT_EXTENSIBLE && + reinterpret_cast(m_pwfx)->SubFormat == KSDATAFORMAT_SUBTYPE_PCM)) + { + // Convert PCM to float + if (flags & AUDCLNT_BUFFERFLAGS_SILENT) + { + samples.resize(static_cast(numFramesAvailable) * m_pwfx->nChannels, 0.0f); + } + else if (m_pwfx->wBitsPerSample == 16) + { + int16_t* pcmData = reinterpret_cast(pData); + samples.resize(static_cast(numFramesAvailable) * m_pwfx->nChannels); + for (size_t i = 0; i < samples.size(); i++) + { + samples[i] = static_cast(pcmData[i]) / 32768.0f; + } + } + else if (m_pwfx->wBitsPerSample == 32) + { + int32_t* pcmData = reinterpret_cast(pData); + samples.resize(static_cast(numFramesAvailable) * m_pwfx->nChannels); + for (size_t i = 0; i < samples.size(); i++) + { + samples[i] = static_cast(pcmData[i]) / 2147483648.0f; + } + } + } + + if (!samples.empty()) + { + auto lock = m_lock.lock_exclusive(); + m_sampleQueue.push_back(std::move(samples)); + m_samplesReadyEvent.SetEvent(); + } + } + + hr = m_captureClient->ReleaseBuffer(numFramesAvailable); + if (FAILED(hr)) + { + break; + } + + hr = m_captureClient->GetNextPacketSize(&packetLength); + if (FAILED(hr)) + { + break; + } + } + } +} + +bool LoopbackCapture::TryGetSamples(std::vector& samples) +{ + auto lock = m_lock.lock_exclusive(); + if (m_sampleQueue.empty()) + { + return false; + } + + samples = std::move(m_sampleQueue.front()); + m_sampleQueue.pop_front(); + + if (m_sampleQueue.empty()) + { + m_samplesReadyEvent.ResetEvent(); + } + + return true; +} diff --git a/src/modules/ZoomIt/ZoomIt/LoopbackCapture.h b/src/modules/ZoomIt/ZoomIt/LoopbackCapture.h new file mode 100644 index 0000000000..53f70817b5 --- /dev/null +++ b/src/modules/ZoomIt/ZoomIt/LoopbackCapture.h @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +class LoopbackCapture +{ +public: + LoopbackCapture(); + ~LoopbackCapture(); + + HRESULT Initialize(); + HRESULT Start(); + void Stop(); + + // Returns audio samples in the format: PCM float, stereo, 48kHz + bool TryGetSamples(std::vector& samples); + + WAVEFORMATEX* GetFormat() const { return m_pwfx; } + uint32_t GetSampleRate() const { return m_pwfx ? m_pwfx->nSamplesPerSec : 48000; } + uint32_t GetChannels() const { return m_pwfx ? m_pwfx->nChannels : 2; } + +private: + void CaptureThread(); + void DrainCaptureClient(); + + winrt::com_ptr m_deviceEnumerator; + winrt::com_ptr m_device; + winrt::com_ptr m_audioClient; + winrt::com_ptr m_captureClient; + WAVEFORMATEX* m_pwfx{ nullptr }; + + wil::unique_event m_stopEvent; + wil::unique_event m_samplesReadyEvent; + std::thread m_captureThread; + + wil::srwlock m_lock; + std::deque> m_sampleQueue; + + std::atomic m_initialized{ false }; + std::atomic m_started{ false }; +}; diff --git a/src/modules/ZoomIt/ZoomIt/Utility.cpp b/src/modules/ZoomIt/ZoomIt/Utility.cpp index ccc72ad752..f4e170c804 100644 --- a/src/modules/ZoomIt/ZoomIt/Utility.cpp +++ b/src/modules/ZoomIt/ZoomIt/Utility.cpp @@ -8,6 +8,579 @@ //============================================================================== #include "pch.h" #include "Utility.h" +#include + +#pragma comment(lib, "uxtheme.lib") + +//---------------------------------------------------------------------------- +// Dark Mode - Static/Global State +//---------------------------------------------------------------------------- +static bool g_darkModeInitialized = false; +static bool g_darkModeEnabled = false; +static HBRUSH g_darkBackgroundBrush = nullptr; +static HBRUSH g_darkControlBrush = nullptr; +static HBRUSH g_darkSurfaceBrush = nullptr; + +// Theme override from registry (defined in ZoomItSettings.h) +extern DWORD g_ThemeOverride; + +// Preferred App Mode values for Windows 10/11 dark mode +enum class PreferredAppMode +{ + Default, + AllowDark, + ForceDark, + ForceLight, + Max +}; + +// Undocumented ordinals from uxtheme.dll for dark mode support +using fnSetPreferredAppMode = PreferredAppMode(WINAPI*)(PreferredAppMode appMode); +using fnAllowDarkModeForWindow = bool(WINAPI*)(HWND hWnd, bool allow); +using fnShouldAppsUseDarkMode = bool(WINAPI*)(); +using fnRefreshImmersiveColorPolicyState = void(WINAPI*)(); +using fnFlushMenuThemes = void(WINAPI*)(); + +static fnSetPreferredAppMode pSetPreferredAppMode = nullptr; +static fnAllowDarkModeForWindow pAllowDarkModeForWindow = nullptr; +static fnShouldAppsUseDarkMode pShouldAppsUseDarkMode = nullptr; +static fnRefreshImmersiveColorPolicyState pRefreshImmersiveColorPolicyState = nullptr; +static fnFlushMenuThemes pFlushMenuThemes = nullptr; + +//---------------------------------------------------------------------------- +// +// InitializeDarkModeSupport +// +// Initialize dark mode function pointers from uxtheme.dll +// +//---------------------------------------------------------------------------- +static void InitializeDarkModeSupport() +{ + if (g_darkModeInitialized) + return; + + g_darkModeInitialized = true; + + HMODULE hUxTheme = GetModuleHandleW(L"uxtheme.dll"); + if (hUxTheme) + { + // These are undocumented ordinal exports + // Ordinal 135: SetPreferredAppMode (Windows 10 1903+) + pSetPreferredAppMode = reinterpret_cast( + GetProcAddress(hUxTheme, MAKEINTRESOURCEA(135))); + // Ordinal 133: AllowDarkModeForWindow + pAllowDarkModeForWindow = reinterpret_cast( + GetProcAddress(hUxTheme, MAKEINTRESOURCEA(133))); + // Ordinal 132: ShouldAppsUseDarkMode + pShouldAppsUseDarkMode = reinterpret_cast( + GetProcAddress(hUxTheme, MAKEINTRESOURCEA(132))); + // Ordinal 104: RefreshImmersiveColorPolicyState + pRefreshImmersiveColorPolicyState = reinterpret_cast( + GetProcAddress(hUxTheme, MAKEINTRESOURCEA(104))); + // Ordinal 136: FlushMenuThemes + pFlushMenuThemes = reinterpret_cast( + GetProcAddress(hUxTheme, MAKEINTRESOURCEA(136))); + + // Set preferred app mode based on our theme override or system setting + // Note: We check g_ThemeOverride directly here because IsDarkModeEnabled + // calls InitializeDarkModeSupport, which would cause recursion + if (pSetPreferredAppMode) + { + bool useDarkMode = false; + if (g_ThemeOverride == 0) + { + useDarkMode = false; // Force light + } + else if (g_ThemeOverride == 1) + { + useDarkMode = true; // Force dark + } + else if (pShouldAppsUseDarkMode) + { + useDarkMode = pShouldAppsUseDarkMode(); // Use system setting + } + + if (useDarkMode) + { + pSetPreferredAppMode(PreferredAppMode::ForceDark); + } + else + { + pSetPreferredAppMode(PreferredAppMode::ForceLight); + } + } + + // Flush menu themes to apply dark mode to context menus + if (pFlushMenuThemes) + { + pFlushMenuThemes(); + } + } + + // Update cached dark mode state + g_darkModeEnabled = false; + if (g_ThemeOverride == 0) + { + g_darkModeEnabled = false; + } + else if (g_ThemeOverride == 1) + { + g_darkModeEnabled = true; + } + else if (pShouldAppsUseDarkMode) + { + g_darkModeEnabled = pShouldAppsUseDarkMode(); + } +} + +//---------------------------------------------------------------------------- +// +// IsDarkModeEnabled +// +//---------------------------------------------------------------------------- +bool IsDarkModeEnabled() +{ + // Check for theme override from registry (0=light, 1=dark, 2+=system) + if (g_ThemeOverride == 0) + { + return false; // Force light mode + } + else if (g_ThemeOverride == 1) + { + return true; // Force dark mode + } + + InitializeDarkModeSupport(); + + // Check the undocumented API first + if (pShouldAppsUseDarkMode) + { + return pShouldAppsUseDarkMode(); + } + + // Fallback: Check registry for system theme preference + HKEY hKey; + if (RegOpenKeyExW(HKEY_CURRENT_USER, + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize", + 0, KEY_READ, &hKey) == ERROR_SUCCESS) + { + DWORD value = 1; + DWORD size = sizeof(value); + RegQueryValueExW(hKey, L"AppsUseLightTheme", nullptr, nullptr, + reinterpret_cast(&value), &size); + RegCloseKey(hKey); + return value == 0; // 0 = dark mode, 1 = light mode + } + + return false; +} + +//---------------------------------------------------------------------------- +// +// RefreshDarkModeState +// +//---------------------------------------------------------------------------- +void RefreshDarkModeState() +{ + InitializeDarkModeSupport(); + + if (pRefreshImmersiveColorPolicyState) + { + pRefreshImmersiveColorPolicyState(); + } + + // Update preferred app mode based on our IsDarkModeEnabled (respects override) + bool useDark = IsDarkModeEnabled(); + if (pSetPreferredAppMode) + { + if (useDark) + { + pSetPreferredAppMode(PreferredAppMode::ForceDark); + } + else + { + pSetPreferredAppMode(PreferredAppMode::ForceLight); + } + } + + // Flush menu themes to apply dark mode to context menus + if (pFlushMenuThemes) + { + pFlushMenuThemes(); + } + + g_darkModeEnabled = useDark; +} + +//---------------------------------------------------------------------------- +// +// SetDarkModeForWindow +// +//---------------------------------------------------------------------------- +void SetDarkModeForWindow(HWND hWnd, bool enable) +{ + InitializeDarkModeSupport(); + + if (pAllowDarkModeForWindow) + { + pAllowDarkModeForWindow(hWnd, enable); + } + + // Use DWMWA_USE_IMMERSIVE_DARK_MODE attribute (Windows 10 build 17763+) + // Attribute 20 is DWMWA_USE_IMMERSIVE_DARK_MODE + BOOL useDarkMode = enable ? TRUE : FALSE; + HMODULE hDwmapi = GetModuleHandleW(L"dwmapi.dll"); + if (hDwmapi) + { + using fnDwmSetWindowAttribute = HRESULT(WINAPI*)(HWND, DWORD, LPCVOID, DWORD); + auto pDwmSetWindowAttribute = reinterpret_cast( + GetProcAddress(hDwmapi, "DwmSetWindowAttribute")); + if (pDwmSetWindowAttribute) + { + // Try attribute 20 first (Windows 11 / newer Windows 10) + HRESULT hr = pDwmSetWindowAttribute(hWnd, 20, &useDarkMode, sizeof(useDarkMode)); + if (FAILED(hr)) + { + // Fall back to attribute 19 (older Windows 10) + pDwmSetWindowAttribute(hWnd, 19, &useDarkMode, sizeof(useDarkMode)); + } + } + } +} + +//---------------------------------------------------------------------------- +// +// GetDarkModeBrush / GetDarkModeControlBrush / GetDarkModeSurfaceBrush +// +//---------------------------------------------------------------------------- +HBRUSH GetDarkModeBrush() +{ + if (!g_darkBackgroundBrush) + { + g_darkBackgroundBrush = CreateSolidBrush(DarkMode::BackgroundColor); + } + return g_darkBackgroundBrush; +} + +HBRUSH GetDarkModeControlBrush() +{ + if (!g_darkControlBrush) + { + g_darkControlBrush = CreateSolidBrush(DarkMode::ControlColor); + } + return g_darkControlBrush; +} + +HBRUSH GetDarkModeSurfaceBrush() +{ + if (!g_darkSurfaceBrush) + { + g_darkSurfaceBrush = CreateSolidBrush(DarkMode::SurfaceColor); + } + return g_darkSurfaceBrush; +} + +//---------------------------------------------------------------------------- +// +// ApplyDarkModeToDialog +// +//---------------------------------------------------------------------------- +void ApplyDarkModeToDialog(HWND hDlg) +{ + if (IsDarkModeEnabled()) + { + SetDarkModeForWindow(hDlg, true); + + // Set dark theme for the dialog + SetWindowTheme(hDlg, L"DarkMode_Explorer", nullptr); + + // Apply dark theme to common controls (buttons, edit boxes, etc.) + EnumChildWindows(hDlg, [](HWND hChild, LPARAM) -> BOOL { + wchar_t className[64] = { 0 }; + GetClassNameW(hChild, className, _countof(className)); + + // Apply appropriate theme based on control type + if (_wcsicmp(className, L"Button") == 0) + { + // Check if this is a checkbox or radio button + LONG style = GetWindowLong(hChild, GWL_STYLE); + LONG buttonType = style & BS_TYPEMASK; + if (buttonType == BS_CHECKBOX || buttonType == BS_AUTOCHECKBOX || + buttonType == BS_3STATE || buttonType == BS_AUTO3STATE || + buttonType == BS_RADIOBUTTON || buttonType == BS_AUTORADIOBUTTON) + { + // Subclass checkbox/radio for dark mode painting - but keep DarkMode_Explorer theme + // for proper hit testing (empty theme can break mouse interaction) + SetWindowTheme(hChild, L"DarkMode_Explorer", nullptr); + SetWindowSubclass(hChild, CheckboxSubclassProc, 2, 0); + } + else if (buttonType == BS_GROUPBOX) + { + // Subclass group box for dark mode painting + SetWindowTheme(hChild, L"", L""); + SetWindowSubclass(hChild, GroupBoxSubclassProc, 4, 0); + } + else + { + SetWindowTheme(hChild, L"DarkMode_Explorer", nullptr); + } + } + else if (_wcsicmp(className, L"Edit") == 0) + { + // Use empty theme and subclass for dark mode border drawing + SetWindowTheme(hChild, L"", L""); + SetWindowSubclass(hChild, EditControlSubclassProc, 3, 0); + } + else if (_wcsicmp(className, L"ComboBox") == 0) + { + SetWindowTheme(hChild, L"DarkMode_CFD", nullptr); + } + else if (_wcsicmp(className, L"SysListView32") == 0 || + _wcsicmp(className, L"SysTreeView32") == 0) + { + SetWindowTheme(hChild, L"DarkMode_Explorer", nullptr); + } + else if (_wcsicmp(className, L"msctls_trackbar32") == 0) + { + // Subclass trackbar controls for dark mode painting + SetWindowTheme(hChild, L"", L""); + SetWindowSubclass(hChild, SliderSubclassProc, 1, 0); + } + else if (_wcsicmp(className, L"SysTabControl32") == 0) + { + // Use empty theme for tab control to allow dark background + SetWindowTheme(hChild, L"", L""); + } + else if (_wcsicmp(className, L"msctls_updown32") == 0) + { + SetWindowTheme(hChild, L"DarkMode_Explorer", nullptr); + } + else if (_wcsicmp(className, L"msctls_hotkey32") == 0) + { + // Subclass hotkey controls for dark mode painting + SetWindowTheme(hChild, L"", L""); + SetWindowSubclass(hChild, HotkeyControlSubclassProc, 1, 0); + } + else if (_wcsicmp(className, L"Static") == 0) + { + // Check if this is a text label (not an owner-draw or image control) + LONG style = GetWindowLong(hChild, GWL_STYLE); + LONG staticType = style & SS_TYPEMASK; + + // Options header uses a dedicated static subclass (to support large title font). + // Avoid applying the generic static subclass on top of it. + const int controlId = GetDlgCtrlID( hChild ); + if( controlId == IDC_VERSION || controlId == IDC_COPYRIGHT ) + { + SetWindowTheme( hChild, L"", L"" ); + return TRUE; + } + + if (staticType == SS_LEFT || staticType == SS_CENTER || staticType == SS_RIGHT || + staticType == SS_LEFTNOWORDWRAP || staticType == SS_SIMPLE) + { + // Subclass text labels for proper dark mode painting + SetWindowTheme(hChild, L"", L""); + SetWindowSubclass(hChild, StaticTextSubclassProc, 5, 0); + } + else + { + // Other static controls (icons, bitmaps, frames) - just remove theme + SetWindowTheme(hChild, L"", L""); + } + } + else + { + SetWindowTheme(hChild, L"DarkMode_Explorer", nullptr); + } + return TRUE; + }, 0); + } + else + { + // Light mode - remove dark mode + SetDarkModeForWindow(hDlg, false); + SetWindowTheme(hDlg, nullptr, nullptr); + + EnumChildWindows(hDlg, [](HWND hChild, LPARAM) -> BOOL { + // Remove subclass from controls + wchar_t className[64] = { 0 }; + GetClassNameW(hChild, className, _countof(className)); + if (_wcsicmp(className, L"msctls_hotkey32") == 0) + { + RemoveWindowSubclass(hChild, HotkeyControlSubclassProc, 1); + } + else if (_wcsicmp(className, L"msctls_trackbar32") == 0) + { + RemoveWindowSubclass(hChild, SliderSubclassProc, 1); + } + else if (_wcsicmp(className, L"Button") == 0) + { + LONG style = GetWindowLong(hChild, GWL_STYLE); + LONG buttonType = style & BS_TYPEMASK; + if (buttonType == BS_CHECKBOX || buttonType == BS_AUTOCHECKBOX || + buttonType == BS_3STATE || buttonType == BS_AUTO3STATE || + buttonType == BS_RADIOBUTTON || buttonType == BS_AUTORADIOBUTTON) + { + RemoveWindowSubclass(hChild, CheckboxSubclassProc, 2); + } + else if (buttonType == BS_GROUPBOX) + { + RemoveWindowSubclass(hChild, GroupBoxSubclassProc, 4); + } + } + else if (_wcsicmp(className, L"Edit") == 0) + { + RemoveWindowSubclass(hChild, EditControlSubclassProc, 3); + } + else if (_wcsicmp(className, L"Static") == 0) + { + RemoveWindowSubclass(hChild, StaticTextSubclassProc, 5); + } + SetWindowTheme(hChild, nullptr, nullptr); + return TRUE; + }, 0); + } +} + +//---------------------------------------------------------------------------- +// +// HandleDarkModeCtlColor +// +//---------------------------------------------------------------------------- +HBRUSH HandleDarkModeCtlColor(HDC hdc, HWND hCtrl, UINT message) +{ + if (!IsDarkModeEnabled()) + { + return nullptr; + } + + switch (message) + { + case WM_CTLCOLORDLG: + SetBkColor(hdc, DarkMode::BackgroundColor); + SetTextColor(hdc, DarkMode::TextColor); + return GetDarkModeBrush(); + + case WM_CTLCOLORSTATIC: + SetBkMode(hdc, TRANSPARENT); + // Use dimmed color for disabled static controls + if (!IsWindowEnabled(hCtrl)) + { + SetTextColor(hdc, RGB(100, 100, 100)); + } + else + { + SetTextColor(hdc, DarkMode::TextColor); + } + return GetDarkModeBrush(); + + case WM_CTLCOLORBTN: + SetBkColor(hdc, DarkMode::ControlColor); + SetTextColor(hdc, DarkMode::TextColor); + return GetDarkModeControlBrush(); + + case WM_CTLCOLOREDIT: + SetBkColor(hdc, DarkMode::SurfaceColor); + SetTextColor(hdc, DarkMode::TextColor); + return GetDarkModeSurfaceBrush(); + + case WM_CTLCOLORLISTBOX: + SetBkColor(hdc, DarkMode::SurfaceColor); + SetTextColor(hdc, DarkMode::TextColor); + return GetDarkModeSurfaceBrush(); + } + + return nullptr; +} + +//---------------------------------------------------------------------------- +// +// ApplyDarkModeToMenu +// +// Uses undocumented uxtheme functions to enable dark mode for menus +// +//---------------------------------------------------------------------------- +void ApplyDarkModeToMenu(HMENU hMenu) +{ + if (!hMenu) + { + return; + } + + if (!IsDarkModeEnabled()) + { + // Light mode - clear any dark background + MENUINFO mi = { sizeof(mi) }; + mi.fMask = MIM_BACKGROUND | MIM_APPLYTOSUBMENUS; + mi.hbrBack = nullptr; + SetMenuInfo(hMenu, &mi); + return; + } + + // For popup menus, we need to use MENUINFO to set the background + MENUINFO mi = { sizeof(mi) }; + mi.fMask = MIM_BACKGROUND | MIM_APPLYTOSUBMENUS; + mi.hbrBack = GetDarkModeSurfaceBrush(); + SetMenuInfo(hMenu, &mi); +} + +//---------------------------------------------------------------------------- +// +// RefreshWindowTheme +// +// Forces a window and all its children to redraw with current theme +// +//---------------------------------------------------------------------------- +void RefreshWindowTheme(HWND hWnd) +{ + if (!hWnd) + { + return; + } + + // Reapply theme to this window + ApplyDarkModeToDialog(hWnd); + + // Force redraw + RedrawWindow(hWnd, nullptr, nullptr, RDW_INVALIDATE | RDW_ERASE | RDW_ALLCHILDREN | RDW_FRAME); +} + +//---------------------------------------------------------------------------- +// +// CleanupDarkModeResources +// +//---------------------------------------------------------------------------- +void CleanupDarkModeResources() +{ + if (g_darkBackgroundBrush) + { + DeleteObject(g_darkBackgroundBrush); + g_darkBackgroundBrush = nullptr; + } + if (g_darkControlBrush) + { + DeleteObject(g_darkControlBrush); + g_darkControlBrush = nullptr; + } + if (g_darkSurfaceBrush) + { + DeleteObject(g_darkSurfaceBrush); + g_darkSurfaceBrush = nullptr; + } +} + +//---------------------------------------------------------------------------- +// +// InitializeDarkMode +// +// Public wrapper to initialize dark mode support early in app startup +// +//---------------------------------------------------------------------------- +void InitializeDarkMode() +{ + InitializeDarkModeSupport(); +} //---------------------------------------------------------------------------- // @@ -151,3 +724,177 @@ POINT ScalePointInRects( POINT point, const RECT& source, const RECT& target ) return { targetCenter.x + MulDiv( point.x - sourceCenter.x, targetSize.cx, sourceSize.cx ), targetCenter.y + MulDiv( point.y - sourceCenter.y, targetSize.cy, sourceSize.cy ) }; } + +//---------------------------------------------------------------------------- +// +// ScaleDialogForDpi +// +// Scales a dialog and all its child controls for the specified DPI. +// oldDpi defaults to DPI_BASELINE (96) for initial scaling. +// +//---------------------------------------------------------------------------- +void ScaleDialogForDpi( HWND hDlg, UINT newDpi, UINT oldDpi ) +{ + if( newDpi == oldDpi || newDpi == 0 || oldDpi == 0 ) + { + return; + } + + // With PerMonitorV2, Windows automatically scales dialogs (layout and fonts) when created. + // We only need to scale when moving between monitors with different DPIs. + // When oldDpi == DPI_BASELINE, this is initial creation and Windows already handled scaling. + if( oldDpi == DPI_BASELINE ) + { + return; + } + + // Scale the dialog window itself + RECT dialogRect; + GetWindowRect( hDlg, &dialogRect ); + int dialogWidth = MulDiv( dialogRect.right - dialogRect.left, newDpi, oldDpi ); + int dialogHeight = MulDiv( dialogRect.bottom - dialogRect.top, newDpi, oldDpi ); + SetWindowPos( hDlg, nullptr, 0, 0, dialogWidth, dialogHeight, SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE ); + + // Enumerate and scale all child controls + HWND hChild = GetWindow( hDlg, GW_CHILD ); + while( hChild != nullptr ) + { + RECT childRect; + GetWindowRect( hChild, &childRect ); + MapWindowPoints( nullptr, hDlg, reinterpret_cast(&childRect), 2 ); + + int x = MulDiv( childRect.left, newDpi, oldDpi ); + int y = MulDiv( childRect.top, newDpi, oldDpi ); + int width = MulDiv( childRect.right - childRect.left, newDpi, oldDpi ); + int height = MulDiv( childRect.bottom - childRect.top, newDpi, oldDpi ); + + SetWindowPos( hChild, nullptr, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE ); + + // Scale the font for the control + HFONT hFont = reinterpret_cast(SendMessage( hChild, WM_GETFONT, 0, 0 )); + if( hFont != nullptr ) + { + LOGFONT lf{}; + if( GetObject( hFont, sizeof(lf), &lf ) ) + { + lf.lfHeight = MulDiv( lf.lfHeight, newDpi, oldDpi ); + HFONT hNewFont = CreateFontIndirect( &lf ); + if( hNewFont ) + { + SendMessage( hChild, WM_SETFONT, reinterpret_cast(hNewFont), TRUE ); + // Note: The old font might be shared, so we don't delete it here + // The system will clean up fonts when the dialog is destroyed + } + } + } + + hChild = GetWindow( hChild, GW_HWNDNEXT ); + } + + // Also scale the dialog's own font + HFONT hDialogFont = reinterpret_cast(SendMessage( hDlg, WM_GETFONT, 0, 0 )); + if( hDialogFont != nullptr ) + { + LOGFONT lf{}; + if( GetObject( hDialogFont, sizeof(lf), &lf ) ) + { + lf.lfHeight = MulDiv( lf.lfHeight, newDpi, oldDpi ); + HFONT hNewFont = CreateFontIndirect( &lf ); + if( hNewFont ) + { + SendMessage( hDlg, WM_SETFONT, reinterpret_cast(hNewFont), TRUE ); + } + } + } +} + +//---------------------------------------------------------------------------- +// +// ScaleChildControlsForDpi +// +// Scales a window's direct child controls (and their fonts) for the specified DPI. +// Unlike ScaleDialogForDpi, this does not resize the parent window itself. +// +// This is useful for child dialogs used as tab pages: the tab page window is +// already scaled when the parent options dialog is scaled, but the controls +// inside the page are not (because they are grandchildren of the options dialog). +// +//---------------------------------------------------------------------------- +void ScaleChildControlsForDpi( HWND hParent, UINT newDpi, UINT oldDpi ) +{ + if( newDpi == oldDpi || newDpi == 0 || oldDpi == 0 ) + { + return; + } + + // With PerMonitorV2, Windows automatically scales dialogs (layout and fonts) when created. + // We only need to scale when moving between monitors with different DPIs. + // When oldDpi == DPI_BASELINE, this is initial creation and Windows already handled scaling. + if( oldDpi == DPI_BASELINE ) + { + return; + } + + HWND hChild = GetWindow( hParent, GW_CHILD ); + while( hChild != nullptr ) + { + RECT childRect; + GetWindowRect( hChild, &childRect ); + MapWindowPoints( nullptr, hParent, reinterpret_cast(&childRect), 2 ); + + int x = MulDiv( childRect.left, newDpi, oldDpi ); + int y = MulDiv( childRect.top, newDpi, oldDpi ); + int width = MulDiv( childRect.right - childRect.left, newDpi, oldDpi ); + int height = MulDiv( childRect.bottom - childRect.top, newDpi, oldDpi ); + + SetWindowPos( hChild, nullptr, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE ); + + // Scale the font for the control + HFONT hFont = reinterpret_cast(SendMessage( hChild, WM_GETFONT, 0, 0 )); + if( hFont != nullptr ) + { + LOGFONT lf{}; + if( GetObject( hFont, sizeof(lf), &lf ) ) + { + lf.lfHeight = MulDiv( lf.lfHeight, newDpi, oldDpi ); + HFONT hNewFont = CreateFontIndirect( &lf ); + if( hNewFont ) + { + SendMessage( hChild, WM_SETFONT, reinterpret_cast(hNewFont), TRUE ); + } + } + } + + hChild = GetWindow( hChild, GW_HWNDNEXT ); + } +} + +//---------------------------------------------------------------------------- +// +// HandleDialogDpiChange +// +// Handles WM_DPICHANGED message for dialogs. Call this from the dialog's +// WndProc when WM_DPICHANGED is received. +// +//---------------------------------------------------------------------------- +void HandleDialogDpiChange( HWND hDlg, WPARAM wParam, LPARAM lParam, UINT& currentDpi ) +{ + UINT newDpi = HIWORD( wParam ); + if( newDpi != currentDpi && newDpi != 0 ) + { + const RECT* pSuggestedRect = reinterpret_cast(lParam); + + // Scale the dialog controls from the current DPI to the new DPI + ScaleDialogForDpi( hDlg, newDpi, currentDpi ); + + // Move and resize the dialog to the suggested rectangle + SetWindowPos( hDlg, nullptr, + pSuggestedRect->left, + pSuggestedRect->top, + pSuggestedRect->right - pSuggestedRect->left, + pSuggestedRect->bottom - pSuggestedRect->top, + SWP_NOZORDER | SWP_NOACTIVATE ); + + currentDpi = newDpi; + } +} diff --git a/src/modules/ZoomIt/ZoomIt/Utility.h b/src/modules/ZoomIt/ZoomIt/Utility.h index a78ecdf14e..75a8142b46 100644 --- a/src/modules/ZoomIt/ZoomIt/Utility.h +++ b/src/modules/ZoomIt/ZoomIt/Utility.h @@ -9,6 +9,10 @@ #pragma once #include "pch.h" +#include + +// DPI baseline for scaling calculations (dialog units are designed at 96 DPI) +constexpr UINT DPI_BASELINE = USER_DEFAULT_SCREEN_DPI; RECT ForceRectInBounds( RECT rect, const RECT& bounds ); UINT GetDpiForWindowHelper( HWND window ); @@ -16,3 +20,86 @@ RECT GetMonitorRectFromCursor(); RECT RectFromPointsMinSize( POINT a, POINT b, LONG minSize ); int ScaleForDpi( int value, UINT dpi ); POINT ScalePointInRects( POINT point, const RECT& source, const RECT& target ); + +// Dialog DPI scaling functions +void ScaleDialogForDpi( HWND hDlg, UINT newDpi, UINT oldDpi = DPI_BASELINE ); +void ScaleChildControlsForDpi( HWND hParent, UINT newDpi, UINT oldDpi = DPI_BASELINE ); +void HandleDialogDpiChange( HWND hDlg, WPARAM wParam, LPARAM lParam, UINT& currentDpi ); + +//---------------------------------------------------------------------------- +// Dark Mode Support +//---------------------------------------------------------------------------- + +// Dark mode colors +namespace DarkMode +{ + // Background colors + constexpr COLORREF BackgroundColor = RGB(32, 32, 32); + constexpr COLORREF SurfaceColor = RGB(45, 45, 48); + constexpr COLORREF ControlColor = RGB(51, 51, 55); + + // Text colors + constexpr COLORREF TextColor = RGB(200, 200, 200); + constexpr COLORREF DisabledTextColor = RGB(120, 120, 120); + constexpr COLORREF LinkColor = RGB(86, 156, 214); + + // Border/accent colors + constexpr COLORREF BorderColor = RGB(67, 67, 70); + constexpr COLORREF AccentColor = RGB(0, 120, 215); + constexpr COLORREF HoverColor = RGB(62, 62, 66); + + // Light mode colors for contrast + constexpr COLORREF LightBackgroundColor = RGB(255, 255, 255); + constexpr COLORREF LightTextColor = RGB(0, 0, 0); +} + +// Check if system dark mode is enabled +bool IsDarkModeEnabled(); + +// Refresh dark mode state (call when WM_SETTINGCHANGE received) +void RefreshDarkModeState(); + +// Enable dark mode title bar for a window +void SetDarkModeForWindow(HWND hWnd, bool enable); + +// Apply dark mode to a dialog and enable dark title bar +void ApplyDarkModeToDialog(HWND hDlg); + +// Get the appropriate background brush for dark/light mode +HBRUSH GetDarkModeBrush(); +HBRUSH GetDarkModeControlBrush(); +HBRUSH GetDarkModeSurfaceBrush(); + +// Handle WM_CTLCOLOR* messages for dark mode +// Returns the brush to use, or nullptr if default handling should be used +HBRUSH HandleDarkModeCtlColor(HDC hdc, HWND hCtrl, UINT message); + +// Apply dark mode theme to a popup menu +void ApplyDarkModeToMenu(HMENU hMenu); + +// Force redraw of a window and all its children for theme change +void RefreshWindowTheme(HWND hWnd); + +// Cleanup dark mode resources (call at app exit) +void CleanupDarkModeResources(); + +// Initialize dark mode support early in app startup (call before creating windows) +void InitializeDarkMode(); + +// Subclass procedure for hotkey controls - needs to be accessible from Utility.cpp +LRESULT CALLBACK HotkeyControlSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData); + +// Subclass procedure for checkbox controls - needs to be accessible from Utility.cpp +LRESULT CALLBACK CheckboxSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData); + +// Subclass procedure for edit controls - needs to be accessible from Utility.cpp +LRESULT CALLBACK EditControlSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData); + +// Subclass procedure for group box controls - needs to be accessible from Utility.cpp +LRESULT CALLBACK GroupBoxSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData); + +// Subclass procedure for slider/trackbar controls - needs to be accessible from Utility.cpp +LRESULT CALLBACK SliderSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData); + +// Subclass procedure for static text controls - needs to be accessible from Utility.cpp +LRESULT CALLBACK StaticTextSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData); diff --git a/src/modules/ZoomIt/ZoomIt/VideoRecordingSession.cpp b/src/modules/ZoomIt/ZoomIt/VideoRecordingSession.cpp index 086c8bfb2a..d8c465bea4 100644 --- a/src/modules/ZoomIt/ZoomIt/VideoRecordingSession.cpp +++ b/src/modules/ZoomIt/ZoomIt/VideoRecordingSession.cpp @@ -9,8 +9,26 @@ #include "pch.h" #include "VideoRecordingSession.h" #include "CaptureFrameWait.h" +#include "Utility.h" +#include +#include +#include +#include +#include // For SHCreateStreamOnFileEx +#include // For timeBeginPeriod/timeEndPeriod + +#pragma comment(lib, "shlwapi.lib") +#pragma comment(lib, "winmm.lib") extern DWORD g_RecordScaling; +extern DWORD g_TrimDialogWidth; +extern DWORD g_TrimDialogHeight; +extern DWORD g_TrimDialogVolume; +extern class ClassRegistry reg; +extern REG_SETTING RegSettings[]; +extern HINSTANCE g_hInstance; + +HWND hDlgTrimDialog = nullptr; namespace winrt { @@ -19,11 +37,15 @@ namespace winrt using namespace Windows::Graphics::Capture; using namespace Windows::Graphics::DirectX; using namespace Windows::Graphics::DirectX::Direct3D11; + using namespace Windows::Graphics::Imaging; using namespace Windows::Storage; using namespace Windows::UI::Composition; using namespace Windows::Media::Core; using namespace Windows::Media::Transcoding; using namespace Windows::Media::MediaProperties; + using namespace Windows::Media::Editing; + using namespace Windows::Media::Playback; + using namespace Windows::Storage::FileProperties; } namespace util @@ -32,6 +54,10 @@ namespace util } const float CLEAR_COLOR[] = { 0.0f, 0.0f, 0.0f, 1.0f }; +constexpr UINT kGifDefaultDelayCs = 10; // 100ms (~10 FPS) when metadata delay is missing +constexpr UINT kGifMinDelayCs = 2; // 20ms minimum; browsers treat <2cs as 10cs (100ms) +constexpr UINT kGifBrowserFixupThreshold = 2; // Delays < this are treated as 10cs by browsers +constexpr UINT kGifMaxPreviewDimension = 1280; // cap decoded GIF preview size to keep playback smooth int32_t EnsureEven(int32_t value) { @@ -45,6 +71,784 @@ int32_t EnsureEven(int32_t value) } } +static bool IsGifPath(const std::wstring& path) +{ + try + { + const auto ext = std::filesystem::path(path).extension().wstring(); + return _wcsicmp(ext.c_str(), L".gif") == 0; + } + catch (...) + { + return false; + } +} + +static void CleanupGifFrames(VideoRecordingSession::TrimDialogData* pData) +{ + if (!pData) + { + return; + } + + for (auto& frame : pData->gifFrames) + { + if (frame.hBitmap) + { + DeleteObject(frame.hBitmap); + frame.hBitmap = nullptr; + } + } + pData->gifFrames.clear(); +} + +static size_t FindGifFrameIndex(const std::vector& frames, int64_t ticks) +{ + if (frames.empty()) + { + return 0; + } + + // Linear scan is fine for typical GIF counts; keeps logic simple and predictable + for (size_t i = 0; i < frames.size(); ++i) + { + const auto start = frames[i].start.count(); + const auto end = start + frames[i].duration.count(); + if (ticks >= start && ticks < end) + { + return i; + } + } + + // If we fall through, clamp to last frame + return frames.size() - 1; +} + +static bool LoadGifFrames(const std::wstring& gifPath, VideoRecordingSession::TrimDialogData* pData) +{ + OutputDebugStringW((L"[GIF Trim] LoadGifFrames called for: " + gifPath + L"\n").c_str()); + + if (!pData) + { + OutputDebugStringW(L"[GIF Trim] pData is null\n"); + return false; + } + + try + { + CleanupGifFrames(pData); + + winrt::com_ptr factory; + HRESULT hrFactory = CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(factory.put())); + if (FAILED(hrFactory)) + { + OutputDebugStringW((L"[GIF Trim] CoCreateInstance WICImagingFactory failed hr=0x" + std::to_wstring(hrFactory) + L"\n").c_str()); + return false; + } + + winrt::com_ptr decoder; + + auto logHr = [&](const wchar_t* step, HRESULT hr) + { + wchar_t buf[512]{}; + swprintf_s(buf, L"[GIF Trim] %s failed hr=0x%08X path=%s\n", step, static_cast(hr), gifPath.c_str()); + OutputDebugStringW(buf); + }; + + auto tryCreateDecoder = [&]() -> bool + { + OutputDebugStringW(L"[GIF Trim] Trying CreateDecoderFromFilename...\n"); + HRESULT hr = factory->CreateDecoderFromFilename(gifPath.c_str(), nullptr, GENERIC_READ, WICDecodeMetadataCacheOnLoad, decoder.put()); + if (SUCCEEDED(hr)) + { + OutputDebugStringW(L"[GIF Trim] CreateDecoderFromFilename succeeded\n"); + return true; + } + + logHr(L"CreateDecoderFromFilename", hr); + + // Fallback: try opening with FILE_SHARE_READ | FILE_SHARE_WRITE to handle locked files + OutputDebugStringW(L"[GIF Trim] Trying CreateStreamOnFile fallback...\n"); + HANDLE hFile = CreateFileW(gifPath.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (hFile != INVALID_HANDLE_VALUE) + { + winrt::com_ptr fileStream; + // Create an IStream over the file handle using SHCreateStreamOnFileEx + CloseHandle(hFile); + hr = SHCreateStreamOnFileEx(gifPath.c_str(), STGM_READ | STGM_SHARE_DENY_NONE, 0, FALSE, nullptr, fileStream.put()); + if (SUCCEEDED(hr) && fileStream) + { + hr = factory->CreateDecoderFromStream(fileStream.get(), nullptr, WICDecodeMetadataCacheOnLoad, decoder.put()); + if (SUCCEEDED(hr)) + { + OutputDebugStringW(L"[GIF Trim] CreateDecoderFromStream (SHCreateStreamOnFileEx) succeeded\n"); + return true; + } + logHr(L"CreateDecoderFromStream(SHCreateStreamOnFileEx)", hr); + } + else + { + logHr(L"SHCreateStreamOnFileEx", hr); + } + } + + return false; + }; + + auto tryCopyAndDecode = [&]() -> bool + { + OutputDebugStringW(L"[GIF Trim] Trying temp file copy fallback...\n"); + // Copy file to temp using Win32 APIs (no WinRT async) + wchar_t tempDir[MAX_PATH]; + if (GetTempPathW(MAX_PATH, tempDir) == 0) + { + return false; + } + + std::wstring tempPath = std::wstring(tempDir) + L"ZoomIt\\"; + CreateDirectoryW(tempPath.c_str(), nullptr); + + std::wstring tempName = L"gif_trim_cache_" + std::to_wstring(GetTickCount64()) + L".gif"; + tempPath += tempName; + + if (!CopyFileW(gifPath.c_str(), tempPath.c_str(), FALSE)) + { + logHr(L"CopyFileW", HRESULT_FROM_WIN32(GetLastError())); + return false; + } + + HRESULT hr = factory->CreateDecoderFromFilename(tempPath.c_str(), nullptr, GENERIC_READ, WICDecodeMetadataCacheOnLoad, decoder.put()); + if (SUCCEEDED(hr)) + { + OutputDebugStringW(L"[GIF Trim] CreateDecoderFromFilename(temp copy) succeeded\n"); + return true; + } + logHr(L"CreateDecoderFromFilename(temp copy)", hr); + + // Clean up temp file on failure + DeleteFileW(tempPath.c_str()); + return false; + }; + + if (!tryCreateDecoder()) + { + if (!tryCopyAndDecode()) + { + return false; + } + } + + UINT frameCount = 0; + if (FAILED(decoder->GetFrameCount(&frameCount)) || frameCount == 0) + { + return false; + } + + int64_t cumulativeTicks = 0; + UINT frameWidth = 0; + UINT frameHeight = 0; + + for (UINT i = 0; i < frameCount; ++i) + { + winrt::com_ptr frame; + if (FAILED(decoder->GetFrame(i, frame.put()))) + { + continue; + } + + if (i == 0) + { + frame->GetSize(&frameWidth, &frameHeight); + } + + UINT delayCs = kGifDefaultDelayCs; + try + { + winrt::com_ptr metadata; + if (SUCCEEDED(frame->GetMetadataQueryReader(metadata.put())) && metadata) + { + PROPVARIANT prop{}; + PropVariantInit(&prop); + if (SUCCEEDED(metadata->GetMetadataByName(L"/grctlext/Delay", &prop))) + { + if (prop.vt == VT_UI2) + { + delayCs = prop.uiVal; + } + else if (prop.vt == VT_UI1) + { + delayCs = prop.bVal; + } + } + PropVariantClear(&prop); + } + } + catch (...) + { + // Keep fallback delay + } + + if (delayCs == 0) + { + // GIF spec: delay of 0 means "as fast as possible"; browsers use ~10ms + delayCs = kGifDefaultDelayCs; + } + else if (delayCs < kGifBrowserFixupThreshold) + { + // Browsers treat delays < 2cs (20ms) as 10cs (100ms) to prevent CPU-hogging GIFs + delayCs = kGifDefaultDelayCs; + } + + // Log the first few frame delays for debugging + if (i < 3) + { + OutputDebugStringW((L"[GIF Trim] Frame " + std::to_wstring(i) + L" delay: " + std::to_wstring(delayCs) + L" cs (" + std::to_wstring(delayCs * 10) + L" ms)\n").c_str()); + } + + // Respect a max preview size to avoid huge allocations on large GIFs + UINT targetWidth = frameWidth; + UINT targetHeight = frameHeight; + if (targetWidth > kGifMaxPreviewDimension || targetHeight > kGifMaxPreviewDimension) + { + const double scaleX = static_cast(kGifMaxPreviewDimension) / static_cast(targetWidth); + const double scaleY = static_cast(kGifMaxPreviewDimension) / static_cast(targetHeight); + const double scale = (std::min)(scaleX, scaleY); + targetWidth = static_cast(std::lround(static_cast(targetWidth) * scale)); + targetHeight = static_cast(std::lround(static_cast(targetHeight) * scale)); + targetWidth = (std::max)(1u, targetWidth); + targetHeight = (std::max)(1u, targetHeight); + } + + winrt::com_ptr source = frame; + if (targetWidth != frameWidth || targetHeight != frameHeight) + { + winrt::com_ptr scaler; + if (SUCCEEDED(factory->CreateBitmapScaler(scaler.put()))) + { + if (SUCCEEDED(scaler->Initialize(frame.get(), targetWidth, targetHeight, WICBitmapInterpolationModeFant))) + { + source = scaler; + } + } + } + + winrt::com_ptr converter; + if (FAILED(factory->CreateFormatConverter(converter.put()))) + { + continue; + } + + if (FAILED(converter->Initialize(source.get(), GUID_WICPixelFormat32bppPBGRA, WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom))) + { + continue; + } + + UINT convertedWidth = 0; + UINT convertedHeight = 0; + converter->GetSize(&convertedWidth, &convertedHeight); + if (convertedWidth == 0 || convertedHeight == 0) + { + continue; + } + + const UINT stride = convertedWidth * 4; + std::vector buffer(static_cast(stride) * convertedHeight); + if (FAILED(converter->CopyPixels(nullptr, stride, static_cast(buffer.size()), buffer.data()))) + { + continue; + } + + BITMAPINFO bmi{}; + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = static_cast(convertedWidth); + bmi.bmiHeader.biHeight = -static_cast(convertedHeight); + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + + void* bits = nullptr; + HDC hdcScreen = GetDC(nullptr); + HBITMAP hBitmap = CreateDIBSection(hdcScreen, &bmi, DIB_RGB_COLORS, &bits, nullptr, 0); + ReleaseDC(nullptr, hdcScreen); + + if (!hBitmap || !bits) + { + if (hBitmap) + { + DeleteObject(hBitmap); + } + continue; + } + + for (UINT row = 0; row < convertedHeight; ++row) + { + memcpy(static_cast(bits) + static_cast(row) * stride, + buffer.data() + static_cast(row) * stride, + stride); + } + + VideoRecordingSession::TrimDialogData::GifFrame gifFrame; + gifFrame.hBitmap = hBitmap; + gifFrame.start = winrt::TimeSpan{ cumulativeTicks }; + gifFrame.duration = winrt::TimeSpan{ static_cast(delayCs) * 100'000 }; // centiseconds to 100ns + gifFrame.width = convertedWidth; + gifFrame.height = convertedHeight; + + cumulativeTicks += gifFrame.duration.count(); + pData->gifFrames.push_back(gifFrame); + } + + if (pData->gifFrames.empty()) + { + OutputDebugStringW(L"[GIF Trim] No frames loaded\n"); + return false; + } + + const auto& lastFrame = pData->gifFrames.back(); + pData->videoDuration = winrt::TimeSpan{ lastFrame.start.count() + lastFrame.duration.count() }; + pData->trimEnd = pData->videoDuration; + pData->gifFramesLoaded = true; + pData->gifLastFrameIndex = 0; + + OutputDebugStringW((L"[GIF Trim] Successfully loaded " + std::to_wstring(pData->gifFrames.size()) + L" frames\n").c_str()); + return true; + } + catch (const winrt::hresult_error& e) + { + OutputDebugStringW((L"[GIF Trim] Exception in LoadGifFrames: " + e.message() + L"\n").c_str()); + return false; + } + catch (const std::exception& e) + { + OutputDebugStringA("[GIF Trim] std::exception in LoadGifFrames: "); + OutputDebugStringA(e.what()); + OutputDebugStringA("\n"); + return false; + } + catch (...) + { + OutputDebugStringW(L"[GIF Trim] Unknown exception in LoadGifFrames\n"); + return false; + } +} + +namespace +{ + struct __declspec(uuid("5b0d3235-4dba-4d44-8657-1f1d0f83e9a3")) IMemoryBufferByteAccess : IUnknown + { + virtual HRESULT STDMETHODCALLTYPE GetBuffer(BYTE** value, UINT32* capacity) = 0; + }; + + constexpr int kTimelinePadding = 12; + constexpr int kTimelineTrackHeight = 24; + constexpr int kTimelineTrackTopOffset = 18; + constexpr int kTimelineHandleHalfWidth = 5; + constexpr int kTimelineHandleHeight = 40; + constexpr int kTimelineHandleHitRadius = 18; + constexpr int64_t kJogStepTicks = 20'000'000; // 2 seconds (or 1s for short videos) + constexpr int64_t kPreviewMinDeltaTicks = 2'000'000; // 20ms between thumbnails while playing + constexpr UINT32 kPreviewRequestWidthPlaying = 320; + constexpr UINT32 kPreviewRequestHeightPlaying = 180; + constexpr int64_t kTicksPerMicrosecond = 10; // 100ns units per microsecond + constexpr int64_t kPlaybackSyncIntervalMs = 40; // refresh baseline frequently for smoother prediction + constexpr int64_t kPlaybackDriftCheckMs = 40; // sample MediaPlayer at least every 40ms (overridden to every tick currently) + constexpr int64_t kPlaybackDriftSnapTicks = 2'000'000; // snap if drift exceeds 200ms + constexpr int kPlaybackDriftBlendNumerator = 1; // blend 20% toward real position + constexpr int kPlaybackDriftBlendDenominator = 5; + constexpr UINT WMU_PREVIEW_READY = WM_USER + 1; + constexpr UINT WMU_PREVIEW_SCHEDULED = WM_USER + 2; + constexpr UINT WMU_DURATION_CHANGED = WM_USER + 3; + constexpr UINT WMU_PLAYBACK_POSITION = WM_USER + 4; + constexpr UINT WMU_PLAYBACK_STOP = WM_USER + 5; + constexpr UINT_PTR kPreviewDebounceTimerId = 100; + constexpr UINT kPreviewDebounceDelayMs = 50; // Debounce delay for preview updates during dragging + + std::atomic g_highResTimerRefs{ 0 }; + + void AcquireHighResTimer() + { + if (g_highResTimerRefs.fetch_add(1, std::memory_order_relaxed) == 0) + { + timeBeginPeriod(1); + } + } + + void ReleaseHighResTimer() + { + const int prev = g_highResTimerRefs.fetch_sub(1, std::memory_order_relaxed); + if (prev == 1) + { + timeEndPeriod(1); + } + } + + bool EnsurePlaybackDevice(VideoRecordingSession::TrimDialogData* pData) + { + if (!pData) + { + return false; + } + + if (pData->previewD3DDevice && pData->previewD3DContext) + { + return true; + } + + UINT creationFlags = D3D11_CREATE_DEVICE_BGRA_SUPPORT; +#if defined(_DEBUG) + creationFlags |= D3D11_CREATE_DEVICE_DEBUG; +#endif + + D3D_FEATURE_LEVEL levels[] = { D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0, D3D_FEATURE_LEVEL_10_1, D3D_FEATURE_LEVEL_10_0 }; + D3D_FEATURE_LEVEL levelCreated = D3D_FEATURE_LEVEL_11_0; + + winrt::com_ptr device; + winrt::com_ptr context; + if (SUCCEEDED(D3D11CreateDevice( + nullptr, + D3D_DRIVER_TYPE_HARDWARE, + nullptr, + creationFlags, + levels, + ARRAYSIZE(levels), + D3D11_SDK_VERSION, + device.put(), + &levelCreated, + context.put()))) + { + pData->previewD3DDevice = device; + pData->previewD3DContext = context; + return true; + } + + return false; + } + + bool EnsureFrameTextures(VideoRecordingSession::TrimDialogData* pData, UINT width, UINT height) + { + if (!pData || !pData->previewD3DDevice) + { + return false; + } + + auto recreate = [&]() + { + pData->previewFrameTexture = nullptr; + pData->previewFrameStaging = nullptr; + + D3D11_TEXTURE2D_DESC desc{}; + desc.Width = width; + desc.Height = height; + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + desc.SampleDesc.Count = 1; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE; + + winrt::com_ptr frameTex; + if (FAILED(pData->previewD3DDevice->CreateTexture2D(&desc, nullptr, frameTex.put()))) + { + return false; + } + + desc.BindFlags = 0; + desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + desc.Usage = D3D11_USAGE_STAGING; + + winrt::com_ptr staging; + if (FAILED(pData->previewD3DDevice->CreateTexture2D(&desc, nullptr, staging.put()))) + { + return false; + } + + pData->previewFrameTexture = frameTex; + pData->previewFrameStaging = staging; + return true; + }; + + if (!pData->previewFrameTexture || !pData->previewFrameStaging) + { + return recreate(); + } + + D3D11_TEXTURE2D_DESC existing{}; + pData->previewFrameTexture->GetDesc(&existing); + if (existing.Width != width || existing.Height != height) + { + return recreate(); + } + + return true; + } + + void CenterTrimDialog(HWND hDlg) + { + if (!hDlg) + { + return; + } + + RECT rcDlg{}; + if (!GetWindowRect(hDlg, &rcDlg)) + { + return; + } + + const int dlgWidth = rcDlg.right - rcDlg.left; + const int dlgHeight = rcDlg.bottom - rcDlg.top; + + // Always center on the monitor containing the dialog, not the parent window + RECT rcTarget{}; + HMONITOR monitor = MonitorFromWindow(hDlg, MONITOR_DEFAULTTONEAREST); + MONITORINFO mi{ sizeof(mi) }; + if (GetMonitorInfo(monitor, &mi)) + { + rcTarget = mi.rcWork; + } + else + { + rcTarget.left = 0; + rcTarget.top = 0; + rcTarget.right = GetSystemMetrics(SM_CXSCREEN); + rcTarget.bottom = GetSystemMetrics(SM_CYSCREEN); + } + + const int targetWidth = rcTarget.right - rcTarget.left; + const int targetHeight = rcTarget.bottom - rcTarget.top; + + int newX = rcTarget.left + (targetWidth - dlgWidth) / 2; + int newY = rcTarget.top + (targetHeight - dlgHeight) / 2; + + if (dlgWidth >= targetWidth) + { + newX = rcTarget.left; + } + else + { + newX = static_cast((std::clamp)(static_cast(newX), rcTarget.left, rcTarget.right - dlgWidth)); + } + + if (dlgHeight >= targetHeight) + { + newY = rcTarget.top; + } + else + { + newY = static_cast((std::clamp)(static_cast(newY), rcTarget.top, rcTarget.bottom - dlgHeight)); + } + + SetWindowPos(hDlg, nullptr, newX, newY, 0, 0, SWP_NOACTIVATE | SWP_NOSIZE | SWP_NOZORDER); + } + + std::wstring FormatTrimTime(const winrt::TimeSpan& value, bool includeMilliseconds) + { + const int64_t ticks = (std::max)(value.count(), int64_t{ 0 }); + const int64_t totalMilliseconds = ticks / 10000LL; + const int milliseconds = static_cast(totalMilliseconds % 1000); + const int64_t totalSeconds = totalMilliseconds / 1000LL; + const int seconds = static_cast(totalSeconds % 60LL); + const int64_t totalMinutes = totalSeconds / 60LL; + const int minutes = static_cast(totalMinutes % 60LL); + const int hours = static_cast(totalMinutes / 60LL); + + wchar_t buffer[32]{}; + if (hours > 0) + { + swprintf_s(buffer, L"%d:%02d:%02d", hours, minutes, seconds); + } + else + { + swprintf_s(buffer, L"%02d:%02d", minutes, seconds); + } + + if (!includeMilliseconds) + { + return std::wstring(buffer); + } + + wchar_t msBuffer[8]{}; + swprintf_s(msBuffer, L".%03d", milliseconds); + return std::wstring(buffer) + msBuffer; + } + + std::wstring FormatDurationString(const winrt::TimeSpan& duration) + { + return L"Selection: " + FormatTrimTime(duration, true); + } + + void SetTimeText(HWND hDlg, int controlId, const winrt::TimeSpan& value, bool includeMilliseconds) + { + const std::wstring formatted = FormatTrimTime(value, includeMilliseconds); + // Only update if the text has changed to prevent flashing + wchar_t currentText[64] = {}; + GetDlgItemText(hDlg, controlId, currentText, _countof(currentText)); + if (formatted != currentText) + { + SetDlgItemText(hDlg, controlId, formatted.c_str()); + } + } + + int TimelineTimeToClientX(const VideoRecordingSession::TrimDialogData* pData, winrt::TimeSpan value, int clientWidth, UINT dpi = DPI_BASELINE) + { + const int padding = ScaleForDpi(kTimelinePadding, dpi); + const int trackWidth = (std::max)(clientWidth - padding * 2, 1); + return padding + pData->TimeToPixel(value, trackWidth); + } + + winrt::TimeSpan TimelinePixelToTime(const VideoRecordingSession::TrimDialogData* pData, int x, int clientWidth, UINT dpi = DPI_BASELINE) + { + const int padding = ScaleForDpi(kTimelinePadding, dpi); + const int trackWidth = (std::max)(clientWidth - padding * 2, 1); + const int localX = std::clamp(x - padding, 0, trackWidth); + return pData->PixelToTime(localX, trackWidth); + } + + void UpdateDurationDisplay(HWND hDlg, VideoRecordingSession::TrimDialogData* pData) + { + if (!pData || !hDlg) + { + return; + } + + const int64_t selectionTicks = (std::max)(pData->trimEnd.count() - pData->trimStart.count(), int64_t{ 0 }); + const std::wstring durationText = FormatDurationString(winrt::TimeSpan{ selectionTicks }); + // Only update if the text has changed to prevent flashing + wchar_t currentText[64] = {}; + GetDlgItemText(hDlg, IDC_TRIM_DURATION_LABEL, currentText, _countof(currentText)); + if (durationText != currentText) + { + SetDlgItemText(hDlg, IDC_TRIM_DURATION_LABEL, durationText.c_str()); + } + + // Enable OK when trimming is active (even if unchanged since dialog opened), + // or when the user changed the selection (including reverting to full length). + const bool trimChanged = (pData->trimStart.count() != pData->originalTrimStart.count()) || + (pData->trimEnd.count() != pData->originalTrimEnd.count()); + const bool trimIsActive = (pData->trimStart.count() > 0) || + (pData->videoDuration.count() > 0 && pData->trimEnd.count() < pData->videoDuration.count()); + EnableWindow(GetDlgItem(hDlg, IDOK), trimChanged || trimIsActive); + } + + RECT GetTimelineTrackRect(const RECT& clientRect, UINT dpi) + { + const int padding = ScaleForDpi(kTimelinePadding, dpi); + const int trackOffset = ScaleForDpi(kTimelineTrackTopOffset, dpi); + const int trackHeight = ScaleForDpi(kTimelineTrackHeight, dpi); + const int trackLeft = clientRect.left + padding; + const int trackRight = clientRect.right - padding; + const int trackTop = clientRect.top + trackOffset; + const int trackBottom = trackTop + trackHeight; + RECT track{ trackLeft, trackTop, trackRight, trackBottom }; + return track; + } + + RECT GetPlayheadBoundsRect(const RECT& clientRect, int x, UINT dpi) + { + RECT track = GetTimelineTrackRect(clientRect, dpi); + const int lineThick = ScaleForDpi(3, dpi); + const int topExt = ScaleForDpi(12, dpi); + const int botExt = ScaleForDpi(22, dpi); + const int circleR = ScaleForDpi(6, dpi); + const int circleBotOff = ScaleForDpi(12, dpi); + const int circleBotEnd = ScaleForDpi(24, dpi); + RECT lineRect{ x - lineThick + 1, track.top - topExt, x + lineThick, track.bottom + botExt }; + RECT circleRect{ x - circleR, track.bottom + circleBotOff, x + circleR, track.bottom + circleBotEnd }; + RECT combined{}; + UnionRect(&combined, &lineRect, &circleRect); + return combined; + } + + void InvalidatePlayheadRegion(HWND hTimeline, const RECT& clientRect, int previousX, int newX, UINT dpi) + { + if (!hTimeline) + { + return; + } + + RECT invalidRect{}; + bool hasRect = false; + + if (previousX >= 0) + { + RECT oldRect = GetPlayheadBoundsRect(clientRect, previousX, dpi); + invalidRect = oldRect; + hasRect = true; + } + + if (newX >= 0) + { + RECT newRect = GetPlayheadBoundsRect(clientRect, newX, dpi); + if (hasRect) + { + RECT unionRect{}; + UnionRect(&unionRect, &invalidRect, &newRect); + invalidRect = unionRect; + } + else + { + invalidRect = newRect; + hasRect = true; + } + } + + if (hasRect) + { + InflateRect(&invalidRect, 2, 2); + InvalidateRect(hTimeline, &invalidRect, FALSE); + } + } +} + +static int64_t SteadyClockMicros() +{ + return std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); +} + +static void ResetSmoothPlayback(VideoRecordingSession::TrimDialogData* pData) +{ + if (!pData) + { + return; + } + + pData->smoothActive.store(false, std::memory_order_relaxed); + pData->smoothBaseTicks.store(0, std::memory_order_relaxed); + pData->smoothLastSyncMicroseconds.store(0, std::memory_order_relaxed); + pData->smoothHasNonZeroSample.store(false, std::memory_order_relaxed); +} + +static void LogSmoothingEvent(const wchar_t* label, int64_t predictedTicks, int64_t mediaTicks, int64_t driftTicks); + +static void SyncSmoothPlayback(VideoRecordingSession::TrimDialogData* pData, int64_t mediaTicks, int64_t /*minTicks*/, int64_t /*maxTicks*/) +{ + if (!pData) + { + return; + } + + const int64_t nowUs = SteadyClockMicros(); + pData->smoothBaseTicks.store(mediaTicks, std::memory_order_relaxed); + pData->smoothLastSyncMicroseconds.store(nowUs, std::memory_order_relaxed); + pData->smoothActive.store(true, std::memory_order_relaxed); + pData->smoothHasNonZeroSample.store(mediaTicks > 0, std::memory_order_relaxed); + + LogSmoothingEvent(L"setBase", mediaTicks, mediaTicks, 0); +} + +static void LogSmoothingEvent(const wchar_t* label, int64_t predictedTicks, int64_t mediaTicks, int64_t driftTicks) +{ + wchar_t buf[256]{}; + swprintf_s(buf, L"[TrimSmooth] %s pred=%lld media=%lld drift=%lld\n", + label ? label : L"", static_cast(predictedTicks), static_cast(mediaTicks), static_cast(driftTicks)); + OutputDebugStringW(buf); +} + +static void StopPlayback(HWND hDlg, VideoRecordingSession::TrimDialogData* pData, bool capturePosition = true); +static winrt::fire_and_forget StartPlaybackAsync(HWND hDlg, VideoRecordingSession::TrimDialogData* pData); + + //---------------------------------------------------------------------------- // // VideoRecordingSession::VideoRecordingSession @@ -56,6 +860,7 @@ VideoRecordingSession::VideoRecordingSession( RECT const cropRect, uint32_t frameRate, bool captureAudio, + bool captureSystemAudio, winrt::Streams::IRandomAccessStream const& stream) { m_device = device; @@ -134,13 +939,10 @@ VideoRecordingSession::VideoRecordingSession( video.PixelAspectRatio().Denominator(1); m_encodingProfile.Video(video); - // if audio capture, set up audio profile - if (captureAudio) - { - auto audio = m_encodingProfile.Audio(); - audio = winrt::AudioEncodingProperties::CreateAac(48000, 1, 16); - m_encodingProfile.Audio(audio); - } + // Always set up audio profile for loopback capture (stereo AAC) + auto audio = m_encodingProfile.Audio(); + audio = winrt::AudioEncodingProperties::CreateAac(48000, 2, 192000); + m_encodingProfile.Audio(audio); // Describe our input: uncompressed BGRA8 buffers auto properties = winrt::VideoEncodingProperties::CreateUncompressed( @@ -161,14 +963,8 @@ VideoRecordingSession::VideoRecordingSession( winrt::check_hresult(m_previewSwapChain->GetBuffer(0, winrt::guid_of(), backBuffer.put_void())); winrt::check_hresult(m_d3dDevice->CreateRenderTargetView(backBuffer.get(), nullptr, m_renderTargetView.put())); - if( captureAudio ) { - - m_audioGenerator = std::make_unique(); - } - else { - - m_audioGenerator = nullptr; - } + // Always create audio generator for loopback capture; captureAudio controls microphone + m_audioGenerator = std::make_unique(captureAudio, captureSystemAudio); } @@ -215,8 +1011,34 @@ winrt::IAsyncAction VideoRecordingSession::StartAsync() auto self = shared_from_this(); // Start encoding - auto transcode = co_await m_transcoder.PrepareMediaStreamSourceTranscodeAsync(m_streamSource, m_stream, m_encodingProfile); - co_await transcode.TranscodeAsync(); + // If the user stops recording immediately after starting, MediaTranscoder may fail + // with MF_E_NO_SAMPLE_PROCESSED (0xC00D4A44). Avoid surfacing this as an error. + if (m_closed.load()) + { + co_return; + } + + winrt::PrepareTranscodeResult transcode{ nullptr }; + try + { + transcode = co_await m_transcoder.PrepareMediaStreamSourceTranscodeAsync(m_streamSource, m_stream, m_encodingProfile); + + if (m_closed.load()) + { + co_return; + } + + co_await transcode.TranscodeAsync(); + } + catch (winrt::hresult_error const& error) + { + constexpr HRESULT MF_E_NO_SAMPLE_PROCESSED = static_cast(0xC00D4A44); + if (m_closed.load() || error.code() == MF_E_NO_SAMPLE_PROCESSED) + { + co_return; + } + throw; + } } co_return; } @@ -289,9 +1111,10 @@ std::shared_ptr VideoRecordingSession::Create( RECT const& crop, uint32_t frameRate, bool captureAudio, + bool captureSystemAudio, winrt::Streams::IRandomAccessStream const& stream) { - return std::shared_ptr(new VideoRecordingSession(device, item, crop, frameRate, captureAudio, stream)); + return std::shared_ptr(new VideoRecordingSession(device, item, crop, frameRate, captureAudio, captureSystemAudio, stream)); } //---------------------------------------------------------------------------- @@ -361,6 +1184,7 @@ void VideoRecordingSession::OnMediaStreamSourceSampleRequested( winrt::check_hresult(m_previewSwapChain->Present1(0, 0, &presentParameters)); auto sample = winrt::MediaStreamSample::CreateFromDirect3D11Surface(sampleSurface, timeStamp); + m_hasVideoSample.store(true); request.Sample(sample); } catch (winrt::hresult_error const& error) @@ -376,16 +1200,4110 @@ void VideoRecordingSession::OnMediaStreamSourceSampleRequested( request.Sample(nullptr); CloseInternal(); } - } + } else if (auto audioStreamDescriptor = streamDescriptor.try_as()) { - if (auto sample = m_audioGenerator->TryGetNextSample()) + try { - request.Sample(sample.value()); + if (auto sample = m_audioGenerator->TryGetNextSample()) + { + request.Sample(sample.value()); + } + else + { + request.Sample(nullptr); + } } - else + catch (winrt::hresult_error const& error) { + OutputDebugStringW(error.message().c_str()); request.Sample(nullptr); + CloseInternal(); + return; } } } + +//---------------------------------------------------------------------------- +// +// Custom file dialog events handler for Trim button +// +//---------------------------------------------------------------------------- +class CTrimFileDialogEvents : public IFileDialogEvents, public IFileDialogControlEvents +{ +private: + long m_cRef; + HWND m_hParent; + std::wstring m_videoPath; + std::wstring* m_pTrimmedPath; + winrt::TimeSpan* m_pTrimStart; + winrt::TimeSpan* m_pTrimEnd; + bool* m_pShouldTrim; + bool m_bIconSet; + +public: + CTrimFileDialogEvents(HWND hParent, const std::wstring& videoPath, + std::wstring* pTrimmedPath, winrt::TimeSpan* pTrimStart, + winrt::TimeSpan* pTrimEnd, bool* pShouldTrim) + : m_cRef(1), m_hParent(hParent), m_videoPath(videoPath), + m_pTrimmedPath(pTrimmedPath), m_pTrimStart(pTrimStart), + m_pTrimEnd(pTrimEnd), m_pShouldTrim(pShouldTrim), m_bIconSet(false) + { + } + + // IUnknown + IFACEMETHODIMP QueryInterface(REFIID riid, void** ppv) + { + static const QITAB qit[] = { + QITABENT(CTrimFileDialogEvents, IFileDialogEvents), + QITABENT(CTrimFileDialogEvents, IFileDialogControlEvents), + { 0 }, + }; + return QISearch(this, qit, riid, ppv); + } + + IFACEMETHODIMP_(ULONG) AddRef() + { + return InterlockedIncrement(&m_cRef); + } + + IFACEMETHODIMP_(ULONG) Release() + { + long cRef = InterlockedDecrement(&m_cRef); + if (!cRef) + delete this; + return cRef; + } + + // IFileDialogEvents + IFACEMETHODIMP OnFileOk(IFileDialog*) { return S_OK; } + + IFACEMETHODIMP OnFolderChange(IFileDialog* pfd) + { + // Set the ZoomIt icon on the save dialog (only once) + if (!m_bIconSet) + { + m_bIconSet = true; + wil::com_ptr pOleWnd; + if (SUCCEEDED(pfd->QueryInterface(IID_PPV_ARGS(&pOleWnd)))) + { + HWND hDlg = nullptr; + if (SUCCEEDED(pOleWnd->GetWindow(&hDlg)) && hDlg) + { + HICON hIcon = LoadIcon(g_hInstance, L"APPICON"); + if (hIcon) + { + SendMessage(hDlg, WM_SETICON, ICON_BIG, reinterpret_cast(hIcon)); + SendMessage(hDlg, WM_SETICON, ICON_SMALL, reinterpret_cast(hIcon)); + } + + // Make dialog appear in taskbar + LONG_PTR exStyle = GetWindowLongPtr(hDlg, GWL_EXSTYLE); + SetWindowLongPtr(hDlg, GWL_EXSTYLE, exStyle | WS_EX_APPWINDOW); + } + } + } + return S_OK; + } + + IFACEMETHODIMP OnFolderChanging(IFileDialog*, IShellItem*) { return S_OK; } + IFACEMETHODIMP OnSelectionChange(IFileDialog*) { return S_OK; } + IFACEMETHODIMP OnShareViolation(IFileDialog*, IShellItem*, FDE_SHAREVIOLATION_RESPONSE*) { return S_OK; } + IFACEMETHODIMP OnTypeChange(IFileDialog*) { return S_OK; } + IFACEMETHODIMP OnOverwrite(IFileDialog*, IShellItem*, FDE_OVERWRITE_RESPONSE*) { return S_OK; } + + // IFileDialogControlEvents + IFACEMETHODIMP OnItemSelected(IFileDialogCustomize*, DWORD, DWORD) { return S_OK; } + IFACEMETHODIMP OnCheckButtonToggled(IFileDialogCustomize*, DWORD, BOOL) { return S_OK; } + IFACEMETHODIMP OnControlActivating(IFileDialogCustomize*, DWORD) { return S_OK; } + + IFACEMETHODIMP OnButtonClicked(IFileDialogCustomize* pfdc, DWORD dwIDCtl) + { + if (dwIDCtl == 2000) // Trim button ID + { + try + { + // Get the file dialog's window handle to make trim dialog modal to it + wil::com_ptr pfd; + HWND hFileDlg = nullptr; + if (SUCCEEDED(pfdc->QueryInterface(IID_PPV_ARGS(&pfd)))) + { + wil::com_ptr pOleWnd; + if (SUCCEEDED(pfd->QueryInterface(IID_PPV_ARGS(&pOleWnd)))) + { + pOleWnd->GetWindow(&hFileDlg); + } + } + + // Use file dialog window as parent if found + HWND hParent = hFileDlg ? hFileDlg : m_hParent; + + auto trimResult = VideoRecordingSession::ShowTrimDialog(hParent, m_videoPath, *m_pTrimStart, *m_pTrimEnd); + if (trimResult == IDOK) + { + *m_pShouldTrim = true; + } + else if( trimResult == IDCANCEL ) + { + // Cancel should reset to the default selection (fresh state) and + // disable trimming for the eventual save. + *m_pTrimStart = winrt::TimeSpan{ 0 }; + *m_pTrimEnd = winrt::TimeSpan{ 0 }; + *m_pShouldTrim = false; + } + } + catch (const std::exception& e) + { + (void)e; + } + catch (...) + { + } + } + return S_OK; + } +}; + +//---------------------------------------------------------------------------- +// +// VideoRecordingSession::ShowSaveDialogWithTrim +// +// Main entry point for trim+save workflow +// +//---------------------------------------------------------------------------- +std::wstring VideoRecordingSession::ShowSaveDialogWithTrim( + HWND hParent, + const std::wstring& suggestedFileName, + const std::wstring& originalVideoPath, + std::wstring& trimmedVideoPath) +{ + trimmedVideoPath.clear(); + + const bool isGif = IsGifPath(originalVideoPath); + + std::wstring videoPathToSave = originalVideoPath; + winrt::TimeSpan trimStart{ 0 }; + winrt::TimeSpan trimEnd{ 0 }; + bool shouldTrim = false; + + // Create save dialog with custom Trim button + auto saveDialog = wil::CoCreateInstance<::IFileSaveDialog>(CLSID_FileSaveDialog); + + FILEOPENDIALOGOPTIONS options; + if (SUCCEEDED(saveDialog->GetOptions(&options))) + saveDialog->SetOptions(options | FOS_FORCEFILESYSTEM); + + wil::com_ptr<::IShellItem> videosItem; + if (SUCCEEDED(SHGetKnownFolderItem(FOLDERID_Videos, KF_FLAG_DEFAULT, nullptr, + IID_IShellItem, (void**)videosItem.put()))) + saveDialog->SetDefaultFolder(videosItem.get()); + + if (isGif) + { + saveDialog->SetDefaultExtension(L".gif"); + COMDLG_FILTERSPEC fileTypes[] = { + { L"GIF Animation", L"*.gif" } + }; + saveDialog->SetFileTypes(_countof(fileTypes), fileTypes); + } + else + { + saveDialog->SetDefaultExtension(L".mp4"); + COMDLG_FILTERSPEC fileTypes[] = { + { L"MP4 Video", L"*.mp4" } + }; + saveDialog->SetFileTypes(_countof(fileTypes), fileTypes); + } + saveDialog->SetFileName(suggestedFileName.c_str()); + saveDialog->SetTitle(L"ZoomIt: Save Video As..."); + + // Add custom Trim button + wil::com_ptr pfdCustomize; + if (SUCCEEDED(saveDialog->QueryInterface(IID_PPV_ARGS(&pfdCustomize)))) + { + pfdCustomize->AddPushButton(2000, L"Trim..."); + } + + // Set up event handler + CTrimFileDialogEvents* pEvents = new CTrimFileDialogEvents(hParent, originalVideoPath, + &trimmedVideoPath, &trimStart, &trimEnd, &shouldTrim); + DWORD dwCookie; + saveDialog->Advise(pEvents, &dwCookie); + + HRESULT hr = saveDialog->Show(hParent); + + saveDialog->Unadvise(dwCookie); + pEvents->Release(); + + if (FAILED(hr)) + { + return std::wstring(); // User cancelled save dialog + } + + // If user clicked Trim button and confirmed, perform the trim + if (shouldTrim) + { + try + { + auto trimOp = isGif ? TrimGifAsync(originalVideoPath, trimStart, trimEnd) + : TrimVideoAsync(originalVideoPath, trimStart, trimEnd); + + // Pump messages while waiting for async operation + while (trimOp.Status() == winrt::AsyncStatus::Started) + { + MSG msg; + while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) + { + TranslateMessage(&msg); + DispatchMessage(&msg); + } + Sleep(10); + } + + auto trimmedPath = std::wstring(trimOp.GetResults()); + + if (trimmedPath.empty()) + { + MessageBox(hParent, L"Failed to trim video", L"Error", MB_OK | MB_ICONERROR); + return std::wstring(); + } + + trimmedVideoPath = trimmedPath; + videoPathToSave = trimmedPath; + } + catch (...) + { + MessageBox(hParent, L"Failed to trim video", L"Error", MB_OK | MB_ICONERROR); + return std::wstring(); + } + } + + wil::com_ptr<::IShellItem> item; + THROW_IF_FAILED(saveDialog->GetResult(item.put())); + + wil::unique_cotaskmem_string filePath; + THROW_IF_FAILED(item->GetDisplayName(SIGDN_FILESYSPATH, filePath.put())); + + return std::wstring(filePath.get()); +} + +//---------------------------------------------------------------------------- +// +// VideoRecordingSession::ShowTrimDialog +// +// Shows the trim UI dialog +// +//---------------------------------------------------------------------------- +INT_PTR VideoRecordingSession::ShowTrimDialog( + HWND hParent, + const std::wstring& videoPath, + winrt::TimeSpan& trimStart, + winrt::TimeSpan& trimEnd) +{ + std::promise resultPromise; + auto resultFuture = resultPromise.get_future(); + + std::thread staThread([hParent, videoPath, &trimStart, &trimEnd, promise = std::move(resultPromise)]() mutable + { + bool coInitialized = false; + try + { + winrt::init_apartment(winrt::apartment_type::single_threaded); + } + catch (const winrt::hresult_error&) + { + HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + if (SUCCEEDED(hr)) + { + coInitialized = true; + } + } + + try + { + INT_PTR dlgResult = ShowTrimDialogInternal(hParent, videoPath, trimStart, trimEnd); + promise.set_value(dlgResult); + } + catch (const winrt::hresult_error& e) + { + (void)e; + promise.set_exception(std::current_exception()); + } + catch (const std::exception& e) + { + (void)e; + promise.set_exception(std::current_exception()); + } + catch (...) + { + promise.set_exception(std::current_exception()); + } + + if (coInitialized) + { + CoUninitialize(); + } + }); + + bool quitReceived = false; + while (!quitReceived && resultFuture.wait_for(std::chrono::milliseconds(20)) != std::future_status::ready) + { + MSG msg; + while (PeekMessage(&msg, nullptr, 0, 0, PM_REMOVE)) + { + if (msg.message == WM_QUIT) + { + // WM_QUIT must be reposted so the main application loop can exit cleanly. + quitReceived = true; + } + TranslateMessage(&msg); + DispatchMessage(&msg); + } + } + + // Repost WM_QUIT after waiting for the dialog thread to finish, so the main loop can handle it. + if (quitReceived && hDlgTrimDialog != nullptr) + { + EndDialog(hDlgTrimDialog, IDCANCEL); + PostQuitMessage(0); + } + + INT_PTR dialogResult = quitReceived ? IDCANCEL : resultFuture.get(); + if (staThread.joinable()) + { + staThread.join(); + } + return dialogResult; +} + +INT_PTR VideoRecordingSession::ShowTrimDialogInternal( + HWND hParent, + const std::wstring& videoPath, + winrt::TimeSpan& trimStart, + winrt::TimeSpan& trimEnd) +{ + TrimDialogData data; + data.videoPath = videoPath; + // Initialize from the caller so reopening the trim dialog can preserve prior work. + data.trimStart = trimStart; + data.trimEnd = trimEnd; + data.isGif = IsGifPath(videoPath); + + if (data.isGif) + { + if (!LoadGifFrames(videoPath, &data)) + { + MessageBox(hParent, L"Unable to load the GIF for trimming. The file may be locked or unreadable.", L"Error", MB_OK | MB_ICONERROR); + return IDCANCEL; + } + } + else + { + // Get video duration - use simple file size estimation to avoid blocking + // The actual trim operation will handle the real duration + WIN32_FILE_ATTRIBUTE_DATA fileInfo; + if (GetFileAttributesEx(videoPath.c_str(), GetFileExInfoStandard, &fileInfo)) + { + ULARGE_INTEGER fileSize; + fileSize.LowPart = fileInfo.nFileSizeLow; + fileSize.HighPart = fileInfo.nFileSizeHigh; + + // Estimate: ~10MB per minute for typical 1080p recording + // Duration in 100-nanosecond units (10,000,000 = 1 second) + int64_t estimatedSeconds = fileSize.QuadPart / (10 * 1024 * 1024 / 60); + if (estimatedSeconds < 1) estimatedSeconds = 10; // minimum 10 seconds + if (estimatedSeconds > 3600) estimatedSeconds = 3600; // max 1 hour + + data.videoDuration = winrt::TimeSpan{ estimatedSeconds * 10000000LL }; + if( data.trimEnd.count() <= 0 ) + { + data.trimEnd = data.videoDuration; + } + } + else + { + // Default to 60 seconds if we can't get file size + data.videoDuration = winrt::TimeSpan{ 600000000LL }; + if( data.trimEnd.count() <= 0 ) + { + data.trimEnd = data.videoDuration; + } + } + } + + // Clamp incoming selection to valid bounds now that duration is known. + if( data.videoDuration.count() > 0 ) + { + const int64_t durationTicks = data.videoDuration.count(); + const int64_t endTicks = (data.trimEnd.count() > 0) ? data.trimEnd.count() : durationTicks; + const int64_t clampedEnd = std::clamp( endTicks, 0, durationTicks ); + const int64_t clampedStart = std::clamp( data.trimStart.count(), 0, clampedEnd ); + data.trimStart = winrt::TimeSpan{ clampedStart }; + data.trimEnd = winrt::TimeSpan{ clampedEnd }; + } + + // Track initial selection so we can enable OK only when trimming changes. + data.originalTrimStart = data.trimStart; + data.originalTrimEnd = data.trimEnd; + data.currentPosition = data.trimStart; + data.playbackStartPosition = data.currentPosition; + data.playbackStartPositionValid = true; + + // Center dialog on the screen containing the parent window + HMONITOR hMonitor = MonitorFromWindow(hParent, MONITOR_DEFAULTTONEAREST); + MONITORINFO mi = { sizeof(mi) }; + GetMonitorInfo(hMonitor, &mi); + + // Calculate center position + const int dialogWidth = 521; + const int dialogHeight = 381; + int x = mi.rcWork.left + (mi.rcWork.right - mi.rcWork.left - dialogWidth) / 2; + int y = mi.rcWork.top + (mi.rcWork.bottom - mi.rcWork.top - dialogHeight) / 2; + + // Store position for use in dialog proc + data.dialogX = x; + data.dialogY = y; + + // Pre-load the first frame preview before showing the dialog to avoid "Preview not available" flash + // Must run on a background thread because WinRT async .get() cannot be called on STA (UI) thread + if (!data.isGif) + { + std::thread preloadThread([&data, &videoPath]() + { + winrt::init_apartment(winrt::apartment_type::multi_threaded); + try + { + auto file = winrt::StorageFile::GetFileFromPathAsync(videoPath).get(); + auto clip = winrt::MediaClip::CreateFromFileAsync(file).get(); + + data.composition = winrt::MediaComposition(); + data.composition.Clips().Append(clip); + + // Update to actual duration from clip + auto actualDuration = clip.OriginalDuration(); + if (actualDuration.count() > 0) + { + // If trimEnd was at full length (whether estimated or passed in), snap it to the actual end. + // This handles cases where the file-size estimate was longer or shorter than actual. + const int64_t oldDurationTicks = data.videoDuration.count(); + const int64_t oldTrimEndTicks = data.trimEnd.count(); + const bool endWasFullLength = (oldTrimEndTicks <= 0) || + (oldDurationTicks > 0 && oldTrimEndTicks >= oldDurationTicks); + + data.videoDuration = actualDuration; + + const int64_t newTrimEndTicks = endWasFullLength ? actualDuration.count() + : (std::min)(oldTrimEndTicks, actualDuration.count()); + data.trimEnd = winrt::TimeSpan{ newTrimEndTicks }; + + const int64_t oldOrigEndTicks = data.originalTrimEnd.count(); + const bool origEndWasFullLength = (oldOrigEndTicks <= 0) || + (oldDurationTicks > 0 && oldOrigEndTicks >= oldDurationTicks); + const int64_t newOrigEndTicks = origEndWasFullLength ? actualDuration.count() + : (std::min)(oldOrigEndTicks, actualDuration.count()); + data.originalTrimEnd = winrt::TimeSpan{ newOrigEndTicks }; + } + + // Get first frame thumbnail + const int64_t requestTicks = std::clamp(data.currentPosition.count(), 0, data.videoDuration.count()); + auto stream = data.composition.GetThumbnailAsync( + winrt::TimeSpan{ requestTicks }, + 0, 0, + winrt::VideoFramePrecision::NearestFrame).get(); + + if (stream) + { + winrt::com_ptr wicFactory; + if (SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(wicFactory.put())))) + { + winrt::com_ptr istream; + auto streamAsUnknown = static_cast<::IUnknown*>(winrt::get_abi(stream)); + if (SUCCEEDED(CreateStreamOverRandomAccessStream(streamAsUnknown, IID_PPV_ARGS(istream.put()))) && istream) + { + winrt::com_ptr decoder; + if (SUCCEEDED(wicFactory->CreateDecoderFromStream(istream.get(), nullptr, WICDecodeMetadataCacheOnDemand, decoder.put()))) + { + winrt::com_ptr frame; + if (SUCCEEDED(decoder->GetFrame(0, frame.put()))) + { + winrt::com_ptr converter; + if (SUCCEEDED(wicFactory->CreateFormatConverter(converter.put()))) + { + if (SUCCEEDED(converter->Initialize(frame.get(), GUID_WICPixelFormat32bppBGRA, + WICBitmapDitherTypeNone, nullptr, 0.0, + WICBitmapPaletteTypeCustom))) + { + UINT width, height; + converter->GetSize(&width, &height); + + BITMAPINFO bmi = {}; + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = width; + bmi.bmiHeader.biHeight = -static_cast(height); + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + + void* bits = nullptr; + HDC hdcScreen = GetDC(nullptr); + HBITMAP hBitmap = CreateDIBSection(hdcScreen, &bmi, DIB_RGB_COLORS, &bits, nullptr, 0); + ReleaseDC(nullptr, hdcScreen); + + if (hBitmap && bits) + { + converter->CopyPixels(nullptr, width * 4, width * height * 4, static_cast(bits)); + data.hPreviewBitmap = hBitmap; + data.previewBitmapOwned = true; + data.lastRenderedPreview.store(requestTicks, std::memory_order_relaxed); + } + } + } + } + } + } + } + } + } + catch (...) + { + // If preloading fails, the dialog will show "Preview not available" briefly + // but will recover via the async UpdateVideoPreview path + } + }); + preloadThread.join(); + } + + auto result = DialogBoxParam( + GetModuleHandle(nullptr), + MAKEINTRESOURCE(IDD_VIDEO_TRIM), + hParent, + TrimDialogProc, + reinterpret_cast(&data)); + + if (result == IDOK) + { + trimStart = data.trimStart; + trimEnd = data.trimEnd; + } + + return result; +} + +static void UpdatePositionUI(HWND hDlg, VideoRecordingSession::TrimDialogData* pData, bool invalidateTimeline = true) +{ + if (!pData || !hDlg) + { + return; + } + + const auto previewTime = pData->previewOverrideActive ? pData->previewOverride : pData->currentPosition; + // Show time relative to left grip (trimStart) + const auto relativeTime = winrt::TimeSpan{ (std::max)(previewTime.count() - pData->trimStart.count(), int64_t{ 0 }) }; + SetTimeText(hDlg, IDC_TRIM_POSITION_LABEL, relativeTime, true); + if (invalidateTimeline) + { + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_TIMELINE), nullptr, FALSE); + } +} + +static void SyncMediaPlayerPosition(VideoRecordingSession::TrimDialogData* pData) +{ + if (!pData || !pData->mediaPlayer) + { + return; + } + + try + { + auto session = pData->mediaPlayer.PlaybackSession(); + if (session) + { + // The selection (trimStart..trimEnd) determines what will be trimmed, + // but playback may start before trimStart. Clamp only to valid media bounds. + const int64_t upper = (pData->trimEnd.count() > 0) ? pData->trimEnd.count() : pData->videoDuration.count(); + const int64_t clampedTicks = std::clamp(pData->currentPosition.count(), 0, upper); + session.Position(winrt::TimeSpan{ clampedTicks }); + } + } + catch (...) + { + } +} + +static void CleanupMediaPlayer(VideoRecordingSession::TrimDialogData* pData) +{ + if (!pData || !pData->mediaPlayer) + { + return; + } + + try + { + auto session = pData->mediaPlayer.PlaybackSession(); + if (session) + { + if (pData->positionChangedToken.value) + { + session.PositionChanged(pData->positionChangedToken); + pData->positionChangedToken = {}; + } + if (pData->stateChangedToken.value) + { + session.PlaybackStateChanged(pData->stateChangedToken); + pData->stateChangedToken = {}; + } + } + + if (pData->frameAvailableToken.value) + { + pData->mediaPlayer.VideoFrameAvailable(pData->frameAvailableToken); + pData->frameAvailableToken = {}; + } + + pData->mediaPlayer.Close(); + } + catch (...) + { + } + + pData->mediaPlayer = nullptr; + pData->frameCopyInProgress.store(false, std::memory_order_relaxed); +} + +//---------------------------------------------------------------------------- +// +// Helper: Update video frame preview +// +//---------------------------------------------------------------------------- +static void UpdateVideoPreview(HWND hDlg, VideoRecordingSession::TrimDialogData* pData, bool invalidateTimeline = true) +{ + if (!pData) + { + return; + } + + const auto previewTime = pData->previewOverrideActive ? pData->previewOverride : pData->currentPosition; + + // Update position label and timeline + UpdatePositionUI(hDlg, pData, invalidateTimeline); + + // When playing with the frame server, frames arrive via VideoFrameAvailable; avoid extra thumbnails. + if (pData->isPlaying.load(std::memory_order_relaxed) && pData->mediaPlayer) + { + return; + } + + const int64_t requestTicks = previewTime.count(); + pData->latestPreviewRequest.store(requestTicks, std::memory_order_relaxed); + + if (pData->loadingPreview.exchange(true)) + { + // A preview request is already running; we'll schedule the latest once it completes. + return; + } + + if (pData->isGif) + { + // Use request time directly (don't clamp to trim bounds) so thumbnail updates outside trim region + const int64_t clampedTicks = std::clamp(requestTicks, 0, pData->videoDuration.count()); + if (!pData->gifFrames.empty()) + { + const size_t frameIndex = FindGifFrameIndex(pData->gifFrames, clampedTicks); + pData->gifLastFrameIndex = frameIndex; + { + std::lock_guard lock(pData->previewBitmapMutex); + if (pData->hPreviewBitmap && pData->previewBitmapOwned) + { + DeleteObject(pData->hPreviewBitmap); + } + pData->hPreviewBitmap = pData->gifFrames[frameIndex].hBitmap; + pData->previewBitmapOwned = false; + } + + pData->lastRenderedPreview.store(clampedTicks, std::memory_order_relaxed); + pData->loadingPreview.store(false, std::memory_order_relaxed); + + if (hDlg) + { + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_PREVIEW), nullptr, FALSE); + } + return; + } + + pData->loadingPreview.store(false, std::memory_order_relaxed); + return; + } + + std::thread([](HWND hDlg, VideoRecordingSession::TrimDialogData* pData, int64_t requestTicks) + { + winrt::init_apartment(winrt::apartment_type::multi_threaded); + + const int64_t requestTicksRaw = requestTicks; + bool updatedBitmap = false; + + bool durationChanged = false; + + try + { + if (!pData->composition) + { + auto file = winrt::StorageFile::GetFileFromPathAsync(pData->videoPath).get(); + auto clip = winrt::MediaClip::CreateFromFileAsync(file).get(); + + pData->composition = winrt::MediaComposition(); + pData->composition.Clips().Append(clip); + + auto actualDuration = clip.OriginalDuration(); + if (actualDuration.count() > 0) + { + const int64_t oldDurationTicks = pData->videoDuration.count(); + if (oldDurationTicks != actualDuration.count()) + { + durationChanged = true; + } + + // Update duration, but preserve a user-chosen trim end. + // If the trim end was "full length" (old duration or 0), keep it full length. + pData->videoDuration = actualDuration; + + const int64_t oldTrimEndTicks = pData->trimEnd.count(); + const bool endWasFullLength = (oldTrimEndTicks <= 0) || (oldDurationTicks > 0 && oldTrimEndTicks >= oldDurationTicks); + const int64_t newTrimEndTicks = endWasFullLength ? actualDuration.count() + : (std::min)(oldTrimEndTicks, actualDuration.count()); + pData->trimEnd = winrt::TimeSpan{ newTrimEndTicks }; + + const int64_t oldOrigEndTicks = pData->originalTrimEnd.count(); + const bool origEndWasFullLength = (oldOrigEndTicks <= 0) || (oldDurationTicks > 0 && oldOrigEndTicks >= oldDurationTicks); + const int64_t newOrigEndTicks = origEndWasFullLength ? actualDuration.count() + : (std::min)(oldOrigEndTicks, actualDuration.count()); + pData->originalTrimEnd = winrt::TimeSpan{ newOrigEndTicks }; + + // Clamp starts to the new end. + if (pData->originalTrimStart.count() > pData->originalTrimEnd.count()) + { + pData->originalTrimStart = pData->originalTrimEnd; + } + if (pData->trimStart.count() > pData->trimEnd.count()) + { + pData->trimStart = pData->trimEnd; + } + } + } + + auto composition = pData->composition; + if (composition) + { + auto durationTicks = composition.Duration().count(); + if (durationTicks > 0) + { + requestTicks = std::clamp(requestTicks, 0, durationTicks); + } + + const bool isPlaying = pData->isPlaying.load(std::memory_order_relaxed); + const UINT32 reqW = isPlaying ? kPreviewRequestWidthPlaying : 0; + const UINT32 reqH = isPlaying ? kPreviewRequestHeightPlaying : 0; + + auto stream = composition.GetThumbnailAsync( + winrt::TimeSpan{ requestTicks }, + reqW, + reqH, + winrt::VideoFramePrecision::NearestFrame).get(); + + if (stream) + { + winrt::com_ptr wicFactory; + if (SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(wicFactory.put())))) + { + winrt::com_ptr istream; + auto streamAsUnknown = static_cast<::IUnknown*>(winrt::get_abi(stream)); + if (SUCCEEDED(CreateStreamOverRandomAccessStream(streamAsUnknown, IID_PPV_ARGS(istream.put()))) && istream) + { + winrt::com_ptr decoder; + if (SUCCEEDED(wicFactory->CreateDecoderFromStream(istream.get(), nullptr, WICDecodeMetadataCacheOnDemand, decoder.put()))) + { + winrt::com_ptr frame; + if (SUCCEEDED(decoder->GetFrame(0, frame.put()))) + { + winrt::com_ptr converter; + if (SUCCEEDED(wicFactory->CreateFormatConverter(converter.put()))) + { + if (SUCCEEDED(converter->Initialize(frame.get(), GUID_WICPixelFormat32bppBGRA, + WICBitmapDitherTypeNone, nullptr, 0.0, + WICBitmapPaletteTypeCustom))) + { + UINT width, height; + converter->GetSize(&width, &height); + + BITMAPINFO bmi = {}; + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = width; + bmi.bmiHeader.biHeight = -static_cast(height); + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + + void* bits = nullptr; + HDC hdcScreen = GetDC(nullptr); + HBITMAP hBitmap = CreateDIBSection(hdcScreen, &bmi, DIB_RGB_COLORS, &bits, nullptr, 0); + ReleaseDC(nullptr, hdcScreen); + + if (hBitmap && bits) + { + converter->CopyPixels(nullptr, width * 4, width * height * 4, static_cast(bits)); + + { + std::lock_guard lock(pData->previewBitmapMutex); + if (pData->hPreviewBitmap && pData->previewBitmapOwned) + { + DeleteObject(pData->hPreviewBitmap); + } + pData->hPreviewBitmap = hBitmap; + pData->previewBitmapOwned = true; + } + updatedBitmap = true; + } + } + } + } + } + } + } + } + } + } + catch (...) + { + } + + pData->loadingPreview.store(false, std::memory_order_relaxed); + + if (updatedBitmap) + { + pData->lastRenderedPreview.store(requestTicks, std::memory_order_relaxed); + PostMessage(hDlg, WMU_PREVIEW_READY, 0, 0); + } + + if (pData->latestPreviewRequest.load(std::memory_order_relaxed) != requestTicksRaw) + { + PostMessage(hDlg, WMU_PREVIEW_SCHEDULED, 0, 0); + } + + if (durationChanged) + { + PostMessage(hDlg, WMU_DURATION_CHANGED, 0, 0); + } + }, hDlg, pData, requestTicks).detach(); +} + +//---------------------------------------------------------------------------- +// +// Helper: Draw custom timeline with handles +// +//---------------------------------------------------------------------------- +static void DrawTimeline(HDC hdc, RECT rc, VideoRecordingSession::TrimDialogData* pData, UINT dpi) +{ + const int width = rc.right - rc.left; + const int height = rc.bottom - rc.top; + + // Scale constants for DPI + const int timelinePadding = ScaleForDpi(kTimelinePadding, dpi); + const int timelineTrackHeight = ScaleForDpi(kTimelineTrackHeight, dpi); + const int timelineTrackTopOffset = ScaleForDpi(kTimelineTrackTopOffset, dpi); + const int timelineHandleHalfWidth = ScaleForDpi(kTimelineHandleHalfWidth, dpi); + const int timelineHandleHeight = ScaleForDpi(kTimelineHandleHeight, dpi); + + // Create memory DC for double buffering + HDC hdcMem = CreateCompatibleDC(hdc); + HBITMAP hbmMem = CreateCompatibleBitmap(hdc, width, height); + HBITMAP hbmOld = static_cast(SelectObject(hdcMem, hbmMem)); + + // Draw to memory DC - use dark mode colors if enabled + const bool darkMode = IsDarkModeEnabled(); + HBRUSH hBackground = CreateSolidBrush(darkMode ? DarkMode::BackgroundColor : GetSysColor(COLOR_BTNFACE)); + RECT rcMem = { 0, 0, width, height }; + FillRect(hdcMem, &rcMem, hBackground); + DeleteObject(hBackground); + + const int trackLeft = timelinePadding; + const int trackRight = width - timelinePadding; + const int trackTop = timelineTrackTopOffset; + const int trackBottom = trackTop + timelineTrackHeight; + + RECT rcTrack = { trackLeft, trackTop, trackRight, trackBottom }; + HBRUSH hTrackBase = CreateSolidBrush(darkMode ? RGB(60, 60, 65) : RGB(214, 219, 224)); + FillRect(hdcMem, &rcTrack, hTrackBase); + DeleteObject(hTrackBase); + + int startX = std::clamp(TimelineTimeToClientX(pData, pData->trimStart, width, dpi), trackLeft, trackRight); + int endX = std::clamp(TimelineTimeToClientX(pData, pData->trimEnd, width, dpi), trackLeft, trackRight); + if (endX < startX) + { + std::swap(startX, endX); + } + + RECT rcBefore{ trackLeft, trackTop, startX, trackBottom }; + RECT rcAfter{ endX, trackTop, trackRight, trackBottom }; + HBRUSH hMuted = CreateSolidBrush(darkMode ? RGB(50, 50, 55) : RGB(198, 202, 206)); + FillRect(hdcMem, &rcBefore, hMuted); + FillRect(hdcMem, &rcAfter, hMuted); + DeleteObject(hMuted); + + RECT rcActive{ startX, trackTop, endX, trackBottom }; + HBRUSH hActive = CreateSolidBrush(RGB(90, 147, 250)); + FillRect(hdcMem, &rcActive, hActive); + DeleteObject(hActive); + + HPEN hOutline = CreatePen(PS_SOLID, 1, darkMode ? RGB(80, 80, 85) : RGB(150, 150, 150)); + HPEN hOldPen = static_cast(SelectObject(hdcMem, hOutline)); + MoveToEx(hdcMem, trackLeft, trackTop, nullptr); + LineTo(hdcMem, trackRight, trackTop); + LineTo(hdcMem, trackRight, trackBottom); + LineTo(hdcMem, trackLeft, trackBottom); + LineTo(hdcMem, trackLeft, trackTop); + SelectObject(hdcMem, hOldPen); + DeleteObject(hOutline); + + const int trackWidth = trackRight - trackLeft; + if (trackWidth > 0 && pData && pData->videoDuration.count() > 0) + { + const int tickTop = trackBottom + ScaleForDpi(2, dpi); + const int tickMajorBottom = tickTop + ScaleForDpi(10, dpi); + const int tickMinorBottom = tickTop + ScaleForDpi(6, dpi); + + const std::array fractions{ 0.0, 0.25, 0.5, 0.75, 1.0 }; + HPEN hTickPen = CreatePen(PS_SOLID, 1, darkMode ? RGB(100, 100, 105) : RGB(150, 150, 150)); + HPEN hOldTickPen = static_cast(SelectObject(hdcMem, hTickPen)); + SetBkMode(hdcMem, TRANSPARENT); + SetTextColor(hdcMem, darkMode ? RGB(140, 140, 140) : RGB(80, 80, 80)); + + // Use consistent font for all timeline text - scale for DPI (12pt) + const int fontSize = -MulDiv(12, static_cast(dpi), USER_DEFAULT_SCREEN_DPI); + HFONT hTimelineFont = CreateFont(fontSize, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, + OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, + DEFAULT_PITCH | FF_SWISS, L"Segoe UI"); + HFONT hOldTimelineFont = static_cast(SelectObject(hdcMem, hTimelineFont)); + + for (size_t i = 0; i < fractions.size(); ++i) + { + const double fraction = fractions[i]; + const int x = trackLeft + static_cast(std::round(fraction * trackWidth)); + const bool isMajor = (fraction == 0.0) || (fraction == 0.5) || (fraction == 1.0); + MoveToEx(hdcMem, x, tickTop, nullptr); + LineTo(hdcMem, x, isMajor ? tickMajorBottom : tickMinorBottom); + + if (fraction > 0.0 && fraction < 1.0) + { + // Calculate marker time within the full video duration (untrimmed) + const auto markerTime = winrt::TimeSpan{ static_cast(fraction * pData->videoDuration.count()) }; + // For short videos (under 60 seconds), show fractional seconds to distinguish markers + const bool showMilliseconds = (pData->videoDuration.count() < 600000000LL); // 60 seconds in 100ns ticks + const std::wstring markerText = FormatTrimTime(markerTime, showMilliseconds); + const int markerHalfWidth = ScaleForDpi(showMilliseconds ? 45 : 35, dpi); + const int markerHeight = ScaleForDpi(26, dpi); + RECT rcMarker{ x - markerHalfWidth, tickMajorBottom + ScaleForDpi(10, dpi), x + markerHalfWidth, tickMajorBottom + ScaleForDpi(2, dpi) + markerHeight }; + DrawText(hdcMem, markerText.c_str(), -1, &rcMarker, DT_CENTER | DT_TOP | DT_SINGLELINE | DT_NOPREFIX); + } + } + + SelectObject(hdcMem, hOldTimelineFont); + DeleteObject(hTimelineFont); + SelectObject(hdcMem, hOldTickPen); + DeleteObject(hTickPen); + } + + auto drawGripper = [&](int x) + { + RECT handleRect{ + x - timelineHandleHalfWidth, + trackTop - (timelineHandleHeight - timelineTrackHeight) / 2, + x + timelineHandleHalfWidth, + trackTop - (timelineHandleHeight - timelineTrackHeight) / 2 + timelineHandleHeight + }; + + const COLORREF fillColor = darkMode ? RGB(165, 165, 165) : RGB(200, 200, 200); + const COLORREF lineColor = darkMode ? RGB(90, 90, 90) : RGB(120, 120, 120); + const int cornerRadius = (std::max)(ScaleForDpi(6, dpi), timelineHandleHalfWidth); + const int lineInset = ScaleForDpi(6, dpi); + const int lineWidth = (std::max)(1, ScaleForDpi(2, dpi)); + + HBRUSH hFill = CreateSolidBrush(fillColor); + HPEN hNullPen = static_cast(SelectObject(hdcMem, GetStockObject(NULL_PEN))); + HBRUSH hOldBrush2 = static_cast(SelectObject(hdcMem, hFill)); + RoundRect(hdcMem, handleRect.left, handleRect.top, handleRect.right, handleRect.bottom, cornerRadius, cornerRadius); + SelectObject(hdcMem, hOldBrush2); + SelectObject(hdcMem, hNullPen); + DeleteObject(hFill); + + // Dark vertical line in the middle. + HPEN hLinePen = CreatePen(PS_SOLID, lineWidth, lineColor); + HPEN hOldLinePen = static_cast(SelectObject(hdcMem, hLinePen)); + const int y1 = handleRect.top + lineInset; + const int y2 = handleRect.bottom - lineInset; + MoveToEx(hdcMem, x, y1, nullptr); + LineTo(hdcMem, x, y2); + SelectObject(hdcMem, hOldLinePen); + DeleteObject(hLinePen); + }; + + drawGripper(startX); + drawGripper(endX); + + const int posX = std::clamp(TimelineTimeToClientX(pData, pData->currentPosition, width, dpi), trackLeft, trackRight); + const int posLineWidth = ScaleForDpi(2, dpi); + const int posLineExtend = ScaleForDpi(12, dpi); + const int posLineBelow = ScaleForDpi(22, dpi); + HPEN hPositionPen = CreatePen(PS_SOLID, posLineWidth, RGB(33, 150, 243)); + hOldPen = static_cast(SelectObject(hdcMem, hPositionPen)); + MoveToEx(hdcMem, posX, trackTop - posLineExtend, nullptr); + LineTo(hdcMem, posX, trackBottom + posLineBelow); + SelectObject(hdcMem, hOldPen); + DeleteObject(hPositionPen); + + const int ellipseRadius = ScaleForDpi(6, dpi); + const int ellipseTop = ScaleForDpi(12, dpi); + const int ellipseBottom = ScaleForDpi(24, dpi); + HBRUSH hPositionBrush = CreateSolidBrush(RGB(33, 150, 243)); + HBRUSH hOldBrush = static_cast(SelectObject(hdcMem, hPositionBrush)); + HPEN hOldPenForEllipse = static_cast(SelectObject(hdcMem, GetStockObject(NULL_PEN))); + Ellipse(hdcMem, posX - ellipseRadius, trackBottom + ellipseTop, posX + ellipseRadius, trackBottom + ellipseBottom); + SelectObject(hdcMem, hOldPenForEllipse); + SelectObject(hdcMem, hOldBrush); + DeleteObject(hPositionBrush); + + // Set font for start/end labels (same font used for tick labels - 12pt) + SetBkMode(hdcMem, TRANSPARENT); + SetTextColor(hdcMem, darkMode ? RGB(140, 140, 140) : RGB(80, 80, 80)); + int labelFontSize = -MulDiv(12, static_cast(dpi), USER_DEFAULT_SCREEN_DPI); + HFONT hFont = CreateFont(labelFontSize, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, + OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, + DEFAULT_PITCH | FF_SWISS, L"Segoe UI"); + HFONT hOldFont = static_cast(SelectObject(hdcMem, hFont)); + + // Align with intermediate marker labels: use same calculation as rcMarker + // tickTop = trackBottom + 10, tickMajorBottom = tickTop + 10, marker starts at tickMajorBottom + 2 + const int tickTopForLabels = trackBottom + ScaleForDpi(10, dpi); + const int tickMajorBottomForLabels = tickTopForLabels + ScaleForDpi(10, dpi); + int labelTop = tickMajorBottomForLabels + ScaleForDpi(2, dpi); + int labelBottom = labelTop + ScaleForDpi(26, dpi); + // For short videos (under 60 seconds), show fractional seconds + const bool showMilliseconds = (pData->videoDuration.count() < 600000000LL); // 60 seconds in 100ns ticks + int labelWidth = ScaleForDpi(showMilliseconds ? 80 : 70, dpi); + // Start label: draw to the right of trackLeft (left-aligned) + RECT rcStartLabel{ trackLeft, labelTop, trackLeft + labelWidth, labelBottom }; + const std::wstring startLabel = FormatTrimTime(pData->trimStart, showMilliseconds); + DrawText(hdcMem, startLabel.c_str(), -1, &rcStartLabel, DT_LEFT | DT_TOP | DT_SINGLELINE); + + // End label: draw to the left of trackRight (right-aligned) + RECT rcEndLabel{ trackRight - labelWidth, labelTop, trackRight, labelBottom }; + const std::wstring endLabel = FormatTrimTime(pData->trimEnd, showMilliseconds); + DrawText(hdcMem, endLabel.c_str(), -1, &rcEndLabel, DT_RIGHT | DT_TOP | DT_SINGLELINE); + + SelectObject(hdcMem, hOldFont); + DeleteObject(hFont); + + // Copy the buffered image to the screen + BitBlt(hdc, rc.left, rc.top, width, height, hdcMem, 0, 0, SRCCOPY); + + // Clean up + SelectObject(hdcMem, hbmOld); + DeleteObject(hbmMem); + DeleteDC(hdcMem); +} + +//---------------------------------------------------------------------------- +// +// Helper: Mouse interaction for the trim timeline +// +//---------------------------------------------------------------------------- +namespace +{ + constexpr UINT_PTR kPlaybackTimerId = 1; + constexpr UINT kPlaybackTimerIntervalMs = 16; // Fallback for GIF; MP4 uses multimedia timer + constexpr int64_t kPlaybackStepTicks = static_cast(kPlaybackTimerIntervalMs) * 10'000; + constexpr UINT WMU_MM_TIMER_TICK = WM_USER + 10; // Posted by multimedia timer callback + constexpr UINT kMMTimerIntervalMs = 8; // 8ms for ~120Hz update rate +} + +// Multimedia timer callback - runs in a separate thread, just posts a message +static void CALLBACK MMTimerCallback(UINT /*uTimerID*/, UINT /*uMsg*/, DWORD_PTR dwUser, DWORD_PTR /*dw1*/, DWORD_PTR /*dw2*/) +{ + HWND hDlg = reinterpret_cast(dwUser); + if (hDlg && IsWindow(hDlg)) + { + PostMessage(hDlg, WMU_MM_TIMER_TICK, 0, 0); + } +} + +static void StopMMTimer(VideoRecordingSession::TrimDialogData* pData) +{ + if (pData && pData->mmTimerId != 0) + { + timeKillEvent(pData->mmTimerId); + pData->mmTimerId = 0; + } +} + +static bool StartMMTimer(HWND hDlg, VideoRecordingSession::TrimDialogData* pData) +{ + if (!pData || !hDlg) + { + return false; + } + + StopMMTimer(pData); + + pData->mmTimerId = timeSetEvent( + kMMTimerIntervalMs, + 1, // 1ms resolution + MMTimerCallback, + reinterpret_cast(hDlg), + TIME_PERIODIC | TIME_KILL_SYNCHRONOUS); + + return pData->mmTimerId != 0; +} + +static void RefreshPlaybackButtons(HWND hDlg) +{ + if (!hDlg) + { + return; + } + + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_SKIP_START), nullptr, FALSE); + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_REWIND), nullptr, FALSE); + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_PLAY_PAUSE), nullptr, FALSE); + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_FORWARD), nullptr, FALSE); + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_SKIP_END), nullptr, FALSE); +} + +static void HandlePlaybackCommand(int controlId, VideoRecordingSession::TrimDialogData* pData) +{ + if (!pData || !pData->hDialog) + { + return; + } + + HWND hDlg = pData->hDialog; + + // Helper lambda to invalidate cached start frame when position changes + auto invalidateCachedFrame = [pData]() + { + std::lock_guard lock(pData->previewBitmapMutex); + if (pData->hCachedStartFrame) + { + DeleteObject(pData->hCachedStartFrame); + pData->hCachedStartFrame = nullptr; + } + }; + + switch (controlId) + { + case IDC_TRIM_PLAY_PAUSE: + if (pData->isPlaying.load(std::memory_order_relaxed)) + { + StopPlayback(hDlg, pData, true); + } + else + { + // Always start playback from current time selector position + pData->playbackStartPosition = pData->currentPosition; + pData->playbackStartPositionValid = true; + invalidateCachedFrame(); + StartPlaybackAsync(hDlg, pData); + } + break; + + case IDC_TRIM_REWIND: + { + StopPlayback(hDlg, pData, false); + // Use 1 second step for timelines < 20 seconds, 2 seconds + const int64_t duration = pData->trimEnd.count() - pData->trimStart.count(); + const int64_t stepTicks = (duration < 200'000'000) ? 10'000'000 : kJogStepTicks; + const int64_t newTicks = (std::max)(pData->trimStart.count(), pData->currentPosition.count() - stepTicks); + pData->currentPosition = winrt::TimeSpan{ newTicks }; + pData->playbackStartPosition = pData->currentPosition; + pData->playbackStartPositionValid = true; + invalidateCachedFrame(); + SyncMediaPlayerPosition(pData); + UpdateVideoPreview(hDlg, pData); + break; + } + + case IDC_TRIM_FORWARD: + { + StopPlayback(hDlg, pData, false); + // Use 1 second step for timelines < 20 seconds, 2 seconds + const int64_t duration = pData->trimEnd.count() - pData->trimStart.count(); + const int64_t stepTicks = (duration < 200'000'000) ? 10'000'000 : kJogStepTicks; + const int64_t newTicks = (std::min)(pData->trimEnd.count(), pData->currentPosition.count() + stepTicks); + pData->currentPosition = winrt::TimeSpan{ newTicks }; + pData->playbackStartPosition = pData->currentPosition; + pData->playbackStartPositionValid = true; + invalidateCachedFrame(); + SyncMediaPlayerPosition(pData); + UpdateVideoPreview(hDlg, pData); + break; + } + + case IDC_TRIM_SKIP_END: + { + StopPlayback(hDlg, pData, false); + pData->currentPosition = pData->trimEnd; + pData->playbackStartPosition = pData->currentPosition; + pData->playbackStartPositionValid = true; + invalidateCachedFrame(); + SyncMediaPlayerPosition(pData); + UpdateVideoPreview(hDlg, pData); + break; + } + + default: + StopPlayback(hDlg, pData, false); + pData->currentPosition = pData->trimStart; + pData->playbackStartPosition = pData->currentPosition; + pData->playbackStartPositionValid = true; + invalidateCachedFrame(); + SyncMediaPlayerPosition(pData); + UpdateVideoPreview(hDlg, pData); + break; + } + + RefreshPlaybackButtons(hDlg); +} + +static void StopPlayback(HWND hDlg, VideoRecordingSession::TrimDialogData* pData, bool capturePosition) +{ + if (!pData) + { + return; + } + + // Invalidate any in-flight StartPlaybackAsync continuation (e.g., after awaiting file load). + pData->playbackCommandSerial.fetch_add(1, std::memory_order_acq_rel); + + const bool wasPlaying = pData->isPlaying.exchange(false, std::memory_order_acq_rel); + ResetSmoothPlayback(pData); + + // Cancel any pending initial seek suppression. + pData->pendingInitialSeek.store(false, std::memory_order_relaxed); + pData->pendingInitialSeekTicks.store(0, std::memory_order_relaxed); + + // Stop audio playback and align media position with UI state, but keep player alive for resume + if (pData->mediaPlayer) + { + try + { + auto session = pData->mediaPlayer.PlaybackSession(); + if (session) + { + if (capturePosition) + { + pData->currentPosition = session.Position(); + } + session.Position(pData->currentPosition); + } + pData->mediaPlayer.Pause(); + } + catch (...) + { + } + } + + if (hDlg) + { + if (wasPlaying) + { + StopMMTimer(pData); // Stop multimedia timer for MP4 + KillTimer(hDlg, kPlaybackTimerId); // Stop regular timer for GIF + } + RefreshPlaybackButtons(hDlg); + } +} + +static winrt::fire_and_forget StartPlaybackAsync(HWND hDlg, VideoRecordingSession::TrimDialogData* pData) +{ + if (!pData || !hDlg) + { + co_return; + } + + if (pData->trimEnd.count() <= pData->trimStart.count()) + { + co_return; + } + + ResetSmoothPlayback(pData); + + // If playhead is at/past selection end, restart from trimStart. + if (pData->currentPosition.count() >= pData->trimEnd.count()) + { + pData->currentPosition = pData->trimStart; + UpdateVideoPreview(hDlg, pData); + } + + // Capture resume position (where playback should start/resume from). + const auto resumePosition = pData->currentPosition; + + // Suppress the brief Position==0 report before the initial seek takes effect. + pData->pendingInitialSeek.store(resumePosition.count() > 0, std::memory_order_relaxed); + pData->pendingInitialSeekTicks.store(resumePosition.count(), std::memory_order_relaxed); + + // Capture loop anchor only if not already set by an explicit user positioning. + // This keeps the loop point stable across pause/resume. + if (!pData->playbackStartPositionValid) + { + pData->playbackStartPosition = resumePosition; + pData->playbackStartPositionValid = true; + } + + // Cache the current preview frame for instant restore when playback stops. + // Only cache if we have a valid preview and it matches the playback start position. + { + std::lock_guard lock(pData->previewBitmapMutex); + // Clear any previous cached frame + if (pData->hCachedStartFrame) + { + DeleteObject(pData->hCachedStartFrame); + pData->hCachedStartFrame = nullptr; + } + // Cache if we have a valid preview at the current position + if (pData->hPreviewBitmap && pData->lastRenderedPreview.load(std::memory_order_relaxed) >= 0) + { + // Duplicate the bitmap so we have our own copy + BITMAP bm{}; + if (GetObject(pData->hPreviewBitmap, sizeof(bm), &bm)) + { + HDC hdcScreen = GetDC(nullptr); + HDC hdcSrc = CreateCompatibleDC(hdcScreen); + HDC hdcDst = CreateCompatibleDC(hdcScreen); + HBITMAP hCopy = CreateCompatibleBitmap(hdcScreen, bm.bmWidth, bm.bmHeight); + if (hCopy) + { + HBITMAP hOldSrc = static_cast(SelectObject(hdcSrc, pData->hPreviewBitmap)); + HBITMAP hOldDst = static_cast(SelectObject(hdcDst, hCopy)); + BitBlt(hdcDst, 0, 0, bm.bmWidth, bm.bmHeight, hdcSrc, 0, 0, SRCCOPY); + SelectObject(hdcSrc, hOldSrc); + SelectObject(hdcDst, hOldDst); + pData->hCachedStartFrame = hCopy; + pData->cachedStartFramePosition = pData->playbackStartPosition; + } + DeleteDC(hdcSrc); + DeleteDC(hdcDst); + ReleaseDC(nullptr, hdcScreen); + } + } + } + +#if _DEBUG + OutputDebugStringW((L"[Trim] StartPlayback: currentPos=" + std::to_wstring(pData->currentPosition.count()) + + L" playbackStartPos=" + std::to_wstring(pData->playbackStartPosition.count()) + + L" trimStart=" + std::to_wstring(pData->trimStart.count()) + + L" trimEnd=" + std::to_wstring(pData->trimEnd.count()) + L"\n").c_str()); +#endif + + bool expected = false; + if (!pData->isPlaying.compare_exchange_strong(expected, true, std::memory_order_relaxed)) + { + co_return; + } + + const uint64_t startSerial = pData->playbackCommandSerial.fetch_add(1, std::memory_order_acq_rel) + 1; + + if (pData->isGif) + { + // Initialize GIF timing so playback begins at the current playhead position + // (not at the start of the containing frame). + auto now = std::chrono::steady_clock::now(); + if (!pData->gifFrames.empty() && pData->videoDuration.count() > 0) + { + const int64_t clampedTicks = std::clamp(resumePosition.count(), 0, pData->videoDuration.count()); + const size_t frameIndex = FindGifFrameIndex(pData->gifFrames, clampedTicks); + const auto& frame = pData->gifFrames[frameIndex]; + const int64_t offsetTicks = std::clamp(clampedTicks - frame.start.count(), 0, frame.duration.count()); + const auto offsetMs = std::chrono::milliseconds(offsetTicks / 10'000); + pData->gifFrameStartTime = now - offsetMs; + } + else + { + pData->gifFrameStartTime = now; + } + + // Update lastPlayheadX to current position so timer ticks can track movement properly + { + HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); + if (hTimeline) + { + RECT rc; + GetClientRect(hTimeline, &rc); + const UINT dpi = GetDpiForWindowHelper(hTimeline); + pData->lastPlayheadX = TimelineTimeToClientX(pData, pData->currentPosition, rc.right - rc.left, dpi); + } + } + + // Use multimedia timer for smooth GIF playback + if (!StartMMTimer(hDlg, pData)) + { + pData->isPlaying.store(false, std::memory_order_relaxed); + RefreshPlaybackButtons(hDlg); + co_return; + } + + PostMessage(hDlg, WMU_PLAYBACK_POSITION, 0, 0); + RefreshPlaybackButtons(hDlg); + co_return; + } + + // If a player already exists (paused), resume from the current playhead position. + if (pData->mediaPlayer) + { + // If the user already canceled playback, do nothing. + if (!pData->isPlaying.load(std::memory_order_acquire) || + pData->playbackCommandSerial.load(std::memory_order_acquire) != startSerial) + { + pData->isPlaying.store(false, std::memory_order_relaxed); + RefreshPlaybackButtons(hDlg); + co_return; + } + + try + { + auto session = pData->mediaPlayer.PlaybackSession(); + if (session) + { + // Resume from the current playhead position (do not change the loop anchor) + const int64_t clampedTicks = std::clamp(resumePosition.count(), 0, pData->trimEnd.count()); + session.Position(winrt::TimeSpan{ clampedTicks }); + pData->currentPosition = winrt::TimeSpan{ clampedTicks }; + // Defer smoothing until the first real media sample to avoid extrapolating from zero + pData->smoothActive.store(false, std::memory_order_relaxed); + pData->smoothHasNonZeroSample.store(false, std::memory_order_relaxed); + } + pData->mediaPlayer.Play(); + } + catch (...) + { + } + + // Use multimedia timer for smooth updates + if (!StartMMTimer(hDlg, pData)) + { + pData->isPlaying.store(false, std::memory_order_relaxed); + ResetSmoothPlayback(pData); + RefreshPlaybackButtons(hDlg); + co_return; + } + + // Update lastPlayheadX to current position so timer ticks can track movement properly + { + HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); + if (hTimeline) + { + RECT rc; + GetClientRect(hTimeline, &rc); + const UINT dpi = GetDpiForWindowHelper(hTimeline); + pData->lastPlayheadX = TimelineTimeToClientX(pData, pData->currentPosition, rc.right - rc.left, dpi); + } + } + + PostMessage(hDlg, WMU_PLAYBACK_POSITION, 0, 0); + RefreshPlaybackButtons(hDlg); + co_return; + } + + CleanupMediaPlayer(pData); + + winrt::MediaPlayer newPlayer{ nullptr }; + + try + { + if (!pData->playbackFile) + { + auto file = co_await winrt::StorageFile::GetFileFromPathAsync(pData->videoPath); + pData->playbackFile = file; + } + + // The user may have clicked Pause while the async file lookup was in-flight. + if (!pData->isPlaying.load(std::memory_order_acquire) || + pData->playbackCommandSerial.load(std::memory_order_acquire) != startSerial) + { + pData->isPlaying.store(false, std::memory_order_relaxed); + RefreshPlaybackButtons(hDlg); + co_return; + } + + if (!pData->playbackFile) + { + throw winrt::hresult_error(E_FAIL); + } + + newPlayer = winrt::MediaPlayer(); + newPlayer.AudioCategory(winrt::MediaPlayerAudioCategory::Media); + newPlayer.IsVideoFrameServerEnabled(true); + newPlayer.AutoPlay(false); + newPlayer.Volume(pData->volume); + newPlayer.IsMuted(pData->volume == 0.0); + + pData->frameCopyInProgress.store(false, std::memory_order_relaxed); + pData->mediaPlayer = newPlayer; + + auto mediaSource = winrt::MediaSource::CreateFromStorageFile(pData->playbackFile); + VideoRecordingSession::TrimDialogData* dataPtr = pData; + + pData->frameAvailableToken = pData->mediaPlayer.VideoFrameAvailable([hDlg, dataPtr](auto const& sender, auto const&) + { + if (!dataPtr) + { + return; + } + + if (dataPtr->frameCopyInProgress.exchange(true, std::memory_order_relaxed)) + { + return; + } + + try + { + if (!EnsurePlaybackDevice(dataPtr)) + { + dataPtr->frameCopyInProgress.store(false, std::memory_order_relaxed); + return; + } + + auto session = sender.PlaybackSession(); + UINT width = session.NaturalVideoWidth(); + UINT height = session.NaturalVideoHeight(); + if (width == 0 || height == 0) + { + width = 640; + height = 360; + } + + if (!EnsureFrameTextures(dataPtr, width, height)) + { + dataPtr->frameCopyInProgress.store(false, std::memory_order_relaxed); + return; + } + + winrt::com_ptr dxgiSurface; + if (dataPtr->previewFrameTexture) + { + dxgiSurface = dataPtr->previewFrameTexture.as(); + } + + if (dxgiSurface) + { + winrt::com_ptr inspectableSurface; + if (SUCCEEDED(CreateDirect3D11SurfaceFromDXGISurface(dxgiSurface.get(), inspectableSurface.put()))) + { + auto surface = inspectableSurface.as(); + sender.CopyFrameToVideoSurface(surface); + + if (dataPtr->previewD3DContext && dataPtr->previewFrameStaging) + { + dataPtr->previewD3DContext->CopyResource(dataPtr->previewFrameStaging.get(), dataPtr->previewFrameTexture.get()); + + D3D11_MAPPED_SUBRESOURCE mapped{}; + if (SUCCEEDED(dataPtr->previewD3DContext->Map(dataPtr->previewFrameStaging.get(), 0, D3D11_MAP_READ, 0, &mapped))) + { + const UINT rowPitch = mapped.RowPitch; + const UINT bytesPerPixel = 4; + const UINT destStride = width * bytesPerPixel; + + BITMAPINFO bmi{}; + bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER); + bmi.bmiHeader.biWidth = static_cast(width); + bmi.bmiHeader.biHeight = -static_cast(height); + bmi.bmiHeader.biPlanes = 1; + bmi.bmiHeader.biBitCount = 32; + bmi.bmiHeader.biCompression = BI_RGB; + + void* bits = nullptr; + HDC hdcScreen = GetDC(nullptr); + HBITMAP hBitmap = CreateDIBSection(hdcScreen, &bmi, DIB_RGB_COLORS, &bits, nullptr, 0); + ReleaseDC(nullptr, hdcScreen); + + if (hBitmap && bits) + { + BYTE* dest = static_cast(bits); + const BYTE* src = static_cast(mapped.pData); + for (UINT y = 0; y < height; ++y) + { + memcpy(dest + static_cast(y) * destStride, src + static_cast(y) * rowPitch, destStride); + } + + { + std::lock_guard lock(dataPtr->previewBitmapMutex); + if (dataPtr->hPreviewBitmap && dataPtr->previewBitmapOwned) + { + DeleteObject(dataPtr->hPreviewBitmap); + } + dataPtr->hPreviewBitmap = hBitmap; + dataPtr->previewBitmapOwned = true; + } + + PostMessage(hDlg, WMU_PREVIEW_READY, 0, 0); + } + else if (hBitmap) + { + DeleteObject(hBitmap); + } + + dataPtr->previewD3DContext->Unmap(dataPtr->previewFrameStaging.get(), 0); + } + } + } + } + } + catch (...) + { + } + + dataPtr->frameCopyInProgress.store(false, std::memory_order_relaxed); + }); + + auto session = pData->mediaPlayer.PlaybackSession(); + pData->positionChangedToken = session.PositionChanged([hDlg, dataPtr](auto const& sender, auto const&) + { + if (!dataPtr) + { + return; + } + + try + { + // When not playing, ignore media callbacks so UI-driven seeks remain authoritative. + if (!dataPtr->isPlaying.load(std::memory_order_relaxed)) + { + return; + } + + auto pos = sender.Position(); + + // Suppress the transient 0-position report before the initial seek takes effect. + if (dataPtr->pendingInitialSeek.load(std::memory_order_relaxed) && + dataPtr->pendingInitialSeekTicks.load(std::memory_order_relaxed) > 0 && + pos.count() == 0) + { + return; + } + + // First non-zero sample observed; allow normal updates. + if (pos.count() != 0) + { + dataPtr->pendingInitialSeek.store(false, std::memory_order_relaxed); + dataPtr->pendingInitialSeekTicks.store(0, std::memory_order_relaxed); + } + + // Check for end-of-clip BEFORE updating currentPosition to avoid + // storing a value >= trimEnd that could flash in the UI + if (pos >= dataPtr->trimEnd) + { + // Immediately mark as not playing to prevent further position updates + // before WMU_PLAYBACK_STOP is processed. + dataPtr->isPlaying.store(false, std::memory_order_release); +#if _DEBUG + OutputDebugStringW((L"[Trim] PositionChanged: pos >= trimEnd, posting stop. pos=" + + std::to_wstring(pos.count()) + L"\n").c_str()); +#endif + PostMessage(hDlg, WMU_PLAYBACK_STOP, 0, 0); + return; + } + + dataPtr->currentPosition = pos; + + if (dataPtr->isPlaying.load(std::memory_order_relaxed) && + !dataPtr->smoothHasNonZeroSample.load(std::memory_order_relaxed) && + pos.count() > 0) + { + // Seed smoothing on first real position, but keep baseline exact to avoid a jump + dataPtr->smoothHasNonZeroSample.store(true, std::memory_order_relaxed); + SyncSmoothPlayback(dataPtr, pos.count(), dataPtr->trimStart.count(), dataPtr->trimEnd.count()); + LogSmoothingEvent(L"eventFirst", pos.count(), pos.count(), 0); + } + + PostMessage(hDlg, WMU_PLAYBACK_POSITION, 0, 0); + } + catch (...) + { + } + }); + + pData->stateChangedToken = session.PlaybackStateChanged([hDlg](auto const&, auto const&) + { + PostMessage(hDlg, WMU_PLAYBACK_POSITION, 0, 0); + }); + + // Capture the resume position now since currentPosition may change before MediaOpened fires + const int64_t resumePositionTicks = std::clamp(resumePosition.count(), 0, pData->trimEnd.count()); +#if _DEBUG + OutputDebugStringW((L"[Trim] Setting up MediaOpened callback with resumePos=" + + std::to_wstring(resumePositionTicks) + L"\n").c_str()); +#endif + pData->mediaPlayer.MediaOpened([dataPtr, hDlg, resumePositionTicks, startSerial](auto const& sender, auto const&) + { + if (!dataPtr) + { + return; + } + try + { + if (!dataPtr->isPlaying.load(std::memory_order_acquire) || + dataPtr->playbackCommandSerial.load(std::memory_order_acquire) != startSerial) + { + sender.Pause(); + return; + } + // Seek to the captured resume position (loop anchor is stored separately) +#if _DEBUG + OutputDebugStringW((L"[Trim] MediaOpened: seeking to resumePos=" + + std::to_wstring(resumePositionTicks) + L"\n").c_str()); +#endif + sender.PlaybackSession().Position(winrt::TimeSpan{ resumePositionTicks }); + + // Re-check immediately before playing to reduce Play->Pause races. + if (!dataPtr->isPlaying.load(std::memory_order_acquire) || + dataPtr->playbackCommandSerial.load(std::memory_order_acquire) != startSerial) + { + sender.Pause(); + return; + } + sender.Play(); + + // Once MediaOpened has applied the initial seek, allow position updates again. + dataPtr->pendingInitialSeek.store(false, std::memory_order_relaxed); + dataPtr->pendingInitialSeekTicks.store(0, std::memory_order_relaxed); + } + catch (...) + { + } + }); + + pData->mediaPlayer.Source(mediaSource); + } + catch (...) + { + pData->isPlaying.store(false, std::memory_order_relaxed); + CleanupMediaPlayer(pData); + if (newPlayer) + { + try + { + newPlayer.Close(); + } + catch (...) + { + } + } + RefreshPlaybackButtons(hDlg); + co_return; + } + + // Use multimedia timer for smooth updates + if (!StartMMTimer(hDlg, pData)) + { + pData->isPlaying.store(false, std::memory_order_relaxed); + CleanupMediaPlayer(pData); + ResetSmoothPlayback(pData); + RefreshPlaybackButtons(hDlg); + co_return; + } + + // If a quick Pause happened right after Play, don't start timers/UI updates. + if (!pData->isPlaying.load(std::memory_order_acquire) || + pData->playbackCommandSerial.load(std::memory_order_acquire) != startSerial) + { + StopMMTimer(pData); + pData->isPlaying.store(false, std::memory_order_relaxed); + RefreshPlaybackButtons(hDlg); + co_return; + } + + // Defer smoothing until first real playback position is reported to prevent early extrapolation + pData->smoothActive.store(false, std::memory_order_relaxed); + pData->smoothHasNonZeroSample.store(false, std::memory_order_relaxed); + + // Update lastPlayheadX to current position so timer ticks can track movement properly + { + HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); + if (hTimeline) + { + RECT rc; + GetClientRect(hTimeline, &rc); + const UINT dpi = GetDpiForWindowHelper(hTimeline); + pData->lastPlayheadX = TimelineTimeToClientX(pData, pData->currentPosition, rc.right - rc.left, dpi); + } + } + + PostMessage(hDlg, WMU_PLAYBACK_POSITION, 0, 0); + RefreshPlaybackButtons(hDlg); +} + +static LRESULT CALLBACK TimelineSubclassProc( + HWND hWnd, + UINT message, + WPARAM wParam, + LPARAM lParam, + UINT_PTR uIdSubclass, + DWORD_PTR dwRefData) +{ + auto* pData = reinterpret_cast(dwRefData); + if (!pData) + { + return DefSubclassProc(hWnd, message, wParam, lParam); + } + + auto restorePreviewIfNeeded = [&]() + { + if (!pData->restorePreviewOnRelease) + { + pData->previewOverrideActive = false; + pData->playheadPushed = false; + return; + } + + if (pData->playheadPushed) + { + // Keep pushed playhead; just clear override flags + pData->previewOverrideActive = false; + pData->restorePreviewOnRelease = false; + pData->playheadPushed = false; + return; + } + + if (pData->hDialog) + { + // Restore playhead to where it was before the gripper drag. + // Only clamp to video bounds, not selection bounds, so the playhead + // can remain outside the selection if it was there before. + const int64_t restoredTicks = std::clamp( + pData->positionBeforeOverride.count(), + 0LL, + pData->videoDuration.count()); + pData->currentPosition = winrt::TimeSpan{ restoredTicks }; + pData->previewOverrideActive = false; + pData->restorePreviewOnRelease = false; + pData->playheadPushed = false; + UpdateVideoPreview(pData->hDialog, pData); + } + }; + + switch (message) + { + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, TimelineSubclassProc, uIdSubclass); + break; + + case WM_LBUTTONDOWN: + { + // Pause without recapturing position; we might be parked on a handle + StopPlayback(pData->hDialog, pData, false); + + RECT rcClient{}; + GetClientRect(hWnd, &rcClient); + const int width = rcClient.right - rcClient.left; + if (width <= 0) + { + break; + } + + const int x = GET_X_LPARAM(lParam); + const int y = GET_Y_LPARAM(lParam); + const int clampedX = std::clamp(x, 0, width); + + // Get DPI for scaling hit test regions + const UINT dpi = GetDpiForWindowHelper(hWnd); + const int timelineTrackTopOffset = ScaleForDpi(kTimelineTrackTopOffset, dpi); + const int timelineTrackHeight = ScaleForDpi(kTimelineTrackHeight, dpi); + const int timelineHandleHeight = ScaleForDpi(kTimelineHandleHeight, dpi); + const int timelineHandleHitRadius = ScaleForDpi(kTimelineHandleHitRadius, dpi); + + const int trackTop = timelineTrackTopOffset; + const int trackBottom = trackTop + timelineTrackHeight; + + // Gripper vertical band: centered on track + const int gripperTop = trackTop - (timelineHandleHeight - timelineTrackHeight) / 2; + const int gripperBottom = gripperTop + timelineHandleHeight; + const bool inGripperBand = (y >= gripperTop && y <= gripperBottom); + + // Playhead knob vertical band: below the track (ellipse drawn at trackBottom + 12 to trackBottom + 24) + const int knobTop = trackBottom + ScaleForDpi(8, dpi); // slightly above ellipse for easier hit + const int knobBottom = trackBottom + ScaleForDpi(28, dpi); + const bool inKnobBand = (y >= knobTop && y <= knobBottom); + + // Playhead stem is also hittable (trackTop - 12 to trackBottom + posLineBelow) + const int stemTop = trackTop - ScaleForDpi(12, dpi); + const int stemBottom = trackBottom + ScaleForDpi(22, dpi); + const bool inStemBand = (y >= stemTop && y <= stemBottom); + + const int startX = TimelineTimeToClientX(pData, pData->trimStart, width, dpi); + const int posX = TimelineTimeToClientX(pData, pData->currentPosition, width, dpi); + const int endX = TimelineTimeToClientX(pData, pData->trimEnd, width, dpi); + + pData->dragMode = VideoRecordingSession::TrimDialogData::None; + pData->previewOverrideActive = false; + pData->restorePreviewOnRelease = false; + + // Calculate horizontal distances to each handle + const int distToPos = abs(clampedX - posX); + const int distToStart = abs(clampedX - startX); + const int distToEnd = abs(clampedX - endX); + + // Hit-test with vertical position awareness: + // - Grippers are only hittable in the gripper band (around the track) + // - Playhead is hittable in the knob band (below track) or stem band + // - When clicking in the knob area (below track), playhead always wins + // - When in the gripper band, grippers take priority for horizontal overlaps + + const bool startHit = inGripperBand && distToStart <= timelineHandleHitRadius; + const bool endHit = inGripperBand && distToEnd <= timelineHandleHitRadius; + const bool posHitKnob = inKnobBand && distToPos <= timelineHandleHitRadius; + const bool posHitStem = inStemBand && distToPos <= ScaleForDpi(4, dpi); // tighter radius for stem + + // Prioritize playhead when clicking in the knob area (lollipop head below the track) + if (posHitKnob) + { + pData->dragMode = VideoRecordingSession::TrimDialogData::Position; + } + else if (startHit && (!endHit || distToStart <= distToEnd)) + { + pData->dragMode = VideoRecordingSession::TrimDialogData::TrimStart; + } + else if (endHit) + { + pData->dragMode = VideoRecordingSession::TrimDialogData::TrimEnd; + } + else if (posHitStem) + { + pData->dragMode = VideoRecordingSession::TrimDialogData::Position; + } + + if (pData->dragMode != VideoRecordingSession::TrimDialogData::None) + { + pData->isDragging = true; + pData->playheadPushed = false; + if (pData->dragMode == VideoRecordingSession::TrimDialogData::TrimStart || + pData->dragMode == VideoRecordingSession::TrimDialogData::TrimEnd) + { + pData->positionBeforeOverride = pData->currentPosition; + pData->previewOverrideActive = true; + pData->restorePreviewOnRelease = true; + pData->previewOverride = (pData->dragMode == VideoRecordingSession::TrimDialogData::TrimStart) ? + pData->trimStart : pData->trimEnd; + UpdateVideoPreview(pData->hDialog, pData); + // Show resize cursor during grip drag + SetCursor(LoadCursor(nullptr, IDC_SIZEWE)); + } + SetCapture(hWnd); + return 0; + } + break; + } + + case WM_LBUTTONUP: + { + if (pData->isDragging) + { + // Kill debounce timer and do immediate final update + KillTimer(hWnd, kPreviewDebounceTimerId); + const bool wasPositionDrag = (pData->dragMode == VideoRecordingSession::TrimDialogData::Position); + pData->isDragging = false; + ReleaseCapture(); + SetCursor(LoadCursor(nullptr, IDC_ARROW)); + restorePreviewIfNeeded(); + pData->dragMode = VideoRecordingSession::TrimDialogData::None; + InvalidateRect(hWnd, nullptr, FALSE); + // Ensure final preview update for playhead drag (restorePreviewIfNeeded doesn't update for this case) + if (wasPositionDrag && pData->hDialog) + { + UpdateVideoPreview(pData->hDialog, pData, false); + } + return 0; + } + break; + } + + case WM_MOUSEMOVE: + { + TRACKMOUSEEVENT tme{}; + tme.cbSize = sizeof(tme); + tme.dwFlags = TME_LEAVE; + tme.hwndTrack = hWnd; + TrackMouseEvent(&tme); + + RECT rcClient{}; + GetClientRect(hWnd, &rcClient); + const int width = rcClient.right - rcClient.left; + if (width <= 0) + { + break; + } + + const int rawX = GET_X_LPARAM(lParam); + const int clampedX = std::clamp(rawX, 0, width); + + if (!pData->isDragging) + { + // Get DPI for scaling hit test regions + const UINT dpi = GetDpiForWindowHelper(hWnd); + const int timelineHandleHitRadius = ScaleForDpi(kTimelineHandleHitRadius, dpi); + + const int startX = TimelineTimeToClientX(pData, pData->trimStart, width, dpi); + const int posX = TimelineTimeToClientX(pData, pData->currentPosition, width, dpi); + const int endX = TimelineTimeToClientX(pData, pData->trimEnd, width, dpi); + + if (abs(clampedX - posX) <= timelineHandleHitRadius) + { + SetCursor(LoadCursor(nullptr, IDC_HAND)); + } + else if (abs(clampedX - startX) < timelineHandleHitRadius || abs(clampedX - endX) < timelineHandleHitRadius) + { + SetCursor(LoadCursor(nullptr, IDC_HAND)); + } + else + { + SetCursor(LoadCursor(nullptr, IDC_ARROW)); + } + return 0; + } + + // Set appropriate cursor during drag + if (pData->dragMode == VideoRecordingSession::TrimDialogData::TrimStart || + pData->dragMode == VideoRecordingSession::TrimDialogData::TrimEnd) + { + SetCursor(LoadCursor(nullptr, IDC_SIZEWE)); + } + else if (pData->dragMode == VideoRecordingSession::TrimDialogData::Position) + { + SetCursor(LoadCursor(nullptr, IDC_HAND)); + } + + // Get DPI for pixel-to-time conversion during drag + const UINT dpi = GetDpiForWindowHelper(hWnd); + const auto newTime = TimelinePixelToTime(pData, clampedX, width, dpi); + + bool requestPreviewUpdate = false; + bool applyOverride = false; + winrt::TimeSpan overrideTime{ 0 }; + + switch (pData->dragMode) + { + case VideoRecordingSession::TrimDialogData::TrimStart: + if (newTime.count() < pData->trimEnd.count()) + { + const auto oldTrimStart = pData->trimStart; + if (newTime.count() != pData->trimStart.count()) + { + pData->trimStart = newTime; + UpdateDurationDisplay(pData->hDialog, pData); + } + // Push playhead if gripper crossed over it in either direction: + // - Moving right: playhead was >= oldTrimStart and is now < newTrimStart + // - Moving left: playhead was <= oldTrimStart and is now >= newTrimStart + // (use <= so that once pushed, the playhead continues moving with the gripper) + const bool movingRight = pData->trimStart.count() > oldTrimStart.count(); + const bool movingLeft = pData->trimStart.count() < oldTrimStart.count(); + const bool pushRight = movingRight && + pData->currentPosition.count() >= oldTrimStart.count() && + pData->currentPosition.count() < pData->trimStart.count(); + const bool pushLeft = movingLeft && + pData->currentPosition.count() <= oldTrimStart.count() && + pData->currentPosition.count() >= pData->trimStart.count(); + if (pushRight || pushLeft) + { + pData->playheadPushed = true; + pData->currentPosition = pData->trimStart; + // Also update playback start position so loop resets to pushed position + pData->playbackStartPosition = pData->currentPosition; + pData->playbackStartPositionValid = true; + // Invalidate cached start frame + std::lock_guard lock(pData->previewBitmapMutex); + if (pData->hCachedStartFrame) + { + DeleteObject(pData->hCachedStartFrame); + pData->hCachedStartFrame = nullptr; + } + } + overrideTime = pData->trimStart; + applyOverride = true; + requestPreviewUpdate = true; + } + break; + + case VideoRecordingSession::TrimDialogData::Position: + { + const int previousPosX = TimelineTimeToClientX(pData, pData->currentPosition, width, dpi); + + // Allow playhead to move anywhere within video bounds (0 to videoDuration) + const int64_t clampedTicks = std::clamp(newTime.count(), 0LL, pData->videoDuration.count()); + pData->currentPosition = winrt::TimeSpan{ clampedTicks }; + + // User explicitly positioned the playhead; update the loop anchor. + pData->playbackStartPosition = pData->currentPosition; + pData->playbackStartPositionValid = true; + + // Invalidate cached start frame since position changed - will be re-cached when playback starts. + { + std::lock_guard lock(pData->previewBitmapMutex); + if (pData->hCachedStartFrame) + { + DeleteObject(pData->hCachedStartFrame); + pData->hCachedStartFrame = nullptr; + } + } + + const int newPosX = TimelineTimeToClientX(pData, pData->currentPosition, width, dpi); + RECT clientRect{}; + GetClientRect(hWnd, &clientRect); + InvalidatePlayheadRegion(hWnd, clientRect, previousPosX, newPosX, dpi); + UpdateWindow(hWnd); // Force immediate visual update for smooth dragging + pData->previewOverrideActive = false; + // Debounce preview update for playhead drag as well + SetTimer(hWnd, kPreviewDebounceTimerId, kPreviewDebounceDelayMs, nullptr); + break; + } + + case VideoRecordingSession::TrimDialogData::TrimEnd: + if (newTime.count() > pData->trimStart.count()) + { + const auto oldTrimEnd = pData->trimEnd; + if (newTime.count() != pData->trimEnd.count()) + { + pData->trimEnd = newTime; + UpdateDurationDisplay(pData->hDialog, pData); + } + // Only push playhead if it was inside selection (<= old trimEnd) and handle crossed over it + if (pData->currentPosition.count() <= oldTrimEnd.count() && + pData->currentPosition.count() > pData->trimEnd.count()) + { + pData->playheadPushed = true; + pData->currentPosition = pData->trimEnd; + // Also update playback start position so loop resets to pushed position + pData->playbackStartPosition = pData->currentPosition; + pData->playbackStartPositionValid = true; + // Invalidate cached start frame + std::lock_guard lock(pData->previewBitmapMutex); + if (pData->hCachedStartFrame) + { + DeleteObject(pData->hCachedStartFrame); + pData->hCachedStartFrame = nullptr; + } + } + overrideTime = pData->trimEnd; + applyOverride = true; + requestPreviewUpdate = true; + } + break; + + default: + break; + } + + if (applyOverride) + { + pData->previewOverrideActive = true; + pData->previewOverride = overrideTime; + } + + // Force immediate visual update of gripper for smooth dragging + InvalidateRect(hWnd, nullptr, FALSE); + UpdateWindow(hWnd); + + // Debounce preview update - use a timer to avoid overwhelming the system with requests + // Each mouse move resets the timer; preview only updates after dragging pauses + if (requestPreviewUpdate) + { + SetTimer(hWnd, kPreviewDebounceTimerId, kPreviewDebounceDelayMs, nullptr); + } + + return 0; + } + + case WM_TIMER: + { + if (wParam == kPreviewDebounceTimerId) + { + KillTimer(hWnd, kPreviewDebounceTimerId); + if (pData && pData->hDialog) + { + UpdateVideoPreview(pData->hDialog, pData, false); + } + return 0; + } + break; + } + + case WM_ERASEBKGND: + return 1; + + case WM_MOUSELEAVE: + if (!pData->isDragging) + { + SetCursor(LoadCursor(nullptr, IDC_ARROW)); + } + break; + + case WM_CAPTURECHANGED: + if (pData->isDragging) + { + KillTimer(hWnd, kPreviewDebounceTimerId); + pData->isDragging = false; + pData->dragMode = VideoRecordingSession::TrimDialogData::None; + restorePreviewIfNeeded(); + } + break; + } + + return DefSubclassProc(hWnd, message, wParam, lParam); +} + +//---------------------------------------------------------------------------- +// +// Helper: Draw custom playback buttons (play/pause and restart) +// +//---------------------------------------------------------------------------- +static void DrawPlaybackButton( + const DRAWITEMSTRUCT* pDIS, + VideoRecordingSession::TrimDialogData* pData) +{ + if (!pDIS || !pData) + { + return; + } + + const bool isPlayControl = (pDIS->CtlID == IDC_TRIM_PLAY_PAUSE); + const bool isRewindControl = (pDIS->CtlID == IDC_TRIM_REWIND); + const bool isForwardControl = (pDIS->CtlID == IDC_TRIM_FORWARD); + const bool isSkipStartControl = (pDIS->CtlID == IDC_TRIM_SKIP_START); + const bool isSkipEndControl = (pDIS->CtlID == IDC_TRIM_SKIP_END); + + // Check if skip buttons should be disabled based on position + const bool atStart = (pData->currentPosition.count() <= pData->trimStart.count()); + const bool atEnd = (pData->currentPosition.count() >= pData->trimEnd.count()); + + const bool isHover = isPlayControl ? pData->hoverPlay : + (isRewindControl ? pData->hoverRewind : + (isForwardControl ? pData->hoverForward : + (isSkipStartControl ? pData->hoverSkipStart : pData->hoverSkipEnd))); + bool isDisabled = (pDIS->itemState & ODS_DISABLED) != 0; + + // Disable skip start when at start, skip end when at end + if (isSkipStartControl && atStart) isDisabled = true; + if (isSkipEndControl && atEnd) isDisabled = true; + + const bool isPressed = (pDIS->itemState & ODS_SELECTED) != 0; + const bool isPlaying = pData->isPlaying.load(std::memory_order_relaxed); + + // Media Player color scheme - dark background with gradient + COLORREF bgColorTop = RGB(45, 45, 50); + COLORREF bgColorBottom = RGB(35, 35, 40); + COLORREF iconColor = RGB(220, 220, 220); + COLORREF borderColor = RGB(120, 120, 125); + + if (isHover && !isDisabled) + { + bgColorTop = RGB(60, 60, 65); + bgColorBottom = RGB(50, 50, 55); + iconColor = RGB(255, 255, 255); + borderColor = RGB(150, 150, 155); + } + if (isPressed && !isDisabled) + { + bgColorTop = RGB(30, 30, 35); + bgColorBottom = RGB(25, 25, 30); + iconColor = RGB(200, 200, 200); + } + if (isDisabled) + { + bgColorTop = RGB(40, 40, 45); + bgColorBottom = RGB(35, 35, 40); + iconColor = RGB(100, 100, 100); + } + + int width = pDIS->rcItem.right - pDIS->rcItem.left; + int height = pDIS->rcItem.bottom - pDIS->rcItem.top; + float centerX = pDIS->rcItem.left + width / 2.0f; + float centerY = pDIS->rcItem.top + height / 2.0f; + float radius = min(width, height) / 2.0f - 1.0f; + + // Use GDI+ for antialiased rendering + Gdiplus::Graphics graphics(pDIS->hDC); + graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); + + // Draw flat background circle (no gradient) + Gdiplus::SolidBrush bgBrush(Gdiplus::Color(255, GetRValue(bgColorBottom), GetGValue(bgColorBottom), GetBValue(bgColorBottom))); + graphics.FillEllipse(&bgBrush, centerX - radius, centerY - radius, radius * 2, radius * 2); + + // Draw subtle border + Gdiplus::Pen borderPen(Gdiplus::Color(100, GetRValue(borderColor), GetGValue(borderColor), GetBValue(borderColor)), 0.5f); + graphics.DrawEllipse(&borderPen, centerX - radius, centerY - radius, radius * 2, radius * 2); + + // Draw icons + Gdiplus::SolidBrush iconBrush(Gdiplus::Color(255, GetRValue(iconColor), GetGValue(iconColor), GetBValue(iconColor))); + float iconSize = radius * 0.8f; // slightly larger icons + + if (isPlayControl) + { + if (isPlaying) + { + // Draw pause icon (two vertical bars) + float barWidth = iconSize / 4.0f; + float barHeight = iconSize; + float gap = iconSize / 5.0f; + + graphics.FillRectangle(&iconBrush, + centerX - gap - barWidth, centerY - barHeight / 2.0f, + barWidth, barHeight); + graphics.FillRectangle(&iconBrush, + centerX + gap, centerY - barHeight / 2.0f, + barWidth, barHeight); + } + else + { + // Draw play triangle + float triWidth = iconSize; + float triHeight = iconSize; + Gdiplus::PointF playTri[3] = { + Gdiplus::PointF(centerX - triWidth / 3.0f, centerY - triHeight / 2.0f), + Gdiplus::PointF(centerX + triWidth * 2.0f / 3.0f, centerY), + Gdiplus::PointF(centerX - triWidth / 3.0f, centerY + triHeight / 2.0f) + }; + graphics.FillPolygon(&iconBrush, playTri, 3); + } + } + else if (isRewindControl || isForwardControl) + { + // Draw small play triangle in appropriate direction + float triWidth = iconSize * 3.0f / 5.0f; + float triHeight = iconSize * 3.0f / 5.0f; + + if (isRewindControl) + { + // Triangle pointing left + Gdiplus::PointF tri[3] = { + Gdiplus::PointF(centerX + triWidth / 3.0f, centerY - triHeight / 2.0f), + Gdiplus::PointF(centerX - triWidth * 2.0f / 3.0f, centerY), + Gdiplus::PointF(centerX + triWidth / 3.0f, centerY + triHeight / 2.0f) + }; + graphics.FillPolygon(&iconBrush, tri, 3); + } + else + { + // Triangle pointing right + Gdiplus::PointF tri[3] = { + Gdiplus::PointF(centerX - triWidth / 3.0f, centerY - triHeight / 2.0f), + Gdiplus::PointF(centerX + triWidth * 2.0f / 3.0f, centerY), + Gdiplus::PointF(centerX - triWidth / 3.0f, centerY + triHeight / 2.0f) + }; + graphics.FillPolygon(&iconBrush, tri, 3); + } + } + else if (isSkipStartControl || isSkipEndControl) + { + // Draw skip to start/end icon (triangle + bar) + float triWidth = iconSize * 2.0f / 3.0f; + float triHeight = iconSize; + float barWidth = iconSize / 6.0f; + + if (isSkipStartControl) + { + // Bar on left, triangle pointing left + graphics.FillRectangle(&iconBrush, + centerX - triWidth / 2.0f - barWidth, centerY - triHeight / 2.0f, + barWidth, triHeight); + + Gdiplus::PointF tri[3] = { + Gdiplus::PointF(centerX + triWidth / 2.0f, centerY - triHeight / 2.0f), + Gdiplus::PointF(centerX - triWidth / 2.0f, centerY), + Gdiplus::PointF(centerX + triWidth / 2.0f, centerY + triHeight / 2.0f) + }; + graphics.FillPolygon(&iconBrush, tri, 3); + } + else + { + // Triangle pointing right, bar on right + Gdiplus::PointF tri[3] = { + Gdiplus::PointF(centerX - triWidth / 2.0f, centerY - triHeight / 2.0f), + Gdiplus::PointF(centerX + triWidth / 2.0f, centerY), + Gdiplus::PointF(centerX - triWidth / 2.0f, centerY + triHeight / 2.0f) + }; + graphics.FillPolygon(&iconBrush, tri, 3); + + graphics.FillRectangle(&iconBrush, + centerX + triWidth / 2.0f, centerY - triHeight / 2.0f, + barWidth, triHeight); + } + } +} + +//---------------------------------------------------------------------------- +// +// Helper: Mouse interaction for volume icon +// +//---------------------------------------------------------------------------- +static LRESULT CALLBACK VolumeIconSubclassProc( + HWND hWnd, + UINT message, + WPARAM wParam, + LPARAM lParam, + UINT_PTR uIdSubclass, + DWORD_PTR dwRefData) +{ + auto* pData = reinterpret_cast(dwRefData); + if (!pData) + { + return DefSubclassProc(hWnd, message, wParam, lParam); + } + + switch (message) + { + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, VolumeIconSubclassProc, uIdSubclass); + break; + + case WM_MOUSEMOVE: + { + TRACKMOUSEEVENT tme{ sizeof(tme), TME_LEAVE, hWnd, 0 }; + TrackMouseEvent(&tme); + + if (!pData->hoverVolumeIcon) + { + pData->hoverVolumeIcon = true; + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + } + + case WM_MOUSELEAVE: + if (pData->hoverVolumeIcon) + { + pData->hoverVolumeIcon = false; + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + + case WM_SETCURSOR: + SetCursor(LoadCursor(nullptr, IDC_HAND)); + return TRUE; + } + + return DefSubclassProc(hWnd, message, wParam, lParam); +} + +//---------------------------------------------------------------------------- +// +// Helper: Mouse interaction for playback controls +// +//---------------------------------------------------------------------------- +static LRESULT CALLBACK PlaybackButtonSubclassProc( + HWND hWnd, + UINT message, + WPARAM wParam, + LPARAM lParam, + UINT_PTR uIdSubclass, + DWORD_PTR dwRefData) +{ + auto* pData = reinterpret_cast(dwRefData); + if (!pData) + { + return DefSubclassProc(hWnd, message, wParam, lParam); + } + + switch (message) + { + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, PlaybackButtonSubclassProc, uIdSubclass); + break; + + case WM_LBUTTONDOWN: + SetFocus(hWnd); + SetCapture(hWnd); + return 0; + + case WM_LBUTTONUP: + { + if (GetCapture() == hWnd) + { + ReleaseCapture(); + } + + POINT pt{ GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) }; + RECT rc{}; + GetClientRect(hWnd, &rc); + + if (PtInRect(&rc, pt)) + { + HandlePlaybackCommand(GetDlgCtrlID(hWnd), pData); + } + return 0; + } + + case WM_KEYUP: + if (wParam == VK_SPACE || wParam == VK_RETURN) + { + HandlePlaybackCommand(GetDlgCtrlID(hWnd), pData); + return 0; + } + break; + + case WM_MOUSEMOVE: + { + TRACKMOUSEEVENT tme{ sizeof(tme), TME_LEAVE, hWnd, 0 }; + TrackMouseEvent(&tme); + + const int controlId = GetDlgCtrlID(hWnd); + const bool isPlayControl = (controlId == IDC_TRIM_PLAY_PAUSE); + const bool isRewindControl = (controlId == IDC_TRIM_REWIND); + const bool isForwardControl = (controlId == IDC_TRIM_FORWARD); + const bool isSkipStartControl = (controlId == IDC_TRIM_SKIP_START); + + bool& hoverFlag = isPlayControl ? pData->hoverPlay : + (isRewindControl ? pData->hoverRewind : + (isForwardControl ? pData->hoverForward : + (isSkipStartControl ? pData->hoverSkipStart : pData->hoverSkipEnd))); + if (!hoverFlag) + { + hoverFlag = true; + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + } + + case WM_MOUSELEAVE: + { + const int controlId = GetDlgCtrlID(hWnd); + const bool isPlayControl = (controlId == IDC_TRIM_PLAY_PAUSE); + const bool isRewindControl = (controlId == IDC_TRIM_REWIND); + const bool isForwardControl = (controlId == IDC_TRIM_FORWARD); + const bool isSkipStartControl = (controlId == IDC_TRIM_SKIP_START); + + bool& hoverFlag = isPlayControl ? pData->hoverPlay : + (isRewindControl ? pData->hoverRewind : + (isForwardControl ? pData->hoverForward : + (isSkipStartControl ? pData->hoverSkipStart : pData->hoverSkipEnd))); + if (hoverFlag) + { + hoverFlag = false; + InvalidateRect(hWnd, nullptr, FALSE); + } + return 0; + } + + case WM_SETCURSOR: + SetCursor(LoadCursor(nullptr, IDC_HAND)); + return TRUE; + + case WM_ERASEBKGND: + return 1; + + } + + return DefSubclassProc(hWnd, message, wParam, lParam); +} + +//---------------------------------------------------------------------------- +// +// TrimDialogSubclassProc +// +// Subclass procedure for the trim dialog to handle resize grip hit testing +// +//---------------------------------------------------------------------------- +static LRESULT CALLBACK TrimDialogSubclassProc( + HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam, + UINT_PTR uIdSubclass, DWORD_PTR /*dwRefData*/) +{ + switch (message) + { + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, TrimDialogSubclassProc, uIdSubclass); + break; + + case WM_NCHITTEST: + { + // First let the default handler process it + LRESULT ht = DefSubclassProc(hWnd, message, wParam, lParam); + + // If it's in the client area and not maximized, check for resize grip + if (ht == HTCLIENT && !IsZoomed(hWnd)) + { + RECT rcClient; + GetClientRect(hWnd, &rcClient); + + POINT pt = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) }; + ScreenToClient(hWnd, &pt); + + const int gripWidth = GetSystemMetrics(SM_CXHSCROLL); + const int gripHeight = GetSystemMetrics(SM_CYVSCROLL); + + if (pt.x >= rcClient.right - gripWidth && pt.y >= rcClient.bottom - gripHeight) + { + return HTBOTTOMRIGHT; + } + } + return ht; + } + } + + return DefSubclassProc(hWnd, message, wParam, lParam); +} + +//---------------------------------------------------------------------------- +// +// VideoRecordingSession::TrimDialogProc +// +// Dialog procedure for trim dialog +// +//---------------------------------------------------------------------------- +INT_PTR CALLBACK VideoRecordingSession::TrimDialogProc(HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam) +{ + static TrimDialogData* pData = nullptr; + static UINT currentDpi = DPI_BASELINE; + + switch (message) + { + case WM_INITDIALOG: + { + pData = reinterpret_cast(lParam); + if (!pData) + { + EndDialog(hDlg, IDCANCEL); + return FALSE; + } + + hDlgTrimDialog = hDlg; + SetWindowLongPtr(hDlg, DWLP_USER, lParam); + + pData->hDialog = hDlg; + pData->hoverPlay = false; + pData->hoverRewind = false; + pData->hoverForward = false; + pData->hoverSkipStart = false; + pData->hoverSkipEnd = false; + pData->isPlaying.store(false, std::memory_order_relaxed); + pData->lastRenderedPreview.store(-1, std::memory_order_relaxed); + + AcquireHighResTimer(); + + // Make OK the default button + SendMessage(hDlg, DM_SETDEFID, IDOK, 0); + + // Subclass the dialog to handle resize grip hit testing + SetWindowSubclass(hDlg, TrimDialogSubclassProc, 0, reinterpret_cast(pData)); + + HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); + if (hTimeline) + { + // Remove WS_EX_TRANSPARENT to prevent flicker during resize + SetWindowLongPtr(hTimeline, GWL_EXSTYLE, GetWindowLongPtr(hTimeline, GWL_EXSTYLE) & ~WS_EX_TRANSPARENT); + SetWindowSubclass(hTimeline, TimelineSubclassProc, 1, reinterpret_cast(pData)); + } + HWND hPlayPause = GetDlgItem(hDlg, IDC_TRIM_PLAY_PAUSE); + if (hPlayPause) + { + SetWindowSubclass(hPlayPause, PlaybackButtonSubclassProc, 2, reinterpret_cast(pData)); + } + HWND hRewind = GetDlgItem(hDlg, IDC_TRIM_REWIND); + if (hRewind) + { + SetWindowSubclass(hRewind, PlaybackButtonSubclassProc, 3, reinterpret_cast(pData)); + } + HWND hForward = GetDlgItem(hDlg, IDC_TRIM_FORWARD); + if (hForward) + { + SetWindowSubclass(hForward, PlaybackButtonSubclassProc, 4, reinterpret_cast(pData)); + } + HWND hSkipStart = GetDlgItem(hDlg, IDC_TRIM_SKIP_START); + if (hSkipStart) + { + SetWindowSubclass(hSkipStart, PlaybackButtonSubclassProc, 5, reinterpret_cast(pData)); + } + HWND hSkipEnd = GetDlgItem(hDlg, IDC_TRIM_SKIP_END); + if (hSkipEnd) + { + SetWindowSubclass(hSkipEnd, PlaybackButtonSubclassProc, 6, reinterpret_cast(pData)); + } + HWND hVolumeIcon = GetDlgItem(hDlg, IDC_TRIM_VOLUME_ICON); + if (hVolumeIcon) + { + SetWindowSubclass(hVolumeIcon, VolumeIconSubclassProc, 7, reinterpret_cast(pData)); + } + + // Initialize volume from saved setting + pData->volume = std::clamp(static_cast(g_TrimDialogVolume) / 100.0, 0.0, 1.0); + pData->previousVolume = (pData->volume > 0.0) ? pData->volume : 0.70; // Remember initial volume for unmute + + // Initialize volume slider + HWND hVolume = GetDlgItem(hDlg, IDC_TRIM_VOLUME); + if (hVolume) + { + SendMessage(hVolume, TBM_SETRANGE, TRUE, MAKELPARAM(0, 100)); + SendMessage(hVolume, TBM_SETPOS, TRUE, static_cast(pData->volume * 100)); + } + + // Hide volume controls for GIF (no audio) + if (pData->isGif) + { + if (hVolumeIcon) + { + ShowWindow(hVolumeIcon, SW_HIDE); + } + if (hVolume) + { + ShowWindow(hVolume, SW_HIDE); + } + } + + // Ensure incoming times are sane and within bounds. + if (pData->videoDuration.count() > 0) + { + const int64_t durationTicks = pData->videoDuration.count(); + const int64_t endTicks = (pData->trimEnd.count() > 0) ? pData->trimEnd.count() : durationTicks; + const int64_t clampedEnd = std::clamp(endTicks, 0, durationTicks); + const int64_t clampedStart = std::clamp(pData->trimStart.count(), 0, clampedEnd); + pData->trimStart = winrt::TimeSpan{ clampedStart }; + pData->trimEnd = winrt::TimeSpan{ clampedEnd }; + } + + // Keep the playhead at a valid position. + const int64_t upper = (pData->trimEnd.count() > 0) ? pData->trimEnd.count() : pData->videoDuration.count(); + pData->currentPosition = winrt::TimeSpan{ std::clamp(pData->currentPosition.count(), 0, upper) }; + + UpdateDurationDisplay(hDlg, pData); + + // Update labels and timeline; skip async preview load if we already have a preloaded frame + if (pData->hPreviewBitmap) + { + // Already have a preview from preloading - just update the UI + UpdatePositionUI(hDlg, pData, true); + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_PREVIEW), nullptr, FALSE); + } + else + { + // No preloaded preview - start async video load + UpdateVideoPreview(hDlg, pData); + } + // Show time relative to left grip (trimStart) + const auto relativePos = winrt::TimeSpan{ (std::max)(pData->currentPosition.count() - pData->trimStart.count(), int64_t{ 0 }) }; + SetTimeText(hDlg, IDC_TRIM_POSITION_LABEL, relativePos, true); + + // Initialize currentDpi to actual dialog DPI (for WM_DPICHANGED handling) + currentDpi = GetDpiForWindowHelper(hDlg); + + // Create a larger font for the time position label + { + int fontSize = -MulDiv(12, static_cast(currentDpi), USER_DEFAULT_SCREEN_DPI); // 12pt font + pData->hTimeLabelFont = CreateFont(fontSize, 0, 0, 0, FW_NORMAL, FALSE, FALSE, FALSE, DEFAULT_CHARSET, + OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, CLEARTYPE_QUALITY, DEFAULT_PITCH | FF_DONTCARE, L"Segoe UI"); + if (pData->hTimeLabelFont) + { + HWND hPosition = GetDlgItem(hDlg, IDC_TRIM_POSITION_LABEL); + if (hPosition) + { + SendMessage(hPosition, WM_SETFONT, reinterpret_cast(pData->hTimeLabelFont), TRUE); + } + HWND hDuration = GetDlgItem(hDlg, IDC_TRIM_DURATION_LABEL); + if (hDuration) + { + SendMessage(hDuration, WM_SETFONT, reinterpret_cast(pData->hTimeLabelFont), TRUE); + } + } + } + + // Apply dark mode + ApplyDarkModeToDialog( hDlg ); + + // Apply saved dialog size if available, then center + if (g_TrimDialogWidth > 0 && g_TrimDialogHeight > 0) + { + // Get current window rect to preserve position initially + RECT rcDlg{}; + GetWindowRect(hDlg, &rcDlg); + + // Apply saved size (stored in screen pixels) + SetWindowPos(hDlg, nullptr, 0, 0, + static_cast(g_TrimDialogWidth), + static_cast(g_TrimDialogHeight), + SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE); + } + + // Center dialog on screen + CenterTrimDialog(hDlg); + return TRUE; + } + + case WM_CTLCOLORDLG: + case WM_CTLCOLORBTN: + case WM_CTLCOLOREDIT: + case WM_CTLCOLORLISTBOX: + { + HDC hdc = reinterpret_cast(wParam); + HWND hCtrl = reinterpret_cast(lParam); + HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); + if (hBrush) + { + return reinterpret_cast(hBrush); + } + break; + } + + case WM_CTLCOLORSTATIC: + { + HDC hdc = reinterpret_cast(wParam); + HWND hCtrl = reinterpret_cast(lParam); + // Use timeline marker color for duration and position labels + if (IsDarkModeEnabled()) + { + int ctrlId = GetDlgCtrlID(hCtrl); + if (ctrlId == IDC_TRIM_DURATION_LABEL || ctrlId == IDC_TRIM_POSITION_LABEL) + { + SetBkMode(hdc, TRANSPARENT); + SetTextColor(hdc, RGB(140, 140, 140)); // Match timeline marker color + return reinterpret_cast(GetDarkModeBrush()); + } + } + HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); + if (hBrush) + { + return reinterpret_cast(hBrush); + } + break; + } + + case WM_ERASEBKGND: + if (IsDarkModeEnabled()) + { + HDC hdc = reinterpret_cast(wParam); + RECT rc; + GetClientRect(hDlg, &rc); + FillRect(hdc, &rc, GetDarkModeBrush()); + + // Draw the resize grip at the bottom-right corner (dark mode only for now) + if (!IsZoomed(hDlg)) + { + const int gripWidth = GetSystemMetrics(SM_CXHSCROLL); + const int gripHeight = GetSystemMetrics(SM_CYVSCROLL); + RECT rcGrip = { + rc.right - gripWidth, + rc.bottom - gripHeight, + rc.right, + rc.bottom + }; + + HTHEME hTheme = OpenThemeData(hDlg, L"STATUS"); + if (hTheme) + { + DrawThemeBackground(hTheme, hdc, SP_GRIPPER, 0, &rcGrip, nullptr); + CloseThemeData(hTheme); + } + else + { + DrawFrameControl(hdc, &rcGrip, DFC_SCROLL, DFCS_SCROLLSIZEGRIP); + } + } + + return TRUE; + } + break; + + case WM_GETMINMAXINFO: + { + // Set minimum dialog size to prevent controls from overlapping + MINMAXINFO* mmi = reinterpret_cast(lParam); + // Use MapDialogRect to convert dialog units to pixels + // Minimum size: 440x300 dialog units (smaller than original 521x380) + RECT rcMin = { 0, 0, 440, 300 }; + MapDialogRect(hDlg, &rcMin); + // Add frame/border size + RECT rcFrame = { 0, 0, 0, 0 }; + AdjustWindowRectEx(&rcFrame, GetWindowLong(hDlg, GWL_STYLE), FALSE, GetWindowLong(hDlg, GWL_EXSTYLE)); + const int frameWidth = (rcFrame.right - rcFrame.left); + const int frameHeight = (rcFrame.bottom - rcFrame.top); + mmi->ptMinTrackSize.x = rcMin.right + frameWidth; + mmi->ptMinTrackSize.y = rcMin.bottom + frameHeight; + return 0; + } + + case WM_SIZE: + { + if (wParam == SIZE_MINIMIZED) + { + return 0; + } + + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (!pData) + { + return 0; + } + + const int clientWidth = LOWORD(lParam); + const int clientHeight = HIWORD(lParam); + + // Use MapDialogRect to convert dialog units to pixels properly + // This accounts for font metrics and DPI + auto DluToPixels = [hDlg](int dluX, int dluY, int* pxX, int* pxY) { + RECT rc = { 0, 0, dluX, dluY }; + MapDialogRect(hDlg, &rc); + if (pxX) *pxX = rc.right; + if (pxY) *pxY = rc.bottom; + }; + + // Convert dialog unit values to pixels + int marginLeft, marginRight, marginTop; + DluToPixels(12, 12, &marginLeft, &marginTop); + DluToPixels(11, 0, &marginRight, nullptr); + + // Suppress redraw on the entire dialog during layout to prevent tearing + SendMessage(hDlg, WM_SETREDRAW, FALSE, 0); + + // Fixed heights from RC file (in dialog units) converted to pixels + int labelHeight, timelineHeight, buttonRowHeight, okCancelHeight, bottomMargin; + int spacing4, spacing2, spacing8; + DluToPixels(0, 10, nullptr, &labelHeight); // Label height: 10 DLU (for 8pt font) + DluToPixels(0, 50, nullptr, &timelineHeight); // Timeline height: 50 DLU + DluToPixels(0, 32, nullptr, &buttonRowHeight); // Play button height: 32 DLU + DluToPixels(0, 14, nullptr, &okCancelHeight); // OK/Cancel height: 14 DLU + DluToPixels(0, 8, nullptr, &bottomMargin); // Bottom margin + DluToPixels(0, 4, nullptr, &spacing4); // 4 DLU spacing + DluToPixels(0, 2, nullptr, &spacing2); // 2 DLU spacing + DluToPixels(0, 8, nullptr, &spacing8); // 8 DLU spacing + + // Calculate vertical positions from bottom up + const int okCancelY = clientHeight - bottomMargin - okCancelHeight; + const int buttonRowY = okCancelY - spacing4 - buttonRowHeight; + const int timelineY = buttonRowY - spacing4 - timelineHeight; + const int labelY = timelineY - spacing2 - labelHeight; + + // Preview fills from top to above labels + const int previewHeight = labelY - spacing8 - marginTop; + const int previewWidth = clientWidth - marginLeft - marginRight; + const int timelineWidth = previewWidth; + + // Resize preview + HWND hPreview = GetDlgItem(hDlg, IDC_TRIM_PREVIEW); + if (hPreview) + { + SetWindowPos(hPreview, nullptr, marginLeft, marginTop, previewWidth, previewHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + // Position duration label (left-aligned) + HWND hDuration = GetDlgItem(hDlg, IDC_TRIM_DURATION_LABEL); + if (hDuration) + { + int labelWidth; + DluToPixels(160, 0, &labelWidth, nullptr); + SetWindowPos(hDuration, nullptr, marginLeft, labelY, labelWidth, labelHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + // Position time label (centered) + HWND hPosition = GetDlgItem(hDlg, IDC_TRIM_POSITION_LABEL); + if (hPosition) + { + int posLabelWidth; + DluToPixels(200, 0, &posLabelWidth, nullptr); + const int posLabelX = (clientWidth - posLabelWidth) / 2; + SetWindowPos(hPosition, nullptr, posLabelX, labelY, posLabelWidth, labelHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + // Resize timeline + HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); + if (hTimeline) + { + SetWindowPos(hTimeline, nullptr, marginLeft, timelineY, timelineWidth, timelineHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + // Position playback buttons (centered horizontally) + // Button sizes: play=44x32, small=30x26 (in dialog units) + int playButtonWidth, playButtonHeight, smallButtonWidth, smallButtonHeight, buttonSpacing; + DluToPixels(44, 32, &playButtonWidth, &playButtonHeight); + DluToPixels(30, 26, &smallButtonWidth, &smallButtonHeight); + DluToPixels(2, 0, &buttonSpacing, nullptr); + + // Count actual buttons present to calculate total width + HWND hSkipStart = GetDlgItem(hDlg, IDC_TRIM_SKIP_START); + HWND hRewind = GetDlgItem(hDlg, IDC_TRIM_REWIND); + HWND hPlayPause = GetDlgItem(hDlg, IDC_TRIM_PLAY_PAUSE); + HWND hForward = GetDlgItem(hDlg, IDC_TRIM_FORWARD); + HWND hSkipEnd = GetDlgItem(hDlg, IDC_TRIM_SKIP_END); + + int numSmallButtons = 0; + int numPlayButtons = 0; + if (hSkipStart) numSmallButtons++; + if (hRewind) numSmallButtons++; + if (hPlayPause) numPlayButtons++; + if (hForward) numSmallButtons++; + if (hSkipEnd) numSmallButtons++; + + const int numButtons = numSmallButtons + numPlayButtons; + const int totalButtonWidth = smallButtonWidth * numSmallButtons + playButtonWidth * numPlayButtons + + buttonSpacing * (numButtons > 0 ? numButtons - 1 : 0); + int buttonX = (clientWidth - totalButtonWidth) / 2; + + if (hSkipStart) + { + const int yOffset = (buttonRowHeight - smallButtonHeight) / 2; + SetWindowPos(hSkipStart, nullptr, buttonX, buttonRowY + yOffset, smallButtonWidth, smallButtonHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + buttonX += smallButtonWidth + buttonSpacing; + } + + if (hRewind) + { + const int yOffset = (buttonRowHeight - smallButtonHeight) / 2; + SetWindowPos(hRewind, nullptr, buttonX, buttonRowY + yOffset, smallButtonWidth, smallButtonHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + buttonX += smallButtonWidth + buttonSpacing; + } + + if (hPlayPause) + { + SetWindowPos(hPlayPause, nullptr, buttonX, buttonRowY, playButtonWidth, playButtonHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + buttonX += playButtonWidth + buttonSpacing; + } + + if (hForward) + { + const int yOffset = (buttonRowHeight - smallButtonHeight) / 2; + SetWindowPos(hForward, nullptr, buttonX, buttonRowY + yOffset, smallButtonWidth, smallButtonHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + buttonX += smallButtonWidth + buttonSpacing; + } + + if (hSkipEnd) + { + const int yOffset = (buttonRowHeight - smallButtonHeight) / 2; + SetWindowPos(hSkipEnd, nullptr, buttonX, buttonRowY + yOffset, smallButtonWidth, smallButtonHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + buttonX += smallButtonWidth + buttonSpacing; + } + + // Position volume icon and slider (to the right of playback buttons) + int volumeIconWidth, volumeIconHeight, volumeSliderWidth, volumeSliderHeight, volumeSpacing; + DluToPixels(14, 12, &volumeIconWidth, &volumeIconHeight); + DluToPixels(70, 14, &volumeSliderWidth, &volumeSliderHeight); + DluToPixels(8, 0, &volumeSpacing, nullptr); + + HWND hVolumeIcon = GetDlgItem(hDlg, IDC_TRIM_VOLUME_ICON); + HWND hVolumeSlider = GetDlgItem(hDlg, IDC_TRIM_VOLUME); + + if (hVolumeIcon) + { + const int iconX = buttonX + volumeSpacing; + const int iconY = buttonRowY + (buttonRowHeight - volumeIconHeight) / 2; + SetWindowPos(hVolumeIcon, nullptr, iconX, iconY, volumeIconWidth, volumeIconHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + if (hVolumeSlider) + { + const int sliderX = buttonX + volumeSpacing + volumeIconWidth + 4; + const int sliderY = buttonRowY + (buttonRowHeight - volumeSliderHeight) / 2; + SetWindowPos(hVolumeSlider, nullptr, sliderX, sliderY, volumeSliderWidth, volumeSliderHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + // Position OK/Cancel buttons (right-aligned) + int okCancelWidth, okCancelSpacingH; + DluToPixels(50, 0, &okCancelWidth, nullptr); + DluToPixels(4, 0, &okCancelSpacingH, nullptr); + + HWND hCancel = GetDlgItem(hDlg, IDCANCEL); + if (hCancel) + { + const int cancelX = clientWidth - marginRight - okCancelWidth; + SetWindowPos(hCancel, nullptr, cancelX, okCancelY, okCancelWidth, okCancelHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + HWND hOK = GetDlgItem(hDlg, IDOK); + if (hOK) + { + const int okX = clientWidth - marginRight - okCancelWidth - okCancelSpacingH - okCancelWidth; + SetWindowPos(hOK, nullptr, okX, okCancelY, okCancelWidth, okCancelHeight, + SWP_NOZORDER | SWP_NOACTIVATE); + } + + // Re-enable redraw and repaint the entire dialog + SendMessage(hDlg, WM_SETREDRAW, TRUE, 0); + // Use RDW_ERASE for the dialog, but invalidate timeline separately without erase to prevent flicker + HWND hTimelineCtrl = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); + RedrawWindow(hDlg, nullptr, nullptr, RDW_ERASE | RDW_FRAME | RDW_INVALIDATE | RDW_ALLCHILDREN); + if (hTimelineCtrl) + { + // Redraw timeline without erase - double buffering handles the background + RedrawWindow(hTimelineCtrl, nullptr, nullptr, RDW_INVALIDATE | RDW_UPDATENOW); + } + return 0; + } + + case WMU_PREVIEW_READY: + { + // Video preview loaded - refresh preview area + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (pData) + { + KillTimer(hDlg, kPlaybackTimerId); + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_PREVIEW), nullptr, FALSE); + } + return TRUE; + } + + case WMU_PREVIEW_SCHEDULED: + { + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (pData) + { + UpdateVideoPreview(hDlg, pData); + } + return TRUE; + } + + case WMU_DURATION_CHANGED: + { + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (pData) + { + // If the user hasn't manually trimmed (selection was at estimated full duration), + // update the selection to the actual full video duration + if (pData->trimEnd.count() >= pData->originalTrimEnd.count()) + { + pData->trimEnd = pData->videoDuration; + pData->originalTrimEnd = pData->videoDuration; + } + // Clamp trimEnd to actual duration if it exceeds + if (pData->trimEnd.count() > pData->videoDuration.count()) + { + pData->trimEnd = pData->videoDuration; + } + + if (pData->currentPosition.count() > pData->trimEnd.count()) + { + pData->currentPosition = pData->trimEnd; + } + UpdateDurationDisplay(hDlg, pData); + UpdatePositionUI(hDlg, pData); + } + return TRUE; + } + + case WMU_PLAYBACK_POSITION: + { + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (pData) + { + // Always move the playhead smoothly + UpdatePositionUI(hDlg, pData); + + // Throttle expensive thumbnail generation while playing + const int64_t currentTicks = pData->currentPosition.count(); + const int64_t lastTicks = pData->lastRenderedPreview.load(std::memory_order_relaxed); + if (!pData->loadingPreview.load(std::memory_order_relaxed)) + { + const int64_t delta = (lastTicks < 0) ? kPreviewMinDeltaTicks : std::llabs(currentTicks - lastTicks); + if (delta >= kPreviewMinDeltaTicks) + { + UpdateVideoPreview(hDlg, pData, false); + } + } + } + return TRUE; + } + + case WMU_PLAYBACK_STOP: + { + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (!pData) + { + return TRUE; + } + + // Force UI + session back to the left grip (trim start) position. + pData->currentPosition = pData->trimStart; +#if _DEBUG + OutputDebugStringW((L"[Trim] WMU_PLAYBACK_STOP: resetting to trimStart=" + + std::to_wstring(pData->trimStart.count()) + L"\n").c_str()); +#endif + StopPlayback(hDlg, pData, false); + + // Fast path: if we have a cached frame at the trim start position, restore it instantly. + bool usedCachedFrame = false; + if (pData->hCachedStartFrame && + pData->cachedStartFramePosition.count() == pData->trimStart.count()) + { + std::lock_guard lock(pData->previewBitmapMutex); + if (pData->hCachedStartFrame) // Double-check under lock + { + // Swap the cached frame into the preview + if (pData->hPreviewBitmap && pData->previewBitmapOwned) + { + DeleteObject(pData->hPreviewBitmap); + } + pData->hPreviewBitmap = pData->hCachedStartFrame; + pData->previewBitmapOwned = true; + pData->hCachedStartFrame = nullptr; // Transferred ownership + pData->lastRenderedPreview.store(pData->trimStart.count(), std::memory_order_relaxed); + usedCachedFrame = true; + } + } + + if (usedCachedFrame) + { + // Just update UI - we already have the correct frame + UpdatePositionUI(hDlg, pData, true); + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_PREVIEW), nullptr, FALSE); + } + else + { + // Fall back to regenerating the preview + UpdateVideoPreview(hDlg, pData); + } + return TRUE; + } + + case WM_DRAWITEM: + { + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (!pData) break; + + DRAWITEMSTRUCT* pDIS = reinterpret_cast (lParam); + + if (pDIS->CtlID == IDC_TRIM_TIMELINE) + { + // Draw custom timeline + UINT timelineDpi = GetDpiForWindowHelper(pDIS->hwndItem); + DrawTimeline(pDIS->hDC, pDIS->rcItem, pData, timelineDpi); + return TRUE; + } + else if (pDIS->CtlID == IDC_TRIM_PREVIEW) + { + RECT rcFill = pDIS->rcItem; + const int controlWidth = rcFill.right - rcFill.left; + const int controlHeight = rcFill.bottom - rcFill.top; + + std::unique_lock previewLock(pData->previewBitmapMutex); + + // Create memory DC for double buffering to eliminate flicker + HDC hdcMem = CreateCompatibleDC(pDIS->hDC); + HBITMAP hbmMem = CreateCompatibleBitmap(pDIS->hDC, controlWidth, controlHeight); + HBITMAP hbmOld = static_cast(SelectObject(hdcMem, hbmMem)); + + // Draw to memory DC + RECT rcMem = { 0, 0, controlWidth, controlHeight }; + FillRect(hdcMem, &rcMem, static_cast(GetStockObject(BLACK_BRUSH))); + + if (pData->hPreviewBitmap) + { + HDC hdcBitmap = CreateCompatibleDC(hdcMem); + HBITMAP hOldBitmap = static_cast(SelectObject(hdcBitmap, pData->hPreviewBitmap)); + + BITMAP bm{}; + GetObject(pData->hPreviewBitmap, sizeof(bm), &bm); + + int destWidth = 0; + int destHeight = 0; + + if (bm.bmWidth > 0 && bm.bmHeight > 0) + { + const double scaleX = static_cast(controlWidth) / static_cast(bm.bmWidth); + const double scaleY = static_cast(controlHeight) / static_cast(bm.bmHeight); + // Use min to fit entirely within control (letterbox), not max which crops + const double scale = (std::min)(scaleX, scaleY); + + destWidth = (std::max)(1, static_cast(std::lround(static_cast(bm.bmWidth) * scale))); + destHeight = (std::max)(1, static_cast(std::lround(static_cast(bm.bmHeight) * scale))); + } + else + { + destWidth = controlWidth; + destHeight = controlHeight; + } + + const int offsetX = (controlWidth - destWidth) / 2; + const int offsetY = (controlHeight - destHeight) / 2; + + SetStretchBltMode(hdcMem, HALFTONE); + SetBrushOrgEx(hdcMem, 0, 0, nullptr); + StretchBlt(hdcMem, + offsetX, + offsetY, + destWidth, + destHeight, + hdcBitmap, + 0, + 0, + bm.bmWidth, + bm.bmHeight, + SRCCOPY); + + SelectObject(hdcBitmap, hOldBitmap); + DeleteDC(hdcBitmap); + } + else + { + SetTextColor(hdcMem, RGB(200, 200, 200)); + SetBkMode(hdcMem, TRANSPARENT); + DrawText(hdcMem, L"Preview not available", -1, &rcMem, DT_CENTER | DT_VCENTER | DT_SINGLELINE); + } + + // Copy the buffered image to the screen + BitBlt(pDIS->hDC, rcFill.left, rcFill.top, controlWidth, controlHeight, hdcMem, 0, 0, SRCCOPY); + + // Clean up + SelectObject(hdcMem, hbmOld); + DeleteObject(hbmMem); + DeleteDC(hdcMem); + + return TRUE; + } + else if (pDIS->CtlID == IDC_TRIM_PLAY_PAUSE || pDIS->CtlID == IDC_TRIM_REWIND || + pDIS->CtlID == IDC_TRIM_FORWARD || pDIS->CtlID == IDC_TRIM_SKIP_START || + pDIS->CtlID == IDC_TRIM_SKIP_END) + { + DrawPlaybackButton(pDIS, pData); + return TRUE; + } + else if (pDIS->CtlID == IDC_TRIM_VOLUME_ICON) + { + // Draw speaker icon for volume control + int width = pDIS->rcItem.right - pDIS->rcItem.left; + int height = pDIS->rcItem.bottom - pDIS->rcItem.top; + float centerX = pDIS->rcItem.left + width / 2.0f; + float centerY = pDIS->rcItem.top + height / 2.0f; + + Gdiplus::Graphics graphics(pDIS->hDC); + graphics.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); + + // Dark background + Gdiplus::SolidBrush bgBrush(Gdiplus::Color(255, 35, 35, 40)); + graphics.FillRectangle(&bgBrush, pDIS->rcItem.left, pDIS->rcItem.top, width, height); + + // Icon color - brighter on hover + const bool isHover = pData && pData->hoverVolumeIcon; + COLORREF iconColor = isHover ? RGB(255, 255, 255) : RGB(180, 180, 180); + Gdiplus::SolidBrush iconBrush(Gdiplus::Color(255, GetRValue(iconColor), GetGValue(iconColor), GetBValue(iconColor))); + Gdiplus::Pen iconPen(Gdiplus::Color(255, GetRValue(iconColor), GetGValue(iconColor), GetBValue(iconColor)), 1.2f); + + // Scale for icon + float scale = min(width, height) / 16.0f; + + // Draw speaker body (rectangle + triangle) + float speakerLeft = centerX - 4.0f * scale; + float speakerWidth = 3.0f * scale; + float speakerHeight = 5.0f * scale; + graphics.FillRectangle(&iconBrush, speakerLeft, centerY - speakerHeight / 2.0f, speakerWidth, speakerHeight); + + // Speaker cone (triangle) + Gdiplus::PointF cone[3] = { + Gdiplus::PointF(speakerLeft + speakerWidth, centerY - speakerHeight / 2.0f), + Gdiplus::PointF(speakerLeft + speakerWidth + 3.0f * scale, centerY - 4.0f * scale), + Gdiplus::PointF(speakerLeft + speakerWidth + 3.0f * scale, centerY + 4.0f * scale) + }; + Gdiplus::PointF cone2[3] = { + Gdiplus::PointF(speakerLeft + speakerWidth, centerY + speakerHeight / 2.0f), + cone[1], + cone[2] + }; + graphics.FillPolygon(&iconBrush, cone, 3); + graphics.FillPolygon(&iconBrush, cone2, 3); + + // Draw sound waves based on volume + if (pData && pData->volume > 0.0) + { + float waveX = speakerLeft + speakerWidth + 4.0f * scale; + + // First wave (always visible when volume > 0) + graphics.DrawArc(&iconPen, waveX, centerY - 2.5f * scale, 3.0f * scale, 5.0f * scale, -60.0f, 120.0f); + + // Second wave (visible when volume > 33%) + if (pData->volume > 0.33) + { + graphics.DrawArc(&iconPen, waveX + 1.5f * scale, centerY - 4.0f * scale, 4.5f * scale, 8.0f * scale, -60.0f, 120.0f); + } + + // Third wave (visible when volume > 66%) + if (pData->volume > 0.66) + { + graphics.DrawArc(&iconPen, waveX + 3.0f * scale, centerY - 5.5f * scale, 6.0f * scale, 11.0f * scale, -60.0f, 120.0f); + } + } + else if (pData && pData->volume == 0.0) + { + // Draw X for muted + float xOffset = speakerLeft + speakerWidth + 5.0f * scale; + graphics.DrawLine(&iconPen, xOffset, centerY - 2.5f * scale, xOffset + 3.5f * scale, centerY + 2.5f * scale); + graphics.DrawLine(&iconPen, xOffset, centerY + 2.5f * scale, xOffset + 3.5f * scale, centerY - 2.5f * scale); + } + return TRUE; + } + break; + } + + case WM_DPICHANGED: + { + HandleDialogDpiChange( hDlg, wParam, lParam, currentDpi ); + // Invalidate preview and timeline to redraw at new DPI + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (pData) + { + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_PREVIEW), nullptr, TRUE); + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_TIMELINE), nullptr, TRUE); + } + return TRUE; + } + + case WM_DESTROY: + { + // Save dialog size before closing + RECT rcDlg{}; + if (GetWindowRect(hDlg, &rcDlg)) + { + g_TrimDialogWidth = static_cast(rcDlg.right - rcDlg.left); + g_TrimDialogHeight = static_cast(rcDlg.bottom - rcDlg.top); + reg.WriteRegSettings(RegSettings); + } + + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (pData) + { + StopPlayback(hDlg, pData); + + // Ensure MediaPlayer and event handlers are fully released + CleanupMediaPlayer(pData); + + HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); + if (hTimeline) + { + RemoveWindowSubclass(hTimeline, TimelineSubclassProc, 1); + } + HWND hPlayPause = GetDlgItem(hDlg, IDC_TRIM_PLAY_PAUSE); + if (hPlayPause) + { + RemoveWindowSubclass(hPlayPause, PlaybackButtonSubclassProc, 2); + } + HWND hRewind = GetDlgItem(hDlg, IDC_TRIM_REWIND); + if (hRewind) + { + RemoveWindowSubclass(hRewind, PlaybackButtonSubclassProc, 3); + } + HWND hForward = GetDlgItem(hDlg, IDC_TRIM_FORWARD); + if (hForward) + { + RemoveWindowSubclass(hForward, PlaybackButtonSubclassProc, 4); + } + HWND hVolumeIcon = GetDlgItem(hDlg, IDC_TRIM_VOLUME_ICON); + if (hVolumeIcon) + { + RemoveWindowSubclass(hVolumeIcon, VolumeIconSubclassProc, 7); + } + } + if (pData && pData->hPreviewBitmap) + { + std::lock_guard lock(pData->previewBitmapMutex); + if (pData->previewBitmapOwned) + { + DeleteObject(pData->hPreviewBitmap); + } + pData->hPreviewBitmap = nullptr; + // Also clean up cached playback start frame + if (pData->hCachedStartFrame) + { + DeleteObject(pData->hCachedStartFrame); + pData->hCachedStartFrame = nullptr; + } + } + if (pData) + { + StopMMTimer(pData); // Stop multimedia timer if running + pData->playbackFile = nullptr; + CleanupGifFrames(pData); + // Clean up time label font + if (pData->hTimeLabelFont) + { + DeleteObject(pData->hTimeLabelFont); + pData->hTimeLabelFont = nullptr; + } + } + hDlgTrimDialog = nullptr; + + ReleaseHighResTimer(); + break; + } + + // Multimedia timer tick - handles MP4 and GIF playback with high precision + case WMU_MM_TIMER_TICK: + { + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (!pData) + { + return TRUE; + } + + if (!pData->isPlaying.load(std::memory_order_relaxed)) + { + StopMMTimer(pData); + RefreshPlaybackButtons(hDlg); + return TRUE; + } + + // Handle GIF playback + if (pData->isGif && !pData->gifFrames.empty()) + { + // Allow playing from before trimStart - only clamp to video bounds + const int64_t clampedTicks = std::clamp( + pData->currentPosition.count(), + 0, + pData->videoDuration.count()); + const size_t frameIndex = FindGifFrameIndex(pData->gifFrames, clampedTicks); + const auto& frame = pData->gifFrames[frameIndex]; + + // Check if enough real time has passed to advance to the next frame + auto now = std::chrono::steady_clock::now(); + auto elapsedMs = std::chrono::duration_cast(now - pData->gifFrameStartTime).count(); + auto frameDurationMs = frame.duration.count() / 10'000; // Convert 100-ns ticks to ms + + // Update playhead position smoothly based on elapsed time within current frame + const int64_t frameElapsedTicks = static_cast(elapsedMs) * 10'000; + const int64_t smoothPosition = frame.start.count() + (std::min)(frameElapsedTicks, frame.duration.count()); + // Allow positions before trimStart - only clamp to trimEnd + const int64_t clampedPosition = (std::min)(smoothPosition, pData->trimEnd.count()); + + // Check for end-of-clip BEFORE updating UI to avoid showing the end position + // then immediately jumping back to start + if (clampedPosition >= pData->trimEnd.count()) + { + // Immediately mark as not playing to prevent further position updates + pData->isPlaying.store(false, std::memory_order_release); + PostMessage(hDlg, WMU_PLAYBACK_STOP, 0, 0); + return TRUE; + } + + pData->currentPosition = winrt::TimeSpan{ clampedPosition }; + + // Update playhead + HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); + if (hTimeline) + { + const UINT dpi = GetDpiForWindowHelper(hTimeline); + RECT rc; + GetClientRect(hTimeline, &rc); + const int newX = TimelineTimeToClientX(pData, pData->currentPosition, rc.right - rc.left, dpi); + if (newX != pData->lastPlayheadX) + { + InvalidatePlayheadRegion(hTimeline, rc, pData->lastPlayheadX, newX, dpi); + pData->lastPlayheadX = newX; + UpdateWindow(hTimeline); + } + } + + // Show time relative to left grip (trimStart) + { + const auto relativePos = winrt::TimeSpan{ (std::max)(pData->currentPosition.count() - pData->trimStart.count(), int64_t{ 0 }) }; + SetTimeText(hDlg, IDC_TRIM_POSITION_LABEL, relativePos, true); + } + + if (elapsedMs >= frameDurationMs) + { + // Time to advance to next frame + const int64_t nextTicks = frame.start.count() + frame.duration.count(); + + if (nextTicks >= pData->trimEnd.count()) + { + // Immediately mark as not playing to prevent further position updates + pData->isPlaying.store(false, std::memory_order_release); + PostMessage(hDlg, WMU_PLAYBACK_STOP, 0, 0); + } + else + { + pData->currentPosition = winrt::TimeSpan{ nextTicks }; + pData->gifFrameStartTime = now; // Reset timer for new frame + UpdateVideoPreview(hDlg, pData); + } + } + return TRUE; + } + + // Handle MP4 playback + if (pData->mediaPlayer) + { + try + { + auto session = pData->mediaPlayer.PlaybackSession(); + if (!session) + { + StopPlayback(hDlg, pData, false); + UpdateVideoPreview(hDlg, pData); + return TRUE; + } + + // Simply use MediaPlayer position directly + auto position = session.Position(); + const int64_t mediaTicks = position.count(); + + // Suppress the transient 0-position report before the initial seek takes effect. + if (pData->pendingInitialSeek.load(std::memory_order_relaxed) && + pData->pendingInitialSeekTicks.load(std::memory_order_relaxed) > 0 && + mediaTicks == 0) + { + return TRUE; + } + + if (mediaTicks != 0) + { + pData->pendingInitialSeek.store(false, std::memory_order_relaxed); + pData->pendingInitialSeekTicks.store(0, std::memory_order_relaxed); + } + + // Allow playing from before trimStart - only clamp to video bounds and trimEnd + const int64_t clampedTicks = std::clamp( + mediaTicks, + 0, + pData->trimEnd.count()); + + // Check for end-of-clip BEFORE updating UI to avoid showing the end position + // then immediately jumping back to start + if (clampedTicks >= pData->trimEnd.count()) + { + // Immediately mark as not playing to prevent further position updates + pData->isPlaying.store(false, std::memory_order_release); + PostMessage(hDlg, WMU_PLAYBACK_STOP, 0, 0); + } + else + { + pData->currentPosition = winrt::TimeSpan{ clampedTicks }; + + // Invalidate only the old and new playhead regions for efficiency + HWND hTimeline = GetDlgItem(hDlg, IDC_TRIM_TIMELINE); + if (hTimeline) + { + const UINT dpi = GetDpiForWindowHelper(hTimeline); + RECT rc; + GetClientRect(hTimeline, &rc); + const int newX = TimelineTimeToClientX(pData, pData->currentPosition, rc.right - rc.left, dpi); + // Only repaint if position actually changed + if (newX != pData->lastPlayheadX) + { + InvalidatePlayheadRegion(hTimeline, rc, pData->lastPlayheadX, newX, dpi); + pData->lastPlayheadX = newX; + UpdateWindow(hTimeline); + } + } + // Show time relative to left grip (trimStart) + { + const auto relativePos = winrt::TimeSpan{ (std::max)(pData->currentPosition.count() - pData->trimStart.count(), int64_t{ 0 }) }; + SetTimeText(hDlg, IDC_TRIM_POSITION_LABEL, relativePos, true); + } + } + } + catch (...) + { + } + } + return TRUE; + } + + case WM_TIMER: + // WM_TIMER is no longer used for playback; both MP4 and GIF use multimedia timer (WMU_MM_TIMER_TICK) + // This handler is kept for any other timers that might be added in the future + if (wParam == kPlaybackTimerId) + { + // Legacy timer - should not fire anymore, but clean up if it does + KillTimer(hDlg, kPlaybackTimerId); + return TRUE; + } + break; + + case WM_HSCROLL: + { + HWND hVolumeSlider = GetDlgItem(hDlg, IDC_TRIM_VOLUME); + if (reinterpret_cast(lParam) == hVolumeSlider) + { + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (pData) + { + int pos = static_cast(SendMessage(hVolumeSlider, TBM_GETPOS, 0, 0)); + pData->volume = pos / 100.0; + + // Persist volume setting + g_TrimDialogVolume = static_cast(pos); + reg.WriteRegSettings(RegSettings); + + if (pData->mediaPlayer) + { + try + { + pData->mediaPlayer.Volume(pData->volume); + pData->mediaPlayer.IsMuted(pData->volume == 0.0); + } + catch (...) + { + } + } + // Invalidate volume icon to update its appearance + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_VOLUME_ICON), nullptr, FALSE); + } + return TRUE; + } + break; + } + + case WM_COMMAND: + switch (LOWORD(wParam)) + { + case IDC_TRIM_VOLUME_ICON: + { + if (HIWORD(wParam) == STN_CLICKED) + { + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + if (pData) + { + HWND hVolumeSlider = GetDlgItem(hDlg, IDC_TRIM_VOLUME); + + if (pData->volume > 0.0) + { + // Mute: save current volume and set to 0 + pData->previousVolume = pData->volume; + pData->volume = 0.0; + } + else + { + // Unmute: restore previous volume (default to 70% if never set) + pData->volume = (pData->previousVolume > 0.0) ? pData->previousVolume : 0.70; + } + + // Update slider position + if (hVolumeSlider) + { + SendMessage(hVolumeSlider, TBM_SETPOS, TRUE, static_cast(pData->volume * 100)); + // Force full redraw to avoid leftover thumb artifacts + InvalidateRect(hVolumeSlider, nullptr, TRUE); + } + + // Persist volume setting + g_TrimDialogVolume = static_cast(pData->volume * 100); + reg.WriteRegSettings(RegSettings); + + // Apply to media player + if (pData->mediaPlayer) + { + try + { + pData->mediaPlayer.Volume(pData->volume); + pData->mediaPlayer.IsMuted(pData->volume == 0.0); + } + catch (...) + { + } + } + + // Update icon appearance + InvalidateRect(GetDlgItem(hDlg, IDC_TRIM_VOLUME_ICON), nullptr, FALSE); + } + return TRUE; + } + break; + } + + case IDC_TRIM_REWIND: + case IDC_TRIM_PLAY_PAUSE: + case IDC_TRIM_FORWARD: + case IDC_TRIM_SKIP_START: + case IDC_TRIM_SKIP_END: + { + if (HIWORD(wParam) == BN_CLICKED) + { + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + HandlePlaybackCommand(static_cast(LOWORD(wParam)), pData); + return TRUE; + } + break; + } + + case IDOK: + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + StopPlayback(hDlg, pData); + // Trim times are already set by mouse dragging + EndDialog(hDlg, IDOK); + return TRUE; + + case IDCANCEL: + pData = reinterpret_cast(GetWindowLongPtr(hDlg, DWLP_USER)); + StopPlayback(hDlg, pData); + EndDialog(hDlg, IDCANCEL); + return TRUE; + } + break; + } + + return FALSE; +} + +//---------------------------------------------------------------------------- +// +// VideoRecordingSession::TrimVideoAsync +// +// Performs the actual video trimming operation +// +//---------------------------------------------------------------------------- +winrt::IAsyncOperation VideoRecordingSession::TrimVideoAsync( + const std::wstring& sourceVideoPath, + winrt::TimeSpan trimTimeStart, + winrt::TimeSpan trimTimeEnd) +{ + try + { + // Load the source video file + auto sourceFile = co_await winrt::StorageFile::GetFileFromPathAsync(sourceVideoPath); + + // Create a media composition + winrt::MediaComposition composition; + auto clip = co_await winrt::MediaClip::CreateFromFileAsync(sourceFile); + + // Set the trim times + clip.TrimTimeFromStart(trimTimeStart); + clip.TrimTimeFromEnd(clip.OriginalDuration() - trimTimeEnd); + + // Add the trimmed clip to the composition + composition.Clips().Append(clip); + + // Create output file in temp folder + auto tempFolder = co_await winrt::StorageFolder::GetFolderFromPathAsync( + std::filesystem::temp_directory_path().wstring()); + auto zoomitFolder = co_await tempFolder.CreateFolderAsync( + L"ZoomIt", winrt::CreationCollisionOption::OpenIfExists); + + // Generate unique filename + std::wstring filename = L"zoomit_trimmed_" + + std::to_wstring(GetTickCount64()) + L".mp4"; + auto outputFile = co_await zoomitFolder.CreateFileAsync( + filename, winrt::CreationCollisionOption::ReplaceExisting); + + // Render the composition to the output file with fast trimming (no re-encode) + auto renderResult = co_await composition.RenderToFileAsync( + outputFile, winrt::MediaTrimmingPreference::Fast); + + if (renderResult == winrt::TranscodeFailureReason::None) + { + co_return winrt::hstring(outputFile.Path()); + } + else + { + co_return winrt::hstring(); + } + } + catch (...) + { + co_return winrt::hstring(); + } +} + +winrt::IAsyncOperation VideoRecordingSession::TrimGifAsync( + const std::wstring& sourceGifPath, + winrt::TimeSpan trimTimeStart, + winrt::TimeSpan trimTimeEnd) +{ + co_await winrt::resume_background(); + + try + { + if (trimTimeEnd.count() <= trimTimeStart.count()) + { + co_return winrt::hstring(); + } + + winrt::com_ptr factory; + winrt::check_hresult(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(factory.put()))); + + auto sourceFile = co_await winrt::StorageFile::GetFileFromPathAsync(sourceGifPath); + auto sourceStream = co_await sourceFile.OpenAsync(winrt::FileAccessMode::Read); + + winrt::com_ptr sourceIStream; + winrt::check_hresult(CreateStreamOverRandomAccessStream(winrt::get_unknown(sourceStream), IID_PPV_ARGS(sourceIStream.put()))); + + winrt::com_ptr decoder; + winrt::check_hresult(factory->CreateDecoderFromStream(sourceIStream.get(), nullptr, WICDecodeMetadataCacheOnLoad, decoder.put())); + + UINT frameCount = 0; + winrt::check_hresult(decoder->GetFrameCount(&frameCount)); + if (frameCount == 0) + { + co_return winrt::hstring(); + } + + // Prepare output file + auto tempFolder = co_await winrt::StorageFolder::GetFolderFromPathAsync(std::filesystem::temp_directory_path().wstring()); + auto zoomitFolder = co_await tempFolder.CreateFolderAsync(L"ZoomIt", winrt::CreationCollisionOption::OpenIfExists); + std::wstring filename = L"zoomit_trimmed_" + std::to_wstring(GetTickCount64()) + L".gif"; + auto outputFile = co_await zoomitFolder.CreateFileAsync(filename, winrt::CreationCollisionOption::ReplaceExisting); + auto outputStream = co_await outputFile.OpenAsync(winrt::FileAccessMode::ReadWrite); + + winrt::com_ptr outputIStream; + winrt::check_hresult(CreateStreamOverRandomAccessStream(winrt::get_unknown(outputStream), IID_PPV_ARGS(outputIStream.put()))); + + winrt::com_ptr encoder; + winrt::check_hresult(factory->CreateEncoder(GUID_ContainerFormatGif, nullptr, encoder.put())); + winrt::check_hresult(encoder->Initialize(outputIStream.get(), WICBitmapEncoderNoCache)); + + // Try to set looping metadata + try + { + winrt::com_ptr encoderMetadataWriter; + if (SUCCEEDED(encoder->GetMetadataQueryWriter(encoderMetadataWriter.put())) && encoderMetadataWriter) + { + PROPVARIANT prop{}; + PropVariantInit(&prop); + prop.vt = VT_UI1 | VT_VECTOR; + prop.caub.cElems = 11; + prop.caub.pElems = static_cast(CoTaskMemAlloc(11)); + if (prop.caub.pElems) + { + memcpy(prop.caub.pElems, "NETSCAPE2.0", 11); + encoderMetadataWriter->SetMetadataByName(L"/appext/application", &prop); + } + PropVariantClear(&prop); + + PropVariantInit(&prop); + prop.vt = VT_UI1 | VT_VECTOR; + prop.caub.cElems = 5; + prop.caub.pElems = static_cast(CoTaskMemAlloc(5)); + if (prop.caub.pElems) + { + prop.caub.pElems[0] = 3; + prop.caub.pElems[1] = 1; + prop.caub.pElems[2] = 0; + prop.caub.pElems[3] = 0; + prop.caub.pElems[4] = 0; + encoderMetadataWriter->SetMetadataByName(L"/appext/data", &prop); + } + PropVariantClear(&prop); + } + } + catch (...) + { + // Loop metadata is optional; continue without failing + } + + int64_t cumulativeTicks = 0; + bool wroteFrame = false; + + for (UINT i = 0; i < frameCount; ++i) + { + winrt::com_ptr frame; + if (FAILED(decoder->GetFrame(i, frame.put()))) + { + continue; + } + + UINT delayCs = kGifDefaultDelayCs; + try + { + winrt::com_ptr metadata; + if (SUCCEEDED(frame->GetMetadataQueryReader(metadata.put())) && metadata) + { + PROPVARIANT prop{}; + PropVariantInit(&prop); + if (SUCCEEDED(metadata->GetMetadataByName(L"/grctlext/Delay", &prop))) + { + if (prop.vt == VT_UI2) + { + delayCs = prop.uiVal; + } + else if (prop.vt == VT_UI1) + { + delayCs = prop.bVal; + } + } + PropVariantClear(&prop); + } + } + catch (...) + { + } + + if (delayCs == 0) + { + delayCs = kGifDefaultDelayCs; + } + + const int64_t frameStart = cumulativeTicks; + const int64_t frameEnd = frameStart + static_cast(delayCs) * 100'000; + cumulativeTicks = frameEnd; + + if (frameEnd <= trimTimeStart.count() || frameStart >= trimTimeEnd.count()) + { + continue; + } + + const int64_t visibleStart = (std::max)(frameStart, trimTimeStart.count()); + const int64_t visibleEnd = (std::min)(frameEnd, trimTimeEnd.count()); + const int64_t visibleTicks = visibleEnd - visibleStart; + if (visibleTicks <= 0) + { + continue; + } + + UINT width = 0; + UINT height = 0; + frame->GetSize(&width, &height); + + winrt::com_ptr frameEncode; + winrt::com_ptr propertyBag; + winrt::check_hresult(encoder->CreateNewFrame(frameEncode.put(), propertyBag.put())); + winrt::check_hresult(frameEncode->Initialize(propertyBag.get())); + winrt::check_hresult(frameEncode->SetSize(width, height)); + + WICPixelFormatGUID pixelFormat = GUID_WICPixelFormat8bppIndexed; + winrt::check_hresult(frameEncode->SetPixelFormat(&pixelFormat)); + + winrt::com_ptr converter; + winrt::check_hresult(factory->CreateFormatConverter(converter.put())); + winrt::check_hresult(converter->Initialize(frame.get(), GUID_WICPixelFormat32bppBGRA, WICBitmapDitherTypeNone, nullptr, 0.0, WICBitmapPaletteTypeCustom)); + + winrt::check_hresult(frameEncode->WriteSource(converter.get(), nullptr)); + + try + { + winrt::com_ptr frameMetadataWriter; + if (SUCCEEDED(frameEncode->GetMetadataQueryWriter(frameMetadataWriter.put())) && frameMetadataWriter) + { + PROPVARIANT prop{}; + PropVariantInit(&prop); + prop.vt = VT_UI2; + // Convert ticks (100ns) to centiseconds with rounding and minimum 1 + const int64_t roundedCs = (visibleTicks + 50'000) / 100'000; + prop.uiVal = static_cast((std::max)(1, roundedCs)); + frameMetadataWriter->SetMetadataByName(L"/grctlext/Delay", &prop); + PropVariantClear(&prop); + + PropVariantInit(&prop); + prop.vt = VT_UI1; + prop.bVal = 2; // restore to background + frameMetadataWriter->SetMetadataByName(L"/grctlext/Disposal", &prop); + PropVariantClear(&prop); + } + } + catch (...) + { + } + + winrt::check_hresult(frameEncode->Commit()); + wroteFrame = true; + } + + winrt::check_hresult(encoder->Commit()); + + if (!wroteFrame) + { + co_return winrt::hstring(); + } + + co_return winrt::hstring(outputFile.Path()); + } + catch (...) + { + co_return winrt::hstring(); + } +} diff --git a/src/modules/ZoomIt/ZoomIt/VideoRecordingSession.h b/src/modules/ZoomIt/ZoomIt/VideoRecordingSession.h index 960ac36444..c199e9d4b9 100644 --- a/src/modules/ZoomIt/ZoomIt/VideoRecordingSession.h +++ b/src/modules/ZoomIt/ZoomIt/VideoRecordingSession.h @@ -11,6 +11,12 @@ #include "CaptureFrameWait.h" #include "AudioSampleGenerator.h" #include +#include +#include +#include +#include +#include +#include class VideoRecordingSession : public std::enable_shared_from_this { @@ -21,6 +27,7 @@ public: RECT const& cropRect, uint32_t frameRate, bool captureAudio, + bool captureSystemAudio, winrt::Streams::IRandomAccessStream const& stream); ~VideoRecordingSession(); @@ -28,6 +35,151 @@ public: void EnableCursorCapture(bool enable = true) { m_frameWait->EnableCursorCapture(enable); } void Close(); + bool HasCapturedVideoFrames() const { return m_hasVideoSample.load(); } + + // Trim and save functionality + static std::wstring ShowSaveDialogWithTrim( + HWND hWnd, + const std::wstring& suggestedFileName, + const std::wstring& originalVideoPath, + std::wstring& trimmedVideoPath); + + struct TrimDialogData + { + struct GifFrame + { + HBITMAP hBitmap{ nullptr }; + winrt::Windows::Foundation::TimeSpan start{ 0 }; + winrt::Windows::Foundation::TimeSpan duration{ 0 }; + UINT width{ 0 }; + UINT height{ 0 }; + }; + + std::wstring videoPath; + winrt::Windows::Foundation::TimeSpan videoDuration{ 0 }; + winrt::Windows::Foundation::TimeSpan trimStart{ 0 }; + winrt::Windows::Foundation::TimeSpan trimEnd{ 0 }; + winrt::Windows::Foundation::TimeSpan originalTrimStart{ 0 }; // Initial value to detect if trim needed + winrt::Windows::Foundation::TimeSpan originalTrimEnd{ 0 }; // Initial value to detect if trim needed + winrt::Windows::Foundation::TimeSpan currentPosition{ 0 }; + // Playback loop anchor. This is set when the user explicitly positions the playhead + // (e.g., dragging or using the jog buttons). Pausing/resuming should not change it. + winrt::Windows::Foundation::TimeSpan playbackStartPosition{ 0 }; + bool playbackStartPositionValid{ false }; + + // Cached preview frame at playback start position for instant restore when playback stops. + HBITMAP hCachedStartFrame{ nullptr }; + winrt::Windows::Foundation::TimeSpan cachedStartFramePosition{ -1 }; + + // When starting playback at a non-zero position, MediaPlayer may briefly report Position==0 + // before the initial seek is applied. Use this to suppress a one-frame UI jump to 0. + std::atomic pendingInitialSeek{ false }; + std::atomic pendingInitialSeekTicks{ 0 }; + winrt::Windows::Media::Editing::MediaComposition composition{ nullptr }; + winrt::Windows::Media::Playback::MediaPlayer mediaPlayer{ nullptr }; + winrt::Windows::Storage::StorageFile playbackFile{ nullptr }; + HBITMAP hPreviewBitmap{ nullptr }; + HWND hDialog{ nullptr }; + std::atomic loadingPreview{ false }; + std::atomic latestPreviewRequest{ 0 }; + std::atomic lastRenderedPreview{ -1 }; + std::atomic isPlaying{ false }; + // Monotonic serial used to cancel in-flight StartPlaybackAsync work when the user + // immediately pauses after starting playback. + std::atomic playbackCommandSerial{ 0 }; + std::atomic frameCopyInProgress{ false }; + std::atomic smoothActive{ false }; + std::atomic smoothBaseTicks{ 0 }; + std::atomic smoothLastSyncMicroseconds{ 0 }; + std::atomic smoothHasNonZeroSample{ false }; + std::mutex previewBitmapMutex; + winrt::event_token frameAvailableToken{}; + winrt::event_token positionChangedToken{}; + winrt::event_token stateChangedToken{}; + winrt::com_ptr previewD3DDevice; + winrt::com_ptr previewD3DContext; + winrt::com_ptr previewFrameTexture; + winrt::com_ptr previewFrameStaging; + bool hoverPlay{ false }; + bool hoverRewind{ false }; + bool hoverForward{ false }; + bool hoverSkipStart{ false }; + bool hoverSkipEnd{ false }; + bool hoverVolumeIcon{ false }; + double volume{ 0.70 }; // Volume level 0.0 to 1.0, initialized from g_TrimDialogVolume in dialog init + double previousVolume{ 0.70 }; // Volume before muting, for unmute restoration + winrt::Windows::Foundation::TimeSpan previewOverride{ 0 }; + winrt::Windows::Foundation::TimeSpan positionBeforeOverride{ 0 }; + bool previewOverrideActive{ false }; + bool restorePreviewOnRelease{ false }; + bool playheadPushed{ false }; + int dialogX{ 0 }; + int dialogY{ 0 }; + bool isGif{ false }; + bool previewBitmapOwned{ true }; + std::vector gifFrames; + bool gifFramesLoaded{ false }; + size_t gifLastFrameIndex{ 0 }; + std::chrono::steady_clock::time_point gifFrameStartTime{}; // When the current GIF frame started displaying + + // Font for time labels + HFONT hTimeLabelFont{ nullptr }; + + // Mouse tracking for timeline + enum DragMode { None, TrimStart, Position, TrimEnd }; + DragMode dragMode{ None }; + bool isDragging{ false }; + int lastPlayheadX{ -1 }; // Track last playhead pixel position for efficient invalidation + MMRESULT mmTimerId{ 0 }; // Multimedia timer for smooth MP4 playback + + // Helper to convert time to pixel position + int TimeToPixel(winrt::Windows::Foundation::TimeSpan time, int timelineWidth) const + { + if (timelineWidth <= 0 || videoDuration.count() <= 0) + { + return 0; + } + double ratio = static_cast(time.count()) / static_cast(videoDuration.count()); + ratio = std::clamp(ratio, 0.0, 1.0); + return static_cast(ratio * timelineWidth); + } + + // Helper to convert pixel to time + winrt::Windows::Foundation::TimeSpan PixelToTime(int pixel, int timelineWidth) const + { + if (timelineWidth <= 0 || videoDuration.count() <= 0) + { + return winrt::Windows::Foundation::TimeSpan{ 0 }; + } + int clampedPixel = std::clamp(pixel, 0, timelineWidth); + double ratio = static_cast(clampedPixel) / static_cast(timelineWidth); + return winrt::Windows::Foundation::TimeSpan{ static_cast(ratio * videoDuration.count()) }; + } + }; + + static INT_PTR ShowTrimDialog( + HWND hParent, + const std::wstring& videoPath, + winrt::Windows::Foundation::TimeSpan& trimStart, + winrt::Windows::Foundation::TimeSpan& trimEnd); + +private: + static INT_PTR CALLBACK TrimDialogProc(HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam); + + static winrt::Windows::Foundation::IAsyncOperation TrimVideoAsync( + const std::wstring& sourceVideoPath, + winrt::Windows::Foundation::TimeSpan trimTimeStart, + winrt::Windows::Foundation::TimeSpan trimTimeEnd); + static winrt::Windows::Foundation::IAsyncOperation TrimGifAsync( + const std::wstring& sourceGifPath, + winrt::Windows::Foundation::TimeSpan trimTimeStart, + winrt::Windows::Foundation::TimeSpan trimTimeEnd); + static INT_PTR ShowTrimDialogInternal( + HWND hParent, + const std::wstring& videoPath, + winrt::Windows::Foundation::TimeSpan& trimStart, + winrt::Windows::Foundation::TimeSpan& trimEnd); + private: VideoRecordingSession( winrt::Direct3D11::IDirect3DDevice const& device, @@ -35,6 +187,7 @@ private: RECT const cropRect, uint32_t frameRate, bool captureAudio, + bool captureSystemAudio, winrt::Streams::IRandomAccessStream const& stream); void CloseInternal(); @@ -68,4 +221,7 @@ private: std::atomic m_isRecording = false; std::atomic m_closed = false; + + // Set once the MediaStreamSource successfully returns at least one video sample. + std::atomic m_hasVideoSample = false; }; \ No newline at end of file diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.rc b/src/modules/ZoomIt/ZoomIt/ZoomIt.rc index 5f5e9d16cf..6fe01af2bb 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomIt.rc +++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.rc @@ -32,18 +32,18 @@ LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US // TEXTINCLUDE // -1 TEXTINCLUDE +1 TEXTINCLUDE BEGIN "resource.h\0" END -2 TEXTINCLUDE +2 TEXTINCLUDE BEGIN "#include ""winres.h""\r\n" "\0" END -3 TEXTINCLUDE +3 TEXTINCLUDE BEGIN "#include ""binres.rc""\0" END @@ -113,26 +113,26 @@ END // Dialog // -OPTIONS DIALOGEX 0, 0, 279, 325 +OPTIONS DIALOGEX 0, 0, 299, 325 STYLE DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | DS_CENTER | WS_POPUP | WS_CLIPSIBLINGS | WS_CAPTION | WS_SYSMENU EXSTYLE WS_EX_CONTROLPARENT CAPTION "ZoomIt - Sysinternals: www.sysinternals.com" FONT 8, "MS Shell Dlg", 0, 0, 0x0 BEGIN - DEFPUSHBUTTON "OK",IDOK,166,306,50,14 - PUSHBUTTON "Cancel",IDCANCEL,223,306,50,14 - LTEXT "ZoomIt v9.21",IDC_VERSION,42,7,73,10 - LTEXT "Copyright © 2006-2025 Mark Russinovich",IDC_COPYRIGHT,42,17,231,8 + DEFPUSHBUTTON "OK",IDOK,186,306,50,14 + PUSHBUTTON "Cancel",IDCANCEL,243,306,50,14 + LTEXT "ZoomIt v10.0",IDC_VERSION,42,7,73,10 + LTEXT "Copyright \251 2006-2026 Mark Russinovich",IDC_COPYRIGHT,42,17,251,8 CONTROL "Sysinternals - www.sysinternals.com",IDC_LINK, "SysLink",WS_TABSTOP,42,26,150,9 ICON "APPICON",IDC_STATIC,12,9,20,20 CONTROL "Show tray icon",IDC_SHOW_TRAY_ICON,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,13,295,105,10 - CONTROL "",IDC_TAB,"SysTabControl32",TCS_MULTILINE | WS_TABSTOP,8,46,265,245 + CONTROL "",IDC_TAB,"SysTabControl32",TCS_MULTILINE | WS_TABSTOP,8,46,285,247 CONTROL "Run ZoomIt when Windows starts",IDC_AUTOSTART,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,13,309,122,10 END -ADVANCED_BREAK DIALOGEX 0, 0, 209, 219 -STYLE DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU +ADVANCED_BREAK DIALOGEX 0, 0, 209, 225 +STYLE DS_SETFONT | DS_MODALFRAME | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU CAPTION "Advanced Break Options" FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN @@ -158,23 +158,22 @@ BEGIN EDITTEXT IDC_BACKGROUND_FILE,62,164,125,12,ES_AUTOHSCROLL | ES_READONLY PUSHBUTTON "&...",IDC_BACKGROUND_BROWSE,188,164,13,11 CONTROL "Scale to screen:",IDC_CHECK_BACKGROUND_STRETCH,"Button",BS_AUTOCHECKBOX | WS_TABSTOP,58,180,67,10,WS_EX_RIGHT - DEFPUSHBUTTON "OK",IDOK,97,201,50,14 - PUSHBUTTON "Cancel",IDCANCEL,150,201,50,14 + DEFPUSHBUTTON "OK",IDOK,97,199,50,14 + PUSHBUTTON "Cancel",IDCANCEL,150,199,50,14 LTEXT "Alarm Sound File:",IDC_STATIC_SOUND_FILE,61,26,56,8 LTEXT "Timer Opacity:",IDC_STATIC,8,59,48,8 LTEXT "Timer Position:",IDC_STATIC,8,77,48,8 - CONTROL "",IDC_STATIC,"Static",SS_BLACKFRAME | SS_SUNKEN,7,196,193,1,WS_EX_CLIENTEDGE END ZOOM DIALOGEX 0, 0, 260, 170 -STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU +STYLE DS_SETFONT | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN CONTROL "",IDC_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,59,57,80,12 - LTEXT "After toggling ZoomIt you can zoom in with the mouse wheel or up and down arrow keys. Exit zoom mode with Escape or by pressing the right mouse button.",IDC_STATIC,7,6,246,26 + LTEXT "After toggling ZoomIt you can zoom in with the mouse wheel or up and down arrow keys. Exit zoom mode with Escape or by pressing the right mouse button.",IDC_STATIC,7,6,230,26 LTEXT "Zoom Toggle:",IDC_STATIC,7,59,51,8 CONTROL "",IDC_ZOOM_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_BOTH | TBS_NOTICKS | WS_TABSTOP,53,118,150,15,WS_EX_TRANSPARENT - LTEXT "Specify the initial level of magnification when zooming in:",IDC_STATIC,7,105,215,10 + LTEXT "Specify the initial level of magnification when zooming in:",IDC_STATIC,7,105,230,10 LTEXT "1.25",IDC_STATIC,52,136,16,8 LTEXT "1.5",IDC_STATIC,82,136,12,8 LTEXT "1.75",IDC_STATIC,108,136,16,8 @@ -183,52 +182,52 @@ BEGIN LTEXT "4.0",IDC_STATIC,190,136,12,8 CONTROL "Animate zoom in and zoom out:",IDC_ANIMATE_ZOOM,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,74,116,10 CONTROL "Smooth zoomed image:",IDC_SMOOTH_IMAGE,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,88,116,10 - LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,148,246,17 - LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,6,34,246,18 + LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,148,230,17 + LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,6,34,230,18 END DRAW DIALOGEX 0, 0, 260, 228 -STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_SYSMENU +STYLE DS_SETFONT | DS_CONTROL | WS_CHILD | WS_SYSMENU FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN - LTEXT "Once zoomed, toggle drawing mode by pressing the left mouse button. Undo with Ctrl+Z and all drawing by pressing E. Center the cursor with the space bar. Exit drawing mode by pressing the right mouse button.",IDC_STATIC,7,7,246,24 + LTEXT "Once zoomed, toggle drawing mode by pressing the left mouse button. Undo with Ctrl+Z and all drawing by pressing E. Center the cursor with the space bar. Exit drawing mode by pressing the right mouse button.",IDC_STATIC,7,7,230,24 LTEXT "Pen Control ",IDC_PEN_CONTROL,7,38,40,8 - LTEXT "Change the pen width by pressing left Ctrl and using the mouse wheel or the up and down arrow keys.",IDC_STATIC,19,48,233,16 + LTEXT "Change the pen width by pressing left Ctrl and using the mouse wheel or the up and down arrow keys.",IDC_STATIC,19,48,218,16 LTEXT "Colors",IDC_COLORS,7,70,21,8 - LTEXT "Change the pen color by pressing R (red), G (green), B (blue),\nO (orange), Y (yellow) or P (pink).",IDC_STATIC,19,80,233,16 + LTEXT "Change the pen color by pressing R (red), G (green), B (blue),\nO (orange), Y (yellow) or P (pink).",IDC_STATIC,19,80,218,16 LTEXT "Highlight and Blur",IDC_HIGHLIGHT_AND_BLUR,7,102,58,8 - LTEXT "Hold Shift while pressing a color key for a translucent highlighter color. Press X for blur or Shift+X for a stronger blur.",IDC_STATIC,19,113,233,16 + LTEXT "Hold Shift while pressing a color key for a translucent highlighter color. Press X for blur or Shift+X for a stronger blur.",IDC_STATIC,19,113,218,16 LTEXT "Shapes",IDC_SHAPES,7,134,23,8 - LTEXT "Draw a line by holding down the Shift key, a rectangle with the Ctrl key, an ellipse with the Tab key and an arrow with Shift+Ctrl.",IDC_STATIC,19,144,233,16 + LTEXT "Draw a line by holding down the Shift key, a rectangle with the Ctrl key, an ellipse with the Tab key and an arrow with Shift+Ctrl.",IDC_STATIC,19,144,218,16 LTEXT "Screen",IDC_SCREEN,7,166,22,8 - LTEXT "Clear the screen for a sketch pad by pressing W (white) or K (black). Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,19,176,233,24 + LTEXT "Clear the screen for a sketch pad by pressing W (white) or K (black). Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,19,176,218,24 CONTROL "",IDC_DRAW_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,73,207,80,12 LTEXT "Draw w/out Zoom:",IDC_STATIC,7,210,63,11 END TYPE DIALOGEX 0, 0, 260, 104 -STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_SYSMENU +STYLE DS_SETFONT | DS_CONTROL | WS_CHILD | WS_SYSMENU FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN - LTEXT "Once in drawing mode, type 't' to enter typing mode or shift+'t' to enter typing mode with right-aligned input. Exit typing mode by pressing escape or the left mouse button. Use the mouse wheel or up and down arrow keys to change the font size.",IDC_STATIC,7,7,246,32 - LTEXT "The text color is the current drawing color.",IDC_STATIC,7,47,211,9 + LTEXT "Once in drawing mode, type 't' to enter typing mode or shift+'t' to enter typing mode with right-aligned input. Exit typing mode by pressing escape or the left mouse button. Use the mouse wheel or up and down arrow keys to change the font size.",IDC_STATIC,7,7,230,32 + LTEXT "The text color is the current drawing color.",IDC_STATIC,7,47,230,9 PUSHBUTTON "&Font",IDC_FONT,112,69,41,14 - GROUPBOX "Text Font",IDC_TEXT_FONT,8,61,99,28 + GROUPBOX "Sample",IDC_TEXT_FONT,8,61,99,28 END BREAK DIALOGEX 0, 0, 260, 123 -STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_SYSMENU +STYLE DS_SETFONT | DS_CONTROL | WS_CHILD | WS_SYSMENU FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN CONTROL "",IDC_BREAK_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,52,67,80,12 - EDITTEXT IDC_TIMER,31,86,31,13,ES_RIGHT | ES_AUTOHSCROLL | ES_NUMBER - CONTROL "",IDC_SPIN_TIMER,"msctls_updown32",UDS_SETBUDDYINT | UDS_ALIGNRIGHT | UDS_AUTOBUDDY | UDS_ARROWKEYS | UDS_NOTHOUSANDS,45,86,11,12 - LTEXT "minutes",IDC_STATIC,67,88,25,8 - PUSHBUTTON "&Advanced",IDC_ADVANCED_BREAK,212,102,41,14 - LTEXT "Enter timer mode by using the ZoomIt tray icon's Break menu item. Increase and decrease time with the arrow keys. If you Alt-Tab away from the timer window, reactivate it by left-clicking on the ZoomIt tray icon. Exit timer mode with Escape. ",IDC_STATIC,7,7,246,33 + EDITTEXT IDC_TIMER,52,86,31,13,ES_RIGHT | ES_AUTOHSCROLL | ES_NUMBER + CONTROL "",IDC_SPIN_TIMER,"msctls_updown32",UDS_SETBUDDYINT | UDS_ALIGNRIGHT | UDS_AUTOBUDDY | UDS_ARROWKEYS | UDS_NOTHOUSANDS,66,86,11,12 + LTEXT "minutes",IDC_STATIC,88,88,25,8 + PUSHBUTTON "&Advanced",IDC_ADVANCED_BREAK,192,102,41,14 + LTEXT "Enter timer mode by using the ZoomIt tray icon's Break menu item. Increase and decrease time with the arrow keys. If you Alt-Tab away from the timer window, reactivate it by left-clicking on the ZoomIt tray icon. Exit timer mode with Escape. ",IDC_STATIC,7,7,230,33 LTEXT "Start Timer:",IDC_STATIC,7,70,39,8 LTEXT "Timer:",IDC_STATIC,7,88,20,8 - LTEXT "Change the break timer color using the same keys that the drawing color. The break timer font is the same as text font.",IDC_STATIC,7,45,219,20 + LTEXT "Change the break timer color using the same keys that the drawing color. The break timer font is the same as text font.",IDC_STATIC,7,45,230,20 CONTROL "Show Time Elapsed After Expiration:",IDC_CHECK_SHOW_EXPIRED, "Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,8,104,132,10 END @@ -251,69 +250,90 @@ BEGIN END LIVEZOOM DIALOGEX 0, 0, 260, 134 -STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_SYSMENU +STYLE DS_SETFONT | DS_CONTROL | WS_CHILD | WS_SYSMENU FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN CONTROL "",IDC_LIVE_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,69,108,80,12 - LTEXT "LiveZoom mode is supported on Windows 7 and higher where window updates show while zoomed. ",IDC_STATIC,7,7,246,18 + LTEXT "LiveZoom mode is supported on Windows 7 and higher where window updates show while zoomed. ",IDC_STATIC,7,7,230,18 LTEXT "LiveZoom Toggle:",IDC_STATIC,7,110,62,8 - LTEXT "To enter and exit LiveZoom, enter the hotkey specified below.",IDC_STATIC,7,94,218,13 - LTEXT "Note that in LiveZoom you must use Ctrl+Up and Ctrl+Down to control the zoom level. To enter drawing mode, use the standard zoom-without-draw hotkey and then escape to go back to LiveZoom.",IDC_STATIC,7,30,246,27 - LTEXT "Use LiveDraw to draw and annotate the live desktop. To activate LiveDraw, enter the hotkey with the Shift key in the opposite mode. You can remove LiveDraw annotations by activating LiveDraw and enter the escape key",IDC_STATIC,7,62,246,32 + LTEXT "To enter and exit LiveZoom, enter the hotkey specified below.",IDC_STATIC,7,94,230,13 + LTEXT "Note that in LiveZoom you must use Ctrl+Up and Ctrl+Down to control the zoom level. To enter drawing mode, use the standard zoom-without-draw hotkey and then escape to go back to LiveZoom.",IDC_STATIC,7,30,230,27 + LTEXT "Use LiveDraw to draw and annotate the live desktop. To activate LiveDraw, enter the hotkey with the Shift key in the opposite mode. You can remove LiveDraw annotations by activating LiveDraw and enter the escape key",IDC_STATIC,7,62,230,32 END -RECORD DIALOGEX 0, 0, 260, 169 +RECORD DIALOGEX 0, 0, 260, 181 STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_SYSMENU FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN CONTROL "",IDC_RECORD_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,61,96,80,12 LTEXT "Record Toggle:",IDC_STATIC,7,98,54,8 - LTEXT "Record video of the unzoomed live screen or a static zoomed session by entering the recording hot key and finish the recording by entering it again. ",IDC_STATIC,7,7,246,28 - LTEXT "Note: Recording is only available on Windows 10 (version 1903) and higher.",IDC_STATIC,7,77,246,19 + LTEXT "Record video of the unzoomed live screen or a static zoomed session by entering the recording hot key and finish the recording by entering it again. ",IDC_STATIC,7,7,248,28 + LTEXT "Note: Recording is only available on Windows 10 (version 1903) and higher.",IDC_STATIC,7,77,249,19 LTEXT "Scaling:",IDC_STATIC,30,115,26,8 COMBOBOX IDC_RECORD_SCALING,61,114,26,30,CBS_DROPDOWNLIST | CBS_AUTOHSCROLL | CBS_OEMCONVERT | CBS_SORT | WS_VSCROLL | WS_TABSTOP LTEXT "Format:",IDC_STATIC,30,132,26,8 COMBOBOX IDC_RECORD_FORMAT,61,131,60,30,CBS_DROPDOWNLIST | CBS_AUTOHSCROLL | CBS_OEMCONVERT | WS_VSCROLL | WS_TABSTOP LTEXT "Frame Rate:",IDC_STATIC,119,115,44,8,NOT WS_VISIBLE COMBOBOX IDC_RECORD_FRAME_RATE,166,114,42,30,CBS_DROPDOWNLIST | CBS_AUTOHSCROLL | CBS_OEMCONVERT | CBS_SORT | NOT WS_VISIBLE | WS_VSCROLL | WS_TABSTOP - LTEXT "To crop the portion of the screen that will be recorded, enter the hotkey with the Shift key in the opposite mode. ",IDC_STATIC,7,32,246,19 - LTEXT "To record a specific window, enter the hotkey with the Alt key in the opposite mode.",IDC_STATIC,7,55,246,19 - CONTROL "&Capture audio input:",IDC_CAPTURE_AUDIO,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,149,83,10 - COMBOBOX IDC_MICROPHONE,81,164,172,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP - LTEXT "Microphone:",IDC_STATIC,32,166,47,8 + LTEXT "To crop the portion of the screen that will be recorded, enter the hotkey with the Shift key in the opposite mode. ",IDC_STATIC,7,35,245,19 + LTEXT "To record a specific window, enter the hotkey with the Alt key in the opposite mode.",IDC_STATIC,7,55,251,19 + CONTROL "Capture &system audio",IDC_CAPTURE_SYSTEM_AUDIO,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,149,83,10 + CONTROL "&Capture audio input:",IDC_CAPTURE_AUDIO,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,161,83,10 + COMBOBOX IDC_MICROPHONE,81,176,152,30,CBS_DROPDOWNLIST | WS_VSCROLL | WS_TABSTOP + LTEXT "Microphone:",IDC_MICROPHONE_LABEL,32,178,47,8 END SNIP DIALOGEX 0, 0, 260, 68 -STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU +STYLE DS_SETFONT | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN CONTROL "",IDC_SNIP_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,55,32,80,12 LTEXT "Snip Toggle:",IDC_STATIC,7,33,45,8 - LTEXT "Copy a region of the screen to the clipboard or enter the hotkey with the Shift key in the opposite mode to save it to a file. ",IDC_STATIC,7,7,246,19 + LTEXT "Copy a region of the screen to the clipboard or enter the hotkey with the Shift key in the opposite mode to save it to a file. ",IDC_STATIC,7,7,230,19 END -DEMOTYPE DIALOGEX 0, 0, 259, 249 -STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU +DEMOTYPE DIALOGEX 0, 0, 260, 249 +STYLE DS_SETFONT | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU FONT 8, "MS Shell Dlg", 400, 0, 0x1 BEGIN CONTROL "",IDC_DEMOTYPE_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,74,154,80,12 LTEXT "DemoType toggle:",IDC_STATIC,7,157,63,8 - PUSHBUTTON "&...",IDC_DEMOTYPE_BROWSE,231,137,16,13 + PUSHBUTTON "&...",IDC_DEMOTYPE_BROWSE,211,137,16,13 CONTROL "",IDC_DEMOTYPE_SPEED_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_BOTH | TBS_NOTICKS | WS_TABSTOP,52,202,150,11,WS_EX_TRANSPARENT CONTROL "Drive input with typing:",IDC_DEMOTYPE_USER_DRIVEN, "Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,173,88,10 - LTEXT "DemoType typing speed:",IDC_STATIC,7,189,215,10 + LTEXT "DemoType typing speed:",IDC_STATIC,7,189,230,10 LTEXT "Slow",IDC_DEMOTYPE_STATIC1,51,213,18,8 LTEXT "Fast",IDC_DEMOTYPE_STATIC2,186,213,17,8 - EDITTEXT IDC_DEMOTYPE_FILE,44,137,187,12,ES_AUTOHSCROLL | ES_READONLY + EDITTEXT IDC_DEMOTYPE_FILE,44,137,167,12,ES_AUTOHSCROLL | ES_READONLY LTEXT "Input file:",IDC_STATIC,7,139,32,8 - LTEXT "When you reach the end of the file, ZoomIt will reload the file and start at the beginning. Enter the hotkey with the Shift key in the opposite mode to step back to the last [end].",IDC_STATIC,7,108,248,24 - LTEXT "DemoType has ZoomIt type text specified in the input file when you enter the DemoType toggle. Simply separate snippets with the [end] keyword, or you can insert text from the clipboard if it is prefixed with the [start].",IDC_STATIC,7,7,248,24 - LTEXT " - Insert pauses with the [pause:n] keyword where 'n' is seconds. ",IDC_STATIC,19,34,212,11 - LTEXT "You can have ZoomIt send text automatically, or select the option to drive input with typing. ZoomIt will block keyboard input while sending output.",IDC_STATIC,7,68,248,16 - LTEXT "When driving input, hit the space bar to unblock keyboard input at the end of a snippet. In auto mode, control will be returned upon completion.",IDC_STATIC,7,88,248,16 - LTEXT "- Send text via the clipboard with [paste] and [/paste]. ",IDC_STATIC,23,45,178,8 - LTEXT "- Send keystrokes with [enter], [up], [down], [left], and [right].",IDC_STATIC,23,56,211,8 + LTEXT "When you reach the end of the file, ZoomIt will reload the file and start at the beginning. Enter the hotkey with the Shift key in the opposite mode to step back to the last [end].",IDC_STATIC,7,108,230,24 + LTEXT "DemoType has ZoomIt type text specified in the input file when you enter the DemoType toggle. Simply separate snippets with the [end] keyword, or you can insert text from the clipboard if it is prefixed with the [start].",IDC_STATIC,7,7,230,24 + LTEXT " - Insert pauses with the [pause:n] keyword where 'n' is seconds. ",IDC_STATIC,19,34,218,11 + LTEXT "You can have ZoomIt send text automatically, or select the option to drive input with typing. ZoomIt will block keyboard input while sending output.",IDC_STATIC,7,68,230,16 + LTEXT "When driving input, hit the space bar to unblock keyboard input at the end of a snippet. In auto mode, control will be returned upon completion.",IDC_STATIC,7,88,230,16 + LTEXT "- Send text via the clipboard with [paste] and [/paste]. ",IDC_STATIC,23,45,210,8 + LTEXT "- Send keystrokes with [enter], [up], [down], [left], and [right].",IDC_STATIC,23,56,210,8 +END + +IDD_VIDEO_TRIM DIALOGEX 0, 0, 521, 380 +STYLE DS_SETFONT | DS_MODALFRAME | DS_FIXEDSYS | DS_CENTER | WS_POPUP | WS_CAPTION | WS_SYSMENU | WS_THICKFRAME +CAPTION "ZoomIt Video Trim" +FONT 8, "MS Shell Dlg", 400, 0, 0x1 +BEGIN + LTEXT "",IDC_TRIM_DURATION_LABEL,12,267,160,8 + CONTROL "",IDC_TRIM_PREVIEW,"Static",SS_OWNERDRAW | SS_NOTIFY,12,12,498,244 + CTEXT "00:00.000",IDC_TRIM_POSITION_LABEL,155,267,200,8 + CONTROL "",IDC_TRIM_TIMELINE,"Static",SS_OWNERDRAW | SS_NOTIFY,11,277,498,47,WS_EX_TRANSPARENT + CONTROL "",IDC_TRIM_SKIP_START,"Button",BS_OWNERDRAW | WS_TABSTOP,183,327,30,26 + CONTROL "",IDC_TRIM_REWIND,"Button",BS_OWNERDRAW | WS_TABSTOP,215,327,30,26 + CONTROL "",IDC_TRIM_PLAY_PAUSE,"Button",BS_OWNERDRAW | WS_TABSTOP,247,325,44,32 + CONTROL "",IDC_TRIM_FORWARD,"Button",BS_OWNERDRAW | WS_TABSTOP,293,327,30,26 + CONTROL "",IDC_TRIM_SKIP_END,"Button",BS_OWNERDRAW | WS_TABSTOP,325,327,30,26 + CONTROL "",IDC_TRIM_VOLUME_ICON,"Static",SS_OWNERDRAW | SS_NOTIFY,365,334,14,12 + CONTROL "",IDC_TRIM_VOLUME,"msctls_trackbar32",TBS_NOTICKS | WS_TABSTOP,380,333,70,14 + DEFPUSHBUTTON "OK",IDOK,404,358,50,14 + PUSHBUTTON "Cancel",IDCANCEL,458,358,50,14 END @@ -327,7 +347,7 @@ GUIDELINES DESIGNINFO BEGIN "OPTIONS", DIALOG BEGIN - RIGHTMARGIN, 273 + RIGHTMARGIN, 293 BOTTOMMARGIN, 320 END @@ -340,7 +360,6 @@ BEGIN "ZOOM", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 151 END @@ -348,7 +367,6 @@ BEGIN "DRAW", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 221 END @@ -356,7 +374,6 @@ BEGIN "TYPE", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 97 END @@ -364,7 +381,6 @@ BEGIN "BREAK", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 116 END @@ -378,7 +394,6 @@ BEGIN "LIVEZOOM", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 127 END @@ -386,7 +401,6 @@ BEGIN "RECORD", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 164 END @@ -394,7 +408,6 @@ BEGIN "SNIP", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 253 TOPMARGIN, 7 BOTTOMMARGIN, 61 END @@ -402,10 +415,13 @@ BEGIN "DEMOTYPE", DIALOG BEGIN LEFTMARGIN, 7 - RIGHTMARGIN, 255 TOPMARGIN, 7 BOTTOMMARGIN, 205 END + + IDD_VIDEO_TRIM, DIALOG + BEGIN + END END #endif // APSTUDIO_INVOKED @@ -474,6 +490,11 @@ BEGIN 0 END +IDD_VIDEO_TRIM AFX_DIALOG_LAYOUT +BEGIN + 0 +END + #endif // English (United States) resources ///////////////////////////////////////////////////////////////////////////// diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj b/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj index 77c299f303..6dbacd0016 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj +++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj @@ -216,6 +216,14 @@ false false + + false + false + false + false + false + false + NotUsing NotUsing @@ -293,6 +301,7 @@ + diff --git a/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj.filters b/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj.filters index e0416fe585..2bd93a7095 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj.filters +++ b/src/modules/ZoomIt/ZoomIt/ZoomIt.vcxproj.filters @@ -33,6 +33,9 @@ Source Files + + Source Files + Source Files @@ -80,6 +83,9 @@ Header Files + + Header Files + Header Files diff --git a/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h b/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h index efd731cdce..e7176e6ec8 100644 --- a/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h +++ b/src/modules/ZoomIt/ZoomIt/ZoomItSettings.h @@ -49,8 +49,15 @@ DWORD g_RecordScaling = 100; DWORD g_RecordScalingGIF = 50; DWORD g_RecordScalingMP4 = 100; RecordingFormat g_RecordingFormat = RecordingFormat::MP4; +BOOLEAN g_CaptureSystemAudio = TRUE; BOOLEAN g_CaptureAudio = FALSE; TCHAR g_MicrophoneDeviceId[MAX_PATH] = {0}; +TCHAR g_RecordingSaveLocationBuffer[MAX_PATH] = {0}; +TCHAR g_ScreenshotSaveLocationBuffer[MAX_PATH] = {0}; +DWORD g_ThemeOverride = 2; // 0=light, 1=dark, 2=system default +DWORD g_TrimDialogWidth = 0; // 0 means use default; stored in screen pixels +DWORD g_TrimDialogHeight = 0; // 0 means use default; stored in screen pixels +DWORD g_TrimDialogVolume = 70; // 0-100 volume level for trim dialog preview REG_SETTING RegSettings[] = { { L"ToggleKey", SETTING_TYPE_DWORD, 0, &g_ToggleKey, static_cast(g_ToggleKey) }, @@ -91,6 +98,13 @@ REG_SETTING RegSettings[] = { { L"RecordScalingGIF", SETTING_TYPE_DWORD, 0, &g_RecordScalingGIF, static_cast(g_RecordScalingGIF) }, { L"RecordScalingMP4", SETTING_TYPE_DWORD, 0, &g_RecordScalingMP4, static_cast(g_RecordScalingMP4) }, { L"CaptureAudio", SETTING_TYPE_BOOLEAN, 0, &g_CaptureAudio, static_cast(g_CaptureAudio) }, + { L"CaptureSystemAudio", SETTING_TYPE_BOOLEAN, 0, &g_CaptureSystemAudio, static_cast(g_CaptureSystemAudio) }, { L"MicrophoneDeviceId", SETTING_TYPE_STRING, sizeof(g_MicrophoneDeviceId), g_MicrophoneDeviceId, static_cast(0) }, + { L"RecordingSaveLocation", SETTING_TYPE_STRING, sizeof(g_RecordingSaveLocationBuffer), g_RecordingSaveLocationBuffer, static_cast(0) }, + { L"ScreenshotSaveLocation", SETTING_TYPE_STRING, sizeof(g_ScreenshotSaveLocationBuffer), g_ScreenshotSaveLocationBuffer, static_cast(0) }, + { L"Theme", SETTING_TYPE_DWORD, 0, &g_ThemeOverride, static_cast(g_ThemeOverride) }, + { L"TrimDialogWidth", SETTING_TYPE_DWORD, 0, &g_TrimDialogWidth, static_cast(0) }, + { L"TrimDialogHeight", SETTING_TYPE_DWORD, 0, &g_TrimDialogHeight, static_cast(0) }, + { L"TrimDialogVolume", SETTING_TYPE_DWORD, 0, &g_TrimDialogVolume, static_cast(g_TrimDialogVolume) }, { NULL, SETTING_TYPE_DWORD, 0, NULL, static_cast(0) } }; diff --git a/src/modules/ZoomIt/ZoomIt/Zoomit.cpp b/src/modules/ZoomIt/ZoomIt/Zoomit.cpp index e38ca07f66..68731f1a98 100644 --- a/src/modules/ZoomIt/ZoomIt/Zoomit.cpp +++ b/src/modules/ZoomIt/ZoomIt/Zoomit.cpp @@ -85,6 +85,10 @@ COLORREF g_CustomColors[16]; #define DEMOTYPE_RESET_HOTKEY 11 #define RECORD_GIF_HOTKEY 12 #define RECORD_GIF_WINDOW_HOTKEY 13 +#define SAVE_IMAGE_HOTKEY 14 +#define SAVE_CROP_HOTKEY 15 +#define COPY_IMAGE_HOTKEY 16 +#define COPY_CROP_HOTKEY 17 #define ZOOM_PAGE 0 #define LIVE_PAGE 1 @@ -177,12 +181,13 @@ BOOLEAN g_running = TRUE; // Screen recording globals #define DEFAULT_RECORDING_FILE L"Recording.mp4" #define DEFAULT_GIF_RECORDING_FILE L"Recording.gif" +#define DEFAULT_SCREENSHOT_FILE L"ZoomIt.png" BOOL g_RecordToggle = FALSE; BOOL g_RecordCropping = FALSE; SelectRectangle g_SelectRectangle; std::wstring g_RecordingSaveLocation; -std::wstring g_RecordingSaveLocationGIF; +std::wstring g_ScreenshotSaveLocation; winrt::IDirect3DDevice g_RecordDevice{ nullptr }; std::shared_ptr g_RecordingSession = nullptr; std::shared_ptr g_GifRecordingSession = nullptr; @@ -217,6 +222,74 @@ ClassRegistry reg( _T("Software\\Sysinternals\\") APPNAME ); ComputerGraphicsInit g_GraphicsInit; +// Event handler to set icon and extended style on dialog creation +class OpenSaveDialogEvents : public IFileDialogEvents +{ +public: + OpenSaveDialogEvents(bool showOnTaskbar = true) : m_refCount(1), m_initialized(false), m_showOnTaskbar(showOnTaskbar) {} + + // IUnknown + IFACEMETHODIMP QueryInterface(REFIID riid, void** ppv) + { + static const QITAB qit[] = { + QITABENT(OpenSaveDialogEvents, IFileDialogEvents), + { 0 }, + }; + return QISearch(this, qit, riid, ppv); + } + IFACEMETHODIMP_(ULONG) AddRef() { return InterlockedIncrement(&m_refCount); } + IFACEMETHODIMP_(ULONG) Release() + { + ULONG count = InterlockedDecrement(&m_refCount); + if (count == 0) delete this; + return count; + } + + // IFileDialogEvents + IFACEMETHODIMP OnFileOk(IFileDialog*) { return S_OK; } + IFACEMETHODIMP OnFolderChange(IFileDialog* pfd) + { + if (!m_initialized) + { + m_initialized = true; + wil::com_ptr pWindow; + if (SUCCEEDED(pfd->QueryInterface(IID_PPV_ARGS(&pWindow)))) + { + HWND hwndDialog = nullptr; + if (SUCCEEDED(pWindow->GetWindow(&hwndDialog)) && hwndDialog) + { + if (m_showOnTaskbar) + { + // Set WS_EX_APPWINDOW extended style + LONG_PTR exStyle = GetWindowLongPtr(hwndDialog, GWL_EXSTYLE); + SetWindowLongPtr(hwndDialog, GWL_EXSTYLE, exStyle | WS_EX_APPWINDOW); + } + + // Set the dialog icon + HICON hIcon = LoadIcon(g_hInstance, L"APPICON"); + if (hIcon) + { + SendMessage(hwndDialog, WM_SETICON, ICON_BIG, reinterpret_cast(hIcon)); + SendMessage(hwndDialog, WM_SETICON, ICON_SMALL, reinterpret_cast(hIcon)); + } + } + } + } + return S_OK; + } + IFACEMETHODIMP OnFolderChanging(IFileDialog*, IShellItem*) { return S_OK; } + IFACEMETHODIMP OnSelectionChange(IFileDialog*) { return S_OK; } + IFACEMETHODIMP OnShareViolation(IFileDialog*, IShellItem*, FDE_SHAREVIOLATION_RESPONSE*) { return S_OK; } + IFACEMETHODIMP OnTypeChange(IFileDialog*) { return S_OK; } + IFACEMETHODIMP OnOverwrite(IFileDialog*, IShellItem*, FDE_OVERWRITE_RESPONSE*) { return S_OK; } + +private: + LONG m_refCount; + bool m_initialized; + bool m_showOnTaskbar; +}; + + //---------------------------------------------------------------------------- // // Saves specified filePath to clipboard. @@ -1415,7 +1488,7 @@ HBITMAP LoadImageFile( PTCHAR Filename ) // Use gdi+ to save a PNG. // //---------------------------------------------------------------------------- -DWORD SavePng( PTCHAR Filename, HBITMAP hBitmap ) +DWORD SavePng( LPCTSTR Filename, HBITMAP hBitmap ) { Gdiplus::Bitmap bitmap( hBitmap, NULL ); CLSID pngClsid; @@ -1554,10 +1627,19 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR static TCHAR newBackgroundFile[MAX_PATH]; TCHAR filePath[MAX_PATH], initDir[MAX_PATH]; DWORD i; - OPENFILENAME openFileName; + static UINT currentDpi = DPI_BASELINE; switch ( message ) { case WM_INITDIALOG: + // Set the dialog icon + { + HICON hIcon = LoadIcon( g_hInstance, L"APPICON" ); + if( hIcon ) + { + SendMessage( hDlg, WM_SETICON, ICON_BIG, reinterpret_cast(hIcon) ); + SendMessage( hDlg, WM_SETICON, ICON_SMALL, reinterpret_cast(hIcon) ); + } + } if( pSHAutoComplete ) { pSHAutoComplete( GetDlgItem( hDlg, IDC_SOUND_FILE), SHACF_FILESYSTEM ); pSHAutoComplete( GetDlgItem( hDlg, IDC_BACKGROUND_FILE), SHACF_FILESYSTEM ); @@ -1617,8 +1699,49 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR } SendMessage( GetDlgItem( hDlg, IDC_OPACITY ), CB_SETCURSEL, g_BreakOpacity / 10 - 1, 0 ); + + // Apply DPI scaling to the dialog + currentDpi = GetDpiForWindowHelper( hDlg ); + if( currentDpi != DPI_BASELINE ) + { + ScaleDialogForDpi( hDlg, currentDpi, DPI_BASELINE ); + } + + // Apply dark mode + ApplyDarkModeToDialog( hDlg ); return TRUE; + case WM_DPICHANGED: + HandleDialogDpiChange( hDlg, wParam, lParam, currentDpi ); + return TRUE; + + case WM_ERASEBKGND: + if (IsDarkModeEnabled()) + { + HDC hdc = reinterpret_cast(wParam); + RECT rc; + GetClientRect(hDlg, &rc); + FillRect(hdc, &rc, GetDarkModeBrush()); + return TRUE; + } + break; + + case WM_CTLCOLORDLG: + case WM_CTLCOLORSTATIC: + case WM_CTLCOLORBTN: + case WM_CTLCOLOREDIT: + case WM_CTLCOLORLISTBOX: + { + HDC hdc = reinterpret_cast(wParam); + HWND hCtrl = reinterpret_cast(lParam); + HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); + if (hBrush) + { + return reinterpret_cast(hBrush); + } + break; + } + case WM_COMMAND: switch ( HIWORD( wParam )) { case BN_CLICKED: @@ -1648,77 +1771,126 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR } switch ( LOWORD( wParam )) { case IDC_SOUND_BROWSE: - memset( &openFileName, 0, sizeof(openFileName )); - openFileName.lStructSize = OPENFILENAME_SIZE_VERSION_400; - openFileName.hwndOwner = hDlg; - openFileName.hInstance = static_cast(g_hInstance); - openFileName.nMaxFile = sizeof(filePath)/sizeof(filePath[0]); - openFileName.Flags = OFN_LONGNAMES; - openFileName.lpstrTitle = L"Specify sound file..."; - openFileName.lpstrDefExt = L"*.wav"; - openFileName.nFilterIndex = 1; - openFileName.lpstrFilter = L"Sounds\0*.wav\0All Files\0*.*\0"; + { + auto openDialog = wil::CoCreateInstance( CLSID_FileOpenDialog ); - GetDlgItemText( hDlg, IDC_SOUND_FILE, filePath, sizeof(filePath )); - if( _tcsrchr( filePath, '\\' )) { + FILEOPENDIALOGOPTIONS options; + if( SUCCEEDED( openDialog->GetOptions( &options ) ) ) + openDialog->SetOptions( options | FOS_FORCEFILESYSTEM ); + COMDLG_FILTERSPEC fileTypes[] = { + { L"Sounds", L"*.wav" }, + { L"All Files", L"*.*" } + }; + openDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); + openDialog->SetFileTypeIndex( 1 ); + openDialog->SetDefaultExtension( L"wav" ); + openDialog->SetTitle( L"ZoomIt: Specify Sound File..." ); + + // Set initial folder + GetDlgItemText( hDlg, IDC_SOUND_FILE, filePath, _countof( filePath ) ); + if( _tcsrchr( filePath, '\\' ) ) + { _tcscpy( initDir, filePath ); - _tcscpy( filePath, _tcsrchr( initDir, '\\' )+1); - *(_tcsrchr( initDir, '\\' )+1) = 0; - } else { - + *( _tcsrchr( initDir, '\\' ) + 1 ) = 0; + } + else + { _tcscpy( filePath, L"%WINDIR%\\Media" ); - ExpandEnvironmentStrings( filePath, initDir, sizeof(initDir)/sizeof(initDir[0])); - GetDlgItemText( hDlg, IDC_SOUND_FILE, filePath, sizeof(filePath )); + ExpandEnvironmentStrings( filePath, initDir, _countof( initDir ) ); + } + wil::com_ptr folderItem; + if( SUCCEEDED( SHCreateItemFromParsingName( initDir, nullptr, IID_PPV_ARGS( &folderItem ) ) ) ) + { + openDialog->SetFolder( folderItem.get() ); } - openFileName.lpstrInitialDir = initDir; - openFileName.lpstrFile = filePath; - if( GetOpenFileName( &openFileName )) { - _tcscpy( newSoundFile, filePath ); - if(_tcsrchr( filePath, '\\' )) _tcscpy( filePath, _tcsrchr( newSoundFile, '\\' )+1); - if(_tcsrchr( filePath, '.' )) *_tcsrchr( filePath, '.' ) = 0; - SetDlgItemText( hDlg, IDC_SOUND_FILE, filePath ); + OpenSaveDialogEvents* pEvents = new OpenSaveDialogEvents(false); + DWORD dwCookie = 0; + openDialog->Advise( pEvents, &dwCookie ); + + if( SUCCEEDED( openDialog->Show( hDlg ) ) ) + { + wil::com_ptr resultItem; + if( SUCCEEDED( openDialog->GetResult( &resultItem ) ) ) + { + wil::unique_cotaskmem_string pathStr; + if( SUCCEEDED( resultItem->GetDisplayName( SIGDN_FILESYSPATH, &pathStr ) ) ) + { + _tcscpy( newSoundFile, pathStr.get() ); + _tcscpy( filePath, pathStr.get() ); + if( _tcsrchr( filePath, '\\' ) ) _tcscpy( filePath, _tcsrchr( filePath, '\\' ) + 1 ); + if( _tcsrchr( filePath, '.' ) ) *_tcsrchr( filePath, '.' ) = 0; + SetDlgItemText( hDlg, IDC_SOUND_FILE, filePath ); + } + } } + + openDialog->Unadvise( dwCookie ); + pEvents->Release(); break; + } case IDC_BACKGROUND_BROWSE: - memset( &openFileName, 0, sizeof(openFileName )); - openFileName.lStructSize = OPENFILENAME_SIZE_VERSION_400; - openFileName.hwndOwner = hDlg; - openFileName.hInstance = static_cast(g_hInstance); - openFileName.nMaxFile = sizeof(filePath)/sizeof(filePath[0]); - openFileName.Flags = OFN_LONGNAMES; - openFileName.lpstrTitle = L"Specify background file..."; - openFileName.lpstrDefExt = L"*.bmp"; - openFileName.nFilterIndex = 5; - openFileName.lpstrFilter = L"Bitmap Files (*.bmp;*.dib)\0*.bmp;*.dib\0" - "PNG (*.png)\0*.png\0" - "JPEG (*.jpg;*.jpeg;*.jpe;*.jfif)\0*.jpg;*.jpeg;*.jpe;*.jfif\0" - "GIF (*.gif)\0*.gif\0" - "All Picture Files\0.bmp;*.dib;*.png;*.jpg;*.jpeg;*.jpe;*.jfif;*.gif)\0" - "All Files\0*.*\0\0"; + { + auto openDialog = wil::CoCreateInstance( CLSID_FileOpenDialog ); - GetDlgItemText( hDlg, IDC_BACKGROUND_FILE, filePath, sizeof(filePath )); - if(_tcsrchr( filePath, '\\' )) { + FILEOPENDIALOGOPTIONS options; + if( SUCCEEDED( openDialog->GetOptions( &options ) ) ) + openDialog->SetOptions( options | FOS_FORCEFILESYSTEM ); + COMDLG_FILTERSPEC fileTypes[] = { + { L"Bitmap Files (*.bmp;*.dib)", L"*.bmp;*.dib" }, + { L"PNG (*.png)", L"*.png" }, + { L"JPEG (*.jpg;*.jpeg;*.jpe;*.jfif)", L"*.jpg;*.jpeg;*.jpe;*.jfif" }, + { L"GIF (*.gif)", L"*.gif" }, + { L"All Picture Files", L"*.bmp;*.dib;*.png;*.jpg;*.jpeg;*.jpe;*.jfif;*.gif" }, + { L"All Files", L"*.*" } + }; + openDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); + openDialog->SetFileTypeIndex( 5 ); // Default to "All Picture Files" + openDialog->SetTitle( L"ZoomIt: Specify Background File..." ); + + // Set initial folder + GetDlgItemText( hDlg, IDC_BACKGROUND_FILE, filePath, _countof( filePath ) ); + if( _tcsrchr( filePath, '\\' ) ) + { _tcscpy( initDir, filePath ); - _tcscpy( filePath, _tcsrchr( initDir, '\\' )+1); - *(_tcsrchr( initDir, '\\' )+1) = 0; - } else { - + *( _tcsrchr( initDir, '\\' ) + 1 ) = 0; + } + else + { _tcscpy( filePath, L"%USERPROFILE%\\Pictures" ); - ExpandEnvironmentStrings( filePath, initDir, sizeof(initDir)/sizeof(initDir[0])); - GetDlgItemText( hDlg, IDC_BACKGROUND_FILE, filePath, sizeof(filePath )); + ExpandEnvironmentStrings( filePath, initDir, _countof( initDir ) ); + } + wil::com_ptr folderItem; + if( SUCCEEDED( SHCreateItemFromParsingName( initDir, nullptr, IID_PPV_ARGS( &folderItem ) ) ) ) + { + openDialog->SetFolder( folderItem.get() ); } - openFileName.lpstrInitialDir = initDir; - openFileName.lpstrFile = filePath; - if( GetOpenFileName( &openFileName )) { - _tcscpy( newBackgroundFile, filePath ); - SetDlgItemText( hDlg, IDC_BACKGROUND_FILE, filePath ); + OpenSaveDialogEvents* pEvents = new OpenSaveDialogEvents(false); + DWORD dwCookie = 0; + openDialog->Advise( pEvents, &dwCookie ); + + if( SUCCEEDED( openDialog->Show( hDlg ) ) ) + { + wil::com_ptr resultItem; + if( SUCCEEDED( openDialog->GetResult( &resultItem ) ) ) + { + wil::unique_cotaskmem_string pathStr; + if( SUCCEEDED( resultItem->GetDisplayName( SIGDN_FILESYSPATH, &pathStr ) ) ) + { + _tcscpy( newBackgroundFile, pathStr.get() ); + SetDlgItemText( hDlg, IDC_BACKGROUND_FILE, pathStr.get() ); + } + } } + + openDialog->Unadvise( dwCookie ); + pEvents->Release(); break; + } case IDOK: @@ -1780,6 +1952,45 @@ INT_PTR CALLBACK AdvancedBreakProc( HWND hDlg, UINT message, WPARAM wParam, LPAR // OptionsTabProc // //---------------------------------------------------------------------------- + +static UINT_PTR CALLBACK ChooseFontHookProc(HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam) +{ + switch (message) + { + case WM_INITDIALOG: + // Set the dialog icon + { + HICON hIcon = LoadIcon( g_hInstance, L"APPICON" ); + if( hIcon ) + { + SendMessage( hDlg, WM_SETICON, ICON_BIG, reinterpret_cast(hIcon) ); + SendMessage( hDlg, WM_SETICON, ICON_SMALL, reinterpret_cast(hIcon) ); + } + } + // Basic (incomplete) dark mode attempt: theme the main common dialog window. + ApplyDarkModeToDialog(hDlg); + return 0; + + case WM_CTLCOLORDLG: + case WM_CTLCOLORSTATIC: + case WM_CTLCOLORBTN: + case WM_CTLCOLOREDIT: + case WM_CTLCOLORLISTBOX: + { + HDC hdc = reinterpret_cast(wParam); + HWND hCtrl = reinterpret_cast(lParam); + HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); + if (hBrush) + { + return reinterpret_cast(hBrush); + } + break; + } + } + + return 0; +} + INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam ) { @@ -1791,12 +2002,38 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, HWND hTextPreview; HDC hDc; RECT previewRc; - TCHAR filePath[MAX_PATH] = {0}; - OPENFILENAME openFileName; switch ( message ) { case WM_INITDIALOG: return TRUE; + + case WM_ERASEBKGND: + if (IsDarkModeEnabled()) + { + HDC hdc = reinterpret_cast(wParam); + RECT rc; + GetClientRect(hDlg, &rc); + FillRect(hdc, &rc, GetDarkModeBrush()); + return TRUE; + } + break; + + case WM_CTLCOLORDLG: + case WM_CTLCOLORSTATIC: + case WM_CTLCOLORBTN: + case WM_CTLCOLOREDIT: + case WM_CTLCOLORLISTBOX: + { + HDC hdc = reinterpret_cast(wParam); + HWND hCtrl = reinterpret_cast(lParam); + HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); + if (hBrush) + { + return reinterpret_cast(hBrush); + } + break; + } + case WM_COMMAND: // Handle combo box selection changes if (HIWORD(wParam) == CBN_SELCHANGE) { @@ -1826,7 +2063,7 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, // Check if GIF is selected by comparing the text bool isGifSelected = (wcscmp(selectedText, L"GIF") == 0); - // If GIF is selected, set the scaling to the g_RecordScalingGIF value; otherwise to the g_RecordScalingMP4 value + // If GIF is selected, set the scaling to the g_RecordScalingGIF value if (isGifSelected) { g_RecordScaling = g_RecordScalingGIF; @@ -1844,9 +2081,11 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, } } - // Enable/disable microphone controls based on selection - EnableWindow(GetDlgItem(hDlg, IDC_MICROPHONE), !isGifSelected); + // Enable/disable audio controls based on selection (GIF has no audio) + EnableWindow(GetDlgItem(hDlg, IDC_CAPTURE_SYSTEM_AUDIO), !isGifSelected); EnableWindow(GetDlgItem(hDlg, IDC_CAPTURE_AUDIO), !isGifSelected); + EnableWindow(GetDlgItem(hDlg, IDC_MICROPHONE_LABEL), !isGifSelected); + EnableWindow(GetDlgItem(hDlg, IDC_MICROPHONE), !isGifSelected); } } @@ -1863,7 +2102,7 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, chooseFont.lStructSize = sizeof (CHOOSEFONT); chooseFont.hwndOwner = hDlg; chooseFont.lpLogFont = &lf; - chooseFont.Flags = CF_SCREENFONTS|CF_ENABLETEMPLATE| + chooseFont.Flags = CF_SCREENFONTS|CF_ENABLETEMPLATE|CF_ENABLEHOOK| CF_INITTOLOGFONTSTRUCT|CF_LIMITSIZE; chooseFont.rgbColors = RGB (0, 0, 0); chooseFont.lCustData = 0; @@ -1872,7 +2111,7 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, chooseFont.hInstance = g_hInstance; chooseFont.lpszStyle = static_cast(NULL); chooseFont.nFontType = SCREEN_FONTTYPE; - chooseFont.lpfnHook = reinterpret_cast(static_cast(NULL)); + chooseFont.lpfnHook = ChooseFontHookProc; chooseFont.lpTemplateName = static_cast(MAKEINTRESOURCE (FORMATDLGORD31)); if( ChooseFont( &chooseFont ) ) { g_LogFont = lf; @@ -1880,31 +2119,50 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, } break; case IDC_DEMOTYPE_BROWSE: - memset( &openFileName, 0, sizeof( openFileName ) ); - openFileName.lStructSize = OPENFILENAME_SIZE_VERSION_400; - openFileName.hwndOwner = hDlg; - openFileName.hInstance = static_cast(g_hInstance); - openFileName.nMaxFile = sizeof( filePath ) / sizeof( filePath[0] ); - openFileName.Flags = OFN_LONGNAMES; - openFileName.lpstrTitle = L"Specify DemoType file..."; - openFileName.nFilterIndex = 1; - openFileName.lpstrFilter = L"All Files\0*.*\0\0"; - openFileName.lpstrFile = filePath; + { + auto openDialog = wil::CoCreateInstance( CLSID_FileOpenDialog ); - if( GetOpenFileName( &openFileName ) ) + FILEOPENDIALOGOPTIONS options; + if( SUCCEEDED( openDialog->GetOptions( &options ) ) ) + openDialog->SetOptions( options | FOS_FORCEFILESYSTEM ); + + COMDLG_FILTERSPEC fileTypes[] = { + { L"All Files", L"*.*" } + }; + openDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); + openDialog->SetFileTypeIndex( 1 ); + openDialog->SetTitle( L"ZoomIt: Specify DemoType File..." ); + + OpenSaveDialogEvents* pEvents = new OpenSaveDialogEvents(false); + DWORD dwCookie = 0; + openDialog->Advise( pEvents, &dwCookie ); + + if( SUCCEEDED( openDialog->Show( hDlg ) ) ) { - if( GetFileAttributes( filePath ) == -1 ) + wil::com_ptr resultItem; + if( SUCCEEDED( openDialog->GetResult( &resultItem ) ) ) { - MessageBox( hDlg, L"The specified file is inaccessible", APPNAME, MB_ICONERROR ); - } - else - { - SetDlgItemText( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_FILE, filePath ); - _tcscpy( g_DemoTypeFile, filePath ); + wil::unique_cotaskmem_string pathStr; + if( SUCCEEDED( resultItem->GetDisplayName( SIGDN_FILESYSPATH, &pathStr ) ) ) + { + if( GetFileAttributes( pathStr.get() ) == INVALID_FILE_ATTRIBUTES ) + { + MessageBox( hDlg, L"The specified file is inaccessible", APPNAME, MB_ICONERROR ); + } + else + { + SetDlgItemText( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_FILE, pathStr.get() ); + _tcscpy( g_DemoTypeFile, pathStr.get() ); + } + } } } + + openDialog->Unadvise( dwCookie ); + pEvents->Release(); break; } + } break; case WM_PAINT: @@ -1921,7 +2179,15 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, MapWindowPoints( NULL, hDlg, reinterpret_cast(&previewRc), 2); previewRc.top += 6; - DrawText( hDc, L"Sample", static_cast(_tcslen(L"Sample")), &previewRc, + + // Set text color based on dark mode + if (IsDarkModeEnabled()) + { + SetTextColor(hDc, DarkMode::TextColor); + SetBkMode(hDc, TRANSPARENT); + } + + DrawText( hDc, L"AaBbYyZz", static_cast(_tcslen(L"AaBbYyZz")), &previewRc, DT_CENTER|DT_VCENTER|DT_SINGLELINE ); EndPaint( hDlg, &ps ); @@ -1935,6 +2201,46 @@ INT_PTR CALLBACK OptionsTabProc( HWND hDlg, UINT message, } +//---------------------------------------------------------------------------- +// +// RepositionTabPages +// +// Reposition tab pages to fit current tab control size (call after DPI change) +// +//---------------------------------------------------------------------------- +VOID RepositionTabPages( HWND hTabCtrl ) +{ + RECT rc, pageRc; + GetWindowRect( hTabCtrl, &rc ); + TabCtrl_AdjustRect( hTabCtrl, FALSE, &rc ); + + // Inset the display area to leave room for border in dark mode + if (IsDarkModeEnabled()) + { + rc.left += 2; + rc.top += 2; + rc.right -= 2; + rc.bottom -= 2; + } + + // Get the parent dialog to convert coordinates correctly + HWND hParent = GetParent( hTabCtrl ); + + for( int i = 0; i < sizeof( g_OptionsTabs )/sizeof(g_OptionsTabs[0]); i++ ) { + if( g_OptionsTabs[i].hPage ) { + pageRc = rc; + // Convert screen coords to parent dialog client coords + MapWindowPoints( NULL, hParent, reinterpret_cast(&pageRc), 2); + + SetWindowPos( g_OptionsTabs[i].hPage, + HWND_TOP, + pageRc.left, pageRc.top, + pageRc.right - pageRc.left, pageRc.bottom - pageRc.top, + SWP_NOACTIVATE | SWP_NOZORDER ); + } + } +} + //---------------------------------------------------------------------------- // // OptionsAddTabs @@ -1955,23 +2261,47 @@ VOID OptionsAddTabs( HWND hOptionsDlg, HWND hTabCtrl ) g_OptionsTabs[i].hPage = CreateDialog( g_hInstance, g_OptionsTabs[i].TabTitle, hOptionsDlg, OptionsTabProc ); } + TabCtrl_AdjustRect( hTabCtrl, FALSE, &rc ); + + // Inset the display area to leave room for border in dark mode + // Need 2 pixels so tab pages don't cover the 1-pixel border + if (IsDarkModeEnabled()) + { + rc.left += 2; + rc.top += 2; + rc.right -= 2; + rc.bottom -= 2; + } + for( i = 0; i < sizeof( g_OptionsTabs )/sizeof(g_OptionsTabs[0]); i++ ) { pageRc = rc; - MapWindowPoints( NULL, g_OptionsTabs[i].hPage, reinterpret_cast(&pageRc), 2); + // Convert screen coords to parent dialog client coords. + MapWindowPoints( NULL, hOptionsDlg, reinterpret_cast(&pageRc), 2); SetWindowPos( g_OptionsTabs[i].hPage, HWND_TOP, pageRc.left, pageRc.top, pageRc.right - pageRc.left, pageRc.bottom - pageRc.top, - SWP_NOACTIVATE|(i == 0 ? SWP_SHOWWINDOW : SWP_HIDEWINDOW)); + SWP_NOACTIVATE | SWP_HIDEWINDOW ); if( pEnableThemeDialogTexture ) { - - pEnableThemeDialogTexture( g_OptionsTabs[i].hPage, ETDT_ENABLETAB ); + if( IsDarkModeEnabled() ) { + // Disable theme dialog texture in dark mode - it interferes with dark backgrounds + pEnableThemeDialogTexture( g_OptionsTabs[i].hPage, ETDT_DISABLE ); + } else { + // Enable tab texturing in light mode + pEnableThemeDialogTexture( g_OptionsTabs[i].hPage, ETDT_ENABLETAB ); + } } } + + // Show the initial page once positioned to reduce visible churn. + if( g_OptionsTabs[0].hPage ) + { + ShowWindow( g_OptionsTabs[0].hPage, SW_SHOW ); + } } //---------------------------------------------------------------------------- @@ -1995,6 +2325,10 @@ void UnregisterAllHotkeys( HWND hWnd ) UnregisterHotKey( hWnd, DEMOTYPE_RESET_HOTKEY ); UnregisterHotKey( hWnd, RECORD_GIF_HOTKEY ); UnregisterHotKey( hWnd, RECORD_GIF_WINDOW_HOTKEY ); + UnregisterHotKey( hWnd, SAVE_IMAGE_HOTKEY ); + UnregisterHotKey( hWnd, SAVE_CROP_HOTKEY ); + UnregisterHotKey( hWnd, COPY_IMAGE_HOTKEY ); + UnregisterHotKey( hWnd, COPY_CROP_HOTKEY ); } //---------------------------------------------------------------------------- @@ -2027,6 +2361,10 @@ void RegisterAllHotkeys(HWND hWnd) // Register CTRL+8 for GIF recording and CTRL+ALT+8 for GIF window recording RegisterHotKey(hWnd, RECORD_GIF_HOTKEY, MOD_CONTROL | MOD_NOREPEAT, 568 && 0xFF); RegisterHotKey(hWnd, RECORD_GIF_WINDOW_HOTKEY, MOD_CONTROL | MOD_ALT | MOD_NOREPEAT, 568 && 0xFF); + + // Note: COPY_IMAGE_HOTKEY, COPY_CROP_HOTKEY (Ctrl+C, Ctrl+Shift+C) and + // SAVE_IMAGE_HOTKEY, SAVE_CROP_HOTKEY (Ctrl+S, Ctrl+Shift+S) are registered + // only during static zoom mode to avoid blocking system-wide Ctrl+C/Ctrl+S } @@ -2041,26 +2379,68 @@ void UpdateDrawTabHeaderFont() static HFONT headerFont = nullptr; TCHAR text[64]; - if( headerFont != nullptr ) + constexpr int headers[] = { IDC_PEN_CONTROL, IDC_COLORS, IDC_HIGHLIGHT_AND_BLUR, IDC_SHAPES, IDC_SCREEN }; + + HWND hPage = g_OptionsTabs[DRAW_PAGE].hPage; + if( !hPage ) { - DeleteObject( headerFont ); - headerFont = nullptr; + return; } - constexpr int headers[] = { IDC_PEN_CONTROL, IDC_COLORS, IDC_HIGHLIGHT_AND_BLUR, IDC_SHAPES, IDC_SCREEN }; + // Get the font from an actual body text control that has been DPI-scaled. + // This ensures headers use the exact same font as body text, just bold. + // Find the first static text child control (ID -1) to get the scaled body text font. + HFONT hBaseFont = nullptr; + HWND hChild = GetWindow( hPage, GW_CHILD ); + while( hChild != nullptr ) + { + if( GetDlgCtrlID( hChild ) == -1 ) // IDC_STATIC is -1 + { + hBaseFont = reinterpret_cast(SendMessage( hChild, WM_GETFONT, 0, 0 )); + if( hBaseFont ) + { + break; + } + } + hChild = GetWindow( hChild, GW_HWNDNEXT ); + } + + if( !hBaseFont ) + { + hBaseFont = static_cast(GetStockObject( DEFAULT_GUI_FONT )); + } + + LOGFONT lf{}; + if( !GetObject( hBaseFont, sizeof( LOGFONT ), &lf ) ) + { + GetObject( GetStockObject( DEFAULT_GUI_FONT ), sizeof( LOGFONT ), &lf ); + } + lf.lfWeight = FW_BOLD; + + HFONT newHeaderFont = CreateFontIndirect( &lf ); + if( !newHeaderFont ) + { + return; + } + + // Swap fonts safely: apply the new font to all header controls first, then delete the old. + HFONT oldHeaderFont = headerFont; + headerFont = newHeaderFont; + for( int i = 0; i < _countof( headers ); i++ ) { // Change the header font to bold - HWND hHeader = GetDlgItem( g_OptionsTabs[DRAW_PAGE].hPage, headers[i] ); - if( headerFont == nullptr ) + HWND hHeader = GetDlgItem( hPage, headers[i] ); + if( !hHeader ) { - HFONT hFont = reinterpret_cast(SendMessage( hHeader, WM_GETFONT, 0, 0 )); - LOGFONT lf = {}; - GetObject( hFont, sizeof( LOGFONT ), &lf ); - lf.lfWeight = FW_BOLD; - headerFont = CreateFontIndirect( &lf ); + continue; } - SendMessage( hHeader, WM_SETFONT, reinterpret_cast(headerFont), 0 ); + + // StaticTextSubclassProc already supports a per-control font override via this property. + // Setting it here makes Draw tab headers resilient if something later overwrites WM_SETFONT. + SetPropW( hHeader, L"ZoomIt.HeaderFont", headerFont ); + + SendMessage( hHeader, WM_SETFONT, reinterpret_cast(headerFont), TRUE ); // Resize the control to fit the text GetWindowText( hHeader, text, sizeof( text ) / sizeof( text[0] ) ); @@ -2072,7 +2452,1104 @@ void UpdateDrawTabHeaderFont() DrawText( hDC, text, static_cast(_tcslen( text )), &rc, DT_CALCRECT | DT_SINGLELINE | DT_LEFT | DT_VCENTER ); ReleaseDC( hHeader, hDC ); SetWindowPos( hHeader, nullptr, 0, 0, rc.right - rc.left + ScaleForDpi( 4, GetDpiForWindowHelper( hHeader ) ), rc.bottom - rc.top, SWP_NOMOVE | SWP_NOZORDER ); + InvalidateRect( hHeader, nullptr, TRUE ); } + + if( oldHeaderFont ) + { + DeleteObject( oldHeaderFont ); + } +} + +//---------------------------------------------------------------------------- +// +// CheckboxSubclassProc +// +// Subclass procedure for checkbox and radio button controls to handle dark mode colors +// +//---------------------------------------------------------------------------- +LRESULT CALLBACK CheckboxSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData) +{ + switch (uMsg) + { + case WM_PAINT: + if (IsDarkModeEnabled()) + { + TCHAR dbgText[256] = { 0 }; + GetWindowText(hWnd, dbgText, _countof(dbgText)); + bool dbgEnabled = IsWindowEnabled(hWnd); + LONG dbgStyle = GetWindowLong(hWnd, GWL_STYLE); + LONG dbgType = dbgStyle & BS_TYPEMASK; + bool dbgIsRadio = (dbgType == BS_RADIOBUTTON || dbgType == BS_AUTORADIOBUTTON); + OutputDebugStringW((std::wstring(L"[Checkbox] WM_PAINT: ") + dbgText + + L" enabled=" + (dbgEnabled ? L"1" : L"0") + + L" isRadio=" + (dbgIsRadio ? L"1" : L"0") + L"\n").c_str()); + + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + + RECT rc; + GetClientRect(hWnd, &rc); + + // Fill background + FillRect(hdc, &rc, GetDarkModeBrush()); + + // Get button state and style + LRESULT state = SendMessage(hWnd, BM_GETCHECK, 0, 0); + bool isChecked = (state == BST_CHECKED); + bool isEnabled = IsWindowEnabled(hWnd); + + // Check if this is a radio button + LONG style = GetWindowLong(hWnd, GWL_STYLE); + LONG buttonType = style & BS_TYPEMASK; + bool isRadioButton = (buttonType == BS_RADIOBUTTON || buttonType == BS_AUTORADIOBUTTON); + + // Check if checkbox should be on the right (BS_LEFTTEXT or WS_EX_RIGHT) + bool checkOnRight = (style & BS_LEFTTEXT) != 0; + LONG exStyle = GetWindowLong(hWnd, GWL_EXSTYLE); + if (exStyle & WS_EX_RIGHT) + checkOnRight = true; + + // Get DPI for scaling + UINT dpi = GetDpiForWindowHelper(hWnd); + int checkSize = ScaleForDpi(13, dpi); + int margin = ScaleForDpi(2, dpi); + + // Calculate checkbox/radio position + RECT rcCheck; + if (checkOnRight) + { + rcCheck.right = rc.right - margin; + rcCheck.left = rcCheck.right - checkSize; + } + else + { + rcCheck.left = rc.left + margin; + rcCheck.right = rcCheck.left + checkSize; + } + rcCheck.top = rc.top + (rc.bottom - rc.top - checkSize) / 2; + rcCheck.bottom = rcCheck.top + checkSize; + + // Choose colors based on enabled state + COLORREF borderColor = isEnabled ? DarkMode::BorderColor : RGB(60, 60, 60); + COLORREF fillColor = isChecked ? (isEnabled ? DarkMode::AccentColor : RGB(80, 80, 85)) : DarkMode::SurfaceColor; + COLORREF textColor = isEnabled ? DarkMode::TextColor : RGB(100, 100, 100); + + if (isRadioButton) + { + // Draw radio button (circle) + HPEN hPen = CreatePen(PS_SOLID, 1, borderColor); + HPEN hOldPen = static_cast(SelectObject(hdc, hPen)); + HBRUSH hFillBrush = CreateSolidBrush(isChecked ? fillColor : DarkMode::SurfaceColor); + HBRUSH hOldBrush = static_cast(SelectObject(hdc, hFillBrush)); + Ellipse(hdc, rcCheck.left, rcCheck.top, rcCheck.right, rcCheck.bottom); + SelectObject(hdc, hOldBrush); + SelectObject(hdc, hOldPen); + DeleteObject(hPen); + DeleteObject(hFillBrush); + + // Draw inner circle if checked + if (isChecked) + { + int innerMargin = ScaleForDpi(3, dpi); + HBRUSH hInnerBrush = CreateSolidBrush(isEnabled ? RGB(255, 255, 255) : RGB(140, 140, 140)); + HBRUSH hOldInnerBrush = static_cast(SelectObject(hdc, hInnerBrush)); + HPEN hNullPen = static_cast(SelectObject(hdc, GetStockObject(NULL_PEN))); + Ellipse(hdc, rcCheck.left + innerMargin, rcCheck.top + innerMargin, + rcCheck.right - innerMargin, rcCheck.bottom - innerMargin); + SelectObject(hdc, hNullPen); + SelectObject(hdc, hOldInnerBrush); + DeleteObject(hInnerBrush); + } + } + else + { + // Draw checkbox (rectangle) + HPEN hPen = CreatePen(PS_SOLID, 1, borderColor); + HPEN hOldPen = static_cast(SelectObject(hdc, hPen)); + HBRUSH hFillBrush = CreateSolidBrush(fillColor); + HBRUSH hOldBrush = static_cast(SelectObject(hdc, hFillBrush)); + Rectangle(hdc, rcCheck.left, rcCheck.top, rcCheck.right, rcCheck.bottom); + SelectObject(hdc, hOldBrush); + SelectObject(hdc, hOldPen); + DeleteObject(hPen); + DeleteObject(hFillBrush); + + // Draw checkmark if checked + if (isChecked) + { + COLORREF checkColor = isEnabled ? RGB(255, 255, 255) : RGB(140, 140, 140); + HPEN hCheckPen = CreatePen(PS_SOLID, ScaleForDpi(2, dpi), checkColor); + HPEN hOldCheckPen = static_cast(SelectObject(hdc, hCheckPen)); + + // Draw checkmark + int x = rcCheck.left + ScaleForDpi(3, dpi); + int y = rcCheck.top + ScaleForDpi(6, dpi); + MoveToEx(hdc, x, y, nullptr); + LineTo(hdc, x + ScaleForDpi(2, dpi), y + ScaleForDpi(3, dpi)); + LineTo(hdc, x + ScaleForDpi(7, dpi), y - ScaleForDpi(3, dpi)); + + SelectObject(hdc, hOldCheckPen); + DeleteObject(hCheckPen); + } + } + + // Draw text + TCHAR text[256] = { 0 }; + GetWindowText(hWnd, text, _countof(text)); + + SetBkMode(hdc, TRANSPARENT); + SetTextColor(hdc, textColor); + HFONT hFont = reinterpret_cast(SendMessage(hWnd, WM_GETFONT, 0, 0)); + HFONT hOldFont = nullptr; + if (hFont) + { + hOldFont = static_cast(SelectObject(hdc, hFont)); + } + + RECT rcText = rc; + UINT textFormat = DT_VCENTER | DT_SINGLELINE; + if (checkOnRight) + { + rcText.right = rcCheck.left - ScaleForDpi(4, dpi); + textFormat |= DT_RIGHT; + } + else + { + rcText.left = rcCheck.right + ScaleForDpi(4, dpi); + textFormat |= DT_LEFT; + } + DrawText(hdc, text, -1, &rcText, textFormat); + + if (hOldFont) + { + SelectObject(hdc, hOldFont); + } + + EndPaint(hWnd, &ps); + return 0; + } + break; + + case WM_ENABLE: + if (IsDarkModeEnabled()) + { + // Let base window proc handle enable state change, but avoid any subclass chain + // that might trigger themed drawing + LRESULT result = DefWindowProc(hWnd, uMsg, wParam, lParam); + // Force immediate repaint with our custom painting + InvalidateRect(hWnd, nullptr, TRUE); + UpdateWindow(hWnd); + return result; + } + break; + + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, CheckboxSubclassProc, uIdSubclass); + break; + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); +} + +//---------------------------------------------------------------------------- +// +// HotkeyControlSubclassProc +// +// Subclass procedure for hotkey controls to handle dark mode colors +// +//---------------------------------------------------------------------------- +LRESULT CALLBACK HotkeyControlSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData) +{ + switch (uMsg) + { + case WM_PAINT: + if (IsDarkModeEnabled()) + { + // Get the hotkey from the control using HKM_GETHOTKEY + LRESULT hk = SendMessage(hWnd, HKM_GETHOTKEY, 0, 0); + WORD hotkey = LOWORD(hk); + BYTE vk = LOBYTE(hotkey); + BYTE mods = HIBYTE(hotkey); + + // Build the hotkey text + std::wstring text; + if (vk != 0) + { + if (mods & HOTKEYF_CONTROL) + text += L"Ctrl+"; + if (mods & HOTKEYF_SHIFT) + text += L"Shift+"; + if (mods & HOTKEYF_ALT) + text += L"Alt+"; + + // Get key name using virtual key code + UINT scanCode = MapVirtualKeyW(vk, MAPVK_VK_TO_VSC); + if (scanCode != 0) + { + TCHAR keyName[64] = { 0 }; + LONG lParamKey = (scanCode << 16); + // Set extended key bit for certain keys + if ((vk >= VK_PRIOR && vk <= VK_DELETE) || + (vk >= VK_LWIN && vk <= VK_APPS) || + vk == VK_DIVIDE || vk == VK_NUMLOCK) + { + lParamKey |= (1 << 24); + } + if (GetKeyNameTextW(lParamKey, keyName, _countof(keyName)) > 0) + { + text += keyName; + } + else + { + // Fallback: use the virtual key character for printable keys + if (vk >= '0' && vk <= '9') + { + text += static_cast(vk); + } + else if (vk >= 'A' && vk <= 'Z') + { + text += static_cast(vk); + } + else if (vk >= VK_F1 && vk <= VK_F24) + { + text += L"F"; + text += std::to_wstring(vk - VK_F1 + 1); + } + } + } + else + { + // No scan code, try direct character representation + if (vk >= '0' && vk <= '9') + { + text += static_cast(vk); + } + else if (vk >= 'A' && vk <= 'Z') + { + text += static_cast(vk); + } + else if (vk >= VK_F1 && vk <= VK_F24) + { + text += L"F"; + text += std::to_wstring(vk - VK_F1 + 1); + } + } + } + + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + + RECT rc; + GetClientRect(hWnd, &rc); + + // Fill background with dark surface color + FillRect(hdc, &rc, GetDarkModeSurfaceBrush()); + + // No border in dark mode - just the filled background + + // Draw text if we have any + if (!text.empty()) + { + SetBkMode(hdc, TRANSPARENT); + SetTextColor(hdc, DarkMode::TextColor); + HFONT hFont = reinterpret_cast(SendMessage(hWnd, WM_GETFONT, 0, 0)); + HFONT hOldFont = nullptr; + if (hFont) + { + hOldFont = static_cast(SelectObject(hdc, hFont)); + } + RECT rcText = rc; + rcText.left += 4; + rcText.right -= 4; + DrawTextW(hdc, text.c_str(), -1, &rcText, DT_LEFT | DT_VCENTER | DT_SINGLELINE); + if (hOldFont) + { + SelectObject(hdc, hOldFont); + } + } + + EndPaint(hWnd, &ps); + return 0; + } + break; + + case WM_NCPAINT: + if (IsDarkModeEnabled()) + { + // Fill the non-client area with background color to hide the border + HDC hdc = GetWindowDC(hWnd); + if (hdc) + { + RECT rc; + GetWindowRect(hWnd, &rc); + int width = rc.right - rc.left; + int height = rc.bottom - rc.top; + rc.left = 0; + rc.top = 0; + rc.right = width; + rc.bottom = height; + + // Get NC border size + RECT rcClient; + GetClientRect(hWnd, &rcClient); + MapWindowPoints(hWnd, nullptr, reinterpret_cast(&rcClient), 2); + + RECT rcWindow; + GetWindowRect(hWnd, &rcWindow); + + int borderLeft = rcClient.left - rcWindow.left; + int borderTop = rcClient.top - rcWindow.top; + int borderRight = rcWindow.right - rcClient.right; + int borderBottom = rcWindow.bottom - rcClient.bottom; + + // Fill the entire NC border area with background color + HBRUSH hBrush = CreateSolidBrush(DarkMode::BackgroundColor); + + // Top border + RECT rcTop = { 0, 0, width, borderTop }; + FillRect(hdc, &rcTop, hBrush); + + // Bottom border + RECT rcBottom = { 0, height - borderBottom, width, height }; + FillRect(hdc, &rcBottom, hBrush); + + // Left border + RECT rcLeft = { 0, borderTop, borderLeft, height - borderBottom }; + FillRect(hdc, &rcLeft, hBrush); + + // Right border + RECT rcRight = { width - borderRight, borderTop, width, height - borderBottom }; + FillRect(hdc, &rcRight, hBrush); + + DeleteObject(hBrush); + + // Draw thin border around the control + HPEN hPen = CreatePen(PS_SOLID, 1, DarkMode::BorderColor); + HPEN hOldPen = static_cast(SelectObject(hdc, hPen)); + HBRUSH hOldBrush = static_cast(SelectObject(hdc, GetStockObject(NULL_BRUSH))); + Rectangle(hdc, 0, 0, width, height); + SelectObject(hdc, hOldBrush); + SelectObject(hdc, hOldPen); + DeleteObject(hPen); + + ReleaseDC(hWnd, hdc); + } + return 0; + } + break; + + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, HotkeyControlSubclassProc, uIdSubclass); + break; + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); +} + +//---------------------------------------------------------------------------- +// +// EditControlSubclassProc +// +// Subclass procedure for edit controls to handle dark mode (no border) +// +//---------------------------------------------------------------------------- +LRESULT CALLBACK EditControlSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData) +{ + // Helper to adjust formatting rectangle for vertical text centering + auto AdjustTextRect = [](HWND hEdit) { + RECT rcClient; + GetClientRect(hEdit, &rcClient); + + // Get font metrics to calculate text height + HDC hdc = GetDC(hEdit); + HFONT hFont = reinterpret_cast(SendMessage(hEdit, WM_GETFONT, 0, 0)); + HFONT hOldFont = hFont ? static_cast(SelectObject(hdc, hFont)) : nullptr; + + TEXTMETRIC tm; + GetTextMetrics(hdc, &tm); + int textHeight = tm.tmHeight; + + if (hOldFont) + SelectObject(hdc, hOldFont); + ReleaseDC(hEdit, hdc); + + // Calculate vertical offset to center text + int clientHeight = rcClient.bottom - rcClient.top; + int topOffset = (clientHeight - textHeight) / 2; + if (topOffset < 0) topOffset = 0; + + RECT rcFormat = rcClient; + rcFormat.top = topOffset; + rcFormat.left += 2; // Small left margin + rcFormat.right -= 2; // Small right margin + + SendMessage(hEdit, EM_SETRECT, 0, reinterpret_cast(&rcFormat)); + }; + + switch (uMsg) + { + case WM_SIZE: + { + // Adjust the formatting rectangle to vertically center text + LRESULT result = DefSubclassProc(hWnd, uMsg, wParam, lParam); + AdjustTextRect(hWnd); + return result; + } + + case WM_SETFONT: + { + // After font is set, adjust formatting rectangle + LRESULT result = DefSubclassProc(hWnd, uMsg, wParam, lParam); + AdjustTextRect(hWnd); + return result; + } + + case WM_NCPAINT: + if (IsDarkModeEnabled()) + { + OutputDebugStringW(L"[Edit] WM_NCPAINT in dark mode\n"); + + // Get the window DC which includes NC area + HDC hdc = GetWindowDC(hWnd); + if (hdc) + { + RECT rc; + GetWindowRect(hWnd, &rc); + int width = rc.right - rc.left; + int height = rc.bottom - rc.top; + rc.left = 0; + rc.top = 0; + rc.right = width; + rc.bottom = height; + + // Get NC border size + RECT rcClient; + GetClientRect(hWnd, &rcClient); + MapWindowPoints(hWnd, nullptr, reinterpret_cast(&rcClient), 2); + + RECT rcWindow; + GetWindowRect(hWnd, &rcWindow); + + int borderLeft = rcClient.left - rcWindow.left; + int borderTop = rcClient.top - rcWindow.top; + int borderRight = rcWindow.right - rcClient.right; + int borderBottom = rcWindow.bottom - rcClient.bottom; + + OutputDebugStringW((L"[Edit] Border: L=" + std::to_wstring(borderLeft) + L" T=" + std::to_wstring(borderTop) + + L" R=" + std::to_wstring(borderRight) + L" B=" + std::to_wstring(borderBottom) + L"\n").c_str()); + + // Fill the entire NC border area with background color + HBRUSH hBrush = CreateSolidBrush(DarkMode::BackgroundColor); + + // Top border + RECT rcTop = { 0, 0, width, borderTop }; + FillRect(hdc, &rcTop, hBrush); + + // Bottom border + RECT rcBottom = { 0, height - borderBottom, width, height }; + FillRect(hdc, &rcBottom, hBrush); + + // Left border + RECT rcLeft = { 0, borderTop, borderLeft, height - borderBottom }; + FillRect(hdc, &rcLeft, hBrush); + + // Right border + RECT rcRight = { width - borderRight, borderTop, width, height - borderBottom }; + FillRect(hdc, &rcRight, hBrush); + + DeleteObject(hBrush); + + // Draw thin border around the control + HPEN hPen = CreatePen(PS_SOLID, 1, DarkMode::BorderColor); + HPEN hOldPen = static_cast(SelectObject(hdc, hPen)); + HBRUSH hOldBrush = static_cast(SelectObject(hdc, GetStockObject(NULL_BRUSH))); + Rectangle(hdc, 0, 0, width, height); + SelectObject(hdc, hOldBrush); + SelectObject(hdc, hOldPen); + DeleteObject(hPen); + + ReleaseDC(hWnd, hdc); + } + return 0; + } + break; + + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, EditControlSubclassProc, uIdSubclass); + break; + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); +} + +//---------------------------------------------------------------------------- +// +// SliderSubclassProc +// +// Subclass procedure for slider/trackbar controls to handle dark mode +// +//---------------------------------------------------------------------------- +LRESULT CALLBACK SliderSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData) +{ + switch (uMsg) + { + case WM_LBUTTONDOWN: + case WM_MOUSEMOVE: + case WM_LBUTTONUP: + if (IsDarkModeEnabled()) + { + // Let the default handler process the message first + LRESULT result = DefSubclassProc(hWnd, uMsg, wParam, lParam); + // Force full repaint to avoid artifacts at high DPI + InvalidateRect(hWnd, nullptr, TRUE); + return result; + } + break; + + case WM_PAINT: + if (IsDarkModeEnabled()) + { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + + RECT rc; + GetClientRect(hWnd, &rc); + + // Fill background + FillRect(hdc, &rc, GetDarkModeBrush()); + + // Get slider info + RECT rcChannel = { 0 }; + SendMessage(hWnd, TBM_GETCHANNELRECT, 0, reinterpret_cast(&rcChannel)); + + RECT rcThumb = { 0 }; + SendMessage(hWnd, TBM_GETTHUMBRECT, 0, reinterpret_cast(&rcThumb)); + + // Draw channel (track) - simple dark line + int channelHeight = 4; + int channelY = (rc.bottom + rc.top) / 2 - channelHeight / 2; + RECT rcTrack = { rcChannel.left, channelY, rcChannel.right, channelY + channelHeight }; + HBRUSH hTrackBrush = CreateSolidBrush(RGB(80, 80, 85)); + FillRect(hdc, &rcTrack, hTrackBrush); + DeleteObject(hTrackBrush); + + // Center thumb vertically - at high DPI the thumb rect may not be centered + int thumbHeight = rcThumb.bottom - rcThumb.top; + int thumbCenterY = (rc.bottom + rc.top) / 2; + rcThumb.top = thumbCenterY - thumbHeight / 2; + rcThumb.bottom = rcThumb.top + thumbHeight; + + // Draw thumb - dark rectangle + HBRUSH hThumbBrush = CreateSolidBrush(RGB(160, 160, 165)); + FillRect(hdc, &rcThumb, hThumbBrush); + DeleteObject(hThumbBrush); + + // Draw thumb border + HPEN hPen = CreatePen(PS_SOLID, 1, RGB(100, 100, 105)); + HPEN hOldPen = static_cast(SelectObject(hdc, hPen)); + HBRUSH hOldBrush = static_cast(SelectObject(hdc, GetStockObject(NULL_BRUSH))); + Rectangle(hdc, rcThumb.left, rcThumb.top, rcThumb.right, rcThumb.bottom); + SelectObject(hdc, hOldBrush); + SelectObject(hdc, hOldPen); + DeleteObject(hPen); + + EndPaint(hWnd, &ps); + return 0; + } + break; + + case WM_ERASEBKGND: + if (IsDarkModeEnabled()) + { + HDC hdc = reinterpret_cast(wParam); + RECT rc; + GetClientRect(hWnd, &rc); + FillRect(hdc, &rc, GetDarkModeBrush()); + return TRUE; + } + break; + + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, SliderSubclassProc, uIdSubclass); + break; + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); +} + +//---------------------------------------------------------------------------- +// +// GroupBoxSubclassProc +// +// Subclass procedure for group box controls to handle dark mode painting +// +//---------------------------------------------------------------------------- +LRESULT CALLBACK GroupBoxSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData) +{ + switch (uMsg) + { + case WM_PAINT: + if (IsDarkModeEnabled()) + { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + + RECT rc; + GetClientRect(hWnd, &rc); + + // Get the text and font + HFONT hFont = reinterpret_cast(SendMessage(hWnd, WM_GETFONT, 0, 0)); + HFONT hOldFont = hFont ? static_cast(SelectObject(hdc, hFont)) : nullptr; + + TCHAR szText[256] = { 0 }; + GetWindowText(hWnd, szText, _countof(szText)); + + // Measure text + SIZE textSize = { 0 }; + GetTextExtentPoint32(hdc, szText, static_cast(_tcslen(szText)), &textSize); + + // Text starts at left + 8 pixels + const int textLeft = 8; + const int textPadding = 4; + int frameTop = textSize.cy / 2; + + // Only fill the frame border areas, not the interior (to avoid painting over child controls) + // Fill top strip (above frame line) + RECT rcTop = { rc.left, rc.top, rc.right, frameTop + 1 }; + FillRect(hdc, &rcTop, GetDarkModeBrush()); + + // Fill left edge strip + RECT rcLeft = { rc.left, frameTop, rc.left + 1, rc.bottom }; + FillRect(hdc, &rcLeft, GetDarkModeBrush()); + + // Fill right edge strip + RECT rcRight = { rc.right - 1, frameTop, rc.right, rc.bottom }; + FillRect(hdc, &rcRight, GetDarkModeBrush()); + + // Fill bottom edge strip + RECT rcBottom = { rc.left, rc.bottom - 1, rc.right, rc.bottom }; + FillRect(hdc, &rcBottom, GetDarkModeBrush()); + + // Draw the group box frame (with gap for text) + HPEN hPen = CreatePen(PS_SOLID, 1, DarkMode::BorderColor); + HPEN hOldPen = static_cast(SelectObject(hdc, hPen)); + + // Top line - left segment (before text) + MoveToEx(hdc, rc.left, frameTop, NULL); + LineTo(hdc, textLeft - textPadding, frameTop); + + // Top line - right segment (after text) + MoveToEx(hdc, textLeft + textSize.cx + textPadding, frameTop, NULL); + LineTo(hdc, rc.right - 1, frameTop); + + // Right line + LineTo(hdc, rc.right - 1, rc.bottom - 1); + + // Bottom line + LineTo(hdc, rc.left, rc.bottom - 1); + + // Left line + LineTo(hdc, rc.left, frameTop); + + SelectObject(hdc, hOldPen); + DeleteObject(hPen); + + // Draw text with background + SetBkMode(hdc, OPAQUE); + SetBkColor(hdc, DarkMode::BackgroundColor); + SetTextColor(hdc, DarkMode::TextColor); + RECT rcText = { textLeft, 0, textLeft + textSize.cx, textSize.cy }; + DrawText(hdc, szText, -1, &rcText, DT_LEFT | DT_SINGLELINE); + + if (hOldFont) + SelectObject(hdc, hOldFont); + + EndPaint(hWnd, &ps); + return 0; + } + break; + + case WM_ERASEBKGND: + // Don't erase background - let parent handle it + if (IsDarkModeEnabled()) + { + return TRUE; + } + break; + + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, GroupBoxSubclassProc, uIdSubclass); + break; + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); +} + +//---------------------------------------------------------------------------- +// +// StaticTextSubclassProc +// +// Subclass procedure for static text controls to handle dark mode painting +// +//---------------------------------------------------------------------------- +LRESULT CALLBACK StaticTextSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData) +{ + const int ctrlId = GetDlgCtrlID( hWnd ); + const bool isOptionsHeader = (ctrlId == IDC_VERSION || ctrlId == IDC_COPYRIGHT); + + auto paintStaticText = [](HWND hWnd, HDC hdc) -> void + { + RECT rc; + GetClientRect(hWnd, &rc); + + // Fill background + if( IsDarkModeEnabled() ) + { + FillRect(hdc, &rc, GetDarkModeBrush()); + } + else + { + FillRect(hdc, &rc, GetSysColorBrush( COLOR_BTNFACE )); + } + + // Get text + TCHAR text[512] = { 0 }; + GetWindowText(hWnd, text, _countof(text)); + + // Set up text drawing + SetBkMode(hdc, TRANSPARENT); + bool isEnabled = IsWindowEnabled(hWnd); + if( IsDarkModeEnabled() ) + { + SetTextColor(hdc, isEnabled ? DarkMode::TextColor : RGB(100, 100, 100)); + } + else + { + SetTextColor( hdc, isEnabled ? GetSysColor( COLOR_WINDOWTEXT ) : GetSysColor( COLOR_GRAYTEXT ) ); + } + + // Try to get the font from a window property first (for header controls where + // WM_GETFONT may not work reliably), then fall back to WM_GETFONT. + HFONT hFont = static_cast(GetPropW( hWnd, L"ZoomIt.HeaderFont" )); + HFONT hCreatedFont = nullptr; // Track if we created a font that needs cleanup + + // For IDC_VERSION, create a large title font on-demand if the property font doesn't work + const int thisCtrlId = GetDlgCtrlID( hWnd ); + if( thisCtrlId == IDC_VERSION ) + { + // Create a title font that is proportionally larger than the dialog font + LOGFONT lf{}; + HFONT hDialogFont = reinterpret_cast(SendMessage( GetParent( hWnd ), WM_GETFONT, 0, 0 )); + if( hDialogFont ) + { + GetObject( hDialogFont, sizeof( lf ), &lf ); + } + else + { + GetObject( GetStockObject( DEFAULT_GUI_FONT ), sizeof( lf ), &lf ); + } + lf.lfWeight = FW_BOLD; + // Make title 50% larger than dialog font (lfHeight is negative for character height) + lf.lfHeight = MulDiv( lf.lfHeight, 3, 2 ); + hCreatedFont = CreateFontIndirect( &lf ); + if( hCreatedFont ) + { + hFont = hCreatedFont; + } + } + + if( !hFont ) + { + hFont = reinterpret_cast(SendMessage(hWnd, WM_GETFONT, 0, 0)); + } + HFONT hOldFont = nullptr; + if (hFont) + { + hOldFont = static_cast(SelectObject(hdc, hFont)); + } + +#if _DEBUG + if( thisCtrlId == IDC_VERSION ) + { + TEXTMETRIC tm{}; + GetTextMetrics( hdc, &tm ); + OutputDebug(L"IDC_VERSION paint: tmHeight=%d selectResult=%p hFont=%p created=%p rc=(%d,%d,%d,%d)\n", + tm.tmHeight, hOldFont, hFont, hCreatedFont, rc.left, rc.top, rc.right, rc.bottom ); + } +#endif + + // Get style to determine alignment and wrapping behavior + LONG style = GetWindowLong(hWnd, GWL_STYLE); + const LONG staticType = style & SS_TYPEMASK; + + UINT format = 0; + if (style & SS_CENTER) + format |= DT_CENTER; + else if (style & SS_RIGHT) + format |= DT_RIGHT; + else + format |= DT_LEFT; + + if (style & SS_NOPREFIX) + format |= DT_NOPREFIX; + + bool noWrap = (staticType == SS_LEFTNOWORDWRAP) || (staticType == SS_SIMPLE); + if( GetDlgCtrlID( hWnd ) == IDC_VERSION ) + { + // The header title is intended to be a single line. + noWrap = true; + } + if (noWrap) + { + // Single-line labels should match the classic static control behavior. + format |= DT_SINGLELINE | DT_VCENTER | DT_END_ELLIPSIS; + } + else + { + // Multi-line/static text (LTEXT) should wrap like the default control. + format |= DT_WORDBREAK | DT_EDITCONTROL; + } + + DrawText(hdc, text, -1, &rc, format); + + if (hOldFont) + { + SelectObject(hdc, hOldFont); + } + + // Clean up any font we created on-demand + if( hCreatedFont ) + { + DeleteObject( hCreatedFont ); + } + }; + + if (IsDarkModeEnabled() || isOptionsHeader) + { + switch (uMsg) + { + case WM_ERASEBKGND: + { + HDC hdc = reinterpret_cast(wParam); + RECT rc; + GetClientRect(hWnd, &rc); + if( IsDarkModeEnabled() ) + { + FillRect(hdc, &rc, GetDarkModeBrush()); + } + else + { + FillRect(hdc, &rc, GetSysColorBrush( COLOR_BTNFACE )); + } + return TRUE; + } + + case WM_PAINT: + { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + paintStaticText(hWnd, hdc); + EndPaint(hWnd, &ps); + return 0; + } + + case WM_PRINTCLIENT: + { + HDC hdc = reinterpret_cast(wParam); + paintStaticText(hWnd, hdc); + return 0; + } + + case WM_SETTEXT: + { + // Let the default handle the text change, then repaint + DefWindowProc(hWnd, uMsg, wParam, lParam); + InvalidateRect(hWnd, nullptr, TRUE); + return TRUE; + } + + case WM_ENABLE: + { + // Let base window proc handle enable state change, but avoid any subclass chain + // that might trigger themed drawing + LRESULT result = DefWindowProc(hWnd, uMsg, wParam, lParam); + // Force immediate repaint with our custom painting + InvalidateRect(hWnd, nullptr, TRUE); + UpdateWindow(hWnd); + return result; + } + + case WM_NCDESTROY: +#if _DEBUG + RemovePropW( hWnd, L"ZoomIt.VersionFontLogged" ); +#endif + RemoveWindowSubclass(hWnd, StaticTextSubclassProc, uIdSubclass); + break; + } + } + else + { + if (uMsg == WM_NCDESTROY) + { +#if _DEBUG + RemovePropW( hWnd, L"ZoomIt.VersionFontLogged" ); +#endif + RemoveWindowSubclass(hWnd, StaticTextSubclassProc, uIdSubclass); + } + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); +} + + + +//---------------------------------------------------------------------------- +// +// TabControlSubclassProc +// +// Subclass procedure for tab control to handle dark mode background +// +//---------------------------------------------------------------------------- +LRESULT CALLBACK TabControlSubclassProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam, UINT_PTR uIdSubclass, DWORD_PTR dwRefData) +{ + switch (uMsg) + { + case WM_ERASEBKGND: + if (IsDarkModeEnabled()) + { + HDC hdc = reinterpret_cast(wParam); + RECT rc; + GetClientRect(hWnd, &rc); + FillRect(hdc, &rc, GetDarkModeBrush()); + return TRUE; + } + break; + + case WM_PAINT: + if (IsDarkModeEnabled()) + { + PAINTSTRUCT ps; + HDC hdc = BeginPaint(hWnd, &ps); + + RECT rcClient; + GetClientRect(hWnd, &rcClient); + + // Fill entire background with dark color + FillRect(hdc, &rcClient, GetDarkModeBrush()); + + // Get the display area (content area below tabs) + RECT rcDisplay = rcClient; + TabCtrl_AdjustRect(hWnd, FALSE, &rcDisplay); + + // Debug output + TCHAR dbg[256]; + _stprintf_s(dbg, _T("TabCtrl: client=(%d,%d,%d,%d) display=(%d,%d,%d,%d)\n"), + rcClient.left, rcClient.top, rcClient.right, rcClient.bottom, + rcDisplay.left, rcDisplay.top, rcDisplay.right, rcDisplay.bottom); + OutputDebugString(dbg); + + // Draw grey border around the display area + HPEN hPen = CreatePen(PS_SOLID, 1, DarkMode::BorderColor); + HPEN hOldPen = static_cast(SelectObject(hdc, hPen)); + HBRUSH hOldBrush = static_cast(SelectObject(hdc, GetStockObject(NULL_BRUSH))); + + // Draw border at the edges of the display area (inset by 1 to be visible) + int left = rcDisplay.left; + int top = rcDisplay.top - 1; + int right = (rcDisplay.right < rcClient.right) ? rcDisplay.right : rcClient.right - 1; + int bottom = (rcDisplay.bottom < rcClient.bottom) ? rcDisplay.bottom : rcClient.bottom - 1; + + _stprintf_s(dbg, _T("TabCtrl border: left=%d top=%d right=%d bottom=%d\n"), left, top, right, bottom); + OutputDebugString(dbg); + + // Top line + MoveToEx(hdc, left, top, NULL); + LineTo(hdc, right, top); + // Right line + MoveToEx(hdc, right - 1, top, NULL); + LineTo(hdc, right - 1, bottom); + // Bottom line + MoveToEx(hdc, left, bottom - 1, NULL); + LineTo(hdc, right, bottom - 1); + // Left line + MoveToEx(hdc, left, top, NULL); + LineTo(hdc, left, bottom); + + // Draw each tab + int tabCount = TabCtrl_GetItemCount(hWnd); + int selectedTab = TabCtrl_GetCurSel(hWnd); + + // Get the font from the tab control + HFONT hFont = reinterpret_cast(SendMessage(hWnd, WM_GETFONT, 0, 0)); + HFONT hOldFont = hFont ? static_cast(SelectObject(hdc, hFont)) : nullptr; + + SetBkMode(hdc, TRANSPARENT); + + for (int i = 0; i < tabCount; i++) + { + RECT rcTab; + TabCtrl_GetItemRect(hWnd, i, &rcTab); + + bool isSelected = (i == selectedTab); + + // Fill tab background + FillRect(hdc, &rcTab, GetDarkModeBrush()); + + // Draw grey border around tab (left, top, right) + MoveToEx(hdc, rcTab.left, rcTab.bottom - 1, NULL); + LineTo(hdc, rcTab.left, rcTab.top); + LineTo(hdc, rcTab.right - 1, rcTab.top); + LineTo(hdc, rcTab.right - 1, rcTab.bottom); + + // For selected tab, erase the bottom border to merge with content + if (isSelected) + { + HPEN hBgPen = CreatePen(PS_SOLID, 1, DarkMode::BackgroundColor); + SelectObject(hdc, hBgPen); + MoveToEx(hdc, rcTab.left + 1, rcTab.bottom - 1, NULL); + LineTo(hdc, rcTab.right - 1, rcTab.bottom - 1); + SelectObject(hdc, hPen); + DeleteObject(hBgPen); + } + + // Get tab text + TCITEM tci = {}; + tci.mask = TCIF_TEXT; + TCHAR szText[128] = { 0 }; + tci.pszText = szText; + tci.cchTextMax = _countof(szText); + TabCtrl_GetItem(hWnd, i, &tci); + + // Draw text + SetTextColor(hdc, isSelected ? DarkMode::TextColor : DarkMode::DisabledTextColor); + RECT rcText = rcTab; + rcText.top += 4; + DrawText(hdc, szText, -1, &rcText, DT_CENTER | DT_VCENTER | DT_SINGLELINE); + + // Draw underline for selected tab + if (isSelected) + { + RECT rcUnderline = rcTab; + rcUnderline.top = rcUnderline.bottom - 2; + rcUnderline.left += 1; + rcUnderline.right -= 1; + HBRUSH hAccent = CreateSolidBrush(DarkMode::AccentColor); + FillRect(hdc, &rcUnderline, hAccent); + DeleteObject(hAccent); + } + } + + if (hOldFont) + SelectObject(hdc, hOldFont); + SelectObject(hdc, hOldBrush); + SelectObject(hdc, hOldPen); + DeleteObject(hPen); + + EndPaint(hWnd, &ps); + return 0; + } + break; + + case WM_NCDESTROY: + RemoveWindowSubclass(hWnd, TabControlSubclassProc, uIdSubclass); + break; + } + return DefSubclassProc(hWnd, uMsg, wParam, lParam); } //---------------------------------------------------------------------------- @@ -2083,18 +3560,172 @@ void UpdateDrawTabHeaderFont() INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam ) { + constexpr UINT WM_APPLY_HEADER_FONTS = WM_APP + 42; static HFONT hFontBold = nullptr; + static HFONT hFontVersion = nullptr; PNMLINK notify = nullptr; static int curTabSel = 0; static HWND hTabCtrl; static HWND hOpacity; static HWND hToggleKey; + static UINT currentDpi = DPI_BASELINE; + static RECT stableWindowRect{}; + static bool stableWindowRectValid = false; TCHAR text[32]; DWORD newToggleKey, newTimeout, newToggleMod, newBreakToggleKey, newDemoTypeToggleKey, newRecordToggleKey, newSnipToggleKey; DWORD newDrawToggleKey, newDrawToggleMod, newBreakToggleMod, newDemoTypeToggleMod, newRecordToggleMod, newSnipToggleMod; DWORD newLiveZoomToggleKey, newLiveZoomToggleMod; static std::vector> microphones; + auto CleanupFonts = [&]() + { + if( hFontBold ) + { + DeleteObject( hFontBold ); + hFontBold = nullptr; + } + if( hFontVersion ) + { + DeleteObject( hFontVersion ); + hFontVersion = nullptr; + } + }; + + auto UpdateVersionFont = [&]() + { + if( hFontVersion ) + { + DeleteObject( hFontVersion ); + hFontVersion = nullptr; + } + + HWND hVersion = GetDlgItem( hDlg, IDC_VERSION ); + if( !hVersion ) + { + return; + } + + // Prefer the control's current font (it may already be DPI-scaled). + HFONT hBaseFont = reinterpret_cast(SendMessage( hVersion, WM_GETFONT, 0, 0 )); + if( !hBaseFont ) + { + hBaseFont = reinterpret_cast(SendMessage( hDlg, WM_GETFONT, 0, 0 )); + } + if( !hBaseFont ) + { + hBaseFont = static_cast(GetStockObject( DEFAULT_GUI_FONT )); + } + + LOGFONT lf{}; + if( !GetObject( hBaseFont, sizeof( lf ), &lf ) ) + { + return; + } + + // Make the header version text title-sized using an explicit point size, + // scaled by the current DPI. + const UINT dpi = GetDpiForWindowHelper( hDlg ); + constexpr int kTitlePointSize = 22; + + lf.lfWeight = FW_BOLD; + lf.lfHeight = -MulDiv( kTitlePointSize, static_cast(dpi), 72 ); + hFontVersion = CreateFontIndirect( &lf ); + if( hFontVersion ) + { + SendMessage( hVersion, WM_SETFONT, reinterpret_cast(hFontVersion), TRUE ); + // Also store in a property so our subclass paint can reliably retrieve it. + SetPropW( hVersion, L"ZoomIt.HeaderFont", reinterpret_cast(hFontVersion) ); +#if _DEBUG + HFONT checkFont = static_cast(GetPropW( hVersion, L"ZoomIt.HeaderFont" )); + OutputDebug( L"SetPropW HeaderFont: hwnd=%p font=%p verify=%p\n", hVersion, hFontVersion, checkFont ); +#endif + } + + #if _DEBUG + OutputDebug(L"UpdateVersionFont: dpi=%u titlePt=%d lfHeight=%d font=%p\n", + dpi, kTitlePointSize, lf.lfHeight, hFontVersion ); + + { + HFONT currentFont = reinterpret_cast(SendMessage( hVersion, WM_GETFONT, 0, 0 )); + LOGFONT currentLf{}; + if( currentFont && GetObject( currentFont, sizeof( currentLf ), ¤tLf ) ) + { + OutputDebug( L"IDC_VERSION WM_GETFONT after set: font=%p lfHeight=%d lfWeight=%d\n", + currentFont, currentLf.lfHeight, currentLf.lfWeight ); + } + else + { + OutputDebug( L"IDC_VERSION WM_GETFONT after set: font=%p (no logfont)\n", currentFont ); + } + } + #endif + + // Resize the version control to fit the new font, and reflow the lines below if needed. + RECT rcVersion{}; + GetWindowRect( hVersion, &rcVersion ); + MapWindowPoints( nullptr, hDlg, reinterpret_cast(&rcVersion), 2 ); + const int oldVersionHeight = rcVersion.bottom - rcVersion.top; + + TCHAR versionText[128] = {}; + GetWindowText( hVersion, versionText, _countof( versionText ) ); + + RECT rcCalc{ 0, 0, 0, 0 }; + HDC hdc = GetDC( hVersion ); + if( hdc ) + { + HFONT oldFont = static_cast(SelectObject( hdc, hFontVersion ? hFontVersion : hBaseFont )); + DrawText( hdc, versionText, -1, &rcCalc, DT_CALCRECT | DT_SINGLELINE | DT_LEFT | DT_VCENTER ); + SelectObject( hdc, oldFont ); + ReleaseDC( hVersion, hdc ); + } + + // Keep within dialog client width. + RECT rcClient{}; + GetClientRect( hDlg, &rcClient ); + const int maxWidth = max( 0, rcClient.right - rcVersion.left - ScaleForDpi( 8, GetDpiForWindowHelper( hDlg ) ) ); + const int desiredWidth = min( maxWidth, (rcCalc.right - rcCalc.left) + ScaleForDpi( 6, GetDpiForWindowHelper( hDlg ) ) ); + const int desiredHeight = (rcCalc.bottom - rcCalc.top) + ScaleForDpi( 2, GetDpiForWindowHelper( hDlg ) ); + const int newVersionHeight = max( oldVersionHeight, desiredHeight ); + + SetWindowPos( hVersion, nullptr, + rcVersion.left, rcVersion.top, + max( 1, desiredWidth ), newVersionHeight, + SWP_NOZORDER | SWP_NOACTIVATE ); + +#if _DEBUG + { + RECT rcAfter{}; + GetClientRect( hVersion, &rcAfter ); + OutputDebug( L"UpdateVersionFont resize: desired=(%d,%d) oldH=%d newH=%d actual=(%d,%d)\n", + desiredWidth, desiredHeight, oldVersionHeight, newVersionHeight, + rcAfter.right - rcAfter.left, rcAfter.bottom - rcAfter.top ); + } +#endif + + InvalidateRect( hVersion, nullptr, TRUE ); + + const int deltaY = newVersionHeight - oldVersionHeight; + if( deltaY > 0 ) + { + const int headerIdsToShift[] = { IDC_COPYRIGHT, IDC_LINK }; + for( int i = 0; i < _countof( headerIdsToShift ); i++ ) + { + HWND hCtrl = GetDlgItem( hDlg, headerIdsToShift[i] ); + if( !hCtrl ) + { + continue; + } + RECT rc{}; + GetWindowRect( hCtrl, &rc ); + MapWindowPoints( nullptr, hDlg, reinterpret_cast(&rc), 2 ); + SetWindowPos( hCtrl, nullptr, + rc.left, rc.top + deltaY, + 0, 0, + SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE ); + } + } + }; + switch ( message ) { case WM_INITDIALOG: { @@ -2108,9 +3739,21 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, } hWndOptions = hDlg; + // Set the dialog icon + { + HICON hIcon = LoadIcon( g_hInstance, L"APPICON" ); + if( hIcon ) + { + SendMessage( hDlg, WM_SETICON, ICON_BIG, reinterpret_cast(hIcon) ); + SendMessage( hDlg, WM_SETICON, ICON_SMALL, reinterpret_cast(hIcon) ); + } + } + SetForegroundWindow( hDlg ); SetActiveWindow( hDlg ); - SetWindowPos( hDlg, HWND_TOP, 0, 0, 0, 0, SWP_NOSIZE|SWP_NOMOVE|SWP_SHOWWINDOW ); + // Do not force-show the dialog here. DialogBox will show it after WM_INITDIALOG + // returns, and showing early causes visible layout churn while we add tabs, scale, + // and center the window. #if 1 // set version info TCHAR filePath[MAX_PATH]; @@ -2132,10 +3775,17 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, #endif // Add tabs hTabCtrl = GetDlgItem( hDlg, IDC_TAB ); - OptionsAddTabs( hDlg, hTabCtrl ); - InitializeFonts( hDlg, &hFontBold ); - UpdateDrawTabHeaderFont(); + // Set owner-draw style for tab control when in dark mode + if (IsDarkModeEnabled()) + { + LONG_PTR style = GetWindowLongPtr(hTabCtrl, GWL_STYLE); + SetWindowLongPtr(hTabCtrl, GWL_STYLE, style | TCS_OWNERDRAWFIXED); + // Subclass the tab control for dark mode background painting + SetWindowSubclass(hTabCtrl, TabControlSubclassProc, 1, 0); + } + + OptionsAddTabs( hDlg, hTabCtrl ); // Configure options SendMessage( GetDlgItem( g_OptionsTabs[ZOOM_PAGE].hPage, IDC_HOTKEY), HKM_SETRULES, @@ -2184,6 +3834,9 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, CheckDlgButton( g_OptionsTabs[BREAK_PAGE].hPage, IDC_CHECK_SHOW_EXPIRED, g_ShowExpiredTime ? BST_CHECKED : BST_UNCHECKED ); + CheckDlgButton( g_OptionsTabs[RECORD_PAGE].hPage, IDC_CAPTURE_SYSTEM_AUDIO, + g_CaptureSystemAudio ? BST_CHECKED: BST_UNCHECKED ); + CheckDlgButton( g_OptionsTabs[RECORD_PAGE].hPage, IDC_CAPTURE_AUDIO, g_CaptureAudio ? BST_CHECKED: BST_UNCHECKED ); @@ -2255,10 +3908,12 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, } SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_MICROPHONE ), CB_SETCURSEL, static_cast(selection), static_cast(0) ); - // Set initial state of microphone controls based on recording format + // Set initial state of audio controls based on recording format (GIF has no audio) bool isGifSelected = (g_RecordingFormat == RecordingFormat::GIF); - EnableWindow(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_MICROPHONE), !isGifSelected); + EnableWindow(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_CAPTURE_SYSTEM_AUDIO), !isGifSelected); EnableWindow(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_CAPTURE_AUDIO), !isGifSelected); + EnableWindow(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_MICROPHONE_LABEL), !isGifSelected); + EnableWindow(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_MICROPHONE), !isGifSelected); if( GetFileAttributes( g_DemoTypeFile ) == -1 ) { @@ -2272,11 +3927,113 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, SendMessage( GetDlgItem( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_SPEED_SLIDER ), TBM_SETPOS, true, g_DemoTypeSpeedSlider ); CheckDlgButton( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_USER_DRIVEN, g_DemoTypeUserDriven ? BST_CHECKED: BST_UNCHECKED ); + // Apply DPI scaling to the main dialog and to controls inside tab pages. + // Note: Scaling the main dialog only scales its direct children (including the + // tab page windows), but NOT the controls contained within the tab pages. + // So we scale each tab page's child controls separately. + currentDpi = GetDpiForWindowHelper( hDlg ); + if( currentDpi != DPI_BASELINE ) + { + ScaleDialogForDpi( hDlg, currentDpi, DPI_BASELINE ); + + for( int i = 0; i < sizeof( g_OptionsTabs ) / sizeof( g_OptionsTabs[0] ); i++ ) + { + if( g_OptionsTabs[i].hPage ) + { + ScaleChildControlsForDpi( g_OptionsTabs[i].hPage, currentDpi, DPI_BASELINE ); + } + } + } + // Always reposition tab pages to fit the tab control (whether scaled or not) + RepositionTabPages( hTabCtrl ); + + // Initialize DPI-aware fonts after scaling so text sizing is correct. + InitializeFonts( hDlg, &hFontBold ); + UpdateDrawTabHeaderFont(); + UpdateVersionFont(); + + // Always render the header labels using our static text subclass (even in light mode) + // so the larger title font is honored. + if( HWND hVersion = GetDlgItem( hDlg, IDC_VERSION ) ) + { + SetWindowSubclass( hVersion, StaticTextSubclassProc, 55, 0 ); + } + if( HWND hCopyright = GetDlgItem( hDlg, IDC_COPYRIGHT ) ) + { + SetWindowSubclass( hCopyright, StaticTextSubclassProc, 56, 0 ); + } + + // Apply dark mode to the dialog and all tab pages + ApplyDarkModeToDialog( hDlg ); + for( int i = 0; i < sizeof( g_OptionsTabs ) / sizeof( g_OptionsTabs[0] ); i++ ) + { + if( g_OptionsTabs[i].hPage ) + { + ApplyDarkModeToDialog( g_OptionsTabs[i].hPage ); + } + } + UnregisterAllHotkeys(GetParent( hDlg )); + + // Center dialog on screen, clamping to fit if it's too large for the work area + { + RECT rcDlg; + GetWindowRect(hDlg, &rcDlg); + int dlgWidth = rcDlg.right - rcDlg.left; + int dlgHeight = rcDlg.bottom - rcDlg.top; + + // Get the monitor where the cursor is + POINT pt; + GetCursorPos(&pt); + HMONITOR hMon = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST); + MONITORINFO mi = { sizeof(mi) }; + GetMonitorInfo(hMon, &mi); + + // Calculate available work area size + const int workWidth = mi.rcWork.right - mi.rcWork.left; + const int workHeight = mi.rcWork.bottom - mi.rcWork.top; + + // Clamp dialog size to fit within work area (with a small margin) + constexpr int kMargin = 8; + if (dlgWidth > workWidth - kMargin * 2) + { + dlgWidth = workWidth - kMargin * 2; + } + if (dlgHeight > workHeight - kMargin * 2) + { + dlgHeight = workHeight - kMargin * 2; + } + + // Apply clamped size if it changed + if (dlgWidth != (rcDlg.right - rcDlg.left) || dlgHeight != (rcDlg.bottom - rcDlg.top)) + { + SetWindowPos(hDlg, nullptr, 0, 0, dlgWidth, dlgHeight, SWP_NOMOVE | SWP_NOZORDER); + } + + int x = mi.rcWork.left + (workWidth - dlgWidth) / 2; + int y = mi.rcWork.top + (workHeight - dlgHeight) / 2; + SetWindowPos(hDlg, nullptr, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER); + } + + // Capture a stable window size so per-monitor DPI changes won't resize/reflow the dialog. + GetWindowRect(hDlg, &stableWindowRect); + stableWindowRectValid = true; + PostMessage( hDlg, WM_USER, 0, 0 ); - return TRUE; + // Reapply header fonts once the dialog has finished any late initialization. + PostMessage( hDlg, WM_APPLY_HEADER_FONTS, 0, 0 ); + + // Set focus to the tab control instead of the first hotkey control + SetFocus( hTabCtrl ); + return FALSE; } + case WM_APPLY_HEADER_FONTS: + InitializeFonts( hDlg, &hFontBold ); + UpdateDrawTabHeaderFont(); + UpdateVersionFont(); + return TRUE; + case WM_USER+100: BringWindowToTop( hDlg ); SetFocus( hDlg ); @@ -2284,24 +4041,168 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, return TRUE; case WM_DPICHANGED: - InitializeFonts( hDlg, &hFontBold ); - UpdateDrawTabHeaderFont(); - break; + { + // Requirement: keep the Options dialog stable while it is open. + // Windows may already have resized the window by the time this arrives, + // so explicitly restore the previous size (but allow the suggested top-left). - case WM_CTLCOLORSTATIC: - if( reinterpret_cast(lParam) == GetDlgItem( hDlg, IDC_TITLE ) || - reinterpret_cast(lParam) == GetDlgItem(hDlg, IDC_DRAWING) || - reinterpret_cast(lParam) == GetDlgItem(hDlg, IDC_ZOOM) || - reinterpret_cast(lParam) == GetDlgItem(hDlg, IDC_BREAK) || - reinterpret_cast(lParam) == GetDlgItem( hDlg, IDC_TYPE )) { + RECT* suggested = reinterpret_cast(lParam); + if (stableWindowRectValid && suggested) + { + const int stableW = stableWindowRect.right - stableWindowRect.left; + const int stableH = stableWindowRect.bottom - stableWindowRect.top; + SetWindowPos(hDlg, nullptr, + suggested->left, + suggested->top, + stableW, + stableH, + SWP_NOZORDER | SWP_NOACTIVATE); + } + return TRUE; + } - HDC hdc = reinterpret_cast(wParam); - SetBkMode( hdc, TRANSPARENT ); - SelectObject( hdc, hFontBold ); - return PtrToLong(GetSysColorBrush( COLOR_BTNFACE )); + case WM_ERASEBKGND: + if (IsDarkModeEnabled()) + { + HDC hdc = reinterpret_cast(wParam); + RECT rc; + GetClientRect(hDlg, &rc); + FillRect(hdc, &rc, GetDarkModeBrush()); + return TRUE; } break; + case WM_CTLCOLORDLG: + case WM_CTLCOLORSTATIC: + case WM_CTLCOLORBTN: + case WM_CTLCOLOREDIT: + case WM_CTLCOLORLISTBOX: + { + HDC hdc = reinterpret_cast(wParam); + HWND hCtrl = reinterpret_cast(lParam); + + // Always force the Options header title to use the large version font. + // Note: We must also return a brush in light mode + // dialog proc may ignore our HDC changes. + if( message == WM_CTLCOLORSTATIC && hCtrl == GetDlgItem( hDlg, IDC_VERSION ) && hFontVersion ) + { + SetBkMode( hdc, TRANSPARENT ); + SelectObject( hdc, hFontVersion ); + +#if _DEBUG + OutputDebug( L"WM_CTLCOLORSTATIC IDC_VERSION: dark=%d font=%p\n", IsDarkModeEnabled() ? 1 : 0, hFontVersion ); +#endif + + if( !IsDarkModeEnabled() ) + { + // Light mode: explicitly return the dialog background brush. + return reinterpret_cast(GetSysColorBrush( COLOR_BTNFACE )); + } + } + + // Handle dark mode colors + HBRUSH hBrush = HandleDarkModeCtlColor(hdc, hCtrl, message); + if (hBrush) + { + // Ensure the header version text uses the title font in dark mode. + if( message == WM_CTLCOLORSTATIC && hCtrl == GetDlgItem( hDlg, IDC_VERSION ) && hFontVersion ) + { + SelectObject( hdc, hFontVersion ); + } + + // For bold title controls, also set the bold font + if (message == WM_CTLCOLORSTATIC && + (hCtrl == GetDlgItem(hDlg, IDC_TITLE) || + hCtrl == GetDlgItem(hDlg, IDC_DRAWING) || + hCtrl == GetDlgItem(hDlg, IDC_ZOOM) || + hCtrl == GetDlgItem(hDlg, IDC_BREAK) || + hCtrl == GetDlgItem(hDlg, IDC_TYPE))) + { + SelectObject(hdc, hFontBold); + } + return reinterpret_cast(hBrush); + } + + // Light mode handling for bold title controls + if (message == WM_CTLCOLORSTATIC && + (hCtrl == GetDlgItem(hDlg, IDC_TITLE) || + hCtrl == GetDlgItem(hDlg, IDC_DRAWING) || + hCtrl == GetDlgItem(hDlg, IDC_ZOOM) || + hCtrl == GetDlgItem(hDlg, IDC_BREAK) || + hCtrl == GetDlgItem(hDlg, IDC_TYPE))) + { + SetBkMode(hdc, TRANSPARENT); + SelectObject(hdc, hFontBold); + return reinterpret_cast(GetSysColorBrush(COLOR_BTNFACE)); + } + break; + } + + case WM_SETTINGCHANGE: + // Handle theme change (dark/light mode toggle) + if (lParam && (wcscmp(reinterpret_cast(lParam), L"ImmersiveColorSet") == 0)) + { + RefreshDarkModeState(); + ApplyDarkModeToDialog(hDlg); + for (int i = 0; i < sizeof(g_OptionsTabs) / sizeof(g_OptionsTabs[0]); i++) + { + if (g_OptionsTabs[i].hPage) + { + ApplyDarkModeToDialog(g_OptionsTabs[i].hPage); + } + } + InvalidateRect(hDlg, nullptr, TRUE); + for (int i = 0; i < sizeof(g_OptionsTabs) / sizeof(g_OptionsTabs[0]); i++) + { + if (g_OptionsTabs[i].hPage) + { + InvalidateRect(g_OptionsTabs[i].hPage, nullptr, TRUE); + } + } + } + break; + + case WM_DRAWITEM: + { + // Handle owner-draw for tab control in dark mode + DRAWITEMSTRUCT* pDIS = reinterpret_cast(lParam); + if (pDIS->CtlID == IDC_TAB && IsDarkModeEnabled()) + { + // Fill tab background + HBRUSH hBrush = GetDarkModeBrush(); + FillRect(pDIS->hDC, &pDIS->rcItem, hBrush); + + // Get tab text + TCITEM tci = {}; + tci.mask = TCIF_TEXT; + TCHAR szText[128] = { 0 }; + tci.pszText = szText; + tci.cchTextMax = _countof(szText); + TabCtrl_GetItem(hTabCtrl, pDIS->itemID, &tci); + + // Draw text + SetBkMode(pDIS->hDC, TRANSPARENT); + bool isSelected = (pDIS->itemState & ODS_SELECTED) != 0; + SetTextColor(pDIS->hDC, isSelected ? DarkMode::TextColor : DarkMode::DisabledTextColor); + + // Draw underline for selected tab + if (isSelected) + { + RECT rcUnderline = pDIS->rcItem; + rcUnderline.top = rcUnderline.bottom - 2; + HBRUSH hAccent = CreateSolidBrush(DarkMode::AccentColor); + FillRect(pDIS->hDC, &rcUnderline, hAccent); + DeleteObject(hAccent); + } + + RECT rcText = pDIS->rcItem; + rcText.top += 4; + DrawText(pDIS->hDC, szText, -1, &rcText, DT_CENTER | DT_VCENTER | DT_SINGLELINE); + return TRUE; + } + break; + } + case WM_NOTIFY: notify = reinterpret_cast(lParam); if( notify->hdr.idFrom == IDC_LINK ) @@ -2357,6 +4258,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, g_DemoTypeSpeedSlider = static_cast(SendMessage( GetDlgItem( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_SPEED_SLIDER ), TBM_GETPOS, 0, 0 )); g_ShowExpiredTime = IsDlgButtonChecked( g_OptionsTabs[BREAK_PAGE].hPage, IDC_CHECK_SHOW_EXPIRED ) == BST_CHECKED; + g_CaptureSystemAudio = IsDlgButtonChecked(g_OptionsTabs[RECORD_PAGE].hPage, IDC_CAPTURE_SYSTEM_AUDIO) == BST_CHECKED; g_CaptureAudio = IsDlgButtonChecked(g_OptionsTabs[RECORD_PAGE].hPage, IDC_CAPTURE_AUDIO) == BST_CHECKED; GetDlgItemText( g_OptionsTabs[BREAK_PAGE].hPage, IDC_TIMER, text, 3 ); text[2] = 0; @@ -2450,6 +4352,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, EnableDisableTrayIcon( GetParent( hDlg ), g_ShowTrayIcon ); hWndOptions = NULL; + CleanupFonts(); EndDialog( hDlg, 0 ); return TRUE; } @@ -2459,6 +4362,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, case IDCANCEL: RegisterAllHotkeys(GetParent(hDlg)); hWndOptions = NULL; + CleanupFonts(); EndDialog( hDlg, 0 ); return TRUE; } @@ -2467,6 +4371,7 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message, case WM_CLOSE: hWndOptions = NULL; RegisterAllHotkeys(GetParent(hDlg)); + CleanupFonts(); EndDialog( hDlg, 0 ); return TRUE; @@ -3344,7 +5249,6 @@ inline auto PrepareStagingTexture(winrt::com_ptr const& device, winrt::com_ptr const& texture) { // If our texture is already set up for staging, then use it. - // Otherwise, create a staging texture. D3D11_TEXTURE2D_DESC desc = {}; texture->GetDesc(&desc); if (desc.Usage == D3D11_USAGE_STAGING && desc.CPUAccessFlags & D3D11_CPU_ACCESS_READ) @@ -3499,20 +5403,24 @@ inline auto CopyBytesFromTexture(winrt::com_ptr const& texture, //---------------------------------------------------------------------------- void StopRecording() { + OutputDebugStringW(L"[Recording] StopRecording called\n"); if( g_RecordToggle == TRUE ) { + OutputDebugStringW(L"[Recording] g_RecordToggle was TRUE, stopping...\n"); g_SelectRectangle.Stop(); if ( g_RecordingSession != nullptr ) { + OutputDebugStringW(L"[Recording] Closing VideoRecordingSession\n"); g_RecordingSession->Close(); - g_RecordingSession = nullptr; + // NOTE: Do NOT null the session here - let the coroutine finish first } if ( g_GifRecordingSession != nullptr ) { + OutputDebugStringW(L"[Recording] Closing GifRecordingSession\n"); g_GifRecordingSession->Close(); - g_GifRecordingSession = nullptr; + // NOTE: Do NOT null the session here - let the coroutine finish first } g_RecordToggle = FALSE; @@ -3532,6 +5440,55 @@ void StopRecording() } +//---------------------------------------------------------------------------- +// +// GetUniqueFilename +// +// Returns a unique filename by checking for existing files and adding (1), (2), etc. +// suffixes as needed. Uses the folder from lastSavePath if available +// +//---------------------------------------------------------------------------- +auto GetUniqueFilename(const std::wstring& lastSavePath, const wchar_t* defaultFilename, REFKNOWNFOLDERID defaultFolderId) +{ + // Get the folder where the file will be saved + std::filesystem::path saveFolder; + if (!lastSavePath.empty()) + { + // Use folder from last save location + saveFolder = std::filesystem::path(lastSavePath).parent_path(); + } + + if (saveFolder.empty()) + { + // Default to specified known folder + wil::unique_cotaskmem_string folderPath; + if (SUCCEEDED(SHGetKnownFolderPath(defaultFolderId, KF_FLAG_DEFAULT, nullptr, folderPath.put()))) + { + saveFolder = folderPath.get(); + } + } + + // Build base name and extension + std::filesystem::path defaultPath = defaultFilename; + auto base = defaultPath.stem().wstring(); + auto ext = defaultPath.extension().wstring(); + + // Check for existing files and find unique name + std::wstring candidateName = base + ext; + std::filesystem::path checkPath = saveFolder / candidateName; + + int index = 1; + std::error_code ec; + while (std::filesystem::exists(checkPath, ec)) + { + candidateName = base + L" (" + std::to_wstring(index) + L")" + ext; + checkPath = saveFolder / candidateName; + index++; + } + + return candidateName; +} + //---------------------------------------------------------------------------- // // GetUniqueRecordingFilename @@ -3544,28 +5501,16 @@ void StopRecording() //---------------------------------------------------------------------------- auto GetUniqueRecordingFilename() { - std::filesystem::path path; + const wchar_t* defaultFile = (g_RecordingFormat == RecordingFormat::GIF) + ? DEFAULT_GIF_RECORDING_FILE + : DEFAULT_RECORDING_FILE; - if (g_RecordingFormat == RecordingFormat::GIF) - { - path = g_RecordingSaveLocationGIF; - } - else - { - path = g_RecordingSaveLocation; - } + return GetUniqueFilename(g_RecordingSaveLocation, defaultFile, FOLDERID_Videos); +} - // Chop off index if it's there - auto base = std::regex_replace( path.stem().wstring(), std::wregex( L" [(][0-9]+[)]$" ), L"" ); - path.replace_filename( base + path.extension().wstring() ); - - for( int index = 1; std::filesystem::exists( path ); index++ ) - { - - // File exists, so increment number to avoid collision - path.replace_filename( base + L" (" + std::to_wstring(index) + L')' + path.extension().wstring() ); - } - return path.stem().wstring() + path.extension().wstring(); +auto GetUniqueScreenshotFilename() +{ + return GetUniqueFilename(g_ScreenshotSaveLocation, DEFAULT_SCREENSHOT_FILE, FOLDERID_Pictures); } //---------------------------------------------------------------------------- @@ -3577,6 +5522,9 @@ auto GetUniqueRecordingFilename() //---------------------------------------------------------------------------- winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndRecord ) try { + // Capture the UI thread context so we can resume on it for the save dialog + winrt::apartment_context uiThread; + auto tempFolderPath = std::filesystem::temp_directory_path().wstring(); auto tempFolder = co_await winrt::StorageFolder::GetFolderFromPathAsync( tempFolderPath ); auto appFolder = co_await tempFolder.CreateFolderAsync( L"ZoomIt", winrt::CreationCollisionOption::OpenIfExists ); @@ -3609,6 +5557,9 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR // Create the appropriate recording session based on format OutputDebugStringW((L"Starting recording session. Framerate: " + std::to_wstring(g_RecordFrameRate) + L" scaling: " + std::to_wstring(g_RecordScaling) + L" Format: " + (g_RecordingFormat == RecordingFormat::GIF ? L"GIF" : L"MP4") + L"\n").c_str()); + bool recordingStarted = false; + HRESULT captureStatus = S_OK; + if (g_RecordingFormat == RecordingFormat::GIF) { g_GifRecordingSession = GifRecordingSession::Create( @@ -3618,10 +5569,37 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR g_RecordFrameRate, stream ); + recordingStarted = (g_GifRecordingSession != nullptr); + if( g_hWndLiveZoom != NULL ) g_GifRecordingSession->EnableCursorCapture( false ); - co_await g_GifRecordingSession->StartAsync(); + if (recordingStarted) + { + try + { + co_await g_GifRecordingSession->StartAsync(); + } + catch (const winrt::hresult_error& error) + { + captureStatus = error.code(); + OutputDebugStringW((L"Recording session failed: " + error.message() + L"\n").c_str()); + } + } + + // If no frames were captured, behave as if the hotkey was never pressed. + if (recordingStarted && g_GifRecordingSession && !g_GifRecordingSession->HasCapturedFrames()) + { + if (stream) + { + stream.Close(); + stream = nullptr; + } + try { co_await file.DeleteAsync(); } catch (...) {} + g_RecordingSession = nullptr; + g_GifRecordingSession = nullptr; + co_return; + } } else { @@ -3631,16 +5609,61 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR *rcCrop, g_RecordFrameRate, g_CaptureAudio, + g_CaptureSystemAudio, stream ); + recordingStarted = (g_RecordingSession != nullptr); + if( g_hWndLiveZoom != NULL ) g_RecordingSession->EnableCursorCapture( false ); - co_await g_RecordingSession->StartAsync(); + if (recordingStarted) + { + try + { + co_await g_RecordingSession->StartAsync(); + } + catch (const winrt::hresult_error& error) + { + captureStatus = error.code(); + OutputDebugStringW((L"Recording session failed: " + error.message() + L"\n").c_str()); + } + } + + // If no frames were captured, behave as if the hotkey was never pressed. + if (recordingStarted && g_RecordingSession && !g_RecordingSession->HasCapturedVideoFrames()) + { + if (stream) + { + stream.Close(); + stream = nullptr; + } + try { co_await file.DeleteAsync(); } catch (...) {} + g_RecordingSession = nullptr; + g_GifRecordingSession = nullptr; + co_return; + } } - // Check if recording was aborted - if( g_RecordingSession == nullptr && g_GifRecordingSession == nullptr ) { + // If we never created a session, bail and clean up the temp file silently + if( !recordingStarted ) { + + if (stream) { + stream.Close(); + stream = nullptr; + } + try { co_await file.DeleteAsync(); } catch (...) {} + co_return; + } + + // Recording completed (closed via hotkey or item close). Proceed to save/trim workflow. + OutputDebugStringW(L"[Recording] StartAsync completed, entering save workflow\n"); + + // Resume on the UI thread for the save dialog + co_await uiThread; + OutputDebugStringW(L"[Recording] Resumed on UI thread\n"); + + { g_bSaveInProgress = true; @@ -3649,106 +5672,149 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR winrt::StorageFile destFile = nullptr; HRESULT hr = S_OK; try { - auto saveDialog = wil::CoCreateInstance( CLSID_FileSaveDialog ); - FILEOPENDIALOGOPTIONS options; - if( SUCCEEDED( saveDialog->GetOptions( &options ) ) ) - saveDialog->SetOptions( options | FOS_FORCEFILESYSTEM ); - wil::com_ptr videosItem; - if( SUCCEEDED ( SHGetKnownFolderItem( FOLDERID_Videos, KF_FLAG_DEFAULT, nullptr, IID_IShellItem, (void**) videosItem.put() ) ) ) - saveDialog->SetDefaultFolder( videosItem.get() ); + // Show trim dialog option and save dialog + std::wstring trimmedFilePath; + auto suggestedName = GetUniqueRecordingFilename(); + auto finalPath = VideoRecordingSession::ShowSaveDialogWithTrim( + hWnd, + suggestedName, + std::wstring{ file.Path() }, + trimmedFilePath + ); - // Set file type based on the recording format - if (g_RecordingFormat == RecordingFormat::GIF) + if (!finalPath.empty()) { - saveDialog->SetDefaultExtension( L".gif" ); - COMDLG_FILTERSPEC fileTypes[] = { - { L"GIF Animation", L"*.gif" } - }; - saveDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); + auto path = std::filesystem::path(finalPath); + winrt::StorageFolder folder{ co_await winrt::StorageFolder::GetFolderFromPathAsync(path.parent_path().c_str()) }; + destFile = co_await folder.CreateFileAsync(path.filename().c_str(), winrt::CreationCollisionOption::ReplaceExisting); + + // If user trimmed, use the trimmed file + winrt::StorageFile sourceFile = file; + if (!trimmedFilePath.empty()) + { + sourceFile = co_await winrt::StorageFile::GetFileFromPathAsync(trimmedFilePath); + } + + // Move the chosen source into the user-selected destination + co_await sourceFile.MoveAndReplaceAsync(destFile); + + // If we moved a trimmed copy, clean up the original temp capture file + if (sourceFile != file) + { + try { co_await file.DeleteAsync(); } catch (...) {} + } + + // Use finalPath directly - destFile.Path() may be stale after MoveAndReplaceAsync + g_RecordingSaveLocation = finalPath; + // Update the registry buffer and save to persist across app restarts + wcsncpy_s(g_RecordingSaveLocationBuffer, g_RecordingSaveLocation.c_str(), _TRUNCATE); + reg.WriteRegSettings(RegSettings); + SaveToClipboard(g_RecordingSaveLocation.c_str(), hWnd); } else { - saveDialog->SetDefaultExtension( L".mp4" ); - COMDLG_FILTERSPEC fileTypes[] = { - { L"MP4 Video", L"*.mp4" } - }; - saveDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); + // User cancelled + hr = HRESULT_FROM_WIN32(ERROR_CANCELLED); } - // Peek the folder Windows has chosen to display - static std::filesystem::path lastSaveFolder; - wil::unique_cotaskmem_string chosenFolderPath; - wil::com_ptr currentSelectedFolder; - bool bFolderChanged = false; - if (SUCCEEDED(saveDialog->GetFolder(currentSelectedFolder.put()))) - { - if (SUCCEEDED(currentSelectedFolder->GetDisplayName(SIGDN_FILESYSPATH, chosenFolderPath.put()))) - { - if (lastSaveFolder != chosenFolderPath.get()) - { - lastSaveFolder = chosenFolderPath.get() ? chosenFolderPath.get() : std::filesystem::path{}; - bFolderChanged = true; - } - } - } + //auto saveDialog = wil::CoCreateInstance( CLSID_FileSaveDialog ); + //FILEOPENDIALOGOPTIONS options; + //if( SUCCEEDED( saveDialog->GetOptions( &options ) ) ) + // saveDialog->SetOptions( options | FOS_FORCEFILESYSTEM ); + //wil::com_ptr videosItem; + //if( SUCCEEDED ( SHGetKnownFolderItem( FOLDERID_Videos, KF_FLAG_DEFAULT, nullptr, IID_IShellItem, (void**) videosItem.put() ) ) ) + // saveDialog->SetDefaultFolder( videosItem.get() ); - if( (g_RecordingFormat == RecordingFormat::GIF && g_RecordingSaveLocationGIF.size() == 0) || (g_RecordingFormat == RecordingFormat::MP4 && g_RecordingSaveLocation.size() == 0) || (bFolderChanged)) { + //// Set file type based on the recording format + //if (g_RecordingFormat == RecordingFormat::GIF) + //{ + // saveDialog->SetDefaultExtension( L".gif" ); + // COMDLG_FILTERSPEC fileTypes[] = { + // { L"GIF Animation", L"*.gif" } + // }; + // saveDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); + //} + //else + //{ + // saveDialog->SetDefaultExtension( L".mp4" ); + // COMDLG_FILTERSPEC fileTypes[] = { + // { L"MP4 Video", L"*.mp4" } + // }; + // saveDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); + //} - wil::com_ptr shellItem; - wil::unique_cotaskmem_string folderPath; - if (SUCCEEDED(saveDialog->GetFolder(shellItem.put())) && SUCCEEDED(shellItem->GetDisplayName(SIGDN_FILESYSPATH, folderPath.put()))) { - if (g_RecordingFormat == RecordingFormat::GIF) { - g_RecordingSaveLocationGIF = folderPath.get(); - std::filesystem::path currentPath{ g_RecordingSaveLocationGIF }; - g_RecordingSaveLocationGIF = currentPath / DEFAULT_GIF_RECORDING_FILE; - } - else { - g_RecordingSaveLocation = folderPath.get(); - if (g_RecordingFormat == RecordingFormat::MP4) { - std::filesystem::path currentPath{ g_RecordingSaveLocation }; - g_RecordingSaveLocation = currentPath / DEFAULT_RECORDING_FILE; - } - } - } - } + //// Peek the folder Windows has chosen to display + //static std::filesystem::path lastSaveFolder; + //wil::unique_cotaskmem_string chosenFolderPath; + //wil::com_ptr currentSelectedFolder; + //bool bFolderChanged = false; + //if (SUCCEEDED(saveDialog->GetFolder(currentSelectedFolder.put()))) + //{ + // if (SUCCEEDED(currentSelectedFolder->GetDisplayName(SIGDN_FILESYSPATH, chosenFolderPath.put()))) + // { + // if (lastSaveFolder != chosenFolderPath.get()) + // { + // lastSaveFolder = chosenFolderPath.get() ? chosenFolderPath.get() : std::filesystem::path{}; + // bFolderChanged = true; + // } + // } + //} - // Always use appropriate default filename based on current format - auto suggestedName = GetUniqueRecordingFilename(); - saveDialog->SetFileName( suggestedName.c_str() ); + //if( (g_RecordingFormat == RecordingFormat::GIF && g_RecordingSaveLocationGIF.size() == 0) || (g_RecordingFormat == RecordingFormat::MP4 && g_RecordingSaveLocation.size() == 0) || (bFolderChanged)) { - THROW_IF_FAILED( saveDialog->Show( hWnd ) ); - wil::com_ptr shellItem; - THROW_IF_FAILED(saveDialog->GetResult(shellItem.put())); - wil::unique_cotaskmem_string filePath; - THROW_IF_FAILED(shellItem->GetDisplayName(SIGDN_FILESYSPATH, filePath.put())); - auto path = std::filesystem::path( filePath.get() ); + // wil::com_ptr shellItem; + // wil::unique_cotaskmem_string folderPath; + // if (SUCCEEDED(saveDialog->GetFolder(shellItem.put())) && SUCCEEDED(shellItem->GetDisplayName(SIGDN_FILESYSPATH, folderPath.put()))) { + // if (g_RecordingFormat == RecordingFormat::GIF) { + // g_RecordingSaveLocationGIF = folderPath.get(); + // std::filesystem::path currentPath{ g_RecordingSaveLocationGIF }; + // g_RecordingSaveLocationGIF = currentPath / DEFAULT_GIF_RECORDING_FILE; + // } + // else { + // g_RecordingSaveLocation = folderPath.get(); + // if (g_RecordingFormat == RecordingFormat::MP4) { + // std::filesystem::path currentPath{ g_RecordingSaveLocation }; + // g_RecordingSaveLocation = currentPath / DEFAULT_RECORDING_FILE; + // } + // } + // } + //} - winrt::StorageFolder folder{ co_await winrt::StorageFolder::GetFolderFromPathAsync( path.parent_path().c_str() ) }; - destFile = co_await folder.CreateFileAsync( path.filename().c_str(), winrt::CreationCollisionOption::ReplaceExisting ); + //// Always use appropriate default filename based on current format + //auto suggestedName = GetUniqueRecordingFilename(); + //saveDialog->SetFileName( suggestedName.c_str() ); + + //THROW_IF_FAILED( saveDialog->Show( hWnd ) ); + //wil::com_ptr shellItem; + //THROW_IF_FAILED(saveDialog->GetResult(shellItem.put())); + //wil::unique_cotaskmem_string filePath; + //THROW_IF_FAILED(shellItem->GetDisplayName(SIGDN_FILESYSPATH, filePath.put())); + //auto path = std::filesystem::path( filePath.get() ); + + //winrt::StorageFolder folder{ co_await winrt::StorageFolder::GetFolderFromPathAsync( path.parent_path().c_str() ) }; + //destFile = co_await folder.CreateFileAsync( path.filename().c_str(), winrt::CreationCollisionOption::ReplaceExisting ); } catch( const wil::ResultException& error ) { - + OutputDebugStringW((L"[Recording] wil exception: hr=0x" + std::to_wstring(error.GetErrorCode()) + L"\n").c_str()); hr = error.GetErrorCode(); } + catch( const std::exception& ex ) { + OutputDebugStringA("[Recording] std::exception: "); + OutputDebugStringA(ex.what()); + OutputDebugStringA("\n"); + hr = E_FAIL; + } + catch( ... ) { + OutputDebugStringW(L"[Recording] Unknown exception in save workflow\n"); + hr = E_FAIL; + } if( destFile == nullptr ) { if (stream) { stream.Close(); stream = nullptr; } - co_await file.DeleteAsync(); - } - else { - - co_await file.MoveAndReplaceAsync(destFile); - if (g_RecordingFormat == RecordingFormat::GIF) { - g_RecordingSaveLocationGIF = file.Path(); - SaveToClipboard(g_RecordingSaveLocationGIF.c_str(), hWnd); - } - else { - g_RecordingSaveLocation = file.Path(); - SaveToClipboard(g_RecordingSaveLocation.c_str(), hWnd); - } + try { co_await file.DeleteAsync(); } catch (...) {} } g_bSaveInProgress = false; @@ -3759,18 +5825,19 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR if( FAILED( hr ) ) throw winrt::hresult_error( hr ); } - else { - if (stream) { - stream.Close(); - stream = nullptr; - } - co_await file.DeleteAsync(); - g_RecordingSession = nullptr; - g_GifRecordingSession = nullptr; + // Ensure globals are reset after the save/cleanup path completes + if (stream) { + stream.Close(); + stream = nullptr; } + g_RecordingSession = nullptr; + g_GifRecordingSession = nullptr; } catch( const winrt::hresult_error& error ) { + // Reset the save-in-progress flag so that hotkeys are not blocked after an error or cancellation + g_bSaveInProgress = false; + PostMessage( g_hWndMain, WM_USER_STOP_RECORDING, 0, 0 ); // Suppress the error from canceling the save dialog @@ -3953,7 +6020,6 @@ LRESULT APIENTRY MainWndProc( HWND hWndRecord; int x, y, delta; HMENU hPopupMenu; - OPENFILENAME openFileName; static TCHAR filePath[MAX_PATH] = {L"zoomit"}; NOTIFYICONDATA tNotifyIconData; static DWORD64 g_TelescopingZoomLastTick = 0ull; @@ -4013,6 +6079,13 @@ LRESULT APIENTRY MainWndProc( if( wParam == 2 && zoomLevel == 1 ) { g_Zoomed = FALSE; + + // Unregister Ctrl+C and Ctrl+S hotkeys when exiting static zoom + UnregisterHotKey( hWnd, COPY_IMAGE_HOTKEY ); + UnregisterHotKey( hWnd, COPY_CROP_HOTKEY ); + UnregisterHotKey( hWnd, SAVE_IMAGE_HOTKEY ); + UnregisterHotKey( hWnd, SAVE_CROP_HOTKEY ); + if( g_ZoomOnLiveZoom ) { GetCursorPos( &cursorPos ); @@ -4086,6 +6159,13 @@ LRESULT APIENTRY MainWndProc( reg.ReadRegSettings( RegSettings ); + // Refresh dark mode state after loading theme override from registry + RefreshDarkModeState(); + + // Initialize save location strings from registry buffers + g_RecordingSaveLocation = g_RecordingSaveLocationBuffer; + g_ScreenshotSaveLocation = g_ScreenshotSaveLocationBuffer; + // Set g_RecordScaling based on the current recording format if (g_RecordingFormat == RecordingFormat::GIF) { g_RecordScaling = g_RecordScalingGIF; @@ -4294,6 +6374,10 @@ LRESULT APIENTRY MainWndProc( case SNIP_SAVE_HOTKEY: case SNIP_HOTKEY: { + OutputDebugStringW((L"[Snip] Hotkey received: " + std::to_wstring(LOWORD(wParam)) + + L" (SNIP_SAVE=" + std::to_wstring(SNIP_SAVE_HOTKEY) + + L" SNIP=" + std::to_wstring(SNIP_HOTKEY) + L")\n").c_str()); + // Block liveZoom liveDraw snip due to mirroring bug if( IsWindowVisible( g_hWndLiveZoom ) && ( GetWindowLongPtr( hWnd, GWL_EXSTYLE ) & WS_EX_LAYERED ) ) @@ -4339,10 +6423,8 @@ LRESULT APIENTRY MainWndProc( // Now copy crop or copy+save if( LOWORD( wParam ) == SNIP_SAVE_HOTKEY ) { - // Hide cursor for screen capture - ShowCursor(false); + // IDC_SAVE_CROP handles cursor hiding internally after region selection SendMessage( hWnd, WM_COMMAND, IDC_SAVE_CROP, ( zoomed ? 0 : SHALLOW_ZOOM ) ); - ShowCursor(true); } else { @@ -4378,6 +6460,22 @@ LRESULT APIENTRY MainWndProc( break; } + case SAVE_IMAGE_HOTKEY: + SendMessage(hWnd, WM_COMMAND, IDC_SAVE, 0); + break; + + case SAVE_CROP_HOTKEY: + SendMessage(hWnd, WM_COMMAND, IDC_SAVE_CROP, 0); + break; + + case COPY_IMAGE_HOTKEY: + SendMessage(hWnd, WM_COMMAND, IDC_COPY, 0); + break; + + case COPY_CROP_HOTKEY: + SendMessage(hWnd, WM_COMMAND, IDC_COPY_CROP, 0); + break; + case BREAK_HOTKEY: // // Go to break timer @@ -4528,6 +6626,12 @@ LRESULT APIENTRY MainWndProc( break; } + // Ignore recording hotkey when save dialog is open + if( g_bSaveInProgress ) + { + break; + } + // Start screen recording try { @@ -4731,6 +6835,12 @@ LRESULT APIENTRY MainWndProc( g_DrawingShape = FALSE; OutputDebug( L"Zoom on\n"); + // Register Ctrl+C and Ctrl+S hotkeys only during static zoom + RegisterHotKey(hWnd, COPY_IMAGE_HOTKEY, MOD_CONTROL | MOD_NOREPEAT, 'C'); + RegisterHotKey(hWnd, COPY_CROP_HOTKEY, MOD_CONTROL | MOD_SHIFT | MOD_NOREPEAT, 'C'); + RegisterHotKey(hWnd, SAVE_IMAGE_HOTKEY, MOD_CONTROL | MOD_NOREPEAT, 'S'); + RegisterHotKey(hWnd, SAVE_CROP_HOTKEY, MOD_CONTROL | MOD_SHIFT | MOD_NOREPEAT, 'S'); + #ifdef __ZOOMIT_POWERTOYS__ if( g_StartedByPowerToys ) { @@ -6230,7 +8340,7 @@ LRESULT APIENTRY MainWndProc( DeleteTypedText( &typedKeyList ); // 1 means don't reset the cursor. We get that for font resizing - // Only move the cursor if we're drawing, because otherwise the screen moves to center + // Only move the cursor if we're drawing, else the screen moves to center // on the new cursor position if( wParam != 1 && g_Drawing ) { @@ -6284,6 +8394,8 @@ LRESULT APIENTRY MainWndProc( InsertMenu( hPopupMenu, 0, MF_BYPOSITION|MF_SEPARATOR, 0, NULL ); InsertMenu( hPopupMenu, 0, MF_BYPOSITION, IDC_OPTIONS, L"&Options" ); } + // Apply dark mode theme to the menu + ApplyDarkModeToMenu( hPopupMenu ); TrackPopupMenu( hPopupMenu, 0, pt.x , pt.y, 0, hWnd, NULL ); DestroyMenu( hPopupMenu ); break; @@ -6384,11 +8496,14 @@ LRESULT APIENTRY MainWndProc( { // Reload the settings. This message is called from PowerToys after a setting is changed by the user. reg.ReadRegSettings(RegSettings); - + + // Refresh dark mode state after loading theme override from registry + RefreshDarkModeState(); + if (g_RecordingFormat == RecordingFormat::GIF) { g_RecordScaling = g_RecordScalingGIF; - g_RecordFrameRate = RECORDING_FORMAT_GIF_DEFAULT_FRAMERATE; + g_RecordFrameRate = RECORDING_FORMAT_GIF_DEFAULT_FRAMERATE; } else { @@ -6567,34 +8682,79 @@ LRESULT APIENTRY MainWndProc( // Open the Save As dialog and capture the desired file path and whether to // save the zoomed display or the source bitmap pixels. g_bSaveInProgress = true; - memset( &openFileName, 0, sizeof(openFileName )); - openFileName.lStructSize = OPENFILENAME_SIZE_VERSION_400; - openFileName.hwndOwner = hWnd; - openFileName.hInstance = static_cast(g_hInstance); - openFileName.nMaxFile = sizeof(filePath)/sizeof(filePath[0]); - openFileName.Flags = OFN_LONGNAMES|OFN_HIDEREADONLY|OFN_OVERWRITEPROMPT; - openFileName.lpstrTitle = L"Save zoomed screen..."; - openFileName.lpstrDefExt = NULL; // "*.png"; - openFileName.nFilterIndex = 1; - openFileName.lpstrFilter = L"Zoomed PNG\0*.png\0" - //"Zoomed BMP\0*.bmp\0" - "Actual size PNG\0*.png\0\0"; - //"Actual size BMP\0*.bmp\0\0"; - openFileName.lpstrFile = filePath; - if( GetSaveFileName( &openFileName ) ) + // Get a unique filename suggestion + auto suggestedName = GetUniqueScreenshotFilename(); + + // Create modern IFileSaveDialog + auto saveDialog = wil::CoCreateInstance( CLSID_FileSaveDialog ); + + FILEOPENDIALOGOPTIONS options; + if( SUCCEEDED( saveDialog->GetOptions( &options ) ) ) + saveDialog->SetOptions( options | FOS_FORCEFILESYSTEM | FOS_OVERWRITEPROMPT ); + + // Set file types - index is 1-based when retrieved via GetFileTypeIndex + COMDLG_FILTERSPEC fileTypes[] = { + { L"Zoomed PNG", L"*.png" }, + { L"Actual size PNG", L"*.png" } + }; + saveDialog->SetFileTypes( _countof( fileTypes ), fileTypes ); + saveDialog->SetFileTypeIndex( 1 ); // Default to "Zoomed PNG" + saveDialog->SetDefaultExtension( L"png" ); + saveDialog->SetFileName( suggestedName.c_str() ); + saveDialog->SetTitle( L"ZoomIt: Save Zoomed Screen..." ); + + // Set default folder to the last save location if available + if( !g_ScreenshotSaveLocation.empty() ) { - TCHAR targetFilePath[MAX_PATH]; - _tcscpy( targetFilePath, filePath ); - if( !_tcsrchr( targetFilePath, '.' ) ) + std::filesystem::path lastPath( g_ScreenshotSaveLocation ); + if( lastPath.has_parent_path() ) { - _tcscat( targetFilePath, L".png" ); + wil::com_ptr folderItem; + if( SUCCEEDED( SHCreateItemFromParsingName( lastPath.parent_path().c_str(), + nullptr, IID_PPV_ARGS( &folderItem ) ) ) ) + { + saveDialog->SetFolder( folderItem.get() ); + } + } + } + + OpenSaveDialogEvents* pEvents = new OpenSaveDialogEvents(); + DWORD dwCookie = 0; + saveDialog->Advise(pEvents, &dwCookie); + + UINT selectedFilterIndex = 1; + std::wstring selectedFilePath; + + if( SUCCEEDED( saveDialog->Show( hWnd ) ) ) + { + wil::com_ptr resultItem; + if( SUCCEEDED( saveDialog->GetResult( &resultItem ) ) ) + { + wil::unique_cotaskmem_string pathStr; + if( SUCCEEDED( resultItem->GetDisplayName( SIGDN_FILESYSPATH, &pathStr ) ) ) + { + selectedFilePath = pathStr.get(); + } + } + saveDialog->GetFileTypeIndex( &selectedFilterIndex ); + } + + saveDialog->Unadvise(dwCookie); + pEvents->Release(); + + if( !selectedFilePath.empty() ) + { + std::wstring targetFilePath = selectedFilePath; + if( targetFilePath.find(L'.') == std::wstring::npos ) + { + targetFilePath += L".png"; } - if( openFileName.nFilterIndex == 2 ) + if( selectedFilterIndex == 2 ) { // Save at actual size. - SavePng( targetFilePath, hbmActualSize.get() ); + SavePng( targetFilePath.c_str(), hbmActualSize.get() ); } else { @@ -6621,8 +8781,13 @@ LRESULT APIENTRY MainWndProc( saveWidth, saveHeight, SRCCOPY | CAPTUREBLT ); - SavePng( targetFilePath, hbmZoomed.get() ); + SavePng(targetFilePath.c_str(), hbmZoomed.get()); } + + // Remember the save location for next time and persist to registry + g_ScreenshotSaveLocation = targetFilePath; + wcsncpy_s(g_ScreenshotSaveLocationBuffer, g_ScreenshotSaveLocation.c_str(), _TRUNCATE); + reg.WriteRegSettings(RegSettings); } g_bSaveInProgress = false; @@ -7080,7 +9245,7 @@ LRESULT APIENTRY MainWndProc( return TRUE; case WM_DESTROY: - + CleanupDarkModeResources(); PostQuitMessage( 0 ); break; @@ -7884,6 +10049,11 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance pEnableThemeDialogTexture = (type_pEnableThemeDialogTexture) GetProcAddress( GetModuleHandle( L"uxtheme.dll" ), "EnableThemeDialogTexture" ); + + // Initialize dark mode support early, before any windows are created + // This is required for popup menus to use dark mode + InitializeDarkMode(); + pMonitorFromPoint = (type_MonitorFromPoint) GetProcAddress( LoadLibrarySafe( L"User32.dll", DLL_LOAD_LOCATION_SYSTEM), "MonitorFromPoint" ); pGetMonitorInfo = (type_pGetMonitorInfo) GetProcAddress( LoadLibrarySafe( L"User32.dll", DLL_LOAD_LOCATION_SYSTEM), @@ -7934,7 +10104,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance // Windows Server 2022 (and including Windows 11) introduced a bug where the cursor disappears // in live zoom. Use the full-screen magnifier as a workaround on those versions only. It is // currently impractical as a replacement; it requires calling MagSetInputTransform for all - // input to be transformed. Otherwise, some hit-testing is misdirected. MagSetInputTransform + // input to be transformed. Else, some hit-testing is misdirected. MagSetInputTransform // fails without token UI access, which is impractical; it requires copying the executable // under either %ProgramFiles% or %SystemRoot%, which requires elevation. // diff --git a/src/modules/ZoomIt/ZoomIt/pch.h b/src/modules/ZoomIt/ZoomIt/pch.h index 2afdc4e542..12b0d326b2 100644 --- a/src/modules/ZoomIt/ZoomIt/pch.h +++ b/src/modules/ZoomIt/ZoomIt/pch.h @@ -7,8 +7,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -41,12 +43,15 @@ #include #include #include +#include +#include #include #include #include #include #include #include +#include #include #include @@ -69,6 +74,9 @@ #include #include #include +#include +#include +#include // STL diff --git a/src/modules/ZoomIt/ZoomIt/resource.h b/src/modules/ZoomIt/ZoomIt/resource.h index 2458e8ce75..c3cffd6d7b 100644 --- a/src/modules/ZoomIt/ZoomIt/resource.h +++ b/src/modules/ZoomIt/ZoomIt/resource.h @@ -12,6 +12,7 @@ // Non-localizable ////////////////////////////// #define IDC_AUDIO 117 +#define IDD_VIDEO_TRIM 119 #define IDC_LINK 1000 #define IDC_ALT 1001 #define IDC_CTRL 1002 @@ -94,9 +95,22 @@ #define IDC_DEMOTYPE_STATIC2 1074 #define IDC_COPYRIGHT 1075 #define IDC_RECORD_FORMAT 1076 +#define IDC_TRIM_POSITION_LABEL 1087 +#define IDC_TRIM_PREVIEW 1088 +#define IDC_TRIM_TIMELINE 1089 +#define IDC_TRIM_PLAY_PAUSE 1090 +#define IDC_TRIM_REWIND 1091 +#define IDC_TRIM_FORWARD 1092 +#define IDC_TRIM_DURATION_LABEL 1094 +#define IDC_TRIM_SKIP_START 1095 +#define IDC_TRIM_SKIP_END 1096 +#define IDC_TRIM_VOLUME 1097 +#define IDC_TRIM_VOLUME_ICON 1098 #define IDC_PEN_WIDTH 1105 #define IDC_TIMER 1106 #define IDC_SMOOTH_IMAGE 1107 +#define IDC_CAPTURE_SYSTEM_AUDIO 1108 +#define IDC_MICROPHONE_LABEL 1109 #define IDC_SAVE 40002 #define IDC_COPY 40004 #define IDC_RECORD 40006 @@ -109,9 +123,9 @@ // #ifdef APSTUDIO_INVOKED #ifndef APSTUDIO_READONLY_SYMBOLS -#define _APS_NEXT_RESOURCE_VALUE 118 +#define _APS_NEXT_RESOURCE_VALUE 120 #define _APS_NEXT_COMMAND_VALUE 40013 -#define _APS_NEXT_CONTROL_VALUE 1078 +#define _APS_NEXT_CONTROL_VALUE 1099 #define _APS_NEXT_SYMED_VALUE 101 #endif #endif diff --git a/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs b/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs index 1bca1b573a..6c6535801a 100644 --- a/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs +++ b/src/settings-ui/Settings.UI.Library/ZoomItProperties.cs @@ -89,6 +89,8 @@ namespace Microsoft.PowerToys.Settings.UI.Library public StringProperty RecordFormat { get; set; } + public BoolProperty CaptureSystemAudio { get; set; } + public BoolProperty CaptureAudio { get; set; } public StringProperty MicrophoneDeviceId { get; set; } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/ZoomItPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/ZoomItPage.xaml index 68ff588566..0177d1158f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/ZoomItPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/ZoomItPage.xaml @@ -285,6 +285,9 @@ MP4 + + + diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw index a40d33a7ec..ff7e61cccd 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -1,17 +1,17 @@ - @@ -4834,9 +4834,9 @@ Activate by holding the key for the character you want to add an accent to, then Zoom in or out to enlarge content and make details clearer. - Press **the mouse wheel** or **the Up / Down arrow keys** to zoom in or out. -Press **Esc** or **the right mouse button** to exit zoom mode. -Press **Ctrl + C** to capture the zoomed view, or **Ctrl + S** to save it. + Press **the mouse wheel** or **the Up / Down arrow keys** to zoom in or out. +Press **Esc** or **the right mouse button** to exit zoom mode. +Press **Ctrl + C** to capture the zoomed view, or **Ctrl + S** to save it. Press **Ctrl + Shift** to crop before copying or saving. @@ -4864,23 +4864,23 @@ Press **Ctrl + Shift** to crop before copying or saving. Draw - Press **the left mouse button** to toggle drawing mode when zoomed in, and **the right mouse button** to exit. + Press **the left mouse button** to toggle drawing mode when zoomed in, and **the right mouse button** to exit. Press **Ctrl + Z** to undo, **E** to clear drawings, and **Space** to center the cursor. -**Pen control** +**Pen control** Press **Ctrl + the mouse wheel** or **Ctrl + Up / Down** to adjust the pen width. -**Colors** +**Colors** Press **R** (Red), **G** (Green), **B** (Blue), **O** (Orange), **Y** (Yellow), or **P** (Pink) to switch colors. -**Highlight and blur** +**Highlight and blur** Press **Shift + a color key** for a translucent highlighter, **X** for blur, or **Shift + X** for a stronger blur. -**Shapes** +**Shapes** Press **Shift** for a line, **Ctrl** for a rectangle, **Tab** for an ellipse, or **Shift + Ctrl** for an arrow. -**Screen** -Press **W** or **K** for a white or black sketch pad. +**Screen** +Press **W** or **K** for a white or black sketch pad. Press **Ctrl + C** to copy or **Ctrl + S** to save, and **Ctrl + Shift** to crop. @@ -4907,16 +4907,16 @@ Press **Ctrl + C** to copy or **Ctrl + S** to save, and **Ctrl + Shift** to crop Insert predefined text snippets with a shortcut using a text file. - Text can be pulled from the clipboard when it starts with **[start]**. -Use **[end]** to separate snippets, **[pause:n]** to insert pauses (in seconds), and **[paste]** / **[/paste]** to send clipboard text. + Text can be pulled from the clipboard when it starts with **[start]**. +Use **[end]** to separate snippets, **[pause:n]** to insert pauses (in seconds), and **[paste]** / **[/paste]** to send clipboard text. Use **[enter]**, **[up]**, **[down]**, **[left]**, and **[right]** to issue keystrokes. ZoomIt can send text automatically or run in manual mode. Keyboard input is blocked while text is being sent. -In manual mode, press **Space** to unblock keyboard input at the end of a snippet. +In manual mode, press **Space** to unblock keyboard input at the end of a snippet. In auto mode, control returns automatically after completion. -At the end of the file, ZoomIt reloads the file and restarts from the beginning. +At the end of the file, ZoomIt reloads the file and restarts from the beginning. Press the hotkey with **Shift** in the opposite mode to step back to the previous **[end]** marker. Press **{0}** to reset DemoType and start from the beginning. @@ -4952,12 +4952,12 @@ Press **{0}** to reset DemoType and start from the beginning. Displays a countdown overlay for timed breaks or presentations. - Enter timer mode from the ZoomIt tray icon’s Break menu. + Enter timer mode from the ZoomIt tray icon’s Break menu. Press **the arrow keys** to adjust the time. If the timer window loses focus through **Alt + Tab**, press **the left mouse button** on the ZoomIt tray icon to reactivate it. Press **Esc** to exit timer mode. -Change the break timer color using the same keys as the drawing colors. +Change the break timer color using the same keys as the drawing colors. The break timer font matches the text font. @@ -5098,6 +5098,9 @@ The break timer font matches the text font. Format + + Capture system audio + Capture audio input @@ -6167,14 +6170,14 @@ The break timer font matches the text font. Press **{0}** to activate live drawing and **Esc** to clear annotations or to exit. - + Press **Ctrl + Up / Down** to adjust the zoom level. - Press **T** to switch to typing when drawing mode is active, and **Shift** for right-aligned text. -Press **Esc** or **the left mouse button** to exit typing mode. -Press **the mouse wheel** or **Up / Down** to adjust the font size. + Press **T** to switch to typing when drawing mode is active, and **Shift** for right-aligned text. +Press **Esc** or **the left mouse button** to exit typing mode. +Press **the mouse wheel** or **Up / Down** to adjust the font size. Text uses the current drawing color. diff --git a/src/settings-ui/Settings.UI/ViewModels/ZoomItViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/ZoomItViewModel.cs index 1b6d3b88fb..b925782191 100644 --- a/src/settings-ui/Settings.UI/ViewModels/ZoomItViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/ZoomItViewModel.cs @@ -850,6 +850,20 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels } } + public bool RecordCaptureSystemAudio + { + get => _zoomItSettings.Properties.CaptureSystemAudio.Value; + set + { + if (_zoomItSettings.Properties.CaptureSystemAudio.Value != value) + { + _zoomItSettings.Properties.CaptureSystemAudio.Value = value; + OnPropertyChanged(nameof(RecordCaptureSystemAudio)); + NotifySettingsChanged(); + } + } + } + public bool RecordCaptureAudio { get => _zoomItSettings.Properties.CaptureAudio.Value;