diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70fe93e8..157de064 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "dev.typetype.android" minSdk = 23 targetSdk = 37 - versionCode = 10826 - versionName = "1.9.0-beta.3" + versionCode = 10901 + versionName = "1.9.1-beta.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/app/src/androidTest/java/dev/typetype/android/core/ui/components/RetryingImageTest.kt b/app/src/androidTest/java/dev/typetype/android/core/ui/components/RetryingImageTest.kt new file mode 100644 index 00000000..a0bd2325 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/core/ui/components/RetryingImageTest.kt @@ -0,0 +1,56 @@ +package dev.typetype.android.core.ui.components + +import android.graphics.Bitmap +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.size +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.unit.dp +import coil3.request.ImageRequest +import java.io.File +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class RetryingImageTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun failedImageRecoversWithoutChangingItsUrlOrLeavingTheScreen() { + val file = File.createTempFile("retry-image", ".png", rule.activity.cacheDir) + file.delete() + val errors = AtomicInteger() + val loaded = AtomicBoolean() + val request = ImageRequest.Builder(rule.activity).data(file).build() + try { + rule.setContent { + RetryingImage( + request = request, + contentDescription = "Test image", + contentScale = ContentScale.Crop, + modifier = Modifier.size(48.dp), + onLoaded = { success -> + if (success) { + loaded.set(true) + } else { + errors.incrementAndGet() + val bitmap = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888) + file.outputStream().use { + bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) + } + bitmap.recycle() + } + }, + ) + } + rule.waitUntil(10_000) { loaded.get() } + assertEquals(1, errors.get()) + } finally { + file.delete() + } + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerPlaceholderLayerTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerPlaceholderLayerTest.kt new file mode 100644 index 00000000..76b2e867 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerPlaceholderLayerTest.kt @@ -0,0 +1,46 @@ +package dev.typetype.android.feature.player + +import androidx.activity.ComponentActivity +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toPixelMap +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.captureToImage +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.unit.dp +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class PlayerPlaceholderLayerTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun errorArtworkDisappearsWhenCollapsedAndReturnsWhenExpanded() { + val progress = mutableFloatStateOf(0f) + rule.setContent { + Box(Modifier.size(120.dp).background(Color.Blue).testTag("viewport")) { + PlayerPlaceholderLayer({ progress.floatValue }) { + Box(Modifier.size(120.dp).background(Color.Red)) + } + } + } + assertCenter(Color.Red) + rule.runOnIdle { progress.floatValue = 1f } + assertCenter(Color.Blue) + rule.runOnIdle { progress.floatValue = 0f } + assertCenter(Color.Red) + } + + private fun assertCenter(expected: Color) { + rule.waitForIdle() + val pixels = rule.onNodeWithTag("viewport").captureToImage().toPixelMap() + assertEquals(expected, pixels[pixels.width / 2, pixels.height / 2]) + } +} diff --git a/app/src/androidTest/java/dev/typetype/android/services/PublicVodSeekMeasurementTest.kt b/app/src/androidTest/java/dev/typetype/android/services/PublicVodSeekMeasurementTest.kt new file mode 100644 index 00000000..2984c094 --- /dev/null +++ b/app/src/androidTest/java/dev/typetype/android/services/PublicVodSeekMeasurementTest.kt @@ -0,0 +1,81 @@ +package dev.typetype.android.services + +import android.content.ComponentName +import android.content.Intent +import android.net.Uri +import android.os.SystemClock +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import androidx.test.platform.app.InstrumentationRegistry +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import kotlin.math.abs +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test + +@UnstableApi +class PublicVodSeekMeasurementTest { + private val instrumentation = InstrumentationRegistry.getInstrumentation() + + @Test + fun measurePublicVodSeekReadinessWithPinnedTracks() { + assumeTrue(InstrumentationRegistry.getArguments().getString("publicVodSeekMeasurement") == "true") + val context = instrumentation.targetContext + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse("https://www.youtube.com/watch?v=tXH5EjM_96c")) + .setPackage(context.packageName) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + ) + val controller = MediaController.Builder( + context, + SessionToken(context, ComponentName(context, PlaybackService::class.java)), + ).buildAsync().get(10, TimeUnit.SECONDS) + try { + val deadline = SystemClock.elapsedRealtime() + 40_000L + while (SystemClock.elapsedRealtime() < deadline && read { controller.currentTracks.groups.isEmpty() }) { + Thread.sleep(50L) + } + val tracks = read { + controller.currentTracks.groups.flatMap { group -> + (0 until group.length).filter(group::isTrackSelected).map { index -> + val format = group.getTrackFormat(index) + "${format.id}:${format.sampleMimeType}" + } + } + } + println("Selected public VOD tracks: $tracks") + assertTrue("Expected video itag 299, got $tracks", tracks.any { it.startsWith("299:") }) + assertTrue("Expected audio itag 140, got $tracks", tracks.any { it.startsWith("140:") }) + for (target in listOf(999_108L, 225_191L)) { + val started = SystemClock.elapsedRealtime() + instrumentation.runOnMainSync { + controller.play() + controller.seekTo(target) + } + var ready = false + while (SystemClock.elapsedRealtime() - started < 45_000L) { + ready = read { + controller.playerError == null && controller.isPlaying && + controller.playbackState == Player.STATE_READY && + abs(controller.currentPosition - target) < 4_000L + } + if (ready) break + Thread.sleep(25L) + } + println("Android controller ready: targetMs=$target delayMs=${SystemClock.elapsedRealtime() - started}") + assertTrue("Seek did not recover: $target", ready) + } + } finally { + instrumentation.runOnMainSync { controller.release() } + } + } + + private fun read(block: () -> T): T { + val result = AtomicReference>() + instrumentation.runOnMainSync { result.set(runCatching(block)) } + return requireNotNull(result.get()).getOrThrow() + } +} diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt b/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt index ef0efa8d..b89aad21 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt @@ -10,7 +10,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -23,7 +22,6 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import dev.typetype.android.core.ui.share.LocalServerBaseUrl @@ -40,8 +38,7 @@ fun ChannelAvatar( ) { val serverBaseUrl = LocalServerBaseUrl.current val context = LocalPlatformContext.current - var attempt by remember(avatarUrl) { mutableIntStateOf(0) } - var loaded by remember(avatarUrl, attempt) { mutableStateOf(false) } + var loaded by remember(serverBaseUrl, avatarUrl) { mutableStateOf(false) } val hasSource = avatarUrl.isNotBlank() var missingExpired by remember(avatarUrl) { mutableStateOf(false) } @@ -73,16 +70,13 @@ fun ChannelAvatar( ) } if (hasSource) { - AsyncImage( - model = ImageRequest.Builder(context) - .data(avatarRetryUrl(buildImageUrl(serverBaseUrl, avatarUrl), attempt)) + RetryingImage( + request = ImageRequest.Builder(context) + .data(buildImageUrl(serverBaseUrl, avatarUrl)) .build(), contentDescription = null, contentScale = ContentScale.Crop, - onSuccess = { loaded = true }, - onError = { - if (attempt < MAX_AVATAR_RETRIES) attempt += 1 - }, + onLoaded = { loaded = it }, modifier = Modifier.fillMaxSize(), ) } @@ -102,5 +96,4 @@ internal fun avatarInitial(name: String): String = name.trim().firstOrNull()?.uppercase() ?: "?" private const val AVATAR_RETRY_PARAM = "_tt_avatar_retry" -private const val MAX_AVATAR_RETRIES = 2 private const val MISSING_AVATAR_GRACE_MILLIS = 1_500L diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/RetryingImage.kt b/app/src/main/java/dev/typetype/android/core/ui/components/RetryingImage.kt new file mode 100644 index 00000000..fd2dae2e --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/ui/components/RetryingImage.kt @@ -0,0 +1,49 @@ +package dev.typetype.android.core.ui.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import kotlinx.coroutines.delay + +@Composable +internal fun RetryingImage( + request: ImageRequest, + contentDescription: String?, + contentScale: ContentScale, + modifier: Modifier, + onLoaded: (Boolean) -> Unit, +) { + var attempt by remember(request.data) { mutableIntStateOf(0) } + var failed by remember(request.data, attempt) { mutableStateOf(false) } + LaunchedEffect(request.data, attempt, failed) { + if (failed) { + delay(imageRetryDelayMillis(attempt)) + attempt += 1 + } + } + key(request.data, attempt) { + AsyncImage( + model = request, + contentDescription = contentDescription, + contentScale = contentScale, + onSuccess = { onLoaded(true) }, + onError = { + onLoaded(false) + failed = true + }, + modifier = modifier, + ) + } +} + +internal fun imageRetryDelayMillis(attempt: Int): Long = + 1_000L shl attempt.coerceIn(0, 5) diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt b/app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt index c3b84609..c48434be 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale -import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import coil3.request.crossfade @@ -28,22 +27,21 @@ fun SkeletonImage( ) { val serverBaseUrl = LocalServerBaseUrl.current val context = LocalPlatformContext.current - var loaded by remember(imageUrl) { mutableStateOf(false) } + var loaded by remember(serverBaseUrl, imageUrl) { mutableStateOf(false) } val hasSource = imageUrl.isNotBlank() Box(modifier = modifier.clip(shape)) { if (hasSource && !loaded) { TypeTypeSkeleton(modifier = Modifier.matchParentSize(), shape = shape) } if (hasSource) { - AsyncImage( - model = ImageRequest.Builder(context) + RetryingImage( + request = ImageRequest.Builder(context) .data(buildImageUrl(serverBaseUrl, imageUrl)) .apply { if (crossfadeMillis > 0) crossfade(crossfadeMillis) } .build(), contentDescription = contentDescription, contentScale = contentScale, - onSuccess = { loaded = true }, - onError = { loaded = false }, + onLoaded = { loaded = it }, modifier = Modifier.matchParentSize(), ) } diff --git a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackContract.kt b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackContract.kt index 16df1470..72537464 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackContract.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackContract.kt @@ -182,13 +182,17 @@ private fun SabrPlaybackWindowTrackDto.requireTrack( sabrContractMismatch("SABR returned an empty $expectedKind window") } var previousEndMs = -1L - val resolvedSegments = segments.map { segment -> + val resolvedSegments = segments.mapIndexed { index, segment -> if ( segment.startMs < 0L || segment.durationMs <= 0L || segment.startMs < previousEndMs && previousEndMs - segment.startMs > TIMELINE_ROUNDING_TOLERANCE_MS ) { - sabrContractMismatch("SABR returned an invalid $expectedKind timeline") + sabrContractMismatch( + "SABR returned an invalid $expectedKind timeline: index=$index " + + "startMs=${segment.startMs} durationMs=${segment.durationMs} " + + "previousEndMs=$previousEndMs", + ) } val resolvedUrl = requireMediaUrl( baseUrl, diff --git a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionProtocol.kt b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionProtocol.kt index 9ca84b64..c6548e27 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionProtocol.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionProtocol.kt @@ -49,7 +49,10 @@ internal fun SabrPlaybackResponse.windowRequest( audioItag = audioItag, audioTrackId = audioTrackId, playbackRate = playbackRate.sanitizedPlaybackRate(), - bufferGoalMs = playbackRate.rateAwareBufferGoalMs(isLive), + bufferGoalMs = playbackRate.rateAwareBufferGoalMs( + isLive, + hasBufferedPlayback(ranges, playerTimeMs, audioOnly), + ), bufferedRanges = ranges.map { SabrPlaybackBufferedRangeDto(it.itag, it.startMs, it.endMs) }, @@ -122,7 +125,8 @@ internal val RECOVERABLE_CONTROL_STATUS_CODES = setOf(404, 409, 410) private const val DEFAULT_RETRY_MS = 500L private const val MIN_RETRY_MS = 250L private const val MAX_RETRY_MS = 2_000L -private const val VOD_BUFFER_GOAL_MS = 30_000L +private const val VOD_INITIAL_BUFFER_GOAL_MS = 2_500L +private const val VOD_BUFFER_GOAL_MS = 10_000L private const val LIVE_BUFFER_GOAL_MS = 8_000L private const val MAX_BUFFER_GOAL_MS = 60_000L private const val MIN_PLAYBACK_RATE = 0.25f @@ -131,8 +135,25 @@ private const val MAX_PLAYBACK_RATE = 4.0f private fun Float.sanitizedPlaybackRate(): Float = takeIf { isFinite() && this in MIN_PLAYBACK_RATE..MAX_PLAYBACK_RATE } ?: 1.0f -private fun Float.rateAwareBufferGoalMs(isLive: Boolean): Long = - ((if (isLive) LIVE_BUFFER_GOAL_MS else VOD_BUFFER_GOAL_MS) * +private fun Float.rateAwareBufferGoalMs(isLive: Boolean, hasBufferedPlayback: Boolean): Long = + ((when { + isLive -> LIVE_BUFFER_GOAL_MS + hasBufferedPlayback -> VOD_BUFFER_GOAL_MS + else -> VOD_INITIAL_BUFFER_GOAL_MS + }) * maxOf(1.0f, sanitizedPlaybackRate())) .toLong() .coerceAtMost(MAX_BUFFER_GOAL_MS) + +private fun SabrPlaybackResponse.hasBufferedPlayback( + ranges: List, + playerTimeMs: Long, + audioOnly: Boolean, +): Boolean { + val requiredItags = listOfNotNull(videoItag.takeUnless { audioOnly }, audioItag) + return requiredItags.isNotEmpty() && requiredItags.all { itag -> + ranges.any { range -> + range.itag == itag && range.startMs <= playerTimeMs && range.endMs > playerTimeMs + } + } +} diff --git a/app/src/main/java/dev/typetype/android/data/stream/TransientPlaybackRequest.kt b/app/src/main/java/dev/typetype/android/data/stream/TransientPlaybackRequest.kt index b3c135f2..e422d98e 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/TransientPlaybackRequest.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/TransientPlaybackRequest.kt @@ -58,4 +58,4 @@ internal suspend fun transientPlaybackRequest( private const val MAX_SERVER_RESPONSE_RETRIES = 24 private const val MAX_CONNECTED_TRANSPORT_RETRIES = 8 -private const val MAX_OFFLINE_WAIT_MS = 30 * 60 * 1_000L +private const val MAX_OFFLINE_WAIT_MS = 10_000L diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerPlaceholderLayer.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerPlaceholderLayer.kt new file mode 100644 index 00000000..48762816 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerPlaceholderLayer.kt @@ -0,0 +1,28 @@ +package dev.typetype.android.feature.player + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.semantics.clearAndSetSemantics + +@Composable +internal fun PlayerPlaceholderLayer( + hostTransitionProgress: () -> Float, + content: @Composable () -> Unit, +) { + val hidden by remember(hostTransitionProgress) { + derivedStateOf { hostTransitionProgress() >= 0.99f } + } + Box( + Modifier.fillMaxSize() + .graphicsLayer { alpha = (1f - hostTransitionProgress()).coerceIn(0f, 1f) } + .then(if (hidden) Modifier.clearAndSetSemantics {} else Modifier), + ) { + content() + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceStateMapping.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceStateMapping.kt new file mode 100644 index 00000000..6ab6ffcb --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceStateMapping.kt @@ -0,0 +1,10 @@ +package dev.typetype.android.feature.player + +internal fun PlayerState.applyPreferences(prefs: PlayerPreferenceState): PlayerState = copy( + gestureConfig = prefs.gestureConfig, + playbackBrightnessPercent = prefs.brightnessPercent, + autoplayCountdownSeconds = prefs.autoplayCountdownSeconds, + audioOnlyPlaybackDefault = prefs.audioOnlyPlaybackDefault, + preferredCodec = prefs.preferredCodec, + userSettings = prefs.userSettings, +) diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerScreen.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerScreen.kt index 4f2b2d7a..853b4ec6 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerScreen.kt @@ -152,13 +152,17 @@ fun PlayerScreen( color = MaterialTheme.colorScheme.background, ) { when { - state.isLoading -> LoadingState() - state.error != null -> ErrorState( - classification = state.error, - onNavigateBack = onNavigateBack, - onRetry = { onAction(PlayerAction.OnRetry) }, - onOpenAccounts = onOpenAccounts, - ) + state.isLoading -> PlayerPlaceholderLayer(hostTransitionProgress) { + LoadingState() + } + state.error != null -> PlayerPlaceholderLayer(hostTransitionProgress) { + ErrorState( + classification = state.error, + onNavigateBack = onNavigateBack, + onRetry = { onAction(PlayerAction.OnRetry) }, + onOpenAccounts = onOpenAccounts, + ) + } state.stream != null -> LoadedPlayer( stream = state.stream, videoUrl = state.videoUrl, diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerStreamNetworkRecovery.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerStreamNetworkRecovery.kt new file mode 100644 index 00000000..e4335781 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerStreamNetworkRecovery.kt @@ -0,0 +1,38 @@ +package dev.typetype.android.feature.player + +import dev.typetype.android.data.network.PlaybackNetworkState +import dev.typetype.android.feature.player.error.StreamErrorKind +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +internal fun observePlayerStreamNetworkRecovery( + scope: CoroutineScope, + playerStates: StateFlow, + networkStates: StateFlow, + retryStream: (String) -> Unit, +) { + val gate = PlayerStreamNetworkRetryGate() + scope.launch { + combine(playerStates, networkStates, gate::retryUrl) + .collect { url -> url?.let(retryStream) } + } +} + +internal class PlayerStreamNetworkRetryGate { + private var lastAttempt: Attempt? = null + + fun retryUrl(player: PlayerState, network: PlaybackNetworkState): String? { + if (!network.isAvailable || player.isLoading || player.stream != null || + player.error?.kind != StreamErrorKind.NetworkUnavailable + ) return null + val url = player.videoUrl.takeIf(String::isNotBlank) ?: return null + val attempt = Attempt(network.generation, url) + if (attempt == lastAttempt) return null + lastAttempt = attempt + return url + } + + private data class Attempt(val networkGeneration: Long, val videoUrl: String) +} diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt index a34c9201..b4175c22 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerViewModel.kt @@ -3,6 +3,7 @@ package dev.typetype.android.feature.player import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dev.typetype.android.data.network.PlaybackNetworkMonitor import dev.typetype.android.domain.comments.CommentsRepository import dev.typetype.android.domain.download.DownloadProgress import dev.typetype.android.domain.download.DownloadRepository @@ -42,6 +43,7 @@ class PlayerViewModel @Inject constructor( private val playbackQueueCoordinator: PlaybackQueueCoordinator, val commentsRepository: CommentsRepository, val subtitleCueLoader: PlayerSubtitleCueLoader, + networkMonitor: PlaybackNetworkMonitor, ) : ViewModel() { private val videoUrlFlow = combine( playerHostController.state.map { it.videoUrl }, @@ -76,6 +78,9 @@ class PlayerViewModel @Inject constructor( onWatchLaterChanged = { value -> _state.update { it.copy(isInWatchLater = value) } }, ) init { + observePlayerStreamNetworkRecovery( + viewModelScope, state, networkMonitor.states, ::loadStream, + ) viewModelScope.launch { videoUrlFlow.collect { url -> val hostState = playerHostController.state.value @@ -123,16 +128,7 @@ class PlayerViewModel @Inject constructor( private fun observePreferences() { viewModelScope.launch { playerPreferences.states.collect { prefs -> - _state.update { - it.copy( - gestureConfig = prefs.gestureConfig, - playbackBrightnessPercent = prefs.brightnessPercent, - autoplayCountdownSeconds = prefs.autoplayCountdownSeconds, - audioOnlyPlaybackDefault = prefs.audioOnlyPlaybackDefault, - preferredCodec = prefs.preferredCodec, - userSettings = prefs.userSettings, - ) - } + _state.update { it.applyPreferences(prefs) } } } viewModelScope.launch { playerPreferences.refresh() } diff --git a/app/src/test/java/dev/typetype/android/core/ui/components/ImageRetryDelayTest.kt b/app/src/test/java/dev/typetype/android/core/ui/components/ImageRetryDelayTest.kt new file mode 100644 index 00000000..af90ed6a --- /dev/null +++ b/app/src/test/java/dev/typetype/android/core/ui/components/ImageRetryDelayTest.kt @@ -0,0 +1,15 @@ +package dev.typetype.android.core.ui.components + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ImageRetryDelayTest { + @Test + fun retriesBackOffWithoutGrowingBeyondThirtyTwoSeconds() { + assertEquals(1_000L, imageRetryDelayMillis(0)) + assertEquals(2_000L, imageRetryDelayMillis(1)) + assertEquals(32_000L, imageRetryDelayMillis(5)) + assertEquals(32_000L, imageRetryDelayMillis(100)) + assertEquals(32_000L, imageRetryDelayMillis(Int.MAX_VALUE)) + } +} diff --git a/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackRateContractTest.kt b/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackRateContractTest.kt index 8115b1d4..6bbb0e94 100644 --- a/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackRateContractTest.kt +++ b/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackRateContractTest.kt @@ -1,24 +1,25 @@ package dev.typetype.android.data.stream import dev.typetype.android.data.network.dto.SabrPlaybackResponse +import dev.typetype.android.domain.stream.SabrPlaybackBufferedRange import org.junit.Assert.assertEquals import org.junit.Test class SabrPlaybackRateContractTest { @Test fun fasterPlaybackExpandsTheRequestedBuffer() { - val request = response().windowRequest(emptyList(), playbackRate = 2.0f) + val request = response().windowRequest(bufferedTracks(), playbackRate = 2.0f) assertEquals(2.0f, request.playbackRate) - assertEquals(60_000L, request.bufferGoalMs) + assertEquals(20_000L, request.bufferGoalMs) } @Test fun slowerPlaybackKeepsTheBaselineBuffer() { - val request = response().windowRequest(emptyList(), playbackRate = 0.5f) + val request = response().windowRequest(bufferedTracks(), playbackRate = 0.5f) assertEquals(0.5f, request.playbackRate) - assertEquals(30_000L, request.bufferGoalMs) + assertEquals(10_000L, request.bufferGoalMs) } @Test @@ -26,7 +27,7 @@ class SabrPlaybackRateContractTest { val request = response().windowRequest(emptyList(), playbackRate = Float.NaN) assertEquals(1.0f, request.playbackRate) - assertEquals(30_000L, request.bufferGoalMs) + assertEquals(2_500L, request.bufferGoalMs) } @Test @@ -41,6 +42,48 @@ class SabrPlaybackRateContractTest { assertEquals(16_000L, request.bufferGoalMs) } + @Test + fun emptyBufferStartsWithAShortWindow() { + assertEquals(2_500L, response().windowRequest(emptyList()).bufferGoalMs) + } + + @Test + fun seekOutsideBufferedTracksStartsWithAShortWindow() { + val request = response().windowRequest(bufferedTracks(), playerTimeMs = 999_108L) + + assertEquals(2_500L, request.bufferGoalMs) + } + + @Test + fun bothSelectedTracksMustCoverThePositionBeforeExpanding() { + assertEquals(2_500L, response().windowRequest(bufferedTracks().take(1)).bufferGoalMs) + assertEquals(10_000L, response().windowRequest(bufferedTracks()).bufferGoalMs) + } + + @Test + fun unrelatedTracksDoNotExpandTheWindow() { + val ranges = listOf(SabrPlaybackBufferedRange(303, 0L, 10_000L)) + + assertEquals(2_500L, response().windowRequest(ranges).bufferGoalMs) + } + + @Test + fun audioOnlyDoesNotRequireVideoCoverage() { + val request = response().windowRequest(bufferedTracks().takeLast(1), audioOnly = true) + + assertEquals(10_000L, request.bufferGoalMs) + } + + @Test + fun initialWindowScalesWithPlaybackSpeed() { + assertEquals(5_000L, response().windowRequest(emptyList(), playbackRate = 2.0f).bufferGoalMs) + } + + private fun bufferedTracks() = listOf( + SabrPlaybackBufferedRange(137, 0L, 10_000L), + SabrPlaybackBufferedRange(140, 0L, 10_000L), + ) + private fun response() = SabrPlaybackResponse( sessionId = "session", videoId = "video", diff --git a/app/src/test/java/dev/typetype/android/feature/player/PlayerStreamNetworkRecoveryTest.kt b/app/src/test/java/dev/typetype/android/feature/player/PlayerStreamNetworkRecoveryTest.kt new file mode 100644 index 00000000..3119d93e --- /dev/null +++ b/app/src/test/java/dev/typetype/android/feature/player/PlayerStreamNetworkRecoveryTest.kt @@ -0,0 +1,35 @@ +package dev.typetype.android.feature.player + +import dev.typetype.android.data.network.PlaybackNetworkState +import dev.typetype.android.feature.player.error.StreamErrorClass +import dev.typetype.android.feature.player.error.StreamErrorKind +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PlayerStreamNetworkRecoveryTest { + private val failure = StreamErrorClass(StreamErrorKind.NetworkUnavailable, rawMessage = null) + private val state = PlayerState(videoUrl = "https://video.example/public", isLoading = false, error = failure) + + @Test + fun recoversOncePerNetworkGeneration() { + val gate = PlayerStreamNetworkRetryGate() + assertNull(gate.retryUrl(state, PlaybackNetworkState(false, 1L))) + assertEquals(state.videoUrl, gate.retryUrl(state, PlaybackNetworkState(true, 2L))) + assertNull(gate.retryUrl(state, PlaybackNetworkState(true, 2L))) + assertEquals(state.videoUrl, gate.retryUrl(state, PlaybackNetworkState(true, 3L))) + } + + @Test + fun loadingAndOtherErrorsDoNotRetry() { + val gate = PlayerStreamNetworkRetryGate() + val online = PlaybackNetworkState(true, 2L) + assertNull(gate.retryUrl(state.copy(isLoading = true), online)) + assertNull(gate.retryUrl(state.copy(error = null), online)) + assertNull(gate.retryUrl(state.copy(videoUrl = ""), online)) + assertNull(gate.retryUrl(state.copy(error = StreamErrorClass( + StreamErrorKind.SabrUnavailable, rawMessage = null, + )), online)) + assertEquals(state.videoUrl, gate.retryUrl(state, online)) + } +} diff --git a/release-notes/v1.9.0.md b/release-notes/v1.9.0.md new file mode 100644 index 00000000..8fe8e877 --- /dev/null +++ b/release-notes/v1.9.0.md @@ -0,0 +1,63 @@ +# TypeType for Android 1.9.0 + +TypeType for Android 1.9.0 brings more reliable playback and subscription notifications, smoother loading states, and new player controls. It also moves the Android app from beta to stable. + +## Search + +- Switch between YouTube, BiliBili, and NicoNico directly from search. +- Keep the selected service in effect while searching and browsing suggestions. + +## Playback and providers + +- Play YouTube live streams from the server-provided live manifest, with a fallback when the SABR bootstrap cannot serve a video. +- Play Niconico and BiliBili media handles with the correct video and audio formats. +- Add previous and next video controls, and keep favorite and watch later state synchronized with the library. +- Prewarm SABR playback for video cards, autoplay, and Shorts to help playback start sooner, while skipping unnecessary prewarm work for live videos. +- Show provider access failures as unavailable content and label scheduled live streams as not started. +- Improve player transitions and remove the black strip during the pull-down gesture. + +## Loading and appearance + +- Use animated skeleton placeholders for feed cards, thumbnails, video details, and channel avatars instead of blank areas while content loads. +- Retry failed channel avatars and keep loaded avatars visible while lists update. +- Keep the accent color control disabled when Dynamic theme is selected. + +## Comments + +- Enable bullet comments on BiliBili, alongside NicoNico. + +## Notifications + +- Add subscription notifications with UnifiedPush, a channel bell, and a dedicated notification settings screen. +- Register the Web Push encryption keys required by the server to deliver UnifiedPush notifications. +- Check for new subscription videos locally every fifteen minutes, including when UnifiedPush is registered. + +## Compatibility + +- Support Android 6.0 through Android 17, API 23 through API 37. +- Fix a crash on Android 6.0 caused by URL encoding. +- Keep core behavior independent from Google Play Services. + +## Thx + +Thx to @therealresonix for the player gesture, fullscreen, and appearance reports. [\#50]() [\#72]() + +Thx to @ollieisonline for the notification bell and push notification reports. [\#51]() [\#86]() + +Thx to @303inmyheart for the previous and next player controls report. [\#87]() + +Thx to @filippobaroni for the comment rendering report. [\#82]() + +Thx as well to everyone testing the beta builds, sharing diagnostics, and helping TypeType Android improve. + +## Installing + +Download the APK attached below, or install TypeType through the stable channel of the TypeType F-Droid repository. The APK is built from the tagged source, signed by the release workflow, checked for 16 KiB page alignment, and verified against the Gradle application version. A SHA-256 checksum is provided alongside it. + +The stable and beta F-Droid channels use the same application identity. Switching channels updates the existing installation instead of installing a second TypeType application. + +If something does not work correctly, please open an issue in the [TypeType-Android repository](). + +If you want to support TypeType Android, sharing it with others is the best way to help. GitHub Sponsors also helps cover the server, domain, and coffee costs that keep the project moving. + +**Full changelog:** https://github.com/TypeType-Video/TypeType-Android/compare/v1.8.0...v1.9.0 diff --git a/release-notes/v1.9.1-beta.1.md b/release-notes/v1.9.1-beta.1.md new file mode 100644 index 00000000..a381cf75 --- /dev/null +++ b/release-notes/v1.9.1-beta.1.md @@ -0,0 +1,10 @@ +Beta fixes for playback recovery and image loading. + +- Prepare smaller SABR windows after startup and seeks, then fill the buffer progressively. +- Keep retrying visible channel avatars and thumbnails with bounded backoff. +- Prevent loading and error artwork from covering mini-player metadata. +- Retry failed stream loading after the network returns. + +Validation: 832 unit tests, no failures; lint and debug/release builds passed. Two emulator runs recovered both forward and backward seeks with H.264/AAC. This does not establish that all playback freezes or audio recovery issues are resolved. + +Thanks to everyone testing TypeType and reporting problems.