Skip to content

Commit 378c685

Browse files
committed
feat: add support for end-screen overlays
1 parent 53d8774 commit 378c685

File tree

5 files changed

+128
-1
lines changed

5 files changed

+128
-1
lines changed

Sources/YouTubeKit/YouTubeResponseTypes/ChannelInfos/ChannelInfosResponse.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -809,7 +809,7 @@ public struct ChannelInfosResponse: YouTubeResponse {
809809
toReturn.items.append(decodedPlaylist)
810810
}
811811
}
812-
} else if let musicChannelContinuationToken = secondPlaylistGroup["shelfRenderer", "menu", "menuRenderer", "topLevelButtons", 0, "buttonRenderer", "navigationEndpoint", "showEngagementPanelEndpoint", "engagementPanel", "engagementPanelSectionListRenderer", "content", "sectionListRenderer", "contents", 0, "itemSectionRenderer", "contents", 0, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token"].string {
812+
} else if secondPlaylistGroup["shelfRenderer", "menu", "menuRenderer", "topLevelButtons", 0, "buttonRenderer", "navigationEndpoint", "showEngagementPanelEndpoint", "engagementPanel", "engagementPanelSectionListRenderer", "content", "sectionListRenderer", "contents", 0, "itemSectionRenderer", "contents", 0, "continuationItemRenderer", "continuationEndpoint", "continuationCommand", "token"].string != nil {
813813
return toReturn // empty so that it fetches the continuation token
814814
}
815815
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
//
2+
// EndScreen.swift
3+
// YouTubeKit
4+
//
5+
// Created by Antoine Bollengier on 11.01.2026.
6+
// Copyright © 2026 Antoine Bollengier (github.com/b5i). All rights reserved.
7+
//
8+
9+
public struct EndScreen {
10+
/// The start time in milliseconds of the end screen.
11+
public var startTime: Int?
12+
13+
public var elements: [EndScreenElement] = []
14+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//
2+
// EndScreenElement.swift
3+
// YouTubeKit
4+
//
5+
// Created by Antoine Bollengier on 11.01.2026.
6+
// Copyright © 2026 Antoine Bollengier (github.com/b5i). All rights reserved.
7+
//
8+
9+
import Foundation
10+
11+
public struct EndScreenElement {
12+
public var type: ElementType
13+
14+
/// Start time in milliseconds where the element appears.
15+
public var startTime: Int
16+
17+
/// End time in milliseconds where the element appears.
18+
public var endTime: Int
19+
20+
/// Position of the element on the screen, all values are in percentage (0.0 - 1.0) relative to the video size.
21+
///
22+
/// For example, an origin.x of 0.3 means the element starts at 30% of the video width from the left.
23+
public var position: CGRect
24+
25+
public enum ElementType {
26+
case video(video: YTVideo)
27+
case playlist(playlist: YTPlaylist)
28+
29+
/// - Note: If `subscribeButton` is true, that means a subscribe button is shown instead of the description.
30+
case channel(channel: YTChannel, subscribeButton: Bool, description: String?)
31+
case link(link: Link)
32+
33+
public struct Link {
34+
public var url: URL
35+
public var title: String?
36+
public var thumbnail: [YTThumbnail]
37+
}
38+
}
39+
40+
public init?(fromEndscreenElementRenderer json: JSON){
41+
guard let style = json["style"].string,
42+
let x = json["left"].double,
43+
let y = json["top"].double,
44+
let width = json["width"].double,
45+
let aspectRatio = json["aspectRatio"].double,
46+
let startTime = Int(json["startMs"].stringValue),
47+
let endTime = Int(json["endMs"].stringValue),
48+
let type: ElementType = {
49+
switch style {
50+
case "VIDEO":
51+
guard let videoId = json["endpoint", "watchEndpoint", "videoId"].string else { return nil }
52+
53+
var video = YTVideo(videoId: videoId)
54+
video.title = json["title", "runs"].arrayValue.map { $0["text"].stringValue }.joined()
55+
video.viewCount = json["metadata", "runs", 0, "text"].string
56+
video.timeLength = json["thumbnailOverlays", 0, "thumbnailOverlayTimeStatusRenderer", "text", "runs", 0, "text"].string
57+
YTThumbnail.appendThumbnails(json: json["image"], thumbnailList: &video.thumbnails)
58+
59+
return .video(video: video)
60+
case "PLAYLIST":
61+
guard let playlistId = json["endpoint", "watchEndpoint", "playlistId"].string else { return nil }
62+
63+
var playlist = YTPlaylist(playlistId: playlistId)
64+
playlist.title = json["title", "runs"].arrayValue.map { $0["text"].stringValue }.joined()
65+
playlist.videoCount = json["playlistLength", "runs", 0, "text"].string
66+
YTThumbnail.appendThumbnails(json: json["image"], thumbnailList: &playlist.thumbnails)
67+
68+
return .playlist(playlist: playlist)
69+
case "CHANNEL":
70+
guard let channelId = json["endpoint", "browseEndpoint", "browseId"].string else { return nil }
71+
72+
var channel = YTChannel(channelId: channelId)
73+
channel.name = json["title", "runs"].arrayValue.map { $0["text"].stringValue }.joined()
74+
YTThumbnail.appendThumbnails(json: json["image"], thumbnailList: &channel.thumbnails)
75+
channel.subscriberCount = json["subscribersText", "runs", 0, "text"].string ?? json["metadata", "runs", 0, "text"].string
76+
77+
let subscribeButton = json["isSubscribe"].boolValue
78+
79+
return .channel(channel: channel, subscribeButton: subscribeButton, description: subscribeButton ? nil : json["metadata", "runs"].arrayValue.map { $0["text"].stringValue }.joined())
80+
case "WEBSITE":
81+
guard let bloatedUrl = json["endpoint", "urlEndpoint", "url"].url,
82+
let urlString = URLComponents(url: bloatedUrl, resolvingAgainstBaseURL: false)?.queryItems?.first(where: {$0.name == "q"})?.value,
83+
let url = URL(string: urlString)
84+
else { return nil }
85+
let title = json["title", "runs"].arrayValue.map { $0["text"].stringValue }.joined()
86+
let thumbnail = YTThumbnail.getThumbnails(json: json["image"])
87+
return .link(link: .init(url: url, title: title, thumbnail: thumbnail))
88+
default:
89+
return nil
90+
}
91+
}()
92+
93+
else { return nil }
94+
95+
self.type = type
96+
self.position = CGRect(x: x, y: y, width: width, height: width / (aspectRatio + 0.001))
97+
self.startTime = startTime
98+
self.endTime = endTime
99+
}
100+
}

Sources/YouTubeKit/YouTubeResponseTypes/VideoInfos/VideoInfosResponse.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,9 @@ public struct VideoInfosResponse: YouTubeResponse {
8686
/// Count of view of the video, usually an integer in the string.
8787
public var viewCount: String?
8888

89+
/// Endscreen of the video.
90+
public var endScreen: EndScreen? = nil
91+
8992
/// The aspect ratio of the video (width/height).
9093
public var aspectRatio: Double?
9194

@@ -115,6 +118,7 @@ public struct VideoInfosResponse: YouTubeResponse {
115118
videoURLsExpireAt: Date? = nil,
116119
viewCount: String? = nil,
117120
aspectRatio: Double? = nil,
121+
endScreen: EndScreen? = nil,
118122
//startTime: Int? = nil,
119123
defaultFormats: [any DownloadFormat] = [],
120124
downloadFormats: [any DownloadFormat] = []
@@ -131,6 +135,7 @@ public struct VideoInfosResponse: YouTubeResponse {
131135
self.videoURLsExpireAt = videoURLsExpireAt
132136
self.viewCount = viewCount
133137
self.aspectRatio = aspectRatio
138+
self.endScreen = endScreen
134139
//self.startTime = startTime
135140
self.defaultFormats = defaultFormats
136141
self.downloadFormats = downloadFormats
@@ -157,6 +162,8 @@ public struct VideoInfosResponse: YouTubeResponse {
157162
channel = YTLittleChannelInfos(channelId: channelId, name: videoDetailsJSON["author"].string)
158163
}
159164

165+
let endScreenRenderer = json["endscreen", "endscreenRenderer"]
166+
160167
return VideoInfosResponse(
161168
captions: {
162169
var captionsArray: [YTCaption] = []
@@ -195,6 +202,11 @@ public struct VideoInfosResponse: YouTubeResponse {
195202
}(),
196203
viewCount: videoDetailsJSON["viewCount"].string,
197204
aspectRatio: streamingJSON["aspectRatio"].double,
205+
endScreen: EndScreen(
206+
startTime: Int(endScreenRenderer["startMs"].stringValue),
207+
elements: endScreenRenderer["elements"].arrayValue.compactMap {
208+
$0["endscreenElementRenderer"].exists() ? EndScreenElement(fromEndscreenElementRenderer: $0["endscreenElementRenderer"]) : nil
209+
}),
198210
//startTime: json["playerConfig", "playbackStartConfig", "startSeconds"].int,
199211
defaultFormats: streamingJSON["formats"].arrayValue.compactMap { VideoInfosWithDownloadFormatsResponse.decodeFormatFromJSON(json: $0) },
200212
downloadFormats: streamingJSON["adaptiveFormats"].arrayValue.compactMap { VideoInfosWithDownloadFormatsResponse.decodeFormatFromJSON(json: $0) }

Tests/YouTubeKitTests/YouTubeKitTests.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,7 @@ final class YouTubeKitTests: XCTestCase {
561561
XCTAssertNotNil(requestResult.videoURLsExpireAt, TEST_NAME + "Checking if requestResult.videoURLsExpireAt is not nil.")
562562
XCTAssertNotNil(requestResult.viewCount, TEST_NAME + "Checking if requestResult.viewCount is not nil.")
563563
XCTAssertNotNil(requestResult.aspectRatio, TEST_NAME + "Checking if requestResult.aspectRatio is not nil.")
564+
XCTAssertNotEqual(requestResult.endScreen?.elements.count ?? 0, 0, TEST_NAME + "Checking if there's endscreen elements")
564565
//XCTAssertNotEqual(requestResult.downloadFormats.count, 0, TEST_NAME + "Checking if requestResult.downloadFormats is empty")
565566
//XCTAssertNotEqual(requestResult.defaultFormats.count, 0, TEST_NAME + "Checking if requestResult.defaultFormats is empty")
566567

0 commit comments

Comments
 (0)