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. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fdc5aadf..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 = 10824 - versionName = "1.9.0-beta.1" + versionCode = 10825 + versionName = "1.9.0-beta.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") } 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) + } } } 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( 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) 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