diff --git a/CHANGELOG.md b/CHANGELOG.md index 433cccfe..425bb11e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,4 +16,4 @@ - live stream announce view - collecting of analytic events with playback data for Kinescope dashboard - textField in example project to allow user to input videoId - +- ability to manage options menu on player view diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 7b208145..e5a7e5ed 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -4,6 +4,53 @@ All components of `KinescopePlayerView` is partially customisable. You can chang All properties of `KinescopePlayerViewConfiguration` have default values included in SDK, so customisation is optional. +## Managing of options menu + +Options menu is a set of buttons with different actions like AirPlay, Picture in Picture, etc. You can manage this menu by adding or removing some options. + +By default, options menu is collapsed, but you can show it by tapping on the options button with dots in the bottom right corner of the player view. + +### Custom options + +To add some custom option you can use method `addOption` with `KinescopePlayerOption` enum value. + +For example + +```swift +if let shareIcon = UIImage(systemName: "square.and.arrow.up") { + player?.addCustomPlayerOption(with: CustomPlayerOption.share, + and: shareIcon) +} +``` + +To handle tap on custom option you should implement `KinescopePlayerDelegate` protocol and use method `player(didSelect option: KinescopePlayerOption)`. + +```swift +func player(didSelectCustomOptionWith optionId: AnyHashable, anchoredAt view: UIView) { + guard let option = optionId as? CustomPlayerOption else { + return + } + + switch option { + case .share: + // your code here + } + } +``` +AnchoredView is button which were tapped. You can use it to show popover or action sheet. It's required on iPad applications. + +### Hide/disable options + +To hide some built-in options which you do not want you can use method `disableOptions` with array of options you want to hide. + +For example, to hide AirPlay option use this code: +```swift +player?.disableOptions([.airPlay]) +``` +All built-in options are listed in `KinescopePlayerOption` enum. + +Using `disableOptions` also can disable custom option if id of this option is equal to previously added option. + ## Configuration parameters ### gravity diff --git a/Example/KinescopeExample/Flows/VideoViewController.swift b/Example/KinescopeExample/Flows/VideoViewController.swift index 3cefac9b..ce93846e 100644 --- a/Example/KinescopeExample/Flows/VideoViewController.swift +++ b/Example/KinescopeExample/Flows/VideoViewController.swift @@ -2,6 +2,12 @@ import UIKit import KinescopeSDK final class VideoViewController: UIViewController { + + // MARK: - Nested Types + + private enum CustomPlayerOption: String { + case share + } // MARK: - IBOutlets @@ -40,6 +46,15 @@ final class VideoViewController: UIViewController { PipManager.shared.closePipIfNeeded(with: videoId) player = KinescopeVideoPlayer(config: .init(videoId: videoId)) + + if #available(iOS 13.0, *) { + if let shareIcon = UIImage(systemName: "square.and.arrow.up")?.withRenderingMode(.alwaysTemplate) { + player?.addCustomPlayerOption(with: CustomPlayerOption.share, and: shareIcon) + } + } + player?.disableOptions([.airPlay]) + + player?.setDelegate(delegate: self) player?.attach(view: playerView) player?.play() player?.pipDelegate = PipManager.shared @@ -53,3 +68,24 @@ extension VideoViewController: UINavigationControllerDelegate { return self.supportedInterfaceOrientations } } + +extension VideoViewController: KinescopeVideoPlayerDelegate { + + func player(didSelectCustomOptionWith optionId: AnyHashable, anchoredAt view: UIView) { + guard let option = optionId as? CustomPlayerOption else { + return + } + + switch option { + case .share: + let items = [player?.config.shareLink] + let activityViewController = UIActivityViewController(activityItems: items as [Any], + applicationActivities: nil) + let presentationController = activityViewController.presentationController as? UIPopoverPresentationController + presentationController?.sourceView = view + presentationController?.permittedArrowDirections = .down + present(activityViewController, animated: true) + } + } + +} diff --git a/Sources/KinescopeSDK/Models/KinescopePlayerConfig.swift b/Sources/KinescopeSDK/Models/KinescopePlayerConfig.swift index 1dadfea0..79169ada 100644 --- a/Sources/KinescopeSDK/Models/KinescopePlayerConfig.swift +++ b/Sources/KinescopeSDK/Models/KinescopePlayerConfig.swift @@ -13,6 +13,11 @@ public struct KinescopePlayerConfig { /// If value is `true` show video in infinite loop. public let looped: Bool + + /// Default link to share video and play it on web. + public var shareLink: URL? { + URL(string: "https://kinescope.io/\(videoId)") + } /// - parameter videoId: Id of concrete video. For example from [GET Videos list](https://documenter.getpostman.com/view/10589901/TVCcXpNM) /// - parameter looped: If value is `true` show video in infinite loop. By default is `false` diff --git a/Sources/KinescopeSDK/Models/Options/KinescopePlayerOption.swift b/Sources/KinescopeSDK/Models/Options/KinescopePlayerOption.swift index ee8f3fc0..e4fa18d5 100644 --- a/Sources/KinescopeSDK/Models/Options/KinescopePlayerOption.swift +++ b/Sources/KinescopeSDK/Models/Options/KinescopePlayerOption.swift @@ -5,8 +5,10 @@ // Created by Никита Коробейников on 01.04.2021. // +import UIKit + /// Options available in player control panel -public enum KinescopePlayerOption: String { +public enum KinescopePlayerOption { /// Option to expand control panel and show all available options case more @@ -24,4 +26,90 @@ public enum KinescopePlayerOption: String { case subtitles /// Option to enter in Picture in Picture mode, availalble only if device supported PiP case pip + /// Custom option to perform any action + /// - Parameters: + /// - id: Uniq identifier to distinguish selection of different button. Maybe `String`, `Int`, `UUID` or other `Hashable`. + /// - icon: Image to represent option in menu + case custom(id: AnyHashable, icon: UIImage) + + // MARK: - Appearance + + var icon: UIImage { + switch self { + case .more: + return UIImage.image(named: "more") + case .fullscreen: + return UIImage.image(named: "fullscreen") + case .settings: + return UIImage.image(named: "settings") + case .attachments: + return UIImage.image(named: "attachments") + case .download: + return UIImage.image(named: "download") + case .airPlay: + return UIImage.image(named: "airPlay") + case .subtitles: + return UIImage.image(named: "subtitles") + case .pip: + return UIImage.image(named: "pip") + case .custom(_, let icon): + return icon + } + } + + var iconSelected: UIImage? { + switch self { + case .subtitles: + return UIImage.image(named: "subtitlesSelected") + case .airPlay: + return UIImage.image(named: "airPlayActive") + case .custom(_, let icon): + return icon + default: + return nil + } + } + + // MARK: - Id: + + var optionId: AnyHashable? { + switch self { + case .custom(let id, _): + return id + default: + return nil + } + } + +} + +// MARK: - Equatable + +extension KinescopePlayerOption: Equatable { + + public static func ==(lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.more, .more): + return true + case (.fullscreen, .fullscreen): + return true + case (.settings, .settings): + return true + case (.attachments, .attachments): + return true + case (.download, .download): + return true + case (.airPlay, .airPlay): + return true + case (.subtitles, .subtitles): + return true + case (.pip, .pip): + return true + case (.custom(let lId, _), .custom(let rId, _)): + return lId == rId + default: + return false + } + } + } diff --git a/Sources/KinescopeSDK/Player/KinescopeVideoPlayer.swift b/Sources/KinescopeSDK/Player/KinescopeVideoPlayer.swift index e856c0fc..7f49d347 100644 --- a/Sources/KinescopeSDK/Player/KinescopeVideoPlayer.swift +++ b/Sources/KinescopeSDK/Player/KinescopeVideoPlayer.swift @@ -19,7 +19,7 @@ public class KinescopeVideoPlayer: KinescopePlayer, KinescopePlayerBody, Fullscr // MARK: - Private Properties - private let config: KinescopePlayerConfig + public let config: KinescopePlayerConfig private let dependencies: KinescopePlayerDependencies private var analyticStorage: InnerEventsDataStorage & InnerEventsDataInputs = InMemoryInnerEventsDataStorage() @@ -89,6 +89,8 @@ public class KinescopeVideoPlayer: KinescopePlayer, KinescopePlayerBody, Fullscr return threshold < Constants.maxPlaybackThreshold ? threshold : Constants.maxPlaybackThreshold } + private var customOptions = [KinescopePlayerOption]() + private var disabledOptions = [KinescopePlayerOption]() private var options = [KinescopePlayerOption]() private var playbackObserverFactory: (any Factory)? @@ -213,6 +215,14 @@ public class KinescopeVideoPlayer: KinescopePlayer, KinescopePlayerBody, Fullscr public func setDelegate(delegate: KinescopeVideoPlayerDelegate) { self.delegate = delegate } + + public func addCustomPlayerOption(with id: AnyHashable, and icon: UIImage) { + customOptions.append(.custom(id: id, icon: icon)) + } + + public func disableOptions(_ options: [KinescopePlayerOption]) { + disabledOptions = options + } } // MARK: - Private @@ -260,7 +270,7 @@ private extension KinescopeVideoPlayer { if video.hasSubtitles { options.insert(.subtitles, at: 0) } - + options = (customOptions + options).filter { !disabledOptions.contains($0) } self.options = options return options } @@ -665,4 +675,8 @@ extension KinescopeVideoPlayer: KinescopePlayerViewDelegate { level: KinescopeLoggerLevel.player) } + func didSelect(option: AnyHashable, optionView: UIView) { + delegate?.player(didSelectCustomOptionWith: option, anchoredAt: optionView) + } + } diff --git a/Sources/KinescopeSDK/Player/KinescopeVideoPlayerDelegate.swift b/Sources/KinescopeSDK/Player/KinescopeVideoPlayerDelegate.swift index b8a23b78..b0a572af 100644 --- a/Sources/KinescopeSDK/Player/KinescopeVideoPlayerDelegate.swift +++ b/Sources/KinescopeSDK/Player/KinescopeVideoPlayerDelegate.swift @@ -38,6 +38,8 @@ public protocol KinescopeVideoPlayerDelegate: AnyObject { func player(didFastBackwardTo time: TimeInterval) /// Triggered on quality change func player(changedQualityTo quality: String) + /// Triggered on custom option button selected in options menu + func player(didSelectCustomOptionWith optionId: AnyHashable, anchoredAt view: UIView) } public extension KinescopeVideoPlayerDelegate { diff --git a/Sources/KinescopeSDK/Protocols/KinescopePlayer.swift b/Sources/KinescopeSDK/Protocols/KinescopePlayer.swift index 7b0e8eca..7b3c9552 100644 --- a/Sources/KinescopeSDK/Protocols/KinescopePlayer.swift +++ b/Sources/KinescopeSDK/Protocols/KinescopePlayer.swift @@ -9,6 +9,10 @@ import AVKit /// Control protocol for player public protocol KinescopePlayer { + + /// Current configuration of player. + /// Contains `videoId` and playing strategy. + var config: KinescopePlayerConfig { get } /// Delegate of Picture in Picture controller var pipDelegate: AVPictureInPictureControllerDelegate? { get set } @@ -41,4 +45,15 @@ public protocol KinescopePlayer { /// Set delegate object /// - Parameter delegate: delegate for player events func setDelegate(delegate: KinescopeVideoPlayerDelegate) + + /// Add custom options to bottom menu + /// - Parameters: + /// - id: Uniq identifier of option + /// - icon: Image to represent option in menu + func addCustomPlayerOption(with id: AnyHashable, and icon: UIImage) + + /// Hides options from bottom menu + /// - Parameters: + /// - options: Array of options to ignore. + func disableOptions(_ options: [KinescopePlayerOption]) } diff --git a/Sources/KinescopeSDK/View/Player/KinescopePlayerView.swift b/Sources/KinescopeSDK/View/Player/KinescopePlayerView.swift index a4b11a0c..757ac5da 100644 --- a/Sources/KinescopeSDK/View/Player/KinescopePlayerView.swift +++ b/Sources/KinescopeSDK/View/Player/KinescopePlayerView.swift @@ -496,6 +496,8 @@ extension KinescopePlayerView: PlayerControlOutput { case .pip: let isPipActive = pipController?.isPictureInPictureActive ?? false isPipActive ? pipController?.stopPictureInPicture() : pipController?.startPictureInPicture() + case .custom(let id, _): + delegate?.didSelect(option: id, optionView: controlPanel?.getCustomOptionView(by: id) ?? self) default: break } diff --git a/Sources/KinescopeSDK/View/Player/KinescopePlayerViewDelegate.swift b/Sources/KinescopeSDK/View/Player/KinescopePlayerViewDelegate.swift index 967bba50..35f33298 100644 --- a/Sources/KinescopeSDK/View/Player/KinescopePlayerViewDelegate.swift +++ b/Sources/KinescopeSDK/View/Player/KinescopePlayerViewDelegate.swift @@ -12,4 +12,5 @@ protocol KinescopePlayerViewDelegate: AnyObject { func didSelectAttachment(with id: String) func didSelectDownloadAll(for title: String) func didSelect(subtitles: String) + func didSelect(option: AnyHashable, optionView: UIView) } diff --git a/Sources/KinescopeSDK/View/Player/Layout/AirPlayOptionButton.swift b/Sources/KinescopeSDK/View/Player/Layout/AirPlayOptionControl.swift similarity index 61% rename from Sources/KinescopeSDK/View/Player/Layout/AirPlayOptionButton.swift rename to Sources/KinescopeSDK/View/Player/Layout/AirPlayOptionControl.swift index c509400d..60493580 100644 --- a/Sources/KinescopeSDK/View/Player/Layout/AirPlayOptionButton.swift +++ b/Sources/KinescopeSDK/View/Player/Layout/AirPlayOptionControl.swift @@ -1,13 +1,14 @@ // -// AirPlayOptionButton.swift +// AirPlayOptionControl.swift // KinescopeSDK // // Created by Никита Гагаринов on 13.04.2021. // +import AVKit import MediaPlayer -final class AirPlayOptionButton: UIButton { +final class AirPlayOptionControl: UIControl { // MARK: - Initialization @@ -33,14 +34,25 @@ final class AirPlayOptionButton: UIButton { // MARK: - Private Methods -private extension AirPlayOptionButton { +private extension AirPlayOptionControl { func setupInitialState() { - let volumeView = MPVolumeView() - volumeView.showsVolumeSlider = false - volumeView.setRouteButtonImage(UIImage.image(named: getImageName(for: volumeView)), for: .normal) - addSubview(volumeView) - stretch(view: volumeView) + let systemView: UIView + if #available(iOS 11.0, *) { + let routePickerView = AVRoutePickerView() + if #available(iOS 13.0, *) { + routePickerView.prioritizesVideoDevices = true + } + systemView = routePickerView + } else { + let volumeView = MPVolumeView(frame: .zero) + volumeView.showsVolumeSlider = false + volumeView.setRouteButtonImage(UIImage.image(named: getImageName(for: volumeView)), for: .normal) + systemView = volumeView + } + + addSubview(systemView) + stretch(view: systemView) } func getImageName(for volumeView: MPVolumeView) -> String { diff --git a/Sources/KinescopeSDK/View/Player/Layout/OptionButton.swift b/Sources/KinescopeSDK/View/Player/Layout/OptionButton.swift index aec1afb4..e7abbe99 100644 --- a/Sources/KinescopeSDK/View/Player/Layout/OptionButton.swift +++ b/Sources/KinescopeSDK/View/Player/Layout/OptionButton.swift @@ -28,8 +28,8 @@ final class OptionButton: UIButton { private extension OptionButton { func setupInitialState() { - setImage(UIImage.image(named: option.rawValue), for: .normal) - setImage(UIImage.image(named: option.rawValue + "Selected"), for: .selected) + setImage(option.icon, for: .normal) + setImage(option.iconSelected, for: .selected) } } diff --git a/Sources/KinescopeSDK/View/Player/Layout/PlayerControlOptionsView.swift b/Sources/KinescopeSDK/View/Player/Layout/PlayerControlOptionsView.swift index 773cb388..a5f1fc49 100644 --- a/Sources/KinescopeSDK/View/Player/Layout/PlayerControlOptionsView.swift +++ b/Sources/KinescopeSDK/View/Player/Layout/PlayerControlOptionsView.swift @@ -9,6 +9,8 @@ import UIKit protocol PlayerControlOptionsInput { + func getCustomOptionView(by id: AnyHashable) -> UIView? + /// Set available options /// /// - parameter options: Set of option @@ -37,6 +39,8 @@ class PlayerControlOptionsView: UIControl { private let config: KinescopePlayerOptionsConfiguration private(set) var options: [KinescopePlayerOption] = [] private var isSubtitleOn = false + + private var customOptionsTagMap: [AnyHashable: Int] = [:] weak var output: PlayerControlOptionsOutput? @@ -66,6 +70,12 @@ class PlayerControlOptionsView: UIControl { extension PlayerControlOptionsView: PlayerControlOptionsInput { + func getCustomOptionView(by id: AnyHashable) -> UIView? { + let buttonTag = customOptionsTagMap[id] + return stackView.arrangedSubviews + .first(where: { $0.tag == buttonTag }) + } + func set(options: [KinescopePlayerOption]) { self.options = options self.isExpanded = false @@ -105,20 +115,29 @@ private extension PlayerControlOptionsView { stretch(view: stackView) } - func createButton(from option: KinescopePlayerOption) -> UIButton { - if option == .airPlay { - let button = AirPlayOptionButton() + func createButton(from option: KinescopePlayerOption, at index: Int) -> UIView { + switch option { + case .airPlay: + let button = AirPlayOptionControl() + button.tintColor = config.normalColor button.squareSize(with: config.iconSize) + button.tag = index return button - } - let button = OptionButton(option: option) + default: + let button = OptionButton(option: option) - button.tintColor = config.highlightedColor - button.squareSize(with: config.iconSize) + if let optionId = option.optionId { + customOptionsTagMap[optionId] = index + } + + button.tag = index + button.tintColor = config.normalColor + button.squareSize(with: config.iconSize) - button.addTarget(nil, action: #selector(buttonTapped(sender:)), for: .touchUpInside) + button.addTarget(nil, action: #selector(buttonTapped(sender:)), for: .touchUpInside) - return button + return button + } } func fillStack(with options: [KinescopePlayerOption], expanded: Bool) { @@ -133,7 +152,10 @@ private extension PlayerControlOptionsView { : Array(options.dropFirst(options.count - 2)) filteredOptions - .map(createButton(from:)) + .enumerated() + .map { index, option in + createButton(from: option, at: index) + } .forEach { [weak self] button in self?.stackView.addArrangedSubview(button) } diff --git a/Sources/KinescopeSDK/View/Player/Layout/PlayerControlView.swift b/Sources/KinescopeSDK/View/Player/Layout/PlayerControlView.swift index 44e84597..0885ee2a 100644 --- a/Sources/KinescopeSDK/View/Player/Layout/PlayerControlView.swift +++ b/Sources/KinescopeSDK/View/Player/Layout/PlayerControlView.swift @@ -57,7 +57,11 @@ class PlayerControlView: UIControl { // MARK: - PlayerControlInput extension PlayerControlView: PlayerControlInput { - + + func getCustomOptionView(by id: AnyHashable) -> UIView? { + optionsMenu.getCustomOptionView(by: id) + } + func set(live: Bool?) { if let live { timeIndicator.isHidden = true @@ -100,8 +104,11 @@ extension PlayerControlView: PlayerControlOptionsOutput { } func didSelect(option: KinescopePlayerOption) { - if option == .more { + switch option { + case .more: self.expanded.toggle() + default: + break } output?.didSelect(option: option) }