Suche und Import: Ergebnis-ViewModels und Import-Status (Teil 20)

Die Suche-Seite in EchoPlay zeigt ein Muster, das in vielen Apps vorkommt: Der Nutzer gibt einen Suchbegriff ein, die App fragt eine externe API ab, zeigt Ergebnisse in einer Kachelansicht, und der Nutzer kann einzelne Ergebnisse in die lokale Bibliothek übernehmen. Dahinter stecken mehrere interessante Entwurfsentscheidungen.

Wie der ImportService aufgebaut ist

Der ImportService ist ein Singleton. Er kennt drei Operationen: SearchAsync(query) schickt eine Suchanfrage an den aktiven Provider (Spotify oder Apple Music), abhängig von den AppSettings. IsAlreadyImportedAsync(series) prüft, ob eine bestimmte Serie bereits importiert wurde — dazu sucht er in der Datenbank nach der externen ID. ImportAsync(series) importiert eine Serie vollständig: legt die Series-Entität und alle zugehörigen Episode-Einträge in der Datenbank an.

SucheViewModel: Ablauf einer Suchanfrage

Das SucheViewModel koordiniert den gesamten Ablauf. Der Suchtext kommt als TwoWay-Binding aus der AutoSuggestBox im XAML:

private async Task SearchAsync()
{
    if (_isLoading || string.IsNullOrWhiteSpace(_searchText))
    {
        return;
    }

    _hasSearched = false;
    IsLoading    = true;

    try
    {
        IReadOnlyList<ImportSeries> importResults = await _importService.SearchAsync(_searchText);

        List<SearchResultViewModel> viewModels = new(importResults.Count);

        foreach (ImportSeries series in importResults)
        {
            bool alreadyImported = await _importService.IsAlreadyImportedAsync(series);
            viewModels.Add(new SearchResultViewModel(series, alreadyImported, _importService, _errorDialogService));
        }

        _hasSearched = true;
        Results      = viewModels;
    }
    catch (Exception ex)
    {
        await _errorDialogService.ShowAsync("Suche fehlgeschlagen", ex.Message);
    }
    finally
    {
        IsLoading = false;
    }
}

Das _hasSearched-Flag steuert den „Keine Ergebnisse“-Hinweis: Der soll nicht beim ersten Öffnen der Seite erscheinen, sondern erst nachdem tatsächlich eine Suche durchgeführt wurde.

EmptyStateVisibility — drei Bedingungen gleichzeitig

Der Leer-Hinweis erscheint nur unter einer bestimmten Kombination von Bedingungen — als berechnete Visibility-Property im ViewModel:

public Visibility EmptyStateVisibility =>
    _hasSearched && !_isLoading && _results.Count == 0
        ? Visibility.Visible
        : Visibility.Collapsed;

Da sich EmptyStateVisibility von IsLoading und Results ableitet, muss das ViewModel bei Änderungen beider Properties auch EmptyStateVisibility benachrichtigen. Das ist das Muster für alle abhängigen Properties in diesem Projekt: Änderungen werden explizit weitergeleitet, kein Converter, kein MultiBinding.

SearchResultViewModel: Import-Status als Zustand

Für jedes Suchergebnis gibt es ein eigenes SearchResultViewModel. Es speichert, ob die Serie bereits importiert ist und ob gerade ein Import läuft. Aus diesen zwei bool-Flags leiten sich die Visibility-Properties für XAML ab. Der Import-Button ist sichtbar, solange die Serie noch nicht importiert ist — danach verschwindet er und ein grüner Hinweistext erscheint. Das IsImporting-Flag deaktiviert den Button während des Imports über RelayCommand.SetEnabled(), das automatisch CanExecuteChanged auslöst:

public bool IsImporting
{
    get => _isImporting;
    private set
    {
        if (SetProperty(ref _isImporting, value))
        {
            ((RelayCommand)ImportCommand).SetEnabled(!value);
        }
    }
}

BitmapImage aus einer URL

Das Coverbild einer Suchergebnis-Kachel kommt als URL-String aus der API. BitmapImage aus WinUI 3 kann eine URL direkt entgegennehmen und lädt das Bild asynchron im Hintergrund:

CoverImage = string.IsNullOrEmpty(importSeries.CoverImageUrl)
    ? null
    : new BitmapImage(new Uri(importSeries.CoverImageUrl));

Das new BitmapImage(uri) startet den Download sofort — man muss nicht await aufrufen. WinUI erledigt den Download intern und aktualisiert das Image-Control, sobald das Bild geladen ist.

Suchquellen: Online, Lokal oder beides

EchoPlay kann Serien sowohl online (Spotify, Apple Music) als auch in der lokalen Bibliothek suchen. Der aktive Such-Scope wird über eine ComboBox gesteuert. Beim Scope „Beides“ werden beide Suchen parallel gestartet:

Task<IReadOnlyList<ImportSeries>> onlineTask = _importService.SearchAsync(query);
Task<IReadOnlyList<ImportSeries>> localTask  = SearchLocalAsync(query);

await Task.WhenAll(onlineTask, localTask);

Die Suche kann nicht nur Serien (Künstler) finden, sondern auch einzelne Folgen (Alben). Dafür hat der ImportService eine zweite Methode: SearchAlbumsAsync(query). Beide Ergebnislisten werden zusammengeführt und nach Relevanz sortiert: Treffer, deren Titel den Suchbegriff enthält, stehen oben. Innerhalb gleicher Relevanz entscheidet der Hörspiel-Score, den das Scoring-System anhand von Album-Strukturen und Tracklängen berechnet.

Die gezeigten Code-Beispiele dienen zur Veranschaulichung. Nutzung auf eigene Verantwortung. Mehr dazu