preview_media_page.dart
8.78 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
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!),
),
);
}
}