diff --git a/src/SystemMedia/Core/MediaTime.cs b/src/SystemMedia/Core/MediaTime.cs new file mode 100644 index 0000000..ee54f2c --- /dev/null +++ b/src/SystemMedia/Core/MediaTime.cs @@ -0,0 +1,12 @@ +namespace SystemMedia.Core; + +// Players report lengths they do not know as huge numbers (Chromium sends Int64.MaxValue), beyond what TimeSpan holds. +internal static class MediaTime +{ + private const double MaxMicroseconds = long.MaxValue / TimeSpan.TicksPerMicrosecond; + + internal static TimeSpan? FromMicroseconds(double micros) => + micros is >= 0 and < MaxMicroseconds ? TimeSpan.FromMicroseconds(micros) : null; + + internal static TimeSpan? FromSeconds(double seconds) => FromMicroseconds(seconds * 1_000_000); +} diff --git a/src/SystemMedia/Platform/Linux/MprisPlayer.cs b/src/SystemMedia/Platform/Linux/MprisPlayer.cs index d9ae746..2a3a592 100644 --- a/src/SystemMedia/Platform/Linux/MprisPlayer.cs +++ b/src/SystemMedia/Platform/Linux/MprisPlayer.cs @@ -75,13 +75,16 @@ internal MediaSnapshot ToSnapshot(DateTimeOffset readAt) _ => null, }, StartTime = TimeSpan.Zero, - EndTime = length is > 0 ? TimeSpan.FromMicroseconds(length.Value) : TimeSpan.Zero, - Position = PositionMicros is > 0 ? TimeSpan.FromMicroseconds(PositionMicros.Value) : TimeSpan.Zero, + EndTime = Duration(length), + Position = Duration(PositionMicros), // Position is fetched on every read, so it is current as of now. LastUpdatedTime = readAt, }; } + private static TimeSpan Duration(double? micros) => + micros is { } value ? MediaTime.FromMicroseconds(value) ?? TimeSpan.Zero : TimeSpan.Zero; + private IReadOnlyDictionary Metadata => Properties.GetValueOrDefault("Metadata") as IReadOnlyDictionary ?? _empty; diff --git a/src/SystemMedia/Platform/MacOS/NowPlayingState.cs b/src/SystemMedia/Platform/MacOS/NowPlayingState.cs index 0403555..e36cbe0 100644 --- a/src/SystemMedia/Platform/MacOS/NowPlayingState.cs +++ b/src/SystemMedia/Platform/MacOS/NowPlayingState.cs @@ -53,8 +53,8 @@ internal sealed record NowPlayingInfo _ => null, }, StartTime = TimeSpan.Zero, - EndTime = DurationMicros is { } duration ? TimeSpan.FromMicroseconds(duration) : TimeSpan.Zero, - Position = ElapsedMicros is { } elapsed ? TimeSpan.FromMicroseconds(elapsed) : TimeSpan.Zero, + EndTime = DurationMicros is { } duration ? MediaTime.FromMicroseconds(duration) ?? TimeSpan.Zero : TimeSpan.Zero, + Position = ElapsedMicros is { } elapsed ? MediaTime.FromMicroseconds(elapsed) ?? TimeSpan.Zero : TimeSpan.Zero, LastUpdatedTime = TimestampEpochMicros is { } stamp ? DateTimeOffset.FromUnixTimeMilliseconds(stamp / 1000) : DateTimeOffset.MinValue, diff --git a/src/SystemMedia/Players/MpvPlayer.cs b/src/SystemMedia/Players/MpvPlayer.cs index 1d9d825..72e860b 100644 --- a/src/SystemMedia/Players/MpvPlayer.cs +++ b/src/SystemMedia/Players/MpvPlayer.cs @@ -233,9 +233,7 @@ private static bool HasThumbnail(JsonElement? video, TimeSpan? position, TimeSpa } private static TimeSpan? Seconds(JsonElement? value) => - value is { ValueKind: JsonValueKind.Number } number && number.GetDouble() is var seconds && seconds >= 0 && double.IsFinite(seconds) - ? TimeSpan.FromSeconds(seconds) - : null; + value is { ValueKind: JsonValueKind.Number } number ? MediaTime.FromSeconds(number.GetDouble()) : null; private static void TryDelete(string file) { diff --git a/tests/SystemMedia.Tests/Linux/MprisPlayerTests.cs b/tests/SystemMedia.Tests/Linux/MprisPlayerTests.cs index 02387b7..821177e 100644 --- a/tests/SystemMedia.Tests/Linux/MprisPlayerTests.cs +++ b/tests/SystemMedia.Tests/Linux/MprisPlayerTests.cs @@ -100,6 +100,19 @@ public void A_length_sent_as_a_double_still_counts() Assert.That(snapshot.EndTime, Is.EqualTo(TimeSpan.FromMinutes(1))); } + [Test] + public void A_length_too_long_for_a_timespan_counts_as_none() + { + var snapshot = Player(new() + { + ["Metadata"] = new Dictionary { ["mpris:length"] = long.MaxValue }, + ["Position"] = long.MaxValue, + }).ToSnapshot(_readAt); + + Assert.That(snapshot.HasDuration, Is.False); + Assert.That(snapshot.Position, Is.EqualTo(TimeSpan.Zero)); + } + [Test] public void The_no_track_id_is_not_a_track_to_seek_in() { diff --git a/tests/SystemMedia.Tests/MediaTimeTests.cs b/tests/SystemMedia.Tests/MediaTimeTests.cs new file mode 100644 index 0000000..d465ea9 --- /dev/null +++ b/tests/SystemMedia.Tests/MediaTimeTests.cs @@ -0,0 +1,27 @@ +using NUnit.Framework; +using SystemMedia.Core; + +namespace SystemMedia.Tests; + +[TestFixture] +public sealed class MediaTimeTests +{ + [Test] + public void A_reported_time_converts() + { + Assert.That(MediaTime.FromMicroseconds(60_000_000), Is.EqualTo(TimeSpan.FromMinutes(1))); + Assert.That(MediaTime.FromSeconds(90.5), Is.EqualTo(TimeSpan.FromSeconds(90.5))); + Assert.That(MediaTime.FromSeconds(0), Is.EqualTo(TimeSpan.Zero)); + } + + [TestCase(long.MaxValue)] + [TestCase(1e300)] + [TestCase(-1)] + [TestCase(double.NaN)] + [TestCase(double.PositiveInfinity)] + public void A_time_beyond_what_a_timespan_holds_is_unknown(double value) + { + Assert.That(MediaTime.FromMicroseconds(value), Is.Null); + Assert.That(MediaTime.FromSeconds(value), Is.Null); + } +}