diff --git a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift index 78c643057f..a73a05f26a 100644 --- a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift +++ b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift @@ -1106,7 +1106,7 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, UIScroll } let textSize = videoFrameTextNode.updateLayout(CGSize(width: 100.0, height: 100.0)) videoFrameTextNode.frame = CGRect(origin: CGPoint(), size: textSize) - videoFramePreviewNode.addSubnode(videoFrameTextNode) +// videoFramePreviewNode.addSubnode(videoFrameTextNode) self.videoFramePreviewNode = (videoFramePreviewNode, videoFrameTextNode) self.addSubnode(videoFramePreviewNode) diff --git a/submodules/GalleryUI/Sources/ChatVideoGalleryItemScrubberView.swift b/submodules/GalleryUI/Sources/ChatVideoGalleryItemScrubberView.swift index 14e55d01c2..57ff37afdb 100644 --- a/submodules/GalleryUI/Sources/ChatVideoGalleryItemScrubberView.swift +++ b/submodules/GalleryUI/Sources/ChatVideoGalleryItemScrubberView.swift @@ -136,7 +136,7 @@ final class ChatVideoGalleryItemScrubberView: UIView { if let mappedStatus = mappedStatus { self.chapterDisposable.set((mappedStatus |> deliverOnMainQueue).start(next: { [weak self] status in - if let strongSelf = self, status.duration > 0.0 { + if let strongSelf = self, status.duration > 1.0 { var text: String = "" for chapter in strongSelf.chapters { diff --git a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift index 17f95d92a3..e603a96776 100644 --- a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift @@ -251,7 +251,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { private let overlayContentNode: UniversalVideoGalleryItemOverlayNode private var videoNode: UniversalVideoNode? - private var videoFramePreview: MediaPlayerFramePreview? + private var videoFramePreview: FramePreview? private var pictureInPictureNode: UniversalVideoGalleryItemPictureInPictureNode? private let statusButtonNode: HighlightableButtonNode private let statusNode: RadialStatusNode @@ -280,7 +280,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { private var fetchStatus: MediaResourceStatus? private var fetchControls: FetchControls? - private var scrubbingFrame = Promise(nil) + private var scrubbingFrame = Promise(nil) private var scrubbingFrames = false private var scrubbingFrameDisposable: Disposable? @@ -459,6 +459,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { switch type { case .youtube: disablePictureInPicture = !(item.configuration?.youtubePictureInPictureEnabled ?? false) + self.videoFramePreview = YoutubeEmbedFramePreview(context: item.context, content: content) case .iframe: disablePlayerControls = true default: @@ -841,7 +842,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { } } else if let _ = item.content as? WebEmbedVideoContent { if let time = item.timecode { - seek = .timecode(time) +// seek = .timecode(time) } } } diff --git a/submodules/LocationResources/Sources/MapResources.swift b/submodules/LocationResources/Sources/MapResources.swift index b79c127d71..200bba1536 100644 --- a/submodules/LocationResources/Sources/MapResources.swift +++ b/submodules/LocationResources/Sources/MapResources.swift @@ -81,7 +81,7 @@ public final class MapSnapshotMediaResourceRepresentation: CachedMediaResourceRe } public func isEqual(to: CachedMediaResourceRepresentation) -> Bool { - if let to = to as? MapSnapshotMediaResourceRepresentation { + if to is MapSnapshotMediaResourceRepresentation { return true } else { return false diff --git a/submodules/MediaPlayer/Sources/MediaPlayerFramePreview.swift b/submodules/MediaPlayer/Sources/MediaPlayerFramePreview.swift index 813d56a9b9..2f558786f0 100644 --- a/submodules/MediaPlayer/Sources/MediaPlayerFramePreview.swift +++ b/submodules/MediaPlayer/Sources/MediaPlayerFramePreview.swift @@ -5,6 +5,18 @@ import TelegramCore import SyncCore import FFMpegBinding +public enum FramePreviewResult { + case image(UIImage) + case waitingForData +} + +public protocol FramePreview { + var generatedFrames: Signal { get } + + func generateFrame(at timestamp: Double) + func cancelPendingFrames() +} + private final class FramePreviewContext { let source: UniversalSoftwareVideoSource @@ -29,18 +41,13 @@ private func initializedPreviewContext(queue: Queue, postbox: Postbox, fileRefer } } -public enum MediaPlayerFramePreviewResult { - case image(UIImage) - case waitingForData -} - private final class MediaPlayerFramePreviewImpl { private let queue: Queue private let context: Promise> private let currentFrameDisposable = MetaDisposable() private var currentFrameTimestamp: Double? private var nextFrameTimestamp: Double? - fileprivate let framePipe = ValuePipe() + fileprivate let framePipe = ValuePipe() init(queue: Queue, postbox: Postbox, fileReference: FileMediaReference) { self.queue = queue @@ -108,11 +115,11 @@ private final class MediaPlayerFramePreviewImpl { } } -public final class MediaPlayerFramePreview { +public final class MediaPlayerFramePreview: FramePreview { private let queue: Queue private let impl: QueueLocalObject - public var generatedFrames: Signal { + public var generatedFrames: Signal { return Signal { subscriber in let disposable = MetaDisposable() self.impl.with { impl in diff --git a/submodules/TelegramUI/Resources/WebEmbed/Youtube.html b/submodules/TelegramUI/Resources/WebEmbed/Youtube.html index a0a1020ae2..4d4c6cd65c 100755 --- a/submodules/TelegramUI/Resources/WebEmbed/Youtube.html +++ b/submodules/TelegramUI/Resources/WebEmbed/Youtube.html @@ -29,6 +29,7 @@ var availableQualities = ""; var failed = false; var autostarted = false; + var storyboardSpec = "" YT.ready(function() { player = new YT.Player("player", %@); @@ -36,7 +37,8 @@ function getCurrentTime() { downloadProgress = player.getVideoLoadedFraction(); - position = player.getCurrentTime() + position = player.getCurrentTime(); + storyboardSpec = player.getStoryboardFormat(); updateState(); invoke("tick"); @@ -59,7 +61,7 @@ } function updateState() { - window.location.href = "embed://onState?failed=" + failed + "&playback=" + playbackState + "&position=" + position + "&duration=" + duration + "&download=" + downloadProgress + '&quality=' + quality + '&availableQualities=' + availableQualities; + window.location.href = "embed://onState?failed=" + failed + "&playback=" + playbackState + "&position=" + position + "&duration=" + duration + "&download=" + downloadProgress + '&quality=' + quality + '&availableQualities=' + availableQualities + '&storyboard=' + storyboardSpec; } function onReady(event) { diff --git a/submodules/TelegramUI/Resources/WebEmbed/YoutubeUserScript.js b/submodules/TelegramUI/Resources/WebEmbed/YoutubeUserScript.js index 720132f773..7d71ac168c 100644 --- a/submodules/TelegramUI/Resources/WebEmbed/YoutubeUserScript.js +++ b/submodules/TelegramUI/Resources/WebEmbed/YoutubeUserScript.js @@ -49,6 +49,12 @@ function tick() { paid.style.opacity = "0"; } + var gradient = document.getElementsByClassName("ytp-gradient-top")[0]; + if (gradient != null) { + gradient.style.display = "none"; + gradient.style.opacity = "0"; + } + var end = document.getElementsByClassName("html5-endscreen")[0]; if (end != null) { end.style.display = "none"; diff --git a/submodules/TelegramUI/Sources/FetchCachedRepresentations.swift b/submodules/TelegramUI/Sources/FetchCachedRepresentations.swift index 45dfa94931..f8d2d788be 100644 --- a/submodules/TelegramUI/Sources/FetchCachedRepresentations.swift +++ b/submodules/TelegramUI/Sources/FetchCachedRepresentations.swift @@ -18,6 +18,7 @@ import TelegramAnimatedStickerNode import WallpaperResources import Svg import GZip +import TelegramUniversalVideoContent public func fetchCachedResourceRepresentation(account: Account, resource: MediaResource, representation: CachedMediaResourceRepresentation) -> Signal { if let representation = representation as? CachedStickerAJpegRepresentation { @@ -133,6 +134,8 @@ public func fetchCachedResourceRepresentation(account: Account, resource: MediaR } } else if let resource = resource as? MapSnapshotMediaResource, let _ = representation as? MapSnapshotMediaResourceRepresentation { return fetchMapSnapshotResource(resource: resource) + } else if let resource = resource as? YoutubeEmbedStoryboardMediaResource, let _ = representation as? YoutubeEmbedStoryboardMediaResourceRepresentation { + return fetchYoutubeEmbedStoryboardResource(resource: resource) } return .never() } diff --git a/submodules/TelegramUniversalVideoContent/Sources/GenericEmbedImplementation.swift b/submodules/TelegramUniversalVideoContent/Sources/GenericEmbedImplementation.swift index fa1ed35d83..bcdcb85067 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/GenericEmbedImplementation.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/GenericEmbedImplementation.swift @@ -5,7 +5,7 @@ import UniversalMediaPlayer import AppBundle final class GenericEmbedImplementation: WebEmbedImplementation { - private var evalImpl: ((String) -> Void)? + private var evalImpl: ((String, ((Any?) -> Void)?) -> Void)? private var updateStatus: ((MediaPlayerStatus) -> Void)? private var onPlaybackStarted: (() -> Void)? private var status : MediaPlayerStatus @@ -17,7 +17,7 @@ final class GenericEmbedImplementation: WebEmbedImplementation { self.status = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .buffering(initial: true, whilePlaying: true), soundEnabled: true) } - func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) { + func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String, ((Any?) -> Void)?) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) { let bundle = getAppBundle() guard let userScriptPath = bundle.path(forResource: "GenericUserScript", ofType: "js") else { return diff --git a/submodules/TelegramUniversalVideoContent/Sources/TwitchEmbedImplementation.swift b/submodules/TelegramUniversalVideoContent/Sources/TwitchEmbedImplementation.swift index ea36275ed9..994b80b990 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/TwitchEmbedImplementation.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/TwitchEmbedImplementation.swift @@ -9,7 +9,7 @@ func isTwitchVideoUrl(_ url: String) -> Bool { } final class TwitchEmbedImplementation: WebEmbedImplementation { - private var evalImpl: ((String) -> Void)? + private var evalImpl: ((String, ((Any?) -> Void)?) -> Void)? private var updateStatus: ((MediaPlayerStatus) -> Void)? private var onPlaybackStarted: (() -> Void)? @@ -23,7 +23,7 @@ final class TwitchEmbedImplementation: WebEmbedImplementation { self.status = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .buffering(initial: true, whilePlaying: true), soundEnabled: true) } - func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) { + func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String, ((Any?) -> Void)?) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) { let bundle = getAppBundle() guard let userScriptPath = bundle.path(forResource: "TwitchUserScript", ofType: "js") else { return @@ -57,7 +57,7 @@ final class TwitchEmbedImplementation: WebEmbedImplementation { func play() { if let eval = self.evalImpl { - eval("playPause()") + eval("playPause()", nil) } self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: self.status.duration, dimensions: self.status.dimensions, timestamp: self.status.timestamp, baseRate: 1.0, seekId: self.status.seekId, status: .playing, soundEnabled: self.status.soundEnabled) @@ -68,7 +68,7 @@ final class TwitchEmbedImplementation: WebEmbedImplementation { func pause() { if let eval = self.evalImpl { - eval("playPause()") + eval("playPause()", nil) } self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: self.status.duration, dimensions: self.status.dimensions, timestamp: self.status.timestamp, baseRate: 1.0, seekId: self.status.seekId, status: .paused, soundEnabled: self.status.soundEnabled) diff --git a/submodules/TelegramUniversalVideoContent/Sources/VimeoEmbedImplementation.swift b/submodules/TelegramUniversalVideoContent/Sources/VimeoEmbedImplementation.swift index d0b94965aa..e0e1cde267 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/VimeoEmbedImplementation.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/VimeoEmbedImplementation.swift @@ -82,7 +82,7 @@ func extractVimeoVideoIdAndTimestamp(url: String) -> (String, Int)? { } final class VimeoEmbedImplementation: WebEmbedImplementation { - private var evalImpl: ((String) -> Void)? + private var evalImpl: ((String, ((Any?) -> Void)?) -> Void)? private var updateStatus: ((MediaPlayerStatus) -> Void)? private var onPlaybackStarted: (() -> Void)? @@ -100,7 +100,7 @@ final class VimeoEmbedImplementation: WebEmbedImplementation { self.status = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: Double(timestamp), baseRate: 1.0, seekId: 0, status: .buffering(initial: true, whilePlaying: true), soundEnabled: true) } - func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) { + func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String, ((Any?) -> Void)?) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) { let bundle = getAppBundle() guard let userScriptPath = bundle.path(forResource: "VimeoUserScript", ofType: "js") else { return @@ -135,7 +135,7 @@ final class VimeoEmbedImplementation: WebEmbedImplementation { func play() { if let eval = self.evalImpl { - eval("play();") + eval("play();", nil) } ignorePosition = 2 @@ -143,7 +143,7 @@ final class VimeoEmbedImplementation: WebEmbedImplementation { func pause() { if let eval = self.evalImpl { - eval("pause();") + eval("pause();", nil) } } @@ -157,7 +157,7 @@ final class VimeoEmbedImplementation: WebEmbedImplementation { func seek(timestamp: Double) { if let eval = self.evalImpl { - eval("seek(\(timestamp));") + eval("seek(\(timestamp));", nil) } self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: self.status.duration, dimensions: self.status.dimensions, timestamp: timestamp, baseRate: 1.0, seekId: self.status.seekId + 1, status: self.status.status, soundEnabled: self.status.soundEnabled) diff --git a/submodules/TelegramUniversalVideoContent/Sources/WebEmbedPlayerNode.swift b/submodules/TelegramUniversalVideoContent/Sources/WebEmbedPlayerNode.swift index 12db0f5a2f..cfc0b5e605 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/WebEmbedPlayerNode.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/WebEmbedPlayerNode.swift @@ -8,7 +8,7 @@ import SyncCore import UniversalMediaPlayer protocol WebEmbedImplementation { - func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) + func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String, ((Any?) -> Void)?) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) func play() func pause() @@ -73,7 +73,7 @@ final class WebEmbedPlayerNode: ASDisplayNode, WKNavigationDelegate { return self.readyValue.get() } - private let impl: WebEmbedImplementation + let impl: WebEmbedImplementation private let intrinsicDimensions: CGSize private let webView: WKWebView @@ -118,8 +118,8 @@ final class WebEmbedPlayerNode: ASDisplayNode, WKNavigationDelegate { } self.view.addSubview(self.webView) - self.impl.setup(self.webView, userContentController: userContentController, evaluateJavaScript: { [weak self] js in - self?.evaluateJavaScript(js: js) + self.impl.setup(self.webView, userContentController: userContentController, evaluateJavaScript: { [weak self] js, completion in + self?.evaluateJavaScript(js: js, completion: completion) }, updateStatus: { [weak self] status in self?.statusValue.set(status) }, onPlaybackStarted: { [weak self] in @@ -168,12 +168,15 @@ final class WebEmbedPlayerNode: ASDisplayNode, WKNavigationDelegate { } } - private func evaluateJavaScript(js: String) { + private func evaluateJavaScript(js: String, completion: ((Any?) -> Void)?) { self.queue.async { [weak self] in if let strongSelf = self { let impl = { - strongSelf.webView.evaluateJavaScript(js, completionHandler: { (_, _) in + strongSelf.webView.evaluateJavaScript(js, completionHandler: { (result, _) in strongSelf.semaphore.signal() + if let completion = completion { + completion(result) + } }) } diff --git a/submodules/TelegramUniversalVideoContent/Sources/WebEmbedVideoContent.swift b/submodules/TelegramUniversalVideoContent/Sources/WebEmbedVideoContent.swift index 25d71fd5f1..182b2953dc 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/WebEmbedVideoContent.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/WebEmbedVideoContent.swift @@ -36,8 +36,8 @@ public final class WebEmbedVideoContent: UniversalVideoContent { return WebEmbedVideoContentNode(postbox: postbox, audioSessionManager: audioSession, webPage: self.webPage, webpageContent: self.webpageContent, forcedTimestamp: self.forcedTimestamp) } } - -private final class WebEmbedVideoContentNode: ASDisplayNode, UniversalVideoContentNode { + +final class WebEmbedVideoContentNode: ASDisplayNode, UniversalVideoContentNode { private let webpageContent: TelegramMediaWebpageLoadedContent private let intrinsicDimensions: CGSize @@ -64,6 +64,10 @@ private final class WebEmbedVideoContentNode: ASDisplayNode, UniversalVideoConte private let imageNode: TransformImageNode private let playerNode: WebEmbedPlayerNode + var impl: WebEmbedImplementation { + return playerNode.impl + } + private var readyDisposable = MetaDisposable() init(postbox: Postbox, audioSessionManager: ManagedAudioSession, webPage: TelegramMediaWebpage, webpageContent: TelegramMediaWebpageLoadedContent, forcedTimestamp: Int? = nil) { diff --git a/submodules/TelegramUniversalVideoContent/Sources/YoutubeEmbedImplementation.swift b/submodules/TelegramUniversalVideoContent/Sources/YoutubeEmbedImplementation.swift index 7bc1d1bf88..aab5dabb09 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/YoutubeEmbedImplementation.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/YoutubeEmbedImplementation.swift @@ -1,4 +1,9 @@ import Foundation +import Display +import Postbox +import SyncCore +import TelegramCore +import AccountContext import WebKit import SwiftSignalKit import UniversalMediaPlayer @@ -84,11 +89,16 @@ func extractYoutubeVideoIdAndTimestamp(url: String) -> (String, Int)? { } final class YoutubeEmbedImplementation: WebEmbedImplementation { - private var evalImpl: ((String) -> Void)? + private var evalImpl: ((String, ((Any?) -> Void)?) -> Void)? private var updateStatus: ((MediaPlayerStatus) -> Void)? private var onPlaybackStarted: (() -> Void)? - private let videoId: String + fileprivate let videoId: String + fileprivate var storyboardSpec: String? + fileprivate var duration: Double { + return self.status.duration + } + private var timestamp: Int private var ignoreEarlierTimestamps = false private var status : MediaPlayerStatus @@ -107,8 +117,8 @@ final class YoutubeEmbedImplementation: WebEmbedImplementation { init(videoId: String, timestamp: Int = 0) { self.videoId = videoId - self.timestamp = timestamp - if timestamp > 0 { + self.timestamp = 0 + if self.timestamp > 0 { self.ignoreEarlierTimestamps = true } self.status = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: Double(timestamp), baseRate: 1.0, seekId: 0, status: .buffering(initial: true, whilePlaying: true), soundEnabled: true) @@ -116,7 +126,7 @@ final class YoutubeEmbedImplementation: WebEmbedImplementation { self.benchmarkStartTime = CFAbsoluteTimeGetCurrent() } - func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) { + func setup(_ webView: WKWebView, userContentController: WKUserContentController, evaluateJavaScript: @escaping (String, ((Any?) -> Void)?) -> Void, updateStatus: @escaping (MediaPlayerStatus) -> Void, onPlaybackStarted: @escaping () -> Void) { let bundle = getAppBundle() guard let userScriptPath = bundle.path(forResource: "YoutubeUserScript", ofType: "js") else { return @@ -177,7 +187,7 @@ final class YoutubeEmbedImplementation: WebEmbedImplementation { } if let eval = self.evalImpl { - eval("play();") + eval("play();", nil) } self.ignorePosition = 2 @@ -185,7 +195,7 @@ final class YoutubeEmbedImplementation: WebEmbedImplementation { func pause() { if let eval = self.evalImpl { - eval("pause();") + eval("pause();", nil) } } @@ -203,8 +213,8 @@ final class YoutubeEmbedImplementation: WebEmbedImplementation { self.ignoreEarlierTimestamps = true } - if let eval = evalImpl { - eval("seek(\(timestamp));") + if let eval = self.evalImpl { + eval("seek(\(timestamp));", nil) } self.status = MediaPlayerStatus(generationTimestamp: self.status.generationTimestamp, duration: self.status.duration, dimensions: self.status.dimensions, timestamp: timestamp, baseRate: 1.0, seekId: self.status.seekId + 1, status: self.status.status, soundEnabled: true) @@ -241,6 +251,11 @@ final class YoutubeEmbedImplementation: WebEmbedImplementation { download = Float(value) } else if queryItem.name == "failed" { failed = Bool(value) + } else if queryItem.name == "storyboard" { + let urlString = url.absoluteString + if value.count > 10, let range = urlString.range(of: "storyboard=") { + self.storyboardSpec = String(urlString[range.upperBound.. Bool { + if let to = to as? YoutubeEmbedStoryboardMediaResourceId { + return self.videoId == to.videoId && self.storyboardId == to.storyboardId + } else { + return false + } + } +} + +public class YoutubeEmbedStoryboardMediaResource: TelegramMediaResource { + public let videoId: String + public let storyboardId: Int32 + public let url: String + + public init(videoId: String, storyboardId: Int32, url: String) { + self.videoId = videoId + self.storyboardId = storyboardId + self.url = url + } + + public required init(decoder: PostboxDecoder) { + self.videoId = decoder.decodeStringForKey("v", orElse: "") + self.storyboardId = decoder.decodeInt32ForKey("i", orElse: 0) + self.url = decoder.decodeStringForKey("u", orElse: "") + } + + public func encode(_ encoder: PostboxEncoder) { + encoder.encodeString(self.videoId, forKey: "v") + encoder.encodeInt32(self.storyboardId, forKey: "i") + encoder.encodeString(self.url, forKey: "u") + } + + public var id: MediaResourceId { + return YoutubeEmbedStoryboardMediaResourceId(videoId: self.videoId, storyboardId: self.storyboardId) + } + + public func isEqual(to: MediaResource) -> Bool { + if let to = to as? YoutubeEmbedStoryboardMediaResource { + return self.videoId == to.videoId && self.storyboardId == to.storyboardId && self.url == to.url + } else { + return false + } + } +} + +public final class YoutubeEmbedStoryboardMediaResourceRepresentation: CachedMediaResourceRepresentation { + public let keepDuration: CachedMediaRepresentationKeepDuration = .shortLived + + public var uniqueId: String { + return "cached" + } + + public init() { + } + + public func isEqual(to: CachedMediaResourceRepresentation) -> Bool { + if to is YoutubeEmbedStoryboardMediaResourceRepresentation { + return true + } else { + return false + } + } +} + +public func fetchYoutubeEmbedStoryboardResource(resource: YoutubeEmbedStoryboardMediaResource) -> Signal { + return Signal { subscriber in + subscriber.putNext(.reset) + + let disposable = MetaDisposable() + disposable.set(fetchHttpResource(url: resource.url).start(next: { next in + if case let .dataPart(_, data, _, complete) = next, complete { + let tempFile = TempBox.shared.tempFile(fileName: "image.jpg") + if let _ = try? data.write(to: URL(fileURLWithPath: tempFile.path), options: .atomic) { + subscriber.putNext(.tempFile(tempFile)) + subscriber.putCompletion() + } + } + })) + + return ActionDisposable { + disposable.dispose() + } + } +} + +private func youtubeEmbedStoryboardData(account: Account, resource: YoutubeEmbedStoryboardMediaResource) -> Signal { + return Signal { subscriber in + let dataDisposable = account.postbox.mediaBox.cachedResourceRepresentation(resource, representation: YoutubeEmbedStoryboardMediaResourceRepresentation(), complete: true).start(next: { next in + if next.size != 0 { + subscriber.putNext(next.size == 0 ? nil : try? Data(contentsOf: URL(fileURLWithPath: next.path), options: [])) + } + }, error: subscriber.putError, completed: subscriber.putCompletion) + + return ActionDisposable { + dataDisposable.dispose() + } + } +} + +private func youtubeEmbedStoryboardImage(account: Account, resource: YoutubeEmbedStoryboardMediaResource, frame: Int32, size: YoutubeEmbedFramePreview.StoryboardSpec.StoryboardSize) -> Signal { + let signal = youtubeEmbedStoryboardData(account: account, resource: resource) + + return signal |> map { fullSizeData in + let drawingSize = CGSize(width: CGFloat(size.width), height: CGFloat(size.height)) + let context = DrawingContext(size: drawingSize, clear: true) + + var fullSizeImage: CGImage? + if let fullSizeData = fullSizeData { + let options = NSMutableDictionary() + options[kCGImageSourceShouldCache as NSString] = false as NSNumber + if let imageSource = CGImageSourceCreateWithData(fullSizeData as CFData, nil), let image = CGImageSourceCreateImageAtIndex(imageSource, 0, options as CFDictionary) { + fullSizeImage = image + } + + if let fullSizeImage = fullSizeImage { + let rect: CGRect + let imageSize = CGSize(width: CGFloat(fullSizeImage.width), height: CGFloat(fullSizeImage.height)) + + let row = floor(CGFloat(frame) / CGFloat(size.cols)) + let col = CGFloat(frame % size.cols) + + rect = CGRect(origin: CGPoint(x: -drawingSize.width * col, y: -drawingSize.height * row), size: imageSize) + + context.withFlippedContext { c in + c.setBlendMode(.copy) + c.interpolationQuality = .medium + c.draw(fullSizeImage, in: rect) + } + return context.generateImage() + } + } + return nil + } +} + +public final class YoutubeEmbedFramePreview: FramePreview { + fileprivate struct StoryboardSpec { + struct StoryboardSize { + let width: Int32 + let height: Int32 + let quality: Int32 + let cols: Int32 + let rows: Int32 + let duration: Int32 + let imageName: String + let sigh: String + } + + let baseUrl: String + let sizes: [StoryboardSize] + + init?(specString: String) { + let sections = specString.components(separatedBy: "|") + if sections.count < 2 { + return nil + } + guard let baseUrl = sections.first else { + return nil + } + self.baseUrl = baseUrl + + var sizes: [StoryboardSize] = [] + for i in 1 ..< sections.count - 1 { + let section = sections[i] + let data = section.components(separatedBy: "#") + + if data.count >= 8, let width = Int32(data[0]), let height = Int32(data[1]), let quality = Int32(data[2]), let cols = Int32(data[3]), let rows = Int32(data[4]), let duration = Int32(data[5]) { + let size = StoryboardSize(width: width, height: height, quality: quality, cols: cols, rows: rows, duration: duration, imageName: data[6], sigh: data[7]) + sizes.append(size) + } + } + + self.sizes = sizes + } + + var bestSize: (Int, StoryboardSize)? { + var best: (Int, StoryboardSize)? + for i in 0 ..< self.sizes.count { + let size = self.sizes[i] + if let (_, currentBest) = best { + if currentBest.width < size.width || (currentBest.width == size.width && currentBest.cols < size.cols) { + best = (i, size) + } + } else { + best = (i, size) + } + } + return best + } + } + + private func urlForStoryboard(spec: StoryboardSpec, sizeIndex: Int, num: Int32) -> String { + let size = spec.sizes[sizeIndex] + + var url = spec.baseUrl + url = url.replacingOccurrences(of: "$L", with: "\(sizeIndex)") + url = url.replacingOccurrences(of: "$N", with: size.imageName) + url = url.replacingOccurrences(of: "$M", with: "\(num)") + url += "&sigh=\(size.sigh)" + + return url + } + + private let context: AccountContext + private weak var content: WebEmbedVideoContent? + + private let currentFrameDisposable = MetaDisposable() + private var currentFrameTimestamp: Double? + private var nextFrameTimestamp: Double? + fileprivate let framePipe = ValuePipe() + + public init(context: AccountContext, content: WebEmbedVideoContent) { + self.context = context + self.content = content + } + + deinit { + self.currentFrameDisposable.dispose() + } + + public var generatedFrames: Signal { + return self.framePipe.signal() + } + + public func generateFrame(at timestamp: Double) { + guard let content = self.content else { + return + } + + if self.currentFrameTimestamp != nil { + self.nextFrameTimestamp = timestamp + return + } + self.currentFrameTimestamp = timestamp + + self.context.sharedContext.mediaManager.universalVideoManager.withUniversalVideoContent(id: content.id) { [weak self] node in + guard let strongSelf = self, let node = node as? WebEmbedVideoContentNode, let youtubeImpl = node.impl as? YoutubeEmbedImplementation, youtubeImpl.duration > 0.0, let specString = youtubeImpl.storyboardSpec, let storyboardSpec = StoryboardSpec(specString: specString), let bestSize = storyboardSpec.bestSize else { + return + } + + var duration: Double = Double(bestSize.1.duration) / 1000.0 + var totalStoryboards: Int32 = 1 + var totalFrames: Int32 = 1 + let framesOnStoryboard: Int32 = bestSize.1.cols * bestSize.1.rows + + if duration > 0.0 { + totalFrames = Int32(ceil(youtubeImpl.duration / duration)) + totalStoryboards = Int32(ceil(Double(totalFrames) / Double(framesOnStoryboard))) + } else { + duration = youtubeImpl.duration / Double(framesOnStoryboard) + } + + let globalFrame = Int32(floor(timestamp / youtubeImpl.duration * Double(totalFrames))) + let frame: Int32 = globalFrame % framesOnStoryboard + + let num: Int32 = Int32(floor(Double(globalFrame) / Double(framesOnStoryboard))) + let url = urlForStoryboard(spec: storyboardSpec, sizeIndex: bestSize.0, num: num) + + strongSelf.framePipe.putNext(.waitingForData) + strongSelf.currentFrameDisposable.set(youtubeEmbedStoryboardImage(account: strongSelf.context.account, resource: YoutubeEmbedStoryboardMediaResource(videoId: youtubeImpl.videoId, storyboardId: num, url: url), frame: frame, size: bestSize.1).start(next: { [weak self] image in + if let strongSelf = self { + if let image = image { + strongSelf.framePipe.putNext(.image(image)) + } + strongSelf.currentFrameTimestamp = nil + if let nextFrameTimestamp = strongSelf.nextFrameTimestamp { + strongSelf.nextFrameTimestamp = nil + strongSelf.generateFrame(at: nextFrameTimestamp) + } + } + })) + } + } + + public func cancelPendingFrames() { + self.nextFrameTimestamp = nil + self.currentFrameTimestamp = nil + self.currentFrameDisposable.set(nil) + } +}