Flutter离线模式下HLS(.m3u8)无法播放及本地下载播放方案
解决Flutter中HLS视频离线播放的问题
首先得搞清楚为什么你放在Assets里的.m3u8文件无法播放:HLS不是单一文件,它是一个索引文件,指向多个视频分片(.ts文件)和音频索引文件,你只把主m3u8放进Assets,缺少它依赖的子m3u8和所有分片文件,自然播放不了。而MP4是单个完整文件,所以Assets里的MP4能正常播放。
要实现HLS离线播放,你需要把整个HLS资源包(主m3u8、子m3u8、所有.ts分片、音频分片)下载到本地文件系统,然后修改索引文件中的路径指向本地文件,最后用本地文件路径加载播放。下面是具体的实现步骤和代码示例:
步骤1:准备依赖包
你需要用到几个工具包来简化开发:
dio:用于网络下载文件path_provider:获取本地存储目录chewie和video_player:播放视频(你已经在使用了)hls_parser(可选):帮助解析m3u8文件结构,避免手动解析的麻烦
在pubspec.yaml中添加依赖:
dependencies: flutter: sdk: flutter dio: ^5.3.0 path_provider: ^2.1.1 chewie: ^1.7.0 video_player: ^2.8.0 hls_parser: ^0.1.0 # 可选,用于解析m3u8
步骤2:解析HLS资源并下载所有文件
首先要解析主m3u8文件,提取所有需要下载的子文件链接,然后批量下载到本地目录。这里以你的示例m3u8为例,假设你有一个远程的主m3u8链接(或者先从Assets读取主m3u8内容,再解析依赖文件)。
示例代码:解析并下载HLS资源
import 'dart:io'; import 'package:dio/dio.dart'; import 'package:path_provider/path_provider.dart'; import 'package:hls_parser/hls_parser.dart'; class HlsDownloader { final Dio _dio = Dio(); // 下载HLS资源到本地 Future<String> downloadHls(String masterHlsUrl, String saveDirName) async { // 获取本地存储目录 final Directory appDocDir = await getApplicationDocumentsDirectory(); final String savePath = '${appDocDir.path}/$saveDirName'; await Directory(savePath).create(recursive: true); // 1. 下载主m3u8文件 final String masterM3u8Path = '$savePath/master.m3u8'; await _dio.download(masterHlsUrl, masterM3u8Path); // 2. 解析主m3u8,获取所有需要下载的子文件链接 final String masterM3u8Content = await File(masterM3u8Path).readAsString(); final HlsPlaylist playlist = HlsPlaylistParser.parseString(masterM3u8Content); // 提取所有子m3u8和音频m3u8的链接 final List<String> subUrls = []; if (playlist is HlsMasterPlaylist) { // 添加视频子流的m3u8链接 for (var variant in playlist.variants) { subUrls.add(variant.url); } // 添加音频流的m3u8链接 for (var audio in playlist.audioGroups.values.expand((e) => e)) { subUrls.add(audio.url); } } // 3. 下载所有子m3u8文件,以及它们依赖的.ts分片 for (String subUrl in subUrls) { // 子m3u8的本地保存路径 final String subFileName = subUrl.split('/').last; final String subM3u8Path = '$savePath/$subFileName'; await _dio.download('${masterHlsUrl.substring(0, masterHlsUrl.lastIndexOf('/'))}/$subUrl', subM3u8Path); // 解析子m3u8,下载所有.ts分片 final String subM3u8Content = await File(subM3u8Path).readAsString(); final HlsPlaylist subPlaylist = HlsPlaylistParser.parseString(subM3u8Content); if (subPlaylist is HlsMediaPlaylist) { for (var segment in subPlaylist.segments) { final String tsFileName = segment.url.split('/').last; final String tsPath = '$savePath/$tsFileName'; await _dio.download('${masterHlsUrl.substring(0, masterHlsUrl.lastIndexOf('/'))}/${segment.url}', tsPath); } } } // 4. 修改主m3u8中的路径为本地相对路径(因为所有文件都在同一个目录) String updatedMasterContent = masterM3u8Content; // 替换所有远程链接为本地文件名 for (String subUrl in subUrls) { final String fileName = subUrl.split('/').last; updatedMasterContent = updatedMasterContent.replaceAll(subUrl, fileName); } await File(masterM3u8Path).writeAsString(updatedMasterContent); // 返回本地主m3u8的路径 return masterM3u8Path; } }
步骤3:播放本地HLS文件
下载完成后,使用VideoPlayerController.file加载本地的m3u8文件,然后用Chewie播放:
修改你的MyHomePage代码
class MyHomePage extends StatefulWidget { @override _MyHomePageState createState() => _MyHomePageState(); } class _MyHomePageState extends State<MyHomePage> { String? localHlsPath; bool isDownloading = false; final HlsDownloader _downloader = HlsDownloader(); // 下载HLS资源 Future<void> _downloadHls() async { setState(() { isDownloading = true; }); try { // 替换成你的远程HLS主链接 final String hlsUrl = 'https://your-server-url/videos/f08e80da-bf1d-4e3d-8899-f0f6155f6efa.m3u8'; localHlsPath = await _downloader.downloadHls(hlsUrl, 'my_hls_video'); } catch (e) { print('下载失败: $e'); } finally { setState(() { isDownloading = false; }); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text('HLS 离线播放'), ), body: ListView( children: [ // 下载按钮 ElevatedButton( onPressed: isDownloading ? null : _downloadHls, child: isDownloading ? CircularProgressIndicator(color: Colors.white) : Text('下载HLS视频'), ), // 本地HLS播放 if (localHlsPath != null) Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: ChewieDemo( looping: true, videoPlayerController: VideoPlayerController.file( File(localHlsPath!), ), ), ), // 原MP4播放(保留) ChewieDemo( looping: true, videoPlayerController: VideoPlayerController.network( 'https://assets.mixkit.co/videos/preview/mixkit-forest-stream-in-the-sunlight-529-large.mp4', ), ) ], ), ); } } // 你的ChewieDemo组件(假设已经实现) class ChewieDemo extends StatefulWidget { final bool looping; final VideoPlayerController videoPlayerController; const ChewieDemo({ required this.looping, required this.videoPlayerController, Key? key, }) : super(key: key); @override _ChewieDemoState createState() => _ChewieDemoState(); } class _ChewieDemoState extends State<ChewieDemo> { late ChewieController _chewieController; @override void initState() { super.initState(); _chewieController = ChewieController( videoPlayerController: widget.videoPlayerController, autoPlay: false, looping: widget.looping, // 可添加更多配置,比如播放控件样式、静音等 ); } @override void dispose() { widget.videoPlayerController.dispose(); _chewieController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Chewie(controller: _chewieController); } }
关键注意事项
- 权限配置:
- Android:在
AndroidManifest.xml中添加存储权限(Android 13+需要READ_MEDIA_VIDEO,旧版本需要WRITE_EXTERNAL_STORAGE) - iOS:在
Info.plist中添加NSDocumentDirectoryUsageDescription,说明使用存储的原因
- Android:在
- 路径处理:确保所有下载的文件都在同一个目录,并且m3u8中的路径修改为相对路径或者绝对路径,避免播放时找不到文件
- 错误处理:实际开发中要添加更多错误捕获,比如网络失败、文件写入失败等情况
- 分片数量:如果HLS包含大量分片,考虑添加下载进度显示,提升用户体验
内容的提问来源于stack exchange,提问作者Pravin Kumar
相关产品推荐
相关产品推荐

