diff --git a/apps/ios/InnoLive/InnoLive/Features/Home/LiveStatus/BroadcastLiveStatusPanel.swift b/apps/ios/InnoLive/InnoLive/Features/Home/LiveStatus/BroadcastLiveStatusPanel.swift index 496f5055..f69de87d 100644 --- a/apps/ios/InnoLive/InnoLive/Features/Home/LiveStatus/BroadcastLiveStatusPanel.swift +++ b/apps/ios/InnoLive/InnoLive/Features/Home/LiveStatus/BroadcastLiveStatusPanel.swift @@ -45,7 +45,7 @@ struct BroadcastLiveStatusPanel: View { Circle() .fill(Self.dotColor(tone)) .frame(width: dotSize, height: dotSize) - Text(BroadcastSettingsProvider(rawValue: target.provider)?.title ?? target.title) + Text(target.provider == "youtube" ? target.title : (BroadcastSettingsProvider(rawValue: target.provider)?.title ?? target.title)) .fontWeight(.semibold) Spacer(minLength: 8) Text(policy.targetLabel) diff --git a/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastPlatformSelectionView.swift b/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastPlatformSelectionView.swift index f42c524c..e627cf25 100644 --- a/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastPlatformSelectionView.swift +++ b/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastPlatformSelectionView.swift @@ -10,6 +10,7 @@ struct BroadcastPlatformSelectionView: View { @ObservedObject var authentication: AuthSession @ObservedObject var youtube: YouTubeIntegration @State private var isShowingDisconnectConfirmation = false + @State private var disconnectAccount: StreamingAccountSummary? @State private var isShowingCHZZKDisconnectConfirmation = false private var visiblePlatforms: [BroadcastPlatform] { @@ -139,131 +140,86 @@ struct BroadcastPlatformSelectionView: View { } private var youTubeRow: some View { - Group { - if let connection = youtube.connection { - connectedYouTubeAccountRow(connection) - } else { - SettingsGlassRow { - HStack(spacing: 12) { - platformIdentity(.youTube) - Spacer() - - if youtube.isRefreshingConnection { - ProgressView() - .controlSize(.small) - } else { - Button(action: connectYouTube) { - if youtube.isConnecting { - ProgressView() - } else { - Text(String(localized: "계정 연결")) - } - } - .innoLiveGlassButtonStyle(prominent: true) - .tint(.blue) - .disabled(youtube.isYouTubeAccountChangeBlocked) - } + VStack(alignment: .leading, spacing: 12) { + platformIdentity(.youTube, allowsWrapping: true) + if youtube.youtubeAccounts.isEmpty, let connection = youtube.connection { + VStack(alignment: .leading, spacing: 8) { + Text(connection.channel.title) + .font(.callout.weight(.semibold)) + .fixedSize(horizontal: false, vertical: true) + Text(String(localized: "마지막으로 확인한 채널", table: "YouTubeChannels")) + .font(.caption).foregroundStyle(.secondary) + Button(String(localized: "다시 시도")) { + Task { await youtube.refreshConnection(accessToken: authentication.currentAccessToken()) } } + .disabled(youtube.isYouTubeConnectionOperationInProgress) } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .innoLiveGlassBackground(cornerRadius: 16) } + ForEach(youtube.youtubeAccounts, id: \.channelID) { account in + VStack(alignment: .leading, spacing: 8) { + Text(account.displayTitle) + .font(.callout.weight(.semibold)) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + Text(account.reconnectRequired ? String(localized: "재연결 필요") : String(localized: "연결됨")) + .font(.caption) + .foregroundStyle(account.reconnectRequired ? .orange : .secondary) + .fixedSize(horizontal: false, vertical: true) + ViewThatFits(in: .horizontal) { + HStack(spacing: 8) { youTubeAccountActions(account, allowsWrapping: false) } + VStack(alignment: .leading, spacing: 8) { youTubeAccountActions(account, allowsWrapping: true) } + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .innoLiveGlassBackground(cornerRadius: 16) + } + Button(String(localized: "채널 추가 연결", table: "YouTubeChannels"), action: connectYouTube) + .innoLiveGlassButtonStyle(prominent: true) + .tint(.blue) + .disabled(!youtube.canConnectYouTubeChannel()) + if youtube.isYouTubeConnectionOperationInProgress { ProgressView() } } - .confirmationDialog( - String(localized: "YouTube 연결 해제"), - isPresented: $isShowingDisconnectConfirmation, - titleVisibility: .visible - ) { + .padding(16) + .confirmationDialog(String(localized: "YouTube 연결 해제"), + isPresented: $isShowingDisconnectConfirmation, titleVisibility: .visible) { Button(String(localized: "연결 해제"), role: .destructive) { - Task { - await youtube.disconnectYouTubeAccount( - accessToken: authentication.currentAccessToken() - ) - } + guard let account = disconnectAccount else { return } + Task { await youtube.disconnectYouTubeAccount(accessToken: authentication.currentAccessToken(), accountID: account.accountID) } } - .disabled(!youtube.canDisconnectYouTubeAccount) Button(String(localized: "취소"), role: .cancel) {} } message: { - Text(String(localized: "연결을 해제하면 다시 방송하기 전에 YouTube 계정을 연결해야 합니다.")) - } - } - - private func connectedYouTubeAccountRow(_ connection: YouTubeConnection) -> some View { - VStack(alignment: .leading, spacing: 8) { - platformIdentity(.youTube, allowsWrapping: true) - - VStack(alignment: .leading, spacing: 2) { - Text(connection.channel.title) - .font(.callout.weight(.semibold)) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - Text( - connection.requiresReconnection - ? String(localized: "재연결 필요") - : String(localized: "연결됨") - ) - .font(.caption) - .foregroundStyle(connection.requiresReconnection ? .orange : .secondary) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) - - youTubeConnectedAccountActions(requiresReconnection: connection.requiresReconnection) - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(.horizontal, 16) - .padding(.vertical, 16) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - .innoLiveGlassBackground(cornerRadius: 16) - } - - private func youTubeConnectedAccountActions(requiresReconnection: Bool) -> some View { - ViewThatFits(in: .horizontal) { - HStack(spacing: 8) { - youTubeConnectedAccountActionControls( - requiresReconnection: requiresReconnection, - allowsWrapping: false - ) - } - VStack(alignment: .leading, spacing: 8) { - youTubeConnectedAccountActionControls( - requiresReconnection: requiresReconnection, - allowsWrapping: true - ) - } + Text(disconnectAccount?.displayTitle ?? "YouTube") } } @ViewBuilder - private func youTubeConnectedAccountActionControls( - requiresReconnection: Bool, - allowsWrapping: Bool - ) -> some View { - if youtube.isConnecting || youtube.isRefreshingConnection || youtube.isDisconnecting { - ProgressView() - .controlSize(.small) - } - if requiresReconnection { + private func youTubeAccountActions(_ account: StreamingAccountSummary, allowsWrapping: Bool) -> some View { + if account.reconnectRequired { Button(action: connectYouTube) { youTubeAccountActionLabel(String(localized: "다시 연결"), allowsWrapping: allowsWrapping) } .buttonStyle(.borderless) - .disabled(youtube.isYouTubeAccountChangeBlocked) + .disabled(!youtube.canConnectYouTubeChannel(reconnecting: true)) } Button(role: .destructive) { + disconnectAccount = account isShowingDisconnectConfirmation = true } label: { youTubeAccountActionLabel(String(localized: "연결 해제"), allowsWrapping: allowsWrapping) } .buttonStyle(.borderless) - .disabled(!youtube.canDisconnectYouTubeAccount) + .disabled(!youtube.canDisconnectYouTubeChannel(account)) } @ViewBuilder private func youTubeAccountActionLabel(_ title: String, allowsWrapping: Bool) -> some View { if allowsWrapping { - Text(title) - .multilineTextAlignment(.leading) - .fixedSize(horizontal: false, vertical: true) + Text(title).multilineTextAlignment(.leading).fixedSize(horizontal: false, vertical: true) } else { Text(title) } diff --git a/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastSettingsView.swift b/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastSettingsView.swift index b5e91798..ae2454af 100644 --- a/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastSettingsView.swift +++ b/apps/ios/InnoLive/InnoLive/Features/Settings/BroadcastSettingsView.swift @@ -7,6 +7,7 @@ struct BroadcastSettingsView: View { @ObservedObject private var editor: BroadcastSettingsEditor @State private var query = "" @State private var isSaved = false + @State private var isConfirmingChannel = false @State private var transmissionNoticeProvider: BroadcastSettingsProvider? @State private var nextTransmissionNoticeProvider: BroadcastSettingsProvider? @State private var isShowingMediaConsent = false @@ -111,6 +112,20 @@ struct BroadcastSettingsView: View { .disabled(!youtube.canEditLiveBroadcast(provider) || youtube.isSavingLiveSettings) } } + if editor.provider == .youtube, !isLiveEditing, !youtube.youtubeAccounts.isEmpty { + Section(String(localized: "YouTube 채널", table: "YouTubeChannels")) { + Picker(String(localized: "방송할 채널", table: "YouTubeChannels"), selection: Binding( + get: { youtube.selectedYouTubeAccountID ?? "" }, + set: { youtube.selectYouTubeChannel($0.isEmpty ? nil : $0, accessToken: token) } + )) { + ForEach(youtube.youtubeAccounts, id: \.channelID) { account in + Text(account.displayTitle).tag(account.accountID ?? "") + } + } + .disabled(!youtube.canSelectYouTubeChannel) + .accessibilityIdentifier("youtube-channel-picker") + } + } if editor.provider == .youtube { youtubeForm } else { chzzkForm } if isLiveEditing { @@ -156,6 +171,10 @@ struct BroadcastSettingsView: View { } .navigationTitle(isLiveEditing ? String(localized: "방송 정보 수정") : String(localized: "방송 설정")) .navigationBarTitleDisplayMode(.inline) + .confirmationDialog(String(localized: "방송할 채널", table: "YouTubeChannels"), isPresented: $isConfirmingChannel, titleVisibility: .visible) { + Button(youtube.selectedYouTubeAccount?.displayTitle ?? "YouTube") { performPrimaryAction() } + Button(String(localized: "취소"), role: .cancel) {} + } .onChange(of: youtube.selectedBroadcastProviders) { _, selected in if !isLiveEditing, modeProvider == nil, !selected.contains(editor.provider), let provider = BroadcastSettingsProvider.allCases.first(where: selected.contains) { @@ -403,6 +422,16 @@ struct BroadcastSettingsView: View { } private func searchCHZZK() { Task { await editor.searchCategories(query: query, accessToken: token) } } private func primaryAction() { + if !isLiveEditing, youtube.youtubeAccounts.count > 1, + (modeProvider == .youtube || (onPrepare != nil && youtube.selectedBroadcastProviders.contains(.youtube))), + youtube.canSelectYouTubeChannel { + isConfirmingChannel = true + } else { + performPrimaryAction() + } + } + + private func performPrimaryAction() { if isLiveEditing { Task { isSaved = await youtube.saveLiveSettings(accessToken: token) } return diff --git a/apps/ios/InnoLive/InnoLive/Features/YouTube/BroadcastSettingsEditor.swift b/apps/ios/InnoLive/InnoLive/Features/YouTube/BroadcastSettingsEditor.swift index 903b353e..7188b835 100644 --- a/apps/ios/InnoLive/InnoLive/Features/YouTube/BroadcastSettingsEditor.swift +++ b/apps/ios/InnoLive/InnoLive/Features/YouTube/BroadcastSettingsEditor.swift @@ -18,6 +18,7 @@ final class BroadcastSettingsEditor: ObservableObject { private let api: YouTubeAPI private let preferences: YouTubePreferencesStore private(set) var contextGeneration = UUID() + private var youtubeAccountID: String? private var context: String? private var keys: [BroadcastSettingsProvider: String] = [:] private var revisions: [BroadcastSettingsProvider: Int] = [:] @@ -31,11 +32,12 @@ final class BroadcastSettingsEditor: ObservableObject { self.preferences = preferences } - func configure(scope: BroadcastSessionScope?, channels: [String: String]) { - let next = scope.map { $0.storageKey + ":" + (channels["youtube"] ?? "") + ":" + (channels["chzzk"] ?? "") } + func configure(scope: BroadcastSessionScope?, channels: [String: String], youtubeAccountID: String? = nil) { + let next = scope.map { $0.storageKey + ":" + (youtubeAccountID ?? channels["youtube"] ?? "") + ":" + (channels["chzzk"] ?? "") } guard next != context else { return } reset() context = next + self.youtubeAccountID = youtubeAccountID guard let scope else { return } for provider in BroadcastSettingsProvider.allCases { let channel = channels[provider.rawValue] ?? "unconnected" @@ -113,7 +115,7 @@ final class BroadcastSettingsEditor: ObservableObject { loadingDefaults.insert(provider) defer { if requests[provider] == request { loadingDefaults.remove(provider) } } do { - let values = try await api.broadcastDefaults(session: session, accessToken: accessToken, provider: provider) + let values = try await api.broadcastDefaults(session: session, accessToken: accessToken, provider: provider, accountID: provider == .youtube ? youtubeAccountID : nil) guard context == originalContext, requests[provider] == request, usesDefaults[provider] == true, revisions[provider, default: 0] == revision, revision == 0, !Task.isCancelled else { return } if provider == .youtube { diff --git a/apps/ios/InnoLive/InnoLive/Features/YouTube/StreamingAccountModels.swift b/apps/ios/InnoLive/InnoLive/Features/YouTube/StreamingAccountModels.swift index a0831107..99768947 100644 --- a/apps/ios/InnoLive/InnoLive/Features/YouTube/StreamingAccountModels.swift +++ b/apps/ios/InnoLive/InnoLive/Features/YouTube/StreamingAccountModels.swift @@ -1,6 +1,7 @@ import Foundation struct StreamingAccountSummary: Decodable, Equatable { + var accountID: String? = nil let provider: String let channelID: String let channelTitle: String @@ -8,6 +9,7 @@ struct StreamingAccountSummary: Decodable, Equatable { let reconnectRequired: Bool enum CodingKeys: String, CodingKey { + case accountID = "id" case provider case channelID = "channel_id" case channelTitle = "channel_title" diff --git a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeAPI.swift b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeAPI.swift index d095af4a..6e0a3a45 100644 --- a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeAPI.swift +++ b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeAPI.swift @@ -64,6 +64,8 @@ private struct YouTubeBroadcastSettingsRequest: Encodable { private struct YouTubePrepareStreamRequest: Encodable { var provider = "youtube" + var accountID: String? = nil + enum CodingKeys: String, CodingKey { case provider; case accountID = "account_id" } } private struct YouTubeEmptyRequest: Encodable {} @@ -93,6 +95,8 @@ struct YouTubeSessionCreateRequest: Encodable, Equatable { private struct BroadcastModeRequest: Encodable { let resolution: String let targets: [String] + var accountID: String? = nil + enum CodingKeys: String, CodingKey { case resolution, targets; case accountID = "account_id" } } private struct UpgradeOfferSelectionRequest: Encodable { @@ -223,8 +227,8 @@ final class YouTubeAPI: PlanAPIClient { ) } - func disconnectStreamingAccount(accessToken: String, provider: String = "youtube") async throws { - guard let url = serverURLProvider("/auth/streaming/accounts/\(provider)") else { + func disconnectStreamingAccount(accessToken: String, provider: String = "youtube", accountID: String? = nil) async throws { + guard let url = serverURLProvider("/auth/streaming/accounts/\(provider)" + (accountID.map { "/" + $0 } ?? "")) else { throw YouTubeAPIError.configuration } var request = URLRequest(url: url) @@ -323,10 +327,10 @@ final class YouTubeAPI: PlanAPIClient { } func changeBroadcastMode(session: YouTubeBroadcastSession, accessToken: String, - resolution: String, targets: Set) async throws -> YouTubeSessionResponse { + resolution: String, targets: Set, accountID: String? = nil) async throws -> YouTubeSessionResponse { try await request(path: "/sessions/\(session.sessionID)/broadcast-mode", method: "PUT", accessToken: accessToken, ownerToken: session.ownerToken, - body: BroadcastModeRequest(resolution: resolution, targets: targets.map(\.rawValue).sorted())) + body: BroadcastModeRequest(resolution: resolution, targets: targets.map(\.rawValue).sorted(), accountID: accountID)) } func selectUpgradeOffer(session: YouTubeBroadcastSession, accessToken: String, @@ -343,11 +347,11 @@ final class YouTubeAPI: PlanAPIClient { } func broadcastDefaults(session: YouTubeBroadcastSession, accessToken: String, - provider: BroadcastSettingsProvider) async throws -> BroadcastSettingsDefaults { + provider: BroadcastSettingsProvider, accountID: String? = nil) async throws -> BroadcastSettingsDefaults { try await request(path: "/sessions/\(session.sessionID)/broadcast/defaults", method: "GET", accessToken: accessToken, ownerToken: session.ownerToken, body: Optional.none, - queryItems: [URLQueryItem(name: "provider", value: provider.rawValue)]) + queryItems: [URLQueryItem(name: "provider", value: provider.rawValue)] + (accountID.map { [URLQueryItem(name: "account_id", value: $0)] } ?? [])) } func youtubeCategories(accessToken: String) async throws -> [YouTubeBroadcastCategory] { @@ -368,14 +372,15 @@ final class YouTubeAPI: PlanAPIClient { func prepareStream( session: YouTubeBroadcastSession, accessToken: String, - provider: BroadcastSettingsProvider = .youtube + provider: BroadcastSettingsProvider = .youtube, + accountID: String? = nil ) async throws -> YouTubeSessionResponse { try await request( path: "/sessions/\(session.sessionID)/stream/prepare", method: "POST", accessToken: accessToken, ownerToken: session.ownerToken, - body: YouTubePrepareStreamRequest(provider: provider.rawValue) + body: YouTubePrepareStreamRequest(provider: provider.rawValue, accountID: accountID) ) } diff --git a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeAuthorization.swift b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeAuthorization.swift index 2d71d3a6..ea43d634 100644 --- a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeAuthorization.swift +++ b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeAuthorization.swift @@ -16,6 +16,7 @@ final class YouTubeAuthorization { clientID: clientID, serverClientID: configuration.webClientID ) + GIDSignIn.sharedInstance.signOut() let result = try await GIDSignIn.sharedInstance.signIn( withPresenting: viewController, hint: nil, diff --git a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift index c7d0c16d..6f0bfcfb 100644 --- a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift +++ b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeIntegration.swift @@ -63,7 +63,7 @@ final class YouTubeIntegration: ObservableObject { /// End the old session before preparing a new one; the caller reuses the normal consent/camera flow. func switchPreparationToOnDevice(accessToken: String?) async -> Bool { - guard offersOnDevicePreparation, !isSwitchingToOnDevice, !isChangingStreamState, + guard offersOnDevicePreparation, !isSwitchingToOnDevice, !isYouTubeConnectionOperationInProgress, !isChangingStreamState, !isChangingAIProcessing, !isPreparingSession, !isConnectingVideo, !isEndingSession, !isChangingBroadcastMode, !isTogglingAnonymization, !isTogglingScreenBlur, broadcastPreparationTask == nil, let accessToken, !accessToken.isEmpty else { return false } @@ -452,7 +452,8 @@ final class YouTubeIntegration: ObservableObject { upgradeOffer?.options?.contains(upgradeOption) == true else { return false } } let snapshot = try await api.changeBroadcastMode(session: current, accessToken: accessToken, - resolution: resolution, targets: targets) + resolution: resolution, targets: targets, + accountID: added.contains(.youtube) ? selectedYouTubeAccountID : nil) guard isCurrent() else { if broadcastModeRequest == request, generation == sessionOperationGeneration, session?.sessionID == current.sessionID { broadcastModeAwaitingStatus = true } @@ -549,17 +550,49 @@ final class YouTubeIntegration: ObservableObject { let settingsEditor: BroadcastSettingsEditor @Published private(set) var streamingAccounts: [YouTubeStreamingAccountSummary] = [] + @Published private(set) var selectedYouTubeAccountID: String? + var youtubeAccounts: [StreamingAccountSummary] { streamingAccounts.filter { $0.provider == "youtube" } } + var selectedYouTubeAccount: StreamingAccountSummary? { + youtubeAccounts.first { $0.accountID == selectedYouTubeAccountID } + } + func canConnectYouTubeChannel(reconnecting: Bool = false) -> Bool { + !isYouTubeAccountChangeBlocked && (reconnecting || youtubeAccounts.count < 5) + } + var canSelectYouTubeChannel: Bool { + !isChangingStreamState && !isChangingBroadcastMode && !isPreparingSession && !isSwitchingToOnDevice + && !isEndingSession && !isSavingLiveSettings + && preparationStatus?.isRunning != true && preparationStatus?.phase != .cancelling + && !isYouTubeConnectionOperationInProgress + && !StreamingAccountPolicy.isInUse("youtube", snapshot: responseState) + } + func selectYouTubeChannel(_ id: String?, accessToken: String?) { + guard canSelectYouTubeChannel, let account = youtubeAccounts.first(where: { $0.accountID == id }) else { return } + selectedYouTubeAccountID = id + connection = account.youtubeConnection + configureSettingsEditor(accessToken: accessToken) + } + func canDisconnectYouTubeChannel(_ account: StreamingAccountSummary) -> Bool { + guard !isYouTubeConnectionOperationInProgress, !isChangingStreamState, !isSwitchingToOnDevice, + !isChangingBroadcastMode, !isPreparingSession, !isEndingSession, + preparationStatus?.isRunning != true, preparationStatus?.phase != .cancelling else { return false } + if let target = responseState.details.targets?.first(where: { $0.provider == "youtube" }), + target.stream.broadcastPhaseValue != .idle { + return target.accountID != nil && target.accountID != account.accountID + } + return !StreamingAccountPolicy.isInUse("youtube", snapshot: responseState) + } + func configureSettingsEditor(accessToken: String?) { let scope = accessToken.flatMap { try? api.broadcastSessionScope(accessToken: $0) } var channels = Dictionary(streamingAccounts.map { ($0.provider, $0.channelID) }, uniquingKeysWith: { _, last in last }) if let connection { channels["youtube"] = connection.channel.id } - settingsEditor.configure(scope: scope, channels: channels) + settingsEditor.configure(scope: scope, channels: channels, youtubeAccountID: selectedYouTubeAccountID) } func settingsScopeKey(accessToken: String?) -> String { let scope = accessToken.flatMap { try? api.broadcastSessionScope(accessToken: $0) } return (scope?.storageKey ?? "") + ":" + streamingAccounts.map { $0.provider + ":" + $0.channelID }.sorted().joined(separator: ":") - + ":" + (connection?.channel.id ?? "") + + ":" + (connection?.channel.id ?? "") + ":" + (selectedYouTubeAccountID ?? "") } func isSettingsAccountConnected(_ provider: BroadcastSettingsProvider) -> Bool { @@ -721,6 +754,7 @@ final class YouTubeIntegration: ObservableObject { resetLiveEditing() resetBroadcastMode() streamingAccounts = [] + selectedYouTubeAccountID = nil chzzkAccountMessage = nil sessionOperationGeneration &+= 1 invalidateConnectionOperation() @@ -895,7 +929,7 @@ final class YouTubeIntegration: ObservableObject { var isYouTubeAccountChangeBlocked: Bool { isBroadcastSettingsLocked || isChangingBroadcastMode || isHandlingUpgradeOffer - || isYouTubeConnectionOperationInProgress + || isSwitchingToOnDevice || isYouTubeConnectionOperationInProgress } var canDisconnectYouTubeAccount: Bool { @@ -907,7 +941,7 @@ final class YouTubeIntegration: ObservableObject { } var isCHZZKAccountChangeBlocked: Bool { - isChangingBroadcastMode || isHandlingUpgradeOffer || isYouTubeConnectionOperationInProgress || isPreparingSession || isChangingStreamState + isChangingBroadcastMode || isHandlingUpgradeOffer || isSwitchingToOnDevice || isYouTubeConnectionOperationInProgress || isPreparingSession || isChangingStreamState || isEndingSession || preparationStatus?.isRunning == true || preparationStatus?.phase == .cancelling || StreamingAccountPolicy.isInUse("chzzk", snapshot: responseState) @@ -981,7 +1015,10 @@ final class YouTubeIntegration: ObservableObject { private func applyStreamingAccounts(_ accounts: [StreamingAccountSummary]) { streamingAccounts = accounts - if let connection = accounts.first(where: { $0.provider == "youtube" })?.youtubeConnection { + let selected = accounts.first { $0.provider == "youtube" && $0.accountID == selectedYouTubeAccountID } + ?? accounts.first { $0.provider == "youtube" } + selectedYouTubeAccountID = selected?.accountID + if let connection = selected?.youtubeConnection { self.connection = connection persistConnection() } else { @@ -1073,18 +1110,27 @@ final class YouTubeIntegration: ObservableObject { presenting: viewController ) guard isCurrentConnectionOperation(generation) else { return } - let response = try await api.connect(serverAuthCode: serverAuthCode, accessToken: accessToken) - guard isCurrentConnectionOperation(generation) else { return } - connection = YouTubeConnection(provider: response.provider, channel: response.channel) - persistConnection() + try await connectAuthorizedYouTubeAccount(serverAuthCode: serverAuthCode, accessToken: accessToken) } catch { guard isCurrentConnectionOperation(generation) else { return } handle(error) } } - func disconnectYouTubeAccount(accessToken: String?) async { - guard canDisconnectYouTubeAccount else { return } + // Keep the previous selection until the full account list confirms its connection ID. + func connectAuthorizedYouTubeAccount(serverAuthCode: String, accessToken: String) async throws { + let generation = connectionOperationGeneration + _ = try await api.connect(serverAuthCode: serverAuthCode, accessToken: accessToken) + guard isCurrentConnectionOperation(generation) else { return } + let accounts = try await api.streamingAccounts(accessToken: accessToken) + guard isCurrentConnectionOperation(generation) else { return } + applyStreamingAccounts(accounts) + } + + func disconnectYouTubeAccount(accessToken: String?, accountID: String? = nil) async { + let account = youtubeAccounts.first { $0.accountID == (accountID ?? selectedYouTubeAccountID) } + if accountID != nil && account == nil { return } + guard account.map(canDisconnectYouTubeChannel) ?? canDisconnectYouTubeAccount else { return } clearError() guard let accessToken, !accessToken.isEmpty else { showError(.unauthorized) @@ -1100,11 +1146,9 @@ final class YouTubeIntegration: ObservableObject { } do { - try await api.disconnectStreamingAccount(accessToken: accessToken) + try await api.disconnectStreamingAccount(accessToken: accessToken, accountID: account?.accountID) guard isCurrentConnectionOperation(generation) else { return } - connection = nil - streamingAccounts.removeAll { $0.provider == "youtube" } - clearPersistedYouTubeConnection() + applyStreamingAccounts(streamingAccounts.filter { $0.provider != "youtube" || $0.accountID != account?.accountID }) } catch { guard isCurrentConnectionOperation(generation) else { return } // A failed DELETE does not prove that the server removed the link. @@ -1562,7 +1606,8 @@ final class YouTubeIntegration: ObservableObject { let preparedSnapshot = try await api.prepareStream( session: session, accessToken: accessToken, - provider: provider + provider: provider, + accountID: provider == .youtube ? selectedYouTubeAccountID : nil ) guard !useEditor || settingsEditor.contextGeneration == editorGeneration else { return false } guard applySessionResponse(preparedSnapshot, sessionID: session.sessionID, generation: generation, provider: provider.rawValue, clearsWarnings: true) else { return false } @@ -2127,6 +2172,7 @@ final class YouTubeIntegration: ObservableObject { resetBroadcastMode() resetUpgradeOffer() streamingAccounts = [] + selectedYouTubeAccountID = nil preparationGeneration &+= 1 preparationStatus = nil selectedBroadcastProviders = [.youtube] diff --git a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeModels.swift b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeModels.swift index 4ce8c007..c5d42d34 100644 --- a/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeModels.swift +++ b/apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeModels.swift @@ -412,17 +412,21 @@ enum BroadcastRemainingTime: Equatable { struct BroadcastTargetState: Decodable, Equatable, Identifiable { let provider: String var stream: YouTubeStreamState + var accountID: String? = nil + var channelTitle: String? = nil var videoID: String? = nil enum CodingKeys: String, CodingKey { case provider, stream + case accountID = "account_id" + case channelTitle = "channel_title" case videoID = "video_id" } var id: String { provider } var title: String { switch provider { - case "youtube": return "YouTube" + case "youtube": return channelTitle.map { "YouTube · " + $0 } ?? "YouTube" case "chzzk": return "CHZZK" default: return provider } diff --git a/apps/ios/InnoLive/InnoLive/Resources/YouTubeChannels.xcstrings b/apps/ios/InnoLive/InnoLive/Resources/YouTubeChannels.xcstrings new file mode 100644 index 00000000..6ae602d4 --- /dev/null +++ b/apps/ios/InnoLive/InnoLive/Resources/YouTubeChannels.xcstrings @@ -0,0 +1,94 @@ +{ + "sourceLanguage": "ko", + "strings": { + "채널 추가 연결": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "채널 추가 연결" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Add channel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "チャンネルを追加" + } + } + } + }, + "YouTube 채널": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "YouTube 채널" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "YouTube channel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "YouTube チャンネル" + } + } + } + }, + "방송할 채널": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "방송할 채널" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Broadcast channel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "配信するチャンネル" + } + } + } + }, + "마지막으로 확인한 채널": { + "localizations": { + "ko": { + "stringUnit": { + "state": "translated", + "value": "마지막으로 확인한 채널" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Last confirmed channel" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最後に確認したチャンネル" + } + } + } + } + }, + "version": "1.0" +} diff --git a/apps/ios/InnoLive/InnoLiveTests/BroadcastSessionLifecycleTests.swift b/apps/ios/InnoLive/InnoLiveTests/BroadcastSessionLifecycleTests.swift index 02152f8f..478e6847 100644 --- a/apps/ios/InnoLive/InnoLiveTests/BroadcastSessionLifecycleTests.swift +++ b/apps/ios/InnoLive/InnoLiveTests/BroadcastSessionLifecycleTests.swift @@ -86,6 +86,50 @@ final class BroadcastSessionLifecycleTests: XCTestCase { XCTAssertEqual(methods, ["POST", "DELETE", "DELETE", "POST"]) } + func testRetainedSessionCleanupBlocksChannelChanges() async throws { + let integration = makeIntegration() + SessionLifecycleURLProtocol.responses = [ + .init(status: 200, body: #"[{"id":"a","provider":"youtube","channel_id":"uca","channel_title":"A","reconnect_required":false},{"id":"b","provider":"youtube","channel_id":"ucb","channel_title":"B","reconnect_required":false}]"#), + .empty(204) + ] + await integration.refreshConnection(accessToken: token) + try store.save(old, scope: scope()) + try await seedExhaustedPlan(integration) + var checked = false + SessionLifecycleURLProtocol.beforeResponse = { request in + guard request.httpMethod == "DELETE" else { return } + checked = true + XCTAssertTrue(integration.isSwitchingToOnDevice) + XCTAssertTrue(integration.isYouTubeAccountChangeBlocked) + XCTAssertTrue(integration.isCHZZKAccountChangeBlocked) + XCTAssertFalse(integration.canSelectYouTubeChannel) + integration.selectYouTubeChannel("b", accessToken: self.token) + XCTAssertEqual(integration.selectedYouTubeAccountID, "a") + guard let account = integration.youtubeAccounts.last else { XCTFail("Expected connected channel"); return } + XCTAssertFalse(integration.canDisconnectYouTubeChannel(account)) + await integration.disconnectYouTubeAccount(accessToken: self.token, accountID: "b") + } + let switched = await integration.switchPreparationToOnDevice(accessToken: token) + XCTAssertTrue(switched) + XCTAssertTrue(checked) + XCTAssertEqual(methods, ["GET", "DELETE"]) + XCTAssertEqual(integration.youtubeAccounts.count, 2) + } + + func testAccountRefreshBlocksOnDeviceSwitch() async throws { + let integration = makeIntegration() + try await seedExhaustedPlan(integration) + SessionLifecycleURLProtocol.responses = [.init(status: 200, body: "[]")] + SessionLifecycleURLProtocol.beforeResponse = { _ in + XCTAssertTrue(integration.isYouTubeConnectionOperationInProgress) + let switched = await integration.switchPreparationToOnDevice(accessToken: self.token) + XCTAssertFalse(switched) + } + await integration.refreshConnection(accessToken: token) + XCTAssertEqual(integration.planProcessingMode, .server) + XCTAssertEqual(methods, ["GET"]) + } + func testAccountResetDuringFallbackRejectsLateSwitch() async throws { let integration = makeIntegration() SessionLifecycleURLProtocol.responses = [.created("old"), .empty(204)] @@ -606,7 +650,7 @@ private final class SessionLifecycleURLProtocol: URLProtocol { } @MainActor static var responses: [Response] = [] @MainActor static var requests: [URLRequest] = [] - @MainActor static var beforeResponse: ((URLRequest) -> Void)? + @MainActor static var beforeResponse: ((URLRequest) async -> Void)? override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } @@ -618,7 +662,7 @@ private final class SessionLifecycleURLProtocol: URLProtocol { return } let response = Self.responses.removeFirst() - Self.beforeResponse?(request) + await Self.beforeResponse?(request) let http = HTTPURLResponse(url: request.url!, statusCode: response.status, httpVersion: nil, headerFields: nil)! client?.urlProtocol(self, didReceive: http, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: Data(response.body.utf8)) diff --git a/apps/ios/InnoLive/InnoLiveTests/BroadcastSettingsTests.swift b/apps/ios/InnoLive/InnoLiveTests/BroadcastSettingsTests.swift index 633889f1..127ce805 100644 --- a/apps/ios/InnoLive/InnoLiveTests/BroadcastSettingsTests.swift +++ b/apps/ios/InnoLive/InnoLiveTests/BroadcastSettingsTests.swift @@ -104,6 +104,20 @@ final class BroadcastSettingsTests: XCTestCase { editor.setUsesDefaults(false, provider: .youtube) XCTAssertEqual(editor.youtube.title, "typed") } + func testChannelChangeIgnoresLateDefaultsAndSendsSelectedAccountID() async throws { + editor.configure(scope: scope("A"), channels: ["youtube": "channel-a"], youtubeAccountID: "account-a") + enqueue("{\"title\":\"channel A stale\"}", delay: 0.1) + let session = try session() + let task = Task { await editor.loadDefaults(session: session, accessToken: "access", provider: .youtube) } + await waitForRequest() + let query = URLComponents(url: try XCTUnwrap(SettingsURLProtocol.requests.first?.url), resolvingAgainstBaseURL: false)?.queryItems + XCTAssertEqual(query?.first { $0.name == "account_id" }?.value, "account-a") + editor.configure(scope: scope("A"), channels: ["youtube": "channel-b"], youtubeAccountID: "account-b") + editor.editYouTube { $0.title = "channel B draft" } + await task.value + XCTAssertEqual(editor.youtube.title, "channel B draft") + } + func testLateDefaultsNeverOverwriteManualInput() async throws { enqueue("{\"title\":\"last\"}", delay: 0.1) let session = try session() diff --git a/apps/ios/InnoLive/InnoLiveTests/YouTubeAccountAPITests.swift b/apps/ios/InnoLive/InnoLiveTests/YouTubeAccountAPITests.swift index 8a877b75..b5ea5aa4 100644 --- a/apps/ios/InnoLive/InnoLiveTests/YouTubeAccountAPITests.swift +++ b/apps/ios/InnoLive/InnoLiveTests/YouTubeAccountAPITests.swift @@ -176,6 +176,32 @@ final class YouTubeAccountAPITests: XCTestCase { XCTAssertEqual(AccountManagementURLProtocol.requests.count, 1) } + func testDisconnectSpecificChannelUsesAccountIDPath() async throws { + AccountManagementURLProtocol.responses = [.init(statusCode: 204, data: Data())] + try await makeAPI().disconnectStreamingAccount(accessToken: "access", accountID: "account-b") + XCTAssertEqual(AccountManagementURLProtocol.requests.last?.url?.path, "/auth/streaming/accounts/youtube/account-b") + } + + func testPrepareAndModeCarryAccountIDButExistingTargetModeOmitsIt() async throws { + let session = try JSONDecoder().decode(YouTubeBroadcastSession.self, from: Data("{\"session_id\":\"session\",\"owner_token\":\"owner\",\"stream\":{\"status\":\"idle\",\"publisher_active\":false,\"reconnect_attempts\":0}}".utf8)) + let api = makeAPI() + for operation in 0..<3 { + AccountManagementURLProtocol.responses = [.init(statusCode: 200, data: Data("{}".utf8))] + if operation == 0 { _ = try? await api.prepareStream(session: session, accessToken: "access", accountID: "account-b") } + else { _ = try? await api.changeBroadcastMode(session: session, accessToken: "access", resolution: "fhd", targets: [.youtube], accountID: operation == 1 ? "account-b" : nil) } + let request = try XCTUnwrap(AccountManagementURLProtocol.requests.last) + var data = request.httpBody + if data == nil, let stream = request.httpBodyStream { + stream.open(); defer { stream.close() } + var bytes = [UInt8](repeating: 0, count: 4096) + let count = stream.read(&bytes, maxLength: bytes.count) + data = Data(bytes.prefix(max(0, count))) + } + let body = try XCTUnwrap(try JSONSerialization.jsonObject(with: XCTUnwrap(data)) as? [String: Any]) + XCTAssertEqual(body["account_id"] as? String, operation == 2 ? nil : "account-b") + } + } + private func makeAPI() -> YouTubeAPI { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [AccountManagementURLProtocol.self] diff --git a/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift b/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift index 1bec12b1..3daa9a2c 100644 --- a/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift +++ b/apps/ios/InnoLive/InnoLiveTests/YouTubeIntegrationAccountTests.swift @@ -162,6 +162,59 @@ final class YouTubeIntegrationAccountTests: XCTestCase { XCTAssertNil(store.loadConnection()) } + func testChannelSelectionSurvivesRefreshAndDisconnectOnlyRemovesChosenChannel() async throws { + let body = Data("[{\"id\":\"a\",\"provider\":\"youtube\",\"channel_id\":\"uca\",\"channel_title\":\"A\",\"reconnect_required\":false},{\"id\":\"b\",\"provider\":\"youtube\",\"channel_id\":\"ucb\",\"channel_title\":\"B\",\"reconnect_required\":false}]".utf8) + YouTubeIntegrationAccountURLProtocol.responses = [.init(statusCode: 200, data: body), .init(statusCode: 200, data: body), .init(statusCode: 204, data: Data())] + let integration = makeIntegration(store: YouTubePreferencesStore(userDefaults: userDefaults)) + await integration.refreshConnection(accessToken: "access") + XCTAssertEqual(integration.youtubeAccounts.count, 2) + integration.selectYouTubeChannel("b", accessToken: "access") + await integration.refreshConnection(accessToken: "access") + XCTAssertEqual(integration.selectedYouTubeAccountID, "b") + XCTAssertEqual(integration.connection?.channel.title, "B") + await integration.disconnectYouTubeAccount(accessToken: "access", accountID: "a") + XCTAssertEqual(integration.youtubeAccounts.map(\.accountID), ["b"]) + XCTAssertEqual(integration.selectedYouTubeAccountID, "b") + let requestCount = YouTubeIntegrationAccountURLProtocol.requests.count + await integration.disconnectYouTubeAccount(accessToken: "access", accountID: "a") + XCTAssertEqual(YouTubeIntegrationAccountURLProtocol.requests.count, requestCount) + XCTAssertEqual(integration.selectedYouTubeAccountID, "b") + integration.reset() + XCTAssertNil(integration.selectedYouTubeAccountID) + } + + func testConnectSuccessThenListFailurePreservesSelectedChannel() async throws { + let store = YouTubePreferencesStore(userDefaults: userDefaults) + let integration = makeIntegration(store: store) + YouTubeIntegrationAccountURLProtocol.responses = [ + .init(statusCode: 200, data: Data("[{\"id\":\"a\",\"provider\":\"youtube\",\"channel_id\":\"uca\",\"channel_title\":\"A\",\"reconnect_required\":false}]".utf8)), + .init(statusCode: 200, data: Data("{\"connected\":true,\"provider\":\"youtube\",\"channel\":{\"id\":\"ucb\",\"title\":\"B\"}}".utf8)), + .init(statusCode: 503, data: Data("{}".utf8)) + ] + await integration.refreshConnection(accessToken: "access") + do { + try await integration.connectAuthorizedYouTubeAccount(serverAuthCode: "code", accessToken: "access") + XCTFail("Account list failure must be reported") + } catch {} + XCTAssertEqual(YouTubeIntegrationAccountURLProtocol.requests.count, 3) + XCTAssertEqual(integration.selectedYouTubeAccountID, "a") + XCTAssertEqual(integration.connection?.channel.id, "uca") + XCTAssertEqual(store.loadConnection()?.channel.id, "uca") + } + + func testFiveChannelsStillAllowReconnectButNotAddition() async throws { + let accounts = (1...5).map { index in + ["id": "account-\(index)", "provider": "youtube", "channel_id": "uc-\(index)", + "channel_title": "Channel \(index)", "reconnect_required": true] as [String: Any] + } + YouTubeIntegrationAccountURLProtocol.responses = [.init(statusCode: 200, data: try JSONSerialization.data(withJSONObject: accounts))] + let integration = makeIntegration(store: YouTubePreferencesStore(userDefaults: userDefaults)) + await integration.refreshConnection(accessToken: "access") + XCTAssertEqual(integration.youtubeAccounts.count, 5) + XCTAssertFalse(integration.canConnectYouTubeChannel()) + XCTAssertTrue(integration.canConnectYouTubeChannel(reconnecting: true)) + } + private func makeIntegration(store: YouTubePreferencesStore) -> YouTubeIntegration { let configuration = URLSessionConfiguration.ephemeral configuration.protocolClasses = [YouTubeIntegrationAccountURLProtocol.self] diff --git a/contracts/README.md b/contracts/README.md index f3c248ea..ba6cacf8 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -2,6 +2,7 @@ This directory is the source of truth for behavior shared by all clients. +- `api/youtube-multi-channel-v1.md`: connected YouTube account selection, defaults, and channel-specific disconnect - `api/youtube-viewer-chat-v1.md`: optional YouTube video IDs and iOS viewer-chat lifecycle - `api/streaming-accounts-v1.md`: CHZZK OAuth and shared account listing/disconnection semantics - `api/broadcast-settings-v1.md`: provider-specific settings, categories, defaults and draft isolation diff --git a/contracts/api/youtube-multi-channel-v1.md b/contracts/api/youtube-multi-channel-v1.md new file mode 100644 index 00000000..91e9d693 --- /dev/null +++ b/contracts/api/youtube-multi-channel-v1.md @@ -0,0 +1,15 @@ +# YouTube multi-channel v1 + +Source: [server request, 2026-10-12](https://app.notion.com/p/3f7ba097c659808a978cc00a3ce15c95). + +- GET `/auth/streaming/accounts`: optional `id` is the streaming account UUID; preserve all YouTube entries, channel_id, channel_title and reconnect_required. +- POST `/auth/youtube/connect`: unchanged native authorization code payload. Reconnecting the same channel updates it; another channel adds it, up to five. +- DELETE `/auth/streaming/accounts/youtube/{id}`: remove only that connection. A live target's account cannot be removed; server `streaming_account_in_use` remains authoritative. +- POST `/sessions/{id}/stream/prepare`: optional `account_id` selects YouTube connection. Omit for CHZZK. Multiple YouTube channels require selection. +- GET `/sessions/{id}/broadcast/defaults`: `provider=youtube&account_id={id}`. Settings draft and response generation are scoped to the selected account/channel. Ignore previous channel responses after selection changes. +- PUT `/sessions/{id}/broadcast-mode`: optional `account_id` only when adding YouTube. Existing YouTube target keeps its original channel. +- Session `targets[]`: optional `account_id`, `channel_title`; missing fields remain compatible with earlier servers. + +## Compatibility + +New fields are additive. Legacy one-channel responses without `id` retain provider-only prepare/defaults/delete. Multi-channel responses must supply distinct account UUIDs. Authentication v2 is outside this change. No server API fields are renamed. diff --git a/contracts/fixtures/youtube-multi-channel.v1.json b/contracts/fixtures/youtube-multi-channel.v1.json new file mode 100644 index 00000000..0f170100 --- /dev/null +++ b/contracts/fixtures/youtube-multi-channel.v1.json @@ -0,0 +1,28 @@ +{ + "accounts": [ + { + "id": "11111111-1111-4111-8111-111111111111", + "provider": "youtube", + "channel_id": "UCmain", + "channel_title": "Main", + "reconnect_required": false + }, + { + "id": "22222222-2222-4222-8222-222222222222", + "provider": "youtube", + "channel_id": "UCbrand", + "channel_title": "Brand", + "reconnect_required": false + } + ], + "prepare": { + "provider": "youtube", + "account_id": "22222222-2222-4222-8222-222222222222" + }, + "target": { + "provider": "youtube", + "account_id": "22222222-2222-4222-8222-222222222222", + "channel_title": "Brand", + "stream": {} + } +} diff --git a/docs/ios-youtube-multi-channel.md b/docs/ios-youtube-multi-channel.md new file mode 100644 index 00000000..287f1e55 --- /dev/null +++ b/docs/ios-youtube-multi-channel.md @@ -0,0 +1,7 @@ +# iOS YouTube channel selection + +The platform settings screen lists each connected YouTube channel with a separate disconnect confirmation. Channel addition invokes Google authorization with the previous SDK session signed out so the user can choose another account. The app access token is unchanged. + +Broadcast settings shows the channel picker before preparing YouTube or adding YouTube during CHZZK broadcasting. When multiple channels are connected, each preparation/addition asks the user to confirm the selected channel name. Selection updates the settings scope and reloads defaults. Saving succeeds before prepare receives the selected account ID. A prepared/live YouTube target locks channel selection; the target title displays the server channel_title. Connecting an additional channel preserves the selected connection until the account list confirms both its channel and connection ID. A failed list refresh keeps the previous selection and persisted connection. Refresh preserves the selected connection while it exists. Removing another channel preserves it. + +Verification: focused URLProtocol tests cover account-specific DELETE, prepare/mode payloads, refresh/selection, and late defaults from a previous channel. Actual Google/brand-channel consent and production two-channel streaming require account/device verification.