[Experimental Feature] Supports AirPlay controller skins

Allows users to customize controller skin when AirPlaying games to an external display.
This commit is contained in:
Riley Testut 2023-04-27 14:58:51 -05:00
parent 1137189b57
commit 39522fda58
12 changed files with 284 additions and 34 deletions

@ -1 +1 @@
Subproject commit 3ce43f3103c637dfdb27f85fc0d0041c8a37292b
Subproject commit 44a999ab2c974bcbc7bbc71753a61b77fa306c07

@ -1 +1 @@
Subproject commit b0eeb87c41cf5d78182879a10a51f7c147a60ef7
Subproject commit 581fd3557c4ffd2cfb7dd049dfba14ed2f14a96c

View File

@ -198,6 +198,7 @@
D5AAF27729884F8600F21ACF /* CheatDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5AAF27629884F8600F21ACF /* CheatDevice.swift */; };
D5ADD12229F33FBF00CE0560 /* Features.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5ADD12129F33FBF00CE0560 /* Features.swift */; };
D5D78AE529F9BC3700E064F0 /* DSAirPlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D78AE429F9BC3700E064F0 /* DSAirPlay.swift */; };
D5D78AE729F9D40A00E064F0 /* EnvironmentValues+FeatureOption.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D78AE629F9D40A00E064F0 /* EnvironmentValues+FeatureOption.swift */; };
D5D797E6298D946200738869 /* Contributor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D797E5298D946200738869 /* Contributor.swift */; };
D5D797E9298DCC7300738869 /* cheatbase.zip in Resources */ = {isa = PBXBuildFile; fileRef = D5D797E7298DC9E200738869 /* cheatbase.zip */; };
D5D7C1F929E60EA500663793 /* UserDefaults+OptionValues.swift in Sources */ = {isa = PBXBuildFile; fileRef = D58F39C829E0A702008B4100 /* UserDefaults+OptionValues.swift */; };
@ -211,6 +212,7 @@
D5D7C20829E616CF00663793 /* FeatureContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D7C20729E616CF00663793 /* FeatureContainer.swift */; };
D5D7C20A29E61FA600663793 /* OptionToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D7C20929E61FA600663793 /* OptionToggleView.swift */; };
D5D7C20C29E624CB00663793 /* DisplayInlineKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D7C20B29E624CB00663793 /* DisplayInlineKey.swift */; };
D5DF06E029F326E6009E577C /* AirPlaySkins.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5DF06DE29F326E6009E577C /* AirPlaySkins.swift */; };
D5F82FB82981D3AC00B229AF /* LegacySearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F82FB72981D3AC00B229AF /* LegacySearchBar.swift */; };
/* End PBXBuildFile section */
@ -457,6 +459,7 @@
D5AAF27629884F8600F21ACF /* CheatDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheatDevice.swift; sourceTree = "<group>"; };
D5ADD12129F33FBF00CE0560 /* Features.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Features.swift; sourceTree = "<group>"; };
D5D78AE429F9BC3700E064F0 /* DSAirPlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DSAirPlay.swift; sourceTree = "<group>"; };
D5D78AE629F9D40A00E064F0 /* EnvironmentValues+FeatureOption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EnvironmentValues+FeatureOption.swift"; sourceTree = "<group>"; };
D5D797E5298D946200738869 /* Contributor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Contributor.swift; sourceTree = "<group>"; };
D5D797E7298DC9E200738869 /* cheatbase.zip */ = {isa = PBXFileReference; lastKnownFileType = archive.zip; path = cheatbase.zip; sourceTree = "<group>"; };
D5D7C1E629E5F90200663793 /* OptionValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionValue.swift; sourceTree = "<group>"; };
@ -465,6 +468,7 @@
D5D7C20729E616CF00663793 /* FeatureContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeatureContainer.swift; sourceTree = "<group>"; };
D5D7C20929E61FA600663793 /* OptionToggleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptionToggleView.swift; sourceTree = "<group>"; };
D5D7C20B29E624CB00663793 /* DisplayInlineKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayInlineKey.swift; sourceTree = "<group>"; };
D5DF06DE29F326E6009E577C /* AirPlaySkins.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AirPlaySkins.swift; sourceTree = "<group>"; };
D5F82FB72981D3AC00B229AF /* LegacySearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacySearchBar.swift; sourceTree = "<group>"; };
DC866E433B3BA9AE18ABA1EC /* libPods-Delta.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Delta.a"; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@ -1084,6 +1088,7 @@
isa = PBXGroup;
children = (
D5A9C01C29DE058C00A8D610 /* VariableFastForward.swift */,
D5DF06DE29F326E6009E577C /* AirPlaySkins.swift */,
);
path = Features;
sourceTree = "<group>";
@ -1134,6 +1139,7 @@
D58F39C829E0A702008B4100 /* UserDefaults+OptionValues.swift */,
D517F6BD29E7535F000D14D0 /* Collection+Optionals.swift */,
D54F710329E89DFC009C069A /* NotificationName+Settings.swift */,
D5D78AE629F9D40A00E064F0 /* EnvironmentValues+FeatureOption.swift */,
);
path = Extensions;
sourceTree = "<group>";
@ -1538,6 +1544,7 @@
BF5942951E09BD1A0051894B /* NSManagedObjectContext+Conveniences.swift in Sources */,
BFC3628021ADE2BA00EF2BE6 /* UIAlertController+Error.swift in Sources */,
BF353FF91C5D870B00C1184C /* MenuItem.swift in Sources */,
D5DF06E029F326E6009E577C /* AirPlaySkins.swift in Sources */,
D5D797E6298D946200738869 /* Contributor.swift in Sources */,
D5ADD12229F33FBF00CE0560 /* Features.swift in Sources */,
BFDB3418219E4B1700595A62 /* SyncStatusViewController.swift in Sources */,
@ -1662,6 +1669,7 @@
D54F710229E89DCB009C069A /* SettingsUserInfoKey.swift in Sources */,
D5D7C20629E60F6100663793 /* OptionPickerView.swift in Sources */,
D5D7C20329E60F2000663793 /* Option.swift in Sources */,
D5D78AE729F9D40A00E064F0 /* EnvironmentValues+FeatureOption.swift in Sources */,
D5D7C20229E60F2000663793 /* OptionValue.swift in Sources */,
D5D7C1F929E60EA500663793 /* UserDefaults+OptionValues.swift in Sources */,
);

