kinetic_player 2.0.2 copy "kinetic_player: ^2.0.2" to clipboard
kinetic_player: ^2.0.2 copied to clipboard

Multi-core Flutter video player: GSYVideoPlayer 13.1.0 (Android) + SGPlayer (iOS/macOS) + Artplayer.js (Web).

example/lib/main.dart

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:kinetic_player/kinetic_player.dart';
import 'package:path_provider/path_provider.dart';

import 'io_shim.dart' if (dart.library.html) 'io_shim_web.dart';

void main() {
  runApp(const KineticPlayerExampleApp());
}

class KineticPlayerExampleApp extends StatelessWidget {
  const KineticPlayerExampleApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Kinetic Player',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const PlayerDemoPage(),
    );
  }
}

class _DemoSource {
  const _DemoSource(this.label, this.url, {this.format = 'MP4'});

  final String label;
  final String url;
  /// Container / protocol tag shown in the source picker.
  final String format;

  String get menuLabel => '[$format] $label';
}

/// Demo media URLs — only sources that respond HTTP 200 in smoke checks.
class _DemoMedia {
  static final sources = <_DemoSource>[
    // Progressive — short clips first (also used as continuous playlist)
    const _DemoSource(
      'Sample 5s',
      'https://samplelib.com/lib/preview/mp4/sample-5s.mp4',
      format: 'MP4',
    ),
    const _DemoSource(
      'Flower',
      'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4',
      format: 'MP4',
    ),
    const _DemoSource(
      'Flower',
      'https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.webm',
      format: 'WebM',
    ),
    const _DemoSource(
      'Big Buck Bunny (短)',
      'https://www.w3schools.com/html/mov_bbb.mp4',
      format: 'MP4',
    ),
    const _DemoSource(
      'XGPlayer Demo 360p',
      'https://sf1-cdn-tos.huoshanstatic.com/obj/media-fe/xgplayer_doc_video/mp4/xgplayer-demo-360p.mp4',
      format: 'MP4',
    ),
    const _DemoSource(
      'Sintel Trailer',
      'https://media.w3.org/2010/05/sintel/trailer.mp4',
      format: 'MP4',
    ),
    const _DemoSource(
      'Big Buck Bunny',
      'https://cdn.jsdelivr.net/gh/mediaelement/mediaelement-files@master/big_buck_bunny.mp4',
      format: 'MP4',
    ),
    const _DemoSource(
      '640x360 sample',
      'https://filesamples.com/samples/video/mp4/sample_640x360.mp4',
      format: 'MP4',
    ),
    // Adaptive streaming
    const _DemoSource(
      'Apple BipBop fMP4',
      'https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_fmp4/master.m3u8',
      format: 'HLS',
    ),
    const _DemoSource(
      'Mux Big Buck Bunny',
      'https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8',
      format: 'HLS',
    ),
    const _DemoSource(
      'Tears of Steel VOD',
      'https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8',
      format: 'HLS',
    ),
    const _DemoSource(
      'XGPlayer HLS Demo',
      'https://sf1-cdn-tos.huoshanstatic.com/obj/media-fe/xgplayer_doc_video/hls/xgplayer-demo.m3u8',
      format: 'HLS',
    ),
    const _DemoSource(
      'Envivio MultiRate',
      'https://dash.akamaized.net/envivio/EnvivioDash3/manifest.mpd',
      format: 'DASH',
    ),
    const _DemoSource(
      'BBB 30fps MultiRate',
      'https://dash.akamaized.net/akamai/bbb_30fps/bbb_30fps.mpd',
      format: 'DASH',
    ),
    const _DemoSource(
      'Shaka Angel One',
      'https://storage.googleapis.com/shaka-demo-assets/angel-one/dash.mpd',
      format: 'DASH',
    ),
    // Other containers
    const _DemoSource(
      'ExoPlayer lavf sample',
      'https://storage.googleapis.com/exoplayer-test-media-1/mkv/android-screens-lavf-56.36.100-aac-avc-main-1280x720.mkv',
      format: 'MKV',
    ),
    const _DemoSource(
      'XGPlayer Demo 360p',
      'https://sf1-cdn-tos.huoshanstatic.com/obj/media-fe/xgplayer_doc_video/flv/xgplayer-demo-360p.flv',
      format: 'FLV',
    ),
  ];

  /// Short progressive clips for「播完切下一集」smoke tests.
  static List<_DemoSource> get continuousPlaylist => sources
      .where(
        (s) =>
            s.url.contains('sample-5s') ||
            s.url.contains('flower.mp4') ||
            s.url.contains('flower.webm') ||
            s.url.contains('mov_bbb') ||
            s.url.contains('xgplayer-demo-360p.mp4'),
      )
      .toList(growable: false);

  static List<String> get continuousPlaylistUrls =>
      continuousPlaylist.map((s) => s.url).toList(growable: false);

  /// Demo cover / poster image.
  static const coverUrl = 'https://www.gstatic.com/webp/gallery/1.jpg';

  /// Stable public JPEGs for demo seek-preview cues (avoid picsum 404/redirect).
  static const _previewImages = <String>[
    'https://www.gstatic.com/webp/gallery/1.jpg',
    'https://www.gstatic.com/webp/gallery/2.jpg',
    'https://www.gstatic.com/webp/gallery/3.jpg',
    'https://www.gstatic.com/webp/gallery/4.jpg',
  ];

  /// Writes a WebVTT track with public thumbnail URLs for seek preview.
  static Future<String> preparePreviewVttUri() async {
    final dir = await getTemporaryDirectory();
    final file = File('${dir.path}/kinetic_demo_preview_v2.vtt');
    await file.writeAsString(_buildPreviewVtt());
    return file.uri.toString();
  }

