Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -88,6 +86,9 @@ fun ChannelAvatar(
modifier = Modifier.fillMaxSize(),
)
}
if (loading) {
TypeTypeSkeletonCircle(size = size)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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(),
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,15 @@ 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

@Composable
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,
Expand All @@ -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),
),
)
}

Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 10 additions & 0 deletions deploy/fdroid-beta/webhook.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,22 @@
import hashlib
import hmac
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path


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):
Expand Down Expand Up @@ -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)
Expand Down
33 changes: 33 additions & 0 deletions release-notes/v1.9.0-beta.2.md
Original file line number Diff line number Diff line change
@@ -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