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:
parent
9ad3302f52
commit
94ef84f1ac
7 changed files with 145 additions and 47 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue