Skip to content

Commit c528291

Browse files
author
Arvin
committed
feat(player): add on-device seek previews
1 parent b8b86de commit c528291

6 files changed

Lines changed: 1064 additions & 9 deletions

File tree

app/build.gradle.kts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,15 @@ ksp {
346346
implementation("androidx.media3:media3-exoplayer-hls:$media3Version")
347347
implementation("androidx.media3:media3-exoplayer-dash:$media3Version")
348348
implementation("androidx.media3:media3-datasource-okhttp:$media3Version")
349+
implementation("androidx.media3:media3-effect:$media3Version") {
350+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-guava")
351+
}
352+
implementation("androidx.media3:media3-inspector:$media3Version") {
353+
// Inspector's optional Kotlin Future adapter pulls Coroutines 1.9 into this app, while
354+
// ARVIO's Ktor stack is intentionally pinned to 1.7.3. FrameExtractor is Java/Guava-based
355+
// and does not need that adapter.
356+
exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-guava")
357+
}
349358
implementation("androidx.media3:media3-ui:$media3Version")
350359
implementation("androidx.media3:media3-session:$media3Version")
351360
implementation("androidx.media3:media3-common:$media3Version")
4.1 KB
Binary file not shown.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package com.arflix.tv.ui.screens.player.preview
2+
3+
import android.content.Context
4+
import android.net.Uri
5+
import androidx.test.core.app.ApplicationProvider
6+
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import androidx.test.platform.app.InstrumentationRegistry
8+
import fi.iki.elonen.NanoHTTPD
9+
import java.io.ByteArrayInputStream
10+
import java.io.File
11+
import java.net.ServerSocket
12+
import java.util.concurrent.atomic.AtomicBoolean
13+
import kotlinx.coroutines.runBlocking
14+
import okhttp3.OkHttpClient
15+
import org.junit.Assert.assertFalse
16+
import org.junit.Assert.assertNotNull
17+
import org.junit.Assert.assertTrue
18+
import org.junit.Test
19+
import org.junit.runner.RunWith
20+
21+
@RunWith(AndroidJUnit4::class)
22+
class SeekPreviewFrameProviderDeviceTest {
23+
@Test
24+
fun localVideoReturnsDifferentFramesAcrossTimeline() = runBlocking {
25+
val context = ApplicationProvider.getApplicationContext<Context>()
26+
val sourceFile = File(context.cacheDir, "seek_preview_device_test.mp4")
27+
InstrumentationRegistry.getInstrumentation().context.assets
28+
.open("seek_preview_device_test.mp4").use { input ->
29+
sourceFile.outputStream().use(input::copyTo)
30+
}
31+
val provider = SeekPreviewFrameProvider(
32+
context = context,
33+
playbackClient = OkHttpClient(),
34+
memoryClassMb = 256,
35+
)
36+
37+
try {
38+
provider.configure(
39+
SeekPreviewSource(
40+
url = Uri.fromFile(sourceFile).toString(),
41+
headers = emptyMap(),
42+
cacheIdentity = "device-test-three-scenes",
43+
durationMs = 30_000L,
44+
isLive = false,
45+
isAdaptive = false,
46+
)
47+
)
48+
49+
val openingFrame = provider.frameAt(2_000L)
50+
val endingFrame = provider.frameAt(22_000L)
51+
52+
assertNotNull("The opening preview frame should be decoded", openingFrame)
53+
assertNotNull("The ending preview frame should be decoded", endingFrame)
54+
assertFalse(
55+
"Timeline previews must change as the scrub position changes",
56+
openingFrame!!.bitmap.sameAs(endingFrame!!.bitmap),
57+
)
58+
} finally {
59+
provider.close()
60+
sourceFile.delete()
61+
}
62+
}
63+
64+
@Test
65+
fun rangedHttpVideoReturnsDifferentFramesAcrossTimeline() = runBlocking {
66+
val context = ApplicationProvider.getApplicationContext<Context>()
67+
val videoBytes = InstrumentationRegistry.getInstrumentation().context.assets
68+
.open("seek_preview_device_test.mp4").use { it.readBytes() }
69+
val port = ServerSocket(0).use { it.localPort }
70+
val server = RangeVideoServer(videoBytes, port).apply {
71+
start(NanoHTTPD.SOCKET_READ_TIMEOUT, false)
72+
}
73+
val provider = SeekPreviewFrameProvider(
74+
context = context,
75+
playbackClient = OkHttpClient(),
76+
memoryClassMb = 256,
77+
)
78+
79+
try {
80+
provider.configure(
81+
SeekPreviewSource(
82+
url = "http://127.0.0.1:$port/video.mp4",
83+
headers = mapOf("X-Preview-Test" to "range"),
84+
cacheIdentity = "device-test-range-http-$port",
85+
durationMs = 30_000L,
86+
isLive = false,
87+
isAdaptive = false,
88+
)
89+
)
90+
91+
val openingFrame = provider.frameAt(2_000L)
92+
val endingFrame = provider.frameAt(22_000L)
93+
94+
assertNotNull("The ranged opening frame should be decoded", openingFrame)
95+
assertNotNull("The ranged ending frame should be decoded", endingFrame)
96+
assertFalse(
97+
"Ranged HTTP previews must change with the scrub position",
98+
openingFrame!!.bitmap.sameAs(endingFrame!!.bitmap),
99+
)
100+
assertTrue("Playback authentication headers must reach the media server", server.sawAuthHeader.get())
101+
assertTrue("Progressive previews must use byte-range requests", server.sawRangeRequest.get())
102+
} finally {
103+
provider.close()
104+
server.stop()
105+
}
106+
}
107+
108+
private class RangeVideoServer(
109+
private val videoBytes: ByteArray,
110+
port: Int,
111+
) : NanoHTTPD(port) {
112+
val sawAuthHeader = AtomicBoolean(false)
113+
val sawRangeRequest = AtomicBoolean(false)
114+
115+
override fun serve(session: IHTTPSession): Response {
116+
if (session.headers["x-preview-test"] == "range") {
117+
sawAuthHeader.set(true)
118+
}
119+
if (session.method == Method.HEAD) {
120+
return newFixedLengthResponse(Response.Status.OK, "video/mp4", "").apply {
121+
addHeader("Content-Length", videoBytes.size.toString())
122+
addHeader("Accept-Ranges", "bytes")
123+
}
124+
}
125+
val requestedRange = session.headers["range"]
126+
?.removePrefix("bytes=")
127+
?.substringBefore(',')
128+
if (requestedRange != null) {
129+
sawRangeRequest.set(true)
130+
val start = requestedRange.substringBefore('-').toLongOrNull()?.coerceAtLeast(0L) ?: 0L
131+
if (start >= videoBytes.size) {
132+
return newFixedLengthResponse(Response.Status.RANGE_NOT_SATISFIABLE, "text/plain", "")
133+
}
134+
val requestedEnd = requestedRange.substringAfter('-', "").toLongOrNull()
135+
val end = (requestedEnd ?: videoBytes.lastIndex.toLong())
136+
.coerceIn(start, videoBytes.lastIndex.toLong())
137+
val length = (end - start + 1L).toInt()
138+
return newFixedLengthResponse(
139+
Response.Status.PARTIAL_CONTENT,
140+
"video/mp4",
141+
ByteArrayInputStream(videoBytes, start.toInt(), length),
142+
length.toLong(),
143+
).apply {
144+
addHeader("Accept-Ranges", "bytes")
145+
addHeader("Content-Range", "bytes $start-$end/${videoBytes.size}")
146+
}
147+
}
148+
return newFixedLengthResponse(
149+
Response.Status.OK,
150+
"video/mp4",
151+
ByteArrayInputStream(videoBytes),
152+
videoBytes.size.toLong(),
153+
).apply { addHeader("Accept-Ranges", "bytes") }
154+
}
155+
}
156+
}

0 commit comments

Comments
 (0)