├── .github ├── dependabot.yml └── workflows │ └── flutter-test.yml ├── .gitignore ├── .metadata ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── example ├── .gitignore ├── .metadata ├── README.md ├── lib │ ├── main.dart │ └── samples.dart └── pubspec.yaml ├── lib ├── flutter_hls_parser.dart └── src │ ├── drm_init_data.dart │ ├── exception.dart │ ├── extension.dart │ ├── format.dart │ ├── hls_master_playlist.dart │ ├── hls_media_playlist.dart │ ├── hls_playlist_parser.dart │ ├── hls_track_metadata_entry.dart │ ├── metadata.dart │ ├── mime_types.dart │ ├── playlist.dart │ ├── rendition.dart │ ├── scheme_data.dart │ ├── segment.dart │ ├── util.dart │ ├── variant.dart │ └── variant_info.dart ├── pubspec.yaml └── test ├── hls_master_playlist_parser_test.dart ├── hls_media_playlist_parser_test.dart ├── mime_types_test.dart └── util_test.dart /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: weekly -------------------------------------------------------------------------------- /.github/workflows/flutter-test.yml: -------------------------------------------------------------------------------- 1 | name: FlutterTest 2 | 3 | # This workflow is triggered on pushes to the repository. 4 | 5 | on: 6 | pull_request: 7 | 8 | jobs: 9 | flutter_test: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 10 12 | steps: 13 | - uses: actions/checkout@v3 14 | with: 15 | fetch-depth: 1 16 | - uses: subosito/flutter-action@v2.8.0 17 | with: 18 | channel: 'stable' 19 | - run: flutter pub get 20 | - run: flutter test --coverage --coverage-path=~/coverage/lcov.info 21 | - uses: codecov/codecov-action@v3.1.2 22 | with: 23 | token: ${{secrets.CODECOV_TOKEN}} 24 | file: ~/coverage/lcov.info 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .dart_tool/ 3 | 4 | .packages 5 | .pub/ 6 | 7 | build/ 8 | ### Dart template 9 | # See https://www.dartlang.org/guides/libraries/private-files 10 | 11 | # Files and directories created by pub 12 | .dart_tool/ 13 | .packages 14 | build/ 15 | # If you're building an application, you may want to check-in your pubspec.lock 16 | pubspec.lock 17 | 18 | # Directory created by dartdoc 19 | # If you don't generate documentation locally you can remove this line. 20 | doc/api/ 21 | 22 | # Avoid committing generated Javascript files: 23 | *.dart.js 24 | *.info.json # Produced by the --dump-info flag. 25 | *.js # When generated by dart2js. Don't specify *.js if your 26 | # project includes source files written in JavaScript. 27 | *.js_ 28 | *.js.deps 29 | *.js.map 30 | 31 | ### JetBrains template 32 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 33 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 34 | 35 | # User-specific stuff 36 | .idea/**/workspace.xml 37 | .idea/**/tasks.xml 38 | .idea/**/usage.statistics.xml 39 | .idea/**/dictionaries 40 | .idea/**/shelf 41 | 42 | # Generated files 43 | .idea/**/contentModel.xml 44 | 45 | # Sensitive or high-churn files 46 | .idea/**/dataSources/ 47 | .idea/**/dataSources.ids 48 | .idea/**/dataSources.local.xml 49 | .idea/**/sqlDataSources.xml 50 | .idea/**/dynamic.xml 51 | .idea/**/uiDesigner.xml 52 | .idea/**/dbnavigator.xml 53 | 54 | # Gradle 55 | .idea/**/gradle.xml 56 | .idea/**/libraries 57 | 58 | # Gradle and Maven with auto-import 59 | # When using Gradle or Maven with auto-import, you should exclude module files, 60 | # since they will be recreated, and may cause churn. Uncomment if using 61 | # auto-import. 62 | # .idea/modules.xml 63 | # .idea/*.iml 64 | # .idea/modules 65 | # *.iml 66 | # *.ipr 67 | 68 | # CMake 69 | cmake-build-*/ 70 | 71 | # Mongo Explorer plugin 72 | .idea/**/mongoSettings.xml 73 | 74 | # File-based project format 75 | *.iws 76 | 77 | # IntelliJ 78 | out/ 79 | 80 | # mpeltonen/sbt-idea plugin 81 | .idea_modules/ 82 | 83 | # JIRA plugin 84 | atlassian-ide-plugin.xml 85 | 86 | # Cursive Clojure plugin 87 | .idea/replstate.xml 88 | 89 | # Crashlytics plugin (for Android Studio and IntelliJ) 90 | com_crashlytics_export_strings.xml 91 | crashlytics.properties 92 | crashlytics-build.properties 93 | fabric.properties 94 | 95 | # Editor-based Rest Client 96 | .idea/httpRequests 97 | 98 | # Android studio 3.1+ serialized cache file 99 | .idea/caches/build_file_checksums.ser 100 | 101 | ### Xcode template 102 | # Xcode 103 | # 104 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 105 | 106 | ## User settings 107 | xcuserdata/ 108 | 109 | ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) 110 | *.xcscmblueprint 111 | *.xccheckout 112 | 113 | ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) 114 | build/ 115 | DerivedData/ 116 | *.moved-aside 117 | *.pbxuser 118 | !default.pbxuser 119 | *.mode1v3 120 | !default.mode1v3 121 | *.mode2v3 122 | !default.mode2v3 123 | *.perspectivev3 124 | !default.perspectivev3 125 | 126 | ## Xcode Patch 127 | *.xcodeproj/* 128 | !*.xcodeproj/project.pbxproj 129 | !*.xcodeproj/xcshareddata/ 130 | !*.xcworkspace/contents.xcworkspacedata 131 | /*.gcno 132 | 133 | ### Android template 134 | # Built application files 135 | *.apk 136 | *.ap_ 137 | *.aab 138 | 139 | # Files for the ART/Dalvik VM 140 | *.dex 141 | 142 | # Java class files 143 | *.class 144 | 145 | # Generated files 146 | bin/ 147 | gen/ 148 | out/ 149 | release/ 150 | 151 | # Gradle files 152 | .gradle/ 153 | build/ 154 | 155 | # Local configuration file (sdk path, etc) 156 | local.properties 157 | 158 | # Proguard folder generated by Eclipse 159 | proguard/ 160 | 161 | # Log Files 162 | *.log 163 | 164 | # Android Studio Navigation editor temp files 165 | .navigation/ 166 | 167 | # Android Studio captures folder 168 | captures/ 169 | 170 | # IntelliJ 171 | *.iml 172 | .idea/workspace.xml 173 | .idea/tasks.xml 174 | .idea/gradle.xml 175 | .idea/assetWizardSettings.xml 176 | .idea/dictionaries 177 | .idea/libraries 178 | # Android Studio 3 in .gitignore file. 179 | .idea/caches 180 | .idea/modules.xml 181 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you 182 | .idea/navEditor.xml 183 | 184 | # Keystore files 185 | # Uncomment the following lines if you do not want to check your keystore files in. 186 | #*.jks 187 | #*.keystore 188 | 189 | # External native build folder generated in Android Studio 2.2 and later 190 | .externalNativeBuild 191 | 192 | # Google Services (e.g. APIs or Firebase) 193 | # google-services.json 194 | 195 | # Freeline 196 | freeline.py 197 | freeline/ 198 | freeline_project_description.json 199 | 200 | # fastlane 201 | fastlane/report.xml 202 | fastlane/Preview.html 203 | fastlane/screenshots 204 | fastlane/test_output 205 | fastlane/readme.md 206 | 207 | # Version control 208 | vcs.xml 209 | 210 | # lint 211 | lint/intermediates/ 212 | lint/generated/ 213 | lint/outputs/ 214 | lint/tmp/ 215 | # lint/reports/ 216 | 217 | ### Objective-C template 218 | # Xcode 219 | # 220 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 221 | 222 | ## Build generated 223 | build/ 224 | DerivedData/ 225 | 226 | ## Various settings 227 | *.pbxuser 228 | !default.pbxuser 229 | *.mode1v3 230 | !default.mode1v3 231 | *.mode2v3 232 | !default.mode2v3 233 | *.perspectivev3 234 | !default.perspectivev3 235 | xcuserdata/ 236 | 237 | ## Other 238 | *.moved-aside 239 | *.xccheckout 240 | *.xcscmblueprint 241 | 242 | ## Obj-C/Swift specific 243 | *.hmap 244 | *.ipa 245 | *.dSYM.zip 246 | *.dSYM 247 | 248 | # CocoaPods 249 | # 250 | # We recommend against adding the Pods directory to your .gitignore. However 251 | # you should judge for yourself, the pros and cons are mentioned at: 252 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 253 | # 254 | # Pods/ 255 | # 256 | # Add this line if you want to avoid checking in source code from the Xcode workspace 257 | # *.xcworkspace 258 | 259 | # Carthage 260 | # 261 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 262 | # Carthage/Checkouts 263 | 264 | Carthage/Build 265 | 266 | # fastlane 267 | # 268 | # It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the 269 | # screenshots whenever they are needed. 270 | # For more information about the recommended setup visit: 271 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 272 | 273 | fastlane/report.xml 274 | fastlane/Preview.html 275 | fastlane/screenshots/**/*.png 276 | fastlane/test_output 277 | 278 | # Code Injection 279 | # 280 | # After new code Injection tools there's a generated folder /iOSInjectionProject 281 | # https://github.com/johnno1962/injectionforxcode 282 | 283 | iOSInjectionProject/ 284 | 285 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 68587a0916366e9512a78df22c44163d041dd5f3 8 | channel: stable 9 | 10 | project_type: plugin 11 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 2.0.1 2 | * code clean up 3 | * remove deprecated code 4 | 5 | ## 2.0.0 6 | * initial stable release for null-safety 7 | 8 | ## 2.0.0-nullsafety.0 9 | * delete UnrecognizedInputFormatException 10 | * initial release for null-safety 11 | 12 | ## 1.0.0 13 | 14 | * reformat code 15 | * remove verbose dependencies 16 | * add `parseString` method 17 | 18 | ## 0.0.1 19 | 20 | * initial release 21 | 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flutter_hls_parser 2 | 3 | [![Pub Version](https://img.shields.io/pub/v/flutter_hls_parser)](https://pub.dev/packages/flutter_hls_parser) 4 | [![codecov](https://codecov.io/gh/HiroyukTamura/flutter_hls_parser/branch/master/graph/badge.svg?token=ExYmJJIAVX)](https://codecov.io/gh/HiroyukTamura/flutter_hls_parser) 5 | [![FlutterTest](https://github.com/HiroyukTamura/flutter_hls_parser/actions/workflows/flutter-test.yml/badge.svg)](https://github.com/HiroyukTamura/flutter_hls_parser/actions/workflows/flutter-test.yml) 6 | 7 | dart plugin for parse m3u8 file for HLS. 8 | both of master and media file are supported. 9 | 10 | ## Getting Started 11 | 12 | ```dart 13 | 14 | Uri playlistUri; 15 | try { 16 | playList = await HlsPlaylistParser.create().parseString(playlistUri, contentString); 17 | } on ParserException catch (e) { 18 | print(e); 19 | } 20 | 21 | if (playlist is HlsMasterPlaylist) { 22 | // master m3u8 file 23 | } else if (playlist is HlsMediaPlaylist) { 24 | // media m3u8 file 25 | } 26 | ``` 27 | 28 | ## Supported tags 29 | ``` 30 | EXTM3U 31 | EXT-X-VERSION 32 | EXT-X-PLAYLIST-TYPE 33 | EXT-X-DEFINE 34 | EXT-X-STREAM-INF 35 | EXT-X-MEDIA 36 | EXT-X-TARGETDURATION 37 | EXT-X-DISCONTINUITY 38 | EXT-X-DISCONTINUITY-SEQUENCE 39 | EXT-X-PROGRAM-DATE-TIME 40 | EXT-X-MAP 41 | EXT-X-INDEPENDENT-SEGMENTS 42 | EXTINF 43 | EXT-X-MEDIA-SEQUENCE 44 | EXT-X-START 45 | EXT-X-ENDLIST 46 | EXT-X-KEY 47 | EXT-X-SESSION-KEY 48 | EXT-X-BYTERANGE 49 | EXT-X-GAP 50 | ``` 51 | 52 | ## Not Supported Tags 53 | ``` 54 | EXT-X-I-FRAMES-ONLY 55 | EXT-X-I-FRAME-STREAM-INF 56 | EXT-X-ALLOW-CACHE 57 | EXT-X-SESSION-DATA 58 | EXT-X-DATERANGE 59 | EXT-X-BITRATE 60 | EXT-X-SERVER-CONTROL 61 | EXT-X-CUE-OUT: 62 | EXT-X-CUE-IN 63 | ``` 64 | 65 | ### Note 66 | all bool param is nonnull, and others are often nullable if unknown. 67 | 68 | ### MasterPlaylist example 69 | ```dart 70 | HlsMasterPlaylist playlist; 71 | 72 | playlist.variants[0].format.bitrate;// => 1280000 73 | Util.splitCodec(playlist.variants[0].format.codecs);// => ['mp4a.40.2']['avc1.66.30'] 74 | playlist.variants[0].format.width;// => 304(px) 75 | playlist.subtitles[0].format.id;// => sub1:Eng 76 | playlist.audios[0].format.sampleMimeType// => MimeTypes.AUDIO_AC3 77 | ``` 78 | 79 | ### MediaPlaylist example 80 | ```dart 81 | HlsMediaPlaylist playlist; 82 | 83 | playlist.version;// => 3 84 | playlist.hasEndTag;// => true 85 | playlist.segments[0].durationUs;// => 7975000(microsec) 86 | playlist.segments[0].encryptionIV;// => '0x1566B' 87 | ``` 88 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:pedantic/analysis_options.yaml 2 | 3 | analyzer: 4 | exclude: 5 | - "bin/cache/**" 6 | 7 | linter: 8 | rules: 9 | curly_braces_in_flow_control_structures: false -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | 23 | # Flutter/Dart/Pub related 24 | **/doc/api/ 25 | .dart_tool/ 26 | .flutter-plugins 27 | .packages 28 | .pub-cache/ 29 | .pub/ 30 | /build/ 31 | 32 | # Android related 33 | **/android/**/gradle-wrapper.jar 34 | **/android/.gradle 35 | **/android/captures/ 36 | **/android/gradlew 37 | **/android/gradlew.bat 38 | **/android/local.properties 39 | **/android/**/GeneratedPluginRegistrant.java 40 | 41 | # iOS/XCode related 42 | **/ios/**/*.mode1v3 43 | **/ios/**/*.mode2v3 44 | **/ios/**/*.moved-aside 45 | **/ios/**/*.pbxuser 46 | **/ios/**/*.perspectivev3 47 | **/ios/**/*sync/ 48 | **/ios/**/.sconsign.dblite 49 | **/ios/**/.tags* 50 | **/ios/**/.vagrant/ 51 | **/ios/**/DerivedData/ 52 | **/ios/**/Icon? 53 | **/ios/**/Pods/ 54 | **/ios/**/.symlinks/ 55 | **/ios/**/profile 56 | **/ios/**/xcuserdata 57 | **/ios/.generated/ 58 | **/ios/Flutter/App.framework 59 | **/ios/Flutter/Flutter.framework 60 | **/ios/Flutter/Generated.xcconfig 61 | **/ios/Flutter/app.flx 62 | **/ios/Flutter/app.zip 63 | **/ios/Flutter/flutter_assets/ 64 | **/ios/Flutter/flutter_export_environment.sh 65 | **/ios/ServiceDefinitions.json 66 | **/ios/Runner/GeneratedPluginRegistrant.* 67 | 68 | # Exceptions to above rules. 69 | !**/ios/**/default.mode1v3 70 | !**/ios/**/default.mode2v3 71 | !**/ios/**/default.pbxuser 72 | !**/ios/**/default.perspectivev3 73 | !/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages 74 | /test/ 75 | -------------------------------------------------------------------------------- /example/.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 68587a0916366e9512a78df22c44163d041dd5f3 8 | channel: stable 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | # flutter_hls_parser_example 2 | 3 | Demonstrates how to use the flutter_hls_parser plugin. 4 | 5 | ## Getting Started 6 | 7 | This project is a starting point for a Flutter application. 8 | 9 | A few resources to get you started if this is your first Flutter project: 10 | 11 | - [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab) 12 | - [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) 13 | 14 | For help getting started with Flutter, view our 15 | [online documentation](https://flutter.dev/docs), which offers tutorials, 16 | samples, guidance on mobile development, and a full API reference. 17 | -------------------------------------------------------------------------------- /example/lib/main.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_hls_parser/flutter_hls_parser.dart'; 3 | import 'package:flutter_hls_parser_example/samples.dart'; 4 | 5 | void main() => runApp(const _MyApp()); 6 | 7 | class _MyApp extends StatefulWidget { 8 | const _MyApp(); 9 | 10 | @override 11 | State<_MyApp> createState() => _MyAppState(); 12 | } 13 | 14 | class _MyAppState extends State<_MyApp> with SingleTickerProviderStateMixin { 15 | static const _kTabList = ['master sample', 'media sample']; 16 | 17 | late TabController _tabController; 18 | 19 | @override 20 | void initState() { 21 | super.initState(); 22 | _tabController = TabController(length: _kTabList.length, vsync: this); 23 | } 24 | 25 | @override 26 | void dispose() { 27 | super.dispose(); 28 | _tabController.dispose(); 29 | } 30 | 31 | @override 32 | Widget build(BuildContext context) => MaterialApp( 33 | home: Scaffold( 34 | appBar: AppBar( 35 | title: Text('SAMPLE'), 36 | bottom: TabBar( 37 | isScrollable: true, 38 | tabs: _kTabList.map((it) => Tab(text: it)).toList(), 39 | controller: _tabController, 40 | indicatorColor: Colors.white, 41 | ), 42 | ), 43 | body: SafeArea( 44 | child: TabBarView( 45 | controller: _tabController, 46 | children: [ 47 | SingleChildScrollView( 48 | padding: EdgeInsets.all(16), 49 | child: const Content(), 50 | ), 51 | SingleChildScrollView( 52 | padding: EdgeInsets.all(16), 53 | child: const Content2nd(), 54 | ), 55 | ], 56 | ), 57 | ), 58 | ), 59 | ); 60 | } 61 | 62 | class Content extends StatelessWidget { 63 | const Content({Key? key}) : super(key: key); 64 | 65 | @override 66 | Widget build(BuildContext context) => Column( 67 | crossAxisAlignment: CrossAxisAlignment.center, 68 | children: [ 69 | Container( 70 | color: Colors.black12, 71 | child: Padding( 72 | padding: EdgeInsets.all(16), 73 | child: Text(SAMPLE_MASTER), 74 | ), 75 | ), 76 | SizedBox(height: 32), 77 | Container( 78 | child: ElevatedButton( 79 | child: Text('PARSE!', textDirection: TextDirection.ltr), 80 | onPressed: () async { 81 | final playList = await HlsPlaylistParser.create() 82 | .parseString(Uri.parse(PLAYLIST_URI), SAMPLE_MASTER); 83 | playList as HlsMasterPlaylist; 84 | 85 | final mediaPlaylistUrls = playList.mediaPlaylistUrls; 86 | final codecs = playList.variants.map((it) => it.format.codecs); 87 | final frameRates = 88 | playList.variants.map((it) => it.format.frameRate); 89 | final bandWidth = 90 | playList.variants.map((it) => it.format.bitrate); 91 | 92 | showDialog( 93 | context: context, 94 | builder: (context) => AlertDialog( 95 | content: SingleChildScrollView( 96 | child: Column( 97 | crossAxisAlignment: CrossAxisAlignment.start, 98 | children: [ 99 | DialogHeading(text: 'media playlist uri'), 100 | ...mediaPlaylistUrls.map( 101 | (it) => Text(it.toString()), 102 | ), 103 | DialogHeading(text: 'codec'), 104 | ...codecs.map( 105 | (it) => Text(it.toString()), 106 | ), 107 | DialogHeading(text: 'frame rate'), 108 | ...frameRates.map( 109 | (it) => Text(it.toString()), 110 | ), 111 | DialogHeading(text: 'band width'), 112 | ...bandWidth.map( 113 | (it) => Text(it.toString()), 114 | ), 115 | ], 116 | ), 117 | ), 118 | )); 119 | }, 120 | ), 121 | ), 122 | ], 123 | ); 124 | } 125 | 126 | class Content2nd extends StatelessWidget { 127 | const Content2nd({Key? key}) : super(key: key); 128 | 129 | @override 130 | Widget build(BuildContext context) => Column( 131 | crossAxisAlignment: CrossAxisAlignment.center, 132 | children: [ 133 | Container( 134 | color: Colors.black12, 135 | child: Padding( 136 | padding: EdgeInsets.all(16), 137 | child: Text(SAMPLE_MEDIA), 138 | ), 139 | ), 140 | SizedBox(height: 32), 141 | Container( 142 | child: ElevatedButton( 143 | child: Text('PARSE!', textDirection: TextDirection.ltr), 144 | onPressed: () async { 145 | final playList = await HlsPlaylistParser.create() 146 | .parseString(Uri.parse(PLAYLIST_URI), SAMPLE_MEDIA); 147 | playList as HlsMediaPlaylist; 148 | 149 | final mediaPlaylistUrls = playList.segments.map((it) => it.url); 150 | final titles = playList.segments.map((it) => it.title); 151 | final fullSegmentEncryptionKeyUri = playList.segments.map((it) => it.fullSegmentEncryptionKeyUri); 152 | final encryptionIV = playList.segments.map((it) => it.encryptionIV); 153 | final byterangeLength = playList.segments.map((it) => it.byterangeLength); 154 | 155 | showDialog( 156 | context: context, 157 | builder: (context) => AlertDialog( 158 | content: SingleChildScrollView( 159 | child: Column( 160 | crossAxisAlignment: CrossAxisAlignment.start, 161 | children: [ 162 | DialogHeading(text: 'media uri'), 163 | ...mediaPlaylistUrls.map( 164 | (it) => Text(it.toString()), 165 | ), 166 | DialogHeading(text: 'segment title'), 167 | ...titles.map( 168 | (it) => Text(it.toString()), 169 | ), 170 | DialogHeading(text: 'encryption key uri'), 171 | ...fullSegmentEncryptionKeyUri.map( 172 | (it) => Text(it.toString()), 173 | ), 174 | DialogHeading(text: 'encryption IV'), 175 | ...encryptionIV.map( 176 | (it) => Text(it.toString()), 177 | ), 178 | DialogHeading(text: 'byte range length'), 179 | ...byterangeLength.map( 180 | (it) => Text(it.toString()), 181 | ), 182 | ], 183 | ), 184 | ), 185 | )); 186 | }, 187 | ), 188 | ), 189 | ], 190 | ); 191 | } 192 | 193 | class DialogHeading extends StatelessWidget { 194 | const DialogHeading({Key? key, required this.text}) : super(key: key); 195 | 196 | final String text; 197 | 198 | @override 199 | Widget build(BuildContext context) => Padding( 200 | child: Text( 201 | text, 202 | style: Theme.of(context).textTheme.titleLarge, 203 | ), 204 | padding: EdgeInsets.only(top: 24, bottom: 8), 205 | ); 206 | } 207 | -------------------------------------------------------------------------------- /example/lib/samples.dart: -------------------------------------------------------------------------------- 1 | const PLAYLIST_URI = 'https://example.com/test.m3u8'; 2 | 3 | const SAMPLE_MASTER = ''' 4 | #EXTM3U 5 | 6 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS="mp4a.40.2,avc1.66.30",RESOLUTION=304x128 7 | http://example.com/low.m3u8 8 | 9 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS="mp4a.40.2 , avc1.66.30 " 10 | http://example.com/spaces_in_codecs.m3u8 11 | 12 | #EXT-X-STREAM-INF:BANDWIDTH=2560000,FRAME-RATE=25,RESOLUTION=384x160 13 | http://example.com/mid.m3u8 14 | 15 | #EXT-X-STREAM-INF:BANDWIDTH=7680000,FRAME-RATE=29.997 16 | http://example.com/hi.m3u8 17 | 18 | #EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5" 19 | http://example.com/audio-only.m3u8 20 | '''; 21 | 22 | 23 | const SAMPLE_MEDIA = ''' 24 | #EXTM3U 25 | #EXT-X-VERSION:3 26 | #EXT-X-PLAYLIST-TYPE:VOD 27 | #EXT-X-START:TIME-OFFSET=-25 28 | #EXT-X-TARGETDURATION:8 29 | #EXT-X-MEDIA-SEQUENCE:2679 30 | #EXT-X-DISCONTINUITY-SEQUENCE:4 31 | #EXT-X-ALLOW-CACHE:YES 32 | 33 | #EXTINF:7.975, 34 | #EXT-X-BYTERANGE:51370@0 35 | https://priv.example.com/fileSequence2679.ts 36 | 37 | #EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=2680",IV=0x1566B 38 | #EXTINF:7.975,segment title 39 | #EXT-X-BYTERANGE:51501@2147483648 40 | https://priv.example.com/fileSequence2680.ts 41 | 42 | #EXT-X-KEY:METHOD=NONE 43 | #EXTINF:7.941,segment title .,:/# with interesting chars 44 | #EXT-X-BYTERANGE:51501 45 | https://priv.example.com/fileSequence2681.ts 46 | 47 | #EXT-X-DISCONTINUITY 48 | #EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=2682" 49 | #EXTINF:7.975 50 | #EXT-X-BYTERANGE:51740 51 | https://priv.example.com/fileSequence2682.ts 52 | 53 | #EXTINF:7.975, 54 | https://priv.example.com/fileSequence2683.ts 55 | #EXT-X-ENDLIST 56 | '''; -------------------------------------------------------------------------------- /example/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: flutter_hls_parser_example 2 | description: Demonstrates how to use the flutter_hls_parser plugin. 3 | publish_to: 'none' 4 | 5 | environment: 6 | sdk: '>=2.12.0 <3.0.0' 7 | 8 | dependencies: 9 | flutter: 10 | sdk: flutter 11 | 12 | dev_dependencies: 13 | flutter_test: 14 | sdk: flutter 15 | flutter_hls_parser: 16 | path: ../ 17 | flutter: 18 | uses-material-design: true 19 | -------------------------------------------------------------------------------- /lib/flutter_hls_parser.dart: -------------------------------------------------------------------------------- 1 | library flutter_hls_parser; 2 | 3 | export 'src/drm_init_data.dart'; 4 | export 'src/exception.dart'; 5 | export 'src/format.dart'; 6 | export 'src/hls_master_playlist.dart'; 7 | export 'src/hls_media_playlist.dart'; 8 | export 'src/hls_playlist_parser.dart'; 9 | export 'src/hls_track_metadata_entry.dart'; 10 | export 'src/metadata.dart'; 11 | export 'src/mime_types.dart'; 12 | export 'src/playlist.dart'; 13 | export 'src/rendition.dart'; 14 | export 'src/scheme_data.dart'; 15 | export 'src/segment.dart'; 16 | export 'src/variant.dart'; 17 | export 'src/variant_info.dart'; 18 | export 'src/util.dart'; 19 | -------------------------------------------------------------------------------- /lib/src/drm_init_data.dart: -------------------------------------------------------------------------------- 1 | import 'scheme_data.dart'; 2 | import 'package:collection/collection.dart'; 3 | 4 | class DrmInitData { 5 | const DrmInitData({this.schemeType, this.schemeData = const []}); 6 | 7 | final List schemeData; 8 | final String? schemeType; 9 | 10 | @override 11 | bool operator ==(dynamic other) { 12 | if (other is DrmInitData) 13 | return schemeType == other.schemeType && 14 | const ListEquality().equals(other.schemeData, schemeData); 15 | return false; 16 | } 17 | 18 | @override 19 | int get hashCode => Object.hash(schemeType, schemeData); 20 | } 21 | -------------------------------------------------------------------------------- /lib/src/exception.dart: -------------------------------------------------------------------------------- 1 | class ParserException implements Exception { 2 | const ParserException(this.message) : super(); 3 | 4 | final String message; 5 | 6 | @override 7 | String toString() => 'ParserException: $message'; 8 | } 9 | -------------------------------------------------------------------------------- /lib/src/extension.dart: -------------------------------------------------------------------------------- 1 | extension IterableX on Iterable { 2 | List distinct() => toSet().toList(); 3 | } 4 | -------------------------------------------------------------------------------- /lib/src/format.dart: -------------------------------------------------------------------------------- 1 | import 'drm_init_data.dart'; 2 | import 'metadata.dart'; 3 | import 'util.dart'; 4 | 5 | /// Representation of a media format. 6 | class Format { 7 | Format({ 8 | this.id, 9 | this.label, 10 | this.selectionFlags, 11 | this.roleFlags, 12 | this.bitrate, 13 | this.codecs, 14 | this.metadata, 15 | this.containerMimeType, 16 | this.sampleMimeType, 17 | this.drmInitData, 18 | this.subsampleOffsetUs, 19 | this.width, 20 | this.height, 21 | this.frameRate, 22 | this.channelCount, 23 | String? language, 24 | this.accessibilityChannel, 25 | }) : language = language?.toLowerCase(); 26 | 27 | factory Format.createVideoContainerFormat({ 28 | String? id, 29 | String? label, 30 | String? containerMimeType, 31 | String? sampleMimeType, 32 | required String? codecs, 33 | int? bitrate, 34 | required int? width, 35 | required int? height, 36 | required double? frameRate, 37 | int selectionFlags = Util.SELECTION_FLAG_DEFAULT, 38 | int? roleFlags, 39 | }) => 40 | Format( 41 | id: id, 42 | label: label, 43 | selectionFlags: selectionFlags, 44 | bitrate: bitrate, 45 | codecs: codecs, 46 | containerMimeType: containerMimeType, 47 | sampleMimeType: sampleMimeType, 48 | width: width, 49 | height: height, 50 | frameRate: frameRate, 51 | roleFlags: roleFlags, 52 | ); 53 | 54 | /// An identifier for the format, or null if unknown or not applicable. 55 | final String? id; 56 | 57 | /// The human readable label, or null if unknown or not applicable. 58 | final String? label; 59 | 60 | /// Track selection flags. 61 | /// [Util.SELECTION_FLAG_DEFAULT] or [Util.SELECTION_FLAG_FORCED] or [Util.SELECTION_FLAG_AUTOSELECT] 62 | final int? selectionFlags; 63 | 64 | /// Track role flags. 65 | /// [Util.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND] or [Util.ROLE_FLAG_DESCRIBES_VIDEO] or [Util.ROLE_FLAG_EASY_TO_READ] or [Util.ROLE_FLAG_TRANSCRIBES_DIALOG] 66 | final int? roleFlags; 67 | 68 | /// The average bandwidth in bits per second, or null if unknown or not applicable. 69 | final int? bitrate; 70 | 71 | /// Codecs of the format as described in RFC 6381, or null if unknown or not applicable. 72 | final String? codecs; 73 | 74 | /// Metadata, or null if unknown or not applicable. 75 | final Metadata? metadata; 76 | 77 | /// The mime type of the container, or null if unknown or not applicable. 78 | final String? containerMimeType; 79 | 80 | ///The mime type of the elementary stream (i.e. the individual samples), or null if unknown or not applicable. 81 | final String? sampleMimeType; 82 | 83 | ///DRM initialization data if the stream is protected, or null otherwise. 84 | final DrmInitData? drmInitData; 85 | 86 | //todo ここ追加で検討 87 | /// For samples that contain subsamples, this is an offset that should be added to subsample timestamps. 88 | /// A value of {@link #OFFSET_SAMPLE_RELATIVE} indicates that subsample timestamps are relative to the timestamps of their parent samples. 89 | final int? subsampleOffsetUs; 90 | 91 | /// The width of the video in pixels, or null if unknown or not applicable. 92 | final int? width; 93 | 94 | /// The height of the video in pixels, or null if unknown or not applicable. 95 | final int? height; 96 | 97 | /// The frame rate in frames per second, or null if unknown or not applicable. 98 | final double? frameRate; 99 | 100 | /// The number of audio channels, or null if unknown or not applicable. 101 | final int? channelCount; 102 | 103 | /// The language of the video, or null if unknown or not applicable. 104 | final String? language; 105 | 106 | /// The Accessibility channel, or null if not known or applicable. 107 | final int? accessibilityChannel; 108 | 109 | Format copyWithMetadata(Metadata metadata) => Format( 110 | id: id, 111 | label: label, 112 | selectionFlags: selectionFlags, 113 | roleFlags: roleFlags, 114 | bitrate: bitrate, 115 | codecs: codecs, 116 | metadata: metadata, 117 | containerMimeType: containerMimeType, 118 | sampleMimeType: sampleMimeType, 119 | drmInitData: drmInitData, 120 | subsampleOffsetUs: subsampleOffsetUs, 121 | width: width, 122 | height: height, 123 | frameRate: frameRate, 124 | channelCount: channelCount, 125 | language: language, 126 | accessibilityChannel: accessibilityChannel, 127 | ); 128 | } 129 | -------------------------------------------------------------------------------- /lib/src/hls_master_playlist.dart: -------------------------------------------------------------------------------- 1 | import '../flutter_hls_parser.dart'; 2 | import 'extension.dart'; 3 | 4 | class HlsMasterPlaylist extends HlsPlaylist { 5 | HlsMasterPlaylist({ 6 | String? baseUri, 7 | List tags = const [], 8 | this.variants = const [], 9 | this.videos = const [], 10 | this.audios = const [], 11 | this.subtitles = const [], 12 | this.closedCaptions = const [], 13 | this.muxedAudioFormat, 14 | this.muxedCaptionFormats = const [], 15 | bool hasIndependentSegments = false, 16 | this.variableDefinitions = const {}, 17 | this.sessionKeyDrmInitData = const [], 18 | }) : mediaPlaylistUrls = _getMediaPlaylistUrls( 19 | variants, [videos, audios, subtitles, closedCaptions]), 20 | super( 21 | baseUri: baseUri, 22 | tags: tags, 23 | hasIndependentSegments: hasIndependentSegments); 24 | 25 | /// All of the media playlist URLs referenced by the playlist. 26 | final List mediaPlaylistUrls; 27 | 28 | /// The variants declared by the playlist. 29 | final List variants; 30 | 31 | /// The video renditions declared by the playlist. 32 | final List videos; 33 | 34 | /// The audio renditions declared by the playlist. 35 | final List audios; 36 | 37 | /// The subtitle renditions declared by the playlist. 38 | final List subtitles; 39 | 40 | /// The closed caption renditions declared by the playlist. 41 | final List closedCaptions; 42 | 43 | ///The format of the audio muxed in the variants. May be null if the playlist does not declare any mixed audio. 44 | final Format? muxedAudioFormat; 45 | 46 | ///The format of the closed captions declared by the playlist. May be empty if the playlist 47 | ///explicitly declares no captions are available, or null if the playlist does not declare any 48 | ///captions information. 49 | final List muxedCaptionFormats; 50 | 51 | /// Contains variable definitions, as defined by the #EXT-X-DEFINE tag. 52 | final Map variableDefinitions; 53 | 54 | /// DRM initialization data derived from #EXT-X-SESSION-KEY tags. 55 | final List sessionKeyDrmInitData; 56 | 57 | static List _getMediaPlaylistUrls( 58 | List variants, List> renditionList) => 59 | [ 60 | ...variants.map((it) => it.url), 61 | ...renditionList.expand((it) => it).map((it) => it.url) 62 | ].distinct(); 63 | } 64 | -------------------------------------------------------------------------------- /lib/src/hls_media_playlist.dart: -------------------------------------------------------------------------------- 1 | import 'segment.dart'; 2 | import 'drm_init_data.dart'; 3 | import 'playlist.dart'; 4 | 5 | class HlsMediaPlaylist extends HlsPlaylist { 6 | const HlsMediaPlaylist._({ 7 | required this.playlistType, 8 | required this.startOffsetUs, 9 | required this.startTimeUs, 10 | required this.hasDiscontinuitySequence, 11 | required this.discontinuitySequence, 12 | required this.mediaSequence, 13 | required this.version, 14 | required this.targetDurationUs, 15 | required this.hasEndTag, 16 | required this.hasProgramDateTime, 17 | required this.protectionSchemes, 18 | required this.segments, 19 | required this.durationUs, 20 | required String baseUri, 21 | required List tags, 22 | required bool hasIndependentSegments, 23 | }) : super( 24 | baseUri: baseUri, 25 | tags: tags, 26 | hasIndependentSegments: hasIndependentSegments, 27 | ); 28 | 29 | factory HlsMediaPlaylist.create({ 30 | required int playlistType, 31 | required int? startOffsetUs, 32 | required int? startTimeUs, 33 | required bool hasDiscontinuitySequence, 34 | required int discontinuitySequence, 35 | required int? mediaSequence, 36 | required int? version, 37 | required int? targetDurationUs, 38 | required bool hasEndTag, 39 | required bool hasProgramDateTime, 40 | required DrmInitData? protectionSchemes, 41 | required List segments, 42 | required String baseUri, 43 | required List tags, 44 | required bool hasIndependentSegments, 45 | }) { 46 | var durationUs = segments.isNotEmpty 47 | ? (segments.last.relativeStartTimeUs ?? 0) + 48 | (segments.last.durationUs ?? 0) 49 | : null; 50 | 51 | var startOffsetUsFixed = startOffsetUs ?? 0; 52 | if (startOffsetUsFixed < 0) 53 | startOffsetUsFixed = (durationUs ?? 0) + startOffsetUs!; 54 | 55 | return HlsMediaPlaylist._( 56 | playlistType: playlistType, 57 | startOffsetUs: startOffsetUsFixed, 58 | startTimeUs: startTimeUs, 59 | hasDiscontinuitySequence: hasDiscontinuitySequence, 60 | discontinuitySequence: discontinuitySequence, 61 | mediaSequence: mediaSequence, 62 | version: version, 63 | targetDurationUs: targetDurationUs, 64 | hasEndTag: hasEndTag, 65 | hasProgramDateTime: hasProgramDateTime, 66 | protectionSchemes: protectionSchemes, 67 | segments: segments, 68 | durationUs: durationUs, 69 | baseUri: baseUri, 70 | tags: tags, 71 | hasIndependentSegments: hasIndependentSegments, 72 | ); 73 | } 74 | 75 | static const int PLAYLIST_TYPE_UNKNOWN = 0; 76 | static const int PLAYLIST_TYPE_VOD = 1; 77 | static const int PLAYLIST_TYPE_EVENT = 2; 78 | 79 | /// The type of the playlist. The value is [PLAYLIST_TYPE_UNKNOWN] or [PLAYLIST_TYPE_VOD] or [PLAYLIST_TYPE_EVENT] and not null. 80 | final int playlistType; 81 | 82 | /// The start offset in microseconds, as defined by #EXT-X-START, may be null if unknown. 83 | final int startOffsetUs; 84 | 85 | /// If [hasProgramDateTime] is true, contains the datetime as microseconds since epoch. 86 | /// Otherwise, contains the aggregated duration of removed segments up to this snapshot of the playlist. 87 | final int? startTimeUs; 88 | 89 | /// Whether the playlist contains the #EXT-X-DISCONTINUITY-SEQUENCE tag. 90 | final bool hasDiscontinuitySequence; 91 | 92 | /// The discontinuity sequence number of the first media segment in the playlist, as defined by #EXT-X-DISCONTINUITY-SEQUENCE, may be null if unknown. 93 | final int discontinuitySequence; 94 | 95 | /// The media sequence number of the first media segment in the playlist, as defined by #EXT-X-MEDIA-SEQUENCE, may be null if unknown. 96 | final int? mediaSequence; 97 | 98 | /// The compatibility version, as defined by #EXT-X-VERSION, may be null if unknown. 99 | final int? version; 100 | 101 | /// The target duration in microseconds, as defined by #EXT-X-TARGETDURATION, may be null if unknown. 102 | final int? targetDurationUs; 103 | 104 | /// Whether the playlist contains the #EXT-X-ENDLIST tag. 105 | final bool hasEndTag; 106 | 107 | /// Whether the playlist contains a #EXT-X-PROGRAM-DATE-TIME tag. 108 | final bool hasProgramDateTime; 109 | 110 | /// Contains the CDM protection schemes used by segments in this playlist. Does not contain any key acquisition data. Null if none of the segments in the playlist is CDM-encrypted. 111 | final DrmInitData? protectionSchemes; 112 | 113 | /// The list of segments in the playlist. 114 | final List segments; 115 | 116 | /// The total duration of the playlist in microseconds, may be null if unknown. 117 | final int? durationUs; 118 | } 119 | -------------------------------------------------------------------------------- /lib/src/hls_playlist_parser.dart: -------------------------------------------------------------------------------- 1 | import 'package:collection/collection.dart' show IterableExtension; 2 | import 'dart:async'; 3 | import 'dart:typed_data'; 4 | import 'drm_init_data.dart'; 5 | import 'exception.dart'; 6 | import 'dart:convert'; 7 | import 'util.dart'; 8 | import 'playlist.dart'; 9 | import 'mime_types.dart'; 10 | import 'scheme_data.dart'; 11 | import 'format.dart'; 12 | import 'variant.dart'; 13 | import 'variant_info.dart'; 14 | import 'hls_track_metadata_entry.dart'; 15 | import 'metadata.dart'; 16 | import 'rendition.dart'; 17 | import 'hls_master_playlist.dart'; 18 | import 'hls_media_playlist.dart'; 19 | import 'segment.dart'; 20 | 21 | class HlsPlaylistParser { 22 | HlsPlaylistParser(this.masterPlaylist); 23 | 24 | factory HlsPlaylistParser.create({HlsMasterPlaylist? masterPlaylist}) { 25 | masterPlaylist ??= HlsMasterPlaylist(); 26 | return HlsPlaylistParser(masterPlaylist); 27 | } 28 | 29 | static const String PLAYLIST_HEADER = '#EXTM3U'; 30 | static const String TAG_PREFIX = '#EXT'; 31 | static const String TAG_VERSION = '#EXT-X-VERSION'; 32 | static const String TAG_PLAYLIST_TYPE = '#EXT-X-PLAYLIST-TYPE'; 33 | static const String TAG_DEFINE = '#EXT-X-DEFINE'; 34 | static const String TAG_STREAM_INF = '#EXT-X-STREAM-INF'; 35 | static const String TAG_MEDIA = '#EXT-X-MEDIA'; 36 | static const String TAG_TARGET_DURATION = '#EXT-X-TARGETDURATION'; 37 | static const String TAG_DISCONTINUITY = '#EXT-X-DISCONTINUITY'; 38 | static const String TAG_DISCONTINUITY_SEQUENCE = 39 | '#EXT-X-DISCONTINUITY-SEQUENCE'; 40 | static const String TAG_PROGRAM_DATE_TIME = '#EXT-X-PROGRAM-DATE-TIME'; 41 | static const String TAG_INIT_SEGMENT = '#EXT-X-MAP'; 42 | static const String TAG_INDEPENDENT_SEGMENTS = '#EXT-X-INDEPENDENT-SEGMENTS'; 43 | static const String TAG_MEDIA_DURATION = '#EXTINF'; 44 | static const String TAG_MEDIA_SEQUENCE = '#EXT-X-MEDIA-SEQUENCE'; 45 | static const String TAG_START = '#EXT-X-START'; 46 | static const String TAG_ENDLIST = '#EXT-X-ENDLIST'; 47 | static const String TAG_KEY = '#EXT-X-KEY'; 48 | static const String TAG_SESSION_KEY = '#EXT-X-SESSION-KEY'; 49 | static const String TAG_BYTERANGE = '#EXT-X-BYTERANGE'; 50 | static const String TAG_GAP = '#EXT-X-GAP'; 51 | static const String TYPE_AUDIO = 'AUDIO'; 52 | static const String TYPE_VIDEO = 'VIDEO'; 53 | static const String TYPE_SUBTITLES = 'SUBTITLES'; 54 | static const String TYPE_CLOSED_CAPTIONS = 'CLOSED-CAPTIONS'; 55 | static const String METHOD_NONE = 'NONE'; 56 | static const String METHOD_AES_128 = 'AES-128'; 57 | static const String METHOD_SAMPLE_AES = 'SAMPLE-AES'; 58 | static const String METHOD_SAMPLE_AES_CENC = 'SAMPLE-AES-CENC'; 59 | static const String METHOD_SAMPLE_AES_CTR = 'SAMPLE-AES-CTR'; 60 | static const String KEYFORMAT_PLAYREADY = 'com.microsoft.playready'; 61 | static const String KEYFORMAT_IDENTITY = 'identity'; 62 | static const String KEYFORMAT_WIDEVINE_PSSH_BINARY = 63 | 'urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed'; 64 | static const String KEYFORMAT_WIDEVINE_PSSH_JSON = 'com.widevine'; 65 | static const String BOOLEAN_TRUE = 'YES'; 66 | static const String BOOLEAN_FALSE = 'NO'; 67 | static const String ATTR_CLOSED_CAPTIONS_NONE = 'CLOSED-CAPTIONS=NONE'; 68 | static const String REGEXP_AVERAGE_BANDWIDTH = r'AVERAGE-BANDWIDTH=(\d+)\b'; 69 | static const String REGEXP_VIDEO = 'VIDEO="(.+?)"'; 70 | static const String REGEXP_AUDIO = 'AUDIO="(.+?)"'; 71 | static const String REGEXP_SUBTITLES = 'SUBTITLES="(.+?)"'; 72 | static const String REGEXP_CLOSED_CAPTIONS = 'CLOSED-CAPTIONS="(.+?)"'; 73 | static const String REGEXP_BANDWIDTH = r'[^-]BANDWIDTH=(\d+)\b'; 74 | static const String REGEXP_CHANNELS = 'CHANNELS="(.+?)"'; 75 | static const String REGEXP_CODECS = 'CODECS="(.+?)"'; 76 | static const String REGEXP_RESOLUTION = r'RESOLUTION=(\d+x\d+)'; 77 | static const String REGEXP_FRAME_RATE = r'FRAME-RATE=([\d\.]+)\b'; 78 | static const String REGEXP_TARGET_DURATION = '$TAG_TARGET_DURATION:(\\d+)\\b'; 79 | static const String REGEXP_VERSION = '$TAG_VERSION:(\\d+)\\b'; 80 | static const String REGEXP_PLAYLIST_TYPE = '$TAG_PLAYLIST_TYPE:(.+)\\b'; 81 | static const String REGEXP_MEDIA_SEQUENCE = '$TAG_MEDIA_SEQUENCE:(\\d+)\\b'; 82 | static const String REGEXP_MEDIA_DURATION = 83 | '$TAG_MEDIA_DURATION:([\\d\\.]+)\\b'; 84 | static const String REGEXP_MEDIA_TITLE = 85 | '$TAG_MEDIA_DURATION:[\\d\\.]+\\b,(.+)'; 86 | static const String REGEXP_TIME_OFFSET = r'TIME-OFFSET=(-?[\d\.]+)\b'; 87 | static const String REGEXP_BYTERANGE = '$TAG_BYTERANGE:(\\d+(?:@\\d+)?)\\b'; 88 | static const String REGEXP_ATTR_BYTERANGE = r'BYTERANGE="(\d+(?:@\d+)?)\b"'; 89 | static const String REGEXP_METHOD = 90 | 'METHOD=($METHOD_NONE|$METHOD_AES_128|$METHOD_SAMPLE_AES|$METHOD_SAMPLE_AES_CENC|$METHOD_SAMPLE_AES_CTR)\\s*(?:,|\$)'; 91 | static const String REGEXP_KEYFORMAT = 'KEYFORMAT="(.+?)"'; 92 | static const String REGEXP_KEYFORMATVERSIONS = 'KEYFORMATVERSIONS="(.+?)"'; 93 | static const String REGEXP_URI = 'URI="(.+?)"'; 94 | static const String REGEXP_IV = 'IV=([^,.*]+)'; 95 | static const String REGEXP_TYPE = 96 | 'TYPE=($TYPE_AUDIO|$TYPE_VIDEO|$TYPE_SUBTITLES|$TYPE_CLOSED_CAPTIONS)'; 97 | static const String REGEXP_LANGUAGE = 'LANGUAGE="(.+?)"'; 98 | static const String REGEXP_NAME = 'NAME="(.+?)"'; 99 | static const String REGEXP_GROUP_ID = 'GROUP-ID="(.+?)"'; 100 | static const String REGEXP_CHARACTERISTICS = 'CHARACTERISTICS="(.+?)"'; 101 | static const String REGEXP_INSTREAM_ID = r'INSTREAM-ID="((?:CC|SERVICE)\d+)"'; 102 | static final String regexpAutoselect = 103 | _compileBooleanAttrPattern('AUTOSELECT'); 104 | 105 | static final String regexpDefault = _compileBooleanAttrPattern('DEFAULT'); 106 | 107 | static final String regexpForced = _compileBooleanAttrPattern('FORCED'); 108 | static const String REGEXP_VALUE = 'VALUE="(.+?)"'; 109 | static const String REGEXP_IMPORT = 'IMPORT="(.+?)"'; 110 | static const String REGEXP_VARIABLE_REFERENCE = r'\{\$([a-zA-Z0-9\-_]+)\}'; 111 | 112 | final HlsMasterPlaylist masterPlaylist; 113 | 114 | Future parseString(Uri uri, String inputString) async { 115 | var lines = const LineSplitter().convert(inputString); 116 | return parse(uri, lines); 117 | } 118 | 119 | Future parse(Uri uri, List inputLineList) async { 120 | var lineList = 121 | inputLineList.where((line) => line.trim().isNotEmpty).toList(); 122 | 123 | if (!_checkPlaylistHeader(lineList[0])) 124 | throw ParserException( 125 | 'Input does not start with the #EXTM3U header.'); 126 | 127 | var extraLines = lineList.getRange(1, lineList.length).toList(); 128 | 129 | bool? isMasterPlayList; 130 | for (final line in extraLines) { 131 | if (line.startsWith(TAG_STREAM_INF)) { 132 | isMasterPlayList = true; 133 | break; 134 | } else if (line.startsWith(TAG_TARGET_DURATION) || 135 | line.startsWith(TAG_MEDIA_SEQUENCE) || 136 | line.startsWith(TAG_MEDIA_DURATION) || 137 | line.startsWith(TAG_KEY) || 138 | line.startsWith(TAG_BYTERANGE) || 139 | line == TAG_DISCONTINUITY || 140 | line == TAG_DISCONTINUITY_SEQUENCE || 141 | line == TAG_ENDLIST) { 142 | isMasterPlayList = false; 143 | } 144 | } 145 | if (isMasterPlayList == null) 146 | throw const FormatException("extraLines doesn't have valid tag"); 147 | 148 | return isMasterPlayList 149 | ? _parseMasterPlaylist(extraLines.iterator, uri.toString()) 150 | : _parseMediaPlaylist(masterPlaylist, extraLines, uri.toString()); 151 | } 152 | 153 | static String _compileBooleanAttrPattern(String attribute) => 154 | '$attribute=($BOOLEAN_FALSE|$BOOLEAN_TRUE)'; 155 | 156 | static bool _checkPlaylistHeader(String string) { 157 | var codeUnits = LibUtil.excludeWhiteSpace(string).codeUnits; 158 | 159 | if (codeUnits[0] == 0xEF) { 160 | if (LibUtil.startsWith(codeUnits, [0xEF, 0xBB, 0xBF])) return false; 161 | codeUnits = 162 | codeUnits.getRange(5, codeUnits.length - 1).toList(); //不要な文字が含まれている 163 | } 164 | 165 | if (!LibUtil.startsWith(codeUnits, PLAYLIST_HEADER.runes.toList())) 166 | return false; 167 | 168 | return true; 169 | } 170 | 171 | HlsMasterPlaylist _parseMasterPlaylist( 172 | Iterator extraLines, String baseUri) { 173 | var tags = []; 174 | var mediaTags = []; 175 | var sessionKeyDrmInitData = []; 176 | var variants = []; 177 | var videos = []; 178 | var audios = []; 179 | var subtitles = []; 180 | var closedCaptions = []; 181 | var urlToVariantInfos = >{}; 182 | Format? muxedAudioFormat; 183 | var noClosedCaptions = false; 184 | var hasIndependentSegmentsTag = false; 185 | var muxedCaptionFormats = []; 186 | var variableDefinitions = {}; 187 | 188 | while (extraLines.moveNext()) { 189 | var line = extraLines.current; 190 | 191 | if (line.startsWith(TAG_DEFINE)) { 192 | var key = _parseStringAttr( 193 | source: line, 194 | pattern: REGEXP_NAME, 195 | variableDefinitions: variableDefinitions, 196 | ); 197 | var val = _parseStringAttr( 198 | source: line, 199 | pattern: REGEXP_VALUE, 200 | variableDefinitions: variableDefinitions, 201 | ); 202 | if (key == null) 203 | throw ParserException("Couldn't match $REGEXP_NAME in $line"); 204 | if (val == null) 205 | throw ParserException("Couldn't match $REGEXP_VALUE in $line"); 206 | variableDefinitions[key] = val; 207 | } else if (line == TAG_INDEPENDENT_SEGMENTS) { 208 | hasIndependentSegmentsTag = true; 209 | } else if (line.startsWith(TAG_MEDIA)) { 210 | mediaTags.add(line); 211 | } else if (line.startsWith(TAG_SESSION_KEY)) { 212 | var keyFormat = _parseStringAttr( 213 | source: line, 214 | pattern: REGEXP_KEYFORMAT, 215 | defaultValue: KEYFORMAT_IDENTITY, 216 | variableDefinitions: variableDefinitions, 217 | ); 218 | var schemeData = _parseDrmSchemeData( 219 | line: line, 220 | keyFormat: keyFormat, 221 | variableDefinitions: variableDefinitions, 222 | ); 223 | 224 | if (schemeData != null) { 225 | var method = _parseStringAttr( 226 | source: line, 227 | pattern: REGEXP_METHOD, 228 | variableDefinitions: variableDefinitions, 229 | ); 230 | if (method == null) 231 | throw ParserException( 232 | 'failed to parse session key. key: $TAG_SESSION_KEY value: $line'); 233 | var scheme = _parseEncryptionScheme(method); 234 | var drmInitData = DrmInitData( 235 | schemeType: scheme, 236 | schemeData: [schemeData], 237 | ); 238 | sessionKeyDrmInitData.add(drmInitData); 239 | } 240 | } else if (line.startsWith(TAG_STREAM_INF)) { 241 | noClosedCaptions |= line.contains(ATTR_CLOSED_CAPTIONS_NONE); //todo 再検討 242 | var bitrate = int.parse( 243 | _parseStringAttr(source: line, pattern: REGEXP_BANDWIDTH)!); 244 | var averageBandwidthString = _parseStringAttr( 245 | source: line, 246 | pattern: REGEXP_AVERAGE_BANDWIDTH, 247 | variableDefinitions: variableDefinitions); 248 | if (averageBandwidthString != null) 249 | // If available, the average bandwidth attribute is used as the variant's bitrate. 250 | bitrate = int.parse(averageBandwidthString); 251 | var codecs = _parseStringAttr( 252 | source: line, 253 | pattern: REGEXP_CODECS, 254 | variableDefinitions: variableDefinitions); 255 | var resolutionString = _parseStringAttr( 256 | source: line, 257 | pattern: REGEXP_RESOLUTION, 258 | variableDefinitions: variableDefinitions); 259 | int? width; 260 | int? height; 261 | if (resolutionString != null) { 262 | var widthAndHeight = resolutionString.split('x'); 263 | width = int.parse(widthAndHeight[0]); 264 | height = int.parse(widthAndHeight[1]); 265 | if (width <= 0 || height <= 0) { 266 | // Resolution string is invalid. 267 | width = null; 268 | height = null; 269 | } 270 | } 271 | 272 | double? frameRate; 273 | var frameRateString = _parseStringAttr( 274 | source: line, 275 | pattern: REGEXP_FRAME_RATE, 276 | variableDefinitions: variableDefinitions, 277 | ); 278 | if (frameRateString != null) frameRate = double.parse(frameRateString); 279 | 280 | var videoGroupId = _parseStringAttr( 281 | source: line, 282 | pattern: REGEXP_VIDEO, 283 | variableDefinitions: variableDefinitions, 284 | ); 285 | var audioGroupId = _parseStringAttr( 286 | source: line, 287 | pattern: REGEXP_AUDIO, 288 | variableDefinitions: variableDefinitions, 289 | ); 290 | var subtitlesGroupId = _parseStringAttr( 291 | source: line, 292 | pattern: REGEXP_SUBTITLES, 293 | variableDefinitions: variableDefinitions, 294 | ); 295 | var closedCaptionsGroupId = _parseStringAttr( 296 | source: line, 297 | pattern: REGEXP_CLOSED_CAPTIONS, 298 | variableDefinitions: variableDefinitions, 299 | ); 300 | 301 | extraLines.moveNext(); 302 | 303 | var referenceUri = _parseStringAttr( 304 | source: extraLines.current, 305 | variableDefinitions: variableDefinitions, 306 | ); 307 | if (referenceUri == null) 308 | throw ParserException('failed to parse this line; $line'); 309 | var uri = Uri.parse(baseUri).resolve(referenceUri); 310 | 311 | var format = Format.createVideoContainerFormat( 312 | id: variants.length.toString(), 313 | containerMimeType: MimeTypes.APPLICATION_M3U8, 314 | codecs: codecs, 315 | bitrate: bitrate, 316 | width: width, 317 | height: height, 318 | frameRate: frameRate, 319 | ); 320 | 321 | variants.add(Variant( 322 | url: uri, 323 | format: format, 324 | videoGroupId: videoGroupId, 325 | audioGroupId: audioGroupId, 326 | subtitleGroupId: subtitlesGroupId, 327 | captionGroupId: closedCaptionsGroupId, 328 | )); 329 | 330 | var variantInfosForUrl = urlToVariantInfos[uri]; 331 | if (variantInfosForUrl == null) { 332 | variantInfosForUrl = []; 333 | urlToVariantInfos[uri] = variantInfosForUrl; 334 | } 335 | 336 | variantInfosForUrl.add(VariantInfo( 337 | bitrate: bitrate, 338 | videoGroupId: videoGroupId, 339 | audioGroupId: audioGroupId, 340 | subtitleGroupId: subtitlesGroupId, 341 | captionGroupId: closedCaptionsGroupId, 342 | )); 343 | } 344 | } 345 | 346 | // TODO: Don't deduplicate variants by URL. 347 | var deduplicatedVariants = []; 348 | var urlsInDeduplicatedVariants = {}; 349 | variants.forEach((variant) { 350 | if (urlsInDeduplicatedVariants.add(variant.url)) { 351 | assert(variant.format.metadata == null); 352 | var hlsMetadataEntry = HlsTrackMetadataEntry( 353 | variantInfos: urlToVariantInfos[variant.url], 354 | ); 355 | var metadata = Metadata([hlsMetadataEntry]); 356 | deduplicatedVariants.add( 357 | variant.copyWithFormat(variant.format.copyWithMetadata(metadata))); 358 | } 359 | }); 360 | 361 | mediaTags.forEach((line) { 362 | var groupId = _parseStringAttr( 363 | source: line, 364 | pattern: REGEXP_GROUP_ID, 365 | variableDefinitions: variableDefinitions, 366 | ); 367 | var name = _parseStringAttr( 368 | source: line, 369 | pattern: REGEXP_NAME, 370 | variableDefinitions: variableDefinitions, 371 | ); 372 | var referenceUri = _parseStringAttr( 373 | source: line, 374 | pattern: REGEXP_URI, 375 | variableDefinitions: variableDefinitions, 376 | ); 377 | 378 | var uri = Uri.tryParse(baseUri); 379 | if (referenceUri != null) uri = uri?.resolve(referenceUri); 380 | 381 | var language = _parseStringAttr( 382 | source: line, 383 | pattern: REGEXP_LANGUAGE, 384 | variableDefinitions: variableDefinitions, 385 | ); 386 | var selectionFlags = _parseSelectionFlags(line); 387 | var roleFlags = _parseRoleFlags(line, variableDefinitions); 388 | var formatId = '$groupId:$name'; 389 | Format format; 390 | var entry = HlsTrackMetadataEntry( 391 | groupId: groupId, 392 | name: name, 393 | ); 394 | var metadata = Metadata([entry]); 395 | 396 | switch (_parseStringAttr( 397 | source: line, 398 | pattern: REGEXP_TYPE, 399 | variableDefinitions: variableDefinitions, 400 | )) { 401 | case TYPE_VIDEO: 402 | { 403 | var variant = 404 | variants.firstWhereOrNull((it) => it.videoGroupId == groupId); 405 | String? codecs; 406 | int? width; 407 | int? height; 408 | double? frameRate; 409 | if (variant != null) { 410 | var variantFormat = variant.format; 411 | codecs = LibUtil.getCodecsOfType( 412 | variantFormat.codecs, Util.TRACK_TYPE_VIDEO); 413 | width = variantFormat.width; 414 | height = variantFormat.height; 415 | frameRate = variantFormat.frameRate; 416 | } 417 | var sampleMimeType = MimeTypes.getMediaMimeType(codecs); 418 | 419 | format = Format.createVideoContainerFormat( 420 | id: formatId, 421 | label: name, 422 | containerMimeType: MimeTypes.APPLICATION_M3U8, 423 | sampleMimeType: sampleMimeType, 424 | codecs: codecs, 425 | width: width, 426 | height: height, 427 | frameRate: frameRate, 428 | selectionFlags: selectionFlags, 429 | roleFlags: roleFlags, 430 | ).copyWithMetadata(metadata); 431 | 432 | videos.add(Rendition( 433 | url: uri, 434 | format: format, 435 | groupId: groupId, 436 | name: name, 437 | )); 438 | break; 439 | } 440 | case TYPE_AUDIO: 441 | { 442 | var variant = _getVariantWithAudioGroup(variants, groupId); 443 | var codecs = variant != null 444 | ? LibUtil.getCodecsOfType( 445 | variant.format.codecs, Util.TRACK_TYPE_AUDIO) 446 | : null; 447 | var channelCount = 448 | _parseChannelsAttribute(line, variableDefinitions); 449 | var sampleMimeType = 450 | codecs != null ? MimeTypes.getMediaMimeType(codecs) : null; 451 | var format = Format( 452 | id: formatId, 453 | label: name, 454 | containerMimeType: MimeTypes.APPLICATION_M3U8, 455 | sampleMimeType: sampleMimeType, 456 | codecs: codecs, 457 | channelCount: channelCount, 458 | selectionFlags: selectionFlags, 459 | roleFlags: roleFlags, 460 | language: language, 461 | ); 462 | 463 | if (uri == null) 464 | muxedAudioFormat = format; 465 | else 466 | audios.add(Rendition( 467 | url: uri, 468 | format: format.copyWithMetadata(metadata), 469 | groupId: groupId, 470 | name: name, 471 | )); 472 | break; 473 | } 474 | case TYPE_SUBTITLES: 475 | { 476 | var format = Format( 477 | id: formatId, 478 | label: name, 479 | containerMimeType: MimeTypes.APPLICATION_M3U8, 480 | sampleMimeType: MimeTypes.TEXT_VTT, 481 | selectionFlags: selectionFlags, 482 | roleFlags: roleFlags, 483 | language: language) 484 | .copyWithMetadata(metadata); 485 | subtitles.add(Rendition( 486 | url: uri, 487 | format: format, 488 | groupId: groupId, 489 | name: name, 490 | )); 491 | break; 492 | } 493 | case TYPE_CLOSED_CAPTIONS: 494 | { 495 | var instreamId = _parseStringAttr( 496 | source: line, 497 | pattern: REGEXP_INSTREAM_ID, 498 | variableDefinitions: variableDefinitions, 499 | ); 500 | if (instreamId == null) 501 | throw ParserException( 502 | 'failed to parse session key. key: $TYPE_CLOSED_CAPTIONS value: $line'); 503 | String mimeType; 504 | int accessibilityChannel; 505 | if (instreamId.startsWith('CC')) { 506 | mimeType = MimeTypes.APPLICATION_CEA608; 507 | accessibilityChannel = int.parse(instreamId.substring(2)); 508 | } else 509 | /* starts with SERVICE */ { 510 | mimeType = MimeTypes.APPLICATION_CEA708; 511 | accessibilityChannel = int.parse(instreamId.substring(7)); 512 | } 513 | muxedCaptionFormats.add(Format( 514 | id: formatId, 515 | label: name, 516 | sampleMimeType: mimeType, 517 | selectionFlags: selectionFlags, 518 | roleFlags: roleFlags, 519 | language: language, 520 | accessibilityChannel: accessibilityChannel, 521 | )); 522 | break; 523 | } 524 | default: 525 | break; 526 | } 527 | }); 528 | 529 | if (noClosedCaptions) muxedCaptionFormats = []; 530 | 531 | return HlsMasterPlaylist( 532 | baseUri: baseUri, 533 | tags: tags, 534 | variants: deduplicatedVariants, 535 | videos: videos, 536 | audios: audios, 537 | subtitles: subtitles, 538 | closedCaptions: closedCaptions, 539 | muxedAudioFormat: muxedAudioFormat, 540 | muxedCaptionFormats: muxedCaptionFormats, 541 | hasIndependentSegments: hasIndependentSegmentsTag, 542 | variableDefinitions: variableDefinitions, 543 | sessionKeyDrmInitData: sessionKeyDrmInitData, 544 | ); 545 | } 546 | 547 | static String? _parseStringAttr({ 548 | required String source, 549 | String? pattern, 550 | String? defaultValue, 551 | Map? variableDefinitions, 552 | }) { 553 | var value = pattern == null 554 | ? source 555 | : (RegExp(pattern).firstMatch(source)?.group(1) ?? defaultValue); 556 | 557 | if (value == null) return null; 558 | 559 | return value.replaceAllMapped(RegExp(REGEXP_VARIABLE_REFERENCE), (match) { 560 | final matched = match.group(1); 561 | return matched == null 562 | ? value.substring(match.start, match.end) 563 | : (variableDefinitions ?? {})[matched] ??= 564 | value.substring(match.start, match.end); 565 | }); 566 | } 567 | 568 | static SchemeData? _parseDrmSchemeData({ 569 | required String line, 570 | String? keyFormat, 571 | Map? variableDefinitions, 572 | }) { 573 | var keyFormatVersions = _parseStringAttr( 574 | source: line, 575 | pattern: REGEXP_KEYFORMATVERSIONS, 576 | defaultValue: '1', 577 | variableDefinitions: variableDefinitions, 578 | ); 579 | 580 | if (KEYFORMAT_WIDEVINE_PSSH_BINARY == keyFormat) { 581 | var uriString = _parseStringAttr( 582 | source: line, 583 | pattern: REGEXP_URI, 584 | variableDefinitions: variableDefinitions, 585 | ); 586 | if (uriString == null) 587 | throw ParserException('failed to parse this line: $line'); 588 | var data = _getBase64FromUri(uriString); 589 | return SchemeData( 590 | // uuid: '', //todo 保留 591 | mimeType: MimeTypes.VIDEO_MP4, 592 | data: data, 593 | ); 594 | } else if (KEYFORMAT_WIDEVINE_PSSH_JSON == keyFormat) { 595 | return SchemeData( 596 | // uuid: '', //todo 保留 597 | mimeType: MimeTypes.HLS, 598 | data: const Utf8Encoder().convert(line), 599 | ); 600 | } else if (KEYFORMAT_PLAYREADY == keyFormat && '1' == keyFormatVersions) { 601 | var uriString = _parseStringAttr( 602 | source: line, 603 | pattern: REGEXP_URI, 604 | variableDefinitions: variableDefinitions, 605 | ); 606 | var data = _getBase64FromUri(uriString); 607 | // Uint8List psshData; //todo 保留 608 | return SchemeData( 609 | mimeType: MimeTypes.VIDEO_MP4, 610 | data: data, 611 | ); 612 | } 613 | 614 | return null; 615 | } 616 | 617 | static int _parseSelectionFlags(String line) { 618 | var flags = 0; 619 | if (_parseOptionalBooleanAttribute( 620 | line: line, 621 | pattern: regexpDefault, 622 | defaultValue: false, 623 | )) flags |= Util.SELECTION_FLAG_DEFAULT; 624 | if (_parseOptionalBooleanAttribute( 625 | line: line, 626 | pattern: regexpForced, 627 | defaultValue: false, 628 | )) flags |= Util.SELECTION_FLAG_FORCED; 629 | if (_parseOptionalBooleanAttribute( 630 | line: line, 631 | pattern: regexpAutoselect, 632 | defaultValue: false, 633 | )) flags |= Util.SELECTION_FLAG_AUTOSELECT; 634 | return flags; 635 | } 636 | 637 | static bool _parseOptionalBooleanAttribute({ 638 | required String line, 639 | required String pattern, 640 | required bool defaultValue, 641 | }) { 642 | var list = line.allMatches(pattern); 643 | return list.isEmpty ? defaultValue : list.first.pattern == BOOLEAN_TRUE; 644 | } 645 | 646 | static int _parseRoleFlags( 647 | String line, 648 | Map variableDefinitions, 649 | ) { 650 | var concatenatedCharacteristics = _parseStringAttr( 651 | source: line, 652 | pattern: REGEXP_CHARACTERISTICS, 653 | variableDefinitions: variableDefinitions, 654 | ); 655 | if (concatenatedCharacteristics?.isNotEmpty != true) return 0; 656 | var characteristics = concatenatedCharacteristics!.split(','); 657 | var roleFlags = 0; 658 | if (characteristics.contains('public.accessibility.describes-video')) 659 | roleFlags |= Util.ROLE_FLAG_DESCRIBES_VIDEO; 660 | 661 | if (characteristics 662 | .contains('public.accessibility.transcribes-spoken-dialog')) 663 | roleFlags |= Util.ROLE_FLAG_TRANSCRIBES_DIALOG; 664 | 665 | if (characteristics 666 | .contains('public.accessibility.describes-music-and-sound')) 667 | roleFlags |= Util.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND; 668 | 669 | if (characteristics.contains('public.easy-to-read')) 670 | roleFlags |= Util.ROLE_FLAG_EASY_TO_READ; 671 | 672 | return roleFlags; 673 | } 674 | 675 | static int? _parseChannelsAttribute( 676 | String line, 677 | Map variableDefinitions, 678 | ) { 679 | var channelsString = _parseStringAttr( 680 | source: line, 681 | pattern: REGEXP_CHANNELS, 682 | variableDefinitions: variableDefinitions, 683 | ); 684 | return channelsString != null 685 | ? int.parse(channelsString.split('/')[0]) 686 | : null; 687 | } 688 | 689 | static Variant? _getVariantWithAudioGroup( 690 | List variants, 691 | String? groupId, 692 | ) => 693 | variants.firstWhereOrNull((it) => it.audioGroupId == groupId); 694 | 695 | static String _parseEncryptionScheme(String method) => 696 | METHOD_SAMPLE_AES_CENC == method || METHOD_SAMPLE_AES_CTR == method 697 | ? CencType.CENC 698 | : CencType.CBCS; 699 | 700 | static Uint8List? _getBase64FromUri(String? uriString) { 701 | if (uriString == null) return null; 702 | var uriPre = uriString.substring(uriString.indexOf(',') + 1); 703 | return const Base64Decoder().convert(uriPre); 704 | } 705 | 706 | static HlsMediaPlaylist _parseMediaPlaylist( 707 | HlsMasterPlaylist masterPlaylist, 708 | List extraLines, 709 | String baseUri, 710 | ) { 711 | var playlistType = HlsMediaPlaylist.PLAYLIST_TYPE_UNKNOWN; 712 | int? startOffsetUs; 713 | int? mediaSequence; 714 | int? version; 715 | int? targetDurationUs; 716 | var hasIndependentSegmentsTag = masterPlaylist.hasIndependentSegments; 717 | var hasEndTag = false; 718 | int? segmentByteRangeOffset; 719 | Segment? initializationSegment; 720 | var variableDefinitions = {}; 721 | var segments = []; 722 | var tags = []; 723 | int? segmentByteRangeLength; 724 | var segmentMediaSequence = 0; 725 | int? segmentDurationUs; 726 | String? segmentTitle; 727 | var currentSchemeDatas = {}; 728 | DrmInitData? cachedDrmInitData; 729 | String? encryptionScheme; 730 | DrmInitData? playlistProtectionSchemes; 731 | var hasDiscontinuitySequence = false; 732 | var playlistDiscontinuitySequence = 0; 733 | int? relativeDiscontinuitySequence; 734 | int? playlistStartTimeUs; 735 | int? segmentStartTimeUs; 736 | var hasGapTag = false; 737 | 738 | String? fullSegmentEncryptionKeyUri; 739 | String? fullSegmentEncryptionIV; 740 | 741 | for (var line in extraLines) { 742 | if (line.startsWith(TAG_PREFIX)) { 743 | // We expose all tags through the playlist. 744 | tags.add(line); 745 | } 746 | 747 | if (line.startsWith(TAG_PLAYLIST_TYPE)) { 748 | var playlistTypeString = _parseStringAttr( 749 | source: line, 750 | pattern: REGEXP_PLAYLIST_TYPE, 751 | variableDefinitions: variableDefinitions, 752 | ); 753 | switch (playlistTypeString) { 754 | case 'VOD': 755 | playlistType = HlsMediaPlaylist.PLAYLIST_TYPE_VOD; 756 | break; 757 | case 'EVENT': 758 | playlistType = HlsMediaPlaylist.PLAYLIST_TYPE_EVENT; 759 | break; 760 | } 761 | } else if (line.startsWith(TAG_START)) { 762 | var string = _parseStringAttr( 763 | source: line, 764 | pattern: REGEXP_TIME_OFFSET, 765 | ); 766 | if (string == null) 767 | throw ParserException( 768 | 'failed to parse session key. key: $TAG_START value: $line'); 769 | startOffsetUs = (double.parse(string) * 1000000).toInt(); 770 | } else if (line.startsWith(TAG_INIT_SEGMENT)) { 771 | var uri = _parseStringAttr( 772 | source: line, 773 | pattern: REGEXP_URI, 774 | variableDefinitions: variableDefinitions, 775 | ); 776 | var byteRange = _parseStringAttr( 777 | source: line, 778 | pattern: REGEXP_ATTR_BYTERANGE, 779 | variableDefinitions: variableDefinitions, 780 | ); 781 | if (byteRange != null) { 782 | var splitByteRange = byteRange.split('@'); 783 | segmentByteRangeLength = int.parse(splitByteRange[0]); 784 | if (splitByteRange.length > 1) 785 | segmentByteRangeOffset = int.parse(splitByteRange[1]); 786 | } 787 | 788 | if (fullSegmentEncryptionKeyUri != null && 789 | fullSegmentEncryptionIV == null) 790 | // See RFC 8216, Section 4.3.2.5. 791 | throw ParserException( 792 | 'The encryption IV attribute must be present when an initialization segment is encrypted with METHOD=AES-128.'); 793 | 794 | initializationSegment = Segment( 795 | url: uri, 796 | byterangeOffset: segmentByteRangeOffset, 797 | byterangeLength: segmentByteRangeLength, 798 | fullSegmentEncryptionKeyUri: fullSegmentEncryptionKeyUri, 799 | encryptionIV: fullSegmentEncryptionIV, 800 | ); 801 | segmentByteRangeOffset = null; 802 | segmentByteRangeLength = null; 803 | } else if (line.startsWith(TAG_TARGET_DURATION)) { 804 | final string = _parseStringAttr( 805 | source: line, 806 | pattern: REGEXP_TARGET_DURATION, 807 | ); 808 | if (string == null) 809 | throw ParserException( 810 | 'failed to parse session key. key: $TAG_TARGET_DURATION value: $line'); 811 | targetDurationUs = int.parse(string) * 100000; 812 | } else if (line.startsWith(TAG_MEDIA_SEQUENCE)) { 813 | final string = _parseStringAttr( 814 | source: line, 815 | pattern: REGEXP_MEDIA_SEQUENCE, 816 | ); 817 | if (string == null) 818 | throw ParserException( 819 | 'failed to parse session key. key: $TAG_MEDIA_SEQUENCE value: $line'); 820 | mediaSequence = int.parse(string); 821 | segmentMediaSequence = mediaSequence; 822 | } else if (line.startsWith(TAG_VERSION)) { 823 | final string = _parseStringAttr( 824 | source: line, 825 | pattern: REGEXP_VERSION, 826 | ); 827 | if (string == null) 828 | throw ParserException( 829 | 'failed to parse session key. key: $TAG_VERSION value: $line'); 830 | version = int.parse(string); 831 | } else if (line.startsWith(TAG_DEFINE)) { 832 | var importName = _parseStringAttr( 833 | source: line, 834 | pattern: REGEXP_IMPORT, 835 | variableDefinitions: variableDefinitions, 836 | ); 837 | if (importName != null) { 838 | var value = masterPlaylist.variableDefinitions[importName]; 839 | if (value != null) { 840 | variableDefinitions[importName] = value; 841 | } else { 842 | // The master playlist does not declare the imported variable. Ignore. 843 | } 844 | } else { 845 | var key = _parseStringAttr( 846 | source: line, 847 | pattern: REGEXP_NAME, 848 | variableDefinitions: variableDefinitions, 849 | ); 850 | if (key != null) { 851 | variableDefinitions[key] = _parseStringAttr( 852 | source: line, 853 | pattern: REGEXP_VALUE, 854 | variableDefinitions: variableDefinitions, 855 | ); 856 | } 857 | } 858 | } else if (line.startsWith(TAG_MEDIA_DURATION)) { 859 | var string = _parseStringAttr( 860 | source: line, 861 | pattern: REGEXP_MEDIA_DURATION, 862 | ); 863 | if (string == null) 864 | throw ParserException( 865 | 'failed to parse session key. key: $TAG_MEDIA_DURATION value: $line'); 866 | segmentDurationUs = (double.parse(string) * 1000000).toInt(); 867 | segmentTitle = _parseStringAttr( 868 | source: line, 869 | pattern: REGEXP_MEDIA_TITLE, 870 | defaultValue: '', 871 | variableDefinitions: variableDefinitions, 872 | ); 873 | } else if (line.startsWith(TAG_KEY)) { 874 | var method = _parseStringAttr( 875 | source: line, 876 | pattern: REGEXP_METHOD, 877 | variableDefinitions: variableDefinitions, 878 | ); 879 | var keyFormat = _parseStringAttr( 880 | source: line, 881 | pattern: REGEXP_KEYFORMAT, 882 | defaultValue: KEYFORMAT_IDENTITY, 883 | variableDefinitions: variableDefinitions, 884 | ); 885 | fullSegmentEncryptionKeyUri = null; 886 | fullSegmentEncryptionIV = null; 887 | if (METHOD_NONE == method) { 888 | currentSchemeDatas.clear(); 889 | cachedDrmInitData = null; 890 | } else 891 | /* !METHOD_NONE.equals(method) */ { 892 | fullSegmentEncryptionIV = _parseStringAttr( 893 | source: line, 894 | pattern: REGEXP_IV, 895 | variableDefinitions: variableDefinitions, 896 | ); 897 | if (KEYFORMAT_IDENTITY == keyFormat) { 898 | if (METHOD_AES_128 == method) { 899 | // The segment is fully encrypted using an identity key. 900 | fullSegmentEncryptionKeyUri = _parseStringAttr( 901 | source: line, 902 | pattern: REGEXP_URI, 903 | variableDefinitions: variableDefinitions, 904 | ); 905 | } else { 906 | // Do nothing. Samples are encrypted using an identity key, but this is not supported. 907 | // Hopefully, a traditional DRM alternative is also provided. 908 | } 909 | } else { 910 | encryptionScheme ??= _parseEncryptionScheme(method!); 911 | var schemeData = _parseDrmSchemeData( 912 | line: line, 913 | keyFormat: keyFormat, 914 | variableDefinitions: variableDefinitions, 915 | ); 916 | if (schemeData != null) { 917 | cachedDrmInitData = null; 918 | currentSchemeDatas[keyFormat!] = schemeData; 919 | } 920 | } 921 | } 922 | } else if (line.startsWith(TAG_BYTERANGE)) { 923 | var byteRange = _parseStringAttr( 924 | source: line, 925 | pattern: REGEXP_BYTERANGE, 926 | variableDefinitions: variableDefinitions, 927 | ); 928 | if (byteRange == null) 929 | throw ParserException( 930 | 'failed to parse session key. key: $TAG_BYTERANGE value: $line'); 931 | var splitByteRange = byteRange.split('@'); 932 | segmentByteRangeLength = int.parse(splitByteRange[0]); 933 | if (splitByteRange.length > 1) 934 | segmentByteRangeOffset = int.parse(splitByteRange[1]); 935 | } else if (line.startsWith(TAG_DISCONTINUITY_SEQUENCE)) { 936 | hasDiscontinuitySequence = true; 937 | playlistDiscontinuitySequence = 938 | int.parse(line.substring(line.indexOf(':') + 1)); 939 | } else if (line == TAG_DISCONTINUITY) { 940 | relativeDiscontinuitySequence ??= 0; 941 | relativeDiscontinuitySequence++; 942 | } else if (line.startsWith(TAG_PROGRAM_DATE_TIME)) { 943 | if (playlistStartTimeUs == null) { 944 | var programDatetimeUs = 945 | LibUtil.parseXsDateTime(line.substring(line.indexOf(':') + 1)); 946 | playlistStartTimeUs = programDatetimeUs - (segmentStartTimeUs ?? 0); 947 | } 948 | } else if (line == TAG_GAP) { 949 | hasGapTag = true; 950 | } else if (line == TAG_INDEPENDENT_SEGMENTS) { 951 | hasIndependentSegmentsTag = true; 952 | } else if (line == TAG_ENDLIST) { 953 | hasEndTag = true; 954 | } else if (!line.startsWith('#')) { 955 | String? segmentEncryptionIV; 956 | if (fullSegmentEncryptionKeyUri == null) 957 | segmentEncryptionIV = null; 958 | else if (fullSegmentEncryptionIV != null) 959 | segmentEncryptionIV = fullSegmentEncryptionIV; 960 | else 961 | segmentEncryptionIV = segmentMediaSequence.toRadixString(16); 962 | 963 | segmentMediaSequence++; 964 | if (segmentByteRangeLength == null) segmentByteRangeOffset = null; 965 | 966 | if (cachedDrmInitData?.schemeData.isNotEmpty != true && 967 | currentSchemeDatas.isNotEmpty) { 968 | var schemeDatas = currentSchemeDatas.values.toList(); 969 | cachedDrmInitData = DrmInitData( 970 | schemeType: encryptionScheme, 971 | schemeData: schemeDatas, 972 | ); 973 | playlistProtectionSchemes ??= DrmInitData( 974 | schemeType: encryptionScheme, 975 | schemeData: schemeDatas.map((it) => it.copyWithData(null)).toList(), 976 | ); 977 | } 978 | 979 | var url = _parseStringAttr( 980 | source: line, 981 | variableDefinitions: variableDefinitions, 982 | ); 983 | segments.add(Segment( 984 | url: url, 985 | initializationSegment: initializationSegment, 986 | title: segmentTitle, 987 | durationUs: segmentDurationUs, 988 | relativeDiscontinuitySequence: relativeDiscontinuitySequence, 989 | relativeStartTimeUs: segmentStartTimeUs, 990 | drmInitData: cachedDrmInitData, 991 | fullSegmentEncryptionKeyUri: fullSegmentEncryptionKeyUri, 992 | encryptionIV: segmentEncryptionIV, 993 | byterangeOffset: segmentByteRangeOffset, 994 | byterangeLength: segmentByteRangeLength, 995 | hasGapTag: hasGapTag, 996 | )); 997 | 998 | if (segmentDurationUs != null) { 999 | segmentStartTimeUs ??= 0; 1000 | segmentStartTimeUs += segmentDurationUs; 1001 | } 1002 | segmentDurationUs = null; 1003 | segmentTitle = null; 1004 | if (segmentByteRangeLength != null) { 1005 | segmentByteRangeOffset ??= 0; 1006 | segmentByteRangeOffset += segmentByteRangeLength; 1007 | } 1008 | 1009 | segmentByteRangeLength = null; 1010 | hasGapTag = false; 1011 | } 1012 | } 1013 | 1014 | return HlsMediaPlaylist.create( 1015 | playlistType: playlistType, 1016 | baseUri: baseUri, 1017 | tags: tags, 1018 | startOffsetUs: startOffsetUs, 1019 | startTimeUs: playlistStartTimeUs, 1020 | hasDiscontinuitySequence: hasDiscontinuitySequence, 1021 | discontinuitySequence: playlistDiscontinuitySequence, 1022 | mediaSequence: mediaSequence, 1023 | version: version, 1024 | targetDurationUs: targetDurationUs, 1025 | hasIndependentSegments: hasIndependentSegmentsTag, 1026 | hasEndTag: hasEndTag, 1027 | hasProgramDateTime: playlistStartTimeUs != null, 1028 | protectionSchemes: playlistProtectionSchemes, 1029 | segments: segments, 1030 | ); 1031 | } 1032 | } 1033 | -------------------------------------------------------------------------------- /lib/src/hls_track_metadata_entry.dart: -------------------------------------------------------------------------------- 1 | import 'variant_info.dart'; 2 | import 'package:collection/collection.dart'; 3 | 4 | class HlsTrackMetadataEntry { 5 | const HlsTrackMetadataEntry({ 6 | this.groupId, 7 | this.name, 8 | List? variantInfos, 9 | }) : _variantInfos = variantInfos ?? const []; 10 | 11 | /// The GROUP-ID value of this track, if the track is derived from an EXT-X-MEDIA tag. Null if the 12 | /// track is not derived from an EXT-X-MEDIA TAG. 13 | final String? groupId; 14 | 15 | /// The NAME value of this track, if the track is derived from an EXT-X-MEDIA tag. Null if the 16 | /// track is not derived from an EXT-X-MEDIA TAG. 17 | final String? name; 18 | 19 | /// The EXT-X-STREAM-INF tags attributes associated with this track. This field is non-applicable (and therefore empty) if this track is derived from an EXT-X-MEDIA tag. 20 | final List _variantInfos; 21 | 22 | List get variantInfos => _variantInfos; 23 | 24 | @override 25 | bool operator ==(dynamic other) { 26 | if (other is HlsTrackMetadataEntry) 27 | return other.groupId == groupId && 28 | other.name == name && 29 | const ListEquality() 30 | .equals(other.variantInfos, variantInfos); 31 | return false; 32 | } 33 | 34 | @override 35 | int get hashCode => Object.hash(groupId, name, variantInfos); 36 | } 37 | -------------------------------------------------------------------------------- /lib/src/metadata.dart: -------------------------------------------------------------------------------- 1 | import 'hls_track_metadata_entry.dart'; 2 | import 'package:collection/collection.dart'; 3 | 4 | class Metadata { 5 | const Metadata(this.list); 6 | 7 | final List list; 8 | 9 | @override 10 | bool operator ==(dynamic other) { 11 | if (other is Metadata) 12 | return const ListEquality() 13 | .equals(other.list, list); 14 | return false; 15 | } 16 | 17 | @override 18 | int get hashCode => list.hashCode; 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/mime_types.dart: -------------------------------------------------------------------------------- 1 | import 'util.dart'; 2 | import 'package:collection/collection.dart' show IterableExtension; 3 | 4 | class MimeTypes { 5 | const MimeTypes._(); 6 | 7 | static const String BASE_TYPE_VIDEO = 'video'; 8 | static const String BASE_TYPE_AUDIO = 'audio'; 9 | static const String BASE_TYPE_TEXT = 'text'; 10 | static const String BASE_TYPE_APPLICATION = 'application'; 11 | static const String VIDEO_MP4 = '$BASE_TYPE_VIDEO/mp4'; 12 | static const String VIDEO_WEBM = '$BASE_TYPE_VIDEO/webm'; 13 | static const String VIDEO_H263 = '$BASE_TYPE_VIDEO/3gpp'; 14 | static const String VIDEO_H264 = '$BASE_TYPE_VIDEO/avc'; 15 | static const String VIDEO_H265 = '$BASE_TYPE_VIDEO/hevc'; 16 | static const String VIDEO_VP8 = '$BASE_TYPE_VIDEO/x-vnd.on2.vp8'; 17 | static const String VIDEO_VP9 = '$BASE_TYPE_VIDEO/x-vnd.on2.vp9'; 18 | static const String VIDEO_AV1 = '$BASE_TYPE_VIDEO/av01'; 19 | static const String VIDEO_MP4V = '$BASE_TYPE_VIDEO/mp4v-es'; 20 | static const String VIDEO_MPEG = '$BASE_TYPE_VIDEO/mpeg'; 21 | static const String VIDEO_MPEG2 = '$BASE_TYPE_VIDEO/mpeg2'; 22 | static const String VIDEO_VC1 = '$BASE_TYPE_VIDEO/wvc1'; 23 | static const String VIDEO_DIVX = '$BASE_TYPE_VIDEO/divx'; 24 | static const String VIDEO_DOLBY_VISION = '$BASE_TYPE_VIDEO/dolby-vision'; 25 | static const String VIDEO_UNKNOWN = '$BASE_TYPE_VIDEO/x-unknown'; 26 | static const String AUDIO_MP4 = '$BASE_TYPE_AUDIO/mp4'; 27 | static const String AUDIO_AAC = '$BASE_TYPE_AUDIO/mp4a-latm'; 28 | static const String AUDIO_WEBM = '$BASE_TYPE_AUDIO/webm'; 29 | static const String AUDIO_MPEG = '$BASE_TYPE_AUDIO/mpeg'; 30 | static const String AUDIO_MPEG_L1 = '$BASE_TYPE_AUDIO/mpeg-L1'; 31 | static const String AUDIO_MPEG_L2 = '$BASE_TYPE_AUDIO/mpeg-L2'; 32 | static const String AUDIO_RAW = '$BASE_TYPE_AUDIO/raw'; 33 | static const String AUDIO_ALAW = '$BASE_TYPE_AUDIO/g711-alaw'; 34 | static const String AUDIO_MLAW = '$BASE_TYPE_AUDIO/g711-mlaw'; 35 | static const String AUDIO_AC3 = '$BASE_TYPE_AUDIO/ac3'; 36 | static const String AUDIO_E_AC3 = '$BASE_TYPE_AUDIO/eac3'; 37 | static const String AUDIO_E_AC3_JOC = '$BASE_TYPE_AUDIO/eac3-joc'; 38 | static const String AUDIO_AC4 = '$BASE_TYPE_AUDIO/ac4'; 39 | static const String AUDIO_TRUEHD = '$BASE_TYPE_AUDIO/true-hd'; 40 | static const String AUDIO_DTS = '$BASE_TYPE_AUDIO/vnd.dts'; 41 | static const String AUDIO_DTS_HD = '$BASE_TYPE_AUDIO/vnd.dts.hd'; 42 | static const String AUDIO_DTS_EXPRESS = 43 | '$BASE_TYPE_AUDIO/vnd.dts.hd;profile=lbr'; 44 | static const String AUDIO_VORBIS = '$BASE_TYPE_AUDIO/vorbis'; 45 | static const String AUDIO_OPUS = '$BASE_TYPE_AUDIO/opus'; 46 | static const String AUDIO_AMR_NB = '$BASE_TYPE_AUDIO/3gpp'; 47 | static const String AUDIO_AMR_WB = '$BASE_TYPE_AUDIO/amr-wb'; 48 | static const String AUDIO_FLAC = '$BASE_TYPE_AUDIO/flac'; 49 | static const String AUDIO_ALAC = '$BASE_TYPE_AUDIO/alac'; 50 | static const String AUDIO_MSGSM = '$BASE_TYPE_AUDIO/gsm'; 51 | static const String AUDIO_UNKNOWN = '$BASE_TYPE_AUDIO/x-unknown'; 52 | static const String TEXT_VTT = '$BASE_TYPE_TEXT/vtt'; 53 | static const String TEXT_SSA = '$BASE_TYPE_TEXT/x-ssa'; 54 | static const String APPLICATION_MP4 = '$BASE_TYPE_APPLICATION/mp4'; 55 | static const String APPLICATION_WEBM = '$BASE_TYPE_APPLICATION/webm'; 56 | static const String APPLICATION_MPD = '$BASE_TYPE_APPLICATION/dash+xml'; 57 | static const String APPLICATION_M3U8 = '$BASE_TYPE_APPLICATION/x-mpegURL'; 58 | static const String APPLICATION_SS = '$BASE_TYPE_APPLICATION/vnd.ms-sstr+xml'; 59 | static const String APPLICATION_ID3 = '$BASE_TYPE_APPLICATION/id3'; 60 | static const String APPLICATION_CEA608 = '$BASE_TYPE_APPLICATION/cea-608'; 61 | static const String APPLICATION_CEA708 = '$BASE_TYPE_APPLICATION/cea-708'; 62 | static const String APPLICATION_SUBRIP = '$BASE_TYPE_APPLICATION/x-subrip'; 63 | static const String APPLICATION_TTML = '$BASE_TYPE_APPLICATION/ttml+xml'; 64 | static const String APPLICATION_TX3G = 65 | '$BASE_TYPE_APPLICATION/x-quicktime-tx3g'; 66 | static const String APPLICATION_MP4VTT = '$BASE_TYPE_APPLICATION/x-mp4-vtt'; 67 | static const String APPLICATION_MP4CEA608 = 68 | '$BASE_TYPE_APPLICATION/x-mp4-cea-608'; 69 | static const String APPLICATION_RAWCC = '$BASE_TYPE_APPLICATION/x-rawcc'; 70 | static const String APPLICATION_VOBSUB = '$BASE_TYPE_APPLICATION/vobsub'; 71 | static const String APPLICATION_PGS = '$BASE_TYPE_APPLICATION/pgs'; 72 | static const String APPLICATION_SCTE35 = '$BASE_TYPE_APPLICATION/x-scte35'; 73 | static const String APPLICATION_CAMERA_MOTION = 74 | '$BASE_TYPE_APPLICATION/x-camera-motion'; 75 | static const String APPLICATION_EMSG = '$BASE_TYPE_APPLICATION/x-emsg'; 76 | static const String APPLICATION_DVBSUBS = '$BASE_TYPE_APPLICATION/dvbsubs'; 77 | static const String APPLICATION_EXIF = '$BASE_TYPE_APPLICATION/x-exif'; 78 | static const String APPLICATION_ICY = '$BASE_TYPE_APPLICATION/x-icy'; 79 | 80 | static const String HLS = 'hls'; 81 | 82 | static final List _customMimeTypes = []; 83 | 84 | static String? _getMimeTypeFromMp4ObjectType(int objectType) { 85 | switch (objectType) { 86 | case 0x20: 87 | return MimeTypes.VIDEO_MP4V; 88 | case 0x21: 89 | return MimeTypes.VIDEO_H264; 90 | case 0x23: 91 | return MimeTypes.VIDEO_H265; 92 | case 0x60: 93 | case 0x61: 94 | case 0x62: 95 | case 0x63: 96 | case 0x64: 97 | case 0x65: 98 | return MimeTypes.VIDEO_MPEG2; 99 | case 0x6A: 100 | return MimeTypes.VIDEO_MPEG; 101 | case 0x69: 102 | case 0x6B: 103 | return MimeTypes.AUDIO_MPEG; 104 | case 0xA3: 105 | return MimeTypes.VIDEO_VC1; 106 | case 0xB1: 107 | return MimeTypes.VIDEO_VP9; 108 | case 0x40: 109 | case 0x66: 110 | case 0x67: 111 | case 0x68: 112 | return MimeTypes.AUDIO_AAC; 113 | case 0xA5: 114 | return MimeTypes.AUDIO_AC3; 115 | case 0xA6: 116 | return MimeTypes.AUDIO_E_AC3; 117 | case 0xA9: 118 | case 0xAC: 119 | return MimeTypes.AUDIO_DTS; 120 | case 0xAA: 121 | case 0xAB: 122 | return MimeTypes.AUDIO_DTS_HD; 123 | case 0xAD: 124 | return MimeTypes.AUDIO_OPUS; 125 | case 0xAE: 126 | return MimeTypes.AUDIO_AC4; 127 | default: 128 | return null; 129 | } 130 | } 131 | 132 | static String? getMediaMimeType(String? codec) { 133 | if (codec == null) return null; 134 | 135 | codec = codec.trim().toLowerCase(); 136 | if (codec.startsWith('avc1') || codec.startsWith('avc3')) 137 | return MimeTypes.VIDEO_H264; 138 | 139 | if (codec.startsWith('hev1') || codec.startsWith('hvc1')) 140 | return MimeTypes.VIDEO_H265; 141 | 142 | if (codec.startsWith('dvav') || 143 | codec.startsWith('dva1') || 144 | codec.startsWith('dvhe') || 145 | codec.startsWith('dvh1')) return MimeTypes.VIDEO_DOLBY_VISION; 146 | 147 | if (codec.startsWith('av01')) return MimeTypes.VIDEO_AV1; 148 | 149 | if (codec.startsWith('vp9') || codec.startsWith('vp09')) 150 | return MimeTypes.VIDEO_VP9; 151 | if (codec.startsWith('vp8') || codec.startsWith('vp08')) 152 | return MimeTypes.VIDEO_VP8; 153 | if (codec.startsWith('mp4a')) { 154 | String? mimeType; 155 | if (codec.startsWith('mp4a.')) { 156 | var objectTypeString = codec.substring(5); 157 | if (objectTypeString.length >= 2) { 158 | try { 159 | var objectTypeHexString = 160 | objectTypeString.substring(0, 2).toUpperCase(); 161 | var objectTypeInt = int.parse(objectTypeHexString, radix: 16); 162 | mimeType = _getMimeTypeFromMp4ObjectType(objectTypeInt); 163 | } on FormatException catch (ignored) { 164 | //do nothing 165 | print(ignored); 166 | } 167 | } 168 | } 169 | return mimeType ??= MimeTypes.AUDIO_AAC; 170 | } 171 | if (codec.startsWith('ac-3') || codec.startsWith('dac3')) 172 | return MimeTypes.AUDIO_AC3; 173 | 174 | if (codec.startsWith('ec-3') || codec.startsWith('dec3')) 175 | return MimeTypes.AUDIO_E_AC3; 176 | 177 | if (codec.startsWith('ec+3')) return MimeTypes.AUDIO_E_AC3_JOC; 178 | 179 | if (codec.startsWith('ac-4') || codec.startsWith('dac4')) 180 | return MimeTypes.AUDIO_AC4; 181 | 182 | if (codec.startsWith('dtsc') || codec.startsWith('dtse')) 183 | return MimeTypes.AUDIO_DTS; 184 | 185 | if (codec.startsWith('dtsh') || codec.startsWith('dtsl')) 186 | return MimeTypes.AUDIO_DTS_HD; 187 | if (codec.startsWith('opus')) return MimeTypes.AUDIO_OPUS; 188 | if (codec.startsWith('vorbis')) return MimeTypes.AUDIO_VORBIS; 189 | if (codec.startsWith('flac')) return MimeTypes.AUDIO_FLAC; 190 | return getCustomMimeTypeForCodec(codec); 191 | } 192 | 193 | static String? getCustomMimeTypeForCodec(String codec) => _customMimeTypes 194 | .firstWhereOrNull((it) => codec.startsWith(it.codecPrefix)) 195 | ?.mimeType; 196 | 197 | static int getTrackType(String? mimeType) { 198 | if (mimeType?.isNotEmpty == false) return Util.TRACK_TYPE_UNKNOWN; 199 | 200 | if (isAudio(mimeType)) return Util.TRACK_TYPE_AUDIO; 201 | if (isVideo(mimeType)) return Util.TRACK_TYPE_VIDEO; 202 | if (isText(mimeType) || 203 | APPLICATION_CEA608 == mimeType || 204 | APPLICATION_CEA708 == mimeType || 205 | APPLICATION_MP4CEA608 == mimeType || 206 | APPLICATION_SUBRIP == mimeType || 207 | APPLICATION_TTML == mimeType || 208 | APPLICATION_TX3G == mimeType || 209 | APPLICATION_MP4VTT == mimeType || 210 | APPLICATION_RAWCC == mimeType || 211 | APPLICATION_VOBSUB == mimeType || 212 | APPLICATION_PGS == mimeType || 213 | APPLICATION_DVBSUBS == mimeType) 214 | return Util.TRACK_TYPE_TEXT; 215 | else if ((APPLICATION_ID3 == mimeType) || 216 | (APPLICATION_EMSG == mimeType) || 217 | (APPLICATION_SCTE35 == mimeType)) 218 | return Util.TRACK_TYPE_METADATA; 219 | else if (APPLICATION_CAMERA_MOTION == mimeType) 220 | return Util.TRACK_TYPE_CAMERA_MOTION; 221 | else 222 | return getTrackTypeForCustomMimeType(mimeType); 223 | } 224 | 225 | static int getTrackTypeForCustomMimeType(String? mimeType) => 226 | _customMimeTypes 227 | .firstWhereOrNull((it) => it.mimeType == mimeType) 228 | ?.trackType ?? 229 | Util.TRACK_TYPE_UNKNOWN; 230 | 231 | static String? getTopLevelType(String? mimeType) { 232 | if (mimeType == null) return null; 233 | var indexOfSlash = mimeType.indexOf('/'); 234 | if (indexOfSlash == -1) return null; 235 | return mimeType.substring(0, indexOfSlash); 236 | } 237 | 238 | static bool isAudio(String? mimeType) => 239 | BASE_TYPE_AUDIO == getTopLevelType(mimeType); 240 | 241 | static bool isVideo(String? mimeType) => 242 | BASE_TYPE_VIDEO == getTopLevelType(mimeType); 243 | 244 | static bool isText(String? mimeType) => 245 | BASE_TYPE_TEXT == getTopLevelType(mimeType); 246 | 247 | static int getTrackTypeOfCodec(String codec) => 248 | getTrackType(getMediaMimeType(codec)); 249 | } 250 | 251 | class CustomMimeType { 252 | const CustomMimeType({ 253 | required this.mimeType, 254 | required this.codecPrefix, 255 | required this.trackType, 256 | }); 257 | 258 | final String mimeType; 259 | final String codecPrefix; 260 | final int trackType; 261 | } 262 | -------------------------------------------------------------------------------- /lib/src/playlist.dart: -------------------------------------------------------------------------------- 1 | abstract class HlsPlaylist { 2 | const HlsPlaylist({ 3 | required this.baseUri, 4 | required this.tags, 5 | required this.hasIndependentSegments, 6 | }); 7 | 8 | /// The base uri. Used to resolve relative paths. 9 | final String? baseUri; 10 | 11 | /// The list of tags in the playlist. 12 | final List tags; 13 | 14 | /// Whether the media is formed of independent segments, as defined by the #EXT-X-INDEPENDENT-SEGMENTS tag. 15 | final bool hasIndependentSegments; 16 | } 17 | -------------------------------------------------------------------------------- /lib/src/rendition.dart: -------------------------------------------------------------------------------- 1 | import 'format.dart'; 2 | 3 | class Rendition { 4 | const Rendition({ 5 | this.url, 6 | required this.format, 7 | required this.groupId, 8 | required this.name, 9 | }); 10 | 11 | /// The rendition's url, or null if the tag does not have a URI attribute. 12 | final Uri? url; 13 | 14 | /// Format information associated with this rendition. 15 | final Format format; 16 | 17 | /// The group to which this rendition belongs. 18 | final String? groupId; 19 | 20 | /// The name of the rendition. 21 | final String? name; 22 | } 23 | -------------------------------------------------------------------------------- /lib/src/scheme_data.dart: -------------------------------------------------------------------------------- 1 | import 'dart:typed_data'; 2 | 3 | class SchemeData { 4 | const SchemeData({ 5 | // @required this.uuid, 6 | this.licenseServerUrl, 7 | required this.mimeType, 8 | this.data, 9 | this.requiresSecureDecryption, 10 | }); 11 | 12 | // /// The uuid of the DRM scheme, or null if the data is universal (i.e. applies to all schemes). 13 | // final String uuid; 14 | 15 | /// The URL of the server to which license requests should be made. May be null if unknown. 16 | final String? licenseServerUrl; 17 | 18 | /// The mimeType of [data]. 19 | final String mimeType; 20 | 21 | /// The initialization base data. 22 | /// you should build pssh manually for use. 23 | final Uint8List? data; 24 | 25 | /// Whether secure decryption is required. 26 | final bool? requiresSecureDecryption; 27 | 28 | SchemeData copyWithData(Uint8List? data) => SchemeData( 29 | // uuid: uuid, 30 | licenseServerUrl: licenseServerUrl, 31 | mimeType: mimeType, 32 | data: data, 33 | requiresSecureDecryption: requiresSecureDecryption, 34 | ); 35 | 36 | @override 37 | bool operator ==(dynamic other) { 38 | if (other is SchemeData) { 39 | return other.mimeType == mimeType && 40 | other.licenseServerUrl == licenseServerUrl && 41 | // other.uuid == uuid && 42 | other.requiresSecureDecryption == requiresSecureDecryption && 43 | other.data == data; 44 | } 45 | 46 | return false; 47 | } 48 | 49 | @override 50 | int get hashCode => Object.hash( 51 | /*uuid, */ 52 | licenseServerUrl, 53 | mimeType, 54 | data, 55 | requiresSecureDecryption); 56 | } 57 | -------------------------------------------------------------------------------- /lib/src/segment.dart: -------------------------------------------------------------------------------- 1 | import 'drm_init_data.dart'; 2 | 3 | class Segment { 4 | const Segment({ 5 | required this.url, 6 | this.initializationSegment, 7 | this.durationUs, 8 | this.title, 9 | this.relativeDiscontinuitySequence, 10 | this.relativeStartTimeUs, 11 | this.drmInitData, 12 | required this.fullSegmentEncryptionKeyUri, 13 | required this.encryptionIV, 14 | required this.byterangeOffset, 15 | required this.byterangeLength, 16 | this.hasGapTag = false, 17 | }); 18 | 19 | final String? url; 20 | 21 | /// The media initialization section for this segment, as defined by #EXT-X-MAP. May be null if the media playlist does not define a media section for this segment. 22 | /// The same instance is used for all segments that share an EXT-X-MAP tag. 23 | final Segment? initializationSegment; 24 | 25 | /// The duration of the segment in microseconds, as defined by #EXTINF. 26 | final int? durationUs; 27 | 28 | /// The human readable title of the segment, or null if the title is unknown. 29 | final String? title; 30 | 31 | /// The number of #EXT-X-DISCONTINUITY tags in the playlist before the segment, or null if it's unknown. 32 | final int? relativeDiscontinuitySequence; //todo change to default 0 33 | 34 | /// The start time of the segment in microseconds, relative to the start of the playlist, or null if it's unknown. 35 | final int? relativeStartTimeUs; 36 | 37 | /// DRM initialization data for sample decryption, or null if the segment does not use CDM-DRM protection. 38 | final DrmInitData? drmInitData; 39 | 40 | /// The encryption identity key uri as defined by #EXT-X-KEY, or null if the segment does not use full segment encryption with identity key. 41 | final String? fullSegmentEncryptionKeyUri; 42 | 43 | /// The encryption initialization vector as defined by #EXT-X-KEY, or null if the segment is not encrypted. 44 | final String? encryptionIV; 45 | 46 | /// The segment's byte range offset, as defined by #EXT-X-BYTERANGE. 47 | final int? byterangeOffset; 48 | 49 | /// The segment's byte range length, as defined by #EXT-X-BYTERANGE, or null if no byte range is specified. 50 | final int? byterangeLength; 51 | 52 | /// Whether the segment is tagged with #EXT-X-GAP. 53 | final bool hasGapTag; 54 | } 55 | -------------------------------------------------------------------------------- /lib/src/util.dart: -------------------------------------------------------------------------------- 1 | import 'mime_types.dart'; 2 | import 'package:quiver/strings.dart'; 3 | import 'exception.dart'; 4 | 5 | class LibUtil { 6 | const LibUtil._(); 7 | 8 | static bool startsWith(List source, List checker) { 9 | for (var i = 0; i < checker.length; i++) 10 | if (source[i] != checker[i]) return false; 11 | 12 | return true; 13 | } 14 | 15 | static String excludeWhiteSpace(String string) => 16 | string.split('').where((it) => !isWhitespace(it.codeUnitAt(0))).join(); 17 | 18 | static bool isLineBreak(int codeUnit) => 19 | (codeUnit == '\n'.codeUnitAt(0)) || (codeUnit == '\r'.codeUnitAt(0)); 20 | 21 | static String? getCodecsOfType(String? codecs, int trackType) { 22 | var output = Util.splitCodecs(codecs) 23 | .where((codec) => trackType == MimeTypes.getTrackTypeOfCodec(codec)) 24 | .join(','); 25 | return output.isEmpty ? null : output; 26 | } 27 | 28 | static int parseXsDateTime(String value) { 29 | var pattern = 30 | r'(\d\d\d\d)\-(\d\d)\-(\d\d)[Tt](\d\d):(\d\d):(\d\d)([\\.,](\d+))?([Zz]|((\+|\-)(\d?\d):?(\d\d)))?'; 31 | List matchList = RegExp(pattern).allMatches(value).toList(); 32 | if (matchList.isEmpty) 33 | throw ParserException('Invalid date/time format: $value'); 34 | var match = matchList[0]; 35 | int timezoneShift; 36 | if (match.group(9) == null) { 37 | // No time zone specified. 38 | timezoneShift = 0; 39 | } else if (match.group(9) == 'Z' || match.group(9) == 'z') { 40 | timezoneShift = 0; 41 | } else { 42 | timezoneShift = 43 | int.parse(match.group(12)!) * 60 + int.parse(match.group(13)!); 44 | if ('-' == match.group(11)) timezoneShift *= -1; 45 | } 46 | 47 | //todo UTCではなくGMT? 48 | var dateTime = DateTime.utc( 49 | int.parse(match.group(1)!), 50 | int.parse(match.group(2)!), 51 | int.parse(match.group(3)!), 52 | int.parse(match.group(4)!), 53 | int.parse(match.group(5)!), 54 | int.parse(match.group(6)!)); 55 | if (match.group(8)?.isNotEmpty == true) { 56 | //todo ここ実装再検討 57 | } 58 | 59 | var time = dateTime.millisecondsSinceEpoch; 60 | if (timezoneShift != 0) { 61 | time -= timezoneShift * 60000; 62 | } 63 | 64 | return time; 65 | } 66 | 67 | // static int msToUs(int timeMs) => 68 | // (timeMs == null || timeMs == Util.TIME_END_OF_SOURCE) 69 | // ? timeMs 70 | // : (timeMs * 1000); 71 | } 72 | 73 | class Util { 74 | const Util._(); 75 | 76 | static const int SELECTION_FLAG_DEFAULT = 1; 77 | static const int SELECTION_FLAG_FORCED = 1 << 1; // 2 78 | static const int SELECTION_FLAG_AUTOSELECT = 1 << 2; // 4 79 | static const int ROLE_FLAG_DESCRIBES_VIDEO = 1 << 9; 80 | static const int ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND = 1 << 10; 81 | static const int ROLE_FLAG_TRANSCRIBES_DIALOG = 1 << 12; 82 | static const int ROLE_FLAG_EASY_TO_READ = 1 << 13; 83 | 84 | /// A type constant for tracks of unknown type. 85 | static const int TRACK_TYPE_UNKNOWN = -1; 86 | 87 | /// A type constant for tracks of some default type, where the type itself is unknown. 88 | static const int TRACK_TYPE_DEFAULT = 0; 89 | 90 | /// A type constant for audio tracks. 91 | static const int TRACK_TYPE_AUDIO = 1; 92 | 93 | /// A type constant for video tracks. 94 | static const int TRACK_TYPE_VIDEO = 2; 95 | 96 | /// A type constant for text tracks. 97 | static const int TRACK_TYPE_TEXT = 3; 98 | 99 | /// A type constant for metadata tracks. 100 | static const int TRACK_TYPE_METADATA = 4; 101 | 102 | /// A type constant for camera motion tracks. 103 | static const int TRACK_TYPE_CAMERA_MOTION = 5; 104 | 105 | /// A type constant for a dummy or empty track. 106 | static const int TRACK_TYPE_NONE = 6; 107 | 108 | static const int TIME_END_OF_SOURCE = 0; 109 | 110 | static List splitCodecs(String? codecs) => codecs?.isNotEmpty != true 111 | ? [] 112 | : codecs!.trim().split(RegExp(r'(\s*,\s*)')); 113 | } 114 | 115 | class CencType { 116 | const CencType._(); 117 | 118 | static const String CENC = 'TYPE_CENC'; 119 | static const String CBCS = 'TYPE_CBCS'; 120 | } 121 | -------------------------------------------------------------------------------- /lib/src/variant.dart: -------------------------------------------------------------------------------- 1 | import 'format.dart'; 2 | 3 | class Variant { 4 | const Variant({ 5 | required this.url, 6 | required this.format, 7 | required this.videoGroupId, 8 | required this.audioGroupId, 9 | required this.subtitleGroupId, 10 | required this.captionGroupId, 11 | }); 12 | 13 | /// The variant's url. 14 | final Uri url; 15 | 16 | /// Format information associated with this variant. 17 | final Format format; 18 | 19 | /// The video rendition group referenced by this variant, or [Null]. 20 | final String? videoGroupId; 21 | 22 | /// The audio rendition group referenced by this variant, or [Null]. 23 | final String? audioGroupId; 24 | 25 | /// The subtitle rendition group referenced by this variant, or [Null]. 26 | final String? subtitleGroupId; 27 | 28 | /// The caption rendition group referenced by this variant, or [Null]. 29 | final String? captionGroupId; 30 | 31 | /// Returns a copy of this instance with the given [Format]. 32 | Variant copyWithFormat(Format format) => Variant( 33 | url: url, 34 | format: format, 35 | videoGroupId: videoGroupId, 36 | audioGroupId: audioGroupId, 37 | subtitleGroupId: subtitleGroupId, 38 | captionGroupId: captionGroupId, 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /lib/src/variant_info.dart: -------------------------------------------------------------------------------- 1 | class VariantInfo { 2 | const VariantInfo({ 3 | this.bitrate, 4 | this.videoGroupId, 5 | this.audioGroupId, 6 | this.subtitleGroupId, 7 | this.captionGroupId, 8 | }); 9 | 10 | /// The bitrate as declared by the EXT-X-STREAM-INF tag. */ 11 | final int? bitrate; 12 | 13 | /// The VIDEO value as defined in the EXT-X-STREAM-INF tag, or null if the VIDEO attribute is not 14 | /// present. 15 | final String? videoGroupId; 16 | 17 | /// The AUDIO value as defined in the EXT-X-STREAM-INF tag, or null if the AUDIO attribute is not 18 | /// present. 19 | final String? audioGroupId; 20 | 21 | /// The SUBTITLES value as defined in the EXT-X-STREAM-INF tag, or null if the SUBTITLES 22 | /// attribute is not present. 23 | final String? subtitleGroupId; 24 | 25 | /// The CLOSED-CAPTIONS value as defined in the EXT-X-STREAM-INF tag, or null if the 26 | /// CLOSED-CAPTIONS attribute is not present. 27 | final String? captionGroupId; 28 | 29 | @override 30 | bool operator ==(dynamic other) { 31 | if (other is VariantInfo) 32 | return other.bitrate == bitrate && 33 | other.videoGroupId == videoGroupId && 34 | other.audioGroupId == audioGroupId && 35 | other.subtitleGroupId == subtitleGroupId && 36 | other.captionGroupId == captionGroupId; 37 | return false; 38 | } 39 | 40 | @override 41 | int get hashCode => Object.hash( 42 | bitrate, videoGroupId, audioGroupId, subtitleGroupId, captionGroupId); 43 | } 44 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: flutter_hls_parser 2 | description: dart plugin for parse m3u8 file for HLS. both of master and media file is supported. 3 | version: 2.0.1 4 | homepage: https://github.com/HiroyukTamura/flutter_hls_parser 5 | repository: https://github.com/HiroyukTamura/flutter_hls_parser 6 | 7 | environment: 8 | sdk: '>=2.12.0 <3.0.0' 9 | 10 | dependencies: 11 | flutter: 12 | sdk: flutter 13 | quiver: ^3.2.1 14 | collection: ^1.17.0 15 | 16 | dev_dependencies: 17 | test: 18 | flutter_test: 19 | sdk: flutter 20 | pedantic: 21 | -------------------------------------------------------------------------------- /test/hls_master_playlist_parser_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_hls_parser/src/metadata.dart'; 2 | import 'package:test/test.dart'; 3 | import 'package:flutter_hls_parser/src/hls_master_playlist.dart'; 4 | import 'package:flutter_hls_parser/src/exception.dart'; 5 | import 'package:flutter_hls_parser/src/mime_types.dart'; 6 | import 'package:flutter_hls_parser/src/variant_info.dart'; 7 | import 'package:flutter_hls_parser/src/hls_track_metadata_entry.dart'; 8 | import 'package:flutter_hls_parser/src/hls_playlist_parser.dart'; 9 | 10 | void main() { 11 | const PLAYLIST_URI = 'https://example.com/test.m3u8'; 12 | 13 | const PLAYLIST_SIMPLE = ''' 14 | #EXTM3U 15 | 16 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS="mp4a.40.2,avc1.66.30",RESOLUTION=304x128 17 | http://example.com/low.m3u8 18 | 19 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS="mp4a.40.2 , avc1.66.30 " 20 | http://example.com/spaces_in_codecs.m3u8 21 | 22 | #EXT-X-STREAM-INF:BANDWIDTH=2560000,FRAME-RATE=25,RESOLUTION=384x160 23 | http://example.com/mid.m3u8 24 | 25 | #EXT-X-STREAM-INF:BANDWIDTH=7680000,FRAME-RATE=29.997 26 | http://example.com/hi.m3u8 27 | 28 | #EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="mp4a.40.5" 29 | http://example.com/audio-only.m3u8 30 | '''; 31 | 32 | const PLAYLIST_WITH_AVG_BANDWIDTH = ''' 33 | #EXTM3U 34 | 35 | #EXT-X-STREAM-INF:BANDWIDTH=1280000, 36 | CODECS="mp4a.40.2,avc1.66.30",RESOLUTION=304x128 37 | http://example.com/low.m3u8 38 | 39 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,AVERAGE-BANDWIDTH=1270000, 40 | 41 | CODECS="mp4a.40.2 , avc1.66.30 " 42 | http://example.com/spaces_in_codecs.m3u8 43 | '''; 44 | 45 | const PLAYLIST_WITH_INVALID_HEADER = ''' 46 | #EXTMU3 47 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS="mp4a.40.2,avc1.66.30",RESOLUTION=304x128 48 | http://example.com/low.m3u8 49 | '''; 50 | 51 | const PLAYLIST_WITH_CC = ''' 52 | #EXTM3U 53 | #EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID="cc1","LANGUAGE="es",NAME="Eng",INSTREAM-ID="SERVICE4" 54 | #EXT-X-STREAM-INF:BANDWIDTH=1280000, CODECS="mp4a.40.2,avc1.66.30",RESOLUTION=304x128 55 | http://example.com/low.m3u8 56 | '''; 57 | 58 | const PLAYLIST_WITH_CHANNELS_ATTRIBUTE = ''' 59 | #EXTM3U 60 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",CHANNELS="6",NAME="Eng6",URI="something.m3u8" 61 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",CHANNELS="2/6",NAME="Eng26",URI="something2.m3u8" 62 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Eng",URI="something3.m3u8" 63 | #EXT-X-STREAM-INF:BANDWIDTH=1280000, 64 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="Eng",CODECS="mp4a.40.2,avc1.66.30",AUDIO="audio",RESOLUTION=304x128,URI="something3.m3u8" 65 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS="mp4a.40.2,avc1.66.30",AUDIO="audio",RESOLUTION=304x128 66 | http://example.com/low.m3u8 67 | '''; 68 | 69 | const PLAYLIST_WITHOUT_CC = ''' 70 | #EXTM3U 71 | #EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID="cc1",LANGUAGE="es",NAME="Eng",INSTREAM-ID="SERVICE4" 72 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS="mp4a.40.2,avc1.66.30",RESOLUTION=304x128,CLOSED-CAPTIONS=NONE 73 | http://example.com/low.m3u8 74 | '''; 75 | 76 | const PLAYLIST_WITH_SUBTITLES = ''' 77 | #EXTM3U 78 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="sub1",LANGUAGE="es",NAME="Eng" 79 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS="mp4a.40.2,avc1.66.30",RESOLUTION=304x128 80 | http://example.com/low.m3u8 81 | '''; 82 | 83 | const PLAYLIST_WITH_AUDIO_MEDIA_TAG = ''' 84 | #EXTM3U 85 | #EXT-X-STREAM-INF:BANDWIDTH=2227464,CODECS="avc1.640020,mp4a.40.2",AUDIO="aud1" 86 | uri1.m3u8 87 | #EXT-X-STREAM-INF:BANDWIDTH=8178040,CODECS="avc1.64002a,mp4a.40.2",AUDIO="aud1" 88 | uri2.m3u8 89 | #EXT-X-STREAM-INF:BANDWIDTH=2448841,CODECS="avc1.640020,ac-3",AUDIO="aud2" 90 | uri1.m3u8 91 | #EXT-X-STREAM-INF:BANDWIDTH=8399417,CODECS="avc1.64002a,ac-3",AUDIO="aud2" 92 | uri2.m3u8 93 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud1",LANGUAGE="en",NAME="English",AUTOSELECT=YES,DEFAULT=YES,CHANNELS="2",URI="a1/prog_index.m3u8 94 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud2",LANGUAGE="en",NAME="English",AUTOSELECT=YES,DEFAULT=YES,CHANNELS="6",URI="a2/prog_index.m3u8 95 | '''; 96 | 97 | const PLAYLIST_WITH_INDEPENDENT_SEGMENTS = ''' 98 | #EXTM3U 99 | 100 | #EXT-X-INDEPENDENT-SEGMENTS 101 | 102 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS="mp4a.40.2,avc1.66.30",RESOLUTION=304x128 103 | http://example.com/low.m3u8 104 | 105 | #EXT-X-STREAM-INF:BANDWIDTH=1280000,CODECS="mp4a.40.2 , avc1.66.30 " 106 | http://example.com/spaces_in_codecs.m3u8 107 | '''; 108 | 109 | const PLAYLIST_WITH_MATCHING_STREAM_INF_URLS = ''' 110 | #EXTM3U 111 | #EXT-X-VERSION: 112 | 113 | #EXT-X-STREAM-INF:BANDWIDTH=2227464,CLOSED-CAPTIONS="cc1",AUDIO="aud1",SUBTITLES="sub1" 114 | v5/prog_index.m3u8 115 | #EXT-X-STREAM-INF:BANDWIDTH=6453202,CLOSED-CAPTIONS="cc1",AUDIO="aud1",SUBTITLES="sub1" 116 | v8/prog_index.m3u8 117 | #EXT-X-STREAM-INF:BANDWIDTH=5054232,CLOSED-CAPTIONS="cc1",AUDIO="aud1",SUBTITLES="sub1" 118 | v7/prog_index.m3u8 119 | 120 | #EXT-X-STREAM-INF:BANDWIDTH=2448841,CLOSED-CAPTIONS="cc1",AUDIO="aud2",SUBTITLES="sub1" 121 | v5/prog_index.m3u8 122 | #EXT-X-STREAM-INF:BANDWIDTH=8399417,CLOSED-CAPTIONS="cc1",AUDIO="aud2",SUBTITLES="sub1" 123 | v9/prog_index.m3u8 124 | #EXT-X-STREAM-INF:BANDWIDTH=5275609,CLOSED-CAPTIONS="cc1",AUDIO="aud2",SUBTITLES="sub1" 125 | v7/prog_index.m3u8 126 | 127 | #EXT-X-STREAM-INF:BANDWIDTH=2256841,CLOSED-CAPTIONS="cc1",AUDIO="aud3",SUBTITLES="sub1" 128 | v5/prog_index.m3u8 129 | #EXT-X-STREAM-INF:BANDWIDTH=8207417,CLOSED-CAPTIONS="cc1",AUDIO="aud3",SUBTITLES="sub1" 130 | v9/prog_index.m3u8 131 | #EXT-X-STREAM-INF:BANDWIDTH=6482579,CLOSED-CAPTIONS="cc1",AUDIO="aud3",SUBTITLES="sub1" 132 | v8/prog_index.m3u8 133 | 134 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud1",NAME="English",URI="a1/index.m3u8" 135 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud2",NAME="English",URI="a2/index.m3u8 136 | #EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="aud3",NAME="English",URI="a3/index.m3u8 137 | 138 | #EXT-X-MEDIA:TYPE=CLOSED-CAPTIONS,GROUP-ID="cc1",NAME="English",INSTREAM-ID="CC1" 139 | #EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="sub1",NAME="English",URI="s1/en/prog_index.m3u8" 140 | '''; 141 | 142 | const PLAYLIST_WITH_VARIABLE_SUBSTITUTION = r''' 143 | #EXTM3U 144 | 145 | #EXT-X-DEFINE:NAME="codecs",VALUE="mp4a.40.5" 146 | #EXT-X-DEFINE:NAME="tricky",VALUE="This/{$nested}/reference/shouldnt/work" 147 | #EXT-X-DEFINE:NAME="nested",VALUE="This should not be inserted" 148 | #EXT-X-STREAM-INF:BANDWIDTH=65000,CODECS="{$codecs}" 149 | http://example.com/{$tricky} 150 | '''; 151 | 152 | Metadata _createExtXStreamInfMetadata(List infos) => Metadata([ 153 | HlsTrackMetadataEntry( 154 | variantInfos: infos, 155 | ) 156 | ]); 157 | 158 | Metadata _createExtXMediaMetadata(String groupId, String name) => Metadata([ 159 | HlsTrackMetadataEntry( 160 | groupId: groupId, 161 | name: name, 162 | ) 163 | ]); 164 | 165 | VariantInfo _createVariantInfo(int bitrate, String audioGroupId) => 166 | VariantInfo( 167 | bitrate: bitrate, 168 | audioGroupId: audioGroupId, 169 | subtitleGroupId: 'sub1', 170 | captionGroupId: 'cc1', 171 | ); 172 | 173 | ///@[HlsPlaylistParser.parseMasterPlaylist(extraLines, baseUri)] 174 | Future parseMasterPlaylist( 175 | String uri, List extraLines) async { 176 | var playlistUri = Uri.parse(uri); 177 | var parser = HlsPlaylistParser.create(); 178 | var playList = await parser.parse(playlistUri, extraLines); 179 | return playList as HlsMasterPlaylist; 180 | } 181 | 182 | test('testParseMasterPlaylist', () async { 183 | HlsMasterPlaylist masterPlaylist; 184 | masterPlaylist = 185 | await parseMasterPlaylist(PLAYLIST_URI, PLAYLIST_SIMPLE.split('\n')); 186 | 187 | var variants = masterPlaylist.variants; 188 | 189 | expect(variants.length, 5); 190 | expect(masterPlaylist.muxedCaptionFormats, []); 191 | 192 | variants.asMap().forEach((i, variant) { 193 | switch (i) { 194 | case 0: 195 | expect(variant.format.bitrate, 1280000); 196 | expect(variant.format.codecs, 'mp4a.40.2,avc1.66.30'); 197 | expect(variant.format.width, 304); 198 | expect(variant.format.height, 128); 199 | expect(variant.url, Uri.parse('http://example.com/low.m3u8')); 200 | break; 201 | case 1: 202 | expect(variant.format.bitrate, 1280000); 203 | expect(variant.format.codecs, 'mp4a.40.2 , avc1.66.30 '); 204 | expect(variant.url, 205 | Uri.parse('http://example.com/spaces_in_codecs.m3u8')); 206 | break; 207 | case 2: 208 | expect(variant.format.bitrate, 2560000); 209 | expect(variant.format.codecs, isNull); 210 | expect(variant.format.width, 384); 211 | expect(variant.format.height, 160); 212 | expect(variant.format.frameRate, 25); 213 | expect(variant.url, Uri.parse('http://example.com/mid.m3u8')); 214 | break; 215 | case 3: 216 | expect(variant.format.bitrate, 7680000); 217 | expect(variant.format.codecs, isNull); 218 | expect(variant.format.width, isNull); 219 | expect(variant.format.height, isNull); 220 | expect(variant.format.frameRate, 29.997); 221 | expect(variant.url, Uri.parse('http://example.com/hi.m3u8')); 222 | break; 223 | case 4: 224 | expect(variant.format.bitrate, 65000); 225 | expect(variant.format.codecs, 'mp4a.40.5'); 226 | expect(variant.format.width, isNull); 227 | expect(variant.format.height, isNull); 228 | expect(variant.format.frameRate, isNull); 229 | expect(variant.url, Uri.parse('http://example.com/audio-only.m3u8')); 230 | break; 231 | } 232 | }); 233 | }); 234 | 235 | test('testMasterPlaylistWithBandwdithAverage', () async { 236 | var masterPlaylist = await parseMasterPlaylist( 237 | PLAYLIST_URI, PLAYLIST_WITH_AVG_BANDWIDTH.split('\n')); 238 | 239 | var variants = masterPlaylist.variants; 240 | 241 | expect(variants[0].format.bitrate, 1280000); 242 | expect(variants[1].format.bitrate, 1270000); 243 | }); 244 | 245 | test('testPlaylistWithInvalidHeader', () async { 246 | try { 247 | await parseMasterPlaylist( 248 | PLAYLIST_URI, PLAYLIST_WITH_INVALID_HEADER.split('\n')); 249 | fail('Expected exception not thrown.'); 250 | } on ParserException catch (_) { 251 | // Expected due to invalid header. 252 | } 253 | }); 254 | 255 | test('testPlaylistWithClosedCaption', () async { 256 | var masterPlaylist = await parseMasterPlaylist( 257 | PLAYLIST_URI, 258 | PLAYLIST_WITH_CC.split('\n'), 259 | ); 260 | expect(masterPlaylist.muxedCaptionFormats.length, 1); 261 | expect(masterPlaylist.muxedCaptionFormats[0].sampleMimeType, 262 | MimeTypes.APPLICATION_CEA708); 263 | expect(masterPlaylist.muxedCaptionFormats[0].accessibilityChannel, 4); 264 | expect(masterPlaylist.muxedCaptionFormats[0].language, 'es'); 265 | }); 266 | 267 | test('testPlaylistWithChannelsAttribute', () async { 268 | var masterPlaylist = await parseMasterPlaylist( 269 | PLAYLIST_URI, PLAYLIST_WITH_CHANNELS_ATTRIBUTE.split('\n')); 270 | var audios = masterPlaylist.audios; 271 | expect(audios.length, 3); 272 | expect(audios[0].format.channelCount, 6); 273 | expect(audios[1].format.channelCount, 2); 274 | expect(audios[2].format.channelCount, isNull); 275 | }); 276 | 277 | test('testPlaylistWithoutClosedCaptions', () async { 278 | var masterPlaylist = await parseMasterPlaylist( 279 | PLAYLIST_URI, 280 | PLAYLIST_WITHOUT_CC.split('\n'), 281 | ); 282 | expect(masterPlaylist.muxedCaptionFormats, isEmpty); 283 | }); 284 | 285 | test('testCodecPropagation', () async { 286 | var masterPlaylist = await parseMasterPlaylist( 287 | PLAYLIST_URI, 288 | PLAYLIST_WITH_AUDIO_MEDIA_TAG.split('\n'), 289 | ); 290 | expect(masterPlaylist.audios[0].format.codecs, 'mp4a.40.2'); 291 | expect(masterPlaylist.audios[0].format.sampleMimeType, MimeTypes.AUDIO_AAC); 292 | expect(masterPlaylist.audios[1].format.codecs, 'ac-3'); 293 | expect(masterPlaylist.audios[1].format.sampleMimeType, MimeTypes.AUDIO_AC3); 294 | }); 295 | 296 | test('testAudioIdPropagation', () async { 297 | var playlist = await parseMasterPlaylist( 298 | PLAYLIST_URI, 299 | PLAYLIST_WITH_AUDIO_MEDIA_TAG.split('\n'), 300 | ); 301 | expect(playlist.audios[0].format.id, 'aud1:English'); 302 | expect(playlist.audios[1].format.id, 'aud2:English'); 303 | }); 304 | 305 | test('testCCIdPropagation', () async { 306 | var playlist = await parseMasterPlaylist( 307 | PLAYLIST_URI, 308 | PLAYLIST_WITH_CC.split('\n'), 309 | ); 310 | expect(playlist.muxedCaptionFormats[0].id, 'cc1:Eng'); 311 | }); 312 | 313 | test('testSubtitleIdPropagation', () async { 314 | var playlist = await parseMasterPlaylist( 315 | PLAYLIST_URI, 316 | PLAYLIST_WITH_SUBTITLES.split('\n'), 317 | ); 318 | expect(playlist.subtitles[0].format.id, 'sub1:Eng'); 319 | }); 320 | 321 | test('testIndependentSegments', () async { 322 | var playlistWithIndependentSegments = await parseMasterPlaylist( 323 | PLAYLIST_URI, PLAYLIST_WITH_INDEPENDENT_SEGMENTS.split('\n')); 324 | expect(playlistWithIndependentSegments.hasIndependentSegments, true); 325 | var playlistWithoutIndependentSegments = 326 | await parseMasterPlaylist(PLAYLIST_URI, PLAYLIST_SIMPLE.split('\n')); 327 | expect(playlistWithoutIndependentSegments.hasIndependentSegments, false); 328 | }); 329 | 330 | test('testVariableSubstitution', () async { 331 | var playlistWithSubstitutions = await parseMasterPlaylist( 332 | PLAYLIST_URI, PLAYLIST_WITH_VARIABLE_SUBSTITUTION.split('\n')); 333 | var variant = playlistWithSubstitutions.variants[0]; 334 | expect(variant.format.codecs, 'mp4a.40.5'); 335 | expect( 336 | variant.url, 337 | Uri.parse( 338 | r'http://example.com/This/{$nested}/reference/shouldnt/work')); 339 | }); 340 | 341 | test('testHlsMetadata', () async { 342 | var playlist = await parseMasterPlaylist( 343 | PLAYLIST_URI, PLAYLIST_WITH_MATCHING_STREAM_INF_URLS.split('\n')); 344 | 345 | expect(playlist.variants.length, 4); 346 | 347 | expect( 348 | playlist.variants[0].format.metadata, 349 | _createExtXStreamInfMetadata([ 350 | _createVariantInfo(2227464, 'aud1'), 351 | _createVariantInfo(2448841, 'aud2'), 352 | _createVariantInfo(2256841, 'aud3'), 353 | ])); 354 | expect( 355 | playlist.variants[1].format.metadata, 356 | _createExtXStreamInfMetadata([ 357 | _createVariantInfo(6453202, 'aud1'), 358 | _createVariantInfo(6482579, 'aud3'), 359 | ])); 360 | expect( 361 | playlist.variants[2].format.metadata, 362 | _createExtXStreamInfMetadata([ 363 | _createVariantInfo(5054232, 'aud1'), 364 | _createVariantInfo(5275609, 'aud2'), 365 | ])); 366 | expect( 367 | playlist.variants[3].format.metadata, 368 | _createExtXStreamInfMetadata([ 369 | _createVariantInfo(8399417, 'aud2'), 370 | _createVariantInfo(8207417, 'aud3'), 371 | ])); 372 | 373 | expect(playlist.audios.length, 3); 374 | expect(playlist.audios[0].format.metadata, 375 | _createExtXMediaMetadata('aud1', 'English')); 376 | expect(playlist.audios[1].format.metadata, 377 | _createExtXMediaMetadata('aud2', 'English')); 378 | expect(playlist.audios[2].format.metadata, 379 | _createExtXMediaMetadata('aud3', 'English')); 380 | }); 381 | } 382 | -------------------------------------------------------------------------------- /test/hls_media_playlist_parser_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_hls_parser/src/hls_media_playlist.dart'; 2 | import 'package:flutter_hls_parser/src/util.dart'; 3 | import 'package:test/test.dart'; 4 | import 'package:flutter_hls_parser/src/exception.dart'; 5 | import 'package:flutter_hls_parser/src/hls_master_playlist.dart'; 6 | import 'package:flutter_hls_parser/src/hls_playlist_parser.dart'; 7 | 8 | void main() { 9 | const PLAYLIST_URL = 'https://example.com/test.m3u8'; 10 | 11 | const PLAYLIST_STRING = ''' 12 | #EXTM3U 13 | #EXT-X-VERSION:3 14 | #EXT-X-PLAYLIST-TYPE:VOD 15 | #EXT-X-START:TIME-OFFSET=-25 16 | #EXT-X-TARGETDURATION:8 17 | #EXT-X-MEDIA-SEQUENCE:2679 18 | #EXT-X-DISCONTINUITY-SEQUENCE:4 19 | #EXT-X-ALLOW-CACHE:YES 20 | 21 | #EXTINF:7.975, 22 | #EXT-X-BYTERANGE:51370@0 23 | https://priv.example.com/fileSequence2679.ts 24 | 25 | #EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=2680",IV=0x1566B 26 | #EXTINF:7.975,segment title 27 | #EXT-X-BYTERANGE:51501@2147483648 28 | https://priv.example.com/fileSequence2680.ts 29 | 30 | #EXT-X-KEY:METHOD=NONE 31 | #EXTINF:7.941,segment title .,:/# with interesting chars 32 | #EXT-X-BYTERANGE:51501 33 | https://priv.example.com/fileSequence2681.ts 34 | 35 | #EXT-X-DISCONTINUITY 36 | #EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=2682" 37 | #EXTINF:7.975 38 | #EXT-X-BYTERANGE:51740 39 | https://priv.example.com/fileSequence2682.ts 40 | 41 | #EXTINF:7.975, 42 | https://priv.example.com/fileSequence2683.ts 43 | #EXT-X-ENDLIST 44 | '''; 45 | 46 | const PLAYLIST_STRING_AES = ''' 47 | #EXTM3U 48 | #EXT-X-MEDIA-SEQUENCE:0 49 | #EXTINF:8, 50 | https://priv.example.com/1.ts 51 | 52 | #EXT-X-KEY:METHOD=SAMPLE-AES,URI="data:text/plain;base64,VGhpcyBpcyBhbiBlYXN0ZXIgZWdn",IV=0x9358382AEB449EE23C3D809DA0B9CCD3,KEYFORMATVERSIONS="1",KEYFORMAT="com.widevine",IV=0x1566B 53 | #EXTINF:8, 54 | https://priv.example.com/2.ts 55 | #EXT-X-ENDLIST; 56 | '''; 57 | 58 | const PLAYLIST_STRING_AES_CENC = ''' 59 | #EXTM3U 60 | #EXT-X-MEDIA-SEQUENCE:0 61 | #EXTINF:8, 62 | https://priv.example.com/1.ts 63 | 64 | #EXT-X-KEY:URI="data:text/plain;base64,VGhpcyBpcyBhbiBlYXN0ZXIgZWdn",IV=0x9358382AEB449EE23C3D809DA0B9CCD3,KEYFORMATVERSIONS="1",KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",IV=0x1566B,METHOD=SAMPLE-AES-CENC 65 | #EXTINF:8, 66 | https://priv.example.com/2.ts 67 | #EXT-X-ENDLIST 68 | '''; 69 | 70 | const PLAYLIST_STRING_AES_CTR = ''' 71 | #EXTM3U 72 | #EXT-X-MEDIA-SEQUENCE:0 73 | #EXTINF:8, 74 | https://priv.example.com/1.ts 75 | 76 | #EXT-X-KEY:METHOD=SAMPLE-AES-CTR,URI="data:text/plain;base64,VGhpcyBpcyBhbiBlYXN0ZXIgZWdn",IV=0x9358382AEB449EE23C3D809DA0B9CCD3,KEYFORMATVERSIONS="1",KEYFORMAT="com.widevine",IV=0x1566B 77 | #EXTINF:8, 78 | https://priv.example.com/2.ts 79 | #EXT-X-ENDLIST; 80 | '''; 81 | 82 | const PLAYLIST_STRING_MULTI_EXT = ''' 83 | #EXTM3U 84 | #EXT-X-VERSION:6 85 | #EXT-X-TARGETDURATION:6 86 | #EXT-X-MAP:URI="map.mp4" 87 | #EXTINF:5.005, 88 | s000000.mp4 89 | #EXT-X-KEY:METHOD=SAMPLE-AES,KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",KEYFORMATVERSIONS="1",URI="data:text/plain;base64,Tm90aGluZyB0byBzZWUgaGVyZQ==" 90 | #EXT-X-KEY:METHOD=SAMPLE-AES,KEYFORMAT="com.microsoft.playready",KEYFORMATVERSIONS="1",URI="data:text/plain;charset=UTF-16;base64,VGhpcyBpcyBhbiBlYXN0ZXIgZWdn" 91 | #EXT-X-KEY:METHOD=SAMPLE-AES,KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1",URI="skd://QW5vdGhlciBlYXN0ZXIgZWdn" 92 | #EXT-X-MAP:URI="map.mp4" 93 | #EXTINF:5.005, 94 | s000000.mp4 95 | #EXTINF:5.005, 96 | s000001.mp4 97 | #EXT-X-KEY:METHOD=SAMPLE-AES,KEYFORMAT="urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed",KEYFORMATVERSIONS="1",URI="data:text/plain;base64,RG9uJ3QgeW91IGdldCB0aXJlZCBvZiBkb2luZyB0aGlzPw==" 98 | 99 | #EXT-X-KEY:METHOD=SAMPLE-AES,KEYFORMAT="com.microsoft.playready",KEYFORMATVERSIONS="1",URI="data:text/plain;charset=UTF-16;base64,T2ssIGl0J3Mgbm90IGZ1biBhbnltb3Jl" 100 | #EXT-X-KEY:METHOD=SAMPLE-AES,KEYFORMAT="com.apple.streamingkeydelivery",KEYFORMATVERSIONS="1",URI="skd://V2FpdCB1bnRpbCB5b3Ugc2VlIHRoZSBuZXh0IG9uZSE=" 101 | #EXTINF:5.005, 102 | s000024.mp4 103 | #EXTINF:5.005, 104 | s000025.mp4 105 | #EXT-X-KEY:METHOD=NONE 106 | #EXTINF:5.005, 107 | s000026.mp4 108 | #EXTINF:5.005, 109 | s000026.mp4; 110 | '''; 111 | 112 | const PLAYLIST_STRING_GAP_TAG = ''' 113 | #EXTM3U 114 | #EXT-X-VERSION:3 115 | #EXT-X-TARGETDURATION:5 116 | #EXT-X-PLAYLIST-TYPE:VOD 117 | #EXT-X-MEDIA-SEQUENCE:0 118 | #EXT-X-PROGRAM-DATE-TIME:2016-09-22T02:00:01+00:00 119 | #EXT-X-KEY:METHOD=AES-128,URI="https://example.com/key?value=something" 120 | #EXTINF:5.005, 121 | 02/00/27.ts 122 | #EXTINF:5.005, 123 | 02/00/32.ts 124 | #EXT-X-KEY:METHOD=NONE 125 | #EXTINF:5.005, 126 | #EXT-X-GAP 127 | ../dummy.ts 128 | #EXT-X-KEY:METHOD=AES-128,URI="https://key-service.bamgrid.com/1.0/key?hex-value=9FB8989D15EEAAF8B21B860D7ED3072A",IV=0x410C8AC18AA42EFA18B5155484F5FC34 129 | #EXTINF:5.005, 130 | 02/00/42.ts 131 | #EXTINF:5.005, 132 | 02/00/47.ts 133 | '''; 134 | 135 | const PLAYLIST_STRING_MAP_TAG = ''' 136 | #EXTM3U 137 | #EXT-X-VERSION:3 138 | #EXT-X-TARGETDURATION:5 139 | #EXT-X-MEDIA-SEQUENCE:10 140 | #EXTINF:5.005, 141 | 02/00/27.ts 142 | #EXT-X-MAP:URI="init1.ts" 143 | #EXTINF:5.005, 144 | 02/00/32.ts 145 | #EXTINF:5.005, 146 | 02/00/42.ts 147 | #EXT-X-MAP:URI="init2.ts" 148 | #EXTINF:5.005, 149 | 02/00/47.ts; 150 | '''; 151 | 152 | const PLAYLIST_STRING_ENCRYPTED_MAP = ''' 153 | #EXTM3U 154 | #EXT-X-VERSION:3 155 | #EXT-X-TARGETDURATION:5 156 | #EXT-X-MEDIA-SEQUENCE:10 157 | #EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=2680",IV=0x1566B 158 | #EXT-X-MAP:URI="init1.ts" 159 | #EXTINF:5.005, 160 | 02/00/32.ts 161 | #EXT-X-KEY:METHOD=NONE 162 | #EXT-X-MAP:URI="init2.ts" 163 | #EXTINF:5.005, 164 | 02/00/47.ts 165 | '''; 166 | 167 | const PLAYLIST_STRING_WRONG_ENCRYPTED_MAP = ''' 168 | #EXTM3U 169 | #EXT-X-VERSION:3 170 | #EXT-X-TARGETDURATION:5 171 | #EXT-X-MEDIA-SEQUENCE:10 172 | #EXT-X-KEY:METHOD=AES-128,URI="https://priv.example.com/key.php?r=2680" 173 | #EXT-X-MAP:URI="init1.ts" 174 | #EXTINF:5.005, 175 | 02/00/32.ts 176 | '''; 177 | 178 | const PLAYLIST_STRING_PLANE = ''' 179 | #EXTM3U 180 | #EXT-X-VERSION:3 181 | #EXT-X-TARGETDURATION:5 182 | #EXT-X-MEDIA-SEQUENCE:10 183 | #EXTINF:5.005, 184 | 02/00/27.ts 185 | #EXT-X-MAP:URI="init1.ts" 186 | #EXTINF:5.005, 187 | 02/00/32.ts 188 | #EXTINF:5.005, 189 | 02/00/42.ts 190 | #EXT-X-MAP:URI="init2.ts" 191 | #EXTINF:5.005, 192 | 02/00/47.ts; 193 | '''; 194 | 195 | const PLAYLIST_STRING_VARIABLE_SUBSITUATION = r''' 196 | #EXTM3U 197 | #EXT-X-VERSION:8 198 | #EXT-X-DEFINE:NAME="underscore_1",VALUE="{" 199 | #EXT-X-DEFINE:NAME="dash-1",VALUE="replaced_value.ts" 200 | #EXT-X-TARGETDURATION:5 201 | #EXT-X-MEDIA-SEQUENCE:10 202 | #EXTINF:5.005, 203 | segment1.ts 204 | #EXT-X-MAP:URI="{$dash-1}" 205 | #EXTINF:5.005 206 | segment{$underscore_1}$name_1} 207 | '''; 208 | 209 | const PLAYLIST_STRING_INHERITED_VS = r''' 210 | #EXTM3U 211 | #EXT-X-VERSION:8 212 | #EXT-X-TARGETDURATION:5 213 | #EXT-X-MEDIA-SEQUENCE:10 214 | #EXT-X-DEFINE:IMPORT="imported_base" 215 | #EXTINF:5.005, 216 | {$imported_base}1.ts 217 | #EXTINF:5.005, 218 | {$imported_base}2.ts 219 | #EXTINF:5.005, 220 | {$imported_base}3.ts 221 | #EXTINF:5.005, 222 | {$imported_base}4.ts 223 | '''; 224 | 225 | Future _parseMediaPlaylist( 226 | List extraLines, String uri) async { 227 | var playlistUri = Uri.parse(uri); 228 | var parser = HlsPlaylistParser.create(); 229 | var playList = await parser.parse(playlistUri, extraLines); 230 | return playList as HlsMediaPlaylist; 231 | } 232 | 233 | test('testParseMediaPlaylist', () async { 234 | var playlist = 235 | await _parseMediaPlaylist(PLAYLIST_STRING.split('\n'), PLAYLIST_URL); 236 | expect(playlist.playlistType, HlsMediaPlaylist.PLAYLIST_TYPE_VOD); 237 | expect(playlist.startOffsetUs, playlist.durationUs! - 25000000); 238 | 239 | expect(playlist.mediaSequence, 2679); 240 | expect(playlist.version, 3); 241 | expect(playlist.hasEndTag, true); 242 | expect(playlist.protectionSchemes, null); 243 | expect(playlist.segments, isNotNull); 244 | expect(playlist.segments.length, 5); 245 | 246 | expect( 247 | playlist.discontinuitySequence + 248 | (playlist.segments[0].relativeDiscontinuitySequence ?? 0), 249 | 4); 250 | expect(playlist.segments[0].durationUs, 7975000); 251 | expect(playlist.segments[0].title, isEmpty); 252 | expect(playlist.segments[0].fullSegmentEncryptionKeyUri, null); 253 | expect(playlist.segments[0].encryptionIV, null); 254 | expect(playlist.segments[0].byterangeLength, 51370); 255 | expect(playlist.segments[0].byterangeOffset, 0); 256 | expect(playlist.segments[0].url, 257 | 'https://priv.example.com/fileSequence2679.ts'); 258 | 259 | expect(playlist.segments[1].relativeDiscontinuitySequence, null); 260 | expect(playlist.segments[1].durationUs, 7975000); 261 | expect(playlist.segments[1].title, 'segment title'); 262 | expect(playlist.segments[1].fullSegmentEncryptionKeyUri, 263 | 'https://priv.example.com/key.php?r=2680'); 264 | expect(playlist.segments[1].encryptionIV, '0x1566B'); 265 | expect(playlist.segments[1].byterangeLength, 51501); 266 | expect(playlist.segments[1].byterangeOffset, 2147483648); 267 | expect(playlist.segments[1].url, 268 | 'https://priv.example.com/fileSequence2680.ts'); 269 | 270 | expect(playlist.segments[2].relativeDiscontinuitySequence, null); 271 | expect(playlist.segments[2].durationUs, 7941000); 272 | expect(playlist.segments[2].title, 273 | 'segment title .,:/# with interesting chars'); 274 | expect(playlist.segments[2].fullSegmentEncryptionKeyUri, null); 275 | expect(playlist.segments[2].encryptionIV, null); 276 | expect(playlist.segments[2].byterangeLength, 51501); 277 | expect(playlist.segments[2].byterangeOffset, 2147535149); 278 | expect(playlist.segments[2].url, 279 | 'https://priv.example.com/fileSequence2681.ts'); 280 | 281 | expect(playlist.segments[3].relativeDiscontinuitySequence, 1); 282 | expect(playlist.segments[3].durationUs, 7975000); 283 | expect(playlist.segments[3].title, isEmpty); 284 | expect(playlist.segments[3].fullSegmentEncryptionKeyUri, 285 | 'https://priv.example.com/key.php?r=2682'); 286 | expect(playlist.segments[3].encryptionIV, 'A7A'.toLowerCase()); 287 | expect(playlist.segments[3].byterangeLength, 51740); 288 | expect(playlist.segments[3].byterangeOffset, 2147586650); 289 | expect(playlist.segments[3].url, 290 | 'https://priv.example.com/fileSequence2682.ts'); 291 | 292 | expect(playlist.segments[4].relativeDiscontinuitySequence, 1); 293 | expect(playlist.segments[4].durationUs, 7975000); 294 | expect(playlist.segments[4].fullSegmentEncryptionKeyUri, 295 | 'https://priv.example.com/key.php?r=2682'); 296 | expect(playlist.segments[4].encryptionIV, 'A7B'.toLowerCase()); 297 | expect(playlist.segments[4].byterangeLength, null); 298 | expect(playlist.segments[4].byterangeOffset, null); 299 | expect(playlist.segments[4].url, 300 | 'https://priv.example.com/fileSequence2683.ts'); 301 | }); 302 | 303 | test('testParseSampleAesMethod', () async { 304 | var playlist = await _parseMediaPlaylist( 305 | PLAYLIST_STRING_AES.split('\n'), PLAYLIST_URL); 306 | expect(playlist.protectionSchemes!.schemeType, CencType.CBCS); 307 | // expect(playlist.protectionSchemes.schemeData[0].uuid, true); 308 | expect(playlist.protectionSchemes!.schemeData[0].data?.isNotEmpty != true, 309 | true); 310 | expect(playlist.segments[0].drmInitData, null); 311 | // expect(playlist.segments[1].drmInitData.schemeData[0].uuid, true); 312 | expect( 313 | playlist.segments[1].drmInitData!.schemeData[0].data?.isNotEmpty != 314 | false, 315 | true); 316 | }); 317 | 318 | test('testParseSampleAesCtrMethod', () async { 319 | var playlist = await _parseMediaPlaylist( 320 | PLAYLIST_STRING_AES_CTR.split('\n'), PLAYLIST_URL); 321 | 322 | expect(playlist.protectionSchemes!.schemeType, CencType.CENC); 323 | // expect(playlist.protectionSchemes.schemeData[0].uuid, true); 324 | expect(playlist.protectionSchemes!.schemeData[0].data?.isNotEmpty != true, 325 | true); 326 | }); 327 | 328 | test('testParseSampleAesCencMethod', () async { 329 | var playlist = await _parseMediaPlaylist( 330 | PLAYLIST_STRING_AES_CENC.split('\n'), PLAYLIST_URL); 331 | 332 | expect(playlist.protectionSchemes!.schemeType, CencType.CENC); 333 | // expect(playlist.protectionSchemes.schemeData[0].uuid, true); 334 | expect(playlist.protectionSchemes!.schemeData[0].data?.isNotEmpty != true, 335 | true); 336 | }); 337 | 338 | test('testMultipleExtXKeysForSingleSegment', () async { 339 | var playlist = await _parseMediaPlaylist( 340 | PLAYLIST_STRING_MULTI_EXT.split('\n'), PLAYLIST_URL); 341 | 342 | expect(playlist.protectionSchemes?.schemeType, CencType.CBCS); 343 | expect(playlist.protectionSchemes?.schemeData.length, 2); 344 | // expect(playlist.protectionSchemes.schemeData[0].uuid, true); 345 | expect(playlist.protectionSchemes!.schemeData[0].data?.isNotEmpty != true, 346 | true); 347 | // expect(playlist.protectionSchemes.schemeData[0].uuid, true); 348 | expect(playlist.protectionSchemes!.schemeData[1].data?.isNotEmpty != true, 349 | true); 350 | 351 | expect(playlist.segments[0].drmInitData, null); 352 | 353 | // expect(playlist.segments[0].drmInitData.schemeData[0].uuid, true); 354 | expect( 355 | playlist.segments[1].drmInitData!.schemeData[0].data?.isNotEmpty != 356 | false, 357 | true); 358 | // expect(playlist.segments[0].drmInitData.schemeData[0].uuid, true); 359 | expect( 360 | playlist.segments[1].drmInitData!.schemeData[1].data?.isNotEmpty != 361 | false, 362 | true); 363 | 364 | expect(playlist.segments[1].drmInitData, playlist.segments[2].drmInitData); 365 | expect(playlist.segments[2].drmInitData == playlist.segments[3].drmInitData, 366 | false); 367 | 368 | // expect(playlist.segments[3].drmInitData.schemeData[0].uuid, true); 369 | expect( 370 | playlist.segments[3].drmInitData!.schemeData[0].data?.isNotEmpty != 371 | false, 372 | true); 373 | // expect(playlist.segments[3].drmInitData.schemeData[1].uuid, true); 374 | expect( 375 | playlist.segments[3].drmInitData!.schemeData[1].data?.isNotEmpty != 376 | false, 377 | true); 378 | 379 | expect(playlist.segments[3].drmInitData, playlist.segments[4].drmInitData); 380 | expect(playlist.segments[5].drmInitData, null); 381 | expect(playlist.segments[6].drmInitData, null); 382 | }); 383 | 384 | test('testGapTag', () async { 385 | var playlist = await _parseMediaPlaylist( 386 | PLAYLIST_STRING_GAP_TAG.split('\n'), PLAYLIST_URL); 387 | expect(playlist.hasEndTag, false); 388 | expect(playlist.segments[1].hasGapTag, false); 389 | expect(playlist.segments[2].hasGapTag, true); 390 | expect(playlist.segments[3].hasGapTag, false); 391 | }); 392 | 393 | test('testMapTag', () async { 394 | var playlist = await _parseMediaPlaylist( 395 | PLAYLIST_STRING_MAP_TAG.split('\n'), PLAYLIST_URL); 396 | 397 | var segments = playlist.segments; 398 | expect(segments[0].initializationSegment, null); 399 | expect( 400 | identical(segments[1].initializationSegment, 401 | segments[2].initializationSegment), 402 | true); 403 | expect(segments[1].initializationSegment?.url, 'init1.ts'); 404 | expect(segments[3].initializationSegment?.url, 'init2.ts'); 405 | }); 406 | 407 | test('testEncryptedMapTag', () async { 408 | var playlist = await _parseMediaPlaylist( 409 | PLAYLIST_STRING_ENCRYPTED_MAP.split('\n'), PLAYLIST_URL); 410 | 411 | var segments = playlist.segments; 412 | 413 | expect(segments[0].initializationSegment!.fullSegmentEncryptionKeyUri, 414 | 'https://priv.example.com/key.php?r=2680'); 415 | expect(segments[0].encryptionIV, '0x1566B'); 416 | expect( 417 | segments[1].initializationSegment!.fullSegmentEncryptionKeyUri, null); 418 | expect(segments[1].encryptionIV, null); 419 | }); 420 | 421 | test('testEncryptedMapTagWithNoIvFailure', () async { 422 | try { 423 | await _parseMediaPlaylist( 424 | PLAYLIST_STRING_WRONG_ENCRYPTED_MAP.split('\n'), PLAYLIST_URL); 425 | fail('forced failure'); 426 | } on ParserException catch (_) {} 427 | }); 428 | 429 | test('testMasterPlaylistAttributeInheritance', () async { 430 | var playlist = await _parseMediaPlaylist( 431 | PLAYLIST_STRING_PLANE.split('\n'), PLAYLIST_URL); //todoいい加減このURL共通化する 432 | 433 | expect(playlist.hasIndependentSegments, false); 434 | 435 | var masterPlaylist = HlsMasterPlaylist( 436 | baseUri: 'https://example.com/', 437 | hasIndependentSegments: true, 438 | ); 439 | var h = await HlsPlaylistParser.create(masterPlaylist: masterPlaylist) 440 | .parse(Uri.parse(PLAYLIST_URL), PLAYLIST_STRING_PLANE.split('\n')); 441 | var hlsMediaPlaylist = h as HlsMediaPlaylist; 442 | 443 | expect(hlsMediaPlaylist.hasIndependentSegments, true); 444 | }); 445 | 446 | test('testVariableSubstitution', () async { 447 | var playlist = await _parseMediaPlaylist( 448 | PLAYLIST_STRING_VARIABLE_SUBSITUATION.split('\n'), 449 | PLAYLIST_URL); //todoいい加減このURL共通化する 450 | 451 | expect( 452 | playlist.segments[1].initializationSegment?.url, 'replaced_value.ts'); 453 | expect(playlist.segments[1].url, r'segment{$name_1}'); 454 | }); 455 | 456 | test('testInheritedVariableSubstitution', () async { 457 | var masterPlaylist = HlsMasterPlaylist( 458 | baseUri: '', 459 | variableDefinitions: { 460 | 'imported_base': 'long_path', 461 | }, 462 | ); 463 | 464 | var hlsMediaPlaylist = await HlsPlaylistParser(masterPlaylist).parse( 465 | Uri.parse(PLAYLIST_URL), 466 | PLAYLIST_STRING_INHERITED_VS.split('\n')); //todo 引数そろえるべき 467 | (hlsMediaPlaylist as HlsMediaPlaylist) 468 | .segments 469 | .asMap() 470 | .forEach((i, segment) => expect(segment.url, 'long_path${i + 1}.ts')); 471 | }); 472 | } 473 | -------------------------------------------------------------------------------- /test/mime_types_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:test/test.dart'; 2 | import 'package:flutter_hls_parser/src/mime_types.dart'; 3 | 4 | /// test for [MimeTypes] 5 | class MimeTypesTest { 6 | /// test for [MimeTypes.getMediaMimeType(codec)] 7 | // ignore: non_constant_identifier_names 8 | static void testGetMediaMimeType_fromValidCodecs_returnsCorrectMimeType() { 9 | expect(MimeTypes.getMediaMimeType('avc1'), MimeTypes.VIDEO_H264); 10 | expect(MimeTypes.getMediaMimeType('avc1.42E01E'), MimeTypes.VIDEO_H264); 11 | 12 | expect(MimeTypes.getMediaMimeType('avc1.42E01F'), MimeTypes.VIDEO_H264); 13 | expect(MimeTypes.getMediaMimeType('avc1.4D401F'), MimeTypes.VIDEO_H264); 14 | expect(MimeTypes.getMediaMimeType('avc1.4D4028'), MimeTypes.VIDEO_H264); 15 | expect(MimeTypes.getMediaMimeType('avc1.640028'), MimeTypes.VIDEO_H264); 16 | expect(MimeTypes.getMediaMimeType('avc1.640029'), MimeTypes.VIDEO_H264); 17 | expect(MimeTypes.getMediaMimeType('avc3'), MimeTypes.VIDEO_H264); 18 | expect(MimeTypes.getMediaMimeType('hev1'), MimeTypes.VIDEO_H265); 19 | expect(MimeTypes.getMediaMimeType('hvc1'), MimeTypes.VIDEO_H265); 20 | expect(MimeTypes.getMediaMimeType('vp08'), MimeTypes.VIDEO_VP8); 21 | expect(MimeTypes.getMediaMimeType('vp8'), MimeTypes.VIDEO_VP8); 22 | expect(MimeTypes.getMediaMimeType('vp09'), MimeTypes.VIDEO_VP9); 23 | expect(MimeTypes.getMediaMimeType('vp9'), MimeTypes.VIDEO_VP9); 24 | 25 | expect(MimeTypes.getMediaMimeType('ac-3'), MimeTypes.AUDIO_AC3); 26 | expect(MimeTypes.getMediaMimeType('dac3'), MimeTypes.AUDIO_AC3); 27 | expect(MimeTypes.getMediaMimeType('dec3'), MimeTypes.AUDIO_E_AC3); 28 | expect(MimeTypes.getMediaMimeType('ec-3'), MimeTypes.AUDIO_E_AC3); 29 | expect(MimeTypes.getMediaMimeType('ec+3'), MimeTypes.AUDIO_E_AC3_JOC); 30 | expect(MimeTypes.getMediaMimeType('dtsc'), MimeTypes.AUDIO_DTS); 31 | expect(MimeTypes.getMediaMimeType('dtse'), MimeTypes.AUDIO_DTS); 32 | expect(MimeTypes.getMediaMimeType('dtsh'), MimeTypes.AUDIO_DTS_HD); 33 | expect(MimeTypes.getMediaMimeType('dtsl'), MimeTypes.AUDIO_DTS_HD); 34 | expect(MimeTypes.getMediaMimeType('opus'), MimeTypes.AUDIO_OPUS); 35 | expect(MimeTypes.getMediaMimeType('vorbis'), MimeTypes.AUDIO_VORBIS); 36 | expect(MimeTypes.getMediaMimeType('mp4a'), MimeTypes.AUDIO_AAC); 37 | expect(MimeTypes.getMediaMimeType('mp4a.40.02'), MimeTypes.AUDIO_AAC); 38 | expect(MimeTypes.getMediaMimeType('mp4a.40.05'), MimeTypes.AUDIO_AAC); 39 | expect(MimeTypes.getMediaMimeType('mp4a.40.2'), MimeTypes.AUDIO_AAC); 40 | expect(MimeTypes.getMediaMimeType('mp4a.40.5'), MimeTypes.AUDIO_AAC); 41 | expect(MimeTypes.getMediaMimeType('mp4a.40.29'), MimeTypes.AUDIO_AAC); 42 | expect(MimeTypes.getMediaMimeType('mp4a.66'), MimeTypes.AUDIO_AAC); 43 | expect(MimeTypes.getMediaMimeType('mp4a.67'), MimeTypes.AUDIO_AAC); 44 | expect(MimeTypes.getMediaMimeType('mp4a.68'), MimeTypes.AUDIO_AAC); 45 | expect(MimeTypes.getMediaMimeType('mp4a.69'), MimeTypes.AUDIO_MPEG); 46 | expect(MimeTypes.getMediaMimeType('mp4a.6B'), MimeTypes.AUDIO_MPEG); 47 | expect(MimeTypes.getMediaMimeType('mp4a.a5'), MimeTypes.AUDIO_AC3); 48 | expect(MimeTypes.getMediaMimeType('mp4a.A5'), MimeTypes.AUDIO_AC3); 49 | expect(MimeTypes.getMediaMimeType('mp4a.a6'), MimeTypes.AUDIO_E_AC3); 50 | expect(MimeTypes.getMediaMimeType('mp4a.A6'), MimeTypes.AUDIO_E_AC3); 51 | expect(MimeTypes.getMediaMimeType('mp4a.A9'), MimeTypes.AUDIO_DTS); 52 | expect(MimeTypes.getMediaMimeType('mp4a.AC'), MimeTypes.AUDIO_DTS); 53 | expect(MimeTypes.getMediaMimeType('mp4a.AA'), MimeTypes.AUDIO_DTS_HD); 54 | expect(MimeTypes.getMediaMimeType('mp4a.AB'), MimeTypes.AUDIO_DTS_HD); 55 | expect(MimeTypes.getMediaMimeType('mp4a.AD'), MimeTypes.AUDIO_OPUS); 56 | } 57 | 58 | /// change access modifier when you run test 59 | /// test for [MimeTypes.getMimeTypeFromMp4ObjectType(objectType)] 60 | // ignore: non_constant_identifier_names 61 | static void 62 | testGetMimeTypeFromMp4ObjectType_forValidObjectType_returnsCorrectMimeType() { 63 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0x60), MimeTypes.VIDEO_MPEG2); 64 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0x61), MimeTypes.VIDEO_MPEG2); 65 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0x20), MimeTypes.VIDEO_MP4V); 66 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0x21), MimeTypes.VIDEO_H264); 67 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0x23), MimeTypes.VIDEO_H265); 68 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0x6B), MimeTypes.AUDIO_MPEG); 69 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0x40), MimeTypes.AUDIO_AAC); 70 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0x66), MimeTypes.AUDIO_AAC); 71 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0x67), MimeTypes.AUDIO_AAC); 72 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0x68), MimeTypes.AUDIO_AAC); 73 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0xA5), MimeTypes.AUDIO_AC3); 74 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0xA6), MimeTypes.AUDIO_E_AC3); 75 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0xA9), MimeTypes.AUDIO_DTS); 76 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0xAC), MimeTypes.AUDIO_DTS); 77 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0xAA), MimeTypes.AUDIO_DTS_HD); 78 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0xAB), MimeTypes.AUDIO_DTS_HD); 79 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0xAD), MimeTypes.AUDIO_OPUS); 80 | } 81 | 82 | /// change access modifier when you run test 83 | /// test for [MimeTypes.getMimeTypeFromMp4ObjectType(objectType)] 84 | // ignore: non_constant_identifier_names 85 | static void 86 | testGetMimeTypeFromMp4ObjectType_forInvalidObjectType_returnsNull() { 87 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0), isNull); 88 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0x600), isNull); 89 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(0x01), isNull); 90 | // expect(MimeTypes.getMimeTypeFromMp4ObjectType(-1), isNull); 91 | } 92 | } 93 | 94 | void main() { 95 | test('testMimeType', () { 96 | MimeTypesTest.testGetMediaMimeType_fromValidCodecs_returnsCorrectMimeType(); 97 | MimeTypesTest 98 | .testGetMimeTypeFromMp4ObjectType_forValidObjectType_returnsCorrectMimeType(); 99 | MimeTypesTest 100 | .testGetMimeTypeFromMp4ObjectType_forInvalidObjectType_returnsNull(); 101 | }); 102 | } 103 | -------------------------------------------------------------------------------- /test/util_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:test/test.dart'; 2 | import 'package:flutter_hls_parser/src/util.dart'; 3 | 4 | /// for [Util] 5 | class UtilTest { 6 | /// for [Util.parseXsDateTime(value)] 7 | static void testParseXsDateTime() { 8 | expect(LibUtil.parseXsDateTime('2014-06-19T23:07:42'), 1403219262000); 9 | expect(LibUtil.parseXsDateTime('2014-08-06T11:00:00Z'), 1407322800000); 10 | expect(LibUtil.parseXsDateTime('2014-08-06T11:00:00,000Z'), 1407322800000); 11 | expect(LibUtil.parseXsDateTime('2014-09-19T13:18:55-08:00'), 1411161535000); 12 | expect(LibUtil.parseXsDateTime('2014-09-19T13:18:55-0800'), 1411161535000); 13 | expect( 14 | LibUtil.parseXsDateTime('2014-09-19T13:18:55.000-0800'), 1411161535000); 15 | expect( 16 | LibUtil.parseXsDateTime('2014-09-19T13:18:55.000-800'), 1411161535000); 17 | } 18 | } 19 | 20 | void main() { 21 | test('utilTest', () { 22 | UtilTest.testParseXsDateTime(); 23 | }); 24 | } 25 | --------------------------------------------------------------------------------