import 'dart:async'; import 'package:flutter/foundation.dart'; import '../../../core/contracts/library.dart'; import '../../../shared/utils/app_logger.dart'; import '../../../shared/utils/format_utils.dart'; import 'playlist.dart'; typedef LoadEpisodesFn = Future> Function({ required String seriesId, required String seasonId, }); typedef LoadSeasonsFn = Future> Function(String seriesId); class SeriesEpisodePlaylist implements Playlist { SeriesEpisodePlaylist({ required this.seriesId, required this.loadEpisodes, required this.loadSeasons, }) : _current = ValueNotifier(_placeholder), _currentIndex = ValueNotifier(-1); final String seriesId; final LoadEpisodesFn loadEpisodes; final LoadSeasonsFn loadSeasons; final ValueNotifier _current; final ValueNotifier _currentIndex; String? _currentSeasonId; List _episodes = const []; String? _initialMediaSourceId; int? _initialStartPositionTicks; int? _initialPreferredAudio; int? _initialPreferredSub; bool _initialPlayFromStart = false; bool _initialApplied = false; bool _disposed = false; bool _userNavigated = false; static const PlaylistItem _placeholder = PlaylistItem(itemId: ''); Future initialize({ required String seasonId, required String episodeId, int? initialIndexNumber, String? initialMediaSourceId, int? initialStartPositionTicks, int? initialPreferredAudioStreamIndex, int? initialPreferredSubtitleStreamIndex, bool initialPlayFromStart = false, EmbyRawItem? preloadedItem, }) async { _initialMediaSourceId = initialMediaSourceId; _initialStartPositionTicks = initialStartPositionTicks; _initialPreferredAudio = initialPreferredAudioStreamIndex; _initialPreferredSub = initialPreferredSubtitleStreamIndex; _initialPlayFromStart = initialPlayFromStart; _initialApplied = true; _currentSeasonId = seasonId; _current.value = PlaylistItem( itemId: episodeId, mediaSourceId: initialMediaSourceId, startPositionTicks: initialStartPositionTicks, preferredAudioStreamIndex: initialPreferredAudioStreamIndex, preferredSubtitleStreamIndex: initialPreferredSubtitleStreamIndex, playFromStart: initialPlayFromStart, displayLabel: preloadedItem != null ? _labelOf(preloadedItem) : '', preloadedItem: preloadedItem, ); unawaited(_resolveEpisodeList(seasonId, episodeId, initialIndexNumber)); } Future _resolveEpisodeList( String seasonId, String episodeId, int? initialIndexNumber, ) async { try { await _loadSeasonEpisodes(seasonId); } catch (e) { AppLogger.warn( 'SeriesEpisodePlaylist', 'background episode list load failed', e, ); return; } if (_disposed || _userNavigated) return; var idx = _episodes.indexWhere((e) => e.Id == episodeId); if (idx < 0 && initialIndexNumber != null) { idx = _episodes.indexWhere((e) => e.IndexNumber == initialIndexNumber); } AppLogger.debug( 'SeriesEpisodePlaylist', 'episodeListReady seriesId=$seriesId seasonId=$seasonId ' 'episodeId=$episodeId idx=$idx ' 'episodes=${_episodes.length} ' 'episodeIds=${_episodes.map((e) => e.Id).toList()}', ); if (_episodes.isEmpty) return; _currentIndex.value = idx < 0 ? 0 : idx; } @override ValueListenable get current => _current; @override ValueListenable get currentIndex => _currentIndex; @override int get length => _episodes.length; @override bool get hasNext => _episodes.isNotEmpty; @override bool get hasPrev => _episodes.isNotEmpty; @override Future next() async { if (_disposed || _episodes.isEmpty) return; final cur = _currentIndex.value; if (cur < _episodes.length - 1) { _markMoved(); _setCurrent(cur + 1); return; } await _crossSeason(direction: 1); } @override Future prev() async { if (_disposed || _episodes.isEmpty) return; final cur = _currentIndex.value; if (cur > 0) { _markMoved(); _setCurrent(cur - 1); return; } await _crossSeason(direction: -1); } @override Future goTo(int index) async { if (_disposed || index < 0 || index >= _episodes.length) return; if (index == _currentIndex.value) return; _markMoved(); _setCurrent(index); } @override Future goToItemId(String itemId) async { if (_disposed) return; final idx = _episodes.indexWhere((e) => e.Id == itemId); if (idx < 0) return; if (idx == _currentIndex.value) return; _markMoved(); _setCurrent(idx); } Future goToInSeason({ required String seasonId, required String episodeId, }) async { if (_disposed) return; if (seasonId != _currentSeasonId || _episodes.isEmpty) { await _loadSeasonEpisodes(seasonId); if (_disposed) return; } final idx = _episodes.indexWhere((e) => e.Id == episodeId); if (idx < 0) return; if (_currentSeasonId == seasonId && idx == _currentIndex.value) return; _markMoved(); _setCurrent(idx); } Future _crossSeason({required int direction}) async { final curSeason = _currentSeasonId; if (curSeason == null) return; final seasons = await loadSeasons(seriesId); if (_disposed) return; final ordered = [...seasons] ..sort((a, b) => (a.IndexNumber ?? 0).compareTo(b.IndexNumber ?? 0)); final curPos = ordered.indexWhere((s) => s.Id == curSeason); if (curPos < 0) return; final targetPos = curPos + direction; if (targetPos < 0 || targetPos >= ordered.length) return; final target = ordered[targetPos]; await _loadSeasonEpisodes(target.Id); if (_disposed || _episodes.isEmpty) return; _markMoved(); _setCurrent(direction > 0 ? 0 : _episodes.length - 1); } Future _loadSeasonEpisodes(String seasonId) async { final eps = await loadEpisodes(seriesId: seriesId, seasonId: seasonId); if (_disposed) return; _currentSeasonId = seasonId; _episodes = List.unmodifiable(eps); } void _markMoved() { _initialApplied = true; _userNavigated = true; } void _setCurrent(int index) { _currentIndex.value = index; _current.value = _itemFromEpisode(_episodes[index]); } PlaylistItem _itemFromEpisode(EmbyRawItem ep) { final useInitial = !_initialApplied; _initialApplied = true; return PlaylistItem( itemId: ep.Id, mediaSourceId: useInitial ? _initialMediaSourceId : null, startPositionTicks: useInitial ? _initialStartPositionTicks : null, preferredAudioStreamIndex: useInitial ? _initialPreferredAudio : null, preferredSubtitleStreamIndex: useInitial ? _initialPreferredSub : null, displayLabel: _labelOf(ep), playFromStart: useInitial && _initialPlayFromStart, preloadedItem: ep, ); } String _labelOf(EmbyRawItem ep) { final ep0 = ep.IndexNumber; final name = ep.Name; if (ep0 != null && name.isNotEmpty) { return '${formatEpisodeNumber(ep0)} $name'; } if (name.isNotEmpty) return name; return ep.Id; } @override void dispose() { _disposed = true; _current.dispose(); _currentIndex.dispose(); } }