diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarFullscreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarFullscreen.kt new file mode 100644 index 0000000..e9d0924 --- /dev/null +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarFullscreen.kt @@ -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 +} diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt index 8f9638b..496cf4a 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/CarUi.kt @@ -123,6 +123,7 @@ fun CarUi() { while (true) { delay(250) controlsWake.tick() + CarFullscreen.tick() } } // 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() // Watches every touch without taking any: first in line, consumes nothing. .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 { while (true) { - awaitPointerEvent(PointerEventPass.Initial) + val event = awaitPointerEvent(PointerEventPass.Initial) 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() } val barShown = !keyboardVisible && (!barMayHide || controlsWake.navBarAwake) 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)) { Column(Modifier.fillMaxSize()) { if (!safetyWarningSeen) { @@ -240,13 +258,28 @@ fun CarUi() { // would tear down the map's surface, and coming back would then show black // while the nav app redrew on a new one. 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) { Box( Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) // 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. .pointerInput(Unit) { detectTapGestures { } }, ) { @@ -285,19 +318,29 @@ fun CarUi() { // whatever the screen drew there. DebugBuildBadge(Modifier.align(Alignment.BottomEnd).padding(bottom = homeInset)) } - if (floatingBar) { + if (floatingBar || fullscreen) { androidx.compose.animation.AnimatedVisibility( - barShown, + if (fullscreen) CarFullscreen.revealed else barShown, Modifier.align(Alignment.BottomCenter), enter = androidx.compose.animation.slideInVertically { it }, exit = androidx.compose.animation.slideOutVertically { it }, ) { 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. AppMenu(navigator, rememberRunningApps()) diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt index 13cf66e..c3fffa5 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/MediaScreen.kt @@ -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") /** A screen with a filled right-hand panel: "put the player beside the map". */ 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( name, 24.dp, 24.dp, 24f, 24f, diff --git a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt index 54cbd47..4c87e30 100644 --- a/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt +++ b/gearslip/src/main/java/app/seb3thehacker/gearslip/car/WebApp.kt @@ -2,7 +2,14 @@ package app.seb3thehacker.gearslip.car import android.os.Handler import android.os.Looper +import android.view.View +import android.view.ViewGroup 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.WebResourceRequest import android.webkit.WebResourceResponse @@ -43,11 +50,30 @@ fun WebApp(content: String) { val ring = String.format("#%06X", MaterialTheme.colorScheme.primary.toArgb() and 0xFFFFFF) var web by remember { mutableStateOf(null) } var fieldText by remember { mutableStateOf(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?>(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) { navigator.backInterceptor = { val view = web - if (view != null && view.canGoBack()) { + if (CarFullscreen.active) { + CarFullscreen.exit() + true + } else if (view != null && view.canGoBack()) { view.goBack() true } else { @@ -59,9 +85,14 @@ fun WebApp(content: String) { Column(Modifier.fillMaxSize()) { // 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( - modifier = Modifier.weight(1f).fillMaxSize(), + modifier = Modifier.fillMaxSize(), factory = { context -> WebView(context).apply { settings.javaScriptEnabled = true @@ -69,6 +100,19 @@ fun WebApp(content: String) { settings.loadWithOverviewMode = true settings.useWideViewPort = true 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( FieldFocusBridge(onFocus = { fieldText = it }, onBlur = { fieldText = null }), "GearslipField", @@ -85,6 +129,22 @@ fun WebApp(content: String) { update = { it.settings.isAlgorithmicDarkeningAllowed = darkOutside }, 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 -> CarKeyboard( text = text,