omni_player 2.1.0 copy "omni_player: ^2.1.0" to clipboard
omni_player: ^2.1.0 copied to clipboard

Flutter媒体播放器插件,在Android/iOS上支持视频和音频播放和后台播放。支持MKV、MP4、HLS等.

example/lib/main.dart

import 'dart:convert';
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'video/player_page.dart';
import 'video/utils/video_item.dart';
import 'video/widgerts/resource_sheet.dart';

/// 示例接口 token。
const _apiToken =
    'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiIyMDE2NDQ5ODQ3ODc5NTM2NjQwIiwibmFtZSI6IjIwMTY0NDk4NDc4Nzk1MzY2NDAiLCJ0ZW5hbnRfaWQiOiIwIiwiZGVwdF9pZCI6IjE4NDM0OTE3NDA2ODI1NTUzOTIiLCJpYXQiOiIxNzc5Njc2MTcyIiwiaXAiOiIxMTcuMTc1LjE3Mi4xNjAiLCJkYXRhX3Njb3BlX3R5cGUiOiJBbGwsTXlTZWxmLE15U2VsZixNeVNlbGYsTXlTZWxmLE15U2VsZiIsInRva2VuX3ZlcnNpb24iOiIzMjQiLCJ0ZW5hbnQiOiJERUVQRU5HIiwibmJmIjoxNzc5Njc2MTcyLCJleHAiOjE3ODIyNjgxNzIsImlzcyI6Imh0dHA6Ly9sb2NhbGhvc3QiLCJhdWQiOiJodHRwOi8vbG9jYWxob3N0In0.Ux5XVKZ8VUzqftOs3MK2w6E5J8gbbKwQppFb11woe_A';

/// 资源文件域名。
const _resourceHost = 'https://wch-sh.oss-cn-shanghai.aliyuncs.com/';

/// 需要按顺序播放的资源文件 ID 列表。
const _resourceFileIds = <String>[
  '1896395518704095232',
];

void main() {
  // WidgetsFlutterBinding.ensureInitialized();
  // OmniPlayer.instance.initialize();
  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'OmniPlayer Demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorSchemeSeed: Colors.deepPurple,
        useMaterial3: true,
      ),
      home: const HomePage(token: _apiToken),
    );
  }
}

class HomePage extends StatefulWidget {
  /// 接口鉴权 token。
  final String? token;

  const HomePage({super.key, this.token});

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  /// 当前正在播放的资源 ID 下标。
  int _currentFileIdIndex = -1;

  /// 当前播放列表,用于在列表切换后刷新资源列表 UI。
  List<VideoItem> _videoList = [];

  /// 是否进入播放页
  bool _isPaly = false;

  /// 测试收藏数据,真实项目中可替换为接口返回的数据。
  final Set<String> _favoriteVideoUrls = <String>{};

  /// 当前播放视频是否已收藏,传给 PlayerPage 用于刷新收藏图标。
  final ValueNotifier<bool> _isCurrentVideoFavorite = ValueNotifier(false);

  /// 调用资源列表接口并转换为视频模型列表。
  Future<List<VideoItem>> _fetchVideoList({
    int type = 6,
    required String fileId,
    int pageIndex = 1,
    int pageSize = 1000,
    String classify = '',
    bool isLoginLog = false,
    String? token,
  }) async {
    final uri = Uri.https(
      'api.deepeng.maitianwai.top',
      '/apic/resource/resourcelist',
      {
        'type': '$type',
        'fileId': fileId,
        'pageIndex': '$pageIndex',
        'pageSize': '$pageSize',
        'classify': classify,
        'isLoginLog': '$isLoginLog',
      },
    );

    final client = HttpClient();
    try {
      final request = await client.getUrl(uri);
      if (token != null && token.isNotEmpty) {
        request.headers.set(HttpHeaders.authorizationHeader, token);
      }
      final response = await request.close();
      final body = await response.transform(utf8.decoder).join();

      if (response.statusCode < 200 || response.statusCode >= 300) {
        throw HttpException('请求失败:${response.statusCode}', uri: uri);
      }

      final data = jsonDecode(body);
      if (data is! List) return [];

      return data
          .whereType<Map<String, dynamic>>()
          .map(_createVideoItem)
          .toList();
    } finally {
      client.close(force: true);
    }
  }

