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()) {
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
const int _serverErrorCode = 500;
|
||||
|
||||
/// 用户端不展示内部故障文案。500 与空提示改成可理解的说明,业务提示原样保留。
|
||||
String userFacingApiMessage(AppLocalizations l10n, int code, String message) {
|
||||
if (code >= _serverErrorCode || message.isEmpty) return l10n.authServerError;
|
||||
return message;
|
||||
}
|
||||
|
||||
/// logic 里的错误在两种情况下是"哨兵值":本地网络异常(读不到后端提示语)与
|
||||
/// 我们自己包装的失败。这里统一翻译成当前语言的文案;服务端给的中文提示原样展示。
|
||||
String errorText(AppLocalizations l10n, String? code, String fallback) {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:html/dom.dart' as dom;
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
const double _bodySize = 16;
|
||||
const double _headingSize = 18;
|
||||
const double _blockGap = 12;
|
||||
const double _lineHeight = 1.7;
|
||||
|
||||
const TextStyle _bodyStyle = TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _bodySize,
|
||||
height: _lineHeight,
|
||||
);
|
||||
|
||||
/// 把协议页的 HTML 正文画成普通控件。
|
||||
class LegalArticleView extends StatelessWidget {
|
||||
const LegalArticleView({super.key, required this.html});
|
||||
|
||||
final String html;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final fragment = html_parser.parseFragment(html);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final node in fragment.nodes) _block(node),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _block(dom.Node node) {
|
||||
if (node is! dom.Element) {
|
||||
return _paragraph(node.text ?? '');
|
||||
}
|
||||
switch (node.localName) {
|
||||
case 'h1':
|
||||
case 'h2':
|
||||
case 'h3':
|
||||
return _heading(node.text);
|
||||
case 'ul':
|
||||
case 'ol':
|
||||
return _list(node);
|
||||
case 'pre':
|
||||
return _paragraph(node.text);
|
||||
case 'blockquote':
|
||||
return _paragraph(node.text);
|
||||
default:
|
||||
return _paragraphSpan(node);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _heading(String text) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: _blockGap),
|
||||
child: Text(
|
||||
text.trim(),
|
||||
style: const TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: _headingSize,
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _list(dom.Element element) {
|
||||
final items = element.children.where((child) => child.localName == 'li');
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: _blockGap),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
for (final item in items)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text('• ${item.text.trim()}', style: _bodyStyle),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _paragraph(String text) {
|
||||
final value = text.trim();
|
||||
if (value.isEmpty) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: _blockGap),
|
||||
child: Text(value, style: _bodyStyle),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _paragraphSpan(dom.Element element) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: _blockGap),
|
||||
child: Text.rich(TextSpan(style: _bodyStyle, children: _inlines(element))),
|
||||
);
|
||||
}
|
||||
|
||||
List<InlineSpan> _inlines(dom.Node node) {
|
||||
if (node is dom.Text) {
|
||||
return [TextSpan(text: node.text)];
|
||||
}
|
||||
if (node is! dom.Element) return const [];
|
||||
final children = node.nodes.expand(_inlines).toList();
|
||||
switch (node.localName) {
|
||||
case 'strong':
|
||||
case 'b':
|
||||
return [TextSpan(style: const TextStyle(fontWeight: FontWeight.w700), children: children)];
|
||||
case 'em':
|
||||
case 'i':
|
||||
return [TextSpan(style: const TextStyle(fontStyle: FontStyle.italic), children: children)];
|
||||
case 'a':
|
||||
return [TextSpan(style: const TextStyle(color: AppColors.blue), children: children)];
|
||||
case 'br':
|
||||
return const [TextSpan(text: '\n')];
|
||||
default:
|
||||
return children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
|
||||
final RegExp _articlePattern = RegExp(
|
||||
'<article>([\\s\\S]*)</article>',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
/// 从协议页里取出正文。没有 article 时退回整页。
|
||||
String legalArticle(String html) {
|
||||
final match = _articlePattern.firstMatch(html);
|
||||
final body = match?.group(1);
|
||||
if (body == null || body.trim().isEmpty) return html;
|
||||
return body.trim();
|
||||
}
|
||||
|
||||
/// 拉取协议页 HTML。不走业务 JSON 包装。
|
||||
Future<String> fetchLegalArticle(String url) async {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: AppConfig.requestTimeout,
|
||||
receiveTimeout: AppConfig.requestTimeout,
|
||||
responseType: ResponseType.plain,
|
||||
validateStatus: (code) => code != null && code >= 200 && code < 300,
|
||||
),
|
||||
);
|
||||
final response = await dio.get<String>(url);
|
||||
final body = response.data;
|
||||
if (body == null || body.trim().isEmpty) {
|
||||
throw StateError('empty legal page');
|
||||
}
|
||||
return legalArticle(body);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'legal_web_page.dart';
|
||||
|
||||
/// 在当前 App 里打开协议地址。
|
||||
Future<void> openLegalUrlOrNotify(BuildContext context, String url) {
|
||||
return Navigator.of(context).push<void>(
|
||||
MaterialPageRoute<void>(builder: (_) => LegalWebPage(url: url)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../config/app_config.dart';
|
||||
|
||||
/// 隐私协议公开路径,对应 `/privacy/{语言}`。
|
||||
const String legalPrivacyPath = 'privacy';
|
||||
|
||||
/// 服务条款公开路径,对应 `/terms/{语言}`。
|
||||
const String legalTermsPath = 'terms';
|
||||
|
||||
const Set<String> _legalLanguages = {'zh', 'en', 'id'};
|
||||
|
||||
/// 界面语言落到协议页支持的三种语言。其余语言用英文。
|
||||
String legalLanguageCode(Locale locale) {
|
||||
final code = locale.languageCode;
|
||||
if (_legalLanguages.contains(code)) return code;
|
||||
return 'en';
|
||||
}
|
||||
|
||||
/// 拼出当前服务器上的协议页地址。
|
||||
String legalPageUrl(String path, Locale locale) {
|
||||
final base = AppConfig.apiBaseUrl;
|
||||
final root = base.endsWith('/') ? base.substring(0, base.length - 1) : base;
|
||||
return '$root/$path/${legalLanguageCode(locale)}';
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../widgets/plain_page_scaffold.dart';
|
||||
import 'legal_article_view.dart';
|
||||
import 'legal_html.dart';
|
||||
|
||||
const double _pagePadding = 20;
|
||||
|
||||
/// 在应用内展示协议页,不使用系统 WebView。
|
||||
class LegalWebPage extends StatefulWidget {
|
||||
const LegalWebPage({super.key, required this.url, this.load});
|
||||
|
||||
final String url;
|
||||
|
||||
/// 测试时可替换网络请求。
|
||||
final Future<String> Function(String url)? load;
|
||||
|
||||
@override
|
||||
State<LegalWebPage> createState() => _LegalWebPageState();
|
||||
}
|
||||
|
||||
class _LegalWebPageState extends State<LegalWebPage> {
|
||||
String? _html;
|
||||
var _failed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_html = null;
|
||||
_failed = false;
|
||||
});
|
||||
try {
|
||||
final loader = widget.load ?? fetchLegalArticle;
|
||||
final html = await loader(widget.url);
|
||||
if (!mounted) return;
|
||||
setState(() => _html = html);
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _failed = true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PlainPageScaffold(
|
||||
pageKey: const Key('legal-web-page'),
|
||||
backKey: const Key('legal-web-back'),
|
||||
title: '',
|
||||
child: _body(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _body(BuildContext context) {
|
||||
final html = _html;
|
||||
if (html != null) {
|
||||
return ListView(
|
||||
padding: const EdgeInsets.fromLTRB(_pagePadding, 8, _pagePadding, 28),
|
||||
children: [
|
||||
LegalArticleView(html: html),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (_failed) return _failure(context);
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
key: Key('legal-web-loading'),
|
||||
color: AppColors.blue,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _failure(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(l10n.legalOpenFailed, style: const TextStyle(color: AppColors.muted)),
|
||||
TextButton(
|
||||
key: const Key('legal-web-retry'),
|
||||
onPressed: _load,
|
||||
child: Text(l10n.retry),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,7 @@ abstract final class AppColors {
|
||||
static const mineIconSupport = Color(0xFFA78BFA);
|
||||
static const mineIconLanguage = Color(0xFF22D3EE);
|
||||
static const mineIconSettings = Color(0xFFA1A1AA);
|
||||
static const mineIconNotices = Color(0xFFFF8A4C);
|
||||
static const recommendPlaylistGlow = Color(0x26FF6B2C);
|
||||
|
||||
/// 次级文字与列表 chevron。
|
||||
|
||||
@@ -187,7 +187,7 @@ abstract final class AppDimens {
|
||||
static const double followingAddIconStroke = 1.5;
|
||||
static const double followingGridGap = 6;
|
||||
static const double followingGridRowGap = 12;
|
||||
static const double followingCardTextExtent = 66;
|
||||
static const double followingCardTextExtent = 70;
|
||||
static const double followingSubtitleFont = 12;
|
||||
static const double followingSubtitleGap = 4;
|
||||
static const double followingProgressHeight = 2;
|
||||
|
||||
@@ -115,7 +115,6 @@ class _EmptyCopy extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -126,10 +125,10 @@ class _EmptyCopy extends StatelessWidget {
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: scheme.onSurface,
|
||||
style: const TextStyle(
|
||||
color: AppColors.textTertiary,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w400,
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -13,7 +13,9 @@ const double _titleFontSize = 18;
|
||||
const double _subtitleFontSize = 12;
|
||||
const double _actionGap = 8;
|
||||
const double _iconSize = 22;
|
||||
const double _backIconSize = 34;
|
||||
const double _iconWeight = 600;
|
||||
const double _backIconWeight = 400;
|
||||
const double _shadowBlur = 8;
|
||||
|
||||
/// 二级页面统一圆形操作按钮,返回、分享和更多共用同一尺寸与表面样式。
|
||||
@@ -24,6 +26,8 @@ class AppSubpageCircleButton extends StatelessWidget {
|
||||
required this.tooltip,
|
||||
required this.icon,
|
||||
required this.onPressed,
|
||||
this.iconSize = _iconSize,
|
||||
this.iconWeight = _iconWeight,
|
||||
this.dark = false,
|
||||
});
|
||||
|
||||
@@ -31,6 +35,8 @@ class AppSubpageCircleButton extends StatelessWidget {
|
||||
final String tooltip;
|
||||
final IconData icon;
|
||||
final VoidCallback onPressed;
|
||||
final double iconSize;
|
||||
final double iconWeight;
|
||||
final bool dark;
|
||||
|
||||
@override
|
||||
@@ -65,8 +71,8 @@ class AppSubpageCircleButton extends StatelessWidget {
|
||||
child: Icon(
|
||||
icon,
|
||||
color: dark ? AppColors.onSurface : AppColors.textPrimary,
|
||||
size: _iconSize,
|
||||
weight: _iconWeight,
|
||||
size: iconSize,
|
||||
weight: iconWeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -96,6 +102,8 @@ class AppSubpageBackButton extends StatelessWidget {
|
||||
buttonKey: backKey,
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: Icons.chevron_left_rounded,
|
||||
iconSize: _backIconSize,
|
||||
iconWeight: _backIconWeight,
|
||||
dark: dark,
|
||||
onPressed: onBack ?? () => Navigator.of(context).maybePop(),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user