From bd896232451fb449ffefdde99098ab9458cd4866 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 10 Sep 2026 10:52:10 +0200 Subject: [PATCH 1/2] feat: add storyboard previews while seeking --- .../components/PlayerControlsLayoutTest.kt | 58 +++++++- .../data/stream/StreamRepositoryImpl.kt | 14 ++ .../typetype/android/domain/stream/Stream.kt | 45 ++++++ .../player/components/PlayerControls.kt | 5 + .../player/components/PlayerSurfaceBox.kt | 1 + .../player/components/PlayerSurfaceChrome.kt | 15 +- .../player/components/PlayerTimeBar.kt | 140 +++++++++++------- .../components/SeekStoryboardPreview.kt | 85 +++++++++++ .../domain/stream/StreamStoryboardTest.kt | 45 ++++++ 9 files changed, 346 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/dev/typetype/android/feature/player/components/SeekStoryboardPreview.kt create mode 100644 app/src/test/java/dev/typetype/android/domain/stream/StreamStoryboardTest.kt 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 09dc85f6..28b97533 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 @@ -8,15 +8,21 @@ import androidx.compose.ui.platform.LocalView import android.view.View import androidx.activity.ComponentActivity import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.Dp import androidx.compose.ui.test.assertHeightIsAtLeast import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.captureToImage import androidx.compose.ui.graphics.asAndroidBitmap import androidx.core.view.WindowCompat @@ -26,6 +32,7 @@ import androidx.core.graphics.Insets import android.graphics.Bitmap import java.io.File import dev.typetype.android.R +import dev.typetype.android.domain.stream.StreamStoryboard import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.Timeline @@ -79,6 +86,49 @@ class PlayerControlsLayoutTest { assertEquals(visibleTop, backButtonTopInViewport(), 1f) } + @Test + fun timelineScrubShowsStoryboardPreviewAndTimecode() { + val player = controlsLayoutPlayer( + durationMs = 600_000L, + positionMs = 120_000L, + ) + val storyboard = StreamStoryboard( + urls = listOf("https://example.test/storyboard.jpg"), + frameWidth = 160, + frameHeight = 90, + totalCount = 100, + durationPerFrameMillis = 5_000L, + framesPerPageX = 5, + framesPerPageY = 5, + ) + composeRule.setContent { + PlayerTimeBar( + player = player, + storyboard = storyboard, + modifier = Modifier + .fillMaxWidth() + .padding(top = 200.dp), + ) + } + + composeRule.onNodeWithContentDescription( + composeRule.activity.getString(R.string.player_timeline), + ).performTouchInput { + down(Offset(width * 0.25f, center.y)) + moveBy(Offset(width * 0.5f, 0f)) + } + composeRule.waitForIdle() + + composeRule.onNodeWithTag(PLAYER_SEEK_STORYBOARD_PREVIEW_TAG) + .assertIsDisplayed() + val previewTexts = composeRule.onNodeWithTag(PLAYER_SEEK_STORYBOARD_PREVIEW_TAG) + .fetchSemanticsNode().children + .mapNotNull { child -> + child.config.getOrElse(SemanticsProperties.Text) { emptyList() }.singleOrNull() + } + assertTrue(previewTexts.single().matches(Regex("^\\d{1,2}:\\d{2}$"))) + } + private fun dispatchStatusBarInsets(top: Int) { composeRule.runOnUiThread { ViewCompat.dispatchApplyWindowInsets( @@ -175,13 +225,19 @@ class PlayerControlsLayoutTest { } } -private fun controlsLayoutPlayer(): Player = Proxy.newProxyInstance( +private fun controlsLayoutPlayer( + durationMs: Long = 0L, + positionMs: Long = 0L, +): Player = Proxy.newProxyInstance( Player::class.java.classLoader, arrayOf(Player::class.java), ) { _, method, _ -> when (method.name) { "getApplicationLooper" -> Looper.getMainLooper() "getAvailableCommands" -> Player.Commands.EMPTY + "getDuration" -> durationMs + "getCurrentPosition" -> positionMs + "getBufferedPosition" -> positionMs "getCurrentTimeline" -> Timeline.EMPTY "getPlaybackParameters" -> PlaybackParameters.DEFAULT "getCurrentTracks" -> Tracks.EMPTY 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 27125201..ac562193 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 @@ -4,6 +4,7 @@ import dev.typetype.android.data.account.AccountScopeProvider import dev.typetype.android.core.error.CodedFailure import dev.typetype.android.data.network.PlaybackNetworkObserver import dev.typetype.android.data.network.dto.AudioStreamItem +import dev.typetype.android.data.network.dto.PreviewFrameItem import dev.typetype.android.data.network.dto.SponsorBlockSegmentItem import dev.typetype.android.data.network.dto.StreamResponse import dev.typetype.android.data.network.dto.StreamSegmentItem @@ -19,6 +20,7 @@ import dev.typetype.android.domain.stream.Stream import dev.typetype.android.domain.stream.StreamPlaybackContract import dev.typetype.android.domain.stream.StreamAudioSource import dev.typetype.android.domain.stream.StreamRepository +import dev.typetype.android.domain.stream.StreamStoryboard import dev.typetype.android.domain.stream.StreamRequestScope import dev.typetype.android.domain.stream.StreamVideoSource import dev.typetype.android.domain.stream.isServerSabrAudioFormat @@ -176,6 +178,7 @@ internal class StreamRepositoryImpl @Inject constructor( subtitles = subtitles.mapIndexedNotNull { index, subtitle -> subtitle.toClientSubtitleSource(index) }, + storyboard = previewFrames.firstNotNullOfOrNull { it.toDomainStoryboard() }, startPositionMillis = startPosition * 1000L, sponsorBlockSegments = sponsorBlockSegments.mapNotNull { it.toDomainSponsorBlockSegment(duration) @@ -202,6 +205,17 @@ internal class StreamRepositoryImpl @Inject constructor( .maxByOrNull { it.height } ?.url + private fun PreviewFrameItem.toDomainStoryboard(): StreamStoryboard? = + StreamStoryboard( + urls = urls.filter(String::isNotBlank), + frameWidth = frameWidth, + frameHeight = frameHeight, + totalCount = totalCount, + durationPerFrameMillis = durationPerFrame.toLong(), + framesPerPageX = framesPerPageX, + framesPerPageY = framesPerPageY, + ).takeIf { it.frameAt(0L) != null } + private fun serverManifestUrl(baseUrl: String, path: String, videoUrl: String): String { val normalizedBaseUrl = baseUrl.trimEnd('/') val encoded = URLEncoder.encode(videoUrl, StandardCharsets.UTF_8) 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 aeff9daa..fa142f66 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 @@ -33,6 +33,7 @@ data class Stream( val originalAudioTrackId: String? = null, val preferredDefaultAudioTrackId: String? = null, val subtitles: List = emptyList(), + val storyboard: StreamStoryboard? = null, val startPositionMillis: Long, val sponsorBlockSegments: List = emptyList(), val chapters: List = emptyList(), @@ -43,6 +44,50 @@ data class Stream( val category: String? = null, ) +data class StreamStoryboard( + val urls: List, + val frameWidth: Int, + val frameHeight: Int, + val totalCount: Int, + val durationPerFrameMillis: Long, + val framesPerPageX: Int, + val framesPerPageY: Int, +) { + fun frameAt(positionMs: Long): StreamStoryboardFrame? { + val totalFrames = totalCount.takeIf { it > 0 } ?: return null + val duration = durationPerFrameMillis.takeIf { it > 0L } ?: return null + val pageColumns = framesPerPageX.takeIf { it > 0 } ?: return null + val pageRows = framesPerPageY.takeIf { it > 0 } ?: return null + if (frameWidth <= 0 || frameHeight <= 0 || urls.isEmpty()) return null + + val elapsedFrames = positionMs / duration + val frameIndex = when { + elapsedFrames <= 0L -> 0 + elapsedFrames >= totalFrames - 1L -> totalFrames - 1 + else -> elapsedFrames.toInt() + } + val framesPerPage = pageColumns * pageRows + val pageIndex = (frameIndex / framesPerPage).coerceIn(0, urls.lastIndex) + val pageFrameIndex = frameIndex % framesPerPage + val url = urls.getOrNull(pageIndex) ?: return null + return StreamStoryboardFrame( + url = url, + x = (pageFrameIndex % pageColumns) * frameWidth, + y = (pageFrameIndex / pageColumns) * frameHeight, + width = frameWidth, + height = frameHeight, + ) + } +} + +data class StreamStoryboardFrame( + val url: String, + val x: Int, + val y: Int, + val width: Int, + val height: Int, +) + enum class StreamPlaybackContract { ServerSabr, ProviderMedia, diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt index 95e474af..34b70d90 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerControls.kt @@ -29,12 +29,14 @@ import androidx.compose.ui.unit.dp import androidx.media3.common.Player import dev.typetype.android.R import dev.typetype.android.domain.stream.SponsorBlockSegment +import dev.typetype.android.domain.stream.StreamStoryboard import dev.typetype.android.feature.player.state.ResizeMode @Composable @OptIn(ExperimentalLayoutApi::class) fun PlayerControls( player: Player, + storyboard: StreamStoryboard? = null, title: String, onNavigateBack: () -> Unit, modifier: Modifier = Modifier, @@ -108,6 +110,7 @@ fun PlayerControls( } BottomBar( player = player, + storyboard = storyboard, sponsorBlockSegments = sponsorBlockSegments, seekPreviewPositionMs = seekPreviewPositionMs, timelineScrubbing = timelineScrubbing, @@ -170,6 +173,7 @@ private fun BottomScrim( @Composable private fun BottomBar( player: Player, + storyboard: StreamStoryboard?, sponsorBlockSegments: List, seekPreviewPositionMs: Long?, timelineScrubbing: Boolean, @@ -196,6 +200,7 @@ private fun BottomBar( ) { PlayerTimeBar( player = player, + storyboard = storyboard, segments = sponsorBlockSegments, previewPositionMs = seekPreviewPositionMs, compact = !isFullscreen && !expanded, diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt index e09dc642..5179c9f6 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceBox.kt @@ -235,6 +235,7 @@ internal fun PlayerSurfaceBox( PlayerSurfaceChrome( player = player, + stream = stream, title = stream.title, sponsorBlockSegments = sponsorBlockPolicy.visibleSegments, seekPreviewPositionMs = gestureState.seekDragTargetMs.longValue diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt index e7cfd99c..79caa83e 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerSurfaceChrome.kt @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.material3.Text -import androidx.compose.ui.graphics.Color import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -18,11 +17,13 @@ import androidx.compose.ui.res.stringResource import dev.typetype.android.R import androidx.media3.session.MediaController import dev.typetype.android.domain.stream.SponsorBlockSegment +import dev.typetype.android.domain.stream.Stream import dev.typetype.android.feature.player.state.ResizeMode @Composable internal fun PlayerSurfaceChrome( player: MediaController, + stream: Stream, title: String, sponsorBlockSegments: List, seekPreviewPositionMs: Long?, @@ -56,13 +57,16 @@ internal fun PlayerSurfaceChrome( modifier = Modifier.align(Alignment.BottomCenter), ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = if (fineSeeking) { - stringResource(R.string.player_fine_seek_position, formatPlayerTime(seekDragPositionMs)) + SeekStoryboardPreview( + frame = stream.storyboard?.frameAt(seekDragPositionMs), + timecode = if (fineSeeking) { + stringResource( + R.string.player_fine_seek_position, + formatPlayerTime(seekDragPositionMs), + ) } else { formatPlayerTime(seekDragPositionMs) }, - color = Color.White, ) PlayerSeekScrubOverlay( player = player, @@ -91,6 +95,7 @@ internal fun PlayerSurfaceChrome( ) { PlayerControls( player = player, + storyboard = stream.storyboard, title = title, onNavigateBack = onNavigateBack, onOpenOptions = onOpenOptions, diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt index a5bf0ac0..822a6eb4 100644 --- a/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt +++ b/app/src/main/java/dev/typetype/android/feature/player/components/PlayerTimeBar.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.Text import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable @@ -44,6 +45,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.ui.compose.state.rememberProgressStateWithTickInterval import dev.typetype.android.R import dev.typetype.android.domain.stream.SponsorBlockSegment +import dev.typetype.android.domain.stream.StreamStoryboard import kotlin.time.Duration.Companion.milliseconds private const val TICK_INTERVAL_MS = 200L @@ -59,11 +61,14 @@ private val COMPACT_THUMB_WIDTH = 10.dp private val COMPACT_THUMB_HEIGHT = 10.dp private val SCRUBBING_TRACK_HEIGHT = 6.dp private val SCRUBBING_THUMB_SIZE = 22.dp +private val CLASSIC_PREVIEW_WIDTH = 160.dp +private val CLASSIC_PREVIEW_LIFT = 52.dp @OptIn(markerClass = [UnstableApi::class]) @Composable fun PlayerTimeBar( player: Player, + storyboard: StreamStoryboard? = null, modifier: Modifier = Modifier, segments: List = emptyList(), compact: Boolean = false, @@ -82,63 +87,86 @@ fun PlayerTimeBar( val positionLabel = formatPlayerTime(displayedPosMs) val durationLabel = formatPlayerTime(durationMs) - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + BoxWithConstraints(modifier = modifier) { + val scrubFraction = if (durationMs > 0L) { + (displayedPosMs.toFloat() / durationMs).coerceIn(0f, 1f) + } else { + 0f + } + val previewOffsetX = (maxWidth - CLASSIC_PREVIEW_WIDTH).coerceAtLeast(0.dp) * scrubFraction + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - text = positionLabel, - style = MaterialTheme.typography.labelSmall.copy(fontSize = if (expanded) 16.sp else 12.sp), - color = Color.White, - modifier = if (compact) { - Modifier.widthIn(min = COMPACT_TIME_LABEL_MIN_WIDTH) - } else { - Modifier.width(if (expanded) 68.dp else TIME_LABEL_WIDTH) - }, - textAlign = TextAlign.End, - ) - TimelineTrack( - positionMs = displayedPosMs, - durationMs = durationMs, - segments = segments, - compact = compact, - expanded = expanded, - emphasized = emphasized, - onScrub = { - onScrubbingChange(true) - scrubPositionMs = it - }, - onScrubFinished = { targetMs -> - player.seekTo(targetMs) - onScrubbingChange(false) - scrubPositionMs = null - }, - onScrubCancelled = { - onScrubbingChange(false) - scrubPositionMs = null - }, - accessibilityLabel = stringResource(R.string.player_timeline), - accessibilityStateDescription = stringResource( - R.string.player_timeline_position, - positionLabel, - durationLabel, - ), - modifier = Modifier - .weight(1f) - .padding(horizontal = if (compact) 2.dp else 4.dp) - .height(if (expanded) 56.dp else if (compact) COMPACT_TIMELINE_HEIGHT else TIMELINE_HEIGHT), - ) - Text( - text = durationLabel, - style = MaterialTheme.typography.labelSmall.copy(fontSize = if (expanded) 16.sp else 12.sp), - color = Color.White.copy(alpha = 0.7f), - modifier = if (compact) { - Modifier.widthIn(min = COMPACT_TIME_LABEL_MIN_WIDTH) - } else { - Modifier.width(if (expanded) 68.dp else TIME_LABEL_WIDTH) - }, - ) + Text( + text = positionLabel, + style = MaterialTheme.typography.labelSmall.copy(fontSize = if (expanded) 16.sp else 12.sp), + color = Color.White, + modifier = if (compact) { + Modifier.widthIn(min = COMPACT_TIME_LABEL_MIN_WIDTH) + } else { + Modifier.width(if (expanded) 68.dp else TIME_LABEL_WIDTH) + }, + textAlign = TextAlign.End, + ) + TimelineTrack( + positionMs = displayedPosMs, + durationMs = durationMs, + segments = segments, + compact = compact, + expanded = expanded, + emphasized = emphasized, + onScrub = { + onScrubbingChange(true) + scrubPositionMs = it + }, + onScrubFinished = { targetMs -> + player.seekTo(targetMs) + onScrubbingChange(false) + scrubPositionMs = null + }, + onScrubCancelled = { + onScrubbingChange(false) + scrubPositionMs = null + }, + accessibilityLabel = stringResource(R.string.player_timeline), + accessibilityStateDescription = stringResource( + R.string.player_timeline_position, + positionLabel, + durationLabel, + ), + modifier = Modifier + .weight(1f) + .padding(horizontal = if (compact) 2.dp else 4.dp) + .height(if (expanded) 56.dp else if (compact) COMPACT_TIMELINE_HEIGHT else TIMELINE_HEIGHT), + ) + Text( + text = durationLabel, + style = MaterialTheme.typography.labelSmall.copy(fontSize = if (expanded) 16.sp else 12.sp), + color = Color.White.copy(alpha = 0.7f), + modifier = if (compact) { + Modifier.widthIn(min = COMPACT_TIME_LABEL_MIN_WIDTH) + } else { + Modifier.width(if (expanded) 68.dp else TIME_LABEL_WIDTH) + }, + ) + } + + val classicScrubPositionMs = scrubPositionMs + if (classicScrubPositionMs != null) { + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .offset(x = previewOffsetX, y = -CLASSIC_PREVIEW_LIFT), + ) { + SeekStoryboardPreview( + frame = storyboard?.frameAt(classicScrubPositionMs), + timecode = formatPlayerTime(classicScrubPositionMs), + ) + } + } } } diff --git a/app/src/main/java/dev/typetype/android/feature/player/components/SeekStoryboardPreview.kt b/app/src/main/java/dev/typetype/android/feature/player/components/SeekStoryboardPreview.kt new file mode 100644 index 00000000..879d4c96 --- /dev/null +++ b/app/src/main/java/dev/typetype/android/feature/player/components/SeekStoryboardPreview.kt @@ -0,0 +1,85 @@ +package dev.typetype.android.feature.player.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import dev.typetype.android.domain.stream.StreamStoryboardFrame + +internal const val PLAYER_SEEK_STORYBOARD_PREVIEW_TAG = "player_seek_storyboard_preview" + +private val PREVIEW_WIDTH = 160.dp +private val PREVIEW_HEIGHT = 90.dp + +@Composable +internal fun SeekStoryboardPreview( + frame: StreamStoryboardFrame?, + timecode: String, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier.testTag(PLAYER_SEEK_STORYBOARD_PREVIEW_TAG), + ) { + frame?.let { StoryboardFrame(frame = it) } + Text( + text = timecode, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier + .padding(top = 4.dp) + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.82f), + shape = RoundedCornerShape(8.dp), + ) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) + } +} + +@Composable +private fun StoryboardFrame(frame: StreamStoryboardFrame) { + val scale = PREVIEW_WIDTH.value / frame.width + val spriteWidth = (frame.width * scale).dp + val spriteHeight = (frame.height * scale).dp + Box( + modifier = Modifier + .background( + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.82f), + shape = RoundedCornerShape(12.dp), + ) + .padding(3.dp), + ) { + Box( + modifier = Modifier + .size(PREVIEW_WIDTH, PREVIEW_HEIGHT) + .clipToBounds(), + ) { + AsyncImage( + model = frame.url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .requiredSize( + width = spriteWidth, + height = spriteHeight, + ) + .offset(x = (-frame.x * scale).dp, y = (-frame.y * scale).dp), + ) + } + } +} diff --git a/app/src/test/java/dev/typetype/android/domain/stream/StreamStoryboardTest.kt b/app/src/test/java/dev/typetype/android/domain/stream/StreamStoryboardTest.kt new file mode 100644 index 00000000..eca1671f --- /dev/null +++ b/app/src/test/java/dev/typetype/android/domain/stream/StreamStoryboardTest.kt @@ -0,0 +1,45 @@ +package dev.typetype.android.domain.stream + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class StreamStoryboardTest { + private val storyboard = StreamStoryboard( + urls = listOf("page-0", "page-1"), + frameWidth = 160, + frameHeight = 90, + totalCount = 100, + durationPerFrameMillis = 5_000L, + framesPerPageX = 5, + framesPerPageY = 5, + ) + + @Test + fun selectsFrameInsideFirstStoryboardPage() { + val frame = storyboard.frameAt(57_000L) + + assertEquals("page-0", frame?.url) + assertEquals(160, frame?.x) + assertEquals(180, frame?.y) + assertEquals(160, frame?.width) + assertEquals(90, frame?.height) + } + + @Test + fun selectsPageAndCoordinatesAcrossStoryboardPages() { + val frame = storyboard.frameAt(150_000L) + + assertEquals("page-1", frame?.url) + assertEquals(0, frame?.x) + assertEquals(90, frame?.y) + } + + @Test + fun clampsToLastFrameAndRejectsIncompleteStoryboards() { + assertEquals(640, storyboard.frameAt(Long.MAX_VALUE / 2L)?.x) + + assertNull(storyboard.copy(totalCount = 0).frameAt(0L)) + assertNull(storyboard.copy(urls = emptyList()).frameAt(0L)) + } +} From 7623ffb56aa2ab28c5e8be45d9e158ab3f1cd8f2 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 10 Sep 2026 10:59:50 +0200 Subject: [PATCH 2/2] build: publish storyboard previews beta --- 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 21b6ba65..ca5b6769 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 = 10804 - versionName = "1.8.0-beta.4" + versionCode = 10805 + versionName = "1.8.0-beta.5" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" resValue("string", "app_name", "TypeType") }