Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -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();
}
}