browser: full screen, for the page or a video in it

A button in the header hides it and the nav bar; a page's own full screen,
such as a video's, fills the browser too. A swipe in from the top or bottom
edge brings the nav bar back for a few seconds, with a button to leave, and
Back leaves full screen first.

Other screens and pop-ups now sit in a layer over Home instead of inside
its area, so full screen grows over the nav bar's row while the map's
surface underneath keeps its size.
This commit is contained in:
Seb3thehacker committed 2026-10-19 23:56:06 -07:00
1 parent 99ad0d03d7
commit 75c11a92d9
4 files changed
+167 -11

No files matched your search

@@ -0,0 +1,51 @@
package app.seb3thehacker.gearslip.car
import android.os.SystemClock
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
/**
* Full screen for the screen that asks for it (the browser, or a video in it): no header and no
* nav bar. A swipe in from the top or bottom edge brings the nav bar back for a few seconds,
* with a button to leave full screen; [revealed] says when.
*/
object CarFullscreen {
var active by mutableStateOf(false)
private set
var revealed by mutableStateOf(false)
private set
private var lastTouch = 0L
fun enter() {
active = true
revealed = false
}
fun exit() {
active = false
revealed = false
}
/** A swipe in from an edge: show the nav bar and the way out. */
fun reveal() {
if (!active) return
lastTouch = SystemClock.uptimeMillis()
revealed = true
}
/** Any touch keeps a revealed bar up, so it doesn't slide away under a finger reaching for it. */
fun touch() {
if (revealed) lastTouch = SystemClock.uptimeMillis()
}
/** Called on a timer; hides the bar again after [REVEAL_MS] without a touch. */
fun tick() {
if (revealed && SystemClock.uptimeMillis() - lastTouch >= REVEAL_MS) revealed = false
}
private const val REVEAL_MS = 4_000L
}
@@ -123,6 +123,7 @@ fun CarUi() {
while (true) { while (true) {
delay(250) delay(250)
controlsWake.tick() controlsWake.tick()
CarFullscreen.tick()
} }
} }
// The knob counts as a touch: the map's buttons can't be found while they're faded out. // The knob counts as a touch: the map's buttons can't be found while they're faded out.
@@ -193,10 +194,25 @@ fun CarUi() {
.fillMaxSize() .fillMaxSize()
// Watches every touch without taking any: first in line, consumes nothing. // Watches every touch without taking any: first in line, consumes nothing.
.pointerInput(controlsWake) { .pointerInput(controlsWake) {
// In full screen, a swipe that starts at the top or bottom edge brings the
// nav bar back. Only from the very edge, so scrolling a page still scrolls.
val edge = 32.dp.toPx()
val slop = 24.dp.toPx()
var startY = -1f
awaitPointerEventScope { awaitPointerEventScope {
while (true) { while (true) {
awaitPointerEvent(PointerEventPass.Initial) val event = awaitPointerEvent(PointerEventPass.Initial)
controlsWake.touch() controlsWake.touch()
CarFullscreen.touch()
val change = event.changes.firstOrNull() ?: continue
if (!CarFullscreen.active) continue
if (change.pressed && !change.previousPressed) {
val y = change.position.y
startY = if (y < edge || y > size.height - edge) y else -1f
} else if (startY >= 0f && change.pressed && kotlin.math.abs(change.position.y - startY) > slop) {
CarFullscreen.reveal()
startY = -1f
}
} }
} }
}, },
@@ -227,6 +243,8 @@ fun CarUi() {
LaunchedEffect(barMayHide) { if (barMayHide) controlsWake.touch() } LaunchedEffect(barMayHide) { if (barMayHide) controlsWake.touch() }
val barShown = !keyboardVisible && (!barMayHide || controlsWake.navBarAwake) val barShown = !keyboardVisible && (!barMayHide || controlsWake.navBarAwake)
val homeInset = if (floatingBar && barShown) NAV_BAR_HEIGHT else 0.dp val homeInset = if (floatingBar && barShown) NAV_BAR_HEIGHT else 0.dp
// Only the screen that asked: going Home or anywhere else ends it.
val fullscreen = CarFullscreen.active && screen != CarScreen.Home
Box(Modifier.fillMaxSize().padding(pad)) { Box(Modifier.fillMaxSize().padding(pad)) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
if (!safetyWarningSeen) { if (!safetyWarningSeen) {
@@ -240,13 +258,28 @@ fun CarUi() {
// would tear down the map's surface, and coming back would then show black // would tear down the map's surface, and coming back would then show black
// while the nav app redrew on a new one. // while the nav app redrew on a new one.
CompositionLocalProvider(LocalNavBarInset provides homeInset) { CarHome() } CompositionLocalProvider(LocalNavBarInset provides homeInset) { CarHome() }
}
// The keyboard takes the nav bar's row instead of squeezing in above it -
// nothing on the nav bar is useful mid-type, and the screen is too small to
// show both.
if (!floatingBar && !keyboardVisible) CarNavBar(navigator)
}
// Every other screen, and everything that pops up, in a layer over Home rather
// than inside its area: full screen grows this layer over the nav bar's row while
// Home, and the map's surface in it, keeps its size.
if (safetyWarningSeen) {
Box(
Modifier
.fillMaxSize()
.padding(bottom = if (!floatingBar && !keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp),
) {
if (screen != CarScreen.Home) { if (screen != CarScreen.Home) {
Box( Box(
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background) .background(MaterialTheme.colorScheme.background)
// The floating bar never hides here, so keep the screen clear of it. // The floating bar never hides here, so keep the screen clear of it.
.padding(bottom = if (floatingBar && !keyboardVisible) NAV_BAR_HEIGHT else 0.dp) .padding(bottom = if (floatingBar && !keyboardVisible && !fullscreen) NAV_BAR_HEIGHT else 0.dp)
// Keeps touches that miss this screen's controls off the map. // Keeps touches that miss this screen's controls off the map.
.pointerInput(Unit) { detectTapGestures { } }, .pointerInput(Unit) { detectTapGestures { } },
) { ) {
@@ -285,19 +318,29 @@ fun CarUi() {
// whatever the screen drew there. // whatever the screen drew there.
DebugBuildBadge(Modifier.align(Alignment.BottomEnd).padding(bottom = homeInset)) DebugBuildBadge(Modifier.align(Alignment.BottomEnd).padding(bottom = homeInset))
} }
if (floatingBar) { if (floatingBar || fullscreen) {
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
barShown, if (fullscreen) CarFullscreen.revealed else barShown,
Modifier.align(Alignment.BottomCenter), Modifier.align(Alignment.BottomCenter),
enter = androidx.compose.animation.slideInVertically { it }, enter = androidx.compose.animation.slideInVertically { it },
exit = androidx.compose.animation.slideOutVertically { it }, exit = androidx.compose.animation.slideOutVertically { it },
) { CarNavBar(navigator) } ) { CarNavBar(navigator) }
} }
if (fullscreen) {
androidx.compose.animation.AnimatedVisibility(
CarFullscreen.revealed,
Modifier.align(Alignment.TopCenter).padding(top = 12.dp),
enter = androidx.compose.animation.slideInVertically { -it },
exit = androidx.compose.animation.slideOutVertically { -it },
) {
GsButton(onClick = CarFullscreen::exit, modifier = Modifier.height(48.dp)) {
Icon(MediaIcons.FullscreenExit, contentDescription = null, modifier = Modifier.size(22.dp))
Spacer(Modifier.width(8.dp))
Text("Exit full screen", style = MaterialTheme.typography.titleMedium)
}
}
}
} }
// The keyboard takes the nav bar's row instead of squeezing in above it -
// nothing on the nav bar is useful mid-type, and the screen is too small to
// show both.
if (!floatingBar && !keyboardVisible) CarNavBar(navigator)
} }
// Over everything, nav bar included: a long press on an app, anywhere. // Over everything, nav bar included: a long press on an app, anywhere.
AppMenu(navigator, rememberRunningApps()) AppMenu(navigator, rememberRunningApps())
@@ -688,6 +688,8 @@ internal object MediaIcons {
val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z") val RepeatOne = icon("RepeatOne", "M7,7h10v3l4,-4 -4,-4v3L5,5v6h2L7,7zM17,17L7,17v-3l-4,4 4,4v-3h12v-6h-2v4zM13,15L13,9h-1l-2,1v1h1.5v4L13,15z")
/** A screen with a filled right-hand panel: "put the player beside the map". */ /** A screen with a filled right-hand panel: "put the player beside the map". */
val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd) val DockRight = icon("DockRight", "M2,4h20v16H2zM4,6h10v12H4z", PathFillType.EvenOdd)
val Fullscreen = icon("Fullscreen", "M7,14L5,14v5h5v-2L7,17v-3zM5,10h2L7,7h3L10,5L5,5v5zM17,17h-3v2h5v-5h-2v3zM14,5v2h3v3h2L19,5h-5z")
val FullscreenExit = icon("FullscreenExit", "M5,16h3v3h2v-5L5,14v2zM8,8L5,8v2h5L10,5L8,5v3zM14,19h2v-3h3v-2h-5v5zM16,8L16,5h-2v5h5L19,8h-3z")
private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder( private fun icon(name: String, path: String, fillType: PathFillType = PathFillType.NonZero) = ImageVector.Builder(
name, 24.dp, 24.dp, 24f, 24f, name, 24.dp, 24.dp, 24f, 24f,
@@ -2,7 +2,14 @@ package app.seb3thehacker.gearslip.car
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.view.View
import android.view.ViewGroup
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.widget.FrameLayout
import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.LaunchedEffect
import app.seb3thehacker.gearslip.car.theme.GsIconButton
import android.webkit.WebResourceError import android.webkit.WebResourceError
import android.webkit.WebResourceRequest import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse import android.webkit.WebResourceResponse
@@ -43,11 +50,30 @@ fun WebApp(content: String) {
val ring = String.format("#%06X", MaterialTheme.colorScheme.primary.toArgb() and 0xFFFFFF) val ring = String.format("#%06X", MaterialTheme.colorScheme.primary.toArgb() and 0xFFFFFF)
var web by remember { mutableStateOf<WebView?>(null) } var web by remember { mutableStateOf<WebView?>(null) }
var fieldText by remember { mutableStateOf<String?>(null) } var fieldText by remember { mutableStateOf<String?>(null) }
// A page's own full screen, such as a video's: the view to show, and how to hand it back.
var pageFullscreen by remember { mutableStateOf<Pair<View, WebChromeClient.CustomViewCallback>?>(null) }
// Whether the video asked for full screen, rather than the driver from the header: ending
// the video's then leaves full screen too, instead of stranding them in it.
var videoMadeFullscreen by remember { mutableStateOf(false) }
val fullscreen = CarFullscreen.active
// Leaving the browser ends full screen, whoever started it.
DisposableEffect(Unit) { onDispose { CarFullscreen.exit() } }
// The way out of full screen also ends the page's.
LaunchedEffect(fullscreen) {
if (!fullscreen) pageFullscreen?.let { (_, callback) ->
pageFullscreen = null
callback.onCustomViewHidden()
}
}
DisposableEffect(web) { DisposableEffect(web) {
navigator.backInterceptor = { navigator.backInterceptor = {
val view = web val view = web
if (view != null && view.canGoBack()) { if (CarFullscreen.active) {
CarFullscreen.exit()
true
} else if (view != null && view.canGoBack()) {
view.goBack() view.goBack()
true true
} else { } else {
@@ -59,9 +85,14 @@ fun WebApp(content: String) {
Column(Modifier.fillMaxSize()) { Column(Modifier.fillMaxSize()) {
// Back steps through the page's history first (the interceptor above), then leaves. // Back steps through the page's history first (the interceptor above), then leaves.
ScreenHeader("Browser", Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) if (!fullscreen) {
ScreenHeader("Browser", Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
GsIconButton(MediaIcons.Fullscreen, "Full screen", CarFullscreen::enter)
}
}
Box(Modifier.weight(1f).fillMaxSize()) {
AndroidView( AndroidView(
modifier = Modifier.weight(1f).fillMaxSize(), modifier = Modifier.fillMaxSize(),
factory = { context -> factory = { context ->
WebView(context).apply { WebView(context).apply {
settings.javaScriptEnabled = true settings.javaScriptEnabled = true
@@ -69,6 +100,19 @@ fun WebApp(content: String) {
settings.loadWithOverviewMode = true settings.loadWithOverviewMode = true
settings.useWideViewPort = true settings.useWideViewPort = true
webViewClient = LoggingClient(ring) webViewClient = LoggingClient(ring)
webChromeClient = object : WebChromeClient() {
override fun onShowCustomView(view: View, callback: CustomViewCallback) {
videoMadeFullscreen = !CarFullscreen.active
pageFullscreen = view to callback
CarFullscreen.enter()
}
override fun onHideCustomView() {
if (pageFullscreen == null) return
pageFullscreen = null
if (videoMadeFullscreen) CarFullscreen.exit()
}
}
addJavascriptInterface( addJavascriptInterface(
FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }), FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }),
"GearslipField", "GearslipField",
@@ -85,6 +129,22 @@ fun WebApp(content: String) {
update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside }, update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside },
onRelease = { it.destroy() }, onRelease = { it.destroy() },
) )
pageFullscreen?.let { (view, _) ->
androidx.compose.runtime.key(view) {
AndroidView(
factory = { context ->
FrameLayout(context).apply {
setBackgroundColor(android.graphics.Color.BLACK)
(view.parent as? ViewGroup)?.removeView(view)
addView(view, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
}
},
modifier = Modifier.fillMaxSize(),
onRelease = { it.removeAllViews() },
)
}
}
}
fieldText?.let { text -> fieldText?.let { text ->
CarKeyboard( CarKeyboard(
text = text, text = text,