fix(live-room): 캡쳐 보호 음소거 동기화

This commit is contained in:
Yu Sung
2026-03-24 19:19:29 +09:00
parent 0844c6f4d7
commit 44daabdcae
3 changed files with 484 additions and 46 deletions

View File

@@ -32,6 +32,10 @@ struct LiveRoomViewV2: View {
@State private var guestFollowButtonTypeOverride: FollowButtonImageType? = nil
@State private var selectedChatForDelete: LiveRoomNormalChat? = nil
@State private var isShowChatDeleteDialog: Bool = false
// /
@State private var isScreenCaptureProtected: Bool = UIScreen.main.isCaptured
@State private var shouldRestoreSpeakerMuteAfterCapture: Bool = false
@State private var shouldRestoreMicMuteAfterCapture: Bool = false
let heartWaveTimer = Timer.publish(every: 1/60, on: .main, in: .common).autoconnect()
private var appliedKeyboardHeight: CGFloat {
@@ -59,7 +63,8 @@ struct LiveRoomViewV2: View {
}
var body: some View {
ZStack {
ScreenCaptureSecureContainer {
ZStack {
Color.black.edgesIgnoringSafeArea(.all)
VStack(spacing: 0) {
@@ -527,9 +532,11 @@ struct LiveRoomViewV2: View {
.onAppear {
UIApplication.shared.isIdleTimerDisabled = true
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
viewModel.initAgoraEngine()
//
applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured)
viewModel.getMemberCan()
viewModel.initAgoraEngine()
viewModel.getRoomInfo()
viewModel.getBlockedMemberIdList()
@@ -544,6 +551,8 @@ struct LiveRoomViewV2: View {
.onDisappear {
UIApplication.shared.isIdleTimerDisabled = false
NotificationCenter.default.removeObserver(self)
//
releaseForcedCaptureMute()
viewModel.stopV2VTranslationIfJoined()
viewModel.stopPeriodicPlaybackValidation()
}
@@ -845,46 +854,54 @@ struct LiveRoomViewV2: View {
if isImageLoading {
LoadingView()
}
if isScreenCaptureProtected {
Color.black
.ignoresSafeArea()
.allowsHitTesting(true)
}
}
.overlay(alignment: .center) {
ZStack {
// ( )
WaterHeartView(progress: waterProgress, show: showWaterHeart, phase: wavePhase)
.frame(width: 210, height: 210)
.allowsHitTesting(false)
.opacity(showWaterHeart ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: showWaterHeart)
// ( ) - 01 (1.0~1.5s )
WaterHeartView(progress: viewModel.remoteWaterProgress,
show: viewModel.isShowRemoteBigHeart,
phase: viewModel.remoteWavePhase)
.frame(width: 210, height: 210)
.scaleEffect(viewModel.remoteHeartScale)
.allowsHitTesting(false)
//
.opacity((viewModel.isShowRemoteBigHeart && !showWaterHeart) ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: viewModel.isShowRemoteBigHeart)
// ( , #ff959a)
if !isScreenCaptureProtected {
ZStack {
ForEach(viewModel.bigHeartParticles) { p in
HeartShape()
.fill(Color(hex: "ff959a"))
.frame(width: p.size * p.scale, height: p.size * p.scale)
.rotationEffect(.degrees(p.rotation))
.offset(x: p.x, y: p.y)
.opacity(p.opacity)
.allowsHitTesting(false)
// ( )
WaterHeartView(progress: waterProgress, show: showWaterHeart, phase: wavePhase)
.frame(width: 210, height: 210)
.allowsHitTesting(false)
.opacity(showWaterHeart ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: showWaterHeart)
// ( ) - 01 (1.0~1.5s )
WaterHeartView(progress: viewModel.remoteWaterProgress,
show: viewModel.isShowRemoteBigHeart,
phase: viewModel.remoteWavePhase)
.frame(width: 210, height: 210)
.scaleEffect(viewModel.remoteHeartScale)
.allowsHitTesting(false)
//
.opacity((viewModel.isShowRemoteBigHeart && !showWaterHeart) ? 1 : 0)
.animation(.easeInOut(duration: 0.2), value: viewModel.isShowRemoteBigHeart)
// ( , #ff959a)
ZStack {
ForEach(viewModel.bigHeartParticles) { p in
HeartShape()
.fill(Color(hex: "ff959a"))
.frame(width: p.size * p.scale, height: p.size * p.scale)
.rotationEffect(.degrees(p.rotation))
.offset(x: p.x, y: p.y)
.opacity(p.opacity)
.allowsHitTesting(false)
}
}
// drawingGroup (Rect) ,
.frame(maxWidth: .infinity, maxHeight: .infinity)
.drawingGroup(opaque: false, colorMode: .linear)
}
// drawingGroup (Rect) ,
.frame(maxWidth: .infinity, maxHeight: .infinity)
.drawingGroup(opaque: false, colorMode: .linear)
//
.offset(y: appliedKeyboardHeight > 0 ? -(appliedKeyboardHeight / 2 + 60) : 0)
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: appliedKeyboardHeight)
}
//
.offset(y: appliedKeyboardHeight > 0 ? -(appliedKeyboardHeight / 2 + 60) : 0)
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: appliedKeyboardHeight)
}
.onReceive(heartWaveTimer) { _ in
guard isLongPressingHeart else { return }
@@ -909,11 +926,26 @@ struct LiveRoomViewV2: View {
.onReceive(NotificationCenter.default.publisher(for: .requestLiveRoomQuitForExternalNavigation)) { _ in
viewModel.quitRoom()
}
// ( / )
.onReceive(NotificationCenter.default.publisher(for: UIScreen.capturedDidChangeNotification)) { _ in
applyScreenCaptureProtection(isCaptured: UIScreen.main.isCaptured)
}
.onChange(of: viewModel.isChatFrozenForCurrentUser) { isFrozen in
if isFrozen {
hideKeyboard()
}
}
.onChange(of: viewModel.role) { role in
guard isScreenCaptureProtected,
role == .SPEAKER,
!viewModel.isMute else {
return
}
//
viewModel.setMute(true)
shouldRestoreMicMuteAfterCapture = true
}
.ignoresSafeArea(.keyboard)
.edgesIgnoringSafeArea(appliedKeyboardHeight > 0 ? .bottom : .init())
.sheet(
@@ -1022,6 +1054,7 @@ struct LiveRoomViewV2: View {
hideKeyboard()
}
}
}
}
private func estimatedHeight(for text: String, width: CGFloat) -> CGFloat {
@@ -1072,7 +1105,205 @@ struct LiveRoomViewV2: View {
}
}
// / SwiftUI
private struct ScreenCaptureSecureContainer<Content: View>: UIViewControllerRepresentable {
let content: Content
init(@ViewBuilder content: () -> Content) {
self.content = content()
}
func makeUIViewController(context: Context) -> ScreenCaptureSecureHostingController<Content> {
ScreenCaptureSecureHostingController(rootView: content)
}
func updateUIViewController(_ uiViewController: ScreenCaptureSecureHostingController<Content>, context: Context) {
uiViewController.update(rootView: content)
}
}
// SwiftUI UIKit
private final class ScreenCaptureSecureHostingController<Content: View>: UIViewController {
private let secureContainerView = ScreenCaptureSecureView()
private let hostingController: UIHostingController<Content>
init(rootView: Content) {
hostingController = UIHostingController(rootView: rootView)
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func loadView() {
view = secureContainerView
}
override func viewDidLoad() {
super.viewDidLoad()
hostingController.view.backgroundColor = .clear
addChild(hostingController)
secureContainerView.embed(contentView: hostingController.view)
hostingController.didMove(toParent: self)
}
func update(rootView: Content) {
hostingController.rootView = rootView
secureContainerView.embed(contentView: hostingController.view)
}
}
// isSecureTextEntry
private final class ScreenCaptureSecureView: UIView {
private let secureTextField = ScreenCaptureSecureTextField()
private weak var secureContentView: UIView?
private let failClosedOverlayView = UIView()
private var didLogFailClosedActivation = false
override init(frame: CGRect) {
super.init(frame: frame)
setup()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setup() {
backgroundColor = .clear
//
secureTextField.translatesAutoresizingMaskIntoConstraints = false
secureTextField.isSecureTextEntry = true
secureTextField.backgroundColor = .clear
secureTextField.textColor = .clear
secureTextField.tintColor = .clear
addSubview(secureTextField)
NSLayoutConstraint.activate([
secureTextField.topAnchor.constraint(equalTo: topAnchor),
secureTextField.leadingAnchor.constraint(equalTo: leadingAnchor),
secureTextField.trailingAnchor.constraint(equalTo: trailingAnchor),
secureTextField.bottomAnchor.constraint(equalTo: bottomAnchor)
])
failClosedOverlayView.translatesAutoresizingMaskIntoConstraints = false
failClosedOverlayView.backgroundColor = .black
failClosedOverlayView.isUserInteractionEnabled = true
failClosedOverlayView.isHidden = true
addSubview(failClosedOverlayView)
NSLayoutConstraint.activate([
failClosedOverlayView.topAnchor.constraint(equalTo: topAnchor),
failClosedOverlayView.leadingAnchor.constraint(equalTo: leadingAnchor),
failClosedOverlayView.trailingAnchor.constraint(equalTo: trailingAnchor),
failClosedOverlayView.bottomAnchor.constraint(equalTo: bottomAnchor)
])
//
secureContentView = resolveSecureContentView()
updateFailClosedState(isActive: secureContentView == nil)
}
func embed(contentView: UIView) {
if secureContentView == nil {
secureContentView = resolveSecureContentView()
}
guard let secureContentView else {
contentView.removeFromSuperview()
updateFailClosedState(isActive: true)
return
}
updateFailClosedState(isActive: false)
guard contentView.superview !== secureContentView else {
return
}
//
contentView.removeFromSuperview()
contentView.translatesAutoresizingMaskIntoConstraints = false
secureContentView.addSubview(contentView)
NSLayoutConstraint.activate([
contentView.topAnchor.constraint(equalTo: secureContentView.topAnchor),
contentView.leadingAnchor.constraint(equalTo: secureContentView.leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: secureContentView.trailingAnchor),
contentView.bottomAnchor.constraint(equalTo: secureContentView.bottomAnchor)
])
}
private func resolveSecureContentView() -> UIView? {
secureTextField.subviews.first {
let className = NSStringFromClass(type(of: $0))
return className.contains("CanvasView")
}
}
private func updateFailClosedState(isActive: Bool) {
failClosedOverlayView.isHidden = !isActive
guard isActive, !didLogFailClosedActivation else {
return
}
didLogFailClosedActivation = true
ERROR_LOG("[ScreenCaptureSecureView] secure canvas lookup failed. Activating fail-closed overlay.")
}
}
// UI
private final class ScreenCaptureSecureTextField: UITextField {
override var canBecomeFirstResponder: Bool {
false
}
override func becomeFirstResponder() -> Bool {
false
}
}
private extension LiveRoomViewV2 {
func applyScreenCaptureProtection(isCaptured: Bool) {
// UI
isScreenCaptureProtected = isCaptured
if isCaptured {
//
if !viewModel.isSpeakerMute {
viewModel.setSpeakerMute(true)
shouldRestoreSpeakerMuteAfterCapture = true
}
if !viewModel.isMute {
viewModel.setMute(true)
shouldRestoreMicMuteAfterCapture = true
}
return
}
releaseForcedCaptureMute()
}
func releaseForcedCaptureMute() {
//
if shouldRestoreSpeakerMuteAfterCapture {
if viewModel.isSpeakerMute {
viewModel.setSpeakerMute(false)
}
shouldRestoreSpeakerMuteAfterCapture = false
}
if shouldRestoreMicMuteAfterCapture {
if viewModel.isMute {
viewModel.setMute(false)
}
shouldRestoreMicMuteAfterCapture = false
}
}
func guestFollowButtonType(liveRoomInfo: GetRoomInfoResponse) -> FollowButtonImageType {
if liveRoomInfo.isFollowing {
return guestFollowButtonTypeOverride ?? .following