  static String _buildPreviewVtt() {
    final buffer = StringBuffer('WEBVTT\n\n');
    for (var i = 0; i < 12; i++) {
      final startSec = i * 5;
      final endSec = startSec + 5;
      buffer.writeln(
        '${_formatVttTime(startSec)} --> ${_formatVttTime(endSec)}',
      );
      buffer.writeln(_previewImages[i % _previewImages.length]);
      buffer.writeln();
    }
    return buffer.toString();
  }

  static String _formatVttTime(int totalSeconds) {
    final hours = (totalSeconds ~/ 3600).toString().padLeft(2, '0');
    final minutes = ((totalSeconds % 3600) ~/ 60).toString().padLeft(2, '0');
    final seconds = (totalSeconds % 60).toString().padLeft(2, '0');
    return '$hours:$minutes:$seconds.000';
  }

  static const _subtitleLines = <String>[
    'Kinetic Player 字幕示例',
    '外挂 WebVTT 字幕轨道',
    '拖动进度条可验证同步',
    '支持 SRT / WebVTT 文件',
    '也可用下方输入框推送字幕',
    'gsySetEmbeddedSubtitleText',
  ];

  /// Demo WebVTT subtitle track aligned to the sample video timeline.
  static Future<String> prepareSubtitleVttUri() async {
    final dir = await getTemporaryDirectory();
    final file = File('${dir.path}/kinetic_demo_subtitles.vtt');
    await file.writeAsString(_buildSubtitleVtt());
    return file.uri.toString();
  }

  static String _buildSubtitleVtt() {
    final buffer = StringBuffer('WEBVTT\n\n');
    for (var i = 0; i < _subtitleLines.length; i++) {
      final startSec = i * 8;
      final endSec = startSec + 8;
      buffer.writeln(
        '${_formatVttTime(startSec)} --> ${_formatVttTime(endSec)}',
      );
      buffer.writeln(_subtitleLines[i]);
      buffer.writeln();
    }
    return buffer.toString();
  }

  static const _danmakuColors = <int>[
    0xFFFFFF,
    0xFF5252,
    0xFFD740,
    0x69F0AE,
    0x40C4FF,
  ];

  static const _demoDanmaku = <_DanmakuCue>[
    _DanmakuCue(0, 'Kinetic Player 弹幕示例'),
    _DanmakuCue(2, 'DanmakuFlameMaster + B 站 XML'),
    _DanmakuCue(4, '从右向左滚动弹幕'),
    _DanmakuCue(6, '支持加载本地 XML 文件'),
    _DanmakuCue(8, '支持加载本地 XML 文件'),
    _DanmakuCue(10, 'gsySetDanmakuUrl / gsyToggleDanmaku'),
  ];

  /// Bilibili XML danmaku track for demo playback.
  static Future<String> prepareDanmakuXmlUri({
    List<_DanmakuCue> extra = const [],
  }) async {
    final dir = await getTemporaryDirectory();
    final file = File('${dir.path}/kinetic_demo_danmaku.xml');
    await file.writeAsString(_buildDanmakuXml([..._demoDanmaku, ...extra]));
    return file.uri.toString();
  }

  static String _buildDanmakuXml(List<_DanmakuCue> cues) {
    final buffer = StringBuffer(
      '<?xml version="1.0" encoding="UTF-8"?><i>',
    );
    for (var i = 0; i < cues.length; i++) {
      final cue = cues[i];
      final color = _danmakuColors[i % _danmakuColors.length];
      buffer.write(
        '<d p="${cue.timeSec.toStringAsFixed(1)},1,25,$color,0,0,0,0">'
        '${_escapeXml(cue.text)}</d>',
      );
    }
    buffer.write('</i>');
    return buffer.toString();
  }

  static String _escapeXml(String text) => text
      .replaceAll('&', '&amp;')
      .replaceAll('<', '&lt;')
      .replaceAll('>', '&gt;')
      .replaceAll('"', '&quot;');
}

class _DanmakuCue {
  const _DanmakuCue(this.timeSec, this.text);

  final double timeSec;
  final String text;
}

class PlayerDemoPage extends StatefulWidget {
  const PlayerDemoPage({super.key});

  @override
  State<PlayerDemoPage> createState() => _PlayerDemoPageState();
}

class _PlayerDemoPageState extends State<PlayerDemoPage> {
  String? _previewVttUri;
  CommonVideoController? _controller;
  _DemoSource _selectedSource = _DemoMedia.continuousPlaylist.first;
  bool _playlistReady = false;

