Lokale Mediathek: Drei-Spalten-Navigation mit Auswahl-Kaskade (Teil 19)
Die lokale Mediathek zeigt, welche Hörspiele als Audiodateien auf dem Rechner vorhanden sind. Die Ansicht ist in drei Spalten aufgeteilt: links die Serien, in der Mitte die Folgen der gewählten Serie, rechts die Tracks der gewählten Folge. Diese Art der Navigation — Auswahl in Spalte A befüllt Spalte B, Auswahl in Spalte B befüllt Spalte C — ist ein verbreitetes Muster in Desktop-Apps.
Drei unveränderliche Card-ViewModels
Für jede Spalte gibt es ein eigenes, unveränderliches ViewModel. „Unveränderlich“ bedeutet: kein Property hat einen öffentlichen Setter, keine Liste wird nachträglich bearbeitet. Wenn sich Daten ändern, wird das ViewModel weggeworfen und ein neues erstellt:
public sealed class LocalArtistCardViewModel
{
public Guid SeriesId { get; }
public string Title { get; }
public BitmapImage? CoverImage { get; }
public string? LocalFolderPath { get; }
public int LocalEpisodeCount { get; }
public int TotalEpisodeCount { get; }
public string CountText => $"{LocalEpisodeCount} / {TotalEpisodeCount}";
}
Der Vorteil: x:Bind mit Mode=OneTime reicht aus. Das XAML muss keine Änderungen beobachten, weil es keine gibt. Das spart Performance und vereinfacht den Code.
Die Auswahl-Kaskade
Das übergeordnete MediathekLokalViewModel verwaltet die drei Spalten und koordiniert die Auswahl. Wenn der Nutzer eine Serie auswählt, lädt das ViewModel die Folgen dieser Serie in die mittlere Spalte:
public async Task SelectArtistAsync(LocalArtistCardViewModel artist)
{
SelectedArtist = artist;
SelectedEpisode = null;
Tracks = [];
using IServiceScope scope = _scopeFactory.CreateScope();
IEpisodeDataService episodeService = scope.ServiceProvider
.GetRequiredService<IEpisodeDataService>();
IReadOnlyList<Episode> episodes = await episodeService
.GetBySeriesIdAsync(artist.SeriesId);
List<LocalEpisodeCardViewModel> episodeCards = [];
foreach (Episode episode in episodes
.Where(e => e.LocalFolderPath is not null)
.OrderBy(e => e.EpisodeNumber))
{
episodeCards.Add(new LocalEpisodeCardViewModel(
episodeId: episode.Id,
episodeNumber: episode.EpisodeNumber,
title: episode.Title,
localTrackCount: episode.LocalTrackCount ?? 0,
folderPath: episode.LocalFolderPath));
}
Episodes = episodeCards;
}
Nur Folgen mit einem lokal gefundenen Ordner (LocalFolderPath != null) werden angezeigt. Das ist keine Fehleranzeige, sondern eine bewusste Design-Entscheidung: die Ansicht zeigt nur, was tatsächlich vorhanden ist.
Berechnete Visibility-Properties für Leerzustände
Jede Spalte hat einen Platzhalter-Text, der erscheint, wenn die Spalte leer ist. Diese Sichtbarkeit wird als Property im ViewModel berechnet — das bekannte Muster ohne Converter:
public Visibility ArtistsEmptyVisibility =>
_artists.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
public Visibility SeriesActionsVisibility =>
_selectedArtist?.LocalFolderPath is not null ? Visibility.Visible : Visibility.Collapsed;
Lazy Cover-Loading mit SemaphoreSlim
Die Episodenliste erscheint sofort nach Serien-Auswahl — ohne Cover. Alle Episoden-Karten werden zunächst mit einem Platzhalter-Icon erstellt. Erst danach laden Cover im Hintergrund nach. Ohne Drosselung starten bei 229 Episoden alle Tasks gleichzeitig und überlasten den UI-Thread. Ein SemaphoreSlim mit MaxCount 8 begrenzt die Parallelität.
Entscheidend ist die Trennung zwischen Byte-Laden und Bild-Erstellung. BitmapImage ist ein WinRT-COM-Objekt und darf nur auf dem UI-Thread erstellt werden. Die Lösung: Bytes auf dem Hintergrundthread laden, dann das BitmapImage über DispatcherQueue.TryEnqueue zurück auf den UI-Thread marshallen:
tasks.Add(Task.Run(async () =>
{
byte[]? bytes = await LoadCoverBytesAsync(episode);
if (bytes is not null && !cancellationToken.IsCancellationRequested)
{
TaskCompletionSource tcs = new();
_dispatcherQueue.TryEnqueue(async () =>
{
try { card.CoverImage = await BitmapImageFromBytesAsync(bytes); }
finally { tcs.SetResult(); }
});
await tcs.Task;
}
}, cancellationToken));
Die TaskCompletionSource überbrückt die async void-Lücke: TryEnqueue erwartet einen synchronen Delegate. Ohne TCS wüsste der Hintergrundthread nicht, wann das UI-Thread-Update fertig ist. Bei Serienwechsel bricht ein CancellationToken alle laufenden Cover-Tasks ab.
Favoriten-Stern und Kontextmenü
Jede Serienkachel hat oben links einen kleinen Stern-Button. Ein Klick schaltet den Favoritenstatus um und speichert die Änderung sofort in der Datenbank. Der Stern wechselt zwischen leer und gefüllt — beides sind Segoe-Fluent-Icons:
public string FavoriteGlyph => _isFavorite ? "uE735" : "uE734";
Im Kontextmenü jeder Folge gibt es „Als gehört markieren“ und „Als ungehört markieren“. Das XAML nutzt das Tag-Binding-Pattern: Die EpisodeId wird als Tag an den MenuFlyoutItem gebunden, und der Click-Handler in der Page delegiert an das ViewModel.
Navigation zum Tag-Manager ohne Frame-Zugriff
Ein ViewModel soll nicht wissen, wie die Navigation in der App funktioniert. Statt Frame.Navigate() direkt aufzurufen, gibt es ein Event:
// Im MediathekLokalViewModel:
public event Action<string>? NavigateToTagManagerRequested;
public void RequestTagManagerNavigation(string path)
{
NavigateToTagManagerRequested?.Invoke(path);
}
Die Page abonniert dieses Event und führt die Navigation durch. Das Deabonnieren in OnNavigatedFrom ist wichtig: ein Event-Handler hält eine Referenz auf die Page. Bleibt er angehängt, kann der Garbage Collector die Page nicht freigeben — ein klassischer Memory Leak.
IProgress für Scan-Fortschritt
Der Scan-Vorgang ist eine lang laufende Operation. Dafür bietet .NET das Interface IProgress<T>:
Progress<ScanProgress> progress = new(p =>
{
_statusBar.UpdateScanProgress(p.StatusText, p.Processed, p.Total);
});
SyncResult result = await _syncService.SyncAsync(progress);
Progress<T> führt den Callback auf dem Thread aus, auf dem es erstellt wurde — hier dem UI-Thread. Damit kann direkt eine ViewModel-Property gesetzt werden, ohne DispatcherQueue.TryEnqueue. Ein häufiger Fehler beim ersten Umgang mit async/await: Wenn eine Methode Task zurückgibt, aber intern alles synchron ausführt (z.B. über Task.FromResult), blockiert sie trotzdem den UI-Thread. Die Lösung ist Task.Run, das die Arbeit auf einen Thread-Pool-Thread verschiebt.
Flache Dateistrukturen und Kassetten-Rips
Nicht jede Hörspielsammlung hat die klassische Ordnerstruktur. Viele ältere Sammlungen — vor allem Kassetten-Rips — liegen als einzelne MP3-Dateien direkt im Serienordner. Der Scanner erkennt das automatisch: Wenn keine Unterordner mit Audiodateien gefunden werden, wird jede Datei als eigenständige Episode behandelt. Dabei werden fünf verschiedene Dateinamen-Muster durchprobiert, weil Dateinamen anderen Konventionen folgen als Ordnernamen.
Alte Hörspielsammlungen von Kassetten haben oft zwei Dateien pro Kassette: Seite A und Seite B. Der Scanner erkennt Kassetten-Muster wie 01a Spuk in der Werkstatt.mp3 über einen dedizierten Regex. Die Episodennummer ergibt sich aus Kassettennummer und Seite: Kassette 1 Seite A wird Episode 1, Kassette 1 Seite B wird Episode 2 — so entsteht eine lückenlose Nummerierung.
Die gezeigten Code-Beispiele dienen zur Veranschaulichung. Nutzung auf eigene Verantwortung. Mehr dazu