jellyfin_playback_controller.dart 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. import 'dart:async';
  2. import 'package:flutter/foundation.dart';
  3. import '../../data/models/jellyfin_library.dart';
  4. import '../../data/models/jellyfin_models.dart';
  5. import '../../data/models/jellyfin_playback.dart';
  6. import '../../data/models/media_playback.dart';
  7. import '../../data/services/jellyfin_library_service.dart';
  8. import '../../data/services/jellyfin_playback_platform.dart';
  9. import '../settings/jellyfin_controller.dart';
  10. /// Owns one Jellyfin album queue and polls the native Media3 state. The native
  11. /// service is independent of this object, so playback continues while the app
  12. /// is backgrounded; [restore] reconnects the UI after an activity restart.
  13. class JellyfinPlaybackController extends ChangeNotifier {
  14. JellyfinPlaybackController(
  15. this._platform,
  16. this._library,
  17. this._jellyfin, {
  18. this.pollInterval = const Duration(milliseconds: 700),
  19. }) {
  20. _jellyfin.addListener(_onJellyfinChanged);
  21. }
  22. final JellyfinPlaybackPlatform _platform;
  23. final JellyfinLibraryService _library;
  24. final JellyfinController _jellyfin;
  25. final Duration pollInterval;
  26. JellyfinPlaybackQueue? _queue;
  27. JellyfinAlbum? _album;
  28. JellyfinPlaybackSnapshot _snapshot = JellyfinPlaybackSnapshot.empty;
  29. bool _loading = false;
  30. bool _polling = false;
  31. bool _disposed = false;
  32. String? _error;
  33. int? _pendingIndex;
  34. int _generation = 0;
  35. Timer? _timer;
  36. bool get isActive => _queue != null || _snapshot.available;
  37. bool get hasQueue => _queue != null;
  38. bool get loading => _loading;
  39. bool get playing => _snapshot.playing;
  40. bool get buffering => _snapshot.buffering;
  41. bool isCurrentAlbum(JellyfinAlbum album) => _album?.id == album.id;
  42. JellyfinAlbum? get currentAlbum => _album;
  43. JellyfinPlaybackSnapshot get snapshot => _snapshot;
  44. String? get error => _error;
  45. List<JellyfinQueueItem> get queueItems =>
  46. _queue?.items ?? const <JellyfinQueueItem>[];
  47. JellyfinQueueItem? get currentItem {
  48. final queue = _queue;
  49. if (queue == null) return null;
  50. final index = _snapshot.index;
  51. if (index < 0 || index >= queue.items.length) return null;
  52. return queue.items[index];
  53. }
  54. String? get nowPlayingAlbum =>
  55. currentItem?.album ?? _snapshot.album ?? _album?.name;
  56. String? get nowPlayingArtist =>
  57. currentItem?.artist ?? _snapshot.artist ?? _album?.artist;
  58. MediaPlayback get mediaPlayback {
  59. if (!_snapshot.available) return const MediaPlayback();
  60. return _snapshot.toMediaPlayback(
  61. fallbackDurationMs: currentItem?.durationMs ?? 0,
  62. );
  63. }
  64. bool get canGoNext => _snapshot.hasNext;
  65. bool get canGoPrevious => _snapshot.hasPrevious;
  66. /// Starts an album. The same album is not restarted unless it has ended or
  67. /// [restart] is true. Returns an error message for the caller, or null.
  68. Future<String?> playAlbum(JellyfinAlbum album, {bool restart = false}) async {
  69. if (_disposed) return null;
  70. if (!_jellyfin.configured) return 'Pair Jellyfin first.';
  71. final sameAlbum =
  72. _album?.id == album.id && (_queue != null || _snapshot.available);
  73. if (sameAlbum && !restart) {
  74. if (_snapshot.ended) {
  75. await _platform.seekToIndex(0);
  76. await _platform.play();
  77. }
  78. _startPolling();
  79. await _poll();
  80. return null;
  81. }
  82. final generation = ++_generation;
  83. _loading = true;
  84. _error = null;
  85. _pendingIndex = null;
  86. _notify();
  87. try {
  88. final queue = await _library.buildQueue(album);
  89. if (_disposed || generation != _generation) return null;
  90. if (queue == null || queue.items.isEmpty) {
  91. throw JellyfinException('No playable tracks in this album.');
  92. }
  93. final started = await _platform.setQueue(queue);
  94. if (_disposed || generation != _generation) return null;
  95. if (!started) {
  96. throw JellyfinException('Could not start playback.');
  97. }
  98. _queue = queue;
  99. _album = album;
  100. _snapshot = await _platform.getState();
  101. _loading = false;
  102. _startPolling();
  103. final pending = _pendingIndex;
  104. _pendingIndex = null;
  105. if (pending != null) {
  106. await playTrackAt(pending);
  107. }
  108. _notify();
  109. return null;
  110. } on JellyfinException catch (exception) {
  111. if (_disposed || generation != _generation) return null;
  112. _loading = false;
  113. _error = exception.message;
  114. _notify();
  115. return exception.message;
  116. } catch (_) {
  117. if (_disposed || generation != _generation) return null;
  118. _loading = false;
  119. _error = 'Could not play this album.';
  120. _notify();
  121. return _error;
  122. }
  123. }
  124. Future<void> toggle() async {
  125. if (!_snapshot.available && _queue == null) return;
  126. if (_snapshot.playing) {
  127. await _platform.pause();
  128. _snapshot = _snapshot.copyWith(playing: false);
  129. _notify();
  130. } else {
  131. if (_snapshot.ended) {
  132. await _platform.seekToIndex(0);
  133. }
  134. await _platform.play();
  135. _snapshot = _snapshot.copyWith(playing: true);
  136. _notify();
  137. }
  138. _startPolling();
  139. await _poll();
  140. }
  141. Future<void> next() async {
  142. if (!_snapshot.hasNext) return;
  143. await _platform.next();
  144. await _poll();
  145. }
  146. Future<void> previous() async {
  147. if (!_snapshot.hasPrevious) return;
  148. await _platform.previous();
  149. await _poll();
  150. }
  151. /// Selects an album track. If the queue is still being built, the index is
  152. /// applied as soon as it is ready.
  153. Future<void> playTrackAt(int index) async {
  154. if (_queue == null) {
  155. _pendingIndex = index;
  156. return;
  157. }
  158. if (index < 0 || index >= _queue!.items.length) return;
  159. final selected = await _platform.seekToIndex(index);
  160. if (!selected) {
  161. _error = 'Could not play that track.';
  162. _notify();
  163. return;
  164. }
  165. await _platform.play();
  166. _startPolling();
  167. await _poll();
  168. }
  169. Future<void> seek(Duration position) async {
  170. if (!_snapshot.available) return;
  171. await _platform.seek(position);
  172. await _poll();
  173. }
  174. Future<bool> notificationPermissionGranted() {
  175. return _platform.notificationPermissionGranted();
  176. }
  177. Future<bool> requestNotificationPermission() {
  178. return _platform.requestNotificationPermission();
  179. }
  180. Future<void> stop() async {
  181. _generation++;
  182. await _platform.stop();
  183. _queue = null;
  184. _album = null;
  185. _snapshot = JellyfinPlaybackSnapshot.empty;
  186. _loading = false;
  187. _error = null;
  188. _pendingIndex = null;
  189. _stopPolling();
  190. _notify();
  191. }
  192. /// Reconnects to a service that was already playing when the activity was
  193. /// recreated. The queue itself is not persisted; the snapshot still drives
  194. /// the Home media card.
  195. Future<void> restore() async {
  196. if (_disposed) return;
  197. final snapshot = await _platform.getState();
  198. if (_disposed || !snapshot.available) return;
  199. _snapshot = snapshot;
  200. _startPolling();
  201. _notify();
  202. }
  203. void onAppPaused() {
  204. _stopPolling();
  205. }
  206. void onAppResumed() {
  207. if (!isActive) return;
  208. _startPolling();
  209. unawaited(_poll());
  210. }
  211. Future<void> _poll() async {
  212. if (_disposed || _polling) return;
  213. _polling = true;
  214. try {
  215. final next = await _platform.getState();
  216. if (_disposed) return;
  217. final changed = next != _snapshot;
  218. _snapshot = next;
  219. final nextError = next.error;
  220. if (nextError != null && nextError != _error) {
  221. _error = nextError;
  222. }
  223. if (changed) _notify();
  224. } catch (_) {
  225. // Keep the last known state; the next poll retries.
  226. } finally {
  227. _polling = false;
  228. }
  229. }
  230. void _startPolling() {
  231. if (_disposed || _timer != null) return;
  232. _timer = Timer.periodic(pollInterval, (_) => unawaited(_poll()));
  233. }
  234. void _stopPolling() {
  235. _timer?.cancel();
  236. _timer = null;
  237. }
  238. void _onJellyfinChanged() {
  239. if (!_jellyfin.configured && (_queue != null || _snapshot.available)) {
  240. unawaited(stop());
  241. }
  242. }
  243. void _notify() {
  244. if (!_disposed) notifyListeners();
  245. }
  246. @override
  247. void dispose() {
  248. _disposed = true;
  249. _jellyfin.removeListener(_onJellyfinChanged);
  250. _stopPolling();
  251. _platform.dispose();
  252. super.dispose();
  253. }
  254. }