import 'dart:async'; import 'package:flutter/foundation.dart'; import '../../data/models/jellyfin_library.dart'; import '../../data/models/jellyfin_models.dart'; import '../../data/models/jellyfin_playback.dart'; import '../../data/models/media_playback.dart'; import '../../data/services/jellyfin_library_service.dart'; import '../../data/services/jellyfin_playback_platform.dart'; import '../settings/jellyfin_controller.dart'; /// Owns one Jellyfin album queue and polls the native Media3 state. The native /// service is independent of this object, so playback continues while the app /// is backgrounded; [restore] reconnects the UI after an activity restart. class JellyfinPlaybackController extends ChangeNotifier { JellyfinPlaybackController( this._platform, this._library, this._jellyfin, { this.pollInterval = const Duration(milliseconds: 700), }) { _jellyfin.addListener(_onJellyfinChanged); } final JellyfinPlaybackPlatform _platform; final JellyfinLibraryService _library; final JellyfinController _jellyfin; final Duration pollInterval; JellyfinPlaybackQueue? _queue; JellyfinAlbum? _album; JellyfinPlaybackSnapshot _snapshot = JellyfinPlaybackSnapshot.empty; bool _loading = false; bool _polling = false; bool _disposed = false; String? _error; int? _pendingIndex; int _generation = 0; Timer? _timer; bool get isActive => _queue != null || _snapshot.available; bool get hasQueue => _queue != null; bool get loading => _loading; bool get playing => _snapshot.playing; bool get buffering => _snapshot.buffering; bool isCurrentAlbum(JellyfinAlbum album) => _album?.id == album.id; JellyfinAlbum? get currentAlbum => _album; JellyfinPlaybackSnapshot get snapshot => _snapshot; String? get error => _error; List get queueItems => _queue?.items ?? const []; JellyfinQueueItem? get currentItem { final queue = _queue; if (queue == null) return null; final index = _snapshot.index; if (index < 0 || index >= queue.items.length) return null; return queue.items[index]; } String? get nowPlayingAlbum => currentItem?.album ?? _snapshot.album ?? _album?.name; String? get nowPlayingArtist => currentItem?.artist ?? _snapshot.artist ?? _album?.artist; MediaPlayback get mediaPlayback { if (!_snapshot.available) return const MediaPlayback(); return _snapshot.toMediaPlayback( fallbackDurationMs: currentItem?.durationMs ?? 0, ); } bool get canGoNext => _snapshot.hasNext; bool get canGoPrevious => _snapshot.hasPrevious; /// Starts an album. The same album is not restarted unless it has ended or /// [restart] is true. Returns an error message for the caller, or null. Future playAlbum(JellyfinAlbum album, {bool restart = false}) async { if (_disposed) return null; if (!_jellyfin.configured) return 'Pair Jellyfin first.'; final sameAlbum = _album?.id == album.id && (_queue != null || _snapshot.available); if (sameAlbum && !restart) { if (_snapshot.ended) { await _platform.seekToIndex(0); await _platform.play(); } _startPolling(); await _poll(); return null; } final generation = ++_generation; _loading = true; _error = null; _pendingIndex = null; _notify(); try { final queue = await _library.buildQueue(album); if (_disposed || generation != _generation) return null; if (queue == null || queue.items.isEmpty) { throw JellyfinException('No playable tracks in this album.'); } final started = await _platform.setQueue(queue); if (_disposed || generation != _generation) return null; if (!started) { throw JellyfinException('Could not start playback.'); } _queue = queue; _album = album; _snapshot = await _platform.getState(); _loading = false; _startPolling(); final pending = _pendingIndex; _pendingIndex = null; if (pending != null) { await playTrackAt(pending); } _notify(); return null; } on JellyfinException catch (exception) { if (_disposed || generation != _generation) return null; _loading = false; _error = exception.message; _notify(); return exception.message; } catch (_) { if (_disposed || generation != _generation) return null; _loading = false; _error = 'Could not play this album.'; _notify(); return _error; } } Future toggle() async { if (!_snapshot.available && _queue == null) return; if (_snapshot.playing) { await _platform.pause(); _snapshot = _snapshot.copyWith(playing: false); _notify(); } else { if (_snapshot.ended) { await _platform.seekToIndex(0); } await _platform.play(); _snapshot = _snapshot.copyWith(playing: true); _notify(); } _startPolling(); await _poll(); } Future next() async { if (!_snapshot.hasNext) return; await _platform.next(); await _poll(); } Future previous() async { if (!_snapshot.hasPrevious) return; await _platform.previous(); await _poll(); } /// Selects an album track. If the queue is still being built, the index is /// applied as soon as it is ready. Future playTrackAt(int index) async { if (_queue == null) { _pendingIndex = index; return; } if (index < 0 || index >= _queue!.items.length) return; final selected = await _platform.seekToIndex(index); if (!selected) { _error = 'Could not play that track.'; _notify(); return; } await _platform.play(); _startPolling(); await _poll(); } Future seek(Duration position) async { if (!_snapshot.available) return; await _platform.seek(position); await _poll(); } Future notificationPermissionGranted() { return _platform.notificationPermissionGranted(); } Future requestNotificationPermission() { return _platform.requestNotificationPermission(); } Future stop() async { _generation++; await _platform.stop(); _queue = null; _album = null; _snapshot = JellyfinPlaybackSnapshot.empty; _loading = false; _error = null; _pendingIndex = null; _stopPolling(); _notify(); } /// Reconnects to a service that was already playing when the activity was /// recreated. The queue itself is not persisted; the snapshot still drives /// the Home media card. Future restore() async { if (_disposed) return; final snapshot = await _platform.getState(); if (_disposed || !snapshot.available) return; _snapshot = snapshot; _startPolling(); _notify(); } void onAppPaused() { _stopPolling(); } void onAppResumed() { if (!isActive) return; _startPolling(); unawaited(_poll()); } Future _poll() async { if (_disposed || _polling) return; _polling = true; try { final next = await _platform.getState(); if (_disposed) return; final changed = next != _snapshot; _snapshot = next; final nextError = next.error; if (nextError != null && nextError != _error) { _error = nextError; } if (changed) _notify(); } catch (_) { // Keep the last known state; the next poll retries. } finally { _polling = false; } } void _startPolling() { if (_disposed || _timer != null) return; _timer = Timer.periodic(pollInterval, (_) => unawaited(_poll())); } void _stopPolling() { _timer?.cancel(); _timer = null; } void _onJellyfinChanged() { if (!_jellyfin.configured && (_queue != null || _snapshot.available)) { unawaited(stop()); } } void _notify() { if (!_disposed) notifyListeners(); } @override void dispose() { _disposed = true; _jellyfin.removeListener(_onJellyfinChanged); _stopPolling(); _platform.dispose(); super.dispose(); } }