mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-11 16:57:39 +00:00
Merge remote-tracking branch 'origin/main' into claude/cashu-wallet-wizard-0zr280
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -54,6 +54,9 @@ class UiSettingsFlow(
|
||||
val showProfileFollowersFeed: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val dontShowOnchainPublicWarning: MutableStateFlow<Boolean> = MutableStateFlow(false),
|
||||
val suggestWorkoutsFromHealthConnect: MutableStateFlow<BooleanType> = MutableStateFlow(BooleanType.ALWAYS),
|
||||
val accentColor: MutableStateFlow<AccentColorType> = MutableStateFlow(AccentColorType.PURPLE),
|
||||
val fontFamily: MutableStateFlow<FontFamilyType> = MutableStateFlow(FontFamilyType.SYSTEM),
|
||||
val fontSize: MutableStateFlow<FontSizeType> = MutableStateFlow(FontSizeType.NORMAL),
|
||||
) {
|
||||
val listOfFlows: List<Flow<Any?>> =
|
||||
listOf<Flow<Any?>>(
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+12
@@ -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
|
||||
|
||||
+15
@@ -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) {
|
||||
|
||||
+6
-1
@@ -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,
|
||||
)
|
||||
|
||||
+46
-2
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+27
-2
@@ -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
|
||||
|
||||
+10
-2
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+326
-33
@@ -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<FavoriteApp.NostrApp>().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<FavoriteApp.WebApp>().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<OmniboxSuggestions.Suggestion>,
|
||||
iconKeys: Set<String>,
|
||||
historyUrls: Set<String>,
|
||||
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<OmniboxSuggestions.Suggestion>,
|
||||
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<String>,
|
||||
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<BrowserHistoryEntry>,
|
||||
iconKeys: Set<String>,
|
||||
favoriteUrls: Set<String>,
|
||||
suggested: List<SuggestedWebApp>,
|
||||
nsites: List<DiscoverNostrApp>,
|
||||
napplets: List<DiscoverNostrApp>,
|
||||
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<String>,
|
||||
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<Note>.toDiscoverApps(
|
||||
mine: Boolean,
|
||||
myPubkey: String,
|
||||
matchAuthor: (String) -> Boolean,
|
||||
excludeCoordinates: Set<String>,
|
||||
): List<DiscoverNostrApp> =
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -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,
|
||||
|
||||
+22
@@ -56,6 +56,15 @@ object EmbeddedTabHost {
|
||||
var activeId by mutableStateOf<String?>(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
|
||||
}
|
||||
}
|
||||
|
||||
+3
-1
@@ -162,7 +162,9 @@ fun EmbeddedTabLayer(barFavoriteIds: List<String>) {
|
||||
},
|
||||
) {
|
||||
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) {
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+67
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-4
@@ -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,
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
+77
-23
@@ -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,
|
||||
|
||||
+30
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -659,6 +659,9 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="browser_go">Otwórz</string>
|
||||
<string name="browser_clear">Wyczyść</string>
|
||||
<string name="browser_favorites">Ulubione</string>
|
||||
<string name="browser_suggested">Odkryj apki webowe</string>
|
||||
<string name="browser_discover_nsites">Strony osób, które obserwujesz</string>
|
||||
<string name="browser_discover_napplets">Aplikacje od osób, które obserwujesz</string>
|
||||
<string name="browser_recent_options">Opcje</string>
|
||||
<string name="browser_recent_remove">Usuń z historii</string>
|
||||
<string name="favorite_apps">Ulubione aplikacje</string>
|
||||
@@ -1481,6 +1484,26 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="wallet_connect">Podłącz portfel</string>
|
||||
<string name="language">Język</string>
|
||||
<string name="theme">Motyw</string>
|
||||
<string name="accent_color">Kolor akcentujący</string>
|
||||
<string name="accent_color_description">Główny kolor używany przez przyciski i linki</string>
|
||||
<string name="accent_color_purple">Fioletowy</string>
|
||||
<string name="accent_color_blue">Niebieski</string>
|
||||
<string name="accent_color_green">Zielony</string>
|
||||
<string name="accent_color_orange">Pomarańczowy</string>
|
||||
<string name="accent_color_red">Czerwony</string>
|
||||
<string name="accent_color_pink">Różowy</string>
|
||||
<string name="font_family">Czcionka</string>
|
||||
<string name="font_family_description">Czcionka używana w całej aplikacji</string>
|
||||
<string name="font_family_system">Domyślna</string>
|
||||
<string name="font_family_sans_serif">Sans Serif</string>
|
||||
<string name="font_family_serif">Serif</string>
|
||||
<string name="font_family_monospace">Monospace</string>
|
||||
<string name="font_size">Rozmiar czcionki</string>
|
||||
<string name="font_size_description">Dostosuj rozmiar tekstu w całej aplikacji</string>
|
||||
<string name="font_size_small">Mały</string>
|
||||
<string name="font_size_normal">Normalny</string>
|
||||
<string name="font_size_large">Duży</string>
|
||||
<string name="font_size_huge">Wielki</string>
|
||||
<string name="automatically_load_images_gifs">Podgląd obrazu</string>
|
||||
<string name="automatically_play_videos">Odtwarzanie filmów</string>
|
||||
<string name="autoplay_videos">Autoodtwarzanie filmów</string>
|
||||
@@ -1803,6 +1826,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="cashu_remove_mint">Usuń Minta</string>
|
||||
<string name="cashu_add_mint">Dodaj mint</string>
|
||||
<string name="cashu_history">Historia</string>
|
||||
<string name="cashu_wallet_autosaves">Twój portfel zapisuje dane automatycznie w miarę dodawania lub usuwania mintów. Klucz Nutzap jest generowany automatycznie przy pierwszym dodaniu minta.</string>
|
||||
<string name="cashu_wallet_saving">Zapisywanie…</string>
|
||||
<string name="cashu_p2pk_section">Klucz Nutzap (zaawansowany)</string>
|
||||
<string name="cashu_p2pk_explainer">Oddzielny klucz prywatny służący wyłącznie do odbierania NIP-61 Nutzaps. Nie jest to klucz tożsamości Nostr.</string>
|
||||
<string name="cashu_p2pk_autogen">Wygeneruj nowy klucz</string>
|
||||
@@ -2306,6 +2331,10 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="forked_from">Sklonowany z</string>
|
||||
<string name="git_web_address">Strona internetowa:</string>
|
||||
<string name="git_clone_address">Klonuj:</string>
|
||||
<string name="git_branch">Gałąź</string>
|
||||
<string name="git_commit">Commit</string>
|
||||
<string name="git_merge_base">Scal gałąź główną</string>
|
||||
<string name="git_pr_update_description">Zaktualizowano pull request, uwzględniając nowy commit.</string>
|
||||
<string name="git_status_open">Otwarte</string>
|
||||
<string name="git_status_merged">Połączone</string>
|
||||
<string name="git_status_closed">Zamknięte</string>
|
||||
@@ -2313,11 +2342,15 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="git_repo_tab_overview">Przegląd</string>
|
||||
<string name="git_repo_tab_issues">Problemy</string>
|
||||
<string name="git_repo_tab_patches">Łaty & PRs</string>
|
||||
<string name="git_repo_filter_open">Otwórz</string>
|
||||
<string name="git_repo_filter_closed">Zamknięty & Rozwiązany</string>
|
||||
<string name="git_untitled">Bez tytułu</string>
|
||||
<string name="git_repo_section_about">O programie</string>
|
||||
<string name="git_repo_section_links">Linki</string>
|
||||
<string name="git_repo_section_maintainers">Opiekunowie</string>
|
||||
<string name="git_repo_section_topics">Tematy</string>
|
||||
<string name="git_repo_personal_fork">Osobisty fork</string>
|
||||
<string name="git_repositories">Repozytoria Git</string>
|
||||
<string name="nsite_title">Statyczna Witryna: %1$s</string>
|
||||
<string name="napplet_card_title">nApplet: %1$s</string>
|
||||
<string name="napplet_card_permissions">Uprawnienia:</string>
|
||||
@@ -2608,6 +2641,8 @@ Zaplanowane posty z innych kont nie zostaną opublikowane, dopóki to konto jest
|
||||
<string name="kind_git_patch">Łatka Git</string>
|
||||
<string name="kind_git_repo">Repozytorium Git</string>
|
||||
<string name="kind_git_reply">Odpowiedź Git</string>
|
||||
<string name="kind_git_pr">Wniosek o zmianę</string>
|
||||
<string name="kind_git_pr_update">Aktualizacja PR</string>
|
||||
<string name="kind_zap_goals">Cele Zap-a</string>
|
||||
<string name="kind_hashtag_follows">Obserwowane hashtagi</string>
|
||||
<string name="kind_highlights">Najważniejsze informacje</string>
|
||||
|
||||
@@ -2342,6 +2342,7 @@ Za ohranitev zasebnosti to denarnico polni in prazni prek ne-zasebnih računov,
|
||||
<string name="git_repo_section_maintainers">Vzdrževalci</string>
|
||||
<string name="git_repo_section_topics">Teme</string>
|
||||
<string name="git_repo_personal_fork">Osebni fork</string>
|
||||
<string name="git_repositories">Git repozitoriji</string>
|
||||
<string name="nsite_title">Statična spletna stran: %1$s</string>
|
||||
<string name="napplet_card_title">nApplet: %1$s</string>
|
||||
<string name="napplet_card_permissions">Dovoljenja:</string>
|
||||
|
||||
@@ -675,6 +675,9 @@
|
||||
<string name="browser_go">Open</string>
|
||||
<string name="browser_clear">Clear</string>
|
||||
<string name="browser_favorites">Favorites</string>
|
||||
<string name="browser_suggested">Discover web apps</string>
|
||||
<string name="browser_discover_nsites">Sites from people you follow</string>
|
||||
<string name="browser_discover_napplets">Apps from people you follow</string>
|
||||
<string name="browser_recent_options">Options</string>
|
||||
<string name="browser_recent_remove">Remove from history</string>
|
||||
<string name="favorite_apps">Web apps</string>
|
||||
@@ -1621,6 +1624,26 @@
|
||||
<string name="wallet_connect">Wallet Connect</string>
|
||||
<string name="language">Language</string>
|
||||
<string name="theme">Theme</string>
|
||||
<string name="accent_color">Accent Color</string>
|
||||
<string name="accent_color_description">Main color used across buttons and links</string>
|
||||
<string name="accent_color_purple">Purple</string>
|
||||
<string name="accent_color_blue">Blue</string>
|
||||
<string name="accent_color_green">Green</string>
|
||||
<string name="accent_color_orange">Orange</string>
|
||||
<string name="accent_color_red">Red</string>
|
||||
<string name="accent_color_pink">Pink</string>
|
||||
<string name="font_family">Font</string>
|
||||
<string name="font_family_description">Typeface used throughout the app</string>
|
||||
<string name="font_family_system">System Default</string>
|
||||
<string name="font_family_sans_serif">Sans Serif</string>
|
||||
<string name="font_family_serif">Serif</string>
|
||||
<string name="font_family_monospace">Monospace</string>
|
||||
<string name="font_size">Font Size</string>
|
||||
<string name="font_size_description">Scale the text size across the app</string>
|
||||
<string name="font_size_small">Small</string>
|
||||
<string name="font_size_normal">Normal</string>
|
||||
<string name="font_size_large">Large</string>
|
||||
<string name="font_size_huge">Huge</string>
|
||||
<string name="automatically_load_images_gifs">Image Preview</string>
|
||||
<string name="automatically_play_videos">Video Playback</string>
|
||||
<string name="autoplay_videos">Autoplay Videos</string>
|
||||
|
||||
+151
@@ -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
|
||||
* `<link rel="apple-touch-icon">` / `<link rel="icon">`), 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<SuggestedWebApp> =
|
||||
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<SuggestedWebApp>.webApp(
|
||||
label: String,
|
||||
url: String,
|
||||
description: String,
|
||||
icon: String? = null,
|
||||
) {
|
||||
add(SuggestedWebApp(FavoriteApp.WebApp(url = url, label = label, addedAt = 0L, iconUrl = icon), description))
|
||||
}
|
||||
}
|
||||
+58
-5
@@ -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)
|
||||
|
||||
-4
@@ -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)
|
||||
|
||||
+126
-26
@@ -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) {
|
||||
|
||||
-4
@@ -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)
|
||||
|
||||
@@ -19,12 +19,6 @@
|
||||
<!-- nSite network routing (Tor vs open web) -->
|
||||
<string name="napplet_net_tor_desc">This site loads over Tor. Tap to change.</string>
|
||||
<string name="napplet_net_open_desc">This site loads over the open web. Tap to change.</string>
|
||||
<string name="napplet_net_tor_title">“%1$s” loads over Tor</string>
|
||||
<string name="napplet_net_tor_message">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.</string>
|
||||
<string name="napplet_net_open_title">“%1$s” loads over the open web</string>
|
||||
<string name="napplet_net_open_message">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.</string>
|
||||
<string name="napplet_net_switch_open">Use open web</string>
|
||||
<string name="napplet_net_switch_tor">Use Tor</string>
|
||||
<!-- Short labels for the pull-down sheet's network row -->
|
||||
<string name="napplet_net_tor_label">Loads over Tor</string>
|
||||
<string name="napplet_net_open_label">Loads over the open web</string>
|
||||
@@ -37,4 +31,8 @@
|
||||
<string name="napplet_unavailable_title">Couldn\'t load “%1$s”</string>
|
||||
<string name="napplet_unavailable_subtitle">The publisher\'s servers may be offline, or you\'re not connected. You can try again.</string>
|
||||
<string name="napplet_unavailable_retry">Try again</string>
|
||||
|
||||
<!-- Developer console: page-load failures surfaced as console errors -->
|
||||
<string name="napplet_console_load_error">Failed to load (%1$d): %2$s</string>
|
||||
<string name="napplet_console_http_error">HTTP %1$d %2$s</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user