// Copyright (c) Arlo Godfrey. All Rights Reserved. // Licensed under the GNU Lesser General Public License, Version 3.0 with additional terms. // See the LICENSE, LICENSE.LESSER and LICENSE.ADDITIONAL files in the project root for more information. using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using CommunityToolkit.Diagnostics; using OwlCore.Events; using OwlCore.Extensions; using StrixMusic.Sdk.AppModels; using StrixMusic.Sdk.BaseModels; using StrixMusic.Sdk.MediaPlayback.LocalDevice; namespace StrixMusic.Sdk.MediaPlayback { /// /// Manages an internal queue, handles playback, and delegates playback commands to an . /// public sealed partial class PlaybackHandlerService : IPlaybackHandlerService { private readonly Dictionary _audioPlayerRegistry = new(); private readonly List _prevItems = new(); private readonly List _nextItems = new(); private int[] _shuffleMap; private IAudioPlayerService? _currentPlayerService; private RepeatState _repeatState; private bool _shuffleState; private StrixDevice _localDevice; /// /// Creates a new instance of . /// public PlaybackHandlerService() { _shuffleMap = Array.Empty(); _localDevice = new StrixDevice(this); } private void AttachEvents(IAudioPlayerService audioPlayerService) { audioPlayerService.PositionChanged += PositionChanged; audioPlayerService.PlaybackSpeedChanged += PlaybackSpeedChanged; audioPlayerService.PlaybackStateChanged += CurrentPlayerService_PlaybackStateChanged; audioPlayerService.CurrentSourceChanged += CurrentPlayerService_CurrentSourceChanged; audioPlayerService.VolumeChanged += VolumeChanged; audioPlayerService.QuantumProcessed += QuantumProcessed; } private void DetachEvents(IAudioPlayerService audioPlayerService) { audioPlayerService.PositionChanged -= PositionChanged; audioPlayerService.PlaybackSpeedChanged -= PlaybackSpeedChanged; audioPlayerService.PlaybackStateChanged -= CurrentPlayerService_PlaybackStateChanged; audioPlayerService.CurrentSourceChanged -= CurrentPlayerService_CurrentSourceChanged; audioPlayerService.VolumeChanged -= VolumeChanged; audioPlayerService.QuantumProcessed -= QuantumProcessed; } private void CurrentPlayerService_CurrentSourceChanged(object sender, PlaybackItem? e) { CurrentItem = e; CurrentItemChanged?.Invoke(this, e); } private async void CurrentPlayerService_PlaybackStateChanged(object sender, PlaybackState e) { // Since the player itself can't be queued, we use this as a sentinel value for advancing the queue. if (e == PlaybackState.Loaded) { await AutoAdvanceQueue(); } PlaybackStateChanged?.Invoke(this, e); } private async Task AutoAdvanceQueue() { switch (_repeatState) { case RepeatState.All when NextItems.Count == 0: // Move all items from previous back into Next _nextItems.AddRange(_prevItems); _prevItems.Clear(); await PlayFromNext(0); return; case RepeatState.One: await NextAsync(); return; case RepeatState.None: await NextAsync(); break; default: await NextAsync(); break; } } /// public event EventHandler? RepeatStateChanged; /// public event EventHandler? ShuffleStateChanged; /// public event CollectionChangedEventHandler? NextItemsChanged; /// public event CollectionChangedEventHandler? PreviousItemsChanged; /// public event EventHandler? CurrentItemChanged; /// public event EventHandler? PositionChanged; /// public event EventHandler? PlaybackStateChanged; /// public event EventHandler? VolumeChanged; /// public event EventHandler? PlaybackSpeedChanged; /// public event EventHandler? QuantumProcessed; /// /// Gets or sets the device which is being currently being used for playback, if any. /// public IDevice? ActiveDevice { get; set; } /// /// Gets a device which represents all local playback done by this . /// public IDevice LocalDevice => _localDevice; /// public IReadOnlyList NextItems => _nextItems; /// public IReadOnlyCollection PreviousItems => _prevItems; /// public PlaybackItem? CurrentItem { get; internal set; } /// /// The collection which the is playing from. /// public IPlayableBase? CurrentItemContext { get; private set; } /// public bool ShuffleState => _shuffleState; /// public RepeatState RepeatState => _repeatState; /// public TimeSpan Position => _currentPlayerService?.Position ?? TimeSpan.Zero; /// public PlaybackState PlaybackState => _currentPlayerService?.PlaybackState ?? PlaybackState.None; /// public double Volume => _currentPlayerService?.Volume ?? 1; /// public double PlaybackSpeed => _currentPlayerService?.PlaybackSpeed ?? 1; /// public void RegisterAudioPlayer(IAudioPlayerService audioPlayer, string instanceId) => _audioPlayerRegistry.Add(instanceId, audioPlayer); /// public Task SeekAsync(TimeSpan position, CancellationToken cancellationToken = default) => _currentPlayerService?.SeekAsync(position, cancellationToken) ?? Task.CompletedTask; /// public Task ChangePlaybackSpeedAsync(double speed, CancellationToken cancellationToken = default) => _currentPlayerService?.ChangePlaybackSpeedAsync(speed, cancellationToken) ?? Task.CompletedTask; /// public Task ResumeAsync(CancellationToken cancellationToken = default) => _currentPlayerService?.ResumeAsync(cancellationToken) ?? Task.CompletedTask; /// public Task PauseAsync(CancellationToken cancellationToken = default) => _currentPlayerService?.PauseAsync(cancellationToken) ?? Task.CompletedTask; /// public Task ChangeVolumeAsync(double volume, CancellationToken cancellationToken = default) => _currentPlayerService?.ResumeAsync(cancellationToken) ?? Task.CompletedTask; /// public async Task PlayFromNext(int queueIndex, CancellationToken cancellationToken = default) { if (_currentPlayerService != null) { await _currentPlayerService.PauseAsync(cancellationToken); DetachEvents(_currentPlayerService); } var playbackItem = NextItems.ElementAtOrDefault(queueIndex); if (playbackItem is null) return; Guard.IsNotNull(playbackItem.MediaConfig, nameof(playbackItem.MediaConfig)); _currentPlayerService = _audioPlayerRegistry[playbackItem.MediaConfig.Track.SourceCore.InstanceId]; AttachEvents(_currentPlayerService); if (CurrentItem != null) _prevItems.Add(CurrentItem); CurrentItem = playbackItem; _nextItems.Remove(playbackItem); if (ActiveDevice == LocalDevice) { Guard.IsNotNull(playbackItem.MediaConfig, nameof(playbackItem.MediaConfig)); _localDevice.SetPlaybackData(CurrentItemContext, playbackItem); } await _currentPlayerService.Play(playbackItem, cancellationToken); } /// public async Task PlayFromPrevious(int queueIndex, CancellationToken cancellationToken = default) { Guard.IsNotNull(_currentPlayerService, nameof(_currentPlayerService)); var playbackItem = PreviousItems.ElementAtOrDefault(queueIndex); Guard.IsNotNull(playbackItem, nameof(playbackItem)); Guard.IsNotNull(playbackItem.MediaConfig, nameof(playbackItem.MediaConfig)); await _currentPlayerService.PauseAsync(cancellationToken); DetachEvents(_currentPlayerService); _currentPlayerService = _audioPlayerRegistry[playbackItem.MediaConfig.Track.SourceCore.InstanceId]; AttachEvents(_currentPlayerService); // TODO shift queue, move tracks after the played item into next await _currentPlayerService.Play(playbackItem, cancellationToken); } /// public async Task NextAsync(CancellationToken cancellationToken = default) { if (_currentPlayerService == null && CurrentItem != null) { Guard.IsNotNull(CurrentItem.MediaConfig, nameof(CurrentItem.MediaConfig)); _currentPlayerService = _audioPlayerRegistry[CurrentItem.MediaConfig.Track.SourceCore.InstanceId]; } Guard.IsNotNull(_currentPlayerService?.CurrentSource, nameof(_currentPlayerService.CurrentSource)); var nextIndex = 0; await _currentPlayerService.PauseAsync(cancellationToken); DetachEvents(_currentPlayerService); if (RepeatState == RepeatState.All && NextItems.Count == 0) { // Move all items from previous back into Next _nextItems.AddRange(_prevItems); _prevItems.Clear(); } if (NextItems.Count <= nextIndex) return; PlaybackItem? nextItem; if (RepeatState == RepeatState.One && CurrentItem is not null) { nextItem = CurrentItem; } else { nextItem = NextItems[nextIndex]; CurrentItem = nextItem; // Move NowPlaying into previous _prevItems.Add(_currentPlayerService.CurrentSource); // Take the next item out of the queue (becomes NowPlaying) _nextItems.Remove(nextItem); } var removedItems = new List>() { new(nextItem, nextIndex), }; var addedItems = Array.Empty>(); NextItemsChanged?.Invoke(this, addedItems, removedItems); var instanceId = CurrentItem?.MediaConfig?.Track.SourceCore.InstanceId; Guard.IsNotNull(instanceId, nameof(instanceId)); _currentPlayerService = _audioPlayerRegistry[instanceId]; AttachEvents(_currentPlayerService); await _currentPlayerService.Play(nextItem, cancellationToken); _currentPlayerService.CurrentSource = CurrentItem; CurrentItemChanged?.Invoke(this, nextItem); } /// public Task PreviousAsync(CancellationToken cancellationToken = default) => PreviousAsync(true); /// public void InsertNext(int index, PlaybackItem sourceConfig) { var addedItems = new List>() { new(sourceConfig, index), }; var removedItems = Array.Empty>(); _nextItems.InsertOrAdd(index, sourceConfig); // Handle case when the list is shuffled. if (_shuffleState) { var originalIndex = _prevItems.Count + index + (CurrentItem == null ? 0 : 1); // Needs to be converted to list because InsertOrAdd isn't supported on the fixed number of collection such as arrays. var shuffleList = _shuffleMap.ToList(); shuffleList.InsertOrAdd(originalIndex, originalIndex); _shuffleMap = shuffleList.ToArray(); for (int i = 0; i < _shuffleMap.Length; i++) { // Adjust the all indexes for all elements with the original index greater than or equal to the newly added index. if (_shuffleMap[i] >= originalIndex && i != originalIndex) _shuffleMap[i]++; } } NextItemsChanged?.Invoke(this, addedItems, removedItems); } /// public void RemoveNext(int index) { var removedItems = new List>() { new(NextItems[index], index), }; var addedItems = Array.Empty>(); _nextItems.RemoveAt(index); // Handle case when the list is shuffled. if (_shuffleState) { var indexInShuffledList = _prevItems.Count + index + (CurrentItem == null ? 0 : 1); var originalIndex = _shuffleMap[indexInShuffledList]; // Needs to be converted to list so we can remove an element from the array using the index. // After removing the element, we're decrementing all original indexes in the shufflemap greater than the original index of the removed element, so the tracks can be unshuffled correctly. var shuffleList = _shuffleMap.ToList(); shuffleList.RemoveAt(indexInShuffledList); _shuffleMap = shuffleList.ToArray(); for (int i = 0; i < _shuffleMap.Length; i++) { if (_shuffleMap[i] > originalIndex) _shuffleMap[i]--; } } NextItemsChanged?.Invoke(this, addedItems, removedItems); } /// public void ClearNext() => _nextItems.Clear(); /// public void PushPrevious(PlaybackItem sourceConfig) { var addedItems = new List>() { new(sourceConfig, PreviousItems.Count), }; var removedItems = Array.Empty>(); _prevItems.Add(sourceConfig); PreviousItemsChanged?.Invoke(this, addedItems, removedItems); } /// public PlaybackItem PopPrevious(int index) { var returnItem = _prevItems.Pop(); var addedItems = new List>() { new(returnItem, PreviousItems.Count), }; var removedItems = Array.Empty>(); PreviousItemsChanged?.Invoke(this, addedItems, removedItems); return returnItem; } /// public void ClearPrevious() => _prevItems.Clear(); private async Task PreviousAsync(bool shouldRemoveFromQueue) { if (_currentPlayerService == null && CurrentItem != null) { var instanceId = CurrentItem?.MediaConfig?.Track.SourceCore.InstanceId; Guard.IsNotNull(instanceId, nameof(instanceId)); _currentPlayerService = _audioPlayerRegistry[instanceId]; } Guard.IsNotNull(_currentPlayerService?.CurrentSource, nameof(_currentPlayerService.CurrentSource)); await _currentPlayerService.PauseAsync(); DetachEvents(_currentPlayerService); var currentItem = _currentPlayerService.CurrentSource; _nextItems.Insert(0, currentItem); var newItem = shouldRemoveFromQueue ? _prevItems.Pop() : _prevItems.Last(); var instId = CurrentItem?.MediaConfig?.Track.SourceCore.InstanceId; Guard.IsNotNull(instId, nameof(instId)); _currentPlayerService = _audioPlayerRegistry[instId]; AttachEvents(_currentPlayerService); await _currentPlayerService.Play(newItem); CurrentItem = newItem; CurrentItemChanged?.Invoke(this, newItem); } /// public Task ToggleShuffleAsync(CancellationToken cancellationToken = default) { _shuffleState = !_shuffleState; if (ShuffleState) ShuffleOnInternal(); else ShuffleOffInternal(); ShuffleStateChanged?.Invoke(this, _shuffleState); return Task.CompletedTask; } private void ShuffleOffInternal() { _shuffleState = false; if (_shuffleMap.Length == 0) return; var originalCurrentItemIndex = _shuffleMap[_prevItems.Count]; // The space complexity will remain O(n), because we are not cloning any list we are simply references same items each time. var unshuffledItems = new List(); unshuffledItems.AddRange(_prevItems); if (CurrentItem != null) unshuffledItems.Add(CurrentItem); unshuffledItems.AddRange(_nextItems); unshuffledItems.Unshuffle(_shuffleMap); _nextItems.Clear(); _prevItems.Clear(); // The time complexity will also remain remain at O(n). for (int i = 0; i < unshuffledItems.Count; i++) { if (i < originalCurrentItemIndex) { // Pushing everything before the originalCurrentItemIndex to previous items. _prevItems.Add(unshuffledItems[i]); } else if (i == originalCurrentItemIndex && CurrentItem != null) { // We will not add current item to the _nextItems if its already playing. // We will not set currentItem so the current running track remains unaffected. } else { // Pushing everything after the originalCurrentItemIndex to next Items. _nextItems.Add(unshuffledItems[i]); } } } private void ShuffleOnInternal() { _shuffleState = true; _shuffleMap = Array.Empty(); // This list is only used for shuffle purpose, at the end we will extract nextitems out of it. var list = new List(); list.AddRange(_prevItems); if (CurrentItem != null) list.Add(CurrentItem); list.AddRange(_nextItems); _shuffleMap = list.Shuffle(); // Swapping the items to make sure the CurrentItem and its original index in the map and the temp list is 0th index. var temp = _shuffleMap[0]; var tempItem = list[0]; var orignalCurrentIndex = _prevItems.Count; var newCurrentIndex = Array.IndexOf(_shuffleMap, orignalCurrentIndex); if (CurrentItem != null) { list[0] = CurrentItem; _shuffleMap[0] = orignalCurrentIndex; _shuffleMap[newCurrentIndex] = temp; list[newCurrentIndex] = tempItem; } // Populate everything in the list except the item at 0th index in nextItems because its the CurrentItem. _nextItems.Clear(); for (var i = 1; i < list.Count; i++) { _nextItems.Add(list[i]); } _prevItems.Clear(); } /// public Task SetRepeatStateAsync(RepeatState state, CancellationToken cancellationToken = default) { _repeatState = state; RepeatStateChanged?.Invoke(this, _repeatState); return Task.CompletedTask; } /// public Task ToggleRepeatAsync(CancellationToken cancellationToken = default) { _repeatState = _repeatState switch { RepeatState.None => RepeatState.One, RepeatState.One => RepeatState.All, RepeatState.All => RepeatState.None, _ => ThrowHelper.ThrowArgumentOutOfRangeException(nameof(RepeatState)), }; RepeatStateChanged?.Invoke(this, _repeatState); return Task.CompletedTask; } /// public void Dispose() { if (_currentPlayerService is not null) DetachEvents(_currentPlayerService); } } }