View File

@ -13,16 +13,27 @@ import Harmony
extension ControllerSkinConfigurations
{
init(traits: DeltaCore.ControllerSkin.Traits)
init?(traits: DeltaCore.ControllerSkin.Traits)
{
switch (traits.displayType, traits.orientation)
switch (traits.device, traits.displayType, traits.orientation)
{
case (.standard, .portrait): self = .standardPortrait
case (.standard, .landscape): self = .standardLandscape
case (.edgeToEdge, .portrait): self = .edgeToEdgePortrait
case (.edgeToEdge, .landscape): self = .edgeToEdgeLandscape
case (.splitView, .portrait): self = .splitViewPortrait
case (.splitView, .landscape): self = .splitViewLandscape
case (.iphone, .standard, .portrait): self = .iphoneStandardPortrait
case (.iphone, .standard, .landscape): self = .iphoneStandardLandscape
case (.iphone, .edgeToEdge, .portrait): self = .iphoneEdgeToEdgePortrait
case (.iphone, .edgeToEdge, .landscape): self = .iphoneEdgeToEdgeLandscape
case (.iphone, .splitView, _): return nil
case (.ipad, .standard, .portrait): self = .ipadStandardPortrait
case (.ipad, .standard, .landscape): self = .ipadStandardLandscape
case (.ipad, .edgeToEdge, .portrait): self = .ipadEdgeToEdgePortrait
case (.ipad, .edgeToEdge, .landscape): self = .ipadEdgeToEdgeLandscape
case (.ipad, .splitView, .portrait): self = .ipadSplitViewPortrait
case (.ipad, .splitView, .landscape): self = .ipadSplitViewLandscape
case (.tv, .standard, .portrait): self = .tvStandardPortrait
case (.tv, .standard, .landscape): self = .tvStandardLandscape
case (.tv, .edgeToEdge, _): return nil
case (.tv, .splitView, _): return nil
}
}
}

View File

