Initial commit
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../session/playback_target.dart';
|
||||
import 'playlist.dart';
|
||||
|
||||
|
||||
@immutable
|
||||
class CrossServerPlaylistEntry {
|
||||
const CrossServerPlaylistEntry({
|
||||
required this.serverId,
|
||||
required this.serverName,
|
||||
required this.serverUrl,
|
||||
required this.token,
|
||||
required this.userId,
|
||||
required this.itemId,
|
||||
this.label = '',
|
||||
this.preloadedItem,
|
||||
});
|
||||
|
||||
final String serverId;
|
||||
final String serverName;
|
||||
final String serverUrl;
|
||||
final String token;
|
||||
final String userId;
|
||||
final String itemId;
|
||||
final String label;
|
||||
final EmbyRawItem? preloadedItem;
|
||||
|
||||
PlaybackServerIdentity toIdentity() => PlaybackServerIdentity(
|
||||
serverId: serverId,
|
||||
baseUrl: serverUrl,
|
||||
token: token,
|
||||
userId: userId,
|
||||
serverName: serverName,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is CrossServerPlaylistEntry &&
|
||||
other.serverId == serverId &&
|
||||
other.serverName == serverName &&
|
||||
other.serverUrl == serverUrl &&
|
||||
other.token == token &&
|
||||
other.userId == userId &&
|
||||
other.itemId == itemId &&
|
||||
other.label == label &&
|
||||
other.preloadedItem == preloadedItem;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
serverId,
|
||||
serverName,
|
||||
serverUrl,
|
||||
token,
|
||||
userId,
|
||||
itemId,
|
||||
label,
|
||||
preloadedItem,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
class CrossServerPlaylist implements Playlist {
|
||||
CrossServerPlaylist({
|
||||
required List<CrossServerPlaylistEntry> sequence,
|
||||
required int initialIndex,
|
||||
bool initialPlayFromStart = false,
|
||||
}) : assert(sequence.isNotEmpty, 'cross-server sequence cannot be empty'),
|
||||
assert(initialIndex >= 0 && initialIndex < sequence.length),
|
||||
_entries = List.unmodifiable(sequence),
|
||||
_currentIndex = ValueNotifier<int>(initialIndex),
|
||||
_current = ValueNotifier<PlaylistItem>(
|
||||
_toItem(sequence[initialIndex], playFromStart: initialPlayFromStart),
|
||||
);
|
||||
|
||||
final List<CrossServerPlaylistEntry> _entries;
|
||||
final ValueNotifier<PlaylistItem> _current;
|
||||
final ValueNotifier<int> _currentIndex;
|
||||
|
||||
bool _disposed = false;
|
||||
|
||||
@override
|
||||
ValueListenable<PlaylistItem> get current => _current;
|
||||
@override
|
||||
ValueListenable<int> get currentIndex => _currentIndex;
|
||||
@override
|
||||
int get length => _entries.length;
|
||||
@override
|
||||
bool get hasNext => _currentIndex.value < _entries.length - 1;
|
||||
@override
|
||||
bool get hasPrev => _currentIndex.value > 0;
|
||||
|
||||
@override
|
||||
Future<void> next() => goTo(_currentIndex.value + 1);
|
||||
@override
|
||||
Future<void> prev() => goTo(_currentIndex.value - 1);
|
||||
|
||||
@override
|
||||
Future<void> goTo(int index) async {
|
||||
if (_disposed) return;
|
||||
if (index < 0 || index >= _entries.length) return;
|
||||
if (index == _currentIndex.value) return;
|
||||
final target = _entries[index];
|
||||
_currentIndex.value = index;
|
||||
_current.value = _toItem(target);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> goToItemId(String itemId) async {
|
||||
final idx = _entries.indexWhere((e) => e.itemId == itemId);
|
||||
if (idx < 0) return;
|
||||
await goTo(idx);
|
||||
}
|
||||
|
||||
static PlaylistItem _toItem(
|
||||
CrossServerPlaylistEntry e, {
|
||||
bool playFromStart = false,
|
||||
}) => PlaylistItem(
|
||||
itemId: e.itemId,
|
||||
serverId: e.serverId,
|
||||
serverIdentity: e.toIdentity(),
|
||||
displayLabel: e.label,
|
||||
playFromStart: playFromStart,
|
||||
preloadedItem: e.preloadedItem,
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_current.dispose();
|
||||
_currentIndex.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import 'package:flutter_riverpod/legacy.dart';
|
||||
|
||||
import 'cross_server_playlist.dart';
|
||||
|
||||
|
||||
final pendingCrossServerSequenceProvider =
|
||||
StateProvider<List<CrossServerPlaylistEntry>?>((ref) => null);
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/contracts/library.dart';
|
||||
import '../session/playback_target.dart';
|
||||
|
||||
|
||||
@immutable
|
||||
class PlaylistItem {
|
||||
const PlaylistItem({
|
||||
required this.itemId,
|
||||
this.mediaSourceId,
|
||||
this.startPositionTicks,
|
||||
this.preferredAudioStreamIndex,
|
||||
this.preferredSubtitleStreamIndex,
|
||||
this.serverId,
|
||||
this.serverIdentity,
|
||||
this.displayLabel = '',
|
||||
this.playFromStart = false,
|
||||
this.preloadedItem,
|
||||
});
|
||||
|
||||
final String itemId;
|
||||
final String? mediaSourceId;
|
||||
final int? startPositionTicks;
|
||||
final int? preferredAudioStreamIndex;
|
||||
final int? preferredSubtitleStreamIndex;
|
||||
|
||||
|
||||
final String? serverId;
|
||||
|
||||
|
||||
final PlaybackServerIdentity? serverIdentity;
|
||||
|
||||
|
||||
final String displayLabel;
|
||||
|
||||
|
||||
final bool playFromStart;
|
||||
|
||||
|
||||
final EmbyRawItem? preloadedItem;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
other is PlaylistItem &&
|
||||
other.itemId == itemId &&
|
||||
other.mediaSourceId == mediaSourceId &&
|
||||
other.startPositionTicks == startPositionTicks &&
|
||||
other.preferredAudioStreamIndex == preferredAudioStreamIndex &&
|
||||
other.preferredSubtitleStreamIndex == preferredSubtitleStreamIndex &&
|
||||
other.serverId == serverId &&
|
||||
other.serverIdentity == serverIdentity &&
|
||||
other.displayLabel == displayLabel &&
|
||||
other.playFromStart == playFromStart &&
|
||||
other.preloadedItem == preloadedItem;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(
|
||||
itemId,
|
||||
mediaSourceId,
|
||||
startPositionTicks,
|
||||
preferredAudioStreamIndex,
|
||||
preferredSubtitleStreamIndex,
|
||||
serverId,
|
||||
serverIdentity,
|
||||
displayLabel,
|
||||
playFromStart,
|
||||
preloadedItem,
|
||||
);
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'PlaylistItem(itemId=$itemId, serverId=$serverId, label=$displayLabel)';
|
||||
}
|
||||
|
||||
|
||||
abstract class Playlist {
|
||||
|
||||
ValueListenable<PlaylistItem> get current;
|
||||
|
||||
|
||||
ValueListenable<int> get currentIndex;
|
||||
|
||||
int get length;
|
||||
bool get hasNext;
|
||||
bool get hasPrev;
|
||||
|
||||
Future<void> next();
|
||||
Future<void> prev();
|
||||
Future<void> goTo(int index);
|
||||
Future<void> goToItemId(String itemId);
|
||||
void dispose();
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import '../../../core/contracts/library.dart';
|
||||
import 'cross_server_playlist.dart';
|
||||
import 'playlist.dart';
|
||||
import 'series_episode_playlist.dart';
|
||||
import 'single_item_playlist.dart';
|
||||
|
||||
typedef LoadDetailFn = Future<MediaDetailRes> Function(String itemId);
|
||||
|
||||
|
||||
Future<Playlist> playlistFromLaunch({
|
||||
required String itemId,
|
||||
String? mediaSourceId,
|
||||
int? startPositionTicks,
|
||||
int? preferredAudioStreamIndex,
|
||||
int? preferredSubtitleStreamIndex,
|
||||
bool playFromStart = false,
|
||||
List<CrossServerPlaylistEntry>? crossServerSequence,
|
||||
required LoadDetailFn loadDetail,
|
||||
required LoadEpisodesFn loadEpisodes,
|
||||
required LoadSeasonsFn loadSeasons,
|
||||
}) async {
|
||||
if (crossServerSequence != null && crossServerSequence.isNotEmpty) {
|
||||
var initialIndex = crossServerSequence.indexWhere(
|
||||
(e) => e.itemId == itemId,
|
||||
);
|
||||
if (initialIndex < 0) initialIndex = 0;
|
||||
return CrossServerPlaylist(
|
||||
sequence: crossServerSequence,
|
||||
initialIndex: initialIndex,
|
||||
initialPlayFromStart: playFromStart,
|
||||
);
|
||||
}
|
||||
|
||||
final detail = await loadDetail(itemId);
|
||||
final base = detail.base;
|
||||
if (base.Type == 'Episode' &&
|
||||
base.SeriesId != null &&
|
||||
base.SeasonId != null) {
|
||||
final seriesPlaylist = SeriesEpisodePlaylist(
|
||||
seriesId: base.SeriesId!,
|
||||
loadEpisodes: loadEpisodes,
|
||||
loadSeasons: loadSeasons,
|
||||
);
|
||||
await seriesPlaylist.initialize(
|
||||
seasonId: base.SeasonId!,
|
||||
episodeId: itemId,
|
||||
initialIndexNumber: base.IndexNumber,
|
||||
initialMediaSourceId: mediaSourceId,
|
||||
initialStartPositionTicks: startPositionTicks,
|
||||
initialPreferredAudioStreamIndex: preferredAudioStreamIndex,
|
||||
initialPreferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
|
||||
initialPlayFromStart: playFromStart,
|
||||
preloadedItem: base,
|
||||
);
|
||||
return seriesPlaylist;
|
||||
}
|
||||
|
||||
return SingleItemPlaylist(
|
||||
item: PlaylistItem(
|
||||
itemId: itemId,
|
||||
mediaSourceId: mediaSourceId,
|
||||
startPositionTicks: startPositionTicks,
|
||||
preferredAudioStreamIndex: preferredAudioStreamIndex,
|
||||
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
|
||||
playFromStart: playFromStart,
|
||||
preloadedItem: base,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'playlist.dart';
|
||||
|
||||
|
||||
class SingleItemPlaylist implements Playlist {
|
||||
SingleItemPlaylist({required PlaylistItem item})
|
||||
: _current = ValueNotifier<PlaylistItem>(item),
|
||||
_currentIndex = ValueNotifier<int>(0);
|
||||
|
||||
final ValueNotifier<PlaylistItem> _current;
|
||||
final ValueNotifier<int> _currentIndex;
|
||||
|
||||
@override
|
||||
ValueListenable<PlaylistItem> get current => _current;
|
||||
@override
|
||||
ValueListenable<int> get currentIndex => _currentIndex;
|
||||
@override
|
||||
int get length => 1;
|
||||
@override
|
||||
bool get hasNext => false;
|
||||
@override
|
||||
bool get hasPrev => false;
|
||||
|
||||
@override
|
||||
Future<void> next() async {}
|
||||
@override
|
||||
Future<void> prev() async {}
|
||||
@override
|
||||
Future<void> goTo(int index) async {}
|
||||
@override
|
||||
Future<void> goToItemId(String itemId) async {}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_current.dispose();
|
||||
_currentIndex.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user