Audio-Wiedergabe: MediaPlayer, Playlist und PlaybackState (Teil 13)
EchoPlay spielt Hörbücher von der lokalen Festplatte ab. Dafür nutzt die Anwendung die Windows.Media.Playback-API — eine Windows-native Bibliothek für Medienwiedergabe, die ohne externe Abhängigkeiten auskommt. Dieser Artikel zeigt, wie der Player aufgebaut ist, wie die Position persistent gespeichert wird und warum ein simples Slider-Binding zu einer Endlosschleife führen kann.
MediaPlayer und MediaPlaybackList
Die Wiedergabe basiert auf zwei Klassen. MediaPlayer ist das eigentliche Wiedergabe-Engine: Er spielt einen Track ab, kennt die aktuelle Position und die Wiedergabegeschwindigkeit. MediaPlaybackList ist eine Playlist — sie enthält mehrere MediaPlaybackItem-Objekte und erlaubt Vorwärts- und Rückwärts-Navigation zwischen Tracks.
_player = new MediaPlayer();
_playlist = new MediaPlaybackList();
_player.Source = _playlist;
Der MediaPlayer bekommt die MediaPlaybackList als Source. Ab diesem Moment steuert die List, was der Player abspielt.
Wiedergabe starten
public void Play(Guid episodeId, IReadOnlyList<string> trackPaths, int startIndex = 0, TimeSpan resumePosition = default)
{
_currentEpisodeId = episodeId;
_playlist.Items.Clear();
foreach (string path in trackPaths)
{
_playlist.Items.Add(
new MediaPlaybackItem(
MediaSource.CreateFromUri(new Uri(path))));
}
_playlist.MoveTo((uint)startIndex);
_player.Play();
if (resumePosition > TimeSpan.Zero)
{
_player.PlaybackSession.Position = resumePosition;
}
_positionTimer.Start();
}
MediaSource.CreateFromUri lädt Audiodateien als lokale Datei-URIs. Ein Pfad wie C:HörbücherDreiFragezeichenEpisode01.mp3 muss als file:///C:/Hörbücher/... übergeben werden — new Uri(path) macht diese Konvertierung automatisch, wenn der Pfad ein absoluter Dateipfad ist. MoveTo wechselt zu einem bestimmten Track in der Playlist, damit der Nutzer beim letzten abgespielten Track weitermachen kann statt immer von vorne zu beginnen. Die resumePosition wird aus der Datenbank geladen und nach Play() gesetzt — so hört der Nutzer genau dort weiter, wo er aufgehört hat.
Position-Timer: Regelmäßige Aktualisierung
private readonly System.Timers.Timer _positionTimer;
// Im Konstruktor:
_positionTimer = new(500); // 500 ms Takt
_positionTimer.Elapsed += OnPositionTimerElapsed;
Der Timer feuert alle 500 Millisekunden und triggert dabei drei Aktionen: Er feuert das StateChanged-Event, damit die MiniPlayer-UI Position und Dauer aktualisiert. Er zählt einen internen Tick-Zähler hoch und speichert alle 30 Sekunden (60 Ticks × 500 ms) automatisch die aktuelle Position. Und er zählt den Sleep-Timer herunter, falls einer aktiv ist.
private void OnPositionTimerElapsed(object? sender, System.Timers.ElapsedEventArgs e)
{
_autoSaveTick++;
if (_autoSaveTick >= AutoSaveIntervalTicks) // 60 Ticks = 30 Sekunden
{
_autoSaveTick = 0;
_ = SavePlaybackStateAsync(); // Fire-and-Forget
}
if (_sleepTimerRemaining.HasValue)
{
_sleepTimerRemaining = _sleepTimerRemaining.Value - TimeSpan.FromMilliseconds(500);
if (_sleepTimerRemaining.Value <= TimeSpan.Zero)
{
_sleepTimerRemaining = null;
Pause();
return;
}
}
StateChanged?.Invoke(this, EventArgs.Empty);
}
PlaybackState: Position persistieren
Wenn der Nutzer die App schließt, soll er beim nächsten Start an derselben Stelle weiterhören. EchoPlay speichert dafür die aktuelle Position in der Datenbank — als PlaybackState-Entität.
private async Task SavePlaybackStateAsync()
{
if (_currentEpisodeId == Guid.Empty)
{
return;
}
using IServiceScope scope = _scopeFactory.CreateScope();
IPlaybackStateDataService service = scope.ServiceProvider.GetRequiredService<IPlaybackStateDataService>();
PlaybackState? existing = await service.GetByEpisodeIdAsync(_currentEpisodeId);
TimeSpan currentPosition = Position;
if (existing is null)
{
await service.AddAsync(new PlaybackState
{
EpisodeId = _currentEpisodeId,
LastPosition = currentPosition,
LastPlayedAt = DateTime.UtcNow
});
}
else
{
existing.LastPosition = currentPosition;
existing.LastPlayedAt = DateTime.UtcNow;
await service.UpdateAsync(existing);
}
}
Der Auto-Save alle 30 Sekunden ist bewusst gewählt: Ein Hörbuch dauert oft mehrere Stunden. Wenn die App nach 25 Minuten abstürzt, ohne die Position gespeichert zu haben, verliert der Nutzer seinen Fortschritt. Der Auto-Save begrenzt den maximalen Verlust auf 30 Sekunden. Der IServiceScopeFactory erzeugt für jeden Speichervorgang einen eigenen Scope, weil der DbContext als Scoped registriert ist und der PlayerService als Singleton lebt.
Beim App-Ende wird in Dispose() die Position noch einmal synchron gespeichert, damit auch der letzte Abschnitt vor dem Schließen nicht verloren geht. Der MiniPlayer hat neben Play/Pause auch einen Schließen-Button: Ein Klick ruft Stop() auf dem PlayerService auf — Position speichern, Pause, Playlist leeren, CurrentTrackTitle = null. Da die MiniPlayer-Sichtbarkeit an den Tracktitel gebunden ist, blendet sich der MiniPlayer automatisch aus.
Einschlaf-Timer
Hörspiele werden oft vor dem Einschlafen gehört. Ein Einschlaf-Timer stoppt die Wiedergabe automatisch nach einer definierten Zeit.
public void SetSleepTimer(TimeSpan? duration)
{
_sleepTimerRemaining = duration;
StateChanged?.Invoke(this, EventArgs.Empty);
}
Der Timer zählt im 500-ms-Takt des _positionTimer herunter. Wenn _sleepTimerRemaining den Wert null hat, ist kein Timer aktiv. Das ViewModel des MiniPlayers zeigt die verbleibende Zeit an und reagiert auf Änderungen über das StateChanged-Event.
Zeitanzeige unter dem Slider
Unter dem Position-Slider zeigt der Player zwei Zeitangaben: links die bereits gespielte Zeit, rechts die verbleibende Zeit. Ein Klick auf die rechte Anzeige wechselt zwischen verbleibender Zeit (mit Minus-Vorzeichen, z.B. „-23:45″) und Gesamtdauer (z.B. „1:00:00″).
public string ElapsedText { get; } // z.B. "3:45" oder "1:03:45"
public string RemainingOrTotalText { get; } // z.B. "-56:15" oder "1:00:00"
Das Format passt sich der Länge an: unter einer Stunde wird m:ss angezeigt, ab einer Stunde h:mm:ss. Die Aktualisierung erfolgt im selben 500-ms-Takt wie die Slider-Position — kein zusätzlicher Timer nötig. Der Wechsel zwischen verbleibender und Gesamtzeit ist ein ToggleTimeDisplayCommand, das ein internes Boolean-Flag umschaltet. Das XAML reagiert auf Tapped statt auf Click, weil TextBlock kein Button ist.
Slider-Feedback-Loop verhindern
Im MiniPlayer gibt es einen Seek-Slider, der die aktuelle Position anzeigt. Wenn der Nutzer den Slider bewegt, soll die Wiedergabe an die neue Position springen. Wenn der Timer die Position aktualisiert, soll der Slider folgen. Das Problem: Wenn der Timer den Slider-Wert setzt, löst das ValueChanged aus, das wiederum SeekTo aufruft, das den Timer erneut feuert — eine Endlosschleife.
EchoPlay verhindert das mit einem simplen Boolean-Flag:
private bool _isSeekingFromSlider;
private void OnMiniPlayerPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MiniPlayerViewModel.PositionSeconds))
{
_isSeekingFromSlider = true; // Rückkopplungsschutz aktivieren
SeekSlider.Value = MiniPlayer.PositionSeconds;
_isSeekingFromSlider = false; // Rückkopplungsschutz deaktivieren
}
}
private void OnSeekSliderChanged(object sender, RangeBaseValueChangedEventArgs e)
{
if (_isSeekingFromSlider)
{
return; // Programmatische Änderung – kein Seek
}
MiniPlayer.SeekTo(e.NewValue);
}
Wenn der Timer den Slider-Wert setzt, wird das Flag auf true gesetzt. Der ValueChanged-Handler erkennt das und ignoriert die Änderung. Wenn der Nutzer den Slider manuell bewegt, ist das Flag false, und SeekTo wird aufgerufen. Eine einfache, robuste Lösung für ein Problem, das bei bidirektionalen Bindings häufig auftritt.
Unterstützte Audioformate: Eine zentrale Liste
Der Player nutzt Windows.Media.Playback.MediaPlayer, der intern auf Windows Media Foundation aufbaut. Das bedeutet: Der Player kann alles abspielen, was Windows Media Foundation kennt — nicht nur MP3. EchoPlay unterstützt acht Audioformate: MP3, M4A, FLAC, OGG, WMA, WAV, AAC und Opus.
Diese Formatliste ist an mehreren Stellen im Projekt relevant: Der Scanner muss wissen, welche Dateien Audiodateien sind. Der Player muss wissen, welche Dateien er laden soll. Die Fehlende-Folgen-Analyse muss wissen, ob ein Ordner Audioinhalte enthält. Damit diese Listen nicht auseinanderlaufen, gibt es eine zentrale Klasse:
public static class AudioExtensions
{
public static readonly IReadOnlyList<string> Supported =
[
".mp3", ".m4a", ".flac", ".ogg", ".wma", ".wav", ".aac", ".opus"
];
public static bool IsAudioFile(string filePath)
{
string ext = Path.GetExtension(filePath);
foreach (string supported in Supported)
{
if (string.Equals(ext, supported, StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
}
AudioExtensions lebt in EchoPlay.Core, weil sie keine Abhängigkeit zu UI, Datenbank oder externen APIs hat. Die Methode IsAudioFile wird als Methodengruppe an LINQ-Ausdrücke übergeben — z.B. .Any(AudioExtensions.IsAudioFile) oder .Where(AudioExtensions.IsAudioFile). Bei nur acht Einträgen ist die lineare Suche schneller als ein HashSet-Lookup, weil der Overhead der Hash-Berechnung den Vorteil bei so wenigen Elementen auffrißt.
Formate wie APE oder MPC fehlen bewusst: Windows Media Foundation unterstützt sie nicht nativ, und Codec-Packs sind auf Endnutzer-PCs nicht garantiert. Der Tag-Manager (TagLib#) kann deren Metadaten trotzdem lesen — deshalb hat TagService eine eigene, breitere Liste.
Der PlayerViewModel.LoadFolder nutzt diese zentrale Liste, um alle abspielbaren Dateien aus einem Ordner zu laden:
public void LoadFolder(string folderPath)
{
string[] files = Directory.GetFiles(folderPath, "*.*", SearchOption.TopDirectoryOnly)
.Where(AudioExtensions.IsAudioFile)
.OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
.ToArray();
BuildPlaylist(files);
}
Früher filterte LoadFolder nur nach "*.mp3". Damit wurden FLAC- oder M4A-Dateien ignoriert, obwohl der MediaPlayer sie problemlos abspielen kann. Die zentrale AudioExtensions-Klasse stellt sicher, dass alle unterstützten Formate überall gleich behandelt werden.
MiniPlayer: Zeitanzeige am unteren Rand
Der MiniPlayer am unteren Fensterrand zeigt neben dem Tracktitel auch die Zeitanzeige: gespielte Zeit und verbleibende Zeit. Die Werte werden im selben 500-ms-Takt wie der Slider aktualisiert — über das StateChanged-Event des PlayerService. Die FormatTime-Methode formatiert kurze Tracks als m:ss und lange als h:mm:ss.
Singleton-Pattern für den PlayerService
Der PlayerService ist als Singleton registriert. Es darf immer nur eine MediaPlayer-Instanz geben — zwei MediaPlayer-Objekte würden gleichzeitig spielen, und das wäre ein offensichtlicher Fehler.
builder.Services.AddSingleton<IPlayerService, PlayerService>();
PlayerService implementiert IDisposable. Beim App-Ende wird Dispose() aufgerufen, was den Timer stoppt, die Position ein letztes Mal speichert und den MediaPlayer freigibt. Das Dependency-Injection-Framework kümmert sich automatisch um den Aufruf von Dispose() bei Singletons — vorausgesetzt, die App wird sauber heruntergefahren.
Die gezeigten Code-Beispiele dienen zur Veranschaulichung. Nutzung auf eigene Verantwortung. Mehr dazu