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
|
// 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"
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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.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)) {
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue