更新
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user