Files
sm-emby-share/lib/features/player/playlist/series_episode_playlist.dart
T
2026-07-14 11:11:36 +08:00

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();
}
}