  @override
  void initState() {
    super.initState();
    if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
      _DemoMedia.preparePreviewVttUri().then((uri) {
        if (mounted) setState(() => _previewVttUri = uri);
      });
    }
  }

  @override
  void dispose() {
    _controller?.dispose();
    super.dispose();
  }

  Future<void> _ensureContinuousPlaylist(CommonVideoController controller) async {
    if (_playlistReady) return;
    if (controller is! GSYVideoControllerImpl) {
      // Darwin / Web: still pass playlist via creationParams when supported.
      _playlistReady = true;
      return;
    }
    final urls = _DemoMedia.continuousPlaylistUrls;
    final idx = urls.indexOf(_selectedSource.url);
    await controller.gsySetPlaylist(urls, startIndex: idx < 0 ? 0 : idx);
    _playlistReady = true;
  }

  Future<void> _onSourceChanged(_DemoSource source) async {
    if (source.url == _selectedSource.url) return;
    final controller = _controller;
    final wasPlaying =
        controller?.playerState.value == CommonPlayerState.playing;
    setState(() => _selectedSource = source);
    if (controller == null) return;
    await controller.switchVideoSource(source.url, autoPlay: wasPlaying);
    // Keep native playlist index aligned; outside the continuous list → single-item playlist.
    if (controller is GSYVideoControllerImpl) {
      final urls = _DemoMedia.continuousPlaylistUrls;
      final idx = urls.indexOf(source.url);
      if (idx >= 0) {
        await controller.gsySetPlaylist(urls, startIndex: idx);
      } else {
        await controller.gsySetPlaylist([source.url], startIndex: 0);
      }
    }
  }

  Map<String, dynamic> _creationParams({
    required bool isAndroid,
    required bool isWeb,
  }) {
    final ui = isWeb
        ? ArtplayerUiConfig(
            ui: GsyUiConfig(
              enableNativeControls: true,
              showFullscreenButton: true,
              showVolumeToolbar: true,
              showSettingsButton: true,
              pictureInPictureEnabled: true,
              coverUrl: _DemoMedia.coverUrl,
              videoTitle: _selectedSource.label,
            ),
            artPlugins: const {
              ArtplayerPluginKeys.danmuku: true,
              ArtplayerPluginKeys.documentPip: true,
              ArtplayerPluginKeys.hlsControl: true,
              ArtplayerPluginKeys.dashControl: true,
              ArtplayerPluginKeys.vttThumbnail: true,
              ArtplayerPluginKeys.multipleSubtitles: true,
            },
          ).toCreationParams()
        : isAndroid
            ? GsyUiConfig(
                enableNativeControls: true,
                showFullscreenButton: true,
                showDragProgressTextOnSeekBar: true,
                pictureInPictureEnabled: true,
                // Large remote MKV: disable HttpProxyCache.
                cacheWithPlay: false,
                videoTitle: _selectedSource.label,
                previewVttUrl: _previewVttUri,
                coverUrl: _DemoMedia.coverUrl,
                keepLastFrameWhenComplete: false,
              ).toCreationParams()
            : GsyUiConfig(
                enableNativeControls: true,
                showFullscreenButton: true,
                showVolumeToolbar: true,
                showSettingsButton: true,
                coverUrl: _DemoMedia.coverUrl,
                keepLastFrameWhenComplete: false,
                videoTitle: _selectedSource.label,
              ).toCreationParams();

    // Seed continuous playlist so「播完切下一集」works out of the box.
    final urls = _DemoMedia.continuousPlaylistUrls;
    var start = urls.indexOf(_selectedSource.url);
    if (start < 0) start = 0;
    return <String, dynamic>{
      ...ui,
      'playlist': urls,
      'playlistStartIndex': start,
    };
  }

  @override
  Widget build(BuildContext context) {
    final isAndroid =
        !kIsWeb && defaultTargetPlatform == TargetPlatform.android;
    final previewReady = !isAndroid || _previewVttUri != null;

    return Scaffold(
      appBar: AppBar(
        title: const Text('Kinetic Player Demo'),
        actions: [
          if (isAndroid)
            IconButton(
              tooltip: '列表自动播放',
              onPressed: () {
                Navigator.of(context).push(
                  MaterialPageRoute<void>(
                    builder: (_) => const _AutoPlayListDemoPage(),
                  ),
                );
              },
              icon: const Icon(Icons.view_list),
            ),
        ],
      ),
      body: previewReady
          ? Column(
              children: [
                Expanded(
                  flex: 3,
                  child: CommonVideoPlayerViewBuilder(
                    url: _selectedSource.url,
                    creationParams: _creationParams(
                      isAndroid: isAndroid,
                      isWeb: kIsWeb,
                    ),
                    builder: (controller) {
                      if (!identical(_controller, controller)) {
                        setState(() => _controller = controller);
                        _ensureContinuousPlaylist(controller);
                      }
                    },
                  ),
                ),
                Expanded(
                  flex: 2,
                  child: SingleChildScrollView(
                    child: _ControlPanel(
                      controller: _controller,
                      selectedSource: _selectedSource,
                      onSourceChanged: _onSourceChanged,
                    ),
                  ),
                ),
              ],
            )
          : const Center(child: CircularProgressIndicator()),
    );
  }
}

class _ControlPanel extends StatefulWidget {
  const _ControlPanel({
    this.controller,
    required this.selectedSource,
    required this.onSourceChanged,
  });

  final CommonVideoController? controller;
  final _DemoSource selectedSource;
  final Future<void> Function(_DemoSource source) onSourceChanged;

  @override
  State<_ControlPanel> createState() => _ControlPanelState();
}

class _ControlPanelState extends State<_ControlPanel> {
  static const _renderCoreLabels = <GsyRenderCore, String>{
    GsyRenderCore.ijk: 'IJKPlayer',
    GsyRenderCore.exo: 'Media3 (Exo)',
    GsyRenderCore.system: 'System MediaPlayer',
  };

  static final ValueNotifier<CommonPlayerState> _idleState =
      ValueNotifier(CommonPlayerState.idle);
  static final ValueNotifier<Duration> _zeroDuration =
      ValueNotifier(Duration.zero);

  List<String> _filters = const ['none', 'sepia', 'gaussianBlur', 'greyScale'];
  String _selectedFilter = 'none';
  String? _subtitleVttUri;
  String? _danmakuXmlUri;
  bool _watermarkEnabled = false;
  bool _purePlay = false;
  bool _gifRecording = false;
  String? _lastCapturePath;
  String? _lastGifPath;
  String? _netSpeedText;
  int _renderRotation = 0;
  bool _keepLastFrame = false;
  bool _coverEnabled = true;
  GsyRenderCore _selectedRenderCore = GsyRenderCore.ijk;
  final TextEditingController _subtitleTextController = TextEditingController(
    text: 'Hello from Flutter — gsySetEmbeddedSubtitleText',
  );
  final TextEditingController _danmakuTextController = TextEditingController(
    text: 'Hello Danmaku!',
  );