@ -9,16 +9,35 @@
#ifndef ControllerSkinConfigurations_h
#define ControllerSkinConfigurations_h
// Every possible (supported) combination of traits.
typedef NS_OPTIONS(int16_t, ControllerSkinConfigurations)
{
ControllerSkinConfigurationStandardPortrait = 1 << 0,
ControllerSkinConfigurationStandardLandscape = 1 << 1,
/* iPhone */
ControllerSkinConfigurationiPhoneStandardPortrait NS_SWIFT_NAME(iphoneStandardPortrait) = 1 << 0,
ControllerSkinConfigurationiPhoneStandardLandscape NS_SWIFT_NAME(iphoneStandardLandscape) = 1 << 1,
ControllerSkinConfigurationSplitViewPortrait = 1 << 2,
ControllerSkinConfigurationSplitViewLandscape = 1 << 3,
// iPhone doesn't support Split View
// ControllerSkinConfigurationiPhoneSplitViewPortrait = 1 << 2,
// ControllerSkinConfigurationiPhoneSplitViewLandscape = 1 << 3,
ControllerSkinConfigurationEdgeToEdgePortrait = 1 << 4,
ControllerSkinConfigurationEdgeToEdgeLandscape = 1 << 5,
ControllerSkinConfigurationiPhoneEdgeToEdgePortrait NS_SWIFT_NAME(iphoneEdgeToEdgePortrait) = 1 << 4,
ControllerSkinConfigurationiPhoneEdgeToEdgeLandscape NS_SWIFT_NAME(iphoneEdgeToEdgeLandscape) = 1 << 5,
/* iPad */
ControllerSkinConfigurationiPadStandardPortrait NS_SWIFT_NAME(ipadStandardPortrait) = 1 << 6,
ControllerSkinConfigurationiPadStandardLandscape NS_SWIFT_NAME(ipadStandardLandscape) = 1 << 7,
ControllerSkinConfigurationiPadSplitViewPortrait NS_SWIFT_NAME(ipadSplitViewPortrait) = 1 << 2, // Backwards compatible with legacy ControllerSkinConfigurationSplitViewPortrait
ControllerSkinConfigurationiPadSplitViewLandscape NS_SWIFT_NAME(ipadSplitViewLandscape) = 1 << 3, // Backwards compatible with legacy ControllerSkinConfigurationSplitViewLandscape
ControllerSkinConfigurationiPadEdgeToEdgePortrait NS_SWIFT_NAME(ipadEdgeToEdgePortrait) = 1 << 8,
ControllerSkinConfigurationiPadEdgeToEdgeLandscape NS_SWIFT_NAME(ipadEdgeToEdgeLandscape) = 1 << 9,
/* TV */
ControllerSkinConfigurationTVStandardPortrait = 1 << 10,
ControllerSkinConfigurationTVStandardLandscape = 1 << 11,
};
#endif /* ControllerSkinConfigurations_h */

View File

