diff --git a/WaveformTimeline.Avalonia/Controls/BaseControl.cs b/WaveformTimeline.Avalonia/Controls/BaseControl.cs new file mode 100644 index 0000000..9526441 --- /dev/null +++ b/WaveformTimeline.Avalonia/Controls/BaseControl.cs @@ -0,0 +1,64 @@ +#nullable enable +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using WaveformTimeline.Commons; +using WaveformTimeline.Contracts; +using WaveformTimeline.Primitives; + +namespace WaveformTimeline.Controls +{ + public abstract class BaseControl : TemplatedControl + { + protected Canvas? MainCanvas + { + get => _mainCanvas; + set { if (value != null) _mainCanvas = value; } + } + + protected TuneDuration CoverageArea; + protected WaveformDimensions WaveformDimensions; + private Canvas _mainCanvas = new(); + + public static readonly StyledProperty TuneProperty = + AvaloniaProperty.Register(nameof(Tune), defaultValue: new NoTune()); + + protected abstract void OnTuneChanged(); + + public ITune Tune + { + get => GetValue(TuneProperty) ?? new NoTune(); + set + { + if (value == Tune) return; + if (Tune is IDisposable disposable) disposable.Dispose(); + SetValue(TuneProperty, value ?? new NoTune()); + } + } + + public static readonly StyledProperty ZoomProperty = + AvaloniaProperty.Register(nameof(Zoom), defaultValue: 1.0, + coerce: (_, v) => Math.Max(1.0, new FiniteDouble(v, 1.0).Value())); + + public double Zoom + { + get => GetValue(ZoomProperty); + set => SetValue(ZoomProperty, value); + } + + static BaseControl() + { + TuneProperty.Changed.AddClassHandler((x, _) => x.OnTuneChanged()); + ZoomProperty.Changed.AddClassHandler((x, _) => x.Render()); + } + + protected virtual void MeasureArea() + { + CoverageArea = new TuneDuration(Tune, Zoom); + WaveformDimensions = new WaveformDimensions(CoverageArea, MainCanvas?.Bounds.Width ?? 0d); + } + + protected abstract void Render(); + } +} diff --git a/WaveformTimeline.Avalonia/Controls/Curtains.cs b/WaveformTimeline.Avalonia/Controls/Curtains.cs new file mode 100644 index 0000000..48c74c1 --- /dev/null +++ b/WaveformTimeline.Avalonia/Controls/Curtains.cs @@ -0,0 +1,369 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive; +using System.Reactive.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Shapes; +using Avalonia.Input; +using Avalonia.Media; +using WaveformTimeline.Primitives; + +namespace WaveformTimeline.Controls +{ + public sealed class Curtains : BaseControl + { + private Canvas? _cueMarksCanvas; + private Canvas? _leftSideCurtain; + private Canvas? _rightSideCurtain; + private readonly List _cuePoints = new(); + private readonly List _cuePointMarks = new(); + private readonly List _cuePointLines = new(); + private readonly IBrush _transparentBrush = new SolidColorBrush { Color = Color.FromArgb(0, 0, 0, 0), Opacity = 0 }; + private readonly Dictionary _cueMap = new(); + private ZeroToOne _selectedCuePoint; + private Polygon? _selectedCuePointMark; + private Line? _selectedCuePointLine; + private double _lastKnownGoodX; + private Canvas? _animatedCurtain; + private bool _isMouseDown; + private IDisposable? _watchesCues; + + public static readonly StyledProperty CueMarkBrushProperty = + AvaloniaProperty.Register(nameof(CueMarkBrush), + defaultValue: new SolidColorBrush(Color.FromArgb(0xCD, 0xBA, 0x00, 0xFF))); + + public IBrush CueMarkBrush + { + get => GetValue(CueMarkBrushProperty); + set => SetValue(CueMarkBrushProperty, value); + } + + public static readonly StyledProperty CueBarBackgroundBrushProperty = + AvaloniaProperty.Register(nameof(CueBarBackgroundBrush), + defaultValue: new SolidColorBrush(Color.FromArgb(0xCD, 0xBA, 0x00, 0xFF))); + + public IBrush CueBarBackgroundBrush + { + get => GetValue(CueBarBackgroundBrushProperty); + set => SetValue(CueBarBackgroundBrushProperty, value); + } + + public static readonly StyledProperty CueMarkAccentBrushProperty = + AvaloniaProperty.Register(nameof(CueMarkAccentBrush), + defaultValue: new SolidColorBrush(Color.FromRgb(255, 0, 0))); + + public IBrush CueMarkAccentBrush + { + get => GetValue(CueMarkAccentBrushProperty); + set => SetValue(CueMarkAccentBrushProperty, value); + } + + public static readonly StyledProperty ShowCueMarksProperty = + AvaloniaProperty.Register(nameof(ShowCueMarks), defaultValue: true); + + public bool ShowCueMarks + { + get => GetValue(ShowCueMarksProperty); + set => SetValue(ShowCueMarksProperty, value); + } + + public static readonly StyledProperty ShowCueMarkToolTipProperty = + AvaloniaProperty.Register(nameof(ShowCueMarkToolTip), defaultValue: false); + + public bool ShowCueMarkToolTip + { + get => GetValue(ShowCueMarkToolTipProperty); + set => SetValue(ShowCueMarkToolTipProperty, value); + } + + public static readonly StyledProperty EnableCueMarksRepositioningProperty = + AvaloniaProperty.Register(nameof(EnableCueMarksRepositioning), defaultValue: true); + + public bool EnableCueMarksRepositioning + { + get => GetValue(EnableCueMarksRepositioningProperty); + set => SetValue(EnableCueMarksRepositioningProperty, value); + } + + static Curtains() + { + CueMarkBrushProperty.Changed.AddClassHandler((c, _) => c.Render()); + ShowCueMarksProperty.Changed.AddClassHandler((c, _) => c.Render()); + EnableCueMarksRepositioningProperty.Changed.AddClassHandler((c, _) => c.Render()); + ShowCueMarkToolTipProperty.Changed.AddClassHandler((c, _) => c.Render()); + CueBarBackgroundBrushProperty.Changed.AddClassHandler((c, e) => + { + if (c._cueMarksCanvas != null) + c._cueMarksCanvas.Background = (IBrush?)e.NewValue; + }); + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + MainCanvas = e.NameScope.Find("PART_Curtains"); + _cueMarksCanvas = e.NameScope.Find("PART_CueMarks"); + _leftSideCurtain = e.NameScope.Find("PART_LeftCurtain"); + _rightSideCurtain = e.NameScope.Find("PART_RightCurtain"); + if (_leftSideCurtain != null) _leftSideCurtain.Width = 0; + if (_rightSideCurtain != null) _rightSideCurtain.Width = 0; + + var boundsObs = MainCanvas?.GetObservable(BoundsProperty); + boundsObs?.Subscribe(_ => Render()); + } + + protected override void OnTuneChanged() + { + _watchesCues?.Dispose(); + _watchesCues = Observable.FromEventPattern( + ev => Tune.CuesChanged += ev, + ev => Tune.CuesChanged -= ev) + .Subscribe(TuneOnCuesChanged); + TuneOnCuesChanged(); + } + + private void TuneOnCuesChanged(EventPattern? obj = null) + { + var newCues = Tune.Cues().Select(d => new ZeroToOne(new FiniteDouble(d))).ToList(); + if (newCues.Count == 0 || + (_cuePoints.Count > 0 && + newCues.Intersect(_cuePoints).Count() == _cuePoints.Count)) + return; + _cuePoints.Clear(); + _cuePoints.AddRange(newCues); + Render(); + } + + protected override void OnPointerPressed(PointerPressedEventArgs e) + { + base.OnPointerPressed(e); + if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return; + if (!EnableCueMarksRepositioning) + { + _isMouseDown = true; + return; + } + CurtainMoving(); + _isMouseDown = true; + } + + protected override void OnPointerMoved(PointerEventArgs e) + { + base.OnPointerMoved(e); + if (!_isMouseDown || _cueMarksCanvas == null || MainCanvas == null) return; + + var currentPoint = e.GetPosition(MainCanvas); + if (currentPoint.X < WaveformDimensions.LeftMargin()) + currentPoint = currentPoint.WithX(WaveformDimensions.LeftMargin()); + if (currentPoint.X > MainCanvas.Bounds.Width - WaveformDimensions.RightMargin()) + currentPoint = currentPoint.WithX(MainCanvas.Bounds.Width - WaveformDimensions.RightMargin()); + + var leftCorner = currentPoint.X - (_cueMarksCanvas.Bounds.Height / 2.5d); + var rightCorner = currentPoint.X + (_cueMarksCanvas.Bounds.Height / 2.5d); + if (EnableCueMarksRepositioning && leftCorner >= 0 && rightCorner <= MainCanvas.Bounds.Width) + MoveCuePoint(currentPoint, leftCorner, rightCorner); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + FinishCurtainMovement(e.GetPosition(MainCanvas!).X); + } + + private void FinishCurtainMovement(double xPosition) + { + if (!_isMouseDown || !EnableCueMarksRepositioning) return; + _isMouseDown = false; + MeasureArea(); + CurtainMoved(WaveformDimensions.PercentOfCompleteWaveform(xPosition)); + } + + protected override void OnPointerExited(PointerEventArgs e) + { + base.OnPointerExited(e); + if (MainCanvas != null) + FinishCurtainMovement(e.GetPosition(MainCanvas).X); + } + + protected override void Render() + { + Clear(); + MeasureArea(); + if (!ShowCueMarks || _cuePoints.Count == 0 || _leftSideCurtain == null || _rightSideCurtain == null || + MainCanvas == null || _cueMarksCanvas == null || WaveformDimensions.AreEmpty()) + return; + + double minCuePoint = Math.Max(_cuePoints.Min(), 0); + if (CoverageArea.Includes(minCuePoint)) + { + _leftSideCurtain.Margin = new Thickness(WaveformDimensions.LeftMargin(), 0, 0, 0); + _leftSideCurtain.Width = Math.Max(0, WaveformDimensions.PositionOnCompleteWaveform(minCuePoint)); + } + + double maxCuePoint = _cuePoints.Count == 1 ? 1 : Math.Min(_cuePoints.Max(), 1); + if (CoverageArea.Includes(maxCuePoint)) + { + double rightSideCurtainLeftX = WaveformDimensions.LeftMargin() + WaveformDimensions.AbsoluteLocationToRendered(WaveformDimensions.PositionOnCompleteWaveform(maxCuePoint)); + double rightSideCurtainRightX = WaveformDimensions.LeftMargin() + WaveformDimensions.Width(); + _rightSideCurtain.Width = Math.Max(0, rightSideCurtainRightX - rightSideCurtainLeftX); + _rightSideCurtain.Margin = new Thickness(0, 0, WaveformDimensions.RightMargin(), 0); + } + + var curtains = _cuePoints + .Select(cue => ( + Cue: cue, + Location: new FiniteDouble(WaveformDimensions.LeftMargin() + WaveformDimensions.PositionOnCompleteWaveform(cue)))) + .Select(t => ( + t.Cue, + Location: new FiniteDouble(WaveformDimensions.AbsoluteLocationToRendered(t.Location)))) + .Select(t => ( + Line: CueLine(t.Cue, t.Location), + Polygon: CueHandle(t.Cue, t.Location, _cueMarksCanvas.Bounds.Height / 2))); + foreach (var curtain in curtains) + AddCurtain(curtain); + } + + private void AddCurtain((Line Line, Polygon Polygon) t) + { + if (_cueMarksCanvas == null || MainCanvas == null) return; + _cuePointLines.Add(t.Line); + MainCanvas.Children.Add(t.Line); + _cuePointMarks.Add(t.Polygon); + _cueMarksCanvas.Children.Add(t.Polygon); + } + + private Polygon CueHandle(double cp, double xLocation, double centerOffset) + { + var cue = new Polygon + { + Points = new List + { + new(xLocation, 0), + new(xLocation - centerOffset, _cueMarksCanvas!.Bounds.Height / 2), + new(xLocation - centerOffset, _cueMarksCanvas.Bounds.Height), + new(xLocation + centerOffset, _cueMarksCanvas.Bounds.Height), + new(xLocation + centerOffset, _cueMarksCanvas.Bounds.Height / 2) + } + }; + + if (ShowCueMarkToolTip) + ToolTip.SetTip(cue, TimeSpan.FromTicks((long)(Tune.TotalTime().Ticks * cp)).ToString(@"m\:ss")); + + cue.Fill = CoverageArea.Includes(cp) ? Brushes.White : Brushes.Transparent; + return cue; + } + + private Line CueLine(double cp, double xLocation) => new() + { + Stroke = CoverageArea.Includes(cp) ? CueMarkBrush : _transparentBrush, + StrokeThickness = 1.0d, + StartPoint = new Point(xLocation, 0), + EndPoint = new Point(xLocation, MainCanvas?.Bounds.Height ?? 0) + }; + + public void Clear() + { + foreach (var mark in _cuePointMarks) + _cueMarksCanvas?.Children.Remove(mark); + _cuePointMarks.Clear(); + foreach (var line in _cuePointLines) + MainCanvas?.Children.Remove(line); + _cuePointLines.Clear(); + if (_leftSideCurtain != null) _leftSideCurtain.Width = 0; + if (_rightSideCurtain != null) _rightSideCurtain.Width = 0; + } + + private void CurtainMoving() + { + var cueMarkSelected = _cuePointMarks.FirstOrDefault(cue => cue.IsPointerOver); + if (cueMarkSelected == null) + { + _selectedCuePointMark = null; + _selectedCuePointLine = null; + _animatedCurtain = null; + _selectedCuePoint = 0.0d; + _lastKnownGoodX = 0.0d; + _cueMap.Clear(); + return; + } + + _selectedCuePointMark = cueMarkSelected; + _selectedCuePointMark.Fill = CueMarkAccentBrush; + _selectedCuePointLine = _cuePointLines[_cuePointMarks.IndexOf(_selectedCuePointMark)]; + + _cueMap.Clear(); + for (int i = 0; i < _cuePoints.Count && i < _cuePointMarks.Count; i++) + { + if (_cueMap.ContainsKey(_cuePoints[i])) continue; + _cueMap.Add(_cuePoints[i], _cuePointMarks[i]); + if (ReferenceEquals(_cuePointMarks[i], cueMarkSelected)) + _selectedCuePoint = _cuePoints[i]; + } + + _animatedCurtain = _cuePoints.Count == 1 + ? _leftSideCurtain + : (_selectedCuePoint < _cuePoints.Max() ? _leftSideCurtain : _rightSideCurtain); + } + + private void MoveCuePoint(Point currentPoint, double leftCorner, double rightCorner) + { + if (MainCanvas == null || _cueMarksCanvas == null || _selectedCuePointMark == null || + _leftSideCurtain == null || _rightSideCurtain == null || _selectedCuePointLine == null) + return; + + _lastKnownGoodX = currentPoint.X; + _selectedCuePointMark.Points = new List + { + new(currentPoint.X, 0), + new(leftCorner, _cueMarksCanvas.Bounds.Height / 2), + new(leftCorner, _cueMarksCanvas.Bounds.Height), + new(rightCorner, _cueMarksCanvas.Bounds.Height), + new(rightCorner, _cueMarksCanvas.Bounds.Height / 2) + }; + + _selectedCuePointLine.StartPoint = new Point(_lastKnownGoodX, 0); + _selectedCuePointLine.EndPoint = new Point(_lastKnownGoodX, MainCanvas.Bounds.Height); + if (ReferenceEquals(_animatedCurtain, _leftSideCurtain)) + { + _leftSideCurtain.Margin = new Thickness(WaveformDimensions.LeftMargin(), 0, 0, 0); + _leftSideCurtain.Width = Math.Max(0, _lastKnownGoodX - WaveformDimensions.LeftMargin()); + } + else + { + _rightSideCurtain.Width = Math.Max(0, + MainCanvas.Bounds.Width - _lastKnownGoodX - WaveformDimensions.RightMargin()); + } + } + + private void CurtainMoved(ZeroToOne newCue) + { + if (_selectedCuePointMark == null || !(_lastKnownGoodX > 0.0d)) return; + _cuePoints.Remove(_selectedCuePoint); + AddCuePoint(newCue); + Render(); + _animatedCurtain = null; + _selectedCuePointMark = null; + _selectedCuePointLine = null; + _selectedCuePoint = 0d; + _lastKnownGoodX = 0.0d; + } + + private void AddCuePoint(ZeroToOne pos) + { + if (!_cuePoints.Contains(pos)) _cuePoints.Add(pos); + SyncTrackStartEndTimes(_cuePoints.ToArray()); + } + + private void SyncTrackStartEndTimes(ZeroToOne[] inputs) + { + var values = inputs.OrderBy(x => x).ToArray(); + if (values.Length != 2 || values[1] < values[0]) return; + Tune.TrimStart(TimeSpan.FromTicks((long)(Math.Min(Math.Max(0d, values[0]), values[1]) * Tune.Duration().Ticks))); + Tune.TrimEnd(TimeSpan.FromTicks((long)(Math.Min(Math.Max(values[0], values[1]), values[1]) * Tune.Duration().Ticks))); + } + } +} diff --git a/WaveformTimeline.Avalonia/Controls/ProgressAnimator.cs b/WaveformTimeline.Avalonia/Controls/ProgressAnimator.cs new file mode 100644 index 0000000..549eeee --- /dev/null +++ b/WaveformTimeline.Avalonia/Controls/ProgressAnimator.cs @@ -0,0 +1,191 @@ +#nullable enable +using System; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using System.Threading; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Shapes; +using Avalonia.Input; +using Avalonia.Media; +using Avalonia.Threading; +using WaveformTimeline.Commons; +using WaveformTimeline.Primitives; + +namespace WaveformTimeline.Controls +{ + public sealed class ProgressAnimator : BaseControl + { + private readonly Rectangle _progressRect = new(); + private readonly Rectangle _captureMouse = new(); + private readonly IBrush _transparentBrush = new SolidColorBrush { Color = Color.FromArgb(0, 0, 0, 0), Opacity = 0 }; + private IDisposable? _playbackOnOffNotifier; + private IDisposable? _playbackTempoNotifier; + private DispatcherTimer? _progressTimer; + private IDisposable? _boundsDisposable; + + public static readonly StyledProperty ProgressBarBrushProperty = + AvaloniaProperty.Register(nameof(ProgressBarBrush), + defaultValue: new SolidColorBrush(Color.FromArgb(0xCD, 0xBA, 0x00, 0xFF))); + + public IBrush ProgressBarBrush + { + get => GetValue(ProgressBarBrushProperty); + set => SetValue(ProgressBarBrushProperty, value); + } + + public static readonly StyledProperty ProgressBarThicknessProperty = + AvaloniaProperty.Register(nameof(ProgressBarThickness), defaultValue: 2.0, + coerce: (_, v) => Math.Max(v, 0.0d)); + + public double ProgressBarThickness + { + get => GetValue(ProgressBarThicknessProperty); + set => SetValue(ProgressBarThicknessProperty, value); + } + + public static readonly StyledProperty AllowRepositioningProperty = + AvaloniaProperty.Register(nameof(AllowRepositioning), defaultValue: true); + + public bool AllowRepositioning + { + get => GetValue(AllowRepositioningProperty); + set => SetValue(AllowRepositioningProperty, value); + } + + static ProgressAnimator() + { + ProgressBarBrushProperty.Changed.AddClassHandler((p, _) => p.Render()); + ProgressBarThicknessProperty.Changed.AddClassHandler((p, e) => + p._progressRect.StrokeThickness = (double)(e.NewValue ?? 2.0)); + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + MainCanvas = e.NameScope.Find("PART_ProgressLine"); + if (MainCanvas == null) return; + _captureMouse.Fill = _transparentBrush; + MainCanvas.Children.Add(_captureMouse); + _boundsDisposable?.Dispose(); + _boundsDisposable = MainCanvas.GetObservable(BoundsProperty).Subscribe(_ => + { + if (MainCanvas == null) return; + _captureMouse.Width = MainCanvas.Bounds.Width; + _captureMouse.Height = MainCanvas.Bounds.Height; + Render(); + }); + Render(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _boundsDisposable?.Dispose(); + Clear(); + } + + protected override void OnPointerReleased(PointerReleasedEventArgs e) + { + base.OnPointerReleased(e); + if (MainCanvas == null || !AllowRepositioning || !MainCanvas.IsPointerOver || Tune is NoTune) + return; + var currentPoint = e.GetPosition(MainCanvas); + var percProgress = WaveformDimensions.PercentOfRenderedWaveform(currentPoint.X); + double positionInChannelInSeconds = CoverageArea.ActualPosition(percProgress); + Tune.Seek(TimeSpan.FromTicks( + Math.Min(Tune.TotalTime().Ticks, + Math.Max(0, TimeSpan.FromSeconds(positionInChannelInSeconds).Ticks)))); + Render(); + } + + protected override void OnTuneChanged() => Render(); + + protected override void Render() + { + Clear(); + MeasureArea(); + if (MainCanvas == null || + Tune.TotalTime().TotalSeconds <= 0 || + WaveformDimensions.AreEmpty()) + return; + + var uiContext = SynchronizationContext.Current; + if (uiContext == null) return; + + _playbackOnOffNotifier = Observable.Create(o => + { + EventHandler h = (_, e) => o.OnNext(e); + Tune.Transitioned += h; + return Disposable.Create(() => Tune.Transitioned -= h); + }) + .ObserveOn(uiContext) + .Subscribe(ControlProgressAnimation); + _playbackTempoNotifier = Observable.Create(o => + { + EventHandler h = (_, e) => o.OnNext(e); + Tune.TempoShifted += h; + return Disposable.Create(() => Tune.TempoShifted -= h); + }) + .ObserveOn(uiContext) + .Subscribe(_ => { }); // tempo handled naturally by timer polling CurrentTime() + + _progressRect.Margin = new Thickness(WaveformDimensions.LeftMargin(), 0, 0, 0); + _progressRect.Width = 0; + _progressRect.Height = MainCanvas.Bounds.Height; + MainCanvas.Children.Add(_progressRect); + _progressRect.Stroke = _transparentBrush; + _progressRect.StrokeThickness = 0d; + _progressRect.Fill = new SolidColorBrush(Color.FromRgb(0, 0, 0)) { Opacity = 0.4 }; + ControlProgressAnimation(EventArgs.Empty); + } + + private void ControlProgressAnimation(EventArgs e) + { + if (Tune.PlaybackOn()) + { + StartTimer(); + } + else + { + StopTimer(); + UpdateProgressPosition(); + } + } + + private void StartTimer() + { + if (_progressTimer != null) return; + _progressTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(33) }; + _progressTimer.Tick += OnTimerTick; + _progressTimer.Start(); + } + + private void StopTimer() + { + if (_progressTimer == null) return; + _progressTimer.Tick -= OnTimerTick; + _progressTimer.Stop(); + _progressTimer = null; + } + + private void OnTimerTick(object? sender, EventArgs e) => UpdateProgressPosition(); + + private void UpdateProgressPosition() + { + if (MainCanvas == null || WaveformDimensions.AreEmpty()) return; + var progress = CoverageArea.Progress(Tune.CurrentTime().TotalSeconds); + var width = new FiniteDouble(progress * WaveformDimensions.Width()); + _progressRect.Width = Math.Max(0, Math.Min(width, WaveformDimensions.Width())); + } + + private void Clear() + { + _playbackTempoNotifier?.Dispose(); + _playbackOnOffNotifier?.Dispose(); + StopTimer(); + MainCanvas?.Children.Remove(_progressRect); + } + } +} diff --git a/WaveformTimeline.Avalonia/Controls/Timeline/Timeline.cs b/WaveformTimeline.Avalonia/Controls/Timeline/Timeline.cs new file mode 100644 index 0000000..8c3f231 --- /dev/null +++ b/WaveformTimeline.Avalonia/Controls/Timeline/Timeline.cs @@ -0,0 +1,211 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Reactive.Linq; +using System.Threading; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Shapes; +using Avalonia.Layout; +using Avalonia.Media; +using WaveformTimeline.Commons; +using WaveformTimeline.Primitives; +using Rectangle = Avalonia.Controls.Shapes.Rectangle; + +namespace WaveformTimeline.Controls.Timeline +{ + public sealed class Timeline : BaseControl + { + public Timeline() + { + _redrawObservable = new RedrawObservable(); + } + + private readonly RedrawObservable _redrawObservable; + private IDisposable? _redrawDisposable; + private IDisposable? _boundsDisposable; + private readonly Line _timelineTickLine = new(); + private readonly List _timeLineTicks = new(); + private readonly Rectangle _timelineBackgroundRegion = new(); + private readonly List _timestampTextBlocks = new(); + + protected override void OnTuneChanged() => _redrawObservable.Increment(); + + public static readonly StyledProperty TimelineTickBrushProperty = + AvaloniaProperty.Register(nameof(TimelineTickBrush), + defaultValue: new SolidColorBrush(Colors.Black)); + + public IBrush TimelineTickBrush + { + get => GetValue(TimelineTickBrushProperty); + set => SetValue(TimelineTickBrushProperty, value); + } + + public static readonly StyledProperty MajorTickHeightProperty = + AvaloniaProperty.Register(nameof(MajorTickHeight), defaultValue: 10); + + public int MajorTickHeight + { + get => GetValue(MajorTickHeightProperty); + set => SetValue(MajorTickHeightProperty, Math.Max(1, value)); + } + + public static readonly StyledProperty MinorTickHeightProperty = + AvaloniaProperty.Register(nameof(MinorTickHeight), defaultValue: 3); + + public int MinorTickHeight + { + get => GetValue(MinorTickHeightProperty); + set => SetValue(MinorTickHeightProperty, Math.Max(1, value)); + } + + public static readonly StyledProperty EmptyTuneDurationInSecondsProperty = + AvaloniaProperty.Register(nameof(EmptyTuneDurationInSeconds), defaultValue: 180); + + public int EmptyTuneDurationInSeconds + { + get => GetValue(EmptyTuneDurationInSecondsProperty); + set => SetValue(EmptyTuneDurationInSecondsProperty, Math.Max(0, value)); + } + + public static readonly StyledProperty TimelineTypeProperty = + AvaloniaProperty.Register(nameof(TimelineType), defaultValue: TimelineType.Constant); + + public TimelineType TimelineType + { + get => GetValue(TimelineTypeProperty); + set => SetValue(TimelineTypeProperty, value); + } + + public static readonly StyledProperty EndRevealingMarkProperty = + AvaloniaProperty.Register(nameof(EndRevealingMark), defaultValue: new ZeroToOne(0.75)); + + public ZeroToOne EndRevealingMark + { + get => GetValue(EndRevealingMarkProperty); + set => SetValue(EndRevealingMarkProperty, value); + } + + static Timeline() + { + TimelineTickBrushProperty.Changed.AddClassHandler((t, _) => + { + foreach (var line in t.MainCanvas?.Children.OfType() ?? Enumerable.Empty()) + line.Stroke = t.TimelineTickBrush; + }); + } + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + MainCanvas = e.NameScope.Find("PART_Timeline"); + Debug.Assert(MainCanvas != null, "timeline canvas cannot be null"); + var context = SynchronizationContext.Current; + if (context != null && _redrawDisposable == null) + { + _redrawDisposable = _redrawObservable.Throttle(TimeSpan.FromMilliseconds(100)) + .ObserveOn(context) + .Subscribe(_ => Render()); + } + _boundsDisposable?.Dispose(); + _boundsDisposable = MainCanvas!.GetObservable(BoundsProperty) + .Subscribe(_ => _redrawObservable.Increment()); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _redrawDisposable?.Dispose(); + _boundsDisposable?.Dispose(); + } + + private TextBlock DrawText(string text) => new() + { + FontFamily = FontFamily, + FontStyle = FontStyle, + FontWeight = FontWeight, + FontSize = FontSize, + Foreground = Foreground, + Text = text + }; + + private Line DrawLine(double xLocation, double bottomLoc) => new() + { + Stroke = TimelineTickBrush, + StrokeThickness = 1.0d, + StartPoint = new Point(xLocation, bottomLoc), + EndPoint = new Point(xLocation, bottomLoc - MinorTickHeight) + }; + + private Line LinesAtMajorTickAreLonger(Line line, TimeSpan second, List majorTicksAt, double bottomLoc) + { + if (majorTicksAt.Contains(second)) + line.EndPoint = new Point(line.EndPoint.X, bottomLoc - MajorTickHeight); + return line; + } + + private TextBlock WithMargin(TextBlock tb, double loc) + { + tb.Margin = new Thickness(loc + 2, 0, 0, 0); + return tb; + } + + protected override void MeasureArea() + { + base.MeasureArea(); + var tune = MainCanvas!.Bounds.Width <= 0.0 + ? new NoTune() + : Tune is NoTune || Math.Abs(new FiniteDouble(Tune.TotalTime().TotalSeconds, 0.0d).Value()) < 0.001 + ? new NoTune(EmptyTuneDurationInSeconds) + : Tune; + CoverageArea = new TuneDuration(tune, Zoom); + WaveformDimensions = new WaveformDimensions(CoverageArea, MainCanvas.Bounds.Width); + } + + protected override void Render() + { + Clear(); + MeasureArea(); + var timelineSource = new TimelineSource(CoverageArea); + var bottomLoc = MainCanvas!.Bounds.Height - 1; + var firstMark = timelineSource.Beginning; + var timelineMarkingStrategy = TimelineType.Strategy(CoverageArea, firstMark, EndRevealingMark); + var timelineTickLocation = new TimelineTickLocation(CoverageArea, WaveformDimensions); + var listOfSeconds = timelineSource.Seconds().ToList(); + var majorTicksAt = listOfSeconds.Where(timelineMarkingStrategy.AtMajorTick).ToList(); + _timelineTickLine.StartPoint = new Point(0, MainCanvas.Bounds.Height); + _timelineTickLine.EndPoint = new Point(MainCanvas.Bounds.Width, MainCanvas.Bounds.Height); + _timelineTickLine.Stroke = TimelineTickBrush; + _timelineBackgroundRegion.Width = MainCanvas.Bounds.Width; + _timelineBackgroundRegion.Height = MainCanvas.Bounds.Height; + MainCanvas.Children.Add(_timelineTickLine); + MainCanvas.Children.Add(_timelineBackgroundRegion); + _timeLineTicks.AddRange( + listOfSeconds.Where(timelineMarkingStrategy.AtMinorTick) + .Select(sec => (Second: sec, Location: timelineTickLocation.LocationOnXAxis(sec))) + .Where(t => MainCanvas.Bounds.Width - t.Location >= 28.0d) + .Select(t => LinesAtMajorTickAreLonger(DrawLine(t.Location, bottomLoc), t.Second, majorTicksAt, bottomLoc))); + _timestampTextBlocks.AddRange(majorTicksAt + .Select(sec => (Second: sec, Location: timelineTickLocation.LocationOnXAxis(sec))) + .Select(sec => WithMargin(DrawText(timelineSource.TimespanAsString(sec.Second)), sec.Location))); + foreach (var line in _timeLineTicks) + MainCanvas.Children.Add(line); + foreach (var tb in _timestampTextBlocks) + MainCanvas.Children.Add(tb); + } + + private void Clear() + { + MainCanvas?.Children.Clear(); + foreach (var textblock in _timestampTextBlocks) + MainCanvas?.Children.Remove(textblock); + _timestampTextBlocks.Clear(); + foreach (var line in _timeLineTicks) + MainCanvas?.Children.Remove(line); + _timeLineTicks.Clear(); + } + } +} diff --git a/WaveformTimeline.Avalonia/Controls/Waveform/Waveform.cs b/WaveformTimeline.Avalonia/Controls/Waveform/Waveform.cs new file mode 100644 index 0000000..823f0b7 --- /dev/null +++ b/WaveformTimeline.Avalonia/Controls/Waveform/Waveform.cs @@ -0,0 +1,341 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Reactive.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Controls.Shapes; +using Avalonia.Media; +using Avalonia.VisualTree; +using WaveformTimeline.Commons; +using WaveformTimeline.Contracts; + +namespace WaveformTimeline.Controls.Waveform +{ + public sealed class Waveform : BaseControl + { + public Waveform() + { + _uiContext = SynchronizationContext.Current ?? throw new InvalidOperationException("This class must be instantiated on the UI thread"); + _redrawObservable = new RedrawObservable(); + } + + private readonly SynchronizationContext _uiContext; + private readonly RedrawObservable _redrawObservable; + private IDisposable? _redrawDisposable; + private IDisposable? _waveformBuildDisposable; + private IDisposable? _boundsDisposable; + private readonly Path _leftPath = new(); + private readonly Path _rightPath = new(); + private readonly Line _centerLine = new(); + private readonly List _leftSideOffsetDashes = new(); + private readonly List _rightSideOffsetDashes = new(); + private RenderedToDimensions? _lastRenderedToDimensions; + private BackgroundWorker? _renderingInBackground; + + private class RenderedToDimensions(ITune tune, WaveformDimensions dimensions) + { + public ITune Tune { get; } = tune; + public WaveformDimensions Dimensions { get; } = dimensions; + } + + public static readonly StyledProperty LeftLevelBrushProperty = + AvaloniaProperty.Register(nameof(LeftLevelBrush), + defaultValue: new SolidColorBrush(Colors.Blue)); + + public IBrush LeftLevelBrush + { + get => GetValue(LeftLevelBrushProperty); + set => SetValue(LeftLevelBrushProperty, value); + } + + public static readonly StyledProperty RightLevelBrushProperty = + AvaloniaProperty.Register(nameof(RightLevelBrush), + defaultValue: new SolidColorBrush(Colors.Red)); + + public IBrush RightLevelBrush + { + get => GetValue(RightLevelBrushProperty); + set => SetValue(RightLevelBrushProperty, value); + } + + public static readonly StyledProperty CenterLineBrushProperty = + AvaloniaProperty.Register(nameof(CenterLineBrush), + defaultValue: new SolidColorBrush(Colors.Black)); + + public IBrush CenterLineBrush + { + get => GetValue(CenterLineBrushProperty); + set => SetValue(CenterLineBrushProperty, value); + } + + public static readonly StyledProperty CenterLineThicknessProperty = + AvaloniaProperty.Register(nameof(CenterLineThickness), defaultValue: 1.0, + coerce: (_, v) => Math.Max(v, 0.0d)); + + public double CenterLineThickness + { + get => GetValue(CenterLineThicknessProperty); + set => SetValue(CenterLineThicknessProperty, value); + } + + public static readonly StyledProperty WaveformResolutionProperty = + AvaloniaProperty.Register(nameof(WaveformResolution), defaultValue: 2000, + coerce: (_, v) => Math.Max(1000, Math.Min(16000, v))); + + public int WaveformResolution + { + get => GetValue(WaveformResolutionProperty); + set => SetValue(WaveformResolutionProperty, value); + } + + public static readonly StyledProperty AutoScaleWaveformCacheProperty = + AvaloniaProperty.Register(nameof(AutoScaleWaveformCache), defaultValue: false); + + public bool AutoScaleWaveformCache + { + get => GetValue(AutoScaleWaveformCacheProperty); + set => SetValue(AutoScaleWaveformCacheProperty, value); + } + + public static readonly StyledProperty ProgressiveRenderingProperty = + AvaloniaProperty.Register(nameof(ProgressiveRendering), defaultValue: true); + + public bool ProgressiveRendering + { + get => GetValue(ProgressiveRenderingProperty); + set => SetValue(ProgressiveRenderingProperty, value); + } + + static Waveform() + { + LeftLevelBrushProperty.Changed.AddClassHandler((w, e) => w._leftPath.Fill = (IBrush?)e.NewValue); + RightLevelBrushProperty.Changed.AddClassHandler((w, e) => w._rightPath.Fill = (IBrush?)e.NewValue); + CenterLineBrushProperty.Changed.AddClassHandler((w, e) => w._centerLine.Stroke = (IBrush?)e.NewValue); + CenterLineThicknessProperty.Changed.AddClassHandler((w, e) => w._centerLine.StrokeThickness = (double)(e.NewValue ?? 1.0)); + WaveformResolutionProperty.Changed.AddClassHandler((w, _) => w._redrawObservable.Increment()); + AutoScaleWaveformCacheProperty.Changed.AddClassHandler((w, _) => w.UpdateWaveformCacheScaling()); + } + + protected override void OnTuneChanged() => _redrawObservable.Increment(); + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + MainCanvas = e.NameScope.Find("PART_Waveform"); + if (MainCanvas == null) return; + + MainCanvas.Background = new SolidColorBrush(Colors.Transparent); + MainCanvas.Children.Add(_centerLine); + MainCanvas.Children.Add(_leftPath); + MainCanvas.Children.Add(_rightPath); + + if (CenterLineBrush != null) + { + _centerLine.StartPoint = new Point(0, MainCanvas.Bounds.Height); + _centerLine.EndPoint = new Point(MainCanvas.Bounds.Width, MainCanvas.Bounds.Height); + } + UpdateWaveformCacheScaling(); + + var context = SynchronizationContext.Current; + if (context != null && _redrawDisposable == null) + { + _redrawDisposable = _redrawObservable.Sample(TimeSpan.FromMilliseconds(100)) + .ObserveOn(context) + .Subscribe(_ => Render()); + } + + _boundsDisposable?.Dispose(); + _boundsDisposable = MainCanvas.GetObservable(BoundsProperty) + .Subscribe(_ => _redrawObservable.Increment()); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e) + { + base.OnDetachedFromVisualTree(e); + _redrawDisposable?.Dispose(); + _boundsDisposable?.Dispose(); + } + + private double AdjustedByTransformM11(ITransform? transform) + { + if (transform == null) return 1.0; + var m = transform.Value; + return Math.Abs(m.M12) < 0.001 && Math.Abs(m.M21) < 0.001 && + Math.Abs(m.M31) < 0.001 && Math.Abs(m.M32) < 0.001 && + Math.Abs(m.M11 - m.M22) < 0.001 + ? m.M11 : 1.0; + } + + private double TotalTransformScaleFn() + { + double scale = 1.0; + Visual? current = this; + while (current != null) + { + scale *= AdjustedByTransformM11(current.RenderTransform); + current = current.GetVisualParent(); + } + return scale; + } + + private void UpdateWaveformCacheScaling() + { + } + + private static float[] CreateFloats(byte[] bytes) + { + var floats = new float[bytes.Length / 4]; + Buffer.BlockCopy(bytes, 0, floats, 0, bytes.Length); + return floats; + } + + private bool ShouldRedraw() => + MainCanvas != null && + (_lastRenderedToDimensions == null || !AreTunesTheSame() || !AreDimensionsSame()); + + private bool AreTunesTheSame() => Tune.Name() == _lastRenderedToDimensions?.Tune.Name(); + private bool AreDimensionsSame() => WaveformDimensions.Equals(_lastRenderedToDimensions?.Dimensions); + + protected override void Render() + { + MeasureArea(); + if (!ShouldRedraw() || MainCanvas == null) return; + + Clear(); + var centerHeight = MainCanvas.Bounds.Height / 2.0d; + var availableWidth = MainCanvas.Bounds.Width - WaveformDimensions.RightMargin(); + var leftWaveformPolyLine1 = new PolyLineSegment(); + var rightWaveformPolyLine1 = new PolyLineSegment(); + Point StartPoint() => new(WaveformDimensions.LeftMargin(), centerHeight); + var leftFigure = new PathFigure { StartPoint = StartPoint(), IsClosed = false }; + leftFigure.Segments!.Add(leftWaveformPolyLine1); + var leftGeometry = new PathGeometry(); + leftGeometry.Figures!.Add(leftFigure); + _leftPath.Data = leftGeometry; + var rightFigure = new PathFigure { StartPoint = StartPoint(), IsClosed = false }; + rightFigure.Segments!.Add(rightWaveformPolyLine1); + var rightGeometry = new PathGeometry(); + rightGeometry.Figures!.Add(rightFigure); + _rightPath.Data = rightGeometry; + _centerLine.StartPoint = new Point(WaveformDimensions.LeftMargin(), centerHeight); + _centerLine.EndPoint = new Point(availableWidth, centerHeight); + MainCanvas.Children.Add(_leftPath); + MainCanvas.Children.Add(_rightPath); + MainCanvas.Children.Add(_centerLine); + if (Tune.TotalTime().TotalSeconds > 0) + { + CreateDashedPadding(0, WaveformDimensions.LeftMargin(), _leftSideOffsetDashes); + if (CoverageArea.Includes(1.0)) + CreateDashedPadding(availableWidth, WaveformDimensions.RightMargin(), _rightSideOffsetDashes); + } + var section = new WaveformSection(CoverageArea, Tune, WaveformResolution); + var renderWaveform = new WaveformRenderingProgress(WaveformDimensions, section, MainCanvas.Bounds.Height, leftWaveformPolyLine1, rightWaveformPolyLine1); + var renderingMethod = ProgressiveRendering + ? (Action)RenderProgressively + : BackgroundReadThenRender; + renderingMethod(section, renderWaveform); + _lastRenderedToDimensions = new RenderedToDimensions(Tune, WaveformDimensions); + } + + private void RenderProgressively(WaveformSection section, WaveformRenderingProgress renderWaveform) + { + var waveformFloats = CreateFloats(Tune.WaveformData()); + if (waveformFloats.Length > 0) + { + renderWaveform.DrawWaveform(waveformFloats); + return; + } + var resolution = WaveformResolution; + var observable = Tune.WaveformStream(); + var steps = Math.Min(resolution, 1000); + _waveformBuildDisposable?.Dispose(); + _waveformBuildDisposable = observable.ObserveOn(_uiContext) + .Buffer(steps) + .Subscribe(e => renderWaveform.DrawWaveform(e.ToArray()), + renderWaveform.CompleteWaveform); + Task.Run(() => observable.Waveform(resolution)); + } + + private void BackgroundReadThenRender(WaveformSection section, WaveformRenderingProgress renderWaveform) + { + var waveformFloats = CreateFloats(Tune.WaveformData()); + if (waveformFloats.Length > 0) + { + RenderWaveformSync(renderWaveform, waveformFloats); + return; + } + _renderingInBackground = new BackgroundWorker(); + _renderingInBackground.DoWork += ReadWaveformInBackground; + _renderingInBackground.RunWorkerCompleted += OnBackgroundRenderingCompleted; + _renderingInBackground.RunWorkerAsync(new BackgroundRenderingArgs(Tune, renderWaveform, WaveformResolution)); + } + + private void RenderWaveformSync(WaveformRenderingProgress renderWaveform, float[] waveformFloats) + { + renderWaveform.DrawWaveform(waveformFloats); + renderWaveform.CompleteWaveform(); + } + + private void ReadWaveformInBackground(object? sender, DoWorkEventArgs e) + { + var args = e.Argument as BackgroundRenderingArgs; + args?.Tune.WaveformStream().Waveform(args.Resolution); + e.Result = args; + } + + private void OnBackgroundRenderingCompleted(object? sender, RunWorkerCompletedEventArgs e) + { + if (e.Error != null || e.Result is not BackgroundRenderingArgs args) return; + RenderWaveformSync(args.RenderWaveform, CreateFloats(Tune.WaveformData())); + } + + private class BackgroundRenderingArgs(ITune tune, WaveformRenderingProgress renderWaveform, int resolution) + { + public ITune Tune { get; } = tune; + public int Resolution { get; } = resolution; + public WaveformRenderingProgress RenderWaveform { get; } = renderWaveform; + } + + private Line DrawDash(int i, double centerPos, double startPos, int dashSize, int inBetweenDashesSpace) => + new() + { + Stroke = CenterLineBrush, + StrokeThickness = CenterLineThickness, + StartPoint = new Point(i == 0 ? startPos : startPos + i * dashSize + i * inBetweenDashesSpace, centerPos), + EndPoint = new Point((i == 0 ? startPos : startPos + i * dashSize + i * inBetweenDashesSpace) + dashSize, centerPos) + }; + + private void CreateDashedPadding(double startPos, double spaceInPx, List dashes) + { + if (MainCanvas == null) return; + const int minDashSize = 3; + const int maxDashCount = 5; + const int minInBetweenDashesSpace = 3; + int dashSize = minDashSize; + int dashCount = Math.Min(maxDashCount, (int)Math.Floor(WaveformDimensions.LeftMargin() / dashSize)); + var dashTotalWidth = dashCount * minDashSize; + dashSize += Math.Max(0, (int)Math.Floor((spaceInPx - dashTotalWidth - ((dashCount - 1) * minInBetweenDashesSpace)) / dashCount)); + int inBetweenDashesSpace = Math.Max(minInBetweenDashesSpace, (int)Math.Floor((WaveformDimensions.LeftMargin() - (dashSize * dashCount)) / dashCount)); + var centerPos = MainCanvas.Bounds.Height / 2; + var lines = Enumerable.Range(0, dashCount) + .Select(i => DrawDash(i, centerPos, startPos, dashSize, inBetweenDashesSpace)); + foreach (var dash in lines) + { + dashes.Add(dash); + MainCanvas.Children.Add(dash); + } + } + + public void Clear() + { + _waveformBuildDisposable?.Dispose(); + MainCanvas?.Children.Clear(); + } + } +} diff --git a/WaveformTimeline.Avalonia/Controls/Waveform/WaveformRenderingProgress.cs b/WaveformTimeline.Avalonia/Controls/Waveform/WaveformRenderingProgress.cs new file mode 100644 index 0000000..48a1e85 --- /dev/null +++ b/WaveformTimeline.Avalonia/Controls/Waveform/WaveformRenderingProgress.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using Avalonia; +using Avalonia.Media; +using WaveformTimeline.Commons; + +namespace WaveformTimeline.Controls.Waveform +{ + internal sealed class WaveformRenderingProgress + { + public WaveformRenderingProgress(WaveformDimensions waveformDimensions, + WaveformSection waveformSection, + double canvasHeight, + PolyLineSegment leftWaveformPolyLine, PolyLineSegment rightWaveformPolyLine) + { + _leftWaveformPolyLine = leftWaveformPolyLine; + _rightWaveformPolyLine = rightWaveformPolyLine; + _pointThickness = waveformDimensions.Width() / (int)((waveformSection.End - waveformSection.Start + 1) / 2.0d); + _height = canvasHeight / 2.0d; + _leftMargin = waveformDimensions.LeftMargin(); + _leftWaveformPolyLine.Points.Add(new Point(_xLocation, _height)); + _rightWaveformPolyLine.Points.Add(new Point(_xLocation, _height)); + } + + private readonly PolyLineSegment _leftWaveformPolyLine; + private readonly PolyLineSegment _rightWaveformPolyLine; + private readonly double _height; + private readonly double _leftMargin; + private readonly double _pointThickness; + private double _xLocation; + private double _pointsDrawn; + + public void DrawWaveform(float[] wf) + { + _rightWaveformPolyLine.Points.RemoveAt(_rightWaveformPolyLine.Points.Count - 1); + _leftWaveformPolyLine.Points.RemoveAt(_leftWaveformPolyLine.Points.Count - 1); + var pointsDrawn = _pointsDrawn; + var location = _xLocation; + for (var i = 0; i < wf.Length - 1; i += 2) + { + location = ((pointsDrawn / 2) * _pointThickness) + _leftMargin; + _leftWaveformPolyLine.Points.Add(new Point(location, _height + wf[i] * _height)); + _rightWaveformPolyLine.Points.Add(new Point(location, _height - wf[i + 1] * _height)); + pointsDrawn += 2; + } + (_xLocation, _pointsDrawn) = (location, pointsDrawn); + _rightWaveformPolyLine.Points.Add(new Point(_xLocation, _height)); + _leftWaveformPolyLine.Points.Add(new Point(_xLocation, _height)); + + // Reassign Points to trigger StyledProperty change notification, + // which invalidates the geometry up through PathFigure → PathGeometry → Path. + _leftWaveformPolyLine.Points = new List(_leftWaveformPolyLine.Points); + _rightWaveformPolyLine.Points = new List(_rightWaveformPolyLine.Points); + } + + public void CompleteWaveform() + { + } + } +} diff --git a/WaveformTimeline.Avalonia/Themes/Default.axaml b/WaveformTimeline.Avalonia/Themes/Default.axaml new file mode 100644 index 0000000..134ae85 --- /dev/null +++ b/WaveformTimeline.Avalonia/Themes/Default.axaml @@ -0,0 +1,119 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WaveformTimeline.Avalonia/WaveformTimeline.Avalonia.csproj b/WaveformTimeline.Avalonia/WaveformTimeline.Avalonia.csproj new file mode 100644 index 0000000..65a729f --- /dev/null +++ b/WaveformTimeline.Avalonia/WaveformTimeline.Avalonia.csproj @@ -0,0 +1,16 @@ + + + net10.0 + latest + WaveformTimeline + WaveformTimeline.Avalonia + + + + + + + + + + diff --git a/WaveformTimeline.Avalonia/WaveformTimeline.cs b/WaveformTimeline.Avalonia/WaveformTimeline.cs new file mode 100644 index 0000000..c96b1df --- /dev/null +++ b/WaveformTimeline.Avalonia/WaveformTimeline.cs @@ -0,0 +1,213 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.Media; +using WaveformTimeline.Commons; +using WaveformTimeline.Contracts; +using WaveformTimeline.Controls.Timeline; +using WaveformTimeline.Primitives; + +namespace WaveformTimeline +{ + public sealed class WaveformTimeline : TemplatedControl + { + public static readonly StyledProperty TuneProperty = + AvaloniaProperty.Register(nameof(Tune)); + + public ITune Tune + { + get => GetValue(TuneProperty) ?? new NoTune(); + set => SetValue(TuneProperty, value ?? new NoTune()); + } + + public static readonly StyledProperty ZoomProperty = + AvaloniaProperty.Register(nameof(Zoom), defaultValue: 1.0); + + public double Zoom + { + get => GetValue(ZoomProperty); + set => SetValue(ZoomProperty, value); + } + + public static readonly StyledProperty LeftLevelBrushProperty = + AvaloniaProperty.Register(nameof(LeftLevelBrush), + defaultValue: new SolidColorBrush(Colors.Blue)); + + public IBrush LeftLevelBrush + { + get => GetValue(LeftLevelBrushProperty); + set => SetValue(LeftLevelBrushProperty, value); + } + + public static readonly StyledProperty RightLevelBrushProperty = + AvaloniaProperty.Register(nameof(RightLevelBrush), + defaultValue: new SolidColorBrush(Colors.Red)); + + public IBrush RightLevelBrush + { + get => GetValue(RightLevelBrushProperty); + set => SetValue(RightLevelBrushProperty, value); + } + + public static readonly StyledProperty CenterLineBrushProperty = + AvaloniaProperty.Register(nameof(CenterLineBrush), + defaultValue: new SolidColorBrush(Colors.Black)); + + public IBrush CenterLineBrush + { + get => GetValue(CenterLineBrushProperty); + set => SetValue(CenterLineBrushProperty, value); + } + + public static readonly StyledProperty CenterLineThicknessProperty = + AvaloniaProperty.Register(nameof(CenterLineThickness), defaultValue: 1.0); + + public double CenterLineThickness + { + get => GetValue(CenterLineThicknessProperty); + set => SetValue(CenterLineThicknessProperty, value); + } + + public static readonly StyledProperty AutoScaleWaveformCacheProperty = + AvaloniaProperty.Register(nameof(AutoScaleWaveformCache), defaultValue: false); + + public bool AutoScaleWaveformCache + { + get => GetValue(AutoScaleWaveformCacheProperty); + set => SetValue(AutoScaleWaveformCacheProperty, value); + } + + public static readonly StyledProperty WaveformResolutionProperty = + AvaloniaProperty.Register(nameof(WaveformResolution), defaultValue: 2000); + + public int WaveformResolution + { + get => GetValue(WaveformResolutionProperty); + set => SetValue(WaveformResolutionProperty, value); + } + + public static readonly StyledProperty ProgressiveRenderingProperty = + AvaloniaProperty.Register(nameof(ProgressiveRendering), defaultValue: true); + + public bool ProgressiveRendering + { + get => GetValue(ProgressiveRenderingProperty); + set => SetValue(ProgressiveRenderingProperty, value); + } + + public static readonly StyledProperty ProgressBarBrushProperty = + AvaloniaProperty.Register(nameof(ProgressBarBrush), + defaultValue: new SolidColorBrush(Color.FromArgb(0xCD, 0xBA, 0x00, 0xFF))); + + public IBrush ProgressBarBrush + { + get => GetValue(ProgressBarBrushProperty); + set => SetValue(ProgressBarBrushProperty, value); + } + + public static readonly StyledProperty ProgressBarThicknessProperty = + AvaloniaProperty.Register(nameof(ProgressBarThickness), defaultValue: 2.0); + + public double ProgressBarThickness + { + get => GetValue(ProgressBarThicknessProperty); + set => SetValue(ProgressBarThicknessProperty, value); + } + + public static readonly StyledProperty AllowRepositioningProperty = + AvaloniaProperty.Register(nameof(AllowRepositioning), defaultValue: true); + + public bool AllowRepositioning + { + get => GetValue(AllowRepositioningProperty); + set => SetValue(AllowRepositioningProperty, value); + } + + public static readonly StyledProperty TimelineTickBrushProperty = + AvaloniaProperty.Register(nameof(TimelineTickBrush), + defaultValue: new SolidColorBrush(Colors.Black)); + + public IBrush TimelineTickBrush + { + get => GetValue(TimelineTickBrushProperty); + set => SetValue(TimelineTickBrushProperty, value); + } + + public static readonly StyledProperty TimelineTypeProperty = + AvaloniaProperty.Register(nameof(TimelineType), + defaultValue: TimelineType.Constant); + + public TimelineType TimelineType + { + get => GetValue(TimelineTypeProperty); + set => SetValue(TimelineTypeProperty, value); + } + + public static readonly StyledProperty EndRevealingMarkProperty = + AvaloniaProperty.Register(nameof(EndRevealingMark), + defaultValue: new ZeroToOne(0.75)); + + public ZeroToOne EndRevealingMark + { + get => GetValue(EndRevealingMarkProperty); + set => SetValue(EndRevealingMarkProperty, value); + } + + public static readonly StyledProperty ShowCueMarksProperty = + AvaloniaProperty.Register(nameof(ShowCueMarks), defaultValue: true); + + public bool ShowCueMarks + { + get => GetValue(ShowCueMarksProperty); + set => SetValue(ShowCueMarksProperty, value); + } + + public static readonly StyledProperty ShowCueMarkToolTipProperty = + AvaloniaProperty.Register(nameof(ShowCueMarkToolTip), defaultValue: false); + + public bool ShowCueMarkToolTip + { + get => GetValue(ShowCueMarkToolTipProperty); + set => SetValue(ShowCueMarkToolTipProperty, value); + } + + public static readonly StyledProperty EnableCueMarksRepositioningProperty = + AvaloniaProperty.Register(nameof(EnableCueMarksRepositioning), defaultValue: true); + + public bool EnableCueMarksRepositioning + { + get => GetValue(EnableCueMarksRepositioningProperty); + set => SetValue(EnableCueMarksRepositioningProperty, value); + } + + public static readonly StyledProperty CueMarkBrushProperty = + AvaloniaProperty.Register(nameof(CueMarkBrush), + defaultValue: new SolidColorBrush(Color.FromArgb(0xCD, 0xBA, 0x00, 0xFF))); + + public IBrush CueMarkBrush + { + get => GetValue(CueMarkBrushProperty); + set => SetValue(CueMarkBrushProperty, value); + } + + public static readonly StyledProperty CueBarBackgroundBrushProperty = + AvaloniaProperty.Register(nameof(CueBarBackgroundBrush), + defaultValue: new SolidColorBrush(Color.FromArgb(0xCD, 0xBA, 0x00, 0xFF))); + + public IBrush CueBarBackgroundBrush + { + get => GetValue(CueBarBackgroundBrushProperty); + set => SetValue(CueBarBackgroundBrushProperty, value); + } + + public static readonly StyledProperty CueMarkAccentBrushProperty = + AvaloniaProperty.Register(nameof(CueMarkAccentBrush), + defaultValue: new SolidColorBrush(Color.FromRgb(255, 0, 0))); + + public IBrush CueMarkAccentBrush + { + get => GetValue(CueMarkAccentBrushProperty); + set => SetValue(CueMarkAccentBrushProperty, value); + } + } +} diff --git a/WaveformTimeline.Avalonia/WaveformTimelineTheme.axaml b/WaveformTimeline.Avalonia/WaveformTimelineTheme.axaml new file mode 100644 index 0000000..16a274f --- /dev/null +++ b/WaveformTimeline.Avalonia/WaveformTimelineTheme.axaml @@ -0,0 +1,5 @@ + + + diff --git a/WaveformTimeline.Avalonia/WaveformTimelineTheme.cs b/WaveformTimeline.Avalonia/WaveformTimelineTheme.cs new file mode 100644 index 0000000..a078813 --- /dev/null +++ b/WaveformTimeline.Avalonia/WaveformTimelineTheme.cs @@ -0,0 +1,15 @@ +#nullable enable +using System; +using Avalonia.Markup.Xaml; +using Avalonia.Styling; + +namespace WaveformTimeline +{ + public class WaveformTimelineTheme : Styles + { + public WaveformTimelineTheme(IServiceProvider? sp = null) + { + AvaloniaXamlLoader.Load(sp, this); + } + } +} diff --git a/WaveformTimeline/Commons/NoTune.cs b/WaveformTimeline.Shared/Commons/NoTune.cs similarity index 100% rename from WaveformTimeline/Commons/NoTune.cs rename to WaveformTimeline.Shared/Commons/NoTune.cs diff --git a/WaveformTimeline/Commons/RedrawObservable.cs b/WaveformTimeline.Shared/Commons/RedrawObservable.cs similarity index 100% rename from WaveformTimeline/Commons/RedrawObservable.cs rename to WaveformTimeline.Shared/Commons/RedrawObservable.cs diff --git a/WaveformTimeline/Commons/TuneDuration.cs b/WaveformTimeline.Shared/Commons/TuneDuration.cs similarity index 100% rename from WaveformTimeline/Commons/TuneDuration.cs rename to WaveformTimeline.Shared/Commons/TuneDuration.cs diff --git a/WaveformTimeline/Commons/WaveformDimensions.cs b/WaveformTimeline.Shared/Commons/WaveformDimensions.cs similarity index 100% rename from WaveformTimeline/Commons/WaveformDimensions.cs rename to WaveformTimeline.Shared/Commons/WaveformDimensions.cs diff --git a/WaveformTimeline/Contracts/IAudioWaveformStream.cs b/WaveformTimeline.Shared/Contracts/IAudioWaveformStream.cs similarity index 100% rename from WaveformTimeline/Contracts/IAudioWaveformStream.cs rename to WaveformTimeline.Shared/Contracts/IAudioWaveformStream.cs diff --git a/WaveformTimeline/Contracts/ITimedPlayback.cs b/WaveformTimeline.Shared/Contracts/ITimedPlayback.cs similarity index 100% rename from WaveformTimeline/Contracts/ITimedPlayback.cs rename to WaveformTimeline.Shared/Contracts/ITimedPlayback.cs diff --git a/WaveformTimeline/Contracts/ITune.cs b/WaveformTimeline.Shared/Contracts/ITune.cs similarity index 100% rename from WaveformTimeline/Contracts/ITune.cs rename to WaveformTimeline.Shared/Contracts/ITune.cs diff --git a/WaveformTimeline/Controls/Timeline/ConstantTimeline.cs b/WaveformTimeline.Shared/Controls/Timeline/ConstantTimeline.cs similarity index 100% rename from WaveformTimeline/Controls/Timeline/ConstantTimeline.cs rename to WaveformTimeline.Shared/Controls/Timeline/ConstantTimeline.cs diff --git a/WaveformTimeline/Controls/Timeline/EndRevealingTimeline.cs b/WaveformTimeline.Shared/Controls/Timeline/EndRevealingTimeline.cs similarity index 100% rename from WaveformTimeline/Controls/Timeline/EndRevealingTimeline.cs rename to WaveformTimeline.Shared/Controls/Timeline/EndRevealingTimeline.cs diff --git a/WaveformTimeline/Controls/Timeline/ITimelineMarkingStrategy.cs b/WaveformTimeline.Shared/Controls/Timeline/ITimelineMarkingStrategy.cs similarity index 100% rename from WaveformTimeline/Controls/Timeline/ITimelineMarkingStrategy.cs rename to WaveformTimeline.Shared/Controls/Timeline/ITimelineMarkingStrategy.cs diff --git a/WaveformTimeline/Controls/Timeline/TickDuration.cs b/WaveformTimeline.Shared/Controls/Timeline/TickDuration.cs similarity index 100% rename from WaveformTimeline/Controls/Timeline/TickDuration.cs rename to WaveformTimeline.Shared/Controls/Timeline/TickDuration.cs diff --git a/WaveformTimeline/Controls/Timeline/TimelineSource.cs b/WaveformTimeline.Shared/Controls/Timeline/TimelineSource.cs similarity index 100% rename from WaveformTimeline/Controls/Timeline/TimelineSource.cs rename to WaveformTimeline.Shared/Controls/Timeline/TimelineSource.cs diff --git a/WaveformTimeline/Controls/Timeline/TimelineTickLocation.cs b/WaveformTimeline.Shared/Controls/Timeline/TimelineTickLocation.cs similarity index 100% rename from WaveformTimeline/Controls/Timeline/TimelineTickLocation.cs rename to WaveformTimeline.Shared/Controls/Timeline/TimelineTickLocation.cs diff --git a/WaveformTimeline/Controls/Timeline/TimelineType.cs b/WaveformTimeline.Shared/Controls/Timeline/TimelineType.cs similarity index 100% rename from WaveformTimeline/Controls/Timeline/TimelineType.cs rename to WaveformTimeline.Shared/Controls/Timeline/TimelineType.cs diff --git a/WaveformTimeline/Controls/Waveform/CachedWaveformObservable.cs b/WaveformTimeline.Shared/Controls/Waveform/CachedWaveformObservable.cs similarity index 100% rename from WaveformTimeline/Controls/Waveform/CachedWaveformObservable.cs rename to WaveformTimeline.Shared/Controls/Waveform/CachedWaveformObservable.cs diff --git a/WaveformTimeline/Controls/Waveform/WaveformSection.cs b/WaveformTimeline.Shared/Controls/Waveform/WaveformSection.cs similarity index 100% rename from WaveformTimeline/Controls/Waveform/WaveformSection.cs rename to WaveformTimeline.Shared/Controls/Waveform/WaveformSection.cs diff --git a/WaveformTimeline/Primitives/Even.cs b/WaveformTimeline.Shared/Primitives/Even.cs similarity index 100% rename from WaveformTimeline/Primitives/Even.cs rename to WaveformTimeline.Shared/Primitives/Even.cs diff --git a/WaveformTimeline/Primitives/FiniteDouble.cs b/WaveformTimeline.Shared/Primitives/FiniteDouble.cs similarity index 100% rename from WaveformTimeline/Primitives/FiniteDouble.cs rename to WaveformTimeline.Shared/Primitives/FiniteDouble.cs diff --git a/WaveformTimeline/Primitives/TypeConverters.cs b/WaveformTimeline.Shared/Primitives/TypeConverters.cs similarity index 100% rename from WaveformTimeline/Primitives/TypeConverters.cs rename to WaveformTimeline.Shared/Primitives/TypeConverters.cs diff --git a/WaveformTimeline/Primitives/ZeroToOne.cs b/WaveformTimeline.Shared/Primitives/ZeroToOne.cs similarity index 100% rename from WaveformTimeline/Primitives/ZeroToOne.cs rename to WaveformTimeline.Shared/Primitives/ZeroToOne.cs diff --git a/WaveformTimeline.Shared/Properties/AssemblyInfo.cs b/WaveformTimeline.Shared/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..f347770 --- /dev/null +++ b/WaveformTimeline.Shared/Properties/AssemblyInfo.cs @@ -0,0 +1,5 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("WaveformTimeline")] +[assembly: InternalsVisibleTo("WaveformTimeline.Avalonia")] +[assembly: InternalsVisibleTo("WaveformTimelineTests")] diff --git a/WaveformTimeline.Shared/WaveformTimeline.Shared.csproj b/WaveformTimeline.Shared/WaveformTimeline.Shared.csproj new file mode 100644 index 0000000..e53cfb7 --- /dev/null +++ b/WaveformTimeline.Shared/WaveformTimeline.Shared.csproj @@ -0,0 +1,16 @@ + + + net10.0 + latest + WaveformTimeline + WaveformTimeline.Shared + + + + 5.0.0 + + + 5.0.0 + + + diff --git a/WaveformTimeline.sln b/WaveformTimeline.sln index cd7520a..d463d73 100644 --- a/WaveformTimeline.sln +++ b/WaveformTimeline.sln @@ -17,24 +17,94 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution README.md = README.md EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WaveformTimeline.Shared", "WaveformTimeline.Shared\WaveformTimeline.Shared.csproj", "{716974EF-0E34-4E8C-8146-02B761E0F3C9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WaveformTimeline.Avalonia", "WaveformTimeline.Avalonia\WaveformTimeline.Avalonia.csproj", "{CD7561A3-26CA-4B81-834E-78797F1A633C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WaveformTimelineDemo.Avalonia", "WaveformTimelineDemo.Avalonia\WaveformTimelineDemo.Avalonia.csproj", "{E3A1B2C4-5D6E-7F80-9A1B-2C3D4E5F6A7B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {893E3694-AA6F-4E39-B5E3-E95798445B8C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {893E3694-AA6F-4E39-B5E3-E95798445B8C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {893E3694-AA6F-4E39-B5E3-E95798445B8C}.Debug|x64.ActiveCfg = Debug|Any CPU + {893E3694-AA6F-4E39-B5E3-E95798445B8C}.Debug|x64.Build.0 = Debug|Any CPU + {893E3694-AA6F-4E39-B5E3-E95798445B8C}.Debug|x86.ActiveCfg = Debug|Any CPU + {893E3694-AA6F-4E39-B5E3-E95798445B8C}.Debug|x86.Build.0 = Debug|Any CPU {893E3694-AA6F-4E39-B5E3-E95798445B8C}.Release|Any CPU.ActiveCfg = Release|Any CPU {893E3694-AA6F-4E39-B5E3-E95798445B8C}.Release|Any CPU.Build.0 = Release|Any CPU + {893E3694-AA6F-4E39-B5E3-E95798445B8C}.Release|x64.ActiveCfg = Release|Any CPU + {893E3694-AA6F-4E39-B5E3-E95798445B8C}.Release|x64.Build.0 = Release|Any CPU + {893E3694-AA6F-4E39-B5E3-E95798445B8C}.Release|x86.ActiveCfg = Release|Any CPU + {893E3694-AA6F-4E39-B5E3-E95798445B8C}.Release|x86.Build.0 = Release|Any CPU {32C97BC8-5BC0-48FA-9E21-E42A49C056CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {32C97BC8-5BC0-48FA-9E21-E42A49C056CB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {32C97BC8-5BC0-48FA-9E21-E42A49C056CB}.Debug|x64.ActiveCfg = Debug|Any CPU + {32C97BC8-5BC0-48FA-9E21-E42A49C056CB}.Debug|x64.Build.0 = Debug|Any CPU + {32C97BC8-5BC0-48FA-9E21-E42A49C056CB}.Debug|x86.ActiveCfg = Debug|Any CPU + {32C97BC8-5BC0-48FA-9E21-E42A49C056CB}.Debug|x86.Build.0 = Debug|Any CPU {32C97BC8-5BC0-48FA-9E21-E42A49C056CB}.Release|Any CPU.ActiveCfg = Release|Any CPU {32C97BC8-5BC0-48FA-9E21-E42A49C056CB}.Release|Any CPU.Build.0 = Release|Any CPU + {32C97BC8-5BC0-48FA-9E21-E42A49C056CB}.Release|x64.ActiveCfg = Release|Any CPU + {32C97BC8-5BC0-48FA-9E21-E42A49C056CB}.Release|x64.Build.0 = Release|Any CPU + {32C97BC8-5BC0-48FA-9E21-E42A49C056CB}.Release|x86.ActiveCfg = Release|Any CPU + {32C97BC8-5BC0-48FA-9E21-E42A49C056CB}.Release|x86.Build.0 = Release|Any CPU {0471122F-5571-45EA-B65D-41AB949F7821}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0471122F-5571-45EA-B65D-41AB949F7821}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0471122F-5571-45EA-B65D-41AB949F7821}.Debug|x64.ActiveCfg = Debug|Any CPU + {0471122F-5571-45EA-B65D-41AB949F7821}.Debug|x64.Build.0 = Debug|Any CPU + {0471122F-5571-45EA-B65D-41AB949F7821}.Debug|x86.ActiveCfg = Debug|Any CPU + {0471122F-5571-45EA-B65D-41AB949F7821}.Debug|x86.Build.0 = Debug|Any CPU {0471122F-5571-45EA-B65D-41AB949F7821}.Release|Any CPU.ActiveCfg = Release|Any CPU {0471122F-5571-45EA-B65D-41AB949F7821}.Release|Any CPU.Build.0 = Release|Any CPU + {0471122F-5571-45EA-B65D-41AB949F7821}.Release|x64.ActiveCfg = Release|Any CPU + {0471122F-5571-45EA-B65D-41AB949F7821}.Release|x64.Build.0 = Release|Any CPU + {0471122F-5571-45EA-B65D-41AB949F7821}.Release|x86.ActiveCfg = Release|Any CPU + {0471122F-5571-45EA-B65D-41AB949F7821}.Release|x86.Build.0 = Release|Any CPU + {716974EF-0E34-4E8C-8146-02B761E0F3C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {716974EF-0E34-4E8C-8146-02B761E0F3C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {716974EF-0E34-4E8C-8146-02B761E0F3C9}.Debug|x64.ActiveCfg = Debug|Any CPU + {716974EF-0E34-4E8C-8146-02B761E0F3C9}.Debug|x64.Build.0 = Debug|Any CPU + {716974EF-0E34-4E8C-8146-02B761E0F3C9}.Debug|x86.ActiveCfg = Debug|Any CPU + {716974EF-0E34-4E8C-8146-02B761E0F3C9}.Debug|x86.Build.0 = Debug|Any CPU + {716974EF-0E34-4E8C-8146-02B761E0F3C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {716974EF-0E34-4E8C-8146-02B761E0F3C9}.Release|Any CPU.Build.0 = Release|Any CPU + {716974EF-0E34-4E8C-8146-02B761E0F3C9}.Release|x64.ActiveCfg = Release|Any CPU + {716974EF-0E34-4E8C-8146-02B761E0F3C9}.Release|x64.Build.0 = Release|Any CPU + {716974EF-0E34-4E8C-8146-02B761E0F3C9}.Release|x86.ActiveCfg = Release|Any CPU + {716974EF-0E34-4E8C-8146-02B761E0F3C9}.Release|x86.Build.0 = Release|Any CPU + {CD7561A3-26CA-4B81-834E-78797F1A633C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CD7561A3-26CA-4B81-834E-78797F1A633C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CD7561A3-26CA-4B81-834E-78797F1A633C}.Debug|x64.ActiveCfg = Debug|Any CPU + {CD7561A3-26CA-4B81-834E-78797F1A633C}.Debug|x64.Build.0 = Debug|Any CPU + {CD7561A3-26CA-4B81-834E-78797F1A633C}.Debug|x86.ActiveCfg = Debug|Any CPU + {CD7561A3-26CA-4B81-834E-78797F1A633C}.Debug|x86.Build.0 = Debug|Any CPU + {CD7561A3-26CA-4B81-834E-78797F1A633C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CD7561A3-26CA-4B81-834E-78797F1A633C}.Release|Any CPU.Build.0 = Release|Any CPU + {CD7561A3-26CA-4B81-834E-78797F1A633C}.Release|x64.ActiveCfg = Release|Any CPU + {CD7561A3-26CA-4B81-834E-78797F1A633C}.Release|x64.Build.0 = Release|Any CPU + {CD7561A3-26CA-4B81-834E-78797F1A633C}.Release|x86.ActiveCfg = Release|Any CPU + {CD7561A3-26CA-4B81-834E-78797F1A633C}.Release|x86.Build.0 = Release|Any CPU + {E3A1B2C4-5D6E-7F80-9A1B-2C3D4E5F6A7B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E3A1B2C4-5D6E-7F80-9A1B-2C3D4E5F6A7B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E3A1B2C4-5D6E-7F80-9A1B-2C3D4E5F6A7B}.Debug|x64.ActiveCfg = Debug|Any CPU + {E3A1B2C4-5D6E-7F80-9A1B-2C3D4E5F6A7B}.Debug|x64.Build.0 = Debug|Any CPU + {E3A1B2C4-5D6E-7F80-9A1B-2C3D4E5F6A7B}.Debug|x86.ActiveCfg = Debug|Any CPU + {E3A1B2C4-5D6E-7F80-9A1B-2C3D4E5F6A7B}.Debug|x86.Build.0 = Debug|Any CPU + {E3A1B2C4-5D6E-7F80-9A1B-2C3D4E5F6A7B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E3A1B2C4-5D6E-7F80-9A1B-2C3D4E5F6A7B}.Release|Any CPU.Build.0 = Release|Any CPU + {E3A1B2C4-5D6E-7F80-9A1B-2C3D4E5F6A7B}.Release|x64.ActiveCfg = Release|Any CPU + {E3A1B2C4-5D6E-7F80-9A1B-2C3D4E5F6A7B}.Release|x64.Build.0 = Release|Any CPU + {E3A1B2C4-5D6E-7F80-9A1B-2C3D4E5F6A7B}.Release|x86.ActiveCfg = Release|Any CPU + {E3A1B2C4-5D6E-7F80-9A1B-2C3D4E5F6A7B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/WaveformTimeline/WaveformTimeline.csproj b/WaveformTimeline/WaveformTimeline.csproj index 92b74a9..632a32d 100644 --- a/WaveformTimeline/WaveformTimeline.csproj +++ b/WaveformTimeline/WaveformTimeline.csproj @@ -30,11 +30,6 @@ - - 5.0.0 - - - 5.0.0 - + diff --git a/WaveformTimelineDemo.Avalonia/App.axaml b/WaveformTimelineDemo.Avalonia/App.axaml new file mode 100644 index 0000000..d4300ae --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/App.axaml @@ -0,0 +1,9 @@ + + + + + + diff --git a/WaveformTimelineDemo.Avalonia/App.axaml.cs b/WaveformTimelineDemo.Avalonia/App.axaml.cs new file mode 100644 index 0000000..ebee16a --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/App.axaml.cs @@ -0,0 +1,22 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; + +namespace WaveformTimelineDemo.Avalonia; + +public class App : Application +{ + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + desktop.MainWindow = new MainWindow(); + } + base.OnFrameworkInitializationCompleted(); + } +} diff --git a/WaveformTimelineDemo.Avalonia/Audio/AudioWaveform.cs b/WaveformTimelineDemo.Avalonia/Audio/AudioWaveform.cs new file mode 100644 index 0000000..3441783 --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/Audio/AudioWaveform.cs @@ -0,0 +1,103 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using NAudio.Wave; + +namespace WaveformTimelineDemo.Avalonia.Audio; + +internal sealed class AudioWaveform : IDisposable, IAudioWaveform +{ + public AudioWaveform(string uri) : this(uri, FFTDataSize.FFT1024) + { + } + + public AudioWaveform(string uri, FFTDataSize fftDataSize) + { + _fftDataSize = (int)fftDataSize; + Uri = uri; + } + + private readonly int _fftDataSize; + private bool _disposed; + + private string Uri { get; } + private const int NumChannels = 2; + + public IEnumerable Waveform(int resolution) + { + var audioReader = Reader(); + var waveformInputStream = SampleNotifier(audioReader); + var sampleAggregator = SampleAggregator(_fftDataSize); + void SampleReceiver(SampleEventArgs e) => sampleAggregator.Add(e.Left, e.Right); + using (SampleNotifications(waveformInputStream, SampleReceiver)) + { + var waveformLength = (int)((double)waveformInputStream.Length / _fftDataSize) * NumChannels; + var readBuffer = new byte[_fftDataSize]; + var maxLeftPointLevel = float.MinValue; + var maxRightPointLevel = float.MinValue; + + var currentPointIndex = 0; + var waveMaxPointIndexes = Enumerable.Range(1, resolution).Select(i => + (int)Math.Round(waveformLength * (i / (double)resolution), 0)) + .ToList(); + var readCount = 0; + while (currentPointIndex * 2 < resolution) + { + var bytesRead = waveformInputStream.Read(readBuffer, 0, readBuffer.Length); + if (bytesRead <= 0) + break; + + if (sampleAggregator.LeftMaxVolume > maxLeftPointLevel) + maxLeftPointLevel = sampleAggregator.LeftMaxVolume; + if (sampleAggregator.RightMaxVolume > maxRightPointLevel) + maxRightPointLevel = sampleAggregator.RightMaxVolume; + + if (readCount > waveMaxPointIndexes[currentPointIndex]) + { + yield return maxLeftPointLevel; + yield return maxRightPointLevel; + maxLeftPointLevel = float.MinValue; + maxRightPointLevel = float.MinValue; + currentPointIndex++; + } + + readCount++; + if (_disposed) break; + } + } + + sampleAggregator.Clear(); + } + + private IDisposable SampleNotifications(ISampleNotifier waveformInputStream, + Action onNotified) => + Observable.Create(o => + { + EventHandler h = (_, e) => o.OnNext(e); + waveformInputStream.Sample += h; + return Disposable.Create(() => waveformInputStream.Sample -= h); + }).Subscribe(onNotified); + + private MediaFoundationReader Reader() => new(Uri); + private SampleNotifier SampleNotifier(WaveStream source) => new(source); + private SampleAggregator SampleAggregator(int fftDataSize) => new(fftDataSize); + + public void Dispose() + { + _disposed = true; + } +} + +internal enum FFTDataSize +{ + FFT256 = 256, + FFT512 = 512, + FFT1024 = 1024, + FFT2048 = 2048, + FFT4096 = 4096, + FFT8192 = 8192, + FFT16384 = 16384 +} diff --git a/WaveformTimelineDemo.Avalonia/Audio/IAudioWaveform.cs b/WaveformTimelineDemo.Avalonia/Audio/IAudioWaveform.cs new file mode 100644 index 0000000..68519fe --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/Audio/IAudioWaveform.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace WaveformTimelineDemo.Avalonia.Audio; + +internal interface IAudioWaveform +{ + IEnumerable Waveform(int resolution); +} diff --git a/WaveformTimelineDemo.Avalonia/Audio/ICombiPlayer.cs b/WaveformTimelineDemo.Avalonia/Audio/ICombiPlayer.cs new file mode 100644 index 0000000..a0a6170 --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/Audio/ICombiPlayer.cs @@ -0,0 +1,7 @@ +using WaveformTimeline.Contracts; + +namespace WaveformTimelineDemo.Avalonia.Audio; + +public interface ICombiPlayer : ITune, IPlayer +{ +} diff --git a/WaveformTimelineDemo.Avalonia/Audio/IPlayer.cs b/WaveformTimelineDemo.Avalonia/Audio/IPlayer.cs new file mode 100644 index 0000000..b7ec2c3 --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/Audio/IPlayer.cs @@ -0,0 +1,16 @@ +using System; + +namespace WaveformTimelineDemo.Avalonia.Audio; + +public interface IPlayer +{ + bool IsPlaying(); + bool IsPaused(); + void Play(); + void Pause(); + void Resume(); + void Stop(); + void Seek(TimeSpan desiredTime); + TimeSpan CurrentTime(); + TimeSpan TotalTime(); +} diff --git a/WaveformTimelineDemo.Avalonia/Audio/ISampleAggregator.cs b/WaveformTimelineDemo.Avalonia/Audio/ISampleAggregator.cs new file mode 100644 index 0000000..02a524f --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/Audio/ISampleAggregator.cs @@ -0,0 +1,12 @@ +namespace WaveformTimelineDemo.Avalonia.Audio; + +internal interface ISampleAggregator +{ + float LeftMaxVolume { get; } + float LeftMinVolume { get; } + float RightMaxVolume { get; } + float RightMinVolume { get; } + void Add(float leftValue, float rightValue); + void GetFFTResults(float[] fftBuffer); + void Clear(); +} diff --git a/WaveformTimelineDemo.Avalonia/Audio/Metadata.cs b/WaveformTimelineDemo.Avalonia/Audio/Metadata.cs new file mode 100644 index 0000000..4f25bdf --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/Audio/Metadata.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Linq; +using TagLib; + +namespace WaveformTimelineDemo.Avalonia.Audio; + +internal class Metadata +{ + public Metadata(string uri) + { + var file = File.Create(new File.LocalFileAbstraction(uri)); + var tag = MaybeTag(file); + _title = tag?.Title ?? string.Empty; + } + + private static readonly IList TagTypesPref = new List + { + TagTypes.Id3v2, TagTypes.Id3v1, + TagTypes.Apple, TagTypes.FlacMetadata, + TagTypes.Xiph + }; + + public static Tag? MaybeTag(File file) + { + var pref = TagTypesPref.FirstOrDefault(tt => file.TagTypes.HasFlag(tt)); + return file.GetTag(pref); + } + + private readonly string _title; + public string Title() => _title; +} diff --git a/WaveformTimelineDemo.Avalonia/Audio/NullPlayer.cs b/WaveformTimelineDemo.Avalonia/Audio/NullPlayer.cs new file mode 100644 index 0000000..0c9146d --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/Audio/NullPlayer.cs @@ -0,0 +1,44 @@ +#nullable enable +using System; +using System.Reactive.Disposables; +using WaveformTimeline.Contracts; + +namespace WaveformTimelineDemo.Avalonia.Audio; + +internal class NullPlayer : ICombiPlayer +{ + void IPlayer.Seek(TimeSpan desiredTime) { } + TimeSpan IPlayer.CurrentTime() => TimeSpan.Zero; + TimeSpan IPlayer.TotalTime() => TimeSpan.Zero; + TimeSpan ITimedPlayback.CurrentTime() => TimeSpan.Zero; + TimeSpan ITimedPlayback.TotalTime() => TimeSpan.Zero; + + public TimeSpan Duration() => TimeSpan.Zero; + public string Name() => string.Empty; + public byte[] WaveformData() => Array.Empty(); + + public IAudioWaveformStream WaveformStream() => new DummyWaveformObservable(); + + public double[] Cues() => Array.Empty(); + public double Tempo() => 0; + public bool PlaybackOn() => false; + public bool IsPlaying() => false; + public bool IsPaused() => false; + public void Play() { } + public void Pause() { } + public void Resume() { } + public void Stop() { } + void ITune.Seek(TimeSpan position) { } + public void TrimStart(TimeSpan start) { } + public void TrimEnd(TimeSpan end) { } + + public event EventHandler? Transitioned; + public event EventHandler? TempoShifted; + public event EventHandler? CuesChanged; + + private class DummyWaveformObservable : IAudioWaveformStream + { + public IDisposable Subscribe(IObserver observer) => Disposable.Create(() => { }); + public void Waveform(int resolution) { } + } +} diff --git a/WaveformTimelineDemo.Avalonia/Audio/Player.cs b/WaveformTimelineDemo.Avalonia/Audio/Player.cs new file mode 100644 index 0000000..2613c21 --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/Audio/Player.cs @@ -0,0 +1,51 @@ +#nullable enable +using System; +using NAudio.Wave; + +namespace WaveformTimelineDemo.Avalonia.Audio; + +internal class Player : IPlayer +{ + public Player(string uri) + { + _reader = new MediaFoundationReader(uri); + } + + private readonly MediaFoundationReader _reader; + private WaveOutEvent? _device; + + public bool IsPlaying() => _device?.PlaybackState == PlaybackState.Playing; + public bool IsPaused() => _device?.PlaybackState == PlaybackState.Paused; + + public void Play() + { + if (IsPaused()) + { + Resume(); + return; + } + _device = new WaveOutEvent { DeviceNumber = 0, DesiredLatency = 100 }; + _device.Init(_reader); + _device.Play(); + } + + public void Pause() => _device?.Pause(); + + public void Resume() => _device?.Play(); + + public void Stop() + { + _device?.Stop(); + _device?.Dispose(); + _reader?.Dispose(); + } + + public void Seek(TimeSpan desiredTime) + { + _reader.CurrentTime = desiredTime; + } + + public TimeSpan CurrentTime() => _reader?.CurrentTime ?? TimeSpan.Zero; + + public TimeSpan TotalTime() => _reader?.TotalTime ?? TimeSpan.Zero; +} diff --git a/WaveformTimelineDemo.Avalonia/Audio/SampleAggregator.cs b/WaveformTimelineDemo.Avalonia/Audio/SampleAggregator.cs new file mode 100644 index 0000000..e1ba0ea --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/Audio/SampleAggregator.cs @@ -0,0 +1,75 @@ +using System; +using System.Diagnostics; +using NAudio.Dsp; + +namespace WaveformTimelineDemo.Avalonia.Audio; + +internal class SampleAggregator : ISampleAggregator +{ + private readonly Complex[] _channelData; + private readonly int _bufferSize; + private readonly int _binaryExponentitation; + protected float volumeLeftMaxValue; + protected float volumeLeftMinValue; + protected float volumeRightMaxValue; + protected float volumeRightMinValue; + protected int channelDataPosition; + + public SampleAggregator(int bufferSize) + { + _bufferSize = bufferSize; + _binaryExponentitation = (int)Math.Log(bufferSize, 2); + _channelData = new Complex[bufferSize]; + } + + public float LeftMaxVolume => volumeLeftMaxValue; + public float LeftMinVolume => volumeLeftMinValue; + public float RightMaxVolume => volumeRightMaxValue; + public float RightMinVolume => volumeRightMinValue; + + public void Add(float leftValue, float rightValue) + { + if (channelDataPosition == 0) + { + volumeLeftMaxValue = float.MinValue; + volumeRightMaxValue = float.MinValue; + volumeLeftMinValue = float.MaxValue; + volumeRightMinValue = float.MaxValue; + } + + Debug.Assert(channelDataPosition < _channelData.Length); + _channelData[channelDataPosition].X = (leftValue + rightValue) / 2.0f; + _channelData[channelDataPosition].Y = 0; + channelDataPosition++; + + volumeLeftMaxValue = Math.Max(volumeLeftMaxValue, leftValue); + volumeLeftMinValue = Math.Min(volumeLeftMinValue, leftValue); + volumeRightMaxValue = Math.Max(volumeRightMaxValue, rightValue); + volumeRightMinValue = Math.Min(volumeRightMinValue, rightValue); + + if (channelDataPosition >= _channelData.Length) + { + channelDataPosition = 0; + } + } + + public void GetFFTResults(float[] fftBuffer) + { + Complex[] channelDataClone = new Complex[_bufferSize]; + _channelData.CopyTo(channelDataClone, 0); + FastFourierTransform.FFT(true, _binaryExponentitation, channelDataClone); + for (int i = 0; i < channelDataClone.Length / 2; i++) + { + fftBuffer[i] = (float)Math.Sqrt(channelDataClone[i].X * channelDataClone[i].X + channelDataClone[i].Y * channelDataClone[i].Y); + } + } + + public void Clear() + { + volumeLeftMaxValue = float.MinValue; + volumeRightMaxValue = float.MinValue; + volumeLeftMinValue = float.MaxValue; + volumeRightMinValue = float.MaxValue; + channelDataPosition = 0; + } +} diff --git a/WaveformTimelineDemo.Avalonia/Audio/SampleNotifier.cs b/WaveformTimelineDemo.Avalonia/Audio/SampleNotifier.cs new file mode 100644 index 0000000..8145c4e --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/Audio/SampleNotifier.cs @@ -0,0 +1,43 @@ +#nullable enable +using System; +using System.Reactive.Disposables; +using System.Reactive.Linq; +using NAudio.Wave; + +namespace WaveformTimelineDemo.Avalonia.Audio; + +internal class SampleNotifier : WaveStream, ISampleNotifier, IDisposable +{ + public SampleNotifier(WaveStream source) + { + _source = source; + _waveChannel32 = new WaveChannel32(source); + Observable.Create(o => + { + EventHandler h = (_, e) => o.OnNext(e); + _waveChannel32.Sample += h; + return Disposable.Create(() => _waveChannel32.Sample -= h); + }).Subscribe(OnSampleObserved); + } + + private readonly WaveStream _source; + private readonly WaveChannel32 _waveChannel32; + + public override int Read(byte[] buffer, int offset, int count) => + _waveChannel32.Read(buffer, offset, count); + + public override WaveFormat WaveFormat => _waveChannel32.WaveFormat; + public override long Length => (int)_waveChannel32.Length; + public override long Position { get; set; } + + private void OnSampleObserved(SampleEventArgs e) => + Sample?.Invoke(this, new SampleEventArgs(e.Left, e.Right)); + + public event EventHandler? Sample; + + public new void Dispose() + { + _waveChannel32.Dispose(); + _source.Dispose(); + } +} diff --git a/WaveformTimelineDemo.Avalonia/Audio/StreamingAudioWaveform.cs b/WaveformTimelineDemo.Avalonia/Audio/StreamingAudioWaveform.cs new file mode 100644 index 0000000..57b2458 --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/Audio/StreamingAudioWaveform.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using WaveformTimeline.Contracts; + +namespace WaveformTimelineDemo.Avalonia.Audio; + +internal class StreamingAudioWaveform : IAudioWaveformStream +{ + public StreamingAudioWaveform(IAudioWaveform source) + { + _source = source; + } + + private readonly IAudioWaveform _source; + private readonly List> _observers = new(); + + public IDisposable Subscribe(IObserver observer) + { + if (!_observers.Contains(observer)) + { + _observers.Add(observer); + } + return new Unsubscriber(_observers, observer); + } + + private class Unsubscriber : IDisposable + { + private readonly List> _allObservers; + private readonly IObserver _observer; + + public Unsubscriber(List> allObservers, IObserver observer) + { + _allObservers = allObservers; + _observer = observer; + } + + public void Dispose() + { + if (_allObservers.Contains(_observer)) + _allObservers.Remove(_observer); + } + } + + public void Waveform(int resolution) + { + foreach (var maxVolume in _source.Waveform(resolution)) + { + if (_observers.Count <= 0) break; + foreach (var o in _observers) + o.OnNext(maxVolume); + } + foreach (var o in _observers.ToList()) + o.OnCompleted(); + _observers.Clear(); + } +} diff --git a/WaveformTimelineDemo.Avalonia/Audio/Tune.cs b/WaveformTimelineDemo.Avalonia/Audio/Tune.cs new file mode 100644 index 0000000..8f54d8f --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/Audio/Tune.cs @@ -0,0 +1,86 @@ +#nullable enable +using System; +using System.Collections.Concurrent; +using WaveformTimeline.Contracts; +using WaveformTimelineDemo.Avalonia.Toolbox; + +namespace WaveformTimelineDemo.Avalonia.Audio; + +internal class Tune : ICombiPlayer +{ + public Tune(string uri) + { + _uri = uri; + _meta = new Metadata(uri); + _player = new Player(uri); + } + + private readonly string _uri; + private readonly Metadata _meta; + private readonly IPlayer _player; + private readonly ConcurrentQueue _waveformFloats = new(); + private byte[] _waveformBytes = Array.Empty(); + private IDisposable? _waveformUpdated; + + public bool IsPlaying() => _player.IsPlaying(); + public bool IsPaused() => _player.IsPaused(); + public bool PlaybackOn() => _player.IsPlaying(); + + public void Play() + { + _player.Play(); + Transitioned?.Invoke(this, EventArgs.Empty); + } + + public void Pause() + { + _player.Pause(); + Transitioned?.Invoke(this, EventArgs.Empty); + } + + public void Resume() + { + _player.Resume(); + Transitioned?.Invoke(this, EventArgs.Empty); + } + + public void Stop() + { + _player.Stop(); + Transitioned?.Invoke(this, EventArgs.Empty); + } + + public TimeSpan CurrentTime() => _player.CurrentTime(); + public TimeSpan TotalTime() => _player.TotalTime(); + public TimeSpan Duration() => _player.TotalTime(); + public string Name() => _meta.Title() ?? string.Empty; + public byte[] WaveformData() => _waveformBytes; + + public IAudioWaveformStream WaveformStream() + { + var observable = new StreamingAudioWaveform(new AudioWaveform(_uri, FFTDataSize.FFT2048)); + _waveformUpdated = observable.Subscribe( + AppendWaveformPoint, + OnWaveformDataReady); + return observable; + } + + private void AppendWaveformPoint(float f) => _waveformFloats.Enqueue(f); + + private void OnWaveformDataReady() + { + var wfarr = _waveformFloats.ToArray(); + _waveformBytes = new FloatsAsBytes(wfarr).Bytes(); + _waveformUpdated?.Dispose(); + } + + public double[] Cues() => new double[] { 0, 1 }; + public double Tempo() => 100; + public void Seek(TimeSpan position) => _player.Seek(position); + public void TrimStart(TimeSpan start) { } + public void TrimEnd(TimeSpan end) { } + + public event EventHandler? Transitioned; + public event EventHandler? TempoShifted; + public event EventHandler? CuesChanged; +} diff --git a/WaveformTimelineDemo.Avalonia/MainWindow.axaml b/WaveformTimelineDemo.Avalonia/MainWindow.axaml new file mode 100644 index 0000000..29dff0b --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/MainWindow.axaml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + diff --git a/WaveformTimelineDemo.Avalonia/MainWindow.axaml.cs b/WaveformTimelineDemo.Avalonia/MainWindow.axaml.cs new file mode 100644 index 0000000..265bc98 --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/MainWindow.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia.Controls; + +namespace WaveformTimelineDemo.Avalonia; + +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + } +} diff --git a/WaveformTimelineDemo.Avalonia/Program.cs b/WaveformTimelineDemo.Avalonia/Program.cs new file mode 100644 index 0000000..29cca17 --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/Program.cs @@ -0,0 +1,15 @@ +using System; +using Avalonia; + +namespace WaveformTimelineDemo.Avalonia; + +internal static class Program +{ + [STAThread] + public static void Main(string[] args) => + BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); + + public static AppBuilder BuildAvaloniaApp() => + AppBuilder.Configure() + .UsePlatformDetect(); +} diff --git a/WaveformTimelineDemo.Avalonia/Toolbox/FloatsAsBytes.cs b/WaveformTimelineDemo.Avalonia/Toolbox/FloatsAsBytes.cs new file mode 100644 index 0000000..ec6c9f5 --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/Toolbox/FloatsAsBytes.cs @@ -0,0 +1,21 @@ +#nullable enable +using System; + +namespace WaveformTimelineDemo.Avalonia.Toolbox; + +internal class FloatsAsBytes +{ + public FloatsAsBytes(float[] floats) + { + _floats = floats ?? throw new ArgumentNullException(nameof(floats)); + } + + private readonly float[] _floats; + + public byte[] Bytes() + { + byte[] bd = new byte[_floats.Length * 4]; + Buffer.BlockCopy(_floats, 0, bd, 0, bd.Length); + return bd; + } +} diff --git a/WaveformTimelineDemo.Avalonia/Toolbox/RelayCommand.cs b/WaveformTimelineDemo.Avalonia/Toolbox/RelayCommand.cs new file mode 100644 index 0000000..87a4b77 --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/Toolbox/RelayCommand.cs @@ -0,0 +1,25 @@ +#nullable enable +using System; +using System.Windows.Input; + +namespace WaveformTimelineDemo.Avalonia.Toolbox; + +internal class RelayCommand : ICommand +{ + private readonly Action _execute; + private readonly Func? _canExecute; + + public RelayCommand(Action execute, Func? canExecute = null) + { + _execute = execute ?? throw new ArgumentNullException(nameof(execute)); + _canExecute = canExecute; + } + + public bool CanExecute(object? parameter) => _canExecute?.Invoke() ?? true; + + public void Execute(object? parameter) => _execute(); + + public event EventHandler? CanExecuteChanged; + + public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); +} diff --git a/WaveformTimelineDemo.Avalonia/Toolbox/StringWithPlaceholder.cs b/WaveformTimelineDemo.Avalonia/Toolbox/StringWithPlaceholder.cs new file mode 100644 index 0000000..2d10911 --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/Toolbox/StringWithPlaceholder.cs @@ -0,0 +1,19 @@ +namespace WaveformTimelineDemo.Avalonia.Toolbox; + +internal sealed class StringWithPlaceholder +{ + public StringWithPlaceholder(string value) : this(value, string.Empty) + { + } + + public StringWithPlaceholder(string value, string placeholder) + { + _value = value; + _placeholder = !string.IsNullOrEmpty(placeholder) ? placeholder : string.Empty; + } + + private readonly string _value; + private readonly string _placeholder; + + public string Value() => !string.IsNullOrEmpty(_value) ? _value : _placeholder; +} diff --git a/WaveformTimelineDemo.Avalonia/ViewModel.cs b/WaveformTimelineDemo.Avalonia/ViewModel.cs new file mode 100644 index 0000000..2cc54b3 --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/ViewModel.cs @@ -0,0 +1,101 @@ +#nullable enable +using System; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using System.Windows.Input; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; +using WaveformTimelineDemo.Avalonia.Audio; +using WaveformTimelineDemo.Avalonia.Toolbox; + +namespace WaveformTimelineDemo.Avalonia; + +public class ViewModel : INotifyPropertyChanged +{ + public ViewModel() + { + OpenFile = new RelayCommand(async () => await OpenFileCmd()); + Play = new RelayCommand(PlayCmd, () => _fileUri != string.Empty && !Tune.IsPlaying()); + Pause = new RelayCommand(PauseCmd, () => Tune.IsPlaying()); + Stop = new RelayCommand(StopCmd, () => Tune.IsPlaying() || Tune.IsPaused()); + } + + public ICombiPlayer Tune { get; private set; } = new NullPlayer(); + private string _fileUri = string.Empty; + + public ICommand OpenFile { get; } + public ICommand Play { get; } + public ICommand Pause { get; } + public ICommand Stop { get; } + public string Title => new StringWithPlaceholder(Tune.Name(), "No track").Value(); + + public event PropertyChangedEventHandler? PropertyChanged; + protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null) => + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + + private async Task OpenFileCmd() + { + var window = (Application.Current?.ApplicationLifetime as IClassicDesktopStyleApplicationLifetime)?.MainWindow; + if (window == null) return; + + var files = await window.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = "Select an audio file", + AllowMultiple = false, + FileTypeFilter = new[] + { + new FilePickerFileType("Audio files") { Patterns = new[] { "*.wav", "*.mp3", "*.m4a", "*.flac" } } + } + }); + + var file = files.FirstOrDefault(); + if (file == null) return; + + _fileUri = file.Path.LocalPath; + Tune = new Tune(_fileUri); + OnPropertyChanged(nameof(Title)); + OnPropertyChanged(nameof(Tune)); + RaiseCanExecuteChanged(); + } + + private void PlayCmd() + { + try + { + Tune.Play(); + OnPropertyChanged(nameof(Title)); + RaiseCanExecuteChanged(); + } + catch (Exception) + { + _fileUri = string.Empty; + } + } + + private void PauseCmd() + { + Tune.Pause(); + RaiseCanExecuteChanged(); + } + + private void StopCmd() + { + Tune.Stop(); + _fileUri = string.Empty; + Tune = new NullPlayer(); + OnPropertyChanged(nameof(Title)); + OnPropertyChanged(nameof(Tune)); + RaiseCanExecuteChanged(); + } + + private void RaiseCanExecuteChanged() + { + (Play as RelayCommand)?.RaiseCanExecuteChanged(); + (Pause as RelayCommand)?.RaiseCanExecuteChanged(); + (Stop as RelayCommand)?.RaiseCanExecuteChanged(); + } +} diff --git a/WaveformTimelineDemo.Avalonia/WaveformTimelineDemo.Avalonia.csproj b/WaveformTimelineDemo.Avalonia/WaveformTimelineDemo.Avalonia.csproj new file mode 100644 index 0000000..65ec332 --- /dev/null +++ b/WaveformTimelineDemo.Avalonia/WaveformTimelineDemo.Avalonia.csproj @@ -0,0 +1,22 @@ + + + net10.0-windows + latest + WinExe + enable + WaveformTimelineDemo.Avalonia + WaveformTimelineDemo.Avalonia + + + + + + + + + + + + + + diff --git a/WaveformTimelineTests/WaveformTimelineTests.csproj b/WaveformTimelineTests/WaveformTimelineTests.csproj index 476768a..d4992f3 100644 --- a/WaveformTimelineTests/WaveformTimelineTests.csproj +++ b/WaveformTimelineTests/WaveformTimelineTests.csproj @@ -1,13 +1,13 @@  - net10.0-windows + net10.0 latest WaveformTimelineTests WaveformTimelineTests false - +