├── .gitignore ├── .swift-version ├── Demo ├── Demo.xcodeproj │ ├── project.pbxproj │ └── project.xcworkspace │ │ └── contents.xcworkspacedata ├── Demo.xcworkspace │ └── contents.xcworkspacedata ├── Demo │ ├── AppDelegate.swift │ ├── Assets.xcassets │ │ └── AppIcon.appiconset │ │ │ └── Contents.json │ ├── Base.lproj │ │ ├── LaunchScreen.storyboard │ │ └── Main.storyboard │ ├── Info.plist │ └── ViewController.swift ├── Podfile └── Podfile.lock ├── LICENSE ├── LemonDeer.podspec ├── LemonDeer ├── Assets │ └── .gitkeep └── Classes │ ├── .gitkeep │ ├── LemonDeer.swift │ ├── M3u8Parser.swift │ ├── M3u8Playlist.swift │ ├── M3u8TsSegmentModel.swift │ ├── SegmentDownloader.swift │ ├── VideoDownloader.swift │ └── VideoDownloaderHelper.swift ├── Package.swift ├── README.md ├── Resources └── LemonDeer-logo.png ├── Sources ├── DownloadManager.swift ├── LemonDeer.swift ├── M3u8Parser.swift ├── M3u8Playlist.swift ├── M3u8TsSegmentModel.swift ├── SegmentDownloader.swift ├── VideoDownloader.swift └── VideoDownloaderHelper.swift └── _Pods.xcodeproj /.gitignore: -------------------------------------------------------------------------------- 1 | ### Carthage ### 2 | Carthage/Build 3 | 4 | ### CocoaPods ### 5 | Pods/ 6 | examples/Pods 7 | examples/**/Pods 8 | coverage/ 9 | .coveralls.yml 10 | 11 | ### macOS ### 12 | *.DS_Store 13 | .AppleDouble 14 | .LSOverride 15 | 16 | # Icon must end with two \r 17 | Icon 18 | 19 | # Thumbnails 20 | ._* 21 | 22 | # Files that might appear in the root of a volume 23 | .DocumentRevisions-V100 24 | .fseventsd 25 | .Spotlight-V100 26 | .TemporaryItems 27 | .Trashes 28 | .VolumeIcon.icns 29 | .com.apple.timemachine.donotpresent 30 | 31 | # Directories potentially created on remote AFP share 32 | .AppleDB 33 | .AppleDesktop 34 | Network Trash Folder 35 | Temporary Items 36 | .apdisk 37 | 38 | ## Build generated 39 | build/ 40 | DerivedData/ 41 | 42 | ## Various settings 43 | *.pbxuser 44 | !default.pbxuser 45 | *.mode1v3 46 | !default.mode1v3 47 | *.mode2v3 48 | !default.mode2v3 49 | *.perspectivev3 50 | !default.perspectivev3 51 | xcuserdata/ 52 | 53 | ## Other 54 | *.moved-aside 55 | *.xccheckout 56 | *.xcscmblueprint 57 | 58 | ## Obj-C/Swift specific 59 | *.hmap 60 | *.ipa 61 | *.dSYM.zip 62 | *.dSYM -------------------------------------------------------------------------------- /.swift-version: -------------------------------------------------------------------------------- 1 | 3.0 -------------------------------------------------------------------------------- /Demo/Demo.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 46; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 42A90C25CFF382F2513DCF3F /* Pods_Demo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 936E65AD88DA0F1D1A45085C /* Pods_Demo.framework */; }; 11 | EA263DC11EFCC4EE00649FB3 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA263DC01EFCC4EE00649FB3 /* AppDelegate.swift */; }; 12 | EA263DC31EFCC4EE00649FB3 /* ViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA263DC21EFCC4EE00649FB3 /* ViewController.swift */; }; 13 | EA263DC61EFCC4EE00649FB3 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = EA263DC41EFCC4EE00649FB3 /* Main.storyboard */; }; 14 | EA263DC81EFCC4EE00649FB3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = EA263DC71EFCC4EE00649FB3 /* Assets.xcassets */; }; 15 | EA263DCB1EFCC4EE00649FB3 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = EA263DC91EFCC4EE00649FB3 /* LaunchScreen.storyboard */; }; 16 | /* End PBXBuildFile section */ 17 | 18 | /* Begin PBXFileReference section */ 19 | 6AA36D72764771C9F1196AA0 /* Pods-Demo.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Demo.release.xcconfig"; path = "Pods/Target Support Files/Pods-Demo/Pods-Demo.release.xcconfig"; sourceTree = ""; }; 20 | 936E65AD88DA0F1D1A45085C /* Pods_Demo.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Demo.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 21 | EA263DBD1EFCC4EE00649FB3 /* Demo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Demo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 22 | EA263DC01EFCC4EE00649FB3 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 23 | EA263DC21EFCC4EE00649FB3 /* ViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewController.swift; sourceTree = ""; }; 24 | EA263DC51EFCC4EE00649FB3 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; 25 | EA263DC71EFCC4EE00649FB3 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 26 | EA263DCA1EFCC4EE00649FB3 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 27 | EA263DCC1EFCC4EE00649FB3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 28 | F243F064008530A8580843D2 /* Pods-Demo.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Demo.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Demo/Pods-Demo.debug.xcconfig"; sourceTree = ""; }; 29 | /* End PBXFileReference section */ 30 | 31 | /* Begin PBXFrameworksBuildPhase section */ 32 | EA263DBA1EFCC4EE00649FB3 /* Frameworks */ = { 33 | isa = PBXFrameworksBuildPhase; 34 | buildActionMask = 2147483647; 35 | files = ( 36 | 42A90C25CFF382F2513DCF3F /* Pods_Demo.framework in Frameworks */, 37 | ); 38 | runOnlyForDeploymentPostprocessing = 0; 39 | }; 40 | /* End PBXFrameworksBuildPhase section */ 41 | 42 | /* Begin PBXGroup section */ 43 | 736DF457A3BAC39B2371ED43 /* Frameworks */ = { 44 | isa = PBXGroup; 45 | children = ( 46 | 936E65AD88DA0F1D1A45085C /* Pods_Demo.framework */, 47 | ); 48 | name = Frameworks; 49 | sourceTree = ""; 50 | }; 51 | D80BAC0326A1130D510F78A7 /* Pods */ = { 52 | isa = PBXGroup; 53 | children = ( 54 | F243F064008530A8580843D2 /* Pods-Demo.debug.xcconfig */, 55 | 6AA36D72764771C9F1196AA0 /* Pods-Demo.release.xcconfig */, 56 | ); 57 | name = Pods; 58 | sourceTree = ""; 59 | }; 60 | EA263DB41EFCC4EE00649FB3 = { 61 | isa = PBXGroup; 62 | children = ( 63 | EA263DBF1EFCC4EE00649FB3 /* Demo */, 64 | EA263DBE1EFCC4EE00649FB3 /* Products */, 65 | D80BAC0326A1130D510F78A7 /* Pods */, 66 | 736DF457A3BAC39B2371ED43 /* Frameworks */, 67 | ); 68 | sourceTree = ""; 69 | }; 70 | EA263DBE1EFCC4EE00649FB3 /* Products */ = { 71 | isa = PBXGroup; 72 | children = ( 73 | EA263DBD1EFCC4EE00649FB3 /* Demo.app */, 74 | ); 75 | name = Products; 76 | sourceTree = ""; 77 | }; 78 | EA263DBF1EFCC4EE00649FB3 /* Demo */ = { 79 | isa = PBXGroup; 80 | children = ( 81 | EA263DC01EFCC4EE00649FB3 /* AppDelegate.swift */, 82 | EA263DC21EFCC4EE00649FB3 /* ViewController.swift */, 83 | EA263DC41EFCC4EE00649FB3 /* Main.storyboard */, 84 | EA263DC71EFCC4EE00649FB3 /* Assets.xcassets */, 85 | EA263DC91EFCC4EE00649FB3 /* LaunchScreen.storyboard */, 86 | EA263DCC1EFCC4EE00649FB3 /* Info.plist */, 87 | ); 88 | path = Demo; 89 | sourceTree = ""; 90 | }; 91 | /* End PBXGroup section */ 92 | 93 | /* Begin PBXNativeTarget section */ 94 | EA263DBC1EFCC4EE00649FB3 /* Demo */ = { 95 | isa = PBXNativeTarget; 96 | buildConfigurationList = EA263DCF1EFCC4EE00649FB3 /* Build configuration list for PBXNativeTarget "Demo" */; 97 | buildPhases = ( 98 | CBA9225132157C95F9F3B6AB /* [CP] Check Pods Manifest.lock */, 99 | EA263DB91EFCC4EE00649FB3 /* Sources */, 100 | EA263DBA1EFCC4EE00649FB3 /* Frameworks */, 101 | EA263DBB1EFCC4EE00649FB3 /* Resources */, 102 | 371153AB537F15A53EB79762 /* [CP] Embed Pods Frameworks */, 103 | 3D5F4C8AB2CC730FF9BB2D64 /* [CP] Copy Pods Resources */, 104 | ); 105 | buildRules = ( 106 | ); 107 | dependencies = ( 108 | ); 109 | name = Demo; 110 | productName = Demo; 111 | productReference = EA263DBD1EFCC4EE00649FB3 /* Demo.app */; 112 | productType = "com.apple.product-type.application"; 113 | }; 114 | /* End PBXNativeTarget section */ 115 | 116 | /* Begin PBXProject section */ 117 | EA263DB51EFCC4EE00649FB3 /* Project object */ = { 118 | isa = PBXProject; 119 | attributes = { 120 | LastSwiftUpdateCheck = 0830; 121 | LastUpgradeCheck = 0830; 122 | ORGANIZATIONNAME = Ziyideas; 123 | TargetAttributes = { 124 | EA263DBC1EFCC4EE00649FB3 = { 125 | CreatedOnToolsVersion = 8.3.3; 126 | DevelopmentTeam = J8DC9PK5S4; 127 | ProvisioningStyle = Automatic; 128 | }; 129 | }; 130 | }; 131 | buildConfigurationList = EA263DB81EFCC4EE00649FB3 /* Build configuration list for PBXProject "Demo" */; 132 | compatibilityVersion = "Xcode 3.2"; 133 | developmentRegion = English; 134 | hasScannedForEncodings = 0; 135 | knownRegions = ( 136 | en, 137 | Base, 138 | ); 139 | mainGroup = EA263DB41EFCC4EE00649FB3; 140 | productRefGroup = EA263DBE1EFCC4EE00649FB3 /* Products */; 141 | projectDirPath = ""; 142 | projectRoot = ""; 143 | targets = ( 144 | EA263DBC1EFCC4EE00649FB3 /* Demo */, 145 | ); 146 | }; 147 | /* End PBXProject section */ 148 | 149 | /* Begin PBXResourcesBuildPhase section */ 150 | EA263DBB1EFCC4EE00649FB3 /* Resources */ = { 151 | isa = PBXResourcesBuildPhase; 152 | buildActionMask = 2147483647; 153 | files = ( 154 | EA263DCB1EFCC4EE00649FB3 /* LaunchScreen.storyboard in Resources */, 155 | EA263DC81EFCC4EE00649FB3 /* Assets.xcassets in Resources */, 156 | EA263DC61EFCC4EE00649FB3 /* Main.storyboard in Resources */, 157 | ); 158 | runOnlyForDeploymentPostprocessing = 0; 159 | }; 160 | /* End PBXResourcesBuildPhase section */ 161 | 162 | /* Begin PBXShellScriptBuildPhase section */ 163 | 371153AB537F15A53EB79762 /* [CP] Embed Pods Frameworks */ = { 164 | isa = PBXShellScriptBuildPhase; 165 | buildActionMask = 2147483647; 166 | files = ( 167 | ); 168 | inputPaths = ( 169 | "${SRCROOT}/Pods/Target Support Files/Pods-Demo/Pods-Demo-frameworks.sh", 170 | "${BUILT_PRODUCTS_DIR}/CocoaLumberjack/CocoaLumberjack.framework", 171 | "${BUILT_PRODUCTS_DIR}/GCDWebServer/GCDWebServer.framework", 172 | "${BUILT_PRODUCTS_DIR}/LemonDeer/LemonDeer.framework", 173 | ); 174 | name = "[CP] Embed Pods Frameworks"; 175 | outputPaths = ( 176 | "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/CocoaLumberjack.framework", 177 | "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GCDWebServer.framework", 178 | "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/LemonDeer.framework", 179 | ); 180 | runOnlyForDeploymentPostprocessing = 0; 181 | shellPath = /bin/sh; 182 | shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-Demo/Pods-Demo-frameworks.sh\"\n"; 183 | showEnvVarsInLog = 0; 184 | }; 185 | 3D5F4C8AB2CC730FF9BB2D64 /* [CP] Copy Pods Resources */ = { 186 | isa = PBXShellScriptBuildPhase; 187 | buildActionMask = 2147483647; 188 | files = ( 189 | ); 190 | inputPaths = ( 191 | ); 192 | name = "[CP] Copy Pods Resources"; 193 | outputPaths = ( 194 | ); 195 | runOnlyForDeploymentPostprocessing = 0; 196 | shellPath = /bin/sh; 197 | shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-Demo/Pods-Demo-resources.sh\"\n"; 198 | showEnvVarsInLog = 0; 199 | }; 200 | CBA9225132157C95F9F3B6AB /* [CP] Check Pods Manifest.lock */ = { 201 | isa = PBXShellScriptBuildPhase; 202 | buildActionMask = 2147483647; 203 | files = ( 204 | ); 205 | inputPaths = ( 206 | "${PODS_PODFILE_DIR_PATH}/Podfile.lock", 207 | "${PODS_ROOT}/Manifest.lock", 208 | ); 209 | name = "[CP] Check Pods Manifest.lock"; 210 | outputPaths = ( 211 | "$(DERIVED_FILE_DIR)/Pods-Demo-checkManifestLockResult.txt", 212 | ); 213 | runOnlyForDeploymentPostprocessing = 0; 214 | shellPath = /bin/sh; 215 | shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; 216 | showEnvVarsInLog = 0; 217 | }; 218 | /* End PBXShellScriptBuildPhase section */ 219 | 220 | /* Begin PBXSourcesBuildPhase section */ 221 | EA263DB91EFCC4EE00649FB3 /* Sources */ = { 222 | isa = PBXSourcesBuildPhase; 223 | buildActionMask = 2147483647; 224 | files = ( 225 | EA263DC31EFCC4EE00649FB3 /* ViewController.swift in Sources */, 226 | EA263DC11EFCC4EE00649FB3 /* AppDelegate.swift in Sources */, 227 | ); 228 | runOnlyForDeploymentPostprocessing = 0; 229 | }; 230 | /* End PBXSourcesBuildPhase section */ 231 | 232 | /* Begin PBXVariantGroup section */ 233 | EA263DC41EFCC4EE00649FB3 /* Main.storyboard */ = { 234 | isa = PBXVariantGroup; 235 | children = ( 236 | EA263DC51EFCC4EE00649FB3 /* Base */, 237 | ); 238 | name = Main.storyboard; 239 | sourceTree = ""; 240 | }; 241 | EA263DC91EFCC4EE00649FB3 /* LaunchScreen.storyboard */ = { 242 | isa = PBXVariantGroup; 243 | children = ( 244 | EA263DCA1EFCC4EE00649FB3 /* Base */, 245 | ); 246 | name = LaunchScreen.storyboard; 247 | sourceTree = ""; 248 | }; 249 | /* End PBXVariantGroup section */ 250 | 251 | /* Begin XCBuildConfiguration section */ 252 | EA263DCD1EFCC4EE00649FB3 /* Debug */ = { 253 | isa = XCBuildConfiguration; 254 | buildSettings = { 255 | ALWAYS_SEARCH_USER_PATHS = NO; 256 | CLANG_ANALYZER_NONNULL = YES; 257 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 258 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 259 | CLANG_CXX_LIBRARY = "libc++"; 260 | CLANG_ENABLE_MODULES = YES; 261 | CLANG_ENABLE_OBJC_ARC = YES; 262 | CLANG_WARN_BOOL_CONVERSION = YES; 263 | CLANG_WARN_CONSTANT_CONVERSION = YES; 264 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 265 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 266 | CLANG_WARN_EMPTY_BODY = YES; 267 | CLANG_WARN_ENUM_CONVERSION = YES; 268 | CLANG_WARN_INFINITE_RECURSION = YES; 269 | CLANG_WARN_INT_CONVERSION = YES; 270 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 271 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 272 | CLANG_WARN_UNREACHABLE_CODE = YES; 273 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 274 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 275 | COPY_PHASE_STRIP = NO; 276 | DEBUG_INFORMATION_FORMAT = dwarf; 277 | ENABLE_STRICT_OBJC_MSGSEND = YES; 278 | ENABLE_TESTABILITY = YES; 279 | GCC_C_LANGUAGE_STANDARD = gnu99; 280 | GCC_DYNAMIC_NO_PIC = NO; 281 | GCC_NO_COMMON_BLOCKS = YES; 282 | GCC_OPTIMIZATION_LEVEL = 0; 283 | GCC_PREPROCESSOR_DEFINITIONS = ( 284 | "DEBUG=1", 285 | "$(inherited)", 286 | ); 287 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 288 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 289 | GCC_WARN_UNDECLARED_SELECTOR = YES; 290 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 291 | GCC_WARN_UNUSED_FUNCTION = YES; 292 | GCC_WARN_UNUSED_VARIABLE = YES; 293 | IPHONEOS_DEPLOYMENT_TARGET = 9.0; 294 | MTL_ENABLE_DEBUG_INFO = YES; 295 | ONLY_ACTIVE_ARCH = YES; 296 | SDKROOT = iphoneos; 297 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 298 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 299 | TARGETED_DEVICE_FAMILY = "1,2"; 300 | }; 301 | name = Debug; 302 | }; 303 | EA263DCE1EFCC4EE00649FB3 /* Release */ = { 304 | isa = XCBuildConfiguration; 305 | buildSettings = { 306 | ALWAYS_SEARCH_USER_PATHS = NO; 307 | CLANG_ANALYZER_NONNULL = YES; 308 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 309 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; 310 | CLANG_CXX_LIBRARY = "libc++"; 311 | CLANG_ENABLE_MODULES = YES; 312 | CLANG_ENABLE_OBJC_ARC = YES; 313 | CLANG_WARN_BOOL_CONVERSION = YES; 314 | CLANG_WARN_CONSTANT_CONVERSION = YES; 315 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 316 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 317 | CLANG_WARN_EMPTY_BODY = YES; 318 | CLANG_WARN_ENUM_CONVERSION = YES; 319 | CLANG_WARN_INFINITE_RECURSION = YES; 320 | CLANG_WARN_INT_CONVERSION = YES; 321 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 322 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 323 | CLANG_WARN_UNREACHABLE_CODE = YES; 324 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 325 | "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; 326 | COPY_PHASE_STRIP = NO; 327 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 328 | ENABLE_NS_ASSERTIONS = NO; 329 | ENABLE_STRICT_OBJC_MSGSEND = YES; 330 | GCC_C_LANGUAGE_STANDARD = gnu99; 331 | GCC_NO_COMMON_BLOCKS = YES; 332 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 333 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 334 | GCC_WARN_UNDECLARED_SELECTOR = YES; 335 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 336 | GCC_WARN_UNUSED_FUNCTION = YES; 337 | GCC_WARN_UNUSED_VARIABLE = YES; 338 | IPHONEOS_DEPLOYMENT_TARGET = 9.0; 339 | MTL_ENABLE_DEBUG_INFO = NO; 340 | SDKROOT = iphoneos; 341 | SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule"; 342 | TARGETED_DEVICE_FAMILY = "1,2"; 343 | VALIDATE_PRODUCT = YES; 344 | }; 345 | name = Release; 346 | }; 347 | EA263DD01EFCC4EE00649FB3 /* Debug */ = { 348 | isa = XCBuildConfiguration; 349 | baseConfigurationReference = F243F064008530A8580843D2 /* Pods-Demo.debug.xcconfig */; 350 | buildSettings = { 351 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 352 | DEVELOPMENT_TEAM = J8DC9PK5S4; 353 | INFOPLIST_FILE = Demo/Info.plist; 354 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; 355 | PRODUCT_BUNDLE_IDENTIFIER = com.Ziyideas.WindmillComic.Demo; 356 | PRODUCT_NAME = "$(TARGET_NAME)"; 357 | SWIFT_VERSION = 3.0; 358 | }; 359 | name = Debug; 360 | }; 361 | EA263DD11EFCC4EE00649FB3 /* Release */ = { 362 | isa = XCBuildConfiguration; 363 | baseConfigurationReference = 6AA36D72764771C9F1196AA0 /* Pods-Demo.release.xcconfig */; 364 | buildSettings = { 365 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 366 | DEVELOPMENT_TEAM = J8DC9PK5S4; 367 | INFOPLIST_FILE = Demo/Info.plist; 368 | LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; 369 | PRODUCT_BUNDLE_IDENTIFIER = com.Ziyideas.WindmillComic.Demo; 370 | PRODUCT_NAME = "$(TARGET_NAME)"; 371 | SWIFT_VERSION = 3.0; 372 | }; 373 | name = Release; 374 | }; 375 | /* End XCBuildConfiguration section */ 376 | 377 | /* Begin XCConfigurationList section */ 378 | EA263DB81EFCC4EE00649FB3 /* Build configuration list for PBXProject "Demo" */ = { 379 | isa = XCConfigurationList; 380 | buildConfigurations = ( 381 | EA263DCD1EFCC4EE00649FB3 /* Debug */, 382 | EA263DCE1EFCC4EE00649FB3 /* Release */, 383 | ); 384 | defaultConfigurationIsVisible = 0; 385 | defaultConfigurationName = Release; 386 | }; 387 | EA263DCF1EFCC4EE00649FB3 /* Build configuration list for PBXNativeTarget "Demo" */ = { 388 | isa = XCConfigurationList; 389 | buildConfigurations = ( 390 | EA263DD01EFCC4EE00649FB3 /* Debug */, 391 | EA263DD11EFCC4EE00649FB3 /* Release */, 392 | ); 393 | defaultConfigurationIsVisible = 0; 394 | }; 395 | /* End XCConfigurationList section */ 396 | }; 397 | rootObject = EA263DB51EFCC4EE00649FB3 /* Project object */; 398 | } 399 | -------------------------------------------------------------------------------- /Demo/Demo.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Demo/Demo.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /Demo/Demo/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AppDelegate.swift 3 | // Demo 4 | // 5 | // Created by Ziyi Zhang on 23/06/2017. 6 | // Copyright © 2017 Ziyideas. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | 11 | @UIApplicationMain 12 | class AppDelegate: UIResponder, UIApplicationDelegate { 13 | 14 | var window: UIWindow? 15 | 16 | 17 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool { 18 | // Override point for customization after application launch. 19 | return true 20 | } 21 | 22 | func applicationWillResignActive(_ application: UIApplication) { 23 | // Sent when the application is about to move from active to inactive state. This can occur for certain types of temporary interruptions (such as an incoming phone call or SMS message) or when the user quits the application and it begins the transition to the background state. 24 | // Use this method to pause ongoing tasks, disable timers, and invalidate graphics rendering callbacks. Games should use this method to pause the game. 25 | } 26 | 27 | func applicationDidEnterBackground(_ application: UIApplication) { 28 | // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. 29 | // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. 30 | } 31 | 32 | func applicationWillEnterForeground(_ application: UIApplication) { 33 | // Called as part of the transition from the background to the active state; here you can undo many of the changes made on entering the background. 34 | } 35 | 36 | func applicationDidBecomeActive(_ application: UIApplication) { 37 | // Restart any tasks that were paused (or not yet started) while the application was inactive. If the application was previously in the background, optionally refresh the user interface. 38 | } 39 | 40 | func applicationWillTerminate(_ application: UIApplication) { 41 | // Called when the application is about to terminate. Save data if appropriate. See also applicationDidEnterBackground:. 42 | } 43 | 44 | 45 | } 46 | 47 | -------------------------------------------------------------------------------- /Demo/Demo/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "29x29", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "29x29", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "40x40", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "40x40", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "60x60", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "60x60", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "ipad", 35 | "size" : "29x29", 36 | "scale" : "1x" 37 | }, 38 | { 39 | "idiom" : "ipad", 40 | "size" : "29x29", 41 | "scale" : "2x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "40x40", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "40x40", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "76x76", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "76x76", 61 | "scale" : "2x" 62 | } 63 | ], 64 | "info" : { 65 | "version" : 1, 66 | "author" : "xcode" 67 | } 68 | } -------------------------------------------------------------------------------- /Demo/Demo/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /Demo/Demo/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 33 | 42 | 51 | 60 | 69 | 70 | 71 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | -------------------------------------------------------------------------------- /Demo/Demo/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UILaunchStoryboardName 24 | LaunchScreen 25 | UIMainStoryboardFile 26 | Main 27 | UIRequiredDeviceCapabilities 28 | 29 | armv7 30 | 31 | UISupportedInterfaceOrientations 32 | 33 | UIInterfaceOrientationPortrait 34 | UIInterfaceOrientationLandscapeLeft 35 | UIInterfaceOrientationLandscapeRight 36 | 37 | UISupportedInterfaceOrientations~ipad 38 | 39 | UIInterfaceOrientationPortrait 40 | UIInterfaceOrientationPortraitUpsideDown 41 | UIInterfaceOrientationLandscapeLeft 42 | UIInterfaceOrientationLandscapeRight 43 | 44 | NSAppTransportSecurity 45 | 46 | NSAllowsArbitraryLoads 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /Demo/Demo/ViewController.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewController.swift 3 | // Demo 4 | // 5 | // Created by Ziyi Zhang on 23/06/2017. 6 | // Copyright © 2017 hippo_san. All rights reserved. 7 | // 8 | 9 | import UIKit 10 | import AVFoundation 11 | 12 | import LemonDeer 13 | import GCDWebServer 14 | 15 | class ViewController: UIViewController { 16 | @IBOutlet var progressLabel: UILabel! 17 | @IBOutlet var downloadButton: UIButton! 18 | @IBOutlet var playerView: UIView! 19 | 20 | fileprivate var isDownloading = false 21 | fileprivate var duringDownloadingProcess = false 22 | 23 | private let lemonDeer = LemonDeer() 24 | private var server: GCDWebServer! = nil 25 | private var player = AVPlayer() 26 | private var playerLayer = AVPlayerLayer() 27 | 28 | @IBAction func download(_ sender: Any) { 29 | if !isDownloading { 30 | DispatchQueue.main.async { 31 | self.downloadButton.setTitle("Pause", for: .normal) 32 | } 33 | 34 | isDownloading = true 35 | 36 | if duringDownloadingProcess { 37 | lemonDeer.downloader.resumeDownloadSegment() 38 | } else { 39 | let url = "http://pl-ali.youku.com/playlist/m3u8?ts=1497413452&keyframe=1&vid=704675076&type=hd2&sid=0497413452394200e1f61&token=8269&oip=1696929637&did=2739b348d6020958407ddebff48b76bd&ctype=20&ev=1&ep=yZa8BLwhkewm%2BJYwNpWin%2BP9q1Xl%2FcCHzQ80y23Oig6QzYfmciUxpx%2F65Yk1CRBd" 40 | 41 | lemonDeer.directoryName = "Demo" 42 | lemonDeer.m3u8URL = url 43 | lemonDeer.delegate = self 44 | lemonDeer.parse() 45 | } 46 | } else { 47 | DispatchQueue.main.async { 48 | self.downloadButton.setTitle("Download", for: .normal) 49 | } 50 | 51 | isDownloading = false 52 | duringDownloadingProcess = true 53 | 54 | lemonDeer.downloader.pauseDownloadSegment() 55 | } 56 | } 57 | @IBAction func playOnlineVideo(_ sender: Any) { 58 | configurePlayer(with: "http://pl-ali.youku.com/playlist/m3u8?ts=1497413452&keyframe=1&vid=704675076&type=hd2&sid=0497413452394200e1f61&token=8269&oip=1696929637&did=2739b348d6020958407ddebff48b76bd&ctype=20&ev=1&ep=yZa8BLwhkewm%2BJYwNpWin%2BP9q1Xl%2FcCHzQ80y23Oig6QzYfmciUxpx%2F65Yk1CRBd") 59 | } 60 | 61 | @IBAction func playLocalVideo(_ sender: Any) { 62 | server = GCDWebDAVServer(uploadDirectory: getDocumentsDirectory().appendingPathComponent("Downloads").appendingPathComponent("Demo").path) 63 | server.start() 64 | 65 | configurePlayer(with: "http://127.0.0.1:8080/Demo.m3u8") 66 | } 67 | 68 | @IBAction func deleteDownloadedContents(_ sender: Any) { 69 | lemonDeer.downloader.deleteAllDownloadedContents() 70 | 71 | progressLabel.text = "0 %" 72 | } 73 | 74 | @IBAction func deleteContentWithName(_ sender: Any) { 75 | let alert = UIAlertController(title: "Delete Content", message: "Input the name of directory you want to delete.", preferredStyle: .alert) 76 | alert.addTextField() 77 | 78 | let confirmAction = UIAlertAction(title: "OK", style: .default) { [weak alert] _ in 79 | self.lemonDeer.downloader.deleteDownloadedContents(with: (alert?.textFields?[0].text)!) 80 | 81 | self.progressLabel.text = " " 82 | } 83 | 84 | let cancelAction = UIAlertAction(title: "Cancel", style: .default) { [weak alert] _ in 85 | alert?.dismiss(animated: true) 86 | } 87 | 88 | alert.addAction(confirmAction) 89 | alert.addAction(cancelAction) 90 | 91 | present(alert, animated: true) 92 | } 93 | 94 | private func configurePlayer(with url: String) { 95 | player.pause() 96 | playerLayer.removeFromSuperlayer() 97 | 98 | player = AVPlayer(url: URL(string: url)!) 99 | playerLayer = AVPlayerLayer(player: player) 100 | playerLayer.frame = CGRect(x: 0, y: 0, width: playerView.bounds.width, height: playerView.bounds.height) 101 | playerView.layer.addSublayer(playerLayer) 102 | 103 | player.play() 104 | } 105 | } 106 | 107 | extension ViewController: LemonDeerDelegate { 108 | func videoDownloadSucceeded() { 109 | print("Video download succeeded.") 110 | 111 | isDownloading = false 112 | duringDownloadingProcess = false 113 | 114 | DispatchQueue.main.async { 115 | self.downloadButton.setTitle("Finished", for: .normal) 116 | } 117 | 118 | downloadButton.isUserInteractionEnabled = false 119 | } 120 | 121 | func videoDownloadFailed() { 122 | print("Video download failed.") 123 | } 124 | 125 | func update(_ progress: Float, with directoryName: String) { 126 | progressLabel.text = "\(progress * 100) %" 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /Demo/Podfile: -------------------------------------------------------------------------------- 1 | use_frameworks! 2 | 3 | target 'Demo' do 4 | pod 'LemonDeer', :path => '../' 5 | 6 | pod 'CocoaLumberjack/Swift', "~> 3.2.0" 7 | pod "GCDWebServer/WebDAV", "~> 3.0" 8 | end 9 | -------------------------------------------------------------------------------- /Demo/Podfile.lock: -------------------------------------------------------------------------------- 1 | PODS: 2 | - CocoaLumberjack/Default (3.2.0) 3 | - CocoaLumberjack/Swift (3.2.0): 4 | - CocoaLumberjack/Default 5 | - GCDWebServer/Core (3.3.2) 6 | - GCDWebServer/WebDAV (3.3.2): 7 | - GCDWebServer/WebDAV/Core (= 3.3.2) 8 | - GCDWebServer/WebDAV/Core (3.3.2): 9 | - GCDWebServer/Core 10 | - LemonDeer (1.0.3) 11 | 12 | DEPENDENCIES: 13 | - CocoaLumberjack/Swift (~> 3.2.0) 14 | - GCDWebServer/WebDAV (~> 3.0) 15 | - LemonDeer (from `../`) 16 | 17 | EXTERNAL SOURCES: 18 | LemonDeer: 19 | :path: ../ 20 | 21 | SPEC CHECKSUMS: 22 | CocoaLumberjack: 9b4aed7073d242f29cc2f62068d995faf67f703a 23 | GCDWebServer: 2a375ec42839a41d7187d04e5b688d32fa5c4cd5 24 | LemonDeer: c871306e11cca5cc53105fd30d94b1e8460563f1 25 | 26 | PODFILE CHECKSUM: 68812568e014cf7fdfd3ccf77acbeb7bda1ea0cf 27 | 28 | COCOAPODS: 1.3.0.beta.2 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 hippo_san 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /LemonDeer.podspec: -------------------------------------------------------------------------------- 1 | Pod::Spec.new do |s| 2 | s.name = 'LemonDeer' 3 | s.version = '1.0.3' 4 | s.summary = 'Make m3u8 parse and download as a breeze.' 5 | 6 | s.description = <<-DESC 7 | LemonDeer is an iOS framewrok that parses m3u8 file and downloads videos easy as breeze. It is written purely in Swift, along with several useful customizable methods. 8 | DESC 9 | 10 | s.homepage = 'https://github.com/hipposan/LemonDeer' 11 | s.screenshots = 'https://raw.githubusercontent.com/hipposan/LemonDeer/master/Resources/LemonDeer-logo.png' 12 | s.license = { :type => 'MIT', :file => 'LICENSE' } 13 | s.author = { 'hippo_san' => 'zzy0600@gmail.com' } 14 | s.source = { :git => 'https://github.com/hipposan/LemonDeer.git', :tag => s.version.to_s } 15 | s.social_media_url = 'https://github.com/hipposan' 16 | 17 | s.ios.deployment_target = '9.0' 18 | 19 | s.source_files = 'LemonDeer/Classes/**/*' 20 | end 21 | -------------------------------------------------------------------------------- /LemonDeer/Assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hipposan/LemonDeer/bfb0d9a6225ca9eaf0cec525033ddcb45083325d/LemonDeer/Assets/.gitkeep -------------------------------------------------------------------------------- /LemonDeer/Classes/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hipposan/LemonDeer/bfb0d9a6225ca9eaf0cec525033ddcb45083325d/LemonDeer/Classes/.gitkeep -------------------------------------------------------------------------------- /LemonDeer/Classes/LemonDeer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LemonDeer.swift 3 | // WindmillComic 4 | // 5 | // Created by Ziyi Zhang on 09/06/2017. 6 | // Copyright © 2017 Ziyideas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol LemonDeerDelegate: class { 12 | func videoDownloadSucceeded() 13 | func videoDownloadFailed() 14 | 15 | func update(_ progress: Float, with directoryName: String) 16 | } 17 | 18 | open class LemonDeer { 19 | public let downloader = VideoDownloader() 20 | public var progress: Float = 0.0 21 | public var directoryName: String = "" { 22 | didSet { 23 | m3u8Parser.identifier = directoryName 24 | } 25 | } 26 | public var m3u8URL = "" 27 | 28 | private let m3u8Parser = M3u8Parser() 29 | 30 | public weak var delegate: LemonDeerDelegate? 31 | 32 | public init() { 33 | 34 | } 35 | 36 | open func parse() { 37 | downloader.delegate = self 38 | m3u8Parser.delegate = self 39 | m3u8Parser.parse(with: m3u8URL) 40 | } 41 | } 42 | 43 | extension LemonDeer: M3u8ParserDelegate { 44 | func parseM3u8Succeeded(by parser: M3u8Parser) { 45 | downloader.tsPlaylist = parser.tsPlaylist 46 | downloader.m3u8Data = parser.m3u8Data 47 | downloader.startDownload() 48 | } 49 | 50 | func parseM3u8Failed(by parser: M3u8Parser) { 51 | print("Parse m3u8 file failed.") 52 | } 53 | } 54 | 55 | extension LemonDeer: VideoDownloaderDelegate { 56 | func videoDownloadSucceeded(by downloader: VideoDownloader) { 57 | delegate?.videoDownloadSucceeded() 58 | } 59 | 60 | func videoDownloadFailed(by downloader: VideoDownloader) { 61 | delegate?.videoDownloadFailed() 62 | } 63 | 64 | func update(_ progress: Float) { 65 | self.progress = progress 66 | 67 | delegate?.update(progress, with: directoryName) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /LemonDeer/Classes/M3u8Parser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // m3u8Handler.swift 3 | // WindmillComic 4 | // 5 | // Created by hippo_san on 08/06/2017. 6 | // Copyright © 2017 Ziyideas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol M3u8ParserDelegate: class { 12 | func parseM3u8Succeeded(by parser: M3u8Parser) 13 | func parseM3u8Failed(by parser: M3u8Parser) 14 | } 15 | 16 | open class M3u8Parser { 17 | weak var delegate: M3u8ParserDelegate? 18 | 19 | var m3u8Data: String = "" 20 | var tsSegmentArray = [M3u8TsSegmentModel]() 21 | var tsPlaylist = M3u8Playlist() 22 | var identifier = "" 23 | 24 | /** 25 | To parse m3u8 file with a provided URL. 26 | 27 | - parameter url: A string of URL you want to parse. 28 | */ 29 | open func parse(with url: String) { 30 | guard let m3u8ParserDelegate = delegate else { 31 | print("M3u8ParserDelegate not set.") 32 | return 33 | } 34 | 35 | if !(url.hasPrefix("http://") || url.hasPrefix("https://")) { 36 | print("Invalid URL.") 37 | m3u8ParserDelegate.parseM3u8Failed(by: self) 38 | return 39 | } 40 | 41 | DispatchQueue.global(qos: .background).async { 42 | do { 43 | let m3u8Content = try String(contentsOf: URL(string: url)!, encoding: .utf8) 44 | 45 | if m3u8Content == "" { 46 | print("Empty m3u8 content.") 47 | m3u8ParserDelegate.parseM3u8Failed(by: self) 48 | return 49 | } else { 50 | guard (m3u8Content.range(of: "#EXTINF:") != nil) else { 51 | print("No EXTINF info.") 52 | m3u8ParserDelegate.parseM3u8Failed(by: self) 53 | return 54 | } 55 | 56 | self.m3u8Data = m3u8Content 57 | if self.tsSegmentArray.count > 0 { self.tsSegmentArray.removeAll() } 58 | 59 | let segmentRange = m3u8Content.range(of: "#EXTINF:")! 60 | let segmentsString = String(m3u8Content.characters.suffix(from: segmentRange.lowerBound)).components(separatedBy: "#EXT-X-ENDLIST") 61 | var segmentArray = segmentsString[0].components(separatedBy: "\n") 62 | segmentArray = segmentArray.filter { !$0.contains("#EXT-X-DISCONTINUITY") } 63 | 64 | while (segmentArray.count > 2) { 65 | var segmentModel = M3u8TsSegmentModel() 66 | 67 | let segmentDurationPart = segmentArray[0].components(separatedBy: ":")[1] 68 | var segmentDuration: Float = 0.0 69 | 70 | if segmentDurationPart.contains(",") { 71 | segmentDuration = Float(segmentDurationPart.components(separatedBy: ",")[0])! 72 | } else { 73 | segmentDuration = Float(segmentDurationPart)! 74 | } 75 | 76 | let segmentURL = segmentArray[1] 77 | segmentModel.duration = segmentDuration 78 | segmentModel.locationURL = segmentURL 79 | 80 | self.tsSegmentArray.append(segmentModel) 81 | 82 | segmentArray.remove(at: 0) 83 | segmentArray.remove(at: 0) 84 | } 85 | 86 | self.tsPlaylist.initSegment(with: self.tsSegmentArray) 87 | self.tsPlaylist.identifier = self.identifier 88 | 89 | m3u8ParserDelegate.parseM3u8Succeeded(by: self) 90 | } 91 | } catch let error { 92 | print(error.localizedDescription) 93 | print("Read m3u8 file content error.") 94 | } 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /LemonDeer/Classes/M3u8Playlist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // M3u8Playlist.swift 3 | // WindmillComic 4 | // 5 | // Created by hippo_san on 08/06/2017. 6 | // Copyright © 2017 Ziyideas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class M3u8Playlist { 12 | var tsSegmentArray = [M3u8TsSegmentModel]() 13 | var length = 0 14 | var identifier = "" 15 | 16 | func initSegment(with array: [M3u8TsSegmentModel]) { 17 | tsSegmentArray = array 18 | length = array.count 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /LemonDeer/Classes/M3u8TsSegmentModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // M3u8TsSegmentModel.swift 3 | // WindmillComic 4 | // 5 | // Created by hippo_san on 08/06/2017. 6 | // Copyright © 2017 Ziyideas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct M3u8TsSegmentModel { 12 | var duration: Float = 0.0 13 | var locationURL = "" 14 | var index: Int = 0 15 | } 16 | -------------------------------------------------------------------------------- /LemonDeer/Classes/SegmentDownloader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SegmentDownloader.swift 3 | // WindmillComic 4 | // 5 | // Created by Ziyi Zhang on 09/06/2017. 6 | // Copyright © 2017 Ziyideas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol SegmentDownloaderDelegate { 12 | func segmentDownloadSucceeded(with downloader: SegmentDownloader) 13 | func segmentDownloadFailed(with downloader: SegmentDownloader) 14 | } 15 | 16 | class SegmentDownloader: NSObject { 17 | var fileName: String 18 | var filePath: String 19 | var downloadURL: String 20 | var duration: Float 21 | var index: Int 22 | 23 | lazy var downloadSession: URLSession = { 24 | let configuration = URLSessionConfiguration.default 25 | let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) 26 | 27 | return session 28 | }() 29 | 30 | var downloadTask: URLSessionDownloadTask? 31 | var isDownloading = false 32 | var finishedDownload = false 33 | 34 | var delegate: SegmentDownloaderDelegate? 35 | 36 | init(with url: String, filePath: String, fileName: String, duration: Float, index: Int) { 37 | downloadURL = url 38 | self.filePath = filePath 39 | self.fileName = fileName 40 | self.duration = duration 41 | self.index = index 42 | } 43 | 44 | func startDownload() { 45 | if checkIfIsDownloaded() { 46 | finishedDownload = true 47 | 48 | delegate?.segmentDownloadSucceeded(with: self) 49 | } else { 50 | let url = downloadURL.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! 51 | 52 | guard let taskURL = URL(string: url) else { return } 53 | 54 | downloadTask = downloadSession.downloadTask(with: taskURL) 55 | downloadTask?.resume() 56 | isDownloading = true 57 | } 58 | } 59 | 60 | func cancelDownload() { 61 | downloadTask?.cancel() 62 | isDownloading = false 63 | } 64 | 65 | func pauseDownload() { 66 | if isDownloading { 67 | downloadTask?.suspend() 68 | 69 | isDownloading = false 70 | } 71 | } 72 | 73 | func resumeDownload() { 74 | downloadTask?.resume() 75 | isDownloading = true 76 | } 77 | 78 | func checkIfIsDownloaded() -> Bool { 79 | let filePath = generateFilePath().path 80 | 81 | if FileManager.default.fileExists(atPath: filePath) { 82 | return true 83 | } else { 84 | return false 85 | } 86 | } 87 | 88 | func generateFilePath() -> URL { 89 | return getDocumentsDirectory().appendingPathComponent("Downloads").appendingPathComponent(filePath).appendingPathComponent(fileName) 90 | } 91 | } 92 | 93 | extension SegmentDownloader: URLSessionDownloadDelegate { 94 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { 95 | let destinationURL = generateFilePath() 96 | 97 | finishedDownload = true 98 | isDownloading = false 99 | 100 | if FileManager.default.fileExists(atPath: destinationURL.path) { 101 | return 102 | } else { 103 | do { 104 | try FileManager.default.moveItem(at: location, to: destinationURL) 105 | delegate?.segmentDownloadSucceeded(with: self) 106 | } catch let error as NSError { 107 | print(error.localizedDescription) 108 | } 109 | } 110 | } 111 | 112 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 113 | if error != nil { 114 | finishedDownload = false 115 | isDownloading = false 116 | 117 | delegate?.segmentDownloadFailed(with: self) 118 | } 119 | } 120 | 121 | } 122 | -------------------------------------------------------------------------------- /LemonDeer/Classes/VideoDownloader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VideoDownloader.swift 3 | // WindmillComic 4 | // 5 | // Created by Ziyi Zhang on 09/06/2017. 6 | // Copyright © 2017 Ziyideas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public enum Status { 12 | case started 13 | case paused 14 | case canceled 15 | case finished 16 | } 17 | 18 | protocol VideoDownloaderDelegate { 19 | func videoDownloadSucceeded(by downloader: VideoDownloader) 20 | func videoDownloadFailed(by downloader: VideoDownloader) 21 | 22 | func update(_ progress: Float) 23 | } 24 | 25 | open class VideoDownloader { 26 | public var downloadStatus: Status = .paused 27 | 28 | var m3u8Data: String = "" 29 | var tsPlaylist = M3u8Playlist() 30 | var segmentDownloaders = [SegmentDownloader]() 31 | var tsFilesIndex = 0 32 | var neededDownloadTsFilesCount = 0 33 | var downloadURLs = [String]() 34 | var downloadingProgress: Float { 35 | let finishedDownloadFilesCount = segmentDownloaders.filter({ $0.finishedDownload == true }).count 36 | let fraction = Float(finishedDownloadFilesCount) / Float(neededDownloadTsFilesCount) 37 | let roundedValue = round(fraction * 100) / 100 38 | 39 | return roundedValue 40 | } 41 | 42 | fileprivate var startDownloadIndex = 2 43 | 44 | var delegate: VideoDownloaderDelegate? 45 | 46 | open func startDownload() { 47 | checkOrCreatedM3u8Directory() 48 | 49 | var newSegmentArray = [M3u8TsSegmentModel]() 50 | 51 | let notInDownloadList = tsPlaylist.tsSegmentArray.filter { !downloadURLs.contains($0.locationURL) } 52 | neededDownloadTsFilesCount = tsPlaylist.length 53 | 54 | for i in 0 ..< notInDownloadList.count { 55 | let fileName = "\(tsFilesIndex).ts" 56 | 57 | let segmentDownloader = SegmentDownloader(with: notInDownloadList[i].locationURL, 58 | filePath: tsPlaylist.identifier, 59 | fileName: fileName, 60 | duration: notInDownloadList[i].duration, 61 | index: tsFilesIndex) 62 | segmentDownloader.delegate = self 63 | 64 | segmentDownloaders.append(segmentDownloader) 65 | downloadURLs.append(notInDownloadList[i].locationURL) 66 | 67 | var segmentModel = M3u8TsSegmentModel() 68 | segmentModel.duration = segmentDownloaders[i].duration 69 | segmentModel.locationURL = segmentDownloaders[i].fileName 70 | segmentModel.index = segmentDownloaders[i].index 71 | newSegmentArray.append(segmentModel) 72 | 73 | tsPlaylist.tsSegmentArray = newSegmentArray 74 | 75 | tsFilesIndex += 1 76 | } 77 | 78 | segmentDownloaders[0].startDownload() 79 | segmentDownloaders[1].startDownload() 80 | segmentDownloaders[2].startDownload() 81 | 82 | downloadStatus = .started 83 | } 84 | 85 | func checkDownloadQueue() { 86 | 87 | } 88 | 89 | func updateLocalM3U8file() { 90 | checkOrCreatedM3u8Directory() 91 | 92 | let filePath = getDocumentsDirectory().appendingPathComponent("Downloads").appendingPathComponent(tsPlaylist.identifier).appendingPathComponent("\(tsPlaylist.identifier).m3u8") 93 | 94 | var header = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:15\n" 95 | var content = "" 96 | 97 | for i in 0 ..< tsPlaylist.tsSegmentArray.count { 98 | let segmentModel = tsPlaylist.tsSegmentArray[i] 99 | 100 | let length = "#EXTINF:\(segmentModel.duration),\n" 101 | let fileName = "http://127.0.0.1:8080/\(segmentModel.index).ts\n" 102 | content += (length + fileName) 103 | } 104 | 105 | header.append(content) 106 | header.append("#EXT-X-ENDLIST\n") 107 | 108 | let writeData: Data = header.data(using: .utf8)! 109 | try! writeData.write(to: filePath) 110 | } 111 | 112 | private func checkOrCreatedM3u8Directory() { 113 | let filePath = getDocumentsDirectory().appendingPathComponent("Downloads").appendingPathComponent(tsPlaylist.identifier) 114 | 115 | if !FileManager.default.fileExists(atPath: filePath.path) { 116 | try! FileManager.default.createDirectory(at: filePath, withIntermediateDirectories: true, attributes: nil) 117 | } 118 | } 119 | 120 | open func deleteAllDownloadedContents() { 121 | let filePath = getDocumentsDirectory().appendingPathComponent("Downloads").path 122 | 123 | if FileManager.default.fileExists(atPath: filePath) { 124 | try! FileManager.default.removeItem(atPath: filePath) 125 | } else { 126 | print("File has already been deleted.") 127 | } 128 | } 129 | 130 | open func deleteDownloadedContents(with name: String) { 131 | let filePath = getDocumentsDirectory().appendingPathComponent("Downloads").appendingPathComponent(name).path 132 | 133 | if FileManager.default.fileExists(atPath: filePath) { 134 | try! FileManager.default.removeItem(atPath: filePath) 135 | } else { 136 | print("Could not find directory with name: \(name)") 137 | } 138 | } 139 | 140 | open func pauseDownloadSegment() { 141 | _ = segmentDownloaders.map { $0.pauseDownload() } 142 | 143 | downloadStatus = .paused 144 | } 145 | 146 | open func cancelDownloadSegment() { 147 | _ = segmentDownloaders.map { $0.cancelDownload() } 148 | 149 | downloadStatus = .canceled 150 | } 151 | 152 | open func resumeDownloadSegment() { 153 | _ = segmentDownloaders.map { $0.resumeDownload() } 154 | 155 | downloadStatus = .started 156 | } 157 | } 158 | 159 | extension VideoDownloader: SegmentDownloaderDelegate { 160 | func segmentDownloadSucceeded(with downloader: SegmentDownloader) { 161 | let finishedDownloadFilesCount = segmentDownloaders.filter({ $0.finishedDownload == true }).count 162 | 163 | DispatchQueue.main.async { 164 | self.delegate?.update(self.downloadingProgress) 165 | } 166 | 167 | updateLocalM3U8file() 168 | 169 | let downloadingFilesCount = segmentDownloaders.filter({ $0.isDownloading == true }).count 170 | 171 | if finishedDownloadFilesCount == neededDownloadTsFilesCount { 172 | delegate?.videoDownloadSucceeded(by: self) 173 | 174 | downloadStatus = .finished 175 | } else if startDownloadIndex == neededDownloadTsFilesCount - 1 { 176 | if segmentDownloaders[startDownloadIndex].isDownloading == true { return } 177 | } 178 | else if downloadingFilesCount < 3 || finishedDownloadFilesCount != neededDownloadTsFilesCount { 179 | if startDownloadIndex < neededDownloadTsFilesCount - 1 { 180 | startDownloadIndex += 1 181 | } 182 | 183 | segmentDownloaders[startDownloadIndex].startDownload() 184 | } 185 | } 186 | 187 | func segmentDownloadFailed(with downloader: SegmentDownloader) { 188 | delegate?.videoDownloadFailed(by: self) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /LemonDeer/Classes/VideoDownloaderHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VideoDownloaderHelper.swift 3 | // WindmillComic 4 | // 5 | // Created by Ziyi Zhang on 09/06/2017. 6 | // Copyright © 2017 Ziyideas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public func getDocumentsDirectory() -> URL { 12 | let paths = FileManager.default.urls(for: .documentDirectory, in:.userDomainMask) 13 | let documentsDirectory = paths[0] 14 | return documentsDirectory 15 | } 16 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | import PackageDescription 2 | 3 | let package = Package( 4 | name: "LemonDeer", 5 | dependencies: [ 6 | .Package(url: "https://github.com/hipposan/LemonDeer.git", majorVersion: 1.0.0) 7 | ] 8 | ) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

