Initial commit
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user