@ -1144,7 +1144,13 @@ private extension GameViewController
if let game = self.game, let traits = scene.gameViewController.controllerView.controllerSkinTraits
{
if let standardSkin = DeltaCore.ControllerSkin.standardControllerSkin(for: game.type), standardSkin.supports(traits)
if ExperimentalFeatures.shared.airPlaySkins.isEnabled,
let preferredControllerSkin = ExperimentalFeatures.shared.airPlaySkins.preferredAirPlayControllerSkin(for: game.type), preferredControllerSkin.supports(traits)
{
// Use preferredControllerSkin directly.
controllerSkin = preferredControllerSkin
}
else if let standardSkin = DeltaCore.ControllerSkin.standardControllerSkin(for: game.type), standardSkin.supports(traits)
{
if standardSkin.hasTouchScreen(for: traits)
{
@ -1359,6 +1365,11 @@ private extension GameViewController
case Settings.features.dsAirPlay.$layoutAxis.settingsKey:
self.updateExternalDisplay()
case ExperimentalFeatures.shared.airPlaySkins.settingsKey: fallthrough
case _ where settingsName.rawValue.hasPrefix(ExperimentalFeatures.shared.airPlaySkins.settingsKey.rawValue):
// Update whenever any of the AirPlay skins have changed.
self.updateExternalDisplay()
default: break
}
}

View File

@ -12,6 +12,11 @@ struct ExperimentalFeatures: FeatureContainer
{
static let shared = ExperimentalFeatures()
@Feature(name: "AirPlay Skins",
description: "Customize the appearance of games when AirPlaying to your TV.",
options: AirPlaySkinsOptions())
var airPlaySkins
@Feature(name: "Variable Fast Forward",
description: "Change the preferred Fast Foward speed per-system. You can also change it by long-pressing the Fast Forward button from the Pause Menu.",
options: VariableFastForwardOptions())

View File

@ -0,0 +1,167 @@
//
// AirPlaySkins.swift
// Delta
//
// Created by Riley Testut on 4/20/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import SwiftUI
import DeltaFeatures
import DeltaCore
extension Feature where Options == AirPlaySkinsOptions
{
func preferredAirPlayControllerSkin(for gameType: GameType) -> ControllerSkin?
{
guard let identifier = self[gameType] else { return nil }
let predicate = NSPredicate(format: "%K == %@", #keyPath(ControllerSkin.identifier), identifier)
let controllerSkin = ControllerSkin.instancesWithPredicate(predicate, inManagedObjectContext: DatabaseManager.shared.viewContext, type: ControllerSkin.self).first
return controllerSkin
}
}
struct AirPlaySkinsOptions
{
@Option(name: "Manage Skins", detailView: { _ in SkinManager() })
private var skinManager: String = "" // Hack until I figure out how to support Void properties...
@Option(name: LocalizedStringKey(System.nes.localizedName), description: "The controller skin used when AirPlaying NES games.", detailView: { SkinPicker(gameType: .nes, controllerSkinID: $0) })
var nes: String?
@Option(name: LocalizedStringKey(System.snes.localizedName), description: "The controller skin used when AirPlaying SNES games.", detailView: { SkinPicker(gameType: .snes, controllerSkinID: $0) })
var snes: String?
@Option(name: LocalizedStringKey(System.genesis.localizedName), description: "The controller skin used when AirPlaying Genesis games.", detailView: { SkinPicker(gameType: .genesis, controllerSkinID: $0) })
var genesis: String?
@Option(name: LocalizedStringKey(System.n64.localizedName), description: "The controller skin used when AirPlaying N64 games.", detailView: { SkinPicker(gameType: .n64, controllerSkinID: $0) })
var n64: String?
@Option(name: LocalizedStringKey(System.gbc.localizedName), description: "The controller skin used when AirPlaying GBC games.", detailView: { SkinPicker(gameType: .gbc, controllerSkinID: $0) })
var gbc: String?
@Option(name: LocalizedStringKey(System.gba.localizedName), description: "The controller skin used when AirPlaying GBA games.", detailView: { SkinPicker(gameType: .gba, controllerSkinID: $0) })
var gba: String?
@Option(name: LocalizedStringKey(System.ds.localizedName), description: "The controller skin used when AirPlaying DS games.", detailView: { SkinPicker(gameType: .ds, controllerSkinID: $0) })
var ds: String?
subscript(gameType: GameType) -> String? {
guard let system = System(gameType: gameType) else { return nil }
switch system
{
case .nes: return self.nes
case .snes: return self.snes
case .genesis: return self.genesis
case .n64: return self.n64
case .gbc: return self.gbc
case .gba: return self.gba
case .ds: return self.ds
}
}
}
fileprivate extension AirPlaySkinsOptions
{
struct SkinPicker: View
{
let gameType: GameType
@Binding
var controllerSkinID: String?
@FetchRequest
private var controllerSkins: FetchedResults<ControllerSkin>
@Environment(\.featureOption)
private var option
var body: some View {
Picker(option.name ?? "", selection: $controllerSkinID) {
ForEach(controllerSkins, id: \.identifier) { controllerSkin in
Text(controllerSkin.name)
.tag(Optional<String>(controllerSkin.identifier)) // Must be Optional<String> in order for selection to work.
// .tag(controllerSkin.identifier)
}
Text("None")
.tag(String?.none)
}
.pickerStyle(.menu)
.displayInline()
}
init(gameType: GameType, controllerSkinID: Binding<String?>)
{
self.gameType = gameType
self._controllerSkinID = controllerSkinID
let configuration = ControllerSkinConfigurations.tvStandardLandscape
let predicate = NSPredicate(format: "%K == %@ AND (%K & %d) != 0 AND %K == NO",
#keyPath(ControllerSkin.gameType), self.gameType.rawValue,
#keyPath(ControllerSkin.supportedConfigurations), configuration.rawValue,
#keyPath(ControllerSkin.isStandard))
self._controllerSkins = FetchRequest(entity: ControllerSkin.entity(), sortDescriptors: [NSSortDescriptor(keyPath: \ControllerSkin.name, ascending: true)], predicate: predicate)
}
}
struct SkinManager: View
{
@FetchRequest(entity: ControllerSkin.entity(),
sortDescriptors: [NSSortDescriptor(keyPath: \ControllerSkin.name, ascending: true)],
predicate: {
let configuration = ControllerSkinConfigurations.tvStandardLandscape
return NSPredicate(format: "(%K & %d) != 0 AND %K == NO",
#keyPath(ControllerSkin.supportedConfigurations), configuration.rawValue,
#keyPath(ControllerSkin.isStandard))
}())
private var controllerSkins: FetchedResults<ControllerSkin>
var body: some View {
if controllerSkins.isEmpty
{
Text("No AirPlay Skins")
.foregroundColor(.gray)
}
else
{
List {
ForEach(controllerSkins, id: \.identifier) { controllerSkin in
HStack {
Text(controllerSkin.name)
Spacer()
if let system = System(gameType: controllerSkin.gameType)
{
Text(system.localizedShortName)
.foregroundColor(.gray)
}
}
}
.onDelete(perform: deleteAirPlaySkins)
}
}
}
private func deleteAirPlaySkins(at indexes: IndexSet)
{
let objectIDs = indexes.map { controllerSkins[$0].objectID }
DatabaseManager.shared.performBackgroundTask { context in
let controllerSkins = objectIDs.compactMap { context.object(with: $0) as? ControllerSkin }
for controllerSkin in controllerSkins
{
context.delete(controllerSkin)
}
context.saveWithErrorLogging()
}
}
}
}

View File

@ -34,21 +34,20 @@ extension ControllerSkin
var configurations = ControllerSkinConfigurations()
let device: DeltaCore.ControllerSkin.Device = (UIDevice.current.userInterfaceIdiom == .pad) ? .ipad : .iphone
let traitCollections: [(displayType: DeltaCore.ControllerSkin.DisplayType, orientation: DeltaCore.ControllerSkin.Orientation)] =
[(.standard, .portrait), (.standard, .landscape), (.edgeToEdge, .portrait), (.edgeToEdge, .landscape), (.splitView, .portrait), (.splitView, .landscape)]
for collection in traitCollections
{
let traits = DeltaCore.ControllerSkin.Traits(device: device, displayType: collection.displayType, orientation: collection.orientation)
if skin.supports(traits)
{
let configuration = ControllerSkinConfigurations(traits: traits)
configurations.formUnion(configuration)
let allTraitCombinations = DeltaCore.ControllerSkin.Device.allCases.flatMap { device in
DeltaCore.ControllerSkin.DisplayType.allCases.flatMap { displayType in
DeltaCore.ControllerSkin.Orientation.allCases.map { orientation in
DeltaCore.ControllerSkin.Traits(device: device, displayType: displayType, orientation: orientation)
}
}
}
for traits in allTraitCombinations
{
guard let configuration = ControllerSkinConfigurations(traits: traits), skin.supports(traits) else { continue }
configurations.formUnion(configuration)
}
self.supportedConfigurations = configurations
}
}

View File

@ -108,13 +108,13 @@ private extension ControllerSkinsViewController
{
guard let system = self.system, let traits = self.traits else { return }
let configuration = ControllerSkinConfigurations(traits: traits)
guard let configuration = ControllerSkinConfigurations(traits: traits) else { return }
let fetchRequest: NSFetchRequest<ControllerSkin> = ControllerSkin.fetchRequest()
if traits.device == .iphone && traits.displayType == .edgeToEdge
{
let fallbackConfiguration: ControllerSkinConfigurations = (traits.orientation == .landscape) ? .standardLandscape : .standardPortrait
let fallbackConfiguration: ControllerSkinConfigurations = (traits.orientation == .landscape) ? .iphoneStandardLandscape : .iphoneStandardPortrait
// Allow selecting skins that only support standard display types as well.
fetchRequest.predicate = NSPredicate(format: "%K == %@ AND ((%K & %d) != 0 OR (%K & %d) != 0)",

View File

@ -59,9 +59,11 @@ struct FeatureDetailView<Feature: AnyFeature>: View
private struct OptionRow<Option: AnyOption, DetailView: View>: View where DetailView == Option.DetailView
{
var name: LocalizedStringKey
var value: any LocalizedOptionValue
var detailView: DetailView
let name: LocalizedStringKey
let value: any LocalizedOptionValue
let detailView: DetailView
let option: Option
@State
private var displayInline: Bool = false
@ -78,10 +80,16 @@ private struct OptionRow<Option: AnyOption, DetailView: View>: View where Detail
self.name = name
self.value = value
self.detailView = detailView
self.option = option
}
var body: some View {
VStack {
let detailView = detailView
.environment(\.managedObjectContext, DatabaseManager.shared.viewContext)
.environment(\.featureOption, option)
if displayInline
{
// Display entire view inline.

View File

@ -0,0 +1,22 @@
//
// EnvironmentValues+FeatureOption.swift
// DeltaFeatures
//
// Created by Riley Testut on 4/26/23.
// Copyright © 2023 Riley Testut. All rights reserved.
//
import SwiftUI
private struct FeatureOptionKey: EnvironmentKey
{
static let defaultValue: any AnyOption = Option(wrappedValue: true)
}
public extension EnvironmentValues
{
var featureOption: any AnyOption {
get { self[FeatureOptionKey.self] }
set { self[FeatureOptionKey.self] = newValue }
}
}