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
13 changes: 12 additions & 1 deletion .github/workflows/android-beta.yml
Original file line number Diff line number Diff line change
Expand Up @@ -183,13 +183,24 @@ jobs:
compression-level: 0
retention-days: 30

- name: Prepare beta release notes
if: ${{ github.event_name != 'pull_request' }}
env:
HEAD_MESSAGE: ${{ github.event.head_commit.message }}
run: |
if [ -f "release-notes/${RELEASE_TAG}.md" ]; then
cp "release-notes/${RELEASE_TAG}.md" release-body.md
else
printf '%s\n' "${HEAD_MESSAGE:-Beta build from the development branch.}" > release-body.md
fi

- name: Publish GitHub prerelease
if: ${{ github.event_name != 'pull_request' }}
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ env.RELEASE_TAG }}
name: TypeType Android ${{ env.RELEASE_VERSION }}
body: ${{ github.event.head_commit.message }}
body_path: release-body.md
prerelease: true
make_latest: false
generate_release_notes: false
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 = 10823
versionName = "1.8.1-beta.2"
versionCode = 10824
versionName = "1.9.0-beta.1"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
resValue("string", "app_name", "TypeType")
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package dev.typetype.android.core.ui.components

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coil3.compose.LocalPlatformContext
import coil3.request.ImageRequest
import dev.typetype.android.core.ui.share.LocalServerBaseUrl
import dev.typetype.android.core.ui.share.buildImageUrl
import kotlinx.coroutines.delay

@Composable
fun ChannelAvatar(
avatarUrl: String,
name: String,
size: Dp,
modifier: Modifier = Modifier,
contentDescription: String? = null,
) {
val serverBaseUrl = LocalServerBaseUrl.current
val context = LocalPlatformContext.current
var attempt by remember(avatarUrl) { mutableIntStateOf(0) }
var loaded by remember(avatarUrl, attempt) { mutableStateOf(false) }
val hasSource = avatarUrl.isNotBlank()
var missingExpired by remember(avatarUrl) { mutableStateOf(false) }

LaunchedEffect(avatarUrl, hasSource) {
if (hasSource) {
missingExpired = false
return@LaunchedEffect
}
missingExpired = false
delay(MISSING_AVATAR_GRACE_MILLIS)
missingExpired = true
}

val missing = !hasSource && !missingExpired
val loading = missing || (hasSource && !loaded)
Box(
modifier = modifier
.size(size)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.semantics { contentDescription?.let { this.contentDescription = it } },
contentAlignment = Alignment.Center,
) {
if (loading) {
TypeTypeSkeletonCircle(size = size)
} else {
Text(
text = avatarInitial(name),
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (hasSource && !loaded) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(avatarRetryUrl(buildImageUrl(serverBaseUrl, avatarUrl), attempt))
.build(),
contentDescription = null,
contentScale = ContentScale.Crop,
onSuccess = { loaded = true },
onError = {
if (attempt < MAX_AVATAR_RETRIES) attempt += 1
},
modifier = Modifier.fillMaxSize(),
)
}
}
}

internal fun avatarRetryUrl(url: String, attempt: Int): String {
if (attempt <= 0 || url.isBlank()) return url
val separator = if (url.contains('?')) '&' else '?'
return "$url$separator$AVATAR_RETRY_PARAM=$attempt"
}

internal fun avatarInitial(name: String): String =
name.trim().firstOrNull()?.uppercase() ?: "?"

