1062 lines
30 KiB
Dart
1062 lines
30 KiB
Dart
import 'dart:async';
|
|
import 'dart:collection';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/cupertino.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_svga/flutter_svga.dart';
|
|
import 'package:yumi/app/constants/sc_global_config.dart';
|
|
import 'package:yumi/shared/tools/sc_path_utils.dart';
|
|
import 'package:yumi/shared/tools/sc_room_effect_scheduler.dart';
|
|
import 'package:yumi/shared/data_sources/sources/local/file_cache_manager.dart';
|
|
import 'package:tancent_vap/utils/constant.dart';
|
|
import 'package:yumi/ui_kit/widgets/room/effect/gift_mp4_effect_view.dart';
|
|
|
|
import 'package:yumi/shared/data_sources/sources/local/data_persistence.dart';
|
|
|
|
enum _GiftFxLogLevel { verbose, info, warning, error }
|
|
|
|
class SCGiftVapSvgaManager {
|
|
static const int giftEffectType = 0;
|
|
static const int entryEffectType = 1;
|
|
static const int giftEffectPriority = 100;
|
|
static const int entryEffectPriority = 1000;
|
|
static const int _maxPendingGiftTaskCount = 24;
|
|
static const int _maxPendingEntryTaskCount = 5;
|
|
static const int _maxConsecutiveEntryTasksBeforeGift = 2;
|
|
static const Duration _taskWatchdogTimeout = Duration(seconds: 20);
|
|
static const Duration _entryTaskTtl = Duration(seconds: 6);
|
|
static SCGiftVapSvgaManager? _inst;
|
|
static const int _maxPreloadConcurrency = 1;
|
|
static const int _maxPreloadQueueLength = 12;
|
|
static const int _maxSvgaCacheEntries = 8;
|
|
static const int _maxPlayablePathCacheEntries = 24;
|
|
final Map<String, MovieEntity> _videoItemCache = {};
|
|
final Map<String, int> _svgaEntityRefCounts = {};
|
|
final Set<String> _pendingSvgaEvictions = <String>{};
|
|
|
|
SCGiftVapSvgaManager._internal();
|
|
|
|
factory SCGiftVapSvgaManager() => _inst ??= SCGiftVapSvgaManager._internal();
|
|
|
|
final SCPriorityQueue<SCVapTask> _giftQueue = SCPriorityQueue<SCVapTask>(
|
|
(a, b) => a.compareTo(b), // 调用 SCVapTask 的 compareTo 方法
|
|
);
|
|
final SCPriorityQueue<SCVapTask> _entryQueue = SCPriorityQueue<SCVapTask>(
|
|
(a, b) => a.compareTo(b), // 调用 SCVapTask 的 compareTo 方法
|
|
);
|
|
SCGiftMp4Controller? _mp4Controller;
|
|
SVGAAnimationController? _rsc;
|
|
bool _play = false;
|
|
bool _dis = false;
|
|
SCVapTask? _currentTask;
|
|
String? _currentSvgaRetainPath;
|
|
MovieEntity? _currentSvgaRetainEntity;
|
|
Timer? _currentTaskWatchdogTimer;
|
|
final Queue<String> _preloadQueue = Queue<String>();
|
|
final Set<String> _queuedPreloadPaths = <String>{};
|
|
final Map<String, Future<MovieEntity>> _svgaLoadTasks = {};
|
|
final Map<String, Future<String>> _playablePathTasks = {};
|
|
final Map<String, String> _playablePathCache = {};
|
|
int _activePreloadCount = 0;
|
|
int _consecutiveEntryTaskCount = 0;
|
|
|
|
bool _pause = false;
|
|
|
|
//是否关闭礼物特效声音
|
|
bool _mute = false;
|
|
|
|
void setMute(bool muteMusic) {
|
|
_mute = muteMusic;
|
|
_rsc?.muted = _mute;
|
|
final mp4Controller = _mp4Controller;
|
|
if (mp4Controller != null) {
|
|
unawaited(mp4Controller.setMute(_mute));
|
|
}
|
|
unawaited(DataPersistence.setPlayGiftMusic(_mute));
|
|
}
|
|
|
|
bool getMute() {
|
|
return _mute;
|
|
}
|
|
|
|
bool _shouldPrintLog(_GiftFxLogLevel level) {
|
|
if (kDebugMode) {
|
|
return true;
|
|
}
|
|
if (kProfileMode) {
|
|
return level.index >= _GiftFxLogLevel.warning.index;
|
|
}
|
|
return level.index >= _GiftFxLogLevel.error.index;
|
|
}
|
|
|
|
void _log(String message, {_GiftFxLogLevel level = _GiftFxLogLevel.verbose}) {
|
|
if (!_shouldPrintLog(level)) {
|
|
return;
|
|
}
|
|
debugPrint('[GiftFx][manager][${level.name}] $message');
|
|
}
|
|
|
|
bool _needsSvgaController(String path) {
|
|
return SCPathUtils.getFileExtension(path).toLowerCase() == ".svga";
|
|
}
|
|
|
|
bool _needsMp4Controller(String path) {
|
|
final ext = SCPathUtils.getFileExtension(path).toLowerCase();
|
|
return ext == ".mp4" || ext == ".vap";
|
|
}
|
|
|
|
bool _isSupportedEffectPath(String path) {
|
|
return _needsSvgaController(path) || _needsMp4Controller(path);
|
|
}
|
|
|
|
static int resolveEffectPriority({
|
|
int priority = 0,
|
|
int type = giftEffectType,
|
|
}) {
|
|
if (type == entryEffectType) {
|
|
return priority > entryEffectPriority ? priority : entryEffectPriority;
|
|
}
|
|
if (priority != 0) {
|
|
return priority;
|
|
}
|
|
return giftEffectPriority;
|
|
}
|
|
|
|
bool _isEntryTask(SCVapTask task) => task.type == entryEffectType;
|
|
|
|
int get _pendingTaskCount => _giftQueue.length + _entryQueue.length;
|
|
|
|
String get _queueSummary =>
|
|
'entry=${_entryQueue.length} gift=${_giftQueue.length}';
|
|
|
|
bool _isControllerReady(SCVapTask task) {
|
|
if (_needsSvgaController(task.path)) {
|
|
return _rsc != null;
|
|
}
|
|
return _mp4Controller != null;
|
|
}
|
|
|
|
bool _isPlayableFileReady(String path) {
|
|
final cachedPath = _playablePathCache[path];
|
|
if (cachedPath == null || cachedPath.isEmpty) {
|
|
return false;
|
|
}
|
|
return File(cachedPath).existsSync();
|
|
}
|
|
|
|
bool _isPreloadedOrLoading(String path) {
|
|
if (_needsSvgaController(path)) {
|
|
return _videoItemCache.containsKey(path) ||
|
|
_svgaLoadTasks.containsKey(path);
|
|
}
|
|
final pathType = SCPathUtils.getPathType(path);
|
|
if (pathType == PathType.asset || pathType == PathType.file) {
|
|
return true;
|
|
}
|
|
return _isPlayableFileReady(path) || _playablePathTasks.containsKey(path);
|
|
}
|
|
|
|
Future<void> preload(String path, {bool highPriority = false}) async {
|
|
if (!SCGlobalConfig.allowsHighCostAnimations ||
|
|
path.isEmpty ||
|
|
_dis ||
|
|
_isPreloadedOrLoading(path)) {
|
|
return;
|
|
}
|
|
if (!_isSupportedEffectPath(path)) {
|
|
_log(
|
|
'preload ignored because effect file extension is unsupported '
|
|
'path=$path ext=${SCPathUtils.getFileExtension(path)}',
|
|
level: _GiftFxLogLevel.warning,
|
|
);
|
|
return;
|
|
}
|
|
if (highPriority) {
|
|
_log('high priority preload path=$path');
|
|
await _warmupPath(path);
|
|
return;
|
|
}
|
|
if (_queuedPreloadPaths.contains(path)) {
|
|
return;
|
|
}
|
|
_trimPreloadQueue();
|
|
_preloadQueue.add(path);
|
|
_queuedPreloadPaths.add(path);
|
|
_log('enqueue preload path=$path queue=${_preloadQueue.length}');
|
|
_drainPreloadQueue();
|
|
}
|
|
|
|
void _trimPreloadQueue() {
|
|
while (_preloadQueue.length >= _maxPreloadQueueLength) {
|
|
final removedPath = _preloadQueue.removeFirst();
|
|
_queuedPreloadPaths.remove(removedPath);
|
|
}
|
|
}
|
|
|
|
void _drainPreloadQueue() {
|
|
if (_dis ||
|
|
_pause ||
|
|
_play ||
|
|
_activePreloadCount >= _maxPreloadConcurrency ||
|
|
_preloadQueue.isEmpty) {
|
|
return;
|
|
}
|
|
final path = _preloadQueue.removeFirst();
|
|
_queuedPreloadPaths.remove(path);
|
|
_activePreloadCount++;
|
|
_log(
|
|
'start preload path=$path active=$_activePreloadCount '
|
|
'queueRemaining=${_preloadQueue.length}',
|
|
);
|
|
_warmupPath(path).catchError((_) {}).whenComplete(() {
|
|
_activePreloadCount =
|
|
_activePreloadCount > 0 ? _activePreloadCount - 1 : 0;
|
|
_log(
|
|
'finish preload path=$path active=$_activePreloadCount '
|
|
'queueRemaining=${_preloadQueue.length}',
|
|
);
|
|
_drainPreloadQueue();
|
|
});
|
|
}
|
|
|
|
Future<void> _warmupPath(String path) async {
|
|
if (_needsSvgaController(path)) {
|
|
await _loadSvgaEntity(path);
|
|
return;
|
|
}
|
|
await _ensurePlayableFilePath(path);
|
|
}
|
|
|
|
Future<MovieEntity> loadSvgaEntity(String path, {bool retain = true}) async {
|
|
final resource = path.trim();
|
|
if (resource.isEmpty || !_needsSvgaController(resource)) {
|
|
throw Exception('Unsupported SVGA path: $path');
|
|
}
|
|
if (retain) {
|
|
_retainSvgaEntity(resource);
|
|
}
|
|
try {
|
|
return await _loadSvgaEntity(resource);
|
|
} catch (_) {
|
|
if (retain) {
|
|
_releaseSvgaEntity(resource);
|
|
}
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
void removeSvgaEntity(String path, {bool evict = false}) {
|
|
final resource = path.trim();
|
|
if (resource.isEmpty) {
|
|
return;
|
|
}
|
|
if (evict) {
|
|
_pendingSvgaEvictions.add(resource);
|
|
}
|
|
_releaseSvgaEntity(resource);
|
|
if (evict || _pendingSvgaEvictions.contains(resource) || _dis) {
|
|
_evictCachedSvgaEntityIfUnused(resource);
|
|
}
|
|
_trimSvgaCache();
|
|
}
|
|
|
|
Future<MovieEntity> _loadSvgaEntity(String path) async {
|
|
final cached = _touchCachedSvgaEntity(path);
|
|
if (cached != null) {
|
|
return cached;
|
|
}
|
|
final loadingTask = _svgaLoadTasks[path];
|
|
if (loadingTask != null) {
|
|
return loadingTask;
|
|
}
|
|
final future = () async {
|
|
final pathType = SCPathUtils.getPathType(path);
|
|
late final MovieEntity entity;
|
|
if (pathType == PathType.asset) {
|
|
entity = await SVGAParser.shared.decodeFromAssets(path);
|
|
} else if (pathType == PathType.network) {
|
|
entity = await SVGAParser.shared.decodeFromURL(path);
|
|
} else if (pathType == PathType.file) {
|
|
final bytes = await File(path).readAsBytes();
|
|
entity = await SVGAParser.shared.decodeFromBuffer(bytes);
|
|
} else {
|
|
throw Exception('Unsupported SVGA path: $path');
|
|
}
|
|
entity.autorelease = false;
|
|
if (!_dis || _hasSvgaEntityReferences(path)) {
|
|
_cacheSvgaEntity(path, entity);
|
|
}
|
|
return entity;
|
|
}();
|
|
_svgaLoadTasks[path] = future;
|
|
try {
|
|
return await future;
|
|
} finally {
|
|
_svgaLoadTasks.remove(path);
|
|
}
|
|
}
|
|
|
|
Future<String> _ensurePlayableFilePath(String path) async {
|
|
final pathType = SCPathUtils.getPathType(path);
|
|
if (pathType == PathType.asset || pathType == PathType.file) {
|
|
return path;
|
|
}
|
|
final cachedPath = _touchCachedPlayablePath(path);
|
|
if (cachedPath != null &&
|
|
cachedPath.isNotEmpty &&
|
|
File(cachedPath).existsSync()) {
|
|
return cachedPath;
|
|
}
|
|
final loadingTask = _playablePathTasks[path];
|
|
if (loadingTask != null) {
|
|
return loadingTask;
|
|
}
|
|
final future = () async {
|
|
final file = await FileCacheManager.getInstance().getFile(url: path);
|
|
if (!_dis) {
|
|
_cachePlayablePath(path, file.path);
|
|
}
|
|
return file.path;
|
|
}();
|
|
_playablePathTasks[path] = future;
|
|
try {
|
|
return await future;
|
|
} finally {
|
|
_playablePathTasks.remove(path);
|
|
}
|
|
}
|
|
|
|
MovieEntity? _touchCachedSvgaEntity(String path) {
|
|
final cached = _videoItemCache.remove(path);
|
|
if (cached != null) {
|
|
_videoItemCache[path] = cached;
|
|
}
|
|
return cached;
|
|
}
|
|
|
|
void _cacheSvgaEntity(String path, MovieEntity entity) {
|
|
final previous = _videoItemCache.remove(path);
|
|
if (previous != null && !identical(previous, entity)) {
|
|
_disposeSvgaEntityIfUnused(previous, path: path);
|
|
}
|
|
_videoItemCache[path] = entity;
|
|
_pendingSvgaEvictions.remove(path);
|
|
_trimSvgaCache();
|
|
}
|
|
|
|
void _retainSvgaEntity(String path) {
|
|
_svgaEntityRefCounts[path] = (_svgaEntityRefCounts[path] ?? 0) + 1;
|
|
}
|
|
|
|
void _releaseSvgaEntity(String path) {
|
|
final currentCount = _svgaEntityRefCounts[path];
|
|
if (currentCount == null) {
|
|
return;
|
|
}
|
|
if (currentCount <= 1) {
|
|
_svgaEntityRefCounts.remove(path);
|
|
return;
|
|
}
|
|
_svgaEntityRefCounts[path] = currentCount - 1;
|
|
}
|
|
|
|
String? _touchCachedPlayablePath(String path) {
|
|
final cachedPath = _playablePathCache.remove(path);
|
|
if (cachedPath != null) {
|
|
_playablePathCache[path] = cachedPath;
|
|
}
|
|
return cachedPath;
|
|
}
|
|
|
|
void _cachePlayablePath(String path, String playablePath) {
|
|
_playablePathCache.remove(path);
|
|
_playablePathCache[path] = playablePath;
|
|
_trimResolvedCache(_playablePathCache, _maxPlayablePathCacheEntries);
|
|
}
|
|
|
|
void _trimSvgaCache() {
|
|
while (_videoItemCache.length > _maxSvgaCacheEntries) {
|
|
final removableEntry = _videoItemCache.entries.firstWhere(
|
|
(entry) => !_isSvgaEntityInUse(entry.value, path: entry.key),
|
|
orElse: () => _videoItemCache.entries.first,
|
|
);
|
|
if (_isSvgaEntityInUse(removableEntry.value, path: removableEntry.key)) {
|
|
_videoItemCache.remove(removableEntry.key);
|
|
_videoItemCache[removableEntry.key] = removableEntry.value;
|
|
break;
|
|
}
|
|
_videoItemCache.remove(removableEntry.key);
|
|
_pendingSvgaEvictions.remove(removableEntry.key);
|
|
_disposeSvgaEntity(removableEntry.value);
|
|
}
|
|
}
|
|
|
|
void _trimResolvedCache<T>(Map<String, T> cache, int maxEntries) {
|
|
while (cache.length > maxEntries) {
|
|
final oldestKey = cache.keys.first;
|
|
cache.remove(oldestKey);
|
|
}
|
|
}
|
|
|
|
void _enqueueTask(SCVapTask task) {
|
|
if (_isEntryTask(task)) {
|
|
_dropExpiredEntryTasks();
|
|
_entryQueue.add(task);
|
|
_trimEntryQueue();
|
|
return;
|
|
}
|
|
_giftQueue.add(task);
|
|
_trimGiftQueue();
|
|
}
|
|
|
|
void _trimEntryQueue() {
|
|
while (_entryQueue.length > _maxPendingEntryTaskCount) {
|
|
final removedTask = _removeEntryOverflowTask();
|
|
_completeDroppedQueuedTask(removedTask, 'trim entry queue');
|
|
}
|
|
}
|
|
|
|
SCVapTask _removeEntryOverflowTask() {
|
|
final tasks = _entryQueue.unorderedElements.cast<SCVapTask>();
|
|
var candidate = tasks.first;
|
|
for (final task in tasks.skip(1)) {
|
|
if (task.priority < candidate.priority ||
|
|
(task.priority == candidate.priority &&
|
|
task.createdAt.isBefore(candidate.createdAt))) {
|
|
candidate = task;
|
|
}
|
|
}
|
|
_entryQueue.remove(candidate);
|
|
return candidate;
|
|
}
|
|
|
|
void _trimGiftQueue() {
|
|
while (_giftQueue.length > _maxPendingGiftTaskCount) {
|
|
final removedTask = _giftQueue.removeLast();
|
|
_completeDroppedQueuedTask(removedTask, 'trim gift queue');
|
|
}
|
|
}
|
|
|
|
void _dropExpiredEntryTasks() {
|
|
if (_entryQueue.isEmpty) {
|
|
return;
|
|
}
|
|
final now = DateTime.now();
|
|
final expiredTasks =
|
|
_entryQueue.unorderedElements
|
|
.cast<SCVapTask>()
|
|
.where((task) => now.difference(task.createdAt) > _entryTaskTtl)
|
|
.toList();
|
|
for (final task in expiredTasks) {
|
|
_entryQueue.remove(task);
|
|
_completeDroppedQueuedTask(
|
|
task,
|
|
'drop expired entry task ageMs=${now.difference(task.createdAt).inMilliseconds}',
|
|
);
|
|
}
|
|
}
|
|
|
|
void _completeDroppedQueuedTask(SCVapTask task, String reason) {
|
|
SCRoomEffectScheduler().completeHighCostTask(debugLabel: task.path);
|
|
_log(
|
|
'$reason path=${task.path} priority=${task.priority} '
|
|
'type=${task.type} queue=$_queueSummary',
|
|
);
|
|
}
|
|
|
|
SCVapTask? _peekNextQueuedTask() {
|
|
_dropExpiredEntryTasks();
|
|
if (_entryQueue.isNotEmpty) {
|
|
final shouldYieldToGift =
|
|
_giftQueue.isNotEmpty &&
|
|
_consecutiveEntryTaskCount >= _maxConsecutiveEntryTasksBeforeGift;
|
|
if (!shouldYieldToGift) {
|
|
return _entryQueue.first;
|
|
}
|
|
return _giftQueue.first;
|
|
}
|
|
return _giftQueue.first;
|
|
}
|
|
|
|
void _removeQueuedTask(SCVapTask task) {
|
|
if (_isEntryTask(task)) {
|
|
_entryQueue.remove(task);
|
|
_consecutiveEntryTaskCount++;
|
|
return;
|
|
}
|
|
_giftQueue.remove(task);
|
|
_consecutiveEntryTaskCount = 0;
|
|
}
|
|
|
|
void _scheduleNextTask({Duration delay = Duration.zero}) {
|
|
if (_dis) {
|
|
return;
|
|
}
|
|
if (delay == Duration.zero) {
|
|
Future.microtask(_pn);
|
|
return;
|
|
}
|
|
Future.delayed(delay, _pn);
|
|
}
|
|
|
|
void _finishCurrentTask({Duration delay = Duration.zero}) {
|
|
if (_dis) {
|
|
return;
|
|
}
|
|
final finishedTask = _currentTask;
|
|
if (!_play && finishedTask == null) {
|
|
_scheduleNextTask(delay: delay);
|
|
return;
|
|
}
|
|
_cancelCurrentTaskWatchdog();
|
|
final currentSvgaPath = _currentSvgaRetainPath;
|
|
_releaseCurrentSvgaEntityReference(
|
|
clearController:
|
|
currentSvgaPath != null &&
|
|
_pendingSvgaEvictions.contains(currentSvgaPath),
|
|
);
|
|
SCRoomEffectScheduler().completeHighCostTask(
|
|
debugLabel: finishedTask?.path,
|
|
);
|
|
_play = false;
|
|
_currentTask = null;
|
|
_scheduleNextTask(delay: delay);
|
|
if (delay == Duration.zero) {
|
|
Future.microtask(_drainPreloadQueue);
|
|
} else {
|
|
Future.delayed(delay, _drainPreloadQueue);
|
|
}
|
|
}
|
|
|
|
void _trackCurrentSvgaEntity(String path, MovieEntity entity) {
|
|
if (_currentSvgaRetainPath == path &&
|
|
identical(_currentSvgaRetainEntity, entity)) {
|
|
return;
|
|
}
|
|
_releaseCurrentSvgaEntityReference();
|
|
_currentSvgaRetainPath = path;
|
|
_currentSvgaRetainEntity = entity;
|
|
}
|
|
|
|
void _releaseCurrentSvgaEntityReference({bool clearController = false}) {
|
|
final path = _currentSvgaRetainPath;
|
|
final entity = _currentSvgaRetainEntity;
|
|
_currentSvgaRetainPath = null;
|
|
_currentSvgaRetainEntity = null;
|
|
if (clearController &&
|
|
entity != null &&
|
|
identical(_rsc?.videoItem, entity)) {
|
|
_rsc?.videoItem = null;
|
|
}
|
|
if (path != null) {
|
|
removeSvgaEntity(path);
|
|
}
|
|
}
|
|
|
|
// 绑定控制器
|
|
void bindMp4Ctrl(SCGiftMp4Controller mp4Controller) {
|
|
_mute = DataPersistence.getPlayGiftMusic();
|
|
_dis = false;
|
|
_mp4Controller = mp4Controller;
|
|
unawaited(mp4Controller.setMute(_mute));
|
|
_log(
|
|
'bindMp4Ctrl hasMp4Ctrl=${_mp4Controller != null} '
|
|
'hasSvgaCtrl=${_rsc != null} queue=$_queueSummary mute=$_mute',
|
|
);
|
|
_mp4Controller?.setPlaybackListener(
|
|
onStart: () {
|
|
_log('mp4 onVideoStart path=${_currentTask?.path}');
|
|
},
|
|
onComplete: () {
|
|
_log('mp4 onVideoComplete path=${_currentTask?.path}');
|
|
_hcs();
|
|
},
|
|
onFailed: (error) {
|
|
_log(
|
|
'mp4 onFailed path=${_currentTask?.path} error=$error',
|
|
level: _GiftFxLogLevel.error,
|
|
);
|
|
_hcs();
|
|
},
|
|
);
|
|
_scheduleNextTask();
|
|
_drainPreloadQueue();
|
|
}
|
|
|
|
void bindSvgaCtrl(SVGAAnimationController svgaController) {
|
|
_mute = DataPersistence.getPlayGiftMusic();
|
|
_dis = false;
|
|
_rsc = svgaController;
|
|
_rsc?.muted = _mute;
|
|
_log(
|
|
'bindSvgaCtrl hasSvgaCtrl=${_rsc != null} '
|
|
'hasMp4Ctrl=${_mp4Controller != null} queue=$_queueSummary',
|
|
);
|
|
_rsc?.addStatusListener((AnimationStatus status) {
|
|
if (status.isCompleted) {
|
|
_log('svga completed path=${_currentTask?.path}');
|
|
_rsc?.reset();
|
|
_finishCurrentTask();
|
|
}
|
|
});
|
|
_scheduleNextTask();
|
|
_drainPreloadQueue();
|
|
}
|
|
|
|
// 播放任务
|
|
void play(
|
|
String path, {
|
|
int priority = 0,
|
|
Map<String, VAPContent>? customResources,
|
|
int type = giftEffectType,
|
|
}) {
|
|
if (path.isEmpty) {
|
|
_log(
|
|
'play ignored because path is empty',
|
|
level: _GiftFxLogLevel.warning,
|
|
);
|
|
return;
|
|
}
|
|
if (!_isSupportedEffectPath(path)) {
|
|
_log(
|
|
'play ignored because effect file extension is unsupported '
|
|
'path=$path ext=${SCPathUtils.getFileExtension(path)}',
|
|
level: _GiftFxLogLevel.warning,
|
|
);
|
|
return;
|
|
}
|
|
if (SCRoomEffectScheduler().isSuppressed) {
|
|
_log('play ignored because room effects are suppressed path=$path');
|
|
return;
|
|
}
|
|
final resolvedPriority = resolveEffectPriority(
|
|
priority: priority,
|
|
type: type,
|
|
);
|
|
_log(
|
|
'play request path=$path ext=${SCPathUtils.getFileExtension(path)} '
|
|
'priority=$priority resolvedPriority=$resolvedPriority type=$type '
|
|
'sdkInt=${SCGlobalConfig.sdkInt} maxSdkNoAnim=${SCGlobalConfig.maxSdkNoAnim} '
|
|
'lowPerformance=${SCGlobalConfig.isLowPerformanceDevice} '
|
|
'disposed=$_dis playing=$_play queueBefore=$_queueSummary '
|
|
'hasSvgaCtrl=${_rsc != null} hasMp4Ctrl=${_mp4Controller != null}',
|
|
);
|
|
if (SCGlobalConfig.allowsHighCostAnimations) {
|
|
if (_dis) {
|
|
_log(
|
|
'manager disposed/no controller; queue play until bind path=$path',
|
|
level: _GiftFxLogLevel.warning,
|
|
);
|
|
}
|
|
final task = SCVapTask(
|
|
path: path,
|
|
type: type,
|
|
priority: resolvedPriority,
|
|
customResources: customResources,
|
|
);
|
|
|
|
SCRoomEffectScheduler().registerHighCostTaskQueued(debugLabel: path);
|
|
_enqueueTask(task);
|
|
unawaited(preload(path));
|
|
_log('task enqueued path=$path queueAfter=$_queueSummary');
|
|
if (!_play) {
|
|
_pn();
|
|
}
|
|
} else {
|
|
_log(
|
|
'play ignored because performance profile disabled heavy animations '
|
|
'sdkInt=${SCGlobalConfig.sdkInt} maxSdkNoAnim=${SCGlobalConfig.maxSdkNoAnim} '
|
|
'lowPerformance=${SCGlobalConfig.isLowPerformanceDevice}',
|
|
level: _GiftFxLogLevel.info,
|
|
);
|
|
}
|
|
}
|
|
|
|
// 播放下一个任务
|
|
Future<void> _pn() async {
|
|
if (_pause) {
|
|
_log('skip _pn because paused queue=$_queueSummary');
|
|
return;
|
|
}
|
|
if (_dis || _pendingTaskCount == 0 || _play) return;
|
|
|
|
final task = _peekNextQueuedTask();
|
|
if (task == null || !_isControllerReady(task)) {
|
|
_log(
|
|
'controller not ready for path=${task?.path} '
|
|
'needSvga=${task != null ? _needsSvgaController(task.path) : "unknown"} '
|
|
'hasSvgaCtrl=${_rsc != null} hasMp4Ctrl=${_mp4Controller != null} '
|
|
'queue=$_queueSummary',
|
|
);
|
|
return;
|
|
}
|
|
|
|
_removeQueuedTask(task);
|
|
_play = true;
|
|
_currentTask = task;
|
|
_armCurrentTaskWatchdog(task);
|
|
try {
|
|
final pathType = SCPathUtils.getPathType(task.path);
|
|
_log(
|
|
'start task path=${task.path} '
|
|
'pathType=$pathType '
|
|
'queueRemaining=$_queueSummary '
|
|
'needSvga=${_needsSvgaController(task.path)} '
|
|
'type=${task.type} consecutiveEntry=$_consecutiveEntryTaskCount',
|
|
);
|
|
if (pathType == PathType.asset) {
|
|
await _pa(task);
|
|
} else if (pathType == PathType.file) {
|
|
await _pf(task);
|
|
} else if (pathType == PathType.network) {
|
|
await _pnw(task);
|
|
} else {
|
|
_finishCurrentTask();
|
|
}
|
|
} catch (_) {
|
|
_finishCurrentTask();
|
|
}
|
|
}
|
|
|
|
// 播放资源文件
|
|
Future<void> _pa(SCVapTask task) async {
|
|
if (_dis) return;
|
|
if (SCPathUtils.getFileExtension(task.path).toLowerCase() == ".svga") {
|
|
_log('play asset svga path=${task.path}');
|
|
final entity = await loadSvgaEntity(task.path);
|
|
if (!_isCurrentTask(task)) {
|
|
removeSvgaEntity(task.path);
|
|
return;
|
|
}
|
|
_trackCurrentSvgaEntity(task.path, entity);
|
|
_log('use prepared asset svga path=${task.path}');
|
|
_rsc?.muted = _mute;
|
|
_rsc?.videoItem = entity;
|
|
_rsc?.reset();
|
|
_rsc?.forward();
|
|
_log('forward asset svga path=${task.path}');
|
|
} else {
|
|
_log('play asset vap/mp4 path=${task.path}');
|
|
await _mp4Controller?.playAsset(task.path);
|
|
}
|
|
}
|
|
|
|
// 播放本地文件
|
|
Future<void> _pf(SCVapTask task, {String? playablePath}) async {
|
|
if (_dis) {
|
|
return;
|
|
}
|
|
final resolvedPath = playablePath ?? task.path;
|
|
if (SCPathUtils.getFileExtension(task.path).toLowerCase() == ".svga") {
|
|
final entity = await loadSvgaEntity(resolvedPath);
|
|
if (!_isCurrentTask(task)) {
|
|
removeSvgaEntity(resolvedPath);
|
|
return;
|
|
}
|
|
_trackCurrentSvgaEntity(resolvedPath, entity);
|
|
_log('play local svga file path=$resolvedPath source=${task.path}');
|
|
_rsc?.muted = _mute;
|
|
_rsc?.videoItem = entity;
|
|
_rsc?.reset();
|
|
_rsc?.forward();
|
|
return;
|
|
}
|
|
if (!_needsMp4Controller(task.path) && !_needsMp4Controller(resolvedPath)) {
|
|
_log(
|
|
'skip playFile because effect file extension is unsupported '
|
|
'path=$resolvedPath source=${task.path}',
|
|
level: _GiftFxLogLevel.warning,
|
|
);
|
|
_finishCurrentTask();
|
|
return;
|
|
}
|
|
if (_mp4Controller == null) {
|
|
_log(
|
|
'skip playFile because mp4 controller is null path=$resolvedPath',
|
|
level: _GiftFxLogLevel.warning,
|
|
);
|
|
_finishCurrentTask();
|
|
return;
|
|
}
|
|
_log('play local vap/mp4 file path=$resolvedPath source=${task.path}');
|
|
await _mp4Controller?.setMute(_mute);
|
|
if (!_isCurrentTask(task)) {
|
|
return;
|
|
}
|
|
await _mp4Controller!.playFile(resolvedPath);
|
|
}
|
|
|
|
// 播放网络资源
|
|
Future<void> _pnw(SCVapTask task) async {
|
|
if (_dis) return;
|
|
if (SCPathUtils.getFileExtension(task.path).toLowerCase() == ".svga") {
|
|
_log('play network svga path=${task.path}');
|
|
late final MovieEntity entity;
|
|
try {
|
|
entity = await loadSvgaEntity(task.path);
|
|
} catch (e) {
|
|
_finishCurrentTask();
|
|
return;
|
|
}
|
|
if (!_isCurrentTask(task)) {
|
|
removeSvgaEntity(task.path);
|
|
return;
|
|
}
|
|
_trackCurrentSvgaEntity(task.path, entity);
|
|
_log('use prepared network svga path=${task.path}');
|
|
_rsc?.muted = _mute;
|
|
_rsc?.videoItem = entity;
|
|
_rsc?.reset();
|
|
_rsc?.forward();
|
|
_log('forward network svga path=${task.path}');
|
|
} else {
|
|
_log('download network vap/mp4 path=${task.path}');
|
|
final playablePath = await _ensurePlayableFilePath(task.path);
|
|
if (!_isCurrentTask(task)) {
|
|
return;
|
|
}
|
|
if (!_dis) {
|
|
_log('use prepared network vap/mp4 local path=$playablePath');
|
|
await _pf(task, playablePath: playablePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 处理控制器状态变化
|
|
void _hcs() {
|
|
if (_mp4Controller != null && !_dis) {
|
|
_log('finish mp4 task path=${_currentTask?.path}');
|
|
_finishCurrentTask(delay: const Duration(milliseconds: 50));
|
|
}
|
|
}
|
|
|
|
bool _isCurrentTask(SCVapTask task) {
|
|
return !_dis && _play && identical(_currentTask, task);
|
|
}
|
|
|
|
void _armCurrentTaskWatchdog(SCVapTask task) {
|
|
_cancelCurrentTaskWatchdog();
|
|
_currentTaskWatchdogTimer = Timer(_taskWatchdogTimeout, () {
|
|
if (!_isCurrentTask(task)) {
|
|
return;
|
|
}
|
|
_log(
|
|
'task watchdog timeout path=${task.path}',
|
|
level: _GiftFxLogLevel.warning,
|
|
);
|
|
_mp4Controller?.stop();
|
|
_rsc?.stop();
|
|
_rsc?.reset();
|
|
_rsc?.videoItem = null;
|
|
_finishCurrentTask(delay: const Duration(milliseconds: 50));
|
|
});
|
|
}
|
|
|
|
void _cancelCurrentTaskWatchdog() {
|
|
_currentTaskWatchdogTimer?.cancel();
|
|
_currentTaskWatchdogTimer = null;
|
|
}
|
|
|
|
//暂停动画播放
|
|
void pauseAnim() {
|
|
_pause = true;
|
|
_log('pauseAnim queue=$_queueSummary');
|
|
}
|
|
|
|
//恢复动画播放
|
|
void resumeAnim() {
|
|
_pause = false;
|
|
_log('resumeAnim queue=$_queueSummary');
|
|
_pn();
|
|
}
|
|
|
|
void stopPlayback() {
|
|
_log('stopPlayback queue=$_queueSummary currentPath=${_currentTask?.path}');
|
|
_play = false;
|
|
_currentTask = null;
|
|
_pause = false;
|
|
_consecutiveEntryTaskCount = 0;
|
|
_cancelCurrentTaskWatchdog();
|
|
_giftQueue.clear();
|
|
_entryQueue.clear();
|
|
_preloadQueue.clear();
|
|
_queuedPreloadPaths.clear();
|
|
_activePreloadCount = 0;
|
|
_mp4Controller?.stop();
|
|
_rsc?.stop();
|
|
_rsc?.reset();
|
|
_rsc?.videoItem = null;
|
|
_releaseCurrentSvgaEntityReference();
|
|
SCRoomEffectScheduler().clearHighCostTasks(reason: 'stop_playback');
|
|
}
|
|
|
|
void clearMemoryCache({bool includeCurrent = false}) {
|
|
_preloadQueue.clear();
|
|
_queuedPreloadPaths.clear();
|
|
_activePreloadCount = 0;
|
|
final entries = _videoItemCache.entries.toList(growable: false);
|
|
for (final entry in entries) {
|
|
final entity = entry.value;
|
|
if (_hasSvgaEntityReferences(entry.key) ||
|
|
(!includeCurrent && _isSvgaEntityInUse(entity, path: entry.key))) {
|
|
_pendingSvgaEvictions.add(entry.key);
|
|
continue;
|
|
}
|
|
_videoItemCache.remove(entry.key);
|
|
_pendingSvgaEvictions.remove(entry.key);
|
|
_disposeSvgaEntity(entity);
|
|
}
|
|
_playablePathCache.clear();
|
|
}
|
|
|
|
bool _hasSvgaEntityReferences(String path) {
|
|
return (_svgaEntityRefCounts[path] ?? 0) > 0;
|
|
}
|
|
|
|
bool _isSvgaEntityInUse(MovieEntity entity, {String? path}) {
|
|
if (identical(_rsc?.videoItem, entity)) {
|
|
return true;
|
|
}
|
|
if (path != null && _hasSvgaEntityReferences(path)) {
|
|
return true;
|
|
}
|
|
for (final entry in _videoItemCache.entries) {
|
|
if (identical(entry.value, entity) &&
|
|
_hasSvgaEntityReferences(entry.key)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
void _disposeSvgaEntityIfUnused(MovieEntity entity, {String? path}) {
|
|
if (_isSvgaEntityInUse(entity, path: path)) {
|
|
return;
|
|
}
|
|
_disposeSvgaEntity(entity);
|
|
}
|
|
|
|
void _evictCachedSvgaEntityIfUnused(String path) {
|
|
final entity = _videoItemCache[path];
|
|
if (entity == null) {
|
|
_pendingSvgaEvictions.remove(path);
|
|
return;
|
|
}
|
|
if (_isSvgaEntityInUse(entity, path: path)) {
|
|
return;
|
|
}
|
|
_videoItemCache.remove(path);
|
|
_pendingSvgaEvictions.remove(path);
|
|
_disposeSvgaEntity(entity);
|
|
}
|
|
|
|
void _disposeSvgaEntity(MovieEntity entity) {
|
|
try {
|
|
entity.dispose();
|
|
} catch (_) {}
|
|
}
|
|
|
|
// 释放资源
|
|
void dispose() {
|
|
_log('dispose queue=$_queueSummary currentPath=${_currentTask?.path}');
|
|
_dis = true;
|
|
stopPlayback();
|
|
_svgaLoadTasks.clear();
|
|
_playablePathTasks.clear();
|
|
clearMemoryCache(includeCurrent: true);
|
|
_playablePathCache.clear();
|
|
_mp4Controller?.dispose();
|
|
_mp4Controller = null;
|
|
_rsc?.dispose();
|
|
_rsc = null;
|
|
}
|
|
|
|
// 清除所有任务
|
|
void clearTasks() {
|
|
_log(
|
|
'clearTasks queueBefore=$_queueSummary currentPath=${_currentTask?.path}',
|
|
);
|
|
stopPlayback();
|
|
}
|
|
}
|
|
|
|
// 任务模型
|
|
// 1. 修改 SCVapTask 类,实现 Comparable
|
|
|
|
class SCVapTask implements Comparable<SCVapTask> {
|
|
final String path;
|
|
final int type;
|
|
final int priority;
|
|
final Map<String, VAPContent>? customResources;
|
|
final DateTime createdAt;
|
|
final int _seq;
|
|
|
|
static int _nextSeq = 0;
|
|
|
|
SCVapTask({
|
|
required this.path,
|
|
this.priority = 0,
|
|
this.customResources,
|
|
this.type = 0,
|
|
DateTime? createdAt,
|
|
}) : createdAt = createdAt ?? DateTime.now(),
|
|
_seq = _nextSeq++;
|
|
|
|
@override
|
|
int compareTo(SCVapTask other) {
|
|
// 先按优先级降序排列
|
|
int priorityComparison = other.priority.compareTo(priority);
|
|
if (priorityComparison != 0) {
|
|
return priorityComparison;
|
|
}
|
|
// 相同优先级时,按序列号升序排列(先添加的先执行)
|
|
return _seq.compareTo(other._seq);
|
|
}
|
|
|
|
@override
|
|
String toString() {
|
|
return 'SCVapTask{path: $path, type: $type, priority: $priority, seq: $_seq}';
|
|
}
|
|
}
|
|
|
|
// 优先队列实现
|
|
class SCPriorityQueue<E> {
|
|
final List<E> _els = [];
|
|
final Comparator<E> _cmp;
|
|
|
|
SCPriorityQueue(this._cmp);
|
|
|
|
void add(E element) {
|
|
_els.add(element);
|
|
_els.sort(_cmp);
|
|
}
|
|
|
|
E removeFirst() {
|
|
if (isEmpty) throw StateError("No elements");
|
|
return _els.removeAt(0);
|
|
}
|
|
|
|
E? get first => isEmpty ? null : _els.first;
|
|
|
|
bool get isEmpty => _els.isEmpty;
|
|
|
|
bool get isNotEmpty => _els.isNotEmpty;
|
|
|
|
int get length => _els.length;
|
|
|
|
void clear() => _els.clear();
|
|
|
|
bool remove(E element) => _els.remove(element);
|
|
|
|
E removeLast() {
|
|
if (isEmpty) throw StateError("No elements");
|
|
return _els.removeLast();
|
|
}
|
|
|
|
List<E> get unorderedElements => List.from(_els);
|
|
|
|
// 实现 Iterable 接口
|
|
Iterator<E> get iterator => _els.iterator;
|
|
}
|