feat(app): 完善播放协议搜索与空状态
This commit is contained in:
1 parent
671e0ce143
commit
ef29fc5f15
85 files changed
+2976
-768
No files matched your search
@@ -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