Skip to content

Commit c1b1ebd

Browse files
🎨 Update Artist list layout; support small window sizes
1 parent a58feef commit c1b1ebd

9 files changed

Lines changed: 612 additions & 42 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using System.Globalization;
2+
using Avalonia.Data.Converters;
3+
using Avalonia.Media.Imaging;
4+
5+
namespace OsuPlayer.Extensions.ValueConverters;
6+
7+
/// <summary>
8+
/// Converts a local file path <see cref="string"/> to a <see cref="Bitmap"/>.
9+
/// Returns <c>null</c> when the path is null/empty or the file does not exist.
10+
/// </summary>
11+
public class FilePathToBitmapConverter : IValueConverter
12+
{
13+
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
14+
{
15+
if (value is not string path || string.IsNullOrEmpty(path) || !File.Exists(path))
16+
return null;
17+
18+
try
19+
{
20+
return new Bitmap(path);
21+
}
22+
catch
23+
{
24+
return null;
25+
}
26+
}
27+
28+
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
29+
{
30+
throw new NotImplementedException();
31+
}
32+
}

OsuPlayer/Views/ArtistsView.axaml

Lines changed: 122 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
55
xmlns:views="clr-namespace:OsuPlayer.Views"
66
xmlns:avalonia="clr-namespace:Material.Icons.Avalonia;assembly=Material.Icons.Avalonia"
7+
xmlns:customControls="clr-namespace:OsuPlayer.Views.CustomControls"
78
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
89
x:Class="OsuPlayer.Views.ArtistsView"
910
x:DataType="views:ArtistsViewModel"
@@ -14,43 +15,133 @@
1415
</Design.DataContext>
1516

1617
<Grid RowDefinitions="Auto, Auto, *" Margin="0 1">
17-
<Border Grid.Row="0" Padding="8 24 8 12" VerticalAlignment="Stretch" HorizontalAlignment="Stretch"
18-
>
19-
<Grid ColumnDefinitions="*, Auto">
20-
<TextBlock
21-
Grid.Column="0"
22-
Text="Artists"
23-
FontSize="32"
24-
FontWeight="Bold"
25-
Margin="16 12 0 8"
26-
VerticalAlignment="Center"
27-
HorizontalAlignment="Left" />
28-
</Grid>
18+
<Border Grid.Row="0" Padding="8 24 8 12" VerticalAlignment="Stretch" HorizontalAlignment="Stretch">
19+
<TextBlock
20+
Text="Artists"
21+
FontSize="32"
22+
FontWeight="Bold"
23+
Margin="16 12 0 8"
24+
VerticalAlignment="Center"
25+
HorizontalAlignment="Left" />
2926
</Border>
3027

3128
<TextBox Grid.Row="1" Margin="10" Text="{Binding FilterText}" PlaceholderText="search artists..."
3229
TextChanged="FilterText_Changed" />
3330

