diff --git a/Espera.Core/Song.cs b/Espera.Core/Song.cs index 1bb87023..aad6c42b 100644 --- a/Espera.Core/Song.cs +++ b/Espera.Core/Song.cs @@ -1,10 +1,10 @@ -using Espera.Network; -using Rareform.Validation; -using System; +using System; using System.ComponentModel; using System.Diagnostics; using System.Runtime.CompilerServices; using System.Threading.Tasks; +using Espera.Network; +using Rareform.Validation; namespace Espera.Core { @@ -14,7 +14,7 @@ public abstract class Song : IEquatable, INotifyPropertyChanged private bool isCorrupted; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The path of the song. /// The duration of the song. @@ -45,7 +45,9 @@ protected Song(string path, TimeSpan duration) public string Genre { get; set; } /// - /// A runtime identifier for interaction with the mobile API. + /// A runtime identifier that uniquely identifies this songs. + /// + /// This identifier changes at each startup, but is stable in a running instance of the application. /// public Guid Guid { get; private set; } @@ -56,6 +58,7 @@ protected Song(string path, TimeSpan duration) public bool IsCorrupted { get { return this.isCorrupted; } + set { if (this.isCorrupted != value) diff --git a/Espera.View/Espera.View.csproj b/Espera.View/Espera.View.csproj index 1352ac76..1a899490 100644 --- a/Espera.View/Espera.View.csproj +++ b/Espera.View/Espera.View.csproj @@ -74,6 +74,18 @@ ..\packages\DeltaCompressionDotNet.1.0.0\lib\net45\DeltaCompressionDotNet.PatchApi.dll + + ..\packages\DynamicData.4.3.1.1090\lib\net45\DynamicData.dll + True + + + ..\packages\DynamicData.4.3.1.1090\lib\net45\DynamicData.Plinq.dll + True + + + ..\packages\DynamicData.ReactiveUI.2.2.0.2007\lib\portable-net45+win+wpa81+wp80\DynamicData.ReactiveUI.dll + True + False ..\packages\Espera-Network.1.0.36\lib\portable-net45+monoandroid+wpa81\Espera.Network.dll diff --git a/Espera.View/SearchEngine.cs b/Espera.View/SearchEngine.cs index fe521454..8877f0c0 100644 --- a/Espera.View/SearchEngine.cs +++ b/Espera.View/SearchEngine.cs @@ -1,47 +1,48 @@ -using Espera.Core; -using Rareform.Validation; -using System; -using System.Collections.Generic; +using System; using System.Linq; +using Espera.Core; namespace Espera.View { - public static class SearchEngine + public static class StringExtensions { - /// - /// Filters the source by the specified search text. - /// - /// The songs to search. - /// The search text. - /// The filtered sequence of songs. - public static IEnumerable FilterSongs(this IEnumerable source, string searchText) + public static bool ContainsIgnoreCase(this string value, string other) { - if (searchText == null) - Throw.ArgumentNullException(() => searchText); + return value.IndexOf(other, StringComparison.InvariantCultureIgnoreCase) >= 0; + } + } - if (String.IsNullOrWhiteSpace(searchText)) - return source; + public class SearchEngine + { + private readonly string[] keywords; + private readonly bool passThrough; - IEnumerable keyWords = searchText.Split(' '); + public SearchEngine(string searchText) + { + if (String.IsNullOrWhiteSpace(searchText)) + { + this.passThrough = true; + return; + } - return source - .AsParallel() - .Where - ( - song => keyWords.All - ( - keyword => - song.Artist.ContainsIgnoreCase(keyword) || - song.Album.ContainsIgnoreCase(keyword) || - song.Genre.ContainsIgnoreCase(keyword) || - song.Title.ContainsIgnoreCase(keyword) - ) - ); + this.keywords = searchText.Split(' '); } - private static bool ContainsIgnoreCase(this string value, string other) + public bool Filter(Song song) { - return value.IndexOf(other, StringComparison.InvariantCultureIgnoreCase) >= 0; + if (this.passThrough) + { + return true; + } + + return this.keywords.All + ( + keyword => + song.Artist.ContainsIgnoreCase(keyword) || + song.Album.ContainsIgnoreCase(keyword) || + song.Genre.ContainsIgnoreCase(keyword) || + song.Title.ContainsIgnoreCase(keyword) + ); } } } \ No newline at end of file diff --git a/Espera.View/ViewModels/ArtistViewModel.cs b/Espera.View/ViewModels/ArtistViewModel.cs index 673305dc..bdb617e5 100644 --- a/Espera.View/ViewModels/ArtistViewModel.cs +++ b/Espera.View/ViewModels/ArtistViewModel.cs @@ -1,50 +1,43 @@ -using Espera.Core; -using ReactiveUI; -using Splat; -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Reactive.Linq; -using System.Reactive.Subjects; using System.Threading.Tasks; using System.Windows.Media.Imaging; +using Espera.Core; +using ReactiveUI; +using Splat; namespace Espera.View.ViewModels { - public sealed class ArtistViewModel : ReactiveObject, IComparable, IEquatable, IDisposable + public sealed class ArtistViewModel : ReactiveObject, IEquatable, IDisposable { private readonly ObservableAsPropertyHelper cover; private readonly int orderHint; - private readonly ReactiveList songs; /// /// The constructor. /// /// - /// + /// /// /// A hint that tells this instance which position it has in the artist list. This helps for /// priorizing the album cover loading. The higher the number, the earlier it is in the list /// (Think of a reversed sorted list). /// - public ArtistViewModel(string artistName, IEnumerable songs, int orderHint = 1) + public ArtistViewModel(string artistName, IObservable artworkKeys, int orderHint = 1) { - this.songs = new ReactiveList(); - this.orderHint = orderHint; - this.cover = this.songs.ItemsAdded.Select(x => x.WhenAnyValue(y => y.ArtworkKey)) - .Merge() + this.cover = artworkKeys .Where(x => x != null) .Distinct() // Ignore duplicate artworks - .Select(LoadArtworkAsync) + .Select(key => Observable.FromAsync(() => this.LoadArtworkAsync(key))) .Concat() .FirstOrDefaultAsync(pic => pic != null) .ToProperty(this, x => x.Cover); var connect = this.Cover; // Connect the property to the source observable immediately - this.UpdateSongs(songs); - this.Name = artistName; this.IsAllArtists = false; } @@ -64,46 +57,39 @@ public BitmapSource Cover public string Name { get; private set; } - public int CompareTo(ArtistViewModel other) + public void Dispose() { - if (this.IsAllArtists && other.IsAllArtists) + this.cover?.Dispose(); + } + + public bool Equals(ArtistViewModel other) + { + if (Object.ReferenceEquals(other, null)) { - return 0; + return false; } - if (this.IsAllArtists) + if (this.IsAllArtists && other.IsAllArtists) { - return -1; + return true; } - if (other.IsAllArtists) + if (this.IsAllArtists || other.IsAllArtists) { - return 1; + return false; } - return String.Compare(SortHelpers.RemoveArtistPrefixes(this.Name), SortHelpers.RemoveArtistPrefixes(other.Name), StringComparison.InvariantCultureIgnoreCase); + return this.Name.Equals(other.Name, StringComparison.InvariantCultureIgnoreCase); } - public void Dispose() - { - this.cover.Dispose(); - } - - public bool Equals(ArtistViewModel other) + public override bool Equals(object obj) { - return this.Name == other.Name; + return base.Equals(obj as ArtistViewModel); } - public void UpdateSongs(IEnumerable songs) + public override int GetHashCode() { - var songsToAdd = songs.Where(x => !this.songs.Contains(x)).ToList(); - - // Can't use AddRange here, ReactiveList resets the list on big changes and we don't get - // the add notification - foreach (LocalSong song in songsToAdd) - { - this.songs.Add(song); - } + return new { A = this.IsAllArtists, B = this.Name }.GetHashCode(); } private async Task LoadArtworkAsync(string key) @@ -136,5 +122,67 @@ private async Task LoadArtworkAsync(string key) return null; } } + + /// + /// A custom equality class for the artist grouping, until + /// https://github.com/RolandPheasant/DynamicData/issues/31 is resolved + /// + public class ArtistString : IEquatable + { + private readonly string artistName; + + public ArtistString(string artistName) + { + this.artistName = artistName; + } + + public static implicit operator ArtistString(string source) + { + return new ArtistString(source); + } + + public static implicit operator string(ArtistString source) + { + return source.artistName; + } + + public bool Equals(ArtistString other) + { + return StringComparer.InvariantCultureIgnoreCase.Equals(this.artistName, other.artistName); + } + + public override bool Equals(object obj) + { + return this.Equals(obj as ArtistString); + } + + public override int GetHashCode() + { + return StringComparer.InvariantCultureIgnoreCase.GetHashCode(this.artistName); + } + } + + public class Comparer : IComparer + { + public int Compare(ArtistViewModel x, ArtistViewModel y) + { + if (x.IsAllArtists && y.IsAllArtists) + { + return 0; + } + + if (x.IsAllArtists) + { + return -1; + } + + if (y.IsAllArtists) + { + return 1; + } + + return String.Compare(SortHelpers.RemoveArtistPrefixes(x.Name), SortHelpers.RemoveArtistPrefixes(y.Name), StringComparison.InvariantCultureIgnoreCase); + } + } } } \ No newline at end of file diff --git a/Espera.View/ViewModels/LocalViewModel.cs b/Espera.View/ViewModels/LocalViewModel.cs index c8c5174b..19c1ac62 100644 --- a/Espera.View/ViewModels/LocalViewModel.cs +++ b/Espera.View/ViewModels/LocalViewModel.cs @@ -4,25 +4,31 @@ using System.Reactive; using System.Reactive.Linq; using System.Reactive.Subjects; +using DynamicData; +using DynamicData.Binding; +using DynamicData.Controllers; +using DynamicData.Operators; +using DynamicData.ReactiveUI; using Espera.Core; using Espera.Core.Management; using Espera.Core.Settings; using Rareform.Validation; +using ReactiveMarrow; using ReactiveUI; namespace Espera.View.ViewModels { public class LocalViewModel : SongSourceViewModel { - private readonly ReactiveList allArtists; private readonly ArtistViewModel allArtistsViewModel; private readonly SortOrder artistOrder; + private readonly ReactiveList artists; private readonly Subject artistUpdateSignal; private readonly ObservableAsPropertyHelper isUpdating; private readonly ReactiveCommand playNowCommand; private readonly ObservableAsPropertyHelper showAddSongsHelperMessage; + private readonly ReactiveList songs; private readonly ViewSettings viewSettings; - private ILookup filteredSongs; private ArtistViewModel selectedArtist; public LocalViewModel(Library library, ViewSettings viewSettings, CoreSettings coreSettings, Guid accessToken) @@ -36,35 +42,75 @@ public LocalViewModel(Library library, ViewSettings viewSettings, CoreSettings c this.artistUpdateSignal = new Subject(); this.allArtistsViewModel = new ArtistViewModel("All Artists"); - this.allArtists = new ReactiveList { this.allArtistsViewModel }; - - this.Artists = this.allArtists.CreateDerivedCollection(x => x, - x => x.IsAllArtists || this.filteredSongs.Contains(x.Name), (x, y) => x.CompareTo(y), this.artistUpdateSignal); // We need a default sorting order this.ApplyOrder(SortHelpers.GetOrderByArtist, ref this.artistOrder); this.SelectedArtist = this.allArtistsViewModel; - var gate = new object(); + this.songs = new ReactiveList(); + this.SelectableSongs = this.songs; + this.artists = new ReactiveList(); + var songSource = new SourceCache(x => x.Guid); + + IObservableCache songsCache = songSource.Connect() + .Transform(x => new LocalSongViewModel(x)) + .DisposeMany() + .AsObservableCache(); + + IObservableCache artistsCache = songSource.Connect() + .Group(x => (ArtistViewModel.ArtistString)x.Artist) + .Transform(x => new ArtistViewModel(x.Key, x.Cache.Connect().WhereReasonsAre(ChangeReason.Add).Flatten().Select(y => y.Current.ArtworkKey))) + .DisposeMany() + .AsObservableCache(); + + IObservable> searchEngine = this.WhenAnyValue(x => x.SearchText) + .Select(searchText => new SearchEngine(searchText)) + .Select(engine => new Func(song => engine.Filter(song.Model))); + + IObservable> artistFilter = this.WhenAnyValue(x => x.SelectedArtist) + .Select(artist => new Func(song => artist.IsAllArtists || song.Artist.Equals(artist.Name, StringComparison.InvariantCultureIgnoreCase))); + + var filteredSource = songsCache.Connect() + .Filter(searchEngine) + .Publish() + .RefCount(); + + filteredSource + .Filter(artistFilter) + .Sort(SortExpressionComparer.Ascending(x => SortHelpers.RemoveArtistPrefixes(x.Artist)).ThenByAscending(x => x.Album).ThenByAscending(x => x.TrackNumber)) + .ObserveOn(RxApp.MainThreadScheduler) + .Bind(this.songs) + .DisposeMany() + .Subscribe(); + + var filteredArtistGrouping = filteredSource + .Group(x => x.Artist) + .Convert(x => x.Key) + .ToCollection() + .Select(x => new HashSet(x, StringComparer.InvariantCultureIgnoreCase)) + .Select(artists => new Func(artistViewModel => artists.Contains(artistViewModel.Name))); + + artistsCache.Connect() + .Filter(filteredArtistGrouping) + .StartWithItem(this.allArtistsViewModel, Guid.NewGuid().ToString()) + .Sort(new ArtistViewModel.Comparer(), SortOptimisations.ComparesImmutableValuesOnly) + .ObserveOn(RxApp.MainThreadScheduler) + .Bind(this.artists) + .DisposeMany() + .Subscribe(); + this.Library.SongsUpdated .Buffer(TimeSpan.FromSeconds(1), RxApp.TaskpoolScheduler) .Where(x => x.Any()) - .Select(_ => Unit.Default) - .Merge(this.WhenAny(x => x.SearchText, _ => Unit.Default) - .Do(_ => this.SelectedArtist = this.allArtistsViewModel)) - .Synchronize(gate) - .ObserveOn(RxApp.MainThreadScheduler) - .Subscribe(_ => + .ToUnit() + .StartWith(Unit.Default) + .Select(_ => this.Library.Songs) + .Subscribe(x => songSource.Edit(update => { - this.UpdateSelectableSongs(); - this.UpdateArtists(); - }); - - this.WhenAnyValue(x => x.SelectedArtist) - .Skip(1) - .Synchronize(gate) - .Subscribe(_ => this.UpdateSelectableSongs()); + update.Clear(); + update.AddOrUpdate(x); + })); this.playNowCommand = ReactiveCommand.CreateAsyncTask(this.Library.LocalAccessControl.ObserveAccessPermission(accessToken) .Select(x => x == AccessPermission.Admin || !coreSettings.LockPlayPause), _ => @@ -87,7 +133,7 @@ public LocalViewModel(Library library, ViewSettings viewSettings, CoreSettings c this.OpenTagEditor = ReactiveCommand.Create(this.WhenAnyValue(x => x.SelectedSongs, x => x.Any())); } - public IReactiveDerivedList Artists { get; private set; } + public IReadOnlyReactiveList Artists => this.artists; public override DefaultPlaybackAction DefaultPlaybackAction { @@ -121,6 +167,7 @@ public override ReactiveCommand PlayNowCommand public ArtistViewModel SelectedArtist { get { return this.selectedArtist; } + set { // We don't ever want the selected artist to be null @@ -138,92 +185,5 @@ public int TitleColumnWidth get { return this.viewSettings.LocalTitleColumnWidth; } set { this.viewSettings.LocalTitleColumnWidth = value; } } - - private void UpdateArtists() - { - var groupedByArtist = this.Library.Songs - .ToLookup(x => x.Artist, StringComparer.InvariantCultureIgnoreCase); - - List artistsToRemove = this.allArtists.Where(x => !groupedByArtist.Contains(x.Name)).ToList(); - artistsToRemove.Remove(this.allArtistsViewModel); - - this.allArtists.RemoveAll(artistsToRemove); - - foreach (ArtistViewModel artistViewModel in artistsToRemove) - { - artistViewModel.Dispose(); - } - - // We use this reverse ordered list of artists so we can priorize the loading of album - // covers of artists that we display first in the artist list. This way we can "fake" a - // fast loading of all covers, as the user doesn't see most of the artists down the - // list. The higher the number, the higher the prioritization. - List orderedArtists = groupedByArtist.Select(x => x.Key) - .OrderByDescending(SortHelpers.RemoveArtistPrefixes) - .ToList(); - - foreach (var songs in groupedByArtist) - { - ArtistViewModel model = this.allArtists.FirstOrDefault(x => x.Name.Equals(songs.Key, StringComparison.InvariantCultureIgnoreCase)); - - if (model == null) - { - int priority = orderedArtists.IndexOf(songs.Key) + 1; - this.allArtists.Add(new ArtistViewModel(songs.Key, songs, priority)); - } - - else - { - model.UpdateSongs(songs); - } - } - } - - private void UpdateSelectableSongs() - { - this.filteredSongs = this.Library.Songs.FilterSongs(this.SearchText) - .ToLookup(x => x.Artist, StringComparer.InvariantCultureIgnoreCase); - - var newArtists = new HashSet(this.filteredSongs.Select(x => x.Key)); - var oldArtists = this.Artists.Where(x => !x.IsAllArtists).Select(x => x.Name); - - if (!newArtists.SetEquals(oldArtists)) - { - this.artistUpdateSignal.OnNext(Unit.Default); - } - - List selectableSongs = this.filteredSongs - .Where(group => this.SelectedArtist.IsAllArtists || @group.Key.Equals(this.SelectedArtist.Name, StringComparison.InvariantCultureIgnoreCase)) - .SelectMany(x => x) - .Select(song => new LocalSongViewModel(song)) - .OrderBy(this.SongOrderFunc) - .ToList(); - - // Ignore redundant song updates. - if (!selectableSongs.SequenceEqual(this.SelectableSongs)) - { - // Scratch the old viewmodels - foreach (var viewModel in this.SelectableSongs) - { - viewModel.Dispose(); - } - - this.SelectableSongs = selectableSongs; - } - - else - { - // We don't have to update the selectable songs, get rid of the redundant ones we've created - foreach (LocalSongViewModel viewModel in selectableSongs) - { - viewModel.Dispose(); - } - } - - if (this.SelectedSongs == null) - { - this.SelectedSongs = this.SelectableSongs.Take(1).ToList(); - } - } } } \ No newline at end of file diff --git a/Espera.View/packages.config b/Espera.View/packages.config index ecf1c242..89b32f26 100644 --- a/Espera.View/packages.config +++ b/Espera.View/packages.config @@ -6,6 +6,8 @@ + +