private const val AVATAR_RETRY_PARAM = "_tt_avatar_retry"
private const val MAX_AVATAR_RETRIES = 2
private const val MISSING_AVATAR_GRACE_MILLIS = 1_500L
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
Expand Down Expand Up @@ -124,18 +123,15 @@ fun RelatedVideoCard(
overflow = TextOverflow.Ellipsis,
)
Row(verticalAlignment = Alignment.CenterVertically) {
AsyncImage(
model = buildImageUrl(serverBaseUrl, video.uploaderAvatarUrl),
ChannelAvatar(
avatarUrl = video.uploaderAvatarUrl,
name = video.uploaderName,
size = 18.dp,
contentDescription = stringResource(
R.string.video_open_channel_accessibility,
video.uploaderName,
),
contentScale = ContentScale.Crop,
modifier = Modifier
.size(18.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.combinedClickable(onClick = onChannelClick, role = Role.Button),
modifier = Modifier.combinedClickable(onClick = onChannelClick, role = Role.Button),
)
Spacer(Modifier.width(6.dp))
Text(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package dev.typetype.android.core.ui.components

import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
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,
animationSpec = infiniteRepeatable(
animation = tween(SKELETON_PULSE_MILLIS),
repeatMode = RepeatMode.Reverse,
),
label = "skeleton alpha",
)
return alpha
}

@Composable
internal fun TypeTypeSkeleton(
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(SKELETON_CORNER),
alpha: Float = skeletonPulseAlpha(),
) {
Spacer(
modifier = modifier
.clip(shape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.graphicsLayer { this.alpha = alpha },
)
}

@Composable
internal fun TypeTypeSkeletonLine(
widthFraction: Float,
height: Dp = 10.dp,
modifier: Modifier = Modifier,
alpha: Float = skeletonPulseAlpha(),
) {
TypeTypeSkeleton(
modifier = modifier.fillMaxWidth(widthFraction).height(height),
alpha = alpha,
)
}

@Composable
internal fun TypeTypeSkeletonCircle(
size: Dp,
modifier: Modifier = Modifier,
alpha: Float = skeletonPulseAlpha(),
) {
TypeTypeSkeleton(
modifier = modifier.size(size),
shape = CircleShape,
alpha = alpha,
)
}

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_PULSE_MILLIS = 850
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material3.Icon
Expand Down Expand Up @@ -130,24 +129,22 @@ fun VideoCard(
Spacer(Modifier.height(10.dp))
Row(verticalAlignment = Alignment.Top) {
val avatarModifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
.let {
if (onChannelClick != null) {
it.combinedClickable(onClick = onChannelClick, role = Role.Button)
} else {
it
}
}
AsyncImage(
model = buildImageUrl(serverBaseUrl, video.uploaderAvatarUrl),
ChannelAvatar(
avatarUrl = video.uploaderAvatarUrl,
name = video.uploaderName,
size = 36.dp,
contentDescription = if (onChannelClick != null) {
stringResource(R.string.video_open_channel_accessibility, video.uploaderName)
} else {
null
},
contentScale = ContentScale.Crop,
modifier = avatarModifier,
)
Spacer(Modifier.width(10.dp))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package dev.typetype.android.core.ui.components

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import dev.typetype.android.R

@Composable
internal fun VideoCardSkeleton() {
val alpha = skeletonPulseAlpha()
Column(modifier = Modifier.fillMaxWidth()) {
TypeTypeSkeleton(
modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f),
shape = SkeletonCardShape,
alpha = alpha,
)
Spacer(Modifier.height(10.dp))
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
TypeTypeSkeletonCircle(size = 36.dp, alpha = alpha)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(7.dp),
) {
TypeTypeSkeletonLine(widthFraction = 1f, height = 12.dp, alpha = alpha)
TypeTypeSkeletonLine(widthFraction = 0.72f, height = 12.dp, alpha = alpha)
TypeTypeSkeletonLine(widthFraction = 0.45f, alpha = alpha)
}
}
}
}

@Composable
internal fun RelatedVideoCardSkeleton(thumbnailWidth: Dp = 148.dp) {
val alpha = skeletonPulseAlpha()
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
TypeTypeSkeleton(
modifier = Modifier.width(thumbnailWidth).aspectRatio(16f / 9f),
shape = SkeletonCardShape,
alpha = alpha,
)
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
TypeTypeSkeletonLine(widthFraction = 1f, height = 10.dp, alpha = alpha)
TypeTypeSkeletonLine(widthFraction = 0.75f, height = 10.dp, alpha = alpha)
TypeTypeSkeletonLine(widthFraction = 0.34f, height = 10.dp, alpha = alpha)
}
}
}

@Composable
fun VideoGridSkeleton(
modifier: Modifier = Modifier,
count: Int = DEFAULT_SKELETON_CARDS,
minCellWidth: Dp = 280.dp,
horizontalPadding: Dp = 16.dp,
verticalPadding: Dp = 16.dp,
verticalSpacing: Dp = 24.dp,
contentDescription: String? = null,
) {
val description = contentDescription ?: stringResource(R.string.state_loading)
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = minCellWidth),
modifier = modifier
.fillMaxSize()
.semantics { this.contentDescription = description },
contentPadding = PaddingValues(horizontal = horizontalPadding, vertical = verticalPadding),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(verticalSpacing),
userScrollEnabled = false,
) {
items(SKELETON_CARD_SLOTS.take(count), key = { it }) { VideoCardSkeleton() }
}
}

private const val DEFAULT_SKELETON_CARDS = 6
private val SKELETON_CARD_SLOTS = (0 until 12).toList()
Loading