hm_video_player 0.4.9
hm_video_player: ^0.4.9 copied to clipboard
跨平台视频播放器插件,支持全屏、画中画、投屏、倍速等功能。
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:hm_video_player/hm_video_player.dart';
import 'cast_page.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const DemoApp());
}
class DemoApp extends StatelessWidget {
const DemoApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
debugShowCheckedModeBanner: false,
home: DemoHomePage(),
);
}
}
class DemoHomePage extends StatefulWidget {
const DemoHomePage({super.key});
@override
State<DemoHomePage> createState() => _DemoHomePageState();
}
class _DemoHomePageState extends State<DemoHomePage> {
final controller = HmVideoPlayerController();
bool _pipEnabled = true;
bool _autoEnterPip = true;
bool _autoRotateFullscreen = true;
static const url =
'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4';
@override
void initState() {
super.initState();
controller.events.listen((event) {
if (!mounted) return;
debugPrint('hm player event: $event');
if (event is CastRequestedEvent) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => CastPage(
controller: controller,
initialContext: CastRequestContext(
mediaUrl: event.mediaUrl,
title: event.title,
position: event.position,
playing: event.playing,
supportedProtocols: event.supportedProtocols,
currentSession: event.currentSession,
),
),
),
);
}
if (event is CastStateChangedEvent) {
debugPrint(
'cast state: ${event.state} protocol=${event.protocol} '
'device=${event.deviceName} message=${event.message}',
);
}
if (event is QualitySelectedEvent) {
// 应用层:根据 qualities[event.index].value 请求新播放地址,再调用 setSource。
// controller.setSource(newUrl, position: currentPosition);
}
if (event is PipOpenedEvent) {
debugPrint('pip opened');
}
if (event is PipClosedEvent) {
debugPrint('pip closed');
}
});
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xfff7f7f7),
appBar: AppBar(
title: const Text('HM Video Player'),
backgroundColor: Colors.white,
surfaceTintColor: Colors.white,
),
body: ListView(
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: HmVideoPlayer(
controller: controller,
videoUrl: url,
title: '莫离 第01集',
cover: false,
looping: true,
autoPlay: true,
autoRotateFullscreen: _autoRotateFullscreen,
syncSystemGestureDirection: true,
showNativeControls: true,
enablePictureInPicture: _pipEnabled,
autoEnterPictureInPicture: _autoEnterPip,
inlineControls: NativeControlsConfig.inlinePreset,
fullscreenControls: NativeControlsConfig.fullscreenPreset,
qualities: const [
VideoQuality(
name: '臻彩MAX',
subName: 'AI增强',
value: '6',
vip: true,
vipDesc: 'VIP尊享',
fullWidth: true,
),
VideoQuality(
name: '4K',
subName: '超高清 SDR',
value: '5',
vip: true,
vipDesc: 'VIP尊享',
fullWidth: true,
),
VideoQuality(
name: '1080P',
subName: 'HDR Vivid',
value: '4',
fullWidth: true,
),
VideoQuality(
name: '1080P',
subName: '高清 SDR',
value: '3',
),
VideoQuality(
name: '720P',
subName: '准高清',
value: '2',
),
VideoQuality(
name: '480P',
subName: '标清',
value: '1',
),
],
currentQualityIndex: 4,
currentEpisodeIndex: 2,
episodes: [
for (var i = 1; i <= 100; i++)
VideoEpisode(
title: switch (i) {
1 => '沾喜气!精分孤女轮椅战神王府大婚',
2 => '王爷初遇莫离',
3 => '轮椅上的秘密',
4 => '王府夜宴风波',
5 => '莫离展露锋芒',
6 => '暗流涌动',
7 => '危机四伏',
8 => '联手破局',
9 => '真相浮现',
10 => '情感纠葛',
11 => '绝地反击',
12 => '大婚前夕',
_ => '第 $i 集',
},
url: url,
cover: 'https://picsum.photos/seed/hm_episode_$i/320/180',
),
],
danmakuItems: const [
DanmakuItem(text: '原生小窗播放器', at: Duration(seconds: 1)),
DanmakuItem(
text: '同一个原生播放器进入全屏',
at: Duration(seconds: 2),
),
],
speeds: const [3.0, 2.0, 1.5, 1.25, 1.0, 0.5],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: _PlayerSettingsCard(
pipEnabled: _pipEnabled,
autoEnterPip: _autoEnterPip,
autoRotateFullscreen: _autoRotateFullscreen,
onPipEnabledChanged: (value) async {
await controller.setPictureInPictureEnabled(value);
if (mounted) setState(() => _pipEnabled = value);
},
onAutoEnterPipChanged: (value) async {
await controller.setAutoEnterPictureInPicture(value);
if (mounted) setState(() => _autoEnterPip = value);
},
onAutoRotateFullscreenChanged: (value) async {
await controller.setAutoRotateFullscreen(value);
if (mounted) setState(() => _autoRotateFullscreen = value);
},
),
),
Padding(
padding: const EdgeInsets.all(16),
child: FilledButton(
onPressed: _pipEnabled ? controller.enterPictureInPicture : null,
child: const Text('进入画中画'),
),
),
// Padding(
// padding: const EdgeInsets.all(16),
// child: Wrap(
// spacing: 12,
// runSpacing: 12,
// children: [
// FilledButton(
// onPressed: controller.enterFullscreen,
// child: const Text('进入全屏'),
// ),
// OutlinedButton(
// onPressed: () => controller.setSpeed(1.5),
// child: const Text('1.5x'),
// ),
// OutlinedButton(
// onPressed: () =>
// controller.seekTo(const Duration(seconds: 5)),
// child: const Text('跳到 5 秒'),
// ),
// ],
// ),
// ),
// const _InfoCard(
// title: '架构说明',
// body:
// 'Flutter 与原生通过 Pigeon 生成的类型安全 API 通信。小窗和全屏都是原生播放器,避免 Flutter texture 与原生画面交接问题。',
// ),
// const _InfoCard(
// title: '控制栏',
// body:
// '小屏/全屏控制栏均为原生实现,默认布局参考腾讯视频。可通过 inlineControls / fullscreenControls 配置各按钮显隐,controlButtonTapped 事件回传 Flutter。',
// ),
// const _InfoCard(
// title: '测试重点',
// body:
// '1. 点击全屏按钮进入全屏。\n2. 旋转手机进入全屏。\n3. 全屏中转回竖屏自动退出。\n4. 全屏中左右横屏切换。\n5. 退出时是否还有双层画面或底部残留。',
// ),
// const SizedBox(height: 800),
],
),
);
}
}
class _PlayerSettingsCard extends StatelessWidget {
const _PlayerSettingsCard({
required this.pipEnabled,
required this.autoEnterPip,
required this.autoRotateFullscreen,
required this.onPipEnabledChanged,
required this.onAutoEnterPipChanged,
required this.onAutoRotateFullscreenChanged,
});
final bool pipEnabled;
final bool autoEnterPip;
final bool autoRotateFullscreen;
final ValueChanged<bool> onPipEnabledChanged;
final ValueChanged<bool> onAutoEnterPipChanged;
final ValueChanged<bool> onAutoRotateFullscreenChanged;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(12, 12, 12, 4),
child: Text(
'播放设置',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
),
SwitchListTile(
title: const Text('旋转自动全屏'),
subtitle: const Text('小屏播放时旋转手机自动进入全屏'),
value: autoRotateFullscreen,
onChanged: onAutoRotateFullscreenChanged,
),
SwitchListTile(
title: const Text('允许画中画'),
subtitle: const Text('关闭后无法手动或自动进入 PiP'),
value: pipEnabled,
onChanged: onPipEnabledChanged,
),
SwitchListTile(
title: const Text('自动画中画'),
subtitle: const Text('播放中离开 App 时自动进入画中画'),
value: autoEnterPip,
onChanged: pipEnabled ? onAutoEnterPipChanged : null,
),
],
),
);
}
}
class _InfoCard extends StatelessWidget {
const _InfoCard({required this.title, required this.body});
final String title;
final String body;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(16, 8, 16, 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(body, style: const TextStyle(height: 1.45)),
],
),
);
}
}