From b6aff04afb302589df91c8be82e7acb043bb2834 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 18:12:14 +0200 Subject: [PATCH 01/18] fix: play youtube lives from the server live manifest --- .../android/data/stream/ServerRelativeUrl.kt | 5 + .../data/stream/StreamEndpointLoader.kt | 9 +- .../data/stream/StreamRepositoryImpl.kt | 15 +- .../data/stream/StreamEndpointLoaderTest.kt | 54 ++++- .../data/stream/StreamRepositoryLiveTest.kt | 201 ++++++++++++++++++ 5 files changed, 272 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryLiveTest.kt diff --git a/app/src/main/java/dev/typetype/android/data/stream/ServerRelativeUrl.kt b/app/src/main/java/dev/typetype/android/data/stream/ServerRelativeUrl.kt index ab9d568c..10f052a4 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/ServerRelativeUrl.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/ServerRelativeUrl.kt @@ -14,6 +14,11 @@ internal fun resolveServerUrl(baseUrl: String, value: String?): String? { return resolved.takeIf { it.hasSameOrigin(server) }?.toString() } +internal fun resolvePlaybackUrl(baseUrl: String, value: String?): String? { + val source = value?.takeIf { it.isNotBlank() } ?: return null + return source.toHttpUrlOrNull()?.toString() ?: resolveServerUrl(baseUrl, source) +} + internal fun resolveSabrPlaybackManifestUrl( baseUrl: String, value: String?, diff --git a/app/src/main/java/dev/typetype/android/data/stream/StreamEndpointLoader.kt b/app/src/main/java/dev/typetype/android/data/stream/StreamEndpointLoader.kt index dc0e55a4..7092b7b9 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/StreamEndpointLoader.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/StreamEndpointLoader.kt @@ -40,16 +40,19 @@ private fun Response.canFallbackToGeneric(): Boolean = internal fun StreamResponse.hasPlayableSabrContract(baseUrl: String? = null): Boolean { val playableVideoItags = (videoStreams + videoOnlyStreams).filter { it.deliveryMethod == SABR_DELIVERY_METHOD && it.itag > 0 && - it.manifestUrl.isAllowedSabrManifest(baseUrl) && isServerSabrVideoFormat(it.codec) + it.manifestUrl.isAllowedServerManifest(baseUrl) && isServerSabrVideoFormat(it.codec) }.mapTo(mutableSetOf()) { it.itag } return playableVideoItags.isNotEmpty() && audioStreams.any { it.deliveryMethod == SABR_DELIVERY_METHOD && it.itag > 0 && - it.itag !in playableVideoItags && it.manifestUrl.isAllowedSabrManifest(baseUrl) && + it.itag !in playableVideoItags && it.manifestUrl.isAllowedServerManifest(baseUrl) && isServerSabrAudioFormat(it.mimeType, it.codec) } } -private fun String?.isAllowedSabrManifest(baseUrl: String?): Boolean = +internal fun StreamResponse.hasPlayableLiveContract(baseUrl: String? = null): Boolean = + (isLive || hasLiveManifest) && hlsUrl.isAllowedServerManifest(baseUrl) + +private fun String?.isAllowedServerManifest(baseUrl: String?): Boolean = !isNullOrBlank() && (baseUrl == null || resolveServerUrl(baseUrl, this) != null) internal fun String.streamProvider(): StreamProvider { diff --git a/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt b/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt index 7d81fb1c..70cb4f9c 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt @@ -103,7 +103,10 @@ internal class StreamRepositoryImpl @Inject constructor( } val body = response.body() ?: error("Empty stream body") activeAccountScope.verify(scope) - if (provider == StreamProvider.YouTube && !body.hasPlayableSabrContract(server.baseUrl)) { + if (provider == StreamProvider.YouTube && + !body.hasPlayableSabrContract(server.baseUrl) && + !body.hasPlayableLiveContract(server.baseUrl) + ) { throw SabrContractException() } body.toDomain(videoUrl, server.baseUrl, scope, provider) @@ -115,7 +118,9 @@ internal class StreamRepositoryImpl @Inject constructor( scope: dev.typetype.android.data.account.AccountScope, provider: StreamProvider, ): Stream { - val serverSabr = provider == StreamProvider.YouTube + val serverYoutube = provider == StreamProvider.YouTube + val serverSabr = serverYoutube && hasPlayableSabrContract(baseUrl) + val resolvedHlsUrl = resolvePlaybackUrl(baseUrl, hlsUrl).orEmpty() return Stream( playbackContract = if (serverSabr) { StreamPlaybackContract.ServerSabr @@ -136,12 +141,12 @@ internal class StreamRepositoryImpl @Inject constructor( likeCount = likeCount, dislikeCount = dislikeCount, uploadedAtMillis = uploaded, - hlsUrl = hlsUrl.takeIf { !serverSabr && it.isNotBlank() }, + hlsUrl = resolvedHlsUrl.takeIf { !serverSabr && it.isNotBlank() }, dashMpdUrl = dashMpdUrl.takeIf { !serverSabr && it.isNotBlank() }, progressiveUrl = pickBestProgressiveStream(videoStreams).takeUnless { serverSabr }, serverDashManifestUrl = serverManifestUrl(baseUrl, "streams/manifest", videoUrl) - .takeUnless { serverSabr }, - serverHlsManifestUrl = hlsUrl.takeIf { !serverSabr && it.isNotBlank() } + .takeUnless { serverSabr || serverYoutube }, + serverHlsManifestUrl = hlsUrl.takeIf { !serverSabr && !serverYoutube && it.isNotBlank() } ?.let { serverManifestUrl(baseUrl, "streams/hls-manifest", videoUrl) }, serverSabrManifestUrl = resolveServerUrl(baseUrl, firstSabrManifestUrl()), sabrVideoStreams = (videoOnlyStreams + videoStreams) diff --git a/app/src/test/java/dev/typetype/android/data/stream/StreamEndpointLoaderTest.kt b/app/src/test/java/dev/typetype/android/data/stream/StreamEndpointLoaderTest.kt index ee6e3e7d..4fc17535 100644 --- a/app/src/test/java/dev/typetype/android/data/stream/StreamEndpointLoaderTest.kt +++ b/app/src/test/java/dev/typetype/android/data/stream/StreamEndpointLoaderTest.kt @@ -73,6 +73,38 @@ class StreamEndpointLoaderTest { assertEquals(1, server.requestCount) } + @Test + fun youtubeLiveHlsResponseIsAcceptedAsLiveContract() = runBlocking { + server.enqueue( + jsonResponse( + sabr = false, + live = true, + hlsUrl = "/streams/hls-manifest?token=signed", + ), + ) + + val body = api.loadStreamResponse(YOUTUBE_URL).body() + + assertFalse(body?.hasPlayableSabrContract(server.url("/").toString()) == true) + assertTrue(body?.hasPlayableLiveContract(server.url("/").toString()) == true) + assertEquals(1, server.requestCount) + } + + @Test + fun youtubeLiveHlsResponseOnAnotherOriginIsRejected() = runBlocking { + server.enqueue( + jsonResponse( + sabr = false, + live = true, + hlsUrl = "https://media.example/live.m3u8", + ), + ) + + val body = api.loadStreamResponse(YOUTUBE_URL).body() + + assertFalse(body?.hasPlayableLiveContract(server.url("/").toString()) == true) + } + @Test fun youtubeSabrResponseWithoutValidItagsIsRejected() = runBlocking { server.enqueue(jsonResponse(sabr = true, videoItag = 0)) @@ -162,6 +194,8 @@ class StreamEndpointLoaderTest { private fun jsonResponse( sabr: Boolean, hls: Boolean = false, + live: Boolean = false, + hlsUrl: String? = null, videoItag: Int = 137, manifestUrl: String = "/sabr/manifest/video", videoCodec: String = "avc1.640028", @@ -170,7 +204,18 @@ class StreamEndpointLoaderTest { ): MockResponse = MockResponse() .setResponseCode(200) .setHeader("Content-Type", "application/json") - .setBody(streamJson(sabr, hls, videoItag, manifestUrl, videoCodec, audioMimeType, audioCodec)) + .setBody( + streamJson( + sabr = sabr, + live = live, + hlsUrlValue = hlsUrl ?: if (hls) "https://media.example/live.m3u8" else "", + videoItag = videoItag, + manifestUrl = manifestUrl, + videoCodec = videoCodec, + audioMimeType = audioMimeType, + audioCodec = audioCodec, + ), + ) private fun errorResponse(code: Int): MockResponse = MockResponse() .setResponseCode(code) @@ -179,7 +224,8 @@ class StreamEndpointLoaderTest { private fun streamJson( sabr: Boolean, - hls: Boolean, + live: Boolean, + hlsUrlValue: String, videoItag: Int, manifestUrl: String, videoCodec: String, @@ -189,7 +235,6 @@ class StreamEndpointLoaderTest { val delivery = if (sabr) "sabr" else "progressive" val manifest = if (sabr) "\"$manifestUrl\"" else "null" val mediaUrl = if (sabr) "" else "https://media.example/video.mp4" - val hlsUrl = if (hls) "https://media.example/live.m3u8" else "" return """ { "id":"video","title":"Video","uploaderName":"Channel","uploaderUrl":"/channel", @@ -197,7 +242,8 @@ class StreamEndpointLoaderTest { "viewCount":1,"likeCount":0,"dislikeCount":0,"uploadDate":"","uploaded":-1, "uploaderSubscriberCount":0,"uploaderVerified":false,"category":"","license":"", "visibility":"public","streamType":"VIDEO_STREAM","isShortFormContent":false, - "requiresMembership":false,"startPosition":0,"hlsUrl":"$hlsUrl","dashMpdUrl":"", + "requiresMembership":false,"startPosition":0,"hlsUrl":"$hlsUrlValue","dashMpdUrl":"", + "isLive":$live,"hasLiveManifest":$live, "videoStreams":[], "videoOnlyStreams":[{"url":"$mediaUrl","mimeType":"video/mp4","format":"MPEG_4", "resolution":"1080p","codec":"$videoCodec","isVideoOnly":true,"itag":$videoItag, diff --git a/app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryLiveTest.kt b/app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryLiveTest.kt new file mode 100644 index 00000000..83d8be37 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryLiveTest.kt @@ -0,0 +1,201 @@ +package dev.typetype.android.data.stream + +import dev.typetype.android.core.error.CodedFailure +import dev.typetype.android.data.account.AccountScope +import dev.typetype.android.data.account.AccountScopeProvider +import dev.typetype.android.data.network.AlwaysAvailablePlaybackNetworkObserver +import dev.typetype.android.data.network.dto.AudioStreamItem +import dev.typetype.android.data.network.dto.StreamResponse +import dev.typetype.android.data.network.dto.VideoStreamItem +import dev.typetype.android.domain.server.Server +import dev.typetype.android.domain.server.ServerRepository +import dev.typetype.android.domain.stream.StreamPlaybackContract +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import retrofit2.Response + +class StreamRepositoryLiveTest { + @Test + fun `youtube live hls response resolves the signed server manifest`() = runBlocking { + val repository = repository( + response( + isLive = true, + hasLiveManifest = true, + hlsUrl = LIVE_HLS_URL, + ), + ) + + val stream = repository.loadPlaybackStream(VIDEO_URL).getOrThrow() + + assertEquals(StreamPlaybackContract.ProviderMedia, stream.playbackContract) + assertEquals("$BASE_URL$LIVE_HLS_PATH", stream.hlsUrl) + assertNull(stream.serverSabrManifestUrl) + assertNull(stream.serverHlsManifestUrl) + assertNull(stream.serverDashManifestUrl) + assertTrue(stream.isLive) + assertTrue(stream.sabrVideoStreams.isEmpty()) + } + + @Test + fun `youtube live response without a server manifest is rejected`() = runBlocking { + val repository = repository( + response( + isLive = true, + hasLiveManifest = true, + hlsUrl = "https://media.example/live.m3u8", + ), + ) + + val failure = repository.loadPlaybackStream(VIDEO_URL).exceptionOrNull() + + assertEquals("youtube_sabr_unavailable", (failure as CodedFailure).failureCode) + } + + @Test + fun `youtube video without sabr or live manifest is rejected`() = runBlocking { + val repository = repository(response(isLive = false, hasLiveManifest = false, hlsUrl = "")) + + val failure = repository.loadPlaybackStream(VIDEO_URL).exceptionOrNull() + + assertEquals("youtube_sabr_unavailable", (failure as CodedFailure).failureCode) + } + + @Test + fun `youtube vod keeps the sabr contract and drops provider manifests`() = runBlocking { + val repository = repository( + response( + isLive = false, + hasLiveManifest = false, + hlsUrl = "", + sabrFormats = true, + ), + ) + + val stream = repository.loadPlaybackStream(VIDEO_URL).getOrThrow() + + assertEquals(StreamPlaybackContract.ServerSabr, stream.playbackContract) + assertNull(stream.hlsUrl) + assertEquals("$BASE_URL${SABR_MANIFEST_PATH.substringAfter('/')}", stream.serverSabrManifestUrl) + } + + private fun repository(response: StreamResponse) = StreamRepositoryImpl( + remoteSource = FixedStreamRemoteSource(response), + activeAccountScope = FixedAccountScope, + serverRepository = FixedServerRepository, + networkMonitor = AlwaysAvailablePlaybackNetworkObserver, + ) + + private class FixedStreamRemoteSource( + private val response: StreamResponse, + ) : StreamRemoteSource { + override suspend fun load( + scope: AccountScope, + videoUrl: String, + provider: StreamProvider, + playbackBootstrap: Boolean, + ): Response = Response.success(response) + } + + private object FixedAccountScope : AccountScopeProvider { + private val scope = AccountScope(SERVER_ID, ACCOUNT_ID) + + override fun observe(): Flow = flowOf(scope) + + override suspend fun require(): AccountScope = scope + + override suspend fun verify(expected: AccountScope) { + check(expected == scope) + } + } + + private object FixedServerRepository : ServerRepository { + private val server = Server(SERVER_ID, BASE_URL, "Instance", 0L) + + override fun observeServers(): Flow> = flowOf(listOf(server)) + override fun observeCurrentServer(): Flow = flowOf(server) + override suspend fun getServer(id: String): Server? = server.takeIf { id == SERVER_ID } + override suspend fun addServer(server: Server) = Unit + override suspend fun deleteServer(id: String) = Unit + override suspend fun setCurrentServer(id: String) = Unit + override suspend fun clearCurrentServer() = Unit + } + + private fun response( + isLive: Boolean, + hasLiveManifest: Boolean, + hlsUrl: String, + sabrFormats: Boolean = false, + ) = StreamResponse( + id = "video", + title = "Video", + uploaderName = "Channel", + uploaderUrl = "/channel", + uploaderAvatarUrl = "", + thumbnailUrl = "", + description = "", + duration = if (isLive) 0L else 60L, + viewCount = 1L, + likeCount = 0L, + dislikeCount = 0L, + uploadDate = "", + uploaded = 0L, + uploaderSubscriberCount = 1L, + uploaderVerified = false, + category = "", + license = "", + visibility = "public", + streamType = if (isLive) "LIVE_STREAM" else "VIDEO_STREAM", + isShortFormContent = false, + requiresMembership = false, + isLive = isLive, + hasLiveManifest = hasLiveManifest, + startPosition = 0L, + hlsUrl = hlsUrl, + dashMpdUrl = "", + videoOnlyStreams = if (sabrFormats) listOf(videoStream()) else emptyList(), + audioStreams = if (sabrFormats) listOf(audioStream()) else emptyList(), + ) + + private fun videoStream() = VideoStreamItem( + url = "", + mimeType = "video/mp4", + format = "MPEG_4", + resolution = "720p", + codec = "avc1.64001f", + isVideoOnly = true, + itag = 136, + width = 1280, + height = 720, + fps = 30, + contentLength = 1L, + deliveryMethod = "sabr", + manifestUrl = SABR_MANIFEST_PATH, + ) + + private fun audioStream() = AudioStreamItem( + url = "", + mimeType = "audio/mp4", + format = "MPEG_4", + codec = "mp4a.40.2", + itag = 140, + contentLength = 1L, + isOriginal = true, + deliveryMethod = "sabr", + manifestUrl = SABR_MANIFEST_PATH, + ) + + private companion object { + const val SERVER_ID = "server" + const val ACCOUNT_ID = "account" + const val BASE_URL = "https://instance.example/api/" + const val VIDEO_URL = "https://www.youtube.com/watch?v=video" + const val SABR_MANIFEST_PATH = "/sabr/manifest/video" + const val LIVE_HLS_PATH = "streams/hls-manifest?token=signed" + const val LIVE_HLS_URL = "/$LIVE_HLS_PATH" + } +} From 3d4ed8bde03171dab7ff26378f2133e64d281d02 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 18:18:30 +0200 Subject: [PATCH 02/18] feat: prewarm sabr sessions for shorts preload --- .../android/data/network/TypeTypeMediaApi.kt | 1 + .../data/stream/SabrPlaybackRepositoryImpl.kt | 7 ++++++ .../stream/SabrPlaybackSessionPreparer.kt | 8 +++++-- .../domain/stream/SabrPlaybackRepository.kt | 3 +++ .../feature/shorts/ShortsPlaybackPreheater.kt | 2 +- .../stream/SabrPlaybackSessionPreparerTest.kt | 24 +++++++++++++++++++ 6 files changed, 42 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/dev/typetype/android/data/network/TypeTypeMediaApi.kt b/app/src/main/java/dev/typetype/android/data/network/TypeTypeMediaApi.kt index b24a71fa..005a315d 100644 --- a/app/src/main/java/dev/typetype/android/data/network/TypeTypeMediaApi.kt +++ b/app/src/main/java/dev/typetype/android/data/network/TypeTypeMediaApi.kt @@ -48,6 +48,7 @@ interface TypeTypeMediaApi { suspend fun createSabrPlayback( @Path("videoId") videoId: String, @Body body: SabrPlaybackRequest, + @Query("prewarm") prewarm: Boolean? = null, ): Response @POST("sabr/playback/{sessionId}/seek") diff --git a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackRepositoryImpl.kt b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackRepositoryImpl.kt index 0e663e06..21de4b17 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackRepositoryImpl.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackRepositoryImpl.kt @@ -41,6 +41,13 @@ class SabrPlaybackRepositoryImpl @Inject constructor( preparer.prepareOnce(api, target.requestScope.baseUrl, target, startTimeMs) } + override suspend fun prewarm( + target: SabrPlaybackTarget, + startTimeMs: Long, + ): Result = execute(target) { api -> + preparer.prepare(api, target.requestScope.baseUrl, target, startTimeMs, prewarm = true) + } + override suspend fun seek( target: SabrPlaybackTarget, binding: SabrPlaybackBinding, diff --git a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt index c7db219d..cb0323a1 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparer.kt @@ -25,7 +25,8 @@ internal class SabrPlaybackSessionPreparer( baseUrl: String, target: SabrPlaybackTarget, startTimeMs: Long = 0L, - ): SabrPlaybackSession = createSessionWithRecovery(api, baseUrl, target, startTimeMs) + prewarm: Boolean = false, + ): SabrPlaybackSession = createSessionWithRecovery(api, baseUrl, target, startTimeMs, prewarm) suspend fun prepareOnce( api: TypeTypeMediaApi, @@ -39,11 +40,13 @@ internal class SabrPlaybackSessionPreparer( baseUrl: String, target: SabrPlaybackTarget, startTimeMs: Long, + prewarm: Boolean = false, ): SabrPlaybackSession { val response = transientPlaybackRequest(pause, network) { api.createSabrPlayback( target.videoId, target.controlRequest(startTimeMs), + prewarm.takeIf { it }, ) } response.requireControlEndpoint( @@ -139,8 +142,9 @@ internal class SabrPlaybackSessionPreparer( baseUrl: String, target: SabrPlaybackTarget, startTimeMs: Long, + prewarm: Boolean = false, ): SabrPlaybackSession = try { - createSession(api, baseUrl, target, startTimeMs) + createSession(api, baseUrl, target, startTimeMs, prewarm) } catch (failure: SabrPlaybackRecoveryException) { recoverSession(api, baseUrl, target, startTimeMs, failure) } diff --git a/app/src/main/java/dev/typetype/android/domain/stream/SabrPlaybackRepository.kt b/app/src/main/java/dev/typetype/android/domain/stream/SabrPlaybackRepository.kt index 18a9a52b..26725d7f 100644 --- a/app/src/main/java/dev/typetype/android/domain/stream/SabrPlaybackRepository.kt +++ b/app/src/main/java/dev/typetype/android/domain/stream/SabrPlaybackRepository.kt @@ -49,6 +49,9 @@ interface SabrPlaybackRepository { onFailure = Result.Companion::failure, ) + suspend fun prewarm(target: SabrPlaybackTarget, startTimeMs: Long = 0L): Result = + prepare(target, startTimeMs) + suspend fun recoverOnce( target: SabrPlaybackTarget, startTimeMs: Long, diff --git a/app/src/main/java/dev/typetype/android/feature/shorts/ShortsPlaybackPreheater.kt b/app/src/main/java/dev/typetype/android/feature/shorts/ShortsPlaybackPreheater.kt index df0f745a..c8e63d6e 100644 --- a/app/src/main/java/dev/typetype/android/feature/shorts/ShortsPlaybackPreheater.kt +++ b/app/src/main/java/dev/typetype/android/feature/shorts/ShortsPlaybackPreheater.kt @@ -40,6 +40,6 @@ class ShortsPlaybackPreheater @Inject constructor( ) ?: return val target = stream.sabrPlaybackTarget(selection) val reservation = preloads.reserve(target) - if (reservation.owner) reservation.result.complete(sabr.prepare(target, 0L)) + if (reservation.owner) reservation.result.complete(sabr.prewarm(target, 0L)) } } diff --git a/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTest.kt b/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTest.kt index 0af6a50c..24c62e96 100644 --- a/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTest.kt +++ b/app/src/test/java/dev/typetype/android/data/stream/SabrPlaybackSessionPreparerTest.kt @@ -81,6 +81,30 @@ class SabrPlaybackSessionPreparerTest { assertEquals("140", body.getValue("audioItag").jsonPrimitive.content) } + @Test + fun prewarmCreationMarksTheSessionForReuse() = runBlocking { + enqueueCreate(startTimeMs = 0, ready = true) + enqueuePosition(0) + enqueuePrefetch(ready = true) + enqueueWindow(startTimeMs = 0, endOfStream = false) + + preparer().prepare(api, baseUrl, target(), 0L, prewarm = true) + + assertEquals("/api/sabr/playback/video?prewarm=true", server.takeRequest().path) + } + + @Test + fun playbackCreationOmitsThePrewarmFlag() = runBlocking { + enqueueCreate(startTimeMs = 0, ready = true) + enqueuePosition(0) + enqueuePrefetch(ready = true) + enqueueWindow(startTimeMs = 0, endOfStream = false) + + preparer().prepare(api, baseUrl, target(), 0L) + + assertEquals("/api/sabr/playback/video", server.takeRequest().path) + } + @Test fun refreshReportsBufferedRangesForBothSelectedTracks() = runBlocking { enqueuePosition(55_000) From 00bed9aa3d8b3d7979a68dc85f7b092844b3ae31 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 18:22:04 +0200 Subject: [PATCH 03/18] feat: map typed provider errors to content unavailable --- .../android/feature/player/PlayerErrorState.kt | 2 ++ .../feature/player/error/StreamErrorClassifier.kt | 10 ++++++++++ .../player/error/StreamErrorClassifierTest.kt | 14 ++++++++++++++ 3 files changed, 26 insertions(+) diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerErrorState.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerErrorState.kt index 37998601..95088f13 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerErrorState.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerErrorState.kt @@ -33,6 +33,7 @@ fun ErrorState( -> R.raw.member_only StreamErrorKind.Generic, StreamErrorKind.AuthenticationExpired, + StreamErrorKind.ContentUnavailable, StreamErrorKind.LiveUnsupported, StreamErrorKind.NetworkUnavailable, StreamErrorKind.SabrInvalidIndex, @@ -45,6 +46,7 @@ fun ErrorState( -> R.raw.error_cat } val displayMessage = when (classification.kind) { + StreamErrorKind.ContentUnavailable -> stringResource(R.string.error_content_unavailable) StreamErrorKind.MemberOnly -> stringResource(R.string.state_member_only_message) StreamErrorKind.PaidContent -> stringResource(R.string.video_paid_message) StreamErrorKind.ScheduledPremiere -> stringResource(R.string.video_scheduled_message) diff --git a/app/src/main/java/dev/typetype/android/feature/player/error/StreamErrorClassifier.kt b/app/src/main/java/dev/typetype/android/feature/player/error/StreamErrorClassifier.kt index 6c8fbbaa..6154b6d2 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/error/StreamErrorClassifier.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/error/StreamErrorClassifier.kt @@ -107,6 +107,7 @@ fun parseGeoRestriction(message: String?): String? { enum class StreamErrorKind { Generic, AuthenticationExpired, + ContentUnavailable, MemberOnly, PaidContent, ScheduledPremiere, @@ -168,6 +169,10 @@ fun classifyStreamError(failure: Throwable): StreamErrorClass { if (code == "members_only") { return StreamErrorClass(StreamErrorKind.MemberOnly, rawMessage = null, requestId = requestId) } + + if (code in CONTENT_UNAVAILABLE_CODES) { + return StreamErrorClass(StreamErrorKind.ContentUnavailable, rawMessage = null, requestId = requestId) + } if (code == "paid_content") { return StreamErrorClass(StreamErrorKind.PaidContent, rawMessage = null, requestId = requestId) } @@ -268,6 +273,11 @@ private val SABR_UNAVAILABLE_CODES = setOf( "youtube_sabr_preparation_failed", ) +private val CONTENT_UNAVAILABLE_CODES = setOf( + "content_unavailable", + "provider_access_blocked", +) + private val SABR_CONTRACT_CODES = setOf( "youtube_sabr_contract_mismatch", "youtube_android_playback_incompatible", diff --git a/app/src/test/java/dev/typetype/android/feature/player/error/StreamErrorClassifierTest.kt b/app/src/test/java/dev/typetype/android/feature/player/error/StreamErrorClassifierTest.kt index bf3c4989..a069385d 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/error/StreamErrorClassifierTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/error/StreamErrorClassifierTest.kt @@ -116,6 +116,20 @@ class StreamErrorClassifierTest { assertEquals(StreamErrorKind.MemberOnly, classifyStreamError(members).kind) } + @Test + fun `maps typed provider failures to content unavailable`() { + val missing = ServerResponseException( + ServerError("This live stream recording is not available.", "content_unavailable", 404, "request-7"), + ) + val blocked = ServerResponseException( + ServerError("Provider blocked this instance", "provider_access_blocked", 403, "request-8"), + ) + + assertEquals(StreamErrorKind.ContentUnavailable, classifyStreamError(missing).kind) + assertEquals("request-7", classifyStreamError(missing).requestId) + assertEquals(StreamErrorKind.ContentUnavailable, classifyStreamError(blocked).kind) + } + @Test fun `finds network failures wrapped by another layer`() { val failure = IllegalStateException("Request failed", IOException("socket closed")) From fff48280e6e50181a7bf246958378b3a5e111b05 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 18:33:04 +0200 Subject: [PATCH 04/18] feat: deliver subscription notifications locally --- .../LocalNotificationDecision.kt | 37 +++++ .../notifications/LocalNotificationPoller.kt | 55 ++++++++ .../LocalNotificationSeenStore.kt | 58 ++++++++ .../android/domain/push/PushModels.kt | 5 + .../channel/ChannelNotificationsController.kt | 9 +- .../android/services/push/PushNotifier.kt | 43 ++++-- .../LocalNotificationDecisionTest.kt | 130 ++++++++++++++++++ 7 files changed, 320 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationDecision.kt create mode 100644 app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationPoller.kt create mode 100644 app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationSeenStore.kt create mode 100644 app/src/test/java/dev/typetype/android/data/notifications/LocalNotificationDecisionTest.kt diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationDecision.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationDecision.kt new file mode 100644 index 00000000..9cbb81a1 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationDecision.kt @@ -0,0 +1,37 @@ +package dev.typetype.android.data.notifications + +import dev.typetype.android.domain.notifications.NotificationItem +import dev.typetype.android.domain.push.ChannelNotificationsPreference +import dev.typetype.android.domain.push.matchesChannel + +internal data class LocalNotificationDecision( + val keysToRecord: List, + val notified: List, +) + +internal fun decideLocalNotifications( + items: List, + knownKeys: Set, + seeded: Boolean, + enabledChannels: List, +): LocalNotificationDecision { + val fresh = items.filter { item -> item.localNotificationKey() !in knownKeys } + if (!seeded) { + return LocalNotificationDecision( + keysToRecord = items.map { item -> item.localNotificationKey() }, + notified = emptyList(), + ) + } + if (fresh.isEmpty()) return LocalNotificationDecision(emptyList(), emptyList()) + val deliverableChannels = enabledChannels.filter { it.enabled } + val deliverable = fresh + .filter { item -> deliverableChannels.any { it.matchesChannel(item.channelUrl) } } + .asReversed() + return LocalNotificationDecision( + keysToRecord = fresh.map { item -> item.localNotificationKey() }, + notified = deliverable, + ) +} + +private fun NotificationItem.localNotificationKey(): String = + video.id.takeIf { it.isNotBlank() } ?: video.url diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationPoller.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationPoller.kt new file mode 100644 index 00000000..5c8c548b --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationPoller.kt @@ -0,0 +1,55 @@ +package dev.typetype.android.data.notifications + +import dev.typetype.android.data.account.AccountDao +import dev.typetype.android.data.account.AccountScope +import dev.typetype.android.data.account.ActiveAccountScope +import dev.typetype.android.domain.notifications.NotificationsRepository +import dev.typetype.android.domain.push.PushRepository +import dev.typetype.android.services.push.PushNotifier +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CancellationException + +@Singleton +class LocalNotificationPoller @Inject constructor( + private val activeAccountScope: ActiveAccountScope, + private val accountDao: AccountDao, + private val notifications: NotificationsRepository, + private val pushRepository: PushRepository, + private val seenStore: LocalNotificationSeenStore, + private val notifier: PushNotifier, +) { + suspend fun poll(): Result = try { + Result.success(pollOnce()) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (failure: Throwable) { + Result.failure(failure) + } + + private suspend fun pollOnce(): Int { + val scope = eligibleScope() + val enabledChannels = pushRepository.channelPreferences().getOrThrow() + val page = notifications.page(0).getOrThrow() + val state = seenStore.state(scope) + val decision = decideLocalNotifications( + items = page.items, + knownKeys = state.keys.toHashSet(), + seeded = state.seeded, + enabledChannels = enabledChannels, + ) + if (decision.keysToRecord.isEmpty() && decision.notified.isEmpty()) return 0 + seenStore.record(scope, decision.keysToRecord, seeded = true) + decision.notified.forEach(notifier::notifySubscription) + return decision.notified.size + } + + private suspend fun eligibleScope(): AccountScope { + val scope = activeAccountScope.require() + val account = accountDao.get(scope.serverId, scope.accountId) + check(account != null && !account.isGuest) { + "Local notifications are unavailable for guest accounts" + } + return scope + } +} diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationSeenStore.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationSeenStore.kt new file mode 100644 index 00000000..8dedb537 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationSeenStore.kt @@ -0,0 +1,58 @@ +package dev.typetype.android.data.notifications + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import dev.typetype.android.data.account.AccountScope +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.first + +@Singleton +class LocalNotificationSeenStore @Inject constructor( + private val dataStore: DataStore, +) { + data class State( + val seeded: Boolean, + val keys: List, + ) + + suspend fun state(scope: AccountScope): State = dataStore.data.first().toState(scope) + + suspend fun record(scope: AccountScope, keys: List, seeded: Boolean) { + if (keys.isEmpty() && !seeded) return + dataStore.edit { preferences -> + val merged = (preferences.toState(scope).keys + keys).distinct().takeLast(MAX_KEYS) + preferences[keysKey(scope)] = merged.joinToString(SEPARATOR) + preferences[seededKey(scope)] = seeded + } + } + + suspend fun clear(scope: AccountScope) { + dataStore.edit { preferences -> + preferences.remove(keysKey(scope)) + preferences.remove(seededKey(scope)) + } + } + + private fun Preferences.toState(scope: AccountScope): State = State( + seeded = this[seededKey(scope)] ?: false, + keys = this[keysKey(scope)] + ?.split(SEPARATOR) + ?.filter { it.isNotBlank() } + .orEmpty(), + ) + + private fun keysKey(scope: AccountScope) = + stringPreferencesKey("local_notifications_${scope.serverId}_${scope.accountId}_seen") + + private fun seededKey(scope: AccountScope) = + booleanPreferencesKey("local_notifications_${scope.serverId}_${scope.accountId}_seeded") + + private companion object { + const val SEPARATOR = "\n" + const val MAX_KEYS = 300 + } +} diff --git a/app/src/main/java/dev/typetype/android/domain/push/PushModels.kt b/app/src/main/java/dev/typetype/android/domain/push/PushModels.kt index 08befe8e..a2791d45 100644 --- a/app/src/main/java/dev/typetype/android/domain/push/PushModels.kt +++ b/app/src/main/java/dev/typetype/android/domain/push/PushModels.kt @@ -10,6 +10,11 @@ data class ChannelNotificationsPreference( val enabled: Boolean, ) +fun ChannelNotificationsPreference.matchesChannel(channelUrl: String): Boolean = + this.channelUrl == channelUrl || + channelUrl.startsWith("${this.channelUrl}/") || + this.channelUrl.startsWith("$channelUrl/") + sealed interface PushRegistrationStatus { data object Disabled : PushRegistrationStatus diff --git a/app/src/main/java/dev/typetype/android/feature/channel/ChannelNotificationsController.kt b/app/src/main/java/dev/typetype/android/feature/channel/ChannelNotificationsController.kt index e1157d96..a9b84ab9 100644 --- a/app/src/main/java/dev/typetype/android/feature/channel/ChannelNotificationsController.kt +++ b/app/src/main/java/dev/typetype/android/feature/channel/ChannelNotificationsController.kt @@ -1,8 +1,8 @@ package dev.typetype.android.feature.channel import dev.typetype.android.data.account.ActiveAccountScope -import dev.typetype.android.domain.push.ChannelNotificationsPreference import dev.typetype.android.domain.push.PushRepository +import dev.typetype.android.domain.push.matchesChannel import dev.typetype.android.domain.server.ServerRepository import javax.inject.Inject @@ -21,16 +21,11 @@ class ChannelNotificationsController @Inject constructor( suspend fun currentEnabled(channelUrl: String): Boolean? = pushRepository.channelPreferences().getOrNull() - ?.firstOrNull { it.matches(channelUrl) } + ?.firstOrNull { it.matchesChannel(channelUrl) } ?.enabled suspend fun setEnabled(channelUrl: String, enabled: Boolean): Boolean? { pushRepository.setChannelPreference(channelUrl, enabled).getOrNull() ?: return null return enabled } - - private fun ChannelNotificationsPreference.matches(channelUrl: String): Boolean = - this.channelUrl == channelUrl || - channelUrl.endsWith(this.channelUrl) || - this.channelUrl.endsWith(channelUrl) } diff --git a/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt b/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt index 7fef2ea0..036aaade 100644 --- a/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt +++ b/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt @@ -14,6 +14,7 @@ import androidx.core.app.NotificationManagerCompat import dagger.hilt.android.qualifiers.ApplicationContext import dev.typetype.android.R import dev.typetype.android.domain.navigation.toPublicWatchParameter +import dev.typetype.android.domain.notifications.NotificationItem import dev.typetype.android.domain.push.PushPayload import dev.typetype.android.domain.push.parsePushPayload import javax.inject.Inject @@ -23,32 +24,56 @@ import javax.inject.Singleton class PushNotifier @Inject constructor( @ApplicationContext private val context: Context, ) { + fun canNotify(): Boolean = NotificationManagerCompat.from(context).areNotificationsEnabled() + fun notify(payload: PushPayload) { + show( + title = payload.channelName.ifBlank { payload.serviceName }, + text = payload.title, + videoUrl = payload.videoUrl, + notificationId = payload.eventId.hashCode(), + ) + } + + fun notifySubscription(item: NotificationItem) { + show( + title = item.channelName.ifBlank { item.video.uploaderName }, + text = item.title, + videoUrl = item.video.url, + notificationId = item.video.id.hashCode(), + ) + } + + private fun show(title: String, text: String, videoUrl: String, notificationId: Int) { val manager = context.getSystemService(NotificationManager::class.java) ?: return if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { manager.createNotificationChannel(notificationChannel()) } - manager.notify(payload.notificationId(), buildNotification(payload)) + manager.notify(notificationId, buildNotification(title, text, videoUrl, notificationId)) } - private fun buildNotification(payload: PushPayload): Notification { + private fun buildNotification( + title: String, + text: String, + videoUrl: String, + notificationId: Int, + ): Notification { val deepLink = Intent( Intent.ACTION_VIEW, - Uri.parse("typetype://watch?v=${toPublicWatchParameter(payload.videoUrl)}"), + Uri.parse("typetype://watch?v=${toPublicWatchParameter(videoUrl)}"), ) val contentIntent = PendingIntent.getActivity( context, - payload.notificationId(), + notificationId, deepLink, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) - val fallbackTitle = payload.channelName.ifBlank { payload.serviceName } return NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(R.drawable.ic_launcher_monochrome) - .setContentTitle(fallbackTitle) - .setContentText(payload.title) - .setStyle(NotificationCompat.BigTextStyle().bigText(payload.title)) + .setContentTitle(title) + .setContentText(text) + .setStyle(NotificationCompat.BigTextStyle().bigText(text)) .setContentIntent(contentIntent) .setAutoCancel(true) .build() @@ -61,8 +86,6 @@ class PushNotifier @Inject constructor( NotificationManager.IMPORTANCE_DEFAULT, ) - private fun PushPayload.notificationId(): Int = eventId.hashCode() - private companion object { const val CHANNEL_ID = "subscription_push" } diff --git a/app/src/test/java/dev/typetype/android/data/notifications/LocalNotificationDecisionTest.kt b/app/src/test/java/dev/typetype/android/data/notifications/LocalNotificationDecisionTest.kt new file mode 100644 index 00000000..fea55273 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/data/notifications/LocalNotificationDecisionTest.kt @@ -0,0 +1,130 @@ +package dev.typetype.android.data.notifications + +import dev.typetype.android.domain.feed.Video +import dev.typetype.android.domain.notifications.NotificationItem +import dev.typetype.android.domain.push.ChannelNotificationsPreference +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class LocalNotificationDecisionTest { + @Test + fun `first poll records the backlog without notifying`() { + val decision = decideLocalNotifications( + items = listOf(item(videoId = "a", channelUrl = CHANNEL), item(videoId = "b", channelUrl = CHANNEL)), + knownKeys = emptySet(), + seeded = false, + enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)), + ) + + assertEquals(listOf("a", "b"), decision.keysToRecord) + assertTrue(decision.notified.isEmpty()) + } + + @Test + fun `alerts only new videos from enabled channels`() { + val decision = decideLocalNotifications( + items = listOf( + item(videoId = "new-enabled", channelUrl = CHANNEL), + item(videoId = "new-silent", channelUrl = OTHER_CHANNEL), + item(videoId = "seen", channelUrl = CHANNEL), + ), + knownKeys = setOf("seen"), + seeded = true, + enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)), + ) + + assertEquals(listOf("new-enabled", "new-silent"), decision.keysToRecord) + assertEquals(listOf("new-enabled"), decision.notified.map { it.video.id }) + } + + @Test + fun `disabled channels are recorded but never notified`() { + val decision = decideLocalNotifications( + items = listOf(item(videoId = "a", channelUrl = CHANNEL)), + knownKeys = emptySet(), + seeded = true, + enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = false)), + ) + + assertEquals(listOf("a"), decision.keysToRecord) + assertTrue(decision.notified.isEmpty()) + } + + @Test + fun `preference matches a channel url with an extra path segment on either side`() { + val trailingOnItem = decideLocalNotifications( + items = listOf(item(videoId = "a", channelUrl = "$CHANNEL/videos")), + knownKeys = emptySet(), + seeded = true, + enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)), + ) + val trailingOnPreference = decideLocalNotifications( + items = listOf(item(videoId = "a", channelUrl = CHANNEL)), + knownKeys = emptySet(), + seeded = true, + enabledChannels = listOf(ChannelNotificationsPreference("$CHANNEL/videos", enabled = true)), + ) + + assertEquals(listOf("a"), trailingOnItem.notified.map { it.video.id }) + assertEquals(listOf("a"), trailingOnPreference.notified.map { it.video.id }) + } + + @Test + fun `a longer channel identifier is not treated as the same channel`() { + val decision = decideLocalNotifications( + items = listOf(item(videoId = "a", channelUrl = "${CHANNEL}extra")), + knownKeys = emptySet(), + seeded = true, + enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)), + ) + + assertTrue(decision.notified.isEmpty()) + } + + @Test + fun `newest first input is notified oldest first`() { + val decision = decideLocalNotifications( + items = listOf( + item(videoId = "newest", channelUrl = CHANNEL), + item(videoId = "older", channelUrl = CHANNEL), + ), + knownKeys = emptySet(), + seeded = true, + enabledChannels = listOf(ChannelNotificationsPreference(CHANNEL, enabled = true)), + ) + + assertEquals(listOf("older", "newest"), decision.notified.map { it.video.id }) + } + + private fun item(videoId: String, channelUrl: String) = NotificationItem( + type = "subscription_new_video", + title = "Video $videoId", + createdAtMillis = 1L, + publishedAtMillis = 1L, + channelUrl = channelUrl, + channelName = "Channel", + channelAvatarUrl = "", + video = Video( + id = videoId, + url = "https://example.test/watch?v=$videoId", + title = "Video $videoId", + thumbnailUrl = "", + uploaderName = "Channel", + uploaderUrl = channelUrl, + uploaderAvatarUrl = "", + uploaderVerified = false, + durationSeconds = 0L, + isLive = false, + viewCount = 0L, + uploadedAtMillis = 1L, + isShortFormContent = false, + shortDescription = null, + ), + ) + + private companion object { + const val CHANNEL = "https://www.youtube.com/channel/UCaaaaaaaaaaaaaaaaaaaaaa" + const val OTHER_CHANNEL = "https://www.youtube.com/channel/UCbbbbbbbbbbbbbbbbbbbbbb" + } +} From 3bd5690079fc3d675254e522cc313e658d6a659b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 18:33:05 +0200 Subject: [PATCH 05/18] feat: schedule local notifications every fifteen minutes --- .../dev/typetype/android/MainViewModel.kt | 12 +++++- .../LocalNotificationScheduler.kt | 41 +++++++++++++++++++ .../notifications/LocalNotificationWorker.kt | 27 ++++++++++++ .../notifications/PushNotificationsScreen.kt | 8 ++++ .../PushNotificationsViewModel.kt | 4 ++ app/src/main/res/values/strings.xml | 1 + 6 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationScheduler.kt create mode 100644 app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationWorker.kt diff --git a/app/src/main/java/dev/typetype/android/MainViewModel.kt b/app/src/main/java/dev/typetype/android/MainViewModel.kt index bfaeaaf7..5deb807d 100644 --- a/app/src/main/java/dev/typetype/android/MainViewModel.kt +++ b/app/src/main/java/dev/typetype/android/MainViewModel.kt @@ -27,6 +27,8 @@ import dev.typetype.android.domain.subscriptions.SubscriptionsRepository import dev.typetype.android.domain.usersettings.UserSettings import dev.typetype.android.domain.usersettings.UserSettingsRepository import dev.typetype.android.feature.player.host.PlayerHostController +import dev.typetype.android.data.notifications.LocalNotificationScheduler +import dev.typetype.android.domain.push.PushRegistrationStatus import dev.typetype.android.services.push.PushRegistrationManager import javax.inject.Inject import kotlinx.coroutines.channels.Channel @@ -65,6 +67,7 @@ class MainViewModel @Inject constructor( private val libraryRepository: LibraryRepository, private val activeAccountScope: ActiveAccountScope, private val pushRegistrationManager: PushRegistrationManager, + private val localNotificationScheduler: LocalNotificationScheduler, private val startupLandingStore: StartupLandingStore, private val playbackResumeRepository: PlaybackResumeRepository, private val playbackQueueRepository: PlaybackQueueRepository, @@ -184,10 +187,15 @@ class MainViewModel @Inject constructor( launch { profileRepository.refresh() } launch { subscriptionsRepository.refresh() } launch { libraryRepository.resumePendingWrites() } - launch { pushRegistrationManager.reconcileRegistration() } + launch { reconcileNotificationDelivery() } } } + private suspend fun reconcileNotificationDelivery() { + val status = pushRegistrationManager.reconcileRegistration() + localNotificationScheduler.sync(status != PushRegistrationStatus.Registered) + } + private companion object { const val STARTUP_TIMEOUT_MS = 4_000L const val SESSION_VALIDATION_TIMEOUT_MS = 6_000L @@ -224,7 +232,7 @@ class MainViewModel @Inject constructor( launch { profileRepository.refresh() } launch { subscriptionsRepository.refresh() } launch { libraryRepository.resumePendingWrites() } - launch { pushRegistrationManager.reconcileRegistration() } + launch { reconcileNotificationDelivery() } launch { restorePlaybackUnlessExternalRequestArrives() } diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationScheduler.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationScheduler.kt new file mode 100644 index 00000000..0122602a --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationScheduler.kt @@ -0,0 +1,41 @@ +package dev.typetype.android.data.notifications + +import android.content.Context +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import dagger.hilt.android.qualifiers.ApplicationContext +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class LocalNotificationScheduler @Inject constructor( + @ApplicationContext context: Context, +) { + private val workManager = WorkManager.getInstance(context) + + fun sync(enabled: Boolean) { + if (!enabled) { + workManager.cancelUniqueWork(WORK_NAME) + return + } + workManager.enqueueUniquePeriodicWork( + WORK_NAME, + ExistingPeriodicWorkPolicy.UPDATE, + PeriodicWorkRequestBuilder(INTERVAL_MINUTES, TimeUnit.MINUTES) + .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) + .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, MIN_BACKOFF_MINUTES, TimeUnit.MINUTES) + .build(), + ) + } + + private companion object { + const val WORK_NAME = "typetype-local-notifications" + const val INTERVAL_MINUTES = 15L + const val MIN_BACKOFF_MINUTES = 5L + } +} diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationWorker.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationWorker.kt new file mode 100644 index 00000000..895f7afb --- /dev/null +++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationWorker.kt @@ -0,0 +1,27 @@ +package dev.typetype.android.data.notifications + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import dev.typetype.android.services.push.PushNotifier + +@HiltWorker +class LocalNotificationWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted parameters: WorkerParameters, + private val poller: LocalNotificationPoller, + private val notifier: PushNotifier, +) : CoroutineWorker(context, parameters) { + override suspend fun doWork(): Result { + if (!notifier.canNotify()) return Result.success() + return poller.poll().fold( + onSuccess = { Result.success() }, + onFailure = { failure -> + if (failure is IllegalStateException) Result.success() else Result.retry() + }, + ) + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt index 323ad25a..3f508382 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt @@ -92,6 +92,14 @@ internal fun PushNotificationsScreen( if (state.status == PushRegistrationStatus.MissingDistributor) { DistributorRow() } + if (!state.status.isRegistered()) { + Text( + text = stringResource(R.string.push_local_fallback), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(20.dp, 4.dp), + ) + } if (state.capabilityEnabled && state.deviceCount > 0) { StatusRow( label = stringResource(R.string.push_devices_count, state.deviceCount, state.maxDevices), diff --git a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsViewModel.kt b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsViewModel.kt index abd2f67a..b3ef011a 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsViewModel.kt @@ -3,6 +3,7 @@ package dev.typetype.android.feature.settings.notifications import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dev.typetype.android.data.notifications.LocalNotificationScheduler import dev.typetype.android.domain.push.PushRegistrationStatus import dev.typetype.android.domain.push.PushRepository import dev.typetype.android.services.push.PushRegistrationManager @@ -32,6 +33,7 @@ data class PushSettingsState( class PushNotificationsViewModel @Inject constructor( private val pushRepository: PushRepository, private val registrationManager: PushRegistrationManager, + private val localNotificationScheduler: LocalNotificationScheduler, ) : ViewModel() { private val _state = MutableStateFlow(PushSettingsState()) val state = _state.asStateFlow() @@ -44,6 +46,7 @@ class PushNotificationsViewModel @Inject constructor( viewModelScope.launch { registrationManager.status.collect { status -> _state.update { it.copy(status = status) } + localNotificationScheduler.sync(status != PushRegistrationStatus.Registered) } } } @@ -92,4 +95,5 @@ class PushNotificationsViewModel @Inject constructor( } } } + } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8296c3b8..3d1080a8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1211,6 +1211,7 @@ Install a UnifiedPush distributor such as ntfy to receive notifications. Choose distributor Couldn\'t update push notifications + UnifiedPush is inactive, so the app checks for new videos every 15 minutes Turn on new video notifications Turn off new video notifications Disable battery optimization for reliable notification delivery. From 0e5fd095cc817fc5d9ced807c48114f3820cc607 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 18:37:10 +0200 Subject: [PATCH 06/18] feat: prewarm sabr sessions for autoplay preload --- .../dev/typetype/android/services/PlaybackQueueItemResolver.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/dev/typetype/android/services/PlaybackQueueItemResolver.kt b/app/src/main/java/dev/typetype/android/services/PlaybackQueueItemResolver.kt index d78f6dd9..7a73ac36 100644 --- a/app/src/main/java/dev/typetype/android/services/PlaybackQueueItemResolver.kt +++ b/app/src/main/java/dev/typetype/android/services/PlaybackQueueItemResolver.kt @@ -42,7 +42,7 @@ class PlaybackQueueItemResolver @Inject constructor( preferOriginalLanguage = settings.preferOriginalLanguage, codecSupport = codecSupport, prepareSabrPlayback = { loaded, selection, _ -> - sabrPlaybackRepository.prepare(loaded.sabrPlaybackTarget(selection)).getOrThrow() + sabrPlaybackRepository.prewarm(loaded.sabrPlaybackTarget(selection)).getOrThrow() }, ), ) { "No playable source for queued video" } From bd510aa406f65ffe03247803d1296c7800eb88d2 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 18:54:32 +0200 Subject: [PATCH 07/18] feat: prewarm sabr playback when a video card is shown --- .../java/dev/typetype/android/AppNavHost.kt | 9 ++++- .../java/dev/typetype/android/MainActivity.kt | 10 +++++- .../android/core/di/RepositoryModule.kt | 6 ++++ .../core/ui/components/RelatedVideoCard.kt | 12 +++++++ .../android/core/ui/components/VideoCard.kt | 11 ++++++ .../data/stream/StreamRepositoryImpl.kt | 1 + .../typetype/android/domain/stream/Stream.kt | 1 + .../feature/player/LocalPlaybackPrewarm.kt | 11 ++++++ .../PlaybackPreheater.kt} | 35 ++++++++++++------- .../feature/player/PlaybackPrewarmLauncher.kt | 30 ++++++++++++++++ .../android/feature/shorts/ShortsViewModel.kt | 3 +- 11 files changed, 114 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/feature/player/LocalPlaybackPrewarm.kt rename app/src/main/java/dev/typetype/android/feature/{shorts/ShortsPlaybackPreheater.kt => player/PlaybackPreheater.kt} (59%) create mode 100644 app/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt diff --git a/app/src/main/java/dev/typetype/android/AppNavHost.kt b/app/src/main/java/dev/typetype/android/AppNavHost.kt index b43f93d3..aac890b3 100644 --- a/app/src/main/java/dev/typetype/android/AppNavHost.kt +++ b/app/src/main/java/dev/typetype/android/AppNavHost.kt @@ -27,6 +27,8 @@ import dev.typetype.android.core.ui.navigation.PrivacySettingsRoute import dev.typetype.android.core.ui.navigation.PublicPlaylistRoute import dev.typetype.android.core.ui.navigation.ProfileSettingsRoute import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.feature.player.LocalPlaybackPrewarm +import dev.typetype.android.feature.player.PlaybackPrewarm import dev.typetype.android.core.ui.navigation.AppearanceRoute import dev.typetype.android.core.ui.navigation.ContentSettingsRoute import dev.typetype.android.core.ui.navigation.HomeRoute @@ -61,7 +63,11 @@ import dev.typetype.android.feature.subscriptions.SubscriptionsRoute as Subscrip import kotlinx.coroutines.flow.collectLatest @Composable -fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) { +fun AppNavHost( + startRoute: Any, + mainViewModel: MainViewModel, + playbackPrewarm: PlaybackPrewarm? = null, +) { val navController: NavHostController = rememberNavController() val playerHostController = remember { mainViewModel.playerHostController } val currentServer by mainViewModel.currentServer.collectAsStateWithLifecycle() @@ -133,6 +139,7 @@ fun AppNavHost(startRoute: Any, mainViewModel: MainViewModel) { CompositionLocalProvider( LocalServerBaseUrl provides currentServer?.baseUrl, LocalDeArrowBranding provides deArrowEnvironment, + LocalPlaybackPrewarm provides playbackPrewarm, ) { AppShell( navController = navController, diff --git a/app/src/main/java/dev/typetype/android/MainActivity.kt b/app/src/main/java/dev/typetype/android/MainActivity.kt index 82093334..30b2839f 100644 --- a/app/src/main/java/dev/typetype/android/MainActivity.kt +++ b/app/src/main/java/dev/typetype/android/MainActivity.kt @@ -36,6 +36,7 @@ import dev.typetype.android.domain.navigation.resolveIncomingVideoUrl import dev.typetype.android.domain.navigation.resolveSharedVideoUrl import dev.typetype.android.domain.session.ActiveSessionRepository import dev.typetype.android.feature.player.components.PIP_ACTION_AUDIO_ONLY +import dev.typetype.android.feature.player.PlaybackPrewarm import dev.typetype.android.feature.player.components.PIP_ACTION_PLAY_PAUSE import dev.typetype.android.feature.player.components.PictureInPictureActionStateOwner import dev.typetype.android.feature.player.components.updatePictureInPicturePlaybackAction @@ -56,6 +57,9 @@ class MainActivity : ComponentActivity(), PictureInPictureActionStateOwner { @Inject lateinit var oidcCallbackRelay: OidcCallbackRelay + @Inject + lateinit var playbackPrewarm: PlaybackPrewarm + private val viewModel: MainViewModel by viewModels() private var activityReportingJob: Job? = null private var pipAudioOnlyAvailable = false @@ -105,7 +109,11 @@ class MainActivity : ComponentActivity(), PictureInPictureActionStateOwner { report = pendingCrashReport, onContinue = viewModel::continueAfterCrash, ) - startRoute != null -> AppNavHost(startRoute = startRoute, mainViewModel = viewModel) + startRoute != null -> AppNavHost( + startRoute = startRoute, + mainViewModel = viewModel, + playbackPrewarm = playbackPrewarm, + ) else -> FullScreenLoader() } } diff --git a/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt b/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt index 6675b24a..68100017 100644 --- a/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt +++ b/app/src/main/java/dev/typetype/android/core/di/RepositoryModule.kt @@ -23,6 +23,8 @@ import dev.typetype.android.data.imports.RemotePortabilityRepository import dev.typetype.android.data.imports.RoomYoutubeTakeoutImportRepository import dev.typetype.android.data.library.RoomVideoMetaRepository import dev.typetype.android.data.notifications.RemoteNotificationsRepository +import dev.typetype.android.feature.player.PlaybackPrewarm +import dev.typetype.android.feature.player.PlaybackPrewarmLauncher import dev.typetype.android.data.network.NetworkAvailabilityObserver import dev.typetype.android.data.network.PlaybackNetworkMonitor import dev.typetype.android.data.network.PlaybackNetworkObserver @@ -179,6 +181,10 @@ abstract class RepositoryModule { @Singleton abstract fun bindPushRepository(impl: RemotePushRepository): PushRepository + @Binds + @Singleton + internal abstract fun bindPlaybackPrewarm(impl: PlaybackPrewarmLauncher): PlaybackPrewarm + @Binds @Singleton internal abstract fun bindStreamRepository(impl: StreamRepositoryImpl): StreamRepository diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt index 3d2bacf2..4063537c 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt @@ -18,6 +18,7 @@ import androidx.compose.foundation.shape.CircleShape 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 @@ -41,6 +42,9 @@ import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.feed.VideoAvailability import dev.typetype.android.domain.feed.availabilityAt import dev.typetype.android.domain.feed.releaseTimeMillis +import dev.typetype.android.feature.player.CARD_PREWARM_DELAY_MILLIS +import dev.typetype.android.feature.player.LocalPlaybackPrewarm +import kotlinx.coroutines.delay @OptIn(ExperimentalFoundationApi::class) @Composable @@ -55,6 +59,14 @@ fun RelatedVideoCard( var menuVisible by remember { mutableStateOf(false) } var availabilityVisible by remember { mutableStateOf(false) } val serverBaseUrl = LocalServerBaseUrl.current + val playbackPrewarm = LocalPlaybackPrewarm.current + + LaunchedEffect(video.url, playbackPrewarm) { + if (playbackPrewarm == null) return@LaunchedEffect + delay(CARD_PREWARM_DELAY_MILLIS) + playbackPrewarm.prewarm(video.url) + } + val availability = video.availabilityAt(System.currentTimeMillis()) val branding = rememberVideoBranding( sourceUrl = video.url, diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt index d0e300fd..b2f5e907 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.Icon 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 @@ -42,6 +43,7 @@ import androidx.compose.ui.platform.LocalContext import coil3.compose.AsyncImage import coil3.request.ImageRequest import coil3.request.crossfade +import kotlinx.coroutines.delay import dev.typetype.android.R import dev.typetype.android.core.ui.branding.rememberVideoBranding import dev.typetype.android.core.ui.share.LocalServerBaseUrl @@ -50,6 +52,8 @@ import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.feed.VideoAvailability import dev.typetype.android.domain.feed.availabilityAt import dev.typetype.android.domain.feed.releaseTimeMillis +import dev.typetype.android.feature.player.CARD_PREWARM_DELAY_MILLIS +import dev.typetype.android.feature.player.LocalPlaybackPrewarm @OptIn(ExperimentalFoundationApi::class) @Composable @@ -73,6 +77,13 @@ fun VideoCard( durationSeconds = video.durationSeconds, ) val openDescription = stringResource(R.string.video_open_accessibility, branding.title) + val playbackPrewarm = LocalPlaybackPrewarm.current + + LaunchedEffect(video.url, playbackPrewarm) { + if (playbackPrewarm == null) return@LaunchedEffect + delay(CARD_PREWARM_DELAY_MILLIS) + playbackPrewarm.prewarm(video.url) + } Column( modifier = modifier diff --git a/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt b/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt index 70cb4f9c..78772605 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt @@ -195,6 +195,7 @@ internal class StreamRepositoryImpl @Inject constructor( isLive = isLive, isPostLive = isPostLive, isLiveContent = isLiveContent, + requiresMembership = requiresMembership, category = category, ) } diff --git a/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt b/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt index fa142f66..88ecc117 100644 --- a/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt +++ b/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt @@ -41,6 +41,7 @@ data class Stream( val isLive: Boolean = false, val isPostLive: Boolean = false, val isLiveContent: Boolean = false, + val requiresMembership: Boolean = false, val category: String? = null, ) diff --git a/app/src/main/java/dev/typetype/android/feature/player/LocalPlaybackPrewarm.kt b/app/src/main/java/dev/typetype/android/feature/player/LocalPlaybackPrewarm.kt new file mode 100644 index 00000000..3217cb00 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/LocalPlaybackPrewarm.kt @@ -0,0 +1,11 @@ +package dev.typetype.android.feature.player + +import androidx.compose.runtime.staticCompositionLocalOf + +fun interface PlaybackPrewarm { + fun prewarm(videoUrl: String) +} + +val LocalPlaybackPrewarm = staticCompositionLocalOf { null } + +const val CARD_PREWARM_DELAY_MILLIS = 200L diff --git a/app/src/main/java/dev/typetype/android/feature/shorts/ShortsPlaybackPreheater.kt b/app/src/main/java/dev/typetype/android/feature/player/PlaybackPreheater.kt similarity index 59% rename from app/src/main/java/dev/typetype/android/feature/shorts/ShortsPlaybackPreheater.kt rename to app/src/main/java/dev/typetype/android/feature/player/PlaybackPreheater.kt index c8e63d6e..4b69aa99 100644 --- a/app/src/main/java/dev/typetype/android/feature/shorts/ShortsPlaybackPreheater.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlaybackPreheater.kt @@ -1,32 +1,40 @@ -package dev.typetype.android.feature.shorts +package dev.typetype.android.feature.player +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext import dev.typetype.android.domain.stream.SabrPlaybackRepository +import dev.typetype.android.domain.stream.Stream import dev.typetype.android.domain.stream.StreamRepository import dev.typetype.android.domain.stream.sabrPlaybackTarget import dev.typetype.android.domain.usersettings.UserSettings -import dev.typetype.android.feature.player.PlaybackCodecSupport -import dev.typetype.android.feature.player.RECOMMENDED_CODEC_KEY -import dev.typetype.android.feature.player.effectiveQuality -import dev.typetype.android.feature.player.initialAudioKey -import dev.typetype.android.feature.player.initialQuality -import dev.typetype.android.feature.player.SabrPlaybackPreloadStore -import dev.typetype.android.feature.player.sabrSelection +import dev.typetype.android.domain.usersettings.UserSettingsRepository import javax.inject.Inject +import javax.inject.Singleton -class ShortsPlaybackPreheater @Inject constructor( +@Singleton +class PlaybackPreheater @Inject constructor( + @ApplicationContext context: Context, private val streams: StreamRepository, private val sabr: SabrPlaybackRepository, private val preloads: SabrPlaybackPreloadStore, + private val userSettingsRepository: UserSettingsRepository, ) { + private val codecSupport: PlaybackCodecSupport = DevicePlaybackCodecSupport(context) + + suspend fun preheat(videoUrl: String) { + val settings = userSettingsRepository.current().getOrNull() ?: return + preheat(videoUrl, settings, codecSupport, { true }) + } + internal suspend fun preheat( videoUrl: String, settings: UserSettings, codecSupport: PlaybackCodecSupport, - prepareSession: () -> Boolean, + canPrepare: () -> Boolean, ) { val stream = streams.prefetchPlaybackStream(videoUrl).getOrNull() ?: return - if (!prepareSession()) return - if (stream.isLive || stream.isLiveContent) return + if (!stream.isPreheatable()) return + if (!canPrepare()) return val selection = stream.sabrSelection( selectedQuality = stream.initialQuality().effectiveQuality(settings.defaultQuality), selectedAudioKey = stream.initialAudioKey( @@ -43,3 +51,6 @@ class ShortsPlaybackPreheater @Inject constructor( if (reservation.owner) reservation.result.complete(sabr.prewarm(target, 0L)) } } + +private fun Stream.isPreheatable(): Boolean = + !isLive && !isPostLive && !isLiveContent && !requiresMembership diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt b/app/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt new file mode 100644 index 00000000..06f36b84 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt @@ -0,0 +1,30 @@ +package dev.typetype.android.feature.player + +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +@Singleton +internal class PlaybackPrewarmLauncher @Inject constructor( + private val preheater: PlaybackPreheater, +) : PlaybackPrewarm { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val inFlight = mutableSetOf() + + override fun prewarm(videoUrl: String) { + if (videoUrl.isBlank()) return + synchronized(inFlight) { + if (!inFlight.add(videoUrl)) return + } + scope.launch { + try { + preheater.preheat(videoUrl) + } finally { + synchronized(inFlight) { inFlight.remove(videoUrl) } + } + } + } +} diff --git a/app/src/main/java/dev/typetype/android/feature/shorts/ShortsViewModel.kt b/app/src/main/java/dev/typetype/android/feature/shorts/ShortsViewModel.kt index f1f17c70..a79ec3b5 100644 --- a/app/src/main/java/dev/typetype/android/feature/shorts/ShortsViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/shorts/ShortsViewModel.kt @@ -14,6 +14,7 @@ import dev.typetype.android.domain.library.cacheVideos import dev.typetype.android.domain.usersettings.UserSettingsRepository import dev.typetype.android.domain.usersettings.UserSettings import dev.typetype.android.feature.player.PlaybackCodecSupport +import dev.typetype.android.feature.player.PlaybackPreheater import javax.inject.Inject import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job @@ -32,7 +33,7 @@ class ShortsViewModel @Inject constructor( private val videoMetaRepository: VideoMetaRepository, private val userSettingsRepository: UserSettingsRepository, private val errorMapper: UserErrorMapper, - private val playbackPreheater: ShortsPlaybackPreheater, + private val playbackPreheater: PlaybackPreheater, ) : ViewModel() { private val _state = MutableStateFlow(ShortsState()) val state = _state.asStateFlow() From 9243012f83d53e79830ad7eb11589dc26578d8b6 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 19:10:27 +0200 Subject: [PATCH 08/18] perf: serialize card prewarm requests --- .../android/feature/player/PlaybackPrewarmLauncher.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt b/app/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt index 06f36b84..def35f27 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt @@ -12,18 +12,19 @@ internal class PlaybackPrewarmLauncher @Inject constructor( private val preheater: PlaybackPreheater, ) : PlaybackPrewarm { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val inFlight = mutableSetOf() + private var inFlight = false override fun prewarm(videoUrl: String) { if (videoUrl.isBlank()) return - synchronized(inFlight) { - if (!inFlight.add(videoUrl)) return + synchronized(this) { + if (inFlight) return + inFlight = true } scope.launch { try { preheater.preheat(videoUrl) } finally { - synchronized(inFlight) { inFlight.remove(videoUrl) } + synchronized(this@PlaybackPrewarmLauncher) { inFlight = false } } } } From 13ebbf5262fe6a04a3ba392eb14bd0ba2b42f959 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 19:40:49 +0200 Subject: [PATCH 09/18] fix: play provider media handles on niconico and bilibili --- .../data/stream/StreamRepositoryImpl.kt | 35 ++- .../typetype/android/domain/stream/Stream.kt | 1 + .../feature/player/PlayerMediaBinder.kt | 9 +- .../feature/player/ProviderPlaybackSource.kt | 4 +- .../StreamRepositoryProviderMediaTest.kt | 203 ++++++++++++++++++ .../player/PlaybackSourceSelectorTest.kt | 29 +++ 6 files changed, 268 insertions(+), 13 deletions(-) create mode 100644 app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryProviderMediaTest.kt diff --git a/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt b/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt index 78772605..bfa69d49 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt @@ -143,7 +143,7 @@ internal class StreamRepositoryImpl @Inject constructor( uploadedAtMillis = uploaded, hlsUrl = resolvedHlsUrl.takeIf { !serverSabr && it.isNotBlank() }, dashMpdUrl = dashMpdUrl.takeIf { !serverSabr && it.isNotBlank() }, - progressiveUrl = pickBestProgressiveStream(videoStreams).takeUnless { serverSabr }, + progressiveUrl = pickBestProgressiveStream(baseUrl, videoStreams).takeUnless { serverSabr }, serverDashManifestUrl = serverManifestUrl(baseUrl, "streams/manifest", videoUrl) .takeUnless { serverSabr || serverYoutube }, serverHlsManifestUrl = hlsUrl.takeIf { !serverSabr && !serverYoutube && it.isNotBlank() } @@ -207,11 +207,14 @@ internal class StreamRepositoryImpl @Inject constructor( item.manifestUrl.takeIf { item.deliveryMethod == SABR_DELIVERY_METHOD } } - private fun pickBestProgressiveStream(videoStreams: List): String? = + private fun pickBestProgressiveStream( + baseUrl: String, + videoStreams: List, + ): String? = videoStreams .filter { !it.isVideoOnly && it.url.isNotBlank() } .maxByOrNull { it.height } - ?.url + ?.let { resolvePlaybackUrl(baseUrl, it.url) } private fun selectStoryboard(storyboards: List): StreamStoryboard? = storyboards.filter { it.frameWidth >= TARGET_STORYBOARD_FRAME_WIDTH } @@ -236,16 +239,21 @@ internal class StreamRepositoryImpl @Inject constructor( } private fun VideoStreamItem.toDomainVideoSource(baseUrl: String): StreamVideoSource = StreamVideoSource( - url = if (deliveryMethod == SABR_DELIVERY_METHOD) resolveServerUrl(baseUrl, manifestUrl).orEmpty() else url, + url = if (deliveryMethod == SABR_DELIVERY_METHOD) { + resolveServerUrl(baseUrl, manifestUrl).orEmpty() + } else { + resolvePlaybackUrl(baseUrl, url).orEmpty() + }, mimeType = mimeType, + playbackMimeType = HLS_MIME_TYPE.takeIf { deliveryMethod == HLS_DELIVERY_METHOD }, codec = codec, resolution = buildString { - append("${height.coerceAtLeast(0)}p") + append("${resolvedHeight()}p") fps.takeIf { it > 0 }?.let { append(it) } if (resolution.contains("HDR", ignoreCase = true)) append(" HDR") }, - width = width, - height = height, + width = width.coerceAtLeast(0), + height = resolvedHeight(), fps = fps, bitrate = bitrate, isVideoOnly = isVideoOnly, @@ -253,7 +261,11 @@ internal class StreamRepositoryImpl @Inject constructor( ) private fun AudioStreamItem.toDomainAudioSource(baseUrl: String): StreamAudioSource = StreamAudioSource( - url = if (deliveryMethod == SABR_DELIVERY_METHOD) resolveServerUrl(baseUrl, manifestUrl).orEmpty() else url, + url = if (deliveryMethod == SABR_DELIVERY_METHOD) { + resolveServerUrl(baseUrl, manifestUrl).orEmpty() + } else { + resolvePlaybackUrl(baseUrl, url).orEmpty() + }, mimeType = mimeType, codec = codec, bitrate = bitrate, @@ -300,8 +312,15 @@ internal suspend fun cancellableStreamResult( } private const val SABR_DELIVERY_METHOD = "sabr" +private const val HLS_DELIVERY_METHOD = "hls" +private const val HLS_MIME_TYPE = "application/vnd.apple.mpegurl" private const val TARGET_STORYBOARD_FRAME_WIDTH = 160 +private fun VideoStreamItem.resolvedHeight(): Int = + height.takeIf { it > 0 } ?: RESOLUTION_HEIGHT_PATTERN.find(resolution)?.groupValues?.get(1)?.toIntOrNull() ?: 0 + +private val RESOLUTION_HEIGHT_PATTERN = Regex("(\\d{3,4})") + private class SabrContractException : IllegalStateException("The server returned no playable SABR contract"), CodedFailure { diff --git a/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt b/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt index 88ecc117..35acd661 100644 --- a/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt +++ b/app/src/main/java/dev/typetype/android/domain/stream/Stream.kt @@ -97,6 +97,7 @@ enum class StreamPlaybackContract { data class StreamVideoSource( val url: String, val mimeType: String, + val playbackMimeType: String? = null, val codec: String?, val resolution: String, val width: Int, diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlayerMediaBinder.kt b/app/src/main/java/dev/typetype/android/feature/player/PlayerMediaBinder.kt index 0f7a2eac..b97be1fd 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlayerMediaBinder.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlayerMediaBinder.kt @@ -10,6 +10,7 @@ import dev.typetype.android.domain.stream.Stream import dev.typetype.android.domain.stream.StreamAudioSource import dev.typetype.android.domain.stream.StreamPlaybackContract import dev.typetype.android.domain.stream.StreamSubtitleSource +import dev.typetype.android.domain.stream.StreamVideoSource import dev.typetype.android.services.MergedStreamMediaKeys import dev.typetype.android.services.sabrPlaybackBinding import dev.typetype.android.services.sabrPlaybackTarget @@ -216,7 +217,6 @@ private fun pickMergedSource( codecSupport: PlaybackCodecSupport, selectedCodec: String, ): PlayableSource? { - if (selectedAudioKey == null) return null val video = stream.videoOnlyStreams.pickVideo( selectedQuality, codecSupport, @@ -232,7 +232,7 @@ private fun pickMergedSource( ) ?: return null return PlayableSource( url = video.url, - mimeType = video.mimeType.normalizedMimeType(), + mimeType = video.playbackContainerMimeType(), audioUrl = audio.url, audioMimeType = audio.mimeType.normalizedMimeType(), ) @@ -247,7 +247,7 @@ private fun pickMuxedSource( stream.muxedVideoStreams.pickVideo(defaultQuality, codecSupport, selectedCodec)?.let { source -> PlayableSource( url = source.url, - mimeType = source.mimeType.normalizedMimeType() ?: MimeTypes.VIDEO_MP4, + mimeType = source.playbackContainerMimeType() ?: MimeTypes.VIDEO_MP4, ) } @@ -279,6 +279,9 @@ private fun applyTrackSelectionDefaults( private fun String.normalizedMimeType(): String? = substringBefore(";").trim().takeIf { it.isNotBlank() } +internal fun StreamVideoSource.playbackContainerMimeType(): String? = + (playbackMimeType ?: mimeType).normalizedMimeType() + internal val StreamAudioSource.key: String get() = audioTrackId?.takeIf { it.isNotBlank() } ?: audioLocale?.takeIf { it.isNotBlank() } diff --git a/app/src/main/java/dev/typetype/android/feature/player/ProviderPlaybackSource.kt b/app/src/main/java/dev/typetype/android/feature/player/ProviderPlaybackSource.kt index 5e452b4f..5fab22af 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/ProviderPlaybackSource.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/ProviderPlaybackSource.kt @@ -17,7 +17,7 @@ internal fun Stream.pickExplicitProviderSource( if (!video.isVideoOnly) { return PlayableSource( url = video.url, - mimeType = video.mimeType.normalizedProviderMimeType() ?: MimeTypes.VIDEO_MP4, + mimeType = video.playbackContainerMimeType() ?: MimeTypes.VIDEO_MP4, sourceKey = video.videoSelectionKey(), ) } @@ -31,7 +31,7 @@ internal fun Stream.pickExplicitProviderSource( ) ?: return null return PlayableSource( url = video.url, - mimeType = video.mimeType.normalizedProviderMimeType(), + mimeType = video.playbackContainerMimeType(), audioUrl = audio.url, audioMimeType = audio.mimeType.normalizedProviderMimeType(), sourceKey = video.videoSelectionKey(), diff --git a/app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryProviderMediaTest.kt b/app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryProviderMediaTest.kt new file mode 100644 index 00000000..4b281bbd --- /dev/null +++ b/app/src/test/java/dev/typetype/android/data/stream/StreamRepositoryProviderMediaTest.kt @@ -0,0 +1,203 @@ +package dev.typetype.android.data.stream + +import dev.typetype.android.data.account.AccountScope +import dev.typetype.android.data.account.AccountScopeProvider +import dev.typetype.android.data.network.AlwaysAvailablePlaybackNetworkObserver +import dev.typetype.android.data.network.dto.AudioStreamItem +import dev.typetype.android.data.network.dto.StreamResponse +import dev.typetype.android.data.network.dto.VideoStreamItem +import dev.typetype.android.domain.server.Server +import dev.typetype.android.domain.server.ServerRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import retrofit2.Response + +class StreamRepositoryProviderMediaTest { + @Test + fun `niconico media handles resolve and keep hls playback container`() = runBlocking { + val repository = repository( + NICO_URL, + response( + hlsVideoHandles = true, + videoHandles = listOf(HANDLE_A, HANDLE_B), + audioHandles = listOf(HANDLE_AUDIO), + ), + ) + + val stream = repository.loadPlaybackStream(NICO_URL).getOrThrow() + val video = stream.videoOnlyStreams.first() + + assertEquals("$BASE_URL${HANDLE_A.substringAfter('/')}", video.url) + assertEquals("application/vnd.apple.mpegurl", video.playbackMimeType) + assertEquals(360, video.height) + assertEquals("$BASE_URL${HANDLE_AUDIO.substringAfter('/')}", stream.audioStreams.first().url) + assertNull(stream.hlsUrl) + } + + @Test + fun `bilibili progressive handles resolve without a playback container`() = runBlocking { + val repository = repository( + BILI_URL, + response( + hlsVideoHandles = false, + videoHandles = listOf(HANDLE_A), + audioHandles = listOf(HANDLE_AUDIO), + height = 384, + codec = "avc1.64001E", + ), + ) + + val stream = repository.loadPlaybackStream(BILI_URL).getOrThrow() + + assertEquals("$BASE_URL${HANDLE_A.substringAfter('/')}", stream.videoOnlyStreams.first().url) + assertNull(stream.videoOnlyStreams.first().playbackMimeType) + assertEquals(384, stream.videoOnlyStreams.first().height) + } + + @Test + fun `absolute provider media url is preserved`() = runBlocking { + val repository = repository( + NICO_URL, + response( + hlsVideoHandles = false, + videoHandles = listOf(), + audioHandles = listOf(), + absoluteUrl = "https://cdn.example/media.m3u8", + ), + ) + + val stream = repository.loadPlaybackStream(NICO_URL).getOrThrow() + + assertEquals("https://cdn.example/media.m3u8", stream.videoOnlyStreams.first().url) + } + + private fun repository(videoUrl: String, response: StreamResponse) = StreamRepositoryImpl( + remoteSource = FixedStreamRemoteSource(videoUrl, response), + activeAccountScope = FixedAccountScope, + serverRepository = FixedServerRepository, + networkMonitor = AlwaysAvailablePlaybackNetworkObserver, + ) + + private class FixedStreamRemoteSource( + private val expectedUrl: String, + private val response: StreamResponse, + ) : StreamRemoteSource { + override suspend fun load( + scope: AccountScope, + videoUrl: String, + provider: StreamProvider, + playbackBootstrap: Boolean, + ): Response { + check(videoUrl == expectedUrl) + return Response.success(response) + } + } + + private object FixedAccountScope : AccountScopeProvider { + private val scope = AccountScope(SERVER_ID, ACCOUNT_ID) + + override fun observe(): Flow = flowOf(scope) + + override suspend fun require(): AccountScope = scope + + override suspend fun verify(expected: AccountScope) { + check(expected == scope) + } + } + + private object FixedServerRepository : ServerRepository { + private val server = Server(SERVER_ID, BASE_URL, "Instance", 0L) + + override fun observeServers(): Flow> = flowOf(listOf(server)) + override fun observeCurrentServer(): Flow = flowOf(server) + override suspend fun getServer(id: String): Server? = server.takeIf { id == SERVER_ID } + override suspend fun addServer(server: Server) = Unit + override suspend fun deleteServer(id: String) = Unit + override suspend fun setCurrentServer(id: String) = Unit + override suspend fun clearCurrentServer() = Unit + } + + private fun response( + hlsVideoHandles: Boolean, + videoHandles: List, + audioHandles: List, + height: Int = 0, + codec: String? = null, + absoluteUrl: String? = null, + ) = StreamResponse( + id = "video", + title = "Video", + uploaderName = "Channel", + uploaderUrl = "/channel", + uploaderAvatarUrl = "", + thumbnailUrl = "", + description = "", + duration = 320L, + viewCount = 1L, + likeCount = 0L, + dislikeCount = 0L, + uploadDate = "", + uploaded = 0L, + uploaderSubscriberCount = 1L, + uploaderVerified = false, + category = "", + license = "", + visibility = "public", + streamType = "VIDEO_STREAM", + isShortFormContent = false, + requiresMembership = false, + startPosition = 0L, + hlsUrl = "", + dashMpdUrl = "", + videoOnlyStreams = (videoHandles + listOfNotNull(absoluteUrl)).map { handle -> + videoStream(handle, hlsVideoHandles, height, codec) + }, + audioStreams = audioHandles.map { handle -> audioStream(handle) }, + ) + + private fun videoStream( + url: String, + hls: Boolean, + height: Int, + codec: String?, + ) = VideoStreamItem( + url = url, + mimeType = "video/mp4", + format = "MPEG-4", + resolution = "360p", + codec = codec, + isVideoOnly = true, + itag = -1, + width = 0, + height = height, + fps = 0, + contentLength = 0L, + deliveryMethod = if (hls) "hls" else "progressive", + ) + + private fun audioStream(url: String) = AudioStreamItem( + url = url, + mimeType = "audio/mp4", + format = "m4a", + codec = null, + itag = -1, + contentLength = 0L, + isOriginal = false, + deliveryMethod = "progressive", + ) + + private companion object { + const val SERVER_ID = "server" + const val ACCOUNT_ID = "account" + const val BASE_URL = "https://instance.example/api/" + const val NICO_URL = "https://www.nicovideo.jp/watch/sm9" + const val BILI_URL = "https://www.bilibili.com/video/BV1xx411c7mD" + const val HANDLE_A = "/media/m1_WcTj0wQ0RcWaByyoMvP8E0ak" + const val HANDLE_B = "/media/m1_vyvt1J_-qiWsTYEyHrEP_q-D" + const val HANDLE_AUDIO = "/media/m1_X9jjkzoAVheGT2QYaOYSR08A" + } +} diff --git a/app/src/test/java/dev/typetype/android/feature/player/PlaybackSourceSelectorTest.kt b/app/src/test/java/dev/typetype/android/feature/player/PlaybackSourceSelectorTest.kt index 0a1981ca..2b6b11c2 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/PlaybackSourceSelectorTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/PlaybackSourceSelectorTest.kt @@ -4,6 +4,7 @@ import dev.typetype.android.domain.stream.StreamAudioSource import dev.typetype.android.domain.stream.Stream import dev.typetype.android.domain.stream.StreamPlaybackContract import dev.typetype.android.domain.stream.StreamVideoSource +import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Test @@ -257,6 +258,34 @@ class PlaybackSourceSelectorTest { assertEquals(french, selected) } + @Test + fun `provider video with separate audio merges without an explicit audio key`() = runBlocking { + val video = video("video", height = 360, codec = "avc1.64001E") + .copy(playbackMimeType = "application/vnd.apple.mpegurl") + val audio = audio("audio", locale = "ja") + val support = FakeCodecSupport( + video = mapOf(video.url to DecoderSupport.Hardware), + audio = mapOf(audio.url to DecoderSupport.Hardware), + ) + + val source = pickPlayableSource( + stream = providerStream(listOf(video), listOf(audio)), + selectedQuality = RECOMMENDED_QUALITY_KEY, + selectedAudioKey = null, + defaultAudioLanguage = "en", + automaticQualityCap = RECOMMENDED_QUALITY_KEY, + preferOriginalLanguage = false, + codecSupport = support, + prepareSabrPlayback = { _, _, _ -> null }, + ) + + requireNotNull(source) + assertEquals("video", source.url) + assertEquals("audio", source.audioUrl) + assertEquals("application/vnd.apple.mpegurl", source.mimeType) + assertEquals("audio/mp4", source.audioMimeType) + } + private fun video(url: String, height: Int, codec: String, itag: Int = 0) = StreamVideoSource( url = url, mimeType = "video/mp4", From 56d82cc85be4a0a4a12e9873d094fd1480061306 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 20:06:17 +0200 Subject: [PATCH 10/18] fix: fall back when sabr bootstrap cannot serve a video --- .../data/stream/StreamEndpointLoader.kt | 11 +++++- .../data/stream/StreamEndpointLoaderTest.kt | 35 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/dev/typetype/android/data/stream/StreamEndpointLoader.kt b/app/src/main/java/dev/typetype/android/data/stream/StreamEndpointLoader.kt index 7092b7b9..b23c02b1 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/StreamEndpointLoader.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/StreamEndpointLoader.kt @@ -26,9 +26,18 @@ internal suspend fun TypeTypeMediaApi.loadYouTubeSabrBootstrapResponse( ): Response { val response = youtubeSabrBootstrap(videoUrl) response.rejectSabrRedirect("SABR stream bootstrap attempted to redirect") - return response + if (response.isSuccessful) return response + if (!response.canFallBackToSabrStreams()) return response + val fallback = youtubeSabrStreams(videoUrl) + fallback.rejectSabrRedirect("SABR stream discovery attempted to redirect") + return fallback } +private fun Response.canFallBackToSabrStreams(): Boolean = + code() in BOOTSTRAP_FALLBACK_STATUSES + +private val BOOTSTRAP_FALLBACK_STATUSES = setOf(404, 405, 422, 500, 501, 502, 503, 504) + private suspend fun TypeTypeMediaApi.fallbackToGeneric( response: Response, videoUrl: String, diff --git a/app/src/test/java/dev/typetype/android/data/stream/StreamEndpointLoaderTest.kt b/app/src/test/java/dev/typetype/android/data/stream/StreamEndpointLoaderTest.kt index 4fc17535..2724714e 100644 --- a/app/src/test/java/dev/typetype/android/data/stream/StreamEndpointLoaderTest.kt +++ b/app/src/test/java/dev/typetype/android/data/stream/StreamEndpointLoaderTest.kt @@ -105,6 +105,41 @@ class StreamEndpointLoaderTest { assertFalse(body?.hasPlayableLiveContract(server.url("/").toString()) == true) } + @Test + fun youtubePlaybackFallsBackWhenTheBootstrapRouteCannotServeTheVideo() = runBlocking { + server.enqueue( + MockResponse() + .setResponseCode(503) + .setHeader("Content-Type", "application/json") + .setBody("{\"error\":\"The video provider is blocking requests\",\"code\":\"provider_access_blocked\"}"), + ) + server.enqueue( + jsonResponse( + sabr = false, + live = true, + hlsUrl = "/streams/hls-manifest?token=signed", + ), + ) + + val response = api.loadYouTubeSabrBootstrapResponse(YOUTUBE_URL) + val body = response.body() + + assertTrue(response.isSuccessful) + assertTrue(body?.hasPlayableLiveContract(server.url("/").toString()) == true) + assertEquals("/streams/youtube/sabr/bootstrap?url=${encode(YOUTUBE_URL)}", server.takeRequest().path) + assertEquals("/streams/youtube/sabr?url=${encode(YOUTUBE_URL)}", server.takeRequest().path) + } + + @Test + fun youtubePlaybackKeepsABootstrapFailureThatTheSabrRouteCannotFix() = runBlocking { + server.enqueue(errorResponse(401)) + + val response = api.loadYouTubeSabrBootstrapResponse(YOUTUBE_URL) + + assertEquals(401, response.code()) + assertEquals(1, server.requestCount) + } + @Test fun youtubeSabrResponseWithoutValidItagsIsRejected() = runBlocking { server.enqueue(jsonResponse(sabr = true, videoItag = 0)) From 11c07192e6b229d1f9b09094f4c397cce802d809 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 20:31:03 +0200 Subject: [PATCH 11/18] fix: encode watch urls without the jvm url encoder --- .../android/core/ui/share/ShareUrls.kt | 7 +++--- .../android/core/url/PercentEncoding.kt | 22 ++++++++++++++++ .../data/stream/StreamRepositoryImpl.kt | 5 ++-- .../android/core/url/PercentEncodingTest.kt | 25 +++++++++++++++++++ 4 files changed, 52 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/core/url/PercentEncoding.kt create mode 100644 app/src/test/java/dev/typetype/android/core/url/PercentEncodingTest.kt diff --git a/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt b/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt index 9fbb1c6f..f7aeddb1 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/share/ShareUrls.kt @@ -1,11 +1,10 @@ package dev.typetype.android.core.ui.share import androidx.compose.runtime.compositionLocalOf +import dev.typetype.android.core.url.percentEncode import dev.typetype.android.domain.navigation.resolveIncomingVideoUrl import dev.typetype.android.domain.navigation.toPublicWatchParameter import java.net.URI -import java.net.URLEncoder -import java.nio.charset.StandardCharsets val LocalServerBaseUrl = compositionLocalOf { null } @@ -16,7 +15,7 @@ fun buildShareUrl(serverBaseUrl: String?, videoUrl: String): String { .removeSuffix("/api") .trimEnd('/') if (origin.isBlank()) return videoUrl - val encoded = URLEncoder.encode(toPublicWatchParameter(videoUrl), StandardCharsets.UTF_8.toString()) + val encoded = percentEncode(toPublicWatchParameter(videoUrl)) return "$origin/watch?v=$encoded" } @@ -77,7 +76,7 @@ fun buildImageUrl(serverBaseUrl: String?, imageUrl: String): String { if (!source.startsWith("http://") && !source.startsWith("https://")) return source if (!needsImageProxy(source)) return source - val encoded = URLEncoder.encode(source, StandardCharsets.UTF_8.toString()) + val encoded = percentEncode(source) return "$base/proxy?url=$encoded" } diff --git a/app/src/main/java/dev/typetype/android/core/url/PercentEncoding.kt b/app/src/main/java/dev/typetype/android/core/url/PercentEncoding.kt new file mode 100644 index 00000000..0829225e --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/url/PercentEncoding.kt @@ -0,0 +1,22 @@ +package dev.typetype.android.core.url + +private const val HEX = "0123456789ABCDEF" + +internal fun percentEncode(value: String): String { + val bytes = value.toByteArray(Charsets.UTF_8) + val encoded = StringBuilder(bytes.size) + for (byte in bytes) { + val code = byte.toInt() and 0xff + val character = code.toChar() + if (character in 'A'..'Z' || character in 'a'..'z' || character in '0'..'9' || + character == '-' || character == '_' || character == '.' || character == '~' + ) { + encoded.append(character) + } else { + encoded.append('%') + .append(HEX[(code shr 4) and 0x0f]) + .append(HEX[code and 0x0f]) + } + } + return encoded.toString() +} diff --git a/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt b/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt index bfa69d49..3ee789cf 100644 --- a/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt +++ b/app/src/main/java/dev/typetype/android/data/stream/StreamRepositoryImpl.kt @@ -2,6 +2,7 @@ package dev.typetype.android.data.stream import dev.typetype.android.data.account.AccountScopeProvider import dev.typetype.android.core.error.CodedFailure +import dev.typetype.android.core.url.percentEncode import dev.typetype.android.data.network.PlaybackNetworkObserver import dev.typetype.android.data.network.dto.AudioStreamItem import dev.typetype.android.data.network.dto.PreviewFrameItem @@ -26,8 +27,6 @@ import dev.typetype.android.domain.stream.StreamVideoSource import dev.typetype.android.domain.stream.isServerSabrAudioFormat import dev.typetype.android.domain.stream.isServerSabrVideoFormat import dev.typetype.android.domain.server.ServerRepository -import java.net.URLEncoder -import java.nio.charset.StandardCharsets import javax.inject.Inject import javax.inject.Singleton import kotlin.math.roundToLong @@ -234,7 +233,7 @@ internal class StreamRepositoryImpl @Inject constructor( private fun serverManifestUrl(baseUrl: String, path: String, videoUrl: String): String { val normalizedBaseUrl = baseUrl.trimEnd('/') - val encoded = URLEncoder.encode(videoUrl, StandardCharsets.UTF_8) + val encoded = percentEncode(videoUrl) return "$normalizedBaseUrl/$path?url=$encoded" } diff --git a/app/src/test/java/dev/typetype/android/core/url/PercentEncodingTest.kt b/app/src/test/java/dev/typetype/android/core/url/PercentEncodingTest.kt new file mode 100644 index 00000000..39535ac5 --- /dev/null +++ b/app/src/test/java/dev/typetype/android/core/url/PercentEncodingTest.kt @@ -0,0 +1,25 @@ +package dev.typetype.android.core.url + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PercentEncodingTest { + @Test + fun `keeps unreserved characters`() { + assertEquals("aZ0-_.~", percentEncode("aZ0-_.~")) + } + + @Test + fun `escapes reserved query characters`() { + assertEquals( + "https%3A%2F%2Fexample.test%2Fwatch%3Fv%3Da%26b%3Dc", + percentEncode("https://example.test/watch?v=a&b=c"), + ) + } + + @Test + fun `escapes spaces and multibyte characters as utf8`() { + assertEquals("a%20b", percentEncode("a b")) + assertEquals("%C3%A9t%C3%A9", percentEncode("été")) + } +} From e05505b009d6c2d53b2471fe5e7ecf02d6a9d4f3 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 21:32:35 +0200 Subject: [PATCH 12/18] perf: skip sabr prewarm for live videos --- .../typetype/android/core/ui/components/RelatedVideoCard.kt | 2 +- .../java/dev/typetype/android/core/ui/components/VideoCard.kt | 2 +- .../typetype/android/feature/player/LocalPlaybackPrewarm.kt | 2 +- .../dev/typetype/android/feature/player/PlaybackPreheater.kt | 3 ++- .../android/feature/player/PlaybackPrewarmLauncher.kt | 4 ++-- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt index 4063537c..88faaba4 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/RelatedVideoCard.kt @@ -64,7 +64,7 @@ fun RelatedVideoCard( LaunchedEffect(video.url, playbackPrewarm) { if (playbackPrewarm == null) return@LaunchedEffect delay(CARD_PREWARM_DELAY_MILLIS) - playbackPrewarm.prewarm(video.url) + playbackPrewarm.prewarm(video.url, video.isLive) } val availability = video.availabilityAt(System.currentTimeMillis()) diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt index b2f5e907..3bf674d7 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/VideoCard.kt @@ -82,7 +82,7 @@ fun VideoCard( LaunchedEffect(video.url, playbackPrewarm) { if (playbackPrewarm == null) return@LaunchedEffect delay(CARD_PREWARM_DELAY_MILLIS) - playbackPrewarm.prewarm(video.url) + playbackPrewarm.prewarm(video.url, video.isLive) } Column( diff --git a/app/src/main/java/dev/typetype/android/feature/player/LocalPlaybackPrewarm.kt b/app/src/main/java/dev/typetype/android/feature/player/LocalPlaybackPrewarm.kt index 3217cb00..9d648da9 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/LocalPlaybackPrewarm.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/LocalPlaybackPrewarm.kt @@ -3,7 +3,7 @@ package dev.typetype.android.feature.player import androidx.compose.runtime.staticCompositionLocalOf fun interface PlaybackPrewarm { - fun prewarm(videoUrl: String) + fun prewarm(videoUrl: String, knownLive: Boolean) } val LocalPlaybackPrewarm = staticCompositionLocalOf { null } diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlaybackPreheater.kt b/app/src/main/java/dev/typetype/android/feature/player/PlaybackPreheater.kt index 4b69aa99..d980fa3f 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlaybackPreheater.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlaybackPreheater.kt @@ -21,7 +21,8 @@ class PlaybackPreheater @Inject constructor( ) { private val codecSupport: PlaybackCodecSupport = DevicePlaybackCodecSupport(context) - suspend fun preheat(videoUrl: String) { + suspend fun preheat(videoUrl: String, knownLive: Boolean = false) { + if (knownLive) return val settings = userSettingsRepository.current().getOrNull() ?: return preheat(videoUrl, settings, codecSupport, { true }) } diff --git a/app/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt b/app/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt index def35f27..ce7be716 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/PlaybackPrewarmLauncher.kt @@ -14,7 +14,7 @@ internal class PlaybackPrewarmLauncher @Inject constructor( private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private var inFlight = false - override fun prewarm(videoUrl: String) { + override fun prewarm(videoUrl: String, knownLive: Boolean) { if (videoUrl.isBlank()) return synchronized(this) { if (inFlight) return @@ -22,7 +22,7 @@ internal class PlaybackPrewarmLauncher @Inject constructor( } scope.launch { try { - preheater.preheat(videoUrl) + preheater.preheat(videoUrl, knownLive) } finally { synchronized(this@PlaybackPrewarmLauncher) { inFlight = false } } From 90f1b9397b5d1f196c6fe8ef5ca5c95c3c00d4aa Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 22:32:01 +0200 Subject: [PATCH 13/18] test: repair stale instrumented ui checks --- .../feature/player/PlayerInteractionRowComposeTest.kt | 5 +++++ .../android/feature/player/components/CommentBodyTest.kt | 4 +++- .../feature/player/components/PlayerControlsLayoutTest.kt | 5 +++++ .../feature/settings/appearance/AppearanceScreenTest.kt | 7 ++++--- .../feature/settings/imports/ImportDataScreenTest.kt | 2 ++ .../settings/player/PlayerSettingsCaptionStyleTest.kt | 3 +++ .../feature/settings/player/PlayerSettingsDanmakuTest.kt | 3 +++ .../android/services/PlaybackBackgroundTransitionTest.kt | 2 +- 8 files changed, 26 insertions(+), 5 deletions(-) diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowComposeTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowComposeTest.kt index a4ebd3dc..2f301c60 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowComposeTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/PlayerInteractionRowComposeTest.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import dev.typetype.android.R import org.junit.Assert.assertEquals +import org.junit.Assume.assumeTrue import org.junit.Rule import org.junit.Test @@ -24,6 +25,10 @@ class PlayerInteractionRowComposeTest { @Test fun tabletShowsLabelsAndLargerTouchTargets() { + assumeTrue( + "tablet interaction row needs a 600dp wide device", + composeRule.activity.resources.configuration.smallestScreenWidthDp >= 600, + ) var commentClicks = 0 setActions(720.dp) { commentClicks += 1 } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/CommentBodyTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/CommentBodyTest.kt index d1958adc..20074a45 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/CommentBodyTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/CommentBodyTest.kt @@ -21,7 +21,7 @@ class CommentBodyTest { @Test fun longCommentCollapsesAndExpandsWithReadMore() { - val suffix = " more comment detail".repeat(60) + val suffix = " more comment detail".repeat(30) val comment = Comment( id = "comment", text = "Long comment starts here$suffix", @@ -47,7 +47,9 @@ class CommentBodyTest { } composeRule.onNodeWithText("Read more").assertExists().performClick() + composeRule.waitForIdle() composeRule.onNodeWithText("Show less").assertExists().performClick() + composeRule.waitForIdle() composeRule.onNodeWithText("Read more").assertExists() } } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt index 82d253ae..0b6584d5 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/player/components/PlayerControlsLayoutTest.kt @@ -46,6 +46,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import java.lang.reflect.Proxy import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -164,6 +165,10 @@ class PlayerControlsLayoutTest { @Test fun tabletControlsHaveLargerTargetsWithoutOverlapping() { + assumeTrue( + "tablet player controls need a 600dp wide device", + composeRule.activity.resources.configuration.smallestScreenWidthDp >= 600, + ) setControls(720.dp, 405.dp, 720) assertControlsDoNotOverlap() composeRule.onNodeWithTag(PLAYER_CENTER_CONTROLS_TAG) diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt index 3da84b1a..de2b9a6d 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/appearance/AppearanceScreenTest.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.junit4.v2.createComposeRule import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollToIndex +import androidx.compose.ui.test.performScrollToNode import dev.typetype.android.core.ui.theme.TypeTypeTheme import dev.typetype.android.domain.preferences.AccentColor import dev.typetype.android.domain.preferences.AppPreferences @@ -109,8 +110,8 @@ class AppearanceScreenTest { } } - composeRule.onAllNodes(hasScrollAction())[0].performScrollToIndex(11) - composeRule.onNode(isSelectable() and hasText("Red")) - .assertIsEnabled() + val accentCell = isSelectable() and hasText("Red") + composeRule.onAllNodes(hasScrollAction())[0].performScrollToNode(accentCell) + composeRule.onNode(accentCell).assertIsEnabled() } } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/imports/ImportDataScreenTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/imports/ImportDataScreenTest.kt index fa0784ff..eb67587d 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/settings/imports/ImportDataScreenTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/imports/ImportDataScreenTest.kt @@ -22,6 +22,8 @@ class ImportDataScreenTest { fun restoreRequiresASelectedArchive() { setScreen(ImportDataState()) + composeRule.onNode(hasScrollAction()) + .performScrollToNode(hasText("Restore backup")) composeRule.onNodeWithText("Restore backup").assertIsNotEnabled() } diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsCaptionStyleTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsCaptionStyleTest.kt index e9179b4c..3cd6ee55 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsCaptionStyleTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsCaptionStyleTest.kt @@ -30,6 +30,9 @@ class PlayerSettingsCaptionStyleTest { } } + composeRule.onNode(hasScrollAction()) + .performScrollToNode(hasText("Show advanced player settings")) + composeRule.onNodeWithText("Show advanced player settings").performClick() composeRule.onNode(hasScrollAction()) .performScrollToNode(hasText("Text size")) composeRule.onNodeWithText("Text size").performClick() diff --git a/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsDanmakuTest.kt b/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsDanmakuTest.kt index f30fa374..45052702 100644 --- a/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsDanmakuTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/feature/settings/player/PlayerSettingsDanmakuTest.kt @@ -30,6 +30,9 @@ class PlayerSettingsDanmakuTest { } } + composeRule.onNode(hasScrollAction()) + .performScrollToNode(hasText("Show advanced player settings")) + composeRule.onNodeWithText("Show advanced player settings").performClick() composeRule.onNode(hasScrollAction()) .performScrollToNode(hasText("Show bullet comments")) composeRule.onNodeWithText("Show bullet comments") diff --git a/app/src/androidTest/java/dev/typetype/android/services/PlaybackBackgroundTransitionTest.kt b/app/src/androidTest/java/dev/typetype/android/services/PlaybackBackgroundTransitionTest.kt index 77045353..8749203b 100644 --- a/app/src/androidTest/java/dev/typetype/android/services/PlaybackBackgroundTransitionTest.kt +++ b/app/src/androidTest/java/dev/typetype/android/services/PlaybackBackgroundTransitionTest.kt @@ -82,7 +82,7 @@ class PlaybackBackgroundTransitionTest { } private fun waitForMainThread(condition: () -> Boolean): Boolean { - val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30) while (System.nanoTime() < deadline) { if (readOnMainThread(condition)) return true Thread.sleep(50) From b44a21dddcc537584cc884abd50528362a2e544a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 21:35:34 +0200 Subject: [PATCH 14/18] build: publish mobile beta 1.8.1-beta.2 --- 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 91643eae..ad3b3ab4 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 = 10822 - versionName = "1.8.1-beta.1" + versionCode = 10823 + versionName = "1.8.1-beta.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } From e1e551878fa93b91062616db53fbcebe60700647 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 22:53:32 +0200 Subject: [PATCH 15/18] fix: poll new videos even when unified push is registered --- .../dev/typetype/android/MainViewModel.kt | 5 ++- .../LocalNotificationScheduler.kt | 6 +--- .../notifications/PushNotificationsScreen.kt | 14 ++++---- .../PushNotificationsViewModel.kt | 2 +- .../android/services/push/PushNotifier.kt | 16 +++++++-- app/src/main/res/values/strings.xml | 2 +- .../push/SubscriptionNotificationIdTest.kt | 34 +++++++++++++++++++ 7 files changed, 59 insertions(+), 20 deletions(-) create mode 100644 app/src/test/java/dev/typetype/android/services/push/SubscriptionNotificationIdTest.kt diff --git a/app/src/main/java/dev/typetype/android/MainViewModel.kt b/app/src/main/java/dev/typetype/android/MainViewModel.kt index 5deb807d..6ffc47c9 100644 --- a/app/src/main/java/dev/typetype/android/MainViewModel.kt +++ b/app/src/main/java/dev/typetype/android/MainViewModel.kt @@ -28,7 +28,6 @@ import dev.typetype.android.domain.usersettings.UserSettings import dev.typetype.android.domain.usersettings.UserSettingsRepository import dev.typetype.android.feature.player.host.PlayerHostController import dev.typetype.android.data.notifications.LocalNotificationScheduler -import dev.typetype.android.domain.push.PushRegistrationStatus import dev.typetype.android.services.push.PushRegistrationManager import javax.inject.Inject import kotlinx.coroutines.channels.Channel @@ -192,8 +191,8 @@ class MainViewModel @Inject constructor( } private suspend fun reconcileNotificationDelivery() { - val status = pushRegistrationManager.reconcileRegistration() - localNotificationScheduler.sync(status != PushRegistrationStatus.Registered) + pushRegistrationManager.reconcileRegistration() + localNotificationScheduler.sync() } private companion object { diff --git a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationScheduler.kt b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationScheduler.kt index 0122602a..c8586d4d 100644 --- a/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationScheduler.kt +++ b/app/src/main/java/dev/typetype/android/data/notifications/LocalNotificationScheduler.kt @@ -18,11 +18,7 @@ class LocalNotificationScheduler @Inject constructor( ) { private val workManager = WorkManager.getInstance(context) - fun sync(enabled: Boolean) { - if (!enabled) { - workManager.cancelUniqueWork(WORK_NAME) - return - } + fun sync() { workManager.enqueueUniquePeriodicWork( WORK_NAME, ExistingPeriodicWorkPolicy.UPDATE, diff --git a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt index 3f508382..883af2bc 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsScreen.kt @@ -92,14 +92,12 @@ internal fun PushNotificationsScreen( if (state.status == PushRegistrationStatus.MissingDistributor) { DistributorRow() } - if (!state.status.isRegistered()) { - Text( - text = stringResource(R.string.push_local_fallback), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(20.dp, 4.dp), - ) - } + Text( + text = stringResource(R.string.push_local_fallback), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(20.dp, 4.dp), + ) if (state.capabilityEnabled && state.deviceCount > 0) { StatusRow( label = stringResource(R.string.push_devices_count, state.deviceCount, state.maxDevices), diff --git a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsViewModel.kt b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsViewModel.kt index b3ef011a..fd691660 100644 --- a/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsViewModel.kt +++ b/app/src/main/java/dev/typetype/android/feature/settings/notifications/PushNotificationsViewModel.kt @@ -46,7 +46,7 @@ class PushNotificationsViewModel @Inject constructor( viewModelScope.launch { registrationManager.status.collect { status -> _state.update { it.copy(status = status) } - localNotificationScheduler.sync(status != PushRegistrationStatus.Registered) + localNotificationScheduler.sync() } } } diff --git a/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt b/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt index 036aaade..ef8bf960 100644 --- a/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt +++ b/app/src/main/java/dev/typetype/android/services/push/PushNotifier.kt @@ -31,7 +31,11 @@ class PushNotifier @Inject constructor( title = payload.channelName.ifBlank { payload.serviceName }, text = payload.title, videoUrl = payload.videoUrl, - notificationId = payload.eventId.hashCode(), + notificationId = subscriptionNotificationId( + videoId = payload.videoId, + videoUrl = payload.videoUrl, + fallback = payload.eventId, + ), ) } @@ -40,7 +44,11 @@ class PushNotifier @Inject constructor( title = item.channelName.ifBlank { item.video.uploaderName }, text = item.title, videoUrl = item.video.url, - notificationId = item.video.id.hashCode(), + notificationId = subscriptionNotificationId( + videoId = item.video.id, + videoUrl = item.video.url, + fallback = item.video.url, + ), ) } @@ -76,6 +84,7 @@ class PushNotifier @Inject constructor( .setStyle(NotificationCompat.BigTextStyle().bigText(text)) .setContentIntent(contentIntent) .setAutoCancel(true) + .setOnlyAlertOnce(true) .build() } @@ -90,3 +99,6 @@ class PushNotifier @Inject constructor( const val CHANNEL_ID = "subscription_push" } } + +internal fun subscriptionNotificationId(videoId: String, videoUrl: String, fallback: String): Int = + videoId.trim().ifBlank { videoUrl.trim() }.ifBlank { fallback.trim() }.hashCode() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3d1080a8..29ef1893 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1211,7 +1211,7 @@ Install a UnifiedPush distributor such as ntfy to receive notifications. Choose distributor Couldn\'t update push notifications - UnifiedPush is inactive, so the app checks for new videos every 15 minutes + The app also checks for new videos every 15 minutes Turn on new video notifications Turn off new video notifications Disable battery optimization for reliable notification delivery. diff --git a/app/src/test/java/dev/typetype/android/services/push/SubscriptionNotificationIdTest.kt b/app/src/test/java/dev/typetype/android/services/push/SubscriptionNotificationIdTest.kt new file mode 100644 index 00000000..a0a5c91b --- /dev/null +++ b/app/src/test/java/dev/typetype/android/services/push/SubscriptionNotificationIdTest.kt @@ -0,0 +1,34 @@ +package dev.typetype.android.services.push + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class SubscriptionNotificationIdTest { + @Test + fun `id follows the video id`() { + assertEquals( + "TIcVuMOK78I".hashCode(), + subscriptionNotificationId("TIcVuMOK78I", "https://www.youtube.com/watch?v=TIcVuMOK78I", "event"), + ) + } + + @Test + fun `id falls back to the video url when the id is blank`() { + val url = "https://www.youtube.com/watch?v=TIcVuMOK78I" + assertEquals(url.hashCode(), subscriptionNotificationId(" ", url, "event")) + } + + @Test + fun `id falls back to the event key when both video fields are blank`() { + assertEquals("event".hashCode(), subscriptionNotificationId("", " ", "event")) + } + + @Test + fun `push and local notifications share the id of the same video`() { + val pushId = subscriptionNotificationId("abc123", "https://example.test/watch?v=abc123", "event") + val localId = subscriptionNotificationId("abc123", "https://example.test/watch?v=abc123", "abc123") + assertEquals(pushId, localId) + assertNotEquals(pushId, subscriptionNotificationId("other", "https://example.test/watch?v=other", "event")) + } +} From fe9c16fa63dbf6396851b46e1b6e561fbedfe2d0 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 23:06:44 +0200 Subject: [PATCH 16/18] fix: label scheduled lives as not started --- .../player/error/StreamErrorClassifier.kt | 3 ++- .../player/error/StreamErrorClassifierTest.kt | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/dev/typetype/android/feature/player/error/StreamErrorClassifier.kt b/app/src/main/java/dev/typetype/android/feature/player/error/StreamErrorClassifier.kt index 6154b6d2..51fe6828 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/error/StreamErrorClassifier.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/error/StreamErrorClassifier.kt @@ -20,6 +20,7 @@ private val SCHEDULED_PREMIERE_NEEDLES = listOf( "premieres in", "premiere has not started", "premiere scheduled", + "live event will begin", ) private val PAID_CONTENT_NEEDLES = listOf( @@ -176,7 +177,7 @@ fun classifyStreamError(failure: Throwable): StreamErrorClass { if (code == "paid_content") { return StreamErrorClass(StreamErrorKind.PaidContent, rawMessage = null, requestId = requestId) } - if (code == "scheduled_premiere") { + if (code == "scheduled_premiere" || code == "live_not_started") { return StreamErrorClass(StreamErrorKind.ScheduledPremiere, rawMessage = null, requestId = requestId) } if (code == "youtube_session_needs_reconnect" || code == "youtube_session_unavailable") { diff --git a/app/src/test/java/dev/typetype/android/feature/player/error/StreamErrorClassifierTest.kt b/app/src/test/java/dev/typetype/android/feature/player/error/StreamErrorClassifierTest.kt index a069385d..1255c29f 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/error/StreamErrorClassifierTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/error/StreamErrorClassifierTest.kt @@ -116,6 +116,22 @@ class StreamErrorClassifierTest { assertEquals(StreamErrorKind.MemberOnly, classifyStreamError(members).kind) } + @Test + fun `classifies scheduled lives the server reports as not started`() { + val scheduledLive = ServerResponseException( + ServerError("This live event will begin in 55 minutes.", "live_not_started", 409, "request-9"), + ) + + val classified = classifyStreamError(scheduledLive) + + assertEquals(StreamErrorKind.ScheduledPremiere, classified.kind) + assertEquals("request-9", classified.requestId) + assertEquals( + StreamErrorKind.ScheduledPremiere, + classifyStreamError("This live event will begin in 55 minutes.").kind, + ) + } + @Test fun `maps typed provider failures to content unavailable`() { val missing = ServerResponseException( From ed0498f5ebd37f9725fa8f00b6c8c0f4ce909bda Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 23:26:13 +0200 Subject: [PATCH 17/18] fix: enable bullet comments on bilibili --- .../android/feature/player/DanmakuSupport.kt | 12 +++++++----- .../android/feature/player/DanmakuSupportTest.kt | 8 ++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/dev/typetype/android/feature/player/DanmakuSupport.kt b/app/src/main/java/dev/typetype/android/feature/player/DanmakuSupport.kt index 2d3dcb3c..e07f93b9 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/DanmakuSupport.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/DanmakuSupport.kt @@ -1,8 +1,10 @@ package dev.typetype.android.feature.player -import java.net.URI +import dev.typetype.android.data.stream.StreamProvider +import dev.typetype.android.data.stream.streamProvider -internal fun supportsServerBulletComments(videoUrl: String): Boolean { - val host = runCatching { URI(videoUrl).host?.lowercase() }.getOrNull() ?: return false - return host == "nicovideo.jp" || host.endsWith(".nicovideo.jp") || host == "nico.ms" -} +internal fun supportsServerBulletComments(videoUrl: String): Boolean = + when (videoUrl.streamProvider()) { + StreamProvider.NicoNico, StreamProvider.BiliBili -> true + StreamProvider.YouTube, StreamProvider.Generic -> false + } diff --git a/app/src/test/java/dev/typetype/android/feature/player/DanmakuSupportTest.kt b/app/src/test/java/dev/typetype/android/feature/player/DanmakuSupportTest.kt index de755238..ea1b6510 100644 --- a/app/src/test/java/dev/typetype/android/feature/player/DanmakuSupportTest.kt +++ b/app/src/test/java/dev/typetype/android/feature/player/DanmakuSupportTest.kt @@ -12,9 +12,17 @@ class DanmakuSupportTest { assertTrue(supportsServerBulletComments("https://nico.ms/sm9")) } + @Test + fun acceptsSupportedBiliBiliHosts() { + assertTrue(supportsServerBulletComments("https://www.bilibili.com/video/BV14whX66Eka?p=1")) + assertTrue(supportsServerBulletComments("https://bilibili.com/video/BV14whX66Eka")) + assertTrue(supportsServerBulletComments("https://b23.tv/abc123")) + } + @Test fun rejectsLookalikeAndUnrelatedHosts() { assertFalse(supportsServerBulletComments("https://nicovideo.jp.example.com/watch/sm9")) + assertFalse(supportsServerBulletComments("https://bilibili.com.example.com/video/BV1")) assertFalse(supportsServerBulletComments("https://youtube.com/watch?v=sm9")) assertFalse(supportsServerBulletComments("not a url")) } From f512ecf97f8cdc27ffc8eb164182b153c09781b9 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 27 Sep 2026 23:41:49 +0200 Subject: [PATCH 18/18] ci: expose java to apk verification steps --- .github/workflows/android-beta.yml | 1 + .github/workflows/android.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/workflows/android-beta.yml b/.github/workflows/android-beta.yml index a60d2088..04c0b819 100644 --- a/.github/workflows/android-beta.yml +++ b/.github/workflows/android-beta.yml @@ -147,6 +147,7 @@ jobs: - name: Verify and prepare beta APK run: | + export PATH="$JAVA_HOME/bin:$PATH" mkdir -p artifacts apk="$(find app/build/outputs/apk/release -name '*.apk' | head -n 1)" test -n "$apk" diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 5d343366..4036e3c0 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -207,6 +207,7 @@ jobs: - name: Prepare APK artifact run: | + export PATH="$JAVA_HOME/bin:$PATH" mkdir -p artifacts apk="$(find app/build/outputs/apk/release -name '*.apk' | head -n 1)" test -n "$apk"