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
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

### 🔄 Changed
### ✅ Added
- Improved support for moderation events handling. [#1004](https://github.com/GetStream/stream-video-swift/pull/1004)

# [1.37.0](https://github.com/GetStream/stream-video-swift/releases/tag/1.37.0)
_November 28, 2025_
Expand Down
35 changes: 35 additions & 0 deletions DemoApp/Sources/Components/AppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,41 @@ extension AppEnvironment {
}()
}

extension AppEnvironment {
enum ModerationVideoPolicy: Hashable, Debuggable, Sendable {

case blur(TimeInterval), pixelate(TimeInterval)

var title: String {
switch self {
case let .blur(duration):
if duration > 0 {
return "Blur (\(duration)s)"
} else {
return "Blur"
}
case let .pixelate(duration):
if duration > 0 {
return "Pixelate (\(duration)s)"
} else {
return "Pixelate"
}
}
}

var value: Moderation.VideoPolicy {
switch self {
case .blur(let duration):
return Moderation.VideoPolicy(duration: duration, videoFilter: .blur)
case .pixelate(let duration):
return Moderation.VideoPolicy(duration: duration, videoFilter: .pixelate)
}
}
}

static var moderationVideoPolicy: ModerationVideoPolicy = .blur(20)
}

extension AppEnvironment {

static var clientCapabilities: Set<ClientCapability>?
Expand Down
2 changes: 2 additions & 0 deletions DemoApp/Sources/Components/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,8 @@ final class AppState: ObservableObject {
AppEnvironment
.proximityPolicies
.forEach { try? activeCall.addProximityPolicy($0.value) }

activeCall.moderation.setVideoPolicy(AppEnvironment.moderationVideoPolicy.value)
}
}