  /// 将接口资源数据转换为播放模型,并在列表加载处处理资源地址。
  VideoItem _createVideoItem(Map<String, dynamic> map) {
    final cover = map['cover'] as String? ?? '';
    final videoPath = cover.replaceFirst(
      RegExp(r'\.jpg$', caseSensitive: false),
      '.mp4',
    );
    final srtPath = cover.replaceFirst(
      RegExp(r'\.jpg$', caseSensitive: false),
      '.srt',
    );

    return VideoItem(
      url: _buildResourceUrl(videoPath),
      cover: _buildResourceUrl(cover),
      title: map['name'] as String? ?? '',
      srt: _buildResourceUrl(srtPath),
    );
  }

  /// 拼接资源域名,完整地址直接返回。
  String _buildResourceUrl(String path) {
    if (path.isEmpty) return '';
    final uri = Uri.tryParse(path);
    if (uri != null && uri.hasScheme) return path;

    return Uri.parse(_resourceHost).resolve(path).toString();
  }

  /// 构建资源列表弹窗内容 Widget。
  ///
  /// 由调用方实现,传给 [PlayerPage.resourceSheetBuilder]。
  /// [currentIndex] 由调用方在 builder 闭包内动态读取,以确保高亮始终最新。
  Widget _buildResourceSheetChild(
    List<VideoItem> videoList,
    PlayerPageController playerController,
    int? currentIndex,
  ) {
    final scrollController = ItemScrollController();
    final isDark = MediaQuery.orientationOf(context) == Orientation.landscape;

    // 首帧后滚动到当前播放项
    if (currentIndex != null && currentIndex > 0) {
      WidgetsBinding.instance.addPostFrameCallback((_) {
        if (scrollController.isAttached) {
          scrollController.scrollTo(
            index: currentIndex,
            duration: const Duration(milliseconds: 350),
            curve: Curves.easeOut,
            alignment: 0.3,
          );
        }
      });
    }

    return ScrollablePositionedList.builder(
      itemScrollController: scrollController,
      padding: const EdgeInsets.fromLTRB(12, 10, 12, 16),
      itemCount: videoList.length + 1,
      itemBuilder: (context, index) {
        if (index >= videoList.length) {
          return Padding(
            padding: const EdgeInsets.symmetric(vertical: 16),
            child: Center(
              child: Text(
                '没有更多数据了',
                style: TextStyle(
                  fontSize: 13,
                  color: isDark ? Colors.white54 : Colors.grey,
                ),
              ),
            ),
          );
        }
        return _ResourceListItem(
          index: index,
          item: videoList[index],
          isPlaying: index == currentIndex,
          isDark: isDark,
          onTap: (ctx) {
            Navigator.of(ctx).pop();
            playerController.playAt(index);
          },
        );
      },
    );
  }

  /// 按资源 ID 下标加载播放列表。
  Future<List<VideoItem>?> _loadVideoListAt(int index) async {
    if (index < 0 || index >= _resourceFileIds.length) {
      return null;
    }

    final fileId = _resourceFileIds[index];
    debugPrint('加载资源列表 fileId=$fileId index=$index');
    final videoList = await _fetchVideoList(
      fileId: fileId,
      token: widget.token,
    );
    if (videoList.isEmpty) return null;
    _currentFileIdIndex = index;
    return videoList;
  }

  /// 当前播放列表完成后,加载下一个资源 ID 对应的播放列表。
  Future<List<VideoItem>?> _loadNextVideoList() async {
    final nextIndex = _currentFileIdIndex + 1;
    final videoList = await _loadVideoListAt(nextIndex);
    if (videoList == null) {
      debugPrint('测试消息:所有资源列表播放完成');
    }
    return videoList;
  }

  /// 翻译方法:由 HomePage 实现,传入 PlayerPage。
  /// 后期替换为真实 AI 翻译 API。
  Future<String?> _translate(String enText) async {
    // 模拟网络延迟
    await Future.delayed(const Duration(milliseconds: 600));
    return '【测试翻译】$enText';
  }

  /// 清晰朗读方法:由 HomePage 实现,传入 PlayerPage。
  /// 后期替换为真实 TTS API,返回音频 URL。
  Future<String?> _clearReading(String enText) async {
    // 测试音频地址
    return 'https://wch-sh.oss-cn-shanghai.aliyuncs.com/%E5%8D%95%E8%AF%8D%E5%8F%91%E9%9F%B3/uk/a/abasing.mp3';
  }

  /// 测试查询收藏状态接口。
  Future<bool> _queryFavoriteStatus(VideoItem video) async {
    await Future.delayed(const Duration(milliseconds: 300));
    return _favoriteVideoUrls.contains(video.url);
  }

  /// 测试添加收藏接口。
  Future<void> _addFavorite(VideoItem video) async {
    await Future.delayed(const Duration(milliseconds: 300));
    _favoriteVideoUrls.add(video.url);
  }