Make m3u8 parse and video download as white magic.

4 |

5 | 6 |

7 | 8 | Pods Version 9 | 10 | 11 | Platforms 12 | 13 | 14 | Carthage 15 | 16 | 17 | Swift Package Manager 18 | 19 | 20 | Swift Version 21 | 22 | 23 | License 24 | 25 | 26 | Twitter 27 | 28 |

29 | 30 | ___________________ 31 | 32 | Features| 33 | ------------------------------- | 34 | Parse and download m3u8 files| 35 | Customize downloading progress| 36 | Pure Swift| 37 | 38 | 39 | ## Example 40 | To run the example project, clone the repo, and run `pod install` from the Example directory first. 41 | 42 | 43 | ## Requirements 44 | * Xcode 8.0+ 45 | * iOS 9.0+ 46 | * Swift 3.0+ 47 | 48 | > **Note:** 49 | > * Your m3u8 file should include **#EXFINT** information to make parsing pass. 50 | > * Your local server's port should be **8080** to make local video play. 51 | 52 | 53 | ## Usage 54 | Define dowloading directory name: 55 | 56 | ```swift 57 | let directoryName = "Name" 58 | let lemonDeer = LemonDeer() 59 | lemonDeer.directoryName = directoryName 60 | ``` 61 | ____________ 62 | 63 | Parse and begin downloading m3u8 with URL: 64 | 65 | ```swift 66 | let directoryName = "Name" 67 | let lemonDeer = LemonDeer() 68 | lemonDeer.directoryName = directoryName 69 | 70 | let url = "https://urlstring.m3u8" 71 | lemonDeer.m3u8URL = url 72 | lemonDeer.parse() 73 | ``` 74 | ____________ 75 | 76 | Manipulate downloading process: 77 | * Pause 78 | 79 | ```swift 80 | lemonDeer.downloader.pauseDownloadSegment() 81 | ``` 82 | 83 | * Resume 84 | 85 | ```swift 86 | lemonDeer.downloader.resumeDownloadSegment() 87 | ``` 88 | 89 | * Cancel 90 | 91 | ```swift 92 | lemonDeer.downloader.cancelDownloadSegment() 93 | ``` 94 | ____________ 95 | 96 | Delete downloaded contents 97 | * Delete a specific directory 98 | 99 | ```swift 100 | lemonDeer.downloader.deleteDownloadedContents(with: ("DirectoryNameYouWantToDelete") 101 | ``` 102 | 103 | * Delete all downloaded contents 104 | 105 | ```swift 106 | lemonDeer.downloader.deleteAllDownloadedContents() 107 | ``` 108 | ____________ 109 | 110 | Define your own after download succeeded 111 | 112 | ```swift 113 | class YourClass: LemonDeerDelegate { 114 | func videoDownloadSucceeded() 115 | } 116 | ``` 117 | 118 | Define your own after download failed 119 | 120 | ```swift 121 | class YourClass: LemonDeerDelegate { 122 | func videoDownloadFailed() 123 | } 124 | ``` 125 | 126 | Customize downloading progress 127 | 128 | ```swift 129 | class YourClass: LemonDeerDelegate { 130 | func update(_ progress: Float, with directoryName: String) {} 131 | } 132 | ``` 133 | 134 | ## Installation 135 | ### Installation with CocoaPods 136 | LemonDeer is available through [CocoaPods](http://cocoapods.org). To install it, simply add the following line to your Podfile: 137 | 138 | ```ruby 139 | pod "LemonDeer" 140 | ``` 141 | 142 | ### Installation with Carthage 143 | [Carthage](https://github.com/Carthage/Carthage) is a lightweight dependency manager for Swift and Objective-C. It leverages CocoaTouch modules and is less invasive than CocoaPods. 144 | 145 | To install with carthage, follow the instruction on [Carthage](https://github.com/Carthage/Carthage) 146 | 147 | #### Cartfile 148 | ``` 149 | github "hipposan/LemonDeer" 150 | ``` 151 | 152 | ### Installation with Swift Package Manager 153 | The Swift Package Manager is a tool for managing the distribution of Swift code. Just add the url of this repo to your `Package.swift` file as a dependency: 154 | 155 | ```swift 156 | import PackageDescription 157 | 158 | let package = Package( 159 | name: "YourPackage", 160 | dependencies: [ 161 | .Package(url: "https://github.com/hipposan/LemonDeer.git", majorVersion: 1.0.0) 162 | ] 163 | ) 164 | ``` 165 | 166 | ## Author 167 | Contact me at [Twitter](https://twitter.com/zzy0600). 168 | 169 | 170 | ## License 171 | LemonDeer is available under the MIT license. See the LICENSE file for more info. 172 | -------------------------------------------------------------------------------- /Resources/LemonDeer-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hipposan/LemonDeer/bfb0d9a6225ca9eaf0cec525033ddcb45083325d/Resources/LemonDeer-logo.png -------------------------------------------------------------------------------- /Sources/DownloadManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DownloadManager.swift 3 | // Demo 4 | // 5 | // Created by Ziyi Zhang on 14/06/2017. 6 | // Copyright © 2017 Ziyideas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol DownloadManagerDelegate { 12 | func downloadSucceeded(with downloadManager: DownloadManager) 13 | func downloadFailed(with downloadManager: DownloadManager) 14 | } 15 | 16 | public class DownloadManager: NSObject { 17 | var fileName: String = "" 18 | var filePath: String = "" 19 | 20 | 21 | 22 | var delegate: DownloadManagerDelegate? 23 | 24 | func generateFilePath() -> URL { 25 | return getDocumentsDirectory().appendingPathComponent("Downloads").appendingPathComponent(filePath).appendingPathComponent(fileName) 26 | } 27 | } 28 | 29 | 30 | -------------------------------------------------------------------------------- /Sources/LemonDeer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LemonDeer.swift 3 | // WindmillComic 4 | // 5 | // Created by Ziyi Zhang on 09/06/2017. 6 | // Copyright © 2017 Ziyideas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public protocol LemonDeerDelegate: class { 12 | func videoDownloadSucceeded() 13 | func videoDownloadFailed() 14 | 15 | func updateProgressLabel(by percentage: String) 16 | } 17 | 18 | open class LemonDeer { 19 | public let downloader = VideoDownloader() 20 | let m3u8Parser = M3u8Parser() 21 | var playURL = "" 22 | var isM3u8 = false 23 | 24 | public weak var delegate: LemonDeerDelegate? 25 | 26 | public init(directoryName: String) { 27 | m3u8Parser.identifier = directoryName 28 | } 29 | 30 | open func parse(m3u8URL: String) { 31 | downloader.delegate = self 32 | m3u8Parser.delegate = self 33 | m3u8Parser.parse(with: m3u8URL) 34 | 35 | playURL = m3u8URL 36 | } 37 | } 38 | 39 | extension LemonDeer: M3u8ParserDelegate { 40 | func parseM3u8Succeeded(by parser: M3u8Parser) { 41 | downloader.tsPlaylist = parser.tsPlaylist 42 | downloader.m3u8Data = parser.m3u8Data 43 | downloader.startDownload() 44 | } 45 | 46 | func parseM3u8Failed(by parser: M3u8Parser) { 47 | print("Parse m3u8 file failed.") 48 | } 49 | } 50 | 51 | extension LemonDeer: VideoDownloaderDelegate { 52 | func videoDownloadSucceeded(by downloader: VideoDownloader) { 53 | delegate?.videoDownloadSucceeded() 54 | } 55 | 56 | func videoDownloadFailed(by downloader: VideoDownloader) { 57 | delegate?.videoDownloadFailed() 58 | } 59 | 60 | func updateProgressLabel(by percentage: String) { 61 | delegate?.updateProgressLabel(by: percentage) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/M3u8Parser.swift: -------------------------------------------------------------------------------- 1 | // 2 | // m3u8Handler.swift 3 | // WindmillComic 4 | // 5 | // Created by hippo_san on 08/06/2017. 6 | // Copyright © 2017 Ziyideas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol M3u8ParserDelegate: class { 12 | func parseM3u8Succeeded(by parser: M3u8Parser) 13 | func parseM3u8Failed(by parser: M3u8Parser) 14 | } 15 | 16 | open class M3u8Parser { 17 | weak var delegate: M3u8ParserDelegate? 18 | 19 | var m3u8Data: String = "" 20 | var tsSegmentArray = [M3u8TsSegmentModel]() 21 | var tsPlaylist = M3u8Playlist() 22 | var identifier = "" 23 | 24 | /** 25 | To parse m3u8 file with a provided URL. 26 | 27 | - parameter url: A string of URL you want to parse. 28 | */ 29 | open func parse(with url: String) { 30 | guard let m3u8ParserDelegate = delegate else { 31 | print("M3u8ParserDelegate not set.") 32 | return 33 | } 34 | 35 | if !(url.hasPrefix("http://") || url.hasPrefix("https://")) { 36 | print("Invalid URL.") 37 | m3u8ParserDelegate.parseM3u8Failed(by: self) 38 | return 39 | } 40 | 41 | do { 42 | let m3u8Content = try String(contentsOf: URL(string: url)!, encoding: .utf8) 43 | 44 | if m3u8Content == "" { 45 | print("Empty m3u8 content.") 46 | m3u8ParserDelegate.parseM3u8Failed(by: self) 47 | return 48 | } else { 49 | guard (m3u8Content.range(of: "#EXTINF:") != nil) else { 50 | print("No EXTINF info.") 51 | m3u8ParserDelegate.parseM3u8Failed(by: self) 52 | return 53 | } 54 | 55 | self.m3u8Data = m3u8Content 56 | if tsSegmentArray.count > 0 { tsSegmentArray.removeAll() } 57 | 58 | let segmentRange = m3u8Content.range(of: "#EXTINF:")! 59 | let segmentsString = String(m3u8Content.characters.suffix(from: segmentRange.lowerBound)).components(separatedBy: "#EXT-X-ENDLIST") 60 | var segmentArray = segmentsString[0].components(separatedBy: "\r\n") 61 | 62 | if segmentArray.contains("#EXT-X-DISCONTINUITY") { 63 | segmentArray = segmentArray.filter { $0 != "#EXT-X-DISCONTINUITY" } 64 | } 65 | 66 | while (segmentArray.count > 2) { 67 | var segmentModel = M3u8TsSegmentModel() 68 | 69 | let segmentDurationPart = segmentArray[0].components(separatedBy: ":")[1] 70 | var segmentDuration: Float = 0.0 71 | 72 | if segmentDurationPart.contains(",") { 73 | segmentDuration = Float(segmentDurationPart.components(separatedBy: ",")[0])! 74 | } else { 75 | segmentDuration = Float(segmentDurationPart)! 76 | } 77 | 78 | let segmentURL = segmentArray[1] 79 | segmentModel.duration = segmentDuration 80 | segmentModel.locationURL = segmentURL 81 | 82 | tsSegmentArray.append(segmentModel) 83 | 84 | segmentArray.remove(at: 0) 85 | segmentArray.remove(at: 0) 86 | } 87 | 88 | tsPlaylist.initSegment(with: tsSegmentArray) 89 | tsPlaylist.identifier = identifier 90 | 91 | m3u8ParserDelegate.parseM3u8Succeeded(by: self) 92 | } 93 | } catch let error { 94 | print(error.localizedDescription) 95 | print("Read m3u8 file content error.") 96 | } 97 | 98 | 99 | } 100 | 101 | } 102 | -------------------------------------------------------------------------------- /Sources/M3u8Playlist.swift: -------------------------------------------------------------------------------- 1 | // 2 | // M3u8Playlist.swift 3 | // WindmillComic 4 | // 5 | // Created by hippo_san on 08/06/2017. 6 | // Copyright © 2017 Ziyideas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | class M3u8Playlist { 12 | var tsSegmentArray = [M3u8TsSegmentModel]() 13 | var length = 0 14 | var identifier = "" 15 | 16 | func initSegment(with array: [M3u8TsSegmentModel]) { 17 | tsSegmentArray = array 18 | length = array.count 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sources/M3u8TsSegmentModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // M3u8TsSegmentModel.swift 3 | // WindmillComic 4 | // 5 | // Created by hippo_san on 08/06/2017. 6 | // Copyright © 2017 Ziyideas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | struct M3u8TsSegmentModel { 12 | var duration: Float = 0.0 13 | var locationURL = "" 14 | var index: Int = 0 15 | } 16 | -------------------------------------------------------------------------------- /Sources/SegmentDownloader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SegmentDownloader.swift 3 | // WindmillComic 4 | // 5 | // Created by Ziyi Zhang on 09/06/2017. 6 | // Copyright © 2017 Ziyideas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol SegmentDownloaderDelegate { 12 | func segmentDownloadSucceeded(with downloader: SegmentDownloader) 13 | func segmentDownloadFailed(with downloader: SegmentDownloader) 14 | } 15 | 16 | class SegmentDownloader: NSObject { 17 | var fileName: String 18 | var filePath: String 19 | var downloadURL: String 20 | var duration: Float 21 | var index: Int 22 | 23 | lazy var downloadSession: URLSession = { 24 | let configuration = URLSessionConfiguration.default 25 | let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) 26 | 27 | return session 28 | }() 29 | 30 | var downloadTask: URLSessionDownloadTask? 31 | var resumeData: Data? 32 | var isDownloading = false 33 | 34 | var delegate: SegmentDownloaderDelegate? 35 | 36 | init(with url: String, filePath: String, fileName: String, duration: Float, index: Int) { 37 | downloadURL = url 38 | self.filePath = filePath 39 | self.fileName = fileName 40 | self.duration = duration 41 | self.index = index 42 | } 43 | 44 | func startDownload() { 45 | if checkIfIsDownloaded() { 46 | delegate?.segmentDownloadSucceeded(with: self) 47 | return 48 | } else { 49 | downloadTask = downloadSession.downloadTask(with: URL(string: downloadURL)!) 50 | downloadTask?.resume() 51 | isDownloading = true 52 | } 53 | } 54 | 55 | func cancelDownload() { 56 | downloadTask?.cancel() 57 | isDownloading = false 58 | } 59 | 60 | func pauseDownload() { 61 | if isDownloading { 62 | downloadTask?.suspend() 63 | 64 | isDownloading = false 65 | } 66 | } 67 | 68 | func resumeDownload() { 69 | downloadTask?.resume() 70 | isDownloading = true 71 | } 72 | 73 | func checkIfIsDownloaded() -> Bool { 74 | let filePath = generateFilePath().path 75 | 76 | if FileManager.default.fileExists(atPath: filePath) { 77 | return true 78 | } else { 79 | return false 80 | } 81 | } 82 | 83 | func generateFilePath() -> URL { 84 | return getDocumentsDirectory().appendingPathComponent("Downloads").appendingPathComponent(filePath).appendingPathComponent(fileName) 85 | } 86 | } 87 | 88 | extension SegmentDownloader: URLSessionDownloadDelegate { 89 | func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { 90 | let destinationURL = generateFilePath() 91 | 92 | if FileManager.default.fileExists(atPath: destinationURL.path) { 93 | delegate?.segmentDownloadSucceeded(with: self) 94 | return 95 | } else { 96 | do { 97 | try FileManager.default.moveItem(at: location, to: destinationURL) 98 | delegate?.segmentDownloadSucceeded(with: self) 99 | } catch let error as NSError { 100 | print(error.localizedDescription) 101 | } 102 | } 103 | } 104 | 105 | func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { 106 | if error != nil { 107 | delegate?.segmentDownloadFailed(with: self) 108 | } 109 | } 110 | 111 | } 112 | -------------------------------------------------------------------------------- /Sources/VideoDownloader.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VideoDownloader.swift 3 | // WindmillComic 4 | // 5 | // Created by Ziyi Zhang on 09/06/2017. 6 | // Copyright © 2017 Ziyideas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | protocol VideoDownloaderDelegate { 12 | func videoDownloadSucceeded(by downloader: VideoDownloader) 13 | func videoDownloadFailed(by downloader: VideoDownloader) 14 | 15 | func updateProgressLabel(by percentage: String) 16 | } 17 | 18 | open class VideoDownloader { 19 | var m3u8Data: String = "" 20 | var tsPlaylist = M3u8Playlist() 21 | var segmentDownloaders = [SegmentDownloader]() 22 | var tsFilesIndex = 0 23 | var downloadedTsFilesCount = 0 24 | var neededDownloadTsFilesCount = 0 25 | var downloadURLs = [String]() 26 | var downloadingProgress: String { 27 | let fraction: Float = Float((Float(downloadedTsFilesCount) / Float(neededDownloadTsFilesCount)) * 100) 28 | let roundedValue: Int = Int(fraction.rounded(.toNearestOrEven)) 29 | let progressString = roundedValue.description + " %" 30 | 31 | return progressString 32 | } 33 | 34 | var delegate: VideoDownloaderDelegate? 35 | 36 | open func startDownload() { 37 | checkOrCreatedM3u8Directory() 38 | 39 | var newSegmentArray = [M3u8TsSegmentModel]() 40 | 41 | let notInDownloadList = tsPlaylist.tsSegmentArray.filter { !downloadURLs.contains($0.locationURL) } 42 | neededDownloadTsFilesCount = notInDownloadList.count 43 | 44 | for i in 0 ..< notInDownloadList.count { 45 | let fileName = "\(tsFilesIndex).ts" 46 | 47 | let segmentDownloader = SegmentDownloader(with: notInDownloadList[i].locationURL, 48 | filePath: tsPlaylist.identifier, 49 | fileName: fileName, 50 | duration: notInDownloadList[i].duration, 51 | index: tsFilesIndex) 52 | segmentDownloader.delegate = self 53 | 54 | segmentDownloaders.append(segmentDownloader) 55 | downloadURLs.append(notInDownloadList[i].locationURL) 56 | 57 | var segmentModel = M3u8TsSegmentModel() 58 | segmentModel.duration = segmentDownloaders[i].duration 59 | segmentModel.locationURL = segmentDownloaders[i].fileName 60 | segmentModel.index = segmentDownloaders[i].index 61 | newSegmentArray.append(segmentModel) 62 | 63 | tsPlaylist.tsSegmentArray = newSegmentArray 64 | 65 | segmentDownloaders[i].startDownload() 66 | 67 | tsFilesIndex += 1 68 | } 69 | } 70 | 71 | func updateLocalM3U8file() { 72 | checkOrCreatedM3u8Directory() 73 | 74 | let filePath = getDocumentsDirectory().appendingPathComponent("Downloads").appendingPathComponent(tsPlaylist.identifier).appendingPathComponent("\(tsPlaylist.identifier).m3u8") 75 | 76 | var header = "#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:15\n" 77 | var content = "" 78 | 79 | for i in 0 ..< tsPlaylist.tsSegmentArray.count { 80 | let segmentModel = tsPlaylist.tsSegmentArray[i] 81 | 82 | let length = "#EXTINF:\(segmentModel.duration),\n" 83 | let fileName = "http://127.0.0.1:8080/\(segmentModel.index).ts\n" 84 | content += (length + fileName) 85 | } 86 | 87 | header.append(content) 88 | header.append("#EXT-X-ENDLIST\n") 89 | 90 | let writeData: Data = header.data(using: .utf8)! 91 | try! writeData.write(to: filePath) 92 | } 93 | 94 | private func checkOrCreatedM3u8Directory() { 95 | let filePath = getDocumentsDirectory().appendingPathComponent("Downloads").appendingPathComponent(tsPlaylist.identifier) 96 | 97 | if !FileManager.default.fileExists(atPath: filePath.path) { 98 | try! FileManager.default.createDirectory(at: filePath, withIntermediateDirectories: true, attributes: nil) 99 | } 100 | } 101 | 102 | open func deleteAllDownloadedContents() { 103 | let filePath = getDocumentsDirectory().appendingPathComponent("Downloads").path 104 | 105 | if FileManager.default.fileExists(atPath: filePath) { 106 | try! FileManager.default.removeItem(atPath: filePath) 107 | } else { 108 | print("File has already been deleted.") 109 | } 110 | } 111 | 112 | open func deleteDownloadedContents(with name: String) { 113 | let filePath = getDocumentsDirectory().appendingPathComponent("Downloads").appendingPathComponent(name).path 114 | 115 | if FileManager.default.fileExists(atPath: filePath) { 116 | try! FileManager.default.removeItem(atPath: filePath) 117 | } else { 118 | print("Could not find directory with name: \(name)") 119 | } 120 | } 121 | 122 | open func pauseDownloadSegment() { 123 | _ = segmentDownloaders.map { $0.pauseDownload() } 124 | } 125 | 126 | open func cancelDownloadSegment() { 127 | _ = segmentDownloaders.map { $0.cancelDownload() } 128 | } 129 | 130 | open func resumeDownloadSegment() { 131 | _ = segmentDownloaders.map { $0.resumeDownload() } 132 | } 133 | } 134 | 135 | extension VideoDownloader: SegmentDownloaderDelegate { 136 | func segmentDownloadSucceeded(with downloader: SegmentDownloader) { 137 | downloadedTsFilesCount += 1 138 | 139 | DispatchQueue.main.async { 140 | self.delegate?.updateProgressLabel(by: self.downloadingProgress) 141 | } 142 | 143 | updateLocalM3U8file() 144 | 145 | if downloadedTsFilesCount == neededDownloadTsFilesCount { 146 | delegate?.videoDownloadSucceeded(by: self) 147 | } 148 | } 149 | 150 | func segmentDownloadFailed(with downloader: SegmentDownloader) { 151 | delegate?.videoDownloadFailed(by: self) 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /Sources/VideoDownloaderHelper.swift: -------------------------------------------------------------------------------- 1 | // 2 | // VideoDownloaderHelper.swift 3 | // WindmillComic 4 | // 5 | // Created by Ziyi Zhang on 09/06/2017. 6 | // Copyright © 2017 Ziyideas. All rights reserved. 7 | // 8 | 9 | import Foundation 10 | 11 | public func getDocumentsDirectory() -> URL { 12 | let paths = FileManager.default.urls(for: .documentDirectory, in:.userDomainMask) 13 | let documentsDirectory = paths[0] 14 | return documentsDirectory 15 | } 16 | -------------------------------------------------------------------------------- /_Pods.xcodeproj: -------------------------------------------------------------------------------- 1 | Example/Pods/Pods.xcodeproj --------------------------------------------------------------------------------