jellyfin_playback_controller.dart 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  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. }
  62. bool get canGoNext => _snapshot.hasNext;
  63. bool get canGoPrevious => _snapshot.hasPrevious;
  64. /// Starts an album. The same album is not restarted unless it has ended or
  65. /// [restart] is true. Returns an error message for the caller, or null.
  66. Future<String?> playAlbum(JellyfinAlbum album, {bool restart = false}) async {
  67. if (_disposed) return null;
  68. if (!_jellyfin.configured) return 'Pair Jellyfin first.';
  69. final sameAlbum =
  70. _album?.id == album.id && (_queue != null || _snapshot.available);
  71. if (sameAlbum && !restart) {
  72. if (_snapshot.ended) {
  73. await _platform.seekToIndex(0);
  74. await _platform.play();
  75. }
  76. _startPolling();
  77. await _poll();
  78. return null;
  79. }
  80. final generation = ++_generation;
  81. _loading = true;
  82. _error = null;
  83. _pendingIndex = null;
  84. _notify();
  85. try {
  86. final queue = await _library.buildQueue(album);
  87. if (_disposed || generation != _generation) return null;
  88. if (queue == null || queue.items.isEmpty) {
  89. throw JellyfinException('No playable tracks in this album.');
  90. }
  91. final started = await _platform.setQueue(queue);
  92. if (_disposed || generation != _generation) return null;
  93. if (!started) {
  94. throw JellyfinException('Could not start playback.');
  95. }
  96. _queue = queue;
  97. _album = album;
  98. _snapshot = await _platform.getState();
  99. _loading = false;
  100. _startPolling();
  101. final pending = _pendingIndex;
  102. _pendingIndex = null;
  103. if (pending != null) {
  104. await playTrackAt(pending);
  105. }
  106. _notify();
  107. return null;
  108. } on JellyfinException catch (exception) {
  109. if (_disposed || generation != _generation) return null;
  110. _loading = false;
  111. _error = exception.message;
  112. _notify();
  113. return exception.message;
  114. } catch (_) {
  115. if (_disposed || generation != _generation) return null;
  116. _loading = false;
  117. _error = 'Could not play this album.';
  118. _notify();
  119. return _error;
  120. }
  121. }
  122. Future<void> toggle() async {
  123. if (!_snapshot.available && _queue == null) return;
  124. if (_snapshot.playing) {
  125. await _platform.pause();
  126. _snapshot = _snapshot.copyWith(playing: false);
  127. _notify();
  128. } else {
  129. if (_snapshot.ended) {
  130. await _platform.seekToIndex(0);
  131. }
  132. await _platform.play();
  133. _snapshot = _snapshot.copyWith(playing: true);
  134. _notify();
  135. }
  136. _startPolling();
  137. await _poll();
  138. }
  139. Future<void> next() async {
  140. if (!_snapshot.hasNext) return;
  141. await _platform.next();
  142. await _poll();
  143. }
  144. Future<void> previous() async {
  145. if (!_snapshot.hasPrevious) return;
  146. await _platform.previous();
  147. await _poll();
  148. }
  149. /// Selects an album track. If the queue is still being built, the index is
  150. /// applied as soon as it is ready.
  151. Future<void> playTrackAt(int index) async {
  152. if (_queue == null) {
  153. _pendingIndex = index;
  154. return;
  155. }
  156. if (index < 0 || index >= _queue!.items.length) return;
  157. final selected = await _platform.seekToIndex(index);
  158. if (!selected) {
  159. _error = 'Could not play that track.';
  160. _notify();
  161. return;
  162. }
  163. await _platform.play();
  164. _startPolling();
  165. await _poll();
  166. }
  167. Future<void> seek(Duration position) async {
  168. if (!_snapshot.available) return;
  169. await _platform.seek(position);
  170. await _poll();
  171. }
  172. Future<bool> notificationPermissionGranted() {
  173. return _platform.notificationPermissionGranted();
  174. }
  175. Future<bool> requestNotificationPermission() {
  176. return _platform.requestNotificationPermission();
  177. }
  178. Future<void> stop() async {
  179. _generation++;
  180. await _platform.stop();
  181. _queue = null;
  182. _album = null;
  183. _snapshot = JellyfinPlaybackSnapshot.empty;
  184. _loading = false;
  185. _error = null;
  186. _pendingIndex = null;
  187. _stopPolling();
  188. _notify();
  189. }
  190. /// Reconnects to a service that was already playing when the activity was
  191. /// recreated. The queue itself is not persisted; the snapshot still drives
  192. /// the Home media card.
  193. Future<void> restore() async {
  194. if (_disposed) return;
  195. final snapshot = await _platform.getState();
  196. if (_disposed || !snapshot.available) return;
  197. _snapshot = snapshot;
  198. _startPolling();
  199. _notify();
  200. }
  201. void onAppPaused() {
  202. _stopPolling();
  203. }
  204. void onAppResumed() {
  205. if (!isActive) return;
  206. _startPolling();
  207. unawaited(_poll());
  208. }
  209. Future<void> _poll() async {
  210. if (_disposed || _polling) return;
  211. _polling = true;
  212. try {
  213. final next = await _platform.getState();
  214. if (_disposed) return;
  215. final changed = next != _snapshot;
  216. _snapshot = next;
  217. final nextError = next.error;
  218. if (nextError != null && nextError != _error) {
  219. _error = nextError;
  220. }
  221. if (changed) _notify();
  222. } catch (_) {
  223. // Keep the last known state; the next poll retries.
  224. } finally {
  225. _polling = false;
  226. }
  227. }
  228. void _startPolling() {
  229. if (_disposed || _timer != null) return;
  230. _timer = Timer.periodic(pollInterval, (_) => unawaited(_poll()));
  231. }
  232. void _stopPolling() {
  233. _timer?.cancel();
  234. _timer = null;
  235. }
  236. void _onJellyfinChanged() {
  237. if (!_jellyfin.configured && (_queue != null || _snapshot.available)) {
  238. unawaited(stop());
  239. }
  240. }
  241. void _notify() {
  242. if (!_disposed) notifyListeners();
  243. }
  244. @override
  245. void dispose() {
  246. _disposed = true;
  247. _jellyfin.removeListener(_onJellyfinChanged);
  248. _stopPolling();
  249. _platform.dispose();
  250. super.dispose();
  251. }
  252. }