mirror of
https://github.com/ARMSX2/ARMSX2.git
synced 2026-08-24 16:50:16 -07:00
iOS: Improve menu backgrounds, Liquid Glass navigation, game launching, and library performance
# iOS: Improve menu backgrounds, Liquid Glass navigation, game launching, and library performance ## Summary This PR contains the non-Virtual-Pad iOS changes. It stabilizes Dynamic and video backgrounds across native tab changes, retains Liquid Glass presentation state, improves the Games and BIOS interfaces, adds a card-to-gameplay transition, restores menu system chrome, and removes several sources of unnecessary library and BIOS-session work. The Virtual Pad/Dynamic Actions changes are intentionally excluded and supplied as a separate PR archive. ## Menu background lifecycle - Installs one persistent menu background renderer above the complete menu tab hierarchy. - Keeps the renderer identity and session start time stable while moving between Games, BIOS, and Settings. - Changes only presentation opacity when a selected tab has its background disabled. - Preserves video playback position and unmuted audio while the renderer is temporarily hidden. - Resumes a paused video without seeking it back to the beginning. - Avoids recreating Dynamic Background Metal surfaces, shaders, animated-image decoding, video players, or display links on normal tab changes. - Gives the Appearance preview exclusive access to a Dynamic Background renderer so the menu and preview do not render the same expensive effect simultaneously. - Keeps imported image and video backgrounds mounted during Appearance preview, preserving playback and audio state. - Fully releases the menu renderer when gameplay replaces the menu hierarchy. - Starts a fresh background session only when returning from gameplay after the previous renderer was intentionally released. ## Native tabs, navigation, and Liquid Glass - Retains Games, BIOS, and Settings pages instead of rebuilding their view hierarchies on every selection. - Uses presentation state, hit-testing, accessibility visibility, and z-order to switch between retained pages. - Keeps Games and BIOS in one persistent navigation hierarchy so their native titles and toolbar shapes retain identity. - Keeps the native tab bar in the foreground above tab content. - Adds cross-fade tab changes and horizontal swipe navigation. - Preserves the expected swipe direction in right-to-left layouts. - Protects navigation-edge gestures, controls, lists, sliders, and horizontal cover flow from accidental tab swipes. - Adds bottom scroll-content clearance so the final row or card remains above the floating native tab bar. - Preserves legacy landscape safe-area handling where SwiftUI does not report reliable horizontal insets. - Uses stable Liquid Glass containers and identity transitions to prevent an opaque UI from materializing before becoming clear glass. - Adds **Clear Liquid Glass UI** under Appearance, allowing users to choose clear or regular Liquid Glass. - Hides the retained Settings root while a child destination is presented, preventing the parent rows from appearing through the child. - Reselecting the Settings tab returns its navigation path to the main Settings screen. ## Background settings and defaults - Defaults Dynamic Background style to **PlayStation 3 XMB by Mart**. - Defaults the shared palette to **Blue** and ribbons to **Cyan**. - Enables face buttons by default. - Sets face-button amount to 50% and size to 1.3×. - Enables backgrounds by default for BIOS, Help, and Settings. - Help now follows the Settings background presentation because Help is embedded under Settings. - Selecting a picture or video background automatically disables **Use Dynamic Background**, preventing two competing sources. - Replaces a competing custom drag gesture with the Slider editing callback, allowing Dynamic Background preview sliders to remain usable on iOS 18. - Updates built-in device preset descriptions and application logic to change the Settings background option without independently overriding Help. ## Games library - Keeps **Boot BIOS** visible in the Games toolbar even before a BIOS file is imported. - Shows **BIOS not yet imported.** instead of attempting to start a game or BIOS session without firmware. - Adds localized **Boot BIOS** wording. - Adds a Liquid Glass card-to-gameplay zoom transition for selected games. - Holds the active menu background behind the zoom and fades it into the live Metal gameplay surface. - Reveals gameplay controls as the card overlay fades away. - Completes the transition without waiting indefinitely for optional startup work, avoiding coverage of game intro video. - Replaces the observable per-card frame dictionary with weak, non-observable UIKit card anchors. - Reads only the tapped card's frame when constructing its launch snapshot. - Prevents every visible card from publishing geometry into SwiftUI state while the library scrolls. - Cleans the card registry when library resources are released for gameplay. - Keeps favorite stars in front of game-card content. - Adds favorite symbol feedback and animated reordering while updating the cached library snapshot. - Keeps the **Now Running** card long enough to perform a simple fade. - After the fade completes, animates library cards into the released space. - Uses a 62 pt landscape cover-flow reservation above the tab bar. - Preserves cover metadata and cache-aware library reload behavior around gameplay. ## BIOS and gameplay lifecycle - Adds **Boot BIOS** to the BIOS toolbar. - Returns to an already-running BIOS session or offers to restart a different running VM. - Prevents game and BIOS startup when no BIOS is available. - Restores the status bar and home-indicator state when gameplay returns to the menu. - Clears fullscreen state through the native bridge before presenting menu chrome again. - Hides gameplay controls during the card transition and restores them during the final fade. - Avoids resolving a runtime pad-layout identity before the game is ready. - Detects BIOS-only sessions and avoids falling through to library matching. - Prevents BIOS Quick Menu presentation from synchronously opening every local disc image when no game disc is active. ## Help and Settings - Moves Help into the Settings About section. - Converts Settings navigation to a typed navigation path. - Adds a Help destination with the existing troubleshooting content, version information, and copy-diagnostics action. - Supports returning to the Settings root by reselecting the native Settings tab. - Keeps background and Liquid Glass state consistent while navigating between Settings destinations. ## Before and after | Area | Before | After | |---|---|---| | Tab backgrounds | Renderer could be removed/recreated between tabs | One root renderer persists across menu tabs | | Video wallpaper | Playback could restart from zero | Playback time and audio are retained | | Dynamic Background | Metal/display-link state could be rebuilt during navigation | Renderer identity remains stable until gameplay | | Liquid Glass | Opaque material could appear before clear glass | Retained containers use stable identity transitions | | Game-card scrolling | Every card frame could publish observable geometry updates | Only the tapped card's weak anchor is queried | | Game launch | Menu snapped directly into gameplay | Selected card zooms and fades into the live renderer | | Now Running removal | Card could disappear abruptly | Card fades, then the library closes the space | | BIOS Quick Menu | BIOS-only identity lookup could inspect local disc images | BIOS-only sessions bypass library matching | | Returning to menu | Gameplay fullscreen state could leave the status bar hidden | Native fullscreen and SwiftUI chrome state are restored | ## Performance and resource impact ### Tab changes - Eliminates repeated allocation and teardown of background renderers. - Reduces shader, player, decoder, and display-link churn. - Avoids resetting video queues and playback time. - Reduces tab-switch CPU/GPU spikes, transient memory allocations, battery use, and thermal load. ### Games scrolling - Removes a scroll-driven observable dictionary containing every visible game-card frame. - Card movement no longer invalidates the complete Games view through a PreferenceKey update. - Weak UIKit anchors do not retain card views after lazy stacks recycle them. - Snapshot conversion and rasterization occur only for the selected card at launch. ### Gameplay transition and teardown - The launch overlay stores one rasterized card rather than retaining the interactive card hierarchy. - Menu background resources remain only for the short transition, then are released through the existing gameplay teardown boundary. - Cover work, library state, and the card registry are released when gameplay takes ownership. - Gameplay continues with the emulator renderer after menu-only resources are dismantled. ### BIOS Quick Menu - BIOS-only sessions no longer scan candidate games or open disc images to infer a game identity. - Quick Menu presentation avoids that synchronous I/O and metadata work. ## Files changed - `platforms/ios/app/src/main/swift/Models/AppLanguage+MainTranslations.swift` - `platforms/ios/app/src/main/swift/Models/AppState.swift` - `platforms/ios/app/src/main/swift/Models/SettingsPresetCatalog.swift` - `platforms/ios/app/src/main/swift/Models/SettingsStore.swift` - `platforms/ios/app/src/main/swift/Views/BIOSListView.swift` - `platforms/ios/app/src/main/swift/Views/Background/Dynamic/DynamicBackgroundPresentation.swift` - `platforms/ios/app/src/main/swift/Views/Background/Dynamic/DynamicSettings.swift` - `platforms/ios/app/src/main/swift/Views/Background/MenuBackgroundSupport.swift` - `platforms/ios/app/src/main/swift/Views/Background/VideoBackgroundView.swift` - `platforms/ios/app/src/main/swift/Views/GameListView.swift` - `platforms/ios/app/src/main/swift/Views/GameScreenView.swift` - `platforms/ios/app/src/main/swift/Views/HelpView.swift` - `platforms/ios/app/src/main/swift/Views/RootView.swift` - `platforms/ios/app/src/main/swift/Views/Settings/AppearanceSettingsView.swift` - `platforms/ios/app/src/main/swift/Views/Settings/SettingsRootView.swift` ## Validation - The complete combined working tree builds successfully with: ```sh ./platforms/ios/scripts/build-ios-ipa.sh ``` - Swift whole-module compilation and iOS arm64 linking completed successfully. - Source whitespace validation passed. - This archive excludes IPA files, build products, historical ZIPs, unrelated PR documents, and Virtual Pad source changes. ## Suggested review checks 1. Switch repeatedly among Games, BIOS, and Settings with Dynamic, image, and video backgrounds. 2. Confirm video time and audio continue across tabs where the background is hidden. 3. Verify only one background renderer is active and that it is released when gameplay starts. 4. Test clear and regular Liquid Glass modes across tabs and Settings destinations. 5. Swipe between tabs and verify lists, sliders, navigation-edge gestures, and landscape cover flow retain their own interactions. 6. Scroll the Games library and compare smoothness with the previous per-card frame PreferenceKey implementation. 7. Launch a game and verify the selected card and background fade into gameplay without covering the intro video. 8. Add and remove a favorite and verify the star remains above the card during feedback and reordering. 9. Stop a running game and verify Now Running fades before cards slide into its former space. 10. Try starting a game and Boot BIOS without imported firmware and verify the disclaimer. 11. Boot BIOS, open Quick Menu, and verify the library is not synchronously scanned. 12. Return from gameplay and verify the status bar, signal, battery, and home-indicator behavior.
This commit is contained in:
@@ -29,7 +29,7 @@ extension AppLanguage {
|
||||
"About": "关于",
|
||||
"Import Games": "导入游戏",
|
||||
"Import BIOS": "导入 BIOS",
|
||||
"BIOS Only": "仅启动 BIOS",
|
||||
"Boot BIOS": "启动 BIOS",
|
||||
"No Games Found": "未找到游戏",
|
||||
"Import PS2 disc images to add them here.": "导入 PS2 光盘镜像后会显示在这里。",
|
||||
"No BIOS Found": "未找到 BIOS",
|
||||
@@ -149,7 +149,7 @@ extension AppLanguage {
|
||||
"About": "حول",
|
||||
"Import Games": "استيراد الألعاب",
|
||||
"Import BIOS": "استيراد BIOS",
|
||||
"BIOS Only": "BIOS فقط",
|
||||
"Boot BIOS": "تشغيل BIOS",
|
||||
"No Games Found": "لم يتم العثور على ألعاب",
|
||||
"Import PS2 disc images to add them here.": "استورد صور أقراص PS2 لإضافتها هنا.",
|
||||
"No BIOS Found": "لم يتم العثور على BIOS",
|
||||
@@ -274,7 +274,7 @@ extension AppLanguage {
|
||||
"About": "Acerca de",
|
||||
"Import Games": "Importar juegos",
|
||||
"Import BIOS": "Importar BIOS",
|
||||
"BIOS Only": "Solo BIOS",
|
||||
"Boot BIOS": "Iniciar BIOS",
|
||||
"No Games Found": "No se encontraron juegos",
|
||||
"Import PS2 disc images to add them here.": "Importa imágenes de disco de PS2 para añadirlas aquí.",
|
||||
"No BIOS Found": "No se encontró BIOS",
|
||||
@@ -390,6 +390,7 @@ extension AppLanguage {
|
||||
"About": "À propos",
|
||||
"Import Games": "Importer des jeux",
|
||||
"Import BIOS": "Importer le BIOS",
|
||||
"Boot BIOS": "Démarrer le BIOS",
|
||||
"No Games Found": "Aucun jeu trouvé",
|
||||
"No BIOS Found": "Aucun BIOS trouvé",
|
||||
"Emulator": "Émulateur",
|
||||
@@ -491,7 +492,7 @@ extension AppLanguage {
|
||||
"About": "Über",
|
||||
"Import Games": "Spiele importieren",
|
||||
"Import BIOS": "BIOS importieren",
|
||||
"BIOS Only": "Nur BIOS",
|
||||
"Boot BIOS": "BIOS starten",
|
||||
"No Games Found": "Keine Spiele gefunden",
|
||||
"Import PS2 disc images to add them here.": "Importiere PS2-Disc-Images, um sie hier hinzuzufügen.",
|
||||
"No BIOS Found": "Kein BIOS gefunden",
|
||||
@@ -611,7 +612,7 @@ extension AppLanguage {
|
||||
"About": "Informazioni",
|
||||
"Import Games": "Importa giochi",
|
||||
"Import BIOS": "Importa BIOS",
|
||||
"BIOS Only": "Solo BIOS",
|
||||
"Boot BIOS": "Avvia BIOS",
|
||||
"No Games Found": "Nessun gioco trovato",
|
||||
"Import PS2 disc images to add them here.": "Importa immagini disco PS2 per aggiungerle qui.",
|
||||
"No BIOS Found": "Nessun BIOS trovato",
|
||||
@@ -727,6 +728,7 @@ extension AppLanguage {
|
||||
"About": "Sobre",
|
||||
"Import Games": "Importar jogos",
|
||||
"Import BIOS": "Importar BIOS",
|
||||
"Boot BIOS": "Iniciar BIOS",
|
||||
"No Games Found": "Nenhum jogo encontrado",
|
||||
"No BIOS Found": "Nenhuma BIOS encontrada",
|
||||
"Emulator": "Emulador",
|
||||
@@ -819,6 +821,7 @@ extension AppLanguage {
|
||||
"About": "情報",
|
||||
"Import Games": "ゲームをインポート",
|
||||
"Import BIOS": "BIOSをインポート",
|
||||
"Boot BIOS": "BIOS を起動",
|
||||
"No Games Found": "ゲームが見つかりません",
|
||||
"No BIOS Found": "BIOSが見つかりません",
|
||||
"Emulator": "エミュレーター",
|
||||
@@ -916,6 +919,7 @@ extension AppLanguage {
|
||||
"About": "정보",
|
||||
"Import Games": "게임 가져오기",
|
||||
"Import BIOS": "BIOS 가져오기",
|
||||
"Boot BIOS": "BIOS 부팅",
|
||||
"No Games Found": "게임을 찾을 수 없음",
|
||||
"No BIOS Found": "BIOS를 찾을 수 없음",
|
||||
"Emulator": "에뮬레이터",
|
||||
|
||||
@@ -2,6 +2,17 @@
|
||||
// SPDX-License-Identifier: GPL-3.0+
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
/// A single rasterized library card retained only while a game crosses from the
|
||||
/// menu root to the live Metal surface. The card is animated above the existing
|
||||
/// menu background, which is released as soon as the transition finishes.
|
||||
struct GameplayLaunchTransition: Identifiable {
|
||||
let id = UUID()
|
||||
let snapshot: UIImage
|
||||
let sourceFrame: CGRect
|
||||
let cornerRadius: CGFloat
|
||||
}
|
||||
|
||||
struct EmulationOnlyPresentation: Equatable {
|
||||
var showsVirtualControls = false
|
||||
@@ -27,6 +38,10 @@ final class AppState: @unchecked Sendable {
|
||||
var currentScreen: Screen = .menu
|
||||
var selectedTab: Int = 0
|
||||
var runningGameName: String? = nil
|
||||
var bootDisclaimerMessage: String?
|
||||
var gameplayLaunchTransition: GameplayLaunchTransition?
|
||||
var gameplayLaunchControlsVisible = true
|
||||
var gameplayLaunchBackgroundVisible = false
|
||||
var isEmulationOnlyMode: Bool = false
|
||||
var emulationOnlyPresentation = EmulationOnlyPresentation.minimal
|
||||
private(set) var emulationOnlyStartupReady: Bool = false
|
||||
@@ -57,6 +72,7 @@ final class AppState: @unchecked Sendable {
|
||||
object: nil, queue: .main
|
||||
) { [weak self] _ in
|
||||
self?.runningGameName = nil
|
||||
self?.cancelGameplayLaunchTransition()
|
||||
self?.isEmulationOnlyMode = false
|
||||
self?.emulationOnlyPresentation = .minimal
|
||||
self?.emulationOnlyStartupReady = false
|
||||
@@ -65,6 +81,7 @@ final class AppState: @unchecked Sendable {
|
||||
action()
|
||||
} else {
|
||||
// No pending reboot — return to menu (VM crash / normal shutdown)
|
||||
self?.restoreMenuSystemChrome()
|
||||
self?.currentScreen = .menu
|
||||
}
|
||||
}
|
||||
@@ -77,6 +94,7 @@ final class AppState: @unchecked Sendable {
|
||||
self?.isEmulationOnlyMode = false
|
||||
self?.emulationOnlyPresentation = .minimal
|
||||
self?.emulationOnlyStartupReady = false
|
||||
self?.cancelGameplayLaunchTransition()
|
||||
self?.releaseMenuBackgroundResourcesForGameplay()
|
||||
self?.runningGameName = "AutoBoot"
|
||||
self?.currentScreen = .playing
|
||||
@@ -90,11 +108,22 @@ final class AppState: @unchecked Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
func bootGame(isoName: String) {
|
||||
@discardableResult
|
||||
func bootGame(
|
||||
isoName: String,
|
||||
launchTransition: GameplayLaunchTransition? = nil
|
||||
) -> Bool {
|
||||
guard requireBootableBIOS() else { return false }
|
||||
|
||||
isEmulationOnlyMode = false
|
||||
emulationOnlyPresentation = .minimal
|
||||
emulationOnlyStartupReady = false
|
||||
releaseMenuBackgroundResourcesForGameplay()
|
||||
gameplayLaunchTransition = launchTransition
|
||||
gameplayLaunchControlsVisible = launchTransition == nil
|
||||
gameplayLaunchBackgroundVisible = launchTransition != nil
|
||||
if launchTransition == nil {
|
||||
releaseMenuBackgroundResourcesForGameplay()
|
||||
}
|
||||
Task { @MainActor in
|
||||
StikDebugLauncher.autoOpenIfNeeded(reason: "game boot")
|
||||
}
|
||||
@@ -105,12 +134,17 @@ final class AppState: @unchecked Sendable {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
ARMSX2Bridge.requestVMBoot()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func bootBIOSOnly() {
|
||||
@discardableResult
|
||||
func bootBIOSOnly() -> Bool {
|
||||
guard requireBootableBIOS() else { return false }
|
||||
|
||||
isEmulationOnlyMode = false
|
||||
emulationOnlyPresentation = .minimal
|
||||
emulationOnlyStartupReady = false
|
||||
cancelGameplayLaunchTransition()
|
||||
releaseMenuBackgroundResourcesForGameplay()
|
||||
Task { @MainActor in
|
||||
StikDebugLauncher.autoOpenIfNeeded(reason: "BIOS boot")
|
||||
@@ -122,6 +156,7 @@ final class AppState: @unchecked Sendable {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
||||
ARMSX2Bridge.requestVMBoot()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func returnToMenu() {
|
||||
@@ -130,6 +165,8 @@ final class AppState: @unchecked Sendable {
|
||||
}
|
||||
isEmulationOnlyMode = false
|
||||
emulationOnlyPresentation = .minimal
|
||||
cancelGameplayLaunchTransition()
|
||||
restoreMenuSystemChrome()
|
||||
currentScreen = .menu
|
||||
// [P44-2] Restore opaque background on hosting controller
|
||||
NotificationCenter.default.post(name: NSNotification.Name("ARMSX2iOSReturnToMenu"), object: nil)
|
||||
@@ -139,6 +176,7 @@ final class AppState: @unchecked Sendable {
|
||||
if runningGameName != nil {
|
||||
isEmulationOnlyMode = false
|
||||
emulationOnlyPresentation = .minimal
|
||||
cancelGameplayLaunchTransition()
|
||||
releaseMenuBackgroundResourcesForGameplay()
|
||||
// [P44-2] Clear background so Metal surface shows through
|
||||
NotificationCenter.default.post(name: NSNotification.Name("ARMSX2iOSEnterGameScreen"), object: nil)
|
||||
@@ -148,6 +186,7 @@ final class AppState: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func shutdownAndBoot(isoName: String) {
|
||||
guard requireBootableBIOS() else { return }
|
||||
pendingBootAction = { [weak self] in
|
||||
self?.bootGame(isoName: isoName)
|
||||
}
|
||||
@@ -155,6 +194,7 @@ final class AppState: @unchecked Sendable {
|
||||
}
|
||||
|
||||
func shutdownAndBootBIOS() {
|
||||
guard requireBootableBIOS() else { return }
|
||||
pendingBootAction = { [weak self] in
|
||||
self?.bootBIOSOnly()
|
||||
}
|
||||
@@ -179,10 +219,47 @@ final class AppState: @unchecked Sendable {
|
||||
isEmulationOnlyMode = true
|
||||
}
|
||||
|
||||
func revealGameplayLaunchControls() {
|
||||
gameplayLaunchControlsVisible = true
|
||||
gameplayLaunchBackgroundVisible = false
|
||||
}
|
||||
|
||||
func completeGameplayLaunchTransition(id: UUID) {
|
||||
guard gameplayLaunchTransition?.id == id else { return }
|
||||
gameplayLaunchTransition = nil
|
||||
gameplayLaunchControlsVisible = true
|
||||
gameplayLaunchBackgroundVisible = false
|
||||
releaseMenuBackgroundResourcesForGameplay()
|
||||
}
|
||||
|
||||
func cancelGameplayLaunchTransition() {
|
||||
gameplayLaunchTransition = nil
|
||||
gameplayLaunchControlsVisible = true
|
||||
gameplayLaunchBackgroundVisible = false
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func requireBootableBIOS() -> Bool {
|
||||
guard ARMSX2Bridge.hasBIOS() else {
|
||||
bootDisclaimerMessage = "BIOS not yet imported."
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func releaseMenuBackgroundResourcesForGameplay() {
|
||||
NotificationCenter.default.post(
|
||||
name: Self.releaseMenuBackgroundResourcesNotification,
|
||||
object: nil
|
||||
)
|
||||
}
|
||||
|
||||
private func restoreMenuSystemChrome() {
|
||||
// Gameplay fullscreen belongs to SDL's root controller. Clear that
|
||||
// authoritative state before asking the child SwiftUI host to show
|
||||
// the menu status bar and home indicator again.
|
||||
ARMSX2Bridge.setFullScreen(false)
|
||||
hideStatusBar = false
|
||||
hideHomeIndicator = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,17 +33,17 @@ enum BuiltInSettingsPreset: String, CaseIterable, Identifiable {
|
||||
var detail: String {
|
||||
switch self {
|
||||
case .defaultPreset:
|
||||
return "Default disables Fast Boot, PNACH Cheats, Widescreen Patches, Fast CDVD, FXAA, CAS Sharpening, OPH Flag Hack, Emulation-Only Mode, and backgrounds in Help and Settings; restores Internal Resolution to 1x Native, Aspect Ratio to Auto, and Queue Size to 8; and selects the White Colored Virtual Pad skin."
|
||||
return "Default disables Fast Boot, PNACH Cheats, Widescreen Patches, Fast CDVD, FXAA, CAS Sharpening, OPH Flag Hack, Emulation-Only Mode, and the background in Settings; restores Internal Resolution to 1x Native, Aspect Ratio to Auto, and Queue Size to 8; and selects the White Colored Virtual Pad skin."
|
||||
case .ultraQuality:
|
||||
return "Ultra Quality enables Fast Boot, PNACH Cheats, Widescreen Patches, Fast CDVD, FXAA, CAS Sharpening, and backgrounds in Help and Settings; sets Internal Resolution to 2x (1024x896), Aspect Ratio to Stretch to Window, and Queue Size to 2. OPH Flag Hack and Emulation-Only Mode are disabled. The selected Virtual Pad skin is preserved."
|
||||
return "Ultra Quality enables Fast Boot, PNACH Cheats, Widescreen Patches, Fast CDVD, FXAA, CAS Sharpening, and the background in Settings; sets Internal Resolution to 2x (1024x896), Aspect Ratio to Stretch to Window, and Queue Size to 2. OPH Flag Hack and Emulation-Only Mode are disabled. The selected Virtual Pad skin is preserved."
|
||||
case .highQuality:
|
||||
return "High Quality uses the Ultra Quality settings, including backgrounds in Help and Settings, with Internal Resolution set to 1x Native (512x448). The selected Virtual Pad skin is preserved."
|
||||
return "High Quality uses the Ultra Quality settings, including the background in Settings, with Internal Resolution set to 1x Native (512x448). The selected Virtual Pad skin is preserved."
|
||||
case .highQuality30FPS:
|
||||
return "High Quality 30 FPS uses the High Quality graphics and emulation settings, enables OPH Flag Hack, and disables backgrounds in Help and Settings. It does not change the frame-limiter target or the selected Virtual Pad skin."
|
||||
return "High Quality 30 FPS uses the High Quality graphics and emulation settings, enables OPH Flag Hack, and disables the background in Settings. It does not change the frame-limiter target or the selected Virtual Pad skin."
|
||||
case .performance:
|
||||
return "Performance uses the High Quality settings with FXAA, CAS Sharpening, and backgrounds in Help and Settings disabled. The selected Virtual Pad skin is preserved."
|
||||
return "Performance uses the High Quality settings with FXAA, CAS Sharpening, and the background in Settings disabled. The selected Virtual Pad skin is preserved."
|
||||
case .ultraPerformance:
|
||||
return "Ultra Performance uses the Performance settings, disables backgrounds in Help and Settings, and enables Emulation-Only Mode. This preset is intended for low-end devices and requires an external controller when Virtual Control Layout unloading is enabled. The selected Virtual Pad skin is preserved."
|
||||
return "Ultra Performance uses the Performance settings, disables the background in Settings, and enables Emulation-Only Mode. This preset is intended for low-end devices and requires an external controller when Virtual Control Layout unloading is enabled. The selected Virtual Pad skin is preserved."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,8 +68,7 @@ enum BuiltInSettingsPreset: String, CaseIterable, Identifiable {
|
||||
settings.upscaleMultiplier == configuration.upscaleMultiplier &&
|
||||
settings.gameFixEnabled("OPHFlagHack") == configuration.ophFlagHack &&
|
||||
settings.emulationOnlyModeEnabled == configuration.emulationOnlyMode &&
|
||||
settings.backgroundEnabledInHelp == configuration.showBackgroundInHelpAndSettings &&
|
||||
settings.backgroundEnabledInSettings == configuration.showBackgroundInHelpAndSettings
|
||||
settings.backgroundEnabledInSettings == configuration.showBackgroundInSettings
|
||||
guard settingsMatch else { return false }
|
||||
guard self == .defaultPreset else { return true }
|
||||
return skinLibrary.selectedSkinID == VirtualPadSkin.armsx2Refresh.descriptorID &&
|
||||
@@ -93,8 +92,7 @@ enum BuiltInSettingsPreset: String, CaseIterable, Identifiable {
|
||||
settings.upscaleMultiplier = configuration.upscaleMultiplier
|
||||
settings.setGameFix("OPHFlagHack", configuration.ophFlagHack)
|
||||
settings.emulationOnlyModeEnabled = configuration.emulationOnlyMode
|
||||
settings.backgroundEnabledInHelp = configuration.showBackgroundInHelpAndSettings
|
||||
settings.backgroundEnabledInSettings = configuration.showBackgroundInHelpAndSettings
|
||||
settings.backgroundEnabledInSettings = configuration.showBackgroundInSettings
|
||||
if self == .defaultPreset {
|
||||
skinLibrary.selectSkin(id: VirtualPadSkin.armsx2Refresh.descriptorID)
|
||||
settings.virtualPadSkin = .armsx2Refresh
|
||||
@@ -119,11 +117,11 @@ enum BuiltInSettingsPreset: String, CaseIterable, Identifiable {
|
||||
)
|
||||
case .ultraQuality:
|
||||
var configuration = qualityConfiguration(upscaleMultiplier: 2)
|
||||
configuration.showBackgroundInHelpAndSettings = true
|
||||
configuration.showBackgroundInSettings = true
|
||||
return configuration
|
||||
case .highQuality:
|
||||
var configuration = qualityConfiguration(upscaleMultiplier: 1)
|
||||
configuration.showBackgroundInHelpAndSettings = true
|
||||
configuration.showBackgroundInSettings = true
|
||||
return configuration
|
||||
case .highQuality30FPS:
|
||||
var configuration = qualityConfiguration(upscaleMultiplier: 1)
|
||||
@@ -176,7 +174,7 @@ enum BuiltInSettingsPreset: String, CaseIterable, Identifiable {
|
||||
var upscaleMultiplier: Float
|
||||
var ophFlagHack: Bool
|
||||
var emulationOnlyMode: Bool
|
||||
var showBackgroundInHelpAndSettings = false
|
||||
var showBackgroundInSettings = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1743,6 +1743,11 @@ final class SettingsStore {
|
||||
var dynamicAppearancePreferences: DynamicAppearancePreferences = .standard {
|
||||
didSet { dynamicAppearancePreferences.save() }
|
||||
}
|
||||
var clearLiquidGlassUI: Bool = true {
|
||||
didSet {
|
||||
UserDefaults.standard.set(clearLiquidGlassUI, forKey: "ARMSX2iOSClearLiquidGlassUI")
|
||||
}
|
||||
}
|
||||
var backgroundPrimaryAsset: BackgroundAsset? {
|
||||
didSet {
|
||||
if let asset = backgroundPrimaryAsset {
|
||||
@@ -1782,12 +1787,12 @@ final class SettingsStore {
|
||||
UserDefaults.standard.set(backgroundEnabledInBIOS, forKey: "ARMSX2iOSBackgroundEnabledInBIOS")
|
||||
}
|
||||
}
|
||||
var backgroundEnabledInHelp: Bool = false {
|
||||
var backgroundEnabledInHelp: Bool = true {
|
||||
didSet {
|
||||
UserDefaults.standard.set(backgroundEnabledInHelp, forKey: "ARMSX2iOSBackgroundEnabledInHelp")
|
||||
}
|
||||
}
|
||||
var backgroundEnabledInSettings: Bool = false {
|
||||
var backgroundEnabledInSettings: Bool = true {
|
||||
didSet {
|
||||
UserDefaults.standard.set(backgroundEnabledInSettings, forKey: "ARMSX2iOSBackgroundEnabledInSettings")
|
||||
}
|
||||
@@ -2025,6 +2030,9 @@ final class SettingsStore {
|
||||
forKey: "ARMSX2iOSDynamicBackgroundsEnabled"
|
||||
) as? Bool ?? true
|
||||
dynamicAppearancePreferences = DynamicAppearancePreferences.load() ?? .standard
|
||||
clearLiquidGlassUI = UserDefaults.standard.object(
|
||||
forKey: "ARMSX2iOSClearLiquidGlassUI"
|
||||
) as? Bool ?? true
|
||||
backgroundPrimaryAsset = Self.loadBackgroundAsset(forKey: "ARMSX2iOSBackgroundPrimaryAsset")
|
||||
backgroundLandscapeAsset = Self.loadBackgroundAsset(forKey: "ARMSX2iOSBackgroundLandscapeAsset")
|
||||
backgroundFitMode = BackgroundFitMode(rawValue: UserDefaults.standard.string(forKey: "ARMSX2iOSBackgroundFitMode") ?? "") ?? .fill
|
||||
@@ -2032,8 +2040,8 @@ final class SettingsStore {
|
||||
backgroundVideoMuted = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundVideoMuted") as? Bool ?? true
|
||||
backgroundDim = Self.clampedBackgroundDim(UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundDim") as? Double ?? 0.0)
|
||||
backgroundEnabledInBIOS = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundEnabledInBIOS") as? Bool ?? true
|
||||
backgroundEnabledInHelp = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundEnabledInHelp") as? Bool ?? false
|
||||
backgroundEnabledInSettings = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundEnabledInSettings") as? Bool ?? false
|
||||
backgroundEnabledInHelp = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundEnabledInHelp") as? Bool ?? true
|
||||
backgroundEnabledInSettings = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundEnabledInSettings") as? Bool ?? true
|
||||
normalizeDEV9Settings()
|
||||
VPadSkinLibraryStore.shared.adoptLegacySelection(virtualPadSkin)
|
||||
ARMSX2Bridge.setINIString("EmuCore/GS", key: "AspectRatio", value: Self.aspectRatioName(for: aspectRatio))
|
||||
@@ -2252,6 +2260,9 @@ final class SettingsStore {
|
||||
forKey: "ARMSX2iOSDynamicBackgroundsEnabled"
|
||||
) as? Bool ?? true
|
||||
dynamicAppearancePreferences = DynamicAppearancePreferences.load() ?? .standard
|
||||
clearLiquidGlassUI = UserDefaults.standard.object(
|
||||
forKey: "ARMSX2iOSClearLiquidGlassUI"
|
||||
) as? Bool ?? true
|
||||
backgroundPrimaryAsset = Self.loadBackgroundAsset(forKey: "ARMSX2iOSBackgroundPrimaryAsset")
|
||||
backgroundLandscapeAsset = Self.loadBackgroundAsset(forKey: "ARMSX2iOSBackgroundLandscapeAsset")
|
||||
backgroundFitMode = BackgroundFitMode(rawValue: UserDefaults.standard.string(forKey: "ARMSX2iOSBackgroundFitMode") ?? "") ?? .fill
|
||||
@@ -2259,8 +2270,8 @@ final class SettingsStore {
|
||||
backgroundVideoMuted = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundVideoMuted") as? Bool ?? true
|
||||
backgroundDim = Self.clampedBackgroundDim(UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundDim") as? Double ?? 0.0)
|
||||
backgroundEnabledInBIOS = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundEnabledInBIOS") as? Bool ?? true
|
||||
backgroundEnabledInHelp = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundEnabledInHelp") as? Bool ?? false
|
||||
backgroundEnabledInSettings = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundEnabledInSettings") as? Bool ?? false
|
||||
backgroundEnabledInHelp = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundEnabledInHelp") as? Bool ?? true
|
||||
backgroundEnabledInSettings = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundEnabledInSettings") as? Bool ?? true
|
||||
normalizeDEV9Settings()
|
||||
VPadSkinLibraryStore.shared.adoptLegacySelection(virtualPadSkin)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,12 @@ import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct BIOSListView: View {
|
||||
let embeddedInMenuNavigation: Bool
|
||||
|
||||
init(embeddedInMenuNavigation: Bool = false) {
|
||||
self.embeddedInMenuNavigation = embeddedInMenuNavigation
|
||||
}
|
||||
|
||||
@State private var bioses: [ARMSX2BIOSInfo] = []
|
||||
@State private var defaultBIOS: String = ""
|
||||
@State private var settings = SettingsStore.shared
|
||||
@@ -12,8 +18,10 @@ struct BIOSListView: View {
|
||||
@State private var showBIOSImporter = false
|
||||
@State private var showBIOSCompatibilityImporter = false
|
||||
@State private var showBIOSReplacementAlert = false
|
||||
@State private var showRestartAlert = false
|
||||
@State private var pendingBIOSImportURLs: [URL] = []
|
||||
@State private var existingBIOSImportFileNames: [String] = []
|
||||
@State private var appState = AppState.shared
|
||||
@Environment(\.menuTabIsActive) private var menuTabIsActive
|
||||
|
||||
private var backgroundConfigured: Bool {
|
||||
@@ -25,7 +33,7 @@ struct BIOSListView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
OptionalMenuNavigationStack(embedded: embeddedInMenuNavigation) {
|
||||
ZStack {
|
||||
if backgroundConfigured {
|
||||
MenuBackgroundLayer(isActive: menuTabIsActive)
|
||||
@@ -49,10 +57,37 @@ struct BIOSListView: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.navigationTitle(settings.localized("BIOS"))
|
||||
.toolbarBackground(backgroundActive ? .hidden : .automatic, for: .navigationBar)
|
||||
.stableMenuContentGlassContainer()
|
||||
.clearNavigationContainerBackground()
|
||||
.optionalMenuNavigationChrome(
|
||||
title: settings.localized("BIOS"),
|
||||
backgroundHidden: backgroundActive,
|
||||
embedded: embeddedInMenuNavigation
|
||||
)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
if !embeddedInMenuNavigation || menuTabIsActive {
|
||||
ToolbarItem(id: "menu.bootBIOS", placement: .topBarLeading) {
|
||||
Button {
|
||||
if appState.runningGameName == "BIOS" {
|
||||
appState.returnToGame()
|
||||
} else if appState.runningGameName != nil {
|
||||
showRestartAlert = true
|
||||
} else {
|
||||
appState.bootBIOSOnly()
|
||||
}
|
||||
} label: {
|
||||
Text(settings.localized("Boot BIOS"))
|
||||
.font(.callout.weight(.semibold))
|
||||
.foregroundStyle(.blue)
|
||||
.lineLimit(1)
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
.padding(.horizontal, 8)
|
||||
.frame(minHeight: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(settings.localized("Boot BIOS"))
|
||||
}
|
||||
ToolbarItem(id: "menu.import", placement: .topBarTrailing) {
|
||||
Menu {
|
||||
Button {
|
||||
presentMenuPanel("bios_import") {
|
||||
@@ -72,13 +107,18 @@ struct BIOSListView: View {
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(settings.localized("Import BIOS"))
|
||||
}
|
||||
ToolbarItem(placement: .topBarTrailing) {
|
||||
ToolbarItem(id: "menu.refresh", placement: .topBarTrailing) {
|
||||
Button { loadBIOSes() } label: {
|
||||
Image(systemName: "arrow.clockwise")
|
||||
.foregroundStyle(.blue)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showBIOSImporter) {
|
||||
@@ -111,6 +151,18 @@ struct BIOSListView: View {
|
||||
} message: {
|
||||
Text(FileImportHandler.replacementConfirmationMessage(for: existingBIOSImportFileNames))
|
||||
}
|
||||
.alert(settings.localized("Restart VM?"), isPresented: $showRestartAlert) {
|
||||
Button(settings.localized("Cancel"), role: .cancel) {}
|
||||
Button(settings.localized("Restart"), role: .destructive) {
|
||||
appState.shutdownAndBootBIOS()
|
||||
}
|
||||
} message: {
|
||||
Text(
|
||||
"\(settings.localized("VM is currently running."))\n" +
|
||||
"\(settings.localized("Shut down and start")) " +
|
||||
"\(settings.localized("Boot BIOS"))?"
|
||||
)
|
||||
}
|
||||
}
|
||||
.onAppear { loadBIOSes() }
|
||||
.onReceive(NotificationCenter.default.publisher(for: InitialContentBootstrap.didChangeNotification)) { _ in
|
||||
|
||||
+36
-6
@@ -4,6 +4,17 @@
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
|
||||
private struct ClearLiquidGlassUIEnvironmentKey: EnvironmentKey {
|
||||
static let defaultValue = true
|
||||
}
|
||||
|
||||
extension EnvironmentValues {
|
||||
var clearLiquidGlassUIEnabled: Bool {
|
||||
get { self[ClearLiquidGlassUIEnvironmentKey.self] }
|
||||
set { self[ClearLiquidGlassUIEnvironmentKey.self] = newValue }
|
||||
}
|
||||
}
|
||||
|
||||
struct DynamicBackgroundRendererView: View {
|
||||
let preferences: DynamicAppearancePreferences
|
||||
@State private var isRenderingEnabled = true
|
||||
@@ -235,6 +246,7 @@ extension View {
|
||||
tint: Color? = nil,
|
||||
interactive: Bool = false,
|
||||
clear: Bool = false,
|
||||
materializeTransition: Bool = false,
|
||||
cornerRadius: CGFloat
|
||||
) -> some View {
|
||||
modifier(
|
||||
@@ -242,6 +254,7 @@ extension View {
|
||||
tint: tint,
|
||||
interactive: interactive,
|
||||
clear: clear,
|
||||
materializeTransition: materializeTransition,
|
||||
cornerRadius: cornerRadius
|
||||
)
|
||||
)
|
||||
@@ -249,33 +262,50 @@ extension View {
|
||||
}
|
||||
|
||||
private struct DynamicBackgroundGlassSurfaceModifier: ViewModifier {
|
||||
@Environment(\.clearLiquidGlassUIEnabled) private var clearLiquidGlassUIEnabled
|
||||
let tint: Color?
|
||||
let interactive: Bool
|
||||
let clear: Bool
|
||||
let materializeTransition: Bool
|
||||
let cornerRadius: CGFloat
|
||||
|
||||
@ViewBuilder
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
if clear {
|
||||
if clear && clearLiquidGlassUIEnabled {
|
||||
content
|
||||
.glassEffect(
|
||||
.clear.tint(tint).interactive(interactive),
|
||||
in: .rect(cornerRadius: cornerRadius)
|
||||
)
|
||||
.glassEffectTransition(
|
||||
materializeTransition ? .materialize : .identity
|
||||
)
|
||||
} else {
|
||||
content
|
||||
.glassEffect(
|
||||
.regular.tint(tint).interactive(interactive),
|
||||
in: .rect(cornerRadius: cornerRadius)
|
||||
)
|
||||
.glassEffectTransition(
|
||||
materializeTransition ? .materialize : .identity
|
||||
)
|
||||
}
|
||||
} else {
|
||||
content
|
||||
.background(
|
||||
.ultraThinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
)
|
||||
if materializeTransition {
|
||||
content
|
||||
.background(
|
||||
.ultraThinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.94)))
|
||||
} else {
|
||||
content
|
||||
.background(
|
||||
.ultraThinMaterial,
|
||||
in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,11 +90,11 @@ struct DynamicParticleSettings: Equatable, Codable {
|
||||
var multiColorAnimationSpeed = 1.0
|
||||
var multiColorAnimationSmoothness = 0.85
|
||||
var multiColorAnimationSpread = 0.35
|
||||
var faceButtonsEnabled = false
|
||||
var faceButtonAmount = 1.0
|
||||
var faceButtonsEnabled = true
|
||||
var faceButtonAmount = 0.5
|
||||
var faceButtonSpeed = 0.33
|
||||
var faceButtonDispersion = 1.0
|
||||
var faceButtonSize = 1.0
|
||||
var faceButtonSize = 1.3
|
||||
var faceButtonOpacity = 0.86
|
||||
var faceButtonRotation = 1.0
|
||||
var faceButtonPulse = 0.75
|
||||
@@ -515,11 +515,11 @@ struct DynamicAppearancePreferences: Codable, Equatable {
|
||||
var isPlayStation3XMBPresetExplicit: Bool
|
||||
|
||||
static let standard = DynamicAppearancePreferences(
|
||||
dynamicBackground: .playStation2Menu,
|
||||
sharedPalette: .xmbAzureHorizon,
|
||||
dynamicBackground: .playStation3XMBByMart,
|
||||
sharedPalette: .blue,
|
||||
sharedCustomColor: nil,
|
||||
sharedMultiColor: ThemeMultiColorSelection(),
|
||||
ribbonPalette: .xmbAzureHorizon,
|
||||
ribbonPalette: .cyan,
|
||||
ribbonCustomColor: nil,
|
||||
ribbonMultiColor: ThemeMultiColorSelection(),
|
||||
particleSettings: DynamicParticleSettings(),
|
||||
@@ -655,30 +655,32 @@ struct DynamicSettingsValueSlider: View {
|
||||
|
||||
Group {
|
||||
if let step {
|
||||
Slider(value: $value, in: range, step: step)
|
||||
Slider(
|
||||
value: $value,
|
||||
in: range,
|
||||
step: step,
|
||||
onEditingChanged: updateEditingState
|
||||
)
|
||||
} else {
|
||||
Slider(value: $value, in: range)
|
||||
Slider(
|
||||
value: $value,
|
||||
in: range,
|
||||
onEditingChanged: updateEditingState
|
||||
)
|
||||
}
|
||||
}
|
||||
.simultaneousGesture(
|
||||
DragGesture(minimumDistance: 0)
|
||||
.onChanged { _ in
|
||||
guard !isEditing else { return }
|
||||
isEditing = true
|
||||
sliderActivity.update(title, formattedValue, true)
|
||||
}
|
||||
.onEnded { _ in
|
||||
guard isEditing else { return }
|
||||
isEditing = false
|
||||
sliderActivity.update(title, formattedValue, false)
|
||||
}
|
||||
)
|
||||
.onChange(of: value) { _, _ in
|
||||
guard isEditing else { return }
|
||||
sliderActivity.update(title, formattedValue, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func updateEditingState(_ editing: Bool) {
|
||||
guard editing != isEditing else { return }
|
||||
isEditing = editing
|
||||
sliderActivity.update(title, formattedValue, editing)
|
||||
}
|
||||
}
|
||||
|
||||
struct BackgroundControlToggle: View {
|
||||
|
||||
@@ -15,160 +15,100 @@ extension EnvironmentValues {
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns exactly one live menu background renderer and moves its UIKit surface
|
||||
/// between the selected tab's background attachment point. Reparenting preserves
|
||||
/// video/display-link/Metal state instead of rebuilding those resources whenever
|
||||
/// the user selects another tab.
|
||||
/// Owns the lifecycle state for the single background renderer installed above
|
||||
/// the complete menu TabView hierarchy.
|
||||
@MainActor
|
||||
final class PersistentMenuBackgroundHost: ObservableObject {
|
||||
let sessionStart = Date()
|
||||
@Published private(set) var sessionStart = Date()
|
||||
|
||||
private var hostingController: UIHostingController<AnyView>?
|
||||
private weak var activeAttachment: UIView?
|
||||
private var hasLoadedRenderer = false
|
||||
private var selectedTabAllowsBackground = true
|
||||
private var exclusivePreviewDepth = 0
|
||||
@Published private(set) var rendererMounted: Bool
|
||||
@Published private(set) var presentationVisible = true
|
||||
@Published private var exclusivePreviewDepth = 0
|
||||
private var menuBackgroundAvailable = true
|
||||
private var releasedForGameplay = false
|
||||
|
||||
func attach(to attachment: UIView) {
|
||||
activeAttachment = attachment
|
||||
guard selectedTabAllowsBackground, exclusivePreviewDepth == 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
let controller = makeHostingControllerIfNeeded()
|
||||
loadRendererIfNeeded(in: controller)
|
||||
|
||||
controller.loadViewIfNeeded()
|
||||
guard let hostedView = controller.view else { return }
|
||||
if hostedView.superview !== attachment {
|
||||
UIView.performWithoutAnimation {
|
||||
hostedView.removeFromSuperview()
|
||||
hostedView.frame = attachment.bounds
|
||||
hostedView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
attachment.insertSubview(hostedView, at: 0)
|
||||
attachment.layoutIfNeeded()
|
||||
hostedView.layoutIfNeeded()
|
||||
}
|
||||
}
|
||||
hostedView.isHidden = false
|
||||
init() {
|
||||
let hasBackground = SettingsStore.shared.hasCustomBackground
|
||||
menuBackgroundAvailable = hasBackground
|
||||
rendererMounted = hasBackground
|
||||
}
|
||||
|
||||
func setSelectedTabAllowsBackground(_ isAllowed: Bool) {
|
||||
selectedTabAllowsBackground = isAllowed
|
||||
guard isAllowed else {
|
||||
// A disabled destination has no valid background attachment.
|
||||
// Clearing it also prevents a later enable from briefly restoring
|
||||
// the renderer into an off-screen tab retained by TabView.
|
||||
activeAttachment = nil
|
||||
unloadRenderer()
|
||||
return
|
||||
}
|
||||
/// Creates or destroys the menu renderer only when the user adds/removes
|
||||
/// the configured background itself. Tab selection never calls this.
|
||||
func setMenuBackgroundAvailable(_ isAvailable: Bool) {
|
||||
menuBackgroundAvailable = isAvailable
|
||||
rendererMounted = isAvailable
|
||||
&& !(exclusivePreviewDepth > 0 && SettingsStore.shared.dynamicBackgroundsEnabled)
|
||||
}
|
||||
|
||||
// TabView may update the destination's UIViewRepresentable before this
|
||||
// selection permission changes. In that order attach(to:) records the
|
||||
// new view but correctly refuses to load while permission is still
|
||||
// false. Retry that recorded attachment now so disabled → enabled tab
|
||||
// transitions cannot remain blank.
|
||||
guard exclusivePreviewDepth == 0, let activeAttachment else { return }
|
||||
attach(to: activeAttachment)
|
||||
/// Starts a fresh renderer session only after gameplay released the previous
|
||||
/// one. Normal tab changes retain the same session and renderer identity.
|
||||
func reactivateForMenu(isAvailable: Bool) {
|
||||
if releasedForGameplay {
|
||||
releasedForGameplay = false
|
||||
sessionStart = Date()
|
||||
}
|
||||
setMenuBackgroundAvailable(isAvailable)
|
||||
}
|
||||
|
||||
/// Hides the persistent renderer without removing it from SwiftUI. Video
|
||||
/// playback position and unmuted audio continue on background-disabled tabs.
|
||||
func setPresentationVisible(_ isVisible: Bool) {
|
||||
presentationVisible = isVisible
|
||||
}
|
||||
|
||||
/// The Appearance screen uses the same expensive renderer in its preview.
|
||||
/// Temporarily unload the full-screen copy so only one background renderer
|
||||
/// can exist while that preview is visible.
|
||||
/// Dynamic backgrounds still yield to that preview to avoid two live
|
||||
/// animation/Metal renderers. Imported image/video backgrounds remain
|
||||
/// mounted but hidden so unmuted video audio and playback time are retained.
|
||||
func beginExclusivePreview() {
|
||||
exclusivePreviewDepth += 1
|
||||
if exclusivePreviewDepth == 1 {
|
||||
unloadRenderer()
|
||||
if exclusivePreviewDepth == 1 && SettingsStore.shared.dynamicBackgroundsEnabled {
|
||||
rendererMounted = false
|
||||
}
|
||||
}
|
||||
|
||||
func endExclusivePreview() {
|
||||
exclusivePreviewDepth = max(0, exclusivePreviewDepth - 1)
|
||||
guard exclusivePreviewDepth == 0,
|
||||
selectedTabAllowsBackground,
|
||||
let activeAttachment else {
|
||||
return
|
||||
if exclusivePreviewDepth == 0 {
|
||||
rendererMounted = menuBackgroundAvailable
|
||||
}
|
||||
attach(to: activeAttachment)
|
||||
}
|
||||
|
||||
/// Stops the renderer only when the selected destination is configured not
|
||||
/// to show a background. Normal tab changes between enabled destinations do
|
||||
/// not call this method.
|
||||
/// Stops the renderer when the complete menu hierarchy is being released.
|
||||
/// Normal tab changes and per-tab visibility toggles do not call this.
|
||||
func suspend() {
|
||||
activeAttachment = nil
|
||||
unloadRenderer()
|
||||
rendererMounted = false
|
||||
}
|
||||
|
||||
private func unloadRenderer() {
|
||||
hostingController?.view.removeFromSuperview()
|
||||
guard let hostingController, hasLoadedRenderer else { return }
|
||||
hostingController.rootView = AnyView(Color.clear)
|
||||
hasLoadedRenderer = false
|
||||
}
|
||||
|
||||
/// Destroys the hosted SwiftUI tree when the complete menu hierarchy leaves
|
||||
/// the root (for example, when gameplay begins).
|
||||
/// The surrounding MenuTabView is also removed at gameplay start, ensuring
|
||||
/// SwiftUI dismantles video, animated-image, display-link, and Metal views.
|
||||
func release() {
|
||||
releasedForGameplay = true
|
||||
menuBackgroundAvailable = false
|
||||
exclusivePreviewDepth = 0
|
||||
suspend()
|
||||
hostingController = nil
|
||||
}
|
||||
|
||||
private func makeHostingControllerIfNeeded() -> UIHostingController<AnyView> {
|
||||
if let hostingController {
|
||||
return hostingController
|
||||
}
|
||||
|
||||
let controller = UIHostingController(rootView: AnyView(Color.clear))
|
||||
controller.view.backgroundColor = .clear
|
||||
controller.view.isOpaque = false
|
||||
controller.view.isUserInteractionEnabled = false
|
||||
hostingController = controller
|
||||
return controller
|
||||
var shouldShowRenderer: Bool {
|
||||
rendererMounted && presentationVisible && exclusivePreviewDepth == 0
|
||||
}
|
||||
}
|
||||
|
||||
private func loadRendererIfNeeded(in controller: UIHostingController<AnyView>) {
|
||||
guard !hasLoadedRenderer else { return }
|
||||
controller.rootView = AnyView(
|
||||
struct PersistentMenuBackgroundLayer: View {
|
||||
@ObservedObject var host: PersistentMenuBackgroundHost
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
if host.rendererMounted {
|
||||
GeometryReader { geometry in
|
||||
BackgroundContainerView(size: geometry.size)
|
||||
}
|
||||
.environment(\.menuBackgroundSessionStart, sessionStart)
|
||||
.environment(\.menuBackgroundSessionStart, host.sessionStart)
|
||||
.opacity(host.shouldShowRenderer ? 1 : 0)
|
||||
.ignoresSafeArea()
|
||||
.accessibilityHidden(true)
|
||||
.allowsHitTesting(false)
|
||||
)
|
||||
hasLoadedRenderer = true
|
||||
}
|
||||
}
|
||||
|
||||
private final class MenuBackgroundAttachmentView: UIView {
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
backgroundColor = .clear
|
||||
isOpaque = false
|
||||
isUserInteractionEnabled = false
|
||||
clipsToBounds = true
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
private struct PersistentMenuBackgroundAttachment: UIViewRepresentable {
|
||||
let host: PersistentMenuBackgroundHost
|
||||
let isActive: Bool
|
||||
|
||||
func makeUIView(context: Context) -> MenuBackgroundAttachmentView {
|
||||
MenuBackgroundAttachmentView()
|
||||
}
|
||||
|
||||
func updateUIView(_ uiView: MenuBackgroundAttachmentView, context: Context) {
|
||||
if isActive {
|
||||
host.attach(to: uiView)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,18 +119,11 @@ struct MenuBackgroundLayer: View {
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
if let persistentHost {
|
||||
PersistentMenuBackgroundAttachment(
|
||||
host: persistentHost,
|
||||
isActive: isActive
|
||||
)
|
||||
if persistentHost != nil {
|
||||
Color.clear
|
||||
.ignoresSafeArea()
|
||||
.accessibilityHidden(true)
|
||||
.allowsHitTesting(false)
|
||||
.transaction { transaction in
|
||||
transaction.animation = nil
|
||||
transaction.disablesAnimations = true
|
||||
}
|
||||
} else if isActive {
|
||||
GeometryReader { geometry in
|
||||
BackgroundContainerView(size: geometry.size)
|
||||
@@ -202,6 +135,77 @@ struct MenuBackgroundLayer: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets Games and BIOS publish their existing navigation/toolbar preferences
|
||||
/// into MenuTabView's one persistent NavigationStack. Standalone previews and
|
||||
/// Catalyst call sites retain their original self-contained navigation stack.
|
||||
struct OptionalMenuNavigationStack<Content: View>: View {
|
||||
let embedded: Bool
|
||||
let content: Content
|
||||
|
||||
init(embedded: Bool, @ViewBuilder content: () -> Content) {
|
||||
self.embedded = embedded
|
||||
self.content = content()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
if embedded {
|
||||
content
|
||||
} else {
|
||||
NavigationStack {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct OptionalMenuNavigationChromeModifier: ViewModifier {
|
||||
let title: String
|
||||
let backgroundHidden: Bool
|
||||
let embedded: Bool
|
||||
|
||||
@ViewBuilder
|
||||
func body(content: Content) -> some View {
|
||||
if embedded {
|
||||
content
|
||||
} else {
|
||||
content
|
||||
.navigationTitle(title)
|
||||
.toolbarBackground(
|
||||
backgroundHidden ? .hidden : .automatic,
|
||||
for: .navigationBar
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ClearNavigationContainerBackgroundModifier: ViewModifier {
|
||||
@ViewBuilder
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 18.0, *) {
|
||||
content.containerBackground(Color.clear, for: .navigation)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps a tab's custom glass scene attached below its NavigationStack chrome.
|
||||
/// MenuTabView retains every tab page, so this container is not dismantled when
|
||||
/// another tab is selected.
|
||||
private struct StableMenuContentGlassContainerModifier: ViewModifier {
|
||||
@ViewBuilder
|
||||
func body(content: Content) -> some View {
|
||||
if #available(iOS 26.0, *) {
|
||||
GlassEffectContainer(spacing: 0) {
|
||||
content
|
||||
}
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MenuBackgroundListRowModifier: ViewModifier {
|
||||
let isEnabled: Bool
|
||||
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
||||
@@ -244,6 +248,28 @@ struct GameCardTintMenuBackgroundListRowModifier: ViewModifier {
|
||||
}
|
||||
|
||||
extension View {
|
||||
func optionalMenuNavigationChrome(
|
||||
title: String,
|
||||
backgroundHidden: Bool,
|
||||
embedded: Bool
|
||||
) -> some View {
|
||||
modifier(
|
||||
OptionalMenuNavigationChromeModifier(
|
||||
title: title,
|
||||
backgroundHidden: backgroundHidden,
|
||||
embedded: embedded
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func stableMenuContentGlassContainer() -> some View {
|
||||
modifier(StableMenuContentGlassContainerModifier())
|
||||
}
|
||||
|
||||
func clearNavigationContainerBackground() -> some View {
|
||||
modifier(ClearNavigationContainerBackgroundModifier())
|
||||
}
|
||||
|
||||
func menuBackgroundListRow(_ isEnabled: Bool) -> some View {
|
||||
modifier(MenuBackgroundListRowModifier(isEnabled: isEnabled))
|
||||
}
|
||||
|
||||
@@ -103,7 +103,10 @@ private final class LoopingVideoView: UIView {
|
||||
|
||||
@objc private func resumeIfReady() {
|
||||
guard UIApplication.shared.applicationState != .background, !ProcessInfo.processInfo.isLowPowerModeEnabled else { return }
|
||||
player?.seek(to: .zero)
|
||||
// When this view remains mounted, AVQueuePlayer retains its current
|
||||
// item time while paused. Do not restart it after a temporary pause
|
||||
// such as Low Power Mode. A full inactive-scene teardown is handled by
|
||||
// BackgroundContainerView and intentionally creates a new session.
|
||||
player?.play()
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,15 @@ import GameController
|
||||
private let runtimeMenuStateChangedNotification = Notification.Name("ARMSX2iOSRuntimeMenuStateChanged")
|
||||
private let retroAchievementsToastNotification = Notification.Name("ARMSX2RetroAchievementsNotification")
|
||||
|
||||
private extension View {
|
||||
func gameplayLaunchChrome(visible: Bool) -> some View {
|
||||
opacity(visible ? 1 : 0)
|
||||
.allowsHitTesting(visible)
|
||||
.accessibilityHidden(!visible)
|
||||
.animation(.easeOut(duration: 0.30), value: visible)
|
||||
}
|
||||
}
|
||||
|
||||
private enum EmulationOnlyNativeReleaseFlag {
|
||||
// Keep these bit positions synchronized with VMManager.h.
|
||||
static let patches: UInt = 1 << 0
|
||||
@@ -165,6 +174,7 @@ struct EmulationOnlyGameView: View {
|
||||
skinDescriptor: appState.emulationOnlyPresentation.padSkinDescriptor,
|
||||
touchActionSession: touchActionSession
|
||||
)
|
||||
.gameplayLaunchChrome(visible: appState.gameplayLaunchControlsVisible)
|
||||
}
|
||||
|
||||
private var dynamicCrosshairOverlay: some View {
|
||||
@@ -173,6 +183,7 @@ struct EmulationOnlyGameView: View {
|
||||
leftRuntime: touchActionSession.left.crosshairState,
|
||||
rightRuntime: touchActionSession.right.crosshairState
|
||||
)
|
||||
.gameplayLaunchChrome(visible: appState.gameplayLaunchControlsVisible)
|
||||
}
|
||||
|
||||
private func preparePresentation() {
|
||||
@@ -191,6 +202,10 @@ struct EmulationOnlyGameView: View {
|
||||
}
|
||||
|
||||
private func releasePresentation() {
|
||||
if case .menu = appState.currentScreen {
|
||||
appState.hideStatusBar = false
|
||||
appState.hideHomeIndicator = false
|
||||
}
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
}
|
||||
}
|
||||
@@ -338,9 +353,11 @@ struct GameScreenView: View {
|
||||
touchActionSession: touchActionSession
|
||||
)
|
||||
.id(padRebuildToken)
|
||||
.gameplayLaunchChrome(visible: appState.gameplayLaunchControlsVisible)
|
||||
}
|
||||
dynamicCrosshairOverlay
|
||||
menuButtonOverlay(isLandscape: true)
|
||||
.gameplayLaunchChrome(visible: appState.gameplayLaunchControlsVisible)
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
} else {
|
||||
@@ -373,6 +390,7 @@ struct GameScreenView: View {
|
||||
touchActionSession: touchActionSession
|
||||
)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.gameplayLaunchChrome(visible: appState.gameplayLaunchControlsVisible)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.id(padRebuildToken)
|
||||
@@ -383,6 +401,7 @@ struct GameScreenView: View {
|
||||
menuButton()
|
||||
.padding(.top, 8)
|
||||
.padding(.trailing, 4)
|
||||
.gameplayLaunchChrome(visible: appState.gameplayLaunchControlsVisible)
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea(.container, edges: .bottom)
|
||||
@@ -699,6 +718,7 @@ struct GameScreenView: View {
|
||||
leftRuntime: touchActionSession.left.crosshairState,
|
||||
rightRuntime: touchActionSession.right.crosshairState
|
||||
)
|
||||
.gameplayLaunchChrome(visible: appState.gameplayLaunchControlsVisible)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -819,8 +839,13 @@ struct GameScreenView: View {
|
||||
}
|
||||
|
||||
private func leaveGameplaySystemChromeMode() {
|
||||
appState.hideHomeIndicator = previousHideHomeIndicator
|
||||
appState.hideStatusBar = previousHideStatusBar
|
||||
if case .menu = appState.currentScreen {
|
||||
appState.hideHomeIndicator = false
|
||||
appState.hideStatusBar = false
|
||||
} else {
|
||||
appState.hideHomeIndicator = previousHideHomeIndicator
|
||||
appState.hideStatusBar = previousHideStatusBar
|
||||
}
|
||||
// Allow the screen to auto-sleep again once gameplay ends.
|
||||
UIApplication.shared.isIdleTimerDisabled = false
|
||||
}
|
||||
@@ -993,7 +1018,7 @@ struct GameScreenView: View {
|
||||
if gameMenuAvailable != gameReady {
|
||||
gameMenuAvailable = gameReady
|
||||
}
|
||||
let identity = runtimePadLayoutIdentityForCurrentGame()
|
||||
let identity = gameReady ? runtimePadLayoutIdentityForCurrentGame() : nil
|
||||
if runtimePadLayoutIdentity != identity {
|
||||
runtimePadLayoutIdentity = identity
|
||||
}
|
||||
@@ -1013,6 +1038,14 @@ struct GameScreenView: View {
|
||||
return gameName
|
||||
}
|
||||
|
||||
// A BIOS-only session has no game identity. Avoid falling through to the
|
||||
// library-matching path, which synchronously opens every local disc image.
|
||||
// Once a disc is inserted, gameMenuAvailable becomes true and the normal
|
||||
// game-name resolution path resumes.
|
||||
if appState.runningGameName == "BIOS" && !gameMenuAvailable {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let gameName = normalizedRuntimeGameName(ARMSX2Bridge.currentGameISOName()) {
|
||||
return gameName
|
||||
}
|
||||
|
||||
@@ -102,13 +102,12 @@ private let helpData: [HelpSection] = [
|
||||
struct HelpView: View {
|
||||
@State private var settings = SettingsStore.shared
|
||||
@State private var copyStatusMessage: String?
|
||||
@Environment(\.menuTabIsActive) private var menuTabIsActive
|
||||
#if targetEnvironment(macCatalyst)
|
||||
@State private var selectedTopic: HelpTopic? = .item(section: 0, item: 0)
|
||||
#endif
|
||||
|
||||
private var backgroundConfigured: Bool {
|
||||
settings.hasCustomBackground && settings.backgroundEnabledInHelp
|
||||
settings.hasCustomBackground && settings.backgroundEnabledInSettings
|
||||
}
|
||||
|
||||
private var backgroundActive: Bool {
|
||||
@@ -148,64 +147,51 @@ struct HelpView: View {
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.containerBackground(backgroundActive ? Color.clear : Color(uiColor: .systemGroupedBackground), for: .navigation)
|
||||
#else
|
||||
NavigationStack {
|
||||
ZStack {
|
||||
if backgroundConfigured {
|
||||
MenuBackgroundLayer(isActive: menuTabIsActive)
|
||||
}
|
||||
|
||||
List {
|
||||
ForEach(helpData) { section in
|
||||
Section {
|
||||
ForEach(section.items) { item in
|
||||
DisclosureGroup {
|
||||
Text(settings.localized(item.answer))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 4)
|
||||
} label: {
|
||||
Text(settings.localized(item.question))
|
||||
.font(.body)
|
||||
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
}
|
||||
} header: {
|
||||
Label(settings.localized(section.title), systemImage: section.icon)
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Text(settings.localized("Version"))
|
||||
Spacer()
|
||||
Text(ARMSX2Bridge.buildVersion())
|
||||
List {
|
||||
ForEach(helpData) { section in
|
||||
Section {
|
||||
ForEach(section.items) { item in
|
||||
DisclosureGroup {
|
||||
Text(settings.localized(item.answer))
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
Button {
|
||||
copyTroubleshootingInfo()
|
||||
.padding(.vertical, 4)
|
||||
} label: {
|
||||
Label(settings.localized("Copy Troubleshooting Info"), systemImage: "doc.on.doc")
|
||||
Text(settings.localized(item.question))
|
||||
.font(.body)
|
||||
.frame(maxWidth: .infinity, minHeight: 44, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
if let copyStatusMessage {
|
||||
Text(settings.localized(copyStatusMessage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
}
|
||||
} header: {
|
||||
Label(settings.localized("About"), systemImage: "info.circle")
|
||||
}
|
||||
} header: {
|
||||
Label(settings.localized(section.title), systemImage: section.icon)
|
||||
}
|
||||
.scrollContentBackground(backgroundActive ? .hidden : .automatic)
|
||||
}
|
||||
.navigationTitle(settings.localized("Help"))
|
||||
.toolbarBackground(backgroundActive ? .hidden : .automatic, for: .navigationBar)
|
||||
|
||||
Section {
|
||||
HStack {
|
||||
Text(settings.localized("Version"))
|
||||
Spacer()
|
||||
Text(ARMSX2Bridge.buildVersion())
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
Button {
|
||||
copyTroubleshootingInfo()
|
||||
} label: {
|
||||
Label(settings.localized("Copy Troubleshooting Info"), systemImage: "doc.on.doc")
|
||||
}
|
||||
if let copyStatusMessage {
|
||||
Text(settings.localized(copyStatusMessage))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} header: {
|
||||
Label(settings.localized("About"), systemImage: "info.circle")
|
||||
}
|
||||
}
|
||||
.navigationTitle(settings.localized("Help"))
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -109,16 +109,23 @@ struct AppearanceSettingsView: View {
|
||||
Toggle(isOn: $settings.backgroundEnabledInBIOS) {
|
||||
Label(settings.localized("BIOS"), systemImage: "cpu")
|
||||
}
|
||||
Toggle(isOn: $settings.backgroundEnabledInHelp) {
|
||||
Label(settings.localized("Help"), systemImage: "questionmark.circle")
|
||||
}
|
||||
Toggle(isOn: $settings.backgroundEnabledInSettings) {
|
||||
Label(settings.localized("Settings"), systemImage: "gearshape")
|
||||
}
|
||||
} header: {
|
||||
Text(settings.localized("Show Background In"))
|
||||
} footer: {
|
||||
Text(settings.localized("The background also shows behind the Games library. Each tab can be toggled independently. Dim or mute from the settings above."))
|
||||
Text(settings.localized("The background always shows behind Games. BIOS and Settings can be toggled independently. Dim or mute from the settings above."))
|
||||
}
|
||||
|
||||
Section {
|
||||
Toggle(isOn: $settings.clearLiquidGlassUI) {
|
||||
Label(settings.localized("Clear Liquid Glass UI"), systemImage: "rectangle.on.rectangle")
|
||||
}
|
||||
} header: {
|
||||
Text(settings.localized("Interface"))
|
||||
} footer: {
|
||||
Text(settings.localized("Uses the clear Liquid Glass style for menu cards and controls. Turn this off to use the more opaque regular Liquid Glass style."))
|
||||
}
|
||||
}
|
||||
.navigationTitle(settings.localized("Appearance"))
|
||||
@@ -133,19 +140,16 @@ struct AppearanceSettingsView: View {
|
||||
.presentationDetents([.large])
|
||||
}
|
||||
.onAppear {
|
||||
if !ownsExclusiveBackgroundPreview {
|
||||
menuBackgroundHost?.beginExclusivePreview()
|
||||
ownsExclusiveBackgroundPreview = true
|
||||
}
|
||||
isAppearanceVisible = true
|
||||
synchronizeExclusivePreview()
|
||||
dynamicPreferences = settings.dynamicAppearancePreferences
|
||||
}
|
||||
.onChange(of: menuTabIsActive) { _, _ in
|
||||
synchronizeExclusivePreview()
|
||||
}
|
||||
.onDisappear {
|
||||
isAppearanceVisible = false
|
||||
if ownsExclusiveBackgroundPreview {
|
||||
menuBackgroundHost?.endExclusivePreview()
|
||||
ownsExclusiveBackgroundPreview = false
|
||||
}
|
||||
synchronizeExclusivePreview()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,6 +159,21 @@ struct AppearanceSettingsView: View {
|
||||
&& presentedEditor == nil
|
||||
}
|
||||
|
||||
private func synchronizeExclusivePreview() {
|
||||
let shouldOwnPreview = isAppearanceVisible && menuTabIsActive
|
||||
guard shouldOwnPreview != ownsExclusiveBackgroundPreview,
|
||||
let menuBackgroundHost else {
|
||||
return
|
||||
}
|
||||
|
||||
if shouldOwnPreview {
|
||||
menuBackgroundHost.beginExclusivePreview()
|
||||
} else {
|
||||
menuBackgroundHost.endExclusivePreview()
|
||||
}
|
||||
ownsExclusiveBackgroundPreview = shouldOwnPreview
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var portraitFitModeHint: some View {
|
||||
switch settings.backgroundFitMode {
|
||||
@@ -198,11 +217,13 @@ struct AppearanceSettingsView: View {
|
||||
|
||||
private func updatePrimary(_ asset: BackgroundAsset?) {
|
||||
if asset == nil { BackgroundStorage.remove(settings.backgroundPrimaryAsset) }
|
||||
if asset != nil { settings.dynamicBackgroundsEnabled = false }
|
||||
settings.backgroundPrimaryAsset = asset
|
||||
}
|
||||
|
||||
private func updateLandscape(_ asset: BackgroundAsset?) {
|
||||
if asset == nil { BackgroundStorage.remove(settings.backgroundLandscapeAsset) }
|
||||
if asset != nil { settings.dynamicBackgroundsEnabled = false }
|
||||
settings.backgroundLandscapeAsset = asset
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ private enum SettingsPane: String, CaseIterable, Identifiable {
|
||||
case gameController
|
||||
case localMultiplayer
|
||||
case virtualPad
|
||||
case help
|
||||
case licenses
|
||||
case about
|
||||
|
||||
@@ -59,6 +60,8 @@ private enum SettingsPane: String, CaseIterable, Identifiable {
|
||||
return "Local Multiplayer"
|
||||
case .virtualPad:
|
||||
return "Virtual Pad"
|
||||
case .help:
|
||||
return "Help"
|
||||
case .licenses:
|
||||
return "Licenses & Credits"
|
||||
case .about:
|
||||
@@ -98,6 +101,8 @@ private enum SettingsPane: String, CaseIterable, Identifiable {
|
||||
return "person.3"
|
||||
case .virtualPad:
|
||||
return "hand.draw"
|
||||
case .help:
|
||||
return "questionmark.circle"
|
||||
case .licenses:
|
||||
return "doc.text"
|
||||
case .about:
|
||||
@@ -107,16 +112,22 @@ private enum SettingsPane: String, CaseIterable, Identifiable {
|
||||
}
|
||||
|
||||
struct SettingsRootView: View {
|
||||
let resetToRootRequest: Int
|
||||
@State private var settings = SettingsStore.shared
|
||||
@State private var jitAvailable = ARMSX2Bridge.isJITAvailable()
|
||||
@State private var noJITFallbackActive = ARMSX2Bridge.isNoJITFallbackActive()
|
||||
@State private var stikDebugOpenFailed = false
|
||||
@State private var stikDebugOpenInProgress = false
|
||||
@State private var navigationPath: [SettingsPane] = []
|
||||
@Environment(\.menuTabIsActive) private var menuTabIsActive
|
||||
#if targetEnvironment(macCatalyst)
|
||||
@State private var selectedPane: SettingsPane? = .emulator
|
||||
#endif
|
||||
|
||||
init(resetToRootRequest: Int = 0) {
|
||||
self.resetToRootRequest = resetToRootRequest
|
||||
}
|
||||
|
||||
private var backgroundConfigured: Bool {
|
||||
settings.hasCustomBackground && settings.backgroundEnabledInSettings
|
||||
}
|
||||
@@ -141,6 +152,7 @@ struct SettingsRootView: View {
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.containerBackground(backgroundActive ? Color.clear : Color(uiColor: .systemGroupedBackground), for: .navigation)
|
||||
#else
|
||||
NavigationStack(path: $navigationPath) {
|
||||
ZStack {
|
||||
if backgroundConfigured {
|
||||
MenuBackgroundLayer(isActive: menuTabIsActive)
|
||||
@@ -148,105 +160,75 @@ struct SettingsRootView: View {
|
||||
|
||||
List {
|
||||
Section(settings.localized("Interface")) {
|
||||
NavigationLink {
|
||||
LanguageSettingsView()
|
||||
} label: {
|
||||
NavigationLink(value: SettingsPane.language) {
|
||||
Label(settings.localized("Language"), systemImage: "globe")
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
NavigationLink {
|
||||
AppearanceSettingsView()
|
||||
} label: {
|
||||
NavigationLink(value: SettingsPane.appearance) {
|
||||
Label(settings.localized("Appearance"), systemImage: "paintpalette")
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
}
|
||||
|
||||
Section(settings.localized("Emulation")) {
|
||||
NavigationLink {
|
||||
EmulatorSettingsView()
|
||||
} label: {
|
||||
NavigationLink(value: SettingsPane.emulator) {
|
||||
Label(settings.localized("Emulator"), systemImage: "cpu")
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
NavigationLink {
|
||||
GraphicsSettingsView()
|
||||
} label: {
|
||||
NavigationLink(value: SettingsPane.graphics) {
|
||||
Label(settings.localized("Graphics"), systemImage: "paintbrush")
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
NavigationLink {
|
||||
FramePacingSettingsView()
|
||||
} label: {
|
||||
NavigationLink(value: SettingsPane.framePacing) {
|
||||
Label(settings.localized("Frame Pacing"), systemImage: "speedometer")
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
NavigationLink {
|
||||
AudioSettingsView()
|
||||
} label: {
|
||||
NavigationLink(value: SettingsPane.audio) {
|
||||
Label(settings.localized("Audio"), systemImage: "speaker.wave.2")
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
}
|
||||
|
||||
Section(settings.localized("Input")) {
|
||||
NavigationLink {
|
||||
GamepadSettingsView()
|
||||
} label: {
|
||||
NavigationLink(value: SettingsPane.gameController) {
|
||||
Label(settings.localized("Game Controller"), systemImage: "gamecontroller")
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
NavigationLink {
|
||||
VirtualPadSettingsView()
|
||||
} label: {
|
||||
NavigationLink(value: SettingsPane.virtualPad) {
|
||||
Label(settings.localized("Virtual Pad"), systemImage: "hand.draw")
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
NavigationLink {
|
||||
LocalMultiplayerSettingsView()
|
||||
} label: {
|
||||
NavigationLink(value: SettingsPane.localMultiplayer) {
|
||||
Label(settings.localized("Local Multiplayer"), systemImage: "person.3")
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
}
|
||||
|
||||
Section(settings.localized("Storage & Memory")) {
|
||||
NavigationLink {
|
||||
MemoryCardSettingsView()
|
||||
} label: {
|
||||
NavigationLink(value: SettingsPane.memoryCards) {
|
||||
Label(settings.localized("Memory Cards"), systemImage: "memorychip")
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
NavigationLink {
|
||||
StorageSettingsView()
|
||||
} label: {
|
||||
NavigationLink(value: SettingsPane.storage) {
|
||||
Label(settings.localized("Storage"), systemImage: "internaldrive")
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
NavigationLink {
|
||||
NetworkSettingsView()
|
||||
} label: {
|
||||
NavigationLink(value: SettingsPane.network) {
|
||||
Label(settings.localized("Network"), systemImage: "network")
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
}
|
||||
|
||||
Section(settings.localized("Features")) {
|
||||
NavigationLink {
|
||||
SettingsPresetsView()
|
||||
} label: {
|
||||
NavigationLink(value: SettingsPane.settingsPresets) {
|
||||
Label(settings.localized("Settings Presets"), systemImage: "slider.horizontal.3")
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
NavigationLink {
|
||||
RetroAchievementsSettingsView()
|
||||
} label: {
|
||||
NavigationLink(value: SettingsPane.retroAchievements) {
|
||||
Label(settings.localized("RetroAchievements"), systemImage: "trophy")
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
NavigationLink {
|
||||
OverlaySettingsView()
|
||||
} label: {
|
||||
NavigationLink(value: SettingsPane.overlay) {
|
||||
Label(settings.localized("Overlay (OSD)"), systemImage: "text.below.photo")
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
@@ -279,9 +261,7 @@ struct SettingsRootView: View {
|
||||
}
|
||||
|
||||
Section {
|
||||
NavigationLink {
|
||||
LicenseView()
|
||||
} label: {
|
||||
NavigationLink(value: SettingsPane.licenses) {
|
||||
Label(settings.localized("Licenses & Credits"), systemImage: "doc.text")
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
@@ -296,10 +276,21 @@ struct SettingsRootView: View {
|
||||
.font(.caption)
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
|
||||
NavigationLink(value: SettingsPane.help) {
|
||||
Label(settings.localized("Help"), systemImage: "questionmark.circle")
|
||||
}
|
||||
.gameCardTintMenuBackgroundListRow(backgroundActive)
|
||||
}
|
||||
}
|
||||
.scrollContentBackground(backgroundActive ? .hidden : .automatic)
|
||||
}
|
||||
.stableMenuContentGlassContainer()
|
||||
// NavigationStack retains its root while a destination is pushed. The
|
||||
// clear destination would otherwise reveal the root Settings rows
|
||||
// underneath it, making both interfaces appear at once.
|
||||
.opacity(navigationPath.isEmpty ? 1 : 0)
|
||||
.clearNavigationContainerBackground()
|
||||
.navigationTitle(settings.localized("Settings"))
|
||||
.toolbarBackground(backgroundActive ? .hidden : .automatic, for: .navigationBar)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
@@ -312,6 +303,18 @@ struct SettingsRootView: View {
|
||||
refreshJITStatus()
|
||||
}
|
||||
#endif
|
||||
.navigationDestination(for: SettingsPane.self) { pane in
|
||||
settingsDetail(for: pane)
|
||||
}
|
||||
}
|
||||
.onChange(of: resetToRootRequest) { _, _ in
|
||||
guard !navigationPath.isEmpty else { return }
|
||||
var transaction = Transaction(animation: nil)
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
navigationPath.removeAll()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -423,6 +426,8 @@ struct SettingsRootView: View {
|
||||
LocalMultiplayerSettingsView()
|
||||
case .virtualPad:
|
||||
VirtualPadSettingsView()
|
||||
case .help:
|
||||
HelpView()
|
||||
case .licenses:
|
||||
LicenseView()
|
||||
case .about:
|
||||
|
||||
Reference in New Issue
Block a user