LiveProject/activity/stream/LiveActivity.swift | ●●●●● patch | view | raw | blame | history | |
LiveProject/activity/stream/LiveViewModel.swift | ●●●●● patch | view | raw | blame | history | |
LiveProject/activity/stream/views/MiniWindow.swift | ●●●●● patch | view | raw | blame | history | |
LiveProject/controller/CameraCapture.swift | ●●●●● patch | view | raw | blame | history | |
LiveProject/data/IconInfo.swift | ●●●●● patch | view | raw | blame | history | |
LiveProject/enum/Icons.swift | ●●●●● patch | view | raw | blame | history | |
LiveProject/tool/MetalRenderer.swift | ●●●●● patch | view | raw | blame | history | |
LiveProject/views/VideoRendererView.swift | ●●●●● patch | view | raw | blame | history |
LiveProject/activity/stream/LiveActivity.swift
@@ -34,12 +34,19 @@ Color.clear .ignoresSafeArea() // 填满全屏 ZStack{ VideoRendererView(pixelBuffer: $mViewModel.pixelBuffer).background(Color.black).frame(width: mainSize.width,height:mainSize.height) let optionalIntBinding = Binding<Int?>( get: { 0 }, set: { newValue in } ) VideoRendererView(pixelBuffer: $mViewModel.pixelBuffer,rotate: optionalIntBinding).background(Color.black).frame(width: mainSize.width,height:mainSize.height) .frame(maxWidth: .infinity,maxHeight: .infinity,alignment: .topTrailing) ForEach(miniWindows, id: \.id) { miniWindow in NewMiniWindow(miniWindow: miniWindow) } }.border(Color.blue) }.border(Color.blue).clipped() .overlay( ForEach(miniWindows, id: \.id) { miniWindow in NewMiniWindow(miniData: miniWindow) } ) VStack{ Spacer() @@ -64,6 +71,7 @@ } }) .border(Color.red) .clipped() .onDisappear { print("onDisappear 视图消失了!") @@ -157,13 +165,29 @@ ForEach(devices, id: \.self) { device in MButton(icon: device.icon,text: device.name){ mViewModel.newWindowAction(device: device){ status in var miniData = MiniWindowData(streamType: device.type); switch device.type{ case .CAMERA: miniData.hasAudio = false; miniData.hasVideo = true; break; case .MICROPHONE: miniData.hasAudio = true; miniData.hasVideo = false; break; default: break } miniData.mainSize = mainSize miniData.name = device.name; mViewModel.newWindowAction(minidata: miniData){ status in withAnimation{ showDeviceDialog = false; } miniWindows.append(MiniWindowData(streamType: device.type)) miniWindows.append(miniData); } print("\(device.name) click \(self.miniWindows.count)") print("\(device.name) \(device.type) click \(self.miniWindows.count)") } } } @@ -228,12 +252,13 @@ } } func NewMiniWindow(miniWindow:MiniWindowData) -> some View{ MiniWindow(miniData: miniWindow) func NewMiniWindow(miniData:MiniWindowData) -> some View{ return MiniWindow(miniData: miniData) .frame(maxWidth: .infinity,maxHeight: .infinity,alignment: .topLeading) .onCloseClick { guard let index = miniWindows.firstIndex(where: { $0.id == miniWindow.id }) else { return } guard let index = miniWindows.firstIndex(where: { $0.id == miniData.id }) else { return } miniWindows.remove(at: index) mViewModel.closeWindowAction(miniData: miniData) } } LiveProject/activity/stream/LiveViewModel.swift
@@ -10,15 +10,15 @@ class LiveViewModel: ObservableObject { @Published var pixelBuffer: CVPixelBuffer? let encoder = H264Encoder(width: 1080, height: 1920, fps: 30, bitrate: 1_000_000) //let encoder = H264Encoder(width: 1080, height: 1920, fps: 30, bitrate: 1_000_000) var frameIndex: Int64 = 0 let encodeQueue = DispatchQueue(label: "encoder.queue") //let encodeQueue = DispatchQueue(label: "encoder.queue") lazy var camera = CameraCapture() var timestamp = Int(Date().timeIntervalSince1970 * 1000) func newWindowAction(device:DeviceInfo,completion: @escaping (Bool) -> Void = {b in}){ switch device.type{ func newWindowAction(minidata:MiniWindowData,completion: @escaping (Bool) -> Void = {b in}){ switch minidata.streamType{ case StreamType.CAMERA: requestCameraPermission(mediaType: .video){ staus in if(staus){ @@ -33,15 +33,18 @@ print("Invalid pixel buffer size: \(width)x\(height)") return } if(minidata.size.width != CGFloat(width) || minidata.size.height != CGFloat(height)){ minidata.size = CGSize(width:width,height:height); } self.frameIndex += 1 let ts = Int(Date().timeIntervalSince1970 * 1000) self.timestamp = ts; let cmTime = CMTimeMake(value: Int64(CACurrentMediaTime() * 1000), timescale: 1000); self.encoder.encode(pixelBuffer: buffer, pts: cmTime) //self.encoder.encode(pixelBuffer: buffer, pts: cmTime) DispatchQueue.main.async { self.pixelBuffer = buffer; minidata.pixelBuffer = buffer; } //print("画面更新") } @@ -49,12 +52,12 @@ self.camera.start() } print("启动相机") self.encoder.onEncoded = { (data: Data, ctime: CMTime, isKey: Bool) in /*self.encoder.onEncoded = { (data: Data, ctime: CMTime, isKey: Bool) in let timestamp2 = Int(Date().timeIntervalSince1970 * 1000) print("编码时间2 \(timestamp2 - self.timestamp)") print("Encoded NALU size: \(data.count), key frame: \(isKey)") } }*/ }else{ } @@ -67,10 +70,11 @@ } } func closeWindowAction(device:DeviceInfo){ switch device.type{ func closeWindowAction(miniData:MiniWindowData){ switch miniData.streamType{ case StreamType.CAMERA: print("关闭相机") camera.stop(); break; default: break; LiveProject/activity/stream/views/MiniWindow.swift
@@ -26,22 +26,36 @@ } @Published var pixelBuffer: CVPixelBuffer? = nil @Published var size:CGSize = CGSize(width:300,height:200) //流尺寸(图片和视频的分辨率) @Published var size:CGSize = CGSize(width:16,height:9) //视图比例(相对于 main 尺寸) @Published var viewRate = 0.5 //位置 @Published var position:CGSize = CGSize(width: 0, height: 0) //主选框位置 @Published var mainPosition:CGSize = CGSize(width: 0, height: 0) //main 尺寸 @Published var mainSize:CGSize = CGSize(width:200,height:100) //是否加入直播 @Published var videoState:LiveState = .IN_LIVE @Published var audioState:LiveState = .IN_LIVE //是否静音 @Published var speakerState:MuteState = .UN_MUTE //包含视频和图片 @Published var hasVideo = true; //包含音频 @Published var hasAudio = true; //旋转角度 @Published var rotate = 0; //fps @Published var frameFPS = 0; // @Published var name = " 名称"; //相机焦距 @Published var minZoom = 0; @Published var maxZoom = 0; @Published var zoom = 1.0 //文本颜色 @Published var textColor = Color.white; } struct MiniWindowActions { @@ -72,11 +86,25 @@ @ObservedObject var miniData:MiniWindowData @State var lastLocation = CGSize(width: 0, height: 0) @State var lastZoom = 0.0 @Environment(\.miniWindowActions) var actions var body: some View { ZStack(){ VideoRendererView(pixelBuffer: $miniData.pixelBuffer).background(Color.black) var width = (miniData.mainSize.width * miniData.viewRate); var height = (miniData.mainSize.width / (miniData.size.width / miniData.size.height) * miniData.viewRate); if(miniData.rotate == 90 || miniData.rotate == 270){ var temp = width; width = height; height = temp; } return ZStack(){ let optionalIntBinding = Binding<Int?>( get: { miniData.rotate }, set: { newValue in miniData.rotate = newValue ?? 0 } ) VideoRendererView(pixelBuffer: $miniData.pixelBuffer,rotate:optionalIntBinding).background(Color.black) //名称 Text(self.miniData.name) @@ -104,33 +132,43 @@ .clipShape(RoundedCorner(radius: 20,corners: [.bottomRight])) .frame(maxWidth: .infinity,maxHeight: .infinity,alignment: .topLeading) //关闭 IconButton(info: Icons.CLOSE,action:actions.onCloseClick).frame(maxWidth: .infinity,maxHeight: .infinity,alignment: .topTrailing) .padding(EdgeInsets(top: 10, leading: 0, bottom: 0, trailing: 10)) IconButton(info: Icons.CLOSE,action:actions.onCloseClick,width: 40,height: 40).frame(maxWidth: .infinity,maxHeight: .infinity,alignment: .topTrailing) .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) if(self.miniData.streamType != .TEXT && self.miniData.hasVideo){ //旋转 VStack{ Spacer() IconButton(info: Icons.ROTATE_LEFT,action:actions.onRotateLeftClick).padding(EdgeInsets(top: 0, leading: 13, bottom: 0, trailing: 0)) IconButton(info: Icons.ROTATE_LEFT,action:{ if (self.miniData.rotate == 0) { self.miniData.rotate = 270 } else { self.miniData.rotate -= 90 } actions.onRotateLeftClick() },width: 40,height: 40).padding(EdgeInsets(top: 0, leading: 13, bottom: 0, trailing: 0)) Spacer() IconButton(info: Icons.ROTATE_RIGHT,action:actions.onRotateRightClick).padding(EdgeInsets(top: 0, leading: 13, bottom: 0, trailing: 11)) IconButton(info: Icons.ROTATE_RIGHT,action:{ self.miniData.rotate = (self.miniData.rotate + 90) % 360 actions.onRotateRightClick() },width: 40,height: 40).padding(EdgeInsets(top: 0, leading: 13, bottom: 0, trailing: 11)) Spacer() }.frame(maxWidth: .infinity,maxHeight: .infinity,alignment: .topTrailing) .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 10)) .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) } BottomBtns().background(Color.gray.opacity(0.4)).frame(maxWidth: .infinity,maxHeight: .infinity,alignment: .bottom) BottomBtns().background(Color.black.opacity(0.4)).frame(maxWidth: .infinity,maxHeight: .infinity,alignment: .bottom) }.background(Color.blue).frame(width: miniData.size.width,height:miniData.size.height) }.background(Color.blue).frame(width:width,height:height) .offset(self.miniData.position) .gesture(DragGesture(minimumDistance: 0) .gesture(SimultaneousGesture(DragGesture(minimumDistance: 0) .onChanged{ val in let x = self.miniData.position.width let y = self.miniData.position.height let newX = x - lastLocation.width + val.translation.width; let newY = y - lastLocation.height + val.translation.height; self.miniData.position = CGSize(width: newX, height: newY) print(" onChanged \(index) \(self.miniData.position) ") //print(" onChanged \(index) \(self.miniData.position) ") lastLocation = val.translation; }.onEnded{ val in let x = self.miniData.position.width @@ -138,10 +176,36 @@ let newX = x - lastLocation.width + val.translation.width; let newY = y - lastLocation.height + val.translation.height; self.miniData.position = CGSize(width: newX, height: newY) print(" onChanged \(index) \(self.miniData.position) ") //print(" onChanged \(index) \(self.miniData.position) ") lastLocation = CGSize(width: 0, height: 0); print(" onEnded \(val)") }) //print(" onEnded \(val)") },MagnifyGesture().onChanged{ val in if(lastZoom > 0){ var l = val.magnification - lastZoom; miniData.viewRate += l; if(miniData.viewRate < 0.4){ miniData.viewRate = 0.4 }else{ var w = miniData.mainSize.width * l / 2 self.miniData.position = CGSize(width: self.miniData.position.width - w , height: self.miniData.position.height - w) } } lastZoom = val.magnification; }.onEnded{ val in var l = val.magnification - lastZoom; miniData.viewRate += l; if(miniData.viewRate < 0.4){ miniData.viewRate = 0.4 }else{ var w = miniData.mainSize.width * l / 2 self.miniData.position = CGSize(width: self.miniData.position.width - w , height: self.miniData.position.height - w) } lastZoom = 0; })) .onAppear{ print("onAppear body width = \(width) , height = \(height)") } } /** @@ -179,7 +243,7 @@ actions.onMicClick() } Spacer() if(self.miniData.streamType != .MICROPHONE && self.miniData.streamType != .UAC && self.miniData.streamType != .SYSTEM ){ if(self.miniData.streamType != .MICROPHONE && self.miniData.streamType != .UAC && self.miniData.streamType != .SYSTEM && self.miniData.streamType != .CAMERA){ IconButton(info: self.miniData.speakerState == .UN_MUTE ? Icons.SPEAKER : Icons.SPEAKER_MUTE){ if(self.miniData.speakerState == .MUTE){ self.miniData.speakerState = .UN_MUTE @@ -202,7 +266,7 @@ .padding(EdgeInsets(top: 5, leading: 15, bottom: 5, trailing: 15)) } func IconButton(info:IconInfo,action:@escaping ()->Void = {}, disabled:Bool = true,allow:Bool = true) -> some View{ func IconButton(info:IconInfo,action:@escaping ()->Void = {},width:CGFloat = 20,height:CGFloat = 20) -> some View{ Button(action:{ print("IconButton ") action() @@ -214,7 +278,8 @@ .frame(width: info.size.width, height: info.size.height) .aspectRatio(contentMode: .fit) .foregroundColor(Color.white) }.frame(width: 30,height: 30) }.frame(width: width,height: height) .contentShape(Rectangle()) // 明确指定可点击区域 }.buttonStyle(TextBtnStyle()) //.allowsHitTesting(allow) // ✅ 完全禁用点击(不可交互,不触发,不高亮) //.disabled(disabled) // ✅ 禁用点击事件,但按钮可能变灰(系统默认行为) @@ -286,14 +351,35 @@ struct MiniWindow_BottomBtns_Previews: PreviewProvider{ @EnvironmentObject var miniData:MiniWindowData static var previews: some View { var miniData = MiniWindowData(streamType: StreamType.CAMERA) var miniData = MiniWindowData(streamType: StreamType.CAMERA); var miniDataBinding = Binding<MiniWindowData> { miniData.hasAudio = false; return miniData } set: { MiniWindowData, Transaction in } MiniWindow(miniData:miniData).environmentObject(miniData); MiniWindow(miniData:miniData).frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .topLeading) .background( GeometryReader { geometry in Color.clear .onAppear { miniData.mainSize = geometry.size; print("displaySize:\(miniData.mainSize)") } .onChange(of: geometry.size) { newSize in miniData.mainSize = newSize; print("displaySize:\(miniData.mainSize)") } }) .border(Color.red) .onDisappear { print("onDisappear 视图消失了!") }.onAppear { print("onAppear 视图出现了!") }.environmentObject(miniData); } } LiveProject/controller/CameraCapture.swift
@@ -27,19 +27,28 @@ if session.canAddInput(input) { session.addInput(input) } let output = AVCaptureVideoDataOutput() output.videoSettings = [ kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA ] output.setSampleBufferDelegate(self, queue: DispatchQueue(label: "camera.queue")) if session.canAddOutput(output) { session.addOutput(output) } self.videoOutput = output // 在相机配置代码中 if let videoConnection = output.connection(with: .video) { // 自动旋转(推荐) videoConnection.automaticallyAdjustsVideoMirroring = false videoConnection.videoOrientation = .portrait // 或根据UI方向设置 // 对于前置摄像头启用镜像 /*if videoConnection.isVideoMirroringSupported { videoConnection.isVideoMirrored = (cameraPosition == .front) }*/ } session.commitConfiguration() session.startRunning() print("📷 相机已开启") @@ -51,7 +60,9 @@ guard let buffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } let width = CVPixelBufferGetWidth(buffer) let height = CVPixelBufferGetHeight(buffer) //print("📷 当前帧尺寸: \(width)x\(height)") //print("Buffer Size: \(CVPixelBufferGetWidth(buffer))x\(CVPixelBufferGetHeight(buffer))") //print("Connection orientation: \(connection.videoOrientation.rawValue)") //print("Rotation angle: \(connection.videoRotationAngle)") onFrame?(buffer) } LiveProject/data/IconInfo.swift
@@ -8,5 +8,5 @@ struct IconInfo{ var name:String var size:CGSize = CGSize(width: 20, height: 20) var size:CGSize = CGSize(width: 15, height: 20) } LiveProject/enum/Icons.swift
@@ -2,28 +2,28 @@ // Icons.swift // LiveProject // // Created by 倪路朋 on 7/4/25. // Created by 倪路朋 on 7/4/22. // import SwiftUI struct Icons{ static let CAMERA = IconInfo(name: "camera",size: CGSize(width: 25, height: 20)) static let MIC = IconInfo(name: "mic",size: CGSize(width: 15, height: 23)) static let MIC_MUTE = IconInfo(name: "mic.slash",size: CGSize(width: 18, height: 20)) static let PORTRAIT = IconInfo(name: "ipad",size: CGSize(width: 18, height: 23)) static let LANDSCAPE = IconInfo(name: "ipad.landscape",size: CGSize(width: 25, height: 20)) static let BACK = IconInfo(name: "arrow.left",size: CGSize(width: 25, height: 20)) static let CLOSE = IconInfo(name: "xmark",size: CGSize(width: 22, height: 22)) static let CAMERA = IconInfo(name: "camera",size: CGSize(width: 22, height: 17)) static let MIC = IconInfo(name: "mic",size: CGSize(width: 13, height: 20)) static let MIC_MUTE = IconInfo(name: "mic.slash",size: CGSize(width: 15, height: 17)) static let PORTRAIT = IconInfo(name: "ipad",size: CGSize(width: 15, height: 20)) static let LANDSCAPE = IconInfo(name: "ipad.landscape",size: CGSize(width: 22, height: 17)) static let BACK = IconInfo(name: "arrow.left",size: CGSize(width: 22, height: 17)) static let CLOSE = IconInfo(name: "xmark",size: CGSize(width: 19, height: 19)) static let SPEAKER = IconInfo(name: "speaker",size: CGSize(width: 18, height: 23)) static let SPEAKER_MUTE = IconInfo(name: "speaker.slash",size: CGSize(width: 18, height: 23)) static let IMAGE = IconInfo(name: "video",size: CGSize(width: 28, height: 19)) static let IMAGE_MUTE = IconInfo(name: "video.slash",size: CGSize(width: 25, height: 20)) static let ROTATE_LEFT = IconInfo(name: "rotate.left",size: CGSize(width: 23, height: 25)) static let ROTATE_RIGHT = IconInfo(name: "rotate.right",size: CGSize(width: 23, height: 25)) static let INFO = IconInfo(name: "info.circle",size: CGSize(width: 25, height: 25)) static let PAINT = IconInfo(name: "paintpalette",size: CGSize(width: 25, height: 25)) static let HOME = IconInfo(name: "house",size: CGSize(width: 25, height: 23)) static let ROTATE = IconInfo(name: "camera.rotate",size: CGSize(width: 25, height: 20)) static let MORE = IconInfo(name: "ellipsis",size: CGSize(width: 25, height: 5)) static let SPEAKER = IconInfo(name: "speaker",size: CGSize(width: 15, height: 20)) static let SPEAKER_MUTE = IconInfo(name: "speaker.slash",size: CGSize(width: 15, height: 20)) static let IMAGE = IconInfo(name: "video",size: CGSize(width: 23, height: 16)) static let IMAGE_MUTE = IconInfo(name: "video.slash",size: CGSize(width: 22, height: 17)) static let ROTATE_LEFT = IconInfo(name: "rotate.left",size: CGSize(width: 20, height: 22)) static let ROTATE_RIGHT = IconInfo(name: "rotate.right",size: CGSize(width: 20, height: 22)) static let INFO = IconInfo(name: "info.circle",size: CGSize(width: 22, height: 22)) static let PAINT = IconInfo(name: "paintpalette",size: CGSize(width: 22, height: 22)) static let HOME = IconInfo(name: "house",size: CGSize(width: 22, height: 20)) static let ROTATE = IconInfo(name: "camera.rotate",size: CGSize(width: 22, height: 17)) static let MORE = IconInfo(name: "ellipsis",size: CGSize(width: 18, height: 3)) } LiveProject/tool/MetalRenderer.swift
@@ -16,6 +16,7 @@ private var currentPixelBuffer: CVPixelBuffer? private let textureCache: CVMetalTextureCache private var rotate:Int = 0; init(mtkView: MTKView) { guard let device = MTLCreateSystemDefaultDevice(), @@ -63,7 +64,24 @@ guard drawableSize.width > 0, drawableSize.height > 0 else { return } // 加方向修正:顺时针旋转90度 var ciImage = CIImage(cvPixelBuffer: pixelBuffer).oriented(.right) var orien:CGImagePropertyOrientation = .up; switch self.rotate{ case 0: break; case 90: orien = .right; break; case 180: orien = .down; break; case 270: orien = .left; break; default: orien = .up; } print(" roate = \(rotate)") var ciImage = CIImage(cvPixelBuffer: pixelBuffer).oriented(orien) // 等比缩放后居中 let sourceExtent = ciImage.extent @@ -99,4 +117,9 @@ //刷新 } func updateRotate(angle:Int){ rotate = angle; } } LiveProject/views/VideoRendererView.swift
@@ -9,6 +9,7 @@ struct VideoRendererView: UIViewRepresentable { @Binding var pixelBuffer: CVPixelBuffer? @Binding var rotate:Int?; //用 Coordinator 缓存实例 func makeCoordinator() -> Coordinator { @@ -24,7 +25,12 @@ //print("updateUIView") context.coordinator.renderer.display(pixelBuffer: buffer) } if let angle = rotate{ //print("updateUIView rotate \(angle)") context.coordinator.renderer.updateRotate(angle: angle) } } class Coordinator { let mtkView: MTKView