mirror of
https://github.com/wwiinnddyy/LanMountainDesktop.git
synced 2026-06-25 03:04:26 +08:00
0.2.6
媒体播放组件,录音组件
This commit is contained in:
643
LanMontainDesktop/Services/IAudioRecorderService.cs
Normal file
643
LanMontainDesktop/Services/IAudioRecorderService.cs
Normal file
@@ -0,0 +1,643 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using PortAudioSharp;
|
||||
using PortAudioStream = PortAudioSharp.Stream;
|
||||
|
||||
namespace LanMontainDesktop.Services;
|
||||
|
||||
public enum AudioRecorderRuntimeState
|
||||
{
|
||||
Unsupported = 0,
|
||||
Ready = 1,
|
||||
Recording = 2,
|
||||
Paused = 3,
|
||||
Error = 4
|
||||
}
|
||||
|
||||
public sealed record AudioRecorderSnapshot(
|
||||
AudioRecorderRuntimeState State,
|
||||
TimeSpan Duration,
|
||||
double InputLevel,
|
||||
string LastSavedFilePath,
|
||||
string LastError)
|
||||
{
|
||||
public bool IsSupported => State != AudioRecorderRuntimeState.Unsupported;
|
||||
}
|
||||
|
||||
public interface IAudioRecorderService : IDisposable
|
||||
{
|
||||
AudioRecorderSnapshot GetSnapshot();
|
||||
|
||||
bool StartOrResume();
|
||||
|
||||
bool Pause();
|
||||
|
||||
string? StopAndSave();
|
||||
|
||||
void Discard();
|
||||
}
|
||||
|
||||
public static class AudioRecorderServiceFactory
|
||||
{
|
||||
private static readonly Lazy<IAudioRecorderService> SharedService = new(
|
||||
() =>
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() && !OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS())
|
||||
{
|
||||
return new NoOpAudioRecorderService("Unsupported platform");
|
||||
}
|
||||
|
||||
return new PortAudioRecorderService();
|
||||
},
|
||||
isThreadSafe: true);
|
||||
|
||||
public static IAudioRecorderService CreateDefault()
|
||||
{
|
||||
return SharedService.Value;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NoOpAudioRecorderService(string reason) : IAudioRecorderService
|
||||
{
|
||||
private readonly AudioRecorderSnapshot _snapshot = new(
|
||||
AudioRecorderRuntimeState.Unsupported,
|
||||
TimeSpan.Zero,
|
||||
0,
|
||||
string.Empty,
|
||||
reason);
|
||||
|
||||
public AudioRecorderSnapshot GetSnapshot()
|
||||
{
|
||||
return _snapshot;
|
||||
}
|
||||
|
||||
public bool StartOrResume()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool Pause()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public string? StopAndSave()
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Discard()
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PortAudioRecorderService : IAudioRecorderService
|
||||
{
|
||||
private const int ChannelCount = 1;
|
||||
private const int BitsPerSample = 16;
|
||||
private const int BytesPerSample = BitsPerSample / 8;
|
||||
private const int PreferredSampleRate = 16000;
|
||||
private const uint FramesPerBuffer = 320;
|
||||
|
||||
private readonly object _syncRoot = new();
|
||||
|
||||
private PortAudioStream? _stream;
|
||||
private PortAudioStream.Callback? _streamCallback;
|
||||
private MemoryStream? _pcmBuffer;
|
||||
|
||||
private AudioRecorderRuntimeState _state = AudioRecorderRuntimeState.Unsupported;
|
||||
private string _lastSavedFilePath = string.Empty;
|
||||
private string _lastError = string.Empty;
|
||||
private int _inputDeviceIndex = -1;
|
||||
private int _sampleRate = PreferredSampleRate;
|
||||
private double _deviceDefaultSampleRate = PreferredSampleRate;
|
||||
private long _capturedFrames;
|
||||
private double _inputLevel;
|
||||
private bool _isPortAudioInitialized;
|
||||
private bool _isDisposed;
|
||||
|
||||
public PortAudioRecorderService()
|
||||
{
|
||||
InitializeRuntime();
|
||||
}
|
||||
|
||||
public AudioRecorderSnapshot GetSnapshot()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
var level = _state == AudioRecorderRuntimeState.Recording
|
||||
? Math.Clamp(_inputLevel, 0, 1)
|
||||
: 0;
|
||||
|
||||
var duration = _capturedFrames <= 0 || _sampleRate <= 0
|
||||
? TimeSpan.Zero
|
||||
: TimeSpan.FromSeconds(_capturedFrames / (double)_sampleRate);
|
||||
|
||||
return new AudioRecorderSnapshot(
|
||||
State: _state,
|
||||
Duration: duration,
|
||||
InputLevel: level,
|
||||
LastSavedFilePath: _lastSavedFilePath,
|
||||
LastError: _lastError);
|
||||
}
|
||||
}
|
||||
|
||||
public bool StartOrResume()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_state == AudioRecorderRuntimeState.Unsupported)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_state == AudioRecorderRuntimeState.Error)
|
||||
{
|
||||
_state = AudioRecorderRuntimeState.Ready;
|
||||
}
|
||||
|
||||
if (_state == AudioRecorderRuntimeState.Recording)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (_state == AudioRecorderRuntimeState.Paused && _stream is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
_stream.Start();
|
||||
_state = AudioRecorderRuntimeState.Recording;
|
||||
_lastError = string.Empty;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetErrorLocked(ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
EnsureBufferLocked();
|
||||
ResetCaptureStateLocked();
|
||||
if (!TryOpenInputStreamLocked())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
_state = AudioRecorderRuntimeState.Recording;
|
||||
_lastError = string.Empty;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Pause()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_isDisposed || _state != AudioRecorderRuntimeState.Recording || _stream is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_stream.Stop();
|
||||
_state = AudioRecorderRuntimeState.Paused;
|
||||
_inputLevel = 0;
|
||||
_lastError = string.Empty;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SetErrorLocked(ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string? StopAndSave()
|
||||
{
|
||||
byte[] pcmData;
|
||||
int sampleRate;
|
||||
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_isDisposed ||
|
||||
(_state != AudioRecorderRuntimeState.Recording && _state != AudioRecorderRuntimeState.Paused))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
StopStreamLocked();
|
||||
|
||||
pcmData = _pcmBuffer?.ToArray() ?? Array.Empty<byte>();
|
||||
sampleRate = _sampleRate;
|
||||
|
||||
ResetCaptureStateLocked();
|
||||
_state = AudioRecorderRuntimeState.Ready;
|
||||
_inputLevel = 0;
|
||||
}
|
||||
|
||||
if (pcmData.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var outputPath = BuildOutputPath();
|
||||
try
|
||||
{
|
||||
WriteWaveFile(outputPath, pcmData, sampleRate, ChannelCount, BitsPerSample);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
SetErrorLocked(ex);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (_syncRoot)
|
||||
{
|
||||
_lastSavedFilePath = outputPath;
|
||||
_lastError = string.Empty;
|
||||
}
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
public void Discard()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StopStreamLocked();
|
||||
ResetCaptureStateLocked();
|
||||
_inputLevel = 0;
|
||||
_lastError = string.Empty;
|
||||
|
||||
if (_state != AudioRecorderRuntimeState.Unsupported)
|
||||
{
|
||||
_state = AudioRecorderRuntimeState.Ready;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_isDisposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isDisposed = true;
|
||||
|
||||
StopStreamLocked();
|
||||
_pcmBuffer?.Dispose();
|
||||
_pcmBuffer = null;
|
||||
|
||||
if (_isPortAudioInitialized)
|
||||
{
|
||||
try
|
||||
{
|
||||
PortAudio.Terminate();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore shutdown failures.
|
||||
}
|
||||
|
||||
_isPortAudioInitialized = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeRuntime()
|
||||
{
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_isDisposed || _isPortAudioInitialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
PortAudio.LoadNativeLibrary();
|
||||
PortAudio.Initialize();
|
||||
_isPortAudioInitialized = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_state = AudioRecorderRuntimeState.Unsupported;
|
||||
_lastError = ResolveErrorMessage(ex);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_inputDeviceIndex = PortAudio.DefaultInputDevice;
|
||||
if (_inputDeviceIndex < 0)
|
||||
{
|
||||
_state = AudioRecorderRuntimeState.Unsupported;
|
||||
_lastError = "No input device";
|
||||
return;
|
||||
}
|
||||
|
||||
var deviceInfo = PortAudio.GetDeviceInfo(_inputDeviceIndex);
|
||||
if (deviceInfo.maxInputChannels < 1)
|
||||
{
|
||||
_state = AudioRecorderRuntimeState.Unsupported;
|
||||
_lastError = "Input channels unavailable";
|
||||
return;
|
||||
}
|
||||
|
||||
_deviceDefaultSampleRate = deviceInfo.defaultSampleRate > 0
|
||||
? deviceInfo.defaultSampleRate
|
||||
: PreferredSampleRate;
|
||||
_state = AudioRecorderRuntimeState.Ready;
|
||||
_lastError = string.Empty;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_state = AudioRecorderRuntimeState.Unsupported;
|
||||
_lastError = ResolveErrorMessage(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryOpenInputStreamLocked()
|
||||
{
|
||||
if (!_isPortAudioInitialized || _inputDeviceIndex < 0)
|
||||
{
|
||||
_state = AudioRecorderRuntimeState.Unsupported;
|
||||
return false;
|
||||
}
|
||||
|
||||
var inputParameters = new StreamParameters
|
||||
{
|
||||
device = _inputDeviceIndex,
|
||||
channelCount = ChannelCount,
|
||||
sampleFormat = SampleFormat.Int16,
|
||||
suggestedLatency = ResolveSuggestedLatency(),
|
||||
hostApiSpecificStreamInfo = IntPtr.Zero
|
||||
};
|
||||
|
||||
_streamCallback ??= OnStreamCallback;
|
||||
foreach (var candidateRate in BuildSampleRateCandidates())
|
||||
{
|
||||
try
|
||||
{
|
||||
_stream?.Dispose();
|
||||
_stream = new PortAudioStream(
|
||||
inputParameters,
|
||||
null,
|
||||
candidateRate,
|
||||
FramesPerBuffer,
|
||||
StreamFlags.ClipOff,
|
||||
_streamCallback,
|
||||
this);
|
||||
_sampleRate = Math.Clamp((int)Math.Round(candidateRate), 8000, 96000);
|
||||
_stream.Start();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_stream?.Dispose();
|
||||
_stream = null;
|
||||
_lastError = ResolveErrorMessage(ex);
|
||||
}
|
||||
}
|
||||
|
||||
_state = AudioRecorderRuntimeState.Error;
|
||||
return false;
|
||||
}
|
||||
|
||||
private StreamCallbackResult OnStreamCallback(
|
||||
IntPtr input,
|
||||
IntPtr output,
|
||||
uint frameCount,
|
||||
ref StreamCallbackTimeInfo timeInfo,
|
||||
StreamCallbackFlags statusFlags,
|
||||
IntPtr userData)
|
||||
{
|
||||
_ = output;
|
||||
_ = timeInfo;
|
||||
_ = statusFlags;
|
||||
_ = userData;
|
||||
|
||||
if (frameCount == 0 || input == IntPtr.Zero)
|
||||
{
|
||||
return StreamCallbackResult.Continue;
|
||||
}
|
||||
|
||||
var byteCount = checked((int)(frameCount * ChannelCount * BytesPerSample));
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(byteCount);
|
||||
|
||||
try
|
||||
{
|
||||
Marshal.Copy(input, buffer, 0, byteCount);
|
||||
var peak = CalculatePeak(buffer, byteCount);
|
||||
|
||||
lock (_syncRoot)
|
||||
{
|
||||
if (_state != AudioRecorderRuntimeState.Recording)
|
||||
{
|
||||
return StreamCallbackResult.Continue;
|
||||
}
|
||||
|
||||
_pcmBuffer?.Write(buffer, 0, byteCount);
|
||||
_capturedFrames += frameCount;
|
||||
_inputLevel = (_inputLevel * 0.72) + (peak * 0.28);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Keep callback resilient to transient IO/interop errors.
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
|
||||
return StreamCallbackResult.Continue;
|
||||
}
|
||||
|
||||
private void StopStreamLocked()
|
||||
{
|
||||
if (_stream is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_stream.IsActive)
|
||||
{
|
||||
_stream.Stop();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore stop errors.
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_stream.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore close errors.
|
||||
}
|
||||
|
||||
_stream.Dispose();
|
||||
_stream = null;
|
||||
}
|
||||
|
||||
private void ResetCaptureStateLocked()
|
||||
{
|
||||
_capturedFrames = 0;
|
||||
_sampleRate = Math.Clamp(_sampleRate, 8000, 96000);
|
||||
_inputLevel = 0;
|
||||
_pcmBuffer?.SetLength(0);
|
||||
}
|
||||
|
||||
private void EnsureBufferLocked()
|
||||
{
|
||||
if (_pcmBuffer is not null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_pcmBuffer = new MemoryStream(capacity: 128 * 1024);
|
||||
}
|
||||
|
||||
private double ResolveSuggestedLatency()
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = PortAudio.GetDeviceInfo(_inputDeviceIndex);
|
||||
if (info.defaultLowInputLatency > 0)
|
||||
{
|
||||
return info.defaultLowInputLatency;
|
||||
}
|
||||
|
||||
if (info.defaultHighInputLatency > 0)
|
||||
{
|
||||
return info.defaultHighInputLatency;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall through to default latency.
|
||||
}
|
||||
|
||||
return 0.04;
|
||||
}
|
||||
|
||||
private double[] BuildSampleRateCandidates()
|
||||
{
|
||||
var ordered = new[] { PreferredSampleRate, _deviceDefaultSampleRate, 44100d, 48000d };
|
||||
var unique = new HashSet<int>();
|
||||
var list = new List<double>(ordered.Length);
|
||||
foreach (var rate in ordered)
|
||||
{
|
||||
var rounded = (int)Math.Round(rate);
|
||||
if (rounded < 8000 || rounded > 96000 || !unique.Add(rounded))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
list.Add(rounded);
|
||||
}
|
||||
|
||||
return list.ToArray();
|
||||
}
|
||||
|
||||
private static double CalculatePeak(byte[] buffer, int byteCount)
|
||||
{
|
||||
double peak = 0;
|
||||
for (var i = 0; i + 1 < byteCount; i += 2)
|
||||
{
|
||||
var sample = (short)(buffer[i] | (buffer[i + 1] << 8));
|
||||
var normalized = Math.Abs(sample) / 32768d;
|
||||
if (normalized > peak)
|
||||
{
|
||||
peak = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.Clamp(peak, 0, 1);
|
||||
}
|
||||
|
||||
private static string BuildOutputPath()
|
||||
{
|
||||
var root = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
root = AppContext.BaseDirectory;
|
||||
}
|
||||
|
||||
var folder = Path.Combine(root, "LanMontainDesktop", "Recordings");
|
||||
Directory.CreateDirectory(folder);
|
||||
|
||||
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff");
|
||||
return Path.Combine(folder, $"recording_{timestamp}.wav");
|
||||
}
|
||||
|
||||
private static void WriteWaveFile(string path, byte[] pcmData, int sampleRate, int channels, int bitsPerSample)
|
||||
{
|
||||
var byteRate = sampleRate * channels * (bitsPerSample / 8);
|
||||
var blockAlign = channels * (bitsPerSample / 8);
|
||||
using var stream = File.Create(path);
|
||||
using var writer = new BinaryWriter(stream);
|
||||
|
||||
writer.Write(new[] { (byte)'R', (byte)'I', (byte)'F', (byte)'F' });
|
||||
writer.Write(36 + pcmData.Length);
|
||||
writer.Write(new[] { (byte)'W', (byte)'A', (byte)'V', (byte)'E' });
|
||||
writer.Write(new[] { (byte)'f', (byte)'m', (byte)'t', (byte)' ' });
|
||||
writer.Write(16);
|
||||
writer.Write((short)1);
|
||||
writer.Write((short)channels);
|
||||
writer.Write(sampleRate);
|
||||
writer.Write(byteRate);
|
||||
writer.Write((short)blockAlign);
|
||||
writer.Write((short)bitsPerSample);
|
||||
writer.Write(new[] { (byte)'d', (byte)'a', (byte)'t', (byte)'a' });
|
||||
writer.Write(pcmData.Length);
|
||||
writer.Write(pcmData);
|
||||
}
|
||||
|
||||
private void SetErrorLocked(Exception ex)
|
||||
{
|
||||
_lastError = ResolveErrorMessage(ex);
|
||||
_state = AudioRecorderRuntimeState.Error;
|
||||
}
|
||||
|
||||
private static string ResolveErrorMessage(Exception ex)
|
||||
{
|
||||
return ex.Message.Trim().Length > 0
|
||||
? ex.Message.Trim()
|
||||
: ex.GetType().Name;
|
||||
}
|
||||
}
|
||||
121
LanMontainDesktop/Services/IMusicControlService.cs
Normal file
121
LanMontainDesktop/Services/IMusicControlService.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMontainDesktop.Services;
|
||||
|
||||
public enum MusicPlaybackStatus
|
||||
{
|
||||
Unknown = 0,
|
||||
Opened = 1,
|
||||
Changing = 2,
|
||||
Stopped = 3,
|
||||
Playing = 4,
|
||||
Paused = 5
|
||||
}
|
||||
|
||||
public sealed record MusicPlaybackState(
|
||||
bool IsSupported,
|
||||
bool HasSession,
|
||||
string SourceAppId,
|
||||
string SourceAppName,
|
||||
string Title,
|
||||
string Artist,
|
||||
string AlbumTitle,
|
||||
byte[]? ThumbnailBytes,
|
||||
TimeSpan Position,
|
||||
TimeSpan Duration,
|
||||
MusicPlaybackStatus PlaybackStatus,
|
||||
bool CanPlayPause,
|
||||
bool CanSkipPrevious,
|
||||
bool CanSkipNext)
|
||||
{
|
||||
public static MusicPlaybackState Unsupported()
|
||||
{
|
||||
return new MusicPlaybackState(
|
||||
IsSupported: false,
|
||||
HasSession: false,
|
||||
SourceAppId: string.Empty,
|
||||
SourceAppName: string.Empty,
|
||||
Title: string.Empty,
|
||||
Artist: string.Empty,
|
||||
AlbumTitle: string.Empty,
|
||||
ThumbnailBytes: null,
|
||||
Position: TimeSpan.Zero,
|
||||
Duration: TimeSpan.Zero,
|
||||
PlaybackStatus: MusicPlaybackStatus.Unknown,
|
||||
CanPlayPause: false,
|
||||
CanSkipPrevious: false,
|
||||
CanSkipNext: false);
|
||||
}
|
||||
|
||||
public static MusicPlaybackState NoSession(bool isSupported = true)
|
||||
{
|
||||
return new MusicPlaybackState(
|
||||
IsSupported: isSupported,
|
||||
HasSession: false,
|
||||
SourceAppId: string.Empty,
|
||||
SourceAppName: string.Empty,
|
||||
Title: string.Empty,
|
||||
Artist: string.Empty,
|
||||
AlbumTitle: string.Empty,
|
||||
ThumbnailBytes: null,
|
||||
Position: TimeSpan.Zero,
|
||||
Duration: TimeSpan.Zero,
|
||||
PlaybackStatus: MusicPlaybackStatus.Unknown,
|
||||
CanPlayPause: false,
|
||||
CanSkipPrevious: false,
|
||||
CanSkipNext: false);
|
||||
}
|
||||
}
|
||||
|
||||
public interface IMusicControlService
|
||||
{
|
||||
Task<MusicPlaybackState> GetCurrentStateAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> TogglePlayPauseAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> SkipNextAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> SkipPreviousAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> LaunchSourceAppAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public static class MusicControlServiceFactory
|
||||
{
|
||||
public static IMusicControlService CreateDefault()
|
||||
{
|
||||
return OperatingSystem.IsWindows()
|
||||
? new WindowsSmtcMusicControlService()
|
||||
: new NoOpMusicControlService();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class NoOpMusicControlService : IMusicControlService
|
||||
{
|
||||
public Task<MusicPlaybackState> GetCurrentStateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(MusicPlaybackState.Unsupported());
|
||||
}
|
||||
|
||||
public Task<bool> TogglePlayPauseAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public Task<bool> SkipNextAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public Task<bool> SkipPreviousAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public Task<bool> LaunchSourceAppAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
}
|
||||
579
LanMontainDesktop/Services/WindowsSmtcMusicControlService.cs
Normal file
579
LanMontainDesktop/Services/WindowsSmtcMusicControlService.cs
Normal file
@@ -0,0 +1,579 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LanMontainDesktop.Services;
|
||||
|
||||
public sealed class WindowsSmtcMusicControlService : IMusicControlService
|
||||
{
|
||||
private static readonly Type? SessionManagerType = ResolveWinRtType("Windows.Media.Control.GlobalSystemMediaTransportControlsSessionManager");
|
||||
private static readonly Type? AppInfoType = ResolveWinRtType("Windows.ApplicationModel.AppInfo");
|
||||
private static readonly MethodInfo? RequestSessionManagerAsyncMethod =
|
||||
SessionManagerType?.GetMethod("RequestAsync", BindingFlags.Public | BindingFlags.Static);
|
||||
private static readonly MethodInfo? AsTaskGenericMethodDefinition = ResolveAsTaskGenericMethod();
|
||||
private static readonly MethodInfo? AsStreamForReadMethod = ResolveAsStreamForReadMethod();
|
||||
|
||||
private static readonly SemaphoreSlim ManagerLock = new(1, 1);
|
||||
private static object? _sessionManager;
|
||||
|
||||
private readonly ConcurrentDictionary<string, string> _sourceAppNameCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly SemaphoreSlim _stateGate = new(1, 1);
|
||||
|
||||
private string _thumbnailKey = string.Empty;
|
||||
private byte[]? _thumbnailBytesCache;
|
||||
|
||||
public async Task<MusicPlaybackState> GetCurrentStateAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsRuntimeSupported())
|
||||
{
|
||||
return MusicPlaybackState.Unsupported();
|
||||
}
|
||||
|
||||
await _stateGate.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
var session = await GetCurrentSessionAsync(cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
return MusicPlaybackState.NoSession(isSupported: true);
|
||||
}
|
||||
|
||||
var mediaProperties = await TryGetMediaPropertiesAsync(session, cancellationToken);
|
||||
var title = ReadStringProperty(mediaProperties, "Title");
|
||||
var artist = ReadStringProperty(mediaProperties, "Artist");
|
||||
var albumTitle = ReadStringProperty(mediaProperties, "AlbumTitle");
|
||||
|
||||
var playbackInfo = GetPropertyValue(session, "PlaybackInfo") ?? InvokeMethod(session, "GetPlaybackInfo");
|
||||
var controls = GetPropertyValue(playbackInfo, "Controls");
|
||||
|
||||
var playbackStatusRaw = ReadIntProperty(playbackInfo, "PlaybackStatus");
|
||||
var canPlayPause = ReadBoolProperty(controls, "IsPauseEnabled") || ReadBoolProperty(controls, "IsPlayEnabled");
|
||||
var canSkipNext = ReadBoolProperty(controls, "IsNextEnabled");
|
||||
var canSkipPrevious = ReadBoolProperty(controls, "IsPreviousEnabled");
|
||||
|
||||
var sourceAppId = ReadStringProperty(session, "SourceAppUserModelId");
|
||||
var sourceAppName = await ResolveSourceAppDisplayNameAsync(sourceAppId, cancellationToken);
|
||||
|
||||
var timeline = InvokeMethod(session, "GetTimelineProperties");
|
||||
var position = ReadTimeSpanProperty(timeline, "Position");
|
||||
var start = ReadTimeSpanProperty(timeline, "StartTime");
|
||||
var end = ReadTimeSpanProperty(timeline, "EndTime");
|
||||
|
||||
var duration = end - start;
|
||||
if (duration < TimeSpan.Zero)
|
||||
{
|
||||
duration = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
var normalizedPosition = position - start;
|
||||
if (normalizedPosition < TimeSpan.Zero)
|
||||
{
|
||||
normalizedPosition = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
if (duration > TimeSpan.Zero && normalizedPosition > duration)
|
||||
{
|
||||
normalizedPosition = duration;
|
||||
}
|
||||
|
||||
var thumbnailBytes = await ResolveThumbnailBytesAsync(
|
||||
mediaProperties,
|
||||
sourceAppId,
|
||||
title,
|
||||
artist,
|
||||
albumTitle,
|
||||
cancellationToken);
|
||||
|
||||
return new MusicPlaybackState(
|
||||
IsSupported: true,
|
||||
HasSession: true,
|
||||
SourceAppId: sourceAppId,
|
||||
SourceAppName: sourceAppName,
|
||||
Title: title,
|
||||
Artist: artist,
|
||||
AlbumTitle: albumTitle,
|
||||
ThumbnailBytes: thumbnailBytes,
|
||||
Position: normalizedPosition,
|
||||
Duration: duration,
|
||||
PlaybackStatus: MapPlaybackStatus(playbackStatusRaw),
|
||||
CanPlayPause: canPlayPause,
|
||||
CanSkipPrevious: canSkipPrevious,
|
||||
CanSkipNext: canSkipNext);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return MusicPlaybackState.NoSession(isSupported: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_stateGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> TogglePlayPauseAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsRuntimeSupported())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var session = await GetCurrentSessionAsync(cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var playbackInfo = GetPropertyValue(session, "PlaybackInfo") ?? InvokeMethod(session, "GetPlaybackInfo");
|
||||
var controls = GetPropertyValue(playbackInfo, "Controls");
|
||||
var playbackStatusRaw = ReadIntProperty(playbackInfo, "PlaybackStatus");
|
||||
|
||||
object? operation = null;
|
||||
if (playbackStatusRaw == 4 && ReadBoolProperty(controls, "IsPauseEnabled"))
|
||||
{
|
||||
operation = InvokeMethod(session, "TryPauseAsync");
|
||||
}
|
||||
else if (ReadBoolProperty(controls, "IsPlayEnabled"))
|
||||
{
|
||||
operation = InvokeMethod(session, "TryPlayAsync");
|
||||
}
|
||||
else if (ReadBoolProperty(controls, "IsPauseEnabled"))
|
||||
{
|
||||
operation = InvokeMethod(session, "TryPauseAsync");
|
||||
}
|
||||
else
|
||||
{
|
||||
operation = InvokeMethod(session, "TryTogglePlayPauseAsync");
|
||||
}
|
||||
|
||||
return await AwaitBooleanWinRtOperationAsync(operation, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> SkipNextAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsRuntimeSupported())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var session = await GetCurrentSessionAsync(cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var playbackInfo = GetPropertyValue(session, "PlaybackInfo") ?? InvokeMethod(session, "GetPlaybackInfo");
|
||||
var controls = GetPropertyValue(playbackInfo, "Controls");
|
||||
if (!ReadBoolProperty(controls, "IsNextEnabled"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return await AwaitBooleanWinRtOperationAsync(InvokeMethod(session, "TrySkipNextAsync"), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> SkipPreviousAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsRuntimeSupported())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var session = await GetCurrentSessionAsync(cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var playbackInfo = GetPropertyValue(session, "PlaybackInfo") ?? InvokeMethod(session, "GetPlaybackInfo");
|
||||
var controls = GetPropertyValue(playbackInfo, "Controls");
|
||||
if (!ReadBoolProperty(controls, "IsPreviousEnabled"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return await AwaitBooleanWinRtOperationAsync(InvokeMethod(session, "TrySkipPreviousAsync"), cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> LaunchSourceAppAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!IsRuntimeSupported())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var session = await GetCurrentSessionAsync(cancellationToken);
|
||||
if (session is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sourceAppId = ReadStringProperty(session, "SourceAppUserModelId");
|
||||
if (string.IsNullOrWhiteSpace(sourceAppId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryOpenSourceApp(sourceAppId);
|
||||
}
|
||||
|
||||
private async Task<object?> GetCurrentSessionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var manager = await GetSessionManagerAsync(cancellationToken);
|
||||
return manager is null ? null : InvokeMethod(manager, "GetCurrentSession");
|
||||
}
|
||||
|
||||
private static async Task<object?> GetSessionManagerAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_sessionManager is not null)
|
||||
{
|
||||
return _sessionManager;
|
||||
}
|
||||
|
||||
await ManagerLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (_sessionManager is not null)
|
||||
{
|
||||
return _sessionManager;
|
||||
}
|
||||
|
||||
var operation = RequestSessionManagerAsyncMethod?.Invoke(null, null);
|
||||
var manager = await AwaitWinRtOperationAsync(operation, cancellationToken);
|
||||
_sessionManager = manager;
|
||||
return manager;
|
||||
}
|
||||
finally
|
||||
{
|
||||
ManagerLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<object?> TryGetMediaPropertiesAsync(object session, CancellationToken cancellationToken)
|
||||
{
|
||||
var operation = InvokeMethod(session, "TryGetMediaPropertiesAsync");
|
||||
return await AwaitWinRtOperationAsync(operation, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<byte[]?> ResolveThumbnailBytesAsync(
|
||||
object? mediaProperties,
|
||||
string sourceAppId,
|
||||
string title,
|
||||
string artist,
|
||||
string albumTitle,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var key = $"{sourceAppId}|{title}|{artist}|{albumTitle}";
|
||||
if (string.Equals(key, _thumbnailKey, StringComparison.Ordinal) && _thumbnailBytesCache is not null)
|
||||
{
|
||||
return _thumbnailBytesCache;
|
||||
}
|
||||
|
||||
var thumbnailReference = GetPropertyValue(mediaProperties, "Thumbnail");
|
||||
var thumbnailBytes = await TryReadThumbnailBytesAsync(thumbnailReference, cancellationToken);
|
||||
|
||||
_thumbnailKey = key;
|
||||
_thumbnailBytesCache = thumbnailBytes;
|
||||
return thumbnailBytes;
|
||||
}
|
||||
|
||||
private static async Task<byte[]?> TryReadThumbnailBytesAsync(object? thumbnailReference, CancellationToken cancellationToken)
|
||||
{
|
||||
if (thumbnailReference is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
object? randomAccessStream = null;
|
||||
try
|
||||
{
|
||||
var openReadAsyncOperation = InvokeMethod(thumbnailReference, "OpenReadAsync");
|
||||
randomAccessStream = await AwaitWinRtOperationAsync(openReadAsyncOperation, cancellationToken);
|
||||
if (randomAccessStream is null || AsStreamForReadMethod is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var dotnetStream = AsStreamForReadMethod.Invoke(null, [randomAccessStream]) as Stream;
|
||||
if (dotnetStream is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var buffer = new MemoryStream();
|
||||
await dotnetStream.CopyToAsync(buffer, cancellationToken);
|
||||
return buffer.Length > 0 ? buffer.ToArray() : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (randomAccessStream is IDisposable disposable)
|
||||
{
|
||||
disposable.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ResolveSourceAppDisplayNameAsync(string sourceAppId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceAppId))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
if (_sourceAppNameCache.TryGetValue(sourceAppId, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var resolved = sourceAppId;
|
||||
try
|
||||
{
|
||||
if (AppInfoType is not null)
|
||||
{
|
||||
var getFromAumidMethod = AppInfoType.GetMethod(
|
||||
"GetFromAppUserModelId",
|
||||
BindingFlags.Public | BindingFlags.Static,
|
||||
null,
|
||||
[typeof(string)],
|
||||
null);
|
||||
var appInfo = getFromAumidMethod?.Invoke(null, [sourceAppId]);
|
||||
var displayInfo = GetPropertyValue(appInfo, "DisplayInfo");
|
||||
var displayName = ReadStringProperty(displayInfo, "DisplayName");
|
||||
if (!string.IsNullOrWhiteSpace(displayName))
|
||||
{
|
||||
resolved = displayName;
|
||||
}
|
||||
else
|
||||
{
|
||||
resolved = SimplifySourceAppId(sourceAppId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
resolved = SimplifySourceAppId(sourceAppId);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
resolved = SimplifySourceAppId(sourceAppId);
|
||||
}
|
||||
|
||||
_sourceAppNameCache[sourceAppId] = resolved;
|
||||
await Task.CompletedTask;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static string SimplifySourceAppId(string sourceAppId)
|
||||
{
|
||||
var text = sourceAppId.Trim();
|
||||
if (text.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var exclamationIndex = text.IndexOf('!');
|
||||
if (exclamationIndex > 0)
|
||||
{
|
||||
text = text[..exclamationIndex];
|
||||
}
|
||||
|
||||
var packageSplit = text.Split('_');
|
||||
if (packageSplit.Length > 0 && packageSplit[0].Length > 0)
|
||||
{
|
||||
text = packageSplit[0];
|
||||
}
|
||||
|
||||
if (text.EndsWith(".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
text = Path.GetFileNameWithoutExtension(text);
|
||||
}
|
||||
|
||||
if (text.Contains('.'))
|
||||
{
|
||||
var lastSegment = text.Split('.', StringSplitOptions.RemoveEmptyEntries).LastOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(lastSegment))
|
||||
{
|
||||
text = lastSegment;
|
||||
}
|
||||
}
|
||||
|
||||
return text.Replace('_', ' ').Replace('-', ' ').Trim();
|
||||
}
|
||||
|
||||
private static bool TryOpenSourceApp(string sourceAppId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var launchTarget = $"shell:AppsFolder\\{sourceAppId}";
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "explorer.exe",
|
||||
Arguments = launchTarget,
|
||||
UseShellExecute = true
|
||||
});
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> AwaitBooleanWinRtOperationAsync(object? operation, CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await AwaitWinRtOperationAsync(operation, cancellationToken);
|
||||
return result is bool boolValue && boolValue;
|
||||
}
|
||||
|
||||
private static async Task<object?> AwaitWinRtOperationAsync(object? operation, CancellationToken cancellationToken)
|
||||
{
|
||||
if (operation is null || AsTaskGenericMethodDefinition is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resultType = ResolveWinRtOperationResultType(operation.GetType());
|
||||
if (resultType is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var asTaskMethod = AsTaskGenericMethodDefinition.MakeGenericMethod(resultType);
|
||||
var taskObject = asTaskMethod.Invoke(null, [operation]) as Task;
|
||||
if (taskObject is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await taskObject.WaitAsync(cancellationToken);
|
||||
return taskObject.GetType().GetProperty("Result", BindingFlags.Public | BindingFlags.Instance)?.GetValue(taskObject);
|
||||
}
|
||||
|
||||
private static Type? ResolveWinRtOperationResultType(Type operationType)
|
||||
{
|
||||
if (operationType.IsGenericType)
|
||||
{
|
||||
var genericArguments = operationType.GetGenericArguments();
|
||||
if (genericArguments.Length == 1)
|
||||
{
|
||||
return genericArguments[0];
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var iface in operationType.GetInterfaces())
|
||||
{
|
||||
if (!iface.IsGenericType)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var genericTypeDef = iface.GetGenericTypeDefinition();
|
||||
if (string.Equals(genericTypeDef.FullName, "Windows.Foundation.IAsyncOperation`1", StringComparison.Ordinal))
|
||||
{
|
||||
return iface.GetGenericArguments()[0];
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static MethodInfo? ResolveAsTaskGenericMethod()
|
||||
{
|
||||
var type = Type.GetType("System.WindowsRuntimeSystemExtensions, System.Runtime.WindowsRuntime", throwOnError: false);
|
||||
return type?
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.FirstOrDefault(method =>
|
||||
method.Name == "AsTask" &&
|
||||
method.IsGenericMethodDefinition &&
|
||||
method.GetParameters().Length == 1);
|
||||
}
|
||||
|
||||
private static MethodInfo? ResolveAsStreamForReadMethod()
|
||||
{
|
||||
var type = Type.GetType("System.IO.WindowsRuntimeStreamExtensions, System.Runtime.WindowsRuntime", throwOnError: false);
|
||||
return type?
|
||||
.GetMethods(BindingFlags.Public | BindingFlags.Static)
|
||||
.FirstOrDefault(method =>
|
||||
method.Name == "AsStreamForRead" &&
|
||||
method.GetParameters().Length == 1);
|
||||
}
|
||||
|
||||
private static Type? ResolveWinRtType(string typeName)
|
||||
{
|
||||
return Type.GetType($"{typeName}, Windows, ContentType=WindowsRuntime", throwOnError: false);
|
||||
}
|
||||
|
||||
private static bool IsRuntimeSupported()
|
||||
{
|
||||
return OperatingSystem.IsWindows() &&
|
||||
SessionManagerType is not null &&
|
||||
RequestSessionManagerAsyncMethod is not null &&
|
||||
AsTaskGenericMethodDefinition is not null;
|
||||
}
|
||||
|
||||
private static object? InvokeMethod(object? target, string methodName)
|
||||
{
|
||||
return target?.GetType().GetMethod(methodName, BindingFlags.Public | BindingFlags.Instance)?.Invoke(target, null);
|
||||
}
|
||||
|
||||
private static object? GetPropertyValue(object? target, string propertyName)
|
||||
{
|
||||
return target?.GetType().GetProperty(propertyName, BindingFlags.Public | BindingFlags.Instance)?.GetValue(target);
|
||||
}
|
||||
|
||||
private static string ReadStringProperty(object? target, string propertyName)
|
||||
{
|
||||
return GetPropertyValue(target, propertyName)?.ToString()?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static bool ReadBoolProperty(object? target, string propertyName)
|
||||
{
|
||||
var value = GetPropertyValue(target, propertyName);
|
||||
return value is bool boolValue && boolValue;
|
||||
}
|
||||
|
||||
private static int ReadIntProperty(object? target, string propertyName)
|
||||
{
|
||||
var value = GetPropertyValue(target, propertyName);
|
||||
if (value is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Convert.ToInt32(value);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static TimeSpan ReadTimeSpanProperty(object? target, string propertyName)
|
||||
{
|
||||
var value = GetPropertyValue(target, propertyName);
|
||||
return value is TimeSpan timeSpan ? timeSpan : TimeSpan.Zero;
|
||||
}
|
||||
|
||||
private static MusicPlaybackStatus MapPlaybackStatus(int rawStatus)
|
||||
{
|
||||
return rawStatus switch
|
||||
{
|
||||
1 => MusicPlaybackStatus.Opened,
|
||||
2 => MusicPlaybackStatus.Changing,
|
||||
3 => MusicPlaybackStatus.Stopped,
|
||||
4 => MusicPlaybackStatus.Playing,
|
||||
5 => MusicPlaybackStatus.Paused,
|
||||
_ => MusicPlaybackStatus.Unknown
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user