diff --git a/Client.xcodeproj/project.pbxproj b/Client.xcodeproj/project.pbxproj index 673cdc99433..d9a9fe202f4 100644 --- a/Client.xcodeproj/project.pbxproj +++ b/Client.xcodeproj/project.pbxproj @@ -866,6 +866,7 @@ CA439A5925E6F29D00FE9150 /* VideoPlayerInfoBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA439A5825E6F29D00FE9150 /* VideoPlayerInfoBar.swift */; }; CA439A7625E8054A00FE9150 /* VideoPlayerControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA439A7525E8054A00FE9150 /* VideoPlayerControlsView.swift */; }; CA439A9025E80EE400FE9150 /* VideoPlayerTrackbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA439A8F25E80EE400FE9150 /* VideoPlayerTrackbar.swift */; }; + CA752EA526CEABF8009356EF /* PlaylistToast.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA752EA426CEABF8009356EF /* PlaylistToast.swift */; }; CA9A22FE26A71ADA00923D70 /* PlaylistPopoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9A22FD26A71ADA00923D70 /* PlaylistPopoverView.swift */; }; CA9A230026A7370C00923D70 /* FontScaling.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9A22FF26A7370C00923D70 /* FontScaling.swift */; }; CA9A233426B97B4300923D70 /* PlaylistMenuButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA9A233326B97B4300923D70 /* PlaylistMenuButton.swift */; }; @@ -2553,6 +2554,7 @@ CA439A5825E6F29D00FE9150 /* VideoPlayerInfoBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerInfoBar.swift; sourceTree = ""; }; CA439A7525E8054A00FE9150 /* VideoPlayerControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerControlsView.swift; sourceTree = ""; }; CA439A8F25E80EE400FE9150 /* VideoPlayerTrackbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerTrackbar.swift; sourceTree = ""; }; + CA752EA426CEABF8009356EF /* PlaylistToast.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistToast.swift; sourceTree = ""; }; CA9A22FD26A71ADA00923D70 /* PlaylistPopoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistPopoverView.swift; sourceTree = ""; }; CA9A22FF26A7370C00923D70 /* FontScaling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontScaling.swift; sourceTree = ""; }; CA9A233326B97B4300923D70 /* PlaylistMenuButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistMenuButton.swift; sourceTree = ""; }; @@ -5195,6 +5197,7 @@ 4452CAEF255412800053EFE6 /* DefaultBrowserIntroCalloutViewController.swift */, 0A0A5ED025B1F080007B3E74 /* DefaultBrowserIntroManager.swift */, 0AE50869261C6F2E0099C6A3 /* BraveSearchHelper.swift */, + CA752EA426CEABF8009356EF /* PlaylistToast.swift */, ); indentWidth = 4; path = Browser; @@ -7441,6 +7444,7 @@ 4422D56521BFFB7F00BF1855 /* filtered_re2.cc in Sources */, 277223722469B4FB0059A7EB /* FaviconFetcher.swift in Sources */, 274398F524E71D8700E79605 /* FailableDecodable.swift in Sources */, + CA752EA526CEABF8009356EF /* PlaylistToast.swift in Sources */, 0A918DCD252C81FA00496088 /* BraveVPNRegionPickerViewController.swift in Sources */, 279C756B219DDE3B001CD1CB /* FingerprintingProtection.swift in Sources */, E650755F1E37F756006961AC /* Try.m in Sources */, diff --git a/Client/Frontend/Browser/Playlist/BrowserViewController+Playlist.swift b/Client/Frontend/Browser/Playlist/BrowserViewController+Playlist.swift index 76971beaa0c..e0c30260ab0 100644 --- a/Client/Frontend/Browser/Playlist/BrowserViewController+Playlist.swift +++ b/Client/Frontend/Browser/Playlist/BrowserViewController+Playlist.swift @@ -129,6 +129,97 @@ extension BrowserViewController: PlaylistHelperDelegate { popover.present(from: topToolbar.locationView.playlistButton, on: self) } + func showPlaylistToast(tab: Tab?, state: PlaylistItemAddedState, item: PlaylistInfo?) { + updatePlaylistURLBar(tab: tab, state: state, item: item) + + guard let selectedTab = tabManager.selectedTab, + selectedTab === tab, + selectedTab.url?.isPlaylistSupportedSiteURL == true else { + return + } + + if let toast = pendingToast as? PlaylistToast { + toast.item = item + return + } + + pendingToast = PlaylistToast(item: item, state: state, completion: { [weak self] buttonPressed in + guard let self = self, + let item = (self.pendingToast as? PlaylistToast)?.item else { return } + + switch state { + // Item requires user action to add it to playlists + case .none: + if buttonPressed { + // Update playlist with new items.. + self.addToPlaylist(item: item) { [weak self] didAddItem in + guard let self = self else { return } + + log.debug("Playlist Item Added") + self.pendingToast = nil + + if didAddItem { + self.showPlaylistToast(tab: tab, state: .existingItem, item: item) + UIImpactFeedbackGenerator(style: .medium).bzzt() + } + } + } else { + self.pendingToast = nil + } + + // Item already exists in playlist, so ask them if they want to view it there + // Item was added to playlist by the user, so ask them if they want to view it there + case .newItem, .existingItem: + if buttonPressed { + UIImpactFeedbackGenerator(style: .medium).bzzt() + + DispatchQueue.main.async { + if let webView = tab?.webView { + PlaylistHelper.getCurrentTime(webView: webView, nodeTag: item.tagId) { [weak self] currentTime in + self?.openPlaylist(item: item, playbackOffset: currentTime) + } + } else { + self.openPlaylist(item: item, playbackOffset: 0.0) + } + } + } + + self.pendingToast = nil + } + }) + + if let pendingToast = pendingToast { + let duration = state == .none ? 10 : 5 + show(toast: pendingToast, afterWaiting: .milliseconds(250), duration: .seconds(duration)) + } + } + + func showPlaylistAlert(tab: Tab?, state: PlaylistItemAddedState, item: PlaylistInfo?) { + // Has to be done otherwise it is impossible to play a video after selecting its elements + UIMenuController.shared.hideMenu() + + let style: UIAlertController.Style = UIDevice.current.userInterfaceIdiom == .pad ? .alert : .actionSheet + let alert = UIAlertController( + title: Strings.PlayList.addToPlayListAlertTitle, message: Strings.PlayList.addToPlayListAlertDescription, preferredStyle: style) + + alert.addAction(UIAlertAction(title: Strings.PlayList.addToPlayListAlertTitle, style: .default, handler: { _ in + // Update playlist with new items.. + + guard let item = item else { return } + self.addToPlaylist(item: item) { [weak self] addedToPlaylist in + guard let self = self else { return } + + UIImpactFeedbackGenerator(style: .medium).bzzt() + + if addedToPlaylist { + self.showPlaylistToast(tab: tab, state: .existingItem, item: item) + } + } + })) + alert.addAction(UIAlertAction(title: Strings.cancelButtonTitle, style: .cancel, handler: nil)) + present(alert, animated: true, completion: nil) + } + func showPlaylistOnboarding(tab: Tab?) { if Preferences.Playlist.showAddToPlaylistURLBarOnboarding.value < 2 && shouldShowPlaylistOnboardingThisSession { Preferences.Playlist.showAddToPlaylistURLBarOnboarding.value += 1 diff --git a/Client/Frontend/Browser/PlaylistHelper.swift b/Client/Frontend/Browser/PlaylistHelper.swift index 7c69d3bdca3..90fd44fd316 100644 --- a/Client/Frontend/Browser/PlaylistHelper.swift +++ b/Client/Frontend/Browser/PlaylistHelper.swift @@ -21,6 +21,8 @@ enum PlaylistItemAddedState { protocol PlaylistHelperDelegate: NSObject { func updatePlaylistURLBar(tab: Tab?, state: PlaylistItemAddedState, item: PlaylistInfo?) func showPlaylistPopover(tab: Tab?, state: PlaylistPopoverState) + func showPlaylistToast(tab: Tab?, state: PlaylistItemAddedState, item: PlaylistInfo?) + func showPlaylistAlert(tab: Tab?, state: PlaylistItemAddedState, item: PlaylistInfo?) func showPlaylistOnboarding(tab: Tab?) } @@ -98,13 +100,15 @@ class PlaylistHelper: NSObject, TabContentScript { } if PlaylistItem.itemExists(item) { - self.updateItem(item) + // Item already exists, so just update the database with new token or URL. + self.updateItem(item, detected: item.detected) } else if item.detected { + // Automatic Detection delegate.updatePlaylistURLBar(tab: self.tab, state: .newItem, item: item) delegate.showPlaylistOnboarding(tab: self.tab) } else { - delegate.updatePlaylistURLBar(tab: self.tab, state: .newItem, item: item) - delegate.showPlaylistPopover(tab: self.tab, state: .addToPlaylist) + // Long-Press + delegate.showPlaylistAlert(tab: self.tab, state: .newItem, item: item) } } } @@ -161,15 +165,28 @@ class PlaylistHelper: NSObject, TabContentScript { } } - private func updateItem(_ item: PlaylistInfo) { - self.delegate?.updatePlaylistURLBar(tab: self.tab, state: .existingItem, item: item) + private func updateItem(_ item: PlaylistInfo, detected: Bool) { + if detected { + self.delegate?.updatePlaylistURLBar(tab: self.tab, state: .existingItem, item: item) + } - PlaylistItem.updateItem(item) { + PlaylistItem.updateItem(item) { [weak self] in + guard let self = self else { return } + log.debug("Playlist Item Updated") if !self.playlistItems.contains(item.src) { self.playlistItems.insert(item.src) - self.delegate?.updatePlaylistURLBar(tab: self.tab, state: .existingItem, item: item) + + if let delegate = self.delegate { + if detected { + delegate.updatePlaylistURLBar(tab: self.tab, state: .existingItem, item: item) + } else { + delegate.showPlaylistToast(tab: self.tab, state: .existingItem, item: item) + } + } + } else { + self.delegate?.showPlaylistToast(tab: self.tab, state: .existingItem, item: item) } } } diff --git a/Client/Frontend/Browser/PlaylistToast.swift b/Client/Frontend/Browser/PlaylistToast.swift new file mode 100644 index 00000000000..94c2fc982a7 --- /dev/null +++ b/Client/Frontend/Browser/PlaylistToast.swift @@ -0,0 +1,337 @@ +// Copyright 2020 The Brave Authors. All rights reserved. +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. + +import Foundation +import UIKit +import BraveShared +import Shared +import SnapKit +import Data +import BraveUI + +private class ToastShadowView: UIView { + private var shadowLayer: CAShapeLayer? + + override func layoutSubviews() { + super.layoutSubviews() + + let path = UIBezierPath(roundedRect: bounds, + cornerRadius: ButtonToastUX.toastButtonBorderRadius).cgPath + if let shadowLayer = shadowLayer { + shadowLayer.path = path + shadowLayer.shadowPath = path + } else { + shadowLayer = CAShapeLayer().then { + $0.path = path + $0.fillColor = UIColor.clear.cgColor + $0.shadowColor = UIColor.black.cgColor + $0.shadowPath = path + $0.shadowOffset = .zero + $0.shadowOpacity = 0.5 + $0.shadowRadius = ButtonToastUX.toastButtonBorderRadius + } + + shadowLayer?.do { + layer.insertSublayer($0, at: 0) + } + } + } +} + +private class HighlightableButton: UIButton { + private var shadowLayer: CAShapeLayer? + + var shadowLayerZOrder: Int { + guard let shadowLayer = shadowLayer else { return -1 } + return self.layer.sublayers?.firstIndex(of: shadowLayer) ?? -1 + } + + var isShadowHidden: Bool = false { + didSet { + shadowLayer?.isHidden = isShadowHidden + } + } + + override var isHighlighted: Bool { + didSet { + backgroundColor = isHighlighted ? UIColor.white.withAlphaComponent(0.2) : .clear + } + } + + override func layoutSubviews() { + super.layoutSubviews() + + let path = UIBezierPath(roundedRect: bounds, + cornerRadius: ButtonToastUX.toastButtonBorderRadius).cgPath + if let shadowLayer = shadowLayer { + shadowLayer.path = path + shadowLayer.shadowPath = path + } else { + shadowLayer = CAShapeLayer().then { + $0.path = path + $0.fillColor = UIColor.clear.cgColor + $0.shadowColor = UIColor.black.cgColor + $0.shadowPath = path + $0.shadowOffset = .zero + $0.shadowOpacity = 0.5 + $0.shadowRadius = ButtonToastUX.toastButtonBorderRadius + } + + shadowLayer?.do { + layer.insertSublayer($0, at: 0) + } + } + } +} + +class PlaylistToast: Toast { + private struct DesignUX { + static let maxToastWidth: CGFloat = 450.0 + } + + private let toastShadowView = ToastShadowView() + private lazy var gradientView = BraveGradientView.gradient02 + + private let button = HighlightableButton() + private var panState: CGPoint = .zero + + private let state: PlaylistItemAddedState + var item: PlaylistInfo? + + init(item: PlaylistInfo?, state: PlaylistItemAddedState, completion: ((_ buttonPressed: Bool) -> Void)?) { + self.item = item + self.state = state + super.init(frame: .zero) + + self.completionHandler = completion + toastView.backgroundColor = .clear + clipsToBounds = false + + addSubview(createView(item, state)) + + toastView.snp.makeConstraints { + $0.leading.trailing.height.equalTo(self) + self.animationConstraint = $0.top.equalTo(self).offset(ButtonToastUX.toastHeight).constraint + } + + self.snp.makeConstraints { + $0.height.equalTo(ButtonToastUX.toastHeight) + } + + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(onSwipeToDismiss(_:))) + toastView.addGestureRecognizer(panGesture) + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func createView(_ item: PlaylistInfo?, _ state: PlaylistItemAddedState) -> UIView { + if state == .newItem || state == .existingItem { + let horizontalStackView = UIStackView().then { + $0.alignment = .center + $0.spacing = ButtonToastUX.toastPadding + } + + let labelStackView = UIStackView().then { + $0.axis = .vertical + $0.alignment = .leading + } + + let label = UILabel().then { + $0.textAlignment = .left + $0.textColor = .white + $0.font = ButtonToastUX.toastLabelFont + $0.lineBreakMode = .byWordWrapping + $0.numberOfLines = 0 + + if state == .newItem { + $0.text = Strings.PlayList.toastAddedToPlaylistTitle + } else { + $0.text = Strings.PlayList.toastExitingItemPlaylistTitle + } + } + + self.button.do { + $0.layer.cornerRadius = ButtonToastUX.toastButtonBorderRadius + $0.layer.borderWidth = ButtonToastUX.toastButtonBorderWidth + $0.layer.borderColor = UIColor.white.cgColor + $0.imageView?.tintColor = .white + $0.setTitle(Strings.PlayList.toastAddToPlaylistOpenButton, for: []) + $0.setTitleColor(.white, for: .highlighted) + $0.titleLabel?.font = SimpleToastUX.toastFont + $0.titleLabel?.numberOfLines = 1 + $0.titleLabel?.lineBreakMode = .byClipping + $0.titleLabel?.adjustsFontSizeToFitWidth = true + $0.titleLabel?.minimumScaleFactor = 0.1 + $0.isShadowHidden = true + $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(buttonPressed))) + } + + self.button.snp.makeConstraints { + if let titleLabel = self.button.titleLabel { + $0.width.equalTo(titleLabel.intrinsicContentSize.width + 2 * ButtonToastUX.toastButtonPadding) + } + } + + labelStackView.addArrangedSubview(label) + horizontalStackView.addArrangedSubview(labelStackView) + horizontalStackView.addArrangedSubview(button) + + toastView.addSubview(toastShadowView) + toastView.addSubview(horizontalStackView) + + toastShadowView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + + horizontalStackView.snp.makeConstraints { + $0.centerX.equalTo(toastView) + $0.centerY.equalTo(toastView) + $0.width.equalTo(toastView.snp.width).offset(-2 * ButtonToastUX.toastPadding) + } + + updateGradientView() + return toastView + } + + let horizontalStackView = UIStackView().then { + $0.alignment = .center + $0.spacing = ButtonToastUX.toastPadding + } + + self.button.do { + $0.layer.cornerRadius = ButtonToastUX.toastButtonBorderRadius + $0.backgroundColor = .clear + $0.setTitleColor(.white, for: .highlighted) + $0.imageView?.tintColor = .white + $0.tintColor = .white + $0.titleLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium) + $0.titleLabel?.numberOfLines = 1 + $0.titleLabel?.lineBreakMode = .byClipping + $0.titleLabel?.adjustsFontSizeToFitWidth = true + $0.titleLabel?.minimumScaleFactor = 0.1 + $0.contentHorizontalAlignment = .left + $0.contentEdgeInsets = UIEdgeInsets(top: 10.0, left: 10.0, bottom: 10.0, right: 20.0) + $0.titleEdgeInsets = UIEdgeInsets(top: 0.0, left: 10.0, bottom: 0.0, right: -10.0) + $0.isShadowHidden = false + $0.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(buttonPressed))) + } + + horizontalStackView.addArrangedSubview(button) + toastView.addSubview(horizontalStackView) + + horizontalStackView.snp.makeConstraints { + $0.centerX.equalTo(toastView) + $0.centerY.equalTo(toastView) + $0.width.equalTo(toastView.snp.width).offset(-2 * ButtonToastUX.toastPadding) + } + + if state == .none { + button.setImage(#imageLiteral(resourceName: "quick_action_new_tab").template, for: []) + button.setTitle(Strings.PlayList.toastAddToPlaylistTitle, for: []) + } else { + assertionFailure("Should Never get here. Others case are handled at the start of this function.") + } + + updateGradientView() + return toastView + } + + @objc func buttonPressed(_ gestureRecognizer: UIGestureRecognizer) { + completionHandler?(true) + dismiss(true) + } + + @objc override func handleTap(_ gestureRecognizer: UIGestureRecognizer) { + dismiss(false) + } + + override func showToast(viewController: UIViewController? = nil, delay: DispatchTimeInterval, duration: DispatchTimeInterval?, makeConstraints: @escaping (SnapKit.ConstraintMaker) -> Swift.Void) { + super.showToast(viewController: viewController, delay: delay, duration: duration) { + guard let viewController = viewController as? BrowserViewController else { + assertionFailure("Playlist Toast should only be presented on BrowserViewController") + return + } + + $0.centerX.equalTo(viewController.view.snp.centerX) + $0.bottom.equalTo(viewController.webViewContainer.safeArea.bottom) + $0.leading.equalTo(viewController.view.safeArea.leading).priority(.high) + $0.trailing.equalTo(viewController.view.safeArea.trailing).priority(.high) + $0.width.lessThanOrEqualTo(DesignUX.maxToastWidth) + } + } + + override func dismiss(_ buttonPressed: Bool) { + self.dismiss(buttonPressed, animated: true) + } + + func dismiss(_ buttonPressed: Bool, animated: Bool) { + if displayState == .pendingDismiss || displayState == .dismissed { + return + } + + displayState = .pendingDismiss + superview?.removeGestureRecognizer(gestureRecognizer) + layer.removeAllAnimations() + + let duration = animated ? SimpleToastUX.toastAnimationDuration : 0.1 + UIView.animate(withDuration: duration, animations: { + self.animationConstraint?.update(offset: SimpleToastUX.toastHeight) + self.layoutIfNeeded() + }) { finished in + self.displayState = .dismissed + self.removeFromSuperview() + if !buttonPressed { + self.completionHandler?(false) + } + } + } + + private var shadowLayerZOrder: Int { + if state == .newItem || state == .existingItem { + // In this state, the shadow is on the toastView + let index = toastView.subviews.firstIndex(of: toastShadowView) ?? -1 + return index + 1 + } + // In this state, the shadow is on the button itself + return button.shadowLayerZOrder + 1 + } + + private func updateGradientView() { + gradientView.removeFromSuperview() + + if state == .newItem || state == .existingItem { + toastView.insertSubview(gradientView, at: shadowLayerZOrder) + } else { + button.insertSubview(gradientView, at: shadowLayerZOrder) + } + + gradientView.snp.makeConstraints { + $0.edges.equalToSuperview() + } + } + + @objc + private func onSwipeToDismiss(_ recognizer: UIPanGestureRecognizer) { + // Distance travelled after decelerating to zero velocity at a constant rate + func project(initialVelocity: CGFloat, decelerationRate: CGFloat) -> CGFloat { + return (initialVelocity / 1000.0) * decelerationRate / (1.0 - decelerationRate) + } + + if recognizer.state == .began { + panState = toastView.center + } else if recognizer.state == .ended { + let velocity = recognizer.velocity(in: toastView) + if abs(velocity.y) > abs(velocity.x) { + let y = min(panState.y, panState.y + recognizer.translation(in: toastView).y) + let projected = project(initialVelocity: velocity.y, decelerationRate: UIScrollView.DecelerationRate.normal.rawValue) + if y + projected > toastView.frame.maxY { + dismiss(false) + } + } + } + } +}