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:
parent
080346716b
commit
fbccdce65a
8 changed files with 153 additions and 65 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
}
|
||||
}
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue