feat(app): 完善播放协议搜索与空状态
This commit is contained in:
1 parent
671e0ce143
commit
ef29fc5f15
85 files changed
+2976
-768
No files matched your search
@@ -14,6 +14,7 @@ class PlayTicket {
|
||||
this.unlockCoin = 0,
|
||||
this.previewSeconds = 0,
|
||||
this.qualities = const [],
|
||||
this.tracks = const [],
|
||||
this.thumbnailsUrl,
|
||||
this.likeCount,
|
||||
this.commentCount,
|
||||
@@ -39,8 +40,11 @@ class PlayTicket {
|
||||
/// 试看秒数:仅 `COIN_REQUIRED` 时有意义(付费解锁在 B2 做)。
|
||||
final int previewSeconds;
|
||||
|
||||
/// 后端下发的清晰度档位(如 AUTO / 720P / 1080P),HLS 由播放器自动切换。
|
||||
/// 后端下发的清晰度档位(如 AUTO / 720p / 1080p)。
|
||||
final List<String> qualities;
|
||||
|
||||
/// 各档签名地址。AUTO 是主列表,点选其它档位时换成对应地址。
|
||||
final List<PlayQualityTrack> tracks;
|
||||
final String? thumbnailsUrl;
|
||||
final int? likeCount;
|
||||
final int? commentCount;
|
||||
@@ -64,6 +68,7 @@ class PlayTicket {
|
||||
.whereType<String>()
|
||||
.where((quality) => quality.isNotEmpty)
|
||||
.toList(),
|
||||
tracks: _tracks(json['tracks'], json['playUrl'] as String? ?? ''),
|
||||
thumbnailsUrl: _optionalString(json['thumbnailsUrl']),
|
||||
likeCount: json['likeCount'] == null
|
||||
? null
|
||||
@@ -75,6 +80,67 @@ class PlayTicket {
|
||||
}
|
||||
}
|
||||
|
||||
List<PlayQualityTrack> _tracks(dynamic raw, String playUrl) {
|
||||
final tracks = <PlayQualityTrack>[];
|
||||
if (raw is List) {
|
||||
for (final item in raw) {
|
||||
if (item is! Map) continue;
|
||||
final quality = item['quality'] as String? ?? '';
|
||||
final url = item['playUrl'] as String? ?? '';
|
||||
if (quality.isEmpty || url.isEmpty) continue;
|
||||
tracks.add(PlayQualityTrack(quality: quality, playUrl: url));
|
||||
}
|
||||
}
|
||||
if (tracks.isEmpty && playUrl.isNotEmpty) {
|
||||
tracks.add(PlayQualityTrack(quality: 'AUTO', playUrl: playUrl));
|
||||
}
|
||||
return tracks;
|
||||
}
|
||||
|
||||
/// 按票据里的分轨地址取播放地址;没有分轨时,从主列表路径推出同目录的单档地址。
|
||||
String? qualityPlayUrl({
|
||||
required String quality,
|
||||
required List<PlayQualityTrack> tracks,
|
||||
required String currentUrl,
|
||||
}) {
|
||||
final wanted = quality.toUpperCase();
|
||||
for (final track in tracks) {
|
||||
if (track.quality.toUpperCase() == wanted && track.playUrl.isNotEmpty) {
|
||||
return track.playUrl;
|
||||
}
|
||||
}
|
||||
return siblingRenditionUrl(currentUrl, wanted);
|
||||
}
|
||||
|
||||
/// `.../master.m3u8` 与 `.../720p/index.m3u8` 互相换算,查询参数原样保留。
|
||||
String? siblingRenditionUrl(String currentUrl, String quality) {
|
||||
final uri = Uri.tryParse(currentUrl);
|
||||
if (uri == null || uri.path.isEmpty) return null;
|
||||
final path = uri.path;
|
||||
const masterSuffix = '/master.m3u8';
|
||||
String? root;
|
||||
if (path.endsWith(masterSuffix)) {
|
||||
root = path.substring(0, path.length - masterSuffix.length);
|
||||
} else {
|
||||
final match = RegExp(r'/(\d+p)/index\.m3u8$').firstMatch(path);
|
||||
if (match == null) return null;
|
||||
root = path.substring(0, match.start);
|
||||
}
|
||||
final next = quality.toUpperCase() == 'AUTO'
|
||||
? '$root$masterSuffix'
|
||||
: '$root/${quality.toLowerCase()}/index.m3u8';
|
||||
if (next == path) return currentUrl;
|
||||
return uri.replace(path: next).toString();
|
||||
}
|
||||
|
||||
/// 一档清晰度的签名播放地址。
|
||||
class PlayQualityTrack {
|
||||
const PlayQualityTrack({required this.quality, required this.playUrl});
|
||||
|
||||
final String quality;
|
||||
final String playUrl;
|
||||
}
|
||||
|
||||
String? _optionalString(dynamic value) {
|
||||
final text = value is String ? value.trim() : '';
|
||||
return text.isEmpty ? null : text;
|
||||
|
||||
@@ -18,6 +18,7 @@ class WatchProgress {
|
||||
this.totalEpisodes,
|
||||
this.finished = false,
|
||||
this.playCount = 0,
|
||||
this.viewCount,
|
||||
this.updatedAt,
|
||||
});
|
||||
|
||||
@@ -31,6 +32,9 @@ class WatchProgress {
|
||||
final Duration duration;
|
||||
final bool finished;
|
||||
final int playCount;
|
||||
|
||||
/// 短剧热度。历史列表没下发时为空,不能拿用户自己的 playCount 冒充。
|
||||
final int? viewCount;
|
||||
final DateTime? updatedAt;
|
||||
|
||||
bool get hasPosition => position > Duration.zero;
|
||||
@@ -57,6 +61,9 @@ class WatchProgress {
|
||||
duration: Duration(milliseconds: jsonToInt(json['durationMs'])),
|
||||
finished: json['finished'] == true,
|
||||
playCount: jsonToInt(json['playCount']),
|
||||
viewCount: json['viewCount'] == null
|
||||
? null
|
||||
: jsonToInt(json['viewCount']),
|
||||
updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? ''),
|
||||
);
|
||||
}
|
||||
@@ -72,6 +79,7 @@ class WatchProgress {
|
||||
'durationMs': duration.inMilliseconds,
|
||||
'finished': finished,
|
||||
'playCount': playCount,
|
||||
if (viewCount != null) 'viewCount': viewCount,
|
||||
if (updatedAt != null) 'updatedAt': updatedAt!.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,7 +148,8 @@ class ApiClient {
|
||||
skipAuthRetry: skipAuthRetry,
|
||||
);
|
||||
}
|
||||
onUnauthorized?.call();
|
||||
final token = accessTokenReader?.call();
|
||||
if (token == null || token.isEmpty) onUnauthorized?.call();
|
||||
throw ApiException(401, cause.message ?? '登录已过期');
|
||||
}
|
||||
if (cause is ApiException) {
|
||||
|
||||
@@ -33,6 +33,7 @@ class AuthApi {
|
||||
required String code,
|
||||
required String deviceId,
|
||||
String platform = '',
|
||||
String osVersion = '',
|
||||
String appVersion = '',
|
||||
}) async {
|
||||
final data = await _client.post(
|
||||
@@ -43,6 +44,7 @@ class AuthApi {
|
||||
'code': code,
|
||||
'deviceId': deviceId,
|
||||
'platform': platform,
|
||||
'osVersion': osVersion,
|
||||
'appVersion': appVersion,
|
||||
},
|
||||
);
|
||||
@@ -55,6 +57,7 @@ class AuthApi {
|
||||
required String code,
|
||||
required String deviceId,
|
||||
String platform = '',
|
||||
String osVersion = '',
|
||||
String appVersion = '',
|
||||
}) async {
|
||||
final data = await _client.post(
|
||||
@@ -65,6 +68,7 @@ class AuthApi {
|
||||
'code': code,
|
||||
'deviceId': deviceId,
|
||||
'platform': platform,
|
||||
'osVersion': osVersion,
|
||||
'appVersion': appVersion,
|
||||
},
|
||||
);
|
||||
@@ -76,6 +80,7 @@ class AuthApi {
|
||||
required String password,
|
||||
required String deviceId,
|
||||
String platform = '',
|
||||
String osVersion = '',
|
||||
String appVersion = '',
|
||||
}) async {
|
||||
final data = await _client.post(
|
||||
@@ -87,6 +92,7 @@ class AuthApi {
|
||||
'countryCode': 'ID',
|
||||
'deviceId': deviceId,
|
||||
'platform': platform,
|
||||
'osVersion': osVersion,
|
||||
'appVersion': appVersion,
|
||||
},
|
||||
);
|
||||
@@ -98,6 +104,7 @@ class AuthApi {
|
||||
required String password,
|
||||
required String deviceId,
|
||||
String platform = '',
|
||||
String osVersion = '',
|
||||
String appVersion = '',
|
||||
}) async {
|
||||
final data = await _client.post(
|
||||
@@ -108,18 +115,25 @@ class AuthApi {
|
||||
'password': password,
|
||||
'deviceId': deviceId,
|
||||
'platform': platform,
|
||||
'osVersion': osVersion,
|
||||
'appVersion': appVersion,
|
||||
},
|
||||
);
|
||||
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
Future<AuthSession> refresh(String refreshToken) async {
|
||||
Future<AuthSession> refresh(
|
||||
String refreshToken, {
|
||||
String platform = '',
|
||||
String osVersion = '',
|
||||
}) async {
|
||||
final data = await _client.post(
|
||||
'/app/auth/refresh',
|
||||
body: {'refreshToken': refreshToken},
|
||||
// 刷新请求本身不带旧令牌重试,失败就是失败
|
||||
skipAuthRetry: true,
|
||||
body: {
|
||||
'refreshToken': refreshToken,
|
||||
'platform': platform,
|
||||
'osVersion': osVersion,
|
||||
},
|
||||
);
|
||||
return AuthSession.fromJson((data as Map).cast<String, dynamic>());
|
||||
}
|
||||
|
||||
@@ -36,4 +36,9 @@ class LibraryApi {
|
||||
Future<void> removeFavorite(String dramaId) async {
|
||||
await _client.delete('/app/favorites/$dramaId');
|
||||
}
|
||||
|
||||
/// 一次取消多部短剧,请求体为 dramaIds 数组。
|
||||
Future<void> removeFavorites(List<String> dramaIds) async {
|
||||
await _client.post('/app/favorites/remove', body: {'dramaIds': dramaIds});
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ abstract interface class FavoriteRepository {
|
||||
Future<PageResult<Drama>> page({int pageIndex = 1, int pageSize = 20});
|
||||
|
||||
Future<void> remove(String dramaId);
|
||||
|
||||
/// 一次取消多部短剧。
|
||||
Future<void> removeMany(List<String> dramaIds);
|
||||
}
|
||||
|
||||
class HttpFavoriteRepository implements FavoriteRepository {
|
||||
@@ -36,6 +39,10 @@ class HttpFavoriteRepository implements FavoriteRepository {
|
||||
|
||||
@override
|
||||
Future<void> remove(String dramaId) => _api.removeFavorite(dramaId);
|
||||
|
||||
@override
|
||||
Future<void> removeMany(List<String> dramaIds) =>
|
||||
_api.removeFavorites(dramaIds);
|
||||
}
|
||||
|
||||
/// 内存实现:单测与 widget 测试注入用。
|
||||
@@ -51,6 +58,7 @@ class InMemoryFavoriteRepository implements FavoriteRepository {
|
||||
bool failRemove = false;
|
||||
int toggleCalls = 0;
|
||||
int pageCalls = 0;
|
||||
int removeManyCalls = 0;
|
||||
|
||||
@override
|
||||
Future<bool> toggle(String dramaId) async {
|
||||
@@ -80,4 +88,11 @@ class InMemoryFavoriteRepository implements FavoriteRepository {
|
||||
if (failRemove) throw StateError('favorite unavailable');
|
||||
_saved.remove(dramaId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeMany(List<String> dramaIds) async {
|
||||
removeManyCalls += 1;
|
||||
if (failRemove) throw StateError('favorite unavailable');
|
||||
_saved.removeAll(dramaIds);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/auth_session.dart';
|
||||
@@ -9,7 +11,7 @@ import 'auth_session_store.dart';
|
||||
///
|
||||
/// 职责:本地会话的读写([AuthSessionStore])、注册/登录/登出、令牌刷新;
|
||||
/// 也是 [ApiClient] 的 `accessTokenReader` / `tokenRefresher` / `onUnauthorized` 实现方 ——
|
||||
/// 401 → 刷新 → 重试失败就清会话,UI 通过 [isSignedIn] 自动回到未登录态。
|
||||
/// 401 → 刷新 → 重试。令牌被拒绝才清会话,服务中断时保持登录。
|
||||
class AuthController extends ChangeNotifier {
|
||||
AuthController(this._api, this._store);
|
||||
|
||||
@@ -19,6 +21,10 @@ class AuthController extends ChangeNotifier {
|
||||
AuthSession? _session;
|
||||
String _deviceId = '';
|
||||
bool _loaded = false;
|
||||
Future<bool>? _refreshing;
|
||||
|
||||
/// 登录已过期、账号停用才清本地会话。服务不可达不能当成退出登录。
|
||||
static const Set<int> _rejectedSessionCodes = {401, 100001};
|
||||
|
||||
bool get isLoaded => _loaded;
|
||||
bool get isSignedIn => _session != null;
|
||||
@@ -43,6 +49,7 @@ class AuthController extends ChangeNotifier {
|
||||
password: password,
|
||||
deviceId: await _store.deviceId(),
|
||||
platform: _platform,
|
||||
osVersion: _osVersion,
|
||||
);
|
||||
await _apply(session);
|
||||
}
|
||||
@@ -53,14 +60,12 @@ class AuthController extends ChangeNotifier {
|
||||
password: password,
|
||||
deviceId: await _store.deviceId(),
|
||||
platform: _platform,
|
||||
osVersion: _osVersion,
|
||||
);
|
||||
await _apply(session);
|
||||
}
|
||||
|
||||
Future<void> sendOtp({
|
||||
required String target,
|
||||
String scene = 'LOGIN',
|
||||
}) async {
|
||||
Future<void> sendOtp({required String target, String scene = 'LOGIN'}) async {
|
||||
await _api.sendOtp(
|
||||
target: target,
|
||||
scene: scene,
|
||||
@@ -77,6 +82,7 @@ class AuthController extends ChangeNotifier {
|
||||
code: code,
|
||||
deviceId: await _store.deviceId(),
|
||||
platform: _platform,
|
||||
osVersion: _osVersion,
|
||||
);
|
||||
await _apply(session);
|
||||
}
|
||||
@@ -90,10 +96,17 @@ class AuthController extends ChangeNotifier {
|
||||
code: code,
|
||||
deviceId: await _store.deviceId(),
|
||||
platform: _platform,
|
||||
osVersion: _osVersion,
|
||||
);
|
||||
await _apply(session);
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
_session = null;
|
||||
await _store.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> signOut() async {
|
||||
final refreshToken = _session?.refreshToken ?? '';
|
||||
_session = null;
|
||||
@@ -107,29 +120,43 @@ class AuthController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 供 [ApiClient] 调用:刷新令牌并落盘。失败时清本地会话。
|
||||
Future<bool> refresh() async {
|
||||
/// 供 [ApiClient] 调用:刷新令牌并落盘。
|
||||
///
|
||||
/// 同一时刻只刷新一次,避免重启期间并发 401 把新令牌轮换掉。
|
||||
/// 网络失败或 5xx 保留本地会话;只有令牌被拒绝才退出登录。
|
||||
Future<bool> refresh() {
|
||||
final current = _refreshing;
|
||||
if (current != null) return current;
|
||||
final run = _refreshSingle();
|
||||
_refreshing = run;
|
||||
return run.whenComplete(() {
|
||||
if (identical(_refreshing, run)) _refreshing = null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> _refreshSingle() async {
|
||||
final refreshToken = _session?.refreshToken ?? '';
|
||||
if (refreshToken.isEmpty) {
|
||||
await clear();
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
await _apply(await _api.refresh(refreshToken));
|
||||
await _apply(
|
||||
await _api.refresh(
|
||||
refreshToken,
|
||||
platform: _platform,
|
||||
osVersion: _osVersion,
|
||||
),
|
||||
);
|
||||
return true;
|
||||
} on ApiException catch (error) {
|
||||
if (_rejectedSessionCodes.contains(error.code)) await clear();
|
||||
return false;
|
||||
} catch (_) {
|
||||
await clear();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
if (_session == null) return;
|
||||
_session = null;
|
||||
await _store.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 资料保存成功后同步登录态摘要,保留当前令牌。
|
||||
Future<void> applyProfile(AuthUser user) async {
|
||||
final current = _session;
|
||||
@@ -154,4 +181,9 @@ class AuthController extends ChangeNotifier {
|
||||
if (kIsWeb) return 'WEB';
|
||||
return defaultTargetPlatform == TargetPlatform.iOS ? 'IOS' : 'ANDROID';
|
||||
}
|
||||
|
||||
String get _osVersion {
|
||||
if (kIsWeb) return '';
|
||||
return Platform.operatingSystemVersion;
|
||||
}
|
||||
}
|
||||
@@ -161,6 +161,37 @@ class FavoriteController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次取消多部收藏。失败时整批回滚。
|
||||
Future<bool> removeMany(List<String> dramaIds) async {
|
||||
final ids = dramaIds.toSet();
|
||||
if (ids.isEmpty) return true;
|
||||
final previousCards = cards;
|
||||
final previousTotal = total;
|
||||
final previousHasMore = hasMore;
|
||||
final removed = cards.where((drama) => ids.contains(drama.id)).toList();
|
||||
if (removed.isEmpty) return true;
|
||||
cards = cards.where((drama) => !ids.contains(drama.id)).toList();
|
||||
for (final id in ids) {
|
||||
_saved.remove(id);
|
||||
}
|
||||
total = total > removed.length ? total - removed.length : 0;
|
||||
hasMore = cards.length < total;
|
||||
notify();
|
||||
try {
|
||||
await _repository.removeMany(dramaIds);
|
||||
return true;
|
||||
} catch (_) {
|
||||
cards = previousCards;
|
||||
total = previousTotal;
|
||||
hasMore = previousHasMore;
|
||||
for (final drama in removed) {
|
||||
_saved.add(drama.id);
|
||||
}
|
||||
notify();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消收藏("我的"页列表里删除某条)。
|
||||
Future<bool> remove(String dramaId) async {
|
||||
final index = cards.indexWhere((drama) => drama.id == dramaId);
|
||||
|
||||
@@ -5,6 +5,10 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/drama.dart';
|
||||
import '../models/player_port.dart';
|
||||
import 'video_player_port.dart';
|
||||
|
||||
/// 新纹理第一帧常是黑的,旧画面只盖住这一小段。
|
||||
const Duration _qualityHandoffHold = Duration(milliseconds: 200);
|
||||
|
||||
typedef PlayerFactory = PlayerPort Function(Episode episode);
|
||||
typedef PlayerPreparation =
|
||||
@@ -66,6 +70,7 @@ class PlaybackSession extends ChangeNotifier {
|
||||
final Set<_PlayerSlot> _live = {};
|
||||
_PlayerSlot? _active;
|
||||
_PlayerSlot? _next;
|
||||
_PlayerSlot? _handoff;
|
||||
Future<void> _operations = Future<void>.value();
|
||||
Future<void> _preloadTask = Future<void>.value();
|
||||
int _generation = 0;
|
||||
@@ -127,6 +132,15 @@ class PlaybackSession extends ChangeNotifier {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 清晰度切换时盖住黑帧的旧播放器。只在仍由会话持有时交给界面。
|
||||
PlayerPort? handoffAt(int index) {
|
||||
final slot = _handoff;
|
||||
if (_closed || slot == null || slot.retiring || slot.index != index) {
|
||||
return null;
|
||||
}
|
||||
return slot.player;
|
||||
}
|
||||
|
||||
/// 队列重排后把当前仍在播放的 slot 映射到新索引,不重新创建播放器。
|
||||
void remapCurrentIndex(int index, {int? preloadIndex}) {
|
||||
if (_closed || index < 0 || index >= episodes.length) return;
|
||||
@@ -184,9 +198,10 @@ class PlaybackSession extends ChangeNotifier {
|
||||
Future<void> _retire(_PlayerSlot? slot) async {
|
||||
if (slot == null || !_live.contains(slot)) return;
|
||||
slot.retiring = true;
|
||||
final cleared = _active == slot || _next == slot;
|
||||
final cleared = _active == slot || _next == slot || _handoff == slot;
|
||||
if (_active == slot) _active = null;
|
||||
if (_next == slot) _next = null;
|
||||
if (_handoff == slot) _handoff = null;
|
||||
if (cleared) _notify();
|
||||
await waitForFrame?.call();
|
||||
await slot.ready;
|
||||
@@ -235,6 +250,7 @@ class PlaybackSession extends ChangeNotifier {
|
||||
_PlayerSlot? target;
|
||||
await _enqueue(() async {
|
||||
if (_closed || request != _generation) return;
|
||||
await _retire(_handoff);
|
||||
final old = _active;
|
||||
if (old != null &&
|
||||
old.index == index &&
|
||||
@@ -272,6 +288,108 @@ class PlaybackSession extends ChangeNotifier {
|
||||
if (!_closed && request == _generation) _schedulePreload();
|
||||
}
|
||||
|
||||
/// 后台准备新片源,成功后再替换当前播放器。失败时原播放器保持不动。
|
||||
Future<bool> replaceCurrent() async {
|
||||
if (_closed || !_selected) return false;
|
||||
final index = _currentIndex;
|
||||
if (index < 0 || index >= episodes.length || isLocked) return false;
|
||||
if (!canPlay(episodes[index])) return false;
|
||||
final request = _generation;
|
||||
final slot = await _prepareReplacement(index, request);
|
||||
if (slot == null) {
|
||||
await _resumeActive();
|
||||
return false;
|
||||
}
|
||||
await slot.ready;
|
||||
if (!_replacementReady(slot, request)) {
|
||||
await _dropReplacement(slot);
|
||||
await _resumeActive();
|
||||
return false;
|
||||
}
|
||||
final previous = await _showReplacement(slot, request);
|
||||
if (_active != slot) {
|
||||
await _dropReplacement(slot);
|
||||
return false;
|
||||
}
|
||||
await _enqueue(() => _start(slot, request));
|
||||
await Future<void>.delayed(_qualityHandoffHold);
|
||||
if (previous != null && _handoff == previous && _active == slot) {
|
||||
await _enqueue(() => _retire(previous));
|
||||
}
|
||||
if (!_closed && request == _generation) _schedulePreload();
|
||||
return _active == slot && slot.failure == null;
|
||||
}
|
||||
|
||||
Future<_PlayerSlot?> _prepareReplacement(int index, int request) async {
|
||||
_PlayerSlot? created;
|
||||
await _enqueue(() async {
|
||||
if (_closed || request != _generation || _active == null) return;
|
||||
final warm = _next;
|
||||
_next = null;
|
||||
await _retire(_handoff);
|
||||
await _retire(warm);
|
||||
if (_closed || request != _generation || _active == null) return;
|
||||
try {
|
||||
created = _create(index);
|
||||
} catch (_) {
|
||||
created = null;
|
||||
}
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
bool _replacementReady(_PlayerSlot slot, int request) {
|
||||
return !_closed &&
|
||||
request == _generation &&
|
||||
_active != null &&
|
||||
slot.failure == null &&
|
||||
slot.prepared;
|
||||
}
|
||||
|
||||
Future<void> _resumeActive() async {
|
||||
final slot = _active;
|
||||
if (slot == null || !_userWantsPlay || _closed) return;
|
||||
await _enqueue(() => _applyPlayback(slot));
|
||||
}
|
||||
|
||||
Future<void> _dropReplacement(_PlayerSlot slot) {
|
||||
return _enqueue(() async {
|
||||
if (_live.contains(slot)) await _retire(slot);
|
||||
});
|
||||
}
|
||||
|
||||
/// 先把已出画的新播放器交到界面上,旧播放器留给调用方稍后释放。
|
||||
Future<_PlayerSlot?> _showReplacement(_PlayerSlot slot, int request) async {
|
||||
_PlayerSlot? previous;
|
||||
var shown = false;
|
||||
await _enqueue(() async {
|
||||
if (!_replacementReady(slot, request)) return;
|
||||
previous = _active;
|
||||
await _catchUp(slot, previous);
|
||||
_handoff = previous;
|
||||
_active = slot;
|
||||
shown = true;
|
||||
if (previous != null) await previous!.player.pause();
|
||||
_notify();
|
||||
});
|
||||
if (!shown || _active != slot) return null;
|
||||
return previous;
|
||||
}
|
||||
|
||||
/// 旧片仍在播,把新片对齐到当前进度,盖住时画面才接得上。
|
||||
Future<void> _catchUp(_PlayerSlot slot, _PlayerSlot? previous) async {
|
||||
final next = slot.player;
|
||||
final current = previous?.player;
|
||||
if (next is! VideoPlayerPort || current is! VideoPlayerPort) return;
|
||||
final position = current.controller.value.position;
|
||||
if (position <= Duration.zero) return;
|
||||
try {
|
||||
await next.seekTo(position);
|
||||
} catch (_) {
|
||||
// 对齐失败仍用准备阶段的进度,不因此放弃切换。
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _start(_PlayerSlot slot, int request) async {
|
||||
bool valid() => !_closed && request == _generation && _active == slot;
|
||||
if (!valid()) return;
|
||||
@@ -387,7 +505,7 @@ class PlaybackSession extends ChangeNotifier {
|
||||
Future<void> _releasePlayers() {
|
||||
_generation++;
|
||||
return _enqueue(() async {
|
||||
_active = _next = null;
|
||||
_active = _next = _handoff = null;
|
||||
_loading = false;
|
||||
_notify();
|
||||
for (final slot in _live.toList()) {
|
||||
|
||||
Reference in new issue
Block a user