Compare commits
No commits in common. "f636c5d5051c7317538a6c18b952d8c592e6ab60" and "d0ca53501cc460b488dd2dda9e75f7584218e68d" have entirely different histories.
f636c5d505
...
d0ca53501c
@ -20,12 +20,6 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
|
||||||
// Separate app ID so a debug/test build installs alongside the
|
|
||||||
// release app instead of colliding on signature.
|
|
||||||
applicationIdSuffix = ".debug"
|
|
||||||
versionNameSuffix = "-debug"
|
|
||||||
}
|
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
isShrinkResources = true
|
isShrinkResources = true
|
||||||
|
|||||||
@ -18,7 +18,6 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
@ -29,11 +28,8 @@ import androidx.compose.foundation.layout.safeDrawing
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Close
|
|
||||||
import androidx.compose.material.icons.filled.CloudOff
|
import androidx.compose.material.icons.filled.CloudOff
|
||||||
import androidx.compose.material.icons.filled.OpenInBrowser
|
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.LinearProgressIndicator
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@ -45,10 +41,8 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import com.archipelago.app.R
|
import com.archipelago.app.R
|
||||||
@ -57,67 +51,7 @@ import com.archipelago.app.ui.theme.SurfaceBlack
|
|||||||
import com.archipelago.app.ui.theme.TextMuted
|
import com.archipelago.app.ui.theme.TextMuted
|
||||||
import com.archipelago.app.ui.theme.TextPrimary
|
import com.archipelago.app.ui.theme.TextPrimary
|
||||||
|
|
||||||
/** Open a URL in the phone's default browser (genuinely external links). */
|
|
||||||
private fun openExternalUrl(context: android.content.Context, url: String) {
|
|
||||||
try {
|
|
||||||
val intent = android.content.Intent(
|
|
||||||
android.content.Intent.ACTION_VIEW,
|
|
||||||
android.net.Uri.parse(url),
|
|
||||||
).apply {
|
|
||||||
// Required when launching from a non-Activity/binder thread
|
|
||||||
// (the JS bridge below can run off the UI thread).
|
|
||||||
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
context.startActivity(intent)
|
|
||||||
} catch (_: Exception) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when [url] points at the same host as the connected Archipelago node
|
|
||||||
* (ignoring port). Such URLs are node apps — e.g. one that can't be iframed —
|
|
||||||
* and should stay inside the app rather than bouncing out to the browser. */
|
|
||||||
private fun isSameHost(url: String, base: String): Boolean {
|
|
||||||
return try {
|
|
||||||
val a = android.net.Uri.parse(url).host ?: return false
|
|
||||||
val b = android.net.Uri.parse(base).host ?: return false
|
|
||||||
a.equals(b, ignoreCase = true)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Apply the WebView settings shared by the kiosk view and the in-app browser.
|
|
||||||
* These are tuned for SPA performance and parity with the mobile browser;
|
|
||||||
* none of them alter how a page renders visually. */
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
private fun WebView.applyArchipelagoSettings() {
|
|
||||||
// Pre-rasterize just outside the viewport so flinging the kiosk/app doesn't
|
|
||||||
// show blank checkerboarding — the single biggest scroll-smoothness win and
|
|
||||||
// a major part of the "feels slower than the browser" gap. (API 23+)
|
|
||||||
settings.setOffscreenPreRaster(true)
|
|
||||||
|
|
||||||
settings.apply {
|
|
||||||
javaScriptEnabled = true
|
|
||||||
domStorageEnabled = true
|
|
||||||
databaseEnabled = true
|
|
||||||
mediaPlaybackRequiresUserGesture = false
|
|
||||||
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
|
||||||
useWideViewPort = true
|
|
||||||
loadWithOverviewMode = true
|
|
||||||
setSupportZoom(false)
|
|
||||||
builtInZoomControls = false
|
|
||||||
cacheMode = WebSettings.LOAD_DEFAULT
|
|
||||||
allowContentAccess = true
|
|
||||||
allowFileAccess = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// chrome://inspect profiling on debuggable builds only — lets us measure the
|
|
||||||
// real in-page bottleneck rather than guess. No effect on release builds.
|
|
||||||
val debuggable = 0 != (context.applicationInfo.flags and
|
|
||||||
android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE)
|
|
||||||
if (debuggable) WebView.setWebContentsDebuggingEnabled(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("SetJavaScriptEnabled", "ClickableViewAccessibility")
|
|
||||||
@Composable
|
@Composable
|
||||||
fun WebViewScreen(
|
fun WebViewScreen(
|
||||||
serverUrl: String,
|
serverUrl: String,
|
||||||
@ -129,12 +63,7 @@ fun WebViewScreen(
|
|||||||
var hasError by remember { mutableStateOf(false) }
|
var hasError by remember { mutableStateOf(false) }
|
||||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||||
|
|
||||||
// A node app that refused iframing, opened in a local WebView overlay.
|
BackHandler(enabled = webView?.canGoBack() == true) {
|
||||||
// null = no overlay. The kiosk WebView underneath stays alive (and warm)
|
|
||||||
// while this is shown, so closing it returns instantly with no reload.
|
|
||||||
var inAppUrl by remember { mutableStateOf<String?>(null) }
|
|
||||||
|
|
||||||
BackHandler(enabled = inAppUrl == null && webView?.canGoBack() == true) {
|
|
||||||
webView?.goBack()
|
webView?.goBack()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -203,6 +132,20 @@ fun WebViewScreen(
|
|||||||
AndroidView(
|
AndroidView(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
factory = { context ->
|
factory = { context ->
|
||||||
|
fun openExternalUrl(url: String) {
|
||||||
|
try {
|
||||||
|
val intent = android.content.Intent(
|
||||||
|
android.content.Intent.ACTION_VIEW,
|
||||||
|
android.net.Uri.parse(url),
|
||||||
|
).apply {
|
||||||
|
// Required when launching from a non-Activity/binder
|
||||||
|
// thread (the JS bridge below runs off the UI thread).
|
||||||
|
addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
context.startActivity(intent)
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
WebView(context).apply {
|
WebView(context).apply {
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
layoutParams = ViewGroup.LayoutParams(
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||||
@ -216,8 +159,19 @@ fun WebViewScreen(
|
|||||||
cookieManager.setAcceptCookie(true)
|
cookieManager.setAcceptCookie(true)
|
||||||
cookieManager.setAcceptThirdPartyCookies(this, true)
|
cookieManager.setAcceptThirdPartyCookies(this, true)
|
||||||
|
|
||||||
applyArchipelagoSettings()
|
|
||||||
settings.apply {
|
settings.apply {
|
||||||
|
javaScriptEnabled = true
|
||||||
|
domStorageEnabled = true
|
||||||
|
databaseEnabled = true
|
||||||
|
mediaPlaybackRequiresUserGesture = false
|
||||||
|
mixedContentMode = WebSettings.MIXED_CONTENT_COMPATIBILITY_MODE
|
||||||
|
useWideViewPort = true
|
||||||
|
loadWithOverviewMode = true
|
||||||
|
setSupportZoom(false)
|
||||||
|
builtInZoomControls = false
|
||||||
|
cacheMode = WebSettings.LOAD_DEFAULT
|
||||||
|
allowContentAccess = true
|
||||||
|
allowFileAccess = false
|
||||||
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
setSupportMultipleWindows(true) // enables onCreateWindow for window.open
|
||||||
// Let JS open windows without a synchronous user-gesture
|
// Let JS open windows without a synchronous user-gesture
|
||||||
// chain; without this, window.open() from a Vue click
|
// chain; without this, window.open() from a Vue click
|
||||||
@ -225,35 +179,18 @@ fun WebViewScreen(
|
|||||||
javaScriptCanOpenWindowsAutomatically = true
|
javaScriptCanOpenWindowsAutomatically = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deterministic bridge for "open in the phone's browser".
|
||||||
|
// The web UI calls window.ArchipelagoNative.openExternal(url)
|
||||||
|
// when present (companion app), falling back to window.open
|
||||||
|
// in a plain mobile browser. This avoids relying on the
|
||||||
|
// window.open → onCreateWindow path, which noopener/noreferrer
|
||||||
|
// can suppress in the WebView.
|
||||||
val webViewRef = this
|
val webViewRef = this
|
||||||
|
|
||||||
// Decide where an outbound URL goes:
|
|
||||||
// - same host as the node → in-app WebView overlay
|
|
||||||
// (this is the "open in browser" target for apps the
|
|
||||||
// kiosk couldn't iframe — keep the user inside the app)
|
|
||||||
// - different host → the phone's real browser
|
|
||||||
fun routeOutbound(url: String) {
|
|
||||||
if (isSameHost(url, serverUrl)) {
|
|
||||||
inAppUrl = url
|
|
||||||
} else {
|
|
||||||
openExternalUrl(context, url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// JS bridge. The web UI calls:
|
|
||||||
// window.ArchipelagoNative.openExternal(url) — host-routed
|
|
||||||
// window.ArchipelagoNative.openInApp(url) — force in-app
|
|
||||||
// Falls back to window.open in a plain mobile browser.
|
|
||||||
addJavascriptInterface(
|
addJavascriptInterface(
|
||||||
object {
|
object {
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun openExternal(url: String) {
|
fun openExternal(url: String) {
|
||||||
webViewRef.post { routeOutbound(url) }
|
webViewRef.post { openExternalUrl(url) }
|
||||||
}
|
|
||||||
|
|
||||||
@android.webkit.JavascriptInterface
|
|
||||||
fun openInApp(url: String) {
|
|
||||||
webViewRef.post { inAppUrl = url }
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ArchipelagoNative",
|
"ArchipelagoNative",
|
||||||
@ -315,10 +252,10 @@ fun WebViewScreen(
|
|||||||
request: WebResourceRequest?,
|
request: WebResourceRequest?,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val url = request?.url?.toString() ?: return false
|
val url = request?.url?.toString() ?: return false
|
||||||
// Keep kiosk navigation (same origin incl. port) in place
|
// Keep navigation within the Archipelago server
|
||||||
if (url.startsWith(serverUrl)) return false
|
if (url.startsWith(serverUrl)) return false
|
||||||
// Same node (other port) → in-app; external → browser
|
// Open external URLs in the system browser
|
||||||
routeOutbound(url)
|
openExternalUrl(url)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -328,9 +265,7 @@ fun WebViewScreen(
|
|||||||
loadProgress = newProgress
|
loadProgress = newProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
// window.open() — e.g. the kiosk's "Open in new tab"
|
// Handle window.open() — open in system browser
|
||||||
// for an app that can't be iframed. Capture the target
|
|
||||||
// URL via a throwaway WebView and route it ourselves.
|
|
||||||
override fun onCreateWindow(
|
override fun onCreateWindow(
|
||||||
view: WebView?,
|
view: WebView?,
|
||||||
isDialog: Boolean,
|
isDialog: Boolean,
|
||||||
@ -348,12 +283,12 @@ fun WebViewScreen(
|
|||||||
request: WebResourceRequest?,
|
request: WebResourceRequest?,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val url = request?.url?.toString() ?: return true
|
val url = request?.url?.toString() ?: return true
|
||||||
routeOutbound(url)
|
openExternalUrl(url)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
|
||||||
if (url != null) routeOutbound(url)
|
if (url != null) openExternalUrl(url)
|
||||||
view?.stopLoading()
|
view?.stopLoading()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -415,140 +350,6 @@ fun WebViewScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-app browser overlay for non-iframeable node apps. Rendered last
|
|
||||||
// so it sits above the kiosk WebView, which stays alive underneath.
|
|
||||||
inAppUrl?.let { target ->
|
|
||||||
InAppBrowser(
|
|
||||||
url = target,
|
|
||||||
serverUrl = serverUrl,
|
|
||||||
onClose = { inAppUrl = null },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Lightweight in-app browser used when the kiosk hands off an app that can't be
|
|
||||||
* shown in an iframe. Loads the app in a local WebView with a minimal top bar
|
|
||||||
* (close + title + escalate-to-real-browser). Same-host navigation stays here;
|
|
||||||
* any genuinely external link escapes to the phone's browser.
|
|
||||||
*/
|
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
|
||||||
@Composable
|
|
||||||
private fun InAppBrowser(
|
|
||||||
url: String,
|
|
||||||
serverUrl: String,
|
|
||||||
onClose: () -> Unit,
|
|
||||||
) {
|
|
||||||
val context = LocalContext.current
|
|
||||||
var browser by remember { mutableStateOf<WebView?>(null) }
|
|
||||||
var title by remember { mutableStateOf(android.net.Uri.parse(url).host ?: url) }
|
|
||||||
var progress by remember { mutableIntStateOf(0) }
|
|
||||||
var loading by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
// Back: walk the in-app history first, then close the overlay.
|
|
||||||
BackHandler {
|
|
||||||
val b = browser
|
|
||||||
if (b != null && b.canGoBack()) b.goBack() else onClose()
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(SurfaceBlack)
|
|
||||||
.windowInsetsPadding(WindowInsets.safeDrawing),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(48.dp)
|
|
||||||
.padding(horizontal = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
IconButton(onClick = onClose) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Close,
|
|
||||||
contentDescription = stringResource(R.string.close),
|
|
||||||
tint = TextPrimary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = TextPrimary,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
IconButton(onClick = { openExternalUrl(context, browser?.url ?: url) }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.OpenInBrowser,
|
|
||||||
contentDescription = stringResource(R.string.open_in_browser),
|
|
||||||
tint = TextMuted,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AnimatedVisibility(visible = loading, enter = fadeIn(), exit = fadeOut()) {
|
|
||||||
LinearProgressIndicator(
|
|
||||||
progress = { progress / 100f },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
color = BitcoinOrange,
|
|
||||||
trackColor = SurfaceBlack,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
AndroidView(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
factory = { ctx ->
|
|
||||||
WebView(ctx).apply {
|
|
||||||
layoutParams = ViewGroup.LayoutParams(
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
||||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
|
||||||
)
|
|
||||||
isVerticalScrollBarEnabled = false
|
|
||||||
isHorizontalScrollBarEnabled = false
|
|
||||||
|
|
||||||
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
|
|
||||||
applyArchipelagoSettings()
|
|
||||||
|
|
||||||
webChromeClient = object : WebChromeClient() {
|
|
||||||
override fun onProgressChanged(view: WebView?, newProgress: Int) {
|
|
||||||
progress = newProgress
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onReceivedTitle(view: WebView?, t: String?) {
|
|
||||||
if (!t.isNullOrBlank()) title = t
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
webViewClient = object : WebViewClient() {
|
|
||||||
override fun onPageStarted(view: WebView?, u: String?, favicon: Bitmap?) {
|
|
||||||
loading = true
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPageFinished(view: WebView?, u: String?) {
|
|
||||||
loading = false
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun shouldOverrideUrlLoading(
|
|
||||||
view: WebView?,
|
|
||||||
request: WebResourceRequest?,
|
|
||||||
): Boolean {
|
|
||||||
val u = request?.url?.toString() ?: return false
|
|
||||||
// Stay in the overlay for same-node navigation;
|
|
||||||
// hand genuinely external links to the real browser.
|
|
||||||
if (isSameHost(u, serverUrl)) return false
|
|
||||||
openExternalUrl(ctx, u)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
browser = this
|
|
||||||
loadUrl(url)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -21,6 +21,4 @@
|
|||||||
<string name="retry">Retry</string>
|
<string name="retry">Retry</string>
|
||||||
<string name="remote_input">Remote Control</string>
|
<string name="remote_input">Remote Control</string>
|
||||||
<string name="remote_input_hint">Use your phone as a keyboard and mouse for the kiosk</string>
|
<string name="remote_input_hint">Use your phone as a keyboard and mouse for the kiosk</string>
|
||||||
<string name="close">Close</string>
|
|
||||||
<string name="open_in_browser">Open in browser</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -304,17 +304,9 @@ function refreshIframe() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openInNewTab() {
|
function openInNewTab() {
|
||||||
if (!store.url) return
|
if (store.url) {
|
||||||
// Inside the Archipelago companion app, open the app in the in-app WebView
|
window.open(store.url, '_blank', 'noopener,noreferrer')
|
||||||
// instead of window.open — which the WebView suppresses for noopener popups
|
|
||||||
// (so the tap silently no-ops). The native bridge is reliable; fall back to
|
|
||||||
// window.open in a plain mobile browser.
|
|
||||||
const native = (window as any).ArchipelagoNative
|
|
||||||
if (native && typeof native.openInApp === 'function') {
|
|
||||||
native.openInApp(store.url)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
window.open(store.url, '_blank', 'noopener,noreferrer')
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function openInNewTabAndClose() {
|
function openInNewTabAndClose() {
|
||||||
|
|||||||
@ -1,37 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<!-- Offline Banner -->
|
||||||
<!-- Offline Banner -->
|
<div v-if="isOffline && !store.isReconnecting && store.isAuthenticated" class="path-option-card mx-6 mt-6 px-6 py-3 border-l-4 border-yellow-500">
|
||||||
<Transition name="conn-banner">
|
<div class="flex items-center gap-2 text-yellow-200">
|
||||||
<div
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
v-if="isOffline && !store.isReconnecting && store.isAuthenticated"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
class="conn-banner-overlay"
|
</svg>
|
||||||
>
|
<span class="font-medium">
|
||||||
<div class="path-option-card px-6 py-3 border-l-4 border-yellow-500 inline-flex items-center gap-2 text-yellow-200 shadow-2xl">
|
{{ isRestarting ? 'Server is restarting...' : isShuttingDown ? 'Server is shutting down...' : 'Connection lost' }}
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</span>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
</div>
|
||||||
</svg>
|
</div>
|
||||||
<span class="font-medium">
|
|
||||||
{{ isRestarting ? 'Server is restarting...' : isShuttingDown ? 'Server is shutting down...' : 'Connection lost' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<!-- Reconnecting Banner -->
|
<!-- Reconnecting Banner -->
|
||||||
<Transition name="conn-banner">
|
<div v-if="store.isReconnecting && store.isAuthenticated" class="path-option-card mx-6 mt-6 px-6 py-3 border-l-4 border-blue-500">
|
||||||
<div
|
<div class="flex items-center gap-2 text-blue-200">
|
||||||
v-if="store.isReconnecting && store.isAuthenticated"
|
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
class="conn-banner-overlay"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
>
|
</svg>
|
||||||
<div class="path-option-card px-6 py-3 border-l-4 border-blue-500 inline-flex items-center gap-2 text-blue-200 shadow-2xl">
|
<span class="font-medium">Reconnecting...</span>
|
||||||
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
</div>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
</div>
|
||||||
</svg>
|
|
||||||
<span class="font-medium">Reconnecting...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@ -44,34 +32,3 @@ const isOffline = computed(() => store.isOffline)
|
|||||||
const isRestarting = computed(() => store.isRestarting)
|
const isRestarting = computed(() => store.isRestarting)
|
||||||
const isShuttingDown = computed(() => store.isShuttingDown)
|
const isShuttingDown = computed(() => store.isShuttingDown)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Float the connection banners over the UI instead of occupying layout space
|
|
||||||
* (which previously pushed the whole dashboard down when reconnecting).
|
|
||||||
* Pinned top-center, clear of the status bar via the safe-area inset that the
|
|
||||||
* Android companion app injects (--safe-area-top), falling back to env(). */
|
|
||||||
.conn-banner-overlay {
|
|
||||||
position: fixed;
|
|
||||||
top: calc(1rem + var(--safe-area-top, env(safe-area-inset-top, 0px)));
|
|
||||||
left: 50%;
|
|
||||||
z-index: 60;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
max-width: calc(100% - 2rem);
|
|
||||||
pointer-events: none; /* purely informational — never intercept taps */
|
|
||||||
}
|
|
||||||
|
|
||||||
.conn-banner-enter-active,
|
|
||||||
.conn-banner-leave-active {
|
|
||||||
transition: opacity 0.25s ease, transform 0.25s ease;
|
|
||||||
}
|
|
||||||
.conn-banner-enter-from,
|
|
||||||
.conn-banner-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateX(-50%) translateY(-8px);
|
|
||||||
}
|
|
||||||
.conn-banner-enter-to,
|
|
||||||
.conn-banner-leave-from {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateX(-50%) translateY(0);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user