diff --git a/NativeMethods.cs b/NativeMethods.cs index 78ef4b7..5321aab 100644 --- a/NativeMethods.cs +++ b/NativeMethods.cs @@ -2,6 +2,7 @@ using System; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; +using Windows.Media.Control; namespace TimerApp { @@ -59,12 +60,15 @@ namespace TimerApp private static bool _mediaPlaying; private static long _mediaLastUpdateMs = -1; private static int _mediaUpdateInProgress; + private const int MediaCacheWhenPlayingMs = 500; + private const int MediaCacheWhenNotPlayingMs = 1200; private static void EnsureMediaPlaybackStatusFresh() { long now = Environment.TickCount64; long last = Interlocked.Read(ref _mediaLastUpdateMs); - if (last >= 0 && now - last < 3000) + int cacheMs = Volatile.Read(ref _mediaPlaying) ? MediaCacheWhenPlayingMs : MediaCacheWhenNotPlayingMs; + if (last >= 0 && now - last < cacheMs) { return; } @@ -74,13 +78,24 @@ namespace TimerApp return; } - _ = Task.Run(async () => + _ = Task.Run(() => { bool playing = false; try { - await Task.Yield(); - playing = TryIsAudioPlaying(); + playing = RunOnStaThread(() => + { + try + { + return TryIsSystemMediaSessionPlayingAsync().GetAwaiter().GetResult(); + } + catch + { + return false; + } + }); + + playing = playing || TryIsAudioPlaying(); } catch { @@ -96,6 +111,11 @@ namespace TimerApp } private static bool TryIsAudioPlaying() + { + return TryIsAudioPlaying(ERole.eMultimedia) || TryIsAudioPlaying(ERole.eConsole) || TryIsAudioPlaying(ERole.eCommunications); + } + + private static bool TryIsAudioPlaying(ERole role) { object? deviceEnumeratorObj = null; IMMDeviceEnumerator? deviceEnumerator = null; @@ -115,7 +135,7 @@ namespace TimerApp return false; deviceEnumerator = (IMMDeviceEnumerator)deviceEnumeratorObj; - Marshal.ThrowExceptionForHR(deviceEnumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, ERole.eMultimedia, out device)); + Marshal.ThrowExceptionForHR(deviceEnumerator.GetDefaultAudioEndpoint(EDataFlow.eRender, role, out device)); Guid iid = typeof(IAudioSessionManager2).GUID; Marshal.ThrowExceptionForHR(device.Activate(ref iid, CLSCTX.CLSCTX_ALL, IntPtr.Zero, out sessionManagerObj)); @@ -141,7 +161,7 @@ namespace TimerApp if (sessionControl is IAudioMeterInformation meter) { Marshal.ThrowExceptionForHR(meter.GetPeakValue(out float peak)); - if (peak > 0.001f) + if (peak > 0.0001f) return true; } else @@ -170,6 +190,82 @@ namespace TimerApp } } + private static async Task TryIsSystemMediaSessionPlayingAsync() + { + try + { + GlobalSystemMediaTransportControlsSessionManager manager = await GlobalSystemMediaTransportControlsSessionManager.RequestAsync(); + + GlobalSystemMediaTransportControlsSession? current = manager.GetCurrentSession(); + if (IsPlaying(current)) + { + return true; + } + + foreach (GlobalSystemMediaTransportControlsSession session in manager.GetSessions()) + { + if (IsPlaying(session)) + { + return true; + } + } + + return false; + } + catch + { + return false; + } + } + + private static bool IsPlaying(GlobalSystemMediaTransportControlsSession? session) + { + if (session is null) + { + return false; + } + + try + { + GlobalSystemMediaTransportControlsSessionPlaybackInfo? info = session.GetPlaybackInfo(); + return info is not null && info.PlaybackStatus == GlobalSystemMediaTransportControlsSessionPlaybackStatus.Playing; + } + catch + { + return false; + } + } + + private static bool RunOnStaThread(Func action) + { + bool result = false; + Exception? error = null; + + Thread thread = new Thread(() => + { + try + { + result = action(); + } + catch (Exception ex) + { + error = ex; + } + }); + + thread.IsBackground = true; + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (error is not null) + { + return false; + } + + return result; + } + private static readonly Guid CLSID_MMDeviceEnumerator = new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E"); private enum EDataFlow @@ -244,8 +340,15 @@ namespace TimerApp [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] private interface IAudioSessionControl { - int NotImpl1(); int GetState(out AudioSessionState state); + int NotImpl1(); + int NotImpl2(); + int NotImpl3(); + int NotImpl4(); + int NotImpl5(); + int NotImpl6(); + int NotImpl7(); + int NotImpl8(); } [ComImport] diff --git a/README.md b/README.md index 7e66804..8491a8b 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,17 @@ - 休息倒计时以“秒级稳定推进”为目标 - 设计目的:让休息提示的倒计时更平滑、更可预期 +## 媒体播放检测(视频/音频) + +- 目标:当你在“播放视频/音频”时,倾向不推进工作计时,避免把被动观看算作连续高强度工作 +- 判定策略(由强到弱): + - 系统媒体会话(SMTC/GSMTC):当播放器/浏览器接入系统媒体控制时,可直接识别“播放/暂停” + - 音频会话峰值兜底:当系统媒体会话不可用时,通过默认音频设备的会话峰值判断是否正在输出声音 +- 响应延迟: + - 计时器自身按 1 秒节拍检查一次 + - 媒体状态会做短缓存刷新:播放中约 0.5 秒刷新、未播放约 1.2 秒刷新 + - 实际体验通常是“暂停后约 1 秒左右恢复计时” + ## 休息提醒(强提示的边界) - 休息阶段会出现遮罩式提醒,核心是“让你意识到现在该休息了” @@ -81,3 +92,29 @@ - 将“工作/休息时长、空闲判定阈值、主题偏好”等作为可持久化设置,保证重启后仍保持用户习惯 - 同时考虑“安装使用”和“解压即用”的便携形态:便携形态下更倾向把设置跟随程序一起携带,便于拷贝迁移 + +## 系统要求 + +- Windows 10 2004(版本号 19041)及以上:媒体播放检测依赖系统媒体会话相关 API + +## 编译与发布 + +- 开发环境要求 + - Windows(WinForms 桌面程序) + - .NET SDK 9(项目目标框架:net9.0-windows10.0.19041.0) +- 本地编译 + - Debug: + - `dotnet build` + - Release: + - `dotnet build -c Release` +- 本地运行 + - `dotnet run` +- 便携版打包(生成 dist zip) + - PowerShell: + - `.\scripts\publish-portable.ps1` + - 自包含(目标机无需安装 .NET Desktop Runtime): + - `.\scripts\publish-portable.ps1 -SelfContained $true` + - 指定架构(示例:x64): + - `.\scripts\publish-portable.ps1 -Rid win-x64` + - CMD: + - `.\scripts\publish-portable.cmd` diff --git a/TimerApp.csproj b/TimerApp.csproj index a3de757..44f119f 100644 --- a/TimerApp.csproj +++ b/TimerApp.csproj @@ -2,7 +2,7 @@ WinExe - net9.0-windows + net9.0-windows10.0.19041.0 enable true enable