feat(file-download): 文件下载全量实现 (#14~#18)

- CdnUrlResolver: 相对路径/S3 URL 统一解析为 apiBaseUrl 代理链路 (#14)
- FileDownloadManager: Notifier<Map<url, state>>,四态(idle/progress/done/failed),
  断点续传 + 取消令牌 + 本地缓存(systemTemp/im_file_cache) (#15)
- FileMessageBubble: 三态 UI,文件类型图标/颜色,大小格式化,
  idle/failed 点击触发下载,done 点击回调 open_filex TODO (#16)
- AudioMessageBubble: 语音消息下载框架,静态波形装饰,
  播放 TODO(audioplayers 接入后解开) (#17)
- VideoMessageBubble: 缩略图 Image.network + CdnUrlResolver,
  播放按钮覆盖,上传进度环,video_player 接入 TODO (#18)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
pp-bot
2026-03-23 20:02:46 +09:00
parent aeeda6f059
commit 83774f5f61
6 changed files with 1096 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
import 'package:im_app/core/foundation/config.dart';
/// CDN URL 解析器
///
/// 对应 Gitea issue #14
///
/// 文件/图片/语音/视频的 CDN 路径有三种形态:
/// 1. 相对路径(如 `Files/xxx.pdf`, `Image/xxx.jpg`
/// → 拼接 [AppConfig.apiBaseUrl]
/// 2. 完整 http/https URL非 S3
/// → 直接返回
/// 3. S3 URL含 `.amazonaws.com`
/// → 路由通过 [AppConfig.apiBaseUrl] 代理S3 桶是私有的,直接访问 403
/// → 提取 path 部分拼接 baseUrl
///
/// ## 使用
///
/// ```dart
/// final url = CdnUrlResolver.resolve('Files/report.pdf');
/// // → 'http://gateway.winwayinfo.com/Files/report.pdf'
/// ```
// ignore: avoid_classes_with_only_static_members
class CdnUrlResolver {
CdnUrlResolver._();
/// 将任意格式的 CDN 路径解析为完整可下载 URL
static String resolve(String path) {
if (path.isEmpty) return path;
// S3 直链 → 路由通过 API 代理
if (_isS3Url(path)) {
return _proxyS3(path);
}
// 完整 HTTP URL非 S3→ 直接使用
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
// 相对路径 → 拼接 baseUrl
final base = AppConfig.apiBaseUrl.endsWith('/')
? AppConfig.apiBaseUrl.substring(0, AppConfig.apiBaseUrl.length - 1)
: AppConfig.apiBaseUrl;
final suffix = path.startsWith('/') ? path : '/$path';
return '$base$suffix';
}
/// 判断是否为 S3 URL
static bool _isS3Url(String url) {
return url.contains('.amazonaws.com') ||
url.contains('s3-accelerate') ||
url.contains('s3.amazonaws');
}
/// S3 URL → 通过 apiBaseUrl 代理
///
/// S3 presigned URL 带有 `/bucket-name/path?X-Amz-...` 结构,
/// 取 path 部分(去掉 query string拼接 baseUrl。
static String _proxyS3(String s3Url) {
try {
final uri = Uri.parse(s3Url);
// path 可能是 /bucket/Image/xxx 或 /Image/xxx取最后有意义的段
final path = uri.path;
final base = AppConfig.apiBaseUrl.endsWith('/')
? AppConfig.apiBaseUrl.substring(0, AppConfig.apiBaseUrl.length - 1)
: AppConfig.apiBaseUrl;
return '$base$path';
} catch (_) {
return s3Url;
}
}
}

View File

@@ -0,0 +1,183 @@
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:networks_sdk/networks_sdk.dart';
import 'package:im_app/app/di/network_provider.dart';
import 'package:im_app/core/services/cdn_url_resolver.dart';
/// 单文件下载状态
///
/// 对应 iOS `FileDownloadState` enum
sealed class FileDownloadState {
const FileDownloadState();
}
/// 未下载(初始态)
class FileDownloadIdle extends FileDownloadState {
const FileDownloadIdle();
}
/// 下载中0.0 ~ 1.0
class FileDownloadProgress extends FileDownloadState {
final double progress;
const FileDownloadProgress(this.progress);
}
/// 已下载localPath 为本地缓存路径
class FileDownloadDone extends FileDownloadState {
final String localPath;
const FileDownloadDone(this.localPath);
}
/// 下载失败
class FileDownloadFailed extends FileDownloadState {
final String error;
const FileDownloadFailed(this.error);
}
/// 文件下载管理器
///
/// 对应 Gitea issue #15
///
/// 维护 URL → [FileDownloadState] 的映射,通过 [NetworksSdkApi.executeDownload]
/// 执行带进度的文件下载(内部支持断点续传)。
///
/// ## 缓存策略
///
/// 缓存目录:`Directory.systemTemp/im_file_cache/`(同 iOS Library/Caches 语义OS 负责清理)
/// 缓存命名:`{url.hashCode 8位 hex}_{sanitized_filename}`
///
/// ## 幂等性
///
/// [download] 先检查本地缓存,命中则直接更新状态为 [FileDownloadDone],不发起网络请求。
///
/// ## 并发控制
///
/// 同一 URL 下载中时再次调用 [download] 会静默忽略。
///
/// ## 使用
///
/// ```dart
/// // 触发下载
/// ref.read(fileDownloadManagerProvider.notifier).download(
/// url: 'Files/report.pdf',
/// fileName: 'report.pdf',
/// );
///
/// // 读取状态
/// final dlState = ref.watch(fileDownloadManagerProvider.select(
/// (map) => map['Files/report.pdf'],
/// ));
/// ```
class FileDownloadManager extends Notifier<Map<String, FileDownloadState>> {
/// 进行中的取消令牌key = 原始 URL
final _cancelTokens = <String, CancelToken>{};
/// 已主动取消的 URL防止 catch 块将其状态覆盖为 failed
final _cancelledUrls = <String>{};
@override
Map<String, FileDownloadState> build() => {};
// ── 公开 API ────────────────────────────────────────────────────────────────
/// 下载文件(幂等)
///
/// [url] 原始 CDN 路径(相对路径或完整 URL由 [CdnUrlResolver.resolve] 处理。
/// [fileName] 原始文件名(用于缓存命名和 MIME 类型推断)。
///
/// 返回本地缓存路径(已缓存时立即返回,新下载完成后返回)。
Future<String?> download({
required String url,
required String fileName,
}) async {
// 检查本地缓存
final cachePath = await _cachePath(url, fileName);
final file = File(cachePath);
if (file.existsSync() && file.lengthSync() > 0) {
_updateState(url, FileDownloadDone(cachePath));
return cachePath;
}
// 已在下载中,忽略
final current = state[url];
if (current is FileDownloadProgress) return null;
// 开始下载
_updateState(url, const FileDownloadProgress(0));
final cancelToken = CancelToken();
_cancelTokens[url] = cancelToken;
try {
final resolvedUrl = CdnUrlResolver.resolve(url);
final api = ref.read(networkSdkApiProvider);
await api.executeDownload(
url: resolvedUrl,
savePath: cachePath,
cancelToken: cancelToken,
resume: true,
onProgress: (received, total) {
if (total > 0) {
final progress = received / total;
_updateState(url, FileDownloadProgress(progress.clamp(0.0, 1.0)));
}
},
);
_cancelTokens.remove(url);
_updateState(url, FileDownloadDone(cachePath));
return cachePath;
} catch (e) {
_cancelTokens.remove(url);
// 若已被主动取消state 已置为 idle不再覆盖
if (_cancelledUrls.remove(url)) return null;
_updateState(url, FileDownloadFailed(e.toString()));
return null;
}
}
/// 取消下载
void cancelDownload(String url) {
_cancelledUrls.add(url);
_cancelTokens[url]?.cancel('用户取消');
_cancelTokens.remove(url);
_updateState(url, const FileDownloadIdle());
}
/// 获取本地缓存路径(不触发下载)
///
/// 返回 null 表示尚未下载。
Future<String?> localPath(String url, String fileName) async {
final path = await _cachePath(url, fileName);
final file = File(path);
if (file.existsSync() && file.lengthSync() > 0) return path;
return null;
}
// ── 内部辅助 ────────────────────────────────────────────────────────────────
void _updateState(String url, FileDownloadState s) {
state = {...state, url: s};
}
/// 构造缓存文件路径
///
/// 格式:`{tempDir}/im_file_cache/{hash8}_{safeName}`
Future<String> _cachePath(String url, String fileName) async {
final cacheDir = Directory('${Directory.systemTemp.path}/im_file_cache');
if (!cacheDir.existsSync()) {
await cacheDir.create(recursive: true);
}
final hash = url.hashCode.toUnsigned(32).toRadixString(16).padLeft(8, '0');
final safeName = fileName.replaceAll(RegExp(r'[^\w.\-]'), '_');
return '${cacheDir.path}/${hash}_$safeName';
}
}
/// 文件下载管理器 Provider
final fileDownloadManagerProvider =
NotifierProvider<FileDownloadManager, Map<String, FileDownloadState>>(
FileDownloadManager.new,
);

View File

@@ -0,0 +1,194 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/core/services/file_download_manager.dart';
/// 语音消息气泡typ = 3
///
/// 对应 Gitea issue #17 / iOS VoiceMessageBubble + AudioPlaybackService
///
/// ## 数据格式
///
/// rawContent JSON`{ "url": "Voice/xxx.m4a", "duration": 5 }`
///
/// ## 当前实现
///
/// 下载框架已实现,播放能力待 audioplayers 包接入:
/// 1. 点击播放按钮 → 通过 FileDownloadManager 下载语音文件
/// 2. 下载完成 → TODO: AudioPlayer().play(DeviceFileSource(localPath))
///
/// ## 接入 audioplayers
///
/// ```yaml
/// # pubspec.yaml
/// dependencies:
/// audioplayers: ^6.0.0
/// ```
///
/// 解开 TODO 注释即可完成播放功能。
class AudioMessageBubble extends ConsumerWidget {
const AudioMessageBubble({
super.key,
required this.rawContent,
required this.messageId,
this.isSelf = false,
});
final String rawContent;
final String messageId;
/// 是否为自己发送的消息(影响气泡方向)
final bool isSelf;
@override
Widget build(BuildContext context, WidgetRef ref) {
final parsed = _parseContent(rawContent);
final url = parsed['url'] as String? ?? '';
final duration = parsed['duration'] as int? ?? 0;
final fileName = 'voice_$messageId.m4a';
final downloadState =
ref.watch(fileDownloadManagerProvider.select((map) => map[url])) ??
const FileDownloadIdle();
final theme = Theme.of(context);
final isDownloading = downloadState is FileDownloadProgress;
final isDownloaded = downloadState is FileDownloadDone;
return InkWell(
borderRadius: BorderRadius.circular(20),
onTap: isDownloading || url.isEmpty
? null
: () => _handleTap(ref, url, fileName, downloadState),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
constraints: const BoxConstraints(minWidth: 100, maxWidth: 220),
decoration: BoxDecoration(
color: isSelf
? theme.colorScheme.primary
: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildLeadingIcon(context, downloadState),
const SizedBox(width: 8),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 时长 / 进度
if (isDownloading)
Text(
'${((downloadState as FileDownloadProgress).progress * 100).toStringAsFixed(0)}%',
style: theme.textTheme.bodySmall?.copyWith(
color: isSelf ? Colors.white70 : Colors.grey,
),
)
else
Text(
duration > 0 ? "${duration}''" : "语音",
style: theme.textTheme.bodyMedium?.copyWith(
color: isSelf ? Colors.white : null,
),
),
// 波形装饰(静态占位)
const SizedBox(height: 2),
_WaveformDecoration(isSelf: isSelf, isPlaying: isDownloaded),
],
),
),
],
),
),
);
}
Widget _buildLeadingIcon(BuildContext context, FileDownloadState state) {
final isSelfBubble = isSelf;
final iconColor = isSelfBubble ? Colors.white : Theme.of(context).colorScheme.primary;
switch (state) {
case FileDownloadIdle():
return Icon(Icons.play_circle_outline, size: 28, color: iconColor);
case FileDownloadProgress(:final progress):
return SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
value: progress,
strokeWidth: 2.5,
color: iconColor,
),
);
case FileDownloadDone():
// TODO: AudioPlaybackService 接入后区分 playing / paused 态
return Icon(Icons.pause_circle_outline, size: 28, color: iconColor);
case FileDownloadFailed():
return Icon(Icons.error_outline, size: 28, color: Colors.red.shade300);
}
}
void _handleTap(
WidgetRef ref,
String url,
String fileName,
FileDownloadState state,
) {
if (state is FileDownloadDone) {
// TODO: 接入 audioplayers 后播放:
// import 'package:audioplayers/audioplayers.dart';
// final player = AudioPlayer();
// await player.play(DeviceFileSource(state.localPath));
return;
}
ref.read(fileDownloadManagerProvider.notifier).download(
url: url,
fileName: fileName,
);
}
Map<String, dynamic> _parseContent(String raw) {
try {
return jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return {};
}
}
}
/// 静态波形装饰(未接入音频分析时的占位)
class _WaveformDecoration extends StatelessWidget {
const _WaveformDecoration({required this.isSelf, required this.isPlaying});
final bool isSelf;
final bool isPlaying;
@override
Widget build(BuildContext context) {
final baseColor = isSelf ? Colors.white54 : Colors.grey.shade400;
final activeColor =
isSelf ? Colors.white : Theme.of(context).colorScheme.primary;
final heights = [3.0, 6.0, 4.0, 8.0, 5.0, 7.0, 3.0, 6.0, 4.0];
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: heights.map((h) {
return Container(
width: 2.5,
height: h,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: isPlaying ? activeColor : baseColor,
borderRadius: BorderRadius.circular(1),
),
);
}).toList(),
);
}
}

