fix: 优化视频/音频检测
This commit is contained in:
117
NativeMethods.cs
117
NativeMethods.cs
@@ -2,6 +2,7 @@ using System;
|
|||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Windows.Media.Control;
|
||||||
|
|
||||||
namespace TimerApp
|
namespace TimerApp
|
||||||
{
|
{
|
||||||
@@ -59,12 +60,15 @@ namespace TimerApp
|
|||||||
private static bool _mediaPlaying;
|
private static bool _mediaPlaying;
|
||||||
private static long _mediaLastUpdateMs = -1;
|
private static long _mediaLastUpdateMs = -1;
|
||||||
private static int _mediaUpdateInProgress;
|
private static int _mediaUpdateInProgress;
|
||||||
|
private const int MediaCacheWhenPlayingMs = 500;
|
||||||
|
private const int MediaCacheWhenNotPlayingMs = 1200;
|
||||||
|
|
||||||
private static void EnsureMediaPlaybackStatusFresh()
|
private static void EnsureMediaPlaybackStatusFresh()
|
||||||
{
|
{
|
||||||
long now = Environment.TickCount64;
|
long now = Environment.TickCount64;
|
||||||
long last = Interlocked.Read(ref _mediaLastUpdateMs);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -74,13 +78,24 @@ namespace TimerApp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(() =>
|
||||||
{
|
{
|
||||||
bool playing = false;
|
bool playing = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await Task.Yield();
|
playing = RunOnStaThread(() =>
|
||||||
playing = TryIsAudioPlaying();
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return TryIsSystemMediaSessionPlayingAsync().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
playing = playing || TryIsAudioPlaying();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
@@ -96,6 +111,11 @@ namespace TimerApp
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryIsAudioPlaying()
|
private static bool TryIsAudioPlaying()
|
||||||
|
{
|
||||||
|
return TryIsAudioPlaying(ERole.eMultimedia) || TryIsAudioPlaying(ERole.eConsole) || TryIsAudioPlaying(ERole.eCommunications);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryIsAudioPlaying(ERole role)
|
||||||
{
|
{
|
||||||
object? deviceEnumeratorObj = null;
|
object? deviceEnumeratorObj = null;
|
||||||
IMMDeviceEnumerator? deviceEnumerator = null;
|
IMMDeviceEnumerator? deviceEnumerator = null;
|
||||||
@@ -115,7 +135,7 @@ namespace TimerApp
|
|||||||
return false;
|
return false;
|
||||||
deviceEnumerator = (IMMDeviceEnumerator)deviceEnumeratorObj;
|
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;
|
Guid iid = typeof(IAudioSessionManager2).GUID;
|
||||||
Marshal.ThrowExceptionForHR(device.Activate(ref iid, CLSCTX.CLSCTX_ALL, IntPtr.Zero, out sessionManagerObj));
|
Marshal.ThrowExceptionForHR(device.Activate(ref iid, CLSCTX.CLSCTX_ALL, IntPtr.Zero, out sessionManagerObj));
|
||||||
@@ -141,7 +161,7 @@ namespace TimerApp
|
|||||||
if (sessionControl is IAudioMeterInformation meter)
|
if (sessionControl is IAudioMeterInformation meter)
|
||||||
{
|
{
|
||||||
Marshal.ThrowExceptionForHR(meter.GetPeakValue(out float peak));
|
Marshal.ThrowExceptionForHR(meter.GetPeakValue(out float peak));
|
||||||
if (peak > 0.001f)
|
if (peak > 0.0001f)
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -170,6 +190,82 @@ namespace TimerApp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<bool> 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<bool> 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 static readonly Guid CLSID_MMDeviceEnumerator = new Guid("BCDE0395-E52F-467C-8E3D-C4579291692E");
|
||||||
|
|
||||||
private enum EDataFlow
|
private enum EDataFlow
|
||||||
@@ -244,8 +340,15 @@ namespace TimerApp
|
|||||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||||
private interface IAudioSessionControl
|
private interface IAudioSessionControl
|
||||||
{
|
{
|
||||||
int NotImpl1();
|
|
||||||
int GetState(out AudioSessionState state);
|
int GetState(out AudioSessionState state);
|
||||||
|
int NotImpl1();
|
||||||
|
int NotImpl2();
|
||||||
|
int NotImpl3();
|
||||||
|
int NotImpl4();
|
||||||
|
int NotImpl5();
|
||||||
|
int NotImpl6();
|
||||||
|
int NotImpl7();
|
||||||
|
int NotImpl8();
|
||||||
}
|
}
|
||||||
|
|
||||||
[ComImport]
|
[ComImport]
|
||||||
|
|||||||
37
README.md
37
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`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<TargetFramework>net9.0-windows</TargetFramework>
|
<TargetFramework>net9.0-windows10.0.19041.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<UseWindowsForms>true</UseWindowsForms>
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
|||||||
Reference in New Issue
Block a user