Expand Down
7 changes: 7 additions & 0 deletions DemoApp/Sources/Components/Router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@ final class Router: ObservableObject {
appState.unsecureRepository.save(configuration: AppEnvironment.configuration)
appState.unsecureRepository.save(baseURL: AppEnvironment.baseURL)

switch AppEnvironment.baseURL {
case let .custom(_, apiKey, _):
appState.apiKey = apiKey
default:
break
}

Task {
do {
try await loadLoggedInUser()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import CoreGraphics
import CoreImage
import Foundation
import StreamVideo

/// Applies a pixelation effect to fully obfuscate a frame.
final class ModerationPixelateVideoFilter: VideoFilter, @unchecked Sendable {
@available(*, unavailable)
override public init(
id: String,
name: String,
filter: @escaping (Input) async -> CIImage
) { fatalError() }

/// Creates a moderation pixelation filter.
/// - Parameters:
/// - pixelBlockFactor: Larger values create bigger pixel blocks.
/// - downscaleFactor: Downscale before pixelation to boost performance.
init(
pixelBlockFactor: CGFloat = 40,
downscaleFactor: CGFloat = 0.5
) {
let clampedDownscale = max(min(downscaleFactor, 1), 0.1)
let blockFactor = max(pixelBlockFactor, 1)
let name = String(describing: type(of: self)).lowercased()

super.init(
id: "io.getstream.\(name)",
name: name,
filter: { input in
let srcImage = input.originalImage
let extent = srcImage.extent

// Optional downscale before pixelation for better performance.
let workingImage: CIImage
if clampedDownscale < 1 {
workingImage = srcImage.transformed(
by: CGAffineTransform(
scaleX: clampedDownscale,
y: clampedDownscale
)
)
} else {
workingImage = srcImage
}

let workingExtent = workingImage.extent

let pixelate = CIFilter.pixellate()
pixelate.inputImage = workingImage

// Big cells -> heavy censorship, size-aware.
let maxDimension = max(workingExtent.width, workingExtent.height)
pixelate.scale = Float(maxDimension / blockFactor)

guard var out = pixelate.outputImage else {
return srcImage
}

// If we downscaled, scale back up to original size. t
if clampedDownscale < 1 {
let scaleBack = 1 / clampedDownscale
out = out.transformed(
by: CGAffineTransform(
scaleX: scaleBack,
y: scaleBack
)
)
}

// Crop to original extent to avoid any edge artifacts.
return out.cropped(to: extent)
}
)
}
}

extension VideoFilter {

/// Applies a pixelation effect over the entire frame.
static let pixelate: VideoFilter = ModerationPixelateVideoFilter()
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ struct DemoEffectButton: View {
switch effect {
case .none:
return appState.videoFilter == nil
case .pixelate:
return appState.videoFilter?.id == VideoFilter.pixelate.id
case .blur:
return appState.videoFilter?.id == VideoFilter.blur.id
case .blurBackground:
return appState.videoFilter?.id == VideoFilter.blurredBackground.id
default:
return appState.videoFilter?.id == effect.rawValue
Expand Down Expand Up @@ -70,6 +74,8 @@ struct DemoEffectButton: View {
enum BackgroundEffect: String, CaseIterable, Identifiable {
case none
case blur
case pixelate
case blurBackground
case amsterdam1 = "amsterdam-1"
case amsterdam2 = "amsterdam-2"
case boulder1 = "boulder-1"
Expand All @@ -84,7 +90,11 @@ enum BackgroundEffect: String, CaseIterable, Identifiable {
switch self {
case .none:
return nil
case .pixelate:
return .pixelate
case .blur:
return .blur
case .blurBackground:
return .blurredBackground
default:
guard
Expand All @@ -101,8 +111,12 @@ enum BackgroundEffect: String, CaseIterable, Identifiable {
switch self {
case .none:
return Image(systemName: "circle.slash")
case .pixelate:
return Image(systemName: "square.grid.3x3.square")
case .blur:
return Image(systemName: "square.stack.3d.forward.dottedline.fill")
case .blurBackground:
return Image(systemName: "square.stack.3d.forward.dottedline")
default:
return Image(rawValue)
}
Expand All @@ -112,7 +126,7 @@ enum BackgroundEffect: String, CaseIterable, Identifiable {
switch self {
case .none:
return 10
case .blur:
case .pixelate, .blur, .blurBackground:
return 10
default:
return 0
Expand Down
62 changes: 42 additions & 20 deletions DemoApp/Sources/Views/Login/DebugMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,13 @@ struct DebugMenu: View {
didSet { AppEnvironment.clientCapabilities = preferredClientCapabilities }
}

@State private var moderationVideoPolicy = AppEnvironment.moderationVideoPolicy {
didSet { AppEnvironment.moderationVideoPolicy = moderationVideoPolicy }
}

@State private var customModerationVideoPolicyDuration: TimeInterval = 20
@State private var presentsCustomModerationVideoPolicyDuration = false

var body: some View {
Menu {
makeMenu(
Expand Down Expand Up @@ -254,6 +261,13 @@ struct DebugMenu: View {
label: "Auto Leave policy"
) { self.autoLeavePolicy = $0 }

makeMenu(
for: [.blur(customModerationVideoPolicyDuration), .pixelate(customModerationVideoPolicyDuration)],
currentValue: moderationVideoPolicy,
additionalItems: { customModerationVideoView },
label: "Moderation Video Policy"
) { self.moderationVideoPolicy = $0 }

makeMenu(
for: [.never, .twoMinutes],
currentValue: disconnectionTimeout,
Expand Down Expand Up @@ -384,6 +398,21 @@ struct DebugMenu: View {
self.preferredCallType = customPreferredCallType
}
)
.alertWithTextField(
title: "Enter moderation policy duration in seconds",
placeholder: "Duration",
presentationBinding: $presentsCustomModerationVideoPolicyDuration,
valueBinding: $customModerationVideoPolicyDuration,
transformer: { TimeInterval($0) ?? 0 },
action: {
switch moderationVideoPolicy {
case .blur:
moderationVideoPolicy = .blur(customModerationVideoPolicyDuration)
case .pixelate:
moderationVideoPolicy = .pixelate(customModerationVideoPolicyDuration)
}
}
)
}

@ViewBuilder
Expand All @@ -402,11 +431,7 @@ struct DebugMenu: View {
Button {
presentsCustomEnvironmentSetup = true
} label: {
Label {
Text("Custom")
} icon: {
EmptyView()
}
Text("Custom")
}
}
}
Expand All @@ -427,11 +452,7 @@ struct DebugMenu: View {
Button {
presentsCustomTokenExpiration = true
} label: {
Label {
Text("Custom")
} icon: {
EmptyView()
}
Text("Custom")
}
}
}
Expand All @@ -452,11 +473,7 @@ struct DebugMenu: View {
Button {
presentsCustomCallExpiration = true
} label: {
Label {
Text("Custom")
} icon: {
EmptyView()
}
Text("Custom")
}
}
}
Expand All @@ -477,11 +494,7 @@ struct DebugMenu: View {
Button {
presentsCustomDisconnectionTimeout = true
} label: {
Label {
Text("Custom")
} icon: {
EmptyView()
}
Text("Custom")
}
}
}
Expand All @@ -499,6 +512,15 @@ struct DebugMenu: View {
}
}

@ViewBuilder
private var customModerationVideoView: some View {
Button {
presentsCustomModerationVideoPolicyDuration = true
} label: {
Text("Duration")
}
}

@ViewBuilder
private func makeMenu<Item: Debuggable>(
for items: [Item],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
objects = {

/* Begin PBXBuildFile section */
400062A42EDF390D0086E14B /* 27-moderation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400062A32EDF390D0086E14B /* 27-moderation.swift */; };
400D91B62B63D88100EBA47D /* DocumentationTests.h in Headers */ = {isa = PBXBuildFile; fileRef = 400D91B52B63D88100EBA47D /* DocumentationTests.h */; settings = {ATTRIBUTES = (Public, ); }; };
400D91C72B63D96800EBA47D /* 03-quickstart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400D91C62B63D96800EBA47D /* 03-quickstart.swift */; };
400D91C92B63DB3700EBA47D /* 01-client-auth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 400D91C82B63DB3700EBA47D /* 01-client-auth.swift */; };
Expand Down Expand Up @@ -90,6 +91,7 @@
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
400062A32EDF390D0086E14B /* 27-moderation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "27-moderation.swift"; sourceTree = "<group>"; };
400D91B22B63D88100EBA47D /* DocumentationTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = DocumentationTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
400D91B52B63D88100EBA47D /* DocumentationTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = DocumentationTests.h; sourceTree = "<group>"; };
400D91C62B63D96800EBA47D /* 03-quickstart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "03-quickstart.swift"; sourceTree = "<group>"; };
Expand Down Expand Up @@ -292,6 +294,7 @@
401C1EF32D494CED00304609 /* 24-closed-captions.swift */,
40895E612E264BB000D3049D /* 25-incoming-video-state.swift */,
4050725C2E5F416A003D2109 /* 26-permissions-prompt-customization.swift */,
400062A32EDF390D0086E14B /* 27-moderation.swift */,
);
path = "05-ui-cookbook";
sourceTree = "<group>";
Expand Down Expand Up @@ -481,6 +484,7 @@
400D91D52B63E27300EBA47D /* 07-dependency-injection.swift in Sources */,
40FFDC512B63EF58004DA7A2 /* 05-call-controls.swift in Sources */,
400D91D32B63DFA500EBA47D /* 06-querying-calls.swift in Sources */,
400062A42EDF390D0086E14B /* 27-moderation.swift in Sources */,
401C1EF42D494CED00304609 /* 24-closed-captions.swift in Sources */,
845494D72DB9039000211413 /* 13-livestreaming.swift in Sources */,
40FFDC922B63FF70004DA7A2 /* 06-lobby-preview.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//
// Copyright © 2025 Stream.io Inc. All rights reserved.
//

import Combine
import StreamVideo
import StreamVideoSwiftUI
import SwiftUI

@MainActor
private func content() {
container {
struct CallContainer<Factory: ViewFactory>: View {
@StateObject var viewModel: CallViewModel

var body: some View {
Group {
// Body content
// ...
}
.moderationWarning(call: viewModel.call)
}
}
}

container {
let call = streamVideo.call(callType: "default", callId: "my-call-id")
let videoPolicy = Moderation.VideoPolicy(duration: 10, videoFilter: .blur)
call.moderation.setVideoPolicy(videoPolicy)
}
}
Loading