View File

@@ -0,0 +1,290 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:im_app/core/services/file_download_manager.dart';
/// 文件消息气泡typ = 6
///
/// 对应 Gitea issue #16 / iOS FileMessageBubble
///
/// 三种展示态:
/// - idle / failed → 文件名 + 大小 + 下载图标,点击触发下载
/// - downloading → 文件名 + 大小 + 线性进度条 + 百分比
/// - downloaded → 文件名 + 大小 + 打开图标,点击回调 [onTap]
///
/// ## 使用
///
/// ```dart
/// FileMessageBubble(
/// url: 'Files/report.pdf',
/// fileName: 'report.pdf',
/// fileSize: 1234567,
/// onTap: (localPath) async {
/// // TODO: open_filex 接入后: await OpenFilex.open(localPath);
/// },
/// )
/// ```
class FileMessageBubble extends ConsumerWidget {
const FileMessageBubble({
super.key,
required this.url,
required this.fileName,
required this.fileSize,
this.onTap,
});
final String url;
final String fileName;
final int fileSize;
/// 点击已下载文件回调localPath 为本地路径
final void Function(String localPath)? onTap;
@override
Widget build(BuildContext context, WidgetRef ref) {
final downloadState =
ref.watch(fileDownloadManagerProvider.select((map) => map[url])) ??
const FileDownloadIdle();
return _FileMessageBubbleContent(
url: url,
fileName: fileName,
fileSize: fileSize,
downloadState: downloadState,
onTap: onTap,
);
}
}
class _FileMessageBubbleContent extends ConsumerWidget {
const _FileMessageBubbleContent({
required this.url,
required this.fileName,
required this.fileSize,
required this.downloadState,
this.onTap,
});
final String url;
final String fileName;
final int fileSize;
final FileDownloadState downloadState;
final void Function(String localPath)? onTap;
@override
Widget build(BuildContext context, WidgetRef ref) {
final theme = Theme.of(context);
final isDownloading = downloadState is FileDownloadProgress;
final isDownloaded = downloadState is FileDownloadDone;
return InkWell(
borderRadius: BorderRadius.circular(8),
onTap: isDownloading
? null
: () => _handleTap(context, ref, downloadState),
child: Container(
constraints: const BoxConstraints(minWidth: 180, maxWidth: 260),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.5),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
// 文件类型图标
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: _fileColor(fileName).withOpacity(0.15),
borderRadius: BorderRadius.circular(6),
),
child: Icon(
_fileIcon(fileName),
size: 20,
color: _fileColor(fileName),
),
),
const SizedBox(width: 10),
// 文件名 + 大小
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
fileName,
style: theme.textTheme.bodyMedium
?.copyWith(fontWeight: FontWeight.w500),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
_formatSize(fileSize),
style: theme.textTheme.bodySmall
?.copyWith(color: Colors.grey),
),
],
),
),
const SizedBox(width: 8),
// 右侧状态图标
_buildTrailingIcon(context, downloadState),
],
),
// 下载进度条
if (isDownloading) ...[
const SizedBox(height: 8),
_DownloadProgressBar(
progress: (downloadState as FileDownloadProgress).progress,
),
],
// 已下载提示
if (isDownloaded)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'已下载,点击打开',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.primary),
),
),
// 失败提示
if (downloadState is FileDownloadFailed)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'下载失败,点击重试',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.error),
),
),
],
),
),
);
}
Widget _buildTrailingIcon(BuildContext context, FileDownloadState state) {
switch (state) {
case FileDownloadIdle():
return const Icon(Icons.download_outlined, size: 22, color: Colors.grey);
case FileDownloadProgress():
return SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
value: state.progress,
strokeWidth: 2.5,
color: Theme.of(context).colorScheme.primary,
),
);
case FileDownloadDone():
return Icon(
Icons.folder_open_outlined,
size: 22,
color: Theme.of(context).colorScheme.primary,
);
case FileDownloadFailed():
return const Icon(Icons.error_outline, size: 22, color: Colors.red);
}
}
void _handleTap(
BuildContext context,
WidgetRef ref,
FileDownloadState state,
) {
if (state is FileDownloadDone) {
onTap?.call(state.localPath);
// TODO: open_filex 接入后:
// import 'package:open_filex/open_filex.dart';
// await OpenFilex.open(state.localPath);
return;
}
// idle / failed → 触发下载
ref.read(fileDownloadManagerProvider.notifier).download(
url: url,
fileName: fileName,
);
}
// ── 辅助 ────────────────────────────────────────────────────────────────────
IconData _fileIcon(String name) {
final ext = name.split('.').last.toLowerCase();
return switch (ext) {
'pdf' => Icons.picture_as_pdf_outlined,
'doc' || 'docx' => Icons.description_outlined,
'xls' || 'xlsx' => Icons.table_chart_outlined,
'ppt' || 'pptx' => Icons.slideshow_outlined,
'zip' || 'rar' || '7z' => Icons.folder_zip_outlined,
'mp3' || 'aac' || 'm4a' || 'wav' => Icons.audio_file_outlined,
'mp4' || 'mov' || 'avi' || 'mkv' => Icons.video_file_outlined,
'jpg' || 'jpeg' || 'png' || 'gif' || 'webp' => Icons.image_outlined,
_ => Icons.insert_drive_file_outlined,
};
}
Color _fileColor(String name) {
final ext = name.split('.').last.toLowerCase();
return switch (ext) {
'pdf' => Colors.red.shade600,
'doc' || 'docx' => Colors.blue.shade700,
'xls' || 'xlsx' => Colors.green.shade700,
'ppt' || 'pptx' => Colors.orange.shade700,
'zip' || 'rar' || '7z' => Colors.brown.shade600,
'mp3' || 'aac' || 'm4a' || 'wav' => Colors.purple.shade600,
'mp4' || 'mov' || 'avi' || 'mkv' => Colors.pink.shade600,
_ => Colors.blueGrey.shade600,
};
}
String _formatSize(int bytes) {
if (bytes <= 0) return '0 B';
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
}
/// 下载进度条组件
class _DownloadProgressBar extends StatelessWidget {
const _DownloadProgressBar({required this.progress});
final double progress;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(2),
child: LinearProgressIndicator(
value: progress,
minHeight: 4,
backgroundColor:
Theme.of(context).colorScheme.outline.withOpacity(0.2),
),
),
const SizedBox(height: 2),
Text(
'${(progress * 100).toStringAsFixed(0)}%',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontSize: 10,
),
),
],
);
}
}

