diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt index 069c8a8805..e986f5f778 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettings.kt @@ -54,6 +54,9 @@ data class UiSettings( val showProfileFollowersFeed: Boolean = true, val dontShowOnchainPublicWarning: Boolean = false, val suggestWorkoutsFromHealthConnect: BooleanType = BooleanType.ALWAYS, + val accentColor: AccentColorType = AccentColorType.PURPLE, + val fontFamily: FontFamilyType = FontFamilyType.SYSTEM, + val fontSize: FontSizeType = FontSizeType.NORMAL, ) enum class ThemeType( @@ -73,6 +76,68 @@ fun parseThemeType(code: Int?): ThemeType = else -> ThemeType.SYSTEM } +enum class AccentColorType( + val screenCode: Int, + val resourceId: Int, +) { + PURPLE(0, R.string.accent_color_purple), + BLUE(1, R.string.accent_color_blue), + GREEN(2, R.string.accent_color_green), + ORANGE(3, R.string.accent_color_orange), + RED(4, R.string.accent_color_red), + PINK(5, R.string.accent_color_pink), +} + +fun parseAccentColorType(screenCode: Int): AccentColorType = + when (screenCode) { + AccentColorType.PURPLE.screenCode -> AccentColorType.PURPLE + AccentColorType.BLUE.screenCode -> AccentColorType.BLUE + AccentColorType.GREEN.screenCode -> AccentColorType.GREEN + AccentColorType.ORANGE.screenCode -> AccentColorType.ORANGE + AccentColorType.RED.screenCode -> AccentColorType.RED + AccentColorType.PINK.screenCode -> AccentColorType.PINK + else -> AccentColorType.PURPLE + } + +enum class FontFamilyType( + val screenCode: Int, + val resourceId: Int, +) { + SYSTEM(0, R.string.font_family_system), + SANS_SERIF(1, R.string.font_family_sans_serif), + SERIF(2, R.string.font_family_serif), + MONOSPACE(3, R.string.font_family_monospace), +} + +fun parseFontFamilyType(screenCode: Int): FontFamilyType = + when (screenCode) { + FontFamilyType.SYSTEM.screenCode -> FontFamilyType.SYSTEM + FontFamilyType.SANS_SERIF.screenCode -> FontFamilyType.SANS_SERIF + FontFamilyType.SERIF.screenCode -> FontFamilyType.SERIF + FontFamilyType.MONOSPACE.screenCode -> FontFamilyType.MONOSPACE + else -> FontFamilyType.SYSTEM + } + +enum class FontSizeType( + val scale: Float, + val screenCode: Int, + val resourceId: Int, +) { + SMALL(0.85f, 0, R.string.font_size_small), + NORMAL(1.0f, 1, R.string.font_size_normal), + LARGE(1.15f, 2, R.string.font_size_large), + HUGE(1.3f, 3, R.string.font_size_huge), +} + +fun parseFontSizeType(screenCode: Int): FontSizeType = + when (screenCode) { + FontSizeType.SMALL.screenCode -> FontSizeType.SMALL + FontSizeType.NORMAL.screenCode -> FontSizeType.NORMAL + FontSizeType.LARGE.screenCode -> FontSizeType.LARGE + FontSizeType.HUGE.screenCode -> FontSizeType.HUGE + else -> FontSizeType.NORMAL + } + enum class ConnectivityType( val prefCode: Boolean?, val screenCode: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index 176691dfe6..d86fc45a51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -54,6 +54,9 @@ class UiSettingsFlow( val showProfileFollowersFeed: MutableStateFlow = MutableStateFlow(true), val dontShowOnchainPublicWarning: MutableStateFlow = MutableStateFlow(false), val suggestWorkoutsFromHealthConnect: MutableStateFlow = MutableStateFlow(BooleanType.ALWAYS), + val accentColor: MutableStateFlow = MutableStateFlow(AccentColorType.PURPLE), + val fontFamily: MutableStateFlow = MutableStateFlow(FontFamilyType.SYSTEM), + val fontSize: MutableStateFlow = MutableStateFlow(FontSizeType.NORMAL), ) { val listOfFlows: List> = listOf>( @@ -82,6 +85,9 @@ class UiSettingsFlow( showProfileFollowersFeed, dontShowOnchainPublicWarning, suggestWorkoutsFromHealthConnect, + accentColor, + fontFamily, + fontSize, ) // emits at every change in any of the propertyes. @@ -114,6 +120,9 @@ class UiSettingsFlow( flows[22] as Boolean, flows[23] as Boolean, flows[24] as BooleanType, + flows[25] as AccentColorType, + flows[26] as FontFamilyType, + flows[27] as FontSizeType, ) } @@ -144,6 +153,9 @@ class UiSettingsFlow( showProfileFollowersFeed.value, dontShowOnchainPublicWarning.value, suggestWorkoutsFromHealthConnect.value, + accentColor.value, + fontFamily.value, + fontSize.value, ) fun update(torSettings: UiSettings): Boolean { @@ -249,6 +261,18 @@ class UiSettingsFlow( suggestWorkoutsFromHealthConnect.tryEmit(torSettings.suggestWorkoutsFromHealthConnect) any = true } + if (accentColor.value != torSettings.accentColor) { + accentColor.tryEmit(torSettings.accentColor) + any = true + } + if (fontFamily.value != torSettings.fontFamily) { + fontFamily.tryEmit(torSettings.fontFamily) + any = true + } + if (fontSize.value != torSettings.fontSize) { + fontSize.tryEmit(torSettings.fontSize) + any = true + } return any } @@ -299,6 +323,9 @@ class UiSettingsFlow( MutableStateFlow(uiSettings.showProfileFollowersFeed), MutableStateFlow(uiSettings.dontShowOnchainPublicWarning), MutableStateFlow(uiSettings.suggestWorkoutsFromHealthConnect), + MutableStateFlow(uiSettings.accentColor), + MutableStateFlow(uiSettings.fontFamily), + MutableStateFlow(uiSettings.fontSize), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt index af4c15f520..25f5ba21cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/UISharedPreferences.kt @@ -31,9 +31,12 @@ import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.model.AccentColorType import com.vitorpamplona.amethyst.model.BooleanType import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType +import com.vitorpamplona.amethyst.model.FontFamilyType +import com.vitorpamplona.amethyst.model.FontSizeType import com.vitorpamplona.amethyst.model.ProfileGalleryType import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.UiSettings @@ -120,6 +123,9 @@ class UiSharedPreferences( val UI_SHOW_PROFILE_FOLLOWERS_FEED = booleanPreferencesKey("ui.show_profile_followers_feed") val UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING = booleanPreferencesKey("ui.dont_show_onchain_public_warning") val UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT = stringPreferencesKey("ui.suggest_workouts_from_health_connect") + val UI_ACCENT_COLOR = stringPreferencesKey("ui.accent_color") + val UI_FONT_FAMILY = stringPreferencesKey("ui.font_family") + val UI_FONT_SIZE = stringPreferencesKey("ui.font_size") suspend fun uiPreferences(context: Context): UiSettings? = try { @@ -157,6 +163,9 @@ class UiSharedPreferences( dontShowOnchainPublicWarning = preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] ?: false, suggestWorkoutsFromHealthConnect = preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT]?.let { BooleanType.valueOf(it) } ?: BooleanType.ALWAYS, + accentColor = preferences[UI_ACCENT_COLOR]?.let { AccentColorType.valueOf(it) } ?: AccentColorType.PURPLE, + fontFamily = preferences[UI_FONT_FAMILY]?.let { FontFamilyType.valueOf(it) } ?: FontFamilyType.SYSTEM, + fontSize = preferences[UI_FONT_SIZE]?.let { FontSizeType.valueOf(it) } ?: FontSizeType.NORMAL, ) } catch (e: Exception) { if (e is CancellationException) throw e @@ -206,6 +215,9 @@ class UiSharedPreferences( preferences[UI_SHOW_PROFILE_FOLLOWERS_FEED] = sharedSettings.showProfileFollowersFeed preferences[UI_DONT_SHOW_ONCHAIN_PUBLIC_WARNING] = sharedSettings.dontShowOnchainPublicWarning preferences[UI_SUGGEST_WORKOUTS_FROM_HEALTH_CONNECT] = sharedSettings.suggestWorkoutsFromHealthConnect.name + preferences[UI_ACCENT_COLOR] = sharedSettings.accentColor.name + preferences[UI_FONT_FAMILY] = sharedSettings.fontFamily.name + preferences[UI_FONT_SIZE] = sharedSettings.fontSize.name } } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt index f1fce2f1f9..79478bdc34 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt @@ -81,6 +81,16 @@ fun GetVideoController( ).onEach { state -> Log.d("PlaybackService") { "Controller instance: ${state.controller}" } + // A warm-pool ExoPlayer can be handed back still carrying a prior + // PlaybackException (e.g. a decoder-init failure from an earlier acquire). The + // re-prepare below clears it before WatchPlaybackErrors ever attaches, so this + // is the only place the stale error — and its decoder/codec cause chain — is + // observable. Logged so a "Can't play this video" blink that self-heals can be + // attributed to warm-pool reuse rather than a genuinely undecodable stream. + state.controller.playerError?.let { err -> + Log.w(ERROR_LOG_TAG) { "Controller arrived carrying error for ${mediaItem.item.mediaId}: ${err.describe()}" } + } + // The default ExoPlayer volume is 1f and the MediaSessionPool reset lambda // sets it to 0f when the player is acquired, so the controller arrives at 0f. // Read first and only push an IPC if the value actually needs to change — @@ -110,6 +120,11 @@ fun GetVideoController( val targetMediaId = mediaItem.item.mediaId val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId if (needsLoad) { + // Cold load: a fresh decoder/codec instance gets allocated here. If a + // second controller for the same URI is still alive (see liveControllers + // in PlaybackServiceClient), this prepare() is where MediaCodec.start() + // can collide and fail. + Log.d("PlaybackService") { "Cold load (setMediaItem+prepare) for $targetMediaId" } state.controller.setMediaItem(mediaItem.item) state.controller.prepare() } else if (state.controller.playbackState == Player.STATE_IDLE) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index 5df1c13240..45f72cf3fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -193,7 +193,12 @@ fun VideoView( DisplayBlurHash( blurhash, null, - contentScale, + // The placeholder bitmap is decoded at the blurhash's DCT component-grid aspect + // (e.g. a 5x5 grid -> a square bitmap), NOT the real media shape. When `ratio` is + // known the Box is already sized to the true aspect, so crop the placeholder to + // fill it. Without this, FillWidth letterboxes the square placeholder inside the + // taller portrait box — the "square blurhash on a twice-as-tall space" bug. + if (ratio != null) ContentScale.Crop else contentScale, if (ratio != null) borderModifier.aspectRatio(ratio) else borderModifier, thumbhash = thumbhash, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt index 6a8e6d7467..8141b4a80f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt @@ -30,8 +30,33 @@ import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.delay +// Debug tag for the playback-error lifecycle. Logs every appearance, clear, synthetic-stall raise +// and recovery so a transient decoder-init collision (which self-recovers on a later attempt) can +// be told apart from a genuinely undecodable stream in a field logcat. See WatchPlaybackErrors. +internal const val ERROR_LOG_TAG = "PlaybackError" + +/** + * Flattens a [PlaybackException] into a single line: error code, message, and the full nested + * cause chain (e.g. `DecoderInitializationException <- MediaCodec.CodecException`). The cause + * chain is what distinguishes "format truly unsupported" from "decoder failed to start while a + * second controller held the codec" — both surface as the same top-level renderer error with + * `format_supported=YES`. + * + * Internal (not private) so [GetVideoController] can log the same detail at controller-acquire + * time — a warm-pool player can arrive already in ERROR and get re-prepared (cleared) before + * this watcher ever attaches, so the acquire site is the only place that error is observable. + */ +internal fun PlaybackException.describe(): String { + val causeChain = + generateSequence(cause) { it.cause } + .joinToString(" <- ") { "${it::class.simpleName}: ${it.message}" } + .ifEmpty { "none" } + return "code=$errorCodeName($errorCode) msg=$message causes=[$causeChain]" +} + // How often the decode-stall watchdog samples the controller's position/buffer. private const val STALL_POLL_INTERVAL_MS = 1_000L @@ -69,10 +94,18 @@ fun WatchPlaybackErrors(controllerState: MediaControllerState) { // Prime from the controller's current state — a warm-pool player may already be in ERROR // when we attach, in which case onPlayerErrorChanged will not fire again until prepare(). errorState.value = controller.playerError + controller.playerError?.let { + Log.w(ERROR_LOG_TAG) { "Primed with existing error on ${controller.currentMediaItem?.mediaId}: ${it.describe()}" } + } val listener = object : Player.Listener { override fun onPlayerErrorChanged(error: PlaybackException?) { + if (error != null) { + Log.w(ERROR_LOG_TAG) { "Error raised on ${controller.currentMediaItem?.mediaId}: ${error.describe()}" } + } else if (errorState.value != null) { + Log.d(ERROR_LOG_TAG) { "Error cleared on ${controller.currentMediaItem?.mediaId}" } + } errorState.value = error } @@ -81,7 +114,10 @@ fun WatchPlaybackErrors(controllerState: MediaControllerState) { reason: Int, ) { // A new item on a pooled player starts fresh; drop any error from the old one. - if (errorState.value != null) errorState.value = null + if (errorState.value != null) { + Log.d(ERROR_LOG_TAG) { "Error dropped on media transition (reason=$reason) -> ${mediaItem?.mediaId}" } + errorState.value = null + } } override fun onPlaybackStateChanged(state: Int) { @@ -90,7 +126,10 @@ fun WatchPlaybackErrors(controllerState: MediaControllerState) { // STATE_BUFFERING: the synthetic decode-stall error below is raised *while* // buffering, and clearing on every buffering event would wipe it instantly. if (state == Player.STATE_READY) { - if (errorState.value != null) errorState.value = null + if (errorState.value != null) { + Log.d(ERROR_LOG_TAG) { "Recovered (STATE_READY) on ${controller.currentMediaItem?.mediaId} — clearing overlay" } + errorState.value = null + } } } } @@ -141,6 +180,10 @@ private suspend fun watchForDecodeStall( if (unproductiveSinceMs < 0) { unproductiveSinceMs = now } else if (now - unproductiveSinceMs >= STALL_TIMEOUT_MS && errorState.value == null) { + Log.w(ERROR_LOG_TAG) { + "Synthetic decode-stall after ${STALL_TIMEOUT_MS}ms fed-but-frozen " + + "(pos=$position buffered=${controller.bufferedPosition}) on ${controller.currentMediaItem?.mediaId}" + } errorState.value = PlaybackException( "Video decoding stalled with a full buffer — likely an unsupported codec", @@ -156,6 +199,7 @@ private suspend fun watchForDecodeStall( // drop the stall overlay. Real decoder errors leave the player IDLE with a frozen // playhead, so they never progress here and are left for the STATE_READY listener. if (progressed && errorState.value != null) { + Log.d(ERROR_LOG_TAG) { "Playhead progressed to $position — clearing stall overlay on ${controller.currentMediaItem?.mediaId}" } errorState.value = null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt index c3567da5b9..e38aa51c0a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt @@ -117,8 +117,20 @@ class ExoPlayerPool( if (preferredMediaId != null) { val warm = takeWarm(preferredMediaId) if (warm != null) { - Log.d("PlaybackService") { "ExoPlayerPool warm hit: $preferredMediaId" } - return warm + // A warm player can error *after* it was pooled clean — its decoder dies + // asynchronously while paused (emulator surface reclaim, codec loss). releasePlayer + // can't catch that (the error appears post-release), so it's caught here at acquire: + // never hand a stale PlaybackException to a controller. Release the dead player and + // fall through to a clean cold/fresh one — a guaranteed setMediaItem+prepare ahead. + val error = warm.playerError + if (error != null) { + Log.d("PlaybackService") { "ExoPlayerPool discarding errored warm player: $preferredMediaId (${error.errorCodeName})" } + PcmTapRegistry.unregisterPlayer(warm) + warm.release() + } else { + Log.d("PlaybackService") { "ExoPlayerPool warm hit: $preferredMediaId" } + return warm + } } } return coldPool.poll() ?: builder.build(context) @@ -148,6 +160,19 @@ class ExoPlayerPool( mutex.withLock { if (player.isReleased) return@withLock + // A player that errored out (decoder-init failure, decode error) must never be + // returned to either pool. Kept warm, it hands the stale PlaybackException straight + // back to the next acquire of the same URI — the "Can't play this video" flash traced + // to warm-pool reuse. Its failed MediaCodec instance is also suspect. Drop it so the + // pool builds a clean replacement on the next miss. + val error = player.playerError + if (error != null) { + Log.d("PlaybackService") { "ExoPlayerPool dropping errored player: ${player.currentMediaItem?.mediaId} (${error.errorCodeName})" } + PcmTapRegistry.unregisterPlayer(player) + player.release() + return@withLock + } + val mediaId = player.currentMediaItem?.mediaId if (mediaId != null && warmSlotsCap > 0) { // Warm path: keep the player paused but loaded so a quick scroll-back to the diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt index d641cd8d0c..7339bce641 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.callbackFlow import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -46,6 +47,13 @@ object PlaybackServiceClient { // video, each lingering for the 60s keep-alive afterwards. val executorService: ExecutorService = Executors.newFixedThreadPool(4) + // Number of MediaControllers currently held alive (prepared and not yet released). Two + // controllers alive for the same videoUri at once is the signature of the decoder-init + // collision that surfaces as a transient "Can't play this video": the second one's + // MediaCodec.start() fails because the first still holds a codec instance. Logged on every + // prepare/release so the overlap is visible in a field logcat. + private val liveControllers = AtomicInteger(0) + fun shutdown() { executorService.shutdown() } @@ -83,7 +91,7 @@ object PlaybackServiceClient { .setConnectionHints(bundle) .buildAsync() - Log.d("PlaybackService") { "Preparing Controller $id $videoUri" } + Log.d("PlaybackService") { "Preparing Controller $id (live=${liveControllers.incrementAndGet()}) $videoUri" } controllerFuture.addListener( { @@ -108,7 +116,7 @@ object PlaybackServiceClient { ) awaitClose { - Log.d("PlaybackService") { "Releasing Controller $id $videoUri" } + Log.d("PlaybackService") { "Releasing Controller $id (live=${liveControllers.decrementAndGet()}) $videoUri" } try { MediaController.releaseFuture(controllerFuture) } catch (e: Exception) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index c08dcb5da7..d1ad12f5d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -120,6 +120,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.DvmContentDiscoveryScr import com.vitorpamplona.amethyst.ui.screen.loggedIn.dvms.favorites.FavoriteAlgoFeedsListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabLayer import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabPreloader +import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.EmbeddedTabThemeWatcher import com.vitorpamplona.amethyst.ui.screen.loggedIn.embed.FavoriteAppManifestPreloader import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.browse.BrowseEmojiSetsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.emojipacks.display.EmojiPackScreen @@ -271,6 +272,9 @@ fun AppNavigation( EmbeddedTabLayer(bottomBarItems.favoriteIds()) // Warm every pinned tab at startup so the first tap is instant (content already local). EmbeddedTabPreloader(accountViewModel) + // Rebuild the warm surfaces in the new theme when the app's DARK/LIGHT preference flips + // (an embed WebView's theme is fixed at construction, so it can't follow a live switch). + EmbeddedTabThemeWatcher() } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 5cd99c5e0f..36ef874ded 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -451,7 +451,10 @@ fun RenderNutzapGallery( Row(Modifier.fillMaxWidth()) { Box( - modifier = WidthAuthorPictureModifier, + // Reuse the reaction galleries' icon column (55dp wide with a 5dp end + // inset) so the cashu glyph lines up with the like/boost icons above + // it, instead of sitting flush-right like the lightning ZappedIcon. + modifier = NotificationIconModifier, ) { Icon( imageVector = CustomHashTagIcons.Cashu, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt index 060d5a4be1..6d7fb4dbcd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/BrowserScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.shape.RoundedCornerShape @@ -70,10 +71,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.browser.DefaultWebClients import com.vitorpamplona.amethyst.commons.browser.OmniboxInput import com.vitorpamplona.amethyst.commons.browser.OmniboxSuggestions +import com.vitorpamplona.amethyst.commons.browser.SuggestedWebApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter @@ -83,12 +88,22 @@ import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppLauncher import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry import com.vitorpamplona.amethyst.favorites.PreloadFavoriteNostrApps +import com.vitorpamplona.amethyst.favorites.rememberNappletIconModel +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.ui.navigation.bottombars.AppBottomBar import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.favorites.favoriteAppItems +import com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets.datasource.NappletsFilterAssemblerSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.nsites.datasource.NsitesFilterAssemblerSubscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent import com.vitorpamplona.amethyst.commons.R as CommonsR /** How many of the most recent history entries the idle browser home surfaces under "Recent". */ @@ -132,7 +147,10 @@ private fun BrowserLauncher( var field by remember { mutableStateOf(TextFieldValue("")) } - // Favorites + visit history flattened into the neutral candidate shape the ranker consumes. + // Favorites + visit history + the hardcoded Discover apps, flattened into the neutral candidate shape + // the ranker consumes — so typing the omnibox finds a suggested app even before its first visit. The + // ranker dedupes by host, and favorites/history outscore a plain default, so a default that the user + // already pinned or visited collapses into that higher-ranked row instead of showing twice. val candidates = remember(apps, history) { buildList { @@ -148,19 +166,70 @@ private fun BrowserLauncher( ), ) } + DefaultWebClients.list.forEach { add(OmniboxSuggestions.Candidate(it.app.url, it.app.label, isFavorite = false)) } } } + // Visited URLs, so the suggestion list can tell a Recent result from a Discover one (and only the + // former offers "Remove from history"). + val historyUrls = remember(history) { history.mapTo(HashSet()) { it.url } } + + // Discover nsites & napplets — the NIP-5A sites and NIP-5D apps published by the people the user + // follows. Same feed + follow-list filter the dedicated nSites/nApplets screens use (set those to + // "All Follows" for a pure follows list): the subscriptions pull manifests into LocalCache while the + // Browser tab is open, and we observe the addressable store and keep the matching authors'. + NsitesFilterAssemblerSubscription(accountViewModel) + NappletsFilterAssemblerSubscription(accountViewModel) + + val nsiteNotes by remember { + Amethyst.instance.cache.observeNotes(Filter(kinds = listOf(RootSiteEvent.KIND, NamedSiteEvent.KIND))) + }.collectAsStateWithLifecycle(emptyList()) + val nappletNotes by remember { + Amethyst.instance.cache.observeNotes(Filter(kinds = listOf(RootNappletEvent.KIND, NamedNappletEvent.KIND))) + }.collectAsStateWithLifecycle(emptyList()) + + val nsiteFollows by accountViewModel.account.liveNsitesFollowLists.collectAsStateWithLifecycle() + val nsiteListName by accountViewModel.account.settings.defaultNsitesFollowList + .collectAsStateWithLifecycle() + val nappletFollows by accountViewModel.account.liveNappletsFollowLists.collectAsStateWithLifecycle() + val nappletListName by accountViewModel.account.settings.defaultNappletsFollowList + .collectAsStateWithLifecycle() + val myPubkey = accountViewModel.account.userProfile().pubkeyHex + + // Drop ones already pinned — they show under Favorites, not twice. + val favoriteCoordinates = remember(apps) { apps.filterIsInstance().mapTo(HashSet()) { it.coordinate } } + val followedNsites = + remember(nsiteNotes, nsiteFollows, nsiteListName, myPubkey, favoriteCoordinates) { + nsiteNotes.toDiscoverApps(nsiteListName == TopFilter.Mine, myPubkey, nsiteFollows::matchAuthor, favoriteCoordinates) + } + val followedNapplets = + remember(nappletNotes, nappletFollows, nappletListName, myPubkey, favoriteCoordinates) { + nappletNotes.toDiscoverApps(nappletListName == TopFilter.Mine, myPubkey, nappletFollows::matchAuthor, favoriteCoordinates) + } + // What the user actually typed, excluding any selected ghost-completion suffix (selection.min is the // caret when collapsed, or the start of the highlighted suffix when a completion is showing). val typed = field.text.take(field.selection.min.coerceIn(0, field.text.length)) - val suggestions = remember(typed, candidates) { OmniboxSuggestions.rank(typed, candidates) } + val suggestions = remember(typed, candidates) { OmniboxSuggestions.rank(typed, candidates, limit = 12) } fun open(text: String) { val target = OmniboxInput.resolve(text) ?: return FavoriteAppLauncher.launchUrl(context, target.url, target.forceTor) } + // Pin/unpin a plain web URL by its favorite id. Shared by the suggestion list and the Recent rows. + fun toggleFavorite( + url: String, + label: String, + ) { + val id = "url:$url" + if (FavoriteAppsRegistry.isFavorite(id)) { + FavoriteAppsRegistry.remove(id) + } else { + FavoriteAppsRegistry.add(FavoriteApp.WebApp(url, label.ifBlank { OmniboxInput.hostOf(url) ?: url }, System.currentTimeMillis())) + } + } + // Inline autocomplete: when the user appends a character, offer the top host as selected ghost text so // the next keystroke replaces it. On deletion or mid-string edits, leave the value untouched. fun onValueChange(new: TextFieldValue) { @@ -206,39 +275,29 @@ private fun BrowserLauncher( SuggestionGrid( suggestions = suggestions, iconKeys = iconKeys, + historyUrls = historyUrls, onOpen = { open(it.url) }, + onToggleFavorite = { toggleFavorite(it.url, it.label) }, + onRemoveFromHistory = { BrowserHistoryRegistry.remove(it) }, modifier = contentModifier, ) - apps.isEmpty() && history.isEmpty() -> - Box( - contentModifier.padding(32.dp), - contentAlignment = Alignment.Center, - ) { - Text( - stringResource(R.string.favorite_apps_empty), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else -> { val favoriteUrls = remember(apps) { apps.filterIsInstance().mapTo(HashSet()) { it.url } } + // Hardcoded starter web apps, minus any the user already pinned (those show under Favorites). + val suggested = remember(favoriteUrls) { DefaultWebClients.list.filter { it.app.url !in favoriteUrls } } BrowserHome( apps = apps, history = history, iconKeys = iconKeys, favoriteUrls = favoriteUrls, + suggested = suggested, + nsites = followedNsites, + napplets = followedNapplets, onOpenApp = { FavoriteAppLauncher.launch(context, it) }, onRemoveApp = { FavoriteAppsRegistry.remove(it.id) }, + onAddApp = { FavoriteAppsRegistry.add(it) }, onOpenUrl = { open(it) }, - onToggleRecentFavorite = { entry -> - val id = "url:" + entry.url - if (FavoriteAppsRegistry.isFavorite(id)) { - FavoriteAppsRegistry.remove(id) - } else { - FavoriteAppsRegistry.add( - FavoriteApp.WebApp(entry.url, entry.title.ifBlank { entry.host }, System.currentTimeMillis()), - ) - } - }, + onToggleRecentFavorite = { entry -> toggleFavorite(entry.url, entry.title.ifBlank { entry.host }) }, onRemoveRecent = { BrowserHistoryRegistry.remove(it) }, modifier = contentModifier, ) @@ -305,25 +364,50 @@ private fun OmniBar( } } -/** The typed-state body: ranked suggestions split into a highlighted Favorites group then Recent. */ +/** + * The typed-state body: ranked suggestions split into a highlighted Favorites group, then Recent (visited + * sites), then Discover (the hardcoded web apps the user hasn't pinned or visited yet). Every row carries + * the same 3-dot menu as the idle Recent cards (pin/unpin; plus remove-from-history for visited sites). + */ @Composable private fun SuggestionGrid( suggestions: List, iconKeys: Set, + historyUrls: Set, onOpen: (OmniboxSuggestions.Suggestion) -> Unit, + onToggleFavorite: (OmniboxSuggestions.Suggestion) -> Unit, + onRemoveFromHistory: (String) -> Unit, modifier: Modifier = Modifier, ) { val favorites = suggestions.filter { it.isFavorite } - val others = suggestions.filterNot { it.isFavorite } + val recent = suggestions.filter { !it.isFavorite && it.url in historyUrls } + val discover = suggestions.filter { !it.isFavorite && it.url !in historyUrls } + + fun LazyGridScope.section( + keyPrefix: String, + title: Int, + rows: List, + highlighted: Boolean, + ) { + if (rows.isEmpty()) return + item(key = "h-$keyPrefix") { SectionHeader(stringResource(title)) } + items(rows, key = { "$keyPrefix:" + it.url }) { suggestion -> + SuggestionRow( + suggestion = suggestion, + iconKeys = iconKeys, + highlighted = highlighted, + removableFromHistory = suggestion.url in historyUrls, + onClick = { onOpen(suggestion) }, + onToggleFavorite = { onToggleFavorite(suggestion) }, + onRemoveFromHistory = { onRemoveFromHistory(suggestion.url) }, + ) + } + } + LazyVerticalGrid(columns = GridCells.Fixed(1), modifier = modifier) { - if (favorites.isNotEmpty()) { - item(key = "h-fav") { SectionHeader(stringResource(R.string.browser_favorites)) } - items(favorites, key = { "f:" + it.url }) { SuggestionRow(it, iconKeys, highlighted = true) { onOpen(it) } } - } - if (others.isNotEmpty()) { - item(key = "h-rec") { SectionHeader(stringResource(R.string.favorite_app_recent)) } - items(others, key = { "o:" + it.url }) { SuggestionRow(it, iconKeys, highlighted = false) { onOpen(it) } } - } + section("f", R.string.browser_favorites, favorites, highlighted = true) + section("o", R.string.favorite_app_recent, recent, highlighted = false) + section("s", R.string.browser_suggested, discover, highlighted = false) } } @@ -332,15 +416,19 @@ private fun SuggestionRow( suggestion: OmniboxSuggestions.Suggestion, iconKeys: Set, highlighted: Boolean, + removableFromHistory: Boolean, onClick: () -> Unit, + onToggleFavorite: () -> Unit, + onRemoveFromHistory: () -> Unit, ) { + var menuOpen by remember { mutableStateOf(false) } Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = onClick) .background(if (highlighted) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.25f) else Color.Transparent) - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(start = 16.dp, top = 12.dp, bottom = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { SiteIcon(suggestion.host, suggestion.isFavorite, iconKeys, Modifier.size(24.dp)) @@ -363,6 +451,33 @@ private fun SuggestionRow( ) } } + Box { + IconButton(onClick = { menuOpen = true }) { + Icon(MaterialSymbols.MoreVert, contentDescription = stringResource(R.string.browser_recent_options)) + } + DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) { + DropdownMenuItem( + text = { Text(stringResource(if (suggestion.isFavorite) R.string.favorite_app_remove else R.string.favorite_app_add)) }, + leadingIcon = { + Icon(if (suggestion.isFavorite) MaterialSymbols.Star else MaterialSymbols.StarBorder, contentDescription = null) + }, + onClick = { + menuOpen = false + onToggleFavorite() + }, + ) + if (removableFromHistory) { + DropdownMenuItem( + text = { Text(stringResource(R.string.browser_recent_remove)) }, + leadingIcon = { Icon(MaterialSymbols.Delete, contentDescription = null) }, + onClick = { + menuOpen = false + onRemoveFromHistory() + }, + ) + } + } + } } } @@ -373,8 +488,12 @@ private fun BrowserHome( history: List, iconKeys: Set, favoriteUrls: Set, + suggested: List, + nsites: List, + napplets: List, onOpenApp: (FavoriteApp) -> Unit, onRemoveApp: (FavoriteApp) -> Unit, + onAddApp: (FavoriteApp) -> Unit, onOpenUrl: (String) -> Unit, onToggleRecentFavorite: (BrowserHistoryEntry) -> Unit, onRemoveRecent: (String) -> Unit, @@ -405,6 +524,180 @@ private fun BrowserHome( ) } } + if (nsites.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }, key = "h-nsite") { SectionHeader(stringResource(R.string.browser_discover_nsites)) } + items(nsites, span = { GridItemSpan(maxLineSpan) }, key = { "ns:" + it.app.coordinate }) { entry -> + NostrAppRow(entry, onClick = { onOpenApp(entry.app) }, onAddFavorite = { onAddApp(entry.app) }) + } + } + if (napplets.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }, key = "h-napp") { SectionHeader(stringResource(R.string.browser_discover_napplets)) } + items(napplets, span = { GridItemSpan(maxLineSpan) }, key = { "np:" + it.app.coordinate }) { entry -> + NostrAppRow(entry, onClick = { onOpenApp(entry.app) }, onAddFavorite = { onAddApp(entry.app) }) + } + } + if (suggested.isNotEmpty()) { + item(span = { GridItemSpan(maxLineSpan) }, key = "h-sug") { SectionHeader(stringResource(R.string.browser_suggested)) } + items(suggested, span = { GridItemSpan(maxLineSpan) }, key = { "s:" + it.app.url }) { entry -> + SuggestedRow( + entry = entry, + iconKeys = iconKeys, + onClick = { onOpenApp(entry.app) }, + onAddFavorite = { onAddApp(entry.app) }, + ) + } + } + } +} + +/** A Discover row: the app's own icon, its name, and a one-line description, with a star to pin it. */ +@Composable +private fun SuggestedRow( + entry: SuggestedWebApp, + iconKeys: Set, + onClick: () -> Unit, + onAddFavorite: () -> Unit, +) { + val iconModel = remember(entry, iconKeys) { OmniboxInput.hostOf(entry.app.url)?.let(BrowserIconRegistry::iconModelFor) } + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(start = 8.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FavoriteAppIcon( + app = entry.app, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(28.dp), + iconModel = iconModel, + ) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + entry.app.label, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + entry.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = onAddFavorite) { + Icon(MaterialSymbols.StarBorder, contentDescription = stringResource(R.string.favorite_app_add)) + } + } +} + +/** A followed nsite/napplet, resolved into its launchable [app] plus the manifest [description]. */ +private data class DiscoverNostrApp( + val app: FavoriteApp.NostrApp, + val description: String?, +) + +/** How many followed nsites/napplets each Discover section surfaces, so the launcher stays tidy. */ +private const val DISCOVER_NOSTR_LIMIT = 12 + +/** + * Keeps the [Note]s authored by the followed set (or by the user, in the "Mine" case — the shared + * matcher resolves Mine to all-follows, so it can't serve that case), drops ones already pinned, maps + * each to its launchable [DiscoverNostrApp], and caps the result. + */ +private fun List.toDiscoverApps( + mine: Boolean, + myPubkey: String, + matchAuthor: (String) -> Boolean, + excludeCoordinates: Set, +): List = + asSequence() + .filter { note -> + val author = note.event?.pubKey ?: return@filter false + if (mine) author == myPubkey else matchAuthor(author) + }.mapNotNull { it.toDiscoverNostrApp() } + .filter { it.app.coordinate !in excludeCoordinates } + .take(DISCOVER_NOSTR_LIMIT) + .toList() + +/** Resolve a cached nsite/napplet manifest note into a launchable favorite + its description, or null. */ +private fun Note.toDiscoverNostrApp(): DiscoverNostrApp? { + val event = event ?: return null + val coordinate = FavoriteAppLauncher.coordinateOf(event) + val label: String + val description: String? + when (event) { + is RootSiteEvent -> { + label = event.title()?.ifBlank { null } ?: "Website" + description = event.description() + } + is NamedSiteEvent -> { + label = event.title()?.ifBlank { null } ?: event.identifier() + description = event.description() + } + is RootNappletEvent -> { + label = event.title()?.ifBlank { null } ?: "App" + description = event.description() + } + is NamedNappletEvent -> { + label = event.title()?.ifBlank { null } ?: event.identifier() + description = event.description() + } + else -> return null + } + return DiscoverNostrApp(FavoriteApp.NostrApp(coordinate, label, 0L), description) +} + +/** A Discover row for a followed nsite/napplet: its manifest icon, name, and description, with a pin star. */ +@Composable +private fun NostrAppRow( + discover: DiscoverNostrApp, + onClick: () -> Unit, + onAddFavorite: () -> Unit, +) { + val app = discover.app + val iconModel = rememberNappletIconModel(app.coordinate) + Row( + modifier = + Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(start = 8.dp, top = 4.dp, bottom = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + FavoriteAppIcon( + app = app, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(28.dp), + iconModel = iconModel, + ) + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text( + app.label, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!discover.description.isNullOrBlank()) { + Text( + discover.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + IconButton(onClick = onAddFavorite) { + Icon(MaterialSymbols.StarBorder, contentDescription = stringResource(R.string.favorite_app_add)) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt index a3491f8831..0699770886 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/browser/WebAppScreen.kt @@ -113,8 +113,10 @@ private fun EmbeddedWebAppTab( val backgroundColor = MaterialTheme.colorScheme.background.toArgb() + // Keyed on the theme epoch too: when the app theme flips, the warm session is torn down and this + // re-acquires a freshly-themed one (the embed WebView's theme is fixed at construction). val controller = - remember(id) { + remember(id, EmbeddedTabHost.themeEpoch) { EmbeddedTabFactory.acquireWebApp(context, url, backgroundColor) } @@ -128,7 +130,7 @@ private fun EmbeddedWebAppTab( // Rebuilt only when a displayed value changes, so the tab layer isn't recomposed every frame. val chrome = - remember(currentUrl, torOn, proxyAvailable, isFavorite) { + remember(currentUrl, torOn, proxyAvailable, isFavorite, controller) { EmbeddedTabChrome( title = hostLabel(currentUrl), isSandbox = false, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt index a95a3aff56..bc0ed87804 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabHost.kt @@ -56,6 +56,15 @@ object EmbeddedTabHost { var activeId by mutableStateOf(null) private set + /** + * Bumped whenever the app's resolved DARK/LIGHT theme flips (see [rebuildAllForTheme]). The embed + * WebView's theme is locked in at construction (`nightThemedContext`), so following a theme change + * means rebuilding the surface — the favorite screens and the preloader key their acquisition on this + * so they re-acquire a freshly-themed session instead of the stale warm one. + */ + var themeEpoch by mutableStateOf(0) + private set + /** Window-space bounds of the active tab's reserved content area. */ var contentBounds by mutableStateOf(Rect.Zero) private set @@ -158,4 +167,17 @@ object EmbeddedTabHost { warm.clear() copy.forEach { it.controller.teardown() } } + + /** + * The app theme changed: tear down every warm session (their WebViews are pinned to the old theme) + * and bump [themeEpoch] so the visible screen and the preloader re-acquire freshly-themed sessions. + * Unlike [evictAll] this keeps [activeId], so the visible tab re-activates the instant its screen + * re-acquires — the user just sees the current tab reload in the new theme, not a blanked-out surface. + */ + fun rebuildAllForTheme() { + val copy = warm.toList() + warm.clear() + copy.forEach { it.controller.teardown() } + themeEpoch += 1 + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt index 2b5e3e33de..98584c6460 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabLayer.kt @@ -162,7 +162,9 @@ fun EmbeddedTabLayer(barFavoriteIds: List) { }, ) { EmbeddedTabHost.sessions.forEach { session -> - key(session.id) { + // Key on the controller too: a theme rebuild replaces the controller under the same id, and + // the new one needs a fresh SandboxedSdkView (the factory below attaches the surface once). + key(session.id, session.controller) { val active = session.id == activeId LaunchedEffect(active) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloader.kt index 312f76a25e..39ffea72e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabPreloader.kt @@ -76,7 +76,9 @@ fun EmbeddedTabPreloader(accountViewModel: AccountViewModel) { } } - LaunchedEffect(favoriteIds, backgroundColor) { + // Re-warms after a theme flip: [rebuildAllForTheme] tears down the warm sessions and bumps the epoch, + // so this sweep re-acquires them in the new theme (keying on the epoch also orders it after the teardown). + LaunchedEffect(favoriteIds, backgroundColor, EmbeddedTabHost.themeEpoch) { if (favoriteIds.isEmpty()) return@LaunchedEffect // Hydrate the per-site Tor/open-web choices BEFORE the first preload: a cold start otherwise reads // the bare Tor default and would route a site the user pinned to the open web through Tor (or stall diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.kt new file mode 100644 index 0000000000..2a22372db8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/embed/EmbeddedTabThemeWatcher.kt @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.embed + +import android.os.Build +import androidx.annotation.RequiresApi +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.model.ThemeType + +/** + * Keeps the warm embedded tabs in sync with the app's DARK/LIGHT theme. An embed WebView resolves its + * theme from the context it's built with (`nightThemedContext`), once, at construction — a runtime config + * change does NOT re-flip the renderer — so the only way a live theme switch reaches an already-running + * surface is to rebuild it. This watches the resolved theme and, on an actual flip, asks + * [EmbeddedTabHost] to tear down + re-acquire every session in the new theme. + * + * Mount once next to [EmbeddedTabLayer]/[EmbeddedTabPreloader]. Draws nothing. + */ +@RequiresApi(Build.VERSION_CODES.R) +@Composable +fun EmbeddedTabThemeWatcher() { + val theme by Amethyst.instance.uiPrefs.value.theme + .collectAsStateWithLifecycle() + // SYSTEM resolves against the device night mode, so a scheduled/auto device flip also rebuilds. + val systemDark = isSystemInDarkTheme() + val resolvedDark = + when (theme) { + ThemeType.DARK -> true + ThemeType.LIGHT -> false + ThemeType.SYSTEM -> systemDark + } + + // Holds the theme the warm surfaces were last built in; a mismatch (only after a real flip — the + // first composition seeds it equal) triggers exactly one rebuild. + val applied = remember { mutableStateOf(resolvedDark) } + LaunchedEffect(resolvedDark) { + if (applied.value != resolvedDark) { + applied.value = resolvedDark + EmbeddedTabHost.rebuildAllForTheme() + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt index bd33602d4f..9fe92ee7a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/favorites/NostrAppScreen.kt @@ -112,8 +112,9 @@ private fun EmbeddedNostrAppTab( // Matches FavoriteApp.NostrApp.id, so warm-keep membership lines up with the bottom-bar favorites. val id = "nostr:$coordinate" - // Mint the verified launch params (a fresh token per resolve); null until the event loads. - val params = remember(coordinate) { FavoriteAppLauncher.embedParams(context, coordinate) } + // Mint the verified launch params (a fresh token per resolve); null until the event loads. Re-minted + // on a theme flip (the params carry the resolved theme into the sandbox host's WebView). + val params = remember(coordinate, EmbeddedTabHost.themeEpoch) { FavoriteAppLauncher.embedParams(context, coordinate) } if (params == null) { UnavailableTab(coordinate, accountViewModel, nav) return @@ -133,7 +134,7 @@ private fun EmbeddedNostrAppTab( val isFavorite = remember(apps, coordinate) { apps.any { it.id == "nostr:$coordinate" } } val controller = - remember(id) { + remember(id, EmbeddedTabHost.themeEpoch) { EmbeddedTabFactory.acquireNostrApp(context, coordinate, params, backgroundColor) } @@ -147,7 +148,7 @@ private fun EmbeddedNostrAppTab( // Stable per app (title/coordinate/isFavorite don't change often), so the tab layer isn't recomposed every frame. val chrome = - remember(title, coordinate, isFavorite) { + remember(title, coordinate, isFavorite, controller) { EmbeddedTabChrome( title = title.ifBlank { coordinate }, isSandbox = true, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt index cd432f0cfa..c3d39717bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchScreen.kt @@ -193,7 +193,12 @@ private fun SearchBar( } } - Column(modifier = Modifier.statusBarsPadding()) { + Column( + modifier = + Modifier + .background(MaterialTheme.colorScheme.surface) + .statusBarsPadding(), + ) { SearchTextField(searchBarViewModel, Modifier) // Inline Namecoin lookup feedback for the global search field. // Mirrors the wiring in OnchainZapSendDialog: the local prefix diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt index 0d3a39e992..9a3f6839da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AppSettingsScreen.kt @@ -48,15 +48,19 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.os.LocaleListCompat import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.AccentColorType import com.vitorpamplona.amethyst.model.ConnectivityType import com.vitorpamplona.amethyst.model.FeatureSetType -import com.vitorpamplona.amethyst.model.ProfileGalleryType +import com.vitorpamplona.amethyst.model.FontFamilyType +import com.vitorpamplona.amethyst.model.FontSizeType import com.vitorpamplona.amethyst.model.ThemeType import com.vitorpamplona.amethyst.model.UiSettingsFlow +import com.vitorpamplona.amethyst.model.parseAccentColorType import com.vitorpamplona.amethyst.model.parseBooleanType import com.vitorpamplona.amethyst.model.parseConnectivityType import com.vitorpamplona.amethyst.model.parseFeatureSetType -import com.vitorpamplona.amethyst.model.parseGalleryType +import com.vitorpamplona.amethyst.model.parseFontFamilyType +import com.vitorpamplona.amethyst.model.parseFontSizeType import com.vitorpamplona.amethyst.model.parseThemeType import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TitleExplainer @@ -113,6 +117,9 @@ fun SettingsScreen(sharedPrefs: UiSettingsFlow) { ) { ShowLanguageChoice(sharedPrefs) ShowThemeChoice(sharedPrefs) + ShowAccentColorChoice(sharedPrefs) + ShowFontFamilyChoice(sharedPrefs) + ShowFontSizeChoice(sharedPrefs) ShowImagePreviewChoice(sharedPrefs) ShowVideoPlaybackChoice(sharedPrefs) AutoplayVideosChoice(sharedPrefs) @@ -120,7 +127,6 @@ fun SettingsScreen(sharedPrefs: UiSettingsFlow) { ShowProfilePictureChoice(sharedPrefs) ImmersiveScrollingChoice(sharedPrefs) FeatureSetChoice(sharedPrefs) - GalleryChoice(sharedPrefs) } } @@ -218,6 +224,74 @@ fun ShowThemeChoice(sharedPrefs: UiSettingsFlow) { } } +@Composable +fun ShowAccentColorChoice(sharedPrefs: UiSettingsFlow) { + val accentOptions = + persistentListOf( + TitleExplainer(stringRes(AccentColorType.PURPLE.resourceId)), + TitleExplainer(stringRes(AccentColorType.BLUE.resourceId)), + TitleExplainer(stringRes(AccentColorType.GREEN.resourceId)), + TitleExplainer(stringRes(AccentColorType.ORANGE.resourceId)), + TitleExplainer(stringRes(AccentColorType.RED.resourceId)), + TitleExplainer(stringRes(AccentColorType.PINK.resourceId)), + ) + + val accentIndex by sharedPrefs.accentColor.collectAsState() + + SettingsRow( + R.string.accent_color, + R.string.accent_color_description, + accentOptions, + accentIndex.screenCode, + ) { + sharedPrefs.accentColor.tryEmit(parseAccentColorType(it)) + } +} + +@Composable +fun ShowFontFamilyChoice(sharedPrefs: UiSettingsFlow) { + val fontOptions = + persistentListOf( + TitleExplainer(stringRes(FontFamilyType.SYSTEM.resourceId)), + TitleExplainer(stringRes(FontFamilyType.SANS_SERIF.resourceId)), + TitleExplainer(stringRes(FontFamilyType.SERIF.resourceId)), + TitleExplainer(stringRes(FontFamilyType.MONOSPACE.resourceId)), + ) + + val fontIndex by sharedPrefs.fontFamily.collectAsState() + + SettingsRow( + R.string.font_family, + R.string.font_family_description, + fontOptions, + fontIndex.screenCode, + ) { + sharedPrefs.fontFamily.tryEmit(parseFontFamilyType(it)) + } +} + +@Composable +fun ShowFontSizeChoice(sharedPrefs: UiSettingsFlow) { + val fontSizeOptions = + persistentListOf( + TitleExplainer(stringRes(FontSizeType.SMALL.resourceId)), + TitleExplainer(stringRes(FontSizeType.NORMAL.resourceId)), + TitleExplainer(stringRes(FontSizeType.LARGE.resourceId)), + TitleExplainer(stringRes(FontSizeType.HUGE.resourceId)), + ) + + val fontSizeIndex by sharedPrefs.fontSize.collectAsState() + + SettingsRow( + R.string.font_size, + R.string.font_size_description, + fontSizeOptions, + fontSizeIndex.screenCode, + ) { + sharedPrefs.fontSize.tryEmit(parseFontSizeType(it)) + } +} + @Composable fun ShowImagePreviewChoice(sharedPrefs: UiSettingsFlow) { val connectivityBasedOptions = @@ -363,26 +437,6 @@ fun FeatureSetChoice(sharedPrefs: UiSettingsFlow) { } } -@Composable -fun GalleryChoice(sharedPrefs: UiSettingsFlow) { - val galleryItems = - persistentListOf( - TitleExplainer(stringRes(ProfileGalleryType.CLASSIC.resourceId)), - TitleExplainer(stringRes(ProfileGalleryType.MODERN.resourceId)), - ) - - val galleryIndex by sharedPrefs.gallerySet.collectAsState() - - SettingsRow( - R.string.gallery_style, - R.string.gallery_style_description, - galleryItems, - galleryIndex.screenCode, - ) { - sharedPrefs.gallerySet.tryEmit(parseGalleryType(it)) - } -} - @Composable fun SettingsRow( name: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt index 5b6ffb4fe2..c39794a696 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ProfileUiSettingsScreen.kt @@ -43,6 +43,10 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.ProfileGalleryType +import com.vitorpamplona.amethyst.model.UiSettingsFlow +import com.vitorpamplona.amethyst.model.parseGalleryType +import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton @@ -51,6 +55,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import kotlinx.collections.immutable.persistentListOf @Preview @Composable @@ -129,11 +134,36 @@ fun ProfileUiSettingsContent(accountViewModel: AccountViewModel) { checked = showFollowers, onCheckedChange = { ui.showProfileFollowersFeed.tryEmit(it) }, ) + HorizontalDivider(modifier = Modifier.padding(horizontal = Size20dp)) + + Column(modifier = Modifier.padding(vertical = 12.dp, horizontal = Size20dp)) { + GalleryChoice(ui) + } Spacer(Modifier.height(16.dp)) } } +@Composable +fun GalleryChoice(sharedPrefs: UiSettingsFlow) { + val galleryItems = + persistentListOf( + TitleExplainer(stringRes(ProfileGalleryType.CLASSIC.resourceId)), + TitleExplainer(stringRes(ProfileGalleryType.MODERN.resourceId)), + ) + + val galleryIndex by sharedPrefs.gallerySet.collectAsStateWithLifecycle() + + SettingsRow( + R.string.gallery_style, + R.string.gallery_style_description, + galleryItems, + galleryIndex.screenCode, + ) { + sharedPrefs.gallerySet.tryEmit(parseGalleryType(it)) + } +} + @Composable private fun ProfileUiSwitchRow( title: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt index 98c40b26bc..6ca1574e84 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Color.kt @@ -36,6 +36,19 @@ val Purple200 = Color(0xFFBB86FC) val Purple500 = Color(0xFF6200EE) val Purple700 = Color(0xFF3700B3) val Teal200 = Color(0xFF03DAC5) + +// Accent palette options selected through Settings -> Accent Color. +// Each accent ships a brighter variant for the dark theme and a deeper variant for the light theme. +val AccentBlueDark = Color(0xFF82B1FF) +val AccentBlueLight = Color(0xFF1565C0) +val AccentGreenDark = Color(0xFF80CBC4) +val AccentGreenLight = Color(0xFF2E7D32) +val AccentOrangeDark = Color(0xFFFFB74D) +val AccentOrangeLight = Color(0xFFE65100) +val AccentRedDark = Color(0xFFEF9A9A) +val AccentRedLight = Color(0xFFC62828) +val AccentPinkDark = Color(0xFFF48FB1) +val AccentPinkLight = Color(0xFFAD1457) val BitcoinOrange = Color(0xFFF7931A) val RoyalBlue = Color(0xFF4169E1) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt index 213d04d4e6..39f561d15d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Theme.kt @@ -31,12 +31,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.ColorScheme +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -45,11 +48,13 @@ import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp @@ -62,48 +67,58 @@ import com.patrykandpatrick.vico.compose.common.VicoTheme import com.patrykandpatrick.vico.compose.common.VicoTheme.CandlestickCartesianLayerColors import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.commons.icons.symbols.ProvideMaterialSymbols +import com.vitorpamplona.amethyst.model.AccentColorType +import com.vitorpamplona.amethyst.model.FontFamilyType +import com.vitorpamplona.amethyst.model.FontSizeType import com.vitorpamplona.amethyst.model.ThemeType -private val DarkColorPalette = +// The accent color (primary/secondary/tertiary) is user-selectable in Settings -> Accent Color. +// Purple keeps the original Amethyst look (purple primary + teal secondary). Every other accent +// uses its single hue across primary and secondary for a cohesive single-color theme. +private fun accentPrimary( + accent: AccentColorType, + dark: Boolean, +): Color = + when (accent) { + AccentColorType.PURPLE -> if (dark) Purple200 else Purple500 + AccentColorType.BLUE -> if (dark) AccentBlueDark else AccentBlueLight + AccentColorType.GREEN -> if (dark) AccentGreenDark else AccentGreenLight + AccentColorType.ORANGE -> if (dark) AccentOrangeDark else AccentOrangeLight + AccentColorType.RED -> if (dark) AccentRedDark else AccentRedLight + AccentColorType.PINK -> if (dark) AccentPinkDark else AccentPinkLight + } + +private fun accentSecondary( + accent: AccentColorType, + dark: Boolean, +): Color = if (accent == AccentColorType.PURPLE) Teal200 else accentPrimary(accent, dark) + +private fun darkColors(accent: AccentColorType) = darkColorScheme( - primary = Purple200, - secondary = Teal200, - tertiary = Teal200, + primary = accentPrimary(accent, dark = true), + secondary = accentSecondary(accent, dark = true), + tertiary = accentSecondary(accent, dark = true), background = Color.Black, surface = Color.Black, surfaceDim = Color.Black, surfaceVariant = Color(red = 29, green = 26, blue = 34), ) -private val LightColorPalette = +private fun lightColors(accent: AccentColorType) = lightColorScheme( - primary = Purple500, - secondary = Teal200, - tertiary = Teal200, + primary = accentPrimary(accent, dark = false), + secondary = accentSecondary(accent, dark = false), + tertiary = accentSecondary(accent, dark = false), surfaceContainerHighest = Color(red = 236, green = 230, blue = 240), surfaceVariant = Color(red = 250, green = 245, blue = 252), ) -private val DarkNewItemBackground = DarkColorPalette.primary.copy(0.12f) -private val LightNewItemBackground = LightColorPalette.primary.copy(0.12f) +private val DarkColorPalette = darkColors(AccentColorType.PURPLE) +private val LightColorPalette = lightColors(AccentColorType.PURPLE) private val DarkTransparentBackground = DarkColorPalette.background.copy(0.32f) private val LightTransparentBackground = LightColorPalette.background.copy(0.32f) -private val DarkSelectedNote = DarkNewItemBackground.compositeOver(DarkColorPalette.background) -private val LightSelectedNote = LightNewItemBackground.compositeOver(LightColorPalette.background) - -private val DarkButtonBackground = - DarkColorPalette.primary.copy(alpha = 0.32f).compositeOver(DarkColorPalette.background) -private val LightButtonBackground = - LightColorPalette.primary.copy(alpha = 0.32f).compositeOver(LightColorPalette.background) - -private val DarkLessImportantLink = DarkColorPalette.primary.copy(alpha = 0.52f) -private val LightLessImportantLink = LightColorPalette.primary.copy(alpha = 0.52f) - -private val DarkMediumImportantLink = DarkColorPalette.primary.copy(alpha = 0.32f) -private val LightMediumImportantLink = LightColorPalette.primary.copy(alpha = 0.32f) - private val DarkGrayText = DarkColorPalette.onSurface.copy(alpha = 0.52f) private val LightGrayText = LightColorPalette.onSurface.copy(alpha = 0.52f) @@ -407,26 +422,32 @@ val MarkDownStyleOnLight = ), ) +// Compared against the dark palette's background instead of a fixed primary so the check keeps +// working when the user picks a non-purple accent (accent only changes primary/secondary, never +// background). Kept as a single reference comparison because this getter fans out to hundreds of +// themed-color call sites on hot rendering paths — luminance()/etc. would add real per-frame cost. val ColorScheme.isLight: Boolean - get() = primary == Purple500 + get() = background != Color.Black +// The accent-derived tints below are computed from the live scheme's primary so they follow +// the selected accent color. Color is an inline value class, so these copies don't allocate. val ColorScheme.newItemBackgroundColor: Color - get() = if (isLight) LightNewItemBackground else DarkNewItemBackground + get() = primary.copy(alpha = 0.12f) val ColorScheme.transparentBackground: Color get() = if (isLight) LightTransparentBackground else DarkTransparentBackground val ColorScheme.selectedNote: Color - get() = if (isLight) LightSelectedNote else DarkSelectedNote + get() = primary.copy(alpha = 0.12f).compositeOver(background) val ColorScheme.secondaryButtonBackground: Color - get() = if (isLight) LightButtonBackground else DarkButtonBackground + get() = primary.copy(alpha = 0.32f).compositeOver(background) val ColorScheme.lessImportantLink: Color - get() = if (isLight) LightLessImportantLink else DarkLessImportantLink + get() = primary.copy(alpha = 0.52f) val ColorScheme.mediumImportanceLink: Color - get() = if (isLight) LightMediumImportantLink else DarkMediumImportantLink + get() = primary.copy(alpha = 0.32f) val ColorScheme.placeholderText: Color get() = if (isLight) LightPlaceholderText else DarkPlaceholderText @@ -562,15 +583,21 @@ val ColorScheme.chartStyle: VicoTheme @Composable fun AmethystTheme(content: @Composable () -> Unit) { - val theme by Amethyst.instance.uiPrefs.value.theme - .collectAsStateWithLifecycle() + val uiPrefs = Amethyst.instance.uiPrefs.value + val theme by uiPrefs.theme.collectAsStateWithLifecycle() + val accentColor by uiPrefs.accentColor.collectAsStateWithLifecycle() + val fontFamily by uiPrefs.fontFamily.collectAsStateWithLifecycle() + val fontSize by uiPrefs.fontSize.collectAsStateWithLifecycle() - AmethystTheme(theme, content) + AmethystTheme(theme, accentColor, fontFamily, fontSize, content) } @Composable fun AmethystTheme( prefTheme: ThemeType, + accentColor: AccentColorType = AccentColorType.PURPLE, + fontFamily: FontFamilyType = FontFamilyType.SYSTEM, + fontSize: FontSizeType = FontSizeType.NORMAL, content: @Composable () -> Unit, ) { val context = LocalContext.current @@ -592,13 +619,33 @@ fun AmethystTheme( isSystemInDarkTheme() } } - val colors = if (darkTheme) DarkColorPalette else LightColorPalette + val colors = + remember(darkTheme, accentColor) { + if (darkTheme) darkColors(accentColor) else lightColors(accentColor) + } + + val resolvedFontFamily = remember(fontFamily) { fontFamily.toFontFamily() } + val typography = remember(fontFamily) { Typography.withFontFamily(resolvedFontFamily) } + + val density = LocalDensity.current + val scaledDensity = + remember(density, fontSize) { + Density(density.density, density.fontScale * fontSize.scale) + } MaterialTheme( colorScheme = colors, - typography = Typography, + typography = typography, shapes = Shapes, - content = { ProvideMaterialSymbols(content = content) }, + content = { + ProvideMaterialSymbols { + CompositionLocalProvider( + LocalDensity provides scaledDensity, + LocalTextStyle provides LocalTextStyle.current.merge(TextStyle(fontFamily = resolvedFontFamily)), + content = content, + ) + } + }, ) val view = LocalView.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt index b36b940d4f..88aa8e5178 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Type.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.em import androidx.compose.ui.unit.sp import com.halilibo.richtext.ui.HeadingStyle +import com.vitorpamplona.amethyst.model.FontFamilyType // Set of Material typography styles to start with val Typography = @@ -52,6 +53,39 @@ val Typography = */ ) +// Maps the user-selected font preference to a Compose [FontFamily]. +// SYSTEM returns null so the platform default is used unchanged. +fun FontFamilyType.toFontFamily(): FontFamily? = + when (this) { + FontFamilyType.SYSTEM -> null + FontFamilyType.SANS_SERIF -> FontFamily.SansSerif + FontFamilyType.SERIF -> FontFamily.Serif + FontFamilyType.MONOSPACE -> FontFamily.Monospace + } + +// Applies the chosen [FontFamily] to every text style so Material components pick it up too. +// A null family leaves the typography untouched (platform default). +fun Typography.withFontFamily(fontFamily: FontFamily?): Typography { + if (fontFamily == null) return this + return copy( + displayLarge = displayLarge.copy(fontFamily = fontFamily), + displayMedium = displayMedium.copy(fontFamily = fontFamily), + displaySmall = displaySmall.copy(fontFamily = fontFamily), + headlineLarge = headlineLarge.copy(fontFamily = fontFamily), + headlineMedium = headlineMedium.copy(fontFamily = fontFamily), + headlineSmall = headlineSmall.copy(fontFamily = fontFamily), + titleLarge = titleLarge.copy(fontFamily = fontFamily), + titleMedium = titleMedium.copy(fontFamily = fontFamily), + titleSmall = titleSmall.copy(fontFamily = fontFamily), + bodyLarge = bodyLarge.copy(fontFamily = fontFamily), + bodyMedium = bodyMedium.copy(fontFamily = fontFamily), + bodySmall = bodySmall.copy(fontFamily = fontFamily), + labelLarge = labelLarge.copy(fontFamily = fontFamily), + labelMedium = labelMedium.copy(fontFamily = fontFamily), + labelSmall = labelSmall.copy(fontFamily = fontFamily), + ) +} + val Font4SP = 4.sp val Font6SP = 6.sp val Font8SP = 8.sp diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 559843c1a6..71030f54e7 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -659,6 +659,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Otwórz Wyczyść Ulubione + Odkryj apki webowe + Strony osób, które obserwujesz + Aplikacje od osób, które obserwujesz Opcje Usuń z historii Ulubione aplikacje @@ -1481,6 +1484,26 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Podłącz portfel Język Motyw + Kolor akcentujący + Główny kolor używany przez przyciski i linki + Fioletowy + Niebieski + Zielony + Pomarańczowy + Czerwony + Różowy + Czcionka + Czcionka używana w całej aplikacji + Domyślna + Sans Serif + Serif + Monospace + Rozmiar czcionki + Dostosuj rozmiar tekstu w całej aplikacji + Mały + Normalny + Duży + Wielki Podgląd obrazu Odtwarzanie filmów Autoodtwarzanie filmów @@ -1803,6 +1826,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Usuń Minta Dodaj mint Historia + Twój portfel zapisuje dane automatycznie w miarę dodawania lub usuwania mintów. Klucz Nutzap jest generowany automatycznie przy pierwszym dodaniu minta. + Zapisywanie… Klucz Nutzap (zaawansowany) Oddzielny klucz prywatny służący wyłącznie do odbierania NIP-61 Nutzaps. Nie jest to klucz tożsamości Nostr. Wygeneruj nowy klucz @@ -2306,6 +2331,10 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Sklonowany z Strona internetowa: Klonuj: + Gałąź + Commit + Scal gałąź główną + Zaktualizowano pull request, uwzględniając nowy commit. Otwarte Połączone Zamknięte @@ -2313,11 +2342,15 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Przegląd Problemy Łaty & PRs + Otwórz + Zamknięty & Rozwiązany + Bez tytułu O programie Linki Opiekunowie Tematy Osobisty fork + Repozytoria Git Statyczna Witryna: %1$s nApplet: %1$s Uprawnienia: @@ -2608,6 +2641,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest Łatka Git Repozytorium Git Odpowiedź Git + Wniosek o zmianę + Aktualizacja PR Cele Zap-a Obserwowane hashtagi Najważniejsze informacje diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index 81571d3406..86866fedea 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -2342,6 +2342,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov, Vzdrževalci Teme Osebni fork + Git repozitoriji Statična spletna stran: %1$s nApplet: %1$s Dovoljenja: diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 97e66fa674..b5be13d8b8 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -675,6 +675,9 @@ Open Clear Favorites + Discover web apps + Sites from people you follow + Apps from people you follow Options Remove from history Web apps @@ -1621,6 +1624,26 @@ Wallet Connect Language Theme + Accent Color + Main color used across buttons and links + Purple + Blue + Green + Orange + Red + Pink + Font + Typeface used throughout the app + System Default + Sans Serif + Serif + Monospace + Font Size + Scale the text size across the app + Small + Normal + Large + Huge Image Preview Video Playback Autoplay Videos diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt new file mode 100644 index 0000000000..34ee0b0bbb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/browser/DefaultWebClients.kt @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.browser + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp + +/** One suggested web app: the launchable [app] plus a short [description] of what it does. */ +@Immutable +data class SuggestedWebApp( + val app: FavoriteApp.WebApp, + val description: String, +) + +/** + * The Nostr **web apps** offered as starting points in the browser launcher when the user hasn't + * pinned or visited anything of their own yet. They are shown under a "Discover" section below Recent, + * so they're discoverable without ever getting in the way of the user's own favorites/history. + * + * The list is drawn from the [nostrapps.com](https://nostrapps.com) directory (cross-checked against + * [awesome-nostr](https://github.com/aljazceru/awesome-nostr)) — every entry that ships a + * browser-openable web version — grouped here by what it does. Entries that are browser *extensions* or + * signer-only tools (nos2x, Nostrame, …) are intentionally left out: they aren't something you open in + * an in-app browser. URLs are the apps' own canonical domains. + * + * Each entry carries: + * - a short curated [SuggestedWebApp.description] (a trimmed version of the app's own meta description) + * shown as the row subtitle, since these are apps the user likely hasn't seen before; + * - the app's **own** logo as [FavoriteApp.iconUrl] (the PNG/SVG declared in its + * `` / ``), so it matches the favicon look of the + * Favorites/Recent rows without routing through any third-party favicon service. Only PNG/SVG are + * used — Coil has no ICO decoder — so apps whose only icon is a `.ico` (and a few that couldn't be + * resolved) are left icon-less and fall back to the globe glyph until their real favicon is captured + * the normal way on first visit. + * + * The [app]s are plain [FavoriteApp.WebApp] entries, so tapping one opens it like any other web + * favorite and the user can star it to pin it for real. They are **not** persisted as favorites: the + * list is hardcoded, device-local, and never becomes account state. + */ +object DefaultWebClients { + val list: List = + buildList { + // Social / microblogging clients + webApp("Primal", "https://primal.net", "All-in-one client with a built-in wallet", "https://primal.net/assets/apple-touch-icon-a536f430.png") + webApp("Coracle", "https://coracle.social", "Relay-savvy client for regular people", "https://coracle.social/icons/apple-touch-icon-76x76.png") + webApp("Snort", "https://snort.social", "Fast, feature-packed social client", "https://snort.social/img/apple-touch-icon.png") + webApp("noStrudel", "https://nostrudel.ninja", "Power-user client for exploring Nostr", "https://nostrudel.ninja/apple-touch-icon.png") + webApp("Iris", "https://iris.to", "Simple, fast social client", "https://iris.to/img/apple-touch-icon.png") + webApp("Nostter", "https://nostter.app", "Lightweight web social client", "https://nostter.app/apple-touch-icon.png") + webApp("Jumble", "https://jumble.social", "Explore feeds relay by relay", "https://jumble.social/favicon.svg") + webApp("Nostria", "https://nostria.app", "Social without the noise", "https://nostria.app/icons/icon-192x192-maskable.png") + webApp("Nosotros", "https://nosotros.app", "A weirdly fast social client", "https://nosotros.app/apple-touch-icon-144x144.png") + webApp("lumilumi", "https://lumilumi.app", "Lightweight Nostr client", "https://lumilumi.app/apple-touch-icon-180x180.png") + webApp("Phoenix", "https://phoenix.social", "Snort-based social client", "https://phoenix.social/img/apple-touch-icon.png") + webApp("Shosho", "https://shosho.live", "Live-streaming marketplace", "https://shosho.live/apple-touch-icon.png") + webApp("ants", "https://ants.sh", "Advanced Nostr text search", "https://ants.sh/apple-touch-icon.png") + webApp("YakiHonne", "https://yakihonne.com", "Decentralized media & long-form") + webApp("Ditto", "https://ditto.pub", "Your content, your vibe, your rules", "https://ditto.pub/apple-touch-icon.png") + webApp("x21", "https://x21.social", "Relay feed explorer", "https://x21.social/apple-touch-icon.png?v=4") + webApp("Ghostr", "https://ghostr.org", "Draft & delegated publishing", "https://ghostr.org/favicon/apple-touch-icon.png") + webApp("Mutable", "https://mutable.top", "Your mute list manager", "https://mutable.top/mutable_logo.svg") + + // Reading / long-form / feeds + webApp("Habla", "https://habla.news", "Long-form articles & blogs") + webApp("Highlighter", "https://highlighter.com", "Articles, highlights & communities", "https://highlighter.com/apple-touch-icon-180x180.png") + webApp("Boris", "https://readwithboris.com", "Distraction-free reading & highlights", "https://readwithboris.com/apple-touch-icon.png") + webApp("Noflux", "https://noflux.nostr.technology", "RSS-style feed reader") + + // Communities / chat + webApp("Flotilla", "https://flotilla.social", "Community spaces & chat", "https://framerusercontent.com/images/8UjnVxSvRkmvY2lEYU5z8OMOw0M.png") + webApp("Chachi", "https://chachi.chat", "Group chat & communities") + webApp("NostrChat", "https://www.nostrchat.io", "Decentralized chat", "https://www.nostrchat.io/logo192.png") + webApp("NymChat", "https://www.nymchat.com", "Anonymous, ephemeral chat") + webApp("HiveTalk", "https://hivetalk.org", "Lightning-powered video conferencing", "https://hivetalk.org/_astro/apple-touch-icon.BAevOzwc.png") + + // Media — video, photo, audio, podcasts, files + webApp("zap.stream", "https://zap.stream", "Live streaming with Lightning", "https://zap.stream/logo.png") + webApp("Divine Video", "https://divine.video", "6-second looping videos", "https://divine.video/app_icon.png") + webApp("Olas", "https://olas.app", "Photo & media sharing", "https://olas.app/favicon.png") + webApp("Zappix", "https://zappix.app", "Share & discover images", "https://zappix.app/icon-192.png") + webApp("Slidestr", "https://slidestr.net", "Media slideshow viewer", "https://slidestr.net/slidestr.svg") + webApp("Bouquet", "https://bouquet.slidestr.net", "Blossom media manager", "https://bouquet.slidestr.net/bouquet.png") + webApp("YakBak", "https://yakbak.app", "Voice messages", "https://yakbak.app/yakbak-logo.png") + webApp("ZapTrax", "https://zaptrax.app", "Music streaming with Wavlake", "https://zaptrax.app/icon-192.png") + webApp("Podstr", "https://podstr.org", "Podcasts on Nostr", "https://podstr.org/favicon.svg") + webApp("Nests", "https://nostrnests.com", "Live audio rooms", "https://nostrnests.com/apple-touch-icon.png") + + // Knowledge / wiki + webApp("Wikifreedia", "https://wikifreedia.xyz", "Decentralized encyclopedia", "https://wikifreedia.xyz/favicon.svg") + webApp("Wikistr", "https://wikistr.com", "A wiki built on Nostr", "https://wikistr.com/favicon.png") + + // Marketplace / food + webApp("Shopstr", "https://shopstr.store", "Bitcoin-native marketplace") + webApp("Plebeian Market", "https://plebeian.market", "Decentralized marketplace", "https://plebeian.market/logo-st5zpap9.svg") + webApp("Zap Cooking", "https://zap.cooking", "Recipes & food culture", "https://zap.cooking/favicon.svg") + + // Tools / utilities + webApp("Emojito", "https://emojito.meme", "Custom emoji sets") + webApp("Formstr", "https://formstr.app", "Decentralized forms", "https://formstr.app/logo192.png") + webApp("Nostree", "https://nostree.me", "Link-in-bio pages") + webApp("Badges", "https://badges.page", "Create & award badges") + webApp("Nstart", "https://nstart.me", "Guided account onboarding", "https://nstart.me/favicon.png") + webApp("alphaama", "https://alphaama.com", "Nostr tools & experiments") + webApp("Treasures", "https://treasures.to", "Geocaching on Nostr", "https://treasures.to/apple-touch-icon.png") + webApp("Yondar", "https://yondar.me", "Places & maps", "https://yondar.me/apple-touch-icon.png") + webApp("DTAN", "https://dtan.xyz", "Torrents on Nostr") + webApp("Nostrocket", "https://nostrocket.org", "Project coordination") + webApp("Plektos", "https://plektos.app", "Decentralized meetup events", "https://plektos.app/icon-180.png") + webApp("Zaplytics", "https://zaplytics.app", "Zap analytics for creators") + webApp("Brainstorm", "https://brainstorm.world", "Web-of-trust explorer", "https://brainstorm.world/brainstorm.svg") + webApp("MAKIMONO", "https://makimono.lumilumi.app", "Long-form article editor", "https://makimono.lumilumi.app/favicon3.png") + webApp("Primal Studio", "https://studio.primal.net", "Schedule & publish content") + webApp("nostr.build", "https://nostr.build", "Media & image uploads", "https://nostr.build/apple-touch-icon.png") + webApp("nostrcheck", "https://nostrcheck.me", "Media hosting & NIP-05", "https://nostrcheck.me/apple-touch-icon.png") + webApp("Metadata", "https://metadata.nostr.com", "Edit your profile metadata") + + // Games + webApp("Plebs vs Zombies", "https://www.plebsvszombies.cc", "Clean up your follow list", "https://www.plebsvszombies.cc/favicon.svg") + webApp("Blobbi", "https://www.blobbi.pet", "A virtual pet game", "https://www.blobbi.pet/icons/apple-touch-icon.png") + } + + // addedAt is 0L: these are hardcoded suggestions, not user-added favorites, so they never need a + // real "added" timestamp for ordering — the curated order in `list` is what matters. `icon` is the + // app's own logo URL, or null to fall back to the globe glyph until a favicon is captured on visit. + private fun MutableList.webApp( + label: String, + url: String, + description: String, + icon: String? = null, + ) { + add(SuggestedWebApp(FavoriteApp.WebApp(url = url, label = label, addedAt = 0L, iconUrl = icon), description)) + } +} diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt index bfc3c5e812..ef053427ea 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserActivity.kt @@ -24,6 +24,7 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.content.res.ColorStateList import android.graphics.Bitmap import android.net.Uri import android.os.Bundle @@ -40,6 +41,7 @@ import android.webkit.ConsoleMessage import android.webkit.WebChromeClient import android.webkit.WebResourceError import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient @@ -58,7 +60,6 @@ import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.ProxyConfig import androidx.webkit.ProxyController import androidx.webkit.WebMessageCompat -import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.browser.OmniboxInput @@ -94,6 +95,10 @@ class NappletBrowserActivity : ComponentActivity() { private var controlSheet: NappletControlSheet? = null private var consolePanel: NappletConsolePanel? = null + // A thin determinate progress bar pinned to the top edge (browser-style), driven by the chrome + // client's onProgressChanged; hidden at 100%. + private val topProgressBar by lazy { buildTopProgressBar() } + // Visit-history gating: only a clean main-frame load (no error) is recorded, so a misspelled/ // unresolved address never enters history. Reset on each main-frame page start. private var pendingMainFrameUrl: String? = null @@ -200,6 +205,8 @@ class NappletBrowserActivity : ComponentActivity() { Gravity.BOTTOM, ), ) + // Added last so the thin loading bar paints above the content (and over the grabber's top edge). + addView(topProgressBar) } setContentView(root) // Pad by the system bars + cutout, but NOT the IME — windowSoftInputMode=adjustResize shrinks the @@ -307,16 +314,20 @@ class NappletBrowserActivity : ComponentActivity() { safeBrowsingEnabled = true } } - if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { - WebSettingsCompat.setAlgorithmicDarkeningAllowed(wv.settings, true) - } WebView.setWebContentsDebuggingEnabled(false) wv.webViewClient = BrowserClient() wv.webChromeClient = BrowserChromeClient() } - /** Captures favicon and console output; the only source of both is the WebChromeClient. */ + /** Captures favicon and console output, and drives the top loading bar; all come from the WebChromeClient. */ private inner class BrowserChromeClient : WebChromeClient() { + override fun onProgressChanged( + view: WebView, + newProgress: Int, + ) { + updateLoadProgress(newProgress) + } + override fun onReceivedIcon( view: WebView, icon: Bitmap?, @@ -377,6 +388,15 @@ class NappletBrowserActivity : ComponentActivity() { // A main-frame failure (DNS miss on a misspelled host, no connection, …) disqualifies this // navigation from history. Sub-resource errors are irrelevant to whether the page opened. if (request.isForMainFrame) mainFrameLoadFailed = true + logConsoleError(request, getString(R.string.napplet_console_load_error, error.errorCode, error.description?.toString().orEmpty())) + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + logConsoleError(request, getString(R.string.napplet_console_http_error, errorResponse.statusCode, errorResponse.reasonPhrase.orEmpty())) } override fun onPageCommitVisible( @@ -658,6 +678,39 @@ class NappletBrowserActivity : ComponentActivity() { addView(ProgressBar(this@NappletBrowserActivity)) } + /** + * A thin determinate progress bar pinned to the top edge, like a browser's. Driven by + * [BrowserChromeClient.onProgressChanged]: visible while the page loads and gone at 100%. + */ + private fun buildTopProgressBar(): ProgressBar = + ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply { + max = 100 + isIndeterminate = false + visibility = View.GONE + progressTintList = ColorStateList.valueOf(resolveThemeColor(android.R.attr.colorPrimary)) + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, dp(3), Gravity.TOP) + } + + /** Shows the thin top bar at [progress]% while loading, hiding it once the page is fully loaded. */ + private fun updateLoadProgress(progress: Int) { + if (progress >= 100) { + topProgressBar.visibility = View.GONE + } else { + topProgressBar.progress = progress + topProgressBar.visibility = View.VISIBLE + } + } + + /** Appends a single ERROR line to the console panel and refreshes the chrome's unread count. */ + private fun logConsoleError( + request: WebResourceRequest, + message: String, + ) { + val panel = consolePanel ?: return + panel.appendLog(ConsoleMessage.MessageLevel.ERROR, message, request.url?.toString().orEmpty(), 0) + controlSheet?.updateConsoleCount(panel.entryCount) + } + private fun resolveThemeColor(attr: Int): Int { val tv = android.util.TypedValue() theme.resolveAttribute(attr, tv, true) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt index 5f000eef66..3ba6e87554 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletBrowserService.kt @@ -48,7 +48,6 @@ import androidx.annotation.RequiresApi import androidx.privacysandbox.ui.provider.toCoreLibInfo import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.WebMessageCompat -import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.browser.OmniboxInput @@ -313,9 +312,6 @@ class NappletBrowserService : Service() { safeBrowsingEnabled = true } } - if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { - WebSettingsCompat.setAlgorithmicDarkeningAllowed(wv.settings, true) - } WebView.setWebContentsDebuggingEnabled(false) wv.webViewClient = BrowserClient(tab) wv.webChromeClient = BrowserChromeClient(tab) diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt index cbd602b5b7..4861ab7798 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostActivity.kt @@ -24,6 +24,7 @@ import android.app.AlertDialog import android.content.ComponentName import android.content.Intent import android.content.ServiceConnection +import android.content.res.ColorStateList import android.net.Uri import android.os.Bundle import android.os.Handler @@ -37,6 +38,9 @@ import android.view.Gravity import android.view.KeyEvent import android.view.View import android.view.ViewGroup +import android.webkit.ConsoleMessage +import android.webkit.WebChromeClient +import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebSettings @@ -58,7 +62,6 @@ import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.ProxyConfig import androidx.webkit.ProxyController import androidx.webkit.WebMessageCompat -import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract @@ -147,6 +150,18 @@ class NappletHostActivity : ComponentActivity() { private val contentFrame by lazy { FrameLayout(this) } private val uiScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + // The loading splash (monogram + spinner). Kept on top of the mounted WebView and removed only on + // first paint, so there's never a blank/dark gap between the index probe and the shell's first frame. + private var loadingView: View? = null + + // A thin determinate progress bar pinned to the top edge (browser-style), driven by the + // WebChromeClient's onProgressChanged; hidden at 100%. + private val topProgressBar by lazy { buildTopProgressBar() } + + // Bottom pull-up developer console: the page's console.log/warn/error plus any resource load errors. + private var consolePanel: NappletConsolePanel? = null + private var controlSheet: NappletControlSheet? = null + // Set once the WebView has begun loading the shell, so a retry doesn't reload it. private var started = false @@ -268,6 +283,18 @@ class NappletHostActivity : ComponentActivity() { Gravity.TOP, ), ) + addView( + buildConsolePanel(), + FrameLayout + .LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.BOTTOM, + ), + ) + // Added last so the thin loading bar paints above the content (and over the grabber's top + // edge); it's GONE except while loading, so it never obscures the trusted chrome. + addView(topProgressBar) } setContentView(root) // Activities are edge-to-edge by default on recent Android; pad by the system bar and @@ -295,22 +322,26 @@ class NappletHostActivity : ComponentActivity() { */ private fun probeAndMount() { contentFrame.removeAllViews() - contentFrame.addView(buildLoadingView()) + loadingView = buildLoadingView().also { contentFrame.addView(it) } uiScope.launch { val available = withContext(Dispatchers.IO) { contentServer.resolve("/") is StaticSiteResolution.Resolved } if (available) { mountWebView() } else { contentFrame.removeAllViews() + loadingView = null contentFrame.addView(buildErrorView { probeAndMount() }) } } } private fun mountWebView() { - contentFrame.removeAllViews() (webView.parent as? ViewGroup)?.removeView(webView) - contentFrame.addView(webView, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) + // Mount the WebView UNDER the loading splash (index 0) instead of replacing it: the shell + applet + // bundle still take time to paint (seconds over Tor), and the WebView shows only its dark + // colorBackground until then. The splash stays until the first frame paints (onPageCommitVisible), + // so the user never sees a blank/black screen with no sign that anything is loading. + contentFrame.addView(webView, 0, FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)) if (!started) { started = true webView.loadUrl(NappletWebContract.SHELL_URL) @@ -475,15 +506,13 @@ class NappletHostActivity : ComponentActivity() { safeBrowsingEnabled = true } } - if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { - WebSettingsCompat.setAlgorithmicDarkeningAllowed(webView.settings, true) - } // Disable the overscroll stretch/glow: forcing a scroll past the content edge stretched the // WebView's output and exposed the shell document's background behind the applet iframe at the // seam (a stray white band at the bottom). The applet's own content still scrolls normally. webView.overScrollMode = View.OVER_SCROLL_NEVER WebView.setWebContentsDebuggingEnabled(false) webView.webViewClient = NappletWebViewClient() + webView.webChromeClient = NappletWebChromeClient() } /** @@ -529,6 +558,34 @@ class NappletHostActivity : ComponentActivity() { syncBackState() } + // The shell has painted its first frame — drop the loading splash so the running app shows + // through. Null-safe so a later in-app navigation/reload (splash already gone) is a no-op. + override fun onPageCommitVisible( + view: WebView, + url: String, + ) { + loadingView?.let { contentFrame.removeView(it) } + loadingView = null + } + + // Surface failed resource fetches (a missing blob, a verify miss, an off-origin request the + // default-deny CSP blocked) in the console so an nsite/napplet developer can see what broke. + override fun onReceivedError( + view: WebView, + request: WebResourceRequest, + error: WebResourceError, + ) { + logConsoleError(request, getString(R.string.napplet_console_load_error, error.errorCode, error.description?.toString().orEmpty())) + } + + override fun onReceivedHttpError( + view: WebView, + request: WebResourceRequest, + errorResponse: WebResourceResponse, + ) { + logConsoleError(request, getString(R.string.napplet_console_http_error, errorResponse.statusCode, errorResponse.reasonPhrase.orEmpty())) + } + override fun shouldOverrideUrlLoading( view: WebView, request: WebResourceRequest, @@ -550,6 +607,43 @@ class NappletHostActivity : ComponentActivity() { } } + /** Drives the top loading bar and forwards the applet/site's `console.*` output to the console panel. */ + private inner class NappletWebChromeClient : WebChromeClient() { + override fun onProgressChanged( + view: WebView, + newProgress: Int, + ) { + updateLoadProgress(newProgress) + } + + override fun onConsoleMessage(consoleMessage: ConsoleMessage): Boolean { + val panel = consolePanel ?: return false + panel.appendLog(consoleMessage.messageLevel(), consoleMessage.message(), consoleMessage.sourceId(), consoleMessage.lineNumber()) + controlSheet?.updateConsoleCount(panel.entryCount) + return true + } + } + + /** Shows the thin top bar at [progress]% while loading, hiding it once the page is fully loaded. */ + private fun updateLoadProgress(progress: Int) { + if (progress >= 100) { + topProgressBar.visibility = View.GONE + } else { + topProgressBar.progress = progress + topProgressBar.visibility = View.VISIBLE + } + } + + /** Appends a single ERROR line to the console panel and refreshes the chrome's unread count. */ + private fun logConsoleError( + request: WebResourceRequest, + message: String, + ) { + val panel = consolePanel ?: return + panel.appendLog(ConsoleMessage.MessageLevel.ERROR, message, request.url?.toString().orEmpty(), 0) + controlSheet?.updateConsoleCount(panel.entryCount) + } + // ---- bridge: shell <-> native ---- private fun onShellMessage( @@ -663,6 +757,9 @@ class NappletHostActivity : ComponentActivity() { orientation = LinearLayout.VERTICAL gravity = Gravity.CENTER setPadding(dp(32), dp(32), dp(32), dp(32)) + // Opaque so the splash/error screen fully covers the WebView it now overlays (mounted beneath + // it until first paint) instead of letting the dark, not-yet-painted page show through. + setBackgroundColor(resolveThemeColor(android.R.attr.colorBackground)) layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT) } @@ -736,30 +833,33 @@ class NappletHostActivity : ComponentActivity() { title = barTitle(), isSandbox = true, onReload = { if (this::webView.isInitialized) webView.reload() }, - // Website-mode nSites can re-route over Tor; switching rebuilds the session via a confirm - // dialog, so the row taps through rather than toggling inline. + // Website-mode nSites can re-route over Tor; switching rebuilds the session, so the row taps + // through to a full relaunch rather than toggling inline. torInitiallyOn = if (profile.exposesNetwork && proxyPort > 0) useTor else null, - onNetworkTap = if (profile.exposesNetwork && proxyPort > 0) ({ showNetworkDialog() }) else null, + onNetworkTap = if (profile.exposesNetwork && proxyPort > 0) ({ setNetworkMode(!useTor) }) else null, onInfo = { showAccessDialog() }, - ) + onConsole = { consolePanel?.toggle() }, + ).also { controlSheet = it } + + private fun buildConsolePanel(): View = + NappletConsolePanel(this).also { + it.onClearCallback = { controlSheet?.updateConsoleCount(0) } + consolePanel = it + } /** - * Explains the site's current network routing and offers to switch it. Switching persists the - * per-site choice (via the broker, which owns the preference) and relaunches this screen so the new - * routing applies cleanly from [onCreate] — the proxy and content server are rebuilt for the new mode. + * A thin determinate progress bar pinned to the top edge, like a browser's. Driven by + * [NappletWebChromeClient.onProgressChanged]: visible while the shell + verified blobs load and gone + * at 100%, so a slow load (e.g. a large bundle over Tor) shows progress instead of a blank dark WebView. */ - private fun showNetworkDialog() { - val titleRes = if (useTor) R.string.napplet_net_tor_title else R.string.napplet_net_open_title - val messageRes = if (useTor) R.string.napplet_net_tor_message else R.string.napplet_net_open_message - val switchRes = if (useTor) R.string.napplet_net_switch_open else R.string.napplet_net_switch_tor - AlertDialog - .Builder(this) - .setTitle(getString(titleRes, barTitle())) - .setMessage(getString(messageRes)) - .setPositiveButton(getString(switchRes)) { _, _ -> setNetworkMode(!useTor) } - .setNegativeButton(android.R.string.cancel, null) - .show() - } + private fun buildTopProgressBar(): ProgressBar = + ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal).apply { + max = 100 + isIndeterminate = false + visibility = View.GONE + progressTintList = ColorStateList.valueOf(resolveThemeColor(android.R.attr.colorPrimary)) + layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, dp(3), Gravity.TOP) + } /** Persists the new routing choice in the main process, then relaunches this screen to apply it. */ private fun setNetworkMode(newUseTor: Boolean) { diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt index 79de4ee87e..b2fe56b483 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/NappletHostService.kt @@ -50,7 +50,6 @@ import androidx.webkit.JavaScriptReplyProxy import androidx.webkit.ProxyConfig import androidx.webkit.ProxyController import androidx.webkit.WebMessageCompat -import androidx.webkit.WebSettingsCompat import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import com.vitorpamplona.amethyst.commons.napplet.NappletWebContract @@ -340,9 +339,6 @@ class NappletHostService : Service() { safeBrowsingEnabled = true } } - if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { - WebSettingsCompat.setAlgorithmicDarkeningAllowed(wv.settings, true) - } wv.overScrollMode = View.OVER_SCROLL_NEVER WebView.setWebContentsDebuggingEnabled(false) wv.webViewClient = HostClient(tab) diff --git a/nappletHost/src/main/res/values/strings.xml b/nappletHost/src/main/res/values/strings.xml index ddd4b168d4..c5f8ed7576 100644 --- a/nappletHost/src/main/res/values/strings.xml +++ b/nappletHost/src/main/res/values/strings.xml @@ -19,12 +19,6 @@ This site loads over Tor. Tap to change. This site loads over the open web. Tap to change. - “%1$s” loads over Tor - This site\'s traffic is routed through Tor, so it can\'t see your IP address. Some sites are slow or broken over Tor — you can switch this site to the open web. Your choice is remembered for this site. - “%1$s” loads over the open web - This site loads directly, so it (and the servers it contacts) can see your IP address. Switch it back to Tor to keep your IP private. Your choice is remembered for this site. - Use open web - Use Tor Loads over Tor Loads over the open web @@ -37,4 +31,8 @@ Couldn\'t load “%1$s” The publisher\'s servers may be offline, or you\'re not connected. You can try again. Try again + + + Failed to load (%1$d): %2$s + HTTP %1$d %2$s