// // NCSubtitlePlayer.swift // Nextcloud // // Created by Federico Malagoni on 18/02/22. // Copyright © 2022 Federico Malagoni. All rights reserved. // Copyright © 2022 Marino Faggiana All rights reserved. // // Author Federico Malagoni // Author Marino Faggiana // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // import Foundation import AVKit import NextcloudKit extension NCPlayer { private struct AssociatedKeys { static var FontKey = "FontKey" static var ColorKey = "FontKey" static var SubtitleKey = "SubtitleKey" static var SubtitleContainerViewKey = "SubtitleContainerViewKey" static var SubtitleContainerViewHeightKey = "SubtitleContainerViewHeightKey" static var SubtitleHeightKey = "SubtitleHeightKey" static var SubtitleWidthKey = "SubtitleWidthKey" static var SubtitleContainerViewWidthKey = "SubtitleContainerViewWidthKey" static var SubtitleBottomKey = "SubtitleBottomKey" static var PayloadKey = "PayloadKey" } private var widthProportion: CGFloat { return 0.9 } private var bottomConstantPortrait: CGFloat { get { if UIDevice.current.hasNotch { return -60 } else { return -40 } } set { _ = newValue } } private var bottomConstantLandscape: CGFloat { get { if UIDevice.current.hasNotch { return -120 } else { return -100 } } set { _ = newValue } } var subtitleContainerView: UIView? { get { return objc_getAssociatedObject(self, &AssociatedKeys.SubtitleContainerViewKey) as? UIView } set (value) { objc_setAssociatedObject(self, &AssociatedKeys.SubtitleContainerViewKey, value, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)} } var subtitleLabel: UILabel? { get { return objc_getAssociatedObject(self, &AssociatedKeys.SubtitleKey) as? UILabel } set (value) { objc_setAssociatedObject(self, &AssociatedKeys.SubtitleKey, value, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } fileprivate var subtitleLabelHeightConstraint: NSLayoutConstraint? { get { return objc_getAssociatedObject(self, &AssociatedKeys.SubtitleHeightKey) as? NSLayoutConstraint } set (value) { objc_setAssociatedObject(self, &AssociatedKeys.SubtitleHeightKey, value, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } fileprivate var subtitleContainerViewHeightConstraint: NSLayoutConstraint? { get { return objc_getAssociatedObject(self, &AssociatedKeys.SubtitleContainerViewHeightKey) as? NSLayoutConstraint } set (value) { objc_setAssociatedObject(self, &AssociatedKeys.SubtitleContainerViewHeightKey, value, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } fileprivate var subtitleLabelBottomConstraint: NSLayoutConstraint? { get { return objc_getAssociatedObject(self, &AssociatedKeys.SubtitleBottomKey) as? NSLayoutConstraint } set (value) { objc_setAssociatedObject(self, &AssociatedKeys.SubtitleBottomKey, value, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } fileprivate var subtitleLabelWidthConstraint: NSLayoutConstraint? { get { return objc_getAssociatedObject(self, &AssociatedKeys.SubtitleWidthKey) as? NSLayoutConstraint } set (value) { objc_setAssociatedObject(self, &AssociatedKeys.SubtitleWidthKey, value, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } fileprivate var subtitleContainerViewWidthConstraint: NSLayoutConstraint? { get { return objc_getAssociatedObject(self, &AssociatedKeys.SubtitleContainerViewWidthKey) as? NSLayoutConstraint } set (value) { objc_setAssociatedObject(self, &AssociatedKeys.SubtitleContainerViewWidthKey, value, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } fileprivate var parsedPayload: NSDictionary? { get { return objc_getAssociatedObject(self, &AssociatedKeys.PayloadKey) as? NSDictionary } set (value) { objc_setAssociatedObject(self, &AssociatedKeys.PayloadKey, value, objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC) } } func setUpForSubtitle() { self.subtitleUrls.removeAll() if let url = CCUtility.getDirectoryProviderStorageOcId(metadata.ocId) { let enumerator = FileManager.default.enumerator(atPath: url) let filePaths = (enumerator?.allObjects as? [String]) if let filePaths = filePaths { let txtFilePaths = (filePaths.filter { $0.contains(".srt") }).sorted { guard let str1LastChar = $0.dropLast(4).last, let str2LastChar = $1.dropLast(4).last else { return false } return str1LastChar < str2LastChar } for txtFilePath in txtFilePaths { let subtitleUrl = URL(fileURLWithPath: CCUtility.getDirectoryProviderStorageOcId(metadata.ocId, fileNameView: txtFilePath)) self.subtitleUrls.append(subtitleUrl) } } } let (all, existing) = NCManageDatabase.shared.getSubtitles(account: metadata.account, serverUrl: metadata.serverUrl, fileName: metadata.fileName) if !existing.isEmpty { for subtitle in existing { let subtitleUrl = URL(fileURLWithPath: CCUtility.getDirectoryProviderStorageOcId(subtitle.ocId, fileNameView: subtitle.fileName)) self.subtitleUrls.append(subtitleUrl) } } if all.count != existing.count { let error = NKError(errorCode: NCGlobal.shared.errorInternalError, errorDescription: "_subtitle_not_dowloaded_") NCContentPresenter.shared.showInfo(error: error) } self.setSubtitleToolbarIcon(subtitleUrls: subtitleUrls) self.hideSubtitle() } func setSubtitleToolbarIcon(subtitleUrls: [URL]) { if subtitleUrls.isEmpty { playerToolBar?.subtitleButton.isHidden = true } else { playerToolBar?.subtitleButton.isHidden = false } } func addSubtitlesTo(_ vc: UIViewController, _ playerToolBar: NCPlayerToolBar?) { addSubtitleLabel(vc, playerToolBar) NotificationCenter.default.addObserver(self, selector: #selector(deviceRotated(_:)), name: UIDevice.orientationDidChangeNotification, object: nil) } func loadText(filePath: URL, _ completion: @escaping (_ contents: String?) -> Void) { DispatchQueue.global(qos: .background).async { guard let data = try? Data(contentsOf: filePath), let encoding = NCUtility.shared.getEncondingDataType(data: data) else { return } if let decodedString = String(data: data, encoding: encoding) { completion(decodedString) } else { completion(nil) } } } func open(fileFromLocal url: URL) { subtitleLabel?.text = "" self.loadText(filePath: url) { contents in guard let contents = contents else { return } DispatchQueue.main.async { self.subtitleLabel?.text = "" self.show(subtitles: contents) } } } @objc public func hideSubtitle() { self.subtitleLabel?.isHidden = true self.subtitleContainerView?.isHidden = true self.currentSubtitle = nil } @objc public func showSubtitle(url: URL) { self.subtitleLabel?.isHidden = false self.subtitleContainerView?.isHidden = false self.currentSubtitle = url } private func show(subtitles string: String) { parsedPayload = try? NCSubtitles.parseSubRip(string) if let parsedPayload = parsedPayload { addPeriodicNotification(parsedPayload: parsedPayload) } } private func showByDictionary(dictionaryContent: NSMutableDictionary) { parsedPayload = dictionaryContent if let parsedPayload = parsedPayload { addPeriodicNotification(parsedPayload: parsedPayload) } } func addPeriodicNotification(parsedPayload: NSDictionary) { // Add periodic notifications let interval = CMTimeMake(value: 1, timescale: 60) self.player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in guard let strongSelf = self, let label = strongSelf.subtitleLabel, let containerView = strongSelf.subtitleContainerView else { return } DispatchQueue.main.async { label.text = NCSubtitles.searchSubtitles(strongSelf.parsedPayload, time.seconds) strongSelf.adjustViewWidth(containerView: containerView) strongSelf.adjustLabelHeight(label: label) } } } @objc private func deviceRotated(_ notification: Notification) { guard let label = self.subtitleLabel, let containerView = self.subtitleContainerView else { return } DispatchQueue.main.async { self.adjustViewWidth(containerView: containerView) self.adjustLabelHeight(label: label) self.adjustLabelBottom(label: label) containerView.layoutIfNeeded() label.layoutIfNeeded() } } private func adjustLabelHeight(label: UILabel) { let baseSize = CGSize(width: label.bounds.width, height: .greatestFiniteMagnitude) let rect = label.sizeThatFits(baseSize) if label.text != nil { self.subtitleLabelHeightConstraint?.constant = rect.height + 5.0 } else { self.subtitleLabelHeightConstraint?.constant = rect.height } } private func adjustLabelBottom(label: UILabel) { var bottomConstant: CGFloat = bottomConstantPortrait switch UIApplication.shared.statusBarOrientation { case .portrait: bottomConstant = bottomConstantLandscape case .landscapeLeft, .landscapeRight, .portraitUpsideDown: bottomConstant = bottomConstantPortrait default: () } subtitleLabelBottomConstraint?.constant = bottomConstant } private func adjustViewWidth(containerView: UIView) { let widthConstant: CGFloat = UIScreen.main.bounds.width * widthProportion subtitleContainerViewWidthConstraint!.constant = widthConstant subtitleLabel?.preferredMaxLayoutWidth = (widthConstant - 20) } fileprivate func addSubtitleLabel(_ vc: UIViewController, _ playerToolBar: NCPlayerToolBar?) { guard subtitleLabel == nil, subtitleContainerView == nil else { return } subtitleContainerView = UIView() subtitleLabel = UILabel() subtitleContainerView?.translatesAutoresizingMaskIntoConstraints = false subtitleContainerView?.layer.cornerRadius = 5.0 subtitleContainerView?.layer.masksToBounds = true subtitleContainerView?.layer.shouldRasterize = true subtitleContainerView?.layer.rasterizationScale = UIScreen.main.scale subtitleContainerView?.backgroundColor = UIColor.black.withAlphaComponent(0.35) subtitleLabel?.translatesAutoresizingMaskIntoConstraints = false subtitleLabel?.textAlignment = .center subtitleLabel?.numberOfLines = 0 let fontSize = UIDevice.current.userInterfaceIdiom == .pad ? 38.0 : 20.0 subtitleLabel?.font = UIFont.incosolataMedium(size: fontSize) subtitleLabel?.lineBreakMode = .byWordWrapping subtitleLabel?.textColor = .white subtitleLabel?.backgroundColor = .clear subtitleContainerView?.addSubview(subtitleLabel!) var isFound = false for v in vc.view.subviews where v is UIScrollView { if let scrollView = v as? UIScrollView { for subView in scrollView.subviews where subView is imageVideoContainerView { subView.addSubview(subtitleContainerView!) isFound = true break } } } if !isFound { vc.view.addSubview(subtitleContainerView!) } NSLayoutConstraint.activate([ subtitleLabel!.centerXAnchor.constraint(equalTo: subtitleContainerView!.centerXAnchor), subtitleLabel!.centerYAnchor.constraint(equalTo: subtitleContainerView!.centerYAnchor) ]) subtitleContainerViewHeightConstraint = NSLayoutConstraint(item: subtitleContainerView!, attribute: .height, relatedBy: .equal, toItem: subtitleLabel!, attribute: .height, multiplier: 1.0, constant: 0.0) vc.view?.addConstraint(subtitleContainerViewHeightConstraint!) var bottomConstant: CGFloat = bottomConstantPortrait switch UIApplication.shared.statusBarOrientation { case .portrait, .portraitUpsideDown: bottomConstant = bottomConstantLandscape case .landscapeLeft, .landscapeRight: bottomConstant = bottomConstantPortrait default: () } let widthConstant: CGFloat = UIScreen.main.bounds.width * widthProportion NSLayoutConstraint.activate([ subtitleContainerView!.centerXAnchor.constraint(equalTo: vc.view.centerXAnchor) ]) subtitleContainerViewWidthConstraint = NSLayoutConstraint(item: subtitleContainerView!, attribute: .width, relatedBy: .lessThanOrEqual, toItem: nil, attribute: .width, multiplier: 1, constant: widthConstant) // setting default width == 0 because there is no text inside of the label subtitleLabelWidthConstraint = NSLayoutConstraint(item: subtitleLabel!, attribute: .width, relatedBy: .equal, toItem: subtitleContainerView, attribute: .width, multiplier: 1, constant: -20) subtitleLabelBottomConstraint = NSLayoutConstraint(item: subtitleContainerView!, attribute: .bottom, relatedBy: .equal, toItem: vc.view, attribute: .bottom, multiplier: 1, constant: bottomConstant) vc.view?.addConstraint(subtitleContainerViewWidthConstraint!) vc.view?.addConstraint(subtitleLabelWidthConstraint!) vc.view?.addConstraint(subtitleLabelBottomConstraint!) } internal func showAlertSubtitles() { let alert = UIAlertController(title: nil, message: NSLocalizedString("_subtitle_", comment: ""), preferredStyle: .actionSheet) for url in subtitleUrls { print("Play Subtitle at:\n\(url.path)") let videoUrlTitle = self.metadata.fileName.alphanumeric.dropLast(3) let subtitleUrlTitle = url.lastPathComponent.alphanumeric.dropLast(3) var titleSubtitle = String(subtitleUrlTitle.dropFirst(videoUrlTitle.count)) if titleSubtitle.isEmpty { titleSubtitle = NSLocalizedString("_subtitle_", comment: "") } let action = UIAlertAction(title: titleSubtitle, style: .default, handler: { [self] _ in if NCUtilityFileSystem.shared.getFileSize(filePath: url.path) > 0 { self.open(fileFromLocal: url) if let viewController = viewController { self.addSubtitlesTo(viewController, self.playerToolBar) self.showSubtitle(url: url) } } else { let alertError = UIAlertController(title: NSLocalizedString("_error_", comment: ""), message: NSLocalizedString("_subtitle_not_found_", comment: ""), preferredStyle: .alert) alertError.addAction(UIKit.UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default, handler: nil)) viewController?.present(alertError, animated: true, completion: nil) } }) alert.addAction(action) if currentSubtitle == url { action.setValue(true, forKey: "checked") } } let disable = UIAlertAction(title: NSLocalizedString("_disable_", comment: ""), style: .default, handler: { _ in self.hideSubtitle() }) alert.addAction(disable) if currentSubtitle == nil { disable.setValue(true, forKey: "checked") } alert.addAction(UIAlertAction(title: NSLocalizedString("_cancel_", comment: ""), style: .cancel, handler: { _ in })) alert.popoverPresentationController?.sourceView = self.viewController?.view self.viewController?.present(alert, animated: true, completion: nil) } }