视频播放器与图片预览器功能增强开发笔记

December 28, 2025
5 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

涉及功能: 视频倍速/音轨/字幕/画面比例、图片滑动切换/双击缩放/旋转/信息面板


一、需求背景与调研

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 策略避免内存溢出
  • 大图使用采样加载
  • 视频封面帧预加载

八、经验总结

  1. 调研先行: 充分调研现有库能力(如 media_kit),避免重复造轮子
  2. 分阶段实现: 按优先级分阶段,快速交付核心功能
  3. 主题系统活用: media_kit 的 MaterialVideoControlsTheme 可精细控制不同模式的 UI
  4. 布局一致性: 菜单项使用固定宽度 leading 而非 trailing,避免对齐问题
  5. 状态隔离: 多项目(如多图片)的状态使用 Map 隔离存储