From 4568538383248e52555a2ae4abe5cd3393f2af22 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 19 Sep 2026 11:04:24 +0200 Subject: [PATCH 1/9] ci: use neutral self-hosted labels [skip ci] --- .github/workflows/android-beta.yml | 2 +- .github/workflows/android.yml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/android-beta.yml b/.github/workflows/android-beta.yml index 524fe121..a60d2088 100644 --- a/.github/workflows/android-beta.yml +++ b/.github/workflows/android-beta.yml @@ -45,7 +45,7 @@ env: jobs: release: - runs-on: [self-hosted, Linux, X64, arko, android] + runs-on: [self-hosted, Linux, X64, ci-stack, android] timeout-minutes: 35 permissions: contents: write diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 96972995..72a23868 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -47,7 +47,7 @@ env: jobs: verify: if: ${{ github.event_name != 'workflow_dispatch' || !inputs.publish_release || github.ref != 'refs/heads/main' }} - runs-on: [self-hosted, Linux, X64, arko, android] + runs-on: [self-hosted, Linux, X64, ci-stack, android] timeout-minutes: 30 steps: - name: Checkout @@ -116,7 +116,7 @@ jobs: release: if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch' && inputs.publish_release }} - runs-on: [self-hosted, Linux, X64, arko, android] + runs-on: [self-hosted, Linux, X64, ci-stack, android] timeout-minutes: 35 permissions: contents: write From 1f0d6aea8a0e551c6f752abcb7332def1efefb3f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 28 Sep 2026 13:00:29 +0000 Subject: [PATCH 2/9] fix: send UnifiedPush encryption keys --- .../android/data/network/dto/PushDto.kt | 2 ++ .../data/push/PushRegistrationStore.kt | 28 +++++++++++++++---- .../android/data/push/RemotePushRepository.kt | 9 ++++-- .../android/domain/push/PushRepository.kt | 2 +- .../services/push/PushRegistrationManager.kt | 21 +++++++------- .../services/push/TypeTypePushService.kt | 7 ++++- .../PushDeviceRegistrationRequestDtoTest.kt | 26 +++++++++++++++++ 7 files changed, 74 insertions(+), 21 deletions(-) create mode 100644 app/src/test/java/dev/typetype/android/data/network/dto/PushDeviceRegistrationRequestDtoTest.kt diff --git a/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt b/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt index 2824192f..77e70599 100644 --- a/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt +++ b/app/src/main/java/dev/typetype/android/data/network/dto/PushDto.kt @@ -9,6 +9,8 @@ data class PushDeviceRegistrationRequestDto( val deviceId: String, val platform: String = "android", val endpoint: String, + val p256dh: String, + val auth: String, val expiresAt: Long? = null, ) diff --git a/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt b/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt index 46159fc0..fae37d70 100644 --- a/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt +++ b/app/src/main/java/dev/typetype/android/data/push/PushRegistrationStore.kt @@ -19,6 +19,8 @@ class PushRegistrationStore @Inject constructor( data class Registration( val deviceId: String, val endpoint: String?, + val p256dh: String?, + val auth: String?, ) fun registration(scope: AccountScope): Flow = dataStore.data.map { prefs -> @@ -39,10 +41,11 @@ class PushRegistrationStore @Inject constructor( return dataStore.data.first()[deviceIdKey(scope)] ?: error("The stored push device id disappeared") } - suspend fun setEndpoint(scope: AccountScope, endpoint: String?) { + suspend fun setSubscription(scope: AccountScope, endpoint: String, p256dh: String, auth: String) { dataStore.edit { prefs -> - val key = endpointKey(scope) - if (endpoint == null) prefs.remove(key) else prefs[key] = endpoint + prefs[endpointKey(scope)] = endpoint + prefs[p256dhKey(scope)] = p256dh + prefs[authKey(scope)] = auth } } @@ -50,19 +53,32 @@ class PushRegistrationStore @Inject constructor( dataStore.edit { prefs -> prefs.remove(deviceIdKey(scope)) prefs.remove(endpointKey(scope)) + prefs.remove(p256dhKey(scope)) + prefs.remove(authKey(scope)) } } private fun Preferences.registration(scope: AccountScope): Registration? { val deviceId = this[deviceIdKey(scope)]?.takeIf(String::isNotBlank) ?: return null - return Registration(deviceId, this[endpointKey(scope)]) + return Registration( + deviceId = deviceId, + endpoint = this[endpointKey(scope)], + p256dh = this[p256dhKey(scope)], + auth = this[authKey(scope)], + ) } private fun deviceIdKey(scope: AccountScope) = - stringPreferencesKey("push_device_id_${scopeKey(scope)}") + stringPreferencesKey("push_device_id_" + scopeKey(scope)) private fun endpointKey(scope: AccountScope) = - stringPreferencesKey("push_endpoint_${scopeKey(scope)}") + stringPreferencesKey("push_endpoint_" + scopeKey(scope)) + + private fun p256dhKey(scope: AccountScope) = + stringPreferencesKey("push_p256dh_" + scopeKey(scope)) + + private fun authKey(scope: AccountScope) = + stringPreferencesKey("push_auth_" + scopeKey(scope)) private fun scopeKey(scope: AccountScope) = "${scope.serverId}_${scope.accountId}" } diff --git a/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt b/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt index 7c5fe569..19fea4c0 100644 --- a/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt +++ b/app/src/main/java/dev/typetype/android/data/push/RemotePushRepository.kt @@ -36,11 +36,16 @@ class RemotePushRepository @Inject constructor( ?: PushCapability() } - override suspend fun registerDevice(deviceId: String, endpoint: String): Result = guarded { + override suspend fun registerDevice( + deviceId: String, + endpoint: String, + p256dh: String, + auth: String, + ): Result = guarded { val api = apiHolder.require(requireEligibleScope()) val response = withContext(Dispatchers.IO) { api.registerPushDevice( - PushDeviceRegistrationRequestDto(deviceId = deviceId, endpoint = endpoint), + PushDeviceRegistrationRequestDto(deviceId = deviceId, endpoint = endpoint, p256dh = p256dh, auth = auth), ) } response.requireSuccessfulResponse() diff --git a/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt b/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt index 7579a30a..d8fcb3f5 100644 --- a/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt +++ b/app/src/main/java/dev/typetype/android/domain/push/PushRepository.kt @@ -5,7 +5,7 @@ import dev.typetype.android.domain.server.PushCapability interface PushRepository { suspend fun currentCapability(): PushCapability - suspend fun registerDevice(deviceId: String, endpoint: String): Result + suspend fun registerDevice(deviceId: String, endpoint: String, p256dh: String, auth: String): Result suspend fun unregisterDevice(deviceId: String): Result diff --git a/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt b/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt index dfaf7a77..9eacb490 100644 --- a/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt +++ b/app/src/main/java/dev/typetype/android/services/push/PushRegistrationManager.kt @@ -49,13 +49,10 @@ class PushRegistrationManager @Inject constructor( return update(PushRegistrationStatus.Unavailable) } val endpoint = registration.endpoint ?: return startRegistration(scope) - val devices = repository.devices().getOrElse { - return update(PushRegistrationStatus.Failed) - } - if (devices.any { device -> device.deviceId == registration.deviceId }) { - return update(PushRegistrationStatus.Registered) - } - return repository.registerDevice(registration.deviceId, endpoint).fold( + val p256dh = registration.p256dh + val auth = registration.auth + if (p256dh.isNullOrBlank() || auth.isNullOrBlank()) return startRegistration(scope) + return repository.registerDevice(registration.deviceId, endpoint, p256dh, auth).fold( onSuccess = { update(PushRegistrationStatus.Registered) }, onFailure = { update(PushRegistrationStatus.Failed) }, ) @@ -92,18 +89,20 @@ class PushRegistrationManager @Inject constructor( val registration = registrationStore.registrationOnce(scope) return when { registration == null -> update(PushRegistrationStatus.Disabled) - registration.endpoint != null -> update(PushRegistrationStatus.Registered) + !registration.endpoint.isNullOrBlank() && + !registration.p256dh.isNullOrBlank() && + !registration.auth.isNullOrBlank() -> update(PushRegistrationStatus.Registered) else -> update(PushRegistrationStatus.Registering) } } - fun onEndpointAvailable(instance: String, endpoint: String) { + fun onEndpointAvailable(instance: String, endpoint: String, p256dh: String, auth: String) { managerScope.launch { val accountScope = scopeFromInstanceName(instance) ?: return@launch val deviceId = registrationStore.ensureDeviceId(accountScope) - repository.registerDevice(deviceId, endpoint).fold( + repository.registerDevice(deviceId, endpoint, p256dh, auth).fold( onSuccess = { - registrationStore.setEndpoint(accountScope, endpoint) + registrationStore.setSubscription(accountScope, endpoint, p256dh, auth) update(PushRegistrationStatus.Registered) }, onFailure = { update(PushRegistrationStatus.Failed) }, diff --git a/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt b/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt index af093df2..d7191236 100644 --- a/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt +++ b/app/src/main/java/dev/typetype/android/services/push/TypeTypePushService.kt @@ -15,7 +15,12 @@ class TypeTypePushService : PushService() { @Inject lateinit var notifier: PushNotifier override fun onNewEndpoint(endpoint: PushEndpoint, instance: String) { - registrationManager.onEndpointAvailable(instance, endpoint.url) + val keySet = endpoint.pubKeySet + if (keySet == null) { + registrationManager.onRegistrationFailed(instance) + return + } + registrationManager.onEndpointAvailable(instance, endpoint.url, keySet.pubKey, keySet.auth) } override fun onMessage(message: PushMessage, instance: String) { diff --git a/app/src/test/java/dev/typetype/android/data/network/dto/PushDeviceRegistrationRequestDtoTest.kt b/app/src/test/java/dev/typetype/android/data/network/dto/PushDeviceRegistrationRequestDtoTest.kt new file mode 100644 index 00000000..92f516f0 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/data/network/dto/PushDeviceRegistrationRequestDtoTest.kt @@ -0,0 +1,26 @@ +package dev.typetype.android.data.network.dto + +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.junit.Assert.assertEquals +import org.junit.Test + +class PushDeviceRegistrationRequestDtoTest { + @Test + fun registrationJsonIncludesWebPushSubscriptionKeys() { + val request = PushDeviceRegistrationRequestDto( + deviceId = "device-1", + endpoint = "https://push.example/subscription", + p256dh = "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4", + auth = "BTBZMqHH6r4Tts7J_aSIgg", + ) + + val json = Json.parseToJsonElement(Json.encodeToString(request)).jsonObject + + assertEquals(request.endpoint, json["endpoint"]?.jsonPrimitive?.content) + assertEquals(request.p256dh, json["p256dh"]?.jsonPrimitive?.content) + assertEquals(request.auth, json["auth"]?.jsonPrimitive?.content) + } +} From a5c3ebd6233a59111a4d2f618eb1286d3f082f17 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 28 Sep 2026 19:07:36 +0200 Subject: [PATCH 3/9] docs: add Android 1.9.0 release notes --- release-notes/v1.9.0.md | 63 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 release-notes/v1.9.0.md 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 From 383b25de98e437d1e6cb7c73f065ee38ac37a9d5 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 28 Sep 2026 19:08:05 +0200 Subject: [PATCH 4/9] build: prepare Android 1.9.0 release --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 70fe93e8..72c76786 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 = 10900 + versionName = "1.9.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } From fa561c2520b24ce10c77eedf8e590c8cb29e0275 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 3 Oct 2026 17:28:04 +0200 Subject: [PATCH 5/9] fix: prepare progressive SABR windows after seeks --- .../services/PublicVodSeekMeasurementTest.kt | 81 +++++++++++++++++++ .../data/stream/SabrPlaybackContract.kt | 8 +- .../stream/SabrPlaybackSessionProtocol.kt | 29 ++++++- .../stream/SabrPlaybackRateContractTest.kt | 53 ++++++++++-- 4 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 app/src/androidTest/java/dev/typetype/android/services/PublicVodSeekMeasurementTest.kt 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/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/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", From 6a62f6197ea5edc5e952cdcb1ae130dfcf6f1fef Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 3 Oct 2026 17:28:25 +0200 Subject: [PATCH 6/9] fix: retry visible avatars and thumbnails with backoff --- .../core/ui/components/RetryingImageTest.kt | 56 +++++++++++++++++++ .../core/ui/components/ChannelAvatar.kt | 17 ++---- .../core/ui/components/RetryingImage.kt | 49 ++++++++++++++++ .../core/ui/components/SkeletonImage.kt | 10 ++-- .../core/ui/components/ImageRetryDelayTest.kt | 15 +++++ 5 files changed, 129 insertions(+), 18 deletions(-) create mode 100644 app/src/androidTest/java/dev/typetype/android/core/ui/components/RetryingImageTest.kt create mode 100644 app/src/main/java/dev/typetype/android/core/ui/components/RetryingImage.kt create mode 100644 app/src/test/java/dev/typetype/android/core/ui/components/ImageRetryDelayTest.kt 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/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/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)) + } +} From 4242be0cb258203d5002e0e93e4bf7fc516eae08 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 3 Oct 2026 17:28:41 +0200 Subject: [PATCH 7/9] fix: hide error artwork in the collapsed player --- .../player/PlayerPlaceholderLayerTest.kt | 46 +++++++++++++++++++ .../feature/player/PlayerPlaceholderLayer.kt | 28 +++++++++++ .../android/feature/player/PlayerScreen.kt | 18 +++++--- 3 files changed, 85 insertions(+), 7 deletions(-) create mode 100644 app/src/androidTest/java/dev/typetype/android/feature/player/PlayerPlaceholderLayerTest.kt create mode 100644 app/src/main/java/dev/typetype/android/feature/player/PlayerPlaceholderLayer.kt 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/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/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, From 90ec89e48c54c774ea89224cdd662688bab6a228 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 3 Oct 2026 17:28:55 +0200 Subject: [PATCH 8/9] fix: recover failed stream loading when connectivity returns --- .../data/stream/TransientPlaybackRequest.kt | 2 +- .../player/PlayerPreferenceStateMapping.kt | 10 +++++ .../player/PlayerStreamNetworkRecovery.kt | 38 +++++++++++++++++++ .../android/feature/player/PlayerViewModel.kt | 16 +++----- .../player/PlayerStreamNetworkRecoveryTest.kt | 35 +++++++++++++++++ 5 files changed, 90 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/feature/player/PlayerPreferenceStateMapping.kt create mode 100644 app/src/main/java/dev/typetype/android/feature/player/PlayerStreamNetworkRecovery.kt create mode 100644 app/src/test/java/dev/typetype/android/feature/player/PlayerStreamNetworkRecoveryTest.kt 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/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/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/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)) + } +} From afbfa421490ea5f91c90918910cefe737cf8544d Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 3 Oct 2026 17:29:43 +0200 Subject: [PATCH 9/9] build: prepare mobile beta 1.9.1-beta.1 --- app/build.gradle.kts | 4 ++-- release-notes/v1.9.1-beta.1.md | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) create mode 100644 release-notes/v1.9.1-beta.1.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 72c76786..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 = 10900 - versionName = "1.9.0" + versionCode = 10901 + versionName = "1.9.1-beta.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } 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.