flutter架 构项目基础

This commit is contained in:
崔宝新
2026-09-04 15:22:20 +08:00
parent 70967229d0
commit 0c5aee59c0
265 changed files with 35484 additions and 0 deletions
+157
View File
@@ -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 ? '立即更新' : '立即更新'),
),
],
);
}
}
+479
View File
@@ -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)),
],
),
),
);
}
}
+330
View File
@@ -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(),
),
),
],
);
},
);
}
}
+132
View File
@@ -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: (_, _) {},
);
}
}
+134
View File
@@ -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,
),
),
),
);
}
}
+256
View File
@@ -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!);
}
}