Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,7 @@ jobs:
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
tag_name: ${{ env.RELEASE_TAG }}
target_commitish: ${{ github.sha }}
name: ${{ env.RELEASE_TAG }}
body_path: release-notes/${{ env.RELEASE_TAG }}.md
files: |
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ android {

defaultConfig {
applicationId = "dev.typetype.android"
versionCode = 11110
versionName = "1.11.0-beta.10"
versionCode = 11112
versionName = "1.12.0-beta.1"
}

signingConfigs {
Expand Down
8 changes: 8 additions & 0 deletions app/src/main/java/dev/typetype/android/AppNavHost.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
Expand Down Expand Up @@ -37,7 +38,9 @@ import dev.typetype.android.core.ui.navigation.ShortsRoute
import dev.typetype.android.core.ui.navigation.StorageSettingsRoute
import dev.typetype.android.core.ui.navigation.SubscriptionsRoute
import dev.typetype.android.core.ui.prewarm.LocalPlaybackPrewarm
import dev.typetype.android.core.ui.prewarm.LocalSettledCardPrefetch
import dev.typetype.android.core.ui.prewarm.PlaybackPrewarm
import dev.typetype.android.core.ui.prewarm.SettledCardPrefetch
import dev.typetype.android.core.ui.share.LocalServerBaseUrl
import dev.typetype.android.domain.playback.toPlaybackQueueEntry
import dev.typetype.android.feature.home.HomeRoute as HomeRouteScreen
Expand Down Expand Up @@ -72,6 +75,10 @@ fun AppNavHost(
) {
val navController: NavHostController = rememberNavController()
val playerHostController = remember { mainViewModel.playerHostController }
val prefetchScope = rememberCoroutineScope()
val settledCardPrefetch = remember(playbackPrewarm, prefetchScope) {
playbackPrewarm?.let { SettledCardPrefetch(prefetchScope, it) }
}
val currentServer by mainViewModel.currentServer.collectAsStateWithLifecycle()
val currentProfile by mainViewModel.currentProfile.collectAsStateWithLifecycle()
val appPreferences by mainViewModel.preferences.collectAsStateWithLifecycle()
Expand All @@ -98,6 +105,7 @@ fun AppNavHost(
LocalServerBaseUrl provides currentServer?.baseUrl,
LocalDeArrowBranding provides deArrowEnvironment,
LocalPlaybackPrewarm provides playbackPrewarm,
LocalSettledCardPrefetch provides settledCardPrefetch,
LocalPlaybackRecovery provides (playbackRecovery ?: LocalPlaybackRecovery.current),
) {
AppShell(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

internal class ActivePlaybackReporter(
Expand All @@ -21,16 +20,11 @@ internal class ActivePlaybackReporter(
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var startedIdentity: PlaybackIdentity? = null
private var startJob: Job? = null
private val progressJob: Job
private val heartbeat = PlaybackProgressHeartbeat(scope, PROGRESS_INTERVAL_MILLIS, report = ::reportProgress)

init {
player.addListener(this)
progressJob = scope.launch {
while (true) {
delay(PROGRESS_INTERVAL_MILLIS)
reportProgress()
}
}
heartbeat.onPlayingChanged(player.isPlaying)
}

override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
Expand All @@ -47,6 +41,7 @@ internal class ActivePlaybackReporter(
}

override fun onIsPlayingChanged(isPlaying: Boolean) {
heartbeat.onPlayingChanged(isPlaying)
if (isPlaying) startIfNeeded() else reportProgress()
}

Expand All @@ -70,7 +65,7 @@ internal class ActivePlaybackReporter(

override fun close() {
player.removeListener(this)
progressJob.cancel()
heartbeat.stop()
startJob?.cancel()
val identity = startedIdentity
startedIdentity = null
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package dev.typetype.android.services

import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

internal class PlaybackProgressHeartbeat(
private val scope: CoroutineScope,
private val intervalMillis: Long,
private val wait: suspend (Long) -> Unit = { delay(it) },
private val report: () -> Unit,
) {
private var job: Job? = null

fun onPlayingChanged(isPlaying: Boolean) {
if (!isPlaying) {
stop()
return
}
if (job?.isActive == true) return
job = scope.launch {
while (true) {
wait(intervalMillis)
report()
}
}
}

fun stop() {
job?.cancel()
job = null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package dev.typetype.android.services

import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.yield
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test

class PlaybackProgressHeartbeatTest {
@Test
fun `reports once per interval while playing`() = runBlocking {
val ticks = Channel<Unit>()
var reports = 0
val heartbeat = PlaybackProgressHeartbeat(this, INTERVAL, { ticks.receive() }) { reports++ }

heartbeat.onPlayingChanged(true)
ticks.send(Unit)
ticks.send(Unit)
yield()

assertEquals(2, reports)
heartbeat.stop()
}

@Test
fun `sends nothing while paused`() = runBlocking {
val ticks = Channel<Unit>()
var reports = 0
val heartbeat = PlaybackProgressHeartbeat(this, INTERVAL, { ticks.receive() }) { reports++ }

heartbeat.onPlayingChanged(true)
ticks.send(Unit)
yield()
heartbeat.onPlayingChanged(false)
yield()

assertTrue(ticks.trySend(Unit).isFailure)
assertEquals(1, reports)
}

@Test
fun `sends nothing before playback starts`() = runBlocking {
val ticks = Channel<Unit>()
var reports = 0
PlaybackProgressHeartbeat(this, INTERVAL, { ticks.receive() }) { reports++ }
yield()

assertTrue(ticks.trySend(Unit).isFailure)
assertEquals(0, reports)
}

@Test
fun `resumes reporting after a pause`() = runBlocking {
val ticks = Channel<Unit>()
var reports = 0
val heartbeat = PlaybackProgressHeartbeat(this, INTERVAL, { ticks.receive() }) { reports++ }

heartbeat.onPlayingChanged(true)
heartbeat.onPlayingChanged(false)
heartbeat.onPlayingChanged(true)
ticks.send(Unit)
yield()

assertEquals(1, reports)
heartbeat.stop()
}

private companion object {
const val INTERVAL = 15_000L
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package dev.typetype.android.core.ui.prewarm

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Text
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeUp
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import java.util.Collections
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class CardPlaybackPrewarmTest {
@get:Rule
val composeRule = createComposeRule()

private val prewarm = RecordingPrewarm()

@Test
fun tappingACardConfirmsItsSession() {
showCards()

composeRule.onNodeWithText("video-0").performClick()
composeRule.waitForIdle()

assertEquals(listOf("confirm:video-0"), prewarm.pressEvents)
}

@Test
fun scrollingPastACardCancelsItsSession() {
showCards()

composeRule.onNodeWithTag(LIST_TAG).performTouchInput { swipeUp() }
composeRule.waitForIdle()

assertTrue(prewarm.pressEvents.isNotEmpty())
assertTrue(prewarm.pressEvents.all { it.startsWith("cancel:") })
}

@Test
fun aSettledListPrefetchesOnlyTheMostVisibleStreams() {
showCards()

composeRule.waitUntil(timeoutMillis = 5_000) { prewarm.batches.isNotEmpty() }

assertEquals(listOf(listOf(url(0), url(1))), prewarm.batches.map { batch -> batch.map { it.videoUrl } })
assertTrue(prewarm.pressEvents.isEmpty())
}

private fun showCards() {
composeRule.setContent {
val scope = rememberCoroutineScope()
val settled = remember { SettledCardPrefetch(scope, prewarm) }
CompositionLocalProvider(
LocalPlaybackPrewarm provides prewarm,
LocalSettledCardPrefetch provides settled,
) {
LazyColumn(Modifier.testTag(LIST_TAG)) {
items(30) { index ->
Box(
Modifier
.cardPlaybackPrewarm(rememberCardPlaybackPrewarm(url(index), isLive = false))
.fillMaxWidth()
.height(160.dp)
.clickable {},
) {
Text("video-$index")
}
}
}
}
}
}

private fun url(index: Int) = "https://www.youtube.com/watch?v=video-$index"

private class RecordingPrewarm : PlaybackPrewarm {
val batches: MutableList<List<PrewarmRequest>> = Collections.synchronizedList(mutableListOf())
val pressEvents: MutableList<String> = Collections.synchronizedList(mutableListOf())

override fun prefetchStreams(requests: List<PrewarmRequest>) {
batches += requests
}

override fun cancelStreamPrefetch() = Unit

override fun pressSession(request: PrewarmRequest): PressedSession {
val id = request.videoUrl.substringAfter("v=")
return object : PressedSession {
override fun confirm() {
pressEvents += "confirm:$id"
}

override fun cancel() {
pressEvents += "cancel:$id"
}
}
}
}

private companion object {
const val LIST_TAG = "cards"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
Expand All @@ -34,15 +33,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.typetype.android.R
import dev.typetype.android.core.ui.branding.rememberVideoBranding
import dev.typetype.android.core.ui.prewarm.CARD_PREWARM_DELAY_MILLIS
import dev.typetype.android.core.ui.prewarm.LocalPlaybackPrewarm
import dev.typetype.android.core.ui.prewarm.cardPlaybackPrewarm
import dev.typetype.android.core.ui.prewarm.rememberCardPlaybackPrewarm
import dev.typetype.android.domain.feed.Video
import dev.typetype.android.domain.feed.VideoAvailability
import dev.typetype.android.domain.feed.VideoBadgeStatus
import dev.typetype.android.domain.feed.availabilityAt
import dev.typetype.android.domain.feed.badgeStatusAt
import dev.typetype.android.domain.feed.releaseTimeMillis
import kotlinx.coroutines.delay

@OptIn(ExperimentalFoundationApi::class)
@Composable
Expand All @@ -56,13 +54,7 @@ fun RelatedVideoCard(
) {
var menuVisible by remember { mutableStateOf(false) }
var availabilityVisible by remember { mutableStateOf(false) }
val playbackPrewarm = LocalPlaybackPrewarm.current

LaunchedEffect(video.url, playbackPrewarm) {
if (playbackPrewarm == null) return@LaunchedEffect
delay(CARD_PREWARM_DELAY_MILLIS)
playbackPrewarm.prewarm(video.url, video.isLive)
}
val playbackPrewarm = rememberCardPlaybackPrewarm(video.url, video.isLive)

val availability = video.availabilityAt(System.currentTimeMillis())
val branding = rememberVideoBranding(
Expand All @@ -73,6 +65,7 @@ fun RelatedVideoCard(
)
Row(
modifier = modifier
.cardPlaybackPrewarm(playbackPrewarm)
.fillMaxWidth()
.combinedClickable(
onClick = {
Expand Down
Loading