Compare commits
4 Commits
d0ca53501c
...
f636c5d505
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f636c5d505 | ||
|
|
0f43870e6c | ||
|
|
d1fbcd9b0a | ||
|
|
b5a9deb815 |
@ -20,6 +20,12 @@ 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,6 +18,7 @@ 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
|
||||||
@ -28,8 +29,11 @@ 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
|
||||||
@ -41,8 +45,10 @@ 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
|
||||||
@ -51,7 +57,67 @@ 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,
|
||||||
@ -63,7 +129,12 @@ 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) }
|
||||||
|
|
||||||
BackHandler(enabled = webView?.canGoBack() == true) {
|
// A node app that refused iframing, opened in a local WebView overlay.
|
||||||
|
// 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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,20 +203,6 @@ 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,
|
||||||
@ -159,19 +216,8 @@ 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
|
||||||
@ -179,18 +225,35 @@ 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 { openExternalUrl(url) }
|
webViewRef.post { routeOutbound(url) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@android.webkit.JavascriptInterface
|
||||||
|
fun openInApp(url: String) {
|
||||||
|
webViewRef.post { inAppUrl = url }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ArchipelagoNative",
|
"ArchipelagoNative",
|
||||||
@ -252,10 +315,10 @@ fun WebViewScreen(
|
|||||||
request: WebResourceRequest?,
|
request: WebResourceRequest?,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val url = request?.url?.toString() ?: return false
|
val url = request?.url?.toString() ?: return false
|
||||||
// Keep navigation within the Archipelago server
|
// Keep kiosk navigation (same origin incl. port) in place
|
||||||
if (url.startsWith(serverUrl)) return false
|
if (url.startsWith(serverUrl)) return false
|
||||||
// Open external URLs in the system browser
|
// Same node (other port) → in-app; external → browser
|
||||||
openExternalUrl(url)
|
routeOutbound(url)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -265,7 +328,9 @@ fun WebViewScreen(
|
|||||||
loadProgress = newProgress
|
loadProgress = newProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle window.open() — open in system browser
|
// window.open() — e.g. the kiosk's "Open in new tab"
|
||||||
|
// 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,
|
||||||
@ -283,12 +348,12 @@ fun WebViewScreen(
|
|||||||
request: WebResourceRequest?,
|
request: WebResourceRequest?,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val url = request?.url?.toString() ?: return true
|
val url = request?.url?.toString() ?: return true
|
||||||
openExternalUrl(url)
|
routeOutbound(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) openExternalUrl(url)
|
if (url != null) routeOutbound(url)
|
||||||
view?.stopLoading()
|
view?.stopLoading()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -350,6 +415,140 @@ 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,4 +21,6 @@
|
|||||||
<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,9 +304,17 @@ function refreshIframe() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openInNewTab() {
|
function openInNewTab() {
|
||||||
if (store.url) {
|
if (!store.url) return
|
||||||
window.open(store.url, '_blank', 'noopener,noreferrer')
|
// Inside the Archipelago companion app, open the app in the in-app WebView
|
||||||
|
// 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,25 +1,37 @@
|
|||||||
<template>
|
<template>
|
||||||
<!-- Offline Banner -->
|
<Teleport to="body">
|
||||||
<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">
|
<!-- Offline Banner -->
|
||||||
<div class="flex items-center gap-2 text-yellow-200">
|
<Transition name="conn-banner">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div
|
||||||
<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" />
|
v-if="isOffline && !store.isReconnecting && store.isAuthenticated"
|
||||||
</svg>
|
class="conn-banner-overlay"
|
||||||
<span class="font-medium">
|
>
|
||||||
{{ isRestarting ? 'Server is restarting...' : isShuttingDown ? 'Server is shutting down...' : 'Connection lost' }}
|
<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">
|
||||||
</span>
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</div>
|
<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>
|
||||||
|
<span class="font-medium">
|
||||||
|
{{ isRestarting ? 'Server is restarting...' : isShuttingDown ? 'Server is shutting down...' : 'Connection lost' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
<!-- Reconnecting Banner -->
|
<!-- Reconnecting 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">
|
<Transition name="conn-banner">
|
||||||
<div class="flex items-center gap-2 text-blue-200">
|
<div
|
||||||
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
v-if="store.isReconnecting && store.isAuthenticated"
|
||||||
<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" />
|
class="conn-banner-overlay"
|
||||||
</svg>
|
>
|
||||||
<span class="font-medium">Reconnecting...</span>
|
<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">
|
||||||
</div>
|
<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" />
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium">Reconnecting...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
@ -32,3 +44,34 @@ 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