  @override
  void initState() {
    super.initState();
    _loadFilters();
    if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
      _DemoMedia.prepareSubtitleVttUri().then((uri) {
        if (mounted) setState(() => _subtitleVttUri = uri);
      });
      _DemoMedia.prepareDanmakuXmlUri().then((uri) {
        if (mounted) setState(() => _danmakuXmlUri = uri);
      });
    }
  }

  @override
  void dispose() {
    _subtitleTextController.dispose();
    _danmakuTextController.dispose();
    super.dispose();
  }

  @override
  void didUpdateWidget(covariant _ControlPanel oldWidget) {
    super.didUpdateWidget(oldWidget);
    if (oldWidget.controller != widget.controller) {
      _renderRotation = 0;
      _keepLastFrame = false;
      _coverEnabled = true;
      _selectedRenderCore = GsyRenderCore.ijk;
      _loadFilters();
    }
  }

  Future<void> _onDemoSourceChanged(String? url) async {
    if (url == null || url == widget.selectedSource.url) return;
    final source = _DemoMedia.sources.firstWhere((s) => s.url == url);
    await widget.onSourceChanged(source);
  }

  Future<void> _onRenderCoreChanged(GsyRenderCore? core) async {
    if (core == null || core == _selectedRenderCore) return;
    final gsy = widget.controller;
    if (gsy is! GSYVideoControllerImpl) return;

    final wasPlaying = gsy.playerState.value == CommonPlayerState.playing;
    final pos = gsy.position.value;

    await gsy.gsySwitchRenderCore(core);
    await gsy.switchVideoSource(
      widget.selectedSource.url,
      autoPlay: wasPlaying,
    );
    if (pos > Duration.zero) {
      await gsy.seekTo(pos);
    }

    if (!mounted) return;
    setState(() => _selectedRenderCore = core);

    if (!mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text('已切换至 ${_renderCoreLabels[core]},视频已重新加载'),
      ),
    );
  }

  Future<void> _loadFilters() async {
    final gsy = widget.controller;
    if (gsy is! GSYVideoControllerImpl) return;
    final names = await gsy.gsyListEffectFilters();
    if (!mounted || names.isEmpty) return;
    setState(() {
      _filters = names;
      if (!_filters.contains(_selectedFilter)) {
        _selectedFilter = _filters.contains('sepia') ? 'sepia' : _filters.first;
      }
    });
  }

  Future<void> _onFilterChanged(String? value) async {
    if (value == null) return;
    final gsy = widget.controller;
    if (gsy is! GSYVideoControllerImpl) return;
    setState(() => _selectedFilter = value);
    if (value == 'none') {
      await gsy.gsySetEffectFilter('none');
      await gsy.gsySetRenderType(GsyRenderType.texture);
    } else {
      await gsy.gsySetRenderType(GsyRenderType.glSurface);
      await gsy.gsySetEffectFilter(value);
    }
  }

  Future<void> _loadWebVttSubtitles(GSYVideoControllerImpl gsy) async {
    final uri = _subtitleVttUri ?? await _DemoMedia.prepareSubtitleVttUri();
    if (!mounted) return;
    setState(() => _subtitleVttUri = uri);
    await gsy.gsySetSubtitleUrl(uri, mimeType: 'text/vtt');
    await gsy.gsySetSubtitleEnabled(enabled: true);
  }

  Future<void> _sendEmbeddedSubtitle(GSYVideoControllerImpl gsy) async {
    final text = _subtitleTextController.text.trim();
    if (text.isEmpty) {
      await gsy.gsySetEmbeddedSubtitleText(null);
    } else {
      await gsy.gsySetEmbeddedSubtitleText(text);
    }
    await gsy.gsySetSubtitleEnabled(enabled: true);
  }

  Future<void> _clearSubtitles(GSYVideoControllerImpl gsy) async {
    await gsy.gsySetEmbeddedSubtitleText(null);
    await gsy.gsySetSubtitleEnabled(enabled: false);
  }

  Future<void> _loadDemoDanmaku(GSYVideoControllerImpl gsy) async {
    final uri = await _DemoMedia.prepareDanmakuXmlUri();
    if (!mounted) return;
    setState(() => _danmakuXmlUri = uri);
    await gsy.gsySetDanmakuUrl(uri);
    await gsy.gsyToggleDanmaku(enabled: true);
  }

  Future<void> _toggleWatermark(GSYVideoControllerImpl gsy) async {
    final next = !_watermarkEnabled;
    await gsy.gsySetWatermarkUrl(next ? _DemoMedia.coverUrl : null);
    if (mounted) setState(() => _watermarkEnabled = next);
  }

  Future<void> _togglePurePlay(GSYVideoControllerImpl gsy) async {
    final next = !_purePlay;
    await gsy.gsySetPurePlayMode(enabled: next);
    if (mounted) setState(() => _purePlay = next);
  }

  Future<void> _playPreRollAd(GSYVideoControllerImpl gsy) async {
    final messenger = ScaffoldMessenger.of(context);
    await gsy.gsyPlayWithPreRollAd(
      adUrl: 'https://www.w3schools.com/html/mov_bbb.mp4',
      contentUrl: widget.selectedSource.url,
      skipAfter: const Duration(seconds: 3),
    );
    if (!mounted) return;
    messenger.showSnackBar(
      const SnackBar(content: Text('片头广告:约 3 秒后可跳过')),
    );
  }

  Future<void> _armMidRollAds(GSYVideoControllerImpl gsy) async {
    await gsy.gsySetMidRollAds([
      {
        'positionMs': 8000,
        'adUrl': 'https://www.w3schools.com/html/mov_bbb.mp4',
        'contentUrl': widget.selectedSource.url,
      },
    ]);
    if (!mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('已设置中插:约 8s 触发广告')),
    );
  }

  Future<void> _toggleGif(GSYVideoControllerImpl gsy) async {
    final messenger = ScaffoldMessenger.of(context);
    if (_gifRecording) {
      final path = await gsy.gsyStopGifRecording();
      if (!mounted) return;
      setState(() {
        _gifRecording = false;
        _lastGifPath = path;
      });
      messenger.showSnackBar(
        SnackBar(content: Text(path == null ? 'GIF 失败' : 'GIF: $path')),
      );
    } else {
      await gsy.gsyStartGifRecording();
      if (!mounted) return;
      setState(() => _gifRecording = true);
      messenger.showSnackBar(const SnackBar(content: Text('正在录制 GIF…')));
    }
  }

  Future<void> _refreshNetSpeed(GSYVideoControllerImpl gsy) async {
    final speed = await gsy.gsyGetNetSpeed();
    if (!mounted) return;
    setState(() => _netSpeedText = '${speed.text} (${speed.bytesPerSecond} B/s)');
  }

  Future<void> _rotateBy(int deltaDegrees) async {
    final next = (_renderRotation + deltaDegrees) % 360;
    final normalized = next < 0 ? next + 360 : next;
    final controller = widget.controller;
    if (controller is GSYVideoControllerImpl) {
      await controller.gsySetRenderRotation(normalized);
    } else if (controller is SGVideoControllerImpl) {
      await controller.sgSetRenderRotation(normalized);
    } else {
      return;
    }
    if (mounted) setState(() => _renderRotation = normalized);
  }

  Future<void> _toggleKeepLastFrame() async {
    final next = !_keepLastFrame;
    final controller = widget.controller;
    if (controller is GSYVideoControllerImpl) {
      await controller.gsySetKeepLastFrameWhenComplete(enabled: next);
    } else if (controller is SGVideoControllerImpl) {
      await controller.sgSetKeepLastFrameWhenComplete(enabled: next);
    } else {
      return;
    }
    if (mounted) setState(() => _keepLastFrame = next);
  }

  Future<void> _toggleCover() async {
    final next = !_coverEnabled;
    final url = next ? _DemoMedia.coverUrl : null;
    final controller = widget.controller;
    if (controller is GSYVideoControllerImpl) {
      await controller.gsySetCoverUrl(url);
    } else if (controller is SGVideoControllerImpl) {
      await controller.sgSetCoverUrl(url);
    } else {
      return;
    }
    if (mounted) setState(() => _coverEnabled = next);
  }

  @override
  Widget build(BuildContext context) {
    final active = widget.controller;
    final isAndroidGsy = active is GSYVideoControllerImpl;
    final isAppleSg = active is SGVideoControllerImpl;
    final isWebArt = active is ArtplayerVideoControllerImpl;
    final supportsTransform = isAndroidGsy || isAppleSg;
    final supportsCover = isAndroidGsy || isAppleSg;

    return Padding(
      padding: const EdgeInsets.all(12),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          ValueListenableBuilder<CommonPlayerState>(
            valueListenable: active?.playerState ?? _idleState,
            builder: (_, state, widget) => Text('State: $state'),
          ),
          ValueListenableBuilder<Duration>(
            valueListenable: active?.position ?? _zeroDuration,
            builder: (_, position, __) {
              final duration = active?.duration.value ?? Duration.zero;
              if (active is! SGVideoControllerImpl) {
                return Text('${_format(position)} / ${_format(duration)}');
              }
              return ValueListenableBuilder<Duration>(
                valueListenable: active.buffered,
                builder: (_, buffered, __) => Text(
                  '${_format(position)} / ${_format(duration)}  缓冲 ${_format(buffered)}',
                ),
              );
            },
          ),
          if (active is SGVideoControllerImpl) ...[
            ValueListenableBuilder<String?>(
              valueListenable: active.playerError,
              builder: (_, err, __) {
                if (err == null || err.isEmpty) {
                  return const SizedBox.shrink();
                }
                return Padding(
                  padding: const EdgeInsets.only(top: 4),
                  child: Text(
                    'Error: $err',
                    style:
                        const TextStyle(color: Colors.redAccent, fontSize: 12),
                  ),
                );
              },
            ),
          ],
          if (active != null) ...[
            const SizedBox(height: 12),
            const Text(
              '设置',
              style: TextStyle(fontWeight: FontWeight.w600),
            ),
            const SizedBox(height: 6),
            Row(
              children: [
                const Text('片源:'),
                const SizedBox(width: 8),
                Expanded(
                  child: DropdownButton<String>(
                    isExpanded: true,
                    value: widget.selectedSource.url,
                    items: [
                      for (final source in _DemoMedia.sources)
                        DropdownMenuItem(
                          value: source.url,
                          child: Text(
                            source.menuLabel,
                            overflow: TextOverflow.ellipsis,
                          ),
                        ),
                    ],
                    onChanged: _onDemoSourceChanged,
                  ),
                ),
              ],
            ),
            const SizedBox(height: 4),
            Text(
              widget.selectedSource.url,
              style: const TextStyle(fontSize: 11, color: Colors.black45),
              maxLines: 2,
              overflow: TextOverflow.ellipsis,
            ),
            const SizedBox(height: 4),
            const Text(
              '倍速 / 音量 / 循环 / 音轨 / 清晰度请用播放器原生底栏与设置面板。',
              style: TextStyle(fontSize: 12, color: Colors.black54),
            ),
          ],
          if (isAndroidGsy) ...[
            const SizedBox(height: 8),
            const Text(
              '播放内核',
              style: TextStyle(fontWeight: FontWeight.w600),
            ),
            const SizedBox(height: 6),
            Row(
              children: [
                const Text('内核:'),
                const SizedBox(width: 8),
                Expanded(
                  child: DropdownButton<GsyRenderCore>(
                    isExpanded: true,
                    value: _selectedRenderCore,
                    items: [
                      for (final core in GsyRenderCore.values)
                        DropdownMenuItem(
                          value: core,
                          child: Text(_renderCoreLabels[core]!),
                        ),
                    ],
                    onChanged: _onRenderCoreChanged,
                  ),
                ),
              ],
            ),
            const SizedBox(height: 4),
            const Text(
              '切换后会重新加载当前视频;IJK 内核下可配合 ijkEnableAccurateSeek 减轻拖动回弹。',
              style: TextStyle(fontSize: 12, color: Colors.black54),
            ),
            const SizedBox(height: 8),
            const Text(
              '播放中点击画面可唤出暂停按钮;GL 滤镜需从下拉框手动开启。'
              '播放时按 Home 或切到后台会自动进入画中画(API 26+)。',
              style: TextStyle(fontSize: 12, color: Colors.black54),
            ),
            const SizedBox(height: 4),
            Row(
              children: [
                const Text('GL 滤镜:'),
                const SizedBox(width: 8),
                Expanded(
                  child: DropdownButton<String>(
                    isExpanded: true,
                    value: _filters.contains(_selectedFilter)
                        ? _selectedFilter
                        : _filters.first,
                    items: [
                      for (final name in _filters)
                        DropdownMenuItem(value: name, child: Text(name)),
                    ],
                    onChanged: _onFilterChanged,
                  ),
                ),
              ],
            ),
          ],
          if (isAndroidGsy) ...[
            const SizedBox(height: 12),
            const Text(
              '字幕',
              style: TextStyle(fontWeight: FontWeight.w600),
            ),
            const SizedBox(height: 6),
            TextField(
              controller: _subtitleTextController,
              decoration: const InputDecoration(
                labelText: '推送字幕文本',
                border: OutlineInputBorder(),
                isDense: true,
              ),
              maxLines: 2,
            ),
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                FilledButton.tonal(
                  onPressed: _subtitleVttUri == null
                      ? null
                      : () => _loadWebVttSubtitles(active),
                  child: const Text('加载 WebVTT'),
                ),
                FilledButton.tonal(
                  onPressed: () => _sendEmbeddedSubtitle(active),
                  child: const Text('推送字幕文本'),
                ),
                FilledButton.tonal(
                  onPressed: () => _clearSubtitles(active),
                  child: const Text('清除字幕'),
                ),
              ],
            ),
            const SizedBox(height: 4),
            const Text(
              '开关字幕请用底栏 CC 图标。「加载 WebVTT」挂外挂轨;「推送字幕文本」走 gsySetEmbeddedSubtitleText。',
              style: TextStyle(fontSize: 12, color: Colors.black54),
            ),
            const SizedBox(height: 12),
            const Text(
              '弹幕',
              style: TextStyle(fontWeight: FontWeight.w600),
            ),
            const SizedBox(height: 6),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                FilledButton.tonal(
                  onPressed: _danmakuXmlUri == null
                      ? null
                      : () => _loadDemoDanmaku(active),
                  child: const Text('加载弹幕 XML'),
                ),
              ],
            ),
            const SizedBox(height: 4),
            const Text(
              '开关 / 发送弹幕请用底栏弹幕图标与输入框。此处仅演示加载 B 站 XML。',
              style: TextStyle(fontSize: 12, color: Colors.black54),
            ),
            const SizedBox(height: 12),
            const Text(
              'Android · 水印 / 广告 / GIF / 其它',
              style: TextStyle(fontWeight: FontWeight.w600),
            ),
            const SizedBox(height: 6),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                FilledButton.tonal(
                  onPressed: () => _toggleWatermark(active),
                  child: Text(_watermarkEnabled ? '水印: 开' : '水印: 关'),
                ),
                FilledButton.tonal(
                  onPressed: () => _togglePurePlay(active),
                  child: Text(_purePlay ? '纯播放: 开' : '纯播放: 关'),
                ),
                FilledButton.tonal(
                  onPressed: () => _playPreRollAd(active),
                  child: const Text('片头广告'),
                ),
                FilledButton.tonal(
                  onPressed: () => _armMidRollAds(active),
                  child: const Text('设置中插(8s)'),
                ),
                FilledButton.tonal(
                  onPressed: () => active.gsySkipAd(),
                  child: const Text('跳过广告'),
                ),
                FilledButton.tonal(
                  onPressed: () => _toggleGif(active),
                  child: Text(_gifRecording ? '停止 GIF' : '开始 GIF'),
                ),
                FilledButton.tonal(
                  onPressed: () async {
                    final ok = await active.gsyEnterPictureInPicture();
                    if (!context.mounted) return;
                    ScaffoldMessenger.of(context).showSnackBar(
                      SnackBar(content: Text(ok ? '已请求 PiP' : 'PiP 不可用')),
                    );
                  },
                  child: const Text('手动 PiP'),
                ),
                FilledButton.tonal(
                  onPressed: () async {
                    final ok = await active.gsyPlayNextInPlaylist();
                    if (!context.mounted) return;
                    ScaffoldMessenger.of(context).showSnackBar(
                      SnackBar(
                        content: Text(ok ? '已切下一首' : '已是最后一首'),
                      ),
                    );
                  },
                  child: const Text('播放下一首'),
                ),
                FilledButton.tonal(
                  onPressed: () => _refreshNetSpeed(active),
                  child: const Text('网速'),
                ),
                FilledButton.tonal(
                  onPressed: () => active.gsyReleaseAllVideos(),
                  child: const Text('释放全部'),
                ),
              ],
            ),
            if (_netSpeedText != null) ...[
              const SizedBox(height: 4),
              Text(
                '网速: $_netSpeedText',
                style: const TextStyle(fontSize: 12, color: Colors.black54),
              ),
            ],
            if (_lastGifPath != null) ...[
              const SizedBox(height: 4),
              Text(
                '最近 GIF: $_lastGifPath',
                style: const TextStyle(fontSize: 12, color: Colors.black54),
              ),
            ],
            const SizedBox(height: 4),
            const Text(
              '启动已注入短片 playlist;设置→更多→「播完切下一集」可验证连播。'
              '比例 / 镜像 / 循环请用原生设置。右上角可打开列表自动播 Demo。',
              style: TextStyle(fontSize: 12, color: Colors.black54),
            ),
          ],
          const SizedBox(height: 12),
          const Text(
            '公共 · 截图',
            style: TextStyle(fontWeight: FontWeight.w600),
          ),
          const SizedBox(height: 6),
          Wrap(
            spacing: 8,
            runSpacing: 8,
            children: [
              FilledButton.tonal(
                onPressed: active == null
                    ? null
                    : () async {
                        final messenger = ScaffoldMessenger.of(context);
                        final path = await active.captureFrame(
                          highQuality: true,
                          includeOverlay: isAndroidGsy,
                        );
                        if (!mounted) return;
                        setState(() => _lastCapturePath = path);
                        messenger.showSnackBar(
                          SnackBar(
                            content: Text(
                              path == null ? '截图失败' : '已保存: $path',
                            ),
                          ),
                        );
                      },
                child: const Text('截图 captureFrame'),
              ),
            ],
          ),
          if (_lastCapturePath != null) ...[
            const SizedBox(height: 4),
            Text(
              '最近截图: $_lastCapturePath',
              style: const TextStyle(fontSize: 12, color: Colors.black54),
            ),
          ],
          const SizedBox(height: 4),
          Text(
            isAndroidGsy
                ? '截图可用 includeOverlay。倍速/音量/循环请用原生底栏。'
                : isWebArt
                    ? 'Web:截图返回 data URL;倍速/音量用 Artplayer 控件。'
                    : '截图为临时 PNG。倍速/音量/循环请用原生底栏。',
            style: const TextStyle(fontSize: 12, color: Colors.black54),
          ),
          if (isWebArt) ...[
            const SizedBox(height: 12),
            const Text(
              'Web · Artplayer',
              style: TextStyle(fontWeight: FontWeight.w600),
            ),
            const SizedBox(height: 6),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                FilledButton.tonal(
                  onPressed: () async {
                    try {
                      await active.togglePip();
                    } catch (e) {
                      if (!context.mounted) return;
                      ScaffoldMessenger.of(context).showSnackBar(
                        SnackBar(content: Text('PiP: $e')),
                      );
                    }
                  },
                  child: const Text('Video PiP'),
                ),
                FilledButton.tonal(
                  onPressed: () async {
                    try {
                      await active.artToggleDocumentPip();
                    } catch (e) {
                      if (!context.mounted) return;
                      ScaffoldMessenger.of(context).showSnackBar(
                        SnackBar(content: Text('Document PiP: $e')),
                      );
                    }
                  },
                  child: const Text('Document PiP'),
                ),
                FilledButton.tonal(
                  onPressed: () async {
                    await active.artEmitDanmuku({
                      'text': _danmakuTextController.text.trim().isEmpty
                          ? 'Hello Web Danmaku'
                          : _danmakuTextController.text.trim(),
                      'time': active.position.value.inSeconds,
                    });
                  },
                  child: const Text('发送弹幕'),
                ),
                FilledButton.tonal(
                  onPressed: () async {
                    final keys = await active.artAvailablePlugins();
                    if (!context.mounted) return;
                    ScaffoldMessenger.of(context).showSnackBar(
                      SnackBar(content: Text('插件: ${keys.join(', ')}')),
                    );
                  },
                  child: const Text('可用插件'),
                ),
              ],
            ),
            const SizedBox(height: 6),
            TextField(
              controller: _danmakuTextController,
              decoration: const InputDecoration(
                labelText: 'Web 弹幕内容',
                border: OutlineInputBorder(),
                isDense: true,
              ),
            ),
            const SizedBox(height: 4),
            const Text(
              '创建已启用 danmuku / documentPip / hlsControl / dashControl 等插件。',
              style: TextStyle(fontSize: 12, color: Colors.black54),
            ),
          ],
          if (supportsTransform) ...[
            const SizedBox(height: 12),
            const Text(
              '旋转',
              style: TextStyle(fontWeight: FontWeight.w600),
            ),
            const SizedBox(height: 6),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                FilledButton.tonal(
                  onPressed: () => _rotateBy(-90),
                  child: const Text('左转 90°'),
                ),
                FilledButton.tonal(
                  onPressed: () => _rotateBy(90),
                  child: const Text('右转 90°'),
                ),
                FilledButton.tonal(
                  onPressed: () async {
                    final controller = widget.controller;
                    if (controller is GSYVideoControllerImpl) {
                      await controller.gsySetRenderRotation(0);
                    } else if (controller is SGVideoControllerImpl) {
                      await controller.sgSetRenderRotation(0);
                    }
                    if (mounted) setState(() => _renderRotation = 0);
                  },
                  child: const Text('复位 0°'),
                ),
              ],
            ),
            const SizedBox(height: 4),
            Text(
              '当前旋转 $_renderRotation°。镜像请用原生设置面板。'
              '${isAndroidGsy ? '(gsySetRenderRotation)' : '(sgSetRenderRotation)'}',
              style: const TextStyle(fontSize: 12, color: Colors.black54),
            ),
          ],
          if (supportsCover) ...[
            const SizedBox(height: 12),
            const Text(
              '封面 / 最后一帧',
              style: TextStyle(fontWeight: FontWeight.w600),
            ),
            const SizedBox(height: 6),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                FilledButton.tonal(
                  onPressed: _toggleCover,
                  child: Text(_coverEnabled ? '封面: 开' : '封面: 关'),
                ),
                FilledButton.tonal(
                  onPressed: _toggleKeepLastFrame,
                  child: Text(_keepLastFrame ? '保留最后一帧: 开' : '保留最后一帧: 关'),
                ),
              ],
            ),
            const SizedBox(height: 4),
            Text(
              _keepLastFrame
                  ? '播完停留在最后一帧(不盖封面)。'
                  : '播完显示封面;关闭封面时 Android 清空画面,iOS 显示黑底。',
              style: const TextStyle(fontSize: 12, color: Colors.black54),
            ),
          ],
          if (active is SGVideoControllerImpl) ...[
            const SizedBox(height: 12),
            const Text(
              'SGPlayer 高级',
              style: TextStyle(fontWeight: FontWeight.w600),
            ),
            const SizedBox(height: 6),
            Wrap(
              spacing: 8,
              runSpacing: 8,
              children: [
                FilledButton.tonal(
                  onPressed: () => active.sgSetPitch(1.25),
                  child: const Text('音高 1.25'),
                ),
                FilledButton.tonal(
                  onPressed: () => active.sgSetPitch(1.0),
                  child: const Text('音高复位'),
                ),
                FilledButton.tonal(
                  onPressed: () => active.sgSetDisplayMode(SgDisplayMode.vr),
                  child: const Text('VR'),
                ),
                FilledButton.tonal(
                  onPressed: () => active.sgSetDisplayMode(SgDisplayMode.vrBox),
                  child: const Text('VRBox'),
                ),
                FilledButton.tonal(
                  onPressed: () => active.sgSetDisplayMode(SgDisplayMode.plane),
                  child: const Text('平面'),
                ),
                FilledButton.tonal(
                  onPressed: () async {
                    await active.sgSetBackgroundPlaybackPolicy(
                      const SgBackgroundPlaybackPolicy(
                        pausesWhenEnteredBackground: false,
                      ),
                    );
                    if (!context.mounted) return;
                    ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(
                        content: Text(
                          '已开启后台继续播(需 Info.plist UIBackgroundModes=audio)',
                        ),
                      ),
                    );
                  },
                  child: const Text('后台继续播'),
                ),
                FilledButton.tonal(
                  onPressed: () => active.sgSetDemuxerOptions(
                    const SgDemuxerOptions(
                      timeout: Duration(seconds: 20),
                      reconnect: true,
                      userAgent: 'KineticPlayer-Example',
                    ),
                  ),
                  child: const Text('Demuxer 选项'),
                ),
                FilledButton.tonal(
                  onPressed: () async {
                    final tracks = await active.sgGetVideoTracks();
                    if (!context.mounted) return;
                    ScaffoldMessenger.of(context).showSnackBar(
                      SnackBar(content: Text('视频轨 ${tracks.length} 条')),
                    );
                  },
                  child: const Text('视频轨'),
                ),
                FilledButton.tonal(
                  onPressed: () async {
                    await active.sgReplaceWithSegments([
                      SgMediaSegment(url: widget.selectedSource.url),
                      const SgMediaSegment(
                        url: 'https://www.w3schools.com/html/mov_bbb.mp4',
                      ),
                    ]);
                    if (!context.mounted) return;
                    ScaffoldMessenger.of(context).showSnackBar(
                      const SnackBar(content: Text('已替换为两段资源')),
                    );
                  },
                  child: const Text('多段资源'),
                ),
                FilledButton.tonal(
                  onPressed: () => active.sgSetVrViewport(
                    const SgVrViewport(degrees: 75, sensorEnable: true),
                  ),
                  child: const Text('VR 视口'),
                ),
                FilledButton.tonal(
                  onPressed: () async {
                    final seekable = await active.sgIsSeekable();
                    if (!context.mounted) return;
                    ScaffoldMessenger.of(context).showSnackBar(
                      SnackBar(content: Text('可 seek: $seekable')),
                    );
                  },
                  child: const Text('是否可 Seek'),
                ),
              ],
            ),
            const SizedBox(height: 4),
            const Text(
              '缓冲/错误见上方。macOS 无滑动手势与后台策略;VR/sensor 请用真机。',
              style: TextStyle(fontSize: 12, color: Colors.black54),
            ),
          ],
          const SizedBox(height: 8),
          Wrap(
            spacing: 8,
            runSpacing: 8,
            children: [
              FilledButton(
                onPressed: active == null ? null : () => active.play(),
                child: const Text('Play'),
              ),
              FilledButton(
                onPressed: active == null ? null : () => active.pause(),
                child: const Text('Pause'),
              ),
              FilledButton(
                onPressed: active == null
                    ? null
                    : () => active.seekTo(const Duration(seconds: 10)),
                child: const Text('Seek 10s'),
              ),
            ],
          ),
          const SizedBox(height: 4),
          const Text(
            '全屏请用播放器底栏按钮。',
            style: TextStyle(fontSize: 12, color: Colors.black54),
          ),
        ],
      ),
    );
  }

  String _format(Duration value) {
    final minutes = value.inMinutes.remainder(60).toString().padLeft(2, '0');
    final seconds = value.inSeconds.remainder(60).toString().padLeft(2, '0');
    return '$minutes:$seconds';
  }
}

