From f3d1c08584573136da06a64e92233282e57648a3 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 28 Sep 2026 10:06:07 +0200 Subject: [PATCH 1/5] docs: drop the beta notice from the readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index c4fbbb79..b50c2215 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,8 @@ You want to know the current position of TypeType about AI ? Go check [this](htt TypeType Android is the native client for [TypeType](https://github.com/TypeType-Video/TypeType), a self-hosted video -platform. The app is in beta, supports Android 6.0 and newer, and does not -require Google Play Services. +platform. The app supports Android 6.0 and newer and does not require Google +Play Services. TypeType Android uses the TypeType Server selected during setup for extraction, playback sessions, recommendations, synchronization, and downloads. From 9be13c4262de5b73b52348f8740165d74b2309ce Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 28 Sep 2026 10:10:33 +0200 Subject: [PATCH 2/5] fix: retrigger the fdroid beta sync after a release race --- deploy/fdroid-beta/webhook.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/deploy/fdroid-beta/webhook.py b/deploy/fdroid-beta/webhook.py index bbc1bac8..b90992fb 100644 --- a/deploy/fdroid-beta/webhook.py +++ b/deploy/fdroid-beta/webhook.py @@ -2,6 +2,8 @@ import hashlib import hmac import json +import threading +import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -9,6 +11,13 @@ SECRET_FILE = Path("/srv/fdroid-beta/secrets/github-webhook-secret") TRIGGER_FILE = Path("/run/typetype-fdroid-beta-webhook/sync") MAX_PAYLOAD_SIZE = 1024 * 1024 +RETRY_DELAY_SECONDS = 90 + + +def retrigger_sync() -> None: + time.sleep(RETRY_DELAY_SECONDS) + TRIGGER_FILE.unlink(missing_ok=True) + TRIGGER_FILE.touch(exist_ok=True) class WebhookHandler(BaseHTTPRequestHandler): @@ -58,6 +67,7 @@ def do_POST(self): and release.get("prerelease", False) ): TRIGGER_FILE.touch(exist_ok=True) + threading.Thread(target=retrigger_sync, daemon=True).start() self.send_response(202) else: self.send_response(204) From acb399b54872b4d45a68ffad9ee66cf49cb4e1d2 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 28 Sep 2026 10:27:32 +0200 Subject: [PATCH 3/5] fix: keep loaded channel avatars on screen --- .../typetype/android/core/ui/components/ChannelAvatar.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt b/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt index 978b3827..ef0efa8d 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/ChannelAvatar.kt @@ -65,16 +65,14 @@ fun ChannelAvatar( .semantics { contentDescription?.let { this.contentDescription = it } }, contentAlignment = Alignment.Center, ) { - if (loading) { - TypeTypeSkeletonCircle(size = size) - } else { + if (!loading) { Text( text = avatarInitial(name), style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - if (hasSource && !loaded) { + if (hasSource) { AsyncImage( model = ImageRequest.Builder(context) .data(avatarRetryUrl(buildImageUrl(serverBaseUrl, avatarUrl), attempt)) @@ -88,6 +86,9 @@ fun ChannelAvatar( modifier = Modifier.fillMaxSize(), ) } + if (loading) { + TypeTypeSkeletonCircle(size = size) + } } } From fe58fc9b04027f5a56ccaa727745fe4b6358b52b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 28 Sep 2026 10:27:33 +0200 Subject: [PATCH 4/5] feat: animate thumbnails with the shared skeleton --- .../core/ui/components/HorizontalVideoCard.kt | 5 +- .../core/ui/components/PlaylistVideoCard.kt | 5 +- .../core/ui/components/RelatedVideoCard.kt | 9 +--- .../core/ui/components/SkeletonImage.kt | 51 +++++++++++++++++++ .../core/ui/components/TypeTypeSkeleton.kt | 15 +++--- .../android/core/ui/components/VideoCard.kt | 15 ++---- .../feature/home/ContinueWatchingSection.kt | 6 +-- 7 files changed, 71 insertions(+), 35 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/HorizontalVideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/HorizontalVideoCard.kt index a0a73a57..9a6fa02d 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/HorizontalVideoCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/HorizontalVideoCard.kt @@ -85,10 +85,9 @@ fun HorizontalVideoCard( .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceVariant), ) { - AsyncImage( - model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl), + SkeletonImage( + imageUrl = branding.thumbnailUrl, contentDescription = null, - contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), ) VideoThumbnailBadges( diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/PlaylistVideoCard.kt b/app/src/main/java/dev/typetype/android/core/ui/components/PlaylistVideoCard.kt index 62fa31ca..29baa6fb 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/PlaylistVideoCard.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/PlaylistVideoCard.kt @@ -84,10 +84,9 @@ fun PlaylistVideoCard( .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceVariant), ) { - AsyncImage( - model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl), + SkeletonImage( + imageUrl = branding.thumbnailUrl, contentDescription = null, - contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize(), ) if (isWatched) { 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 c1f22c15..576bd4ac 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 @@ -32,11 +32,8 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage import dev.typetype.android.R import dev.typetype.android.core.ui.branding.rememberVideoBranding -import dev.typetype.android.core.ui.share.LocalServerBaseUrl -import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.feed.VideoAvailability import dev.typetype.android.domain.feed.availabilityAt @@ -57,7 +54,6 @@ 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) { @@ -96,10 +92,9 @@ fun RelatedVideoCard( .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceVariant), ) { - AsyncImage( - model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl), + SkeletonImage( + imageUrl = branding.thumbnailUrl, contentDescription = null, - contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), ) if (menuItemState.isWatched) { diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt b/app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt new file mode 100644 index 00000000..c3b84609 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/core/ui/components/SkeletonImage.kt @@ -0,0 +1,51 @@ +package dev.typetype.android.core.ui.components + +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.layout.ContentScale +import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.request.crossfade +import dev.typetype.android.core.ui.share.LocalServerBaseUrl +import dev.typetype.android.core.ui.share.buildImageUrl + +@Composable +fun SkeletonImage( + imageUrl: String, + contentDescription: String?, + modifier: Modifier = Modifier, + shape: Shape = SkeletonCardShape, + contentScale: ContentScale = ContentScale.Crop, + crossfadeMillis: Int = 0, +) { + val serverBaseUrl = LocalServerBaseUrl.current + val context = LocalPlatformContext.current + var loaded by remember(imageUrl) { mutableStateOf(false) } + val hasSource = imageUrl.isNotBlank() + Box(modifier = modifier.clip(shape)) { + if (hasSource && !loaded) { + TypeTypeSkeleton(modifier = Modifier.matchParentSize(), shape = shape) + } + if (hasSource) { + AsyncImage( + model = ImageRequest.Builder(context) + .data(buildImageUrl(serverBaseUrl, imageUrl)) + .apply { if (crossfadeMillis > 0) crossfade(crossfadeMillis) } + .build(), + contentDescription = contentDescription, + contentScale = contentScale, + onSuccess = { loaded = true }, + onError = { loaded = false }, + modifier = Modifier.matchParentSize(), + ) + } + } +} diff --git a/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeSkeleton.kt b/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeSkeleton.kt index 01ede4b2..111ebf69 100644 --- a/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeSkeleton.kt +++ b/app/src/main/java/dev/typetype/android/core/ui/components/TypeTypeSkeleton.kt @@ -18,7 +18,6 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -26,8 +25,8 @@ import androidx.compose.ui.unit.dp internal fun skeletonPulseAlpha(): Float { val transition = rememberInfiniteTransition(label = "skeleton") val alpha by transition.animateFloat( - initialValue = SKELETON_MIN_ALPHA, - targetValue = SKELETON_MAX_ALPHA, + initialValue = SKELETON_PULSE_MAX, + targetValue = SKELETON_PULSE_MIN, animationSpec = infiniteRepeatable( animation = tween(SKELETON_PULSE_MILLIS), repeatMode = RepeatMode.Reverse, @@ -46,8 +45,9 @@ internal fun TypeTypeSkeleton( Spacer( modifier = modifier .clip(shape) - .background(MaterialTheme.colorScheme.surfaceVariant) - .graphicsLayer { this.alpha = alpha }, + .background( + MaterialTheme.colorScheme.onSurface.copy(alpha = SKELETON_TINT * alpha), + ), ) } @@ -80,6 +80,7 @@ internal fun TypeTypeSkeletonCircle( internal val SkeletonCardShape = RoundedCornerShape(12.dp) private const val SKELETON_CORNER = 4 -private const val SKELETON_MIN_ALPHA = 0.38f -private const val SKELETON_MAX_ALPHA = 0.72f +private const val SKELETON_TINT = 0.15f +private const val SKELETON_PULSE_MIN = 0.55f +private const val SKELETON_PULSE_MAX = 1f private const val SKELETON_PULSE_MILLIS = 850 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 ba417bba..85dc515c 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 @@ -39,14 +39,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp 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 -import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.feed.Video import dev.typetype.android.domain.feed.VideoAvailability import dev.typetype.android.domain.feed.availabilityAt @@ -66,7 +61,6 @@ fun VideoCard( ) { var menuVisible by remember { mutableStateOf(false) } var availabilityVisible by remember { mutableStateOf(false) } - val serverBaseUrl = LocalServerBaseUrl.current val availability = video.availabilityAt(System.currentTimeMillis()) val metadata = video.metadataText() val branding = rememberVideoBranding( @@ -103,13 +97,10 @@ fun VideoCard( .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceVariant), ) { - AsyncImage( - model = ImageRequest.Builder(LocalContext.current) - .data(buildImageUrl(serverBaseUrl, branding.thumbnailUrl)) - .crossfade(200) - .build(), + SkeletonImage( + imageUrl = branding.thumbnailUrl, contentDescription = null, - contentScale = ContentScale.Crop, + crossfadeMillis = 200, modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), ) if (menuItemState.isWatched) { diff --git a/app/src/main/java/dev/typetype/android/feature/home/ContinueWatchingSection.kt b/app/src/main/java/dev/typetype/android/feature/home/ContinueWatchingSection.kt index 74df4e9c..72a96ace 100644 --- a/app/src/main/java/dev/typetype/android/feature/home/ContinueWatchingSection.kt +++ b/app/src/main/java/dev/typetype/android/feature/home/ContinueWatchingSection.kt @@ -36,6 +36,7 @@ import dev.typetype.android.R import dev.typetype.android.core.ui.branding.rememberVideoBranding import dev.typetype.android.core.ui.components.SectionHeader import dev.typetype.android.core.ui.components.VideoDurationBadge +import dev.typetype.android.core.ui.components.SkeletonImage import dev.typetype.android.core.ui.share.LocalServerBaseUrl import dev.typetype.android.core.ui.share.buildImageUrl import dev.typetype.android.domain.library.HistoryItem @@ -98,10 +99,9 @@ private fun ContinueWatchingCard( .clip(MaterialTheme.shapes.medium) .background(MaterialTheme.colorScheme.surfaceVariant), ) { - AsyncImage( - model = buildImageUrl(serverBaseUrl, branding.thumbnailUrl), + SkeletonImage( + imageUrl = branding.thumbnailUrl, contentDescription = null, - contentScale = ContentScale.Crop, modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f), ) VideoDurationBadge( From 13b0b9effac276ec9ecab81ad3c80637be80d1ab Mon Sep 17 00:00:00 2001 From: Priveetee Date: Mon, 28 Sep 2026 10:28:04 +0200 Subject: [PATCH 5/5] build: publish mobile beta 1.9.0-beta.2 --- app/build.gradle.kts | 4 ++-- release-notes/v1.9.0-beta.2.md | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 release-notes/v1.9.0-beta.2.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ad3b3ab4..3ba296c1 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 = 10823 - versionName = "1.8.1-beta.2" + versionCode = 10825 + versionName = "1.9.0-beta.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } diff --git a/release-notes/v1.9.0-beta.2.md b/release-notes/v1.9.0-beta.2.md new file mode 100644 index 00000000..cc3b2109 --- /dev/null +++ b/release-notes/v1.9.0-beta.2.md @@ -0,0 +1,33 @@ +# TypeType for Android 1.9.0-beta.2 + +TypeType for Android 1.9.0-beta.2 fixes channel avatars that disappeared after loading, and brings the loading animation of the web client to thumbnails as well as avatars. + +This is a beta build on the 1.9 line. + +## Loading placeholders + +- Keep channel avatars visible once loaded, after a beta.1 regression that removed them from the layout. +- Animate video thumbnails with the same pulsing placeholder as avatars while they load, on video cards, related videos, continue watching, playlists, and the horizontal cards. +- Use one skeleton primitive across the application, tinted from the foreground color like the web client, instead of the previous surface tint. +- Retry a failed channel avatar up to two times with a distinct cache key, then fall back to the channel initial. + +## Everything from beta.1 + +- Play YouTube lives from the server live manifest, fall back to the SABR route when the bootstrap cannot serve a video, and play Niconico and BiliBili provider media handles. +- Add previous and next video controls to the player, and keep the favorite and watch later state synchronized with the library. +- Show card placeholders while the home, search, and subscription feeds load, and details placeholders while a video loads. +- Enable bullet comments on BiliBili, and label scheduled lives as not started instead of reporting a delivery failure. +- Add subscription notifications with UnifiedPush, plus a local check every fifteen minutes. +- Grey out the accent color picker when the Dynamic theme is selected. +- Prewarm SABR playback when a card is shown, and skip that prewarm for live videos. +- Keep Android 6.0 through Android 17 support, API 23 through API 37, and fix a crash on Android 6.0 caused by the JVM URL encoder. + +## Thx + +Thx to @therealresonix, @ollieisonline, @303inmyheart, and @filippobaroni for the reports that shaped this line, and to everyone testing the beta builds. + +## Installing + +Download the APK attached below, or use the beta channel of the TypeType F-Droid repository. The APK is built from the tagged source, signed by the release workflow, checked for 16 KiB page alignment, and verified against the Gradle application version. A SHA-256 checksum is provided alongside it. + +**Full changelog:** https://github.com/TypeType-Video/TypeType-Android/compare/v1.9.0-beta.1...v1.9.0-beta.2