| 1 | import SwiftUI |
| 2 | import WebKit |
| 3 | |
| 4 | // The click-to-play YouTube card (chat.js buildYouTubeCard): the video's own |
| 5 | // thumbnail (i.ytimg.com) with a play badge; clicking swaps in a contained |
| 6 | // youtube-nocookie embed, autoplaying. The embed is the one webview the native |
| 7 | // feed keeps — it's YouTube's player for YouTube's content, nothing of ours |
| 8 | // (the same iframe the web feed uses; zero E2E surface). |
| 9 | public struct YouTubeCard: View { |
| 10 | public let id: String |
| 11 | public let start: Int |
| 12 | /// When set, clicking hands (id, start, card frame in window coords) to |
| 13 | /// the container — the Mac zooms the player up into the lightbox. When |
| 14 | /// nil, the embed swaps in place (the web's behaviour). |
| 15 | public let onPlay: ((String, Int, CGRect) -> Void)? |
| 16 | |
| 17 | @State private var playing = false |
| 18 | // Non-observed holder, not @State: reactively storing the frame looped |
| 19 | // layout (see LiveFrame / #580). Read at tap time only. |
| 20 | @State private var liveFrame = LiveFrame() |
| 21 | |
| 22 | public init(id: String, start: Int, onPlay: ((String, Int, CGRect) -> Void)? = nil) { |
| 23 | self.id = id |
| 24 | self.start = start |
| 25 | self.onPlay = onPlay |
| 26 | } |
| 27 | |
| 28 | private var width: CGFloat { 340 } |
| 29 | private var height: CGFloat { 340 * 9 / 16 } |
| 30 | |
| 31 | @State private var thumb: PlatformImage? |
| 32 | |
| 33 | public var body: some View { |
| 34 | Group { |
| 35 | if playing { |
| 36 | YouTubeEmbedView(id: id, start: start) |
| 37 | } else { |
| 38 | Button { |
| 39 | if let onPlay { |
| 40 | onPlay(id, start, liveFrame.rect) |
| 41 | } else { |
| 42 | playing = true |
| 43 | } |
| 44 | } label: { |
| 45 | ZStack { |
| 46 | Color.black |
| 47 | // Our own cached loader, NOT AsyncImage — reattached |
| 48 | // rows (switch chats and back) left AsyncImage stuck |
| 49 | // on its black placeholder until a rematerialise. |
| 50 | if let thumb { |
| 51 | Image(platform: thumb).resizable().scaledToFill() |
| 52 | } |
| 53 | Image(systemName: "play.fill") |
| 54 | .font(.system(size: 20)) |
| 55 | .foregroundStyle(.white) |
| 56 | .padding(16) |
| 57 | .background(Circle().fill(.black.opacity(0.55))) |
| 58 | } |
| 59 | .contentShape(Rectangle()) |
| 60 | } |
| 61 | .buttonStyle(.plain) |
| 62 | } |
| 63 | } |
| 64 | .frame(width: width, height: height) |
| 65 | .trackingFrame(into: liveFrame) |
| 66 | .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) |
| 67 | .task(id: id) { |
| 68 | if thumb == nil { thumb = await YouTubeThumbCache.shared.image(for: id) } |
| 69 | } |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | // One decoded poster per video, shared by every card that shows it. |
| 74 | @MainActor |
| 75 | final class YouTubeThumbCache { |
| 76 | static let shared = YouTubeThumbCache() |
| 77 | private var images: [String: PlatformImage] = [:] |
| 78 | private var inFlight: [String: Task<PlatformImage?, Never>] = [:] |
| 79 | |
| 80 | func image(for id: String) async -> PlatformImage? { |
| 81 | if let hit = images[id] { return hit } |
| 82 | if let task = inFlight[id] { return await task.value } |
| 83 | guard let url = URL(string: "https://i.ytimg.com/vi/\(id)/hqdefault.jpg") else { return nil } |
| 84 | let task = Task.detached { () -> PlatformImage? in |
| 85 | guard let (data, resp) = try? await URLSession.shared.data(from: url), |
| 86 | let http = resp as? HTTPURLResponse, http.statusCode == 200 else { return nil } |
| 87 | return PlatformImage(data: data) |
| 88 | } |
| 89 | inFlight[id] = task |
| 90 | let img = await task.value |
| 91 | inFlight.removeValue(forKey: id) |
| 92 | if let img { images[id] = img } |
| 93 | return img |
| 94 | } |
| 95 | } |
| 96 | |
| 97 | /// The playable embed, shell + iframe — public so the Mac lightbox can host |
| 98 | /// the same player the inline card uses. |
| 99 | public struct YouTubeEmbedView: View { |
| 100 | public let id: String |
| 101 | public let start: Int |
| 102 | |
| 103 | public init(id: String, start: Int) { |
| 104 | self.id = id |
| 105 | self.start = start |
| 106 | } |
| 107 | |
| 108 | public var body: some View { |
| 109 | YouTubeEmbed(url: embedURL) |
| 110 | } |
| 111 | |
| 112 | private var embedURL: URL { |
| 113 | var s = "https://www.youtube-nocookie.com/embed/\(id)?autoplay=1&rel=0&playsinline=1" |
| 114 | if start > 0 { s += "&start=\(start)" } |
| 115 | return URL(string: s)! |
| 116 | } |
| 117 | } |
| 118 | |
| 119 | // The embed must live in an iframe on a page with a real https origin — loading |
| 120 | // the embed URL as the top-level document gives the player no parent origin and |
| 121 | // it refuses with "Error 153: Video player configuration error". This shell + |
| 122 | // baseURL is exactly the situation the web feed's iframe is in. |
| 123 | func youtubeEmbedHTML(_ embedURL: URL) -> String { |
| 124 | """ |
| 125 | <!doctype html><html><head><meta name="viewport" content="initial-scale=1"> |
| 126 | <style>html,body{margin:0;height:100%;background:#000;overflow:hidden} |
| 127 | iframe{border:0;width:100%;height:100%}</style></head> |
| 128 | <body><iframe src="\(embedURL.absoluteString)" |
| 129 | allow="autoplay; encrypted-media; fullscreen" allowfullscreen></iframe></body></html> |
| 130 | """ |
| 131 | } |
| 132 | |
| 133 | let youtubeEmbedBase = URL(string: "https://www.youtube-nocookie.com")! |
| 134 | |
| 135 | // The minimal embed host — inline playback only, no navigation surface. |
| 136 | #if canImport(AppKit) && !targetEnvironment(macCatalyst) |
| 137 | private struct YouTubeEmbed: NSViewRepresentable { |
| 138 | let url: URL |
| 139 | |
| 140 | func makeNSView(context: Context) -> WKWebView { |
| 141 | let web = WKWebView(frame: .zero, configuration: embedConfig()) |
| 142 | web.setValue(false, forKey: "drawsBackground") |
| 143 | web.loadHTMLString(youtubeEmbedHTML(url), baseURL: youtubeEmbedBase) |
| 144 | return web |
| 145 | } |
| 146 | |
| 147 | func updateNSView(_ web: WKWebView, context: Context) {} |
| 148 | } |
| 149 | #else |
| 150 | private struct YouTubeEmbed: UIViewRepresentable { |
| 151 | let url: URL |
| 152 | |
| 153 | func makeUIView(context: Context) -> WKWebView { |
| 154 | let web = WKWebView(frame: .zero, configuration: embedConfig()) |
| 155 | web.isOpaque = false |
| 156 | web.loadHTMLString(youtubeEmbedHTML(url), baseURL: youtubeEmbedBase) |
| 157 | return web |
| 158 | } |
| 159 | |
| 160 | func updateUIView(_ web: WKWebView, context: Context) {} |
| 161 | } |
| 162 | #endif |
| 163 | |
| 164 | private func embedConfig() -> WKWebViewConfiguration { |
| 165 | let cfg = WKWebViewConfiguration() |
| 166 | cfg.allowsInlineMediaPlayback() |
| 167 | cfg.mediaTypesRequiringUserActionForPlayback = [] |
| 168 | return cfg |
| 169 | } |
| 170 | |
| 171 | private extension WKWebViewConfiguration { |
| 172 | func allowsInlineMediaPlayback() { |
| 173 | #if !os(macOS) |
| 174 | allowsInlineMediaPlayback = true |
| 175 | #endif |
| 176 | } |
| 177 | } |
| 178 | |