/// Android list auto-play demo (visibility-based; not detail seamless).
class _AutoPlayListDemoPage extends StatelessWidget {
  const _AutoPlayListDemoPage();

  @override
  Widget build(BuildContext context) {
    final urls = _DemoMedia.continuousPlaylistUrls;
    return Scaffold(
      appBar: AppBar(title: const Text('列表滑动自动播放')),
      body: GsyAutoPlayVideoList(
        urls: urls,
        aspectRatio: 16 / 9,
        coverBuilder: (context, index) {
          final label = _DemoMedia.continuousPlaylist[index].menuLabel;
          return ColoredBox(
            color: Colors.black,
            child: Center(
              child: Padding(
                padding: const EdgeInsets.all(12),
                child: Text(
                  label,
                  textAlign: TextAlign.center,
                  style: const TextStyle(color: Colors.white70),
                ),
              ),
            ),
          );
        },
      ),
    );
  }
}
0
likes
160
points
267
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Multi-core Flutter video player: GSYVideoPlayer 13.1.0 (Android) + SGPlayer (iOS/macOS) + Artplayer.js (Web).

Repository (GitHub)
View/report issues

License

MIT (license)

Dependencies

flutter, flutter_web_plugins, web

More

Packages that depend on kinetic_player

Packages that implement kinetic_player