diff --git a/app/src/main/java/com/limelight/PcView.kt b/app/src/main/java/com/limelight/PcView.kt index d1a81a5f7d..b5448c6f07 100644 --- a/app/src/main/java/com/limelight/PcView.kt +++ b/app/src/main/java/com/limelight/PcView.kt @@ -18,6 +18,7 @@ import com.bumptech.glide.load.engine.DiskCacheStrategy import com.bumptech.glide.load.resource.bitmap.DownsampleStrategy import com.bumptech.glide.request.FutureTarget import com.bumptech.glide.request.RequestOptions +import com.bumptech.glide.signature.ObjectKey import com.limelight.binding.PlatformBinding import com.limelight.binding.crypto.AndroidCryptoProvider import com.limelight.computers.ComputerManagerService @@ -114,7 +115,6 @@ import android.animation.AnimatorSet import androidx.core.animation.doOnEnd import android.view.animation.DecelerateInterpolator import android.provider.Settings -import android.util.LruCache import android.view.GestureDetector import android.view.Gravity import android.view.KeyEvent @@ -216,21 +216,15 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas // Single Job owning the current async background load. Replacing it on every // reload cancels any in-flight Glide work from the previous source so a late // completion cannot overpaint the newer selection. - private data class BackgroundFutureTarget( - val cacheKey: String, - val futureTarget: FutureTarget - ) - private var backgroundLoadJob: Job? = null private val backgroundTargetLock = Any() private var backgroundLoadGeneration = 0 - private var backgroundFutureTarget: BackgroundFutureTarget? = null + private var backgroundFutureTarget: FutureTarget? = null private var lastBackgroundSource: BackgroundSource? = null private var backgroundPrefsListener: SharedPreferences.OnSharedPreferenceChangeListener? = null // Managers private var managerBinder: ComputerManagerService.ComputerManagerBinder? = null - private lateinit var bitmapLruCache: LruCache // Handlers private val refreshHandler = Handler(Looper.getMainLooper()) @@ -342,7 +336,6 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas easyTierController = EasyTierController(this, this) inForeground = true - initBitmapCache() val glPrefs = GlPreferences.readPreferences(this) if (glPrefs.savedFingerprint != Build.FINGERPRINT || glPrefs.glRenderer.isEmpty()) { @@ -409,16 +402,6 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas // Initialization Methods - private fun initBitmapCache() { - val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt() - val cacheSize = maxMemory / 8 - bitmapLruCache = object : LruCache(cacheSize) { - override fun sizeOf(key: String, value: Bitmap): Int { - return value.byteCount / 1024 - } - } - } - private fun initGlRenderer(glPrefs: GlPreferences) { val surfaceView = GLSurfaceView(this) surfaceView.setRenderer(object : GLSurfaceView.Renderer { @@ -807,9 +790,10 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas val loadGeneration = existingGeneration ?: cancelPreviousBackgroundLoad() - val source = BackgroundSource.current(this) val orientation = resources.configuration.orientation - val target = source.resolveTarget(this, orientation) + val resolved = BackgroundSource.resolveCurrentTarget(this, orientation) + val source = resolved.source + val target = resolved.target lastBackgroundSource = source if (target == null) { @@ -821,10 +805,9 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas backgroundLoadJob = uiScope.launch { try { val bitmap = withContext(Dispatchers.IO) { - decodeBackgroundBitmap(target, source, loadGeneration) + decodeBackgroundBitmap(resolved, loadGeneration) } if (isActive) { - bitmapLruCache.put(target, bitmap) applyBlurredBackground(bitmap) } } catch (_: CancellationException) { @@ -837,10 +820,6 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas } } - /** Currently resolved target (URL or file path) for the active background source, or null. */ - private fun currentBackgroundTarget(): String? = - BackgroundSource.current(this).resolveTarget(this, resources.configuration.orientation) - /** Glide target normalization: HTTP URLs go straight, filesystem paths become Files. */ private fun resolveGlideTarget(target: String): Any { if (target.startsWith("http")) return target @@ -860,17 +839,20 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas } private fun decodeBackgroundBitmap( - target: String, - source: BackgroundSource, + resolved: BackgroundSource.ResolvedTarget, loadGeneration: Int ): Bitmap { + val target = requireNotNull(resolved.target) val request = Glide.with(this@PcView as Context) .asBitmap() .load(resolveGlideTarget(target)) .skipMemoryCache(true) - .diskCacheStrategy(DiskCacheStrategy.NONE) + .signature(ObjectKey(resolved.cacheKey)) + // Keep the exact response bytes so settings can reuse the image + // selected by random-image APIs instead of issuing a second draw. + .diskCacheStrategy(DiskCacheStrategy.DATA) - val futureTarget = if (source !== BackgroundSource.Local) { + val futureTarget = if (resolved.source !== BackgroundSource.Local) { // Preserve the existing decode and cache behavior for network // backgrounds, including full-resolution long-press saves. request.submit() @@ -890,7 +872,7 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas if (loadGeneration != backgroundLoadGeneration) { false } else { - backgroundFutureTarget = BackgroundFutureTarget(target, futureTarget) + backgroundFutureTarget = futureTarget true } } @@ -904,8 +886,8 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas } /** - * Stops the previous decode only after its ImageView request and LRU entry - * have released the decoded bitmap. Returns a token for the replacement. + * Stops the previous decode after releasing both Glide targets. Returns a + * token that prevents an older request from replacing a newer wallpaper. */ private fun cancelPreviousBackgroundLoad(): Int { backgroundLoadJob?.cancel() @@ -918,11 +900,8 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas } } previousTarget?.let { - if (::bitmapLruCache.isInitialized) { - bitmapLruCache.remove(it.cacheKey) - } - it.futureTarget.cancel(true) - Glide.with(applicationContext).clear(it.futureTarget) + it.cancel(true) + Glide.with(applicationContext).clear(it) } return generation } @@ -1073,25 +1052,24 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas if (backgroundImageView == null) return val loadGeneration = cancelPreviousBackgroundLoad() + BackgroundSource.invalidateResolvedTarget() - val source = BackgroundSource.current(this) val orientation = resources.configuration.orientation - val target = source.resolveTarget(this, orientation) + val resolved = BackgroundSource.resolveCurrentTarget(this, orientation) + val source = resolved.source + val target = resolved.target lastBackgroundSource = source if (target == null) { backgroundImageView?.setImageDrawable(null) return } - bitmapLruCache.remove(target) - backgroundLoadJob = uiScope.launch { try { val bitmap = withContext(Dispatchers.IO) { - decodeBackgroundBitmap(target, source, loadGeneration) + decodeBackgroundBitmap(resolved, loadGeneration) } if (isActive) { - bitmapLruCache.put(target, bitmap) applyBlurredBackground(bitmap) if (isFromShake) { showToast(getString(R.string.background_refreshed_with_remaining, getRemainingRefreshCount())) @@ -1143,75 +1121,76 @@ class PcView : Activity(), AdapterFragmentCallbacks, ShakeDetector.Listener, Eas } private fun saveImage() { - val target = currentBackgroundTarget() - val bitmap = if (target != null) bitmapLruCache.get(target) else null - - if (bitmap == null) { - if (backgroundImageView != null && backgroundImageView?.drawable != null) { - showToast(getString(R.string.downloading_image_please_wait)) - downloadAndSaveImage() - } else { - showToast(getString(R.string.image_not_loaded_please_retry)) - } + val resolved = BackgroundSource.resolveCurrentTarget( + this, + resources.configuration.orientation + ) + if (resolved.target == null || backgroundImageView?.drawable == null) { + showToast(getString(R.string.image_not_loaded_please_retry)) return } - saveBitmapToFile(bitmap) + + showToast(getString(R.string.downloading_image_please_wait)) + saveResolvedBackground(resolved) } - private fun downloadAndSaveImage() { + /** + * Save only the bytes belonging to the wallpaper currently on screen. + * A cache miss must never fall back to a fresh network request because + * random-image endpoints can return a different wallpaper for the same URL. + */ + private fun saveResolvedBackground(resolved: BackgroundSource.ResolvedTarget) { uiScope.launch { try { - val target = currentBackgroundTarget() - if (target == null) { - showToast(getString(R.string.image_download_failed_retry)) - return@launch - } - val bitmap = withContext(Dispatchers.IO) { - Glide.with(this@PcView as Context) - .asBitmap() - .load(resolveGlideTarget(target)) - .submit() - .get() - } - if (bitmap != null) { - bitmapLruCache.put(target, bitmap) - saveBitmapToFile(bitmap) - } else { - showToast(getString(R.string.image_download_failed_retry)) + val file = withContext(Dispatchers.IO) { + saveResolvedBackgroundFromCache(resolved) } + refreshSystemPic(file) + showToast(getString(R.string.image_saved_successfully)) } catch (e: Exception) { e.printStackTrace() - showToast(getString(R.string.image_download_failed_with_error, e.message)) + showToast(getString(R.string.image_save_failed_with_error, e.message)) } } } - private fun saveBitmapToFile(bitmap: Bitmap?) { - if (bitmap == null) { - showToast(getString(R.string.image_invalid)) - return + private fun saveResolvedBackgroundFromCache( + resolved: BackgroundSource.ResolvedTarget + ): File { + val target = requireNotNull(resolved.target) + val futureTarget = Glide.with(applicationContext) + .asBitmap() + .load(resolveGlideTarget(target)) + .apply( + RequestOptions() + .signature(ObjectKey(resolved.cacheKey)) + .diskCacheStrategy(DiskCacheStrategy.DATA) + .onlyRetrieveFromCache(true) + ) + .submit() + + try { + return writeBitmapToFile(futureTarget.get()) + } finally { + Glide.with(applicationContext).clear(futureTarget) } + } + private fun writeBitmapToFile(bitmap: Bitmap): File { val dir = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), "setu") if (!dir.exists() && !dir.mkdirs()) { - showToast(getString(R.string.image_save_failed_with_error, "Failed to create directory")) - return + throw IOException("Failed to create directory") } val fileName = "pipw-${System.currentTimeMillis()}.png" val file = File(dir, fileName) - - try { - FileOutputStream(file).use { outputStream -> - bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream) - outputStream.flush() + FileOutputStream(file).use { outputStream -> + if (!bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)) { + throw IOException("Failed to encode bitmap") } - refreshSystemPic(file) - showToast(getString(R.string.image_saved_successfully)) - } catch (e: IOException) { - e.printStackTrace() - showToast(getString(R.string.image_save_failed_with_error, e.message)) + outputStream.flush() } + return file } private fun refreshSystemPic(file: File) { diff --git a/app/src/main/java/com/limelight/gamemenu/GameMenu.kt b/app/src/main/java/com/limelight/gamemenu/GameMenu.kt index a5af22b044..3be685c5f7 100644 --- a/app/src/main/java/com/limelight/gamemenu/GameMenu.kt +++ b/app/src/main/java/com/limelight/gamemenu/GameMenu.kt @@ -800,42 +800,14 @@ class GameMenu( } private fun showCardEditorDialog() { - val items = arrayOf( - getString(R.string.game_menu_tab_bitrate), - getString(R.string.game_menu_tab_audio_haptics), - getString(R.string.game_menu_tab_gyro), - getString(R.string.game_menu_tab_shortcuts) - ) - - val selected = setOfNotNull( - 0.takeIf { game.prefConfig.showBitrateCard }, - 1.takeIf { game.prefConfig.showAudioHapticsCard }, - 2.takeIf { game.prefConfig.showGyroCard }, - 3.takeIf { game.prefConfig.showQuickKeyCard } - ) - AppActionSheet.showMultiSelect( - context = game, - title = getString(R.string.game_menu_card_config_title), - actions = items.mapIndexed { index, label -> - AppActionSheet.Action(index, label, checked = index in selected) - }, - confirmLabel = getString(R.string.game_menu_ok).trim(), - cancelLabel = getString(R.string.game_menu_cancel).trim(), - minimumSelectionCount = 1, - onConfirm = { selectedIds -> - game.prefConfig.showBitrateCard = 0 in selectedIds - game.prefConfig.showAudioHapticsCard = 1 in selectedIds - game.prefConfig.showGyroCard = 2 in selectedIds - game.prefConfig.showQuickKeyCard = 3 in selectedIds - game.prefConfig.writePreferences(game) - composeUiState?.let { state -> - state.value = state.value.copy( - visibleCards = readVisibleCards(), - customKeys = getSavedCustomKeys() - ) - } + GameMenuCardVisibilityEditor.show(game, game.prefConfig) { + composeUiState?.let { state -> + state.value = state.value.copy( + visibleCards = readVisibleCards(), + customKeys = getSavedCustomKeys() + ) } - ) + } } // --- 简单的按键数据模型 --- diff --git a/app/src/main/java/com/limelight/gamemenu/GameMenuCardVisibilityEditor.kt b/app/src/main/java/com/limelight/gamemenu/GameMenuCardVisibilityEditor.kt new file mode 100644 index 0000000000..84bacf8e56 --- /dev/null +++ b/app/src/main/java/com/limelight/gamemenu/GameMenuCardVisibilityEditor.kt @@ -0,0 +1,59 @@ +package com.limelight.gamemenu + +import android.content.Context +import com.limelight.R +import com.limelight.preferences.PreferenceConfiguration +import com.limelight.utils.AppActionSheet + +/** Shared card-visibility editor used by both Settings and the in-stream menu. */ +internal object GameMenuCardVisibilityEditor { + private const val BITRATE = 0 + private const val AUDIO_HAPTICS = 1 + private const val GYRO = 2 + private const val SHORTCUTS = 3 + + fun show( + context: Context, + config: PreferenceConfiguration, + onSaved: (PreferenceConfiguration) -> Unit + ) { + val selected = selectedIds(config) + AppActionSheet.showMultiSelect( + context = context, + title = context.getString(R.string.game_menu_card_config_title), + actions = labels(context).mapIndexed { index, label -> + AppActionSheet.Action(index, label, checked = index in selected) + }, + confirmLabel = context.getString(R.string.game_menu_ok).trim(), + cancelLabel = context.getString(R.string.game_menu_cancel).trim(), + minimumSelectionCount = 1, + onConfirm = { selectedIds -> + config.showBitrateCard = BITRATE in selectedIds + config.showAudioHapticsCard = AUDIO_HAPTICS in selectedIds + config.showGyroCard = GYRO in selectedIds + config.showQuickKeyCard = SHORTCUTS in selectedIds + config.writePreferences(context) + onSaved(config) + } + ) + } + + fun selectedLabels(context: Context, config: PreferenceConfiguration): List { + val labels = labels(context) + return selectedIds(config).sorted().map(labels::get) + } + + private fun labels(context: Context): List = listOf( + context.getString(R.string.game_menu_tab_bitrate), + context.getString(R.string.game_menu_tab_audio_haptics), + context.getString(R.string.game_menu_tab_gyro), + context.getString(R.string.game_menu_tab_shortcuts) + ) + + private fun selectedIds(config: PreferenceConfiguration): Set = setOfNotNull( + BITRATE.takeIf { config.showBitrateCard }, + AUDIO_HAPTICS.takeIf { config.showAudioHapticsCard }, + GYRO.takeIf { config.showGyroCard }, + SHORTCUTS.takeIf { config.showQuickKeyCard } + ) +} diff --git a/app/src/main/java/com/limelight/preferences/BackgroundSource.kt b/app/src/main/java/com/limelight/preferences/BackgroundSource.kt index 9af0806b34..a98d49b892 100644 --- a/app/src/main/java/com/limelight/preferences/BackgroundSource.kt +++ b/app/src/main/java/com/limelight/preferences/BackgroundSource.kt @@ -7,6 +7,7 @@ import android.content.pm.PackageManager import android.content.res.Configuration import androidx.preference.PreferenceManager import java.io.File +import java.util.UUID /** * Background image source model (issue #263). @@ -21,6 +22,17 @@ import java.io.File */ sealed class BackgroundSource(val prefValue: String) { + /** + * Immutable identity of the wallpaper currently selected for display. + * [cacheKey] distinguishes successive images from random APIs even when + * their request URL is unchanged. + */ + data class ResolvedTarget( + val source: BackgroundSource, + val target: String?, + val cacheKey: String + ) + /** * Resolve the target that Glide should load for this source. * @@ -103,6 +115,8 @@ sealed class BackgroundSource(val prefValue: String) { private const val LEGACY_KEY_TYPE = "background_image_type" private val ALL = listOf(Auto, Pipw, Picsum, Api, Local, None) + private var cachedResolvedTarget: ResolvedTarget? = null + private var resolvedTargetOrientation: Int? = null fun fromPrefValue(value: String?): BackgroundSource = ALL.firstOrNull { it.prefValue == value } ?: Auto @@ -114,6 +128,33 @@ sealed class BackgroundSource(val prefValue: String) { return fromPrefValue(prefs.getString(KEY_SOURCE, null)) } + @Synchronized + fun resolveCurrentTarget(ctx: Context, orientation: Int): ResolvedTarget { + val source = current(ctx) + cachedResolvedTarget?.let { cached -> + if (cached.source.prefValue == source.prefValue && + resolvedTargetOrientation == orientation + ) { + return cached + } + } + + val resolved = ResolvedTarget( + source = source, + target = source.resolveTarget(ctx, orientation), + cacheKey = UUID.randomUUID().toString() + ) + cachedResolvedTarget = resolved + resolvedTargetOrientation = orientation + return resolved + } + + @Synchronized + fun invalidateResolvedTarget() { + cachedResolvedTarget = null + resolvedTargetOrientation = null + } + /** * Atomically switch to [source], clear unrelated keys, mark the first-run * dialog as handled, and broadcast a refresh so live UI updates. @@ -130,6 +171,7 @@ sealed class BackgroundSource(val prefValue: String) { if (source !is Api) editor.remove(KEY_API_URL) if (source !is Local) editor.remove(KEY_LOCAL_PATH) editor.apply() + invalidateResolvedTarget() ctx.sendBroadcast(Intent(ACTION_REFRESH).setPackage(ctx.packageName)) } @@ -141,6 +183,7 @@ sealed class BackgroundSource(val prefValue: String) { .putBoolean(KEY_DIALOG_SHOWN, true) .remove(LEGACY_KEY_TYPE) .apply() + invalidateResolvedTarget() ctx.sendBroadcast(Intent(ACTION_REFRESH).setPackage(ctx.packageName)) } diff --git a/app/src/main/java/com/limelight/preferences/GameMenuCardsPreference.kt b/app/src/main/java/com/limelight/preferences/GameMenuCardsPreference.kt new file mode 100644 index 0000000000..ce080ea429 --- /dev/null +++ b/app/src/main/java/com/limelight/preferences/GameMenuCardsPreference.kt @@ -0,0 +1,48 @@ +package com.limelight.preferences + +import android.content.Context +import android.util.AttributeSet +import androidx.preference.Preference +import androidx.preference.PreferenceManager +import com.limelight.R +import com.limelight.gamemenu.GameMenuCardVisibilityEditor + +/** Configures the same game-menu card visibility flags used by the in-stream editor. */ +class GameMenuCardsPreference : Preference { + + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int, defStyleRes: Int) : + super(context, attrs, defStyleAttr, defStyleRes) { initialize() } + + constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : + super(context, attrs, defStyleAttr) { initialize() } + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) { initialize() } + + constructor(context: Context) : super(context) { initialize() } + + private fun initialize() { + isPersistent = false + } + + override fun onAttachedToHierarchy(preferenceManager: PreferenceManager) { + super.onAttachedToHierarchy(preferenceManager) + updateSummary(PreferenceConfiguration.readPreferences(context)) + } + + override fun onClick() { + val config = PreferenceConfiguration.readPreferences(context) + GameMenuCardVisibilityEditor.show(context, config, ::updateSummary) + } + + private fun updateSummary(config: PreferenceConfiguration) { + val selectedNames = GameMenuCardVisibilityEditor.selectedLabels(context, config) + summary = if (selectedNames.isEmpty()) { + context.getString(R.string.summary_game_menu_cards_none) + } else { + context.getString( + R.string.summary_game_menu_cards_selected, + selectedNames.joinToString(context.getString(R.string.game_menu_card_list_separator)) + ) + } + } +} diff --git a/app/src/main/java/com/limelight/preferences/StreamSettings.kt b/app/src/main/java/com/limelight/preferences/StreamSettings.kt index 6d72b7a61a..d97bb850f0 100644 --- a/app/src/main/java/com/limelight/preferences/StreamSettings.kt +++ b/app/src/main/java/com/limelight/preferences/StreamSettings.kt @@ -68,6 +68,7 @@ import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions import com.bumptech.glide.request.RequestOptions import com.bumptech.glide.request.target.CustomTarget import com.bumptech.glide.request.transition.Transition +import com.bumptech.glide.signature.ObjectKey import com.limelight.LimeLog import com.limelight.PcView import com.limelight.R @@ -144,8 +145,6 @@ class StreamSettings : AppCompatActivity() { // HACK for Android 9 var displayCutoutP: DisplayCutout? = null - private const val SETTINGS_BG_URL = "https://raw.githubusercontent.com/qiin2333/qiin.github.io/assets/img/moonlight-bg2.webp" - /** * 获取分类对应的 Phosphor 矢量图标资源 ID(与鸿蒙项目一致)。 */ @@ -2977,36 +2976,23 @@ class StreamSettings : AppCompatActivity() { val localImagePicker = findPreference("local_image_picker") localImagePicker?.setFragment(this) - // 为背景图片API URL设置监听器,保存时设置类型为"api" + // Route API URL changes through BackgroundSource so source state, + // cache invalidation, and the live refresh stay atomic. val backgroundImageUrlPref = findPreference("background_image_url") backgroundImageUrlPref?.onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, newValue -> - val url = newValue as String - val prefs = PreferenceManager.getDefaultSharedPreferences(requireActivity()) + val context = requireActivity() + val url = (newValue as? String).orEmpty().trim() - if (url.trim().isNotEmpty()) { - // 设置为API类型,并清除本地文件配置 - prefs.edit { - putString("background_image_type", "api") - .putString("background_image_url", url.trim()) - .remove("background_image_local_path") + if (url.isNotEmpty()) { + PreferenceManager.getDefaultSharedPreferences(context).edit { + putString(BackgroundSource.KEY_API_URL, url) } - - // 发送广播通知 PcView 更新背景图片 - val broadcastIntent = Intent("com.limelight.REFRESH_BACKGROUND_IMAGE") - requireActivity().sendBroadcast(broadcastIntent) + BackgroundSource.setActivePreservingExtras(context, BackgroundSource.Api) } else { - // 恢复默认 - prefs.edit { - putString("background_image_type", "default") - .remove("background_image_url") - } - - // 发送广播通知 PcView 更新背景图片 - val broadcastIntent = Intent("com.limelight.REFRESH_BACKGROUND_IMAGE") - requireActivity().sendBroadcast(broadcastIntent) + BackgroundSource.setActive(context, BackgroundSource.Auto) } - true // 允许保存 + true } // hide on-screen controls category on non touch screen devices @@ -4360,6 +4346,17 @@ class StreamSettings : AppCompatActivity() { private fun loadBackgroundImage() { val imageView = findViewById(R.id.settingsBackgroundImage) + val resolved = BackgroundSource.resolveCurrentTarget( + this, + resources.configuration.orientation + ) + val target = resolved.target + + if (target == null) { + Glide.with(this).clear(imageView) + imageView.setImageDrawable(null) + return + } // 解码尺寸根据当前可用堆按比例约束(详见 computeBackgroundDecodeSize): // - 4K 电视 + 大堆设备保持原分辨率 @@ -4384,14 +4381,31 @@ class StreamSettings : AppCompatActivity() { .override(width, height) .format(DecodeFormat.PREFER_RGB_565) .transform(transformations) + .signature(ObjectKey(resolved.cacheKey)) .diskCacheStrategy(DiskCacheStrategy.ALL) // 候选 URL(含原始与所有代理变体)。Glide 缓存键以 URL 为基础, // 因此可能上次走代理 A 命中、原始 URL 在缓存中并不存在;这里逐个尝试, // 任一变体在缓存中就立即贴图,体验等同本地资源。 + if (!target.startsWith("http")) { + val localFile = File(target) + if (!localFile.exists()) { + imageView.setImageDrawable(null) + return + } + Glide.with(this) + .load(localFile) + .apply(options) + .transition(DrawableTransitionOptions.withCrossFade(400)) + .into(imageView) + return + } + + // Reuse the active home-screen source. Proxy variants remain fallbacks + // for remote sources so this page keeps its existing network resilience. val candidates = mutableListOf().apply { - add(SETTINGS_BG_URL) - try { addAll(UpdateManager.buildProxiedUrls(SETTINGS_BG_URL)) } catch (_: Exception) {} + add(target) + try { addAll(UpdateManager.buildProxiedUrls(target)) } catch (_: Exception) {} }.distinct() tryCachedThenNetwork(imageView, options, candidates, 0) @@ -4435,8 +4449,7 @@ class StreamSettings : AppCompatActivity() { Thread { // 代理列表可能在调用前还未就绪,需要刷新一次 UpdateManager.ensureProxyListUpdated(this) - val candidates = preBuiltCandidates?.takeIf { it.isNotEmpty() } - ?: UpdateManager.buildProxiedUrls(SETTINGS_BG_URL) + val candidates = preBuiltCandidates.orEmpty() for (url in candidates) { try { if (isDestroyed || isFinishing) return@Thread diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index b9e741c82a..3ebc2cbb56 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -416,6 +416,11 @@ 显示游戏菜单 界面设置 + 游戏菜单卡片 + 选择串流中游戏菜单显示的功能卡片 + 已显示:%1$s + 未显示任何卡片 + 备份 画面设置 语言 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8937430bba..88f8a46889 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1657,6 +1657,11 @@ Tap again to replace it. Gamepad Both Visible Cards + Game menu cards + Choose which cards appear in the in-stream game menu + Shown: %1$s + No cards shown + , Enable microphone redirection in Settings first Pan and Zoom Pan/Zoom disabled diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 183c0fe964..7697d87138 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -341,6 +341,10 @@ android:key="checkbox_small_icon_mode" android:title="@string/title_checkbox_small_icon_mode" android:summary="@string/summary_checkbox_small_icon_mode" /> +