vc=54: red progress-bar overlay on video thumbnails

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.
This commit is contained in:
Kayos 2026-05-26 09:28:04 -07:00
parent 080346716b
commit fbccdce65a
8 changed files with 153 additions and 65 deletions

View file

@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no // strawcore-core (Sulkta-Coop/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path. // NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 53 const val STRAW_VERSION_CODE = 54
const val STRAW_VERSION_NAME = "0.1.0-BM" const val STRAW_VERSION_NAME = "0.1.0-BN"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -80,12 +80,11 @@ import com.sulkta.straw.data.History
import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.data.WatchHistoryItem import com.sulkta.straw.data.WatchHistoryItem
import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel 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.VideoActionTarget
import com.sulkta.straw.feature.playlist.VideoActionsSheet import com.sulkta.straw.feature.playlist.VideoActionsSheet
import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.rememberBottomContentPadding import com.sulkta.straw.util.rememberBottomContentPadding
import com.sulkta.straw.OverlayDimColor
import com.sulkta.straw.util.formatDuration
import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.formatViews
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -508,8 +507,9 @@ private fun FeedRow(
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
) { ) {
ThumbnailWithDuration( VideoThumbnail(
thumbnail = item.thumbnail, thumbnail = item.thumbnail,
videoUrl = item.url,
durationSeconds = item.durationSeconds, durationSeconds = item.durationSeconds,
modifier = Modifier modifier = Modifier
.width(140.dp) .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 @Composable
private fun SubChip( private fun SubChip(
ch: ChannelRef, ch: ChannelRef,
@ -643,13 +608,13 @@ private fun RecentRow(
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
AsyncImage( VideoThumbnail(
model = item.thumbnail, thumbnail = item.thumbnail,
contentDescription = null, videoUrl = item.url,
durationSeconds = 0L,
modifier = Modifier modifier = Modifier
.width(120.dp) .width(120.dp)
.height(68.dp) .height(68.dp),
.clip(RoundedCornerShape(6.dp)),
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {

View file

@ -82,3 +82,9 @@ fun strawDarkColors(): ColorScheme = darkColorScheme(
// minibar thumbnail. Kept here so a theme tweak touches one place. // minibar thumbnail. Kept here so a theme tweak touches one place.
val OverlayChromeColor = Color(0xCC222222) val OverlayChromeColor = Color(0xCC222222)
val OverlayDimColor = Color(0xCC000000) 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)

View file

@ -49,6 +49,7 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.sulkta.straw.feature.player.VideoThumbnail
import com.sulkta.straw.data.ChannelRef import com.sulkta.straw.data.ChannelRef
import com.sulkta.straw.data.Subscriptions import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.feature.playlist.VideoActionTarget import com.sulkta.straw.feature.playlist.VideoActionTarget
@ -177,13 +178,13 @@ private fun ChannelVideoRow(
.padding(horizontal = 16.dp, vertical = 10.dp), .padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
) { ) {
AsyncImage( VideoThumbnail(
model = item.thumbnail, thumbnail = item.thumbnail,
contentDescription = null, videoUrl = item.url,
durationSeconds = item.durationSeconds,
modifier = Modifier modifier = Modifier
.width(140.dp) .width(140.dp)
.height(80.dp) .height(80.dp),
.clip(RoundedCornerShape(6.dp)),
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {

View file

@ -99,6 +99,7 @@ import com.sulkta.straw.feature.download.DownloadKind
import com.sulkta.straw.feature.download.Downloader import com.sulkta.straw.feature.download.Downloader
import com.sulkta.straw.feature.player.LocalStrawController import com.sulkta.straw.feature.player.LocalStrawController
import com.sulkta.straw.feature.player.NowPlaying 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.player.setPlayingFrom
import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.LogDump import com.sulkta.straw.util.LogDump
@ -680,13 +681,13 @@ private fun RelatedRow(
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
) { ) {
AsyncImage( VideoThumbnail(
model = item.thumbnail, thumbnail = item.thumbnail,
contentDescription = null, videoUrl = item.url,
durationSeconds = item.durationSeconds,
modifier = Modifier modifier = Modifier
.width(140.dp) .width(140.dp)
.height(80.dp) .height(80.dp),
.clip(RoundedCornerShape(6.dp)),
) )
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {

View file

@ -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) })
}
}

View file

@ -45,6 +45,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.sulkta.straw.feature.player.VideoThumbnail
import com.sulkta.straw.data.Playlists import com.sulkta.straw.data.Playlists
import com.sulkta.straw.util.rememberBottomContentPadding import com.sulkta.straw.util.rememberBottomContentPadding
@ -231,13 +232,13 @@ fun PlaylistViewScreen(
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
) { ) {
AsyncImage( VideoThumbnail(
model = item.thumbnail, thumbnail = item.thumbnail,
contentDescription = null, videoUrl = item.streamUrl,
durationSeconds = 0L,
modifier = Modifier modifier = Modifier
.width(140.dp) .width(140.dp)
.height(80.dp) .height(80.dp),
.clip(RoundedCornerShape(6.dp)),
) )
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {

View file

@ -45,6 +45,7 @@ import androidx.compose.runtime.collectAsState
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.sulkta.straw.feature.player.VideoThumbnail
import com.sulkta.straw.data.History import com.sulkta.straw.data.History
import com.sulkta.straw.feature.playlist.VideoActionTarget import com.sulkta.straw.feature.playlist.VideoActionTarget
import com.sulkta.straw.feature.playlist.VideoActionsSheet import com.sulkta.straw.feature.playlist.VideoActionsSheet
@ -202,13 +203,13 @@ private fun ResultRow(
.padding(vertical = 10.dp), .padding(vertical = 10.dp),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
) { ) {
AsyncImage( VideoThumbnail(
model = item.thumbnail, thumbnail = item.thumbnail,
contentDescription = null, videoUrl = item.url,
durationSeconds = item.durationSeconds,
modifier = Modifier modifier = Modifier
.width(160.dp) .width(160.dp)
.height(90.dp) .height(90.dp),
.clip(RoundedCornerShape(6.dp)),
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {