hm_video_player 0.7.0
hm_video_player: ^0.7.0 copied to clipboard
跨平台视频播放器插件,支持全屏、画中画、投屏、倍速等功能。
example/lib/main.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hm_video_player/hm_video_player.dart';
import 'cast_page.dart';
import 'demo_media.dart';
import 'demo_panels.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();
late final List<VideoEpisode> _episodes = DemoMedia.buildEpisodes();
late final TextEditingController _fullscreenTitleController =
TextEditingController(text: '莫离');
late final TextEditingController _fullscreenSubtitleController =
TextEditingController(text: _episodes[2].title);
StreamSubscription<HmPlayerEvent>? _eventSub;
Timer? _statusTimer;
DateTime? _lastProgressLog;
int _playerKey = 0;
bool _playerVisible = true;
bool _rebuildingPlayer = false;
String _videoUrl = DemoMedia.bee;
String? _localOceansUri;
String? _localTrailerUri;
bool _localReady = false;
bool _usingAuthSource = false;
bool _pipEnabled = true;
bool _autoEnterPip = true;
bool _autoRotateFullscreen = true;
bool _muted = false;
bool _looping = true;
double _volume = 1;
double _selectedSpeed = 1;
int _qualityIndex = 4;
int _episodeIndex = 2;
bool _cover = false;
bool _autoPlay = true;
bool _showNativeControls = true;
bool _syncSystemGestureDirection = true;
bool _enableInlineDoubleTap = true;
bool _enableInlineDrag = true;
bool _useHttpHeaders = false;
bool _trialEnabled = false;
int _initialPositionSeconds = 0;
bool _hideShareButton = false;
bool _showScreenshotButton = false;
HmCastUiMode _castUiMode = HmCastUiMode.flutter;
final List<String> _eventLog = <String>[];
List<String> _statusLines = const <String>[];
@override
void initState() {
super.initState();
_eventSub = controller.events.listen(_onPlayerEvent);
_statusTimer = Timer.periodic(
const Duration(milliseconds: 500),
(_) => _refreshStatus(),
);
_prepareLocalSample();
}
Future<void> _applyFullscreenLabels({
bool applyTitle = true,
bool applySubtitle = true,
}) async {
final title = applyTitle ? _fullscreenTitleController.text : null;
final subtitle = applySubtitle ? _fullscreenSubtitleController.text : null;
await controller.setFullscreenLabels(title: title, subtitle: subtitle);
_appendLog(
'setFullscreenLabels'
'${title != null ? ' title=$title' : ''}'
'${subtitle != null ? ' subtitle=$subtitle' : ''}',
);
}
Future<void> _applyInitialPlaybackIndices() async {
await controller.setCurrentQualityIndex(_qualityIndex);
await controller.setCurrentEpisodeIndex(_episodeIndex);
_appendLog(
'setCurrentQualityIndex index=$_qualityIndex, '
'setCurrentEpisodeIndex index=$_episodeIndex',
);
}
void _syncFullscreenSubtitleFromEpisode(int index) {
if (index < 0 || index >= _episodes.length) return;
_fullscreenSubtitleController.text = _episodes[index].title;
}
Future<void> _prepareLocalSample() async {
final locals = await DemoMedia.ensureLocalAssetSamples();
if (!mounted) return;
final oceans = locals[DemoMedia.oceansAsset];
final trailer = locals[DemoMedia.trailerAsset];
setState(() {
_localOceansUri = oceans;
_localTrailerUri = trailer;
_localReady = oceans != null && trailer != null;
});
if (_localReady) {
_appendLog('本地 oceans: $oceans');
_appendLog('本地 trailer: $trailer');
} else {
_appendLog('本地 asset 复制失败(检查 assets/oceans.mp4、trailer.mp4)');
}
}
void _appendLog(String line) {
if (!mounted) return;
final now = DateTime.now();
final stamp = '${now.hour.toString().padLeft(2, '0')}:'
'${now.minute.toString().padLeft(2, '0')}:'
'${now.second.toString().padLeft(2, '0')}';
setState(() {
_eventLog.add('[$stamp] $line');
if (_eventLog.length > 200) {
_eventLog.removeRange(0, _eventLog.length - 200);
}
});
}
Future<void> _refreshStatus() async {
if (!mounted || !controller.isAttached) return;
try {
final results = await Future.wait<Object>([
controller.position,
controller.duration,
controller.isPlaying,
controller.speed,
controller.isFullscreen,
controller.isInPictureInPicture,
controller.isPictureInPictureSupported,
]);
if (!mounted) return;
final position = results[0] as Duration;
final duration = results[1] as Duration;
setState(() {
_statusLines = [
'viewId=${controller.viewId} isAttached=${controller.isAttached}',
'position=${_fmt(position)} duration=${_fmt(duration)}',
'isPlaying=${results[2]} speed=${results[3]}',
'isFullscreen=${results[4]} isInPip=${results[5]} pipSupported=${results[6]}',
'qualityIndex=$_qualityIndex episodeIndex=$_episodeIndex',
];
});
} catch (_) {
// Ignore transient PlatformView detach races.
}
}
String _fmt(Duration d) {
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
final ms = (d.inMilliseconds.remainder(1000) ~/ 100).toString();
return '$m:$s.$ms';
}
Future<void> _onPlayerEvent(HmPlayerEvent event) async {
if (!mounted) return;
debugPrint('hm player event: $event');
switch (event) {
case ProgressEvent(:final position, :final duration):
final now = DateTime.now();
if (_lastProgressLog == null ||
now.difference(_lastProgressLog!) >= const Duration(seconds: 1)) {
_lastProgressLog = now;
_appendLog('Progress ${_fmt(position)} / ${_fmt(duration)}');
}
case MediaInfoLoadedEvent(:final width, :final height, :final duration):
_appendLog(
'MediaInfoLoaded ${width}x$height duration=${_fmt(duration)}',
);
case EndedEvent():
_appendLog('Ended');
case FullscreenOpenedEvent():
_appendLog('FullscreenOpened');
case FullscreenClosedEvent(:final position, :final playing):
_appendLog(
'FullscreenClosed position=${_fmt(position)} playing=$playing',
);
case SpeedChangedEvent(:final speed):
setState(() => _selectedSpeed = speed);
_appendLog('SpeedChanged $speed');
case QualitySelectedEvent(:final index):
_appendLog('QualitySelected index=$index');
await _applyQualitySource(index);
case EpisodeSelectedEvent(:final index):
setState(() => _episodeIndex = index);
_syncFullscreenSubtitleFromEpisode(index);
_appendLog('EpisodeSelected index=$index');
case LockChangedEvent(:final locked):
_appendLog('LockChanged locked=$locked');
case BufferingEvent(:final isBuffering):
_appendLog('Buffering $isBuffering');
case ErrorEvent(:final code, :final message):
_appendLog('Error code=$code message=$message');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('播放失败 [$code] $message'),
backgroundColor: Colors.red.shade700,
),
);
}
case TrialEndedEvent(:final position):
_appendLog('TrialEnded position=${_fmt(position)}');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('试看结束 @ ${_fmt(position)}')),
);
}
case PlayingChangedEvent(:final playing):
_appendLog('PlayingChanged playing=$playing');
case ControlButtonTappedEvent(:final button, :final danmakuEnabled):
_appendLog(
'ControlButtonTapped $button'
'${danmakuEnabled != null ? ' danmaku=$danmakuEnabled' : ''}',
);
case CastStateChangedEvent(
:final state,
:final protocol,
:final deviceName,
:final message,
):
_appendLog(
'CastStateChanged $state protocol=$protocol '
'device=$deviceName message=$message',
);
case CastRequestedEvent(
:final mediaUrl,
:final title,
:final position,
:final playing,
:final supportedProtocols,
:final currentSession,
):
_appendLog('CastRequested title=$title url=$mediaUrl');
if (_castUiMode == HmCastUiMode.flutter && mounted) {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => CastPage(
controller: controller,
initialContext: CastRequestContext(
mediaUrl: mediaUrl,
title: title,
position: position,
playing: playing,
supportedProtocols: supportedProtocols,
currentSession: currentSession,
),
),
),
);
}
case CastDevicesUpdatedEvent(:final devices):
_appendLog('CastDevicesUpdated count=${devices.length}');
case CastProgressEvent(:final position, :final duration):
_appendLog('CastProgress ${_fmt(position)} / ${_fmt(duration)}');
case CastPlayingChangedEvent(:final playing):
_appendLog('CastPlayingChanged playing=$playing');
case CastCommandResultEvent(:final result):
_appendLog(
'CastCommandResult command=${result.command} success=${result.success} '
'pending=${result.pending} code=${result.code} message=${result.message}',
);
case PipOpenedEvent():
_appendLog('PipOpened');
case PipClosedEvent():
_appendLog('PipClosed');
}
}
Map<String, String> get _widgetHttpHeaders {
if (_usingAuthSource) return DemoMedia.authHttpHeaders;
if (_useHttpHeaders) return DemoMedia.sampleHttpHeaders;
return const {};
}
Future<void> _applyQualitySource(int index) async {
if (index < 0 || index >= DemoMedia.qualities.length) return;
setState(() {
_qualityIndex = index;
_usingAuthSource = false;
});
final value = DemoMedia.qualities[index].value;
final url = DemoMedia.urlForQualityValue(value);
final position = await controller.position;
await controller.setSource(
url,
position: position,
httpHeaders: _useHttpHeaders ? DemoMedia.sampleHttpHeaders : null,
);
setState(() => _videoUrl = url);
_appendLog('setSource(quality=$value) → $url @ ${_fmt(position)}');
}
Future<void> _setSource(
String url, {
String label = '',
Map<String, String>? httpHeaders,
bool usingAuthSource = false,
}) async {
final position = await controller.position;
final headers = httpHeaders ??
(_useHttpHeaders && !url.startsWith('file:')
? DemoMedia.sampleHttpHeaders
: null);
await controller.setSource(
url,
position: position,
httpHeaders: headers,
);
setState(() {
_videoUrl = url;
_usingAuthSource = usingAuthSource;
});
_appendLog('setSource${label.isEmpty ? '' : '($label)'} → $url');
}
Future<void> _setAuthFhdSource() async {
await _setSource(
DemoMedia.authFhd,
label: 'auth FHD',
httpHeaders: DemoMedia.authHttpHeaders,
usingAuthSource: true,
);
}
Future<void> _playEncryptedSample() async {
setState(() {
_videoUrl = DemoMedia.encryptedMp4;
_usingAuthSource = false;
});
await controller.playEncrypted(
DemoMedia.encryptedMp4,
decryptionKey: DemoMedia.encryptedDecryptionKey,
);
_appendLog('playEncrypted → encrypted sample');
}
Future<void> _seekRelative(Duration delta) async {
final position = await controller.position;
final next = position + delta;
await controller.seekTo(next < Duration.zero ? Duration.zero : next);
}
Future<void> _rebuildPlayer() async {
if (_rebuildingPlayer) return;
_rebuildingPlayer = true;
try {
await controller.pause();
if (!mounted) return;
_appendLog('重建播放器:先销毁旧 PlatformView…');
setState(() => _playerVisible = false);
// 等待旧原生 ExoPlayer/AVPlayer dispose,避免与新实例 autoPlay 叠音。
await Future<void>.delayed(const Duration(milliseconds: 200));
if (!mounted) return;
setState(() {
_playerKey++;
_playerVisible = true;
});
_appendLog('重建播放器 key=$_playerKey');
} finally {
_rebuildingPlayer = false;
}
}
NativeControlsConfig get _inlineControls {
final buttons = Map<String, bool>.from(
NativeControlsConfig.inlinePreset.buttons,
);
if (_hideShareButton) buttons[NativeControlButton.share] = false;
return NativeControlsConfig(buttons: buttons);
}
NativeControlsConfig get _fullscreenControls {
final buttons = Map<String, bool>.from(
NativeControlsConfig.fullscreenPreset.buttons,
);
if (_hideShareButton) buttons[NativeControlButton.share] = false;
if (_showScreenshotButton) {
buttons[NativeControlButton.screenshot] = true;
}
return NativeControlsConfig(buttons: buttons);
}
Future<void> _openCastPageManually() async {
final ctx = await controller.getCastRequestContext();
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => CastPage(
controller: controller,
initialContext: ctx,
),
),
);
}
@override
void dispose() {
_eventSub?.cancel();
_statusTimer?.cancel();
_fullscreenTitleController.dispose();
_fullscreenSubtitleController.dispose();
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xfff7f7f7),
appBar: AppBar(
title: const Text('HM Video Player API Demo'),
backgroundColor: Colors.white,
surfaceTintColor: Colors.white,
),
body: Column(
children: [
AspectRatio(
aspectRatio: 16 / 9,
child: _playerVisible
? HmVideoPlayer(
key: ValueKey(_playerKey),
controller: controller,
videoUrl: _videoUrl,
title: '莫离',
cover: _cover,
looping: _looping,
autoPlay: _autoPlay,
autoRotateFullscreen: _autoRotateFullscreen,
syncSystemGestureDirection: _syncSystemGestureDirection,
showNativeControls: _showNativeControls,
enableInlineDoubleTapPlayPause: _enableInlineDoubleTap,
enableInlineDragGestures: _enableInlineDrag,
enablePictureInPicture: _pipEnabled,
autoEnterPictureInPicture: _autoEnterPip,
inlineControls: _inlineControls,
fullscreenControls: _fullscreenControls,
initialPosition: Duration(seconds: _initialPositionSeconds),
trialDuration:
_trialEnabled ? const Duration(seconds: 15) : null,
httpHeaders: _widgetHttpHeaders,
castUiMode: _castUiMode,
qualities: DemoMedia.qualities,
currentQualityIndex: _qualityIndex,
episodes: _episodes,
currentEpisodeIndex: _episodeIndex,
danmakuItems: DemoMedia.danmakuItems,
speeds: DemoMedia.speeds,
onCreated: (c) async {
_appendLog('onCreated viewId=${c.viewId}');
final messenger = ScaffoldMessenger.of(context);
await _applyFullscreenLabels();
await _applyInitialPlaybackIndices();
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text('播放器已创建 viewId=${c.viewId}'),
),
);
_refreshStatus();
},
)
: const ColoredBox(
color: Colors.black,
child: Center(
child: Text(
'重建中…',
style: TextStyle(color: Colors.white70),
),
),
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: 32),
children: [
StatusQueryPanel(lines: _statusLines),
PlaybackControlsPanel(
volume: _volume,
muted: _muted,
looping: _looping,
cover: _cover,
selectedSpeed: _selectedSpeed,
qualityIndex: _qualityIndex,
episodeIndex: _episodeIndex,
qualities: DemoMedia.qualities,
episodes: _episodes,
currentSourceUrl: _videoUrl,
localReady: _localReady,
onPlay: controller.play,
onPause: controller.pause,
onSeekRelative: _seekRelative,
onSeekAbsolute: controller.seekTo,
onSpeed: (speed) async {
await controller.setSpeed(speed);
setState(() => _selectedSpeed = speed);
},
onVolume: (v) async {
await controller.setVolume(v);
setState(() => _volume = v);
},
onMuted: (v) async {
await controller.setMuted(v);
setState(() => _muted = v);
},
onLooping: (v) async {
await controller.setLooping(v);
setState(() => _looping = v);
},
onCover: (v) async {
await controller.setCover(v);
setState(() => _cover = v);
_appendLog('setCover cover=$v');
},
onSelectQuality: (i) async {
await controller.selectQuality(i);
_appendLog('selectQuality index=$i');
},
onSelectEpisode: (i) async {
await controller.selectEpisode(i);
setState(() => _episodeIndex = i);
_syncFullscreenSubtitleFromEpisode(i);
_appendLog('selectEpisode index=$i');
},
onQualityIndexChanged: (i) =>
setState(() => _qualityIndex = i),
onEpisodeIndexChanged: (i) =>
setState(() => _episodeIndex = i),
onSetCurrentQualityIndex: () async {
await controller.setCurrentQualityIndex(_qualityIndex);
_appendLog('setCurrentQualityIndex index=$_qualityIndex');
},
onSetCurrentEpisodeIndex: () async {
await controller.setCurrentEpisodeIndex(_episodeIndex);
_syncFullscreenSubtitleFromEpisode(_episodeIndex);
_appendLog(
'setCurrentEpisodeIndex index=$_episodeIndex',
);
},
onSetNetworkA: () => _setSource(DemoMedia.bee, label: 'bee'),
onSetNetworkB: () =>
_setSource(DemoMedia.butterfly, label: 'butterfly'),
onSetAuthFhd: _setAuthFhdSource,
onPlayEncrypted: _playEncryptedSample,
onSetLocalOceans: () {
final uri = _localOceansUri;
if (uri != null) {
_setSource(uri, label: 'local oceans');
}
},
onSetLocalTrailer: () {
final uri = _localTrailerUri;
if (uri != null) {
_setSource(uri, label: 'local trailer');
}
},
),
FullscreenPipPanel(
pipEnabled: _pipEnabled,
autoEnterPip: _autoEnterPip,
autoRotateFullscreen: _autoRotateFullscreen,
fullscreenTitleController: _fullscreenTitleController,
fullscreenSubtitleController: _fullscreenSubtitleController,
onEnterFullscreen: controller.enterFullscreen,
onExitFullscreen: controller.exitFullscreen,
onEnterPip: controller.enterPictureInPicture,
onExitPip: controller.exitPictureInPicture,
onApplyFullscreenLabels: () => _applyFullscreenLabels(),
onResetFullscreenSubtitle: () {
_syncFullscreenSubtitleFromEpisode(_episodeIndex);
_applyFullscreenLabels(applyTitle: false);
},
onPipEnabledChanged: (value) async {
await controller.setPictureInPictureEnabled(value);
if (mounted) setState(() => _pipEnabled = value);
},
onAutoEnterPipChanged: (value) async {
await controller.setAutoEnterPictureInPicture(value);
if (mounted) setState(() => _autoEnterPip = value);
},
onAutoRotateChanged: (value) async {
await controller.setAutoRotateFullscreen(value);
if (mounted) {
setState(() => _autoRotateFullscreen = value);
}
},
),
CastDemoPanel(
castUiMode: _castUiMode,
onCastUiModeChanged: (mode) =>
setState(() => _castUiMode = mode),
onOpenCastPage: _openCastPageManually,
),
WidgetParamsPanel(
cover: _cover,
looping: _looping,
autoPlay: _autoPlay,
showNativeControls: _showNativeControls,
syncSystemGestureDirection: _syncSystemGestureDirection,
enableInlineDoubleTap: _enableInlineDoubleTap,
enableInlineDrag: _enableInlineDrag,
useHttpHeaders: _useHttpHeaders,
trialEnabled: _trialEnabled,
initialPositionSeconds: _initialPositionSeconds,
hideShareButton: _hideShareButton,
showScreenshotButton: _showScreenshotButton,
onCover: (v) => setState(() => _cover = v),
onLooping: (v) => setState(() => _looping = v),
onAutoPlay: (v) => setState(() => _autoPlay = v),
onShowNativeControls: (v) =>
setState(() => _showNativeControls = v),
onSyncGesture: (v) =>
setState(() => _syncSystemGestureDirection = v),
onInlineDoubleTap: (v) =>
setState(() => _enableInlineDoubleTap = v),
onInlineDrag: (v) => setState(() => _enableInlineDrag = v),
onHttpHeaders: (v) => setState(() => _useHttpHeaders = v),
onTrial: (v) => setState(() => _trialEnabled = v),
onInitialPosition: (v) =>
setState(() => _initialPositionSeconds = v),
onHideShare: (v) => setState(() => _hideShareButton = v),
onShowScreenshot: (v) =>
setState(() => _showScreenshotButton = v),
onRebuild: _rebuildPlayer,
),
EventLogPanel(
lines: _eventLog,
onClear: () => setState(_eventLog.clear),
),
],
),
),
],
),
);
}
}