omni_player 2.1.0
omni_player: ^2.1.0 copied to clipboard
Flutter媒体播放器插件,在Android/iOS上支持视频和音频播放和后台播放。支持MKV、MP4、HLS等.
example/lib/main.dart
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'video/player_page.dart';
import 'video/utils/video_item.dart';
import 'video/widgerts/resource_sheet.dart';
/// 示例接口 token。
const _apiToken =
'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIyMDE2NDQ5ODQ3ODc5NTM2NjQwIiwibmFtZSI6IjIwMTY0NDk4NDc4Nzk1MzY2NDAiLCJ0ZW5hbnRfaWQiOiIwIiwiZGVwdF9pZCI6IjE4NDM0OTE3NDA2ODI1NTUzOTIiLCJpYXQiOiIxNzc5Njc2MTcyIiwiaXAiOiIxMTcuMTc1LjE3Mi4xNjAiLCJkYXRhX3Njb3BlX3R5cGUiOiJBbGwsTXlTZWxmLE15U2VsZixNeVNlbGYsTXlTZWxmLE15U2VsZiIsInRva2VuX3ZlcnNpb24iOiIzMjQiLCJ0ZW5hbnQiOiJERUVQRU5HIiwibmJmIjoxNzc5Njc2MTcyLCJleHAiOjE3ODIyNjgxNzIsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3QiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0In0.Ux5XVKZ8VUzqftOs3MK2w6E5J8gbbKwQppFb11woe_A';
/// 资源文件域名。
const _resourceHost = 'https://wch-sh.oss-cn-shanghai.aliyuncs.com/';
/// 需要按顺序播放的资源文件 ID 列表。
const _resourceFileIds = <String>[
'1896395518704095232',
];
void main() {
// WidgetsFlutterBinding.ensureInitialized();
// OmniPlayer.instance.initialize();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'OmniPlayer Demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.deepPurple,
useMaterial3: true,
),
home: const HomePage(token: _apiToken),
);
}
}
class HomePage extends StatefulWidget {
/// 接口鉴权 token。
final String? token;
const HomePage({super.key, this.token});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
/// 当前正在播放的资源 ID 下标。
int _currentFileIdIndex = -1;
/// 当前播放列表,用于在列表切换后刷新资源列表 UI。
List<VideoItem> _videoList = [];
/// 是否进入播放页
bool _isPaly = false;
/// 测试收藏数据,真实项目中可替换为接口返回的数据。
final Set<String> _favoriteVideoUrls = <String>{};
/// 当前播放视频是否已收藏,传给 PlayerPage 用于刷新收藏图标。
final ValueNotifier<bool> _isCurrentVideoFavorite = ValueNotifier(false);
/// 调用资源列表接口并转换为视频模型列表。
Future<List<VideoItem>> _fetchVideoList({
int type = 6,
required String fileId,
int pageIndex = 1,
int pageSize = 1000,
String classify = '',
bool isLoginLog = false,
String? token,
}) async {
final uri = Uri.https(
'api.deepeng.maitianwai.top',
'/apic/resource/resourcelist',
{
'type': '$type',
'fileId': fileId,
'pageIndex': '$pageIndex',
'pageSize': '$pageSize',
'classify': classify,
'isLoginLog': '$isLoginLog',
},
);
final client = HttpClient();
try {
final request = await client.getUrl(uri);
if (token != null && token.isNotEmpty) {
request.headers.set(HttpHeaders.authorizationHeader, token);
}
final response = await request.close();
final body = await response.transform(utf8.decoder).join();
if (response.statusCode < 200 || response.statusCode >= 300) {
throw HttpException('请求失败:${response.statusCode}', uri: uri);
}
final data = jsonDecode(body);
if (data is! List) return [];
return data
.whereType<Map<String, dynamic>>()
.map(_createVideoItem)
.toList();
} finally {
client.close(force: true);
}
}
/// 将接口资源数据转换为播放模型,并在列表加载处处理资源地址。
VideoItem _createVideoItem(Map<String, dynamic> map) {
final cover = map['cover'] as String? ?? '';
final videoPath = cover.replaceFirst(
RegExp(r'\.jpg$', caseSensitive: false),
'.mp4',
);
final srtPath = cover.replaceFirst(
RegExp(r'\.jpg$', caseSensitive: false),
'.srt',
);
return VideoItem(
url: _buildResourceUrl(videoPath),
cover: _buildResourceUrl(cover),
title: map['name'] as String? ?? '',
srt: _buildResourceUrl(srtPath),
);
}
/// 拼接资源域名,完整地址直接返回。
String _buildResourceUrl(String path) {
if (path.isEmpty) return '';
final uri = Uri.tryParse(path);
if (uri != null && uri.hasScheme) return path;
return Uri.parse(_resourceHost).resolve(path).toString();
}
/// 构建资源列表弹窗内容 Widget。
///
/// 由调用方实现,传给 [PlayerPage.resourceSheetBuilder]。
/// [currentIndex] 由调用方在 builder 闭包内动态读取,以确保高亮始终最新。
Widget _buildResourceSheetChild(
List<VideoItem> videoList,
PlayerPageController playerController,
int? currentIndex,
) {
final scrollController = ItemScrollController();
final isDark = MediaQuery.orientationOf(context) == Orientation.landscape;
// 首帧后滚动到当前播放项
if (currentIndex != null && currentIndex > 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (scrollController.isAttached) {
scrollController.scrollTo(
index: currentIndex,
duration: const Duration(milliseconds: 350),
curve: Curves.easeOut,
alignment: 0.3,
);
}
});
}
return ScrollablePositionedList.builder(
itemScrollController: scrollController,
padding: const EdgeInsets.fromLTRB(12, 10, 12, 16),
itemCount: videoList.length + 1,
itemBuilder: (context, index) {
if (index >= videoList.length) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text(
'没有更多数据了',
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white54 : Colors.grey,
),
),
),
);
}
return _ResourceListItem(
index: index,
item: videoList[index],
isPlaying: index == currentIndex,
isDark: isDark,
onTap: (ctx) {
Navigator.of(ctx).pop();
playerController.playAt(index);
},
);
},
);
}
/// 按资源 ID 下标加载播放列表。
Future<List<VideoItem>?> _loadVideoListAt(int index) async {
if (index < 0 || index >= _resourceFileIds.length) {
return null;
}
final fileId = _resourceFileIds[index];
debugPrint('加载资源列表 fileId=$fileId index=$index');
final videoList = await _fetchVideoList(
fileId: fileId,
token: widget.token,
);
if (videoList.isEmpty) return null;
_currentFileIdIndex = index;
return videoList;
}
/// 当前播放列表完成后,加载下一个资源 ID 对应的播放列表。
Future<List<VideoItem>?> _loadNextVideoList() async {
final nextIndex = _currentFileIdIndex + 1;
final videoList = await _loadVideoListAt(nextIndex);
if (videoList == null) {
debugPrint('测试消息:所有资源列表播放完成');
}
return videoList;
}
/// 翻译方法:由 HomePage 实现,传入 PlayerPage。
/// 后期替换为真实 AI 翻译 API。
Future<String?> _translate(String enText) async {
// 模拟网络延迟
await Future.delayed(const Duration(milliseconds: 600));
return '【测试翻译】$enText';
}
/// 清晰朗读方法:由 HomePage 实现,传入 PlayerPage。
/// 后期替换为真实 TTS API,返回音频 URL。
Future<String?> _clearReading(String enText) async {
// 测试音频地址
return 'https://wch-sh.oss-cn-shanghai.aliyuncs.com/%E5%8D%95%E8%AF%8D%E5%8F%91%E9%9F%B3/uk/a/abasing.mp3';
}
/// 测试查询收藏状态接口。
Future<bool> _queryFavoriteStatus(VideoItem video) async {
await Future.delayed(const Duration(milliseconds: 300));
return _favoriteVideoUrls.contains(video.url);
}
/// 测试添加收藏接口。
Future<void> _addFavorite(VideoItem video) async {
await Future.delayed(const Duration(milliseconds: 300));
_favoriteVideoUrls.add(video.url);
}
/// 测试取消收藏接口。
Future<void> _removeFavorite(VideoItem video) async {
await Future.delayed(const Duration(milliseconds: 300));
_favoriteVideoUrls.remove(video.url);
}
@override
void dispose() {
_isCurrentVideoFavorite.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('OmniPlayer'),
centerTitle: true,
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.play_circle_outline,
size: 80,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 24),
const Text(
'Flutter 媒体播放器插件',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'支持 MKV、MP4、HLS 等格式',
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
const SizedBox(height: 32),
FilledButton.icon(
onPressed: () async {
if (_isPaly) return;
_isPaly = true;
debugPrint(
'[Timing] >>> 点击"进入播放示例" ${DateTime.now().millisecondsSinceEpoch}ms');
final videoList = await _loadVideoListAt(0);
if (!context.mounted) return;
if (videoList == null) return;
final playerController = PlayerPageController();
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => PlayerPage(
videoList: videoList,
controller: playerController,
isFavoriteListenable: _isCurrentVideoFavorite,
// 列表播放完成事件
onPlaylistCompleted: () async {
debugPrint('[事件] 列表播放完成');
return _loadNextVideoList();
},
// 进入后台事件
onAppBackground: () {
debugPrint('[事件] 进入后台');
},
// 列表 - 标题
resourceListTitle: '资源列表(${videoList.length + 1})',
// 列表 - 弹窗内容由调用方实现
resourceSheetBuilder: (ctx, currentIndex) =>
_buildResourceSheetChild(
_videoList.isNotEmpty ? _videoList : videoList,
playerController,
currentIndex,
),
// 单个视频播放完成事件
onVideoCompleted: (index, video) {
debugPrint(
'[事件] 视频播放完成 index=$index title=${video.title}',
);
},
// 退出播放页事件
onPageExit: (index, video) {
_isPaly = false;
debugPrint(
'[事件] 退出播放页 index=$index title=${video?.title ?? '未播放'}',
);
},
// 列表切换时刷新资源列表 UI
onPlaylistChanged: (newVideoList) {
setState(() {
_videoList = newVideoList;
});
},
// 当前视频加载事件:查询当前视频是否已收藏,并通知播放页刷新图标
onVideoLoaded: (index, video) async {
debugPrint(
'[事件] 加载视频 index=$index title=${video.title}',
);
final isFavorite = await _queryFavoriteStatus(video);
_isCurrentVideoFavorite.value = isFavorite;
},
// 收藏点击事件:根据当前收藏状态调用收藏/取消收藏接口
onFavoriteTap: (index, video, isFavorite) async {
debugPrint(
'[事件] 点击收藏 index=$index title=${video.title} isFavorite=$isFavorite',
);
if (isFavorite) {
await _removeFavorite(video);
_isCurrentVideoFavorite.value = false;
} else {
await _addFavorite(video);
_isCurrentVideoFavorite.value = true;
}
},
// 翻译回调:由 HomePage 实现
onTranslate: _translate,
// 清晰朗读回调:由 HomePage 实现
onClearReading: _clearReading,
),
),
);
},
icon: const Icon(Icons.play_arrow),
label: const Text('进入播放示例'),
style: FilledButton.styleFrom(
padding:
const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
),
),
],
),
),
);
}
}
/// 资源列表中的单行 Widget,对应截图中的样式。
class _ResourceListItem extends StatelessWidget {
final int index;
final VideoItem item;
final bool isPlaying;
final bool isDark;
final void Function(BuildContext context)? onTap;
const _ResourceListItem({
required this.index,
required this.item,
required this.isPlaying,
required this.isDark,
this.onTap,
});
@override
Widget build(BuildContext context) {
final backgroundColor = isPlaying
? Colors.blue.withValues(alpha: isDark ? 0.20 : 0.08)
: isDark
? Colors.white.withValues(alpha: index % 2 == 0 ? 0.08 : 0.05)
: index % 2 == 0
? Colors.white
: const Color(0xFFF8F8F8);
final titleColor = isDark ? Colors.white : Colors.black87;
final subTextColor = isDark ? Colors.white54 : Colors.black54;
return GestureDetector(
onTap: onTap != null ? () => onTap!(context) : null,
behavior: HitTestBehavior.opaque,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 5),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(16),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
if (isPlaying)
const Positioned(
left: 0,
top: 0,
bottom: 0,
child: ColoredBox(
color: Colors.blue,
child: SizedBox(width: 4),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// 封面图
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Stack(
children: [
item.thumbnailCover.isNotEmpty
? Image.network(
item.thumbnailCover,
width: 72,
height: 72,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
_placeholderCover(),
)
: _placeholderCover(),
if (isDark)
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.28),
],
),
),
),
),
],
),
),
const SizedBox(width: 12),
// 标题 + 元信息
Expanded(
child: Padding(
padding: EdgeInsets.only(right: isPlaying ? 30 : 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
height: 1.25,
fontWeight: FontWeight.w700,
color: titleColor,
),
),
const SizedBox(height: 10),
Wrap(
spacing: 14,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_ResourceMeta(
icon: Icons.chat_bubble_outline,
text: '0',
iconColor: Colors.orange,
textColor: subTextColor,
),
_ResourceMeta(
icon: Icons.access_time,
text: '--:--',
iconColor: Colors.blue,
textColor: subTextColor,
),
_ResourceMeta(
icon: Icons.play_circle_outline,
text: '--:--',
iconColor: Colors.green,
textColor: subTextColor,
),
],
),
],
),
),
),
],
),
),
if (isPlaying)
const Positioned(
right: 14,
top: 0,
bottom: 0,
child: Center(child: PlayingEqualizer()),
),
],
),
), // Container
); // GestureDetector
}
Widget _placeholderCover() {
return Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.grey.shade200,
),
child: Icon(
Icons.movie,
color: isDark ? Colors.white54 : Colors.grey,
),
);
}
}
class _ResourceMeta extends StatelessWidget {
final IconData icon;
final String text;
final Color iconColor;
final Color textColor;
const _ResourceMeta({
required this.icon,
required this.text,
required this.iconColor,
required this.textColor,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: iconColor),
const SizedBox(width: 3),
Text(
text,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: textColor,
),
),
],
);
}
}