This commit is contained in:
chenshuainan
2026-09-28 20:12:34 +08:00
parent 847986f861
commit 5f53bd8f94
60 changed files with 14624 additions and 398 deletions
+36 -22
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:just_audio/just_audio.dart';
import 'media_pause_scope.dart';
/// ============================================================
/// 【AudioPlayerCard】通用音频播放控制条(在线音频)
@@ -43,7 +44,7 @@ import 'package:just_audio/just_audio.dart';
/// 不支持(返回 200 全量)→ ExoPlayer 无法字节定位,只能从头重新
/// 下载,表现就是"点中间段位置被重置回起点"(且第二次打开有系统
/// 缓存时会看似正常,极易误判)。验收新音频源:
/// curl -s -D - -o NUL -H "Range: bytes=0-1023" <url> 看状态码。
/// curl -s -D - -o NUL -H "Range: bytes=0-1023" `<url>` 看状态码。
/// ============================================================
class AudioPlayerCard extends StatefulWidget {
const AudioPlayerCard({
@@ -88,10 +89,14 @@ class AudioPlayerCard extends StatefulWidget {
}
class AudioPlayerCardState extends State<AudioPlayerCard>
with WidgetsBindingObserver {
with WidgetsBindingObserver
implements MediaPausable {
/// 底层播放器(just_audio)
final AudioPlayer _player = AudioPlayer();
/// 所属媒体暂停作用域(随挂载位置自动注册/注销)
MediaPauseRegistry? _pauseScope;
/// 是否正在加载(setUrl 未完成)
bool _loading = true;
@@ -115,15 +120,29 @@ class AudioPlayerCardState extends State<AudioPlayerCard>
_initPlayer();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// 自动挂到最近的 MediaPauseScope(含 fwfh 富文本内部的播放器)
final scope = MediaPauseScope.of(context);
if (scope != _pauseScope) {
_pauseScope?.unregister(this);
_pauseScope = scope;
_pauseScope?.register(this);
}
}
@override
void dispose() {
_pauseScope?.unregister(this);
WidgetsBinding.instance.removeObserver(this);
// 必须释放播放器,否则音频解码资源泄漏
_player.dispose();
super.dispose();
}
/// 暂停播放(外部通过 GlobalKey 调用,用于切页时暂停音频)
/// 暂停播放(切页时由 MediaPauseRegistry 统一调用)
@override
void pause() {
_player.pause();
}
@@ -403,19 +422,11 @@ class AudioPlayerCardState extends State<AudioPlayerCard>
);
}
/// 标题区:标题 + 地址/缓冲提示小字
/// 标题区:标题 + 缓冲提示小字(不展示音频源地址)
Widget _buildTitle({required bool buffering, required int percent}) {
// 小字:缓冲中显示百分比,否则显示源地址
final String subText;
if (buffering) {
subText = '缓冲中 $percent%...';
} else if (widget.asset != null) {
subText = widget.asset!;
} else {
subText = widget.url ?? '';
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
widget.title ?? (widget.asset != null ? '本地音频' : '音频'),
@@ -427,16 +438,19 @@ class AudioPlayerCardState extends State<AudioPlayerCard>
color: Color(0xFF333333),
),
),
const SizedBox(height: 2),
Text(
subText,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
color: buffering ? const Color(0xFFF59E0B) : const Color(0xFF999999),
// 仅缓冲中展示百分比小字,不展示播放链接 URL
if (buffering) ...[
const SizedBox(height: 2),
Text(
'缓冲中 $percent%...',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 11,
color: Color(0xFFF59E0B),
),
),
),
],
],
);
}
@@ -0,0 +1,50 @@
import 'package:flutter/widgets.dart';
/// 可被外部暂停的媒体(音频/视频播放器 State 实现)
abstract class MediaPausable {
void pause();
}
/// 媒体暂停注册表:播放器挂载时注册、卸载时注销,
/// 暂停时直接遍历注册表,不再运行时递归 visitChildElements,
/// 避免在 build/翻页动画/element 停用阶段遍历 element 树
/// 导致的生命周期断言(RenderViewport 收到 RenderErrorBox 等连锁报错)。
class MediaPauseRegistry {
final Set<MediaPausable> _items = <MediaPausable>{};
void register(MediaPausable item) => _items.add(item);
void unregister(MediaPausable item) => _items.remove(item);
/// 暂停注册表里的全部媒体。
/// 复制一份再遍历,防止 pause 触发的异步卸载修改集合。
void pauseAll() {
for (final item in _items.toList(growable: false)) {
item.pause();
}
}
}
/// 媒体暂停作用域:包裹需要统一暂停媒体的区域(如单个题目页/子题页)。
/// 子树中的 AudioPlayerCard/VideoPlayerView 会自动注册到最近的作用域。
class MediaPauseScope extends InheritedWidget {
final MediaPauseRegistry registry;
const MediaPauseScope({
super.key,
required this.registry,
required super.child,
});
/// 查找最近的作用域注册表;不存在返回 null(不建立依赖关系)。
static MediaPauseRegistry? of(BuildContext context) {
final widget = context
.getElementForInheritedWidgetOfExactType<MediaPauseScope>()
?.widget;
return widget is MediaPauseScope ? widget.registry : null;
}
@override
bool updateShouldNotify(MediaPauseScope oldWidget) =>
registry != oldWidget.registry;
}
+142 -43
View File
@@ -22,6 +22,8 @@ import 'package:new_learn/core/components/video_player_view.dart';
/// customStylesBuilder ← 自定义标签样式(如把 .answer 高亮)
/// customWidgetBuilder ← 自定义元素替换(把任意标签换成业务 widget)
/// renderMode ← 渲染模式(默认 column;列表预览可用 listView)
/// debugImage ← 是否打印 img 解析日志
/// assumeRpx ← HTML 里的 width/height 是否按 rpx 处理
///
/// 三、内置扩展点(本组件已经帮你处理的):
/// 1. <audio> 标签 → 自动替换成 AudioPlayerCard(本项目封装的音频控制条)
@@ -29,7 +31,12 @@ import 'package:new_learn/core/components/video_player_view.dart';
/// 2. <video> 标签 → 自动替换成 VideoPlayerView(本项目内嵌播放器,
/// 无 Scaffold/AppBar),默认 autoPlay: false(避免富文本里
/// 多个视频同时解码占内存),取 title 属性作为错误提示;
/// 3. 公式(\(...\) LaTeX)→ 需要 flutter_math_fork,后续扩展。
/// 3. <img> 标签 → 统一接管:
/// - .svg 走 flutter_svg(带 SSAA 超采样,去锯齿);
/// - 位图走 Image.network;
/// - 公式图(有明确 width/height,或 parent 是行内标签)
/// 用 InlineCustomWidget,按原始尺寸渲染,不缩放;
/// - 块级大图宽度撑满父容器,高度按比例自适应。
///
/// 四、学生端场景:
/// - 字号调节:外部用 Slider 控制 textStyle.fontSize 即可(demo 页示范);
@@ -54,8 +61,15 @@ class RichTextContent extends StatelessWidget {
this.customStylesBuilder,
this.customWidgetBuilder,
this.renderMode = RenderMode.column,
this.htmlWidgetKey,
this.debugImage = false,
this.assumeRpx = true,
});
/// 透传给内部 HtmlWidget 的 key(外部用于调用
/// HtmlWidgetState.scrollToAnchor 滚动到 #anchor)
final Key? htmlWidgetKey;
/// HTML 字符串(后端返回)
final String html;
@@ -74,65 +88,124 @@ class RichTextContent extends StatelessWidget {
/// 渲染模式:column(默认,嵌在滚动容器里)/ listView(页面级)
final RenderMode renderMode;
/// 是否打印 img 解析日志(定位“图片特别大”时打开)
final bool debugImage;
/// HTML 里的 width/height 是否按 rpx 处理。
/// true:750 设计稿,除以 2 再 .w;
/// false:直接当 px,走 .w。
final bool assumeRpx;
/// 行内图片最大高度(逻辑像素)。
/// 没给 width/height 的图片按这个高度兜底,避免撑满整行。
static const double _inlineMaxHeight = 28.0;
@override
Widget build(BuildContext context) {
return HtmlWidget(
key: htmlWidgetKey,
html,
textStyle: textStyle,
onTapUrl: onTapUrl ?? _defaultOnTapUrl,
customStylesBuilder: customStylesBuilder,
customStylesBuilder: _combinedStylesBuilder,
customWidgetBuilder: _combinedCustomWidget(context),
renderMode: renderMode,
);
}
/// 合并默认样式 + 用户自定义样式
CustomStylesBuilder? get _combinedStylesBuilder {
return (element) {
final userResult = customStylesBuilder?.call(element);
if (element.localName == 'img') {
final base = <String, String>{
'max-width': '100%',
'height': 'auto',
};
if (userResult != null) base.addAll(userResult);
return base;
}
return userResult;
};
}
/// 合并默认替换 + 用户自定义替换
CustomWidgetBuilder _combinedCustomWidget(BuildContext context) => (element) {
// 内置:<img src="*.svg"> → flutter_svg 行内渲染
// core 版 flutter_widget_from_html 不支持 SVG,手动处理
// ============================================================
// 1. 统一处理 <img>:SVG + 位图
// ============================================================
if (element.localName == 'img') {
final src = element.attributes['src'];
if (src != null && src.toLowerCase().endsWith('.svg')) {
final width = double.tryParse(element.attributes['width'] ?? '');
final height = double.tryParse(element.attributes['height'] ?? '');
final w = width != null ? width.w : null;
final h = height != null ? height.h : null;
// 超采样(SSAA):SVG 以 3 倍尺寸光栅化再缩回来,消除真机边缘锯齿
const scale = 3.0;
return InlineCustomWidget(
child: SizedBox(
width: w,
height: h,
child: OverflowBox(
maxWidth: w != null ? w * scale : double.infinity,
maxHeight: h != null ? h * scale : double.infinity,
child: Transform.scale(
scale: 1 / scale,
alignment: Alignment.center,
child: SvgPicture.network(
src,
width: w != null ? w * scale : null,
height: h != null ? h * scale : null,
fit: BoxFit.contain,
clipBehavior: Clip.antiAlias,
),
),
),
),
);
}
}
// 内置:<audio> → 本项目 AudioPlayerCard
final src = element.attributes['src'];
if (src == null || src.isEmpty) return null;
final isSvg = src.toLowerCase().split('?').first.endsWith('.svg');
// 解析 width / height(有就用,没有就 null)
double? parsePx(String? raw) {
if (raw == null) return null;
return double.tryParse(raw.replaceAll(RegExp(r'[^0-9.]'), ''));
}
double? width = parsePx(element.attributes['width']);
double? height = parsePx(element.attributes['height']);
final style = element.attributes['style'];
if (style != null) {
final wMatch = RegExp(r'width\s*:\s*([0-9.]+)px').firstMatch(style);
final hMatch = RegExp(r'height\s*:\s*([0-9.]+)px').firstMatch(style);
width = double.tryParse(wMatch?.group(1) ?? '') ?? width;
height = double.tryParse(hMatch?.group(1) ?? '') ?? height;
}
if (isSvg) {
return InlineCustomWidget(
alignment: PlaceholderAlignment.middle, // ← 关键
child: SvgPicture.network(
src,
width: width,
height: height,
fit: BoxFit.fill,
clipBehavior: Clip.antiAlias,
// SVG 解码/下载期间显示加载动效,否则默认空白
placeholderBuilder: (_) =>
_buildLoadingPlaceholder(width, height),
),
);
} else {
return InlineCustomWidget(
alignment: PlaceholderAlignment.middle, // ← 关键
child: Image.network(
src,
width: width,
height: height,
fit: BoxFit.fill,
// 网络图片加载期间显示进度小菊花,否则默认空白
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
final expected = loadingProgress.expectedTotalBytes;
final progress = expected == null
? null
: loadingProgress.cumulativeBytesLoaded / expected;
return _buildLoadingPlaceholder(width, height, progress);
},
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
);
}
}
// ============================================================
// 2. 内置:<audio> → 本项目 AudioPlayerCard
// ============================================================
if (element.localName == 'audio') {
final src = element.attributes['src'];
if (src != null && src.isNotEmpty) {
// 支持 asset:// 协议 → 本地音频(seek 秒响应)
// 注意:网络源必须支持 HTTP Range(响应 206),
// 否则 ExoPlayer 无法字节定位,seek 会失效"回到起始"。
// 免费测试站(如 samplelib)常返回 200 不支持 Range,请勿用。
final isAsset = src.startsWith('asset://');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
padding: EdgeInsets.symmetric(vertical: 8.h),
child: AudioPlayerCard(
url: isAsset ? null : src,
asset: isAsset ? src.substring('asset://'.length) : null,
@@ -141,13 +214,16 @@ class RichTextContent extends StatelessWidget {
);
}
}
// 内置:<video> → 本项目 VideoPlayerView(内嵌播放器)
// 默认不自动播,避免富文本里多个视频同时解码占内存
// ============================================================
// 3. 内置:<video> → 本项目 VideoPlayerView(内嵌播放器)
// 默认不自动播,避免富文本里多个视频同时解码占内存
// ============================================================
if (element.localName == 'video') {
final src = element.attributes['src'];
if (src != null && src.isNotEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
padding: EdgeInsets.symmetric(vertical: 8.h),
child: VideoPlayerView(
url: src,
title: element.attributes['title'] ?? '题目视频',
@@ -156,13 +232,36 @@ class RichTextContent extends StatelessWidget {
);
}
}
// 透传用户自定义替换(公式等扩展点)
// ============================================================
// 4. 透传用户自定义替换(公式等扩展点)
// ============================================================
return customWidgetBuilder?.call(element);
};
/// 图片/SVG 加载中的占位:居中的小菊花(有已知大小时按图片尺寸占位,
/// 行内无尺寸图给一个最小兜底盒子,避免占位为 0 看不到动效)
Widget _buildLoadingPlaceholder(double? width, double? height,
[double? progress]) {
return Container(
width: width ?? 40.w,
height: height ?? _inlineMaxHeight.h,
alignment: Alignment.center,
child: SizedBox(
width: 14.w,
height: 14.w,
child: CircularProgressIndicator(
strokeWidth: 1.5,
// 拿不到总字节数时传 null,小菊花保持 indeterminate 匀速旋转
value: progress,
),
),
);
}
/// 默认链接拦截:防止题目里夹带外部危险链接
static Future<bool> _defaultOnTapUrl(String url) async {
debugPrint('[RichTextContent] 已拦截外部链接: $url');
return true;
}
}
}
+335 -30
View File
@@ -1,8 +1,10 @@
import 'dart:typed_data';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/foundation.dart' show compute, kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:image/image.dart' as img;
import 'package:new_learn/core/api/public_upload/api.dart';
import 'package:new_learn/core/api/public_upload/model.dart';
import 'package:new_learn/core/components/center_toast.dart';
@@ -58,6 +60,9 @@ enum UploadMode { edit, view }
/// 上传组件类型
enum UploadType { handouts, questions, aiGrading, feedback }
/// 选图后的“原始图 + 压缩处理图”配对
typedef UploadPair = ({PickedImage origin, ProcessedImage processed});
/// 图片项(统一承载:本地已�?+ 已上传)
class UploadItem {
const UploadItem({
@@ -267,18 +272,28 @@ class UploadState extends State<Upload> {
processingLoadingShown = true;
// 压缩 + EXIF 修正 + 尺寸过滤
final List<({PickedImage origin, ProcessedImage processed})> validList =
[];
final List<UploadPair> validList = [];
for (final img in picked) {
final bytes = img.bytes ?? await img.entity?.originBytes;
if (bytes == null || bytes.isEmpty) continue;
final processed = await ImageProcessor.process(
bytes: bytes,
fileName: img.name,
maxDimension: 1920,
);
final ProcessedImage? processed;
if (kIsWeb) {
// Web:只有字节,走 bytes 压缩
final bytes = img.bytes;
if (bytes == null || bytes.isEmpty) continue;
processed = await ImageProcessor.process(
bytes: bytes,
fileName: img.name,
maxDimension: 1920,
);
} else {
// 原生:只传文件路径,原图字节不进 Dart 堆、UI 线程不解码
final path = img.path;
if (path == null || path.isEmpty) continue;
processed = await ImageProcessor.processFile(
path: path,
maxDimension: 1920,
);
}
if (processed == null) {
// 压缩失败 / 解码失败 �?跳过
continue;
@@ -301,9 +316,27 @@ class UploadState extends State<Upload> {
return;
}
// TODO: AIGrading 类型的旋转预览弹窗待实现(Vue 端有 $showImagePreview�?
// 暂时直接上传,后续有需求再�?
await _startUpload(validList);
// AIGrading(上传图片作答):先进入全屏旋转预览,
// 用户点“完成”才烘焙旋转并上传;点“取消”则整批放弃(对齐 Vue)
List<UploadPair> pairsToUpload = validList;
if (widget.type == UploadType.aiGrading) {
_hideLoading();
processingLoadingShown = false;
if (!mounted) return;
final rotated = await _ImageRotateEditor.open(
context,
items: validList,
);
if (rotated == null) return; // 用户取消
pairsToUpload = rotated;
if (!mounted) return;
_showLoading('上传中...');
processingLoadingShown = true;
}
await _startUpload(pairsToUpload);
} catch (e) {
_toast('图片处理失败,请重试');
debugPrint('[Upload] 选图异常: $e');
@@ -488,7 +521,7 @@ class UploadState extends State<Upload> {
@override
Widget build(BuildContext context) {
// 模式�?view 且没有图片时不显示(对齐 Vue v-if�?
// 模式:view 且没有图片时不显示(对齐 Vue v-if)
if (widget.mode == UploadMode.edit || _imageList.isNotEmpty) {
return _buildUploadBox();
}
@@ -520,31 +553,38 @@ class UploadState extends State<Upload> {
Widget _buildHeader() {
return Row(
children: [
Container(
padding: EdgeInsets.only(right: 14.w),
decoration: BoxDecoration(
image: DecorationImage(
image: NetworkImage(
Stack(
children: [
// 标题装饰底图:固定尺寸钉在右下角(对标 background-size: 119rpx auto)
Positioned(
right: 10.w,
bottom: 0,
child: Image.network(
ImgUrl.build('/StaticResource/2026-04-01/title-bacs.png'),
width: 59.5.w,
height: 9.h,
fit: BoxFit.fill,
errorBuilder: (_, _, _) => const SizedBox.shrink(),
),
alignment: Alignment.bottomRight,
repeat: ImageRepeat.noRepeat,
),
),
child: Text(
widget.title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF1D2B47),
Padding(
padding: EdgeInsets.only(right: 14.w),
child: Text(
widget.title,
style: TextStyle(
fontSize: 15.sp,
fontWeight: FontWeight.w600,
color: const Color(0xFF1D2B47),
),
),
),
),
],
),
SizedBox(width: 14.w),
Text(
widget.mode == UploadMode.edit
? '已上传:${_imageList.length}/${widget.maxCount}张'
: '共 $_imageList.length 张',
: '共 ${_imageList.length} 张',
style: TextStyle(fontSize: 12.sp, color: const Color(0xFFB4C2E0)),
),
],
@@ -672,3 +712,268 @@ class UploadState extends State<Upload> {
);
}
}
/// ============================================================
/// 全屏旋转预览(对标 Vue components/ImagePreviewPopup):
/// - 左右滑动逐张查看;
/// - “↻ 旋转90°”只改当前图片的视觉角度;
/// - “完成”才把角度烘焙进图片字节并返回(随后才上传);
/// - “取消”/系统返回 → 返回 null,整批放弃。
/// ============================================================
class _ImageRotateEditor extends StatefulWidget {
const _ImageRotateEditor({required this.items});
final List<UploadPair> items;
/// 打开旋转预览;完成返回旋转后的列表,取消返回 null
static Future<List<UploadPair>?> open(
BuildContext context, {
required List<UploadPair> items,
}) {
return Navigator.of(context).push<List<UploadPair>>(
PageRouteBuilder<List<UploadPair>>(
opaque: true,
transitionDuration: const Duration(milliseconds: 200),
reverseTransitionDuration: const Duration(milliseconds: 200),
pageBuilder: (_, __, ___) => _ImageRotateEditor(items: items),
transitionsBuilder: (_, animation, __, child) =>
FadeTransition(opacity: animation, child: child),
),
);
}
@override
State<_ImageRotateEditor> createState() => _ImageRotateEditorState();
}
class _ImageRotateEditorState extends State<_ImageRotateEditor> {
late final List<int> _angles = List.filled(widget.items.length, 0);
final PageController _pageController = PageController();
int _currentIndex = 0;
bool _busy = false;
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
void _onPageChanged(int index) => setState(() => _currentIndex = index);
void _handleRotate() {
if (_busy) return;
setState(() {
_angles[_currentIndex] = (_angles[_currentIndex] + 90) % 360;
});
}
Future<void> _handleConfirm() async {
if (_busy) return;
setState(() => _busy = true);
try {
final result = <UploadPair>[];
for (var i = 0; i < widget.items.length; i++) {
final pair = widget.items[i];
final angle = _angles[i] % 360;
if (angle == 0) {
result.add(pair);
} else {
result.add((
origin: pair.origin,
processed: await _bakeRotation(pair.processed, angle),
));
}
}
if (mounted) Navigator.of(context).pop(result);
} catch (_) {
if (mounted) {
setState(() => _busy = false);
CenterToast.show(context, '图片处理失败,请重试');
}
}
}
void _handleCancel() {
if (_busy) return;
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
PageView.builder(
controller: _pageController,
itemCount: widget.items.length,
onPageChanged: _onPageChanged,
itemBuilder: (context, index) {
return Center(
child: AnimatedRotation(
turns: _angles[index] / 360,
duration: const Duration(milliseconds: 200),
child: Image.memory(
widget.items[index].processed.bytes,
fit: BoxFit.contain,
),
),
);
},
),
// 指示点(多图时)
if (widget.items.length > 1)
Positioned(
left: 0,
right: 0,
bottom: 72.h,
child: _buildDots(),
),
// 底部操作栏
Positioned(
left: 0,
right: 0,
bottom: 0,
child: _buildFooter(),
),
// “处理图片中...”遮罩
if (_busy)
const ColoredBox(
color: Colors.black38,
child: Center(
child: CircularProgressIndicator(color: Colors.white),
),
),
],
),
);
}
Widget _buildDots() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < widget.items.length; i++)
Container(
width: 7.w,
height: 7.w,
margin: EdgeInsets.symmetric(horizontal: 4.w),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == _currentIndex
? Colors.white
: Colors.white.withValues(alpha: 0.3),
),
),
],
);
}
Widget _buildFooter() {
return SafeArea(
top: false,
child: Container(
padding: EdgeInsets.symmetric(horizontal: 20.w, vertical: 12.h),
decoration: const BoxDecoration(color: Color(0xB3000000)),
child: Row(
children: [
_FooterTextButton(label: '取消', onTap: _handleCancel),
Expanded(
child: Center(
child: GestureDetector(
onTap: _handleRotate,
child: Container(
padding: EdgeInsets.symmetric(
horizontal: 20.w,
vertical: 8.h,
),
decoration: BoxDecoration(
color: const Color(0x26FFFFFF),
borderRadius: BorderRadius.circular(30.r),
),
child: Text(
'↻ 旋转90°',
style: TextStyle(
fontSize: 14.sp,
color: Colors.white,
),
),
),
),
),
),
_FooterTextButton(label: '完成', primary: true, onTap: _handleConfirm),
],
),
),
);
}
}
class _FooterTextButton extends StatelessWidget {
const _FooterTextButton({
required this.label,
required this.onTap,
this.primary = false,
});
final String label;
final VoidCallback onTap;
final bool primary;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 4.h),
child: Text(
label,
style: TextStyle(
fontSize: 15.sp,
color: primary ? const Color(0xFF2979FF) : Colors.white,
fontWeight: primary ? FontWeight.bold : FontWeight.normal,
),
),
),
);
}
}
/// 旋转 isolate 任务
typedef _RotateTask = ({Uint8List bytes, int angle, bool isPng});
/// 把旋转角度烘焙进 [processed] 的字节(失败时保留原图)
Future<ProcessedImage> _bakeRotation(
ProcessedImage processed,
int angle,
) async {
final isPng = processed.contentType == 'image/png';
final rotated = await compute<_RotateTask, Uint8List?>(
_rotateInIsolate,
(bytes: processed.bytes, angle: angle, isPng: isPng),
);
if (rotated == null || rotated.isEmpty) return processed;
return ProcessedImage(
bytes: rotated,
extension: isPng ? 'png' : 'jpg',
contentType: isPng ? 'image/png' : 'image/jpeg',
originalBytes: processed.originalBytes,
processedBytes: rotated.length,
);
}
/// isolate 内:解码 → 顺时针旋转 → 编码(JPEG 质量 90,对齐 Vue canvas 0.9)
Uint8List? _rotateInIsolate(_RotateTask task) {
final src = img.decodeImage(task.bytes);
if (src == null) return null;
final dst = img.copyRotate(src, angle: task.angle);
final encoded =
task.isPng ? img.encodePng(dst) : img.encodeJpg(dst, quality: 90);
return Uint8List.fromList(encoded);
}
+21 -2
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import 'package:chewie/chewie.dart';
import 'media_pause_scope.dart';
/// ============================================================
/// 【VideoPlayerView】通用视频播放器(内嵌版,无 Scaffold/AppBar)
@@ -111,7 +112,8 @@ class VideoPlayerView extends StatefulWidget {
}
class VideoPlayerViewState extends State<VideoPlayerView>
with WidgetsBindingObserver {
with WidgetsBindingObserver
implements MediaPausable {
/// 官方底层控制器
VideoPlayerController? _videoController;
@@ -127,6 +129,9 @@ class VideoPlayerViewState extends State<VideoPlayerView>
/// 初始化成功后视频的真实宽高比(未成功前 null → 兜底 16:9)
double? _aspectRatio;
/// 所属媒体暂停作用域(随挂载位置自动注册/注销)
MediaPauseRegistry? _pauseScope;
@override
void initState() {
super.initState();
@@ -135,8 +140,21 @@ class VideoPlayerViewState extends State<VideoPlayerView>
_initPlayer();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// 自动挂到最近的 MediaPauseScope(含 fwfh 富文本内部的播放器)
final scope = MediaPauseScope.of(context);
if (scope != _pauseScope) {
_pauseScope?.unregister(this);
_pauseScope = scope;
_pauseScope?.register(this);
}
}
@override
void dispose() {
_pauseScope?.unregister(this);
WidgetsBinding.instance.removeObserver(this);
// 释放顺序不能反:先 chewie 再 video_player(反了会闪退)
_chewieController?.dispose();
@@ -144,7 +162,8 @@ class VideoPlayerViewState extends State<VideoPlayerView>
super.dispose();
}
/// 暂停播放(外部通过 GlobalKey 调用,用于切页时暂停视频)
/// 暂停播放(切页时由 MediaPauseRegistry 统一调用)
@override
void pause() {
_videoController?.pause();
}