Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios

This commit is contained in:
Ilya Laktyushin 2024-09-27 17:02:30 +04:00
commit a68bf9bcae
22 changed files with 30638 additions and 321 deletions

View File

@ -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

View File

@ -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() {

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)

View File

@ -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)
}
}

View 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>

File diff suppressed because it is too large Load Diff

View File

@ -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 {

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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) {
}
}

View File

@ -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))

View File

@ -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)
}

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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")