eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import SwiftUI
2import 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).
9public 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
75final 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.
99public 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.
123func 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
133let 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)
137private 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
150private 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
164private func embedConfig() -> WKWebViewConfiguration {
165 let cfg = WKWebViewConfiguration()
166 cfg.allowsInlineMediaPlayback()
167 cfg.mediaTypesRequiringUserActionForPlayback = []
168 return cfg
169}
170
171private extension WKWebViewConfiguration {
172 func allowsInlineMediaPlayback() {
173 #if !os(macOS)
174 allowsInlineMediaPlayback = true
175 #endif
176 }
177}
178