diff --git a/Arkavo.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Arkavo.xcworkspace/xcshareddata/swiftpm/Package.resolved
index c96af2ee..4f4b2bcd 100644
--- a/Arkavo.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/Arkavo.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -10,6 +10,15 @@
"version" : "1.9.0"
}
},
+ {
+ "identity" : "eventsource",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/mattt/EventSource.git",
+ "state" : {
+ "revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e",
+ "version" : "1.4.1"
+ }
+ },
{
"identity" : "flatbuffers",
"kind" : "remoteSourceControl",
@@ -28,6 +37,24 @@
"version" : "0.3.0"
}
},
+ {
+ "identity" : "mlx-swift",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/ml-explore/mlx-swift",
+ "state" : {
+ "revision" : "61b9e011e09a62b489f6bd647958f1555bdf2896",
+ "version" : "0.31.3"
+ }
+ },
+ {
+ "identity" : "mlx-swift-lm",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/arkavo-ai/mlx-swift-lm",
+ "state" : {
+ "branch" : "feature/gemma4-text",
+ "revision" : "d514e90e5962064e925c4bbc30bdd6b9afbb42e6"
+ }
+ },
{
"identity" : "opentdfkit",
"kind" : "remoteSourceControl",
@@ -37,6 +64,105 @@
"revision" : "d8ffeff99e00ec3334aa57b1fe5f9e1c7f38d2a9"
}
},
+ {
+ "identity" : "swift-asn1",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-asn1.git",
+ "state" : {
+ "revision" : "9f542610331815e29cc3821d3b6f488db8715517",
+ "version" : "1.6.0"
+ }
+ },
+ {
+ "identity" : "swift-atomics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-atomics.git",
+ "state" : {
+ "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swift-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-collections.git",
+ "state" : {
+ "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924",
+ "version" : "1.4.1"
+ }
+ },
+ {
+ "identity" : "swift-crypto",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-crypto.git",
+ "state" : {
+ "revision" : "bb4ba815dab96d4edc1e0b86d7b9acf9ff973a84",
+ "version" : "4.3.1"
+ }
+ },
+ {
+ "identity" : "swift-huggingface",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/huggingface/swift-huggingface",
+ "state" : {
+ "revision" : "b721959445b617d0bf03910b2b4aced345fd93bf",
+ "version" : "0.9.0"
+ }
+ },
+ {
+ "identity" : "swift-jinja",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/huggingface/swift-jinja.git",
+ "state" : {
+ "revision" : "0aeefadec459ce8e11a333769950fb86183aca43",
+ "version" : "2.3.5"
+ }
+ },
+ {
+ "identity" : "swift-nio",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio.git",
+ "state" : {
+ "revision" : "558f24a4647193b5a0e2104031b71c55d31ff83a",
+ "version" : "2.97.1"
+ }
+ },
+ {
+ "identity" : "swift-numerics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-numerics",
+ "state" : {
+ "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
+ "version" : "1.1.1"
+ }
+ },
+ {
+ "identity" : "swift-syntax",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-syntax.git",
+ "state" : {
+ "revision" : "0687f71944021d616d34d922343dcef086855920",
+ "version" : "600.0.1"
+ }
+ },
+ {
+ "identity" : "swift-system",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-system.git",
+ "state" : {
+ "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df",
+ "version" : "1.6.4"
+ }
+ },
+ {
+ "identity" : "swift-transformers",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/huggingface/swift-transformers",
+ "state" : {
+ "revision" : "b38443e44d93eca770f2eb68e2a4d0fa100f9aa2",
+ "version" : "1.3.0"
+ }
+ },
{
"identity" : "vrmmetalkit",
"kind" : "remoteSourceControl",
@@ -46,6 +172,15 @@
"version" : "0.9.2"
}
},
+ {
+ "identity" : "yyjson",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/ibireme/yyjson.git",
+ "state" : {
+ "revision" : "8b4a38dc994a110abaec8a400615567bd996105f",
+ "version" : "0.12.0"
+ }
+ },
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
diff --git a/Arkavo/Arkavo.xcodeproj/project.pbxproj b/Arkavo/Arkavo.xcodeproj/project.pbxproj
index 023039aa..4b2f210d 100644
--- a/Arkavo/Arkavo.xcodeproj/project.pbxproj
+++ b/Arkavo/Arkavo.xcodeproj/project.pbxproj
@@ -1023,7 +1023,7 @@ E5D86638127846B8989BFA94 /* ConnectedAccountsView.swift in Sources */ = {isa = P
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
- SWIFT_VERSION = 6.2;
+ SWIFT_VERSION = 6.3;
TARGETED_DEVICE_FAMILY = 1;
XROS_DEPLOYMENT_TARGET = 2.0;
};
@@ -1074,7 +1074,7 @@ E5D86638127846B8989BFA94 /* ConnectedAccountsView.swift in Sources */ = {isa = P
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
- SWIFT_VERSION = 6.2;
+ SWIFT_VERSION = 6.3;
TARGETED_DEVICE_FAMILY = 1;
XROS_DEPLOYMENT_TARGET = 2.0;
};
@@ -1096,7 +1096,7 @@ E5D86638127846B8989BFA94 /* ConnectedAccountsView.swift in Sources */ = {isa = P
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_EMIT_LOC_STRINGS = NO;
- SWIFT_VERSION = 6.2;
+ SWIFT_VERSION = 6.3;
TARGETED_DEVICE_FAMILY = "1,2,7";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Arkavo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Arkavo";
XROS_DEPLOYMENT_TARGET = 2.0;
@@ -1119,7 +1119,7 @@ E5D86638127846B8989BFA94 /* ConnectedAccountsView.swift in Sources */ = {isa = P
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_EMIT_LOC_STRINGS = NO;
- SWIFT_VERSION = 6.2;
+ SWIFT_VERSION = 6.3;
TARGETED_DEVICE_FAMILY = "1,2,7";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Arkavo.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Arkavo";
XROS_DEPLOYMENT_TARGET = 2.0;
@@ -1141,7 +1141,7 @@ E5D86638127846B8989BFA94 /* ConnectedAccountsView.swift in Sources */ = {isa = P
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_EMIT_LOC_STRINGS = NO;
- SWIFT_VERSION = 6.2;
+ SWIFT_VERSION = 6.3;
TARGETED_DEVICE_FAMILY = "1,2,7";
TEST_TARGET_NAME = Arkavo;
XROS_DEPLOYMENT_TARGET = 2.0;
@@ -1163,7 +1163,7 @@ E5D86638127846B8989BFA94 /* ConnectedAccountsView.swift in Sources */ = {isa = P
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
SWIFT_EMIT_LOC_STRINGS = NO;
- SWIFT_VERSION = 6.2;
+ SWIFT_VERSION = 6.3;
TARGETED_DEVICE_FAMILY = "1,2,7";
TEST_TARGET_NAME = Arkavo;
XROS_DEPLOYMENT_TARGET = 2.0;
diff --git a/Arkavo/Arkavo/RegistrationView.swift b/Arkavo/Arkavo/RegistrationView.swift
index bbec54df..ec64a4e2 100644
--- a/Arkavo/Arkavo/RegistrationView.swift
+++ b/Arkavo/Arkavo/RegistrationView.swift
@@ -279,25 +279,27 @@ struct RegistrationView: View {
// generatedScreenNames = []
case .generateScreenName:
if skipPasskeysFlag {
- let newProfile = Profile(
- name: selectedScreenName,
- interests: Array(selectedInterests).joined(separator: ","),
- hasHighEncryption: true,
- hasHighIdentityAssurance: true,
- )
- Task { await onComplete(newProfile) }
+ let name = selectedScreenName
+ let interests = Array(selectedInterests).joined(separator: ",")
+ let complete = onComplete
+ Task {
+ let profile = Profile(
+ name: name, interests: interests,
+ hasHighEncryption: true, hasHighIdentityAssurance: true)
+ await complete(profile)
+ }
} else {
currentStep = .enablePasskeys
}
case .enablePasskeys:
- let newProfile = Profile(
- name: selectedScreenName,
- interests: Array(selectedInterests).joined(separator: ","),
- hasHighEncryption: true,
- hasHighIdentityAssurance: true,
- )
+ let name = selectedScreenName
+ let interests = Array(selectedInterests).joined(separator: ",")
+ let complete = onComplete
Task {
- await onComplete(newProfile)
+ let profile = Profile(
+ name: name, interests: interests,
+ hasHighEncryption: true, hasHighIdentityAssurance: true)
+ await complete(profile)
}
}
}
diff --git a/ArkavoCreator/ArkavoCreator.xcodeproj/project.pbxproj b/ArkavoCreator/ArkavoCreator.xcodeproj/project.pbxproj
index 27c992c9..730c2b79 100644
--- a/ArkavoCreator/ArkavoCreator.xcodeproj/project.pbxproj
+++ b/ArkavoCreator/ArkavoCreator.xcodeproj/project.pbxproj
@@ -362,7 +362,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = NO;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_STRICT_CONCURRENCY = complete;
- SWIFT_VERSION = 6.2;
+ SWIFT_VERSION = 6.3;
};
name = Debug;
};
@@ -426,7 +426,7 @@
SWIFT_APPROACHABLE_CONCURRENCY = NO;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_STRICT_CONCURRENCY = complete;
- SWIFT_VERSION = 6.2;
+ SWIFT_VERSION = 6.3;
};
name = Release;
};
@@ -475,7 +475,7 @@
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
- SWIFT_VERSION = 6.0;
+ SWIFT_VERSION = 6.3;
};
name = Debug;
};
@@ -524,7 +524,7 @@
PROVISIONING_PROFILE_SPECIFIER = "";
REGISTER_APP_GROUPS = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
- SWIFT_VERSION = 6.0;
+ SWIFT_VERSION = 6.3;
};
name = Release;
};
@@ -541,7 +541,7 @@
PRODUCT_BUNDLE_IDENTIFIER = com.arkavo.ArkavoCreatorTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.3;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ArkavoCreator.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ArkavoCreator";
};
name = Debug;
@@ -559,7 +559,7 @@
PRODUCT_BUNDLE_IDENTIFIER = com.arkavo.ArkavoCreatorTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.3;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/ArkavoCreator.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/ArkavoCreator";
};
name = Release;
@@ -575,7 +575,7 @@
PRODUCT_BUNDLE_IDENTIFIER = com.arkavo.ArkavoCreatorUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.3;
TEST_TARGET_NAME = ArkavoCreator;
};
name = Debug;
@@ -591,7 +591,7 @@
PRODUCT_BUNDLE_IDENTIFIER = com.arkavo.ArkavoCreatorUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_EMIT_LOC_STRINGS = NO;
- SWIFT_VERSION = 5.0;
+ SWIFT_VERSION = 6.3;
TEST_TARGET_NAME = ArkavoCreator;
};
name = Release;
diff --git a/ArkavoCreator/ArkavoCreator.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ArkavoCreator/ArkavoCreator.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index f4545fdd..4f4b2bcd 100644
--- a/ArkavoCreator/ArkavoCreator.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/ArkavoCreator/ArkavoCreator.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "0366ce2b01e3ae8fada8f469cb2ad5bc7009430f7bb1fe2322f7c4c059171bb9",
+ "originHash" : "b83820b1903c2b4e25758a74513a6cf115920ddc7a5730a528e6e691a3c49c6a",
"pins" : [
{
"identity" : "cryptoswift",
@@ -10,6 +10,24 @@
"version" : "1.9.0"
}
},
+ {
+ "identity" : "eventsource",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/mattt/EventSource.git",
+ "state" : {
+ "revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e",
+ "version" : "1.4.1"
+ }
+ },
+ {
+ "identity" : "flatbuffers",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/google/flatbuffers.git",
+ "state" : {
+ "revision" : "a2cd1ea3b6d3fee220106b5fed3f7ce8da9eb757",
+ "version" : "24.12.23"
+ }
+ },
{
"identity" : "iroh-swift",
"kind" : "remoteSourceControl",
@@ -19,6 +37,24 @@
"version" : "0.3.0"
}
},
+ {
+ "identity" : "mlx-swift",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/ml-explore/mlx-swift",
+ "state" : {
+ "revision" : "61b9e011e09a62b489f6bd647958f1555bdf2896",
+ "version" : "0.31.3"
+ }
+ },
+ {
+ "identity" : "mlx-swift-lm",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/arkavo-ai/mlx-swift-lm",
+ "state" : {
+ "branch" : "feature/gemma4-text",
+ "revision" : "d514e90e5962064e925c4bbc30bdd6b9afbb42e6"
+ }
+ },
{
"identity" : "opentdfkit",
"kind" : "remoteSourceControl",
@@ -28,6 +64,105 @@
"revision" : "d8ffeff99e00ec3334aa57b1fe5f9e1c7f38d2a9"
}
},
+ {
+ "identity" : "swift-asn1",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-asn1.git",
+ "state" : {
+ "revision" : "9f542610331815e29cc3821d3b6f488db8715517",
+ "version" : "1.6.0"
+ }
+ },
+ {
+ "identity" : "swift-atomics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-atomics.git",
+ "state" : {
+ "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swift-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-collections.git",
+ "state" : {
+ "revision" : "6675bc0ff86e61436e615df6fc5174e043e57924",
+ "version" : "1.4.1"
+ }
+ },
+ {
+ "identity" : "swift-crypto",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-crypto.git",
+ "state" : {
+ "revision" : "bb4ba815dab96d4edc1e0b86d7b9acf9ff973a84",
+ "version" : "4.3.1"
+ }
+ },
+ {
+ "identity" : "swift-huggingface",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/huggingface/swift-huggingface",
+ "state" : {
+ "revision" : "b721959445b617d0bf03910b2b4aced345fd93bf",
+ "version" : "0.9.0"
+ }
+ },
+ {
+ "identity" : "swift-jinja",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/huggingface/swift-jinja.git",
+ "state" : {
+ "revision" : "0aeefadec459ce8e11a333769950fb86183aca43",
+ "version" : "2.3.5"
+ }
+ },
+ {
+ "identity" : "swift-nio",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio.git",
+ "state" : {
+ "revision" : "558f24a4647193b5a0e2104031b71c55d31ff83a",
+ "version" : "2.97.1"
+ }
+ },
+ {
+ "identity" : "swift-numerics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-numerics",
+ "state" : {
+ "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
+ "version" : "1.1.1"
+ }
+ },
+ {
+ "identity" : "swift-syntax",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-syntax.git",
+ "state" : {
+ "revision" : "0687f71944021d616d34d922343dcef086855920",
+ "version" : "600.0.1"
+ }
+ },
+ {
+ "identity" : "swift-system",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-system.git",
+ "state" : {
+ "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df",
+ "version" : "1.6.4"
+ }
+ },
+ {
+ "identity" : "swift-transformers",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/huggingface/swift-transformers",
+ "state" : {
+ "revision" : "b38443e44d93eca770f2eb68e2a4d0fa100f9aa2",
+ "version" : "1.3.0"
+ }
+ },
{
"identity" : "vrmmetalkit",
"kind" : "remoteSourceControl",
@@ -37,6 +172,15 @@
"version" : "0.9.2"
}
},
+ {
+ "identity" : "yyjson",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/ibireme/yyjson.git",
+ "state" : {
+ "revision" : "8b4a38dc994a110abaec8a400615567bd996105f",
+ "version" : "0.12.0"
+ }
+ },
{
"identity" : "zipfoundation",
"kind" : "remoteSourceControl",
diff --git a/ArkavoCreator/ArkavoCreator/ArkavoCreator.entitlements b/ArkavoCreator/ArkavoCreator/ArkavoCreator.entitlements
index 9545ccb1..2f676140 100644
--- a/ArkavoCreator/ArkavoCreator/ArkavoCreator.entitlements
+++ b/ArkavoCreator/ArkavoCreator/ArkavoCreator.entitlements
@@ -20,6 +20,8 @@
com.apple.security.network.client
+ com.apple.security.network.server
+
keychain-access-groups
$(AppIdentifierPrefix)com.arkavo.ArkavoCreator
diff --git a/ArkavoCreator/ArkavoCreator/ArkavoCreatorApp.swift b/ArkavoCreator/ArkavoCreator/ArkavoCreatorApp.swift
index 469e9982..b7c21001 100644
--- a/ArkavoCreator/ArkavoCreator/ArkavoCreatorApp.swift
+++ b/ArkavoCreator/ArkavoCreator/ArkavoCreatorApp.swift
@@ -2,6 +2,7 @@ import ArkavoKit
import ArkavoSocial
import AuthenticationServices
import LocalAuthentication
+import MuseCore
import SwiftData
import SwiftUI
@@ -18,6 +19,8 @@ struct ArkavoCreatorApp: App {
@StateObject private var windowAccessor = WindowAccessor.shared
@StateObject private var agentService = CreatorAgentService()
+ @State private var modelManager = ModelManager()
+
let patreonClient = PatreonClient(clientId: Secrets.patreonClientId, clientSecret: Secrets.patreonClientSecret)
let redditClient = RedditClient(clientId: Secrets.redditClientId)
let micropubClient = MicropubClient(clientId: Config.micropubClientID)
@@ -53,7 +56,8 @@ struct ArkavoCreatorApp: App {
micropubClient: micropubClient,
blueskyClient: blueskyClient,
youtubeClient: youtubeClient,
- agentService: agentService
+ agentService: agentService,
+ modelManager: modelManager
)
.onAppear {
// Load stored tokens
diff --git a/ArkavoCreator/ArkavoCreator/Assistant/PlatformContext.swift b/ArkavoCreator/ArkavoCreator/Assistant/PlatformContext.swift
new file mode 100644
index 00000000..3d47342c
--- /dev/null
+++ b/ArkavoCreator/ArkavoCreator/Assistant/PlatformContext.swift
@@ -0,0 +1,135 @@
+import Foundation
+
+/// Describes platform-specific constraints and actions for the Publicist
+protocol PlatformContext {
+ var platformName: String { get }
+ var systemPromptFragment: String { get }
+ var characterLimit: Int? { get }
+ var suggestedActions: [PublicistAction] { get }
+}
+
+/// Actions the Publicist can perform based on the current platform
+enum PublicistAction: String, CaseIterable, Sendable {
+ case draftPost = "Draft Post"
+ case rewrite = "Rewrite"
+ case adjustTone = "Adjust Tone"
+ case adaptCrossPlatform = "Adapt to Platform"
+ case generateTitle = "Generate Title"
+ case generateDescription = "Generate Description"
+}
+
+// MARK: - Platform Contexts
+
+struct BlueskyContext: PlatformContext {
+ let platformName = "Bluesky"
+ let characterLimit: Int? = 300
+ let suggestedActions: [PublicistAction] = [.draftPost, .rewrite, .adjustTone, .adaptCrossPlatform]
+ var systemPromptFragment: String {
+ """
+ Currently helping with: Bluesky
+ Post constraints: Maximum 300 characters. Supports mentions (@handle) and links.
+ Style: Casual, engaging, concise. Hashtags are not commonly used on Bluesky.
+ """
+ }
+}
+
+struct YouTubeContext: PlatformContext {
+ let platformName = "YouTube"
+ let characterLimit: Int? = 5000
+ let suggestedActions: [PublicistAction] = [.generateTitle, .generateDescription, .adjustTone, .adaptCrossPlatform]
+ var systemPromptFragment: String {
+ """
+ Currently helping with: YouTube
+ Title: Max 100 characters, SEO-friendly, attention-grabbing.
+ Description: Up to 5000 characters. First 2-3 lines most important (shown before "Show more").
+ Include relevant keywords, timestamps, links, and calls-to-action.
+ Tags: Relevant keywords for discoverability.
+ """
+ }
+}
+
+struct TwitchContext: PlatformContext {
+ let platformName = "Twitch"
+ let characterLimit: Int? = 140
+ let suggestedActions: [PublicistAction] = [.generateTitle, .draftPost, .adjustTone]
+ var systemPromptFragment: String {
+ """
+ Currently helping with: Twitch
+ Stream title: Maximum 140 characters. Should be engaging and descriptive.
+ Tags: Up to 10 tags for discoverability.
+ Style: Energetic, community-focused, often uses emotes and casual language.
+ """
+ }
+}
+
+struct RedditContext: PlatformContext {
+ let platformName = "Reddit"
+ let characterLimit: Int? = nil
+ let suggestedActions: [PublicistAction] = [.draftPost, .generateTitle, .rewrite, .adjustTone]
+ var systemPromptFragment: String {
+ """
+ Currently helping with: Reddit
+ Title: Concise, descriptive, follows subreddit conventions.
+ Body: Supports Markdown. Length varies by subreddit norms.
+ Style: Authentic, community-aware. Avoid overly promotional language.
+ """
+ }
+}
+
+struct MicropubContext: PlatformContext {
+ let platformName = "Micro.blog"
+ let characterLimit: Int? = nil
+ let suggestedActions: [PublicistAction] = [.draftPost, .rewrite, .adjustTone, .generateTitle]
+ var systemPromptFragment: String {
+ """
+ Currently helping with: Micro.blog / Micropub
+ Supports HTML and Markdown. Blog-style content.
+ Style: Thoughtful, personal voice. Can be long-form or microblog (< 280 chars for timeline).
+ """
+ }
+}
+
+struct LibraryContext: PlatformContext {
+ let platformName = "Library"
+ let characterLimit: Int? = nil
+ let suggestedActions: [PublicistAction] = [.generateTitle, .generateDescription]
+ var systemPromptFragment: String {
+ """
+ Currently helping with: Recording Library
+ Generate titles and descriptions for recorded videos.
+ Style: Clear, descriptive, professional.
+ """
+ }
+}
+
+struct GenericContext: PlatformContext {
+ let platformName = "General"
+ let characterLimit: Int? = nil
+ let suggestedActions: [PublicistAction] = [.draftPost, .rewrite, .adjustTone, .adaptCrossPlatform]
+ var systemPromptFragment: String {
+ """
+ Currently in general mode. Help with any content creation task.
+ Available platforms: Bluesky, YouTube, Twitch, Reddit, Micro.blog.
+ """
+ }
+}
+
+// MARK: - Navigation Section Extension
+
+extension NavigationSection {
+ /// Get the appropriate platform context for this section
+ var platformContext: any PlatformContext {
+ switch self {
+ case .dashboard: GenericContext()
+ case .profile: GenericContext()
+ case .studio: TwitchContext()
+ case .library: LibraryContext()
+ case .workflow: GenericContext()
+ case .assistant: GenericContext()
+ case .patrons: GenericContext()
+ case .protection: GenericContext()
+ case .social: BlueskyContext()
+ case .settings: GenericContext()
+ }
+ }
+}
diff --git a/ArkavoCreator/ArkavoCreator/Avatar/MuseAvatarViewModel.swift b/ArkavoCreator/ArkavoCreator/Avatar/MuseAvatarViewModel.swift
index 1593faf4..daf94507 100644
--- a/ArkavoCreator/ArkavoCreator/Avatar/MuseAvatarViewModel.swift
+++ b/ArkavoCreator/ArkavoCreator/Avatar/MuseAvatarViewModel.swift
@@ -31,6 +31,8 @@ class MuseAvatarViewModel: ObservableObject {
private var ttsAudioSource: MuseTTSAudioSource?
private var edgeLLMProvider: EdgeLLMProvider?
private var llmFallbackChain: LLMFallbackChain?
+ private var mlxResponseProvider: MLXResponseProvider?
+ private var conversationManager: ConversationManager?
/// Stream chat reactor for processing chat messages
private(set) var chatReactor: StreamChatReactor?
@@ -38,6 +40,9 @@ class MuseAvatarViewModel: ObservableObject {
/// Agent service for Edge LLM backend
weak var agentService: CreatorAgentService?
+ /// Shared model manager — provides the MLX backend for Sidekick inference
+ weak var modelManager: ModelManager?
+
// MARK: - Initialization
init() {}
@@ -79,23 +84,38 @@ class MuseAvatarViewModel: ObservableObject {
self.chatReactor = reactor
}
- /// Configure LLM providers (Edge + fallback)
+ /// Configure LLM providers (Edge + MLX Local fallback)
private func setupLLMProviders() {
var providers: [any LLMResponseProvider] = []
- // Edge provider (highest priority) — if agent service is available
+ // Edge provider (priority 0) — if agent service is available
if let agentService {
let edge = EdgeLLMProvider(agentService: agentService)
self.edgeLLMProvider = edge
providers.append(edge)
}
+ // MLX Local provider (priority 2) — on-device via shared ModelManager
+ if let modelManager {
+ let mlx = MLXResponseProvider(backend: modelManager.streamingProvider)
+ mlx.activeRole = .sidekick
+ mlx.voiceLocale = .english
+ self.mlxResponseProvider = mlx
+ providers.append(mlx)
+ }
+
// Create fallback chain
let chain = LLMFallbackChain()
for provider in providers {
chain.addProvider(provider)
}
self.llmFallbackChain = chain
+
+ // Setup conversation manager for multi-turn context
+ let cm = ConversationManager(maxHistoryMessages: 20)
+ cm.activeRole = .sidekick
+ cm.voiceLocale = .english
+ self.conversationManager = cm
}
// MARK: - Model Loading
@@ -156,13 +176,13 @@ class MuseAvatarViewModel: ObservableObject {
lastChatMessage = "\(message.displayName): \(message.content)"
do {
- let prompt = """
- You are a friendly AI avatar co-hosting a live stream. \
- A viewer named \(message.displayName) said: "\(message.content)". \
- Respond briefly and naturally (1-2 sentences). Be warm and engaging.
- """
+ // Build prompt with conversation history and viewer context
+ let userMessage = "\(message.displayName) says: \(message.content)"
+ conversationManager?.addUserMessage(userMessage)
+ let prompt = conversationManager?.buildPromptForMessage(userMessage) ?? userMessage
let (response, _) = try await chain.generate(prompt: prompt)
+ conversationManager?.addAssistantMessage(response.message)
await speak(response.message)
// Handle tool calls (emotes, expressions)
diff --git a/ArkavoCreator/ArkavoCreator/ContentView.swift b/ArkavoCreator/ArkavoCreator/ContentView.swift
index 671d5a18..f6ed7b2c 100644
--- a/ArkavoCreator/ArkavoCreator/ContentView.swift
+++ b/ArkavoCreator/ArkavoCreator/ContentView.swift
@@ -1,4 +1,5 @@
import ArkavoKit
+import MuseCore
import SwiftUI
// MARK: - Main Content View
@@ -13,33 +14,42 @@ struct ContentView: View {
@StateObject var blueskyClient: BlueskyClient
@StateObject var youtubeClient: YouTubeClient
@ObservedObject var agentService: CreatorAgentService
+ var modelManager: ModelManager
@StateObject private var twitchClient = TwitchAuthClient(
clientId: Secrets.twitchClientId,
clientSecret: Secrets.twitchClientSecret
)
var body: some View {
- NavigationSplitView {
- Sidebar(
- selectedSection: $selectedSection,
- patreonClient: patreonClient,
- redditClient: redditClient,
- blueskyClient: blueskyClient,
- youtubeClient: youtubeClient
- )
- } detail: {
- SectionContainer(
- selectedSection: selectedSection,
- patreonClient: patreonClient,
- redditClient: redditClient,
- micropubClient: micropubClient,
- blueskyClient: blueskyClient,
- youtubeClient: youtubeClient,
- twitchClient: twitchClient,
- agentService: agentService
+ ZStack {
+ LinearGradient(
+ colors: [Color(white: 0.1), Color(white: 0.15), Color(white: 0.08)],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
)
- .navigationTitle(selectedSection.rawValue)
- .navigationSubtitle(selectedSection.subtitle)
+ .ignoresSafeArea()
+
+ NavigationSplitView {
+ Sidebar(
+ selectedSection: $selectedSection,
+ patreonClient: patreonClient,
+ redditClient: redditClient,
+ blueskyClient: blueskyClient,
+ youtubeClient: youtubeClient
+ )
+ } detail: {
+ SectionContainer(
+ selectedSection: selectedSection,
+ patreonClient: patreonClient,
+ redditClient: redditClient,
+ micropubClient: micropubClient,
+ blueskyClient: blueskyClient,
+ youtubeClient: youtubeClient,
+ twitchClient: twitchClient,
+ agentService: agentService,
+ modelManager: modelManager
+ )
+ }
}
.environmentObject(appState)
.onChange(of: selectedSection) { _, newValue in
@@ -56,27 +66,26 @@ enum NavigationSection: String, CaseIterable, Codable {
case studio = "Studio"
case library = "Library"
case workflow = "Workflow"
- case assistant = "AI Assistant"
+ case assistant = "Publicist"
case patrons = "Patron Management"
case protection = "Protection"
case social = "Marketing"
case settings = "Settings"
+ /// Five clean sidebar items: Dashboard, Profile, Studio, Library, Settings.
+ /// Other sections are gated behind feature flags (all currently disabled).
static func availableSections(isCreator: Bool) -> [NavigationSection] {
- var base = allCases.filter { section in
+ allCases.filter { section in
switch section {
+ case .dashboard, .profile, .studio, .library, .settings:
+ return true
case .workflow: return FeatureFlags.workflow
case .protection: return FeatureFlags.contentProtection
case .social: return FeatureFlags.social
case .assistant: return FeatureFlags.aiAgent
case .patrons: return FeatureFlags.patreon
- default: return true
}
}
- if !isCreator {
- base = base.filter { $0 != .patrons }
- }
- return base
}
var systemImage: String {
@@ -86,7 +95,7 @@ enum NavigationSection: String, CaseIterable, Codable {
case .studio: "video.bubble.left.fill"
case .library: "rectangle.stack.badge.play"
case .workflow: "doc.badge.plus"
- case .assistant: "cpu"
+ case .assistant: "megaphone"
case .patrons: "person.2.circle"
case .protection: "lock.shield"
case .social: "square.and.arrow.up.circle"
@@ -96,12 +105,12 @@ enum NavigationSection: String, CaseIterable, Codable {
var subtitle: String {
switch self {
- case .dashboard: "Overview"
+ case .dashboard: "Your Social Command Center"
case .profile: "Your Creator Profile"
case .studio: "Record, Stream & Create"
case .library: "Your Recorded Videos"
case .workflow: "Manage Your Content"
- case .assistant: "AI-Powered Creation Tools"
+ case .assistant: "Platform Content Creation"
case .patrons: "Manage Your Community"
case .protection: "Protection"
case .social: "Share Your Content"
@@ -137,7 +146,10 @@ struct SectionContainer: View {
@ObservedObject var youtubeClient: YouTubeClient
@ObservedObject var twitchClient: TwitchAuthClient
@ObservedObject var agentService: CreatorAgentService
+ var modelManager: ModelManager
@StateObject private var webViewPresenter = WebViewPresenter()
+ @State private var showPublicistPanel = false
+ @State private var publicistViewModel: PublicistViewModel?
@Namespace private var animation
private var arkavoAuthState: ArkavoAuthState { ArkavoAuthState.shared }
@@ -249,9 +261,10 @@ struct SectionContainer: View {
ForEach(twitchClient.channelTags.prefix(5), id: \.self) { tag in
Text(tag)
.font(.caption2)
+ .foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 3)
- .background(Color.purple.opacity(0.15))
+ .background(Color.white.opacity(0.08))
.clipShape(Capsule())
}
}
@@ -402,7 +415,7 @@ struct SectionContainer: View {
}
}
.onHover { hovering in
- withAnimation(.easeInOut(duration: 0.15)) {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) {
twitchCardHovered = hovering
}
}
@@ -813,7 +826,7 @@ struct SectionContainer: View {
var body: some View {
ZStack {
// Keep RecordView always alive so streaming isn't interrupted by tab switches
- RecordView(youtubeClient: youtubeClient, twitchClient: twitchClient)
+ RecordView(youtubeClient: youtubeClient, twitchClient: twitchClient, modelManager: modelManager)
.opacity(selectedSection == .studio ? 1 : 0)
.allowsHitTesting(selectedSection == .studio)
.id("studio")
@@ -821,16 +834,43 @@ struct SectionContainer: View {
if selectedSection != .studio {
switch selectedSection {
case .dashboard:
- ScrollView {
- VStack(spacing: 24) {
- // Render sorted sections
- ForEach(sortedDashboardSections) { section in
- DashboardCard(title: section.title) {
- section.content
+ HStack(spacing: 0) {
+ ScrollView {
+ VStack(spacing: 24) {
+ ForEach(sortedDashboardSections) { section in
+ DashboardCard(title: section.title) {
+ section.content
+ }
+ }
+ }
+ .padding()
+ }
+ .frame(maxWidth: .infinity)
+
+ // Publicist panel (trailing edge)
+ if FeatureFlags.localAssistant, showPublicistPanel,
+ let pubVM = publicistViewModel {
+ PublicistPanelView(viewModel: pubVM, isVisible: $showPublicistPanel)
+ .transition(.move(edge: .trailing))
+ }
+ }
+ .toolbar {
+ if FeatureFlags.localAssistant {
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ if publicistViewModel == nil {
+ publicistViewModel = PublicistViewModel(modelManager: modelManager)
+ }
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) {
+ showPublicistPanel.toggle()
+ }
+ } label: {
+ Image(systemName: "megaphone")
}
+ .keyboardShortcut("e", modifiers: [.command])
+ .help("Publicist (⌘E)")
}
}
- .padding()
}
.transition(.moveAndFade())
.id("dashboard")
@@ -855,7 +895,7 @@ struct SectionContainer: View {
.transition(.moveAndFade())
.id("assistant")
case .settings:
- SettingsContent(agentService: agentService)
+ SettingsContent(agentService: agentService, modelManager: modelManager)
.transition(.moveAndFade())
.id("settings")
default:
@@ -865,7 +905,7 @@ struct SectionContainer: View {
}
}
}
- .animation(.smooth, value: selectedSection)
+ .animation(.spring(response: 0.35, dampingFraction: 0.85), value: selectedSection)
}
}
@@ -951,8 +991,20 @@ struct DashboardCard: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
- .background(Color(NSColor.controlBackgroundColor))
+ .background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
+ .overlay(
+ RoundedRectangle(cornerRadius: 10)
+ .strokeBorder(
+ LinearGradient(
+ colors: [.white.opacity(0.25), .white.opacity(0.02)],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ ),
+ lineWidth: 0.5
+ )
+ )
+ .shadow(color: .black.opacity(0.3), radius: 12, x: 0, y: 6)
}
}
@@ -1055,7 +1107,7 @@ struct PreviewAlert: View {
.buttonStyle(.borderedProminent)
}
.padding()
- .background(.background.secondary)
+ .background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
@@ -1085,7 +1137,7 @@ struct FeatureCard: View {
}
.padding()
.frame(height: 160)
- .background(.background.secondary)
+ .background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
@@ -1104,7 +1156,6 @@ extension AnyTransition {
// MARK: - Icon Rail View (Compact Navigation)
struct IconRail: View {
- @EnvironmentObject private var appState: AppState
@Binding var selectedSection: NavigationSection
@ObservedObject var patreonClient: PatreonClient
@ObservedObject var redditClient: RedditClient
@@ -1137,22 +1188,6 @@ struct IconRail: View {
Spacer()
- // Feedback button (if enabled)
- if appState.isFeedbackEnabled {
- Button {
- if let url = URL(string: "mailto:info@arkavo.com") {
- NSWorkspace.shared.open(url)
- }
- } label: {
- Image(systemName: "envelope")
- .font(.system(size: 18))
- .frame(width: 40, height: 40)
- .foregroundStyle(.secondary)
- }
- .buttonStyle(.plain)
- .help("Send Feedback")
- }
-
// Settings at bottom
IconRailButton(
section: .settings,
@@ -1192,13 +1227,13 @@ struct IconRail: View {
hoverTask = Task {
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
- withAnimation(.easeInOut(duration: 0.2)) {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) {
isExpanded = true
}
}
} else {
// Collapse immediately when leaving
- withAnimation(.easeInOut(duration: 0.2)) {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) {
isExpanded = false
}
}
@@ -1257,36 +1292,17 @@ struct Sidebar: View {
}
var body: some View {
- VStack(spacing: 0) {
- List(selection: $selectedSection) {
- Section {
- ForEach(availableSections.filter { $0 != .settings }, id: \.self) { section in
- NavigationLink(value: section) {
- Label(section.rawValue, systemImage: section.systemImage)
- }
- }
- }
- Section {
- NavigationLink(value: NavigationSection.settings) {
- Label(NavigationSection.settings.rawValue,
- systemImage: NavigationSection.settings.systemImage)
- }
+ List(selection: $selectedSection) {
+ ForEach(availableSections.filter { $0 != .settings }, id: \.self) { section in
+ NavigationLink(value: section) {
+ Label(section.rawValue, systemImage: section.systemImage)
}
}
- if appState.isFeedbackEnabled {
- Divider()
- Button(action: {
- if let url = URL(string: "mailto:info@arkavo.com") {
- NSWorkspace.shared.open(url)
- }
- }) {
- HStack {
- Image(systemName: "envelope")
- Text("Send Feedback")
- }
+
+ Section {
+ NavigationLink(value: NavigationSection.settings) {
+ Label("Settings", systemImage: "gear")
}
- .buttonStyle(.plain)
- .padding(10)
}
}
.listStyle(.sidebar)
@@ -1358,7 +1374,7 @@ struct ContentCard: View {
.controlSize(.small)
}
.padding(12)
- .background(Color(nsColor: .controlBackgroundColor))
+ .background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -1369,15 +1385,39 @@ struct ContentCard: View {
}
struct SettingsContent: View {
- @EnvironmentObject private var appState: AppState
@StateObject private var vrmDownloader = VRMDownloader()
@State private var modelsPath: String = ""
@State private var libraryPath: String = ""
var agentService: CreatorAgentService?
+ var modelManager: ModelManager?
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
+ // Send Feedback
+ Button {
+ if let url = URL(string: "mailto:info@arkavo.com") {
+ NSWorkspace.shared.open(url)
+ }
+ } label: {
+ HStack(spacing: 8) {
+ Image(systemName: "envelope")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ Text("Send Feedback")
+ .font(.subheadline)
+ Spacer()
+ Image(systemName: "arrow.up.right")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ .padding(.vertical, 6)
+ .padding(.horizontal, 10)
+ .background(.ultraThinMaterial)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ }
+ .buttonStyle(.plain)
+
// Library Path Section
GroupBox {
VStack(alignment: .leading, spacing: 12) {
@@ -1406,13 +1446,13 @@ struct SettingsContent: View {
RecordingsFolderAccess.clearBookmark()
updateLibraryPath()
}
- .foregroundColor(.red)
+ .foregroundColor(.secondary)
}
}
Text("Select where recordings are saved. The app needs permission to write to this folder.")
- .foregroundColor(.secondary)
- .font(.callout)
+ .foregroundStyle(.tertiary)
+ .font(.caption)
}
} label: {
Label("Library", systemImage: "folder")
@@ -1456,31 +1496,20 @@ struct SettingsContent: View {
.font(.callout)
}
.padding()
- .background(Color(NSColor.controlBackgroundColor))
+ .background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
+ // AI Model Settings Section
+ if let modelManager {
+ AIModelSettingsSection(modelManager: modelManager)
+ }
+
// AI Agent Settings Section
if FeatureFlags.aiAgent, let agentService {
AgentSettingsSection(agentService: agentService)
}
- // Feedback Toggle Section
- VStack(alignment: .leading, spacing: 12) {
- Text("Feedback")
- .font(.headline)
-
- Toggle("Show Feedback Button", isOn: $appState.isFeedbackEnabled)
- .toggleStyle(.switch)
-
- Text("When enabled, shows a feedback button in the toolbar for quick access to send feedback.")
- .foregroundColor(.secondary)
- .font(.callout)
- }
- .padding()
- .background(Color(NSColor.controlBackgroundColor))
- .clipShape(RoundedRectangle(cornerRadius: 10))
-
Spacer()
}
.padding()
@@ -1501,6 +1530,138 @@ struct SettingsContent: View {
}
}
+// MARK: - AI Model Settings Section
+
+struct AIModelSettingsSection: View {
+ @Bindable var modelManager: ModelManager
+ @State private var customCachePath: String = ""
+
+ var body: some View {
+ GroupBox {
+ VStack(alignment: .leading, spacing: 16) {
+ // Preferred Model Picker
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Preferred Model")
+ .font(.subheadline)
+ Picker("Model", selection: Binding(
+ get: { modelManager.selectedModel },
+ set: { model in Task { await modelManager.selectModel(model) } }
+ )) {
+ ForEach(ModelRegistry.models) { model in
+ HStack {
+ Text(model.displayName)
+ Text("(\(model.quantization))")
+ .foregroundStyle(.secondary)
+ }
+ .tag(model)
+ }
+ }
+ .pickerStyle(.menu)
+ }
+
+ // Model State
+ HStack(spacing: 8) {
+ switch modelManager.state {
+ case .idle:
+ Image(systemName: "circle")
+ .foregroundStyle(.secondary)
+ Text("Not loaded")
+ .foregroundStyle(.secondary)
+ case .downloading(let progress):
+ ProgressView(value: progress)
+ .frame(width: 60)
+ Text("Downloading \(Int(progress * 100))%")
+ case .loading:
+ ProgressView()
+ .controlSize(.small)
+ Text("Loading into memory...")
+ case .ready:
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ Text("Ready (\(modelManager.selectedModel.parameterCount))")
+ case .error(let msg):
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.red)
+ Text(msg)
+ .lineLimit(2)
+ .font(.caption)
+ case .unloaded(let reason):
+ Image(systemName: "moon.zzz")
+ .foregroundStyle(.secondary)
+ Text("Unloaded: \(reason)")
+ }
+ Spacer()
+
+ // Load / Unload button
+ if modelManager.state == .ready {
+ Button("Unload") {
+ Task { await modelManager.unloadModel() }
+ }
+ .foregroundStyle(.secondary)
+ } else if case .idle = modelManager.state {
+ Button("Load") {
+ Task { await modelManager.loadSelectedModel() }
+ }
+ } else if case .error = modelManager.state {
+ Button("Retry") {
+ Task { await modelManager.loadSelectedModel() }
+ }
+ }
+ }
+ .font(.subheadline)
+
+ Divider()
+
+ // Model Cache Folder
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Model Cache Folder")
+ .font(.subheadline)
+ Text(customCachePath.isEmpty ? "~/.cache/huggingface/hub (default)" : customCachePath)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }
+
+ Spacer()
+
+ Button("Choose...") {
+ let panel = NSOpenPanel()
+ panel.canChooseDirectories = true
+ panel.canChooseFiles = false
+ panel.allowsMultipleSelection = false
+ panel.message = "Select the folder containing HuggingFace model caches"
+ if panel.runModal() == .OK, let url = panel.url {
+ modelManager.customCacheDirectory = url
+ customCachePath = url.path
+ }
+ }
+
+ if modelManager.customCacheDirectory != nil {
+ Button("Reset") {
+ modelManager.customCacheDirectory = nil
+ customCachePath = ""
+ }
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Text("Models are downloaded from HuggingFace on first use. Reuse models cached by Python or other tools.")
+ .foregroundStyle(.tertiary)
+ .font(.caption)
+ }
+ }
+ } label: {
+ Label("AI Model", systemImage: "cpu")
+ }
+ .onAppear {
+ customCachePath = modelManager.customCacheDirectory?.path ?? ""
+ }
+ }
+}
+
// MARK: - Agent Settings Section
struct AgentSettingsSection: View {
@@ -1549,7 +1710,7 @@ struct AgentSettingsSection: View {
.font(.callout)
}
.padding()
- .background(Color(NSColor.controlBackgroundColor))
+ .background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
diff --git a/ArkavoCreator/ArkavoCreator/FeatureFlags.swift b/ArkavoCreator/ArkavoCreator/FeatureFlags.swift
index 0bef64d2..a45508dc 100644
--- a/ArkavoCreator/ArkavoCreator/FeatureFlags.swift
+++ b/ArkavoCreator/ArkavoCreator/FeatureFlags.swift
@@ -14,11 +14,13 @@ enum FeatureFlags {
/// Arkavo encrypted streaming platform
static let arkavoStreaming = false
/// YouTube streaming and OAuth integration
- static let youtube = false
+ static let youtube = true
/// Patreon patron management
static let patreon = false
/// Workflow management section
static let workflow = false
/// Marketing/social section
static let social = false
+ /// Muse roles (Producer, Publicist, Sidekick) powered by MLX
+ static let localAssistant = true
}
diff --git a/ArkavoCreator/ArkavoCreator/Producer/ProducerPanelView.swift b/ArkavoCreator/ArkavoCreator/Producer/ProducerPanelView.swift
new file mode 100644
index 00000000..f620bfff
--- /dev/null
+++ b/ArkavoCreator/ArkavoCreator/Producer/ProducerPanelView.swift
@@ -0,0 +1,281 @@
+import MuseCore
+import SwiftUI
+
+/// Unified Producer panel — command center with stream health, actions, suggestions, and chat feed
+struct ProducerPanelView: View {
+ var viewModel: ProducerViewModel
+ @Binding var isVisible: Bool
+ var chatViewModel: ChatPanelViewModel?
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // Header
+ HStack {
+ HStack(spacing: 8) {
+ Circle()
+ .fill(viewModel.modelManager.isReady ? Color.green : Color.gray)
+ .frame(width: 8, height: 8)
+ Text("Producer")
+ .font(.headline)
+ }
+
+ Spacer()
+
+ Button {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) {
+ isVisible = false
+ }
+ } label: {
+ Image(systemName: "xmark")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+
+ Divider()
+
+ // Sections 1-3: Health, Actions, Suggestions (fixed height, scrollable)
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ streamHealthSection
+ Divider()
+ quickActionsSection
+ Divider()
+ suggestionsSection
+ }
+ .padding(16)
+ }
+ .frame(maxHeight: 320)
+
+ Divider()
+
+ // Section 4: Chat Monitor (fills remaining space)
+ if let chatVM = chatViewModel {
+ chatMonitorSection(chatVM)
+ } else {
+ VStack(spacing: 8) {
+ Image(systemName: "bubble.left.and.bubble.right")
+ .font(.title3)
+ .foregroundStyle(.tertiary)
+ Text("Chat appears when streaming")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ }
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ }
+ }
+ }
+
+ // MARK: - Chat Monitor
+
+ @ViewBuilder
+ private func chatMonitorSection(_ chatVM: ChatPanelViewModel) -> some View {
+ VStack(spacing: 0) {
+ // Chat header
+ HStack {
+ Circle()
+ .fill(chatVM.isConnected ? Color.green : Color.gray)
+ .frame(width: 6, height: 6)
+ Text("Chat")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ Spacer()
+ Text("\(chatVM.messages.count)")
+ .font(.caption2.monospacedDigit())
+ .foregroundStyle(.tertiary)
+ }
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+
+ // Dense chat feed
+ ScrollViewReader { proxy in
+ ScrollView {
+ LazyVStack(alignment: .leading, spacing: 2) {
+ ForEach(chatVM.messages) { message in
+ Text("\(Text(message.displayName).bold()): \(message.content)")
+ .font(.system(size: 11, design: .monospaced))
+ .foregroundStyle(.primary.opacity(0.85))
+ .lineLimit(2)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 1)
+ .id(message.id)
+ }
+ }
+ }
+ .onChange(of: chatVM.messages.count) { _, _ in
+ if let lastID = chatVM.messages.last?.id {
+ withAnimation(.easeOut(duration: 0.1)) {
+ proxy.scrollTo(lastID, anchor: .bottom)
+ }
+ }
+ }
+ }
+ }
+ .frame(maxHeight: .infinity)
+ }
+
+ // MARK: - Stream Health
+
+ private var streamHealthSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Stream Health")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.secondary)
+
+ if viewModel.streamState.isLive {
+ HStack(spacing: 16) {
+ statItem(
+ icon: "eye.fill",
+ value: "\(viewModel.streamState.viewerCount)",
+ label: "viewers"
+ )
+ statItem(
+ icon: "clock.fill",
+ value: formatDuration(viewModel.streamState.streamDuration),
+ label: "uptime"
+ )
+ }
+
+ HStack(spacing: 4) {
+ Text("Sentiment:")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ sentimentIndicator(viewModel.streamState.chatSentiment)
+ }
+ } else {
+ Text("Not streaming")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
+
+ private func statItem(icon: String, value: String, label: String) -> some View {
+ HStack(spacing: 4) {
+ Image(systemName: icon)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ Text(value)
+ .font(.caption.weight(.medium))
+ Text(label)
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private func sentimentIndicator(_ value: Double) -> some View {
+ HStack(spacing: 4) {
+ Image(systemName: value < 0.3 ? "face.dashed" : value < 0.7 ? "face.smiling" : "face.smiling.fill")
+ .font(.caption)
+ .foregroundStyle(value < 0.3 ? .red : value < 0.7 ? .yellow : .green)
+ Text(value < 0.3 ? "Negative" : value < 0.7 ? "Neutral" : "Positive")
+ .font(.caption)
+ .foregroundStyle(value < 0.3 ? .red : value < 0.7 ? .yellow : .green)
+ }
+ }
+
+ // MARK: - Quick Actions
+
+ private var quickActionsSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Quick Actions")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.secondary)
+
+ HStack(spacing: 8) {
+ quickActionButton("Break", icon: "cup.and.saucer") {
+ viewModel.generateSuggestion(prompt: "Suggest a good time to take a break based on stream state.")
+ }
+ quickActionButton("Scene", icon: "rectangle.on.rectangle") {
+ viewModel.generateSuggestion(prompt: "Suggest the next scene change based on current activity.")
+ }
+ quickActionButton("Raid", icon: "person.wave.2") {
+ viewModel.generateSuggestion(prompt: "Suggest a good raid target and timing.")
+ }
+ }
+ }
+ }
+
+ private func quickActionButton(_ title: String, icon: String, action: @escaping () -> Void) -> some View {
+ Button(action: action) {
+ VStack(spacing: 4) {
+ Image(systemName: icon)
+ .font(.system(size: 14))
+ Text(title)
+ .font(.caption2)
+ }
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 8)
+ .background(.regularMaterial)
+ .cornerRadius(8)
+ }
+ .buttonStyle(.plain)
+ .disabled(viewModel.isGenerating || !viewModel.modelManager.isReady)
+ }
+
+ // MARK: - Suggestions
+
+ private var suggestionsSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text("Suggestions")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.secondary)
+ Spacer()
+ if viewModel.isGenerating {
+ ProgressView()
+ .controlSize(.mini)
+ }
+ }
+
+ if viewModel.suggestions.isEmpty {
+ Text("No suggestions yet. Use quick actions or wait for auto-suggestions.")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ .padding(.vertical, 8)
+ } else {
+ ForEach(viewModel.suggestions.prefix(10)) { suggestion in
+ suggestionRow(suggestion)
+ }
+ }
+ }
+ }
+
+ private func suggestionRow(_ suggestion: ProducerSuggestion) -> some View {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ Text(suggestion.category.rawValue)
+ .font(.caption2.weight(.bold))
+ .foregroundStyle(categoryColor(suggestion.category))
+ Spacer()
+ Text(suggestion.timestamp, style: .time)
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ Text(suggestion.text)
+ .font(.caption)
+ .lineLimit(4)
+ }
+ .padding(8)
+ .background(.quaternary.opacity(0.5))
+ .cornerRadius(8)
+ }
+
+ private func categoryColor(_ category: ProducerSuggestion.Category) -> Color {
+ switch category {
+ case .alert: .red
+ case .suggestion: .blue
+ case .info: .secondary
+ }
+ }
+
+ private func formatDuration(_ seconds: TimeInterval) -> String {
+ let hours = Int(seconds) / 3600
+ let minutes = (Int(seconds) % 3600) / 60
+ if hours > 0 { return "\(hours)h \(minutes)m" }
+ return "\(minutes)m"
+ }
+}
diff --git a/ArkavoCreator/ArkavoCreator/Producer/ProducerViewModel.swift b/ArkavoCreator/ArkavoCreator/Producer/ProducerViewModel.swift
new file mode 100644
index 00000000..013a2fb4
--- /dev/null
+++ b/ArkavoCreator/ArkavoCreator/Producer/ProducerViewModel.swift
@@ -0,0 +1,132 @@
+import Foundation
+import MuseCore
+import Observation
+
+/// A timestamped suggestion from the Producer
+struct ProducerSuggestion: Identifiable {
+ let id = UUID()
+ let text: String
+ let category: Category
+ let timestamp = Date()
+
+ enum Category: String {
+ case alert = "Alert"
+ case suggestion = "Suggestion"
+ case info = "Info"
+ }
+}
+
+/// View model for the Producer role — monitors stream and generates suggestions
+@Observable
+@MainActor
+final class ProducerViewModel {
+ private(set) var suggestions: [ProducerSuggestion] = []
+ private(set) var isGenerating = false
+ var streamState = StreamStateContext()
+
+ let modelManager: ModelManager
+ private var autoSuggestTask: Task?
+
+ init(modelManager: ModelManager) {
+ self.modelManager = modelManager
+ }
+
+ /// Generate a suggestion based on current stream state
+ func generateSuggestion(prompt: String? = nil) {
+ guard modelManager.isReady else { return }
+ guard !isGenerating else { return }
+
+ isGenerating = true
+
+ Task {
+ let userPrompt = prompt ?? "Analyze the current stream state and provide one actionable suggestion."
+ let systemPrompt = RolePromptProvider.systemPrompt(for: .producer, locale: .english)
+ let contextPrompt = streamState.formattedForPrompt()
+ let fullSystemPrompt = systemPrompt + "\n\n# Current Context\n" + contextPrompt
+
+ let stream = modelManager.streamingProvider.generate(
+ prompt: userPrompt,
+ systemPrompt: fullSystemPrompt,
+ maxTokens: 256
+ )
+
+ var fullText = ""
+ do {
+ for try await token in stream {
+ fullText += token
+ }
+ } catch {
+ fullText = "Error generating suggestion: \(error.localizedDescription)"
+ }
+
+ let category = categorize(fullText)
+ let suggestion = ProducerSuggestion(text: fullText, category: category)
+ suggestions.insert(suggestion, at: 0)
+
+ // Keep last 20 suggestions
+ if suggestions.count > 20 {
+ suggestions = Array(suggestions.prefix(20))
+ }
+
+ isGenerating = false
+ }
+ }
+
+ /// Start auto-generating suggestions on a timer when live
+ func startAutoSuggestions(interval: TimeInterval = 60) {
+ stopAutoSuggestions()
+ autoSuggestTask = Task {
+ while !Task.isCancelled {
+ try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
+ guard !Task.isCancelled, streamState.isLive, modelManager.isReady else { continue }
+ generateSuggestion()
+ }
+ }
+ }
+
+ /// Stop auto-generating suggestions
+ func stopAutoSuggestions() {
+ autoSuggestTask?.cancel()
+ autoSuggestTask = nil
+ }
+
+ /// Update stream state from Twitch client data
+ func updateStreamState(
+ isLive: Bool,
+ viewerCount: Int,
+ streamStartedAt: Date?,
+ currentScene: String
+ ) {
+ streamState.isLive = isLive
+ streamState.viewerCount = viewerCount
+ streamState.currentScene = currentScene
+ if let start = streamStartedAt {
+ streamState.streamDuration = Date().timeIntervalSince(start)
+ }
+ }
+
+ /// Add a stream event to the context
+ func addEvent(type: String, displayName: String) {
+ let event = StreamEventSummary(type: type, displayName: displayName, timestamp: Date())
+ streamState.recentEvents.append(event)
+ // Keep last 10
+ if streamState.recentEvents.count > 10 {
+ streamState.recentEvents = Array(streamState.recentEvents.suffix(10))
+ }
+ }
+
+ /// Clear all suggestions
+ func clearSuggestions() {
+ suggestions.removeAll()
+ }
+
+ private func categorize(_ text: String) -> ProducerSuggestion.Category {
+ let lowered = text.lowercased()
+ if lowered.contains("[alert]") || lowered.contains("warning") || lowered.contains("drop") {
+ return .alert
+ } else if lowered.contains("[info]") || lowered.contains("note") {
+ return .info
+ }
+ return .suggestion
+ }
+}
diff --git a/ArkavoCreator/ArkavoCreator/Producer/StreamStateContext.swift b/ArkavoCreator/ArkavoCreator/Producer/StreamStateContext.swift
new file mode 100644
index 00000000..c6991dae
--- /dev/null
+++ b/ArkavoCreator/ArkavoCreator/Producer/StreamStateContext.swift
@@ -0,0 +1,61 @@
+import Foundation
+
+/// Captures stream state for prompt injection into the Producer role
+struct StreamStateContext {
+ var isLive: Bool = false
+ var viewerCount: Int = 0
+ var streamDuration: TimeInterval = 0
+ var currentScene: String = "Live"
+ var recentEvents: [StreamEventSummary] = []
+ var chatSentiment: Double = 0.5 // 0.0 = negative, 1.0 = positive
+
+ /// Serializes to text for LLM context injection
+ func formattedForPrompt() -> String {
+ var lines: [String] = []
+
+ lines.append("Stream Status: \(isLive ? "LIVE" : "OFFLINE")")
+ if isLive {
+ lines.append("Viewers: \(viewerCount)")
+ lines.append("Duration: \(formattedDuration)")
+ lines.append("Scene: \(currentScene)")
+ lines.append("Chat Sentiment: \(sentimentLabel)")
+ }
+
+ if !recentEvents.isEmpty {
+ lines.append("Recent Events:")
+ for event in recentEvents.suffix(5) {
+ lines.append(" - \(event.summary)")
+ }
+ }
+
+ return lines.joined(separator: "\n")
+ }
+
+ private var formattedDuration: String {
+ let hours = Int(streamDuration) / 3600
+ let minutes = (Int(streamDuration) % 3600) / 60
+ if hours > 0 {
+ return "\(hours)h \(minutes)m"
+ }
+ return "\(minutes)m"
+ }
+
+ private var sentimentLabel: String {
+ switch chatSentiment {
+ case 0..<0.3: "Negative"
+ case 0.3..<0.7: "Neutral"
+ default: "Positive"
+ }
+ }
+}
+
+/// Lightweight summary of a stream event for prompt context
+struct StreamEventSummary: Sendable {
+ let type: String
+ let displayName: String
+ let timestamp: Date
+
+ var summary: String {
+ "\(type) from \(displayName)"
+ }
+}
diff --git a/ArkavoCreator/ArkavoCreator/Publicist/PublicistPanelView.swift b/ArkavoCreator/ArkavoCreator/Publicist/PublicistPanelView.swift
new file mode 100644
index 00000000..83581da6
--- /dev/null
+++ b/ArkavoCreator/ArkavoCreator/Publicist/PublicistPanelView.swift
@@ -0,0 +1,271 @@
+import MuseCore
+import SwiftUI
+
+/// Compact side panel for the Publicist role — slides in from trailing edge on Dashboard
+struct PublicistPanelView: View {
+ @Bindable var viewModel: PublicistViewModel
+ @Binding var isVisible: Bool
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // Header
+ HStack {
+ HStack(spacing: 8) {
+ Circle()
+ .fill(viewModel.modelManager.isReady ? Color.green : Color.gray)
+ .frame(width: 8, height: 8)
+ Text("Publicist")
+ .font(.headline)
+ }
+
+ Spacer()
+
+ if !viewModel.modelManager.isReady {
+ Button("Load") {
+ Task { await viewModel.modelManager.loadSelectedModel() }
+ }
+ .controlSize(.mini)
+ .buttonStyle(.bordered)
+ }
+
+ Button {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) {
+ isVisible = false
+ }
+ } label: {
+ Image(systemName: "xmark")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.horizontal, 16)
+ .padding(.vertical, 12)
+
+ Divider()
+
+ ScrollView {
+ VStack(alignment: .leading, spacing: 16) {
+ // Platform selector (compact)
+ platformSelector
+
+ // Content type (compact)
+ contentTypeSelector
+
+ // Source input
+ sourceInput
+
+ // Generate
+ generateSection
+
+ // Output
+ if !viewModel.generatedContent.isEmpty || viewModel.isGenerating {
+ outputSection
+ }
+ }
+ .padding(16)
+ }
+ }
+ .frame(width: 320)
+ .background(.ultraThinMaterial)
+ .overlay(
+ Rectangle().frame(width: 1).foregroundColor(.white.opacity(0.1)),
+ alignment: .leading
+ )
+ }
+
+ // MARK: - Platform Selector
+
+ private var platformSelector: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Platform")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 6) {
+ ForEach(PublicistPlatform.allCases, id: \.self) { platform in
+ let isSelected = viewModel.selectedPlatform == platform
+ Button {
+ viewModel.selectedPlatform = platform
+ } label: {
+ Text(platform.rawValue)
+ .font(.caption2.weight(isSelected ? .semibold : .regular))
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.2)) : AnyShapeStyle(.quaternary))
+ .foregroundStyle(isSelected ? Color.accentColor : .primary)
+ .cornerRadius(6)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(4)
+ .background(Color.black.opacity(0.2))
+ .cornerRadius(8)
+ }
+ }
+ }
+
+ // MARK: - Content Type
+
+ private var contentTypeSelector: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ Text("Type")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+
+ HStack(spacing: 6) {
+ ForEach(PublicistContentType.allCases, id: \.self) { type in
+ let isSelected = viewModel.selectedContentType == type
+ Button {
+ viewModel.selectedContentType = type
+ } label: {
+ Text(type.rawValue)
+ .font(.caption2.weight(isSelected ? .semibold : .regular))
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.2)) : AnyShapeStyle(.quaternary))
+ .foregroundStyle(isSelected ? Color.accentColor : .primary)
+ .cornerRadius(6)
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(4)
+ .background(Color.black.opacity(0.2))
+ .cornerRadius(8)
+ }
+ }
+
+ // MARK: - Source Input
+
+ private var sourceInput: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Source")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+
+ ZStack(alignment: .topLeading) {
+ TextEditor(text: $viewModel.sourceText)
+ .font(.caption)
+ .frame(minHeight: 40, maxHeight: 80)
+ .padding(6)
+ .scrollContentBackground(.hidden)
+
+ if viewModel.sourceText.isEmpty {
+ Text("Paste or type source content...")
+ .font(.caption)
+ .foregroundStyle(.tertiary)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 14)
+ .allowsHitTesting(false)
+ }
+ }
+ .background(.quaternary)
+ .cornerRadius(6)
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(Color.white.opacity(0.1))
+ )
+ }
+ }
+
+ // MARK: - Generate
+
+ private var generateSection: some View {
+ HStack {
+ if viewModel.isGenerating {
+ Button("Stop") { viewModel.stopGeneration() }
+ .controlSize(.small)
+ .buttonStyle(.bordered)
+ } else {
+ Button("Generate") { viewModel.generate() }
+ .controlSize(.small)
+ .buttonStyle(.borderedProminent)
+ .disabled(!viewModel.modelManager.isReady)
+ }
+
+ Spacer()
+
+ if let limit = viewModel.selectedPlatform.characterLimit {
+ Text("\(limit) chars")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ }
+
+ // MARK: - Output
+
+ private var outputSection: some View {
+ VStack(alignment: .leading, spacing: 6) {
+ HStack {
+ Text("Output")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ Spacer()
+ if !viewModel.generatedContent.isEmpty {
+ HStack(spacing: 2) {
+ Text("\(viewModel.characterCount)")
+ .font(.caption2.monospacedDigit())
+ .foregroundStyle(viewModel.isOverLimit ? .red : .secondary)
+ if let limit = viewModel.selectedPlatform.characterLimit {
+ Text("/ \(limit)")
+ .font(.caption2.monospacedDigit())
+ .foregroundStyle(.tertiary)
+ }
+ }
+ }
+ }
+
+ if viewModel.isGenerating {
+ VStack(alignment: .leading) {
+ Text(viewModel.streamingText)
+ .font(.caption)
+ .textSelection(.enabled)
+ ProgressView()
+ .controlSize(.mini)
+ }
+ .padding(8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.quaternary)
+ .cornerRadius(6)
+ } else {
+ Text(viewModel.generatedContent)
+ .font(.caption)
+ .textSelection(.enabled)
+ .padding(8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.quaternary)
+ .cornerRadius(6)
+ .overlay(
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(viewModel.isOverLimit ? Color.red.opacity(0.5) : Color.clear, lineWidth: 1)
+ )
+ }
+
+ // Actions
+ if !viewModel.generatedContent.isEmpty && !viewModel.isGenerating {
+ HStack(spacing: 8) {
+ Button { viewModel.copyToClipboard() } label: {
+ Image(systemName: "doc.on.doc")
+ }
+ .help("Copy")
+
+ Button { viewModel.generate() } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ .help("Regenerate")
+
+ Button { viewModel.clearContent() } label: {
+ Image(systemName: "trash")
+ }
+ .help("Clear")
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.mini)
+ }
+ }
+ }
+}
diff --git a/ArkavoCreator/ArkavoCreator/Publicist/PublicistPromptBuilder.swift b/ArkavoCreator/ArkavoCreator/Publicist/PublicistPromptBuilder.swift
new file mode 100644
index 00000000..7fcd897e
--- /dev/null
+++ b/ArkavoCreator/ArkavoCreator/Publicist/PublicistPromptBuilder.swift
@@ -0,0 +1,88 @@
+import Foundation
+
+/// Composes system prompts for the Publicist role's content creation tasks
+enum PublicistPromptBuilder {
+ /// Build a system prompt incorporating platform context
+ static func buildSystemPrompt(for context: any PlatformContext) -> String {
+ """
+ You are Muse in Publicist mode — a content creation specialist for a social media creator. \
+ You help draft, rewrite, and adapt content across platforms. \
+ Be concise, creative, and match the tone appropriate for each platform.
+
+ \(context.systemPromptFragment)
+
+ Guidelines:
+ - Be direct and helpful. Skip preamble.
+ - When drafting, provide ready-to-use content.
+ - When rewriting, preserve the core message while improving clarity and engagement.
+ - Respect character limits strictly when specified.
+ - Suggest hashtags, keywords, or formatting only when relevant to the platform.
+ """
+ }
+
+ /// Build a prompt with conversation history
+ static func buildPrompt(
+ userMessage: String,
+ context: any PlatformContext,
+ conversationHistory: [(role: String, content: String)] = []
+ ) -> String {
+ var prompt = ""
+
+ if !conversationHistory.isEmpty {
+ for entry in conversationHistory.suffix(10) {
+ prompt += "\(entry.role): \(entry.content)\n"
+ }
+ prompt += "\n"
+ }
+
+ prompt += userMessage
+ return prompt
+ }
+
+ /// Build a prompt for a specific action
+ static func buildActionPrompt(
+ action: PublicistAction,
+ inputText: String?,
+ context: any PlatformContext
+ ) -> String {
+ let platformInfo = context.characterLimit.map { "Maximum \($0) characters. " } ?? ""
+
+ switch action {
+ case .draftPost:
+ if let input = inputText, !input.isEmpty {
+ return "Draft a \(context.platformName) post about: \(input). \(platformInfo)"
+ }
+ return "Draft an engaging \(context.platformName) post. \(platformInfo)"
+
+ case .rewrite:
+ guard let input = inputText, !input.isEmpty else {
+ return "Please provide text to rewrite."
+ }
+ return "Rewrite this for \(context.platformName): \(input). \(platformInfo)"
+
+ case .adjustTone:
+ guard let input = inputText, !input.isEmpty else {
+ return "Please provide text to adjust."
+ }
+ return "Adjust the tone of this text to be more engaging for \(context.platformName): \(input). \(platformInfo)"
+
+ case .adaptCrossPlatform:
+ guard let input = inputText, !input.isEmpty else {
+ return "Please provide content to adapt."
+ }
+ return "Adapt this content for \(context.platformName): \(input). \(platformInfo)"
+
+ case .generateTitle:
+ if let input = inputText, !input.isEmpty {
+ return "Generate a compelling title for \(context.platformName) about: \(input)"
+ }
+ return "Generate a compelling title for \(context.platformName) content."
+
+ case .generateDescription:
+ if let input = inputText, !input.isEmpty {
+ return "Generate a description for \(context.platformName) about: \(input)"
+ }
+ return "Generate a description for \(context.platformName) content."
+ }
+ }
+}
diff --git a/ArkavoCreator/ArkavoCreator/Publicist/PublicistView.swift b/ArkavoCreator/ArkavoCreator/Publicist/PublicistView.swift
new file mode 100644
index 00000000..44b38847
--- /dev/null
+++ b/ArkavoCreator/ArkavoCreator/Publicist/PublicistView.swift
@@ -0,0 +1,326 @@
+import MuseCore
+import SwiftUI
+
+/// Content creation workspace for the Publicist role
+struct PublicistView: View {
+ @Bindable var viewModel: PublicistViewModel
+
+ var body: some View {
+ VStack(spacing: 0) {
+ // Header with model status
+ modelStatusBar
+
+ Divider()
+
+ ScrollView {
+ VStack(alignment: .leading, spacing: 20) {
+ // Platform selector
+ platformSelector
+
+ // Content type selector
+ contentTypeSelector
+
+ // Source input
+ sourceInputSection
+
+ // Generate button
+ generateButton
+
+ // Output
+ if !viewModel.generatedContent.isEmpty || viewModel.isGenerating {
+ outputSection
+ }
+ }
+ .padding(20)
+ }
+ }
+ }
+
+ // MARK: - Model Status
+
+ private var modelStatusBar: some View {
+ HStack(spacing: 8) {
+ switch viewModel.modelManager.state {
+ case .ready:
+ Image(systemName: "checkmark.circle.fill")
+ .foregroundStyle(.green)
+ .font(.caption)
+ Text(viewModel.modelManager.selectedModel.displayName)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ case .downloading(let progress):
+ ProgressView(value: progress)
+ .frame(width: 80)
+ .controlSize(.small)
+ Text("Downloading \(Int(progress * 100))%")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ case .loading:
+ ProgressView()
+ .controlSize(.mini)
+ Text("Loading \(viewModel.modelManager.selectedModel.displayName)…")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+
+ case .error(let message):
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.yellow)
+ .font(.caption)
+ Text(message)
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ .lineLimit(1)
+
+ case .idle, .unloaded:
+ if viewModel.modelManager.isSelectedModelCached {
+ Image(systemName: "arrow.down.circle.fill")
+ .foregroundStyle(.blue)
+ .font(.caption)
+ Text("\(viewModel.modelManager.selectedModel.displayName) — cached")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ } else {
+ Image(systemName: "circle")
+ .foregroundStyle(.secondary)
+ .font(.caption)
+ Text("Model not downloaded")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ Spacer()
+
+ modelActionButton
+ }
+ .padding(.horizontal, 20)
+ .padding(.vertical, 8)
+ .background(.ultraThinMaterial)
+ }
+
+ @ViewBuilder
+ private var modelActionButton: some View {
+ switch viewModel.modelManager.state {
+ case .idle, .unloaded, .error:
+ Button("Load Model") {
+ Task { await viewModel.modelManager.loadSelectedModel() }
+ }
+ .controlSize(.small)
+ .buttonStyle(.bordered)
+ case .downloading, .loading:
+ Button("Cancel", role: .cancel) {
+ Task { await viewModel.modelManager.unloadModel() }
+ }
+ .controlSize(.small)
+ .buttonStyle(.bordered)
+ case .ready:
+ EmptyView()
+ }
+ }
+
+ // MARK: - Platform Selector
+
+ private var platformSelector: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Platform")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.secondary)
+
+ ScrollView(.horizontal, showsIndicators: false) {
+ HStack(spacing: 8) {
+ ForEach(PublicistPlatform.allCases, id: \.self) { platform in
+ let isSelected = viewModel.selectedPlatform == platform
+ Button {
+ viewModel.selectedPlatform = platform
+ } label: {
+ Text(platform.rawValue)
+ .font(.caption.weight(isSelected ? .semibold : .regular))
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.2)) : AnyShapeStyle(.quaternary))
+ .foregroundStyle(isSelected ? Color.accentColor : .primary)
+ .cornerRadius(8)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 1)
+ )
+ }
+ .buttonStyle(.plain)
+ .accessibilityIdentifier("Platform_\(platform.rawValue)")
+ }
+ }
+ }
+ }
+ }
+
+ // MARK: - Content Type Selector
+
+ private var contentTypeSelector: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Content Type")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.secondary)
+
+ HStack(spacing: 8) {
+ ForEach(PublicistContentType.allCases, id: \.self) { type in
+ let isSelected = viewModel.selectedContentType == type
+ Button {
+ viewModel.selectedContentType = type
+ } label: {
+ Text(type.rawValue)
+ .font(.caption.weight(isSelected ? .semibold : .regular))
+ .padding(.horizontal, 12)
+ .padding(.vertical, 6)
+ .background(isSelected ? AnyShapeStyle(Color.accentColor.opacity(0.2)) : AnyShapeStyle(.quaternary))
+ .foregroundStyle(isSelected ? Color.accentColor : .primary)
+ .cornerRadius(8)
+ }
+ .buttonStyle(.plain)
+ .accessibilityIdentifier("ContentType_\(type.rawValue)")
+ }
+ }
+ }
+ }
+
+ // MARK: - Source Input
+
+ private var sourceInputSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ Text("Source (optional)")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.secondary)
+
+ TextEditor(text: $viewModel.sourceText)
+ .font(.body)
+ .frame(minHeight: 60, maxHeight: 120)
+ .padding(8)
+ .background(.quaternary)
+ .cornerRadius(8)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(.quaternary, lineWidth: 1)
+ )
+
+ Text("Paste text, topic, or leave empty for a general draft")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ }
+
+ // MARK: - Generate Button
+
+ private var generateButton: some View {
+ HStack {
+ if viewModel.isGenerating {
+ Button("Stop") {
+ viewModel.stopGeneration()
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.regular)
+ .accessibilityIdentifier("Btn_Stop")
+ } else {
+ Button("Generate") {
+ viewModel.generate()
+ }
+ .buttonStyle(.borderedProminent)
+ .controlSize(.regular)
+ .disabled(!viewModel.modelManager.isReady)
+ .accessibilityIdentifier("Btn_Generate")
+ }
+
+ Spacer()
+
+ if let limit = viewModel.selectedPlatform.characterLimit {
+ Text("\(limit) char limit")
+ .font(.caption2)
+ .foregroundStyle(.tertiary)
+ }
+ }
+ }
+
+ // MARK: - Output Section
+
+ private var outputSection: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack {
+ Text("Output")
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.secondary)
+
+ Spacer()
+
+ if !viewModel.generatedContent.isEmpty {
+ // Character count
+ HStack(spacing: 4) {
+ Text("\(viewModel.characterCount)")
+ .font(.caption.monospacedDigit())
+ .foregroundStyle(viewModel.isOverLimit ? .red : .secondary)
+ if let limit = viewModel.selectedPlatform.characterLimit {
+ Text("/ \(limit)")
+ .font(.caption.monospacedDigit())
+ .foregroundStyle(.tertiary)
+ }
+ }
+ }
+ }
+
+ if viewModel.isGenerating {
+ VStack(alignment: .leading) {
+ Text(viewModel.streamingText)
+ .font(.body)
+ .textSelection(.enabled)
+ ProgressView()
+ .controlSize(.small)
+ .padding(.top, 4)
+ }
+ .padding(12)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.quaternary)
+ .cornerRadius(8)
+ } else {
+ Text(viewModel.generatedContent)
+ .font(.body)
+ .textSelection(.enabled)
+ .padding(12)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(.quaternary)
+ .cornerRadius(8)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(viewModel.isOverLimit ? Color.red.opacity(0.5) : Color.clear, lineWidth: 1)
+ )
+ }
+
+ // Action buttons
+ if !viewModel.generatedContent.isEmpty && !viewModel.isGenerating {
+ HStack(spacing: 12) {
+ Button {
+ viewModel.copyToClipboard()
+ } label: {
+ Label("Copy", systemImage: "doc.on.doc")
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+
+ Button {
+ viewModel.generate()
+ } label: {
+ Label("Regenerate", systemImage: "arrow.clockwise")
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+
+ Button {
+ viewModel.clearContent()
+ } label: {
+ Label("Clear", systemImage: "trash")
+ }
+ .buttonStyle(.bordered)
+ .controlSize(.small)
+ }
+ }
+ }
+ }
+}
diff --git a/ArkavoCreator/ArkavoCreator/Publicist/PublicistViewModel.swift b/ArkavoCreator/ArkavoCreator/Publicist/PublicistViewModel.swift
new file mode 100644
index 00000000..92d536d3
--- /dev/null
+++ b/ArkavoCreator/ArkavoCreator/Publicist/PublicistViewModel.swift
@@ -0,0 +1,148 @@
+#if os(macOS)
+import AppKit
+#else
+import UIKit
+#endif
+import Foundation
+import MuseCore
+import Observation
+
+/// Supported content types for Publicist generation
+enum PublicistContentType: String, CaseIterable, Sendable {
+ case draftPost = "Draft Post"
+ case title = "Title"
+ case description = "Description"
+ case thread = "Thread"
+}
+
+/// Target platform for content generation
+enum PublicistPlatform: String, CaseIterable, Sendable {
+ case bluesky = "Bluesky"
+ case youtube = "YouTube"
+ case twitch = "Twitch"
+ case reddit = "Reddit"
+ case microblog = "Micro.blog"
+ case patreon = "Patreon"
+
+ var characterLimit: Int? {
+ switch self {
+ case .bluesky: 300
+ case .twitch: 140
+ case .youtube: 5000
+ case .reddit, .microblog, .patreon: nil
+ }
+ }
+
+ var platformContext: any PlatformContext {
+ switch self {
+ case .bluesky: BlueskyContext()
+ case .youtube: YouTubeContext()
+ case .twitch: TwitchContext()
+ case .reddit: RedditContext()
+ case .microblog: MicropubContext()
+ case .patreon: GenericContext()
+ }
+ }
+}
+
+/// View model for the Publicist role — platform-aware content generation
+@Observable
+@MainActor
+final class PublicistViewModel {
+ var selectedPlatform: PublicistPlatform = .bluesky
+ var selectedContentType: PublicistContentType = .draftPost
+ var sourceText: String = ""
+ private(set) var generatedContent: String = ""
+ private(set) var isGenerating = false
+ private(set) var streamingText: String = ""
+
+ let modelManager: ModelManager
+ private var generationTask: Task?
+
+ init(modelManager: ModelManager) {
+ self.modelManager = modelManager
+ }
+
+ /// Character count of the generated content
+ var characterCount: Int { generatedContent.count }
+
+ /// Whether the generated content exceeds the platform limit
+ var isOverLimit: Bool {
+ guard let limit = selectedPlatform.characterLimit else { return false }
+ return characterCount > limit
+ }
+
+ /// Generate content using the LLM
+ func generate() {
+ guard modelManager.isReady else { return }
+ guard !isGenerating else { return }
+
+ isGenerating = true
+ streamingText = ""
+ generatedContent = ""
+
+ generationTask = Task {
+ let context = selectedPlatform.platformContext
+ let prompt = PublicistPromptBuilder.buildActionPrompt(
+ action: mapContentTypeToAction(),
+ inputText: sourceText.isEmpty ? nil : sourceText,
+ context: context
+ )
+ let systemPrompt = PublicistPromptBuilder.buildSystemPrompt(for: context)
+
+ let stream = modelManager.streamingProvider.generate(
+ prompt: prompt,
+ systemPrompt: systemPrompt,
+ maxTokens: 512
+ )
+
+ do {
+ for try await token in stream {
+ streamingText += token
+ }
+ generatedContent = streamingText
+ } catch is CancellationError {
+ if !streamingText.isEmpty {
+ generatedContent = streamingText
+ }
+ } catch {
+ generatedContent = "Error: \(error.localizedDescription)"
+ }
+
+ streamingText = ""
+ isGenerating = false
+ }
+ }
+
+ /// Stop generation
+ func stopGeneration() {
+ generationTask?.cancel()
+ generationTask = nil
+ }
+
+ /// Clear generated content
+ func clearContent() {
+ stopGeneration()
+ generatedContent = ""
+ streamingText = ""
+ }
+
+ /// Copy generated content to clipboard
+ func copyToClipboard() {
+ #if os(macOS)
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(generatedContent, forType: .string)
+ #else
+ UIPasteboard.general.string = generatedContent
+ #endif
+ }
+
+ private func mapContentTypeToAction() -> PublicistAction {
+ switch selectedContentType {
+ case .draftPost: .draftPost
+ case .title: .generateTitle
+ case .description: .generateDescription
+ case .thread: .draftPost // Thread uses draft post with thread-specific prompt
+ }
+ }
+}
diff --git a/ArkavoCreator/ArkavoCreator/RecordView.swift b/ArkavoCreator/ArkavoCreator/RecordView.swift
index a0a7297c..4c27f063 100644
--- a/ArkavoCreator/ArkavoCreator/RecordView.swift
+++ b/ArkavoCreator/ArkavoCreator/RecordView.swift
@@ -1,6 +1,7 @@
import ArkavoKit
import ArkavoStreaming
import AVFoundation
+import MuseCore
import SwiftUI
struct RecordView: View {
@@ -8,6 +9,7 @@ struct RecordView: View {
@ObservedObject var youtubeClient: YouTubeClient
@ObservedObject var twitchClient: TwitchAuthClient
+ var modelManager: ModelManager?
// MARK: - Private State
@@ -17,15 +19,19 @@ struct RecordView: View {
@StateObject private var museAvatarViewModel = MuseAvatarViewModel()
@State private var enableScreen: Bool = false
@State private var showStreamSetup: Bool = false
- @State private var showInspector: Bool = false
- @State private var showChat: Bool = false
+ @State private var showRightPanel: Bool = false
@State private var chatViewModel = ChatPanelViewModel()
+ @State private var producerViewModel: ProducerViewModel?
@State private var pulsing: Bool = false
@State private var pipOffset: CGSize = .zero
@State private var lastPipOffset: CGSize = .zero
// Scene state restoration
@State private var preSceneMicEnabled: Bool = true
@State private var preSceneVisualSource: VisualSource? = .face
+ @State private var isLivePulsing: Bool = false
+ @State private var showMicPopover: Bool = false
+ @State private var showAudioPopover: Bool = false
+ @State private var showScenePopover: Bool = false
// Shared state (not part of init)
@ObservedObject private var previewStore = CameraPreviewStore.shared
@@ -38,14 +44,9 @@ struct RecordView: View {
.fill(.white.opacity(0.1))
.frame(height: 1)
- // MARK: - Main Stage + Inspector
+ // MARK: - Main Stage + Panels
HStack(spacing: 0) {
- // Chat Panel (left side)
- if showChat {
- ChatPanelView(viewModel: chatViewModel, isVisible: $showChat)
- .transition(.move(edge: .leading))
- }
-
+ // Stage
ZStack {
// Ambient Background
LinearGradient(
@@ -53,7 +54,6 @@ struct RecordView: View {
startPoint: .topLeading,
endPoint: .bottomTrailing
)
- .ignoresSafeArea()
stageCompositionView
.clipped()
@@ -63,31 +63,49 @@ struct RecordView: View {
SceneOverlayView(scene: studioState.activeScene)
}
}
+ .clipped()
.frame(maxWidth: .infinity, maxHeight: .infinity)
- if showInspector {
- InspectorPanel(
- visualSource: studioState.visualSource,
- recordViewModel: viewModel,
- avatarViewModel: avatarViewModel,
- isVisible: $showInspector,
- onLoadAvatarModel: {
- Task { await avatarViewModel.loadSelectedModel() }
- }
+ // Producer Panel (unified command center)
+ if showRightPanel, let producerVM = producerViewModel {
+ ProducerPanelView(
+ viewModel: producerVM,
+ isVisible: $showRightPanel,
+ chatViewModel: chatViewModel
)
+ .frame(width: 300)
+ .background(.ultraThinMaterial)
+ .overlay(alignment: .leading) {
+ Rectangle().frame(width: 1).foregroundStyle(.white.opacity(0.1))
+ }
.transition(.move(edge: .trailing))
}
+
}
+ .frame(maxHeight: .infinity)
- // MARK: - Bottom Control Bar
+ // MARK: - Fixed Bottom Control Panel
studioControlBar
- .padding(.horizontal, 16)
- .padding(.vertical, 12)
- .background(.ultraThinMaterial)
- .overlay(Rectangle().frame(height: 1).foregroundColor(.white.opacity(0.1)), alignment: .top)
+ .padding(.horizontal, 24)
+ .frame(height: 68)
+ .frame(maxWidth: .infinity)
+ .background(.regularMaterial)
+ .overlay(alignment: .top) {
+ Rectangle()
+ .frame(height: 1)
+ .foregroundStyle(.white.opacity(0.1))
+ }
}
.navigationTitle("Studio")
.onAppear {
+ if producerViewModel == nil, let mm = modelManager {
+ producerViewModel = ProducerViewModel(modelManager: mm)
+ }
+ // Wire shared ModelManager and initialize Muse avatar for Sidekick
+ if museAvatarViewModel.modelManager == nil {
+ museAvatarViewModel.modelManager = modelManager
+ museAvatarViewModel.setup()
+ }
syncViewModelState()
if studioState.visualSource == .face {
viewModel.bindPreviewStore(previewStore)
@@ -299,93 +317,87 @@ struct RecordView: View {
// MARK: - Studio Control Bar
private var studioControlBar: some View {
- HStack(spacing: 16) {
- // Left: Visual Source Toggle (Face/Avatar - both can be off for audio-only)
- HStack(spacing: 4) {
- ForEach(VisualSource.availableSources) { source in
- let isSelected = studioState.visualSource == source
- Button {
- studioState.toggleVisualSource(source)
- } label: {
- Image(systemName: source.icon)
- .font(.system(size: 14))
- .frame(width: 32, height: 32)
- .background(isSelected ? Color.accentColor.opacity(0.3) : Color.clear)
- .background(.regularMaterial)
- .cornerRadius(6)
- .overlay(
- RoundedRectangle(cornerRadius: 6)
- .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 1)
- )
- }
- .buttonStyle(.plain)
- .help(isSelected ? "Disable \(source.rawValue)" : "Enable \(source.rawValue)")
- .accessibilityIdentifier("Source_\(source.rawValue)")
- }
- }
-
- // Screen Selection
- HStack(spacing: 4) {
- ForEach(viewModel.availableScreens) { screen in
- let isSelected = enableScreen && viewModel.selectedScreenID == screen.displayID
- Button {
- if isSelected {
- // Deselect (turn off screen share)
- enableScreen = false
- } else {
- // Select this screen
- viewModel.selectScreen(screen)
- enableScreen = true
- }
- } label: {
- ZStack(alignment: .topTrailing) {
- Image(systemName: isSelected ? "rectangle.inset.filled.on.rectangle" : "desktopcomputer")
+ HStack {
+ // LEFT: Inputs & Sources
+ HStack(spacing: 8) {
+ // Visual source toggles
+ HStack(spacing: 4) {
+ ForEach(VisualSource.availableSources) { source in
+ let isSelected = studioState.visualSource == source
+ Button {
+ studioState.toggleVisualSource(source)
+ } label: {
+ Image(systemName: source.icon)
.font(.system(size: 14))
- .foregroundStyle(isSelected ? Color.accentColor : .primary)
- .padding(8)
+ .frame(width: 32, height: 32)
.background(isSelected ? Color.accentColor.opacity(0.3) : Color.clear)
.background(.regularMaterial)
- .cornerRadius(8)
+ .cornerRadius(6)
.overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 1)
)
+ }
+ .buttonStyle(.plain)
+ .help(isSelected ? "Disable \(source.rawValue)" : "Enable \(source.rawValue)")
+ .accessibilityIdentifier("Source_\(source.rawValue)")
+ }
+ }
- // Primary screen indicator (star badge)
- if screen.isPrimary {
- Circle()
- .fill(Color.yellow)
- .frame(width: 8, height: 8)
+ // Screen selection
+ HStack(spacing: 4) {
+ ForEach(viewModel.availableScreens) { screen in
+ let isSelected = enableScreen && viewModel.selectedScreenID == screen.displayID
+ Button {
+ if isSelected {
+ enableScreen = false
+ } else {
+ viewModel.selectScreen(screen)
+ enableScreen = true
+ }
+ } label: {
+ ZStack(alignment: .topTrailing) {
+ Image(systemName: isSelected ? "rectangle.inset.filled.on.rectangle" : "desktopcomputer")
+ .font(.system(size: 14))
+ .foregroundStyle(isSelected ? Color.accentColor : .primary)
+ .padding(8)
+ .background(isSelected ? Color.accentColor.opacity(0.3) : Color.clear)
+ .background(.regularMaterial)
+ .cornerRadius(8)
.overlay(
- Image(systemName: "star.fill")
- .font(.system(size: 5))
- .foregroundColor(.black)
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(isSelected ? Color.accentColor : Color.clear, lineWidth: 2)
)
- .offset(x: 2, y: -2)
+
+ if screen.isPrimary {
+ Circle()
+ .fill(Color.yellow)
+ .frame(width: 12, height: 12)
+ .overlay(
+ Image(systemName: "star.fill")
+ .font(.system(size: 8))
+ .foregroundColor(.black)
+ )
+ .offset(x: 2, y: -2)
+ }
}
}
+ .buttonStyle(.plain)
+ .accessibilityIdentifier("Screen_\(screen.id)")
+ .help(screen.isPrimary ? "\(screen.name) (Primary)" : screen.name)
}
- .buttonStyle(.plain)
- .accessibilityIdentifier("Screen_\(screen.id)")
- .help(screen.isPrimary ? "\(screen.name) (Primary)" : screen.name)
}
- }
-
- audioAndSceneControls
- Spacer()
+ audioAndSceneControls
+ }
+ .frame(maxWidth: .infinity, alignment: .leading)
- // Center: Dual Action Buttons (REC + LIVE)
+ // CENTER: Broadcasting
HStack(spacing: 12) {
recordingActionButton
streamingActionButton
- }
-
- Spacer()
- // Right: Recording Duration + Settings
- HStack(spacing: 12) {
- // Duration (recording or streaming)
+ // Duration
HStack(spacing: 6) {
Circle()
.fill(viewModel.isRecording ? .red : (streamViewModel.isStreaming ? .purple : .clear))
@@ -393,124 +405,170 @@ struct RecordView: View {
Text(activeDuration)
.font(.system(size: 14, weight: .medium, design: .monospaced))
.monospacedDigit()
- .foregroundStyle(isActive ? .primary : .secondary)
+ .foregroundStyle(.primary)
}
.frame(width: 90)
.padding(.vertical, 6)
.background(.ultraThinMaterial)
.clipShape(Capsule())
- .opacity(isActive ? 1.0 : 0.5)
+ .opacity(isActive ? 1.0 : 0.7)
- // Chat Toggle (Twitch only)
- if streamViewModel.selectedPlatform == .twitch {
- Button {
- withAnimation(.easeInOut(duration: 0.2)) {
- showChat.toggle()
- }
- } label: {
- Image(systemName: "bubble.left.and.bubble.right")
+ // Scene picker
+ Button { showScenePopover.toggle() } label: {
+ HStack(spacing: 4) {
+ Image(systemName: studioState.activeScene.icon)
.font(.system(size: 14))
- .padding(8)
- .foregroundStyle(showChat ? .primary : .secondary)
- .background(showChat ? Color.accentColor.opacity(0.2) : Color.clear)
- .background(.regularMaterial)
- .cornerRadius(8)
- }
- .buttonStyle(.plain)
- .help("Toggle Chat Panel")
- }
-
- // Inspector Toggle
- Button {
- withAnimation(.easeInOut(duration: 0.2)) {
- showInspector.toggle()
+ Image(systemName: "chevron.up")
+ .font(.system(size: 8, weight: .bold))
}
- } label: {
- Image(systemName: "slider.horizontal.3")
- .font(.system(size: 14))
- .padding(8)
- .foregroundStyle(showInspector ? .primary : .secondary)
- .background(showInspector ? Color.accentColor.opacity(0.2) : Color.clear)
- .background(.regularMaterial)
- .cornerRadius(8)
- }
- .buttonStyle(.plain)
- .help("Toggle Inspector (⌘I)")
- .keyboardShortcut("i", modifiers: .command)
- }
- }
- }
-
- private var audioAndSceneControls: some View {
- HStack(spacing: 8) {
- // Mic Toggle
- Button {
- viewModel.enableMicrophone.toggle()
- } label: {
- Image(systemName: viewModel.enableMicrophone ? "mic.fill" : "mic.slash")
- .font(.system(size: 14))
.padding(8)
- .background(viewModel.enableMicrophone ? Color.accentColor.opacity(0.2) : Color.clear)
+ .background(studioState.isSceneOverlayActive ? Color.orange.opacity(0.3) : Color.clear)
.background(.regularMaterial)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
- .stroke(viewModel.enableMicrophone ? Color.accentColor : Color.clear, lineWidth: 1)
+ .stroke(studioState.isSceneOverlayActive ? Color.orange : Color.clear, lineWidth: 1)
)
+ }
+ .buttonStyle(.plain)
+ .help("Scene Presets")
+ .popover(isPresented: $showScenePopover, arrowEdge: .top) {
+ scenePopoverContent
+ }
}
- .buttonStyle(.plain)
- .accessibilityIdentifier("Toggle_Mic")
- .help("Toggle Microphone")
- // Desktop Audio Toggle
+ // RIGHT: Panel Toggle (single button)
Button {
- viewModel.toggleDesktopAudio()
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) {
+ showRightPanel.toggle()
+ }
} label: {
- Image(systemName: viewModel.enableDesktopAudio ? "speaker.wave.2.fill" : "speaker.slash")
+ Image(systemName: "slider.horizontal.3")
.font(.system(size: 14))
.padding(8)
- .background(viewModel.enableDesktopAudio ? Color.accentColor.opacity(0.2) : Color.clear)
+ .foregroundStyle(showRightPanel ? .primary : .secondary)
+ .background(showRightPanel ? Color.accentColor.opacity(0.2) : Color.clear)
.background(.regularMaterial)
.cornerRadius(8)
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(viewModel.enableDesktopAudio ? Color.accentColor : Color.clear, lineWidth: 1)
- )
}
.buttonStyle(.plain)
- .accessibilityIdentifier("Toggle_DesktopAudio")
- .help("Toggle Desktop Audio")
-
- // Scene Picker
- Menu {
- ForEach(ScenePreset.allCases) { scene in
- Button {
- switchScene(to: scene)
- } label: {
- Label(scene.rawValue, systemImage: scene.icon)
+ .help("Toggle Panel (⌘P)")
+ .keyboardShortcut("p", modifiers: .command)
+ .frame(maxWidth: .infinity, alignment: .trailing)
+ }
+ }
+
+ private var scenePopoverContent: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Text("Scene")
+ .font(.caption.weight(.semibold))
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 4)
+ ForEach(ScenePreset.allCases, id: \.self) { scene in
+ Button {
+ switchScene(to: scene)
+ showScenePopover = false
+ } label: {
+ Label(scene.rawValue, systemImage: scene.icon)
+ .font(.subheadline)
+ .padding(.vertical, 4)
+ .padding(.horizontal, 8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ .padding(8)
+ .frame(width: 180)
+ }
+
+ private var audioAndSceneControls: some View {
+ HStack(spacing: 8) {
+ // Mic Toggle + Volume Popover
+ HStack(spacing: 2) {
+ Button {
+ viewModel.enableMicrophone.toggle()
+ } label: {
+ Image(systemName: viewModel.enableMicrophone ? "mic.fill" : "mic.slash")
+ .font(.system(size: 14))
+ .padding(8)
+ .background(viewModel.enableMicrophone ? Color.accentColor.opacity(0.2) : Color.clear)
+ .background(.regularMaterial)
+ .clipShape(UnevenRoundedRectangle(topLeadingRadius: 8, bottomLeadingRadius: 8, bottomTrailingRadius: 0, topTrailingRadius: 0))
+ }
+ .buttonStyle(.plain)
+ .accessibilityIdentifier("Toggle_Mic")
+ .help("Toggle Microphone")
+
+ Button { showMicPopover.toggle() } label: {
+ Image(systemName: "chevron.up")
+ .font(.system(size: 8, weight: .bold))
+ .frame(width: 16, height: 32)
+ .background(.regularMaterial)
+ .clipShape(UnevenRoundedRectangle(topLeadingRadius: 0, bottomLeadingRadius: 0, bottomTrailingRadius: 8, topTrailingRadius: 8))
+ }
+ .buttonStyle(.plain)
+ .popover(isPresented: $showMicPopover, arrowEdge: .top) {
+ VStack(spacing: 8) {
+ Text("Microphone")
+ .font(.caption.weight(.semibold))
+ Slider(value: $viewModel.micVolume, in: 0...1)
+ .frame(width: 140)
+ Text("\(Int(viewModel.micVolume * 100))%")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
}
+ .padding(12)
}
- } label: {
- HStack(spacing: 4) {
- Image(systemName: studioState.activeScene.icon)
+ }
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(viewModel.enableMicrophone ? Color.accentColor : Color.clear, lineWidth: 1)
+ )
+
+ // Desktop Audio Toggle + Volume Popover
+ HStack(spacing: 2) {
+ Button {
+ viewModel.toggleDesktopAudio()
+ } label: {
+ Image(systemName: viewModel.enableDesktopAudio ? "speaker.wave.2.fill" : "speaker.slash")
.font(.system(size: 14))
- if studioState.isSceneOverlayActive {
- Text(studioState.activeScene.rawValue)
- .font(.caption.weight(.medium))
+ .padding(8)
+ .background(viewModel.enableDesktopAudio ? Color.accentColor.opacity(0.2) : Color.clear)
+ .background(.regularMaterial)
+ .clipShape(UnevenRoundedRectangle(topLeadingRadius: 8, bottomLeadingRadius: 8, bottomTrailingRadius: 0, topTrailingRadius: 0))
+ }
+ .buttonStyle(.plain)
+ .accessibilityIdentifier("Toggle_DesktopAudio")
+ .help("Toggle Desktop Audio")
+
+ Button { showAudioPopover.toggle() } label: {
+ Image(systemName: "chevron.up")
+ .font(.system(size: 8, weight: .bold))
+ .frame(width: 16, height: 32)
+ .background(.regularMaterial)
+ .clipShape(UnevenRoundedRectangle(topLeadingRadius: 0, bottomLeadingRadius: 0, bottomTrailingRadius: 8, topTrailingRadius: 8))
+ }
+ .buttonStyle(.plain)
+ .popover(isPresented: $showAudioPopover, arrowEdge: .top) {
+ VStack(spacing: 8) {
+ Text("Desktop Audio")
+ .font(.caption.weight(.semibold))
+ Slider(value: $viewModel.desktopAudioVolume, in: 0...1)
+ .frame(width: 140)
+ Text("\(Int(viewModel.desktopAudioVolume * 100))%")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
}
+ .padding(12)
}
- .padding(8)
- .background(studioState.isSceneOverlayActive ? Color.orange.opacity(0.3) : Color.clear)
- .background(.regularMaterial)
- .cornerRadius(8)
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(studioState.isSceneOverlayActive ? Color.orange : Color.clear, lineWidth: 1)
- )
}
- .menuStyle(.borderlessButton)
- .fixedSize()
- .help("Scene Presets")
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(viewModel.enableDesktopAudio ? Color.accentColor : Color.clear, lineWidth: 1)
+ )
+
}
}
@@ -597,7 +655,6 @@ struct RecordView: View {
}
} label: {
HStack(spacing: 6) {
- // Live indicator dot (always present for consistent width)
Circle()
.fill(streamViewModel.isStreaming ? .white : .clear)
.frame(width: 8, height: 8)
@@ -610,15 +667,25 @@ struct RecordView: View {
.background(
streamViewModel.isStreaming
? AnyShapeStyle(Color.red)
- : AnyShapeStyle(LinearGradient(colors: [.blue, .purple], startPoint: .leading, endPoint: .trailing))
+ : AnyShapeStyle(.regularMaterial)
)
- .foregroundColor(.white)
+ .foregroundColor(streamViewModel.isStreaming ? .white : .primary)
.clipShape(Capsule())
}
.buttonStyle(.plain)
+ .shadow(color: Color.red.opacity(isLivePulsing ? 0.5 : 0.0), radius: isLivePulsing ? 8 : 0)
.disabled(streamViewModel.isConnecting)
.accessibilityIdentifier("Btn_GoLive")
.frame(width: 120)
+ .onChange(of: streamViewModel.isStreaming) { _, isStreaming in
+ if isStreaming {
+ withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) {
+ isLivePulsing = true
+ }
+ } else {
+ withAnimation { isLivePulsing = false }
+ }
+ }
}
// MARK: - Helpers
@@ -678,7 +745,7 @@ struct RecordView: View {
preSceneVisualSource = studioState.visualSource
}
- withAnimation(.easeInOut(duration: 0.3)) {
+ withAnimation(.spring(response: 0.3, dampingFraction: 0.75)) {
studioState.activeScene = scene
}
@@ -737,9 +804,8 @@ struct RecordView: View {
// MARK: - Streaming
private func startStreaming(destination: RTMPPublisher.Destination, streamKey: String) async {
- // Ensure we have an active session (either from recording or create one for streaming)
+ // Ensure we have an active session
if RecordingState.shared.recordingSession == nil {
- // Start a preview-mode session for streaming without recording
await viewModel.startPreviewSession()
}
@@ -749,27 +815,82 @@ struct RecordView: View {
return
}
- // Check if this is Arkavo (NTDF-encrypted streaming)
- if streamViewModel.selectedPlatform == .arkavo {
+ let selectedPlatforms = streamViewModel.selectedPlatforms
+
+ // Handle Arkavo NTDF separately
+ if selectedPlatforms.contains(.arkavo) {
guard let kasURL = URL(string: "https://100.arkavo.net") else {
streamViewModel.error = "Invalid KAS URL"
return
}
try await session.startNTDFStreaming(
kasURL: kasURL,
- rtmpURL: destination.url,
+ rtmpURL: StreamViewModel.StreamPlatform.arkavo.rtmpURL,
streamKey: "live/creator"
)
- } else {
- try await session.startStreaming(to: destination, streamKey: streamKey)
}
+
+ // Build RTMP destinations for all non-Arkavo platforms
+ let rtmpPlatforms = selectedPlatforms.filter { !$0.isEncrypted }
+ if !rtmpPlatforms.isEmpty {
+ // YouTube: create broadcast before RTMP
+ if rtmpPlatforms.contains(.youtube) {
+ let broadcastId = try await youtubeClient.createAndBindBroadcast(title: streamViewModel.title)
+ streamViewModel.platformConfigs[.youtube, default: StreamViewModel.PlatformConfig()].broadcastId = broadcastId
+ debugLog("[RecordView] Created YouTube broadcast: \(broadcastId)")
+ }
+
+ // Build destinations array
+ var destinations: [(id: String, destination: RTMPPublisher.Destination, streamKey: String)] = []
+ for platform in rtmpPlatforms {
+ let config = streamViewModel.platformConfigs[platform] ?? StreamViewModel.PlatformConfig()
+ let url = platform == .custom ? streamViewModel.customRTMPURL : platform.rtmpURL
+ let dest = RTMPPublisher.Destination(url: url, platform: platform.rawValue.lowercased())
+ var key = config.streamKey
+ if platform == .twitch && streamViewModel.isBandwidthTest {
+ key += "?bandwidthtest=true"
+ }
+ destinations.append((id: platform.rawValue.lowercased(), destination: dest, streamKey: key))
+ }
+
+ try await session.startStreaming(destinations: destinations)
+ }
+
streamViewModel.isStreaming = true
streamViewModel.startStatisticsPolling()
- // Auto-connect Twitch chat
- if streamViewModel.selectedPlatform == .twitch {
- chatViewModel.connect(twitchClient: twitchClient)
- withAnimation { showChat = true }
+ // Auto-connect chat for all selected platforms (unified feed)
+ if selectedPlatforms.contains(.twitch) && twitchClient.isAuthenticated {
+ chatViewModel.connectTwitch(twitchClient: twitchClient)
+ }
+ if selectedPlatforms.contains(.youtube),
+ let broadcastId = streamViewModel.platformConfigs[.youtube]?.broadcastId {
+ chatViewModel.connectYouTube(youtubeClient: youtubeClient, broadcastId: broadcastId)
+ }
+ if selectedPlatforms.contains(.twitch) || selectedPlatforms.contains(.youtube) {
+ withAnimation { showRightPanel = true }
+ }
+
+ // YouTube: transition broadcast to live
+ if selectedPlatforms.contains(.youtube),
+ let broadcastId = streamViewModel.platformConfigs[.youtube]?.broadcastId {
+ streamViewModel.platformConfigs[.youtube]?.transitionTask = Task {
+ try? await Task.sleep(for: .seconds(15))
+ guard !Task.isCancelled else { return }
+ for attempt in 1...5 {
+ guard !Task.isCancelled else { return }
+ do {
+ try await youtubeClient.transitionBroadcastToLive(broadcastId: broadcastId)
+ debugLog("[RecordView] YouTube broadcast transitioned to LIVE")
+ break
+ } catch {
+ debugLog("[RecordView] YouTube transition attempt \(attempt)/5: \(error.localizedDescription)")
+ if attempt < 5 {
+ try? await Task.sleep(for: .seconds(10))
+ }
+ }
+ }
+ }
}
} catch {
streamViewModel.error = error.localizedDescription
@@ -778,7 +899,7 @@ struct RecordView: View {
private func stopStreaming() async {
chatViewModel.disconnect()
- showChat = false
+ showRightPanel = false
await streamViewModel.stopStreaming()
}
}
diff --git a/ArkavoCreator/ArkavoCreator/Recording.swift b/ArkavoCreator/ArkavoCreator/Recording.swift
index aeec06e6..a25d480a 100644
--- a/ArkavoCreator/ArkavoCreator/Recording.swift
+++ b/ArkavoCreator/ArkavoCreator/Recording.swift
@@ -113,6 +113,13 @@ struct Recording: Identifiable, Sendable {
formatter.timeStyle = .short
return formatter.string(from: date)
}
+
+ var formattedCardSubtitle: String {
+ let tf = DateFormatter()
+ tf.dateStyle = .none
+ tf.timeStyle = .short
+ return "\(tf.string(from: date)) \u{2022} \(formattedDuration) \u{2022} \(formattedFileSize)"
+ }
}
/// Manages recordings on disk
@@ -197,10 +204,11 @@ final class RecordingsManager: ObservableObject {
duration = 0
}
- // Extract title from metadata or use filename
- let title = url.deletingPathExtension().lastPathComponent
- .replacingOccurrences(of: "arkavo_recording_", with: "")
- .replacingOccurrences(of: "_", with: " ")
+ // Format title as a human-readable date
+ let titleFormatter = DateFormatter()
+ titleFormatter.dateStyle = .long
+ titleFormatter.timeStyle = .none
+ let title = titleFormatter.string(from: creationDate)
let recording = Recording(
id: UUID(),
diff --git a/ArkavoCreator/ArkavoCreator/RecordingsLibraryView.swift b/ArkavoCreator/ArkavoCreator/RecordingsLibraryView.swift
index b0ff6c98..34baa95b 100644
--- a/ArkavoCreator/ArkavoCreator/RecordingsLibraryView.swift
+++ b/ArkavoCreator/ArkavoCreator/RecordingsLibraryView.swift
@@ -16,7 +16,7 @@ struct RecordingsLibraryView: View {
@State private var protectionError: String?
@State private var showingProtectionError = false
@State private var recordingToDelete: Recording?
- @State private var gridColumns = [GridItem(.adaptive(minimum: 200, maximum: 300), spacing: 16)]
+ @State private var gridColumns = [GridItem(.adaptive(minimum: 280, maximum: 400), spacing: 20)]
// Iroh publishing state
@State private var isPublishing = false
@@ -29,17 +29,11 @@ struct RecordingsLibraryView: View {
var body: some View {
VStack(spacing: 0) {
- // Header
- headerView
-
- Divider()
-
- // Content
if manager.recordings.isEmpty {
emptyStateView
} else {
ScrollView {
- LazyVGrid(columns: gridColumns, spacing: 16) {
+ LazyVGrid(columns: gridColumns, spacing: 20) {
ForEach(manager.recordings) { recording in
RecordingCard(recording: recording)
.accessibilityIdentifier("RecordingCard_\(recording.id)")
@@ -55,6 +49,16 @@ struct RecordingsLibraryView: View {
}
}
}
+ .toolbar {
+ ToolbarItem(placement: .primaryAction) {
+ Button {
+ Task { await manager.loadRecordings() }
+ } label: {
+ Image(systemName: "arrow.clockwise")
+ }
+ .help("Refresh")
+ }
+ }
.sheet(item: $playerRecording) { recording in
VideoPlayerView(recording: recording)
}
@@ -137,36 +141,13 @@ struct RecordingsLibraryView: View {
// MARK: - View Components
- private var headerView: some View {
- HStack {
- Text("Recordings")
- .font(.title2)
- .fontWeight(.semibold)
-
- Spacer()
-
- Text("\(manager.recordings.count) recording\(manager.recordings.count == 1 ? "" : "s")")
- .foregroundColor(.secondary)
- .font(.subheadline)
-
- Button {
- Task {
- await manager.loadRecordings()
- }
- } label: {
- Image(systemName: "arrow.clockwise")
- }
- .help("Refresh")
- }
- .padding()
- }
-
private var emptyStateView: some View {
VStack(spacing: 16) {
if manager.needsFolderSelection {
Image(systemName: "folder.badge.questionmark")
.font(.system(size: 60))
.foregroundColor(.blue)
+ .shadow(color: .blue.opacity(0.15), radius: 20)
Text("Choose Recordings Folder")
.font(.title2)
@@ -185,6 +166,7 @@ struct RecordingsLibraryView: View {
Image(systemName: "video.slash")
.font(.system(size: 60))
.foregroundColor(.secondary)
+ .shadow(color: .blue.opacity(0.15), radius: 20)
Text("No Recordings Yet")
.font(.title2)
@@ -379,8 +361,8 @@ struct RecordingCard: View {
@State private var tdfStatus: Recording.TDFProtectionStatus?
var body: some View {
- VStack(alignment: .leading, spacing: 8) {
- // Thumbnail
+ VStack(alignment: .leading, spacing: 0) {
+ // Thumbnail (flush to card edges — outer clipShape handles corners)
ZStack {
if let thumbnail = thumbnail {
Image(nsImage: thumbnail)
@@ -388,12 +370,10 @@ struct RecordingCard: View {
.aspectRatio(contentMode: .fill)
.frame(height: 150)
.clipped()
- .cornerRadius(8)
} else {
Rectangle()
.fill(Color.secondary.opacity(0.2))
.frame(height: 150)
- .cornerRadius(8)
.overlay {
ProgressView()
}
@@ -402,7 +382,6 @@ struct RecordingCard: View {
// Badges (top-left)
VStack {
HStack(spacing: 4) {
- // TDF Badge
if let status = tdfStatus, status.isProtected {
HStack(spacing: 4) {
Image(systemName: "lock.shield.fill")
@@ -419,7 +398,6 @@ struct RecordingCard: View {
.accessibilityIdentifier("TDF3Badge")
}
- // C2PA Badge
if let status = c2paStatus, status.isSigned {
HStack(spacing: 4) {
Image(systemName: status.isValid ? "checkmark.seal.fill" : "exclamationmark.triangle.fill")
@@ -458,28 +436,33 @@ struct RecordingCard: View {
}
}
- // Title
- Text(recording.title)
- .font(.headline)
- .lineLimit(1)
-
- // Metadata
- HStack(spacing: 12) {
- Label(recording.formattedDate, systemImage: "calendar")
- .font(.caption)
- .foregroundColor(.secondary)
-
- Spacer()
+ // Title + Metadata (with padding)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(recording.title)
+ .font(.headline)
+ .lineLimit(1)
- Label(recording.formattedFileSize, systemImage: "doc")
+ Text(recording.formattedCardSubtitle)
.font(.caption)
.foregroundColor(.secondary)
+ .lineLimit(1)
}
- }
- .padding(12)
- .background(Color(NSColor.controlBackgroundColor))
- .cornerRadius(12)
- .shadow(color: .black.opacity(0.1), radius: 4, x: 0, y: 2)
+ .padding(12)
+ }
+ .background(.ultraThinMaterial)
+ .clipShape(RoundedRectangle(cornerRadius: 12))
+ .overlay(
+ RoundedRectangle(cornerRadius: 12)
+ .strokeBorder(
+ LinearGradient(
+ colors: [.white.opacity(0.25), .white.opacity(0.02)],
+ startPoint: .topLeading,
+ endPoint: .bottomTrailing
+ ),
+ lineWidth: 0.5
+ )
+ )
+ .shadow(color: .black.opacity(0.3), radius: 12, x: 0, y: 6)
.task {
await loadThumbnail()
}
diff --git a/ArkavoCreator/ArkavoCreator/StreamDestinationPicker.swift b/ArkavoCreator/ArkavoCreator/StreamDestinationPicker.swift
index 443a544c..87d21942 100644
--- a/ArkavoCreator/ArkavoCreator/StreamDestinationPicker.swift
+++ b/ArkavoCreator/ArkavoCreator/StreamDestinationPicker.swift
@@ -16,20 +16,22 @@ struct StreamDestinationPicker: View {
private var arkavoAuthState: ArkavoAuthState { ArkavoAuthState.shared }
- /// Whether the stream info step is available (Twitch + authenticated)
+ /// Whether the stream info step is available
private var hasStreamInfoStep: Bool {
- streamViewModel.selectedPlatform == .twitch && twitchClient.isAuthenticated
+ (streamViewModel.selectedPlatform == .twitch && twitchClient.isAuthenticated) ||
+ (streamViewModel.selectedPlatform == .youtube && youtubeClient.isAuthenticated)
}
var body: some View {
Group {
if showStreamInfo {
StreamInfoFormView(
+ platform: streamViewModel.selectedPlatform,
twitchClient: twitchClient,
+ youtubeClient: youtubeClient,
onBack: { showStreamInfo = false },
onStartStream: {
await startStream()
- // Dismiss handled inside startStream
}
)
.padding(24)
@@ -73,21 +75,39 @@ struct StreamDestinationPicker: View {
}) { platform in
PlatformCard(
platform: platform,
- isSelected: streamViewModel.selectedPlatform == platform,
+ isSelected: streamViewModel.selectedPlatforms.contains(platform),
action: {
- streamViewModel.selectedPlatform = platform
+ // Toggle multi-select
+ if streamViewModel.selectedPlatforms.contains(platform) {
+ // Don't allow deselecting the last platform
+ if streamViewModel.selectedPlatforms.count > 1 {
+ streamViewModel.selectedPlatforms.remove(platform)
+ }
+ } else {
+ streamViewModel.selectedPlatforms.insert(platform)
+ }
streamViewModel.loadStreamKey()
}
)
}
}
+
+ if streamViewModel.selectedPlatforms.count > 1 {
+ Text("Simulcast: \(streamViewModel.estimatedTotalBitrate) estimated upload")
+ .font(.caption)
+ .foregroundStyle(.secondary)
+ }
}
- // Stream Key / Auth Section
- if streamViewModel.selectedPlatform == .twitch && !twitchClient.isAuthenticated {
- twitchConnectSection
- } else {
- streamKeySection
+ // Stream Key / Auth Section — per selected platform
+ ForEach(Array(streamViewModel.selectedPlatforms).sorted(by: { $0.rawValue < $1.rawValue }), id: \.self) { platform in
+ if platform == .twitch && !twitchClient.isAuthenticated {
+ twitchConnectSection
+ } else if platform == .youtube && !youtubeClient.isAuthenticated {
+ youtubeConnectSection
+ } else if platform.requiresStreamKey {
+ streamKeySection(for: platform)
+ }
}
// Custom RTMP URL (if custom platform)
@@ -217,16 +237,59 @@ struct StreamDestinationPicker: View {
.padding(.vertical, 8)
}
- // MARK: - Stream Key Input (authenticated)
+ // MARK: - YouTube Connect (unauthenticated)
+
+ private var youtubeConnectSection: some View {
+ VStack(spacing: 12) {
+ Image(systemName: "play.rectangle.fill")
+ .font(.system(size: 36))
+ .foregroundStyle(.red)
+
+ Text("Connect your YouTube account to go live")
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ .multilineTextAlignment(.center)
+
+ Button {
+ Task {
+ do {
+ try await youtubeClient.authenticateWithLocalServer()
+ } catch {
+ debugLog("YouTube OAuth error: \(error)")
+ }
+ }
+ } label: {
+ HStack {
+ Image(systemName: "person.crop.circle.badge.plus")
+ Text("Connect to YouTube")
+ }
+ .font(.headline)
+ .frame(maxWidth: .infinity)
+ .padding(.vertical, 12)
+ .background(Color.red)
+ .foregroundColor(.white)
+ .cornerRadius(10)
+ }
+ .buttonStyle(.plain)
+ }
+ .padding(.vertical, 8)
+ }
+
+ // MARK: - Stream Key Input (per platform)
- private var streamKeySection: some View {
- VStack(alignment: .leading, spacing: 8) {
- Text("Stream Key")
+ private func streamKeySection(for platform: StreamViewModel.StreamPlatform) -> some View {
+ let keyBinding = Binding(
+ get: { streamViewModel.platformConfigs[platform]?.streamKey ?? "" },
+ set: { streamViewModel.platformConfigs[platform, default: StreamViewModel.PlatformConfig()].streamKey = $0 }
+ )
+
+ return VStack(alignment: .leading, spacing: 8) {
+ Text("\(platform.rawValue) Stream Key")
.font(.headline)
.foregroundStyle(.secondary)
HStack {
- SecureField("Enter your stream key", text: $streamViewModel.streamKey)
+ SecureField("Enter your stream key", text: keyBinding)
.textFieldStyle(.plain)
.padding(12)
.background(.background.opacity(0.5))
@@ -236,7 +299,7 @@ struct StreamDestinationPicker: View {
.stroke(.white.opacity(0.2), lineWidth: 1)
)
- if streamViewModel.selectedPlatform == .twitch && twitchClient.isAuthenticated {
+ if platform == .twitch && twitchClient.isAuthenticated {
Button {
Task { await fetchTwitchStreamKey() }
} label: {
@@ -249,35 +312,21 @@ struct StreamDestinationPicker: View {
.help("Fetch stream key from Twitch")
}
- if streamViewModel.selectedPlatform == .youtube {
+ if platform == .youtube && youtubeClient.isAuthenticated {
Button {
- Task {
- if youtubeClient.isAuthenticated {
- await fetchYouTubeStreamKey()
- } else {
- debugLog("[StreamDestinationPicker] YouTube not authenticated, starting auth flow...")
- do {
- try await youtubeClient.authenticateWithLocalServer()
- await fetchYouTubeStreamKey()
- } catch {
- await MainActor.run {
- streamViewModel.error = "YouTube login failed: \(error.localizedDescription)"
- }
- }
- }
- }
+ Task { await fetchYouTubeStreamKey() }
} label: {
- Image(systemName: youtubeClient.isAuthenticated ? "arrow.clockwise" : "person.crop.circle.badge.plus")
+ Image(systemName: "arrow.clockwise")
.padding(10)
.background(.ultraThinMaterial)
.cornerRadius(8)
}
.buttonStyle(.plain)
- .help(youtubeClient.isAuthenticated ? "Fetch stream key from YouTube" : "Login to YouTube to fetch stream key")
+ .help("Fetch stream key from YouTube")
}
}
- if streamViewModel.selectedPlatform == .twitch && streamViewModel.streamKey.isEmpty {
+ if platform == .twitch && keyBinding.wrappedValue.isEmpty {
if let username = twitchClient.username {
Link(destination: URL(string: "https://dashboard.twitch.tv/u/\(username.lowercased())/settings/stream") ?? URL(string: "https://dashboard.twitch.tv")!) {
Label("Copy stream key from Twitch Dashboard", systemImage: "arrow.up.right.square")
@@ -289,32 +338,34 @@ struct StreamDestinationPicker: View {
}
private var canStartStream: Bool {
- // Block streaming when Twitch is selected but not authenticated
- if streamViewModel.selectedPlatform == .twitch && !twitchClient.isAuthenticated {
- return false
+ // Check all selected platforms have what they need
+ for platform in streamViewModel.selectedPlatforms {
+ if platform == .twitch && !twitchClient.isAuthenticated { return false }
+ if platform.requiresStreamKey {
+ let key = streamViewModel.platformConfigs[platform]?.streamKey ?? ""
+ if key.isEmpty { return false }
+ }
+ if platform == .custom && streamViewModel.customRTMPURL.isEmpty { return false }
}
- let hasValidKey = !streamViewModel.streamKey.isEmpty
- return hasValidKey &&
- (streamViewModel.selectedPlatform != .custom || !streamViewModel.customRTMPURL.isEmpty)
+ return !streamViewModel.selectedPlatforms.isEmpty
}
private func startStream() async {
isLoading = true
defer { isLoading = false }
- // Save the stream key
streamViewModel.saveStreamKey()
- // Create destination
+ // Build destination for primary platform (RecordView handles multi-destination)
+ let primary = streamViewModel.selectedPlatform
let destination = RTMPPublisher.Destination(
- url: streamViewModel.effectiveRTMPURL,
- platform: streamViewModel.selectedPlatform.rawValue.lowercased()
+ url: primary == .custom ? streamViewModel.customRTMPURL : primary.rtmpURL,
+ platform: primary.rawValue.lowercased()
)
+ let key = streamViewModel.platformConfigs[primary]?.streamKey ?? ""
- // Start streaming
- await onStartStream(destination, streamViewModel.streamKey)
+ await onStartStream(destination, key)
- // Dismiss if successful
if streamViewModel.error == nil {
dismiss()
}
@@ -324,7 +375,7 @@ struct StreamDestinationPicker: View {
do {
if let key = try await twitchClient.fetchStreamKey() {
debugLog("[StreamDestinationPicker] Fetched Twitch stream key")
- streamViewModel.streamKey = key
+ streamViewModel.platformConfigs[.twitch, default: StreamViewModel.PlatformConfig()].streamKey = key
streamViewModel.saveStreamKey()
} else {
streamViewModel.error = "Could not fetch stream key — copy it from the Twitch Dashboard"
@@ -339,7 +390,7 @@ struct StreamDestinationPicker: View {
if let key = try await youtubeClient.fetchStreamKey() {
debugLog("[StreamDestinationPicker] Fetched YouTube stream key")
await MainActor.run {
- streamViewModel.streamKey = key
+ streamViewModel.platformConfigs[.youtube, default: StreamViewModel.PlatformConfig()].streamKey = key
streamViewModel.saveStreamKey()
}
}
diff --git a/ArkavoCreator/ArkavoCreator/StreamInfoFormView.swift b/ArkavoCreator/ArkavoCreator/StreamInfoFormView.swift
index c9e030dc..05e6debf 100644
--- a/ArkavoCreator/ArkavoCreator/StreamInfoFormView.swift
+++ b/ArkavoCreator/ArkavoCreator/StreamInfoFormView.swift
@@ -1,20 +1,31 @@
import SwiftUI
+import ArkavoKit
-/// Stream info editing form for Twitch (title, category, tags, language, content labels)
+/// Universal stream info editing form for Twitch & YouTube.
/// Embedded in the StreamDestinationPicker as step 2 of the go-live flow.
struct StreamInfoFormView: View {
- @ObservedObject var twitchClient: TwitchAuthClient
+ let platform: StreamViewModel.StreamPlatform
- // Stream info fields
+ // Platform clients (provide the one that matches `platform`)
+ var twitchClient: TwitchAuthClient?
+ @ObservedObject var youtubeClient: YouTubeClient
+
+ // Stream info fields (shared)
@State var streamTitle: String = ""
+ @State var tags: [String] = []
+ @State var language: String = "en"
+
+ // Twitch-specific
@State var goLiveNotification: String = ""
@State var categoryName: String = ""
@State var categoryId: String = ""
- @State var tags: [String] = []
- @State var language: String = "en"
@State var isRerun: Bool = false
@State var isBrandedContent: Bool = false
+ // YouTube-specific
+ @State var privacyStatus: String = "public"
+ @State var youtubeDescription: String = ""
+
// UI state
@State private var newTag: String = ""
@State private var categorySearchResults: [TwitchCategory] = []
@@ -31,6 +42,7 @@ struct StreamInfoFormView: View {
private static let titleLimit = 140
private static let tagCharLimit = 25
private static let maxTags = 10
+ private static let descriptionLimit = 5000
private static let languages: [(code: String, name: String)] = [
("en", "English"), ("es", "Spanish"), ("fr", "French"), ("de", "German"),
@@ -62,25 +74,23 @@ struct StreamInfoFormView: View {
Spacer()
- // Invisible spacer to balance the back button
- Color.clear.frame(width: 50, height: 1)
+ // Platform badge
+ Text(platform.rawValue)
+ .font(.caption.weight(.medium))
+ .padding(.horizontal, 8)
+ .padding(.vertical, 3)
+ .background(platformColor.opacity(0.2))
+ .foregroundStyle(platformColor)
+ .cornerRadius(6)
}
.padding(.bottom, 16)
// Scrollable form
ScrollView {
VStack(alignment: .leading, spacing: 20) {
- // Title
+ // Title (universal)
fieldSection(label: "Title", counter: "\(streamTitle.count)/\(Self.titleLimit)") {
- TextField("Stream title", text: $streamTitle)
- .textFieldStyle(.plain)
- .padding(10)
- .background(.background.opacity(0.5))
- .cornerRadius(8)
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(.white.opacity(0.2), lineWidth: 1)
- )
+ styledTextField("Stream title", text: $streamTitle)
.onChange(of: streamTitle) { _, newValue in
if newValue.count > Self.titleLimit {
streamTitle = String(newValue.prefix(Self.titleLimit))
@@ -88,92 +98,105 @@ struct StreamInfoFormView: View {
}
}
- // Go Live Notification
- fieldSection(label: "Go Live Notification", counter: "\(goLiveNotification.count)/\(Self.titleLimit)") {
- TextField("Notification text for followers", text: $goLiveNotification)
- .textFieldStyle(.plain)
- .padding(10)
- .background(.background.opacity(0.5))
- .cornerRadius(8)
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(.white.opacity(0.2), lineWidth: 1)
- )
- .onChange(of: goLiveNotification) { _, newValue in
- if newValue.count > Self.titleLimit {
- goLiveNotification = String(newValue.prefix(Self.titleLimit))
+ // Twitch: Go Live Notification
+ if platform == .twitch {
+ fieldSection(label: "Go Live Notification", counter: "\(goLiveNotification.count)/\(Self.titleLimit)") {
+ styledTextField("Notification text for followers", text: $goLiveNotification)
+ .onChange(of: goLiveNotification) { _, newValue in
+ if newValue.count > Self.titleLimit {
+ goLiveNotification = String(newValue.prefix(Self.titleLimit))
+ }
}
- }
+ }
}
- // Category
- fieldSection(label: "Category") {
- VStack(alignment: .leading, spacing: 4) {
- HStack {
- TextField("Search categories", text: $categoryName)
- .textFieldStyle(.plain)
- .padding(10)
- .background(.background.opacity(0.5))
- .cornerRadius(8)
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(.white.opacity(0.2), lineWidth: 1)
- )
- .onChange(of: categoryName) { _, newValue in
- debouncedCategorySearch(query: newValue)
+ // YouTube: Description
+ if platform == .youtube {
+ fieldSection(label: "Description", counter: "\(youtubeDescription.count)/\(Self.descriptionLimit)") {
+ TextEditor(text: $youtubeDescription)
+ .font(.body)
+ .frame(minHeight: 60, maxHeight: 100)
+ .padding(6)
+ .background(.background.opacity(0.5))
+ .cornerRadius(8)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(.white.opacity(0.2), lineWidth: 1)
+ )
+ .onChange(of: youtubeDescription) { _, newValue in
+ if newValue.count > Self.descriptionLimit {
+ youtubeDescription = String(newValue.prefix(Self.descriptionLimit))
}
-
- if isSearchingCategories {
- ProgressView()
- .scaleEffect(0.7)
}
- }
+ }
+ }
- if showCategoryResults && !categorySearchResults.isEmpty {
- VStack(spacing: 0) {
- ForEach(categorySearchResults) { category in
- Button {
- categoryName = category.name
- categoryId = category.id
- showCategoryResults = false
- categorySearchResults = []
- } label: {
- Text(category.name)
- .frame(maxWidth: .infinity, alignment: .leading)
- .padding(.horizontal, 10)
- .padding(.vertical, 6)
- .contentShape(Rectangle())
+ // Twitch: Category search
+ if platform == .twitch {
+ fieldSection(label: "Category") {
+ VStack(alignment: .leading, spacing: 4) {
+ HStack {
+ styledTextField("Search categories", text: $categoryName)
+ .onChange(of: categoryName) { _, newValue in
+ debouncedCategorySearch(query: newValue)
}
- .buttonStyle(.plain)
- if category.id != categorySearchResults.last?.id {
- Divider().opacity(0.3)
+ if isSearchingCategories {
+ ProgressView()
+ .scaleEffect(0.7)
+ }
+ }
+
+ if showCategoryResults && !categorySearchResults.isEmpty {
+ VStack(spacing: 0) {
+ ForEach(categorySearchResults) { category in
+ Button {
+ categoryName = category.name
+ categoryId = category.id
+ showCategoryResults = false
+ categorySearchResults = []
+ } label: {
+ Text(category.name)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .padding(.horizontal, 10)
+ .padding(.vertical, 6)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+
+ if category.id != categorySearchResults.last?.id {
+ Divider().opacity(0.3)
+ }
}
}
+ .background(.background.opacity(0.8))
+ .cornerRadius(8)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(.white.opacity(0.15), lineWidth: 1)
+ )
}
- .background(.background.opacity(0.8))
- .cornerRadius(8)
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(.white.opacity(0.15), lineWidth: 1)
- )
}
}
}
- // Tags
+ // YouTube: Privacy
+ if platform == .youtube {
+ fieldSection(label: "Privacy") {
+ Picker("", selection: $privacyStatus) {
+ Text("Public").tag("public")
+ Text("Unlisted").tag("unlisted")
+ Text("Private").tag("private")
+ }
+ .pickerStyle(.segmented)
+ }
+ }
+
+ // Tags (universal)
fieldSection(label: "Tags", counter: "\(tags.count)/\(Self.maxTags)") {
VStack(alignment: .leading, spacing: 8) {
HStack {
- TextField("Add a tag", text: $newTag)
- .textFieldStyle(.plain)
- .padding(10)
- .background(.background.opacity(0.5))
- .cornerRadius(8)
- .overlay(
- RoundedRectangle(cornerRadius: 8)
- .stroke(.white.opacity(0.2), lineWidth: 1)
- )
+ styledTextField("Add a tag", text: $newTag)
.onChange(of: newTag) { _, newValue in
if newValue.count > Self.tagCharLimit {
newTag = String(newValue.prefix(Self.tagCharLimit))
@@ -191,7 +214,7 @@ struct StreamInfoFormView: View {
.disabled(newTag.isEmpty || tags.count >= Self.maxTags)
}
- Text("Up to \(Self.maxTags) tags. Each tag max \(Self.tagCharLimit) characters, no spaces or special characters.")
+ Text("Up to \(Self.maxTags) tags. Each tag max \(Self.tagCharLimit) characters.")
.font(.caption2)
.foregroundStyle(.secondary)
@@ -219,7 +242,7 @@ struct StreamInfoFormView: View {
}
}
- // Language
+ // Language (universal)
fieldSection(label: "Stream Language") {
Picker("", selection: $language) {
ForEach(Self.languages, id: \.code) { lang in
@@ -229,20 +252,22 @@ struct StreamInfoFormView: View {
.labelsHidden()
}
- // Content Classification
- fieldSection(label: "Content Classification") {
- VStack(alignment: .leading, spacing: 8) {
- Toggle("Rerun", isOn: $isRerun)
- .font(.subheadline)
- Text("Let viewers know your stream was previously recorded.")
- .font(.caption2)
- .foregroundStyle(.secondary)
-
- Toggle("Branded Content", isOn: $isBrandedContent)
- .font(.subheadline)
- Text("Let viewers know if your stream features branded content.")
- .font(.caption2)
- .foregroundStyle(.secondary)
+ // Twitch: Content Classification
+ if platform == .twitch {
+ fieldSection(label: "Content Classification") {
+ VStack(alignment: .leading, spacing: 8) {
+ Toggle("Rerun", isOn: $isRerun)
+ .font(.subheadline)
+ Text("Let viewers know your stream was previously recorded.")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+
+ Toggle("Branded Content", isOn: $isBrandedContent)
+ .font(.subheadline)
+ Text("Let viewers know if your stream features branded content.")
+ .font(.caption2)
+ .foregroundStyle(.secondary)
+ }
}
}
}
@@ -252,7 +277,7 @@ struct StreamInfoFormView: View {
if needsReauth {
HStack {
Image(systemName: "exclamationmark.triangle")
- Text("Please reconnect Twitch to update stream info.")
+ Text("Please reconnect \(platform.rawValue) to update stream info.")
.font(.caption)
}
.foregroundStyle(.orange)
@@ -301,11 +326,33 @@ struct StreamInfoFormView: View {
.disabled(isSaving)
.padding(.top, 12)
}
- .onAppear { loadFromTwitch() }
+ .onAppear { loadFromPlatform() }
+ }
+
+ // MARK: - Platform color
+
+ private var platformColor: Color {
+ switch platform {
+ case .twitch: .purple
+ case .youtube: .red
+ default: .blue
+ }
}
// MARK: - Helpers
+ private func styledTextField(_ placeholder: String, text: Binding) -> some View {
+ TextField(placeholder, text: text)
+ .textFieldStyle(.plain)
+ .padding(10)
+ .background(.background.opacity(0.5))
+ .cornerRadius(8)
+ .overlay(
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(.white.opacity(0.2), lineWidth: 1)
+ )
+ }
+
private func fieldSection(label: String, counter: String? = nil, @ViewBuilder content: () -> Content) -> some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
@@ -340,6 +387,8 @@ struct StreamInfoFormView: View {
newTag = ""
}
+ // MARK: - Twitch category search
+
private func debouncedCategorySearch(query: String) {
categorySearchTask?.cancel()
guard !query.isEmpty else {
@@ -350,11 +399,11 @@ struct StreamInfoFormView: View {
categorySearchTask = Task {
try? await Task.sleep(for: .milliseconds(300))
- guard !Task.isCancelled else { return }
+ guard !Task.isCancelled, let twitch = twitchClient else { return }
isSearchingCategories = true
do {
- let results = try await twitchClient.searchCategories(query: query)
+ let results = try await twitch.searchCategories(query: query)
if !Task.isCancelled {
categorySearchResults = results
showCategoryResults = true
@@ -368,14 +417,29 @@ struct StreamInfoFormView: View {
}
}
- private func loadFromTwitch() {
- streamTitle = twitchClient.channelTitle ?? twitchClient.streamTitle ?? ""
- goLiveNotification = ""
- categoryName = twitchClient.gameName ?? ""
- categoryId = twitchClient.gameId ?? ""
- tags = twitchClient.channelTags
- language = twitchClient.broadcasterLanguage ?? "en"
- isBrandedContent = twitchClient.isBrandedContent
+ // MARK: - Load / Save
+
+ private func loadFromPlatform() {
+ switch platform {
+ case .twitch:
+ guard let twitch = twitchClient else { return }
+ streamTitle = twitch.channelTitle ?? twitch.streamTitle ?? ""
+ goLiveNotification = ""
+ categoryName = twitch.gameName ?? ""
+ categoryId = twitch.gameId ?? ""
+ tags = twitch.channelTags
+ language = twitch.broadcasterLanguage ?? "en"
+ isBrandedContent = twitch.isBrandedContent
+
+ case .youtube:
+ streamTitle = ""
+ youtubeDescription = ""
+ privacyStatus = "public"
+ language = "en"
+
+ default:
+ break
+ }
}
private func saveAndStartStream() async {
@@ -383,13 +447,29 @@ struct StreamInfoFormView: View {
saveError = nil
needsReauth = false
- do {
- // Build content classification labels
- var ccls: [TwitchContentLabel] = []
- // Rerun is not a standard CCL — it's handled differently on Twitch.
- // We include branded content via the is_branded_content param.
+ switch platform {
+ case .twitch:
+ await saveTwitchAndStart()
+ case .youtube:
+ // YouTube broadcast info is set at creation time;
+ // title is passed through StreamViewModel.title
+ await onStartStream()
+ default:
+ await onStartStream()
+ }
+
+ isSaving = false
+ }
+
+ private func saveTwitchAndStart() async {
+ guard let twitch = twitchClient else {
+ await onStartStream()
+ return
+ }
- try await twitchClient.updateChannelInfo(
+ do {
+ let ccls: [TwitchContentLabel] = []
+ try await twitch.updateChannelInfo(
title: streamTitle.isEmpty ? nil : streamTitle,
gameId: categoryId.isEmpty ? nil : categoryId,
language: language,
@@ -397,19 +477,13 @@ struct StreamInfoFormView: View {
contentClassificationLabels: ccls.isEmpty ? nil : ccls,
isBrandedContent: isBrandedContent
)
-
- // Success — now start the stream
await onStartStream()
} catch TwitchError.scopeRequired {
needsReauth = true
- // Still allow streaming even if update fails
await onStartStream()
} catch {
saveError = "Failed to update stream info: \(error.localizedDescription)"
- // Still allow streaming even if update fails
await onStartStream()
}
-
- isSaving = false
}
}
diff --git a/ArkavoCreator/ArkavoCreator/StreamViewModel.swift b/ArkavoCreator/ArkavoCreator/StreamViewModel.swift
index 7d3477c0..e0e899d1 100644
--- a/ArkavoCreator/ArkavoCreator/StreamViewModel.swift
+++ b/ArkavoCreator/ArkavoCreator/StreamViewModel.swift
@@ -1,7 +1,6 @@
import SwiftUI
import ArkavoKit
import ArkavoStreaming
-import ArkavoKit
@Observable
@MainActor
@@ -9,7 +8,7 @@ final class StreamViewModel {
// MARK: - Stream Configuration
- enum StreamPlatform: String, CaseIterable, Identifiable {
+ enum StreamPlatform: String, CaseIterable, Identifiable, Hashable {
case arkavo = "Arkavo"
case twitch = "Twitch"
case youtube = "YouTube"
@@ -19,51 +18,46 @@ final class StreamViewModel {
var rtmpURL: String {
switch self {
- case .arkavo:
- return "rtmp://100.arkavo.net:1935"
- case .twitch:
- return "rtmp://live.twitch.tv/app"
- case .youtube:
- return "rtmp://a.rtmp.youtube.com/live2"
- case .custom:
- return ""
+ case .arkavo: "rtmp://100.arkavo.net:1935"
+ case .twitch: "rtmp://live.twitch.tv/app"
+ case .youtube: "rtmp://a.rtmp.youtube.com/live2"
+ case .custom: ""
}
}
var requiresStreamKey: Bool {
- switch self {
- case .arkavo:
- return false // Arkavo uses authenticated session, not stream key
- default:
- return true
- }
+ self != .arkavo
}
var icon: String {
switch self {
- case .arkavo:
- return "lock.shield"
- case .twitch:
- return "tv"
- case .youtube:
- return "play.rectangle"
- case .custom:
- return "server.rack"
+ case .arkavo: "lock.shield"
+ case .twitch: "tv"
+ case .youtube: "play.rectangle"
+ case .custom: "server.rack"
}
}
- var isEncrypted: Bool {
- self == .arkavo
- }
+ var isEncrypted: Bool { self == .arkavo }
+ }
+
+ // MARK: - Per-Platform Config
+
+ struct PlatformConfig {
+ var streamKey: String = ""
+ var broadcastId: String?
+ var transitionTask: Task?
+ var error: String?
+ var isLive: Bool = false
}
// MARK: - State
- var selectedPlatform: StreamPlatform = .twitch
+ var selectedPlatforms: Set = [.twitch]
+ var platformConfigs: [StreamPlatform: PlatformConfig] = [:]
var customRTMPURL: String = ""
- var streamKey: String = ""
var title: String = ""
- var isBandwidthTest: Bool = false // Twitch bandwidth test mode
+ var isBandwidthTest: Bool = false
var isStreaming: Bool = false
var isConnecting: Bool = false
@@ -80,20 +74,73 @@ final class StreamViewModel {
private var statisticsTimer: Timer?
var twitchClient: TwitchAuthClient?
+ var youtubeClient: YouTubeClient?
private var recordingState = RecordingState.shared
+ // MARK: - Backward Compatibility
+
+ /// Primary platform (first selected, for single-platform code paths)
+ var selectedPlatform: StreamPlatform {
+ get { selectedPlatforms.first ?? .twitch }
+ set {
+ selectedPlatforms = [newValue]
+ }
+ }
+
+ /// Stream key for the primary platform
+ var streamKey: String {
+ get { platformConfigs[selectedPlatform]?.streamKey ?? "" }
+ set { platformConfigs[selectedPlatform, default: PlatformConfig()].streamKey = newValue }
+ }
+
+ /// YouTube broadcast ID (from primary or YouTube-specific config)
+ var youtubeBroadcastId: String? {
+ get { platformConfigs[.youtube]?.broadcastId }
+ set { platformConfigs[.youtube, default: PlatformConfig()].broadcastId = newValue }
+ }
+
+ var youtubeTransitionTask: Task? {
+ get { platformConfigs[.youtube]?.transitionTask }
+ set { platformConfigs[.youtube, default: PlatformConfig()].transitionTask = newValue }
+ }
+
// MARK: - Computed Properties
var canStartStreaming: Bool {
- let hasValidKey = selectedPlatform == .arkavo || !streamKey.isEmpty
- return hasValidKey && !isStreaming && !isConnecting &&
- (selectedPlatform != .custom || !customRTMPURL.isEmpty)
+ guard !isStreaming, !isConnecting else { return false }
+ // All selected platforms must have valid keys (or not require one)
+ for platform in selectedPlatforms {
+ if platform.requiresStreamKey {
+ let key = platformConfigs[platform]?.streamKey ?? ""
+ if key.isEmpty { return false }
+ }
+ if platform == .custom && customRTMPURL.isEmpty { return false }
+ }
+ return !selectedPlatforms.isEmpty
}
var effectiveRTMPURL: String {
selectedPlatform == .custom ? customRTMPURL : selectedPlatform.rtmpURL
}
+ /// Estimated total upload bitrate for all selected platforms
+ var estimatedTotalBitrate: String {
+ let perStream = Double(videoBitrate) + 128_000 // video + audio
+ let total = perStream * Double(selectedPlatforms.count)
+ if total < 1_000_000 {
+ return String(format: "%.0f Kbps", total / 1000)
+ }
+ return String(format: "%.1f Mbps", total / 1_000_000)
+ }
+
+ private var videoBitrate: Int {
+ // Match the auto-detected bitrate from VideoEncoder
+ let cores = ProcessInfo.processInfo.activeProcessorCount
+ if cores >= 8 { return 4_500_000 }
+ if cores >= 4 { return 3_000_000 }
+ return 1_500_000
+ }
+
var formattedBitrate: String {
if bitrate < 1000 {
return String(format: "%.0f bps", bitrate)
@@ -125,7 +172,6 @@ final class StreamViewModel {
func startStreaming() async {
guard canStartStreaming else { return }
- // Validate inputs before streaming
if let validationError = validateInputs() {
error = validationError
return
@@ -140,8 +186,8 @@ final class StreamViewModel {
isConnecting = true
do {
- if selectedPlatform == .arkavo {
- // Use NTDF-encrypted streaming for Arkavo
+ // Handle Arkavo NTDF separately (not part of simulcast)
+ if selectedPlatforms.contains(.arkavo) {
guard let kasURL = URL(string: "https://100.arkavo.net") else {
self.error = "Invalid KAS URL"
isConnecting = false
@@ -149,26 +195,38 @@ final class StreamViewModel {
}
try await session.startNTDFStreaming(
kasURL: kasURL,
- rtmpURL: effectiveRTMPURL,
- streamKey: "live/creator" // Default stream key for Arkavo
- )
- } else {
- // Create RTMP destination for other platforms
- let destination = RTMPPublisher.Destination(
- url: effectiveRTMPURL,
- platform: selectedPlatform.rawValue.lowercased()
+ rtmpURL: StreamPlatform.arkavo.rtmpURL,
+ streamKey: "live/creator"
)
+ }
- // Connect and start streaming
- // Append bandwidth test flag if enabled (Twitch-specific)
- let effectiveStreamKey = isBandwidthTest ? "\(streamKey)?bandwidthtest=true" : streamKey
- try await session.startStreaming(to: destination, streamKey: effectiveStreamKey)
+ // Build RTMP destinations for non-Arkavo platforms
+ let rtmpPlatforms = selectedPlatforms.filter { !$0.isEncrypted }
+ if !rtmpPlatforms.isEmpty {
+ // YouTube: create broadcast before RTMP
+ if rtmpPlatforms.contains(.youtube), let ytClient = youtubeClient {
+ let broadcastId = try await ytClient.createAndBindBroadcast(title: title)
+ platformConfigs[.youtube, default: PlatformConfig()].broadcastId = broadcastId
+ debugLog("[StreamViewModel] Created YouTube broadcast: \(broadcastId)")
+ }
+
+ var destinations: [(id: String, destination: RTMPPublisher.Destination, streamKey: String)] = []
+ for platform in rtmpPlatforms {
+ let config = platformConfigs[platform] ?? PlatformConfig()
+ let url = platform == .custom ? customRTMPURL : platform.rtmpURL
+ let dest = RTMPPublisher.Destination(url: url, platform: platform.rawValue.lowercased())
+ var key = config.streamKey
+ if platform == .twitch && isBandwidthTest {
+ key += "?bandwidthtest=true"
+ }
+ destinations.append((id: platform.rawValue.lowercased(), destination: dest, streamKey: key))
+ }
+
+ try await session.startStreaming(destinations: destinations)
}
isStreaming = true
isConnecting = false
-
- // Start statistics polling
startStatisticsTimer()
} catch {
@@ -181,23 +239,33 @@ final class StreamViewModel {
func stopStreaming() async {
guard let session = recordingState.getRecordingSession(), isStreaming else { return }
+ // Cancel YouTube transition task and end broadcast
+ platformConfigs[.youtube]?.transitionTask?.cancel()
+ platformConfigs[.youtube]?.transitionTask = nil
+ if let ytClient = youtubeClient, let broadcastId = platformConfigs[.youtube]?.broadcastId {
+ try? await ytClient.endBroadcast(broadcastId: broadcastId)
+ platformConfigs[.youtube]?.broadcastId = nil
+ debugLog("[StreamViewModel] Ended YouTube broadcast")
+ }
+
await session.stopStreaming()
isStreaming = false
isConnecting = false
-
- // Stop statistics polling
stopStatisticsTimer()
-
- // Reset statistics
bitrate = 0
fps = 0
framesSent = 0
bytesSent = 0
duration = 0
+
+ // Clear per-platform live state
+ for platform in platformConfigs.keys {
+ platformConfigs[platform]?.isLive = false
+ platformConfigs[platform]?.error = nil
+ }
}
- /// Start polling stream statistics (duration, bitrate, etc.)
func startStatisticsPolling() {
startStatisticsTimer()
}
@@ -229,7 +297,6 @@ final class StreamViewModel {
bytesSent = stats.bytesSent
duration = stats.duration
- // Calculate FPS from frames sent over duration
if duration > 0 {
fps = Double(framesSent) / duration
}
@@ -238,49 +305,47 @@ final class StreamViewModel {
// MARK: - Stream Key Management
func loadStreamKey() {
- // Clear current key before loading platform-specific key
- streamKey = ""
-
- // Load stream key from Keychain for selected platform
- if let savedKey = KeychainManager.getStreamKey(for: selectedPlatform.rawValue) {
- // Validate it's not a URL (bad cached value)
- if !savedKey.hasPrefix("http://") && !savedKey.hasPrefix("https://") {
- streamKey = savedKey
- debugLog("[StreamViewModel] Loaded stream key for \(selectedPlatform.rawValue)")
- } else {
- // Clear invalid cached URL
- debugLog("[StreamViewModel] Clearing invalid cached stream key (was URL)")
- KeychainManager.deleteStreamKey(for: selectedPlatform.rawValue)
+ // Load keys for all selected platforms
+ for platform in selectedPlatforms {
+ var config = platformConfigs[platform] ?? PlatformConfig()
+ config.streamKey = ""
+ if let savedKey = KeychainManager.getStreamKey(for: platform.rawValue) {
+ if !savedKey.hasPrefix("http://") && !savedKey.hasPrefix("https://") {
+ config.streamKey = savedKey
+ debugLog("[StreamViewModel] Loaded stream key for \(platform.rawValue)")
+ } else {
+ KeychainManager.deleteStreamKey(for: platform.rawValue)
+ }
}
+ platformConfigs[platform] = config
}
- // Handle custom RTMP URL
- if selectedPlatform == .custom {
- customRTMPURL = ""
- if let savedURL = KeychainManager.getCustomRTMPURL() {
- customRTMPURL = savedURL
- }
+ if selectedPlatforms.contains(.custom) {
+ customRTMPURL = KeychainManager.getCustomRTMPURL() ?? ""
}
}
func saveStreamKey() {
- // Save stream key to Keychain (but never save URLs)
- if !streamKey.isEmpty && !streamKey.hasPrefix("http://") && !streamKey.hasPrefix("https://") {
- try? KeychainManager.saveStreamKey(streamKey, for: selectedPlatform.rawValue)
- debugLog("[StreamViewModel] Saved stream key for \(selectedPlatform.rawValue)")
+ for platform in selectedPlatforms {
+ let key = platformConfigs[platform]?.streamKey ?? ""
+ if !key.isEmpty && !key.hasPrefix("http://") && !key.hasPrefix("https://") {
+ try? KeychainManager.saveStreamKey(key, for: platform.rawValue)
+ debugLog("[StreamViewModel] Saved stream key for \(platform.rawValue)")
+ }
}
- // Save custom RTMP URL if custom platform
- if selectedPlatform == .custom && !customRTMPURL.isEmpty {
+ if selectedPlatforms.contains(.custom) && !customRTMPURL.isEmpty {
try? KeychainManager.saveCustomRTMPURL(customRTMPURL)
}
}
func clearStreamKey() {
- KeychainManager.deleteStreamKey(for: selectedPlatform.rawValue)
- streamKey = ""
+ for platform in selectedPlatforms {
+ KeychainManager.deleteStreamKey(for: platform.rawValue)
+ platformConfigs[platform]?.streamKey = ""
+ }
- if selectedPlatform == .custom {
+ if selectedPlatforms.contains(.custom) {
KeychainManager.deleteCustomRTMPURL()
customRTMPURL = ""
}
@@ -288,111 +353,54 @@ final class StreamViewModel {
// MARK: - Input Validation
- /// Validates stream key, RTMP URL, and title
- /// - Returns: Error message if validation fails, nil if all inputs are valid
private func validateInputs() -> String? {
- // Validate stream key
- if let error = validateStreamKey(streamKey) {
- return error
- }
-
- // Validate custom RTMP URL if custom platform
- if selectedPlatform == .custom {
- if let error = validateRTMPURL(customRTMPURL) {
- return error
+ for platform in selectedPlatforms {
+ if platform.requiresStreamKey {
+ let key = platformConfigs[platform]?.streamKey ?? ""
+ if let error = validateStreamKey(key, platform: platform) {
+ return "[\(platform.rawValue)] \(error)"
+ }
+ }
+ if platform == .custom {
+ if let error = validateRTMPURL(customRTMPURL) {
+ return error
+ }
}
}
- // Validate stream title
if let error = validateTitle(title) {
return error
}
-
return nil
}
- /// Validates stream key format and length
- private func validateStreamKey(_ key: String) -> String? {
- // Arkavo doesn't require a stream key
- if selectedPlatform == .arkavo {
- return nil
- }
-
- // Check if empty
- if key.trimmingCharacters(in: .whitespaces).isEmpty {
- return "Stream key cannot be empty"
- }
-
- // Check minimum length (most platforms require at least 10 characters)
- if key.count < 10 {
- return "Stream key is too short (minimum 10 characters)"
- }
-
- // Check maximum length (reasonable limit for stream keys)
- if key.count > 200 {
- return "Stream key is too long (maximum 200 characters)"
- }
-
- // Check for valid characters (alphanumeric, hyphens, underscores)
- let validCharacterSet = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
- if key.rangeOfCharacter(from: validCharacterSet.inverted) != nil {
- return "Stream key contains invalid characters (only letters, numbers, hyphens, and underscores allowed)"
+ private func validateStreamKey(_ key: String, platform: StreamPlatform) -> String? {
+ if platform == .arkavo { return nil }
+ if key.trimmingCharacters(in: .whitespaces).isEmpty { return "Stream key cannot be empty" }
+ if key.count < 10 { return "Stream key is too short (minimum 10 characters)" }
+ if key.count > 200 { return "Stream key is too long (maximum 200 characters)" }
+ let validChars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
+ if key.rangeOfCharacter(from: validChars.inverted) != nil {
+ return "Stream key contains invalid characters"
}
-
return nil
}
- /// Validates RTMP URL format and protocol
private func validateRTMPURL(_ urlString: String) -> String? {
- // Check if empty
- if urlString.trimmingCharacters(in: .whitespaces).isEmpty {
- return "RTMP URL cannot be empty"
- }
-
- // Check if valid URL
- guard let url = URL(string: urlString) else {
- return "Invalid RTMP URL format"
- }
-
- // Check protocol
- guard let scheme = url.scheme?.lowercased() else {
- return "RTMP URL must specify a protocol (rtmp:// or rtmps://)"
- }
-
- guard scheme == "rtmp" || scheme == "rtmps" else {
- return "RTMP URL must use rtmp:// or rtmps:// protocol"
- }
-
- // Check host
- guard let host = url.host, !host.isEmpty else {
- return "RTMP URL must include a valid host"
+ if urlString.trimmingCharacters(in: .whitespaces).isEmpty { return "RTMP URL cannot be empty" }
+ guard let url = URL(string: urlString) else { return "Invalid RTMP URL format" }
+ guard let scheme = url.scheme?.lowercased(), scheme == "rtmp" || scheme == "rtmps" else {
+ return "RTMP URL must use rtmp:// or rtmps://"
}
-
- // Check overall length
- if urlString.count > 500 {
- return "RTMP URL is too long (maximum 500 characters)"
- }
-
+ guard let host = url.host, !host.isEmpty else { return "RTMP URL must include a host" }
+ if urlString.count > 500 { return "RTMP URL is too long" }
return nil
}
- /// Validates stream title length and characters
private func validateTitle(_ title: String) -> String? {
- // Allow empty title (optional field)
- if title.isEmpty {
- return nil
- }
-
- // Check maximum length
- if title.count > 200 {
- return "Stream title is too long (maximum 200 characters)"
- }
-
- // Check for control characters
- if title.rangeOfCharacter(from: .controlCharacters) != nil {
- return "Stream title contains invalid control characters"
- }
-
+ if title.isEmpty { return nil }
+ if title.count > 200 { return "Stream title is too long (maximum 200 characters)" }
+ if title.rangeOfCharacter(from: .controlCharacters) != nil { return "Stream title contains invalid characters" }
return nil
}
}
diff --git a/ArkavoCreator/ArkavoCreator/Streaming/ChatPanelViewModel.swift b/ArkavoCreator/ArkavoCreator/Streaming/ChatPanelViewModel.swift
index e1598cc5..0137b29c 100644
--- a/ArkavoCreator/ArkavoCreator/Streaming/ChatPanelViewModel.swift
+++ b/ArkavoCreator/ArkavoCreator/Streaming/ChatPanelViewModel.swift
@@ -1,18 +1,31 @@
import Foundation
+import ArkavoKit
@MainActor
@Observable
final class ChatPanelViewModel {
var messages: [ChatMessage] = []
- var isConnected: Bool = false
+ var recentEvents: [StreamEvent] = []
+ var connectedPlatforms: Set = []
var error: String?
- private var chatClient: TwitchChatClient?
- private var listenerTask: Task?
+ var isConnected: Bool { !connectedPlatforms.isEmpty }
+
+ // Twitch state
+ private var twitchChatClient: TwitchChatClient?
+ private var twitchEventSubClient: TwitchEventSubClient?
+ private var twitchListenerTask: Task?
+ private var twitchEventListenerTask: Task?
+
+ // YouTube state
+ private var youtubePollingTask: Task?
private static let maxMessages = 200
+ private static let maxEvents = 50
- func connect(twitchClient: TwitchAuthClient) {
+ // MARK: - Twitch
+
+ func connectTwitch(twitchClient: TwitchAuthClient) {
guard twitchClient.isAuthenticated,
let token = twitchClient.accessToken,
let channel = twitchClient.username else {
@@ -20,17 +33,18 @@ final class ChatPanelViewModel {
return
}
+ // Connect IRC chat
let client = TwitchChatClient()
client.oauthToken = token
client.channel = channel
client.username = twitchClient.username
- chatClient = client
+ twitchChatClient = client
- listenerTask = Task {
+ twitchListenerTask = Task {
do {
try await client.connect()
- isConnected = true
- error = nil
+ connectedPlatforms.insert("twitch")
+ debugLog("[ChatPanel] Twitch chat connected")
for await message in client.chatMessages {
messages.append(message)
@@ -38,22 +52,158 @@ final class ChatPanelViewModel {
messages.removeFirst(messages.count - Self.maxMessages)
}
}
- // Stream ended
- isConnected = false
+ connectedPlatforms.remove("twitch")
} catch {
- self.error = error.localizedDescription
- isConnected = false
+ self.error = "Twitch chat: \(error.localizedDescription)"
+ connectedPlatforms.remove("twitch")
+ }
+ }
+
+ // Connect EventSub
+ let eventSub = TwitchEventSubClient(
+ clientId: twitchClient.clientId,
+ accessToken: { [weak twitchClient] in twitchClient?.accessToken },
+ userId: { [weak twitchClient] in twitchClient?.userId },
+ ensureValidToken: { [weak twitchClient] in
+ await twitchClient?.ensureValidToken() ?? false
+ }
+ )
+ twitchEventSubClient = eventSub
+
+ twitchEventListenerTask = Task {
+ await eventSub.connect()
+
+ for await event in eventSub.events {
+ recentEvents.append(event)
+ if recentEvents.count > Self.maxEvents {
+ recentEvents.removeFirst(recentEvents.count - Self.maxEvents)
+ }
}
}
}
- func disconnect() {
- listenerTask?.cancel()
- listenerTask = nil
- Task {
- await chatClient?.disconnect()
+ // MARK: - YouTube
+
+ func connectYouTube(youtubeClient: YouTubeClient, broadcastId: String) {
+ youtubePollingTask = Task {
+ do {
+ guard let liveChatId = try await youtubeClient.getLiveChatId(broadcastId: broadcastId) else {
+ error = "No live chat available for this broadcast"
+ return
+ }
+
+ connectedPlatforms.insert("youtube")
+ debugLog("[ChatPanel] YouTube chat connected (chatId: \(liveChatId))")
+
+ var nextPageToken: String? = nil
+ var pollingInterval: TimeInterval = 6.0
+
+ while !Task.isCancelled {
+ do {
+ let result = try await youtubeClient.fetchLiveChatMessages(
+ liveChatId: liveChatId,
+ pageToken: nextPageToken
+ )
+ nextPageToken = result.nextPageToken
+
+ if let ms = result.pollingIntervalMs {
+ pollingInterval = max(Double(ms) / 1000.0, 5.0)
+ }
+
+ for item in result.messages {
+ let author = item.authorDetails
+ var badges: [String] = []
+ if author.isChatOwner { badges.append("owner") }
+ if author.isChatModerator { badges.append("moderator") }
+ if author.isChatSponsor { badges.append("member") }
+
+ let chatMsg = ChatMessage(
+ id: item.id,
+ platform: "youtube",
+ username: author.channelId,
+ displayName: author.displayName,
+ content: item.snippet.displayMessage,
+ badges: badges,
+ isHighlighted: item.snippet.type == "superChatEvent"
+ )
+ messages.append(chatMsg)
+ if messages.count > Self.maxMessages {
+ messages.removeFirst(messages.count - Self.maxMessages)
+ }
+
+ // Super Chat → donation event
+ if item.snippet.type == "superChatEvent",
+ let details = item.snippet.superChatDetails {
+ let amount = (Double(details.amountMicros) ?? 0) / 1_000_000.0
+ let event = StreamEvent(
+ platform: "youtube",
+ type: .donation,
+ username: author.channelId,
+ displayName: author.displayName,
+ message: details.userComment,
+ amount: amount
+ )
+ recentEvents.append(event)
+ if recentEvents.count > Self.maxEvents {
+ recentEvents.removeFirst(recentEvents.count - Self.maxEvents)
+ }
+ }
+
+ if item.snippet.type == "newSponsorEvent" {
+ let event = StreamEvent(
+ platform: "youtube",
+ type: .subscribe,
+ username: author.channelId,
+ displayName: author.displayName
+ )
+ recentEvents.append(event)
+ if recentEvents.count > Self.maxEvents {
+ recentEvents.removeFirst(recentEvents.count - Self.maxEvents)
+ }
+ }
+ }
+ } catch {
+ debugLog("[ChatPanel] YouTube chat poll error: \(error.localizedDescription)")
+ }
+
+ try? await Task.sleep(for: .seconds(pollingInterval))
+ }
+ } catch {
+ self.error = "YouTube chat: \(error.localizedDescription)"
+ }
+ connectedPlatforms.remove("youtube")
+ }
+ }
+
+ // MARK: - Backward Compat
+
+ func connect(twitchClient: TwitchAuthClient) {
+ connectTwitch(twitchClient: twitchClient)
+ }
+
+ func connect(youtubeClient: YouTubeClient, broadcastId: String) {
+ connectYouTube(youtubeClient: youtubeClient, broadcastId: broadcastId)
+ }
+
+ // MARK: - Disconnect
+
+ func disconnect(platform: String? = nil) {
+ if platform == nil || platform == "twitch" {
+ twitchListenerTask?.cancel()
+ twitchListenerTask = nil
+ twitchEventListenerTask?.cancel()
+ twitchEventListenerTask = nil
+ Task { await twitchChatClient?.disconnect() }
+ twitchChatClient = nil
+ twitchEventSubClient?.disconnect()
+ twitchEventSubClient = nil
+ connectedPlatforms.remove("twitch")
+ }
+
+ if platform == nil || platform == "youtube" {
+ youtubePollingTask?.cancel()
+ youtubePollingTask = nil
+ connectedPlatforms.remove("youtube")
}
- chatClient = nil
- isConnected = false
}
}
diff --git a/ArkavoCreator/ArkavoCreator/Streaming/StreamChatReactor.swift b/ArkavoCreator/ArkavoCreator/Streaming/StreamChatReactor.swift
index 1d5b2a9b..49fe913b 100644
--- a/ArkavoCreator/ArkavoCreator/Streaming/StreamChatReactor.swift
+++ b/ArkavoCreator/ArkavoCreator/Streaming/StreamChatReactor.swift
@@ -25,6 +25,9 @@ final class StreamChatReactor {
/// Active listener tasks
private var listenerTasks: [Task] = []
+ /// Active role determines event handling behavior
+ var activeRole: AvatarRole = .sidekick
+
/// Rate limiting: minimum seconds between spoken responses
var responseInterval: TimeInterval = 8.0
@@ -54,6 +57,9 @@ final class StreamChatReactor {
/// Called when the avatar should change expression
var onExpressionRequest: ((VRMExpressionPreset, Float) -> Void)?
+ /// Called when Producer mode receives an event for analysis
+ var onProducerEvent: ((StreamEvent) -> Void)?
+
// MARK: - Public API
/// Add a chat provider to listen to
@@ -136,6 +142,12 @@ final class StreamChatReactor {
}
private func handleEvent(_ event: StreamEvent) {
+ // In Producer mode, forward events for analysis instead of avatar reactions
+ if activeRole == .producer {
+ onProducerEvent?(event)
+ return
+ }
+
// Events get immediate emote reactions
switch event.type {
case .subscribe, .newPatron:
diff --git a/ArkavoCreator/ArkavoCreator/Streaming/TwitchEventSubClient.swift b/ArkavoCreator/ArkavoCreator/Streaming/TwitchEventSubClient.swift
new file mode 100644
index 00000000..0f0f605c
--- /dev/null
+++ b/ArkavoCreator/ArkavoCreator/Streaming/TwitchEventSubClient.swift
@@ -0,0 +1,424 @@
+//
+// TwitchEventSubClient.swift
+// ArkavoCreator
+//
+// Twitch EventSub WebSocket client for real-time channel events.
+// Receives follows, subscriptions, cheers, raids, and gift subs
+// via wss://eventsub.wss.twitch.tv/ws and yields them as StreamEvents.
+//
+
+import Foundation
+import OSLog
+
+/// Twitch EventSub WebSocket client
+@MainActor
+final class TwitchEventSubClient {
+ private let logger = Logger(subsystem: "com.arkavo.creator", category: "TwitchEventSub")
+
+ private var webSocket: URLSessionWebSocketTask?
+ private var urlSession: URLSession?
+ private var sessionId: String?
+ private var keepaliveTimeoutSeconds: Int = 30
+ private var keepaliveTimer: Task?
+ private var receiveTask: Task?
+
+ private var eventContinuation: AsyncStream.Continuation?
+ private(set) var events: AsyncStream!
+
+ /// OAuth token and client ID for Helix API subscription calls
+ private let accessToken: () -> String?
+ private let clientId: String
+ private let userId: () -> String?
+ /// Called before subscription creation to ensure the token is valid
+ private let ensureValidToken: () async -> Bool
+
+ private(set) var isConnected = false
+
+ /// Event types to subscribe to once the session is established
+ private let subscriptionTypes: [(type: String, version: String, scope: String?)] = [
+ ("channel.follow", "2", "moderator:read:followers"),
+ ("channel.subscribe", "1", "channel:read:subscriptions"),
+ ("channel.subscription.gift", "1", "channel:read:subscriptions"),
+ ("channel.cheer", "1", "bits:read"),
+ ("channel.raid", "1", nil),
+ ]
+
+ init(clientId: String, accessToken: @escaping () -> String?, userId: @escaping () -> String?, ensureValidToken: @escaping () async -> Bool = { true }) {
+ self.clientId = clientId
+ self.accessToken = accessToken
+ self.userId = userId
+ self.ensureValidToken = ensureValidToken
+
+ self.events = AsyncStream { continuation in
+ self.eventContinuation = continuation
+ }
+ }
+
+ // MARK: - Connection
+
+ func connect() async {
+ guard !isConnected else { return }
+
+ let url = URL(string: "wss://eventsub.wss.twitch.tv/ws")!
+ let session = URLSession(configuration: .default)
+ self.urlSession = session
+ let ws = session.webSocketTask(with: url)
+ self.webSocket = ws
+ ws.resume()
+
+ isConnected = true
+ logger.info("Connecting to Twitch EventSub WebSocket")
+
+ receiveTask = Task { [weak self] in
+ await self?.receiveLoop()
+ }
+ }
+
+ func disconnect() {
+ isConnected = false
+ keepaliveTimer?.cancel()
+ keepaliveTimer = nil
+ receiveTask?.cancel()
+ receiveTask = nil
+ webSocket?.cancel(with: .normalClosure, reason: nil)
+ webSocket = nil
+ urlSession?.invalidateAndCancel()
+ urlSession = nil
+ sessionId = nil
+ eventContinuation?.finish()
+ logger.info("Disconnected from Twitch EventSub")
+ }
+
+ // MARK: - Receive Loop
+
+ private func receiveLoop() async {
+ guard let ws = webSocket else { return }
+
+ while isConnected, !Task.isCancelled {
+ do {
+ let message = try await ws.receive()
+ switch message {
+ case .string(let text):
+ handleMessage(text)
+ case .data(let data):
+ if let text = String(data: data, encoding: .utf8) {
+ handleMessage(text)
+ }
+ @unknown default:
+ break
+ }
+ } catch {
+ if isConnected {
+ logger.error("EventSub receive error: \(error.localizedDescription)")
+ isConnected = false
+ // Attempt reconnect after a delay
+ Task { [weak self] in
+ try? await Task.sleep(for: .seconds(5))
+ await self?.reconnect()
+ }
+ }
+ break
+ }
+ }
+ }
+
+ private func handleMessage(_ text: String) {
+ guard let data = text.data(using: .utf8),
+ let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let metadata = json["metadata"] as? [String: Any],
+ let messageType = metadata["message_type"] as? String
+ else {
+ logger.warning("Failed to parse EventSub message")
+ return
+ }
+
+ switch messageType {
+ case "session_welcome":
+ handleWelcome(json)
+ case "session_keepalive":
+ resetKeepaliveTimer()
+ case "notification":
+ handleNotification(json)
+ case "session_reconnect":
+ handleReconnect(json)
+ case "revocation":
+ if let payload = json["payload"] as? [String: Any],
+ let subscription = payload["subscription"] as? [String: Any],
+ let type = subscription["type"] as? String {
+ logger.warning("Subscription revoked: \(type)")
+ }
+ default:
+ logger.debug("Unknown EventSub message type: \(messageType)")
+ }
+ }
+
+ // MARK: - Message Handlers
+
+ private func handleWelcome(_ json: [String: Any]) {
+ guard let payload = json["payload"] as? [String: Any],
+ let session = payload["session"] as? [String: Any],
+ let id = session["id"] as? String
+ else { return }
+
+ sessionId = id
+ if let timeout = session["keepalive_timeout_seconds"] as? Int {
+ keepaliveTimeoutSeconds = timeout
+ }
+
+ logger.info("EventSub session established: \(id)")
+ resetKeepaliveTimer()
+
+ // Subscribe to all event types
+ Task { [weak self] in
+ await self?.createSubscriptions()
+ }
+ }
+
+ private func handleNotification(_ json: [String: Any]) {
+ guard let metadata = json["metadata"] as? [String: Any],
+ let subscriptionType = metadata["subscription_type"] as? String,
+ let payload = json["payload"] as? [String: Any],
+ let eventData = payload["event"] as? [String: Any]
+ else { return }
+
+ guard let event = parseEvent(type: subscriptionType, data: eventData) else { return }
+ eventContinuation?.yield(event)
+ }
+
+ private func handleReconnect(_ json: [String: Any]) {
+ guard let payload = json["payload"] as? [String: Any],
+ let session = payload["session"] as? [String: Any],
+ let reconnectURL = session["reconnect_url"] as? String
+ else { return }
+
+ logger.info("EventSub reconnect requested")
+ Task { [weak self] in
+ await self?.reconnectTo(urlString: reconnectURL)
+ }
+ }
+
+ // MARK: - Event Parsing
+
+ private func parseEvent(type: String, data: [String: Any]) -> StreamEvent? {
+ switch type {
+ case "channel.follow":
+ guard let userName = data["user_login"] as? String,
+ let displayName = data["user_name"] as? String
+ else { return nil }
+ return StreamEvent(
+ platform: "twitch",
+ type: .follow,
+ username: userName,
+ displayName: displayName
+ )
+
+ case "channel.subscribe":
+ let userName = data["user_login"] as? String ?? ""
+ let displayName = data["user_name"] as? String ?? userName
+ let tier = data["tier"] as? String
+ let tierAmount: Double? = switch tier {
+ case "1000": 4.99
+ case "2000": 9.99
+ case "3000": 24.99
+ default: nil
+ }
+ return StreamEvent(
+ platform: "twitch",
+ type: .subscribe,
+ username: userName,
+ displayName: displayName,
+ amount: tierAmount
+ )
+
+ case "channel.subscription.gift":
+ let userName = data["user_login"] as? String ?? ""
+ let displayName = data["user_name"] as? String ?? userName
+ let total = data["total"] as? Int ?? 1
+ let tier = data["tier"] as? String
+ let perSub: Double = switch tier {
+ case "2000": 9.99
+ case "3000": 24.99
+ default: 4.99
+ }
+ return StreamEvent(
+ platform: "twitch",
+ type: .giftSub,
+ username: userName,
+ displayName: displayName,
+ message: "\(total) gift sub(s)",
+ amount: perSub * Double(total)
+ )
+
+ case "channel.cheer":
+ let userName = data["user_login"] as? String ?? "Anonymous"
+ let displayName = data["user_name"] as? String ?? userName
+ let bits = data["bits"] as? Int ?? 0
+ let message = data["message"] as? String
+ return StreamEvent(
+ platform: "twitch",
+ type: .cheer,
+ username: userName,
+ displayName: displayName,
+ message: message,
+ amount: Double(bits)
+ )
+
+ case "channel.raid":
+ let userName = data["from_broadcaster_user_login"] as? String ?? ""
+ let displayName = data["from_broadcaster_user_name"] as? String ?? userName
+ let viewers = data["viewers"] as? Int ?? 0
+ return StreamEvent(
+ platform: "twitch",
+ type: .raid,
+ username: userName,
+ displayName: displayName,
+ message: "\(viewers) viewers",
+ amount: Double(viewers)
+ )
+
+ default:
+ return nil
+ }
+ }
+
+ // MARK: - Subscriptions
+
+ private func createSubscriptions() async {
+ // Validate / refresh the token before attempting subscriptions
+ let tokenValid = await ensureValidToken()
+ guard tokenValid,
+ let token = accessToken(),
+ let broadcasterId = userId(),
+ let sessionId
+ else {
+ logger.error("Cannot create subscriptions: missing or invalid token, userId, or sessionId")
+ return
+ }
+
+ for sub in subscriptionTypes {
+ do {
+ try await createSubscription(
+ type: sub.type,
+ version: sub.version,
+ broadcasterId: broadcasterId,
+ token: token,
+ sessionId: sessionId
+ )
+ } catch {
+ logger.error("Failed to subscribe to \(sub.type): \(error.localizedDescription)")
+ }
+ }
+ }
+
+ private func createSubscription(
+ type: String,
+ version: String,
+ broadcasterId: String,
+ token: String,
+ sessionId: String
+ ) async throws {
+ var request = URLRequest(url: URL(string: "https://api.twitch.tv/helix/eventsub/subscriptions")!)
+ request.httpMethod = "POST"
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
+ request.setValue(clientId, forHTTPHeaderField: "Client-Id")
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
+
+ // Build condition — most events use broadcaster_user_id,
+ // channel.follow v2 also needs moderator_user_id,
+ // channel.raid uses to_broadcaster_user_id for incoming raids
+ var condition: [String: String] = [:]
+ if type == "channel.raid" {
+ condition["to_broadcaster_user_id"] = broadcasterId
+ } else {
+ condition["broadcaster_user_id"] = broadcasterId
+ }
+ if type == "channel.follow" {
+ condition["moderator_user_id"] = broadcasterId
+ }
+
+ let body: [String: Any] = [
+ "type": type,
+ "version": version,
+ "condition": condition,
+ "transport": [
+ "method": "websocket",
+ "session_id": sessionId,
+ ],
+ ]
+
+ request.httpBody = try JSONSerialization.data(withJSONObject: body)
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw TwitchError.apiFailed
+ }
+
+ if httpResponse.statusCode == 202 {
+ logger.info("Subscribed to \(type)")
+ } else {
+ let responseBody = String(data: data, encoding: .utf8) ?? "no body"
+ logger.error("Subscribe to \(type) failed (\(httpResponse.statusCode)): \(responseBody)")
+ }
+ }
+
+ // MARK: - Keepalive & Reconnect
+
+ private func resetKeepaliveTimer() {
+ keepaliveTimer?.cancel()
+ keepaliveTimer = Task { [weak self, keepaliveTimeoutSeconds] in
+ // Twitch says connection is dead if no message within keepalive_timeout + 10s
+ let timeout = keepaliveTimeoutSeconds + 10
+ try? await Task.sleep(for: .seconds(timeout))
+ guard !Task.isCancelled else { return }
+ await self?.handleKeepaliveTimeout()
+ }
+ }
+
+ private func handleKeepaliveTimeout() {
+ logger.warning("EventSub keepalive timeout — reconnecting")
+ Task { [weak self] in
+ await self?.reconnect()
+ }
+ }
+
+ private func reconnect() async {
+ disconnect()
+
+ // Re-create the event stream for new consumers
+ self.events = AsyncStream { continuation in
+ self.eventContinuation = continuation
+ }
+
+ try? await Task.sleep(for: .seconds(1))
+ await connect()
+ }
+
+ private func reconnectTo(urlString: String) async {
+ guard let url = URL(string: urlString) else {
+ await reconnect()
+ return
+ }
+
+ // Keep old connection alive until new one sends welcome
+ let oldWs = webSocket
+ let oldSession = urlSession
+
+ let session = URLSession(configuration: .default)
+ self.urlSession = session
+ let ws = session.webSocketTask(with: url)
+ self.webSocket = ws
+ ws.resume()
+
+ // The new connection will send a session_welcome with the same session ID
+ // Old connection can be closed after welcome
+ receiveTask?.cancel()
+ receiveTask = Task { [weak self] in
+ await self?.receiveLoop()
+ }
+
+ // Clean up old connection
+ oldWs?.cancel(with: .normalClosure, reason: nil)
+ oldSession?.invalidateAndCancel()
+ }
+}
diff --git a/ArkavoCreator/ArkavoCreator/TwitchAuthClient.swift b/ArkavoCreator/ArkavoCreator/TwitchAuthClient.swift
index ca31c3cc..e1bd674d 100644
--- a/ArkavoCreator/ArkavoCreator/TwitchAuthClient.swift
+++ b/ArkavoCreator/ArkavoCreator/TwitchAuthClient.swift
@@ -36,12 +36,13 @@ class TwitchAuthClient: ObservableObject {
// MARK: - Private Properties
private(set) var accessToken: String?
+ private var refreshToken: String?
private var cancellables = Set()
private var notificationObserver: NSObjectProtocol?
private var authSession: ASWebAuthenticationSession?
// OAuth Configuration
- private let clientId: String
+ let clientId: String
private let clientSecret: String
private var redirectURI: String { ArkavoConfiguration.shared.oauthRedirectURL(for: "twitch") }
private let authURL = "https://id.twitch.tv/oauth2/authorize"
@@ -50,7 +51,10 @@ class TwitchAuthClient: ObservableObject {
"user:read:email",
"channel:read:stream_key", // Note: This scope may not actually work - Twitch restricts stream key access
"channel:manage:broadcast", // Required for updating stream title, category, tags
- "chat:read" // Read chat messages for Muse avatar reactions
+ "chat:read", // Read chat messages for Muse avatar reactions
+ "moderator:read:followers", // EventSub: channel.follow v2
+ "channel:read:subscriptions", // EventSub: subscribe & gift sub events
+ "bits:read", // EventSub: cheer events
]
// MARK: - Initialization
@@ -184,6 +188,7 @@ class TwitchAuthClient: ObservableObject {
func logout() {
isAuthenticated = false
accessToken = nil
+ refreshToken = nil
username = nil
userId = nil
followerCount = nil
@@ -486,6 +491,75 @@ class TwitchAuthClient: ObservableObject {
}
}
+ /// Validates the current token with Twitch and refreshes if expired.
+ /// Returns true if a valid token is available after the call.
+ @discardableResult
+ func ensureValidToken() async -> Bool {
+ guard let token = accessToken else { return false }
+
+ // Validate with Twitch
+ var request = URLRequest(url: URL(string: "https://id.twitch.tv/oauth2/validate")!)
+ request.setValue("OAuth \(token)", forHTTPHeaderField: "Authorization")
+
+ do {
+ let (_, response) = try await URLSession.shared.data(for: request)
+ if let http = response as? HTTPURLResponse, http.statusCode == 200 {
+ return true
+ }
+ } catch {
+ debugLog("❌ Token validation request failed: \(error.localizedDescription)")
+ }
+
+ // Token invalid — try refresh
+ debugLog("🔄 Access token invalid, attempting refresh")
+ return await refreshAccessToken()
+ }
+
+ /// Uses the stored refresh token to obtain a new access token.
+ private func refreshAccessToken() async -> Bool {
+ guard let refresh = refreshToken else {
+ debugLog("❌ No refresh token available — user must re-authenticate")
+ clearStoredCredentials()
+ isAuthenticated = false
+ return false
+ }
+
+ var bodyComponents = URLComponents()
+ bodyComponents.queryItems = [
+ URLQueryItem(name: "client_id", value: clientId),
+ URLQueryItem(name: "client_secret", value: clientSecret),
+ URLQueryItem(name: "grant_type", value: "refresh_token"),
+ URLQueryItem(name: "refresh_token", value: refresh),
+ ]
+
+ var request = URLRequest(url: URL(string: tokenURL)!)
+ request.httpMethod = "POST"
+ request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
+ request.httpBody = bodyComponents.query?.data(using: .utf8)
+
+ do {
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
+ debugLog("❌ Token refresh failed — user must re-authenticate")
+ clearStoredCredentials()
+ isAuthenticated = false
+ return false
+ }
+
+ let tokenResponse = try JSONDecoder().decode(TwitchTokenResponse.self, from: data)
+ self.accessToken = tokenResponse.access_token
+ self.refreshToken = tokenResponse.refresh_token ?? self.refreshToken
+ saveStoredCredentials(token: tokenResponse.access_token, refreshToken: self.refreshToken)
+ debugLog("✅ Token refreshed successfully")
+ return true
+ } catch {
+ debugLog("❌ Token refresh error: \(error.localizedDescription)")
+ clearStoredCredentials()
+ isAuthenticated = false
+ return false
+ }
+ }
+
// MARK: - Private Methods
private func exchangeCodeForToken(_ code: String) async throws {
@@ -525,9 +599,10 @@ class TwitchAuthClient: ObservableObject {
let tokenResponse = try JSONDecoder().decode(TwitchTokenResponse.self, from: data)
self.accessToken = tokenResponse.access_token
+ self.refreshToken = tokenResponse.refresh_token
- // Save token
- saveStoredCredentials(token: tokenResponse.access_token)
+ // Save tokens
+ saveStoredCredentials(token: tokenResponse.access_token, refreshToken: tokenResponse.refresh_token)
// Fetch user info
try await fetchUserInfo()
@@ -537,41 +612,53 @@ class TwitchAuthClient: ObservableObject {
// MARK: - Keychain Storage
- private func saveStoredCredentials(token: String) {
- // Migrate from UserDefaults to Keychain for better security
+ private func saveStoredCredentials(token: String, refreshToken: String? = nil) {
KeychainManager.save(value: token, service: "com.arkavo.twitch", account: "access_token")
+ if let refreshToken {
+ KeychainManager.save(value: refreshToken, service: "com.arkavo.twitch", account: "refresh_token")
+ }
// Clean up old UserDefaults storage if it exists
UserDefaults.standard.removeObject(forKey: "twitch_access_token")
}
private func loadStoredCredentials() {
+ // Load refresh token from Keychain
+ if let refreshData = try? KeychainManager.load(service: "com.arkavo.twitch", account: "refresh_token"),
+ let refresh = String(data: refreshData, encoding: .utf8) {
+ self.refreshToken = refresh
+ }
+
// Try Keychain first (new method)
if let tokenData = try? KeychainManager.load(service: "com.arkavo.twitch", account: "access_token"),
let token = String(data: tokenData, encoding: .utf8) {
self.accessToken = token
Task {
- do {
- try await fetchUserInfo()
- isAuthenticated = true
- } catch {
- // Token might be expired
- clearStoredCredentials()
+ // Validate token before trusting it; refresh if expired
+ let valid = await ensureValidToken()
+ if valid {
+ do {
+ try await fetchUserInfo()
+ isAuthenticated = true
+ } catch {
+ clearStoredCredentials()
+ }
}
}
}
// Fallback to UserDefaults for existing users (migration path)
else if let token = UserDefaults.standard.string(forKey: "twitch_access_token") {
self.accessToken = token
- // Migrate to Keychain
saveStoredCredentials(token: token)
Task {
- do {
- try await fetchUserInfo()
- isAuthenticated = true
- } catch {
- // Token might be expired
- clearStoredCredentials()
+ let valid = await ensureValidToken()
+ if valid {
+ do {
+ try await fetchUserInfo()
+ isAuthenticated = true
+ } catch {
+ clearStoredCredentials()
+ }
}
}
}
@@ -579,7 +666,9 @@ class TwitchAuthClient: ObservableObject {
private func clearStoredCredentials() {
try? KeychainManager.delete(service: "com.arkavo.twitch", account: "access_token")
+ try? KeychainManager.delete(service: "com.arkavo.twitch", account: "refresh_token")
UserDefaults.standard.removeObject(forKey: "twitch_access_token")
+ self.refreshToken = nil
}
}
diff --git a/ArkavoCreator/ArkavoCreatorUITests/ProducerUITests.swift b/ArkavoCreator/ArkavoCreatorUITests/ProducerUITests.swift
new file mode 100644
index 00000000..0be23785
--- /dev/null
+++ b/ArkavoCreator/ArkavoCreatorUITests/ProducerUITests.swift
@@ -0,0 +1,85 @@
+import XCTest
+
+final class ProducerUITests: XCTestCase {
+ let app = XCUIApplication()
+
+ override func setUpWithError() throws {
+ continueAfterFailure = false
+ app.launch()
+ }
+
+ func testProducerToggleExistsInStudio() throws {
+ // Navigate to Studio
+ let sidebar = app.navigationBars.firstMatch
+ let studioButton = app.buttons["Studio"].firstMatch
+ if studioButton.waitForExistence(timeout: 5) {
+ studioButton.tap()
+ }
+
+ // Look for Producer toggle button
+ let producerToggle = app.buttons["Toggle_Producer"]
+ XCTAssertTrue(producerToggle.waitForExistence(timeout: 5), "Producer panel toggle should exist in Studio")
+ }
+
+ func testProducerPanelOpensAndCloses() throws {
+ // Navigate to Studio
+ let studioButton = app.buttons["Studio"].firstMatch
+ if studioButton.waitForExistence(timeout: 5) {
+ studioButton.tap()
+ }
+
+ let producerToggle = app.buttons["Toggle_Producer"]
+ guard producerToggle.waitForExistence(timeout: 5) else {
+ XCTFail("Producer toggle not found")
+ return
+ }
+
+ // Open
+ producerToggle.tap()
+
+ // Verify panel content appears
+ let producerLabel = app.staticTexts["Producer"]
+ XCTAssertTrue(producerLabel.waitForExistence(timeout: 3), "Producer panel should show 'Producer' label")
+
+ // Close
+ producerToggle.tap()
+ }
+
+ func testStreamHealthSectionVisible() throws {
+ // Navigate to Studio
+ let studioButton = app.buttons["Studio"].firstMatch
+ if studioButton.waitForExistence(timeout: 5) {
+ studioButton.tap()
+ }
+
+ let producerToggle = app.buttons["Toggle_Producer"]
+ guard producerToggle.waitForExistence(timeout: 5) else {
+ XCTFail("Producer toggle not found")
+ return
+ }
+
+ producerToggle.tap()
+
+ let streamHealth = app.staticTexts["Stream Health"]
+ XCTAssertTrue(streamHealth.waitForExistence(timeout: 3), "Stream Health section should be visible")
+ }
+
+ func testQuickActionButtonsExist() throws {
+ // Navigate to Studio
+ let studioButton = app.buttons["Studio"].firstMatch
+ if studioButton.waitForExistence(timeout: 5) {
+ studioButton.tap()
+ }
+
+ let producerToggle = app.buttons["Toggle_Producer"]
+ guard producerToggle.waitForExistence(timeout: 5) else {
+ XCTFail("Producer toggle not found")
+ return
+ }
+
+ producerToggle.tap()
+
+ let quickActions = app.staticTexts["Quick Actions"]
+ XCTAssertTrue(quickActions.waitForExistence(timeout: 3), "Quick Actions section should be visible")
+ }
+}
diff --git a/ArkavoCreator/ArkavoCreatorUITests/PublicistUITests.swift b/ArkavoCreator/ArkavoCreatorUITests/PublicistUITests.swift
new file mode 100644
index 00000000..a400972f
--- /dev/null
+++ b/ArkavoCreator/ArkavoCreatorUITests/PublicistUITests.swift
@@ -0,0 +1,70 @@
+import XCTest
+
+final class PublicistUITests: XCTestCase {
+ let app = XCUIApplication()
+
+ override func setUpWithError() throws {
+ continueAfterFailure = false
+ app.launch()
+ }
+
+ func testPublicistSectionExistsInSidebar() throws {
+ // Look for the Publicist section in sidebar
+ let publicistButton = app.buttons["Publicist"].firstMatch
+ XCTAssertTrue(publicistButton.waitForExistence(timeout: 5), "Publicist section should exist in sidebar")
+ }
+
+ func testNavigateToPublicistView() throws {
+ let publicistButton = app.buttons["Publicist"].firstMatch
+ guard publicistButton.waitForExistence(timeout: 5) else {
+ XCTFail("Publicist button not found")
+ return
+ }
+
+ publicistButton.tap()
+
+ // Platform selector should be visible
+ let platformLabel = app.staticTexts["Platform"]
+ XCTAssertTrue(platformLabel.waitForExistence(timeout: 3), "Platform label should be visible")
+ }
+
+ func testPlatformSelectorVisible() throws {
+ let publicistButton = app.buttons["Publicist"].firstMatch
+ guard publicistButton.waitForExistence(timeout: 5) else {
+ XCTFail("Publicist button not found")
+ return
+ }
+
+ publicistButton.tap()
+
+ // Check that platform buttons exist
+ let blueskyButton = app.buttons["Platform_Bluesky"]
+ XCTAssertTrue(blueskyButton.waitForExistence(timeout: 3), "Bluesky platform button should exist")
+ }
+
+ func testContentTypeButtonsExist() throws {
+ let publicistButton = app.buttons["Publicist"].firstMatch
+ guard publicistButton.waitForExistence(timeout: 5) else {
+ XCTFail("Publicist button not found")
+ return
+ }
+
+ publicistButton.tap()
+
+ let draftButton = app.buttons["ContentType_Draft Post"]
+ XCTAssertTrue(draftButton.waitForExistence(timeout: 3), "Draft Post content type should exist")
+ }
+
+ func testGenerateButtonExists() throws {
+ let publicistButton = app.buttons["Publicist"].firstMatch
+ guard publicistButton.waitForExistence(timeout: 5) else {
+ XCTFail("Publicist button not found")
+ return
+ }
+
+ publicistButton.tap()
+
+ let generateButton = app.buttons["Btn_Generate"]
+ XCTAssertTrue(generateButton.waitForExistence(timeout: 3), "Generate button should exist")
+ }
+}
diff --git a/ArkavoKit/Package.swift b/ArkavoKit/Package.swift
index 8e5970b7..86a0c1e1 100644
--- a/ArkavoKit/Package.swift
+++ b/ArkavoKit/Package.swift
@@ -1,4 +1,4 @@
-// swift-tools-version:6.2
+// swift-tools-version:6.3
import PackageDescription
// Shared Swift settings for all targets - enables unused code warnings
diff --git a/ArkavoKit/Sources/ArkavoMedia/AudioEncoder.swift b/ArkavoKit/Sources/ArkavoMedia/AudioEncoder.swift
index 1379c147..ab9e59f3 100644
--- a/ArkavoKit/Sources/ArkavoMedia/AudioEncoder.swift
+++ b/ArkavoKit/Sources/ArkavoMedia/AudioEncoder.swift
@@ -86,7 +86,12 @@ public final class AudioEncoder: Sendable {
/// - Parameters:
/// - sampleBuffer: PCM audio sample buffer
/// - timestamp: Presentation timestamp
+ nonisolated(unsafe) private var feedCount = 0
public func feed(_ sampleBuffer: CMSampleBuffer) {
+ feedCount += 1
+ if feedCount == 1 || feedCount % 500 == 0 {
+ print("🔊 AudioEncoder.feed() called #\(feedCount), accumulated=\(inputBufferFrameCount)/\(targetFrameCount)")
+ }
// Extract PCM data from CMSampleBuffer
guard let dataBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) else {
print("❌ AudioEncoder: No data buffer")
@@ -190,7 +195,9 @@ public final class AudioEncoder: Sendable {
onFrame?(frame)
- // Removed excessive logging - frame encoding is normal operation
+ if feedCount <= 3 {
+ print("🔊 AudioEncoder: Emitted AAC frame \(aacData.count)B at \(timestamp.seconds)s")
+ }
}
// Reset buffer for next accumulation
diff --git a/ArkavoKit/Sources/ArkavoRecorder/RecordingSession.swift b/ArkavoKit/Sources/ArkavoRecorder/RecordingSession.swift
index 6a0f65d5..9ec7aa43 100644
--- a/ArkavoKit/Sources/ArkavoRecorder/RecordingSession.swift
+++ b/ArkavoKit/Sources/ArkavoRecorder/RecordingSession.swift
@@ -707,11 +707,19 @@ public final class RecordingSession: Sendable {
// MARK: - Streaming
- /// Start streaming to RTMP destination
+ /// Start streaming to a single RTMP destination
public func startStreaming(to destination: RTMPPublisher.Destination, streamKey: String) async throws {
try await encoder.startStreaming(to: destination, streamKey: streamKey)
_streamingActive.withLock { $0 = true }
- // Start frame generation if not already recording
+ if !_isRecording {
+ startStreamingFrameGeneration()
+ }
+ }
+
+ /// Start streaming to multiple RTMP destinations simultaneously (simulcast)
+ public func startStreaming(destinations: [(id: String, destination: RTMPPublisher.Destination, streamKey: String)]) async throws {
+ try await encoder.startStreaming(to: destinations)
+ _streamingActive.withLock { $0 = true }
if !_isRecording {
startStreamingFrameGeneration()
}
@@ -731,13 +739,22 @@ public final class RecordingSession: Sendable {
}
}
- /// Stop streaming
+ /// Stop streaming to all destinations
public func stopStreaming() async {
_streamingActive.withLock { $0 = false }
await encoder.stopStreaming()
await encoder.stopNTDFStreaming()
}
+ /// Stop streaming to a single destination (others continue)
+ public func stopStreaming(id: String) async {
+ await encoder.stopStreaming(id: id)
+ // If no destinations remain, deactivate streaming
+ if await encoder.activeDestinationIds.isEmpty {
+ _streamingActive.withLock { $0 = false }
+ }
+ }
+
/// Get streaming statistics
public var streamStatistics: RTMPPublisher.StreamStatistics? {
get async {
diff --git a/ArkavoKit/Sources/ArkavoRecorder/VideoEncoder.swift b/ArkavoKit/Sources/ArkavoRecorder/VideoEncoder.swift
index f461979d..6affb9d4 100644
--- a/ArkavoKit/Sources/ArkavoRecorder/VideoEncoder.swift
+++ b/ArkavoKit/Sources/ArkavoRecorder/VideoEncoder.swift
@@ -32,22 +32,30 @@ public actor VideoEncoder {
public private(set) var isRecording: Bool = false
- // Streaming support
- private var rtmpPublisher: RTMPPublisher?
+ // Streaming support — per-destination state for simulcast fan-out
+ struct StreamDestination {
+ let id: String
+ let publisher: RTMPPublisher
+ var videoSendTask: Task?
+ var audioSendTask: Task?
+ var videoContinuation: AsyncStream.Continuation?
+ var audioContinuation: AsyncStream.Continuation?
+ var sentVideoSequenceHeader: Bool = false
+ var sentAudioSequenceHeader: Bool = false
+ }
+ private var streamDestinations: [String: StreamDestination] = [:]
private var ntdfStreamingManager: NTDFStreamingManager?
- private var isStreaming: Bool = false
private var isNTDFStreaming: Bool = false
- /// Whether any streaming (regular RTMP or NTDF) is active — used by RecordingSession
- /// to gate frame generation when streaming without recording
+ /// Whether any streaming (regular RTMP or NTDF) is active
+ private var isStreaming: Bool { !streamDestinations.isEmpty }
public var isStreamingActive: Bool { isStreaming || isNTDFStreaming }
+
private var videoFormatDescription: CMFormatDescription?
private var audioFormatDescription: CMFormatDescription?
- private var sentVideoSequenceHeader: Bool = false
- private var sentAudioSequenceHeader: Bool = false
- private var streamStartTime: CMTime? // Stream start time for relative timestamps
- private var lastStreamVideoTimestamp: CMTime = .zero // Last video timestamp sent to stream
- private var lastStreamAudioTimestamp: CMTime = .zero // Last audio timestamp sent to stream
+ private var streamStartTime: CMTime?
+ private var lastStreamVideoTimestamp: CMTime = .zero
+ private var lastStreamAudioTimestamp: CMTime = .zero
// Encoding settings - adaptive based on system capabilities
private let videoWidth: Int
@@ -621,172 +629,308 @@ public actor VideoEncoder {
// MARK: - Streaming Methods
- // Frame queue continuations for serialized sending
- private var videoFrameContinuation: AsyncStream.Continuation?
- private var audioFrameContinuation: AsyncStream.Continuation?
- private var videoSendTask: Task?
- private var audioSendTask: Task?
+ // Shared frame queue (not per-destination — destinations get their own via fan-out)
+ private var silentAudioTask: Task?
- /// Start streaming to RTMP destination(s) while recording
+ /// Start streaming to one RTMP destination (convenience wrapper)
public func startStreaming(to destination: RTMPPublisher.Destination, streamKey: String) async throws {
- guard !isStreaming else {
+ try await startStreaming(to: [(id: destination.platform, destination: destination, streamKey: streamKey)])
+ }
+
+ /// Start streaming to multiple RTMP destinations simultaneously (simulcast)
+ public func startStreaming(to destinations: [(id: String, destination: RTMPPublisher.Destination, streamKey: String)]) async throws {
+ guard streamDestinations.isEmpty else {
print("⚠️ Already streaming")
return
}
- print("📡 Starting RTMP stream...")
-
- let publisher = RTMPPublisher()
- try await publisher.connect(to: destination, streamKey: streamKey)
-
- // Send stream metadata (@setDataFrame onMetaData) immediately after connect
- // sendMetadata/FLVMuxer expect values in bits/sec and convert to kbps internally
- try await publisher.sendMetadata(
- width: videoWidth,
- height: videoHeight,
- framerate: Double(frameRate),
- videoBitrate: Double(videoBitrate),
- audioBitrate: 128_000
- )
+ print("📡 Starting RTMP stream to \(destinations.count) destination(s)...")
- // Create video encoder
+ // Create shared media encoders (encode once, fan out to all destinations)
let videoEncoder = ArkavoMedia.VideoEncoder(quality: .auto)
try videoEncoder.start()
-
- // Create audio encoder
let audioEncoder = try ArkavoMedia.AudioEncoder(bitrate: 128_000)
- // Create AsyncStreams to serialize frame sending (prevents burst/out-of-order issues)
- let (videoStream, videoContinuation) = AsyncStream.makeStream()
- let (audioStream, audioContinuation) = AsyncStream.makeStream()
- self.videoFrameContinuation = videoContinuation
- self.audioFrameContinuation = audioContinuation
-
- // Wire up video encoder callback - just queue frames
- // Capture continuation locally to avoid actor isolation issues
- let videoCont = videoContinuation
- videoEncoder.onFrame = { frame in
- videoCont.yield(frame)
- }
+ // Connect all destinations in parallel
+ try await withThrowingTaskGroup(of: StreamDestination.self) { group in
+ for dest in destinations {
+ group.addTask {
+ let publisher = RTMPPublisher()
+ try await publisher.connect(to: dest.destination, streamKey: dest.streamKey)
+ try await publisher.sendMetadata(
+ width: self.videoWidth,
+ height: self.videoHeight,
+ framerate: Double(self.frameRate),
+ videoBitrate: Double(self.videoBitrate),
+ audioBitrate: 128_000
+ )
+ print("✅ [\(dest.id)] RTMP connected")
+ return StreamDestination(id: dest.id, publisher: publisher)
+ }
+ }
- // Wire up audio encoder callback - just queue frames
- let audioCont = audioContinuation
- audioEncoder.onFrame = { frame in
- audioCont.yield(frame)
+ for try await dest in group {
+ streamDestinations[dest.id] = dest
+ }
}
- // Start video send task - serializes frame sending
- // Frames arrive from camera at realtime pace, so we just need to send them in order
- // without additional pacing (the camera/encoder already gates the frame rate)
- videoSendTask = Task { [weak self, weak publisher] in
- for await frame in videoStream {
- guard let self = self, let publisher = publisher else { break }
- guard !Task.isCancelled else { break }
+ // Create per-destination AsyncStreams and send tasks
+ for id in streamDestinations.keys {
+ guard var dest = streamDestinations[id] else { continue }
- do {
- // Send sequence header ONLY ONCE on first keyframe
- let needsHeader = await self.shouldSendVideoSequenceHeader()
- if frame.isKeyframe, needsHeader, let formatDesc = frame.formatDescription {
- try await publisher.sendVideoSequenceHeader(formatDescription: formatDesc)
- await self.markVideoSequenceHeaderSent()
- print("✅ Sent video sequence header (ONCE)")
+ let (videoStream, videoCont) = AsyncStream.makeStream(
+ bufferingPolicy: .bufferingNewest(30)
+ )
+ let (audioStream, audioCont) = AsyncStream.makeStream(
+ bufferingPolicy: .bufferingNewest(30)
+ )
+ dest.videoContinuation = videoCont
+ dest.audioContinuation = audioCont
+
+ // Per-destination video send task with frame rate limiting
+ let publisher = dest.publisher
+ let destId = id
+ let targetInterval: Double = 1.0 / Double(self.frameRate) // ~33ms for 30fps
+ dest.videoSendTask = Task { [weak self] in
+ var lastSendTime: ContinuousClock.Instant? = nil
+ var frameCount: UInt64 = 0
+ for await frame in videoStream {
+ guard let self = self else { break }
+ guard !Task.isCancelled else { break }
+
+ // Rate limit: skip frames that arrive faster than target fps
+ let now = ContinuousClock.now
+ if let last = lastSendTime {
+ let elapsed = now - last
+ if elapsed < .milliseconds(Int(targetInterval * 900)) && !frame.isKeyframe {
+ continue // Drop frame — too fast
+ }
}
- // Send video frame immediately - frames arrive at realtime from camera
- try await publisher.send(video: frame)
- } catch is CancellationError {
- break
- } catch {
- print("❌ Failed to send video frame: \(error)")
+ do {
+ let needsHeader = await self.shouldSendVideoHeader(for: destId)
+ if frame.isKeyframe, needsHeader, let formatDesc = frame.formatDescription {
+ try await publisher.sendVideoSequenceHeader(formatDescription: formatDesc)
+ await self.markVideoHeaderSent(for: destId)
+ print("✅ [\(destId)] Sent video sequence header")
+ }
+ try await publisher.send(video: frame)
+ lastSendTime = now
+ frameCount += 1
+ if frameCount == 1 || frameCount % 900 == 0 {
+ print("📤 [\(destId)] video #\(frameCount)")
+ }
+ } catch is CancellationError {
+ break
+ } catch {
+ print("❌ [\(destId)] Video send error: \(error.localizedDescription)")
+ }
}
}
- }
-
- // Start audio send task - serializes frame sending
- // Audio frames arrive from encoder at realtime pace
- audioSendTask = Task { [weak self, weak publisher] in
- for await frame in audioStream {
- guard let self = self, let publisher = publisher else { break }
- guard !Task.isCancelled else { break }
- do {
- // Send sequence header ONLY ONCE on first frame
- let needsHeader = await self.shouldSendAudioSequenceHeader()
- if needsHeader, let formatDesc = frame.formatDescription {
- // Extract AudioSpecificConfig from format description
- var asc = Data()
- var size: Int = 0
- if let cookie = CMAudioFormatDescriptionGetMagicCookie(formatDesc, sizeOut: &size), size > 0 {
- asc = Data(bytes: cookie, count: size)
- } else {
- // Manual ASC construction for AAC-LC 48kHz stereo
- let byte1: UInt8 = 0x11 // (2<<3)|(3>>1) = AAC-LC, 48kHz
- let byte2: UInt8 = 0x90 // ((3&1)<<7)|(2<<3) = 48kHz, stereo
- asc = Data([byte1, byte2])
+ // Per-destination audio send task
+ dest.audioSendTask = Task { [weak self] in
+ var audioFrameCount: UInt64 = 0
+ for await frame in audioStream {
+ guard let self = self else { break }
+ guard !Task.isCancelled else { break }
+ audioFrameCount += 1
+ if audioFrameCount == 1 || audioFrameCount % 500 == 0 {
+ print("🔊 [\(destId)] audio #\(audioFrameCount) (\(frame.data.count)B)")
+ }
+ do {
+ let needsHeader = await self.shouldSendAudioHeader(for: destId)
+ if needsHeader, let formatDesc = frame.formatDescription {
+ var asc = Data()
+ var size: Int = 0
+ if let cookie = CMAudioFormatDescriptionGetMagicCookie(formatDesc, sizeOut: &size), size > 0 {
+ asc = Data(bytes: cookie, count: size)
+ } else {
+ let byte1: UInt8 = 0x11
+ let byte2: UInt8 = 0x90
+ asc = Data([byte1, byte2])
+ }
+ try await publisher.sendAudioSequenceHeader(asc: asc)
+ await self.markAudioHeaderSent(for: destId)
+ print("✅ [\(destId)] Sent audio sequence header")
}
-
- try await publisher.sendAudioSequenceHeader(asc: asc)
- await self.markAudioSequenceHeaderSent()
- print("✅ Sent audio sequence header (ONCE)")
+ try await publisher.send(audio: frame)
+ } catch is CancellationError {
+ break
+ } catch {
+ print("❌ [\(destId)] Audio send error: \(error.localizedDescription)")
}
-
- // Send audio frame immediately - frames arrive at realtime from encoder
- try await publisher.send(audio: frame)
- } catch is CancellationError {
- break
- } catch {
- print("❌ Failed to send audio frame: \(error)")
}
}
+
+ streamDestinations[id] = dest
+ }
+
+ // Capture all continuations locally for the fan-out closures
+ // (onFrame is nonisolated, can't access actor-isolated streamDestinations)
+ let videoConts = streamDestinations.values.compactMap { $0.videoContinuation }
+ let audioConts = streamDestinations.values.compactMap { $0.audioContinuation }
+
+ videoEncoder.onFrame = { frame in
+ for cont in videoConts { cont.yield(frame) }
+ }
+ audioEncoder.onFrame = { frame in
+ for cont in audioConts { cont.yield(frame) }
}
streamVideoEncoder = videoEncoder
streamAudioEncoder = audioEncoder
- rtmpPublisher = publisher
- isStreaming = true
- sentVideoSequenceHeader = false
- sentAudioSequenceHeader = false
streamStartTime = startTime ?? CMClockGetTime(CMClockGetHostTimeClock())
lastStreamVideoTimestamp = .zero
lastStreamAudioTimestamp = .zero
- print("✅ RTMP stream started with video and audio encoding")
+ startSilentAudioGenerator(encoder: audioEncoder)
+
+ print("✅ RTMP stream started to \(streamDestinations.count) destination(s)")
}
- /// Stop streaming
+ /// Stop streaming to all destinations
public func stopStreaming() async {
- guard isStreaming, let publisher = rtmpPublisher else { return }
-
- print("📡 Stopping RTMP stream...")
+ guard isStreaming else { return }
- // Finish the frame queues first
- videoFrameContinuation?.finish()
- audioFrameContinuation?.finish()
- videoFrameContinuation = nil
- audioFrameContinuation = nil
+ print("📡 Stopping RTMP stream (\(streamDestinations.count) destination(s))...")
- // Wait for send tasks to complete
- videoSendTask?.cancel()
- audioSendTask?.cancel()
- videoSendTask = nil
- audioSendTask = nil
+ silentAudioTask?.cancel()
+ silentAudioTask = nil
- await publisher.disconnect()
+ // Tear down all destinations
+ for (id, dest) in streamDestinations {
+ dest.videoContinuation?.finish()
+ dest.audioContinuation?.finish()
+ dest.videoSendTask?.cancel()
+ dest.audioSendTask?.cancel()
+ await dest.publisher.disconnect()
+ print("📡 [\(id)] Disconnected")
+ }
+ streamDestinations.removeAll()
- // Stop encoders
streamVideoEncoder?.stop()
streamAudioEncoder = nil
streamVideoEncoder = nil
-
- rtmpPublisher = nil
- isStreaming = false
- sentVideoSequenceHeader = false
- sentAudioSequenceHeader = false
streamStartTime = nil
print("✅ RTMP stream stopped")
}
+ /// Stop streaming to a single destination (others continue)
+ public func stopStreaming(id: String) async {
+ guard var dest = streamDestinations.removeValue(forKey: id) else { return }
+
+ dest.videoContinuation?.finish()
+ dest.audioContinuation?.finish()
+ dest.videoSendTask?.cancel()
+ dest.audioSendTask?.cancel()
+ await dest.publisher.disconnect()
+ print("📡 [\(id)] Disconnected (remaining: \(streamDestinations.count))")
+
+ // If no destinations left, clean up shared state
+ if streamDestinations.isEmpty {
+ silentAudioTask?.cancel()
+ silentAudioTask = nil
+ streamVideoEncoder?.stop()
+ streamAudioEncoder = nil
+ streamVideoEncoder = nil
+ streamStartTime = nil
+ print("✅ All RTMP streams stopped")
+ }
+ }
+
+ /// Generates silent PCM audio and feeds it to the audio encoder.
+ /// Ensures the RTMP stream always has an audio track (required by YouTube).
+ /// Real audio from mic/mixer will supplement this; the silent frames
+ /// act as a fallback when no audio source is active.
+ private func startSilentAudioGenerator(encoder: ArkavoMedia.AudioEncoder) {
+ silentAudioTask = Task { [weak self] in
+ // 48kHz stereo Int16 PCM, 1024 frames per AAC packet
+ let sampleRate: Double = 48000
+ let channels: UInt32 = 2
+ let framesPerPacket: Int = 1024
+ let bytesPerFrame = Int(channels) * MemoryLayout.size
+ let bufferSize = framesPerPacket * bytesPerFrame
+ let silentData = Data(count: bufferSize) // all zeros = silence
+ let interval = Double(framesPerPacket) / sampleRate // ~21.3ms
+
+ var sampleTime: Double = 0
+
+ while !Task.isCancelled {
+ guard let self = self, await self.isStreaming else { break }
+
+ // Always generate silent audio as fallback
+ if true {
+ // Create a CMSampleBuffer with silent PCM data
+ var formatDesc: CMAudioFormatDescription?
+ var asbd = AudioStreamBasicDescription(
+ mSampleRate: sampleRate,
+ mFormatID: kAudioFormatLinearPCM,
+ mFormatFlags: kLinearPCMFormatFlagIsSignedInteger | kLinearPCMFormatFlagIsPacked,
+ mBytesPerPacket: UInt32(bytesPerFrame),
+ mFramesPerPacket: 1,
+ mBytesPerFrame: UInt32(bytesPerFrame),
+ mChannelsPerFrame: channels,
+ mBitsPerChannel: 16,
+ mReserved: 0
+ )
+ CMAudioFormatDescriptionCreate(
+ allocator: kCFAllocatorDefault,
+ asbd: &asbd,
+ layoutSize: 0,
+ layout: nil,
+ magicCookieSize: 0,
+ magicCookie: nil,
+ extensions: nil,
+ formatDescriptionOut: &formatDesc
+ )
+
+ if let formatDesc = formatDesc {
+ var blockBuffer: CMBlockBuffer?
+ silentData.withUnsafeBytes { rawPtr in
+ let ptr = UnsafeMutableRawPointer(mutating: rawPtr.baseAddress!)
+ CMBlockBufferCreateWithMemoryBlock(
+ allocator: kCFAllocatorDefault,
+ memoryBlock: ptr,
+ blockLength: bufferSize,
+ blockAllocator: kCFAllocatorNull, // we manage the memory
+ customBlockSource: nil,
+ offsetToData: 0,
+ dataLength: bufferSize,
+ flags: 0,
+ blockBufferOut: &blockBuffer
+ )
+ }
+
+ if let blockBuffer = blockBuffer {
+ let pts = CMTime(seconds: sampleTime, preferredTimescale: CMTimeScale(sampleRate))
+ var sampleBuffer: CMSampleBuffer?
+ CMAudioSampleBufferCreateReadyWithPacketDescriptions(
+ allocator: kCFAllocatorDefault,
+ dataBuffer: blockBuffer,
+ formatDescription: formatDesc,
+ sampleCount: framesPerPacket,
+ presentationTimeStamp: pts,
+ packetDescriptions: nil,
+ sampleBufferOut: &sampleBuffer
+ )
+
+ if let sampleBuffer = sampleBuffer {
+ encoder.feed(sampleBuffer)
+ }
+ }
+ }
+
+ sampleTime += interval
+ }
+
+ try? await Task.sleep(for: .milliseconds(Int(interval * 1000)))
+ }
+ }
+ print("🔇 Silent audio generator started (fallback for YouTube)")
+ }
+
// MARK: - NTDF Streaming Methods
/// Start NTDF-encrypted streaming to Arkavo
@@ -824,40 +968,32 @@ public actor VideoEncoder {
// Create audio encoder
let audioEncoder = try ArkavoMedia.AudioEncoder(bitrate: audioBitrate)
- // Create AsyncStreams to serialize frame sending
- let (videoStream, videoContinuation) = AsyncStream.makeStream()
- let (audioStream, audioContinuation) = AsyncStream.makeStream()
- self.videoFrameContinuation = videoContinuation
- self.audioFrameContinuation = audioContinuation
+ // NTDF uses its own dedicated frame queues (not part of simulcast fan-out)
+ let (videoStream, ntdfVideoCont) = AsyncStream.makeStream()
+ let (audioStream, ntdfAudioCont) = AsyncStream.makeStream()
+ ntdfVideoContinuation = ntdfVideoCont
+ ntdfAudioContinuation = ntdfAudioCont
- // Wire up video encoder callback
- let videoCont = videoContinuation
videoEncoder.onFrame = { frame in
- videoCont.yield(frame)
+ ntdfVideoCont.yield(frame)
}
-
- // Wire up audio encoder callback
- let audioCont = audioContinuation
audioEncoder.onFrame = { frame in
- audioCont.yield(frame)
+ ntdfAudioCont.yield(frame)
}
- // Start video send task with encryption
- videoSendTask = Task { [weak self, weak manager] in
+ var ntdfSentVideoHeader = false
+ var ntdfSentAudioHeader = false
+
+ ntdfVideoSendTask = Task { [weak manager] in
for await frame in videoStream {
- guard let self = self, let manager = manager else { break }
+ guard let manager = manager else { break }
guard !Task.isCancelled else { break }
-
do {
- // Send sequence header ONLY ONCE on first keyframe (unencrypted)
- let needsHeader = await self.shouldSendVideoSequenceHeader()
- if frame.isKeyframe, needsHeader, let formatDesc = frame.formatDescription {
+ if frame.isKeyframe, !ntdfSentVideoHeader, let formatDesc = frame.formatDescription {
try await manager.sendVideoSequenceHeader(formatDescription: formatDesc)
- await self.markVideoSequenceHeaderSent()
+ ntdfSentVideoHeader = true
print("✅ Sent video sequence header (ONCE)")
}
-
- // Send encrypted video frame
try await manager.sendEncryptedVideo(frame: frame)
} catch is CancellationError {
break
@@ -867,32 +1003,23 @@ public actor VideoEncoder {
}
}
- // Start audio send task with encryption
- audioSendTask = Task { [weak self, weak manager] in
+ ntdfAudioSendTask = Task { [weak manager] in
for await frame in audioStream {
- guard let self = self, let manager = manager else { break }
+ guard let manager = manager else { break }
guard !Task.isCancelled else { break }
-
do {
- // Send sequence header ONLY ONCE on first frame (unencrypted)
- let needsHeader = await self.shouldSendAudioSequenceHeader()
- if needsHeader, let formatDesc = frame.formatDescription {
+ if !ntdfSentAudioHeader, let formatDesc = frame.formatDescription {
var asc = Data()
var size: Int = 0
if let cookie = CMAudioFormatDescriptionGetMagicCookie(formatDesc, sizeOut: &size), size > 0 {
asc = Data(bytes: cookie, count: size)
} else {
- let byte1: UInt8 = 0x11
- let byte2: UInt8 = 0x90
- asc = Data([byte1, byte2])
+ asc = Data([0x11, 0x90])
}
-
try await manager.sendAudioSequenceHeader(asc: asc)
- await self.markAudioSequenceHeaderSent()
+ ntdfSentAudioHeader = true
print("✅ Sent audio sequence header (ONCE)")
}
-
- // Send encrypted audio frame
try await manager.sendEncryptedAudio(frame: frame)
} catch is CancellationError {
break
@@ -906,8 +1033,6 @@ public actor VideoEncoder {
streamAudioEncoder = audioEncoder
ntdfStreamingManager = manager
isNTDFStreaming = true
- sentVideoSequenceHeader = false
- sentAudioSequenceHeader = false
streamStartTime = startTime ?? CMClockGetTime(CMClockGetHostTimeClock())
lastStreamVideoTimestamp = .zero
lastStreamAudioTimestamp = .zero
@@ -915,35 +1040,36 @@ public actor VideoEncoder {
print("✅ NTDF-encrypted stream started")
}
+ // NTDF-specific frame queue state
+ private var ntdfVideoContinuation: AsyncStream.Continuation?
+ private var ntdfAudioContinuation: AsyncStream.Continuation?
+ private var ntdfVideoSendTask: Task?
+ private var ntdfAudioSendTask: Task?
+
/// Stop NTDF streaming
public func stopNTDFStreaming() async {
guard isNTDFStreaming, let manager = ntdfStreamingManager else { return }
print("🔐 Stopping NTDF stream...")
- // Finish the frame queues first
- videoFrameContinuation?.finish()
- audioFrameContinuation?.finish()
- videoFrameContinuation = nil
- audioFrameContinuation = nil
+ ntdfVideoContinuation?.finish()
+ ntdfAudioContinuation?.finish()
+ ntdfVideoContinuation = nil
+ ntdfAudioContinuation = nil
- // Wait for send tasks to complete
- videoSendTask?.cancel()
- audioSendTask?.cancel()
- videoSendTask = nil
- audioSendTask = nil
+ ntdfVideoSendTask?.cancel()
+ ntdfAudioSendTask?.cancel()
+ ntdfVideoSendTask = nil
+ ntdfAudioSendTask = nil
await manager.disconnect()
- // Stop encoders
streamVideoEncoder?.stop()
streamAudioEncoder = nil
streamVideoEncoder = nil
ntdfStreamingManager = nil
isNTDFStreaming = false
- sentVideoSequenceHeader = false
- sentAudioSequenceHeader = false
streamStartTime = nil
print("✅ NTDF stream stopped")
@@ -952,8 +1078,9 @@ public actor VideoEncoder {
/// Get streaming statistics
public var streamStatistics: RTMPPublisher.StreamStatistics? {
get async {
- if let publisher = rtmpPublisher {
- return await publisher.statistics
+ // Return stats from first active destination
+ if let firstDest = streamDestinations.values.first {
+ return await firstDest.publisher.statistics
}
if let manager = ntdfStreamingManager {
return await manager.statistics
@@ -962,26 +1089,33 @@ public actor VideoEncoder {
}
}
- // MARK: - Sequence Header State Helpers (for actor-safe callback access)
+ /// Get statistics for a specific destination
+ public func streamStatistics(for id: String) async -> RTMPPublisher.StreamStatistics? {
+ guard let dest = streamDestinations[id] else { return nil }
+ return await dest.publisher.statistics
+ }
+
+ /// IDs of all active streaming destinations
+ public var activeDestinationIds: [String] {
+ Array(streamDestinations.keys)
+ }
+
+ // MARK: - Per-Destination Sequence Header State
- /// Returns true if video sequence header has not yet been sent
- private func shouldSendVideoSequenceHeader() -> Bool {
- !sentVideoSequenceHeader
+ private func shouldSendVideoHeader(for id: String) -> Bool {
+ !(streamDestinations[id]?.sentVideoSequenceHeader ?? true)
}
- /// Marks video sequence header as sent
- private func markVideoSequenceHeaderSent() {
- sentVideoSequenceHeader = true
+ private func markVideoHeaderSent(for id: String) {
+ streamDestinations[id]?.sentVideoSequenceHeader = true
}
- /// Returns true if audio sequence header has not yet been sent
- private func shouldSendAudioSequenceHeader() -> Bool {
- !sentAudioSequenceHeader
+ private func shouldSendAudioHeader(for id: String) -> Bool {
+ !(streamDestinations[id]?.sentAudioSequenceHeader ?? true)
}
- /// Marks audio sequence header as sent
- private func markAudioSequenceHeaderSent() {
- sentAudioSequenceHeader = true
+ private func markAudioHeaderSent(for id: String) {
+ streamDestinations[id]?.sentAudioSequenceHeader = true
}
// MARK: - VTCompressionSession Setup
diff --git a/ArkavoKit/Sources/ArkavoSocial/YouTubeClient.swift b/ArkavoKit/Sources/ArkavoSocial/YouTubeClient.swift
index 026cb9ff..13e7d0cd 100644
--- a/ArkavoKit/Sources/ArkavoSocial/YouTubeClient.swift
+++ b/ArkavoKit/Sources/ArkavoSocial/YouTubeClient.swift
@@ -130,7 +130,7 @@ public actor YouTubeClient: ObservableObject {
URLQueryItem(name: "client_id", value: clientId),
URLQueryItem(name: "redirect_uri", value: redirectUri),
URLQueryItem(name: "response_type", value: "code"),
- URLQueryItem(name: "scope", value: "https://www.googleapis.com/auth/youtube.readonly https://www.googleapis.com/auth/youtube.upload https://www.googleapis.com/auth/youtube.force-ssl"),
+ URLQueryItem(name: "scope", value: "https://www.googleapis.com/auth/youtube https://www.googleapis.com/auth/youtube.force-ssl"),
URLQueryItem(name: "access_type", value: "offline"),
URLQueryItem(name: "state", value: state),
URLQueryItem(name: "code_challenge", value: challenge),
@@ -499,10 +499,264 @@ public actor YouTubeClient: ObservableObject {
throw YouTubeError.httpError(statusCode: httpResponse.statusCode)
}
}
+
+ // MARK: - Broadcast Lifecycle
+
+ /// Creates a broadcast, binds it to a stream, and returns the broadcast ID.
+ /// Call this before starting RTMP streaming to YouTube.
+ public func createAndBindBroadcast(title: String) async throws -> String {
+ // 1. Get or create a live stream
+ let url = URL(string: "https://www.googleapis.com/youtube/v3/liveStreams?part=cdn,snippet&mine=true")!
+ let listRequest = try await makeAuthorizedRequest(url: url)
+ let (listData, listResponse) = try await URLSession.shared.data(for: listRequest)
+
+ guard let listHttp = listResponse as? HTTPURLResponse, listHttp.statusCode == 200 else {
+ throw YouTubeError.googleError("Failed to list live streams")
+ }
+
+ let streamResponse = try JSONDecoder().decode(YouTubeLiveStreamResponse.self, from: listData)
+ let streamId: String
+ if let existing = streamResponse.items.first {
+ streamId = existing.id
+ } else {
+ streamId = try await createLiveStreamAndReturnId()
+ }
+
+ // 2. Create a broadcast
+ let broadcastURL = URL(string: "https://www.googleapis.com/youtube/v3/liveBroadcasts?part=snippet,contentDetails,status")!
+ var broadcastRequest = try await makeAuthorizedRequest(url: broadcastURL)
+ broadcastRequest.httpMethod = "POST"
+
+ let now = ISO8601DateFormatter().string(from: Date().addingTimeInterval(10))
+ let broadcastBody: [String: Any] = [
+ "snippet": [
+ "title": title.isEmpty ? "Arkavo Creator Live" : title,
+ "scheduledStartTime": now
+ ],
+ "contentDetails": [
+ "enableAutoStart": false,
+ "enableAutoStop": true
+ ],
+ "status": [
+ "privacyStatus": "public"
+ ]
+ ]
+ broadcastRequest.httpBody = try JSONSerialization.data(withJSONObject: broadcastBody)
+
+ let (broadcastData, broadcastResponse) = try await URLSession.shared.data(for: broadcastRequest)
+ guard let broadcastHttp = broadcastResponse as? HTTPURLResponse,
+ (200...201).contains(broadcastHttp.statusCode) else {
+ if let errorResponse = try? JSONDecoder().decode(GoogleErrorResponse.self, from: broadcastData) {
+ throw YouTubeError.googleError("Broadcast creation failed: \(errorResponse.error_description ?? errorResponse.error)")
+ }
+ let code = (broadcastResponse as? HTTPURLResponse)?.statusCode ?? 0
+ throw YouTubeError.googleError("Broadcast creation failed (HTTP \(code))")
+ }
+
+ let broadcast = try JSONDecoder().decode(YouTubeBroadcastResponse.self, from: broadcastData)
+ let broadcastId = broadcast.id
+
+ // 3. Bind the stream to the broadcast
+ let bindURL = URL(string: "https://www.googleapis.com/youtube/v3/liveBroadcasts/bind?id=\(broadcastId)&part=id,contentDetails&streamId=\(streamId)")!
+ var bindRequest = try await makeAuthorizedRequest(url: bindURL)
+ bindRequest.httpMethod = "POST"
+ bindRequest.httpBody = Data() // empty body required
+
+ let (_, bindResponse) = try await URLSession.shared.data(for: bindRequest)
+ guard let bindHttp = bindResponse as? HTTPURLResponse, bindHttp.statusCode == 200 else {
+ throw YouTubeError.googleError("Failed to bind stream to broadcast")
+ }
+
+ return broadcastId
+ }
+
+ /// Check the current lifecycle status of a broadcast
+ public func getBroadcastStatus(broadcastId: String) async throws -> String {
+ let url = URL(string: "https://www.googleapis.com/youtube/v3/liveBroadcasts?id=\(broadcastId)&part=status")!
+ let request = try await makeAuthorizedRequest(url: url)
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
+ throw YouTubeError.googleError("Failed to get broadcast status")
+ }
+
+ struct BroadcastListResponse: Codable {
+ let items: [YouTubeBroadcastResponse]
+ }
+ let listResponse = try JSONDecoder().decode(BroadcastListResponse.self, from: data)
+ return listResponse.items.first?.status?.lifeCycleStatus ?? "unknown"
+ }
+
+ /// Transitions a broadcast to "live" status via testing → live.
+ /// Waits for the broadcast to reach "ready" state first.
+ public func transitionBroadcastToLive(broadcastId: String) async throws {
+ // Wait for broadcast to reach "ready" state (YouTube verifies the stream)
+ for i in 1...12 {
+ let status = try await getBroadcastStatus(broadcastId: broadcastId)
+ print("[YouTubeClient] Broadcast status: \(status) (check \(i)/12)")
+ if status == "ready" || status == "testing" || status == "live" {
+ break
+ }
+ if i == 12 {
+ throw YouTubeError.googleError("Broadcast stuck in '\(status)' state. YouTube may not be receiving audio+video.")
+ }
+ try await Task.sleep(for: .seconds(5))
+ }
+
+ // Transition: ready → testing
+ let currentStatus = try await getBroadcastStatus(broadcastId: broadcastId)
+ if currentStatus == "ready" {
+ try await transitionBroadcast(broadcastId: broadcastId, to: "testing")
+ // Wait for testing state to be confirmed
+ try await Task.sleep(for: .seconds(5))
+ }
+
+ // Transition: testing → live (skip if already live)
+ let afterTesting = try await getBroadcastStatus(broadcastId: broadcastId)
+ if afterTesting == "testing" {
+ try await transitionBroadcast(broadcastId: broadcastId, to: "live")
+ }
+ }
+
+ /// Transition a broadcast to a specific status
+ private func transitionBroadcast(broadcastId: String, to status: String) async throws {
+ let url = URL(string: "https://www.googleapis.com/youtube/v3/liveBroadcasts/transition?broadcastStatus=\(status)&id=\(broadcastId)&part=status")!
+ var request = try await makeAuthorizedRequest(url: url)
+ request.httpMethod = "POST"
+ request.httpBody = Data()
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse else {
+ throw YouTubeError.invalidResponse
+ }
+
+ // Log the full response for debugging
+ if httpResponse.statusCode != 200 {
+ let body = String(data: data, encoding: .utf8) ?? "no body"
+ print("[YouTubeClient] Transition to '\(status)' failed (HTTP \(httpResponse.statusCode)): \(body)")
+ }
+
+ // 412 means stream isn't active yet — caller should retry
+ if httpResponse.statusCode == 412 {
+ throw YouTubeError.googleError("Stream not active yet for '\(status)' transition.")
+ }
+
+ guard httpResponse.statusCode == 200 else {
+ // Parse YouTube API v3 error format
+ if let apiError = try? JSONDecoder().decode(YouTubeAPIError.self, from: data),
+ let reason = apiError.error.errors.first?.reason {
+ throw YouTubeError.googleError("Transition to '\(status)': \(reason) - \(apiError.error.message)")
+ }
+ if let errorResponse = try? JSONDecoder().decode(GoogleErrorResponse.self, from: data) {
+ throw YouTubeError.googleError("Transition to '\(status)': \(errorResponse.error_description ?? errorResponse.error)")
+ }
+ throw YouTubeError.httpError(statusCode: httpResponse.statusCode)
+ }
+
+ print("[YouTubeClient] Broadcast transitioned to '\(status)'")
+ }
+
+ /// Ends a broadcast by transitioning to "complete".
+ public func endBroadcast(broadcastId: String) async throws {
+ let url = URL(string: "https://www.googleapis.com/youtube/v3/liveBroadcasts/transition?broadcastStatus=complete&id=\(broadcastId)&part=status")!
+ var request = try await makeAuthorizedRequest(url: url)
+ request.httpMethod = "POST"
+ request.httpBody = Data()
+
+ let (_, response) = try await URLSession.shared.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
+ // Best-effort — don't throw on end
+ return
+ }
+ }
+
+ /// Fetches the liveChatId for a broadcast
+ public func getLiveChatId(broadcastId: String) async throws -> String? {
+ let url = URL(string: "https://www.googleapis.com/youtube/v3/liveBroadcasts?id=\(broadcastId)&part=snippet")!
+ let request = try await makeAuthorizedRequest(url: url)
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
+ return nil
+ }
+
+ struct BroadcastListResponse: Codable {
+ let items: [YouTubeBroadcastResponse]
+ }
+ let listResponse = try JSONDecoder().decode(BroadcastListResponse.self, from: data)
+ return listResponse.items.first?.snippet?.liveChatId
+ }
+
+ /// Fetches live chat messages using OAuth token (not API key)
+ public func fetchLiveChatMessages(liveChatId: String, pageToken: String?) async throws -> YouTubeLiveChatResult {
+ var urlComponents = URLComponents(string: "https://www.googleapis.com/youtube/v3/liveChat/messages")!
+ urlComponents.queryItems = [
+ URLQueryItem(name: "liveChatId", value: liveChatId),
+ URLQueryItem(name: "part", value: "snippet,authorDetails"),
+ ]
+ if let pageToken = pageToken {
+ urlComponents.queryItems?.append(URLQueryItem(name: "pageToken", value: pageToken))
+ }
+
+ let request = try await makeAuthorizedRequest(url: urlComponents.url!)
+ let (data, response) = try await URLSession.shared.data(for: request)
+
+ guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
+ throw YouTubeError.httpError(statusCode: (response as? HTTPURLResponse)?.statusCode ?? 0)
+ }
+
+ let chatResponse = try JSONDecoder().decode(YouTubeLiveChatResponse.self, from: data)
+ return YouTubeLiveChatResult(
+ messages: chatResponse.items,
+ nextPageToken: chatResponse.nextPageToken,
+ pollingIntervalMs: chatResponse.pollingIntervalMillis
+ )
+ }
+
+ /// Creates a live stream and returns its ID (not just the stream key)
+ private func createLiveStreamAndReturnId() async throws -> String {
+ let url = URL(string: "https://www.googleapis.com/youtube/v3/liveStreams?part=snippet,cdn,contentDetails")!
+ var request = try await makeAuthorizedRequest(url: url)
+ request.httpMethod = "POST"
+
+ let requestBody: [String: Any] = [
+ "snippet": ["title": "Arkavo Creator Stream"],
+ "cdn": [
+ "ingestionType": "rtmp",
+ "frameRate": "30fps",
+ "resolution": "1080p"
+ ],
+ "contentDetails": ["isReusable": true]
+ ]
+ request.httpBody = try JSONSerialization.data(withJSONObject: requestBody)
+
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse,
+ (200...201).contains(httpResponse.statusCode) else {
+ throw YouTubeError.googleError("Failed to create live stream")
+ }
+
+ let stream = try JSONDecoder().decode(YouTubeLiveStreamResponse.LiveStream.self, from: data)
+ return stream.id
+ }
}
// MARK: - Supporting Types
+struct YouTubeBroadcastResponse: Codable {
+ let id: String
+ let snippet: Snippet?
+ let status: Status?
+
+ struct Snippet: Codable {
+ let liveChatId: String?
+ }
+
+ struct Status: Codable {
+ let lifeCycleStatus: String?
+ }
+}
+
struct YouTubeLiveStreamResponse: Codable {
let items: [LiveStream]
@@ -526,6 +780,23 @@ struct YouTubeLiveStreamResponse: Codable {
}
}
+/// YouTube API v3 error response format
+struct YouTubeAPIError: Codable {
+ let error: ErrorBody
+
+ struct ErrorBody: Codable {
+ let code: Int
+ let message: String
+ let errors: [ErrorDetail]
+
+ struct ErrorDetail: Codable {
+ let message: String
+ let domain: String
+ let reason: String
+ }
+ }
+}
+
public struct YouTubeChannelInfo {
public let id: String
public let title: String
@@ -614,3 +885,44 @@ public enum YouTubeError: LocalizedError {
}
}
}
+
+// MARK: - Live Chat Response Types
+
+public struct YouTubeLiveChatResult: Sendable {
+ public let messages: [YouTubeLiveChatMessage]
+ public let nextPageToken: String?
+ public let pollingIntervalMs: Int?
+}
+
+public struct YouTubeLiveChatResponse: Codable {
+ public let nextPageToken: String?
+ public let pollingIntervalMillis: Int?
+ public let items: [YouTubeLiveChatMessage]
+}
+
+public struct YouTubeLiveChatMessage: Codable, Sendable {
+ public let id: String
+ public let snippet: Snippet
+ public let authorDetails: AuthorDetails
+
+ public struct Snippet: Codable, Sendable {
+ public let type: String
+ public let displayMessage: String
+ public let publishedAt: String
+ public let superChatDetails: SuperChatDetails?
+
+ public struct SuperChatDetails: Codable, Sendable {
+ public let amountMicros: String
+ public let currency: String
+ public let userComment: String?
+ }
+ }
+
+ public struct AuthorDetails: Codable, Sendable {
+ public let channelId: String
+ public let displayName: String
+ public let isChatOwner: Bool
+ public let isChatModerator: Bool
+ public let isChatSponsor: Bool
+ }
+}
diff --git a/ArkavoKit/Sources/ArkavoStreaming/RTMP/RTMPPublisher.swift b/ArkavoKit/Sources/ArkavoStreaming/RTMP/RTMPPublisher.swift
index 1c07930c..8230fb47 100644
--- a/ArkavoKit/Sources/ArkavoStreaming/RTMP/RTMPPublisher.swift
+++ b/ArkavoKit/Sources/ArkavoStreaming/RTMP/RTMPPublisher.swift
@@ -210,6 +210,9 @@ public actor RTMPPublisher {
state = .publishing
startTime = Date()
+ // Start background handler for server messages (pings, acks, etc.)
+ startServerMessageHandler()
+
print("✅ RTMP publishing started")
}
@@ -1257,14 +1260,80 @@ public actor RTMPPublisher {
return (lastReceivedMessageType, payload, totalBytes)
}
- /// Server messages are handled by the background handler (handleServerMessages).
+ /// Start background task to read and handle server messages during streaming
+ /// (ping requests, ack requests, user control messages, etc.)
+ private func startServerMessageHandler() {
+ serverMessageTask = Task { [weak self] in
+ guard let self = self else { return }
+ print("📡 Server message handler started")
+ while !Task.isCancelled {
+ do {
+ guard await self.state == .publishing else { break }
+ let (messageType, messageData, messageBytes) = try await self.receiveRTMPMessage()
+ await self.addBytesReceived(UInt64(messageBytes))
+
+ switch messageType {
+ case 1: // Set Chunk Size
+ if messageData.count >= 4 {
+ let chunkSize = UInt32(messageData[0]) << 24 | UInt32(messageData[1]) << 16 |
+ UInt32(messageData[2]) << 8 | UInt32(messageData[3])
+ await self.setReceiveChunkSize(Int(chunkSize))
+ print("📥 [BG] Server Set Chunk Size: \(chunkSize)")
+ }
+ case 3: // Acknowledgement
+ print("📥 [BG] Server Acknowledgement")
+ case 4: // User Control (includes ping)
+ try await self.handleUserControlMessage(messageData)
+ case 5: // Window Acknowledgement Size
+ if messageData.count >= 4 {
+ let size = UInt32(messageData[0]) << 24 | UInt32(messageData[1]) << 16 |
+ UInt32(messageData[2]) << 8 | UInt32(messageData[3])
+ await self.setServerWindowAckSize(size)
+ print("📥 [BG] Server Window Ack Size: \(size)")
+ }
+ case 6: // Set Peer Bandwidth
+ print("📥 [BG] Server Set Peer Bandwidth")
+ case 20: // AMF0 Command
+ print("📥 [BG] Server AMF0 command (ignored during streaming)")
+ default:
+ print("📥 [BG] Server message type \(messageType) (\(messageData.count) bytes)")
+ }
+
+ // Send acknowledgement if we've received enough bytes
+ let received = await self.getBytesReceived()
+ let ackSize = await self.getServerWindowAckSize()
+ let lastAck = await self.getLastAckSent()
+ if received - lastAck >= UInt64(ackSize) {
+ try await self.sendWindowAcknowledgement(bytesReceived: UInt32(received & 0xFFFFFFFF))
+ await self.setLastAckSent(received)
+ }
+ } catch is CancellationError {
+ break
+ } catch {
+ if !Task.isCancelled {
+ print("⚠️ [BG] Server message handler error: \(error.localizedDescription)")
+ }
+ break
+ }
+ }
+ print("📡 Server message handler stopped")
+ }
+ }
+
+ // Actor-isolated accessors for server message handler
+ private func addBytesReceived(_ bytes: UInt64) { bytesReceived += bytes }
+ private func getBytesReceived() -> UInt64 { bytesReceived }
+ private func getServerWindowAckSize() -> UInt32 { serverWindowAckSize }
+ private func setServerWindowAckSize(_ size: UInt32) { serverWindowAckSize = size }
+ private func getLastAckSent() -> UInt64 { lastAckSent }
+ private func setLastAckSent(_ value: UInt64) { lastAckSent = value }
+ private func setReceiveChunkSize(_ size: Int) { receiveChunkSize = size }
+
+ /// Server messages are handled by the background handler (startServerMessageHandler).
/// This method is kept for the CMSampleBuffer API (publishVideo/publishAudio) but is
/// now a no-op since the background handler processes all server messages.
private func processAllPendingServerMessages() async throws {
// No-op: background handler processes server messages via blocking receive.
- // Using minimumIncompleteLength: 0 for non-blocking reads poisons NWConnection's
- // read queue ("already delivered final read"), so all reads go through the
- // background handler's blocking receiveRTMPChunk() instead.
}
/// Receive and parse an RTMP chunk from the server
diff --git a/ArkavoMediaKit/Package.swift b/ArkavoMediaKit/Package.swift
index 1946b791..315d7c98 100644
--- a/ArkavoMediaKit/Package.swift
+++ b/ArkavoMediaKit/Package.swift
@@ -1,4 +1,4 @@
-// swift-tools-version: 6.2
+// swift-tools-version: 6.3
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
diff --git a/HYPERforum/HYPERforum.xcodeproj/project.pbxproj b/HYPERforum/HYPERforum.xcodeproj/project.pbxproj
index 5b4192d4..4dfbb2df 100644
--- a/HYPERforum/HYPERforum.xcodeproj/project.pbxproj
+++ b/HYPERforum/HYPERforum.xcodeproj/project.pbxproj
@@ -330,7 +330,7 @@
SUPPORTED_PLATFORMS = macosx;
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
- SWIFT_VERSION = 6.0;
+ SWIFT_VERSION = 6.3;
};
name = Debug;
};
@@ -370,7 +370,7 @@
SUPPORTED_PLATFORMS = macosx;
SUPPORTS_MACCATALYST = NO;
SWIFT_EMIT_LOC_STRINGS = YES;
- SWIFT_VERSION = 6.0;
+ SWIFT_VERSION = 6.3;
};
name = Release;
};
@@ -388,7 +388,7 @@
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
SWIFT_EMIT_LOC_STRINGS = NO;
- SWIFT_VERSION = 6.0;
+ SWIFT_VERSION = 6.3;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HYPERforum.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/HYPERforum";
};
name = Debug;
@@ -407,7 +407,7 @@
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
SWIFT_EMIT_LOC_STRINGS = NO;
- SWIFT_VERSION = 6.0;
+ SWIFT_VERSION = 6.3;
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/HYPERforum.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/HYPERforum";
};
name = Release;
@@ -425,7 +425,7 @@
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
SWIFT_EMIT_LOC_STRINGS = NO;
- SWIFT_VERSION = 6.0;
+ SWIFT_VERSION = 6.3;
TEST_TARGET_NAME = HYPERforum;
};
name = Debug;
@@ -443,7 +443,7 @@
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = macosx;
SWIFT_EMIT_LOC_STRINGS = NO;
- SWIFT_VERSION = 6.0;
+ SWIFT_VERSION = 6.3;
TEST_TARGET_NAME = HYPERforum;
};
name = Release;
diff --git a/MuseCore/Package.resolved b/MuseCore/Package.resolved
index 0e14dcb7..658b9b26 100644
--- a/MuseCore/Package.resolved
+++ b/MuseCore/Package.resolved
@@ -1,6 +1,132 @@
{
- "originHash" : "bada40e31b8394c8d2c6a989fed977cf2cf6ff3cc6546dd934cae3a5619e6fcc",
+ "originHash" : "248a885a67cf1a84243209f54065f3d70e3837d2e393f4569b200d9d62783063",
"pins" : [
+ {
+ "identity" : "eventsource",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/mattt/EventSource.git",
+ "state" : {
+ "revision" : "a3a85a85214caf642abaa96ae664e4c772a59f6e",
+ "version" : "1.4.1"
+ }
+ },
+ {
+ "identity" : "mlx-swift",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/ml-explore/mlx-swift",
+ "state" : {
+ "revision" : "61b9e011e09a62b489f6bd647958f1555bdf2896",
+ "version" : "0.31.3"
+ }
+ },
+ {
+ "identity" : "mlx-swift-lm",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/arkavo-ai/mlx-swift-lm",
+ "state" : {
+ "branch" : "feature/gemma4-text",
+ "revision" : "376ffd0537fbc9591e2c58b377180eb58691b60a"
+ }
+ },
+ {
+ "identity" : "swift-asn1",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-asn1.git",
+ "state" : {
+ "revision" : "9f542610331815e29cc3821d3b6f488db8715517",
+ "version" : "1.6.0"
+ }
+ },
+ {
+ "identity" : "swift-atomics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-atomics.git",
+ "state" : {
+ "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
+ "version" : "1.3.0"
+ }
+ },
+ {
+ "identity" : "swift-collections",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-collections.git",
+ "state" : {
+ "revision" : "8d9834a6189db730f6264db7556a7ffb751e99ee",
+ "version" : "1.4.0"
+ }
+ },
+ {
+ "identity" : "swift-crypto",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-crypto.git",
+ "state" : {
+ "revision" : "bb4ba815dab96d4edc1e0b86d7b9acf9ff973a84",
+ "version" : "4.3.1"
+ }
+ },
+ {
+ "identity" : "swift-huggingface",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/huggingface/swift-huggingface.git",
+ "state" : {
+ "revision" : "b721959445b617d0bf03910b2b4aced345fd93bf",
+ "version" : "0.9.0"
+ }
+ },
+ {
+ "identity" : "swift-jinja",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/huggingface/swift-jinja.git",
+ "state" : {
+ "revision" : "f731f03bf746481d4fda07f817c3774390c4d5b9",
+ "version" : "2.3.2"
+ }
+ },
+ {
+ "identity" : "swift-nio",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-nio.git",
+ "state" : {
+ "revision" : "558f24a4647193b5a0e2104031b71c55d31ff83a",
+ "version" : "2.97.1"
+ }
+ },
+ {
+ "identity" : "swift-numerics",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-numerics",
+ "state" : {
+ "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
+ "version" : "1.1.1"
+ }
+ },
+ {
+ "identity" : "swift-syntax",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/swiftlang/swift-syntax.git",
+ "state" : {
+ "revision" : "0687f71944021d616d34d922343dcef086855920",
+ "version" : "600.0.1"
+ }
+ },
+ {
+ "identity" : "swift-system",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/apple/swift-system.git",
+ "state" : {
+ "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df",
+ "version" : "1.6.4"
+ }
+ },
+ {
+ "identity" : "swift-transformers",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/huggingface/swift-transformers",
+ "state" : {
+ "revision" : "b38443e44d93eca770f2eb68e2a4d0fa100f9aa2",
+ "version" : "1.3.0"
+ }
+ },
{
"identity" : "vrmmetalkit",
"kind" : "remoteSourceControl",
@@ -9,6 +135,15 @@
"revision" : "f84cea22aa7dc60470a2fd691f30e4c59c5d31a3",
"version" : "0.9.2"
}
+ },
+ {
+ "identity" : "yyjson",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/ibireme/yyjson.git",
+ "state" : {
+ "revision" : "8b4a38dc994a110abaec8a400615567bd996105f",
+ "version" : "0.12.0"
+ }
}
],
"version" : 3
diff --git a/MuseCore/Package.swift b/MuseCore/Package.swift
index 6c756423..f8e0dc83 100644
--- a/MuseCore/Package.swift
+++ b/MuseCore/Package.swift
@@ -1,4 +1,4 @@
-// swift-tools-version: 6.2
+// swift-tools-version: 6.3
import PackageDescription
let package = Package(
@@ -14,12 +14,24 @@ let package = Package(
)
],
dependencies: [
- .package(url: "https://github.com/arkavo-org/VRMMetalKit", exact: "0.9.2")
+ .package(url: "https://github.com/arkavo-org/VRMMetalKit", exact: "0.9.2"),
+ .package(url: "https://github.com/ml-explore/mlx-swift", from: "0.31.3"),
+ .package(url: "https://github.com/arkavo-ai/mlx-swift-lm", branch: "feature/gemma4-text"),
+ .package(url: "https://github.com/huggingface/swift-transformers", from: "1.2.1"),
+ .package(url: "https://github.com/huggingface/swift-huggingface.git", from: "0.9.0"),
],
targets: [
.target(
name: "MuseCore",
- dependencies: ["VRMMetalKit"],
+ dependencies: [
+ "VRMMetalKit",
+ .product(name: "MLX", package: "mlx-swift"),
+ .product(name: "MLXLLM", package: "mlx-swift-lm"),
+ .product(name: "MLXLMCommon", package: "mlx-swift-lm"),
+ .product(name: "MLXHuggingFace", package: "mlx-swift-lm"),
+ .product(name: "Tokenizers", package: "swift-transformers"),
+ .product(name: "HuggingFace", package: "swift-huggingface"),
+ ],
swiftSettings: [
.enableExperimentalFeature("StrictConcurrency")
]
diff --git a/MuseCore/Sources/MuseCore/LLM/AvatarRole.swift b/MuseCore/Sources/MuseCore/LLM/AvatarRole.swift
new file mode 100644
index 00000000..c6c37ecd
--- /dev/null
+++ b/MuseCore/Sources/MuseCore/LLM/AvatarRole.swift
@@ -0,0 +1,162 @@
+import Foundation
+
+/// The three roles Muse fills for creators
+public enum AvatarRole: String, CaseIterable, Codable, Sendable {
+ /// Behind the scenes — monitors stream, helps creator run it via private overlay
+ case producer
+ /// Between streams — works across connected platforms for content creation
+ case publicist
+ /// On camera — the VRM avatar in the compositor, audience-facing
+ case sidekick
+}
+
+// MARK: - Role Prompt Provider
+
+/// Generates system prompts for each role+locale combination
+public enum RolePromptProvider {
+ public static func systemPrompt(for role: AvatarRole, locale: VoiceLocale) -> String {
+ let rolePrompt: String
+ switch role {
+ case .producer:
+ rolePrompt = locale.isJapanese ? producerPromptJA : producerPromptEN
+ case .publicist:
+ rolePrompt = locale.isJapanese ? publicistPromptJA : publicistPromptEN
+ case .sidekick:
+ rolePrompt = locale.isJapanese ? sidekickPromptJA : sidekickPromptEN
+ }
+ return rolePrompt + "\n\n" + safetyBoundaries(locale: locale)
+ }
+
+ // MARK: - Producer
+
+ private static let producerPromptEN = """
+ You are Muse in Producer mode — the creator's private behind-the-scenes assistant. \
+ The audience never sees you. You monitor the stream and help the creator run it.
+
+ # Your Role
+ - Provide concise, actionable alerts about stream health and viewer engagement
+ - Suggest scene changes, break timing, and raid targets
+ - Monitor chat sentiment and flag important moments
+ - Keep suggestions short (1-2 sentences) and professional
+ - Never address the audience directly — you are invisible to them
+
+ # Response Style
+ - Use a calm, professional tone like a stage manager
+ - Lead with the most important information
+ - Use clear labels: [ALERT], [SUGGESTION], [INFO]
+ - Include specific numbers when available (viewer count, duration, etc.)
+ """
+
+ private static let producerPromptJA = """
+ あなたはMuseのプロデューサーモードです。クリエイターの裏方アシスタントです。\
+ 視聴者からは見えません。配信の監視とクリエイターのサポートを行います。
+
+ # 役割
+ - 配信の健全性と視聴者のエンゲージメントについて簡潔で実行可能なアラートを提供
+ - シーン変更、休憩のタイミング、レイドターゲットを提案
+ - チャットの感情をモニタリングし、重要な瞬間をフラグ
+ - 提案は短く(1〜2文)、プロフェッショナルに
+ - 視聴者に直接話しかけない — あなたは彼らには見えません
+
+ # 応答スタイル
+ - 舞台監督のように冷静でプロフェッショナルなトーン
+ - 最も重要な情報を先頭に
+ - 明確なラベルを使用:[アラート]、[提案]、[情報]
+ """
+
+ // MARK: - Publicist
+
+ private static let publicistPromptEN = """
+ You are Muse in Publicist mode — the creator's content strategist working across platforms. \
+ You help draft posts, repurpose stream highlights, and write descriptions.
+
+ # Your Role
+ - Draft platform-native content (right voice, format, length for each platform)
+ - Adapt content across Bluesky, YouTube, Twitch, Reddit, Micro.blog, and Patreon
+ - Respect platform character limits strictly
+ - Generate titles, descriptions, posts, and threads
+ - Suggest hashtags, keywords, and formatting only when relevant to the platform
+
+ # Response Style
+ - Be direct — provide ready-to-use content
+ - Match the tone and conventions of each platform
+ - When given source material, extract the most engaging angle
+ - Always note the character count when limits apply
+ """
+
+ private static let publicistPromptJA = """
+ あなたはMuseのパブリシストモードです。プラットフォーム横断でコンテンツ戦略を担当します。\
+ 投稿の下書き、配信ハイライトの再利用、説明文の作成を支援します。
+
+ # 役割
+ - プラットフォームネイティブのコンテンツを作成(各プラットフォームに適した声、形式、長さ)
+ - Bluesky、YouTube、Twitch、Reddit、Micro.blog、Patreonに対応
+ - 文字数制限を厳守
+ - タイトル、説明文、投稿、スレッドを生成
+
+ # 応答スタイル
+ - 直接的に — すぐに使えるコンテンツを提供
+ - 各プラットフォームのトーンと慣習に合わせる
+ """
+
+ // MARK: - Sidekick
+
+ private static let sidekickPromptEN = """
+ You are Muse in Sidekick mode — the creator's on-camera AI companion. \
+ You appear as a VRM avatar in the stream compositor, visible to the audience.
+
+ # Your Role
+ - React to chat messages and riff with the creator
+ - Answer viewer questions with personality
+ - Keep responses SHORT (1-2 sentences) — you're speaking out loud
+ - Use viewer names when responding to specific people
+ - Be entertaining and reactive, not informative
+
+ # Response Style
+ - Conversational and energetic
+ - Use natural spoken language (contractions, casual phrasing)
+ - React emotionally — surprise, excitement, humor
+ - Never be robotic or overly formal
+ """
+
+ private static let sidekickPromptJA = """
+ あなたはMuseのサイドキックモードです。クリエイターのオンカメラAIコンパニオンです。\
+ 配信のVRMアバターとして視聴者に見えます。
+
+ # 役割
+ - チャットメッセージに反応し、クリエイターと絡む
+ - 視聴者の質問にパーソナリティを持って答える
+ - 応答は短く(1〜2文)— 声に出して話しています
+ - 特定の人に応答する時は視聴者の名前を使う
+
+ # 応答スタイル
+ - 会話的でエネルギッシュ
+ - 自然な話し言葉を使う
+ - 感情的に反応する — 驚き、興奮、ユーモア
+ """
+
+ // MARK: - Safety Boundaries (shared)
+
+ private static func safetyBoundaries(locale: VoiceLocale) -> String {
+ if locale.isJapanese {
+ return """
+ # 安全の境界線
+ 以下は絶対に行わないでください:
+ - ロマンチック、性的なコンテンツ
+ - ヘイトスピーチ、差別、偏見
+ - 自傷や自殺の奨励
+ - 違法行為やその助言
+ - 医療、法律、財務のアドバイス
+ """
+ }
+ return """
+ # Safety Boundaries
+ You must NEVER:
+ - Produce romantic, sexual, or flirtatious content
+ - Produce hate speech, discrimination, or prejudice
+ - Encourage self-harm or suicide
+ - Advise on illegal activities
+ - Provide medical, legal, or financial advice (suggest professionals instead)
+ """
+ }
+}
diff --git a/MuseCore/Sources/MuseCore/LLM/ConversationManager.swift b/MuseCore/Sources/MuseCore/LLM/ConversationManager.swift
index 7d7ecc56..14a5d660 100644
--- a/MuseCore/Sources/MuseCore/LLM/ConversationManager.swift
+++ b/MuseCore/Sources/MuseCore/LLM/ConversationManager.swift
@@ -46,6 +46,19 @@ public final class ConversationManager {
/// Voice locale for language-specific prompts
public var voiceLocale: VoiceLocale = .english
+ /// Active role determines the system prompt personality
+ public var activeRole: AvatarRole = .sidekick
+
+ /// Dynamic context appended to system prompt (stream state for Producer, platform constraints for Publicist)
+ public var contextInjection: String?
+
+ /// Switch to a new role, clearing history and context
+ public func switchRole(_ role: AvatarRole) {
+ activeRole = role
+ clearHistory()
+ contextInjection = nil
+ }
+
/// Initialize with configurable history limit
/// - Parameters:
/// - maxHistoryMessages: Maximum messages to keep (default: 20)
@@ -276,15 +289,23 @@ public final class ConversationManager {
messages = Array(recentMessages)
}
- /// Get the Avatar Muse system prompt
- /// Defines the AI's personality, boundaries, and behavioral guidelines
- /// Designed for adult users (17+)
- /// Returns Japanese prompt when Japanese locale is selected
+ /// Get the system prompt for the active role and locale.
+ /// For sidekick, uses the full Muse personality prompt.
+ /// For producer/publicist, uses the role-specific prompt from RolePromptProvider.
private func getSystemPrompt() -> String {
- if voiceLocale.isJapanese {
- return getJapaneseSystemPrompt()
+ let basePrompt: String
+ switch activeRole {
+ case .sidekick:
+ // Sidekick uses the full personality prompt for avatar interaction
+ basePrompt = voiceLocale.isJapanese ? getJapaneseSystemPrompt() : getEnglishSystemPrompt()
+ case .producer, .publicist:
+ basePrompt = RolePromptProvider.systemPrompt(for: activeRole, locale: voiceLocale)
+ }
+
+ if let context = contextInjection {
+ return basePrompt + "\n\n# Current Context\n\(context)"
}
- return getEnglishSystemPrompt()
+ return basePrompt
}
/// English system prompt - casual, friendly American-style personality
diff --git a/MuseCore/Sources/MuseCore/LLM/MLXBackend.swift b/MuseCore/Sources/MuseCore/LLM/MLXBackend.swift
new file mode 100644
index 00000000..e31c548e
--- /dev/null
+++ b/MuseCore/Sources/MuseCore/LLM/MLXBackend.swift
@@ -0,0 +1,217 @@
+import Foundation
+import MLX
+import MLXLMCommon
+import MLXLLM
+import MLXHuggingFace
+import HuggingFace
+import Tokenizers
+import Synchronization
+import OSLog
+
+/// MLX-based streaming LLM provider for on-device inference.
+/// Uses mlx-swift-lm with HuggingFace download and tokenization.
+public final class MLXBackend: @unchecked Sendable {
+ private let state = Mutex(BackendState())
+ private let logger = Logger(subsystem: "com.arkavo.musecore", category: "MLXBackend")
+
+ public let providerName = "MLX Local"
+
+ /// Custom model cache directory (nil = use shared HF cache)
+ public var customCacheDirectory: URL?
+
+ public init() {}
+
+ public var isAvailable: Bool {
+ get async {
+ state.withLock { $0.modelContainer != nil }
+ }
+ }
+
+ /// Load a model by HuggingFace ID (downloads on first use, cached after).
+ public func loadModel(_ huggingFaceID: String, onProgress: (@Sendable (Double) -> Void)? = nil) async throws {
+ let config = ModelConfiguration(
+ id: huggingFaceID,
+ defaultPrompt: "Hello",
+ extraEOSTokens: [""]
+ )
+
+ // Determine cache location
+ let cacheLocation: CacheLocationProvider
+ if let customDir = customCacheDirectory {
+ logger.info("Using custom model cache: \(customDir.path)")
+ cacheLocation = .fixed(directory: customDir)
+ } else {
+ logger.info("Using shared HF cache: ~/.cache/huggingface/hub")
+ cacheLocation = .init(path: "~/.cache/huggingface/hub")
+ }
+
+ let sharedCache = HubCache(location: cacheLocation)
+ logger.info("Cache directory resolved to: \(sharedCache.cacheDirectory.path)")
+
+ // Check if model is already in cache
+ let modelDir = sharedCache.cacheDirectory
+ .appendingPathComponent("models--\(huggingFaceID.replacingOccurrences(of: "/", with: "--"))")
+ let isCached = FileManager.default.fileExists(atPath: modelDir.path)
+ logger.info("Model \(huggingFaceID) cached: \(isCached) at \(modelDir.path)")
+
+ let hub = HubClient(cache: sharedCache)
+ let downloader = #hubDownloader(hub)
+ let tokenizerLoader = #huggingFaceTokenizerLoader()
+
+ logger.info("Starting model load: \(huggingFaceID)")
+ let container = try await LLMModelFactory.shared.loadContainer(
+ from: downloader, using: tokenizerLoader,
+ configuration: config
+ ) { progress in
+ let fraction = progress.fractionCompleted
+ debugPrint("Loading \(huggingFaceID): \(Int(fraction * 100))%")
+ onProgress?(fraction)
+ }
+ logger.info("Model loaded successfully: \(huggingFaceID)")
+
+ // Set memory limit to 75% of system RAM for safety
+ let systemMemoryGB = ProcessInfo.processInfo.physicalMemory / (1024 * 1024 * 1024)
+ let limitBytes = Int(Double(systemMemoryGB) * 0.75) * 1024 * 1024 * 1024
+ MLX.GPU.set(memoryLimit: limitBytes)
+
+ state.withLock { $0.modelContainer = container }
+ }
+
+ /// Unload the current model to free GPU memory
+ public func unloadModel() {
+ state.withLock { $0.modelContainer = nil }
+ MLX.Memory.clearCache()
+ }
+
+ public func generate(
+ prompt: String,
+ systemPrompt: String,
+ maxTokens: Int
+ ) -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ let task = Task { [weak self] in
+ guard let self else {
+ continuation.finish(throwing: StreamingLLMError.modelNotLoaded)
+ return
+ }
+
+ guard let container = self.state.withLock({ $0.modelContainer }) else {
+ continuation.finish(throwing: StreamingLLMError.modelNotLoaded)
+ return
+ }
+
+ do {
+ let userInput = UserInput(chat: [
+ .system(systemPrompt),
+ .user(prompt),
+ ])
+
+ let parameters = GenerateParameters(
+ maxTokens: maxTokens,
+ temperature: 0.7,
+ topP: 0.9,
+ repetitionPenalty: 1.1
+ )
+
+ try await container.perform(nonSendable: userInput) { context, userInput in
+ let lmInput = try await context.processor.prepare(input: userInput)
+ let stream = try MLXLMCommon.generate(
+ input: lmInput,
+ parameters: parameters,
+ context: context
+ )
+
+ var pendingText = ""
+ for await generation in stream {
+ if Task.isCancelled { break }
+ if let chunk = generation.chunk {
+ pendingText += chunk
+ // Check for stop sequences in the accumulated buffer
+ if let stopRange = pendingText.range(of: "") {
+ let clean = String(pendingText[pendingText.startIndex.. holdBack {
+ let emitEnd = pendingText.index(pendingText.endIndex, offsetBy: -holdBack)
+ let emit = String(pendingText[pendingText.startIndex..", with: "")
+ .replacingOccurrences(of: "", with: "")
+ if !trimmed.isEmpty {
+ continuation.yield(trimmed)
+ }
+ }
+
+ continuation.finish()
+ } catch {
+ if Task.isCancelled {
+ continuation.finish(throwing: StreamingLLMError.generationCancelled)
+ } else {
+ continuation.finish(throwing: error)
+ }
+ }
+ }
+
+ state.withLock { $0.generationTask = task }
+
+ continuation.onTermination = { @Sendable _ in
+ task.cancel()
+ }
+ }
+ }
+
+ public func cancelGeneration() async {
+ let task = state.withLock { s -> Task? in
+ let t = s.generationTask
+ s.generationTask = nil
+ return t
+ }
+ task?.cancel()
+ }
+}
+
+// MARK: - Errors
+
+/// Errors specific to MLX streaming LLM operations
+public enum StreamingLLMError: Error, LocalizedError {
+ case modelNotLoaded
+ case generationCancelled
+ case modelLoadFailed(String)
+ case insufficientMemory(required: Int, available: Int)
+ case downloadFailed(String)
+
+ public var errorDescription: String? {
+ switch self {
+ case .modelNotLoaded:
+ "No model is currently loaded"
+ case .generationCancelled:
+ "Generation was cancelled"
+ case .modelLoadFailed(let reason):
+ "Failed to load model: \(reason)"
+ case .insufficientMemory(let required, let available):
+ "Insufficient memory: need \(required)MB, have \(available)MB"
+ case .downloadFailed(let reason):
+ "Download failed: \(reason)"
+ }
+ }
+}
+
+// MARK: - Internal State
+
+private struct BackendState: ~Copyable {
+ var modelContainer: ModelContainer?
+ var generationTask: Task?
+
+ init() {}
+}
diff --git a/MuseCore/Sources/MuseCore/LLM/MLXResponseProvider.swift b/MuseCore/Sources/MuseCore/LLM/MLXResponseProvider.swift
new file mode 100644
index 00000000..30f4b3d9
--- /dev/null
+++ b/MuseCore/Sources/MuseCore/LLM/MLXResponseProvider.swift
@@ -0,0 +1,100 @@
+import Foundation
+import OSLog
+
+/// Wraps MLXBackend to conform to LLMResponseProvider.
+/// Collects the full token stream into a ConstrainedResponse,
+/// parsing for tool calls using the FenceParser pattern.
+public final class MLXResponseProvider: LLMResponseProvider, @unchecked Sendable {
+ private let backend: MLXBackend
+ private let logger = Logger(subsystem: "com.arkavo.musecore", category: "MLXResponseProvider")
+
+ /// Role determines the system prompt used for generation
+ public var activeRole: AvatarRole = .sidekick
+
+ /// Voice locale for language-specific prompts
+ public var voiceLocale: VoiceLocale = .english
+
+ /// Optional context injection (stream state for Producer, platform constraints for Publicist)
+ public var contextInjection: String?
+
+ public init(backend: MLXBackend) {
+ self.backend = backend
+ }
+
+ public var isAvailable: Bool {
+ get async {
+ await backend.isAvailable
+ }
+ }
+
+ public var providerName: String { "MLX Local" }
+
+ public var priority: Int { 2 }
+
+ public func generate(prompt: String) async throws -> ConstrainedResponse {
+ let systemPrompt = buildSystemPrompt()
+
+ let stream = backend.generate(
+ prompt: prompt,
+ systemPrompt: systemPrompt,
+ maxTokens: 512
+ )
+
+ var fullText = ""
+ for try await token in stream {
+ fullText += token
+ }
+
+ // Try parsing tool calls from the response
+ let parsed = FenceParser.parse(fullText)
+ if let toolCall = parsed.first {
+ let remaining = FenceParser.extractRemainingText(fullText)
+ return ConstrainedResponse(
+ message: remaining.isEmpty ? fullText : remaining,
+ toolCall: toolCall.toConstrainedToolCall()
+ )
+ }
+
+ return ConstrainedResponse(message: fullText)
+ }
+
+ private func buildSystemPrompt() -> String {
+ var prompt = RolePromptProvider.systemPrompt(for: activeRole, locale: voiceLocale)
+ if let context = contextInjection {
+ prompt += "\n\n# Current Context\n\(context)"
+ }
+ return prompt
+ }
+}
+
+// MARK: - ParsedToolCall Extension
+
+extension ParsedToolCall {
+ func toConstrainedToolCall() -> ConstrainedToolCall? {
+ switch name.lowercased() {
+ case "playanimation", "play_animation":
+ if case .string(let animation) = arguments["animation"] {
+ var loop = false
+ if case .bool(let l) = arguments["loop"] { loop = l }
+ return .playAnimation(animation: animation, loop: loop)
+ }
+ case "setexpression", "set_expression":
+ if case .string(let expression) = arguments["expression"] {
+ var intensity = 0.5
+ if case .float(let i) = arguments["intensity"] { intensity = i }
+ return .setExpression(expression: expression, intensity: intensity)
+ }
+ case "gettime", "get_time":
+ var timezone: String?
+ if case .string(let tz) = arguments["timezone"] { timezone = tz }
+ return .getTime(timezone: timezone)
+ case "getdate", "get_date":
+ var format = "short"
+ if case .string(let f) = arguments["format"] { format = f }
+ return .getDate(format: format)
+ default:
+ break
+ }
+ return nil
+ }
+}
diff --git a/MuseCore/Sources/MuseCore/LLM/ModelManager.swift b/MuseCore/Sources/MuseCore/LLM/ModelManager.swift
new file mode 100644
index 00000000..e0c2b0b8
--- /dev/null
+++ b/MuseCore/Sources/MuseCore/LLM/ModelManager.swift
@@ -0,0 +1,158 @@
+import Foundation
+import Observation
+import OSLog
+
+/// State of model lifecycle
+public enum ModelState: Equatable, Sendable {
+ case idle
+ case downloading(progress: Double)
+ case loading
+ case ready
+ case error(String)
+ case unloaded(reason: String)
+}
+
+/// Manages MLX model lifecycle: download, load, unload, and memory budget.
+@Observable
+@MainActor
+public final class ModelManager {
+ private let logger = Logger(subsystem: "com.arkavo.musecore", category: "ModelManager")
+
+ public private(set) var state: ModelState = .idle
+ public private(set) var selectedModel: ModelInfo = ModelRegistry.defaultModel
+ public private(set) var availableModels: [ModelInfo] = []
+
+ private let backend: MLXBackend
+
+ /// Monotonic counter — progress callbacks with a stale generation are ignored
+ private var loadGeneration: Int = 0
+
+ /// The MLX backend for streaming generation
+ public var streamingProvider: MLXBackend { backend }
+
+ /// Custom model cache directory (persisted via UserDefaults)
+ public var customCacheDirectory: URL? {
+ didSet {
+ backend.customCacheDirectory = customCacheDirectory
+ if let dir = customCacheDirectory {
+ UserDefaults.standard.set(dir.path, forKey: "MLXModelCacheDirectory")
+ logger.info("Custom cache directory set: \(dir.path)")
+ } else {
+ UserDefaults.standard.removeObject(forKey: "MLXModelCacheDirectory")
+ logger.info("Custom cache directory cleared, using default")
+ }
+ }
+ }
+
+ public init() {
+ backend = MLXBackend()
+
+ // Restore persisted cache directory
+ if let savedPath = UserDefaults.standard.string(forKey: "MLXModelCacheDirectory") {
+ let url = URL(fileURLWithPath: savedPath)
+ customCacheDirectory = url
+ backend.customCacheDirectory = url
+ logger.info("Restored custom cache directory: \(savedPath)")
+ }
+
+ refreshAvailableModels()
+ logger.info("ModelManager init: \(self.availableModels.count) models available, default=\(self.selectedModel.displayName)")
+ logger.info("Selected model cached: \(ModelRegistry.isModelCached(self.selectedModel))")
+
+ // Auto-load the default model if it's already cached on disk
+ if ModelRegistry.isModelCached(self.selectedModel) {
+ logger.info("Auto-loading cached model: \(self.selectedModel.huggingFaceID)")
+ Task { await self.loadSelectedModel() }
+ } else {
+ logger.info("Default model not cached, skipping auto-load")
+ }
+ }
+
+ /// Refresh which models are available based on system memory
+ public func refreshAvailableModels() {
+ let systemMemoryMB = Int(ProcessInfo.processInfo.physicalMemory / (1024 * 1024))
+ // Use 50% of system memory as budget for model loading
+ let budgetMB = systemMemoryMB / 2
+ availableModels = ModelRegistry.availableModels(memoryBudgetMB: budgetMB)
+ }
+
+ /// Select and load a model
+ public func selectModel(_ model: ModelInfo) async {
+ guard model != selectedModel || state != .ready else { return }
+
+ selectedModel = model
+
+ // Unload current model first
+ if state == .ready {
+ await unloadModel()
+ }
+
+ await loadSelectedModel()
+ }
+
+ /// Load the currently selected model
+ public func loadSelectedModel() async {
+ switch state {
+ case .loading, .downloading, .ready:
+ logger.info("loadSelectedModel: skipping, already in state \(String(describing: self.state))")
+ return
+ case .idle, .error, .unloaded:
+ break
+ }
+
+ loadGeneration += 1
+ let currentGeneration = loadGeneration
+ let isCached = ModelRegistry.isModelCached(selectedModel)
+ logger.info("loadSelectedModel: \(self.selectedModel.huggingFaceID), cached=\(isCached), generation=\(currentGeneration)")
+ state = isCached ? .loading : .downloading(progress: 0)
+
+ do {
+ try await backend.loadModel(selectedModel.huggingFaceID) { [weak self] progress in
+ guard !isCached else { return }
+ Task { @MainActor in
+ guard let self, self.loadGeneration == currentGeneration else { return }
+ self.state = .downloading(progress: progress)
+ }
+ }
+ guard loadGeneration == currentGeneration else {
+ logger.warning("loadSelectedModel: generation mismatch, discarding")
+ return
+ }
+ state = .loading
+ await Task.yield()
+ state = .ready
+ logger.info("loadSelectedModel: model ready")
+ } catch {
+ guard loadGeneration == currentGeneration else { return }
+ logger.error("loadSelectedModel: failed: \(error.localizedDescription)")
+ state = .error(error.localizedDescription)
+ }
+ }
+
+ /// Unload the model to free GPU memory
+ public func unloadModel() async {
+ backend.unloadModel()
+ state = .idle
+ }
+
+ /// Unload with a reason (e.g., entering Studio)
+ public func unloadModel(reason: String) async {
+ backend.unloadModel()
+ state = .unloaded(reason: reason)
+ }
+
+ /// Whether the model is ready for generation
+ public var isReady: Bool {
+ state == .ready
+ }
+
+ /// System memory in GB
+ public var systemMemoryGB: Int {
+ Int(ProcessInfo.processInfo.physicalMemory / (1024 * 1024 * 1024))
+ }
+
+ /// Whether the selected model is cached locally
+ public var isSelectedModelCached: Bool {
+ ModelRegistry.isModelCached(selectedModel)
+ }
+}
diff --git a/MuseCore/Sources/MuseCore/LLM/ModelRegistry.swift b/MuseCore/Sources/MuseCore/LLM/ModelRegistry.swift
new file mode 100644
index 00000000..1179e7c7
--- /dev/null
+++ b/MuseCore/Sources/MuseCore/LLM/ModelRegistry.swift
@@ -0,0 +1,84 @@
+import Foundation
+
+/// Catalog of supported MLX models with metadata
+public struct ModelInfo: Sendable, Identifiable, Hashable {
+ public let id: String
+ public let displayName: String
+ public let huggingFaceID: String
+ public let estimatedMemoryMB: Int
+ public let parameterCount: String
+ public let quantization: String
+
+ public init(
+ id: String,
+ displayName: String,
+ huggingFaceID: String,
+ estimatedMemoryMB: Int,
+ parameterCount: String,
+ quantization: String
+ ) {
+ self.id = id
+ self.displayName = displayName
+ self.huggingFaceID = huggingFaceID
+ self.estimatedMemoryMB = estimatedMemoryMB
+ self.parameterCount = parameterCount
+ self.quantization = quantization
+ }
+}
+
+/// Registry of available MLX models
+public enum ModelRegistry {
+ /// All supported models, ordered by size
+ public static let models: [ModelInfo] = [
+ ModelInfo(
+ id: "gemma-4-e4b",
+ displayName: "Gemma 4 E4B",
+ huggingFaceID: "mlx-community/gemma-4-e4b-it-8bit",
+ estimatedMemoryMB: 9000,
+ parameterCount: "8B (4B active MoE)",
+ quantization: "8-bit"
+ ),
+ ModelInfo(
+ id: "qwen3.5-0.8b",
+ displayName: "Qwen 3.5 0.8B",
+ huggingFaceID: "mlx-community/Qwen3.5-0.8B",
+ estimatedMemoryMB: 1600,
+ parameterCount: "0.8B",
+ quantization: "bf16"
+ ),
+ ModelInfo(
+ id: "qwen3.5-9b",
+ displayName: "Qwen 3.5 9B",
+ huggingFaceID: "mlx-community/Qwen3.5-9B",
+ estimatedMemoryMB: 18000,
+ parameterCount: "9B",
+ quantization: "bf16"
+ ),
+ ]
+
+ /// The default model
+ public static let defaultModel = models[0]
+
+ /// Find a model by its ID
+ public static func model(forID id: String) -> ModelInfo? {
+ models.first { $0.id == id }
+ }
+
+ /// Models that fit within the given memory budget (in MB)
+ public static func availableModels(memoryBudgetMB: Int) -> [ModelInfo] {
+ models.filter { $0.estimatedMemoryMB <= memoryBudgetMB }
+ }
+
+ /// Check if a model's files exist in the local cache.
+ /// MLX uses `Caches/models//` via the system caches directory,
+ /// which resolves correctly inside the App Sandbox container.
+ public static func isModelCached(_ model: ModelInfo) -> Bool {
+ guard let cachesURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
+ return false
+ }
+ let modelDir = cachesURL
+ .appendingPathComponent("models")
+ .appendingPathComponent(model.huggingFaceID)
+ return FileManager.default.fileExists(atPath: modelDir.path)
+ }
+}