downloadMetaData method

Future<void> downloadMetaData(
  1. String classId,
  2. String token
)

根据本地 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);
  }
}