Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [:]
Expand All @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import Foundation

struct StreamingAccountSummary: Decodable, Equatable {
var accountID: String? = nil
let provider: String
let channelID: String
let channelTitle: String
let connectedAt: String?
let reconnectRequired: Bool

enum CodingKeys: String, CodingKey {
case accountID = "id"
case provider
case channelID = "channel_id"
case channelTitle = "channel_title"
Expand Down
21 changes: 13 additions & 8 deletions apps/ios/InnoLive/InnoLive/Features/YouTube/YouTubeAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -323,10 +327,10 @@ final class YouTubeAPI: PlanAPIClient {
}

func changeBroadcastMode(session: YouTubeBroadcastSession, accessToken: String,
resolution: String, targets: Set<BroadcastSettingsProvider>) async throws -> YouTubeSessionResponse {
resolution: String, targets: Set<BroadcastSettingsProvider>, 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,
Expand All @@ -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<YouTubeEmptyRequest>.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] {
Expand All @@ -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)
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading