flutter架 构项目基础
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:new_learn/core/version/remote_version_entity.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【AppUpdateDialog】版本更新弹窗 —— 区分【强制更新】【可选更新】
|
||||
///
|
||||
/// 一、强制更新行为:
|
||||
/// barrierDismissible: false(点外部不关闭)
|
||||
/// PopScope 拦截返回按键(弹窗不可关闭)
|
||||
/// 只有【立即更新】按钮
|
||||
///
|
||||
/// 二、可选更新行为:
|
||||
/// 提供【稍后再说】【立即更新】两个按钮
|
||||
/// 点"稍后再说"关闭弹窗,本次生命周期不再重复弹窗
|
||||
///
|
||||
/// 三、点击"立即更新":
|
||||
/// 安卓 → 打开外部浏览器访问下载地址(androidUrl)
|
||||
/// iOS → 跳转 App Store 页面(iosUrl)
|
||||
/// ============================================================
|
||||
class AppUpdateDialog extends StatelessWidget {
|
||||
const AppUpdateDialog({
|
||||
super.key,
|
||||
required this.remoteInfo,
|
||||
required this.isForce,
|
||||
});
|
||||
|
||||
final RemoteVersionEntity remoteInfo;
|
||||
final bool isForce;
|
||||
|
||||
/// 显示更新弹窗(统一入口)
|
||||
static Future<void> show(
|
||||
BuildContext context,
|
||||
RemoteVersionEntity remoteInfo, {
|
||||
required bool isForce,
|
||||
}) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
barrierDismissible: !isForce, // 强制更新时点外部不关闭
|
||||
builder: (ctx) => PopScope(
|
||||
canPop: !isForce, // 强制更新时拦截返回键
|
||||
child: AppUpdateDialog(remoteInfo: remoteInfo, isForce: isForce),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 点击"立即更新":区分平台跳转
|
||||
Future<void> _launchUpdate() async {
|
||||
final urlStr = Platform.isIOS
|
||||
? (remoteInfo.iosUrl ?? '')
|
||||
: (remoteInfo.androidUrl ?? '');
|
||||
if (urlStr.isEmpty) return;
|
||||
final uri = Uri.tryParse(urlStr);
|
||||
if (uri == null) return;
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final updateDesc = remoteInfo.updateDesc ?? [];
|
||||
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.r)),
|
||||
title: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isForce ? Icons.warning_amber_rounded : Icons.system_update,
|
||||
color: isForce ? Colors.orange : theme.colorScheme.primary,
|
||||
size: 24.w,
|
||||
),
|
||||
SizedBox(width: 8.w),
|
||||
Text(isForce ? '重要更新' : '发现新版本'),
|
||||
],
|
||||
),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 版本号
|
||||
Text(
|
||||
'v${remoteInfo.latestVersion ?? ''}',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
color: const Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 12.h),
|
||||
// 更新日志标题
|
||||
if (updateDesc.isNotEmpty) ...[
|
||||
Text(
|
||||
'更新内容:',
|
||||
style: TextStyle(
|
||||
fontSize: 14.sp,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6.h),
|
||||
// 更新日志列表
|
||||
...updateDesc.map(
|
||||
(desc) => Padding(
|
||||
padding: EdgeInsets.only(bottom: 4.h),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'• ',
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
color: const Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
desc,
|
||||
style: TextStyle(
|
||||
fontSize: 13.sp,
|
||||
color: const Color(0xFF666666),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
// 强制更新提示
|
||||
if (isForce) ...[
|
||||
SizedBox(height: 12.h),
|
||||
Text(
|
||||
'此版本为必要更新,请立即下载以继续使用',
|
||||
style: TextStyle(
|
||||
fontSize: 12.sp,
|
||||
color: Colors.orange[700],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (!isForce)
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('稍后再说'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _launchUpdate,
|
||||
child: Text(isForce ? '立即更新' : '立即更新'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【AudioPlayerCard】通用音频播放控制条(在线音频)
|
||||
///
|
||||
/// 一、为什么用 just_audio:
|
||||
/// 学习类 App 的课程音频(在线/列表/倍速/后台)需求全在它强项上:
|
||||
/// - 底层 ExoPlayer / AVPlayer / HTML5,原生解码稳定;
|
||||
/// - 支持网络流、播放列表 gapless 连播、倍速/变速、循环、音频会话;
|
||||
/// - 不带 UI,控制条由本组件自绘(对标 <audio> 标签的 controls)。
|
||||
///
|
||||
/// 二、props 对照表(Vue 开发者速查):
|
||||
/// url ← 在线音频地址(与 asset 二选一)
|
||||
/// asset ← 本地音频资源 assets/audio/xxx.mp3(本地文件 seek 秒响应)
|
||||
/// title ← 标题文本
|
||||
/// cover ← 封面缩略图 Widget(可选,显示在播放键左侧)
|
||||
/// autoPlay ← autoplay(默认 false)
|
||||
/// looping ← loop(默认 false,单曲循环)
|
||||
/// volume ← volume(默认 1.0)
|
||||
/// playbackSpeeds ← 倍速档位列表(默认 0.5x/1x/1.5x/2x)
|
||||
///
|
||||
/// 三、生命周期(最重要):
|
||||
/// 1. initState 里 setUrl 预加载,dispose 里 _player.dispose() 释放;
|
||||
/// 2. 通过 WidgetsBindingObserver 监听 App 生命周期,
|
||||
/// 切后台/来电自动暂停,防止声音还在后台播放;
|
||||
/// 3. UI 用 StreamBuilder 监听 position / duration / playerState 三个流,
|
||||
/// 播放进度、按钮状态实时刷新(Flutter 响应式写法)。
|
||||
///
|
||||
/// 四、高频坑:
|
||||
/// 1. setUrl 返回 Future,必须 await 完成后再让用户操作,
|
||||
/// 期间展示 loading(buffering 状态也走 loading 样式);
|
||||
/// 2. just_audio 是 0.x 版本,0.9→0.10 改过 setAudioSource API,
|
||||
/// 升级前先看 pub.dev changelog;
|
||||
/// 3. Slider 的 max 不能是 0(未加载到时长时 clamp 到 ≥1);
|
||||
/// 4. 切后台必须暂停:Android 上不暂停声音会一直播到结束,
|
||||
/// 且会被系统判定为"异常后台播放";
|
||||
/// 5. 网络流 seek 跳播后必有一次重新缓冲(ExoPlayer 机制),
|
||||
/// 本组件用 bufferedPositionStream 把缓冲进度画在进度条上
|
||||
/// (secondaryTrackValue),并显示"缓冲中 X%"提示,避免误以为卡死;
|
||||
/// 6. ⚠ seek 能不能跳对位置,取决于服务器是否支持 HTTP Range:
|
||||
/// 支持(返回 206 Partial Content)→ 从目标字节开始续传,跳播正常;
|
||||
/// 不支持(返回 200 全量)→ ExoPlayer 无法字节定位,只能从头重新
|
||||
/// 下载,表现就是"点中间段位置被重置回起点"(且第二次打开有系统
|
||||
/// 缓存时会看似正常,极易误判)。验收新音频源:
|
||||
/// curl -s -D - -o NUL -H "Range: bytes=0-1023" <url> 看状态码。
|
||||
/// ============================================================
|
||||
class AudioPlayerCard extends StatefulWidget {
|
||||
const AudioPlayerCard({
|
||||
super.key,
|
||||
this.url,
|
||||
this.asset,
|
||||
this.title,
|
||||
this.cover,
|
||||
this.autoPlay = false,
|
||||
this.looping = false,
|
||||
this.volume = 1.0,
|
||||
this.playbackSpeeds = const [0.5, 1.0, 1.5, 2.0],
|
||||
}) : assert(url != null || asset != null, 'url 与 asset 必须传一个');
|
||||
|
||||
/// 在线音频地址(http/https 直链,与 asset 二选一)
|
||||
final String? url;
|
||||
|
||||
/// 本地音频资源(assets/audio/xxx.mp3,与 url 二选一;
|
||||
/// 本地文件 seek 跳播无需重新缓冲,秒响应)
|
||||
final String? asset;
|
||||
|
||||
/// 标题文本(不传则显示"音频")
|
||||
final String? title;
|
||||
|
||||
/// 封面缩略图(可选,如课程封面)
|
||||
final Widget? cover;
|
||||
|
||||
/// 是否自动播放
|
||||
final bool autoPlay;
|
||||
|
||||
/// 是否单曲循环
|
||||
final bool looping;
|
||||
|
||||
/// 初始音量(0.0 ~ 1.0)
|
||||
final double volume;
|
||||
|
||||
/// 倍速档位列表
|
||||
final List<double> playbackSpeeds;
|
||||
|
||||
@override
|
||||
State<AudioPlayerCard> createState() => _AudioPlayerCardState();
|
||||
}
|
||||
|
||||
class _AudioPlayerCardState extends State<AudioPlayerCard>
|
||||
with WidgetsBindingObserver {
|
||||
/// 底层播放器(just_audio)
|
||||
final AudioPlayer _player = AudioPlayer();
|
||||
|
||||
/// 是否正在加载(setUrl 未完成)
|
||||
bool _loading = true;
|
||||
|
||||
/// 加载/播放错误(null = 无错误)
|
||||
String? _error;
|
||||
|
||||
/// 当前倍速
|
||||
double _speed = 1.0;
|
||||
|
||||
/// 是否正在拖动进度条(拖动中不触发 seek,松手才 seek 一次)
|
||||
bool _dragging = false;
|
||||
|
||||
/// 拖动中的临时位置(毫秒,用于滑块跟手)
|
||||
double? _dragValue;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 监听 App 生命周期:切后台自动暂停
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_initPlayer();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
// 必须释放播放器,否则音频解码资源泄漏
|
||||
_player.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// App 切后台 / 来电 → 自动暂停
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state != AppLifecycleState.resumed) {
|
||||
_player.pause();
|
||||
}
|
||||
}
|
||||
|
||||
/// 预加载音频源,设置初始音量/循环/倍速
|
||||
Future<void> _initPlayer() async {
|
||||
try {
|
||||
// 本地资源用 setAsset(seek 秒响应),在线用 setUrl
|
||||
if (widget.asset != null) {
|
||||
await _player.setAsset(widget.asset!);
|
||||
} else {
|
||||
await _player.setUrl(widget.url!);
|
||||
}
|
||||
if (widget.volume != 1.0) {
|
||||
await _player.setVolume(widget.volume);
|
||||
}
|
||||
if (widget.looping) {
|
||||
await _player.setLoopMode(LoopMode.one);
|
||||
}
|
||||
if (widget.autoPlay) {
|
||||
_player.play();
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 播放/暂停切换
|
||||
Future<void> _togglePlay() async {
|
||||
if (_player.playing) {
|
||||
await _player.pause();
|
||||
} else {
|
||||
await _player.play();
|
||||
}
|
||||
}
|
||||
|
||||
/// 切换倍速
|
||||
Future<void> _changeSpeed(double speed) async {
|
||||
await _player.setSpeed(speed);
|
||||
setState(() => _speed = speed);
|
||||
}
|
||||
|
||||
/// 时间格式化:mm:ss
|
||||
String _fmt(Duration d) {
|
||||
final m = d.inMinutes.toString().padLeft(2, '0');
|
||||
final s = (d.inSeconds % 60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFEEF1F5)),
|
||||
),
|
||||
child: _buildContent(),
|
||||
);
|
||||
}
|
||||
|
||||
/// 三种状态:loading / 错误 / 播放器
|
||||
Widget _buildContent() {
|
||||
if (_error != null) {
|
||||
return Row(
|
||||
children: [
|
||||
const Icon(Icons.error_outline, color: Color(0xFFEF4444), size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'音频加载失败\n$_error',
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Color(0xFF999999), fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
if (_loading) {
|
||||
return const Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Text('加载中...', style: TextStyle(color: Color(0xFF999999))),
|
||||
],
|
||||
);
|
||||
}
|
||||
return _buildPlayer();
|
||||
}
|
||||
|
||||
/// 播放器主体:按钮行 + 进度行(StreamBuilder 实时刷新)
|
||||
Widget _buildPlayer() {
|
||||
return StreamBuilder<PlayerState>(
|
||||
stream: _player.playerStateStream,
|
||||
builder: (context, stateSnap) {
|
||||
final state = stateSnap.data;
|
||||
final playing = state?.playing ?? false;
|
||||
final buffering = state?.processingState == ProcessingState.buffering;
|
||||
return StreamBuilder<Duration>(
|
||||
stream: _player.positionStream,
|
||||
builder: (context, posSnap) {
|
||||
final position = posSnap.data ?? Duration.zero;
|
||||
return StreamBuilder<Duration?>(
|
||||
// bufferedPositionStream:当前已缓冲到的位置。
|
||||
// 网络流 seek 跳播后必有一次重新缓冲,用这个把
|
||||
// "已缓冲区域"画在进度条上(secondaryTrackValue)
|
||||
stream: _player.bufferedPositionStream,
|
||||
builder: (context, buffSnap) {
|
||||
final buffered = buffSnap.data ?? Duration.zero;
|
||||
return StreamBuilder<Duration>(
|
||||
// just_audio 0.10 的 durationStream 是 Stream<Duration?>,
|
||||
// 用 map 把 null(未加载到时长)归一成 Duration.zero
|
||||
stream: _player.durationStream.map(
|
||||
(d) => d ?? Duration.zero,
|
||||
),
|
||||
builder: (context, durSnap) {
|
||||
final duration = durSnap.data ?? Duration.zero;
|
||||
// Slider 的 max 不能为 0,没拿到时长前保底 1
|
||||
//(clamp 返回 num,要 toDouble)
|
||||
final max = duration.inMilliseconds
|
||||
.toDouble()
|
||||
.clamp(1, double.infinity)
|
||||
.toDouble();
|
||||
final value = position.inMilliseconds
|
||||
.toDouble()
|
||||
.clamp(0, max)
|
||||
.toDouble();
|
||||
final bufferedValue = buffered.inMilliseconds
|
||||
.toDouble()
|
||||
.clamp(0, max)
|
||||
.toDouble();
|
||||
// 缓冲百分比(duration 为 0 时显示 0%)
|
||||
final percent = duration.inMilliseconds > 0
|
||||
? (buffered.inMilliseconds /
|
||||
duration.inMilliseconds *
|
||||
100)
|
||||
.round()
|
||||
: 0;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ---- 第一行:封面 + 播放键 + 标题 + 倍速 ----
|
||||
Row(
|
||||
children: [
|
||||
if (widget.cover != null) ...[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: widget.cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
],
|
||||
_buildPlayButton(playing, buffering),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: _buildTitle(
|
||||
buffering: buffering,
|
||||
percent: percent,
|
||||
),
|
||||
),
|
||||
_buildSpeedMenu(),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// ---- 第二行:当前时间 + 进度条 + 总时长 ----
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
_fmt(position),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: SliderTheme(
|
||||
data: SliderTheme.of(context).copyWith(
|
||||
trackHeight: 3,
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 6,
|
||||
),
|
||||
overlayShape: const RoundSliderOverlayShape(
|
||||
overlayRadius: 14,
|
||||
),
|
||||
),
|
||||
child: Slider(
|
||||
// 拖动中显示手指位置(不被 positionStream 拉回)
|
||||
value: _dragging && _dragValue != null
|
||||
? _dragValue!
|
||||
: value,
|
||||
max: max,
|
||||
// 次要轨道 = 已缓冲区域(浅色)
|
||||
secondaryTrackValue: bufferedValue,
|
||||
activeColor: const Color(0xFF3B82F6),
|
||||
inactiveColor: const Color(0xFFE3E8EF),
|
||||
// 缓冲中禁用拖动,避免无效 seek 把播放位置打回开头
|
||||
onChanged: buffering
|
||||
? null
|
||||
: (v) => setState(() {
|
||||
_dragging = true;
|
||||
_dragValue = v;
|
||||
}),
|
||||
// 松手才真正 seek 一次:
|
||||
// 拖动中每 tick 都 seek 会让网络流反复重建缓冲,
|
||||
// 导致位置被重置回起始(核心修复)
|
||||
onChangeEnd: buffering
|
||||
? null
|
||||
: (v) {
|
||||
setState(() => _dragging = false);
|
||||
_player.seek(
|
||||
Duration(milliseconds: v.round()),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_fmt(duration),
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF999999),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 播放/暂停按钮(buffering 时转圈)
|
||||
Widget _buildPlayButton(bool playing, bool buffering) {
|
||||
return IconButton(
|
||||
iconSize: 42,
|
||||
padding: EdgeInsets.zero,
|
||||
color: const Color(0xFF3B82F6),
|
||||
icon: buffering
|
||||
? const SizedBox(
|
||||
width: 28,
|
||||
height: 28,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
)
|
||||
: Icon(
|
||||
playing ? Icons.pause_circle_filled : Icons.play_circle_filled,
|
||||
),
|
||||
onPressed: buffering ? null : _togglePlay,
|
||||
);
|
||||
}
|
||||
|
||||
/// 标题区:标题 + 地址/缓冲提示小字
|
||||
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,
|
||||
children: [
|
||||
Text(
|
||||
widget.title ?? (widget.asset != null ? '本地音频' : '音频'),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 倍速菜单(PopupMenuButton,对标倍速选择器)
|
||||
Widget _buildSpeedMenu() {
|
||||
return PopupMenuButton<double>(
|
||||
initialValue: _speed,
|
||||
onSelected: _changeSpeed,
|
||||
itemBuilder: (context) => widget.playbackSpeeds
|
||||
.map(
|
||||
(s) => PopupMenuItem<double>(
|
||||
value: s,
|
||||
child: Text(
|
||||
'${s}x',
|
||||
style: TextStyle(
|
||||
fontWeight: s == _speed ? FontWeight.w700 : FontWeight.w400,
|
||||
color: s == _speed
|
||||
? const Color(0xFF3B82F6)
|
||||
: const Color(0xFF333333),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${_speed}x',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF3B82F6),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down, color: Color(0xFF3B82F6)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_screenutil/flutter_screenutil.dart';
|
||||
import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【CommonPagedList】通用分页列表组件(对标 z-paging,基于 infinite_scroll_pagination 5.x)
|
||||
///
|
||||
/// 一、为什么封装(对齐"很多很多分页接口和页面"的诉求):
|
||||
/// 分页页面的共性逻辑——PagingController 生命周期、下拉刷新、触底加载、
|
||||
/// hasMore 判断、错误重试、空状态、没有更多 footer——全部内聚在组件内部,
|
||||
/// 业务页面只需实现两个回调:load(网络请求)+ buildItem(渲染每行)。
|
||||
/// 写法体验对齐 z-paging,PagingController 完全隐藏,杜绝外部乱调用。
|
||||
///
|
||||
/// 二、核心 API(5.x 与 4.x 完全不同,别搜 4.x 老教程):
|
||||
/// - PagingController 构造直接传 getNextPageKey + fetchPage 两个回调;
|
||||
/// - PagingListener 监听 controller,把 state + fetchNextPage 交给渲染层;
|
||||
/// - PagedListView/PagedGridView 接收 state/fetchNextPage/builderDelegate;
|
||||
/// - PagedChildBuilderDelegate 提供 6 个 indicator builder(首屏/翻页的
|
||||
/// loading、错误、空状态、没有更多),不传则用库内置英文默认。
|
||||
///
|
||||
/// 三、封装层做的 4 件事:
|
||||
/// 1. firstPageKey 透传:getNextPageKey 在 keys 为空时返回 firstPageKey
|
||||
/// (默认 1,正好对齐 pageIndex 从 1 开始),之后每次 +1;
|
||||
/// 2. hasMore 翻译:业务 load 返回 PagingResult.hasNext,封装层记录到
|
||||
/// _hasMore,getNextPageKey 读到 false 就返回 null(库自动停);
|
||||
/// 3. 错误接管:业务 load 直接抛异常即可,库自动渲染错误+重试按钮,
|
||||
/// 业务不写 try-catch;
|
||||
/// 4. 生命周期:controller 在 State.dispose 里销毁,防内存泄漏。
|
||||
///
|
||||
/// 四、游标分页(本项目接口特色)适配:
|
||||
/// 接口要 pageIndex + prevPageId 两个参数,而 load 只给 pageKey。
|
||||
/// 解法:pageKey 当 pageIndex,游标 prevPageId 在业务页面闭包里维护一个
|
||||
/// 私有变量(首次 0,每次从响应更新)。见 my_point_page.dart 的 load。
|
||||
///
|
||||
/// 五、高频坑(本组件已处理):
|
||||
/// 1. PagedChildBuilderDelegate 没有 separatorBuilder 参数,分隔线要走
|
||||
/// PagedListView.separated 构造,本组件已按参数自动切换;
|
||||
/// 2. RefreshIndicator 要求子组件 AlwaysScrollableScrollPhysics,否则
|
||||
/// 内容不满一屏下拉手势无效,本组件已内置;
|
||||
/// 3. 首屏加载由库自动触发(status == loadingFirstPage 时挂载即 fetch),
|
||||
/// 下拉刷新调 controller.refresh() 重置后同样自动重载;
|
||||
/// 4. 【致命坑】_hasMore 必须在刷新时重置为 true:全部分页加载完后
|
||||
/// _hasMore=false,若不重置,refresh 重置 state 后首屏请求会被
|
||||
/// getNextPageKey 拒绝,状态永远卡在 loadingFirstPage 死循环,
|
||||
/// 表现为"不停转圈且不再拉接口"(本组件 _handleRefresh 已处理);
|
||||
/// 5. 若未来需要"返回列表页刷新",可给组件加 GlobalKey 暴露 refresh(),
|
||||
/// 当前页面每次 push 都是新 State 天然重载,无需此能力。
|
||||
/// ============================================================
|
||||
|
||||
/// 统一分页返回契约:所有分页接口都翻译成这个结构返回给组件
|
||||
class PagingResult<T> {
|
||||
const PagingResult({required this.data, this.hasNext = false});
|
||||
|
||||
/// 本页数据列表
|
||||
final List<T> data;
|
||||
|
||||
/// 是否还有下一页(组件根据它决定是否继续加载)
|
||||
final bool hasNext;
|
||||
}
|
||||
|
||||
/// 分页加载回调:业务页面实现它,只写网络请求 + 返回数据,不接触分页库
|
||||
typedef PagedLoad<T> = Future<PagingResult<T>> Function(int pageKey);
|
||||
|
||||
class CommonPagedList<T> extends StatefulWidget {
|
||||
const CommonPagedList({
|
||||
super.key,
|
||||
required this.load,
|
||||
required this.buildItem,
|
||||
this.firstPageKey = 1,
|
||||
this.gridDelegate,
|
||||
this.separatorBuilder,
|
||||
this.padding,
|
||||
this.emptyWidget,
|
||||
this.noMoreText = '— 没有更多了 —',
|
||||
this.invisibleItemsThreshold = 0,
|
||||
this.enableRefresh = true,
|
||||
});
|
||||
|
||||
/// 分页加载回调(业务页面唯一必须实现的逻辑之一)
|
||||
/// [pageKey] 首屏为 [firstPageKey],之后自动 +1,业务直接当 pageIndex 用
|
||||
final PagedLoad<T> load;
|
||||
|
||||
/// 单条渲染回调 (context, item, index)
|
||||
final Widget Function(BuildContext context, T item, int index) buildItem;
|
||||
|
||||
/// 首页页码,默认 1(对齐 pageIndex 从 1 开始)
|
||||
final int firstPageKey;
|
||||
|
||||
/// 传了 gridDelegate 就渲染 PagedGridView,否则 PagedListView
|
||||
final SliverGridDelegate? gridDelegate;
|
||||
|
||||
/// 行间分隔线(不传则无分隔线)
|
||||
final Widget Function(BuildContext context, int index)? separatorBuilder;
|
||||
|
||||
/// 列表内边距
|
||||
final EdgeInsetsGeometry? padding;
|
||||
|
||||
/// 空列表占位(不传显示默认"暂无数据")
|
||||
final Widget? emptyWidget;
|
||||
|
||||
/// "没有更多"底部文案
|
||||
final String noMoreText;
|
||||
|
||||
/// 距离底部还剩几项时触发加载下一页。
|
||||
/// 默认 0 = 滚到最后一个 item 才开始加载(避免一进页面就预拉第二页);
|
||||
/// 若希望滚动更顺滑可调大(如 3),代价是提前多拉一页。
|
||||
final int invisibleItemsThreshold;
|
||||
|
||||
/// 是否启用下拉刷新(默认开启)
|
||||
final bool enableRefresh;
|
||||
|
||||
@override
|
||||
State<CommonPagedList<T>> createState() => _CommonPagedListState<T>();
|
||||
}
|
||||
|
||||
class _CommonPagedListState<T> extends State<CommonPagedList<T>> {
|
||||
/// 业务返回的"是否还有更多",getNextPageKey 据此决定是否继续
|
||||
bool _hasMore = true;
|
||||
|
||||
/// PagingController 完全封装在 State 内部(对标 z-paging 内置 controller),
|
||||
/// 业务页面永远拿不到它,防止外部乱调用破坏分页状态。
|
||||
late final PagingController<int, T> _controller = PagingController<int, T>(
|
||||
// 每次 fetch 前调用:返回本次要加载的 pageKey,返回 null 表示没有更多
|
||||
getNextPageKey: (state) {
|
||||
if (!_hasMore) return null; // 业务说没有更多了
|
||||
if (state.lastPageIsEmpty) return null; // 兜底:空页即结束
|
||||
final lastKey = state.keys?.last;
|
||||
return lastKey == null ? widget.firstPageKey : lastKey + 1;
|
||||
},
|
||||
// fetch 一页:调用业务 load,把 PagingResult 翻译成 List 交给分页库
|
||||
fetchPage: (pageKey) async {
|
||||
final result = await widget.load(pageKey);
|
||||
_hasMore = result.hasNext;
|
||||
return result.data;
|
||||
},
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// 内存泄漏红线:controller 必须销毁(它会释放内部监听和 fetch 操作)
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 下拉刷新:
|
||||
/// ① 先重置 _hasMore(否则全部分页加载完后 _hasMore=false,
|
||||
/// refresh 重置 state 后第一页请求会被 getNextPageKey 拒绝,
|
||||
/// 导致永远停在 loadingFirstPage 死循环不拉接口——实测 bug);
|
||||
/// ② controller.refresh() 重置分页状态(库自动重新请求第一页);
|
||||
/// ③ 等待首屏加载结束再收起 RefreshIndicator 转圈(体验对齐原生)。
|
||||
Future<void> _handleRefresh() async {
|
||||
_hasMore = true;
|
||||
_controller.refresh();
|
||||
|
||||
// 监听状态直到离开 loadingFirstPage(加载完成/失败/空)
|
||||
final completer = Completer<void>();
|
||||
void listener() {
|
||||
if (_controller.status != PagingStatus.loadingFirstPage &&
|
||||
!completer.isCompleted) {
|
||||
completer.complete();
|
||||
}
|
||||
}
|
||||
|
||||
_controller.addListener(listener);
|
||||
listener(); // 立即检查一次:refresh 后可能已同步进入 loading
|
||||
await completer.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {},
|
||||
);
|
||||
_controller.removeListener(listener);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PagingListener<int, T>(
|
||||
controller: _controller,
|
||||
builder: (context, state, fetchNextPage) {
|
||||
final delegate = _buildDelegate(state, fetchNextPage);
|
||||
final Widget pagedView = _buildPagedView(state, fetchNextPage, delegate);
|
||||
if (!widget.enableRefresh) return pagedView;
|
||||
return RefreshIndicator(
|
||||
onRefresh: _handleRefresh,
|
||||
child: pagedView,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 根据参数组装 PagedListView / PagedListView.separated / PagedGridView
|
||||
Widget _buildPagedView(
|
||||
PagingState<int, T> state,
|
||||
VoidCallback fetchNextPage,
|
||||
PagedChildBuilderDelegate<T> delegate,
|
||||
) {
|
||||
// RefreshIndicator 要求子组件可滚动;内容不满一屏时也必须能下拉
|
||||
final physics =
|
||||
widget.enableRefresh ? const AlwaysScrollableScrollPhysics() : null;
|
||||
|
||||
if (widget.gridDelegate != null) {
|
||||
return PagedGridView<int, T>(
|
||||
state: state,
|
||||
fetchNextPage: fetchNextPage,
|
||||
gridDelegate: widget.gridDelegate!,
|
||||
builderDelegate: delegate,
|
||||
padding: widget.padding,
|
||||
physics: physics,
|
||||
);
|
||||
}
|
||||
if (widget.separatorBuilder != null) {
|
||||
return PagedListView<int, T>.separated(
|
||||
state: state,
|
||||
fetchNextPage: fetchNextPage,
|
||||
separatorBuilder: widget.separatorBuilder!,
|
||||
builderDelegate: delegate,
|
||||
padding: widget.padding,
|
||||
physics: physics,
|
||||
);
|
||||
}
|
||||
return PagedListView<int, T>(
|
||||
state: state,
|
||||
fetchNextPage: fetchNextPage,
|
||||
builderDelegate: delegate,
|
||||
padding: widget.padding,
|
||||
physics: physics,
|
||||
);
|
||||
}
|
||||
|
||||
/// 组装 6 个状态 UI(loading / 错误 / 空 / 没有更多),业务可传空状态覆盖
|
||||
PagedChildBuilderDelegate<T> _buildDelegate(
|
||||
PagingState<int, T> state,
|
||||
VoidCallback fetchNextPage,
|
||||
) {
|
||||
return PagedChildBuilderDelegate<T>(
|
||||
itemBuilder: widget.buildItem,
|
||||
invisibleItemsThreshold: widget.invisibleItemsThreshold,
|
||||
// ---------- 首屏 loading ----------
|
||||
firstPageProgressIndicatorBuilder: (_) =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
// ---------- 首屏失败:整页错误 + 重试按钮 ----------
|
||||
firstPageErrorIndicatorBuilder: (_) => _FirstPageError(
|
||||
error: state.error,
|
||||
onRetry: fetchNextPage,
|
||||
),
|
||||
// ---------- 翻页 loading(列表底部) ----------
|
||||
newPageProgressIndicatorBuilder: (_) => Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 14.h),
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 22,
|
||||
height: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
// ---------- 翻页失败(列表底部,点击重试) ----------
|
||||
newPageErrorIndicatorBuilder: (_) => _ErrorFooter(onRetry: fetchNextPage),
|
||||
// ---------- 空状态 ----------
|
||||
noItemsFoundIndicatorBuilder: (_) =>
|
||||
widget.emptyWidget ??
|
||||
Center(
|
||||
child: Text(
|
||||
'暂无数据',
|
||||
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)),
|
||||
),
|
||||
),
|
||||
// ---------- 没有更多(列表底部) ----------
|
||||
noMoreItemsIndicatorBuilder: (_) => Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 14.h),
|
||||
child: Center(
|
||||
child: Text(
|
||||
widget.noMoreText,
|
||||
style: TextStyle(fontSize: 12.sp, color: const Color(0xFF999999)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 首屏加载失败:错误图标 + 文案 + 重新加载按钮
|
||||
class _FirstPageError extends StatelessWidget {
|
||||
const _FirstPageError({required this.error, required this.onRetry});
|
||||
|
||||
final Object? error;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.wifi_off_rounded, size: 48.w, color: Colors.grey.shade400),
|
||||
SizedBox(height: 12.h),
|
||||
Text(
|
||||
'加载失败,请检查网络',
|
||||
style: TextStyle(fontSize: 14.sp, color: const Color(0xFF999999)),
|
||||
),
|
||||
SizedBox(height: 16.h),
|
||||
FilledButton(onPressed: onRetry, child: const Text('重新加载')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 翻页失败 footer:底部"加载失败,点击重试"
|
||||
class _ErrorFooter extends StatelessWidget {
|
||||
const _ErrorFooter({required this.onRetry});
|
||||
|
||||
final VoidCallback onRetry;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onRetry,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 14.h),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'加载失败,点击重试',
|
||||
style: TextStyle(fontSize: 13.sp, color: const Color(0xFF3B82F6)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:photo_view/photo_view_gallery.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【ImagePreviewViewer】通用图片预览组件
|
||||
///
|
||||
/// 一、作用:
|
||||
/// 统一承接项目里的图片大图预览:头像、题目图、讲义图、笔记图,
|
||||
/// 都走这一套,不再每个页面各自写 Dialog/PageView。
|
||||
///
|
||||
/// 二、能力:
|
||||
/// 1. 单图 / 多图预览;
|
||||
/// 2. 双指缩放、拖拽、双击放大;
|
||||
/// 3. 多图左右切换;
|
||||
/// 4. 可选 HeroTag,缩略图到大图过渡更顺滑。
|
||||
///
|
||||
/// 三、高频坑:
|
||||
/// 1. 预览页是全屏页面,外层最好直接 push,不要塞进受限高度的 Dialog;
|
||||
/// 2. Widget 是配置,不是真实 View。这里传入的是 ImageProvider,
|
||||
/// 所以网络图、本地图、内存图都能统一处理;
|
||||
/// 3. 多图切换本质还是 PageView 横向滚动,PhotoView 负责缩放和平移。
|
||||
/// ============================================================
|
||||
class PreviewImageItem {
|
||||
const PreviewImageItem({
|
||||
required this.imageProvider,
|
||||
this.heroTag,
|
||||
this.label,
|
||||
});
|
||||
|
||||
final ImageProvider imageProvider;
|
||||
final Object? heroTag;
|
||||
final String? label;
|
||||
}
|
||||
|
||||
class ImagePreviewViewer extends StatefulWidget {
|
||||
const ImagePreviewViewer({
|
||||
super.key,
|
||||
required this.items,
|
||||
this.initialIndex = 0,
|
||||
});
|
||||
|
||||
final List<PreviewImageItem> items;
|
||||
final int initialIndex;
|
||||
|
||||
static Future<void> show(
|
||||
BuildContext context, {
|
||||
required List<PreviewImageItem> items,
|
||||
int initialIndex = 0,
|
||||
}) async {
|
||||
if (items.isEmpty) return;
|
||||
// 进入预览:隐藏状态栏 + 导航栏,图片铺满全屏
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.immersiveSticky,
|
||||
);
|
||||
await Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ImagePreviewViewer(
|
||||
items: items,
|
||||
initialIndex: initialIndex,
|
||||
),
|
||||
),
|
||||
);
|
||||
// 退出预览:恢复系统 UI
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.edgeToEdge,
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> showSingle(
|
||||
BuildContext context, {
|
||||
required ImageProvider imageProvider,
|
||||
Object? heroTag,
|
||||
String? label,
|
||||
}) {
|
||||
return show(
|
||||
context,
|
||||
items: [
|
||||
PreviewImageItem(
|
||||
imageProvider: imageProvider,
|
||||
heroTag: heroTag,
|
||||
label: label,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ImagePreviewViewer> createState() => _ImagePreviewViewerState();
|
||||
}
|
||||
|
||||
class _ImagePreviewViewerState extends State<ImagePreviewViewer> {
|
||||
late final PageController _pageController;
|
||||
late int _currentIndex;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentIndex = widget.initialIndex;
|
||||
_pageController = PageController(initialPage: widget.initialIndex);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = widget.items;
|
||||
final topPadding = MediaQuery.paddingOf(context).top;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
children: [
|
||||
PhotoViewGallery.builder(
|
||||
itemCount: items.length,
|
||||
pageController: _pageController,
|
||||
backgroundDecoration: const BoxDecoration(color: Colors.black),
|
||||
onPageChanged: (index) {
|
||||
setState(() {
|
||||
_currentIndex = index;
|
||||
});
|
||||
},
|
||||
builder: (context, index) {
|
||||
final item = items[index];
|
||||
return PhotoViewGalleryPageOptions(
|
||||
imageProvider: item.imageProvider,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
maxScale: PhotoViewComputedScale.covered * 3,
|
||||
heroAttributes: item.heroTag == null
|
||||
? null
|
||||
: PhotoViewHeroAttributes(tag: item.heroTag!),
|
||||
onTapUp: (tapContext, details, value) =>
|
||||
Navigator.of(tapContext).maybePop(),
|
||||
);
|
||||
},
|
||||
loadingBuilder: (context, event) {
|
||||
final expected = event?.expectedTotalBytes;
|
||||
final loaded = event?.cumulativeBytesLoaded ?? 0;
|
||||
final progress =
|
||||
expected != null && expected > 0 ? loaded / expected : null;
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 36,
|
||||
height: 36,
|
||||
child: CircularProgressIndicator(
|
||||
value: progress,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
if (items.length > 1)
|
||||
Positioned(
|
||||
top: topPadding + 16,
|
||||
right: 16,
|
||||
child: Text(
|
||||
'${_currentIndex + 1}/${items.length}',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:smooth_sheets/smooth_sheets.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【PersistentSnapSheet】常驻多停靠底部面板(smooth_sheets 封装)
|
||||
///
|
||||
/// 一、为什么封装:
|
||||
/// smooth_sheets 原生 API 组件多(SheetViewport / Sheet / SheetSnapGrid /
|
||||
/// SheetPhysics / MaterialSheetDecoration / SheetScrollConfiguration),
|
||||
/// 每次使用都要写一长串配置。本组件把"常驻 + 多停靠点 + 内部可滚动"
|
||||
/// 这个最常见场景封装成一个开箱即用的 Widget。
|
||||
///
|
||||
/// 二、核心参数:
|
||||
/// snapFactors : 停靠点列表(视口高度比例),如 [0.12, 0.5, 0.85]
|
||||
/// · 最少 2 个,推荐 3 个
|
||||
/// · 值域 0.0~1.0,0 = 完全隐藏,1 = 全屏
|
||||
/// child : 面板内容(通常顶层是 Column,内部放 ListView 等)
|
||||
/// controller : 可选,用于代码控制面板跳到某个停靠点
|
||||
///
|
||||
/// 三、默认行为:
|
||||
/// · 初始位置:snapFactors 中间值
|
||||
/// · 物理引擎:BouncingSheetPhysics(iOS 弹性风格)
|
||||
/// · 外观:白色背景 + 顶部圆角 16 + 轻微阴影
|
||||
/// · 滚动联动:onlyFromTop(列表滚到顶后才能拖面板)
|
||||
///
|
||||
/// 四、使用示例:
|
||||
/// PersistentSnapSheet(
|
||||
/// snapFactors: [0.12, 0.5, 0.85],
|
||||
/// child: Column(
|
||||
/// children: [
|
||||
/// // 拖拽手柄
|
||||
/// _buildHandle(),
|
||||
/// // 可滚动内容
|
||||
/// Expanded(child: ListView(...)),
|
||||
/// ],
|
||||
/// ),
|
||||
/// )
|
||||
///
|
||||
/// 五、smooth_sheets 关键组件对照:
|
||||
/// SheetViewport → 非模态面板的容器(提供布局边界)
|
||||
/// Sheet → 面板本体(拖拽 + 吸附 + 滚动联动)
|
||||
/// SheetSnapGrid → 定义停靠点集合
|
||||
/// SheetOffset → 停靠点位置(factor = 视口高度比例)
|
||||
/// SheetPhysics → 物理引擎(弹性 / 夹紧)
|
||||
/// MaterialSheetDecoration → 外观(Material 风格:圆角/阴影/颜色)
|
||||
/// SheetScrollConfiguration → 滚动联动策略
|
||||
/// ============================================================
|
||||
class PersistentSnapSheet extends StatelessWidget {
|
||||
const PersistentSnapSheet({
|
||||
super.key,
|
||||
required this.snapFactors,
|
||||
required this.child,
|
||||
this.controller,
|
||||
this.physics,
|
||||
this.decoration,
|
||||
this.initialOffsetIndex,
|
||||
}) : assert(snapFactors.length >= 2, '至少需要 2 个停靠点');
|
||||
|
||||
/// 停靠点因子列表(视口高度比例,0.0~1.0)
|
||||
/// 例如 [0.12, 0.5, 0.85] = 底部收起 / 半屏 / 大半屏
|
||||
final List<double> snapFactors;
|
||||
|
||||
/// 面板内容(推荐顶层 Column:手柄 + Expanded(可滚动内容))
|
||||
final Widget child;
|
||||
|
||||
/// 可选控制器(代码控制面板位置:animateTo / snap 等)
|
||||
final SheetController? controller;
|
||||
|
||||
/// 物理引擎(默认 BouncingSheetPhysics = iOS 弹性风格)
|
||||
final SheetPhysics? physics;
|
||||
|
||||
/// 面板外观(默认白色 + 圆角 16 + 阴影 4)
|
||||
final SheetDecoration? decoration;
|
||||
|
||||
/// 初始停靠点索引(默认取中间值)
|
||||
final int? initialOffsetIndex;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 将因子列表转为 SheetOffset 列表
|
||||
final snaps = snapFactors.map((f) => SheetOffset(f)).toList();
|
||||
|
||||
// 初始位置:取中间停靠点
|
||||
final initialIdx = initialOffsetIndex ?? (snapFactors.length ~/ 2);
|
||||
|
||||
return SheetViewport(
|
||||
child: Sheet(
|
||||
controller: controller,
|
||||
initialOffset: SheetOffset(snapFactors[initialIdx]),
|
||||
snapGrid: SheetSnapGrid(snaps: snaps),
|
||||
physics: physics ?? const BouncingSheetPhysics(),
|
||||
// 滚动联动:onlyFromTop = 列表滚到顶后才能下拉面板
|
||||
scrollConfiguration: const SheetScrollConfiguration(
|
||||
scrollSyncMode: SheetScrollHandlingBehavior.onlyFromTop,
|
||||
),
|
||||
decoration: decoration ??
|
||||
MaterialSheetDecoration(
|
||||
size: SheetSize.stretch,
|
||||
color: Colors.white,
|
||||
elevation: 4,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(16),
|
||||
topRight: Radius.circular(16),
|
||||
),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:new_learn/core/providers/privacy_provider.dart';
|
||||
import 'package:new_learn/features/auth/privacy_consent_page.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【PrivacyStartupGate】启动层隐私拦截
|
||||
///
|
||||
/// 方案 A:
|
||||
/// 放在 MaterialApp.router.builder 这一层。
|
||||
/// 好处是冷启动时不会先闪一下登录页/主页,再被重定向到隐私页,
|
||||
/// 而是直接用全屏 Gate 先拦住。
|
||||
///
|
||||
/// 和 go_router 守卫的分工:
|
||||
/// - StartupGate:负责首次启动的"视觉第一拦";
|
||||
/// - Router redirect:负责深链 / 刷新 / 运行中导航时的兜底拦截。
|
||||
/// ============================================================
|
||||
class PrivacyStartupGate extends StatelessWidget {
|
||||
const PrivacyStartupGate({
|
||||
super.key,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder<bool>(
|
||||
valueListenable: privacyAcceptedNotifier,
|
||||
builder: (context, accepted, _) {
|
||||
if (accepted) return child;
|
||||
|
||||
// 关键点:
|
||||
// 启动隐私 Gate 不能把 router child 整个替换掉,否则底下真正的根
|
||||
// Navigator 不会建立起来,协议正文页就没有地方 push。
|
||||
// 这里改成"路由树先建好但隐藏",再用隐私页盖在最上层。
|
||||
//
|
||||
// 注意:PrivacyConsentPage 在 builder 层,context 位于 MaterialApp
|
||||
// 内部 Navigator 之上,showDialog 找不到 Navigator 会失败。
|
||||
// 所以必须额外包一层 Navigator,给弹窗提供 push 目标。
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Offstage(
|
||||
offstage: true,
|
||||
child: child,
|
||||
),
|
||||
Navigator(
|
||||
onGenerateRoute: (settings) => PageRouteBuilder(
|
||||
settings: settings,
|
||||
pageBuilder: (_, __, ___) => const PrivacyConsentPage(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_widget_from_html_core/flutter_widget_from_html_core.dart';
|
||||
import 'package:new_learn/core/components/audio_player_card.dart';
|
||||
import 'package:new_learn/core/components/video_player_view.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【RichTextContent】富文本渲染组件(题目/解析 HTML → Flutter widget)
|
||||
///
|
||||
/// 一、为什么用 flutter_widget_from_html_core:
|
||||
/// 1. 纯 widget 渲染(非 WebView)→ 题目分页列表性能好,全平台含 Web;
|
||||
/// 2. 支持 70+ 标签、内联 CSS、表格横滚、sub/sup 上标下标;
|
||||
/// 3. 活跃维护(0.17.2),要求 Flutter 3.32+(本项目满足)。
|
||||
/// 用 core 版而不是 enhanced:audio/video 标签我们要用自己封装的
|
||||
/// 组件渲染(见下文扩展点),不需要 fwfh 全家桶。
|
||||
///
|
||||
/// 二、props 对照表:
|
||||
/// html ← HTML 字符串(必填)
|
||||
/// textStyle ← 全局文本样式(字号调节/深色模式的入口)
|
||||
/// onTapUrl ← 链接点击回调(返回 true 表示已处理)
|
||||
/// customStylesBuilder ← 自定义标签样式(如把 .answer 高亮)
|
||||
/// customWidgetBuilder ← 自定义元素替换(把任意标签换成业务 widget)
|
||||
/// renderMode ← 渲染模式(默认 column;列表预览可用 listView)
|
||||
///
|
||||
/// 三、内置扩展点(本组件已经帮你处理的):
|
||||
/// 1. <audio> 标签 → 自动替换成 AudioPlayerCard(本项目封装的音频控制条)
|
||||
/// 有 title 属性则作为标题,无则显示"题目音频";
|
||||
/// 2. <video> 标签 → 自动替换成 VideoPlayerView(本项目内嵌播放器,
|
||||
/// 无 Scaffold/AppBar),默认 autoPlay: false(避免富文本里
|
||||
/// 多个视频同时解码占内存),取 title 属性作为错误提示;
|
||||
/// 3. 公式(\(...\) LaTeX)→ 需要 flutter_math_fork,后续扩展。
|
||||
///
|
||||
/// 四、学生端场景:
|
||||
/// - 字号调节:外部用 Slider 控制 textStyle.fontSize 即可(demo 页示范);
|
||||
/// - 深色模式:外部传 textStyle.color,页面背景由业务层控制;
|
||||
/// - 链接安全:默认拦截所有链接(防止题目里夹带外部危险链接),
|
||||
/// 需要放行时传 onTapUrl 自行处理。
|
||||
///
|
||||
/// 五、高频坑:
|
||||
/// 1. <img> 相对路径 src(如 /uploads/xxx.png)必须由后端返回完整 URL,
|
||||
/// 或业务层在渲染前拼接 baseUrl,否则图片加载 404;
|
||||
/// 2. 内联 style 只支持常用 CSS,复杂 flex/grid 布局会降级为块级排列;
|
||||
/// 3. customWidgetBuilder 返回的 widget 是块级(占整行),
|
||||
/// 想做行内替换请用 InlineCustomWidget 包裹;
|
||||
/// 4. 不要用 WebView 方案渲染题目(每题一个 WebView,列表会卡死)。
|
||||
/// ============================================================
|
||||
class RichTextContent extends StatelessWidget {
|
||||
const RichTextContent({
|
||||
super.key,
|
||||
required this.html,
|
||||
this.textStyle,
|
||||
this.onTapUrl,
|
||||
this.customStylesBuilder,
|
||||
this.customWidgetBuilder,
|
||||
this.renderMode = RenderMode.column,
|
||||
});
|
||||
|
||||
/// HTML 字符串(后端返回)
|
||||
final String html;
|
||||
|
||||
/// 全局文本样式(字号/颜色的统一入口)
|
||||
final TextStyle? textStyle;
|
||||
|
||||
/// 链接点击回调(返回 true 表示已处理,不返回 true 且是锚点时自动滚动)
|
||||
final Future<bool> Function(String url)? onTapUrl;
|
||||
|
||||
/// 自定义标签样式(如 .answer 高亮、图片圆角等)
|
||||
final CustomStylesBuilder? customStylesBuilder;
|
||||
|
||||
/// 自定义元素替换(audio 已被内置替换,video/公式等在此扩展)
|
||||
final CustomWidgetBuilder? customWidgetBuilder;
|
||||
|
||||
/// 渲染模式:column(默认,嵌在滚动容器里)/ listView(页面级)
|
||||
final RenderMode renderMode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return HtmlWidget(
|
||||
html,
|
||||
textStyle: textStyle,
|
||||
onTapUrl: onTapUrl ?? _defaultOnTapUrl,
|
||||
customStylesBuilder: customStylesBuilder,
|
||||
customWidgetBuilder: _combinedCustomWidget,
|
||||
renderMode: renderMode,
|
||||
);
|
||||
}
|
||||
|
||||
/// 合并默认替换 + 用户自定义替换
|
||||
CustomWidgetBuilder get _combinedCustomWidget => (element) {
|
||||
// 内置:<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),
|
||||
child: AudioPlayerCard(
|
||||
url: isAsset ? null : src,
|
||||
asset: isAsset ? src.substring('asset://'.length) : null,
|
||||
title: element.attributes['title'] ?? '题目音频',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// 内置:<video> → 本项目 VideoPlayerView(内嵌播放器)
|
||||
// 默认不自动播,避免富文本里多个视频同时解码占内存
|
||||
if (element.localName == 'video') {
|
||||
final src = element.attributes['src'];
|
||||
if (src != null && src.isNotEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: VideoPlayerView(
|
||||
url: src,
|
||||
title: element.attributes['title'] ?? '题目视频',
|
||||
autoPlay: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// 透传用户自定义替换(公式等扩展点)
|
||||
return customWidgetBuilder?.call(element);
|
||||
};
|
||||
|
||||
/// 默认链接拦截:防止题目里夹带外部危险链接
|
||||
static Future<bool> _defaultOnTapUrl(String url) async {
|
||||
debugPrint('[RichTextContent] 已拦截外部链接: $url');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【UserCircleAvatar】圆形网络头像
|
||||
///
|
||||
/// 通用组件:加载失败兜底灰底、占位背景。班级动态、积分排行等页面共用。
|
||||
/// ============================================================
|
||||
class UserCircleAvatar extends StatelessWidget {
|
||||
const UserCircleAvatar({super.key, required this.url, required this.radius});
|
||||
|
||||
final String url;
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: const Color(0xFFF0F4FA),
|
||||
backgroundImage: CachedNetworkImageProvider(url),
|
||||
onBackgroundImageError: (_, _) {},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:chewie/chewie.dart';
|
||||
import 'package:new_learn/core/components/video_player_view.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【VideoPlayerPage】视频播放整页(Scaffold + VideoPlayerView)
|
||||
///
|
||||
/// 一、和 VideoPlayerView 的关系(拆分说明):
|
||||
/// - 本页是"整页播放"场景:黑底 + AppBar(带返回)+ 居中播放器;
|
||||
/// - 内嵌场景(富文本 <video>、列表卡片)请直接用 VideoPlayerView,
|
||||
/// 它没有 Scaffold/AppBar,可嵌任意布局。
|
||||
/// 两者共用同一套 props 和播放核心,逻辑都在 video_player_view.dart。
|
||||
///
|
||||
/// 二、props 全部透传给 VideoPlayerView,用法一致:
|
||||
/// url / title / aspectRatio / autoPlay / looping / muted / volume /
|
||||
/// startAt / isLive / showControls / allowFullScreen /
|
||||
/// allowPlaybackSpeedChanging / allowMuting / allowDragging /
|
||||
/// placeholder / playbackSpeeds / hideControlsTimer / materialProgressColors
|
||||
/// 详见 video_player_view.dart 的文档注释。
|
||||
/// ============================================================
|
||||
class VideoPlayerPage extends StatelessWidget {
|
||||
const VideoPlayerPage({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.title,
|
||||
this.aspectRatio,
|
||||
this.autoPlay = true,
|
||||
this.looping = false,
|
||||
this.muted = false,
|
||||
this.volume = 1.0,
|
||||
this.startAt = Duration.zero,
|
||||
this.isLive = false,
|
||||
this.showControls = true,
|
||||
this.allowFullScreen = true,
|
||||
this.allowPlaybackSpeedChanging = true,
|
||||
this.allowMuting = true,
|
||||
this.allowDragging = true,
|
||||
this.placeholder,
|
||||
this.playbackSpeeds,
|
||||
this.hideControlsTimer,
|
||||
this.materialProgressColors,
|
||||
});
|
||||
|
||||
/// 视频地址(http/https 直链,建议后端统一转 H.264 MP4)
|
||||
final String url;
|
||||
|
||||
/// 页面标题(AppBar 显示)
|
||||
final String? title;
|
||||
|
||||
/// 视频宽高比;不传 = 跟随视频本身尺寸
|
||||
final double? aspectRatio;
|
||||
|
||||
/// 是否自动播放
|
||||
final bool autoPlay;
|
||||
|
||||
/// 是否循环播放
|
||||
final bool looping;
|
||||
|
||||
/// 初始是否静音
|
||||
final bool muted;
|
||||
|
||||
/// 初始音量(0.0 ~ 1.0)
|
||||
final double volume;
|
||||
|
||||
/// 起始播放位置
|
||||
final Duration startAt;
|
||||
|
||||
/// 直播模式:隐藏进度条/快进/倍速
|
||||
final bool isLive;
|
||||
|
||||
/// 是否显示控制条
|
||||
final bool showControls;
|
||||
|
||||
/// 是否允许全屏
|
||||
final bool allowFullScreen;
|
||||
|
||||
/// 是否允许切换倍速
|
||||
final bool allowPlaybackSpeedChanging;
|
||||
|
||||
/// 是否允许静音(控制条上的静音按钮)
|
||||
final bool allowMuting;
|
||||
|
||||
/// 是否允许拖拽进度
|
||||
final bool allowDragging;
|
||||
|
||||
/// 封面占位图(加载完成前展示,对标 poster)
|
||||
final Widget? placeholder;
|
||||
|
||||
/// 倍速档位列表(默认 0.25x ~ 2.0x,传 null 用默认)
|
||||
final List<double>? playbackSpeeds;
|
||||
|
||||
/// 控制条自动隐藏延迟(默认 3 秒)
|
||||
final Duration? hideControlsTimer;
|
||||
|
||||
/// 进度条配色(Material 风格)
|
||||
final ChewieProgressColors? materialProgressColors;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: Colors.white,
|
||||
title: Text(title ?? '视频播放'),
|
||||
),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: VideoPlayerView(
|
||||
url: url,
|
||||
title: title,
|
||||
aspectRatio: aspectRatio,
|
||||
autoPlay: autoPlay,
|
||||
looping: looping,
|
||||
muted: muted,
|
||||
volume: volume,
|
||||
startAt: startAt,
|
||||
isLive: isLive,
|
||||
showControls: showControls,
|
||||
allowFullScreen: allowFullScreen,
|
||||
allowPlaybackSpeedChanging: allowPlaybackSpeedChanging,
|
||||
allowMuting: allowMuting,
|
||||
allowDragging: allowDragging,
|
||||
placeholder: placeholder,
|
||||
playbackSpeeds: playbackSpeeds,
|
||||
hideControlsTimer: hideControlsTimer,
|
||||
materialProgressColors: materialProgressColors,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
import 'package:chewie/chewie.dart';
|
||||
|
||||
/// ============================================================
|
||||
/// 【VideoPlayerView】通用视频播放器(内嵌版,无 Scaffold/AppBar)
|
||||
///
|
||||
/// 一、和 VideoPlayerPage 的关系(拆分说明):
|
||||
/// - VideoPlayerView:纯播放器,可嵌进任何页面/富文本/列表卡片,
|
||||
/// 自带圆角黑底 + loading/错误态 + 切后台自动暂停;
|
||||
/// - VideoPlayerPage:整页 = Scaffold + VideoPlayerView,
|
||||
/// 给"独立播放页"场景用。
|
||||
/// 两处共用同一套 props 和播放核心,一处改两处生效。
|
||||
///
|
||||
/// 二、props 对照表(同 VideoPlayerPage,Vue 开发者速查):
|
||||
/// url ← Vue 的 src(必填)
|
||||
/// title ← 标题(本组件内用于加载失败提示)
|
||||
/// aspectRatio ← 宽高比(默认跟随视频本身,兜底 16:9)
|
||||
/// autoPlay / looping / muted / volume / startAt / isLive
|
||||
/// showControls / allowFullScreen / allowPlaybackSpeedChanging
|
||||
/// allowMuting / allowDragging / placeholder / playbackSpeeds
|
||||
/// hideControlsTimer / materialProgressColors
|
||||
///
|
||||
/// 三、高频坑(承接 VideoPlayerPage):
|
||||
/// 1. dispose 顺序不能反:先 chewie 再 video_player(反了闪退);
|
||||
/// 2. await initialize() 后必须检查 mounted 再 setState;
|
||||
/// 3. Web 端不支持 VideoPlayerController.file,一律传网络 URL;
|
||||
/// 4. 列表页/富文本里嵌多个实例时:只让当前可见的实例播放,
|
||||
/// 其余用 autoPlay: false(否则多路同时解码占满内存);
|
||||
/// 5. 初始化成功后 aspectRatio 跟随视频真实比例,未成功前兜底 16:9。
|
||||
/// ============================================================
|
||||
class VideoPlayerView extends StatefulWidget {
|
||||
const VideoPlayerView({
|
||||
super.key,
|
||||
required this.url,
|
||||
this.title,
|
||||
this.aspectRatio,
|
||||
this.autoPlay = false,
|
||||
this.looping = false,
|
||||
this.muted = false,
|
||||
this.volume = 1.0,
|
||||
this.startAt = Duration.zero,
|
||||
this.isLive = false,
|
||||
this.showControls = true,
|
||||
this.allowFullScreen = true,
|
||||
this.allowPlaybackSpeedChanging = true,
|
||||
this.allowMuting = true,
|
||||
this.allowDragging = true,
|
||||
this.placeholder,
|
||||
this.playbackSpeeds,
|
||||
this.hideControlsTimer,
|
||||
this.materialProgressColors,
|
||||
});
|
||||
|
||||
/// 视频地址(http/https 直链,建议后端统一转 H.264 MP4)
|
||||
final String url;
|
||||
|
||||
/// 标题(加载失败提示用)
|
||||
final String? title;
|
||||
|
||||
/// 视频宽高比;不传 = 跟随视频本身,初始化前兜底 16:9
|
||||
final double? aspectRatio;
|
||||
|
||||
/// 是否自动播放(列表/富文本内嵌默认 false,避免多路同时解码)
|
||||
final bool autoPlay;
|
||||
|
||||
/// 是否循环播放
|
||||
final bool looping;
|
||||
|
||||
/// 初始是否静音
|
||||
final bool muted;
|
||||
|
||||
/// 初始音量(0.0 ~ 1.0)
|
||||
final double volume;
|
||||
|
||||
/// 起始播放位置
|
||||
final Duration startAt;
|
||||
|
||||
/// 直播模式:隐藏进度条/快进/倍速
|
||||
final bool isLive;
|
||||
|
||||
/// 是否显示控制条
|
||||
final bool showControls;
|
||||
|
||||
/// 是否允许全屏
|
||||
final bool allowFullScreen;
|
||||
|
||||
/// 是否允许切换倍速
|
||||
final bool allowPlaybackSpeedChanging;
|
||||
|
||||
/// 是否允许静音(控制条上的静音按钮)
|
||||
final bool allowMuting;
|
||||
|
||||
/// 是否允许拖拽进度
|
||||
final bool allowDragging;
|
||||
|
||||
/// 封面占位图(加载完成前展示,对标 poster)
|
||||
final Widget? placeholder;
|
||||
|
||||
/// 倍速档位列表(默认 0.25x ~ 2.0x,传 null 用默认)
|
||||
final List<double>? playbackSpeeds;
|
||||
|
||||
/// 控制条自动隐藏延迟(默认 3 秒)
|
||||
final Duration? hideControlsTimer;
|
||||
|
||||
/// 进度条配色(Material 风格)
|
||||
final ChewieProgressColors? materialProgressColors;
|
||||
|
||||
@override
|
||||
State<VideoPlayerView> createState() => _VideoPlayerViewState();
|
||||
}
|
||||
|
||||
class _VideoPlayerViewState extends State<VideoPlayerView>
|
||||
with WidgetsBindingObserver {
|
||||
/// 官方底层控制器
|
||||
VideoPlayerController? _videoController;
|
||||
|
||||
/// chewie UI 控制器(初始化成功后才创建)
|
||||
ChewieController? _chewieController;
|
||||
|
||||
/// 是否正在初始化(loading 态)
|
||||
bool _initializing = true;
|
||||
|
||||
/// 初始化/播放错误信息(null = 无错误)
|
||||
String? _error;
|
||||
|
||||
/// 初始化成功后视频的真实宽高比(未成功前 null → 兜底 16:9)
|
||||
double? _aspectRatio;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 监听 App 生命周期:切后台自动暂停
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_initPlayer();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
// 释放顺序不能反:先 chewie 再 video_player(反了会闪退)
|
||||
_chewieController?.dispose();
|
||||
_videoController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// App 切后台 / 来电 → 自动暂停,防止声音继续播
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state != AppLifecycleState.resumed) {
|
||||
_videoController?.pause();
|
||||
}
|
||||
}
|
||||
|
||||
/// 初始化播放器:解析视频头 → 设初始状态 → 建 chewie 控制器
|
||||
Future<void> _initPlayer() async {
|
||||
// 1. 创建官方底层控制器(此时还没真正加载视频)
|
||||
final videoController =
|
||||
VideoPlayerController.networkUrl(Uri.parse(widget.url));
|
||||
_videoController = videoController;
|
||||
|
||||
try {
|
||||
// 2. 必须 await 初始化,才能拿到 duration / aspectRatio
|
||||
await videoController.initialize();
|
||||
|
||||
// 3. 初始静音 / 音量(muted 优先,两者互斥时静音生效)
|
||||
if (widget.muted) {
|
||||
await videoController.setVolume(0);
|
||||
} else if (widget.volume != 1.0) {
|
||||
await videoController.setVolume(widget.volume);
|
||||
}
|
||||
|
||||
// 异步返回后页面可能已被销毁,必须检查 mounted
|
||||
if (!mounted) return;
|
||||
|
||||
// 4. 用 chewie 包一层 UI 控制器(所有 props 在这里落地)
|
||||
_chewieController = ChewieController(
|
||||
videoPlayerController: videoController,
|
||||
// 宽高比:传了用传的,没传用视频本身的
|
||||
aspectRatio: widget.aspectRatio ?? videoController.value.aspectRatio,
|
||||
autoPlay: widget.autoPlay,
|
||||
looping: widget.looping,
|
||||
showControls: widget.showControls,
|
||||
allowFullScreen: widget.allowFullScreen,
|
||||
allowPlaybackSpeedChanging: widget.allowPlaybackSpeedChanging,
|
||||
allowMuting: widget.allowMuting,
|
||||
draggableProgressBar: widget.allowDragging,
|
||||
startAt: widget.startAt,
|
||||
isLive: widget.isLive,
|
||||
placeholder: widget.placeholder,
|
||||
playbackSpeeds:
|
||||
widget.playbackSpeeds ?? const [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2],
|
||||
hideControlsTimer:
|
||||
widget.hideControlsTimer ?? const Duration(seconds: 3),
|
||||
materialProgressColors: widget.materialProgressColors,
|
||||
errorBuilder: (context, errorMessage) => Center(
|
||||
child: Text(
|
||||
'播放出错:$errorMessage',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_initializing = false;
|
||||
_aspectRatio = videoController.value.aspectRatio;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_initializing = false;
|
||||
_error = e.toString();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 内嵌布局:圆角黑底 + 宽高比容器(初始化后跟随视频真实比例)
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: AspectRatio(
|
||||
aspectRatio: widget.aspectRatio ?? _aspectRatio ?? 16 / 9,
|
||||
child: ColoredBox(
|
||||
color: Colors.black,
|
||||
child: Center(child: _buildBody()),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 三种状态渲染:loading / 错误 / 播放器
|
||||
Widget _buildBody() {
|
||||
if (_error != null) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 48, color: Colors.white38),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'${widget.title != null ? '${widget.title}:' : ''}加载失败\n$_error',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_initializing || _chewieController == null) {
|
||||
return const CircularProgressIndicator(color: Colors.white);
|
||||
}
|
||||
return Chewie(controller: _chewieController!);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user