hm_video_player 0.7.0 copy "hm_video_player: ^0.7.0" to clipboard
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),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}
1
likes
135
points
180
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

跨平台视频播放器插件,支持全屏、画中画、投屏、倍速等功能。

Repository (GitHub)
View/report issues

Topics

#video-player #video #cast #picture-in-picture #fullscreen

License

MIT (license)

Dependencies

flutter

More

Packages that depend on hm_video_player

Packages that implement hm_video_player