Skip to content

Commit 2bc1ae7

Browse files
authored
Merge pull request #36 from PureSwift/feature/video
VideoPlayer
2 parents 5e0f0c2 + a9353aa commit 2bc1ae7

10 files changed

Lines changed: 146 additions & 0 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
//
2+
// VideoPlayer.swift
3+
// AndroidSwiftUICore
4+
//
5+
// A video player with system playback controls. The node carries the media
6+
// URL; the Android interpreter hosts a platform player view, and the desktop
7+
// rig shows a placeholder.
8+
//
9+
10+
import Foundation
11+
12+
/// A playable media item. Mirrors the AVKit spelling; only URL-backed
13+
/// playback is modeled.
14+
public struct AVPlayer: Sendable {
15+
public var url: URL
16+
public init(url: URL) { self.url = url }
17+
}
18+
19+
public struct VideoPlayer: View {
20+
21+
internal let player: AVPlayer
22+
23+
public init(player: AVPlayer) {
24+
self.player = player
25+
}
26+
27+
public typealias Body = Never
28+
}
29+
30+
extension VideoPlayer: PrimitiveView {
31+
public func _render(in context: ResolveContext) -> RenderNode {
32+
RenderNode(
33+
type: "VideoPlayer",
34+
id: context.path,
35+
props: ["url": .string(player.url.absoluteString)]
36+
)
37+
}
38+
}

AndroidSwiftUICore/Tests/AndroidSwiftUICoreTests/EvaluatorTests.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77

88
import Testing
9+
import Foundation
910
@testable import AndroidSwiftUICore
1011

1112
// MARK: - Emission
@@ -261,6 +262,13 @@ struct GraphicsTests {
261262
#expect(node.children[0].props["latitude"] == .double(-12.045))
262263
}
263264