34-
<ListBox Grid.Row="2" x:Name="ArtistListBox"
35-
ItemsSource="{Binding Artists}" Background="Transparent"
36-
SelectedItem="{Binding SelectedArtist}"
37-
Tapped="ArtistListBox_DoubleTapped">
31+
<!--
32+
Virtualizing grid: the ListBox virtualizes *rows*.
33+
Each row item is an IList<ArtistEntry>; code-behind keeps ColumnCount in sync
34+
with the scroll viewer width so rows are rebuilt at the right breakpoints.
35+
-->
36+
<ListBox Grid.Row="2"
37+
x:Name="ArtistRowListBox"
38+
ItemsSource="{Binding ArtistRows}"
39+
Background="Transparent"
40+
Padding="4"
41+
SizeChanged="ArtistRowListBox_SizeChanged">
42+
43+
<ListBox.Styles>
44+
<!-- Remove default ListBoxItem chrome / selection highlight -->
45+
<Style Selector="ListBoxItem">
46+
<Setter Property="Padding" Value="0" />
47+
<Setter Property="Margin" Value="0" />
48+
<Setter Property="Background" Value="Transparent" />
49+
</Style>
50+
<Style Selector="ListBoxItem:selected /template/ ContentPresenter#PART_ContentPresenter">
51+
<Setter Property="Background" Value="Transparent" />
52+
</Style>
53+
<Style Selector="ListBoxItem:selected:pointerover /template/ ContentPresenter#PART_ContentPresenter">
54+
<Setter Property="Background" Value="Transparent" />
55+
</Style>
56+
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter#PART_ContentPresenter">
57+
<Setter Property="Background" Value="Transparent" />
58+
</Style>
59+
<Style Selector="ListBoxItem:selected /template/ Rectangle#SelectionIndicator">
60+
<Setter Property="IsVisible" Value="False" />
61+
</Style>
62+
</ListBox.Styles>
63+
64+
<!-- One virtualised row per list item -->
3865
<ListBox.ItemTemplate>
39-
<DataTemplate DataType="views:ArtistEntry">
40-
<Grid ColumnDefinitions="Auto, *, Auto" Margin="10 6">
41-
<avalonia:MaterialIcon Grid.Column="0" Kind="AccountMusic" Height="28" Width="28"
42-
Margin="0 0 12 0" VerticalAlignment="Center" />
43-
<TextBlock Grid.Column="1" Text="{Binding Name}" FontSize="18" FontWeight="SemiBold"
44-
VerticalAlignment="Center" />
45-
<TextBlock Grid.Column="2" FontSize="14" VerticalAlignment="Center"
46-
Foreground="{DynamicResource AccentTextFillColorPrimaryBrush}">
47-
<TextBlock.Text>
48-
<MultiBinding StringFormat="{}{0} songs">
49-
<Binding Path="SongCount" />
50-
</MultiBinding>
51-
</TextBlock.Text>
52-
</TextBlock>
53-
</Grid>
66+
<DataTemplate x:DataType="x:Object">
67+
<!-- Row of cards — bound via DataContext cast in code-behind via x:CompileBindings=False -->
68+
<ItemsControl ItemsSource="{Binding}" x:CompileBindings="False" Margin="0 4">
69+
<ItemsControl.ItemsPanel>
70+
<ItemsPanelTemplate>
71+
<StackPanel Orientation="Horizontal" />
72+
</ItemsPanelTemplate>
73+
</ItemsControl.ItemsPanel>
74+
<ItemsControl.ItemTemplate>
75+
<DataTemplate DataType="views:ArtistEntry">
76+
<!-- Card -->
77+
<Border x:Name="ArtistCard"
78+
Width="{Binding $parent[ListBox].((views:ArtistsViewModel)DataContext).CardWidth}"
79+
Margin="4 0"
80+
CornerRadius="16"
81+
ClipToBounds="True"
82+
Cursor="Hand"
83+
Tapped="ArtistCard_Tapped">
84+
<!-- Image + overlays stacked in a fixed-height Panel -->
85+
<Panel Height="160" Background="{DynamicResource CardBackgroundFillColorDefaultBrush}">
86+
87+
<!-- Placeholder icon visible when no image loaded yet -->
88+
<avalonia:MaterialIcon Kind="AccountMusic"
89+
Width="52" Height="52"
90+
HorizontalAlignment="Center"
91+
VerticalAlignment="Center"
92+
Opacity="0.25" />
93+
94+
<!-- Async image: prefers cached artist image, falls back to first song cover -->
95+
<customControls:AsyncArtistImage
96+
CachedImagePath="{Binding CachedImagePath}"
97+
FirstSong="{Binding FirstSong}" />
98+
99+
<!-- Dark gradient from bottom up so text is readable -->
100+
<Panel>
101+
<Panel.Background>
102+
<LinearGradientBrush StartPoint="0%,100%" EndPoint="0%,0%">
103+
<GradientStop Offset="0.1" Color="#CC000000" />
104+
<GradientStop Offset="0.5" Color="#55000000" />
105+
<GradientStop Offset="1.0" Color="#00000000" />
106+
</LinearGradientBrush>
107+
</Panel.Background>
108+
</Panel>
109+
110+
<!-- Song-count badge — top-right (hidden for single-song artists) -->
111+
<Border
112+
HorizontalAlignment="Right"
113+
VerticalAlignment="Top"
114+
Margin="0 8 8 0"
115+
Background="{DynamicResource AccentFillColorDefaultBrush}"
116+
CornerRadius="999"
117+
Padding="6 3"
118+
MinWidth="26"
119+
IsVisible="{Binding ShowSongCount}">
120+
<TextBlock
121+
Text="{Binding SongCount}"
122+
Foreground="#FFFFFFFF"
123+
FontSize="12"
124+
FontWeight="Bold"
125+
HorizontalAlignment="Center"
126+
VerticalAlignment="Center" />
127+
</Border>
128+
129+
<!-- Artist name — bottom of card, over the gradient -->
130+
<TextBlock Text="{Binding Name}"
131+
FontSize="20"
132+
FontWeight="SemiBold"
133+
Foreground="White"
134+
Padding="16 8 8 12"
135+
TextTrimming="CharacterEllipsis"
136+
TextWrapping="Wrap"
137+
MaxHeight="80"
138+
VerticalAlignment="Bottom"
139+
HorizontalAlignment="Left" />
140+
</Panel>
141+
</Border>
142+
</DataTemplate>
143+
</ItemsControl.ItemTemplate>
144+
</ItemsControl>
54145
</DataTemplate>
55146
</ListBox.ItemTemplate>
56147
</ListBox>
Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
using Avalonia.Controls;
22
using Avalonia.Input;
3-
using Avalonia.Interactivity;
3+
using Avalonia.Threading;
4+
using Avalonia.VisualTree;
45
using Nein.Base;
56
using Nein.Extensions;
67
using OsuPlayer.Windows;
@@ -12,25 +13,69 @@ public partial class ArtistsView : ReactiveControl<ArtistsViewModel>
1213
{
1314
private FluentAppWindow? _mainWindow;
1415

16+
// Card margin is 4px left + 4px right = 8px per card.
17+
private const double CardMargin = 8;
18+
private const double MinCardWidth = 110;
19+
1520
public ArtistsView()
1621
{
1722
InitializeComponent();
18-
1923
_mainWindow = Locator.Current.GetRequiredService<FluentAppWindow>();
24+
25+
// Restore the saved scroll offset once the ListBox has been laid out.
26+
ArtistRowListBox.LayoutUpdated += OnFirstLayoutUpdated;
27+
}
28+
29+
private void OnFirstLayoutUpdated(object? sender, EventArgs e)
30+
{
31+
// Only need to fire once per view creation.
32+
ArtistRowListBox.LayoutUpdated -= OnFirstLayoutUpdated;
33+
34+
if (ViewModel == null) return;
35+
var saved = ViewModel.SavedScrollOffset;
36+
if (saved == default) return;
37+
38+
// Post at Background so virtualising panel has finished measuring.
39+
Dispatcher.UIThread.Post(() =>
40+
{
41+
var sv = GetScrollViewer();
42+
if (sv != null) sv.Offset = saved;
43+
}, DispatcherPriority.Background);
2044
}
2145

46+
private ScrollViewer? GetScrollViewer() =>
47+
ArtistRowListBox.FindDescendantOfType<ScrollViewer>();
48+
2249
private void FilterText_Changed(object? sender, TextChangedEventArgs e)
2350
{
2451
ViewModel?.ApplyFilter();
2552
}
2653

27-
private void ArtistListBox_DoubleTapped(object? sender, TappedEventArgs e)
54+
private void ArtistCard_Tapped(object? sender, TappedEventArgs e)
2855
{
2956
if (_mainWindow?.ViewModel == null) return;
30-
if (ViewModel?.SelectedArtist == null) return;
57+
if (sender is not Control control || control.DataContext is not ArtistEntry entry) return;
58+
59+
// Save the current scroll position to the ViewModel (which survives view re-creation).
60+
if (ViewModel != null)
61+
ViewModel.SavedScrollOffset = GetScrollViewer()?.Offset ?? default;
3162

3263
var artistView = _mainWindow.ViewModel.ArtistView;
33-
_ = artistView.LoadArtistAsync(ViewModel.SelectedArtist.Name);
64+
_ = artistView.LoadArtistAsync(entry.Name);
3465
_mainWindow.ViewModel.MainView = artistView;
3566
}
67+
68+
private void ArtistRowListBox_SizeChanged(object? sender, SizeChangedEventArgs e)
69+
{
70+
if (ViewModel == null) return;
71+
var availableWidth = e.NewSize.Width - 8; // subtract ListBox padding (4 each side)
72+
if (availableWidth <= 0) return;
73+
74+
// Compute how many columns fit, targeting ~190px per card (180 + 8 margin + small buffer)
75+
var cols = Math.Max(1, (int)(availableWidth / (180 + CardMargin + 2)));
76+
var cardWidth = Math.Max(MinCardWidth, (availableWidth / cols) - CardMargin);
77+
78+
ViewModel.CardWidth = cardWidth;
79+
ViewModel.ColumnCount = cols;
80+
}
3681
}

0 commit comments

Comments
 (0)