Skip to content

Commit df41714

Browse files
authored
video player component support (#41)
* added video player component * added support for stretched videos * updated readme to include video and support for data binding
1 parent 62e292f commit df41714

File tree

4 files changed

+247
-10
lines changed

4 files changed

+247
-10
lines changed

README.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -165,18 +165,20 @@ https://github.com/aarondemelo/BuilderIOExample
165165

166166
## Current Support
167167

168-
| Builder Component | Color | Margin / Padding | Horizontal Alignment | Click Support | Unsupported Features |
169-
|-------------------|:-----:|:----------------:|:--------------------:|:-------------:|:-------------------------------:|
170-
| **Button** | | | | | |
171-
| **Text** | | | | | |
172-
| **Image** | | | | | Image Position, Lock Aspect Ratio |
173-
| **Columns** | | | | | |
174-
| **Sections** | | | | | Lazy Load |
175-
| **Custom** | | | | | |
176-
| **Video** | 🏗 | 🏗 | 🏗 | 🏗 | |
168+
| Builder Component | Color | Margin / Padding | Horizontal Alignment | Click Support | Unsupported Features
169+
|-------------------|:-----:|:------------------:|:--------------------:|:--------------:|:-----------------------------------------:
170+
| **Button** | | | | |
171+
| **Text** | | | | |
172+
| **Image** | | | | | Image Position, Lock Aspect Ratio
173+
| **Columns** | | | | |
174+
| **Sections** | | | | | Lazy Load
175+
| **Custom** | | | | |
176+
| **Video** | | | | | When set to Cover, controls not available
177+
| **Forms** | 🏗 | 🏗 | 🏗 | 🏗 |
178+
177179

178180
**Unsupported**
179-
JS Code Execution, Data Binding, API Data
181+
JS Code Execution
180182

181183
---
182184

Sources/BuilderIO/ComponentRegistry/BuilderComponentRegistry.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public class BuilderComponentRegistry {
2828
register(type: BuilderButton.componentType, viewClass: BuilderButton.self)
2929
register(type: BuilderColumns.componentType, viewClass: BuilderColumns.self)
3030
register(type: BuilderSection.componentType, viewClass: BuilderSection.self)
31+
register(type: BuilderVideo.componentType, viewClass: BuilderVideo.self)
3132
}
3233

3334
private func register(type: BuilderComponentType, viewClass: any BuilderViewProtocol.Type) {

Sources/BuilderIO/ComponentRegistry/BuilderComponentType.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ public struct BuilderComponentType: Equatable, Hashable {
88
static let section = BuilderComponentType(rawValue: "Core:Section")
99
static let box = BuilderComponentType(rawValue: "Box")
1010
static let empty = BuilderComponentType(rawValue: "Empty")
11+
static let video = BuilderComponentType(rawValue: "Video")
1112

1213
// Add new types dynamically
1314
public static func custom(_ name: String) -> BuilderComponentType {
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
import AVKit
2+
import SwiftUI
3+
4+
struct BuilderVideo: BuilderViewProtocol {
5+
static let componentType: BuilderComponentType = .video
6+
7+
var block: BuilderBlockModel
8+
var children: [BuilderBlockModel]?
9+
10+
// Video properties
11+
var videoURL: URL?
12+
var autoPlay: Bool = false
13+
var controls: Bool = true
14+
var muted: Bool = true
15+
var loop: Bool = true
16+
var playsInline: Bool = true
17+
var contentMode: ContentMode = .fit
18+
var aspectRatio: CGFloat? = nil
19+
20+
// Poster image properties
21+
var posterImageURL: URL?
22+
23+
// AVPlayer
24+
@State private var player: AVPlayer?
25+
26+
@State private var showOverlay: Bool
27+
@State private var isPlaying: Bool = false
28+
29+
init(block: BuilderBlockModel) {
30+
self.block = block
31+
self.children = block.children
32+
33+
let options = block.component?.options?.dictionaryValue
34+
35+
// Video URL
36+
if let videoString = options?["video"]?.stringValue {
37+
self.videoURL = URL(string: videoString)
38+
}
39+
if let videoBinding = block.codeBindings(for: "video")?.stringValue {
40+
self.videoURL = URL(string: videoBinding)
41+
}
42+
43+
// Video options
44+
self.autoPlay = options?["autoPlay"]?.boolValue ?? false
45+
self.controls = options?["controls"]?.boolValue ?? true
46+
self.muted = options?["muted"]?.boolValue ?? true
47+
self.loop = options?["loop"]?.boolValue ?? true
48+
self.playsInline = options?["playsInline"]?.boolValue ?? true
49+
50+
// Content mode and aspect ratio for video
51+
if let fit = options?["fit"]?.stringValue {
52+
switch fit {
53+
case "cover":
54+
self.contentMode = .fill
55+
case "contain":
56+
self.contentMode = .fit
57+
default:
58+
self.contentMode = .fill
59+
}
60+
}
61+
62+
if let ratio = options?["aspectRatio"]?.doubleValue {
63+
self.aspectRatio = CGFloat(1 / ratio)
64+
}
65+
66+
// Poster image URL
67+
if let posterString = options?["posterImage"]?.stringValue, !autoPlay {
68+
self.showOverlay = true
69+
self.posterImageURL = URL(string: posterString)
70+
} else {
71+
self.showOverlay = false
72+
}
73+
}
74+
75+
var body: some View {
76+
Group {
77+
if let videoURL = videoURL {
78+
Rectangle().fill(Color.clear)
79+
.aspectRatio(self.aspectRatio ?? 1, contentMode: self.contentMode)
80+
.if(contentMode == .fill) { view in
81+
view.background(
82+
VideoPlayerView(
83+
videoURL: videoURL,
84+
autoPlay: true,
85+
muted: self.muted,
86+
loop: self.loop
87+
)
88+
)
89+
}
90+
.if(contentMode == .fit) { view in
91+
view.background(
92+
VideoPlayer(player: player)
93+
.onAppear {
94+
player = AVPlayer(url: videoURL)
95+
player?.isMuted = muted
96+
97+
if loop {
98+
NotificationCenter.default.addObserver(
99+
forName: .AVPlayerItemDidPlayToEndTime, object: player?.currentItem,
100+
queue: .main
101+
) { [self] _ in
102+
self.player?.seek(to: CMTime.zero)
103+
self.player?.play()
104+
}
105+
}
106+
107+
if autoPlay {
108+
self.showOverlay = false
109+
player?.play()
110+
}
111+
}.onDisappear {
112+
player?.pause()
113+
player = nil
114+
}
115+
116+
)
117+
}
118+
.aspectRatio(aspectRatio, contentMode: self.contentMode)
119+
.overlay(
120+
Group {
121+
if showOverlay {
122+
// Background for the poster image
123+
Rectangle().fill(Color.clear)
124+
.aspectRatio(self.aspectRatio ?? 1, contentMode: self.contentMode)
125+
.background(
126+
AsyncImage(url: self.posterImageURL) { phase in
127+
switch phase {
128+
case .empty:
129+
ProgressView()
130+
case .success(let image):
131+
image.resizable()
132+
.aspectRatio(contentMode: self.contentMode)
133+
.clipped()
134+
case .failure:
135+
EmptyView()
136+
@unknown default:
137+
EmptyView()
138+
}
139+
}
140+
)
141+
.overlay( // Play button overlay
142+
Button(action: {
143+
self.player?.play()
144+
self.showOverlay = false
145+
self.isPlaying = true
146+
}) {
147+
Image(systemName: "play.circle.fill")
148+
.resizable()
149+
.frame(width: 70, height: 70)
150+
.foregroundColor(.white)
151+
.shadow(radius: 10)
152+
}
153+
)
154+
}
155+
}
156+
)
157+
158+
} else {
159+
// If no video URL, display poster image or an empty view
160+
EmptyView()
161+
}
162+
}
163+
}
164+
}
165+
166+
struct VideoPlayerView: UIViewRepresentable {
167+
var videoURL: URL
168+
var autoPlay: Bool
169+
var muted: Bool
170+
var loop: Bool
171+
172+
func makeUIView(context: Context) -> PlayerContainerView {
173+
let playerContainerView = PlayerContainerView(
174+
videoURL: videoURL,
175+
autoPlay: autoPlay,
176+
muted: muted,
177+
loop: loop
178+
)
179+
return playerContainerView
180+
}
181+
182+
func updateUIView(_ uiView: PlayerContainerView, context: Context) {
183+
184+
uiView.player?.isMuted = muted
185+
186+
}
187+
188+
// 2. Custom UIView subclass to host the AVPlayerLayer
189+
class PlayerContainerView: UIView {
190+
var player: AVPlayer? // Make it optional to handle init scenarios
191+
private var playerLayer: AVPlayerLayer!
192+
private var playerLooper: AVPlayerLooper?
193+
194+
init(videoURL: URL, autoPlay: Bool, muted: Bool, loop: Bool) {
195+
super.init(frame: .zero)
196+
self.backgroundColor = .black
197+
198+
// For seamless looping, use AVPlayerLooper with a queue player
199+
// This also handles the initial autoPlay.
200+
let playerItem = AVPlayerItem(url: videoURL)
201+
let queuePlayer = AVQueuePlayer(playerItem: playerItem)
202+
203+
player = queuePlayer // Assign to the optional player property
204+
playerLayer = AVPlayerLayer(player: queuePlayer)
205+
playerLayer.videoGravity = .resizeAspectFill
206+
207+
self.layer.addSublayer(playerLayer)
208+
209+
// Apply mute setting
210+
player?.isMuted = muted
211+
212+
// Handle looping
213+
if loop {
214+
playerLooper = AVPlayerLooper(player: queuePlayer, templateItem: playerItem)
215+
}
216+
217+
// Handle autoPlay
218+
if autoPlay {
219+
queuePlayer.play()
220+
}
221+
222+
}
223+
224+
required init?(coder: NSCoder) {
225+
fatalError("init(coder:) has not been implemented")
226+
}
227+
228+
override func layoutSubviews() {
229+
super.layoutSubviews()
230+
playerLayer.frame = bounds
231+
}
232+
}
233+
}

0 commit comments

Comments
 (0)