From fbccdce65a11f8b33079a6148302ea5670a20919 Mon Sep 17 00:00:00 2001 From: Kayos Date: Tue, 26 May 2026 09:28:04 -0700 Subject: [PATCH] vc=54: red progress-bar overlay on video thumbnails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit YT/NewPipe-style — when ResumePositionsStore has an entry for a video, paint a 3dp red bar across the bottom of the thumbnail showing position/duration. Reads instantly as "you started this." Consolidated the duplicate thumbnail render logic across 6 row sites (FeedRow, RecentRow, ResultRow, ChannelVideoRow, RelatedRow, PlaylistsScreen) into a single feature/player/VideoThumbnail composable. Includes the existing duration-pill overlay + the new progress bar. ThumbnailProgressOverlay is a BoxScope extension so custom thumbnail compositions can drop it in without going through the full helper. --- buildSrc/src/main/kotlin/ProjectConfig.kt | 4 +- .../main/kotlin/com/sulkta/straw/StrawHome.kt | 51 ++------ .../kotlin/com/sulkta/straw/StrawTheme.kt | 6 + .../straw/feature/channel/ChannelScreen.kt | 11 +- .../straw/feature/detail/VideoDetailScreen.kt | 11 +- .../straw/feature/player/ThumbnailProgress.kt | 113 ++++++++++++++++++ .../straw/feature/playlist/PlaylistsScreen.kt | 11 +- .../straw/feature/search/SearchScreen.kt | 11 +- 8 files changed, 153 insertions(+), 65 deletions(-) create mode 100644 strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index 3bd5dd502..e3e0e57f4 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // NewPipeExtractor in the runtime path. -const val STRAW_VERSION_CODE = 53 -const val STRAW_VERSION_NAME = "0.1.0-BM" +const val STRAW_VERSION_CODE = 54 +const val STRAW_VERSION_NAME = "0.1.0-BN" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt index 251fcceda..7510d452b 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawHome.kt @@ -80,12 +80,11 @@ import com.sulkta.straw.data.History import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.data.WatchHistoryItem import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel +import com.sulkta.straw.feature.player.VideoThumbnail import com.sulkta.straw.feature.playlist.VideoActionTarget import com.sulkta.straw.feature.playlist.VideoActionsSheet import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.rememberBottomContentPadding -import com.sulkta.straw.OverlayDimColor -import com.sulkta.straw.util.formatDuration import com.sulkta.straw.util.formatViews import kotlinx.coroutines.launch @@ -508,8 +507,9 @@ private fun FeedRow( .padding(vertical = 8.dp), verticalAlignment = Alignment.Top, ) { - ThumbnailWithDuration( + VideoThumbnail( thumbnail = item.thumbnail, + videoUrl = item.url, durationSeconds = item.durationSeconds, modifier = Modifier .width(140.dp) @@ -546,41 +546,6 @@ private fun FeedRow( } } -/** - * 16:9 thumbnail with a NewPipe-style duration pill burned into the - * bottom-right corner. `durationSeconds == 0` skips the badge (live - * streams, mixes that come back without a duration, etc.). - */ -@Composable -private fun ThumbnailWithDuration( - thumbnail: String?, - durationSeconds: Long, - modifier: Modifier = Modifier, -) { - Box(modifier = modifier) { - AsyncImage( - model = thumbnail, - contentDescription = null, - modifier = Modifier - .fillMaxSize() - .clip(RoundedCornerShape(6.dp)), - ) - if (durationSeconds > 0) { - Text( - text = formatDuration(durationSeconds), - style = MaterialTheme.typography.labelSmall, - color = androidx.compose.ui.graphics.Color.White, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(4.dp) - .clip(RoundedCornerShape(3.dp)) - .background(OverlayDimColor) - .padding(horizontal = 4.dp, vertical = 1.dp), - ) - } - } -} - @Composable private fun SubChip( ch: ChannelRef, @@ -643,13 +608,13 @@ private fun RecentRow( .padding(vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - AsyncImage( - model = item.thumbnail, - contentDescription = null, + VideoThumbnail( + thumbnail = item.thumbnail, + videoUrl = item.url, + durationSeconds = 0L, modifier = Modifier .width(120.dp) - .height(68.dp) - .clip(RoundedCornerShape(6.dp)), + .height(68.dp), ) Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt b/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt index ebbacd377..85fa21b09 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/StrawTheme.kt @@ -82,3 +82,9 @@ fun strawDarkColors(): ColorScheme = darkColorScheme( // minibar thumbnail. Kept here so a theme tweak touches one place. val OverlayChromeColor = Color(0xCC222222) val OverlayDimColor = Color(0xCC000000) + +// Watch-progress bar painted across the bottom of a video thumbnail when +// the user has a saved scrub-point. Solid red foreground over a slightly- +// dim track. Matches YT / NewPipe conventions so it reads instantly. +val ProgressBarFillColor = Color(0xFFE53935) +val ProgressBarTrackColor = Color(0x66000000) diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt index 6333612d4..6ecd48a08 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/channel/ChannelScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage +import com.sulkta.straw.feature.player.VideoThumbnail import com.sulkta.straw.data.ChannelRef import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.feature.playlist.VideoActionTarget @@ -177,13 +178,13 @@ private fun ChannelVideoRow( .padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.Top, ) { - AsyncImage( - model = item.thumbnail, - contentDescription = null, + VideoThumbnail( + thumbnail = item.thumbnail, + videoUrl = item.url, + durationSeconds = item.durationSeconds, modifier = Modifier .width(140.dp) - .height(80.dp) - .clip(RoundedCornerShape(6.dp)), + .height(80.dp), ) Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 6ae432336..e10c841f9 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -99,6 +99,7 @@ import com.sulkta.straw.feature.download.DownloadKind import com.sulkta.straw.feature.download.Downloader import com.sulkta.straw.feature.player.LocalStrawController import com.sulkta.straw.feature.player.NowPlaying +import com.sulkta.straw.feature.player.VideoThumbnail import com.sulkta.straw.feature.player.setPlayingFrom import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.util.LogDump @@ -680,13 +681,13 @@ private fun RelatedRow( .padding(vertical = 8.dp), verticalAlignment = Alignment.Top, ) { - AsyncImage( - model = item.thumbnail, - contentDescription = null, + VideoThumbnail( + thumbnail = item.thumbnail, + videoUrl = item.url, + durationSeconds = item.durationSeconds, modifier = Modifier .width(140.dp) - .height(80.dp) - .clip(RoundedCornerShape(6.dp)), + .height(80.dp), ) Spacer(modifier = Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt new file mode 100644 index 000000000..bc4bd4e4f --- /dev/null +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/player/ThumbnailProgress.kt @@ -0,0 +1,113 @@ +/* + * SPDX-FileCopyrightText: 2026 Sulkta-Coop + * SPDX-License-Identifier: GPL-3.0-or-later + * + * Red progress bar painted across the bottom of a video thumbnail when + * the user has a saved scrub-point in ResumePositionsStore. Same shape + * YouTube + NewPipe use — instantly readable as "you started this." + * + * Drops into any thumbnail-rendering Box; the caller is responsible for + * being inside a Box (so we can align to Bottom). Returns nothing when + * the videoId is blank or has no recorded position. + */ + +package com.sulkta.straw.feature.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.sulkta.straw.OverlayDimColor +import com.sulkta.straw.ProgressBarFillColor +import com.sulkta.straw.ProgressBarTrackColor +import com.sulkta.straw.data.Resume +import com.sulkta.straw.feature.detail.extractYtVideoId +import com.sulkta.straw.util.formatDuration + +/** + * Paint a 3dp watch-progress bar across the bottom of the surrounding + * Box when ResumePositionsStore has an entry for [videoId]. Silent + * no-op when there's no entry — safe to call unconditionally. + * + * Must be used inside a Box (uses BoxScope.align). Caller's Box sets + * the thumbnail size; this composable just overlays the bar. + */ +@Composable +fun BoxScope.ThumbnailProgressOverlay(videoId: String?) { + if (videoId.isNullOrBlank()) return + val positions by Resume.get().positions.collectAsStateWithLifecycle() + val entry = positions[videoId] ?: return + if (entry.durationMs <= 0L) return + val fraction = (entry.positionMs.toFloat() / entry.durationMs.toFloat()) + .coerceIn(0f, 1f) + Box( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .height(3.dp) + .background(ProgressBarTrackColor), + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .fillMaxWidth(fraction) + .background(ProgressBarFillColor), + ) + } +} + +/** + * One-stop video thumbnail: 16:9 image with optional NewPipe-style + * duration pill at bottom-right + watch-progress overlay at bottom + * when the user has a saved scrub-point for [videoUrl]. + * + * Pass an outer modifier with the desired width/height; the corner + * radius + clip are applied inside so the progress bar bleeds to the + * exact edge of the rounded thumbnail. durationSeconds <= 0 drops the + * badge (live streams, items that come back without a duration). + */ +@Composable +fun VideoThumbnail( + thumbnail: String?, + videoUrl: String?, + durationSeconds: Long, + modifier: Modifier = Modifier, +) { + Box(modifier = modifier.clip(RoundedCornerShape(6.dp))) { + AsyncImage( + model = thumbnail, + contentDescription = null, + modifier = Modifier.fillMaxSize(), + ) + if (durationSeconds > 0) { + Text( + text = formatDuration(durationSeconds), + style = MaterialTheme.typography.labelSmall, + color = Color.White, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(4.dp) + .clip(RoundedCornerShape(3.dp)) + .background(OverlayDimColor) + .padding(horizontal = 4.dp, vertical = 1.dp), + ) + } + ThumbnailProgressOverlay(videoUrl?.let { extractYtVideoId(it) }) + } +} diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt index a512c6565..2a11d694a 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/playlist/PlaylistsScreen.kt @@ -45,6 +45,7 @@ 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 com.sulkta.straw.feature.player.VideoThumbnail import com.sulkta.straw.data.Playlists import com.sulkta.straw.util.rememberBottomContentPadding @@ -231,13 +232,13 @@ fun PlaylistViewScreen( .padding(vertical = 8.dp), verticalAlignment = Alignment.Top, ) { - AsyncImage( - model = item.thumbnail, - contentDescription = null, + VideoThumbnail( + thumbnail = item.thumbnail, + videoUrl = item.streamUrl, + durationSeconds = 0L, modifier = Modifier .width(140.dp) - .height(80.dp) - .clip(RoundedCornerShape(6.dp)), + .height(80.dp), ) Spacer(modifier = Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt index c4e23d6a1..06075d32d 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/search/SearchScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.runtime.collectAsState import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import coil3.compose.AsyncImage +import com.sulkta.straw.feature.player.VideoThumbnail import com.sulkta.straw.data.History import com.sulkta.straw.feature.playlist.VideoActionTarget import com.sulkta.straw.feature.playlist.VideoActionsSheet @@ -202,13 +203,13 @@ private fun ResultRow( .padding(vertical = 10.dp), verticalAlignment = Alignment.Top, ) { - AsyncImage( - model = item.thumbnail, - contentDescription = null, + VideoThumbnail( + thumbnail = item.thumbnail, + videoUrl = item.url, + durationSeconds = item.durationSeconds, modifier = Modifier .width(160.dp) - .height(90.dp) - .clip(RoundedCornerShape(6.dp)), + .height(90.dp), ) Spacer(modifier = Modifier.width(12.dp)) Column(modifier = Modifier.weight(1f)) {