Commit 9a1a3a9b by tanghuan

增加 previewMedia 指令

1 parent 48f46717
......@@ -22,6 +22,7 @@ import 'package:appframe/data/repositories/message/open_link_handler.dart';
import 'package:appframe/data/repositories/message/open_setting_handler.dart';
import 'package:appframe/data/repositories/message/open_weapp_handler.dart';
import 'package:appframe/data/repositories/message/orientation_handler.dart';
import 'package:appframe/data/repositories/message/preview_media_handler.dart';
import 'package:appframe/data/repositories/message/role_info_handler.dart';
import 'package:appframe/data/repositories/message/save_file_to_disk_handler.dart';
import 'package:appframe/data/repositories/message/save_to_album_handler.dart';
......@@ -162,6 +163,9 @@ Future<void> setupLocator() async {
/// 打开文档
getIt.registerLazySingleton<MessageHandler>(() => OpenDocumentHandler(), instanceName: 'openDocument');
/// 预览图片
getIt.registerLazySingleton<MessageHandler>(() => PreviewMediaHandler(), instanceName: 'previewMedia');
/// 录音
getIt.registerLazySingleton<MessageHandler>(() => AudioRecorderStartHandler(), instanceName: 'audioRecorderStart');
getIt.registerLazySingleton<MessageHandler>(() => AudioRecorderPauseHandler(), instanceName: 'audioRecorderPause');
......
......@@ -7,6 +7,7 @@ import 'package:appframe/ui/pages/link_page.dart';
import 'package:appframe/ui/pages/login_main_page.dart';
import 'package:appframe/ui/pages/login_phone_page.dart';
import 'package:appframe/ui/pages/login_qr_page.dart';
import 'package:appframe/ui/pages/media/preview_media_page.dart';
import 'package:appframe/ui/pages/reload_page.dart';
import 'package:appframe/ui/pages/scan_code_page.dart';
import 'package:appframe/ui/pages/setting/account_logoff_page.dart';
......@@ -107,6 +108,12 @@ final GoRouter router = GoRouter(
return const SettingPage();
},
),
GoRoute(
path: '/previewMedia',
builder: (BuildContext context, GoRouterState state) {
return const PreviewMediaPage();
},
),
],
);
......
/// 媒体项类型
enum MediaType {
image,
video,
}
/// 媒体项数据模型
///
/// 用于 [previewMedia] 指令统一描述图片或视频。
class MediaItem {
final String url;
final MediaType type;
/// 视频封面图(仅视频有效,可选)
final String? poster;
const MediaItem({
required this.url,
required this.type,
this.poster,
});
/// 根据 URL 后缀粗略推断媒体类型
///
/// 支持的视频后缀:mp4 / mov / m4v / 3gp / mkv / webm / avi / flv / ts
/// 其它默认按图片处理
factory MediaItem.fromUrl(String url) {
return MediaItem(url: url, type: _detectType(url));
}
/// 从 previewMedia 指令的 source 对象创建:
/// {
/// "url": String, // 必填,远程或本地 URL
/// "type": "image|video", // 可选,未提供时按 URL 后缀推断
/// "poster": String, // 可选,视频封面图
/// }
factory MediaItem.fromSource(Map source) {
final dynamic rawUrl = source['url'];
final String url = rawUrl is String ? rawUrl : '';
MediaType type;
final dynamic rawType = source['type'];
if (rawType is String) {
final String t = rawType.trim().toLowerCase();
if (t == 'video') {
type = MediaType.video;
} else if (t == 'image') {
type = MediaType.image;
} else {
type = _detectType(url);
}
} else {
type = _detectType(url);
}
final dynamic rawPoster = source['poster'];
final String? poster =
(rawPoster is String && rawPoster.isNotEmpty) ? rawPoster : null;
return MediaItem(url: url, type: type, poster: poster);
}
static MediaType _detectType(String url) {
if (url.isEmpty) {
return MediaType.image;
}
// 去除 query / fragment
String path = url;
final int qIdx = path.indexOf('?');
if (qIdx >= 0) {
path = path.substring(0, qIdx);
}
final int hIdx = path.indexOf('#');
if (hIdx >= 0) {
path = path.substring(0, hIdx);
}
final int dotIdx = path.lastIndexOf('.');
if (dotIdx < 0) {
return MediaType.image;
}
final String ext = path.substring(dotIdx + 1).toLowerCase();
const Set<String> videoExt = {
'mp4',
'mov',
'm4v',
'3gp',
'mkv',
'webm',
'avi',
'flv',
'ts',
};
return videoExt.contains(ext) ? MediaType.video : MediaType.image;
}
}
import 'package:appframe/config/routes.dart';
import 'package:appframe/data/models/media_item.dart';
import 'package:appframe/services/dispatcher.dart';
/// previewMedia 指令处理类
///
/// 参数格式:
/// {
/// "sources": [
/// {
/// "url": "https://xxx/a.png", // 必填,支持远程/本地
/// "type": "image", // 可选,image=图片,video=视频
/// "poster": "https://xxx/p.jpg" // 可选,视频封面图
/// },
/// ...
/// ],
/// "current": 0 // 可选,当前显示资源序号,默认 0
/// }
class PreviewMediaHandler extends MessageHandler {
@override
Future<dynamic> handleMessage(params) async {
if (params is! Map<String, dynamic>) {
throw Exception('参数错误');
}
final dynamic raw = params['sources'];
if (raw is! List) {
throw Exception('参数错误');
}
final List<MediaItem> items = <MediaItem>[];
for (final dynamic e in raw) {
if (e is! Map) {
continue;
}
final MediaItem item = MediaItem.fromSource(e);
if (item.url.isEmpty) {
continue;
}
items.add(item);
}
if (items.isEmpty) {
throw Exception('参数错误');
}
int current = 0;
if (params.containsKey('current')) {
final dynamic c = params['current'];
if (c is int) {
current = c;
} else if (c is num) {
current = c.toInt();
}
}
if (current < 0 || current >= items.length) {
current = 0;
}
router.push('/previewMedia', extra: {
'items': items,
'current': current,
});
return true;
}
}
import 'package:appframe/data/models/media_item.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:chewie/chewie.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:photo_view/photo_view.dart';
import 'package:photo_view/photo_view_gallery.dart';
import 'package:video_player/video_player.dart';
/// 媒体预览页面
///
/// 使用 PhotoViewGallery.builder 配合 photo_view(图片)和 chewie(视频)实现
/// 图片缩放查看与视频播放,支持左右滑动切换。
/// PhotoViewGallery 内部已包含 PhotoViewGestureDetectorScope,可自动协调
/// PageView 与 PhotoView 的手势竞技,无需手动包裹。
class PreviewMediaPage extends StatefulWidget {
const PreviewMediaPage({super.key});
@override
State<PreviewMediaPage> createState() => _PreviewMediaPageState();
}
class _PreviewMediaPageState extends State<PreviewMediaPage> {
PageController? _pageController;
int _currentIndex = 0;
List<MediaItem> _items = const [];
bool _initialized = false;
/// 视频条目的 GlobalKey,用于翻页时通知暂停其它视频
final Map<int, GlobalKey<_VideoItemState>> _videoKeys = {};
@override
Widget build(BuildContext context) {
if (!_initialized) {
final Map<String, dynamic>? extra =
GoRouterState.of(context).extra as Map<String, dynamic>?;
final List<MediaItem> items =
((extra?['items'] as List?)?.cast<MediaItem>()) ?? const [];
_items = List<MediaItem>.unmodifiable(items);
_currentIndex = (extra?['current'] as int?) ?? 0;
if (_currentIndex < 0 || _currentIndex >= _items.length) {
_currentIndex = 0;
}
_pageController = PageController(initialPage: _currentIndex);
_initialized = true;
}
return Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
Positioned.fill(
child: PhotoViewGallery.builder(
pageController: _pageController,
itemCount: _items.length,
onPageChanged: _onPageChanged,
backgroundDecoration: const BoxDecoration(color: Colors.black),
loadingBuilder: (ctx, event) => const Center(
child: CircularProgressIndicator(color: Colors.white),
),
builder: (ctx, index) {
final MediaItem item = _items[index];
final String heroTag = 'preview_media_${index}_${item.url}';
if (item.type == MediaType.video) {
final GlobalKey<_VideoItemState> key = _videoKeys
.putIfAbsent(index, () => GlobalKey<_VideoItemState>());
// 视频页:使用 customChild 嵌入自定义视频组件
// disableGestures=true 关闭 PhotoView 的缩放手势,
// 把控制权完全交给 Chewie 自身的进度条/暂停等操作。
return PhotoViewGalleryPageOptions.customChild(
disableGestures: true,
heroAttributes: PhotoViewHeroAttributes(tag: heroTag),
child: _VideoItem(
key: key,
url: item.url,
poster: item.poster,
autoPlay: index == _currentIndex,
),
);
}
// 图片页:标准 PhotoView 配置
return PhotoViewGalleryPageOptions(
imageProvider: CachedNetworkImageProvider(item.url),
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 3,
initialScale: PhotoViewComputedScale.contained,
heroAttributes: PhotoViewHeroAttributes(tag: heroTag),
onTapUp: (_, __, ___) => _close(),
errorBuilder: (c, e, s) => const Center(
child: Icon(
Icons.broken_image,
color: Colors.white54,
size: 48,
),
),
);
},
),
),
Positioned(
top: MediaQuery.of(context).padding.top + 8,
right: 8,
child: IconButton(
icon: const Icon(Icons.close, color: Colors.white, size: 28),
onPressed: _close,
),
),
if (_items.length > 1)
Positioned(
bottom: MediaQuery.of(context).padding.bottom + 24,
left: 0,
right: 0,
child: Center(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(12),
),
child: Text(
'${_currentIndex + 1} / ${_items.length}',
style: const TextStyle(
color: Colors.white,
fontSize: 14,
),
),
),
),
),
],
),
);
}
void _onPageChanged(int index) {
// 通知所有非当前页的视频暂停,避免声音重叠
_videoKeys.forEach((i, key) {
if (i != index) {
key.currentState?.pause();
}
});
setState(() {
_currentIndex = index;
});
}
void _close() {
if (context.canPop()) {
context.pop();
}
}
@override
void dispose() {
_pageController?.dispose();
super.dispose();
}
}
/// 视频项:VideoPlayer + Chewie
///
/// 必须为 StatefulWidget,由 [initState] 初始化控制器,[dispose] 销毁。
/// Hero 动画由 PhotoViewGalleryPageOptions.customChild 的 heroAttributes 接管,
/// 此处不再额外包 Hero,避免双重包裹冲突。
class _VideoItem extends StatefulWidget {
final String url;
final String? poster;
final bool autoPlay;
const _VideoItem({
super.key,
required this.url,
this.poster,
this.autoPlay = false,
});
@override
State<_VideoItem> createState() => _VideoItemState();
}
class _VideoItemState extends State<_VideoItem> {
VideoPlayerController? _videoController;
ChewieController? _chewieController;
bool _initializing = true;
Object? _error;
@override
void initState() {
super.initState();
_initController();
}
Future<void> _initController() async {
try {
final VideoPlayerController controller =
VideoPlayerController.networkUrl(Uri.parse(widget.url));
_videoController = controller;
await controller.initialize();
if (!mounted) {
await controller.dispose();
return;
}
_chewieController = ChewieController(
videoPlayerController: controller,
autoPlay: widget.autoPlay,
looping: false,
allowFullScreen: true,
allowMuting: true,
showControls: true,
aspectRatio: controller.value.aspectRatio,
materialProgressColors: ChewieProgressColors(
playedColor: Colors.white,
handleColor: Colors.white,
bufferedColor: Colors.white38,
backgroundColor: Colors.white12,
),
placeholder: _buildPosterPlaceholder(),
);
setState(() {
_initializing = false;
});
} catch (e) {
if (!mounted) {
return;
}
setState(() {
_error = e;
_initializing = false;
});
}
}
/// 暂停视频(被外部翻页时调用)
void pause() {
_videoController?.pause();
}
/// 视频占位图:有 poster 时使用封面图,否则黑色底
Widget _buildPosterPlaceholder() {
final String? poster = widget.poster;
if (poster == null || poster.isEmpty) {
return const ColoredBox(color: Colors.black);
}
return Center(
child: CachedNetworkImage(
imageUrl: poster,
fit: BoxFit.contain,
errorWidget: (c, u, e) => const ColoredBox(color: Colors.black),
),
);
}
@override
void dispose() {
_chewieController?.dispose();
_videoController?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_initializing) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (_error != null || _chewieController == null) {
return const Center(
child: Icon(
Icons.error_outline,
color: Colors.white54,
size: 48,
),
);
}
return Center(
child: AspectRatio(
aspectRatio: _videoController!.value.aspectRatio,
child: Chewie(controller: _chewieController!),
),
);
}
}
......@@ -46,6 +46,12 @@ dependencies:
image: ^4.5.4
image_size_getter: ^2.4.1
mime: ^2.0.0
# --- 图片预览 ---
cached_network_image: ^3.4.1
photo_view: ^0.15.0
# 视频播放器封装(基于 video_player)
chewie: ^1.12.1
# --- 微信生态 ---
fluwx: ^5.7.2
......
Styling with Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!