downloadMetaData method
根据本地 classId.json 下载视频和文档图片,将在线 URL 替换为本地路径
成功 → classId-success.json;失败 → classId-failed.json。 暂停(pauseDownloadCache)会取消当前 HTTP,保留已写入的部分文件;再次 resumeDownloadCache 通过 HTTP Range 续传(需资源支持 Range)。
Implementation
Future<void> downloadMetaData(String classId, String token) async {
classId = _trimClassId(classId);
if (classId.isEmpty) {
_downloadingClassIds.remove(classId);
return;
}
if (!_downloadMetaDataRunning.add(classId)) {
return;
}
final cancelToken = CancelToken();
_downloadCancelTokens[classId] = cancelToken;
final cacheDir = await _ensureCacheDir();
final safeId = _safeId(classId);
final metaFile = File(p.join(cacheDir.path, '$safeId.json'));
if (!await metaFile.exists()) {
_downloadCancelTokens.remove(classId);
_downloadMetaDataRunning.remove(classId);
_downloadingClassIds.remove(classId);
cacheMetadataListeners[classId]?.onError(
classId,
1002,
'本地缓存文件不存在,请重新缓存',
);
return;
}
try {
final data =
jsonDecode(await metaFile.readAsString()) as Map<String, dynamic>;
final videoUrl = data['video_url'] as String;
final whiteBoardVideoUrl = data['whiteboard_url'] as String;
final documentPages = List<Map<String, dynamic>>.from(
data['document_pages'],
);
final screenShares = List<Map<String, dynamic>>.from(
data['screen_shares'] ?? [],
);
final classCacheDir = Directory(p.join(cacheDir.path, safeId));
if (!await classCacheDir.exists()) {
await classCacheDir.create(recursive: true);
}
final classCachePath = classCacheDir.path;
final teacherPath = p.join(classCachePath, 'teacher_video.mp4');
final wbPath = p.join(classCachePath, 'whiteboard_video.mp4');
final prefetchUrls = <String>[videoUrl, whiteBoardVideoUrl];
final screenShareMeta = <({String url, String path, int shareIndex})>[];
for (var i = 0; i < screenShares.length; i++) {
final shareUrl = (screenShares[i]['video_url'] ?? '').toString();
if (shareUrl.isEmpty) continue;
final sharePath = p.join(classCachePath, 'screen_share_$i.mp4');
prefetchUrls.add(shareUrl);
screenShareMeta.add((url: shareUrl, path: sharePath, shareIndex: i));
}
final imageMeta =
<({String url, String path, int docIndex, int pageIndex})>[];
for (var i = 0; i < documentPages.length; i++) {
final doc = documentPages[i];
final pageIndexList = List<Map<String, dynamic>>.from(
doc['page_index'],
);
for (var j = 0; j < pageIndexList.length; j++) {
final pageIndex = pageIndexList[j];
final imageUrl = pageIndex['url'] as String;
final offset = pageIndex['offset'] as int? ?? 0;
final imageType = _extractExt(imageUrl, '.png').replaceFirst('.', '');
final expectedImagePath = p.join(
classCachePath,
'$offset.$imageType',
);
prefetchUrls.add(imageUrl);
imageMeta.add((
url: imageUrl,
path: expectedImagePath,
docIndex: i,
pageIndex: j,
));
}
}
// 续传场景:提前读取各文件在磁盘上的已有字节,用于初始化进度基准。
final preTeacherBytes = await _localFileLength(teacherPath);
final preWbBytes = await _localFileLength(wbPath);
final preScreenShareBytes = <int>[];
for (final meta in screenShareMeta) {
preScreenShareBytes.add(await _localFileLength(meta.path));
}
final preImageBytes = <int>[];
for (final meta in imageMeta) {
preImageBytes.add(await _localFileLength(meta.path));
}
// completedBytes 以磁盘已有字节初始化;
// 各文件的 snap = completedBytes - preXxxBytes,使得 snap + fileNow 不会重复计算预存部分。
var completedBytes =
preTeacherBytes +
preWbBytes +
preScreenShareBytes.fold<int>(0, (sum, b) => sum + b) +
preImageBytes.fold<int>(0, (sum, b) => sum + b);
// grandTotalBytes 在 HEAD 完成后填充;reportPackageProgress 闭包读取其最新值。
// HEAD 之前 grandTotalBytes = 0,reportPackageProgress 会以 currentBytes 作为临时总量。
var grandTotalBytes = 0;
int? lastReportTimeMs;
const reportIntervalMs = 1000;
/// [currentBytes] 为整包已下载字节(已完成文件体积 + 当前文件已写入字节),
/// [totalBytes] 为 HEAD 汇总的整包总字节(固定,不因切文件而跳变;HEAD 前用缓存 total 或 currentBytes 占位)。
void reportPackageProgress(int currentBytes) {
final now = DateTime.now().millisecondsSinceEpoch;
if (lastReportTimeMs != null &&
now - lastReportTimeMs! < reportIntervalMs) {
return;
}
lastReportTimeMs = now;
// HEAD 完成前 grandTotalBytes = 0,优先用上次缓存的 total 保持 UI 稳定
final effectiveTotal =
grandTotalBytes > 0
? grandTotalBytes
: (_cachedProgressTotalBytes[classId] ?? 0);
final totalBytes = effectiveTotal > 0 ? effectiveTotal : currentBytes;
// 单调递增 + 慢爬动画:
// · 实际字节 > 上次已报 → 直接用真实值
// · 实际字节 ≤ 上次已报(续传/重下追赶中)→ 每次上报 +1KB 保持进度条微动,
// 直到实际进度追上显示值后恢复真实数据,避免 UI 冻结给用户"卡住"的错觉。
final lastKnown = _cachedProgressCurrentBytes[classId] ?? 0;
int effectiveCurrent;
if (currentBytes > lastKnown) {
effectiveCurrent = currentBytes;
} else {
effectiveCurrent = lastKnown + 1024;
// 不超过总量,防止显示 >100%
if (totalBytes > 0 && effectiveCurrent > totalBytes) {
effectiveCurrent = totalBytes;
}
}
// 缓存本次进度,供 resume/re-download 时立即恢复
_cachedProgressCurrentBytes[classId] = effectiveCurrent;
_cachedProgressTotalBytes[classId] = totalBytes;
final currentSizeKb = effectiveCurrent / 1024;
final totalSizeKb = totalBytes / 1024;
cacheMetadataListeners[classId]?.onCacheProgress(
classId,
currentSizeKb,
totalSizeKb,
);
}
// 预读完成后立刻上报已有进度(HEAD 请求前),避免 HEAD 期间 UI 停留在 0
if (completedBytes > 0) {
reportPackageProgress(completedBytes);
}
// HEAD 请求:获取各文件总大小以计算整包进度百分比
final prefetchSizes = await Future.wait(
prefetchUrls.map((u) => _headContentLength(u, cancelToken)),
);
// HEAD 完成后更新 grandTotalBytes;后续 reportPackageProgress 自动使用正确总量
for (final len in prefetchSizes) {
if (len != null && len > 0) {
grandTotalBytes += len;
}
}
var urlIndex = 0;
// snap = completedBytes - preTeacherBytes:去除 teacher 的预存部分,
// 由 _downloadFileWithResume 的 onFileProgress(fileNow) 中 fileNow 已包含预存字节补回。
final snapTeacher = completedBytes - preTeacherBytes;
final localVideoPath = await _downloadFileWithResume(
videoUrl,
teacherPath,
cancelToken: cancelToken,
prefetchedContentLength: prefetchSizes[urlIndex],
onFileProgress: (fileNow, _) {
reportPackageProgress(snapTeacher + fileNow);
},
);
urlIndex++;
completedBytes = snapTeacher + await _localFileLength(localVideoPath);
final snapWb = completedBytes - preWbBytes;
final localWhiteBoardPath = await _downloadFileWithResume(
whiteBoardVideoUrl,
wbPath,
cancelToken: cancelToken,
prefetchedContentLength: prefetchSizes[urlIndex],
onFileProgress: (fileNow, _) {
reportPackageProgress(snapWb + fileNow);
},
);
urlIndex++;
completedBytes = snapWb + await _localFileLength(localWhiteBoardPath);
for (var shareIdx = 0; shareIdx < screenShareMeta.length; shareIdx++) {
final meta = screenShareMeta[shareIdx];
final snapShare = completedBytes - preScreenShareBytes[shareIdx];
final localSharePath = await _downloadFileWithResume(
meta.url,
meta.path,
cancelToken: cancelToken,
prefetchedContentLength: prefetchSizes[urlIndex],
onFileProgress: (fileNow, _) {
reportPackageProgress(snapShare + fileNow);
},
);
urlIndex++;
screenShares[meta.shareIndex] = {
...screenShares[meta.shareIndex],
'video_url': localSharePath,
};
completedBytes = snapShare + await _localFileLength(localSharePath);
}
for (var imgIdx = 0; imgIdx < imageMeta.length; imgIdx++) {
final meta = imageMeta[imgIdx];
final snapImg = completedBytes - preImageBytes[imgIdx];
final localImagePath = await _downloadFileWithResume(
meta.url,
meta.path,
cancelToken: cancelToken,
prefetchedContentLength: prefetchSizes[urlIndex],
onFileProgress: (fileNow, _) {
reportPackageProgress(snapImg + fileNow);
},
);
urlIndex++;
final doc = documentPages[meta.docIndex];
final pageIndexList = List<Map<String, dynamic>>.from(
doc['page_index'],
);
pageIndexList[meta.pageIndex] = {
...pageIndexList[meta.pageIndex],
'url': localImagePath,
};
documentPages[meta.docIndex] = {...doc, 'page_index': pageIndexList};
completedBytes = snapImg + await _localFileLength(localImagePath);
}
final successFile = File(p.join(cacheDir.path, '$safeId-success.json'));
await successFile.writeAsString(
jsonEncode({
'document_pages': documentPages,
'im_messages': data['im_messages'],
'screen_shares': screenShares,
'video_url': localVideoPath,
'whiteboard_url': localWhiteBoardPath,
'class_name': data['class_name'],
'teacher_name': data['teacher_name'] ?? '',
'video_duration': data['video_duration'],
'video_start_time': data['video_start_time'],
'video_start_relative': data['video_start_relative'],
'whiteboard_duration': data['whiteboard_duration'],
'whiteboard_start_time': data['whiteboard_start_time'],
'whiteboard_start_relative': data['whiteboard_start_relative'],
}),
);
final totalSizeKb = completedBytes / 1024;
cacheMetadataListeners[classId]?.onCacheProgress(
classId,
totalSizeKb,
totalSizeKb,
);
final localPathsPayload = <String, dynamic>{
'classId': classId,
'successJsonPath': p.normalize(successFile.path),
'classCacheDirectory': p.normalize(classCachePath),
'document_pages': documentPages,
'im_messages': data['im_messages'],
'screen_shares': screenShares,
'video_url': localVideoPath,
'whiteboard_url': localWhiteBoardPath,
'class_name': data['class_name'],
'video_duration': data['video_duration'],
'video_start_time': data['video_start_time'],
'video_start_relative': data['video_start_relative'],
'whiteboard_duration': data['whiteboard_duration'],
'whiteboard_start_time': data['whiteboard_start_time'],
'whiteboard_start_relative': data['whiteboard_start_relative'],
};
// 下载成功,清除进度缓存(后续不再需要 resume 恢复)
_cachedProgressCurrentBytes.remove(classId);
_cachedProgressTotalBytes.remove(classId);
cacheMetadataListeners[classId]?.onCacheSuccess(
classId,
localPathsPayload,
);
await metaFile.delete();
try {
final oldFailed = File(p.join(cacheDir.path, '$safeId-failed.json'));
if (await oldFailed.exists()) await oldFailed.delete();
} catch (_) {}
} on DioException catch (e, st) {
final isPause =
e.type == DioExceptionType.cancel &&
_pauseRequestedForClassId[classId] == true;
if (isPause) {
_pauseRequestedForClassId.remove(classId);
_pausedClassIds.add(classId);
return;
}
final full = _formatPlaybackCacheError(e, st);
TCICLog.error(
'[PlayBackCache] downloadMetaData DioException classId=$classId\n$full',
actionModule: ActionModule.playBackController.name,
actionName: 'downloadMetaData',
);
cacheMetadataListeners[classId]?.onError(classId, 1003, full);
final failedFile = File(p.join(cacheDir.path, '$safeId-failed.json'));
await failedFile.writeAsString(jsonEncode({'error': full}));
try {
await metaFile.delete();
} catch (_) {}
} catch (e, st) {
final full = _formatPlaybackCacheError(e, st);
TCICLog.error(
'[PlayBackCache] downloadMetaData error classId=$classId\n$full',
actionModule: ActionModule.playBackController.name,
actionName: 'downloadMetaData',
);
cacheMetadataListeners[classId]?.onError(classId, 1003, full);
final failedFile = File(p.join(cacheDir.path, '$safeId-failed.json'));
await failedFile.writeAsString(jsonEncode({'error': full}));
try {
await metaFile.delete();
} catch (_) {}
} finally {
_downloadCancelTokens.remove(classId);
_downloadMetaDataRunning.remove(classId);
_downloadingClassIds.remove(classId);
}
}