From 7804ef5e71cad880f9947dac7e6aa243e2b73c32 Mon Sep 17 00:00:00 2001 From: AlexStich Date: Wed, 15 Feb 2023 21:19:42 +0400 Subject: [PATCH] First commit --- .gitignore | 36 +++ .travis.yml | 14 + AGVideoLoader.podspec | 42 +++ AGVideoLoader/Assets/.gitkeep | 0 AGVideoLoader/Classes/.gitkeep | 0 AGVideoLoader/Classes/AGCacheProvider.swift | 252 ++++++++++++++++++ AGVideoLoader/Classes/AGLogHelper.swift | 32 +++ .../Classes/AGPrefetchProvider.swift | 186 +++++++++++++ AGVideoLoader/Classes/AGVideoLoader.swift | 124 +++++++++ LICENSE | 22 ++ README.md | 29 ++ 11 files changed, 737 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 AGVideoLoader.podspec create mode 100644 AGVideoLoader/Assets/.gitkeep create mode 100644 AGVideoLoader/Classes/.gitkeep create mode 100644 AGVideoLoader/Classes/AGCacheProvider.swift create mode 100644 AGVideoLoader/Classes/AGLogHelper.swift create mode 100644 AGVideoLoader/Classes/AGPrefetchProvider.swift create mode 100644 AGVideoLoader/Classes/AGVideoLoader.swift create mode 100644 LICENSE create mode 100644 README.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..805ffc7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# macOS +.DS_Store + +# Xcode +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata/ +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa + +# Bundler +.bundle + +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build + +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-ignore-the-pods-directory-in-source-control +# +# Note: if you ignore the Pods directory, make sure to uncomment +# `pod install` in .travis.yml +# +# Pods/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..31a99c7 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,14 @@ +# references: +# * https://www.objc.io/issues/6-build-tools/travis-ci/ +# * https://github.com/supermarin/xcpretty#usage + +osx_image: xcode7.3 +language: objective-c +# cache: cocoapods +# podfile: Example/Podfile +# before_install: +# - gem install cocoapods # Since Travis is not always on latest version +# - pod install --project-directory=Example +script: +- set -o pipefail && xcodebuild test -enableCodeCoverage YES -workspace Example/AGVideoLoader.xcworkspace -scheme AGVideoLoader-Example -sdk iphonesimulator9.3 ONLY_ACTIVE_ARCH=NO | xcpretty +- pod lib lint diff --git a/AGVideoLoader.podspec b/AGVideoLoader.podspec new file mode 100644 index 0000000..3c98b09 --- /dev/null +++ b/AGVideoLoader.podspec @@ -0,0 +1,42 @@ +# +# Be sure to run `pod lib lint AGVideoLoader.podspec' to ensure this is a +# valid spec before submitting. +# +# Any lines starting with a # are optional, but their use is encouraged +# To learn more about a Podspec see https://guides.cocoapods.org/syntax/podspec.html +# + +Pod::Spec.new do |s| + s.name = 'AGVideoLoader' + s.version = '1.0.0' + s.summary = 'It prefetch video in UITableView and cache it' + +# This description is used to generate tags and improve search results. +# * Think: What does it do? Why did you write it? What is the focus? +# * Try to keep it short, snappy and to the point. +# * Write the description between the DESC delimiters below. +# * Finally, don't worry about the indent, CocoaPods strips it! + + s.description = <<-DESC +It prefetch video in UITableView and cache it. + DESC + + s.homepage = 'https://github.com/AlexStich/AGVideoLoader' + # s.screenshots = 'www.example.com/screenshots_1', 'www.example.com/screenshots_2' + s.license = { :type => 'MIT', :file => 'LICENSE' } + s.author = { 'AlexStich' => 'alex@rucode.org' } + s.source = { :git => 'https://github.com/AlexStich/AGVideoLoader.git', :tag => s.version.to_s } + # s.social_media_url = 'https://twitter.com/' + + s.ios.deployment_target = '12.0' + + s.source_files = 'AGVideoLoader/**/*' + + # s.resource_bundles = { + # 'AGVideoLoader' => ['AGVideoLoader/Assets/*.png'] + # } + + # s.public_header_files = 'Pod/Classes/**/*.h' + # s.frameworks = 'UIKit', 'MapKit' + # s.dependency 'AFNetworking', '~> 2.3' +end diff --git a/AGVideoLoader/Assets/.gitkeep b/AGVideoLoader/Assets/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/AGVideoLoader/Classes/.gitkeep b/AGVideoLoader/Classes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/AGVideoLoader/Classes/AGCacheProvider.swift b/AGVideoLoader/Classes/AGCacheProvider.swift new file mode 100644 index 0000000..1784c6f --- /dev/null +++ b/AGVideoLoader/Classes/AGCacheProvider.swift @@ -0,0 +1,252 @@ +// +// CacheManager.swift +// Babydaika +// +// Created by Алексей Гребенкин on 01.02.2023. +// Copyright © 2023 dimfcompany. All rights reserved. +// + +import Foundation +import AVFoundation + +class AGCacheProvider: NSObject, AVAssetResourceLoaderDelegate +{ + struct Config + { + var cacheDirectoryName: String = "AGCache" + /// MB + var maxCacheDirectorySpace: Int = 200 * 1024 * 1024 + } + + var config: Config! + var cacheDirectory: URL? + + init(_ config: Config? = nil) + { + if config != nil { + self.config = config + } else { + self.config = AGCacheProvider.Config() + } + + super.init() + + try? self.prepareStorageDirectory() + } + + func applyConfig(_ config: Config) + { + self.config = config + } + + /// Creates if needed the cache directory + func prepareStorageDirectory() throws { + + var cacheURL = FileManager.default.urls(for: .cachesDirectory,in: .userDomainMask).first + + cacheURL = cacheURL?.appendingPathComponent("tech.avgrebenkin.\(config.cacheDirectoryName).cache", isDirectory: true) + cacheURL = cacheURL?.appendingPathComponent("videos", isDirectory: true) + + guard let path = cacheURL?.path, !FileManager.default.fileExists(atPath: path) else { return cacheDirectory = cacheURL } + guard (try? FileManager.default.createDirectory(at: cacheURL!, withIntermediateDirectories: true)) != nil else { return } + + cacheDirectory = cacheURL + } + + /// Creates an output path + /// + /// - Parameters: + /// - url: file url for export + private func getCacheURLPath(url: URL) -> URL? { + + var outputURL: URL? + outputURL = cacheDirectory?.appendingPathComponent(url.lastPathComponent, isDirectory: false) + return outputURL + } + + /// Creates an output path + /// + /// - Parameters: + /// - name: file name for export + private func getCacheURLPath(name: String) -> URL? { + + var outputURL: URL? + outputURL = cacheDirectory?.appendingPathComponent(name, isDirectory: false) + return outputURL + } + + func checkCacheUrl(url: URL) -> URL? + { + guard let cacheURLPath = self.getCacheURLPath(url: url) else { return nil } + guard FileManager.default.fileExists(atPath: cacheURLPath.path) else { return nil } + + return cacheURLPath + } + + func store(asset: AVURLAsset) + { + guard self.cacheDirectory != nil else { return } + guard let cacheURLPath = self.getCacheURLPath(url: asset.url) else { return } + + AGLogHelper.instance.printToConsole("Try to cache asset \(asset.url.path.suffix(10))") + + DispatchQueue.global(qos: .userInitiated).async { + let data = try? Data(contentsOf: asset.url) + guard data != nil, self.freeCacheDirectorySpace(for: data!) else { return } + let result = FileManager.default.createFile(atPath: cacheURLPath.path , contents: data, attributes: nil) + + AGLogHelper.instance.printToConsole("Assets cached \(asset.url.path.suffix(10)) - \(result)") + } + } + + func store(data: Data, name: String) + { + guard self.cacheDirectory != nil else { return } + guard let cacheURLPath = self.getCacheURLPath(name: name) else { return } + + DispatchQueue.global(qos: .userInitiated).async { + guard self.freeCacheDirectorySpace(for: data) else { return } + let result = FileManager.default.createFile(atPath: cacheURLPath.path , contents: data, attributes: nil) + + AGLogHelper.instance.printToConsole("Assets cached \(name) - \(result)") + } + + +// asset.resourceLoader.setDelegate(self, queue: .main) +// let exporter = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetHighestQuality) +// exporter?.outputURL = cacheNamePathURL +// exporter?.outputFileType = AVFileType.mp4 +// +// exporter?.exportAsynchronously(completionHandler: { +// print("**** export work") +// print(exporter?.status.rawValue) +// print(exporter?.error) +// }) + } + + func freeCacheDirectorySpace(for data: Data) -> Bool + { + let ceil__ = ceil(Float(data.count)/(1024*1024)) + AGLogHelper.instance.printToConsole("New file space - \(ceil__)") + + guard self.cacheDirectory != nil else { return false } + guard data.count < config.maxCacheDirectorySpace else { return false } + + var totalSpace = self.cacheDirectory!.folderSize() + + let ceil_ = ceil(Float(config.maxCacheDirectorySpace)/(1024*1024)) - ceil(Float(totalSpace)/(1024*1024)) + AGLogHelper.instance.printToConsole("Total space before store to cache - \(ceil_)") + + if (totalSpace + data.count) > config.maxCacheDirectorySpace { + if var directoryContents = try? FileManager.default.contentsOfDirectory( + at: self.cacheDirectory!, + includingPropertiesForKeys: [.totalFileSizeKey] + ) { + + directoryContents.sort(by: { (url_a, url_b) in + return url_a.creationDate()! <= url_b.creationDate()! + }) + + directoryContents.map({ url in + AGLogHelper.instance.printToConsole("File in directory - \(url.path.suffix(10)) - \(String(describing: url.creationDate()))") + }) + + for url in directoryContents { + + AGLogHelper.instance.printToConsole("File need to remove - \(url.path.suffix(10)) - \(String(describing: url.creationDate()))") + + let values = try? url.resourceValues(forKeys: [.totalFileSizeKey]) + let size = values?.totalFileSize + + if size != nil { + totalSpace -= size! + try? FileManager.default.removeItem(atPath: url.path) + + let ceil = ceil(Float(totalSpace)/(1024*1024)) + AGLogHelper.instance.printToConsole("Total space after removing file - \(ceil)") + + if (totalSpace + data.count) < config.maxCacheDirectorySpace { + break + } + } + } + } + } + + return true + } + + func clearCache() + { + guard self.cacheDirectory != nil else { return } + + let totalSpace = self.cacheDirectory!.folderSize() + let ceil_ = ceil(Float(totalSpace/(1024*1024))) + AGLogHelper.instance.printToConsole("Space before clearing cache - \(ceil_)") + + try? FileManager.default.removeItem(atPath: self.cacheDirectory!.path) + } +} + + +extension URL { + public func directoryContents() -> [URL] { + do { + let directoryContents = try FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil) + return directoryContents + } catch let error { + print("Error: \(error)") + return [] + } + } + + public func folderSize() -> Int { + let contents = self.directoryContents() + var totalSize: Int = 0 + contents.forEach { url in + let size = url.fileSize() + totalSize += size + } + return totalSize + } + + public func fileSize() -> Int { + let attributes = URLFileAttribute(url: self) + return attributes.fileSize ?? 0 + } + + public func creationDate() -> Date? { + let attributes = URLFileAttribute(url: self) + return attributes.creationDate + } +} + +// MARK: - URLFileAttribute +struct URLFileAttribute { + private(set) var fileSize: Int? = nil + private(set) var creationDate: Date? = nil + private(set) var modificationDate: Date? = nil + + init(url: URL) { + let path = url.path + guard let dictionary: [FileAttributeKey: Any] = try? FileManager.default + .attributesOfItem(atPath: path) else { + return + } + + if dictionary.keys.contains(FileAttributeKey.size), + let value = dictionary[FileAttributeKey.size] as? Int { + self.fileSize = value + } + + if dictionary.keys.contains(FileAttributeKey.creationDate), + let value = dictionary[FileAttributeKey.creationDate] as? Date { + self.creationDate = value + } + + if dictionary.keys.contains(FileAttributeKey.modificationDate), + let value = dictionary[FileAttributeKey.modificationDate] as? Date { + self.modificationDate = value + } + } +} diff --git a/AGVideoLoader/Classes/AGLogHelper.swift b/AGVideoLoader/Classes/AGLogHelper.swift new file mode 100644 index 0000000..7516d5d --- /dev/null +++ b/AGVideoLoader/Classes/AGLogHelper.swift @@ -0,0 +1,32 @@ +// +// AGLogHelper.swift +// Babydaika +// +// Created by Алексей Гребенкин on 14.02.2023. +// Copyright © 2023 dimfcompany. All rights reserved. +// + +import Foundation +import SwiftyBeaver + +class AGLogHelper +{ + static let instance = AGLogHelper() + static var debugModeOn: Bool = false + + private init(){} + + func printToConsole(_ str: String) + { + if AGLogHelper.debugModeOn { + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale.current// Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "HH:mm:ss" + dateFormatter.timeZone = .current + let dateString = dateFormatter.string(from: Date()) + + print(dateString + "🐙 AG *** " + str) + } + } +} diff --git a/AGVideoLoader/Classes/AGPrefetchProvider.swift b/AGVideoLoader/Classes/AGPrefetchProvider.swift new file mode 100644 index 0000000..2cee76a --- /dev/null +++ b/AGVideoLoader/Classes/AGPrefetchProvider.swift @@ -0,0 +1,186 @@ +// +// Manager prefetching videos +// AGPrefetchProvider.swift +// +// Created by Алексей Гребенкин on 13.02.2023. +// Copyright © 2021 dimfcompany. All rights reserved. +// + +import Foundation +import UIKit +import AVFoundation + +//protocol AGVideoSourceProtocol +//{ +// func getVideoUrl() -> URL? +//} + +class AGPrefetchProvider: NSObject, UITableViewDataSourcePrefetching +{ + struct Config + { + var maxConcurrentOperationCount: Int = 3 + } + + private var source: [IndexPath: URL] = [IndexPath: URL]() { + didSet{ + AGLogHelper.instance.printToConsole("Add sources. Total items - " + String("\(source.count)")) + } + } + + private var loadingOperations: [Int: VideoLoadOperation] = [Int: VideoLoadOperation]() + private var loadingQueue: OperationQueue = OperationQueue() + + override init() + { + super.init() + + loadingQueue.maxConcurrentOperationCount = 3 + } + + func applyConfig(_ config: Config) + { + loadingQueue.maxConcurrentOperationCount = config.maxConcurrentOperationCount + } + + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) + { + for indexPath in indexPaths { + if self.operationExists(for: indexPath) { + self.createOperation(for: indexPath, completion: nil) + } + } + } + + func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) + { + for indexPath in indexPaths { + self.deleteOperation(for: indexPath) + } + } + + func setPrefetchSource(source: [IndexPath: URL]) + { + self.source = source + } +// +// func getExistingOperation(by url: URL) -> VideoLoadOperation? +// { +// return loadingOperations[url.absoluteString.hash] +// } + + func operationExists(for url: URL) -> Bool + { + let urlHash = url.absoluteString.hash + + return loadingOperations.keys.contains(urlHash) + } + + func operationExists(for indexPath: IndexPath) -> Bool + { + guard let url = source[indexPath] else { return false } + + let urlHash = url.absoluteString.hash + + return loadingOperations.keys.contains(urlHash) + } + + func getExistedOperation(for indexPath: IndexPath) -> VideoLoadOperation? + { + guard let url = source[indexPath] else { return nil } + + let urlHash = url.absoluteString.hash + + return loadingOperations[urlHash] + } + + internal func createOperation(for indexPath: IndexPath, completion: ((AVAsset?)->Void)?) + { + guard let url = source[indexPath] else { return } + + let urlHash = url.absoluteString.hash + + let operation = VideoLoadOperation(url) + operation.loadingCompleteHandler = completion + loadingQueue.addOperation(operation) + loadingOperations[urlHash] = operation + + AGLogHelper.instance.printToConsole("Added loading operation to queue - " + String("\(url.absoluteString.hash)")) + } + + private func deleteOperation(for indexPath: IndexPath) + { + guard let url = source[indexPath] else { return } + let urlHash = url.absoluteString.hash + + if let operation = self.getExistedOperation(for: indexPath) { + + AGLogHelper.instance.printToConsole("Deleted loading operation from queue - " + String("\(url.absoluteString.suffix(10))")) + + operation.cancel() + + loadingOperations.removeValue(forKey: urlHash) + } + } + +// private func createOperation(from url: URL) -> VideoLoadOperation? +// { +// return VideoLoadOperation(url) +// } +} + +class VideoLoadOperation: Operation +{ + var asset: AVAsset? + + var loadingCompleteHandler: ((AVAsset?) -> Void)? + + private var url: URL? + + init(_ url: URL? = nil) + { + if url != nil { + self.url = url! + } + + super.init() + } + + override func main() + { + if isCancelled { return } + + guard let url = self.url else { return } + + AGLogHelper.instance.printToConsole("Loading operation begin load asset - " + String("\(url.path.suffix(10))")) + + let asset_ = AVAsset(url: url) + + asset_.loadValuesAsynchronously(forKeys: PlayerView.assetKeysRequiredToPlay) { [weak self] in + + guard let self = self else { return } + + for key in PlayerView.assetKeysRequiredToPlay { + + var error: NSError? + + if asset_.statusOfValue(forKey: key, error: &error) == .failed { + self.cancel() + return + } + } + + if !asset_.isPlayable || asset_.hasProtectedContent { + self.cancel() + return + } + + AGLogHelper.instance.printToConsole("Loading operation loaded asset - \(url.path.suffix(10))") + + if self.isCancelled { return } + + self.asset = asset_ + self.loadingCompleteHandler?(asset_) + } + } +} diff --git a/AGVideoLoader/Classes/AGVideoLoader.swift b/AGVideoLoader/Classes/AGVideoLoader.swift new file mode 100644 index 0000000..7ad025c --- /dev/null +++ b/AGVideoLoader/Classes/AGVideoLoader.swift @@ -0,0 +1,124 @@ +// +// Manager for loading, prefetching and caching videos +// AGVideoLoader.swift +// +// Created by Алексей Гребенкин on 14.02.2023. +// Copyright avgrebenkin© 2023. All rights reserved. +// + +import Foundation +import AVFoundation + +class AGVideoLoader +{ + var cachingModeOn: Bool = true + var prefetchingModeOn: Bool = true + + var debugModeOn: Bool = false { + didSet{ + AGLogHelper.debugModeOn = debugModeOn + } + } + + var cacheConfig: AGCacheProvider.Config! + { + didSet { + cacheProvider.applyConfig(cacheConfig) + } + } + var prefetchingConf: AGPrefetchProvider.Config! + { + didSet{ + prefetchingProvider.applyConfig(prefetchingConf) + } + } + + var cacheProvider: AGCacheProvider! + var prefetchingProvider: AGPrefetchProvider! + + static let getInstance: AGVideoLoader = AGVideoLoader() + + private init() + { + self.cacheProvider = AGCacheProvider() + self.prefetchingProvider = AGPrefetchProvider() + } + + func loadVideo(url: URL, indexPath: IndexPath? = nil, completion: ((AVAsset?)->Void)?) + { + var final_completion: ((AVAsset?)->Void)? = completion + + if cachingModeOn { + + final_completion = { [weak self] asset in + completion?(asset) + self?.cacheProvider.store(asset: asset as! AVURLAsset) + } + + if let cacheUrl = cacheProvider.checkCacheUrl(url: url) { + + let asset = AVAsset(url: cacheUrl) + final_completion?(asset) + + AGLogHelper.instance.printToConsole("Загрузили из cache - \(cacheUrl.path.suffix(10))") + + return + } + } + + if indexPath != nil { + if prefetchingModeOn { + if let operation = prefetchingProvider.getExistedOperation(for: indexPath!) { + if let asset = operation.asset { + final_completion?(asset) + } else { + operation.loadingCompleteHandler = final_completion + } + } else { + prefetchingProvider.createOperation(for: indexPath!, completion: final_completion) + } + + return + } + } + + loadVideo(url: url, completion: final_completion) + } + + func loadVideo(url: URL, completion: ((AVAsset?)->Void)?) + { + let asset_ = AVAsset(url: url) + + asset_.loadValuesAsynchronously(forKeys: PlayerView.assetKeysRequiredToPlay) { [weak self] in + + guard let self = self else { return } + + for key in PlayerView.assetKeysRequiredToPlay { + + var error: NSError? + + if asset_.statusOfValue(forKey: key, error: &error) == .failed { + return + } + } + + if !asset_.isPlayable || asset_.hasProtectedContent { + return + } + + AGLogHelper.instance.printToConsole("Loading operation loaded asset - \(url.path.suffix(10))") + + completion?(asset_) + } + } + + func clearCache() + { + self.cacheProvider.clearCache() + } + + func setPrefetchSource(source: [IndexPath: URL]) + { + self.prefetchingProvider.setPrefetchSource(source: source) + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..998d3b7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2023 Alexey Grebenkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..3d6443b --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# AGVideoLoader + +[![CI Status](https://img.shields.io/travis/AlexStich/AGVideoLoader.svg?style=flat)](https://travis-ci.org/AlexStich/AGVideoLoader) +[![Version](https://img.shields.io/cocoapods/v/AGVideoLoader.svg?style=flat)](https://cocoapods.org/pods/AGVideoLoader) +[![License](https://img.shields.io/cocoapods/l/AGVideoLoader.svg?style=flat)](https://cocoapods.org/pods/AGVideoLoader) +[![Platform](https://img.shields.io/cocoapods/p/AGVideoLoader.svg?style=flat)](https://cocoapods.org/pods/AGVideoLoader) + +## Example + +To run the example project, clone the repo, and run `pod install` from the Example directory first. + +## Requirements + +## Installation + +AGVideoLoader is available through [CocoaPods](https://cocoapods.org). To install +it, simply add the following line to your Podfile: + +```ruby +pod 'AGVideoLoader' +``` + +## Author + +AlexStich, alex@rucode.org + +## License + +AGVideoLoader is available under the MIT license. See the LICENSE file for more info.