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" const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// Sulkta fork — Straw // Sulkta fork — Straw
const val STRAW_VERSION_CODE = 12 const val STRAW_VERSION_CODE = 13
const val STRAW_VERSION_NAME = "0.1.0-X" const val STRAW_VERSION_NAME = "0.1.0-Y"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" 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.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem 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.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@ -122,16 +126,6 @@ fun StrawHome(
modifier = Modifier.padding(horizontal = 12.dp), modifier = Modifier.padding(horizontal = 12.dp),
) )
HorizontalDivider(modifier = Modifier.padding(vertical = 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( NavigationDrawerItem(
label = { Text("Settings") }, label = { Text("Settings") },
icon = { Text("") }, icon = { Text("") },
@ -149,13 +143,34 @@ fun StrawHome(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Text( // Search-pill in the title slot — tap takes you to the
text = when (view) { // full search screen with the field auto-focused. Same
HomeView.Subs -> "Subscriptions" // idea as YT's mobile top bar.
HomeView.History -> "History" Surface(
}, modifier = Modifier
style = MaterialTheme.typography.titleLarge, .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 = { navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) { IconButton(onClick = { scope.launch { drawerState.open() } }) {
@ -171,6 +186,16 @@ fun StrawHome(
.padding(padding) .padding(padding)
.padding(horizontal = 20.dp, vertical = 8.dp), .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) { when (view) {
HomeView.Subs -> SubsPane(onOpenChannel, onOpenVideo, feedVm) HomeView.Subs -> SubsPane(onOpenChannel, onOpenVideo, feedVm)
HomeView.History -> HistoryPane(onOpenVideo) HomeView.History -> HistoryPane(onOpenVideo)
@ -222,10 +247,16 @@ private fun SubsPane(
Column { Column {
if (subs.isEmpty()) { if (subs.isEmpty()) {
Text( Text(
"No subscriptions yet. Tap Search in the menu, find a channel, and Subscribe.", "No subscriptions yet.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, 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 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.Spacer
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -63,18 +64,18 @@ fun ChannelScreen(
when { when {
state.loading -> Box( state.loading -> Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize().statusBarsPadding(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { CircularProgressIndicator() } ) { CircularProgressIndicator() }
state.error != null -> Box( state.error != null -> Box(
modifier = Modifier.fillMaxSize().padding(16.dp), modifier = Modifier.fillMaxSize().statusBarsPadding().padding(16.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text("error: ${state.error}", color = MaterialTheme.colorScheme.error) Text("error: ${state.error}", color = MaterialTheme.colorScheme.error)
} }
else -> LazyColumn(modifier = Modifier.fillMaxSize()) { else -> LazyColumn(modifier = Modifier.fillMaxSize().statusBarsPadding()) {
item { item {
state.banner?.let { b -> state.banner?.let { b ->
AsyncImage( AsyncImage(

View file

@ -18,6 +18,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -74,6 +75,7 @@ fun VideoDetailScreen(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.statusBarsPadding()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(16.dp), .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(
text = d.title, text = d.title,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
@ -189,6 +224,15 @@ fun VideoDetailScreen(
} }
Spacer(modifier = Modifier.height(16.dp)) 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) { if (showDownloadDialog) {
val info = state.streamInfo val info = state.streamInfo
AlertDialog( 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.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext 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.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
@ -34,6 +39,9 @@ data class VideoDetail(
val ryd: RydVotes? = null, val ryd: RydVotes? = null,
val sbSegmentCount: Int = 0, val sbSegmentCount: Int = 0,
val related: List<com.sulkta.straw.feature.search.StreamItem> = emptyList(), 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( data class VideoDetailUiState(
@ -98,6 +106,38 @@ class VideoDetailViewModel : ViewModel() {
) )
} ?: emptyList() } ?: 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( _ui.value = VideoDetailUiState(
loading = false, loading = false,
detail = VideoDetail( detail = VideoDetail(
@ -111,6 +151,7 @@ class VideoDetailViewModel : ViewModel() {
ryd = ryd, ryd = ryd,
sbSegmentCount = sbCount, sbSegmentCount = sbCount,
related = related, related = related,
moreFromChannel = moreFromChannel,
), ),
streamInfo = info, 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@ -54,7 +55,7 @@ fun SearchScreen(
val state by vm.ui.collectAsStateWithLifecycle() val state by vm.ui.collectAsStateWithLifecycle()
val recentSearches by History.get().searches.collectAsState() val recentSearches by History.get().searches.collectAsState()
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { Column(modifier = Modifier.fillMaxSize().statusBarsPadding().padding(16.dp)) {
OutlinedTextField( OutlinedTextField(
value = state.query, value = state.query,
onValueChange = vm::onQueryChange, 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.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
@ -43,6 +44,7 @@ fun SettingsScreen() {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.statusBarsPadding()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp), .padding(horizontal = 20.dp, vertical = 16.dp),
) { ) {