265+
@Test("VideoPlayer emits its media URL")
266+
func videoPlayer() {
267+
let node = ViewHost(VideoPlayer(player: AVPlayer(url: URL(string: "https://example.com/clip.mp4")!))).evaluate()
268+
#expect(node.type == "VideoPlayer")
269+
#expect(node.props["url"] == .string("https://example.com/clip.mp4"))
270+
}
271+
264272
@Test("Overlay emits base and overlay children with alignment")
265273
func overlay() {
266274
let node = ViewHost(Color.blue.overlay(alignment: .bottomTrailing) { Text("badge") }).evaluate()

Demo/App.swiftpm/Sources/Catalog.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ struct CatalogEntry: Identifiable {
3737
CatalogEntry(id: "color", title: "Color", screen: AnyCatalogScreen(ColorPlayground())),
3838
CatalogEntry(id: "graphics", title: "Graphics", screen: AnyCatalogScreen(GraphicsPlayground())),
3939
CatalogEntry(id: "map", title: "Map", screen: AnyCatalogScreen(MapPlayground())),
40+
CatalogEntry(id: "video", title: "Video", screen: AnyCatalogScreen(VideoPlayground())),
4041
CatalogEntry(id: "scroll", title: "ScrollView", screen: AnyCatalogScreen(ScrollViewPlayground())),
4142
CatalogEntry(id: "list", title: "List", screen: AnyCatalogScreen(ListPlayground())),
4243
CatalogEntry(id: "grid", title: "Grid", screen: AnyCatalogScreen(GridPlayground())),
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
#if canImport(AndroidSwiftUI)
2+
import AndroidSwiftUI
3+
#else
4+
import SwiftUI
5+
import AVKit
6+
#endif
7+
8+
import Foundation
9+
10+
struct VideoPlayground: View {
11+
var body: some View {
12+
ScrollView {
13+
VStack(alignment: .leading, spacing: 0) {
14+
Example("Streaming video") {
15+
VStack(alignment: .leading, spacing: 8) {
16+
VideoPlayer(player: AVPlayer(url: URL(string: "https://storage.googleapis.com/exoplayer-test-media-0/BigBuckBunny_320x180.mp4")!))
17+
.cornerRadius(8)
18+
Text("Tap the video for playback controls")
19+
}
20+
}
21+
}
22+
}
23+
}
24+
}

Demo/app/src/main/AndroidManifest.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
<uses-permission android:name="android.permission.BLUETOOTH" />
66
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
77
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
8+
<uses-permission android:name="android.permission.INTERNET" />
89

910
<application
1011
android:name=".Application"

Demo/swiftui/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ kotlin {
2828
implementation(compose.material3)
2929
implementation(libs.kotlinx.serialization.json)
3030
}
31+
androidMain.dependencies {
32+
// VideoPlayer: the legacy MediaPlayer stack is unreliable for
33+
// remote streams, so the Android player is Media3.
34+
implementation("androidx.media3:media3-exoplayer:1.4.1")
35+
implementation("androidx.media3:media3-ui:1.4.1")
36+
}
3137
// `external fun` is JVM-only; both targets are JVM, so the bridge's
3238
// Swift-implemented classes live in a source set they share.
3339
val jvmShared by creating {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package com.pureswift.swiftui
2+
3+
import androidx.compose.foundation.layout.fillMaxWidth
4+
import androidx.compose.foundation.layout.height
5+
import androidx.compose.runtime.Composable
6+
import androidx.compose.runtime.DisposableEffect
7+
import androidx.compose.runtime.remember
8+
import androidx.compose.ui.platform.LocalContext
9+
import androidx.compose.ui.viewinterop.AndroidView
10+
import androidx.compose.ui.unit.dp
11+
import androidx.media3.common.MediaItem
12+
import androidx.media3.exoplayer.ExoPlayer
13+
import androidx.media3.ui.PlayerView
14+
15+
/// Hosts a Media3 player with its standard controls. The player prepares
16+
/// paused on the first frame; the user starts playback from the controls.
17+
@Composable
18+
internal actual fun RenderVideoPlayer(node: ViewNode) {
19+
val url = node.string("url") ?: return
20+
val context = LocalContext.current
21+
val player = remember(node.id, url) {
22+
ExoPlayer.Builder(context).build().apply {
23+
setMediaItem(MediaItem.fromUri(url))
24+
playWhenReady = false
25+
prepare()
26+
}
27+
}
28+
DisposableEffect(node.id, url) {
29+
onDispose { player.release() }
30+
}
31+
AndroidView(
32+
factory = { PlayerView(it).apply { this.player = player } },
33+
modifier = node.composeModifiers().fillMaxWidth().height(220.dp),
34+
)
35+
}

Demo/swiftui/src/commonMain/kotlin/com/pureswift/swiftui/Render.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,8 @@ private fun RenderResolved(node: ViewNode) {
258258

259259
"Map" -> RenderMap(node)
260260

261+
"VideoPlayer" -> RenderVideoPlayer(node)
262+
261263
"ProgressView" -> {
262264
val value = node.double("value")
263265
if (value != null) {
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.pureswift.swiftui
2+
3+
import androidx.compose.runtime.Composable
4+
5+
/// A video player for the node's `url`. Platform-specific: Android hosts a
6+
/// system player view with playback controls; the desktop rig shows a
7+
/// placeholder.
8+
@Composable
9+
internal expect fun RenderVideoPlayer(node: ViewNode)
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.pureswift.swiftui
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.fillMaxWidth
6+
import androidx.compose.foundation.layout.height
7+
import androidx.compose.runtime.Composable
8+
import androidx.compose.ui.Alignment
9+
import androidx.compose.ui.graphics.Color
10+
import androidx.compose.material3.Text
11+
import androidx.compose.ui.unit.dp
12+
13+
/// The desktop rig has no media stack; show a labeled placeholder.
14+
@Composable
15+
internal actual fun RenderVideoPlayer(node: ViewNode) {
16+
Box(
17+
contentAlignment = Alignment.Center,
18+
modifier = node.composeModifiers().fillMaxWidth().height(220.dp).background(Color.Black),
19+
) {
20+
Text("${node.string("url") ?: ""}", color = Color.White)
21+
}
22+
}

0 commit comments

Comments
 (0)