  /// 测试取消收藏接口。
  Future<void> _removeFavorite(VideoItem video) async {
    await Future.delayed(const Duration(milliseconds: 300));
    _favoriteVideoUrls.remove(video.url);
  }

  @override
  void dispose() {
    _isCurrentVideoFavorite.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('OmniPlayer'),
        centerTitle: true,
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(
              Icons.play_circle_outline,
              size: 80,
              color: Theme.of(context).colorScheme.primary,
            ),
            const SizedBox(height: 24),
            const Text(
              'Flutter 媒体播放器插件',
              style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 8),
            Text(
              '支持 MKV、MP4、HLS 等格式',
              style: TextStyle(fontSize: 14, color: Colors.grey[600]),
            ),
            const SizedBox(height: 32),
            FilledButton.icon(
              onPressed: () async {
                if (_isPaly) return;
                _isPaly = true;
                debugPrint(
                    '[Timing] >>> 点击"进入播放示例" ${DateTime.now().millisecondsSinceEpoch}ms');
                final videoList = await _loadVideoListAt(0);
                if (!context.mounted) return;
                if (videoList == null) return;
                final playerController = PlayerPageController();
                Navigator.push(
                  context,
                  MaterialPageRoute(
                    builder: (_) => PlayerPage(
                      videoList: videoList,
                      controller: playerController,
                      isFavoriteListenable: _isCurrentVideoFavorite,
                      // 列表播放完成事件
                      onPlaylistCompleted: () async {
                        debugPrint('[事件] 列表播放完成');
                        return _loadNextVideoList();
                      },
                      // 进入后台事件
                      onAppBackground: () {
                        debugPrint('[事件] 进入后台');
                      },
                      // 列表 - 标题
                      resourceListTitle: '资源列表(${videoList.length + 1})',
                      // 列表 - 弹窗内容由调用方实现
                      resourceSheetBuilder: (ctx, currentIndex) =>
                          _buildResourceSheetChild(
                        _videoList.isNotEmpty ? _videoList : videoList,
                        playerController,
                        currentIndex,
                      ),
                      // 单个视频播放完成事件
                      onVideoCompleted: (index, video) {
                        debugPrint(
                          '[事件] 视频播放完成 index=$index title=${video.title}',
                        );
                      },
                      // 退出播放页事件
                      onPageExit: (index, video) {
                        _isPaly = false;
                        debugPrint(
                          '[事件] 退出播放页 index=$index title=${video?.title ?? '未播放'}',
                        );
                      },
                      // 列表切换时刷新资源列表 UI
                      onPlaylistChanged: (newVideoList) {
                        setState(() {
                          _videoList = newVideoList;
                        });
                      },
                      // 当前视频加载事件:查询当前视频是否已收藏,并通知播放页刷新图标
                      onVideoLoaded: (index, video) async {
                        debugPrint(
                          '[事件] 加载视频 index=$index title=${video.title}',
                        );
                        final isFavorite = await _queryFavoriteStatus(video);
                        _isCurrentVideoFavorite.value = isFavorite;
                      },
                      // 收藏点击事件:根据当前收藏状态调用收藏/取消收藏接口
                      onFavoriteTap: (index, video, isFavorite) async {
                        debugPrint(
                          '[事件] 点击收藏 index=$index title=${video.title} isFavorite=$isFavorite',
                        );
                        if (isFavorite) {
                          await _removeFavorite(video);
                          _isCurrentVideoFavorite.value = false;
                        } else {
                          await _addFavorite(video);
                          _isCurrentVideoFavorite.value = true;
                        }
                      },
                      // 翻译回调:由 HomePage 实现
                      onTranslate: _translate,
                      // 清晰朗读回调:由 HomePage 实现
                      onClearReading: _clearReading,
                    ),
                  ),
                );
              },
              icon: const Icon(Icons.play_arrow),
              label: const Text('进入播放示例'),
              style: FilledButton.styleFrom(
                padding:
                    const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

/// 资源列表中的单行 Widget,对应截图中的样式。
class _ResourceListItem extends StatelessWidget {
  final int index;
  final VideoItem item;
  final bool isPlaying;
  final bool isDark;
  final void Function(BuildContext context)? onTap;

  const _ResourceListItem({
    required this.index,
    required this.item,
    required this.isPlaying,
    required this.isDark,
    this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    final backgroundColor = isPlaying
        ? Colors.blue.withValues(alpha: isDark ? 0.20 : 0.08)
        : isDark
            ? Colors.white.withValues(alpha: index % 2 == 0 ? 0.08 : 0.05)
            : index % 2 == 0
                ? Colors.white
                : const Color(0xFFF8F8F8);
    final titleColor = isDark ? Colors.white : Colors.black87;
    final subTextColor = isDark ? Colors.white54 : Colors.black54;

    return GestureDetector(
      onTap: onTap != null ? () => onTap!(context) : null,
      behavior: HitTestBehavior.opaque,
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 5),
        decoration: BoxDecoration(
          color: backgroundColor,
          borderRadius: BorderRadius.circular(16),
        ),
        clipBehavior: Clip.antiAlias,
        child: Stack(
          children: [
            if (isPlaying)
              const Positioned(
                left: 0,
                top: 0,
                bottom: 0,
                child: ColoredBox(
                  color: Colors.blue,
                  child: SizedBox(width: 4),
                ),
              ),
            Padding(
              padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.center,
                children: [
                  // 封面图
                  ClipRRect(
                    borderRadius: BorderRadius.circular(12),
                    child: Stack(
                      children: [
                        item.thumbnailCover.isNotEmpty
                            ? Image.network(
                                item.thumbnailCover,
                                width: 72,
                                height: 72,
                                fit: BoxFit.cover,
                                errorBuilder: (_, __, ___) =>
                                    _placeholderCover(),
                              )
                            : _placeholderCover(),
                        if (isDark)
                          Positioned.fill(
                            child: DecoratedBox(
                              decoration: BoxDecoration(
                                gradient: LinearGradient(
                                  begin: Alignment.topCenter,
                                  end: Alignment.bottomCenter,
                                  colors: [
                                    Colors.transparent,
                                    Colors.black.withValues(alpha: 0.28),
                                  ],
                                ),
                              ),
                            ),
                          ),
                      ],
                    ),
                  ),
                  const SizedBox(width: 12),
                  // 标题 + 元信息
                  Expanded(
                    child: Padding(
                      padding: EdgeInsets.only(right: isPlaying ? 30 : 0),
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Text(
                            item.title,
                            maxLines: 2,
                            overflow: TextOverflow.ellipsis,
                            style: TextStyle(
                              fontSize: 14,
                              height: 1.25,
                              fontWeight: FontWeight.w700,
                              color: titleColor,
                            ),
                          ),
                          const SizedBox(height: 10),
                          Wrap(
                            spacing: 14,
                            runSpacing: 6,
                            crossAxisAlignment: WrapCrossAlignment.center,
                            children: [
                              _ResourceMeta(
                                icon: Icons.chat_bubble_outline,
                                text: '0',
                                iconColor: Colors.orange,
                                textColor: subTextColor,
                              ),
                              _ResourceMeta(
                                icon: Icons.access_time,
                                text: '--:--',
                                iconColor: Colors.blue,
                                textColor: subTextColor,
                              ),
                              _ResourceMeta(
                                icon: Icons.play_circle_outline,
                                text: '--:--',
                                iconColor: Colors.green,
                                textColor: subTextColor,
                              ),
                            ],
                          ),
                        ],
                      ),
                    ),
                  ),
                ],
              ),
            ),
            if (isPlaying)
              const Positioned(
                right: 14,
                top: 0,
                bottom: 0,
                child: Center(child: PlayingEqualizer()),
              ),
          ],
        ),
      ), // Container
    ); // GestureDetector
  }

  Widget _placeholderCover() {
    return Container(
      width: 72,
      height: 72,
      decoration: BoxDecoration(
        color: isDark
            ? Colors.white.withValues(alpha: 0.08)
            : Colors.grey.shade200,
      ),
      child: Icon(
        Icons.movie,
        color: isDark ? Colors.white54 : Colors.grey,
      ),
    );
  }
}

class _ResourceMeta extends StatelessWidget {
  final IconData icon;
  final String text;
  final Color iconColor;
  final Color textColor;

  const _ResourceMeta({
    required this.icon,
    required this.text,
    required this.iconColor,
    required this.textColor,
  });

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        Icon(icon, size: 14, color: iconColor),
        const SizedBox(width: 3),
        Text(
          text,
          style: TextStyle(
            fontSize: 12,
            fontWeight: FontWeight.w500,
            color: textColor,
          ),
        ),
      ],
    );
  }
}
2
likes
110
points
96
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Flutter媒体播放器插件,在Android/iOS上支持视频和音频播放和后台播放。支持MKV、MP4、HLS等.

Homepage

License

MIT (license)

Dependencies

crypto, flutter, media_kit, media_kit_video, path_provider, plugin_platform_interface

More

Packages that depend on omni_player

Packages that implement omni_player