254 lines
7.2 KiB
Dart
254 lines
7.2 KiB
Dart
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<List<EmbyRawItem>> Function({
|
|
required String seriesId,
|
|
required String seasonId,
|
|
});
|
|
|
|
typedef LoadSeasonsFn = Future<List<EmbyRawSeason>> Function(String seriesId);
|
|
|
|
|
|
class SeriesEpisodePlaylist implements Playlist {
|
|
SeriesEpisodePlaylist({
|
|
required this.seriesId,
|
|
required this.loadEpisodes,
|
|
required this.loadSeasons,
|
|
}) : _current = ValueNotifier<PlaylistItem>(_placeholder),
|
|
_currentIndex = ValueNotifier<int>(-1);
|
|
|
|
final String seriesId;
|
|
final LoadEpisodesFn loadEpisodes;
|
|
final LoadSeasonsFn loadSeasons;
|
|
|
|
final ValueNotifier<PlaylistItem> _current;
|
|
final ValueNotifier<int> _currentIndex;
|
|
|
|
String? _currentSeasonId;
|
|
List<EmbyRawItem> _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<void> 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<void> _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<PlaylistItem> get current => _current;
|
|
@override
|
|
ValueListenable<int> get currentIndex => _currentIndex;
|
|
@override
|
|
int get length => _episodes.length;
|
|
|
|
|
|
@override
|
|
bool get hasNext => _episodes.isNotEmpty;
|
|
@override
|
|
bool get hasPrev => _episodes.isNotEmpty;
|
|
|
|
@override
|
|
Future<void> 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<void> 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<void> goTo(int index) async {
|
|
if (_disposed || index < 0 || index >= _episodes.length) return;
|
|
if (index == _currentIndex.value) return;
|
|
_markMoved();
|
|
_setCurrent(index);
|
|
}
|
|
|
|
@override
|
|
Future<void> 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<void> 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<void> _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<void> _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();
|
|
}
|
|
}
|