mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-09-01 02:12:39 +00:00
Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios
This commit is contained in:
commit
a68bf9bcae
1
.bazelrc
1
.bazelrc
@ -29,7 +29,6 @@ build --features=debug_prefix_map_pwd_is_dot
|
||||
build --features=swift.cacheable_swiftmodules
|
||||
build --features=swift.debug_prefix_map
|
||||
build --features=swift.enable_vfsoverlays
|
||||
build --features=swift.vfsoverlay
|
||||
|
||||
build --strategy=Genrule=standalone
|
||||
build --spawn_strategy=standalone
|
||||
|
@ -20,7 +20,7 @@ public protocol UniversalVideoContentNode: AnyObject {
|
||||
var status: Signal<MediaPlayerStatus, NoError> { get }
|
||||
var bufferingStatus: Signal<(RangeSet<Int64>, Int64)?, NoError> { get }
|
||||
|
||||
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition)
|
||||
func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition)
|
||||
|
||||
func play()
|
||||
func pause()
|
||||
@ -68,7 +68,7 @@ public protocol UniversalVideoDecoration: AnyObject {
|
||||
|
||||
func updateContentNode(_ contentNode: (UniversalVideoContentNode & ASDisplayNode)?)
|
||||
func updateContentNodeSnapshot(_ snapshot: UIView?)
|
||||
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition)
|
||||
func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition)
|
||||
func tap()
|
||||
}
|
||||
|
||||
@ -247,8 +247,8 @@ public final class UniversalVideoNode: ASDisplayNode {
|
||||
}
|
||||
}
|
||||
|
||||
public func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.decoration.updateLayout(size: size, transition: transition)
|
||||
public func updateLayout(size: CGSize, actualSize: CGSize? = nil, transition: ContainedViewLayoutTransition) {
|
||||
self.decoration.updateLayout(size: size, actualSize: actualSize ?? size, transition: transition)
|
||||
}
|
||||
|
||||
public func play() {
|
||||
|
@ -325,7 +325,7 @@ private final class VideoDecoration: UniversalVideoDecoration {
|
||||
|
||||
private var contentNode: (ASDisplayNode & UniversalVideoContentNode)?
|
||||
|
||||
private var validLayoutSize: CGSize?
|
||||
private var validLayout: (size: CGSize, actualSize: CGSize)?
|
||||
|
||||
public init() {
|
||||
self.contentContainerNode = ASDisplayNode()
|
||||
@ -345,9 +345,9 @@ private final class VideoDecoration: UniversalVideoDecoration {
|
||||
if let contentNode = contentNode {
|
||||
if contentNode.supernode !== self.contentContainerNode {
|
||||
self.contentContainerNode.addSubnode(contentNode)
|
||||
if let validLayoutSize = self.validLayoutSize {
|
||||
contentNode.frame = CGRect(origin: CGPoint(), size: validLayoutSize)
|
||||
contentNode.updateLayout(size: validLayoutSize, transition: .immediate)
|
||||
if let validLayout = self.validLayout {
|
||||
contentNode.frame = CGRect(origin: CGPoint(), size: validLayout.size)
|
||||
contentNode.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -405,8 +405,8 @@ private final class VideoDecoration: UniversalVideoDecoration {
|
||||
public func updateContentNodeSnapshot(_ snapshot: UIView?) {
|
||||
}
|
||||
|
||||
public func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayoutSize = size
|
||||
public func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayout = (size, actualSize)
|
||||
|
||||
let bounds = CGRect(origin: CGPoint(), size: size)
|
||||
if let backgroundNode = self.backgroundNode {
|
||||
@ -421,7 +421,7 @@ private final class VideoDecoration: UniversalVideoDecoration {
|
||||
}
|
||||
if let contentNode = self.contentNode {
|
||||
transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(), size: size))
|
||||
contentNode.updateLayout(size: size, transition: transition)
|
||||
contentNode.updateLayout(size: size, actualSize: actualSize, transition: transition)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1141,7 +1141,7 @@ private final class StickerVideoDecoration: UniversalVideoDecoration {
|
||||
|
||||
private var contentNode: (ASDisplayNode & UniversalVideoContentNode)?
|
||||
|
||||
private var validLayoutSize: CGSize?
|
||||
private var validLayout: (size: CGSize, actualSize: CGSize)?
|
||||
|
||||
public init() {
|
||||
self.contentContainerNode = ASDisplayNode()
|
||||
@ -1161,9 +1161,9 @@ private final class StickerVideoDecoration: UniversalVideoDecoration {
|
||||
if let contentNode = contentNode {
|
||||
if contentNode.supernode !== self.contentContainerNode {
|
||||
self.contentContainerNode.addSubnode(contentNode)
|
||||
if let validLayoutSize = self.validLayoutSize {
|
||||
contentNode.frame = CGRect(origin: CGPoint(), size: validLayoutSize)
|
||||
contentNode.updateLayout(size: validLayoutSize, transition: .immediate)
|
||||
if let validLayout = self.validLayout {
|
||||
contentNode.frame = CGRect(origin: CGPoint(), size: validLayout.size)
|
||||
contentNode.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1221,8 +1221,8 @@ private final class StickerVideoDecoration: UniversalVideoDecoration {
|
||||
public func updateContentNodeSnapshot(_ snapshot: UIView?) {
|
||||
}
|
||||
|
||||
public func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayoutSize = size
|
||||
public func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayout = (size, actualSize)
|
||||
|
||||
let bounds = CGRect(origin: CGPoint(), size: size)
|
||||
if let backgroundNode = self.backgroundNode {
|
||||
@ -1237,7 +1237,7 @@ private final class StickerVideoDecoration: UniversalVideoDecoration {
|
||||
}
|
||||
if let contentNode = self.contentNode {
|
||||
transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(), size: size))
|
||||
contentNode.updateLayout(size: size, transition: transition)
|
||||
contentNode.updateLayout(size: size, actualSize: actualSize, transition: transition)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -14,7 +14,7 @@ public final class GalleryVideoDecoration: UniversalVideoDecoration {
|
||||
|
||||
private var contentNode: (ASDisplayNode & UniversalVideoContentNode)?
|
||||
|
||||
private var validLayoutSize: CGSize?
|
||||
private var validLayout: (size: CGSize, actualSize: CGSize)?
|
||||
|
||||
public init() {
|
||||
self.contentContainerNode = ASDisplayNode()
|
||||
@ -34,9 +34,9 @@ public final class GalleryVideoDecoration: UniversalVideoDecoration {
|
||||
if let contentNode = contentNode {
|
||||
if contentNode.supernode !== self.contentContainerNode {
|
||||
self.contentContainerNode.addSubnode(contentNode)
|
||||
if let validLayoutSize = self.validLayoutSize {
|
||||
contentNode.frame = CGRect(origin: CGPoint(), size: validLayoutSize)
|
||||
contentNode.updateLayout(size: validLayoutSize, transition: .immediate)
|
||||
if let validLayout = self.validLayout {
|
||||
contentNode.frame = CGRect(origin: CGPoint(), size: validLayout.size)
|
||||
contentNode.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -94,8 +94,8 @@ public final class GalleryVideoDecoration: UniversalVideoDecoration {
|
||||
public func updateContentNodeSnapshot(_ snapshot: UIView?) {
|
||||
}
|
||||
|
||||
public func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayoutSize = size
|
||||
public func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayout = (size, actualSize)
|
||||
|
||||
let bounds = CGRect(origin: CGPoint(), size: size)
|
||||
if let backgroundNode = self.backgroundNode {
|
||||
@ -110,7 +110,7 @@ public final class GalleryVideoDecoration: UniversalVideoDecoration {
|
||||
}
|
||||
if let contentNode = self.contentNode {
|
||||
transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(), size: size))
|
||||
contentNode.updateLayout(size: size, transition: transition)
|
||||
contentNode.updateLayout(size: size, actualSize: actualSize, transition: transition)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1186,7 +1186,8 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
|
||||
videoScale = 2.0
|
||||
}
|
||||
let videoSize = CGSize(width: item.content.dimensions.width * videoScale, height: item.content.dimensions.height * videoScale)
|
||||
videoNode.updateLayout(size: videoSize, transition: .immediate)
|
||||
let actualVideoSize = CGSize(width: item.content.dimensions.width, height: item.content.dimensions.height)
|
||||
videoNode.updateLayout(size: videoSize, actualSize: actualVideoSize, transition: .immediate)
|
||||
videoNode.ownsContentNodeUpdated = { [weak self] value in
|
||||
if let strongSelf = self {
|
||||
strongSelf.updateDisplayPlaceholder(!value)
|
||||
|
@ -626,7 +626,7 @@ private final class VideoDecoration: UniversalVideoDecoration {
|
||||
|
||||
private var contentNode: (ASDisplayNode & UniversalVideoContentNode)?
|
||||
|
||||
private var validLayoutSize: CGSize?
|
||||
private var validLayout: (size: CGSize, actualSize: CGSize)?
|
||||
|
||||
public init() {
|
||||
self.contentContainerNode = ASDisplayNode()
|
||||
@ -646,9 +646,9 @@ private final class VideoDecoration: UniversalVideoDecoration {
|
||||
if let contentNode = contentNode {
|
||||
if contentNode.supernode !== self.contentContainerNode {
|
||||
self.contentContainerNode.addSubnode(contentNode)
|
||||
if let validLayoutSize = self.validLayoutSize {
|
||||
contentNode.frame = CGRect(origin: CGPoint(), size: validLayoutSize)
|
||||
contentNode.updateLayout(size: validLayoutSize, transition: .immediate)
|
||||
if let validLayout = self.validLayout {
|
||||
contentNode.frame = CGRect(origin: CGPoint(), size: validLayout.size)
|
||||
contentNode.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -706,8 +706,8 @@ private final class VideoDecoration: UniversalVideoDecoration {
|
||||
public func updateContentNodeSnapshot(_ snapshot: UIView?) {
|
||||
}
|
||||
|
||||
public func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayoutSize = size
|
||||
public func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayout = (size, actualSize)
|
||||
|
||||
let bounds = CGRect(origin: CGPoint(), size: size)
|
||||
if let backgroundNode = self.backgroundNode {
|
||||
@ -722,7 +722,7 @@ private final class VideoDecoration: UniversalVideoDecoration {
|
||||
}
|
||||
if let contentNode = self.contentNode {
|
||||
transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(), size: size))
|
||||
contentNode.updateLayout(size: size, transition: transition)
|
||||
contentNode.updateLayout(size: size, actualSize: actualSize, transition: transition)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -511,7 +511,7 @@ private final class VideoDecoration: UniversalVideoDecoration {
|
||||
|
||||
private var contentNode: (ASDisplayNode & UniversalVideoContentNode)?
|
||||
|
||||
private var validLayoutSize: CGSize?
|
||||
private var validLayout: (size: CGSize, actualSize: CGSize)?
|
||||
|
||||
public init() {
|
||||
self.contentContainerNode = ASDisplayNode()
|
||||
@ -531,9 +531,9 @@ private final class VideoDecoration: UniversalVideoDecoration {
|
||||
if let contentNode = contentNode {
|
||||
if contentNode.supernode !== self.contentContainerNode {
|
||||
self.contentContainerNode.addSubnode(contentNode)
|
||||
if let validLayoutSize = self.validLayoutSize {
|
||||
contentNode.frame = CGRect(origin: CGPoint(), size: validLayoutSize)
|
||||
contentNode.updateLayout(size: validLayoutSize, transition: .immediate)
|
||||
if let validLayout = self.validLayout {
|
||||
contentNode.frame = CGRect(origin: CGPoint(), size: validLayout.size)
|
||||
contentNode.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -591,8 +591,8 @@ private final class VideoDecoration: UniversalVideoDecoration {
|
||||
public func updateContentNodeSnapshot(_ snapshot: UIView?) {
|
||||
}
|
||||
|
||||
public func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayoutSize = size
|
||||
public func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayout = (size, actualSize)
|
||||
|
||||
let bounds = CGRect(origin: CGPoint(), size: size)
|
||||
if let backgroundNode = self.backgroundNode {
|
||||
@ -607,7 +607,7 @@ private final class VideoDecoration: UniversalVideoDecoration {
|
||||
}
|
||||
if let contentNode = self.contentNode {
|
||||
transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(), size: size))
|
||||
contentNode.updateLayout(size: size, transition: transition)
|
||||
contentNode.updateLayout(size: size, actualSize: actualSize, transition: transition)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -112,13 +112,14 @@ private final class InteractiveTextNodeLine {
|
||||
let isTruncated: Bool
|
||||
let isRTL: Bool
|
||||
var strikethroughs: [InteractiveTextNodeStrikethrough]
|
||||
var underlines: [InteractiveTextNodeStrikethrough]
|
||||
var spoilers: [InteractiveTextNodeSpoiler]
|
||||
var spoilerWords: [InteractiveTextNodeSpoiler]
|
||||
var embeddedItems: [InteractiveTextNodeEmbeddedItem]
|
||||
var attachments: [InteractiveTextNodeAttachment]
|
||||
let additionalTrailingLine: (CTLine, Double)?
|
||||
|
||||
init(line: CTLine, constrainedWidth: CGFloat, frame: CGRect, intrinsicWidth: CGFloat, ascent: CGFloat, descent: CGFloat, range: NSRange?, isTruncated: Bool, isRTL: Bool, strikethroughs: [InteractiveTextNodeStrikethrough], spoilers: [InteractiveTextNodeSpoiler], spoilerWords: [InteractiveTextNodeSpoiler], embeddedItems: [InteractiveTextNodeEmbeddedItem], attachments: [InteractiveTextNodeAttachment], additionalTrailingLine: (CTLine, Double)?) {
|
||||
init(line: CTLine, constrainedWidth: CGFloat, frame: CGRect, intrinsicWidth: CGFloat, ascent: CGFloat, descent: CGFloat, range: NSRange?, isTruncated: Bool, isRTL: Bool, strikethroughs: [InteractiveTextNodeStrikethrough], underlines: [InteractiveTextNodeStrikethrough], spoilers: [InteractiveTextNodeSpoiler], spoilerWords: [InteractiveTextNodeSpoiler], embeddedItems: [InteractiveTextNodeEmbeddedItem], attachments: [InteractiveTextNodeAttachment], additionalTrailingLine: (CTLine, Double)?) {
|
||||
self.line = line
|
||||
self.constrainedWidth = constrainedWidth
|
||||
self.frame = frame
|
||||
@ -129,6 +130,7 @@ private final class InteractiveTextNodeLine {
|
||||
self.isTruncated = isTruncated
|
||||
self.isRTL = isRTL
|
||||
self.strikethroughs = strikethroughs
|
||||
self.underlines = underlines
|
||||
self.spoilers = spoilers
|
||||
self.spoilerWords = spoilerWords
|
||||
self.embeddedItems = embeddedItems
|
||||
@ -1452,6 +1454,7 @@ open class InteractiveTextNode: ASDisplayNode, TextNodeProtocol, UIGestureRecogn
|
||||
isTruncated: false,
|
||||
isRTL: false,
|
||||
strikethroughs: [],
|
||||
underlines: [],
|
||||
spoilers: [],
|
||||
spoilerWords: [],
|
||||
embeddedItems: [],
|
||||
@ -1493,6 +1496,7 @@ open class InteractiveTextNode: ASDisplayNode, TextNodeProtocol, UIGestureRecogn
|
||||
isTruncated: false,
|
||||
isRTL: isRTL && segment.blockQuote == nil,
|
||||
strikethroughs: [],
|
||||
underlines: [],
|
||||
spoilers: [],
|
||||
spoilerWords: [],
|
||||
embeddedItems: [],
|
||||
@ -1551,6 +1555,7 @@ open class InteractiveTextNode: ASDisplayNode, TextNodeProtocol, UIGestureRecogn
|
||||
isTruncated: true,
|
||||
isRTL: lastLine.isRTL,
|
||||
strikethroughs: [],
|
||||
underlines: [],
|
||||
spoilers: [],
|
||||
spoilerWords: [],
|
||||
embeddedItems: [],
|
||||
@ -1605,6 +1610,7 @@ open class InteractiveTextNode: ASDisplayNode, TextNodeProtocol, UIGestureRecogn
|
||||
isTruncated: true,
|
||||
isRTL: lastLine.isRTL,
|
||||
strikethroughs: [],
|
||||
underlines: [],
|
||||
spoilers: [],
|
||||
spoilerWords: [],
|
||||
embeddedItems: [],
|
||||
@ -1736,6 +1742,11 @@ open class InteractiveTextNode: ASDisplayNode, TextNodeProtocol, UIGestureRecogn
|
||||
let upperX = ceil(CTLineGetOffsetForStringIndex(line.line, range.location + range.length, nil))
|
||||
let x = lowerX < upperX ? lowerX : upperX
|
||||
line.strikethroughs.append(InteractiveTextNodeStrikethrough(range: range, frame: CGRect(x: x, y: 0.0, width: abs(upperX - lowerX), height: line.frame.height)))
|
||||
} else if let _ = attributes[NSAttributedString.Key.underlineStyle] {
|
||||
let lowerX = floor(CTLineGetOffsetForStringIndex(line.line, range.location, nil))
|
||||
let upperX = ceil(CTLineGetOffsetForStringIndex(line.line, range.location + range.length, nil))
|
||||
let x = lowerX < upperX ? lowerX : upperX
|
||||
line.underlines.append(InteractiveTextNodeStrikethrough(range: range, frame: CGRect(x: x, y: 0.0, width: abs(upperX - lowerX), height: line.frame.height)))
|
||||
}
|
||||
|
||||
if let embeddedItem = (attributes[NSAttributedString.Key(rawValue: "TelegramEmbeddedItem")] as? AnyHashable ?? attributes[NSAttributedString.Key(rawValue: "Attribute__EmbeddedItem")] as? AnyHashable) {
|
||||
@ -2090,6 +2101,14 @@ final class TextContentItem {
|
||||
}
|
||||
}
|
||||
|
||||
private let drawUnderlinesManually: Bool = {
|
||||
if #available(iOS 18.0, *) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}()
|
||||
|
||||
final class TextContentItemLayer: SimpleLayer {
|
||||
final class Params {
|
||||
let item: TextContentItem
|
||||
@ -2322,6 +2341,46 @@ final class TextContentItemLayer: SimpleLayer {
|
||||
}
|
||||
}
|
||||
|
||||
if drawUnderlinesManually {
|
||||
if !line.strikethroughs.isEmpty {
|
||||
for strikethrough in line.strikethroughs {
|
||||
guard let lineRange = line.range else {
|
||||
continue
|
||||
}
|
||||
var textColor: UIColor?
|
||||
params.item.attributedString?.enumerateAttributes(in: NSMakeRange(lineRange.location, lineRange.length), options: []) { attributes, range, _ in
|
||||
if range == strikethrough.range, let color = attributes[NSAttributedString.Key.foregroundColor] as? UIColor {
|
||||
textColor = color
|
||||
}
|
||||
}
|
||||
if let textColor = textColor {
|
||||
context.setFillColor(textColor.cgColor)
|
||||
}
|
||||
let frame = strikethrough.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY)
|
||||
context.fill(CGRect(x: frame.minX, y: frame.midY, width: frame.width, height: 1.0))
|
||||
}
|
||||
}
|
||||
|
||||
if !line.underlines.isEmpty {
|
||||
for strikethrough in line.underlines {
|
||||
guard let lineRange = line.range else {
|
||||
continue
|
||||
}
|
||||
var textColor: UIColor?
|
||||
params.item.attributedString?.enumerateAttributes(in: NSMakeRange(lineRange.location, lineRange.length), options: []) { attributes, range, _ in
|
||||
if range == strikethrough.range, let color = attributes[NSAttributedString.Key.foregroundColor] as? UIColor {
|
||||
textColor = color
|
||||
}
|
||||
}
|
||||
if let textColor = textColor {
|
||||
context.setFillColor(textColor.cgColor)
|
||||
}
|
||||
let frame = strikethrough.frame.offsetBy(dx: lineFrame.minX, dy: lineFrame.minY)
|
||||
context.fill(CGRect(x: frame.minX, y: frame.maxY - 2.0, width: frame.width, height: 1.0))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let (additionalTrailingLine, _) = line.additionalTrailingLine {
|
||||
context.textPosition = CGPoint(x: lineFrame.minX + line.intrinsicWidth, y: lineFrame.maxY - line.descent)
|
||||
|
||||
|
@ -14,7 +14,7 @@ public final class StoryVideoDecoration: UniversalVideoDecoration {
|
||||
|
||||
private var contentNode: (ASDisplayNode & UniversalVideoContentNode)?
|
||||
|
||||
private var validLayoutSize: CGSize?
|
||||
private var validLayout: (size: CGSize, actualSize: CGSize)?
|
||||
|
||||
public init() {
|
||||
self.contentContainerNode = ASDisplayNode()
|
||||
@ -34,9 +34,9 @@ public final class StoryVideoDecoration: UniversalVideoDecoration {
|
||||
if let contentNode = contentNode {
|
||||
if contentNode.supernode !== self.contentContainerNode {
|
||||
self.contentContainerNode.addSubnode(contentNode)
|
||||
if let validLayoutSize = self.validLayoutSize {
|
||||
contentNode.frame = CGRect(origin: CGPoint(), size: validLayoutSize)
|
||||
contentNode.updateLayout(size: validLayoutSize, transition: .immediate)
|
||||
if let validLayout = self.validLayout {
|
||||
contentNode.frame = CGRect(origin: CGPoint(), size: validLayout.size)
|
||||
contentNode.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -94,8 +94,8 @@ public final class StoryVideoDecoration: UniversalVideoDecoration {
|
||||
public func updateContentNodeSnapshot(_ snapshot: UIView?) {
|
||||
}
|
||||
|
||||
public func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayoutSize = size
|
||||
public func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayout = (size, actualSize)
|
||||
|
||||
let bounds = CGRect(origin: CGPoint(), size: size)
|
||||
if let backgroundNode = self.backgroundNode {
|
||||
@ -110,7 +110,7 @@ public final class StoryVideoDecoration: UniversalVideoDecoration {
|
||||
}
|
||||
if let contentNode = self.contentNode {
|
||||
transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(), size: size))
|
||||
contentNode.updateLayout(size: size, transition: transition)
|
||||
contentNode.updateLayout(size: size, actualSize: actualSize, transition: transition)
|
||||
}
|
||||
}
|
||||
|
||||
|
171
submodules/TelegramUI/Resources/WebEmbed/HLSVideoPlayer.html
Executable file
171
submodules/TelegramUI/Resources/WebEmbed/HLSVideoPlayer.html
Executable file
@ -0,0 +1,171 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
#videoPlayer {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
object-fit: fill;
|
||||
}
|
||||
</style>
|
||||
<script src="hls.js"></script>
|
||||
<script type="text/javascript">
|
||||
function postPlayerEvent(eventName, eventData) {
|
||||
window.webkit.messageHandlers.performAction.postMessage({'event': eventName, 'data': eventData});
|
||||
};
|
||||
|
||||
var video;
|
||||
var hls;
|
||||
|
||||
var isManifestParsed = false;
|
||||
var isFirstFrameReady = false;
|
||||
|
||||
var currentTimeUpdateTimeout = null;
|
||||
|
||||
function playerInitialize(params) {
|
||||
video = document.getElementById('videoPlayer');
|
||||
|
||||
video.addEventListener('loadeddata', (event) => {
|
||||
if (!isFirstFrameReady) {
|
||||
isFirstFrameReady = true;
|
||||
refreshPlayerStatus();
|
||||
}
|
||||
});
|
||||
video.addEventListener("playing", function() {
|
||||
refreshPlayerStatus();
|
||||
});
|
||||
video.addEventListener("pause", function() {
|
||||
refreshPlayerStatus();
|
||||
});
|
||||
video.addEventListener("seeking", function() {
|
||||
refreshPlayerStatus();
|
||||
});
|
||||
video.addEventListener("waiting", function() {
|
||||
refreshPlayerStatus();
|
||||
});
|
||||
|
||||
hls = new Hls({
|
||||
startLevel: 0,
|
||||
testBandwidth: false,
|
||||
debug: params['debug'],
|
||||
autoStartLoad: false
|
||||
});
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, function() {
|
||||
isManifestParsed = true;
|
||||
refreshPlayerStatus();
|
||||
});
|
||||
|
||||
hls.on(Hls.Events.LEVEL_SWITCHED, function() {
|
||||
refreshPlayerStatus();
|
||||
});
|
||||
hls.on(Hls.Events.LEVELS_UPDATED, function() {
|
||||
refreshPlayerStatus();
|
||||
});
|
||||
|
||||
hls.loadSource('master.m3u8');
|
||||
hls.attachMedia(video);
|
||||
}
|
||||
|
||||
function playerLoad(initialLevelIndex) {
|
||||
hls.startLevel = initialLevelIndex;
|
||||
hls.startLoad(startPosition=-1);
|
||||
}
|
||||
|
||||
function playerPlay() {
|
||||
video.play();
|
||||
}
|
||||
|
||||
function playerPause() {
|
||||
video.pause();
|
||||
}
|
||||
|
||||
function playerSetBaseRate(value) {
|
||||
video.playbackRate = value;
|
||||
}
|
||||
|
||||
function playerSetLevel(level) {
|
||||
if (level >= 0) {
|
||||
hls.autoLevelEnabled = false;
|
||||
hls.currentLevel = level;
|
||||
} else {
|
||||
hls.autoLevelEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
function playerSeek(value) {
|
||||
video.currentTime = value;
|
||||
}
|
||||
|
||||
function playerSetIsMuted(value) {
|
||||
video.muted = value;
|
||||
}
|
||||
|
||||
function getLevels() {
|
||||
var levels = [];
|
||||
for (var i = 0; i < hls.levels.length; i++) {
|
||||
level = hls.levels[i];
|
||||
levels.push({
|
||||
'index': i,
|
||||
'bitrate': level.bitrate || 0,
|
||||
'width': level.width || 0,
|
||||
'height': level.height || 0
|
||||
});
|
||||
}
|
||||
return levels;
|
||||
}
|
||||
|
||||
function refreshPlayerStatus() {
|
||||
var isPlaying = false;
|
||||
if (!video.paused && !video.ended && video.readyState > 2) {
|
||||
isPlaying = true;
|
||||
}
|
||||
|
||||
postPlayerEvent('playerStatus', {
|
||||
'isReady': isManifestParsed,
|
||||
'isFirstFrameReady': isFirstFrameReady,
|
||||
'isPlaying': !video.paused,
|
||||
'rate': isPlaying ? video.playbackRate : 0.0,
|
||||
'defaultRate': video.playbackRate,
|
||||
'levels': getLevels(),
|
||||
'currentLevel': hls.currentLevel
|
||||
});
|
||||
|
||||
refreshPlayerCurrentTime();
|
||||
|
||||
if (isPlaying) {
|
||||
if (currentTimeUpdateTimeout == null) {
|
||||
currentTimeUpdateTimeout = setTimeout(() => {
|
||||
refreshPlayerCurrentTime();
|
||||
}, 200);
|
||||
}
|
||||
} else {
|
||||
if(currentTimeUpdateTimeout != null){
|
||||
clearTimeout(currentTimeUpdateTimeout);
|
||||
currentTimeUpdateTimeout = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refreshPlayerCurrentTime() {
|
||||
postPlayerEvent('playerCurrentTime', {
|
||||
'value': video.currentTime
|
||||
});
|
||||
currentTimeUpdateTimeout = setTimeout(() => {
|
||||
refreshPlayerCurrentTime()
|
||||
}, 200);
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<video id="videoPlayer" playsinline></video>
|
||||
</body>
|
||||
</html>
|
29290
submodules/TelegramUI/Resources/WebEmbed/hls.js
Normal file
29290
submodules/TelegramUI/Resources/WebEmbed/hls.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -24,7 +24,7 @@ final class OverlayInstantVideoDecoration: UniversalVideoDecoration {
|
||||
private var contentNode: (ASDisplayNode & UniversalVideoContentNode)?
|
||||
private var contentNodeSnapshot: UIView?
|
||||
|
||||
private var validLayoutSize: CGSize?
|
||||
private var validLayout: (size: CGSize, actualSize: CGSize)?
|
||||
|
||||
init(tapped: @escaping () -> Void) {
|
||||
self.tapped = tapped
|
||||
@ -58,9 +58,9 @@ final class OverlayInstantVideoDecoration: UniversalVideoDecoration {
|
||||
self.progressNode.status = contentNode.status
|
||||
if contentNode.supernode !== self.contentContainerNode {
|
||||
self.contentContainerNode.addSubnode(contentNode)
|
||||
if let validLayoutSize = self.validLayoutSize {
|
||||
contentNode.frame = CGRect(origin: CGPoint(), size: validLayoutSize)
|
||||
contentNode.updateLayout(size: validLayoutSize, transition: .immediate)
|
||||
if let validLayout = self.validLayout {
|
||||
contentNode.frame = CGRect(origin: CGPoint(), size: validLayout.size)
|
||||
contentNode.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -74,15 +74,15 @@ final class OverlayInstantVideoDecoration: UniversalVideoDecoration {
|
||||
|
||||
if let snapshot = snapshot {
|
||||
self.contentContainerNode.view.addSubview(snapshot)
|
||||
if let _ = self.validLayoutSize {
|
||||
if let _ = self.validLayout {
|
||||
snapshot.frame = CGRect(origin: CGPoint(), size: snapshot.frame.size)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayoutSize = size
|
||||
func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayout = (size, actualSize)
|
||||
|
||||
self.contentContainerNode.cornerRadius = size.width / 2.0
|
||||
|
||||
@ -96,7 +96,7 @@ final class OverlayInstantVideoDecoration: UniversalVideoDecoration {
|
||||
transition.updateFrame(node: self.contentContainerNode, frame: CGRect(origin: CGPoint(), size: size))
|
||||
if let contentNode = self.contentNode {
|
||||
transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(), size: size).insetBy(dx: -0.5, dy: -0.5))
|
||||
contentNode.updateLayout(size: size, transition: transition)
|
||||
contentNode.updateLayout(size: size, actualSize: actualSize, transition: transition)
|
||||
}
|
||||
|
||||
if let contentNodeSnapshot = self.contentNodeSnapshot {
|
||||
|
@ -16,7 +16,7 @@ public final class ChatBubbleInstantVideoDecoration: UniversalVideoDecoration {
|
||||
private var contentNode: (ASDisplayNode & UniversalVideoContentNode)?
|
||||
private let inset: CGFloat
|
||||
|
||||
private var validLayoutSize: CGSize?
|
||||
private var validLayout: (size: CGSize, actualSize: CGSize)?
|
||||
|
||||
public init(inset: CGFloat, backgroundImage: UIImage?, tapped: @escaping () -> Void) {
|
||||
self.inset = inset
|
||||
@ -51,9 +51,9 @@ public final class ChatBubbleInstantVideoDecoration: UniversalVideoDecoration {
|
||||
if let contentNode = contentNode {
|
||||
if contentNode.supernode !== self.contentContainerNode {
|
||||
self.contentContainerNode.addSubnode(contentNode)
|
||||
if let validLayoutSize = self.validLayoutSize {
|
||||
contentNode.frame = CGRect(origin: CGPoint(), size: validLayoutSize)
|
||||
contentNode.updateLayout(size: validLayoutSize, transition: .immediate)
|
||||
if let validLayout = self.validLayout {
|
||||
contentNode.frame = CGRect(origin: CGPoint(), size: validLayout.size)
|
||||
contentNode.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -63,8 +63,8 @@ public final class ChatBubbleInstantVideoDecoration: UniversalVideoDecoration {
|
||||
public func updateContentNodeSnapshot(_ snapshot: UIView?) {
|
||||
}
|
||||
|
||||
public func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayoutSize = size
|
||||
public func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayout = (size, actualSize)
|
||||
|
||||
let diameter = size.width + inset
|
||||
self.contentContainerNode.cornerRadius = (diameter - 3.0) / 2.0
|
||||
@ -80,7 +80,7 @@ public final class ChatBubbleInstantVideoDecoration: UniversalVideoDecoration {
|
||||
self.contentContainerNode.subnodeTransform = CATransform3DMakeScale((contentFrame.width + 2.0) / contentFrame.width, (contentFrame.width + 2.0) / contentFrame.width, 1.0)
|
||||
if let contentNode = self.contentNode {
|
||||
transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(), size: size))
|
||||
contentNode.updateLayout(size: size, transition: transition)
|
||||
contentNode.updateLayout(size: size, actualSize: actualSize, transition: transition)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -23,7 +23,7 @@ public final class ChatBubbleVideoDecoration: UniversalVideoDecoration {
|
||||
|
||||
private var contentNode: (ASDisplayNode & UniversalVideoContentNode)?
|
||||
|
||||
private var validLayoutSize: CGSize?
|
||||
private var validLayout: (size: CGSize, actualSize: CGSize)?
|
||||
|
||||
public init(corners: ImageCorners, nativeSize: CGSize, contentMode: ChatBubbleVideoDecorationContentMode, backgroundColor: UIColor) {
|
||||
self.corners = corners
|
||||
@ -82,23 +82,23 @@ public final class ChatBubbleVideoDecoration: UniversalVideoDecoration {
|
||||
if let contentNode = contentNode {
|
||||
if contentNode.supernode !== self.contentContainerNode {
|
||||
self.contentContainerNode.addSubnode(contentNode)
|
||||
if let size = self.validLayoutSize {
|
||||
if let validLayout = self.validLayout {
|
||||
var scaledSize: CGSize
|
||||
switch self.contentMode {
|
||||
case .aspectFit:
|
||||
scaledSize = self.nativeSize.aspectFitted(size)
|
||||
scaledSize = self.nativeSize.aspectFitted(validLayout.size)
|
||||
case .aspectFill:
|
||||
scaledSize = self.nativeSize.aspectFilled(size)
|
||||
scaledSize = self.nativeSize.aspectFilled(validLayout.size)
|
||||
}
|
||||
if abs(scaledSize.width - size.width) < 2.0 {
|
||||
scaledSize.width = size.width
|
||||
if abs(scaledSize.width - validLayout.size.width) < 2.0 {
|
||||
scaledSize.width = validLayout.size.width
|
||||
}
|
||||
if abs(scaledSize.height - size.height) < 2.0 {
|
||||
scaledSize.height = size.height
|
||||
if abs(scaledSize.height - validLayout.size.height) < 2.0 {
|
||||
scaledSize.height = validLayout.size.height
|
||||
}
|
||||
|
||||
contentNode.frame = CGRect(origin: CGPoint(x: floor((size.width - scaledSize.width) / 2.0), y: floor((size.height - scaledSize.height) / 2.0)), size: scaledSize)
|
||||
contentNode.updateLayout(size: scaledSize, transition: .immediate)
|
||||
contentNode.frame = CGRect(origin: CGPoint(x: floor((validLayout.size.width - scaledSize.width) / 2.0), y: floor((validLayout.size.height - scaledSize.height) / 2.0)), size: scaledSize)
|
||||
contentNode.updateLayout(size: scaledSize, actualSize: scaledSize, transition: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -108,8 +108,8 @@ public final class ChatBubbleVideoDecoration: UniversalVideoDecoration {
|
||||
public func updateContentNodeSnapshot(_ snapshot: UIView?) {
|
||||
}
|
||||
|
||||
public func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayoutSize = size
|
||||
public func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayout = (size, actualSize)
|
||||
|
||||
let bounds = CGRect(origin: CGPoint(), size: size)
|
||||
if let backgroundNode = self.backgroundNode {
|
||||
@ -137,7 +137,7 @@ public final class ChatBubbleVideoDecoration: UniversalVideoDecoration {
|
||||
scaledSize.height = size.height
|
||||
}
|
||||
transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(x: floor((size.width - scaledSize.width) / 2.0), y: floor((size.height - scaledSize.height) / 2.0)), size: scaledSize))
|
||||
contentNode.updateLayout(size: scaledSize, transition: transition)
|
||||
contentNode.updateLayout(size: scaledSize, actualSize: scaledSize, transition: transition)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -13,8 +13,70 @@ import PhotoResources
|
||||
import RangeSet
|
||||
import TelegramVoip
|
||||
import ManagedFile
|
||||
import WebKit
|
||||
import AppBundle
|
||||
|
||||
public final class HLSQualitySet {
|
||||
public let qualityFiles: [Int: FileMediaReference]
|
||||
public let playlistFiles: [Int: FileMediaReference]
|
||||
|
||||
public init?(baseFile: FileMediaReference) {
|
||||
var qualityFiles: [Int: FileMediaReference] = [:]
|
||||
for alternativeRepresentation in baseFile.media.alternativeRepresentations {
|
||||
if let alternativeFile = alternativeRepresentation as? TelegramMediaFile {
|
||||
for attribute in alternativeFile.attributes {
|
||||
if case let .Video(_, size, _, _, _, videoCodec) = attribute {
|
||||
let _ = size
|
||||
if let videoCodec, NativeVideoContent.isVideoCodecSupported(videoCodec: videoCodec) {
|
||||
qualityFiles[Int(size.height)] = baseFile.withMedia(alternativeFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var playlistFiles: [Int: FileMediaReference] = [:]
|
||||
for alternativeRepresentation in baseFile.media.alternativeRepresentations {
|
||||
if let alternativeFile = alternativeRepresentation as? TelegramMediaFile {
|
||||
if alternativeFile.mimeType == "application/x-mpegurl" {
|
||||
if let fileName = alternativeFile.fileName {
|
||||
if fileName.hasPrefix("mtproto:") {
|
||||
let fileIdString = String(fileName[fileName.index(fileName.startIndex, offsetBy: "mtproto:".count)...])
|
||||
if let fileId = Int64(fileIdString) {
|
||||
for (quality, file) in qualityFiles {
|
||||
if file.media.fileId.id == fileId {
|
||||
playlistFiles[quality] = baseFile.withMedia(alternativeFile)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !playlistFiles.isEmpty && playlistFiles.keys == qualityFiles.keys {
|
||||
self.qualityFiles = qualityFiles
|
||||
self.playlistFiles = playlistFiles
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public final class HLSVideoContent: UniversalVideoContent {
|
||||
public static func minimizedHLSQualityFile(file: FileMediaReference) -> FileMediaReference? {
|
||||
guard let qualitySet = HLSQualitySet(baseFile: file) else {
|
||||
return nil
|
||||
}
|
||||
for (quality, qualityFile) in qualitySet.qualityFiles.sorted(by: { $0.key < $1.key }) {
|
||||
if quality >= 400 {
|
||||
return qualityFile
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
public let id: AnyHashable
|
||||
public let nativeId: PlatformVideoContentId
|
||||
let userLocation: MediaResourceUserLocation
|
||||
@ -42,7 +104,11 @@ public final class HLSVideoContent: UniversalVideoContent {
|
||||
}
|
||||
|
||||
public func makeContentNode(accountId: AccountRecordId, postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode {
|
||||
return HLSVideoContentNode(accountId: accountId, postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, fileReference: self.fileReference, streamVideo: self.streamVideo, loopVideo: self.loopVideo, enableSound: self.enableSound, baseRate: self.baseRate, fetchAutomatically: self.fetchAutomatically)
|
||||
if #available(iOS 17.1, *) {
|
||||
return HLSVideoJSContentNode(accountId: accountId, postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, fileReference: self.fileReference, streamVideo: self.streamVideo, loopVideo: self.loopVideo, enableSound: self.enableSound, baseRate: self.baseRate, fetchAutomatically: self.fetchAutomatically)
|
||||
} else {
|
||||
return HLSVideoAVContentNode(accountId: accountId, postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, fileReference: self.fileReference, streamVideo: self.streamVideo, loopVideo: self.loopVideo, enableSound: self.enableSound, baseRate: self.baseRate, fetchAutomatically: self.fetchAutomatically)
|
||||
}
|
||||
}
|
||||
|
||||
public func isEqual(to other: UniversalVideoContent) -> Bool {
|
||||
@ -59,8 +125,7 @@ public final class HLSVideoContent: UniversalVideoContent {
|
||||
}
|
||||
}
|
||||
|
||||
private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNode {
|
||||
private final class HLSServerSource: SharedHLSServer.Source {
|
||||
private final class HLSServerSource: SharedHLSServer.Source {
|
||||
let id: String
|
||||
let postbox: Postbox
|
||||
let userLocation: MediaResourceUserLocation
|
||||
@ -83,6 +148,30 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod
|
||||
}
|
||||
}
|
||||
|
||||
func arbitraryFileData(path: String) -> Signal<(data: Data, contentType: String)?, NoError> {
|
||||
return Signal { subscriber in
|
||||
if path == "index.html" {
|
||||
if let path = getAppBundle().path(forResource: "HLSVideoPlayer", ofType: "html"), let data = try? Data(contentsOf: URL(fileURLWithPath: path)) {
|
||||
subscriber.putNext((data, "text/html"))
|
||||
} else {
|
||||
subscriber.putNext(nil)
|
||||
}
|
||||
} else if path == "hls.js" {
|
||||
if let path = getAppBundle().path(forResource: "hls", ofType: "js"), let data = try? Data(contentsOf: URL(fileURLWithPath: path)) {
|
||||
subscriber.putNext((data, "application/javascript"))
|
||||
} else {
|
||||
subscriber.putNext(nil)
|
||||
}
|
||||
} else {
|
||||
subscriber.putNext(nil)
|
||||
}
|
||||
|
||||
subscriber.putCompletion()
|
||||
|
||||
return EmptyDisposable
|
||||
}
|
||||
}
|
||||
|
||||
func masterPlaylistData() -> Signal<String, NoError> {
|
||||
var playlistString: String = ""
|
||||
playlistString.append("#EXTM3U\n")
|
||||
@ -233,8 +322,9 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod
|
||||
}
|
||||
|> runOn(queue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class HLSVideoAVContentNode: ASDisplayNode, UniversalVideoContentNode {
|
||||
private let postbox: Postbox
|
||||
private let userLocation: MediaResourceUserLocation
|
||||
private let fileReference: FileMediaReference
|
||||
@ -296,7 +386,7 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod
|
||||
private var dimensions: CGSize?
|
||||
private let dimensionsPromise = ValuePromise<CGSize>(CGSize())
|
||||
|
||||
private var validLayout: CGSize?
|
||||
private var validLayout: (size: CGSize, actualSize: CGSize)?
|
||||
|
||||
private var statusTimer: Foundation.Timer?
|
||||
|
||||
@ -344,46 +434,8 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod
|
||||
|
||||
self.playerNode.frame = CGRect(origin: CGPoint(), size: self.intrinsicDimensions)
|
||||
|
||||
var qualityFiles: [Int: FileMediaReference] = [:]
|
||||
for alternativeRepresentation in fileReference.media.alternativeRepresentations {
|
||||
if let alternativeFile = alternativeRepresentation as? TelegramMediaFile {
|
||||
for attribute in alternativeFile.attributes {
|
||||
if case let .Video(_, size, _, _, _, videoCodec) = attribute {
|
||||
let _ = size
|
||||
if let videoCodec, NativeVideoContent.isVideoCodecSupported(videoCodec: videoCodec) {
|
||||
qualityFiles[Int(size.height)] = fileReference.withMedia(alternativeFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/*for key in Array(qualityFiles.keys) {
|
||||
if key != 144 && key != 720 {
|
||||
qualityFiles.removeValue(forKey: key)
|
||||
}
|
||||
}*/
|
||||
var playlistFiles: [Int: FileMediaReference] = [:]
|
||||
for alternativeRepresentation in fileReference.media.alternativeRepresentations {
|
||||
if let alternativeFile = alternativeRepresentation as? TelegramMediaFile {
|
||||
if alternativeFile.mimeType == "application/x-mpegurl" {
|
||||
if let fileName = alternativeFile.fileName {
|
||||
if fileName.hasPrefix("mtproto:") {
|
||||
let fileIdString = String(fileName[fileName.index(fileName.startIndex, offsetBy: "mtproto:".count)...])
|
||||
if let fileId = Int64(fileIdString) {
|
||||
for (quality, file) in qualityFiles {
|
||||
if file.media.fileId.id == fileId {
|
||||
playlistFiles[quality] = fileReference.withMedia(alternativeFile)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !playlistFiles.isEmpty && playlistFiles.keys == qualityFiles.keys {
|
||||
self.playerSource = HLSServerSource(accountId: accountId.int64, fileId: fileReference.media.fileId.id, postbox: postbox, userLocation: userLocation, playlistFiles: playlistFiles, qualityFiles: qualityFiles)
|
||||
if let qualitySet = HLSQualitySet(baseFile: fileReference) {
|
||||
self.playerSource = HLSServerSource(accountId: accountId.int64, fileId: fileReference.media.fileId.id, postbox: postbox, userLocation: userLocation, playlistFiles: qualitySet.playlistFiles, qualityFiles: qualitySet.qualityFiles)
|
||||
}
|
||||
|
||||
super.init()
|
||||
@ -394,8 +446,8 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod
|
||||
if let dimensions = getSize() {
|
||||
strongSelf.dimensions = dimensions
|
||||
strongSelf.dimensionsPromise.set(dimensions)
|
||||
if let size = strongSelf.validLayout {
|
||||
strongSelf.updateLayout(size: size, transition: .immediate)
|
||||
if let validLayout = strongSelf.validLayout {
|
||||
strongSelf.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -425,7 +477,7 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod
|
||||
let playerItem: AVPlayerItem
|
||||
let assetUrl = "http://127.0.0.1:\(SharedHLSServer.shared.port)/\(playerSource.id)/master.m3u8"
|
||||
#if DEBUG
|
||||
print("HLSVideoContentNode: playing \(assetUrl)")
|
||||
print("HLSVideoAVContentNode: playing \(assetUrl)")
|
||||
#endif
|
||||
playerItem = AVPlayerItem(url: URL(string: assetUrl)!)
|
||||
|
||||
@ -615,7 +667,7 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod
|
||||
}
|
||||
}
|
||||
|
||||
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
transition.updatePosition(node: self.playerNode, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0))
|
||||
transition.updateTransformScale(node: self.playerNode, scale: size.width / self.intrinsicDimensions.width)
|
||||
|
||||
@ -796,3 +848,730 @@ private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNod
|
||||
func setCanPlaybackWithoutHierarchy(_ canPlaybackWithoutHierarchy: Bool) {
|
||||
}
|
||||
}
|
||||
|
||||
private func parseRange(from rangeString: String) -> Range<Int>? {
|
||||
guard rangeString.hasPrefix("bytes=") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let rangeValues = rangeString.dropFirst("bytes=".count).split(separator: "-")
|
||||
|
||||
guard rangeValues.count == 2,
|
||||
let start = Int(rangeValues[0]),
|
||||
let end = Int(rangeValues[1]) else {
|
||||
return nil
|
||||
}
|
||||
return start..<end + 1
|
||||
}
|
||||
|
||||
private final class CustomVideoSchemeHandler: NSObject, WKURLSchemeHandler {
|
||||
private final class PendingTask {
|
||||
let sourceTask: any WKURLSchemeTask
|
||||
let isCompleted = Atomic<Bool>(value: false)
|
||||
var disposable: Disposable?
|
||||
|
||||
init(source: HLSServerSource, sourceTask: any WKURLSchemeTask) {
|
||||
self.sourceTask = sourceTask
|
||||
|
||||
var requestRange: Range<Int>?
|
||||
if let rangeString = sourceTask.request.allHTTPHeaderFields?["Range"] {
|
||||
requestRange = parseRange(from: rangeString)
|
||||
}
|
||||
|
||||
guard let url = sourceTask.request.url else {
|
||||
return
|
||||
}
|
||||
let filePath = (url.absoluteString as NSString).lastPathComponent
|
||||
|
||||
if filePath == "master.m3u8" {
|
||||
self.disposable = source.masterPlaylistData().startStrict(next: { [weak self] data in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.sendResponseAndClose(data: data.data(using: .utf8)!)
|
||||
})
|
||||
} else if filePath.hasPrefix("hls_level_") && filePath.hasSuffix(".m3u8") {
|
||||
guard let levelIndex = Int(String(filePath[filePath.index(filePath.startIndex, offsetBy: "hls_level_".count) ..< filePath.index(filePath.endIndex, offsetBy: -".m3u8".count)])) else {
|
||||
self.sendErrorAndClose()
|
||||
return
|
||||
}
|
||||
|
||||
self.disposable = source.playlistData(quality: levelIndex).startStrict(next: { [weak self] data in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.sendResponseAndClose(data: data.data(using: .utf8)!)
|
||||
})
|
||||
} else if filePath.hasPrefix("partfile") && filePath.hasSuffix(".mp4") {
|
||||
let fileId = String(filePath[filePath.index(filePath.startIndex, offsetBy: "partfile".count) ..< filePath.index(filePath.endIndex, offsetBy: -".mp4".count)])
|
||||
guard let fileIdValue = Int64(fileId) else {
|
||||
self.sendErrorAndClose()
|
||||
return
|
||||
}
|
||||
guard let requestRange else {
|
||||
self.sendErrorAndClose()
|
||||
return
|
||||
}
|
||||
self.disposable = (source.fileData(id: fileIdValue, range: requestRange.lowerBound ..< requestRange.upperBound + 1)
|
||||
|> take(1)).start(next: { [weak self] result in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
if let (file, range, totalSize) = result {
|
||||
guard let allData = try? Data(contentsOf: URL(fileURLWithPath: file.path), options: .mappedIfSafe) else {
|
||||
return
|
||||
}
|
||||
let data = allData.subdata(in: range)
|
||||
|
||||
self.sendResponseAndClose(data: data, range: requestRange, totalSize: totalSize)
|
||||
} else {
|
||||
self.sendErrorAndClose()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
self.sendErrorAndClose()
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.disposable?.dispose()
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
}
|
||||
|
||||
func sendErrorAndClose() {
|
||||
self.sourceTask.didFailWithError(NSError(domain: "LocalVideoError", code: 500, userInfo: nil))
|
||||
}
|
||||
|
||||
private func sendResponseAndClose(data: Data, range: Range<Int>? = nil, totalSize: Int? = nil) {
|
||||
// Create the response with the appropriate content-type and content-length
|
||||
//let mimeType = "application/octet-stream"
|
||||
let responseLength = data.count
|
||||
|
||||
// Construct URLResponse with optional range headers (for partial content responses)
|
||||
var headers: [String: String] = [
|
||||
"Content-Length": "\(responseLength)",
|
||||
"Connection": "close",
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
]
|
||||
|
||||
if let range = range, let totalSize = totalSize {
|
||||
headers["Content-Range"] = "bytes \(range.lowerBound)-\(range.upperBound)/\(totalSize)"
|
||||
}
|
||||
|
||||
// Create the URLResponse object
|
||||
let response = HTTPURLResponse(url: self.sourceTask.request.url!,
|
||||
statusCode: 200,
|
||||
httpVersion: "HTTP/1.1",
|
||||
headerFields: headers)
|
||||
|
||||
// Send the response headers
|
||||
self.sourceTask.didReceive(response!)
|
||||
|
||||
// Send the response data
|
||||
self.sourceTask.didReceive(data)
|
||||
|
||||
// Complete the task
|
||||
self.sourceTask.didFinish()
|
||||
}
|
||||
}
|
||||
|
||||
private let source: HLSServerSource
|
||||
private var pendingTasks: [PendingTask] = []
|
||||
|
||||
init(source: HLSServerSource) {
|
||||
self.source = source
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
|
||||
self.pendingTasks.append(PendingTask(source: self.source, sourceTask: urlSchemeTask))
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, stop urlSchemeTask: any WKURLSchemeTask) {
|
||||
if let index = self.pendingTasks.firstIndex(where: { $0.sourceTask === urlSchemeTask }) {
|
||||
let task = self.pendingTasks[index]
|
||||
self.pendingTasks.remove(at: index)
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
private let f: (WKScriptMessage) -> ()
|
||||
|
||||
init(_ f: @escaping (WKScriptMessage) -> ()) {
|
||||
self.f = f
|
||||
|
||||
super.init()
|
||||
}
|
||||
|
||||
func userContentController(_ controller: WKUserContentController, didReceive scriptMessage: WKScriptMessage) {
|
||||
self.f(scriptMessage)
|
||||
}
|
||||
}
|
||||
|
||||
private final class HLSVideoJSContentNode: ASDisplayNode, UniversalVideoContentNode {
|
||||
private struct Level {
|
||||
let bitrate: Int
|
||||
let width: Int
|
||||
let height: Int
|
||||
|
||||
init(bitrate: Int, width: Int, height: Int) {
|
||||
self.bitrate = bitrate
|
||||
self.width = width
|
||||
self.height = height
|
||||
}
|
||||
}
|
||||
|
||||
private let postbox: Postbox
|
||||
private let userLocation: MediaResourceUserLocation
|
||||
private let fileReference: FileMediaReference
|
||||
private let approximateDuration: Double
|
||||
private let intrinsicDimensions: CGSize
|
||||
|
||||
private let audioSessionManager: ManagedAudioSession
|
||||
private let audioSessionDisposable = MetaDisposable()
|
||||
private var hasAudioSession = false
|
||||
|
||||
private let playerSource: HLSServerSource?
|
||||
private var serverDisposable: Disposable?
|
||||
|
||||
private let playbackCompletedListeners = Bag<() -> Void>()
|
||||
|
||||
private var initializedStatus = false
|
||||
private var statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .paused, soundEnabled: true)
|
||||
private var isBuffering = false
|
||||
private var seekId: Int = 0
|
||||
private let _status = ValuePromise<MediaPlayerStatus>()
|
||||
var status: Signal<MediaPlayerStatus, NoError> {
|
||||
return self._status.get()
|
||||
}
|
||||
|
||||
private let _bufferingStatus = Promise<(RangeSet<Int64>, Int64)?>()
|
||||
var bufferingStatus: Signal<(RangeSet<Int64>, Int64)?, NoError> {
|
||||
return self._bufferingStatus.get()
|
||||
}
|
||||
|
||||
private let _ready = Promise<Void>()
|
||||
var ready: Signal<Void, NoError> {
|
||||
return self._ready.get()
|
||||
}
|
||||
|
||||
private let _preloadCompleted = ValuePromise<Bool>()
|
||||
var preloadCompleted: Signal<Bool, NoError> {
|
||||
return self._preloadCompleted.get()
|
||||
}
|
||||
|
||||
private let imageNode: TransformImageNode
|
||||
private let webView: WKWebView
|
||||
|
||||
private let fetchDisposable = MetaDisposable()
|
||||
|
||||
private var dimensions: CGSize?
|
||||
private let dimensionsPromise = ValuePromise<CGSize>(CGSize())
|
||||
|
||||
private var validLayout: (size: CGSize, actualSize: CGSize)?
|
||||
|
||||
private var statusTimer: Foundation.Timer?
|
||||
|
||||
private var preferredVideoQuality: UniversalVideoContentVideoQuality = .auto
|
||||
|
||||
private var playerIsReady: Bool = false
|
||||
private var playerIsFirstFrameReady: Bool = false
|
||||
private var playerIsPlaying: Bool = false
|
||||
private var playerRate: Double = 0.0
|
||||
private var playerDefaultRate: Double = 1.0
|
||||
private var playerTime: Double = 0.0
|
||||
private var playerTimeGenerationTimestamp: Double = 0.0
|
||||
private var playerAvailableLevels: [Int: Level] = [:]
|
||||
private var playerCurrentLevelIndex: Int?
|
||||
|
||||
private var hasRequestedPlayerLoad: Bool = false
|
||||
|
||||
private var requestedPlaying: Bool = false
|
||||
private var requestedBaseRate: Double = 1.0
|
||||
private var requestedLevelIndex: Int?
|
||||
|
||||
init(accountId: AccountRecordId, postbox: Postbox, audioSessionManager: ManagedAudioSession, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, streamVideo: Bool, loopVideo: Bool, enableSound: Bool, baseRate: Double, fetchAutomatically: Bool) {
|
||||
self.postbox = postbox
|
||||
self.fileReference = fileReference
|
||||
self.approximateDuration = fileReference.media.duration ?? 0.0
|
||||
self.audioSessionManager = audioSessionManager
|
||||
self.userLocation = userLocation
|
||||
self.requestedBaseRate = baseRate
|
||||
|
||||
if var dimensions = fileReference.media.dimensions {
|
||||
if let thumbnail = fileReference.media.previewRepresentations.first {
|
||||
let dimensionsVertical = dimensions.width < dimensions.height
|
||||
let thumbnailVertical = thumbnail.dimensions.width < thumbnail.dimensions.height
|
||||
if dimensionsVertical != thumbnailVertical {
|
||||
dimensions = PixelDimensions(width: dimensions.height, height: dimensions.width)
|
||||
}
|
||||
}
|
||||
self.dimensions = dimensions.cgSize
|
||||
} else {
|
||||
self.dimensions = CGSize(width: 128.0, height: 128.0)
|
||||
}
|
||||
|
||||
self.imageNode = TransformImageNode()
|
||||
|
||||
let config = WKWebViewConfiguration()
|
||||
config.allowsInlineMediaPlayback = true
|
||||
config.mediaTypesRequiringUserActionForPlayback = []
|
||||
config.allowsPictureInPictureMediaPlayback = false
|
||||
|
||||
var playerSource: HLSServerSource?
|
||||
if let qualitySet = HLSQualitySet(baseFile: fileReference) {
|
||||
let playerSourceValue = HLSServerSource(accountId: accountId.int64, fileId: fileReference.media.fileId.id, postbox: postbox, userLocation: userLocation, playlistFiles: qualitySet.playlistFiles, qualityFiles: qualitySet.qualityFiles)
|
||||
playerSource = playerSourceValue
|
||||
let schemeHandler = CustomVideoSchemeHandler(source: playerSourceValue)
|
||||
config.setURLSchemeHandler(schemeHandler, forURLScheme: "tghls")
|
||||
}
|
||||
self.playerSource = playerSource
|
||||
|
||||
let userController = WKUserContentController()
|
||||
|
||||
var handleScriptMessage: ((WKScriptMessage) -> Void)?
|
||||
userController.add(WeakScriptMessageHandler { message in
|
||||
handleScriptMessage?(message)
|
||||
}, name: "performAction")
|
||||
|
||||
let isDebug: Bool
|
||||
#if DEBUG
|
||||
isDebug = true
|
||||
#else
|
||||
isDebug = false
|
||||
#endif
|
||||
|
||||
let mediaDimensions = fileReference.media.dimensions?.cgSize ?? CGSize(width: 480.0, height: 320.0)
|
||||
self.intrinsicDimensions = mediaDimensions.aspectFittedOrSmaller(CGSize(width: 1280.0, height: 1280.0))
|
||||
|
||||
let userScriptJs = """
|
||||
playerInitialize({
|
||||
'debug': \(isDebug),
|
||||
'width': \(Int(self.intrinsicDimensions.width)),
|
||||
'height': \(Int(self.intrinsicDimensions.height))
|
||||
});
|
||||
""";
|
||||
let userScript = WKUserScript(source: userScriptJs, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
|
||||
userController.addUserScript(userScript)
|
||||
|
||||
config.userContentController = userController
|
||||
|
||||
self.webView = WKWebView(frame: CGRect(origin: CGPoint(), size: self.intrinsicDimensions), configuration: config)
|
||||
self.webView.scrollView.isScrollEnabled = false
|
||||
self.webView.allowsLinkPreview = false
|
||||
self.webView.allowsBackForwardNavigationGestures = false
|
||||
self.webView.accessibilityIgnoresInvertColors = true
|
||||
self.webView.scrollView.contentInsetAdjustmentBehavior = .never
|
||||
self.webView.alpha = 0.0
|
||||
|
||||
if #available(iOS 16.4, *) {
|
||||
#if DEBUG
|
||||
self.webView.isInspectable = true
|
||||
#endif
|
||||
}
|
||||
|
||||
super.init()
|
||||
|
||||
self.imageNode.setSignal(internalMediaGridMessageVideo(postbox: postbox, userLocation: self.userLocation, videoReference: fileReference) |> map { [weak self] getSize, getData in
|
||||
Queue.mainQueue().async {
|
||||
if let strongSelf = self, strongSelf.dimensions == nil {
|
||||
if let dimensions = getSize() {
|
||||
strongSelf.dimensions = dimensions
|
||||
strongSelf.dimensionsPromise.set(dimensions)
|
||||
if let validLayout = strongSelf.validLayout {
|
||||
strongSelf.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return getData
|
||||
})
|
||||
|
||||
self.addSubnode(self.imageNode)
|
||||
self.view.addSubview(self.webView)
|
||||
|
||||
self.imageNode.imageUpdated = { [weak self] _ in
|
||||
self?._ready.set(.single(Void()))
|
||||
}
|
||||
|
||||
self._bufferingStatus.set(.single(nil))
|
||||
|
||||
handleScriptMessage = { [weak self] message in
|
||||
Queue.mainQueue().async {
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
guard let body = message.body as? [String: Any] else {
|
||||
return
|
||||
}
|
||||
guard let eventName = body["event"] as? String else {
|
||||
return
|
||||
}
|
||||
|
||||
switch eventName {
|
||||
case "playerStatus":
|
||||
guard let eventData = body["data"] as? [String: Any] else {
|
||||
return
|
||||
}
|
||||
if let isReady = eventData["isReady"] as? Bool {
|
||||
self.playerIsReady = isReady
|
||||
} else {
|
||||
self.playerIsReady = false
|
||||
}
|
||||
if let isFirstFrameReady = eventData["isFirstFrameReady"] as? Bool {
|
||||
self.playerIsFirstFrameReady = isFirstFrameReady
|
||||
} else {
|
||||
self.playerIsFirstFrameReady = false
|
||||
}
|
||||
if let isPlaying = eventData["isPlaying"] as? Bool {
|
||||
self.playerIsPlaying = isPlaying
|
||||
} else {
|
||||
self.playerIsPlaying = false
|
||||
}
|
||||
if let rate = eventData["rate"] as? Double {
|
||||
self.playerRate = rate
|
||||
} else {
|
||||
self.playerRate = 0.0
|
||||
}
|
||||
if let defaultRate = eventData["defaultRate"] as? Double {
|
||||
self.playerDefaultRate = defaultRate
|
||||
} else {
|
||||
self.playerDefaultRate = 0.0
|
||||
}
|
||||
if let levels = eventData["levels"] as? [[String: Any]] {
|
||||
self.playerAvailableLevels.removeAll()
|
||||
|
||||
for level in levels {
|
||||
guard let levelIndex = level["index"] as? Int else {
|
||||
continue
|
||||
}
|
||||
guard let levelBitrate = level["bitrate"] as? Int else {
|
||||
continue
|
||||
}
|
||||
guard let levelWidth = level["width"] as? Int else {
|
||||
continue
|
||||
}
|
||||
guard let levelHeight = level["height"] as? Int else {
|
||||
continue
|
||||
}
|
||||
self.playerAvailableLevels[levelIndex] = Level(
|
||||
bitrate: levelBitrate,
|
||||
width: levelWidth,
|
||||
height: levelHeight
|
||||
)
|
||||
}
|
||||
} else {
|
||||
self.playerAvailableLevels.removeAll()
|
||||
}
|
||||
|
||||
if let currentLevel = eventData["currentLevel"] as? Int {
|
||||
if self.playerAvailableLevels[currentLevel] != nil {
|
||||
self.playerCurrentLevelIndex = currentLevel
|
||||
} else {
|
||||
self.playerCurrentLevelIndex = nil
|
||||
}
|
||||
} else {
|
||||
self.playerCurrentLevelIndex = nil
|
||||
}
|
||||
|
||||
self.webView.alpha = self.playerIsFirstFrameReady ? 1.0 : 0.0
|
||||
if self.playerIsReady {
|
||||
if !self.hasRequestedPlayerLoad {
|
||||
if !self.playerAvailableLevels.isEmpty {
|
||||
var selectedLevelIndex: Int?
|
||||
if let minimizedQualityFile = HLSVideoContent.minimizedHLSQualityFile(file: self.fileReference) {
|
||||
if let dimensions = minimizedQualityFile.media.dimensions {
|
||||
for (index, level) in self.playerAvailableLevels {
|
||||
if level.height == Int(dimensions.height) {
|
||||
selectedLevelIndex = index
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if selectedLevelIndex == nil {
|
||||
selectedLevelIndex = self.playerAvailableLevels.sorted(by: { $0.value.height > $1.value.height }).first?.key
|
||||
}
|
||||
if let selectedLevelIndex {
|
||||
self.hasRequestedPlayerLoad = true
|
||||
self.webView.evaluateJavaScript("playerLoad(\(selectedLevelIndex));", completionHandler: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.webView.evaluateJavaScript("playerSetBaseRate(\(self.requestedBaseRate));", completionHandler: nil)
|
||||
|
||||
if self.requestedPlaying {
|
||||
self.requestPlay()
|
||||
} else {
|
||||
self.requestPause()
|
||||
}
|
||||
}
|
||||
|
||||
self.updateStatus()
|
||||
case "playerCurrentTime":
|
||||
guard let eventData = body["data"] as? [String: Any] else {
|
||||
return
|
||||
}
|
||||
guard let value = eventData["value"] as? Double else {
|
||||
return
|
||||
}
|
||||
self.playerTime = value
|
||||
self.playerTimeGenerationTimestamp = CACurrentMediaTime()
|
||||
self.updateStatus()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let playerSource = self.playerSource {
|
||||
self.serverDisposable = SharedHLSServer.shared.registerPlayer(source: playerSource, completion: { [weak self] in
|
||||
Queue.mainQueue().async {
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
let htmlUrl = "http://127.0.0.1:\(SharedHLSServer.shared.port)/\(playerSource.id)/index.html"
|
||||
self.webView.load(URLRequest(url: URL(string: htmlUrl)!))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.serverDisposable?.dispose()
|
||||
self.audioSessionDisposable.dispose()
|
||||
|
||||
self.statusTimer?.invalidate()
|
||||
}
|
||||
|
||||
private func updateStatus() {
|
||||
let isPlaying = self.requestedPlaying && self.playerRate != 0.0
|
||||
let status: MediaPlayerPlaybackStatus
|
||||
if self.requestedPlaying && !isPlaying {
|
||||
status = .buffering(initial: false, whilePlaying: self.requestedPlaying, progress: 0.0, display: true)
|
||||
} else {
|
||||
status = self.requestedPlaying ? .playing : .paused
|
||||
}
|
||||
var timestamp = self.playerTime
|
||||
if timestamp.isFinite && !timestamp.isNaN {
|
||||
} else {
|
||||
timestamp = 0.0
|
||||
}
|
||||
self.statusValue = MediaPlayerStatus(generationTimestamp: self.playerTimeGenerationTimestamp, duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: timestamp, baseRate: self.requestedBaseRate, seekId: self.seekId, status: status, soundEnabled: true)
|
||||
self._status.set(self.statusValue)
|
||||
|
||||
if case .playing = status {
|
||||
if self.statusTimer == nil {
|
||||
self.statusTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true, block: { [weak self] _ in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.updateStatus()
|
||||
})
|
||||
}
|
||||
} else if let statusTimer = self.statusTimer {
|
||||
self.statusTimer = nil
|
||||
statusTimer.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
private func performActionAtEnd() {
|
||||
for listener in self.playbackCompletedListeners.copyItems() {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
transition.updatePosition(layer: self.webView.layer, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0))
|
||||
transition.updateTransformScale(layer: self.webView.layer, scale: size.width / self.intrinsicDimensions.width)
|
||||
|
||||
transition.updateFrame(node: self.imageNode, frame: CGRect(origin: CGPoint(), size: size))
|
||||
|
||||
if let dimensions = self.dimensions {
|
||||
let imageSize = CGSize(width: floor(dimensions.width / 2.0), height: floor(dimensions.height / 2.0))
|
||||
let makeLayout = self.imageNode.asyncLayout()
|
||||
let applyLayout = makeLayout(TransformImageArguments(corners: ImageCorners(), imageSize: imageSize, boundingSize: imageSize, intrinsicInsets: UIEdgeInsets(), emptyColor: .clear))
|
||||
applyLayout()
|
||||
}
|
||||
}
|
||||
|
||||
func play() {
|
||||
assert(Queue.mainQueue().isCurrent())
|
||||
if !self.initializedStatus {
|
||||
self._status.set(MediaPlayerStatus(generationTimestamp: 0.0, duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, baseRate: self.requestedBaseRate, seekId: self.seekId, status: .buffering(initial: true, whilePlaying: true, progress: 0.0, display: true), soundEnabled: true))
|
||||
}
|
||||
if !self.hasAudioSession {
|
||||
self.audioSessionDisposable.set(self.audioSessionManager.push(audioSessionType: .play(mixWithOthers: false), activate: { [weak self] _ in
|
||||
Queue.mainQueue().async {
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
self.hasAudioSession = true
|
||||
self.requestPlay()
|
||||
}
|
||||
}, deactivate: { [weak self] _ in
|
||||
return Signal { subscriber in
|
||||
if let self {
|
||||
self.hasAudioSession = false
|
||||
self.requestPause()
|
||||
}
|
||||
|
||||
subscriber.putCompletion()
|
||||
|
||||
return EmptyDisposable
|
||||
}
|
||||
|> runOn(.mainQueue())
|
||||
}))
|
||||
} else {
|
||||
self.requestPlay()
|
||||
}
|
||||
}
|
||||
|
||||
private func requestPlay() {
|
||||
self.requestedPlaying = true
|
||||
if self.playerIsReady {
|
||||
self.webView.evaluateJavaScript("playerPlay();", completionHandler: nil)
|
||||
}
|
||||
self.updateStatus()
|
||||
}
|
||||
|
||||
private func requestPause() {
|
||||
self.requestedPlaying = false
|
||||
if self.playerIsReady {
|
||||
self.webView.evaluateJavaScript("playerPause();", completionHandler: nil)
|
||||
}
|
||||
self.updateStatus()
|
||||
}
|
||||
|
||||
func pause() {
|
||||
assert(Queue.mainQueue().isCurrent())
|
||||
self.requestPause()
|
||||
}
|
||||
|
||||
func togglePlayPause() {
|
||||
assert(Queue.mainQueue().isCurrent())
|
||||
|
||||
if self.requestedPlaying {
|
||||
self.pause()
|
||||
} else {
|
||||
self.play()
|
||||
}
|
||||
}
|
||||
|
||||
func setSoundEnabled(_ value: Bool) {
|
||||
assert(Queue.mainQueue().isCurrent())
|
||||
/*if value {
|
||||
if !self.hasAudioSession {
|
||||
self.audioSessionDisposable.set(self.audioSessionManager.push(audioSessionType: .play(mixWithOthers: false), activate: { [weak self] _ in
|
||||
self?.hasAudioSession = true
|
||||
self?.player?.volume = 1.0
|
||||
}, deactivate: { [weak self] _ in
|
||||
self?.hasAudioSession = false
|
||||
self?.player?.pause()
|
||||
return .complete()
|
||||
}))
|
||||
}
|
||||
} else {
|
||||
self.player?.volume = 0.0
|
||||
self.hasAudioSession = false
|
||||
self.audioSessionDisposable.set(nil)
|
||||
}*/
|
||||
}
|
||||
|
||||
func seek(_ timestamp: Double) {
|
||||
assert(Queue.mainQueue().isCurrent())
|
||||
self.seekId += 1
|
||||
|
||||
self.webView.evaluateJavaScript("playerSeek(\(timestamp));", completionHandler: nil)
|
||||
}
|
||||
|
||||
func playOnceWithSound(playAndRecord: Bool, seek: MediaPlayerSeek, actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) {
|
||||
self.webView.evaluateJavaScript("playerSetIsMuted(false);", completionHandler: nil)
|
||||
|
||||
self.play()
|
||||
}
|
||||
|
||||
func setSoundMuted(soundMuted: Bool) {
|
||||
self.webView.evaluateJavaScript("playerSetIsMuted(\(soundMuted));", completionHandler: nil)
|
||||
}
|
||||
|
||||
func continueWithOverridingAmbientMode(isAmbient: Bool) {
|
||||
}
|
||||
|
||||
func setForceAudioToSpeaker(_ forceAudioToSpeaker: Bool) {
|
||||
}
|
||||
|
||||
func continuePlayingWithoutSound(actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) {
|
||||
self.webView.evaluateJavaScript("playerSetIsMuted(true);", completionHandler: nil)
|
||||
self.hasAudioSession = false
|
||||
self.audioSessionDisposable.set(nil)
|
||||
}
|
||||
|
||||
func setContinuePlayingWithoutSoundOnLostAudioSession(_ value: Bool) {
|
||||
}
|
||||
|
||||
func setBaseRate(_ baseRate: Double) {
|
||||
self.requestedBaseRate = baseRate
|
||||
if self.playerIsReady {
|
||||
self.webView.evaluateJavaScript("playerSetBaseRate(\(self.requestedBaseRate));", completionHandler: nil)
|
||||
}
|
||||
self.updateStatus()
|
||||
}
|
||||
|
||||
func setVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) {
|
||||
self.preferredVideoQuality = videoQuality
|
||||
|
||||
switch videoQuality {
|
||||
case .auto:
|
||||
self.requestedLevelIndex = nil
|
||||
case let .quality(quality):
|
||||
if let level = self.playerAvailableLevels.first(where: { $0.value.height == quality }) {
|
||||
self.requestedLevelIndex = level.key
|
||||
} else {
|
||||
self.requestedLevelIndex = nil
|
||||
}
|
||||
}
|
||||
|
||||
if self.playerIsReady {
|
||||
self.webView.evaluateJavaScript("playerSetLevel(\(self.requestedLevelIndex ?? -1));", completionHandler: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func videoQualityState() -> (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? {
|
||||
guard let playerCurrentLevelIndex = self.playerCurrentLevelIndex else {
|
||||
return nil
|
||||
}
|
||||
guard let currentLevel = self.playerAvailableLevels[playerCurrentLevelIndex] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var available = self.playerAvailableLevels.values.map(\.height)
|
||||
available.sort(by: { $0 > $1 })
|
||||
|
||||
return (currentLevel.height, self.preferredVideoQuality, available)
|
||||
}
|
||||
|
||||
func addPlaybackCompleted(_ f: @escaping () -> Void) -> Int {
|
||||
return self.playbackCompletedListeners.add(f)
|
||||
}
|
||||
|
||||
func removePlaybackCompleted(_ index: Int) {
|
||||
self.playbackCompletedListeners.remove(index)
|
||||
}
|
||||
|
||||
func fetchControl(_ control: UniversalVideoNodeFetchControl) {
|
||||
}
|
||||
|
||||
func notifyPlaybackControlsHidden(_ hidden: Bool) {
|
||||
}
|
||||
|
||||
func setCanPlaybackWithoutHierarchy(_ canPlaybackWithoutHierarchy: Bool) {
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -217,7 +217,7 @@ private final class NativeVideoContentNode: ASDisplayNode, UniversalVideoContent
|
||||
private var dimensions: CGSize?
|
||||
private let dimensionsPromise = ValuePromise<CGSize>(CGSize())
|
||||
|
||||
private var validLayout: CGSize?
|
||||
private var validLayout: (size: CGSize, actualSize: CGSize)?
|
||||
|
||||
private var shouldPlay: Bool = false
|
||||
|
||||
@ -306,8 +306,8 @@ private final class NativeVideoContentNode: ASDisplayNode, UniversalVideoContent
|
||||
if let dimensions = getSize() {
|
||||
strongSelf.dimensions = dimensions
|
||||
strongSelf.dimensionsPromise.set(dimensions)
|
||||
if let size = strongSelf.validLayout {
|
||||
strongSelf.updateLayout(size: size, transition: .immediate)
|
||||
if let validLayout = strongSelf.validLayout {
|
||||
strongSelf.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -432,8 +432,8 @@ private final class NativeVideoContentNode: ASDisplayNode, UniversalVideoContent
|
||||
}
|
||||
}
|
||||
|
||||
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayout = size
|
||||
func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayout = (size, actualSize)
|
||||
|
||||
if let dimensions = self.dimensions {
|
||||
let imageSize = CGSize(width: floor(dimensions.width / 2.0), height: floor(dimensions.height / 2.0))
|
||||
|
@ -47,7 +47,7 @@ final class OverlayVideoDecoration: UniversalVideoDecoration {
|
||||
|
||||
private let statusDisposable = MetaDisposable()
|
||||
|
||||
private var validLayoutSize: CGSize?
|
||||
private var validLayout: (size: CGSize, actualSize: CGSize)?
|
||||
|
||||
init(contentDimensions: CGSize, unminimize: @escaping () -> Void, togglePlayPause: @escaping () -> Void, expand: @escaping () -> Void, close: @escaping () -> Void, controlsAreShowingUpdated: @escaping (Bool) -> Void) {
|
||||
self.contentDimensions = contentDimensions
|
||||
@ -106,9 +106,9 @@ final class OverlayVideoDecoration: UniversalVideoDecoration {
|
||||
if let contentNode = contentNode {
|
||||
if contentNode.supernode !== self.contentContainerNode {
|
||||
self.contentContainerNode.addSubnode(contentNode)
|
||||
if let validLayoutSize = self.validLayoutSize {
|
||||
contentNode.frame = self.frameForContent(size: validLayoutSize)
|
||||
contentNode.updateLayout(size: validLayoutSize, transition: .immediate)
|
||||
if let validLayout = self.validLayout {
|
||||
contentNode.frame = self.frameForContent(size: validLayout.size)
|
||||
contentNode.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -118,8 +118,8 @@ final class OverlayVideoDecoration: UniversalVideoDecoration {
|
||||
func updateContentNodeSnapshot(_ snapshot: UIView?) {
|
||||
}
|
||||
|
||||
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayoutSize = size
|
||||
func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
self.validLayout = (size, actualSize)
|
||||
|
||||
let contentFrame = self.frameForContent(size: size)
|
||||
|
||||
@ -146,7 +146,7 @@ final class OverlayVideoDecoration: UniversalVideoDecoration {
|
||||
|
||||
if let contentNode = self.contentNode {
|
||||
transition.updateFrame(node: contentNode, frame: contentFrame)
|
||||
contentNode.updateLayout(size: contentFrame.size, transition: transition)
|
||||
contentNode.updateLayout(size: contentFrame.size, actualSize: contentFrame.size, transition: transition)
|
||||
}
|
||||
}
|
||||
|
||||
@ -209,8 +209,8 @@ final class OverlayVideoDecoration: UniversalVideoDecoration {
|
||||
if self.minimizedBlurView == nil {
|
||||
let minimizedBlurView = UIVisualEffectView(effect: nil)
|
||||
self.minimizedBlurView = minimizedBlurView
|
||||
if let validLayoutSize = self.validLayoutSize {
|
||||
minimizedBlurView.frame = CGRect(origin: CGPoint(), size: validLayoutSize)
|
||||
if let validLayout = self.validLayout {
|
||||
minimizedBlurView.frame = CGRect(origin: CGPoint(), size: validLayout.size)
|
||||
}
|
||||
minimizedBlurView.isHidden = true
|
||||
self.foregroundContainerNode.view.addSubview(minimizedBlurView)
|
||||
@ -222,8 +222,8 @@ final class OverlayVideoDecoration: UniversalVideoDecoration {
|
||||
self.minimizedBlurView?.contentView.addSubview(minimizedArrowView)
|
||||
}
|
||||
if let minimizedArrowView = self.minimizedArrowView {
|
||||
if let validLayoutSize = self.validLayoutSize {
|
||||
setupArrowFrame(size: validLayoutSize, edge: edge, view: minimizedArrowView)
|
||||
if let validLayout = self.validLayout {
|
||||
setupArrowFrame(size: validLayout.size, edge: edge, view: minimizedArrowView)
|
||||
}
|
||||
minimizedArrowView.setAngled(!adjusting, animated: true)
|
||||
}
|
||||
|
@ -170,7 +170,7 @@ private final class PlatformVideoContentNode: ASDisplayNode, UniversalVideoConte
|
||||
private var dimensions: CGSize?
|
||||
private let dimensionsPromise = ValuePromise<CGSize>(CGSize())
|
||||
|
||||
private var validLayout: CGSize?
|
||||
private var validLayout: (size: CGSize, actualSize: CGSize)?
|
||||
|
||||
init(postbox: Postbox, audioSessionManager: ManagedAudioSession, userLocation: MediaResourceUserLocation, content: PlatformVideoContent.Content, streamVideo: Bool, loopVideo: Bool, enableSound: Bool, baseRate: Double, fetchAutomatically: Bool) {
|
||||
self.postbox = postbox
|
||||
@ -203,8 +203,8 @@ private final class PlatformVideoContentNode: ASDisplayNode, UniversalVideoConte
|
||||
if let dimensions = getSize() {
|
||||
strongSelf.dimensions = dimensions
|
||||
strongSelf.dimensionsPromise.set(dimensions)
|
||||
if let size = strongSelf.validLayout {
|
||||
strongSelf.updateLayout(size: size, transition: .immediate)
|
||||
if let validLayout = strongSelf.validLayout {
|
||||
strongSelf.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -371,7 +371,7 @@ private final class PlatformVideoContentNode: ASDisplayNode, UniversalVideoConte
|
||||
}
|
||||
}
|
||||
|
||||
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
transition.updatePosition(node: self.playerNode, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0))
|
||||
transition.updateTransformScale(node: self.playerNode, scale: size.width / self.intrinsicDimensions.width)
|
||||
|
||||
|
@ -207,7 +207,7 @@ private final class SystemVideoContentNode: ASDisplayNode, UniversalVideoContent
|
||||
}
|
||||
}
|
||||
|
||||
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
transition.updatePosition(node: self.playerNode, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0))
|
||||
transition.updateTransformScale(node: self.playerNode, scale: size.width / self.intrinsicDimensions.width)
|
||||
|
||||
|
@ -116,7 +116,7 @@ final class WebEmbedVideoContentNode: ASDisplayNode, UniversalVideoContentNode {
|
||||
self.readyDisposable.dispose()
|
||||
}
|
||||
|
||||
func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
|
||||
transition.updatePosition(node: self.playerNode, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0))
|
||||
transition.updateTransformScale(node: self.playerNode, scale: size.width / self.intrinsicDimensions.width)
|
||||
|
||||
|
@ -336,6 +336,10 @@ public final class ExternalMediaStreamingContext: SharedHLSServerSource {
|
||||
impl.fileData(id: id, range: range).start(next: subscriber.putNext)
|
||||
}
|
||||
}
|
||||
|
||||
public func arbitraryFileData(path: String) -> Signal<(data: Data, contentType: String)?, NoError> {
|
||||
return .single(nil)
|
||||
}
|
||||
}
|
||||
|
||||
public protocol SharedHLSServerSource: AnyObject {
|
||||
@ -345,6 +349,7 @@ public protocol SharedHLSServerSource: AnyObject {
|
||||
func playlistData(quality: Int) -> Signal<String, NoError>
|
||||
func partData(index: Int, quality: Int) -> Signal<Data?, NoError>
|
||||
func fileData(id: Int64, range: Range<Int>) -> Signal<(TempBoxFile, Range<Int>, Int)?, NoError>
|
||||
func arbitraryFileData(path: String) -> Signal<(data: Data, contentType: String)?, NoError>
|
||||
}
|
||||
|
||||
@available(iOS 12.0, macOS 14.0, *)
|
||||
@ -650,9 +655,21 @@ public final class SharedHLSServer {
|
||||
self.sendErrorAndClose(connection: connection, error: .internalServerError)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
let _ = (source.arbitraryFileData(path: filePath)
|
||||
|> deliverOn(self.queue)
|
||||
|> take(1)).start(next: { [weak self] result in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
if let result {
|
||||
self.sendResponseAndClose(connection: connection, data: result.data, contentType: result.contentType)
|
||||
} else {
|
||||
self.sendErrorAndClose(connection: connection, error: .notFound)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func sendErrorAndClose(connection: NWConnection, error: ResponseError = .badRequest) {
|
||||
@ -665,13 +682,14 @@ public final class SharedHLSServer {
|
||||
})
|
||||
}
|
||||
|
||||
private func sendResponseAndClose(connection: NWConnection, data: Data, range: Range<Int>? = nil, totalSize: Int? = nil) {
|
||||
private func sendResponseAndClose(connection: NWConnection, data: Data, contentType: String = "application/octet-stream", range: Range<Int>? = nil, totalSize: Int? = nil) {
|
||||
var responseHeaders = "HTTP/1.1 200 OK\r\n"
|
||||
responseHeaders.append("Content-Length: \(data.count)\r\n")
|
||||
if let range, let totalSize {
|
||||
responseHeaders.append("Content-Range: bytes \(range.lowerBound)-\(range.upperBound)/\(totalSize)\r\n")
|
||||
}
|
||||
responseHeaders.append("Content-Type: application/octet-stream\r\n")
|
||||
|
||||
responseHeaders.append("Content-Type: \(contentType)\r\n")
|
||||
responseHeaders.append("Connection: close\r\n")
|
||||
responseHeaders.append("Access-Control-Allow-Origin: *\r\n")
|
||||
responseHeaders.append("\r\n")
|
||||
|
Loading…
x
Reference in New Issue
Block a user