View File

@@ -0,0 +1,194 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:im_app/core/services/cdn_url_resolver.dart';
/// 视频消息气泡typ = 4 / typ = 24
///
/// 对应 Gitea issue #18 / iOS VideoMessageBubble
///
/// ## 数据格式
///
/// rawContent JSON`{ "url": "Video/xxx.mp4", "thumb": "Image/thumb.jpg", "size": 12345678 }`
///
/// ## 当前实现
///
/// - 缩略图:通过 [CdnUrlResolver.resolve] 解析 thumbUrl 后用 `Image.network` 展示
/// - 播放按钮覆盖层(点击回调由父级处理)
/// - 发送进度环(出站消息上传中)
///
/// ## 接入 video_player
///
/// ```yaml
/// # pubspec.yaml
/// dependencies:
/// video_player: ^2.9.0
/// ```
///
/// 点击后 push 全屏视频路由,用 `VideoPlayerController.networkUrl(Uri.parse(resolvedUrl))` 流式播放,
/// 无需预先下载(同 iOS AVPlayer 流式播放策略)。
class VideoMessageBubble extends StatelessWidget {
const VideoMessageBubble({
super.key,
required this.rawContent,
this.uploadProgress,
this.onTap,
});
final String rawContent;
/// 上传进度0.0~1.0发送中时显示进度环null 表示非发送中
final double? uploadProgress;
/// 点击回调videoUrl, resolvedUrl供父级处理全屏播放
final void Function(String videoUrl)? onTap;
@override
Widget build(BuildContext context) {
final parsed = _parseContent(rawContent);
final videoUrl = parsed['url'] as String? ?? '';
final thumbUrl = parsed['thumb'] as String? ?? '';
final fileSize = parsed['size'] as int? ?? 0;
final resolvedThumb =
thumbUrl.isNotEmpty ? CdnUrlResolver.resolve(thumbUrl) : null;
return GestureDetector(
onTap: videoUrl.isNotEmpty ? () => onTap?.call(videoUrl) : null,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Stack(
alignment: Alignment.center,
children: [
// 缩略图 / 占位
SizedBox(
width: 200,
height: 140,
child: resolvedThumb != null
? Image.network(
resolvedThumb,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
const _VideoThumbnailPlaceholder(),
)
: const _VideoThumbnailPlaceholder(),
),
// 上传进度环 / 播放按钮
if (uploadProgress != null)
_UploadProgressOverlay(progress: uploadProgress!)
else
_PlayButtonOverlay(hasUrl: videoUrl.isNotEmpty),
// 文件大小标签
if (fileSize > 0)
Positioned(
bottom: 4,
right: 6,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(4),
),
child: Text(
_formatSize(fileSize),
style: const TextStyle(color: Colors.white, fontSize: 10),
),
),
),
],
),
),
);
}
Map<String, dynamic> _parseContent(String raw) {
try {
return jsonDecode(raw) as Map<String, dynamic>;
} catch (_) {
return {};
}
}
String _formatSize(int bytes) {
if (bytes <= 0) return '';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
}
}
/// 缩略图占位(无 thumbUrl 或加载失败)
class _VideoThumbnailPlaceholder extends StatelessWidget {
const _VideoThumbnailPlaceholder();
@override
Widget build(BuildContext context) {
return Container(
color: Colors.black87,
child: const Center(
child: Icon(Icons.video_file, size: 48, color: Colors.white54),
),
);
}
}
/// 播放按钮覆盖层
class _PlayButtonOverlay extends StatelessWidget {
const _PlayButtonOverlay({required this.hasUrl});
final bool hasUrl;
@override
Widget build(BuildContext context) {
return Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.5),
shape: BoxShape.circle,
),
child: Icon(
Icons.play_arrow,
color: hasUrl ? Colors.white : Colors.white38,
size: 30,
),
);
}
}
/// 上传进度环覆盖层(发送中态)
class _UploadProgressOverlay extends StatelessWidget {
const _UploadProgressOverlay({required this.progress});
final double progress;
@override
Widget build(BuildContext context) {
return SizedBox(
width: 48,
height: 48,
child: Stack(
alignment: Alignment.center,
children: [
CircularProgressIndicator(
value: progress,
strokeWidth: 3,
color: Colors.white,
backgroundColor: Colors.white30,
),
Text(
'${(progress * 100).toInt()}%',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
}