v0.1.0-Y (vc=13): VideoDetail reorder + home search pill + status-bar padding

VideoDetail card order (top → bottom under the player thumbnail):
  Recommended → More from <uploader> → Video details
NewPipeExtractor fan-out: streamInfo brings 'related', and a parallel
ChannelInfo+VIDEOS-tab fetch brings 'moreFromChannel' (filtered to drop
the current video). Same data shape as feed/search rows; reuses RelatedRow.

Home top bar gets a YouTube-style search pill in the title slot — tap
takes you to the search screen. The drawer 'Search' entry is gone (the
pill replaces it).

Section header below the top bar — 'Latest from your subs' / 'History' —
makes which view you're on obvious. Empty subs state is friendlier.

Status-bar padding (statusBarsPadding) added to VideoDetailScreen,
SearchScreen, ChannelScreen, SettingsScreen — fixes content rendering
under the Pixel camera cutout in edge-to-edge mode.
This commit is contained in:
Kayos 2026-05-24 11:02:39 -07:00
parent 9ad3302f52
commit 94ef84f1ac
7 changed files with 145 additions and 47 deletions

View file

@ -15,6 +15,6 @@ const val NEWPIPE_APPLICATION_ID_OLD = "org.schabi.newpipe"
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// Sulkta fork — Straw
const val STRAW_VERSION_CODE = 12
const val STRAW_VERSION_NAME = "0.1.0-X"
const val STRAW_VERSION_CODE = 13
const val STRAW_VERSION_NAME = "0.1.0-Y"
const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -36,7 +36,11 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@ -122,16 +126,6 @@ fun StrawHome(
modifier = Modifier.padding(horizontal = 12.dp),
)
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
NavigationDrawerItem(
label = { Text("Search") },
icon = { Text("🔍") },
selected = false,
onClick = {
scope.launch { drawerState.close() }
onOpenSearch()
},
modifier = Modifier.padding(horizontal = 12.dp),
)
NavigationDrawerItem(
label = { Text("Settings") },
icon = { Text("") },
@ -149,13 +143,34 @@ fun StrawHome(
topBar = {
TopAppBar(
title = {
Text(
text = when (view) {
HomeView.Subs -> "Subscriptions"
HomeView.History -> "History"
},
style = MaterialTheme.typography.titleLarge,
)
// Search-pill in the title slot — tap takes you to the
// full search screen with the field auto-focused. Same
// idea as YT's mobile top bar.
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(end = 8.dp)
.height(40.dp)
.clip(RoundedCornerShape(20.dp))
.clickable(onClick = onOpenSearch),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 14.dp),
) {
Text(
"🔍",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.width(10.dp))
Text(
"Search YouTube",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) {
@ -171,6 +186,16 @@ fun StrawHome(
.padding(padding)
.padding(horizontal = 20.dp, vertical = 8.dp),
) {
// Section header — clarifies which view is current.
Text(
text = when (view) {
HomeView.Subs -> "Latest from your subs"
HomeView.History -> "History"
},
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(12.dp))
when (view) {
HomeView.Subs -> SubsPane(onOpenChannel, onOpenVideo, feedVm)
HomeView.History -> HistoryPane(onOpenVideo)
@ -222,10 +247,16 @@ private fun SubsPane(
Column {
if (subs.isEmpty()) {
Text(
"No subscriptions yet. Tap Search in the menu, find a channel, and Subscribe.",
"No subscriptions yet.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(4.dp))
Text(
"Tap the search bar above, find a channel, and Subscribe.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
return@Column
}

View file

@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@ -63,18 +64,18 @@ fun ChannelScreen(
when {
state.loading -> Box(
modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentAlignment = Alignment.Center,
) { CircularProgressIndicator() }
state.error != null -> Box(
modifier = Modifier.fillMaxSize().padding(16.dp),
modifier = Modifier.fillMaxSize().statusBarsPadding().padding(16.dp),
contentAlignment = Alignment.Center,
) {
Text("error: ${state.error}", color = MaterialTheme.colorScheme.error)
}
else -> LazyColumn(modifier = Modifier.fillMaxSize()) {
else -> LazyColumn(modifier = Modifier.fillMaxSize().statusBarsPadding()) {
item {
state.banner?.let { b ->
AsyncImage(

View file

@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
@ -74,6 +75,7 @@ fun VideoDetailScreen(
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
@ -121,7 +123,40 @@ fun VideoDetailScreen(
)
}
}
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(20.dp))
// ── Recommended ──────────────────────────────────────────
if (d.related.isNotEmpty()) {
Text(
"Recommended",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
d.related.take(20).forEach { rel ->
RelatedRow(rel) { onOpenVideo(rel.url, rel.title) }
androidx.compose.material3.HorizontalDivider()
}
Spacer(modifier = Modifier.height(20.dp))
}
// ── More from <uploader> ─────────────────────────────────
if (d.moreFromChannel.isNotEmpty()) {
Text(
if (d.uploader.isBlank()) "More from this channel"
else "More from ${d.uploader}",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
d.moreFromChannel.take(20).forEach { item ->
RelatedRow(item) { onOpenVideo(item.url, item.title) }
androidx.compose.material3.HorizontalDivider()
}
Spacer(modifier = Modifier.height(20.dp))
}
// ── Video details ────────────────────────────────────────
Text(
text = d.title,
style = MaterialTheme.typography.titleLarge,
@ -189,6 +224,15 @@ fun VideoDetailScreen(
}
Spacer(modifier = Modifier.height(16.dp))
Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(8.dp))
// AUD-MED: cap input length before regex passes — defends
// against ANR on multi-MB descriptions.
Text(
text = stripHtml(d.description.take(20_000)).take(2000),
style = MaterialTheme.typography.bodySmall,
)
if (showDownloadDialog) {
val info = state.streamInfo
AlertDialog(
@ -249,28 +293,6 @@ fun VideoDetailScreen(
)
}
Text("Description", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.height(8.dp))
// AUD-MED: cap input length before regex passes — defends
// against ANR on multi-MB descriptions.
Text(
text = stripHtml(d.description.take(20_000)).take(2000),
style = MaterialTheme.typography.bodySmall,
)
if (d.related.isNotEmpty()) {
Spacer(modifier = Modifier.height(24.dp))
Text(
"Related",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
Spacer(modifier = Modifier.height(8.dp))
d.related.take(20).forEach { rel ->
RelatedRow(rel) { onOpenVideo(rel.url, rel.title) }
androidx.compose.material3.HorizontalDivider()
}
}
}
}
}

View file

@ -20,6 +20,11 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
@ -34,6 +39,9 @@ data class VideoDetail(
val ryd: RydVotes? = null,
val sbSegmentCount: Int = 0,
val related: List<com.sulkta.straw.feature.search.StreamItem> = emptyList(),
/** Other videos from the same channel separate from related (which is YT's
* algo). Anchored to the uploader the user chose; matches the sub-feed ethos. */
val moreFromChannel: List<com.sulkta.straw.feature.search.StreamItem> = emptyList(),
)
data class VideoDetailUiState(
@ -98,6 +106,38 @@ class VideoDetailViewModel : ViewModel() {
)
} ?: emptyList()
// More from this channel — anchored to the uploader the user
// already chose. Best-effort: empty if the fetch fails so the
// detail screen still renders. Filters out the current video.
val moreFromChannel: List<com.sulkta.straw.feature.search.StreamItem> =
if (info.uploaderUrl.isNullOrBlank()) emptyList()
else withContext(Dispatchers.IO) {
runCatching {
val service = NewPipe.getService(ServiceList.YouTube.serviceId)
val ch = ChannelInfo.getInfo(service, info.uploaderUrl)
val videosTab = ch.tabs.firstOrNull {
it.contentFilters.contains(ChannelTabs.VIDEOS)
} ?: ch.tabs.firstOrNull()
if (videosTab == null) emptyList()
else ChannelTabInfo.getInfo(service, videosTab)
.relatedItems
.filterIsInstance<StreamInfoItem>()
.filter { it.url != streamUrl }
.take(20)
.map { si ->
com.sulkta.straw.feature.search.StreamItem(
url = si.url,
title = si.name ?: "(no title)",
uploader = si.uploaderName ?: uploader,
uploaderUrl = si.uploaderUrl ?: info.uploaderUrl,
thumbnail = bestThumbnail(si.thumbnails),
durationSeconds = si.duration,
viewCount = si.viewCount,
)
}
}.getOrDefault(emptyList())
}
_ui.value = VideoDetailUiState(
loading = false,
detail = VideoDetail(
@ -111,6 +151,7 @@ class VideoDetailViewModel : ViewModel() {
ryd = ryd,
sbSegmentCount = sbCount,
related = related,
moreFromChannel = moreFromChannel,
),
streamInfo = info,
)

View file

@ -16,6 +16,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@ -54,7 +55,7 @@ fun SearchScreen(
val state by vm.ui.collectAsStateWithLifecycle()
val recentSearches by History.get().searches.collectAsState()
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Column(modifier = Modifier.fillMaxSize().statusBarsPadding().padding(16.dp)) {
OutlinedTextField(
value = state.query,
onValueChange = vm::onQueryChange,

View file

@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@ -43,6 +44,7 @@ fun SettingsScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
) {