feature: embed web pages inside the app using <iframe> for web platforms

This commit is contained in:
Antoine Jaury
2026-09-30 11:18:54 +02:00
parent fe0ed2391b
commit 880aba06a2
6 changed files with 81 additions and 40 deletions
+2
View File
@@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- show on boarding as a modal on large screens - show on boarding as a modal on large screens
- limit player controller size - limit player controller size
- browse the web pages inside the app on web, instead of only linking to them
- embed web pages inside the app on web
### Fixed ### Fixed
+9
View File
@@ -14,6 +14,15 @@ kotlin {
implementation(libs.compose.uiToolingPreview) implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.webkit) implementation(libs.androidx.webkit)
} }
// webMain is the js + wasmJs intermediate source set from the default hierarchy template.
webMain.dependencies {
// DOM access (document, navigator) to embed the page in an <iframe>
implementation(libs.kotlinx.browser)
}
wasmJsMain {
// HTMLIFrameElement interop on Wasm requires this opt-in.
languageSettings.optIn("kotlin.js.ExperimentalWasmJsInterop")
}
commonMain.dependencies { commonMain.dependencies {
// Core modules // Core modules
implementation(projects.core.logging) implementation(projects.core.logging)
@@ -4,11 +4,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
/** /**
* Embeds a live web page. Platforms with a native web view (Android, iOS) render the page inline, * Embeds a live web page. Android and iOS use their native web view, the web uses an `<iframe>`
* honouring [darkTheme] so the page matches the app's light/dark appearance; the others fall back to * overlaying the Compose canvas, and all three honour [darkTheme] so the page matches the app's
* [WebViewUnavailable], which offers to open the page in the system browser. * light/dark appearance — as far as the page itself allows. Desktop, which has no web view to
* embed, falls back to [WebViewUnavailable] and offers to open the page in the system browser.
* *
* When [url] cannot be reached, the native web views swap in [offlineHtml] — a bundled offline * When [url] cannot be reached, the embedded views swap in [offlineHtml] — a bundled offline
* snapshot — so the page still shows content. When it is `null` no fallback is loaded. * snapshot — so the page still shows content. When it is `null` no fallback is loaded.
* *
* [onLoadingStateChange] reports whether a page is currently loading, so the caller can show a * [onLoadingStateChange] reports whether a page is currently loading, so the caller can show a
@@ -1,18 +0,0 @@
package bzh.ajaury.chombev.web.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,
) {
// The Compose canvas can't host an iframe cleanly; nothing loads, so report done and open in browser.
LaunchedEffect(Unit) { onLoadingStateChange(false) }
WebViewUnavailable(url = url, modifier = modifier)
}
@@ -1,18 +0,0 @@
package bzh.ajaury.chombev.web.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,
) {
// The Compose canvas can't host an iframe cleanly; nothing loads, so report done and open in browser.
LaunchedEffect(Unit) { onLoadingStateChange(false) }
WebViewUnavailable(url = url, modifier = modifier)
}
@@ -0,0 +1,65 @@
package bzh.ajaury.chombev.web.ui
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.HtmlElementView
import kotlinx.browser.document
import kotlinx.browser.window
import org.w3c.dom.HTMLIFrameElement
/**
* Scripts, forms and target=_blank links keep the embedded site usable; top-level navigation is
* withheld, so a framed page cannot replace the app with itself.
*/
private const val IFRAME_SANDBOX =
"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
/**
* Embeds the page in an `<iframe>`. [HtmlElementView] overlays the element on the Compose canvas and
* keeps it aligned with this composable's bounds, so the page is browsed inside the app rather than
* in a separate tab.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
actual fun PlatformWebView(
url: String,
offlineHtml: String?,
darkTheme: Boolean,
onLoadingStateChange: (Boolean) -> Unit,
modifier: Modifier,
) {
// The element outlives recompositions, so its load listener has to read the latest callback.
val currentOnLoadingStateChange by rememberUpdatedState(onLoadingStateChange)
HtmlElementView(
modifier = modifier,
factory = {
(document.createElement("iframe") as HTMLIFrameElement).apply {
setAttribute("sandbox", IFRAME_SANDBOX)
setAttribute("referrerpolicy", "no-referrer")
style.border = "none"
style.width = "100%"
style.height = "100%"
addEventListener("load") { currentOnLoadingStateChange(false) }
}
},
// Re-applied on recomposition, but only the theme changes without reloading the page: `src`
// is left alone unless it actually differs.
update = { iframe ->
// Propagating a colour scheme is all a cross-origin frame allows; a page with
// `prefers-color-scheme` styles then matches the app.
iframe.style.setProperty("color-scheme", if (darkTheme) "dark" else "light")
if (offlineHtml != null && !window.navigator.onLine) {
// The snapshots use absolute URLs, so they need no base URL to render correctly.
if (iframe.srcdoc != offlineHtml) iframe.srcdoc = offlineHtml
} else if (iframe.src != url) {
currentOnLoadingStateChange(true)
iframe.src = url
}
},
)
}