feat(app): 完善播放协议搜索与空状态

This commit is contained in:
Zhou Weidong committed 2026-10-23 12:38:47 +08:00
1 parent 671e0ce143
commit ef29fc5f15
85 files changed
+2976 -768

No files matched your search

+48 -16
View File
@@ -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);
+120 -2
View File
@@ -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()) {