目录
[一 简介](#一 简介)
[二 设计思路](#二 设计思路)
[三 源码](#三 源码)
一 简介
支持在线检索音乐,支持实时浏览当前收藏的音乐及音乐数据的持久化。
二 设计思路
采用MVVM架构,前后端分离,子界面弹出始终位于主界面的中心。
三 源码
视窗引导启动源码:
cs
namespace Avalonia.MusicStore
{
public class ViewLocator : IDataTemplate
{
public Control? Build(object? data)
{
if (data is null)
return null;
var name = data.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal);
var type = Type.GetType(name);
if (type != null)
{
var control = (Control)Activator.CreateInstance(type)!;
control.DataContext = data;
return control;
}
return new TextBlock { Text = "Not Found: " + name };
}
public bool Match(object? data)
{
return data is ViewModelBase;
}
}
}
using Avalonia;
using Avalonia.ReactiveUI;
using System;
namespace Avalonia.MusicStore
{
internal sealed class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace()
.UseReactiveUI();
}
}
模型源码:
cs
using iTunesSearch.Library;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
namespace Avalonia.MusicStore.Models
{
public class Album
{
private static iTunesSearchManager s_SearchManager = new();
public string Artist { get; set; }
public string Title { get; set; }
public string CoverUrl { get; set; }
public Album(string artist, string title, string coverUrl)
{
Artist = artist;
Title = title;
CoverUrl = coverUrl;
}
public static async Task<IEnumerable<Album>> SearchAsync(string searchTerm)
{
var query = await s_SearchManager.GetAlbumsAsync(searchTerm)
.ConfigureAwait(false);
return query.Albums.Select(x =>
new Album(x.ArtistName, x.CollectionName,
x.ArtworkUrl100.Replace("100x100bb", "600x600bb")));
}
private static HttpClient s_httpClient = new();
private string CachePath => $"./Cache/{Artist} - {Title}";
public async Task<Stream> LoadCoverBitmapAsync()
{
if (File.Exists(CachePath + ".bmp"))
{
return File.OpenRead(CachePath + ".bmp");
}
else
{
var data = await s_httpClient.GetByteArrayAsync(CoverUrl);
return new MemoryStream(data);
}
}
public async Task SaveAsync()
{
if (!Directory.Exists("./Cache"))
{
Directory.CreateDirectory("./Cache");
}
using (var fs = File.OpenWrite(CachePath))
{
await SaveToStreamAsync(this, fs);
}
}
public Stream SaveCoverBitmapStream()
{
return File.OpenWrite(CachePath + ".bmp");
}
private static async Task SaveToStreamAsync(Album data, Stream stream)
{
await JsonSerializer.SerializeAsync(stream, data).ConfigureAwait(false);
}
public static async Task<Album> LoadFromStream(Stream stream)
{
return (await JsonSerializer.DeserializeAsync<Album>(stream).ConfigureAwait(false))!;
}
public static async Task<IEnumerable<Album>> LoadCachedAsync()
{
if (!Directory.Exists("./Cache"))
{
Directory.CreateDirectory("./Cache");
}
var results = new List<Album>();
foreach (var file in Directory.EnumerateFiles("./Cache"))
{
if (!string.IsNullOrWhiteSpace(new DirectoryInfo(file).Extension)) continue;
await using var fs = File.OpenRead(file);
results.Add(await Album.LoadFromStream(fs).ConfigureAwait(false));
}
return results;
}
}
}
模型视图源码:
cs
using Avalonia.Media.Imaging;
using Avalonia.MusicStore.Models;
using ReactiveUI;
using System.Threading.Tasks;
namespace Avalonia.MusicStore.ViewModels
{
public class AlbumViewModel : ViewModelBase
{
private readonly Album _album;
public AlbumViewModel(Album album)
{
_album = album;
}
public string Artist => _album.Artist;
public string Title => _album.Title;
private Bitmap? _cover;
public Bitmap? Cover
{
get => _cover;
private set => this.RaiseAndSetIfChanged(ref _cover, value);
}
public async Task LoadCover()
{
await using (var imageStream = await _album.LoadCoverBitmapAsync())
{
Cover = await Task.Run(() => Bitmap.DecodeToWidth(imageStream, 400));
}
}
public async Task SaveToDiskAsync()
{
await _album.SaveAsync();
if (Cover != null)
{
var bitmap = Cover;
await Task.Run(() =>
{
using (var fs = _album.SaveCoverBitmapStream())
{
bitmap.Save(fs);
}
});
}
}
}
}
cs
using Avalonia.MusicStore.Models;
using ReactiveUI;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Windows.Input;
namespace Avalonia.MusicStore.ViewModels
{
public class MainWindowViewModel : ViewModelBase
{
public ICommand BuyMusicCommand { get; }
public Interaction<MusicStoreViewModel, AlbumViewModel?> ShowDialog { get; }
public ObservableCollection<AlbumViewModel> Albums { get; } = new();
public MainWindowViewModel()
{
ShowDialog = new Interaction<MusicStoreViewModel, AlbumViewModel?>();
BuyMusicCommand = ReactiveCommand.CreateFromTask(async () =>
{
var store = new MusicStoreViewModel();
var result = await ShowDialog.Handle(store);
if (result != null)
{
Albums.Add(result);
await result.SaveToDiskAsync();
}
});
RxApp.MainThreadScheduler.Schedule(LoadAlbums);
}
private async void LoadAlbums()
{
var albums = (await Album.LoadCachedAsync()).Select(x => new AlbumViewModel(x));
foreach (var album in albums)
{
Albums.Add(album);
}
foreach (var album in Albums.ToList())
{
await album.LoadCover();
}
}
}
}
cs
using Avalonia.MusicStore.Models;
using ReactiveUI;
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Reactive;
using System.Reactive.Linq;
using System.Threading;
namespace Avalonia.MusicStore.ViewModels
{
public class MusicStoreViewModel : ViewModelBase
{
private string? _searchText;
private bool _isBusy;
public string? SearchText
{
get => _searchText;
set => this.RaiseAndSetIfChanged(ref _searchText, value);
}
public bool IsBusy
{
get => _isBusy;
set => this.RaiseAndSetIfChanged(ref _isBusy, value);
}
private AlbumViewModel? _selectedAlbum;
public ObservableCollection<AlbumViewModel> SearchResults { get; } = new();
public AlbumViewModel? SelectedAlbum
{
get => _selectedAlbum;
set => this.RaiseAndSetIfChanged(ref _selectedAlbum, value);
}
public MusicStoreViewModel()
{
this.WhenAnyValue(x => x.SearchText)
.Throttle(TimeSpan.FromMilliseconds(400))
.ObserveOn(RxApp.MainThreadScheduler)
.Subscribe(DoSearch!);
BuyMusicCommand = ReactiveCommand.Create(() =>
{
return SelectedAlbum;
});
}
private async void DoSearch(string s)
{
IsBusy = true;
SearchResults.Clear();
_cancellationTokenSource?.Cancel();
_cancellationTokenSource = new CancellationTokenSource();
var cancellationToken = _cancellationTokenSource.Token;
if (!string.IsNullOrWhiteSpace(s))
{
var albums = await Album.SearchAsync(s);
foreach (var album in albums)
{
var vm = new AlbumViewModel(album);
SearchResults.Add(vm);
}
if (!cancellationToken.IsCancellationRequested)
{
LoadCovers(cancellationToken);
}
}
IsBusy = false;
}
private async void LoadCovers(CancellationToken cancellationToken)
{
foreach (var album in SearchResults.ToList())
{
await album.LoadCover();
if (cancellationToken.IsCancellationRequested)
{
return;
}
}
}
private CancellationTokenSource? _cancellationTokenSource;
public ReactiveCommand<Unit, AlbumViewModel?> BuyMusicCommand { get; }
}
}
cs
using ReactiveUI;
namespace Avalonia.MusicStore.ViewModels
{
public class ViewModelBase : ReactiveObject
{
}
}
视图源码:
cs
<UserControl
x:Class="Avalonia.MusicStore.Views.AlbumView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:Avalonia.MusicStore.ViewModels"
Width="200"
d:DesignHeight="450"
d:DesignWidth="800"
x:DataType="vm:AlbumViewModel"
mc:Ignorable="d">
<StackPanel Width="200" Spacing="5">
<Border ClipToBounds="True" CornerRadius="10">
<Panel Background="#7FFF22DD">
<Image
Width="200"
Source="{Binding Cover}"
Stretch="Uniform" />
<Panel Height="200" IsVisible="{Binding Cover, Converter={x:Static ObjectConverters.IsNull}}">
<PathIcon
Width="75"
Height="75"
Data="{StaticResource music_regular}" />
</Panel>
</Panel>
</Border>
<TextBlock HorizontalAlignment="Center" Text="{Binding Title}" />
<TextBlock HorizontalAlignment="Center" Text="{Binding Artist}" />
</StackPanel>
</UserControl>
cs
<Window
x:Class="Avalonia.MusicStore.Views.MainWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:Avalonia.MusicStore.Views"
xmlns:vm="using:Avalonia.MusicStore.ViewModels"
Title="Avalonia.MusicStore"
d:DesignHeight="450"
d:DesignWidth="800"
x:DataType="vm:MainWindowViewModel"
Background="Transparent"
ExtendClientAreaToDecorationsHint="True"
Icon="/Assets/avalonia-logo.ico"
TransparencyLevelHint="AcrylicBlur"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
<Panel>
<ExperimentalAcrylicBorder IsHitTestVisible="False">
<ExperimentalAcrylicBorder.Material>
<ExperimentalAcrylicMaterial
BackgroundSource="Digger"
MaterialOpacity="0.65"
TintColor="Black"
TintOpacity="1" />
</ExperimentalAcrylicBorder.Material>
</ExperimentalAcrylicBorder>
<Panel Margin="40">
<Button
HorizontalAlignment="Right"
VerticalAlignment="Top"
Command="{Binding BuyMusicCommand}">
<PathIcon Data="{StaticResource store_microsoft_regular}" />
</Button>
<ItemsControl Margin="0,40,0,0" ItemsSource="{Binding Albums}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<views:AlbumView Margin="0,0,20,20" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Panel>
</Panel>
</Window>
cs
using Avalonia.MusicStore.ViewModels;
using Avalonia.ReactiveUI;
using ReactiveUI;
using System.Threading.Tasks;
namespace Avalonia.MusicStore.Views
{
public partial class MainWindow : ReactiveWindow<MainWindowViewModel>
{
public MainWindow()
{
InitializeComponent();
this.WhenActivated(action => action(ViewModel!.ShowDialog.RegisterHandler(DoShowDialogAsync)));
}
private async Task DoShowDialogAsync(InteractionContext<MusicStoreViewModel,
AlbumViewModel?> interaction)
{
var dialog = new MusicStoreWindow();
dialog.DataContext = interaction.Input;
var result = await dialog.ShowDialog<AlbumViewModel?>(this);
interaction.SetOutput(result);
}
}
}
cs
<UserControl
x:Class="Avalonia.MusicStore.Views.MusicStoreView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:Avalonia.MusicStore.ViewModels"
d:DesignHeight="450"
d:DesignWidth="800"
x:DataType="vm:MusicStoreViewModel"
mc:Ignorable="d">
<DockPanel>
<StackPanel DockPanel.Dock="Top">
<TextBox Text="{Binding SearchText}" Watermark="Search for Albums...." />
<ProgressBar IsIndeterminate="True" IsVisible="{Binding IsBusy}" />
</StackPanel>
<Button
HorizontalAlignment="Center"
Command="{Binding BuyMusicCommand}"
Content="Buy Album"
DockPanel.Dock="Bottom" />
<ListBox
Margin="0,20"
Background="Transparent"
ItemsSource="{Binding SearchResults}"
SelectedItem="{Binding SelectedAlbum}">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
</ListBox>
</DockPanel>
</UserControl>
XML
<Window
x:Class="Avalonia.MusicStore.Views.MusicStoreWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="using:Avalonia.MusicStore.Views"
Title="MusicStoreWindow"
Width="1000"
Height="550"
ExtendClientAreaToDecorationsHint="True"
TransparencyLevelHint="AcrylicBlur"
WindowStartupLocation="CenterOwner"
mc:Ignorable="d">
<Panel>
<ExperimentalAcrylicBorder IsHitTestVisible="False">
<ExperimentalAcrylicBorder.Material>
<ExperimentalAcrylicMaterial
BackgroundSource="Digger"
MaterialOpacity="0.65"
TintColor="Black"
TintOpacity="1" />
</ExperimentalAcrylicBorder.Material>
</ExperimentalAcrylicBorder>
<Panel Margin="40">
<views:MusicStoreView />
</Panel>
</Panel>
</Window>
cs
using Avalonia.MusicStore.ViewModels;
using Avalonia.ReactiveUI;
using ReactiveUI;
using System;
namespace Avalonia.MusicStore.Views
{
public partial class MusicStoreWindow : ReactiveWindow<MusicStoreViewModel>
{
public MusicStoreWindow()
{
InitializeComponent();
this.WhenActivated(action => action(ViewModel!.BuyMusicCommand.Subscribe(Close)));
}
}
}