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