涉及功能: 视频倍速/音轨/字幕/画面比例、图片滑动切换/双击缩放/旋转/信息面板
一、需求背景与调研
1.1 动机
我希望为视频播放器和图片预览器添加更多专业功能,对标主流播放器和相册应用的体验。
1.2 互联网调研结果
视频播放器主流功能
通过调研 VLC、MX Player、PotPlayer 等应用,整理出以下功能清单:
| 功能类别 | 功能项 | 优先级 | 复杂度 |
|---|---|---|---|
| 播放控制 | 倍速播放 (0.5x-2x) | ⭐⭐⭐ | 低 |
| 播放控制 | 长按加速 | ⭐⭐ | 低 |
| 播放控制 | 双击快进/快退 | ⭐⭐⭐ | 低 |
| 播放控制 | 左右滑动调进度 | ⭐⭐⭐ | 中 |
| 播放控制 | 上下滑动调亮度/音量 | ⭐⭐⭐ | 中 |
| 播放控制 | 锁定屏幕 | ⭐⭐ | 低 |
| 音视频轨道 | 音轨切换 | ⭐⭐⭐ | 中 |
| 音视频轨道 | 字幕切换 | ⭐⭐⭐ | 中 |
| 画面调整 | 画面比例切换 | ⭐⭐ | 低 |
| 记忆功能 | 记忆播放位置 | ⭐⭐⭐ | 低 |
图片预览器主流功能
| 功能类别 | 功能项 | 优先级 | 复杂度 |
|---|---|---|---|
| 浏览手势 | 左右滑动切换图片 | ⭐⭐⭐ | 中 |
| 缩放手势 | 双击放大/还原 | ⭐⭐⭐ | 中 |
| 缩放手势 | 双指缩放 | ⭐⭐⭐ | 低 (已有) |
| 图片信息 | EXIF 信息面板 | ⭐⭐ | 中 |
| 图片计数 | 显示 “3/20” 计数器 | ⭐⭐ | 低 |
| 图片编辑 | 旋转图片 | ⭐⭐ | 低 |
1.3 media_kit 自带功能分析
关键发现: 项目使用 media_kit 包,其 AdaptiveVideoControls 已内置大量手势功能:
| 功能 | 状态 | 说明 |
|---|---|---|
| 双击快进/快退 | ✅ 已有 | 双击左侧 -10s,右侧 +10s |
| 左右滑动调进度 | ✅ 已有 | 滑动调整播放进度 |
| 左侧上下滑动调亮度 | ✅ 已有 | 内置实现 |
| 右侧上下滑动调音量 | ✅ 已有 | 内置实现 |
| 播放/暂停 | ✅ 已有 | 点击中央 |
| 全屏切换 | ✅ 已有 | 全屏按钮 |
player.setRate() | ✅ API 可用 | 需自行添加 UI |
player.setAudioTrack() | ✅ API 可用 | 需自行添加 UI |
player.setSubtitleTrack() | ✅ API 可用 | 需自行添加 UI |
结论: 只需为已有 API 添加 UI 入口,无需实现底层逻辑。
二、实现方案设计
2.1 功能分阶段实现
第一阶段(高优先级)
- 视频:倍速选择 UI、记忆播放位置
- 图片:左右滑动切换、图片计数器、双击放大/还原
第二阶段(中优先级)
- 视频:音轨/字幕选择、画面比例切换
- 图片:文件信息面板、旋转图片(仅查看)
2.2 视频播放器架构设计
VideoPlayerPage├── AppBar (竖屏模式)│ ├── 标题│ ├── 倍速按钮 [1.0x]│ ├── 设置按钮 (齿轮图标)│ └── 更多按钮 (三点图标)│├── Video 组件│ └── MaterialVideoControlsTheme│ ├── normal (竖屏主题)│ │ └── 使用 AppBar 的按钮│ └── fullscreen (横屏/全屏主题)│ └── topButtonBar 添加自定义按钮│└── 底部菜单 ├── 倍速选择菜单 ├── 视频设置菜单 │ ├── 音轨选择 │ ├── 字幕选择 │ └── 画面比例 └── 文件操作菜单2.3 图片预览器架构设计
ImagePreviewPage├── AppBar│ ├── 返回按钮│ ├── 标题 (文件名)│ ├── 旋转按钮│ ├── 信息按钮│ └── 更多按钮│├── PageView (滑动切换)│ └── _ImageViewerItem (每页)│ └── InteractiveViewer│ └── Transform.rotate│ └── Image│├── 底部计数器 "3 / 20"│└── 文件信息面板 (DraggableScrollableSheet) ├── 文件名 ├── 文件大小 ├── 修改时间 └── 图片尺寸 (如可获取)三、关键实现细节
3.1 记忆播放位置 (SharedPreferences)
存储策略:
- Key:
video_position_{fileId} - Value: 播放位置(毫秒)
- 保存时机:
dispose()时保存 - 恢复逻辑: 如果上次位置 < 90% 则恢复,否则从头开始
// 保存
Future<void> _savePosition() async {
final position = _player.state.position.inMilliseconds;
if (position > 0) {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_positionKey, position);
}
}
// 恢复
Future<void> _restorePosition() async {
final prefs = await SharedPreferences.getInstance();
final savedPosition = prefs.getInt(_positionKey);
if (savedPosition != null && savedPosition > 0) {
final duration = _player.state.duration.inMilliseconds;
if (savedPosition < duration * 0.9) {
await _player.seek(Duration(milliseconds: savedPosition));
}
}
}3.2 双击放大/还原 (三档缩放)
缩放策略:
- 1x → 2x → 4x → 1x 循环
- 以双击点为中心放大
- 使用
AnimationController平滑过渡
void _onDoubleTap(TapDownDetails details) {
final position = details.localPosition;
Matrix4 targetMatrix;
if (_currentScale < 1.5) {
targetScale = 2.0;
} else if (_currentScale < 3.0) {
targetScale = 4.0;
} else {
targetScale = 1.0; // 还原
}
if (targetScale == 1.0) {
targetMatrix = Matrix4.identity();
} else {
// 计算以双击点为中心的变换矩阵
final scaleFactor = targetScale / _currentScale;
final dx = (1 - scaleFactor) * position.dx;
final dy = (1 - scaleFactor) * position.dy;
targetMatrix = Matrix4.diagonal3Values(targetScale, targetScale, 1.0)
..setTranslationRaw(dx, dy, 0);
}
// 动画过渡
_animation = Matrix4Tween(
begin: _transformationController.value,
end: targetMatrix,
).animate(_animController);
_animController.forward(from: 0);
}3.3 横竖屏控件差异处理
问题: media_kit 的 AdaptiveVideoControls 在横屏/全屏时隐藏 AppBar,使用内置控件栏。
解决方案: 使用 MaterialVideoControlsTheme 分别配置:
MaterialVideoControlsTheme(
normal: MaterialVideoControlsThemeData(
// 竖屏:不需要顶部按钮(已在 AppBar 中)
topButtonBar: [],
bottomButtonBar: const [
MaterialPositionIndicator(),
Spacer(),
MaterialFullscreenButton(),
],
),
fullscreen: MaterialVideoControlsThemeData(
// 横屏:添加自定义按钮到顶部栏
topButtonBar: [
const MaterialFullscreenButton(),
const Spacer(),
MaterialCustomButton(
onPressed: _showSpeedMenu,
icon: Container(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.white24,
borderRadius: BorderRadius.circular(4),
),
child: Text('${_currentSpeed}x', style: TextStyle(color: Colors.white)),
),
),
MaterialCustomButton(
onPressed: _showVideoSettings,
icon: Icon(TablerIcons.settings, color: Colors.white),
),
MaterialCustomButton(
onPressed: () => _showFileActions(context),
icon: Icon(TablerIcons.dots, color: Colors.white),
),
],
bottomButtonBar: const [
MaterialPositionIndicator(),
Spacer(),
],
),
child: Video(
controller: _controller,
controls: AdaptiveVideoControls,
fit: _currentFit,
),
)3.4 菜单项勾选位置偏移问题
问题: 使用 trailing 显示勾选图标时,只有选中项有 trailing,导致文字位置不一致。
错误写法:
ListTile(
title: Text('1.0x', textAlign: TextAlign.center),
trailing: isSelected ? Icon(Icons.check) : null, // 导致偏移
)修复方案: 改用 leading 并使用固定宽度占位:
ListTile(
leading: SizedBox(
width: 24,
child: isSelected
? Icon(TablerIcons.check, color: primaryColor, size: 20)
: null, // 即使为 null,SizedBox 仍保持宽度
),
title: Text('1.0x'), // 不再需要居中
)四、遇到的问题与解决
4.1 Matrix4.translate() 废弃警告
问题: Matrix4.translate() 方法已废弃。
解决:
// 错误
Matrix4.identity()..translate(dx, dy)..scale(targetScale);
// 正确
Matrix4.diagonal3Values(targetScale, targetScale, 1.0)
..setTranslationRaw(dx, dy, 0);4.2 图片预加载策略
为提升滑动流畅度,实现相邻图片预加载:
void _onPageChanged(int index) {
setState(() => _currentIndex = index);
// 预加载相邻图片
if (index > 0) _preloadImage(index - 1);
if (index < _imageList.length - 1) _preloadImage(index + 1);
}
Future<void> _preloadImage(int index) async {
if (_imageCache.containsKey(index)) return;
final file = _imageList[index];
final bytes = await appState.api.downloadFileToMemory(file.id);
if (mounted && bytes != null) {
setState(() => _imageCache[index] = bytes);
}
}4.3 旋转状态按图片隔离
需求: 每张图片有独立的旋转状态,切换图片后旋转不互相影响。
实现: 使用 Map<int, int> 存储每张图片的旋转次数:
final Map<int, int> _rotationQuarters = {};
int _getRotation(int index) => _rotationQuarters[index] ?? 0;
void _rotateImage() {
setState(() {
final current = _rotationQuarters[_currentIndex] ?? 0;
_rotationQuarters[_currentIndex] = (current + 1) % 4;
});
}五、文件修改清单
| 文件 | 修改类型 | 内容 |
|---|---|---|
video_player_page.dart | 重写 | 添加倍速/音轨/字幕/画面比例、记忆播放位置、横竖屏主题 |
image_preview_page.dart | 重写 | 添加滑动切换/计数器/双击缩放/旋转/信息面板 |
pubspec.yaml | 无变动 | shared_preferences 已存在 |
六、测试要点
视频播放器
- 竖屏模式倍速/设置/更多按钮可用
- 横屏模式相同按钮在顶部栏显示
- 倍速切换后实际播放速度变化
- 音轨/字幕列表正确显示(需多轨道测试文件)
- 画面比例切换生效
- 退出后再进入,播放位置恢复
图片预览器
- 左右滑动切换图片
- 底部计数器正确显示 “3 / 20”
- 双击放大(2x → 4x → 1x 循环)
- 放大后可平移查看
- 旋转按钮点击后图片旋转 90°
- 切换图片后旋转状态独立
- 信息面板正确显示文件信息
七、后续优化建议
第三阶段(低优先级)
- 长按加速播放
- 画中画 (PiP)
- 锁定屏幕
- 图片 EXIF 详细信息(需第三方库)
- 分享到其他应用
性能优化
- 图片缓存使用 LRU 策略避免内存溢出
- 大图使用采样加载
- 视频封面帧预加载
八、经验总结
- 调研先行: 充分调研现有库能力(如 media_kit),避免重复造轮子
- 分阶段实现: 按优先级分阶段,快速交付核心功能
- 主题系统活用: media_kit 的
MaterialVideoControlsTheme可精细控制不同模式的 UI - 布局一致性: 菜单项使用固定宽度 leading 而非 trailing,避免对齐问题
- 状态隔离: 多项目(如多图片)的状态使用 Map 隔离存储