diff --git a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs index ff7a18e37..d0cdae501 100644 --- a/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs +++ b/ErsatzTV.Application/Playouts/Commands/SyncNextPlayoutHandler.cs @@ -395,8 +395,9 @@ public partial class SyncNextPlayoutHandler( playoutItem.StartOffset, shouldLogMessages: false); - // single, permanent watermarks are supported - if (watermarks.Count == 1 && watermarks.All(wm => wm.Watermark.Mode is ChannelWatermarkMode.Permanent)) + // single, permanent or intermittent watermarks are supported + if (watermarks.Count == 1 && watermarks.All(wm => + wm.Watermark.Mode is ChannelWatermarkMode.Permanent or ChannelWatermarkMode.Intermittent)) { foreach (WatermarkOptions watermarkOptions in watermarks) { @@ -445,6 +446,17 @@ public partial class SyncNextPlayoutHandler( Path = watermarkOptions.ImagePath, }; } + + if (watermarkOptions.Watermark.Mode is ChannelWatermarkMode.Intermittent) + { + nextPlayoutItem.Watermark.Timing = new Core.Next.Timing + { + TimingType = Core.Next.TimingType.Periodic, + Clock = Core.Next.PeriodicClock.Wall, + FrequencyMs = watermarkOptions.Watermark.FrequencyMinutes * 60 * 1000, + HoldMs = watermarkOptions.Watermark.DurationSeconds * 1000, + }; + } } } } diff --git a/ErsatzTV.Core/Next/Playout.cs b/ErsatzTV.Core/Next/Playout.cs index 85067e233..5abcb6821 100644 --- a/ErsatzTV.Core/Next/Playout.cs +++ b/ErsatzTV.Core/Next/Playout.cs @@ -257,6 +257,12 @@ namespace ErsatzTV.Core.Next [JsonProperty("stream_index")] public long? StreamIndex { get; set; } + /// + /// Visibility schedule for the watermark. Omit for an always-on watermark. + /// + [JsonProperty("timing")] + public Timing Timing { get; set; } + /// /// Vertical offset from the anchor `location`, as a percent of primary content height /// (0–100). Omit for 0. @@ -352,6 +358,61 @@ namespace ErsatzTV.Core.Next public string UserAgent { get; set; } } + /// + /// Controls when the watermark is shown. Exactly one variant, distinguished by + /// `timing_type`. + /// + /// Cyclical visibility: the watermark fades in, holds, and fades out once every + /// `frequency_ms`. Cycle length is start-to-start, so `2 * fade_ms + hold_ms` must be ≤ + /// `frequency_ms`, and `fade_ms` must be ≤ `hold_ms`. + /// + public partial class Timing + { + /// + /// Reference clock used to position cycles. + /// + [JsonProperty("clock")] + public PeriodicClock Clock { get; set; } + + /// + /// Cap on the time, measured from the start of the playout item in milliseconds, during + /// which new appearances are allowed to begin. An appearance already in progress at the cap + /// is allowed to fade out cleanly. Omit for no cap. + /// + [JsonProperty("disable_after_ms")] + public long? DisableAfterMs { get; set; } + + /// + /// Fade-in and fade-out duration in milliseconds (symmetric). Must be ≤ `hold_ms`. Omit for + /// 1000; set to 0 for hard cuts. + /// + [JsonProperty("fade_ms")] + public long? FadeMs { get; set; } + + /// + /// Period of the cycle, start-to-start, in milliseconds. + /// + [JsonProperty("frequency_ms")] + public long FrequencyMs { get; set; } + + /// + /// Time held fully visible between fade-in and fade-out, in milliseconds. + /// + [JsonProperty("hold_ms")] + public long HoldMs { get; set; } + + /// + /// Shift the cycle by this many milliseconds. With `clock: wall` and `frequency_ms: 300000`, + /// an offset of 0 puts an appearance start at every wall-clock 5-minute boundary; 120000 + /// shifts to :02, :07, :12, etc. Omit for 0. + /// + [JsonProperty("phase_offset_ms")] + public long? PhaseOffsetMs { get; set; } + + [JsonProperty("timing_type")] + public TimingType TimingType { get; set; } + } + public enum SourceType { Http, Lavfi, Local }; /// @@ -362,6 +423,17 @@ namespace ErsatzTV.Core.Next /// public enum WatermarkLocation { BottomCenter, BottomLeft, BottomRight, Center, CenterLeft, CenterRight, TopCenter, TopLeft, TopRight }; + /// + /// Reference clock used to position cycles. + /// + /// Reference clock for periodic timing. `wall` aligns cycles to wall-clock time (so a viewer + /// tuning in at any moment sees the same phase). `content` measures from the start of the + /// containing playout item. + /// + public enum PeriodicClock { Content, Wall }; + + public enum TimingType { Periodic }; + public partial class Playout { public static Playout FromJson(string json) => JsonConvert.DeserializeObject(json, ErsatzTV.Core.Next.Converter.Settings); @@ -383,6 +455,8 @@ namespace ErsatzTV.Core.Next { SourceTypeConverter.Singleton, WatermarkLocationConverter.Singleton, + PeriodicClockConverter.Singleton, + TimingTypeConverter.Singleton, new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal } }, }; @@ -543,4 +617,79 @@ namespace ErsatzTV.Core.Next public static readonly WatermarkLocationConverter Singleton = new WatermarkLocationConverter(); } + + internal class PeriodicClockConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(PeriodicClock) || t == typeof(PeriodicClock?); + + public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) return null; + var value = serializer.Deserialize(reader); + switch (value) + { + case "content": + return PeriodicClock.Content; + case "wall": + return PeriodicClock.Wall; + } + throw new Exception("Cannot unmarshal type PeriodicClock"); + } + + public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) + { + if (untypedValue == null) + { + serializer.Serialize(writer, null); + return; + } + var value = (PeriodicClock)untypedValue; + switch (value) + { + case PeriodicClock.Content: + serializer.Serialize(writer, "content"); + return; + case PeriodicClock.Wall: + serializer.Serialize(writer, "wall"); + return; + } + throw new Exception("Cannot marshal type PeriodicClock"); + } + + public static readonly PeriodicClockConverter Singleton = new PeriodicClockConverter(); + } + + internal class TimingTypeConverter : JsonConverter + { + public override bool CanConvert(Type t) => t == typeof(TimingType) || t == typeof(TimingType?); + + public override object ReadJson(JsonReader reader, Type t, object existingValue, JsonSerializer serializer) + { + if (reader.TokenType == JsonToken.Null) return null; + var value = serializer.Deserialize(reader); + if (value == "periodic") + { + return TimingType.Periodic; + } + throw new Exception("Cannot unmarshal type TimingType"); + } + + public override void WriteJson(JsonWriter writer, object untypedValue, JsonSerializer serializer) + { + if (untypedValue == null) + { + serializer.Serialize(writer, null); + return; + } + var value = (TimingType)untypedValue; + if (value == TimingType.Periodic) + { + serializer.Serialize(writer, "periodic"); + return; + } + throw new Exception("Cannot marshal type TimingType"); + } + + public static readonly TimingTypeConverter Singleton = new TimingTypeConverter(); + } }