Runt
2025-07-09 3b7521a47ae731f0bf0a922822e4417493489539
小窗布局,小窗移动
5 files added
4 files modified
418 ■■■■■ changed files
LiveProject/activity/stream/LiveActivity.swift 22 ●●●● patch | view | raw | blame | history
LiveProject/activity/stream/LiveViewModel.swift 1 ●●●● patch | view | raw | blame | history
LiveProject/activity/stream/views/MiniWindow.swift 301 ●●●●● patch | view | raw | blame | history
LiveProject/enum/Icons.swift 15 ●●●●● patch | view | raw | blame | history
LiveProject/enum/LiveState.swift 13 ●●●●● patch | view | raw | blame | history
LiveProject/enum/MuteState.swift 11 ●●●●● patch | view | raw | blame | history
LiveProject/enum/StreamType.swift 1 ●●●● patch | view | raw | blame | history
LiveProject/listener/MiniWindowListener.swift 32 ●●●●● patch | view | raw | blame | history
LiveProject/shape/RoundedCorner.swift 22 ●●●●● patch | view | raw | blame | history
LiveProject/activity/stream/LiveActivity.swift
@@ -25,6 +25,7 @@
                                  DeviceInfo(name: "话筒", type: .MICROPHONE,deviceId: UUID().uuidString,icon: Icons.MIC),
                                  DeviceInfo(name: "系统", type: .SYSTEM,deviceId : UUID().uuidString,icon: Icons.PORTRAIT)]
    @State private var miniWindows:Array<MiniWindowData> = []
    
    @StateObject private var mViewModel = LiveViewModel()
    
@@ -32,10 +33,14 @@
        ZStack{
            Color.clear
                .ignoresSafeArea() // 填满全屏
            VStack{
            ZStack{
                VideoRendererView(pixelBuffer: $mViewModel.pixelBuffer).background(Color.black).frame(width: mainSize.width,height:mainSize.height)
                Spacer()
                    .frame(maxWidth: .infinity,maxHeight: .infinity,alignment: .topTrailing)
                ForEach(miniWindows, id: \.id) { miniWindow in
                    NewMiniWindow(miniWindow: miniWindow)
                }
            }.border(Color.blue)
            VStack{
                Spacer()
                BottomBtns().frame(alignment: .bottom).border(Color.green)
@@ -156,8 +161,9 @@
                                withAnimation{
                                    showDeviceDialog = false;
                                }
                                miniWindows.append(MiniWindowData(streamType: device.type))
                            }
                            print("\(device.name) click")
                            print("\(device.name) click \(self.miniWindows.count)")
                        }
                    }
                }
@@ -221,6 +227,16 @@
            }
        }
    }
    func NewMiniWindow(miniWindow:MiniWindowData) -> some View{
        MiniWindow(miniData: miniWindow)
            .frame(maxWidth: .infinity,maxHeight: .infinity,alignment: .topLeading)
            .onCloseClick {
                guard let index = miniWindows.firstIndex(where: { $0.id == miniWindow.id }) else { return }
                miniWindows.remove(at: index)
            }
    }
}
LiveProject/activity/stream/LiveViewModel.swift
@@ -62,6 +62,7 @@
            }
            break;
        default:
            completion(true)
            break;
        }
    }
LiveProject/activity/stream/views/MiniWindow.swift
New file
@@ -0,0 +1,301 @@
//
//  MiniWindow.swift
//  LiveProject
//  小窗
//  Created by 倪路朋 on 7/1/25.
//
import SwiftUI
class MiniWindowData:ObservableObject,Identifiable{
    var id:Int;
    var streamType:StreamType
    var remark:String? = nil
    var listener:MiniWindowListener? = nil
    var audioDelay:Int64 = 0;
    var videoDelay:Int64 = 0;
    var volume:Double = 1.0;
    var hasAlpha = false;
    init(streamType: StreamType) {
        self.streamType = streamType
        self.id = UUID().uuidString.hashValue
    }
    @Published var pixelBuffer: CVPixelBuffer? = nil
    @Published var size:CGSize = CGSize(width:300,height:200)
    @Published var position:CGSize = CGSize(width: 0, height: 0)
    @Published var mainPosition:CGSize = CGSize(width: 0, height: 0)
    @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;
    @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 {
    var onTopClick : () -> Void = {}
    var onCloseClick : () -> Void = {}
    var onRotateLeftClick : () -> Void = {}
    var onRotateRightClick : () -> Void = {}
    var onCameraRotateClick : () -> Void = {}
    var onHomeClick : () -> Void = {}
    var onImageClick : () -> Void = {}
    var onMicClick : () -> Void = {}
    var onSpeakerClick : () -> Void = {}
    var onPaintClick : () -> Void = {}
    var onMoreClick : () -> Void = {}
}
private struct MiniWindowActionKey: EnvironmentKey {
    static let defaultValue = MiniWindowActions()
}
extension EnvironmentValues {
    var miniWindowActions: MiniWindowActions {
        get { self[MiniWindowActionKey.self] }
        set { self[MiniWindowActionKey.self] = newValue }
    }
}
struct MiniWindow: View{
    @ObservedObject var miniData:MiniWindowData
    @State var lastLocation = CGSize(width: 0, height: 0)
    @Environment(\.miniWindowActions) var actions
    var body: some View {
        ZStack(){
            VideoRendererView(pixelBuffer: $miniData.pixelBuffer).background(Color.black)
            //名称
            Text(self.miniData.name)
                    .font(Font.system(size: 16))
                    .foregroundColor(Color.white)
                    .frame(width: .infinity, height: 40)
                    .frame(maxWidth: .infinity,maxHeight: .infinity,alignment: .top)
            if(self.miniData.streamType != .TEXT && self.miniData.streamType != .PICTURE
               && self.miniData.hasVideo){
                //fps
                Text("\(self.miniData.frameFPS)")
                        .font(Font.system(size: 16))
                        .foregroundColor(Color.green)
                        .frame(width: .infinity, height: 40)
                        .padding(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 40))
                        .frame(maxWidth: .infinity,maxHeight: .infinity,alignment: .topTrailing)
            }
            //序号
            HStack{
                Text("\(self.miniData.frameFPS)")
                    .font(Font.system(size: 16))
                    .foregroundColor(Color.white)
                    .padding(EdgeInsets(top: 2, leading: 10, bottom: 2, trailing: 10))
            }.background(Color.gray)
                .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))
            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))
                    Spacer()
                    IconButton(info: Icons.ROTATE_RIGHT,action:actions.onRotateRightClick).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))
            }
            BottomBtns().background(Color.gray.opacity(0.4)).frame(maxWidth: .infinity,maxHeight: .infinity,alignment: .bottom)
        }.background(Color.blue).frame(width: miniData.size.width,height:miniData.size.height)
            .offset(self.miniData.position)
            .gesture(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) ")
                    lastLocation = val.translation;
                }.onEnded{ 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) ")
                    lastLocation = CGSize(width: 0, height: 0);
                    print(" onEnded \(val)")
                })
    }
    /**
        小窗底部按钮
     */
    func BottomBtns() -> some View{
        HStack{
            if(self.miniData.streamType == .CAMERA){
                IconButton(info: Icons.ROTATE,action:actions.onCameraRotateClick)
                Spacer()
            }
            if(self.miniData.streamType != .TEXT && self.miniData.streamType != .PICTURE
               && self.miniData.hasVideo){
                IconButton(info: Icons.HOME,action:actions.onHomeClick)
                Spacer()
            }
            if(self.miniData.hasVideo){
                IconButton(info: self.miniData.videoState == .IN_LIVE ? Icons.IMAGE : Icons.IMAGE_MUTE){
                    if(self.miniData.videoState == .IN_LIVE){
                        self.miniData.videoState = .OUT_LIVE
                    }else{
                        self.miniData.videoState = .IN_LIVE
                    }
                    actions.onImageClick()
                }
                Spacer()
            }
            if(self.miniData.hasAudio){
                IconButton(info: self.miniData.audioState == .IN_LIVE ? Icons.MIC : Icons.MIC_MUTE){
                    if(self.miniData.audioState == .IN_LIVE){
                        self.miniData.audioState = .OUT_LIVE
                    }else{
                        self.miniData.audioState = .IN_LIVE
                    }
                    actions.onMicClick()
                }
                Spacer()
                if(self.miniData.streamType != .MICROPHONE && self.miniData.streamType != .UAC && self.miniData.streamType != .SYSTEM ){
                    IconButton(info: self.miniData.speakerState == .UN_MUTE ? Icons.SPEAKER : Icons.SPEAKER_MUTE){
                        if(self.miniData.speakerState == .MUTE){
                            self.miniData.speakerState = .UN_MUTE
                        }else{
                            self.miniData.speakerState = .MUTE
                        }
                        actions.onSpeakerClick()
                    }
                    Spacer()
                }
            }
            if(self.miniData.streamType == .TEXT){
                IconButton(info: Icons.PAINT,action:actions.onPaintClick)
                Spacer()
            }
            if(self.miniData.streamType != .TEXT && self.miniData.streamType != .PICTURE){
                IconButton(info: Icons.MORE,action:actions.onMoreClick)
            }
        }.frame(maxWidth: .infinity)
            .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{
        Button(action:{
            print("IconButton ")
            action()
        }){
            ZStack{
                Image(systemName: info.name)
                    .resizable()
                    .frame(width: info.size.width, height: info.size.height)
                    .aspectRatio(contentMode: .fit)
                    .foregroundColor(Color.white)
            }.frame(width: 30,height: 30)
        }.buttonStyle(TextBtnStyle())
            //.allowsHitTesting(allow) // ✅ 完全禁用点击(不可交互,不触发,不高亮)
            //.disabled(disabled) // ✅ 禁用点击事件,但按钮可能变灰(系统默认行为)
    }
}
extension View {
    func onTopClick(_ action: @escaping () -> Void) -> some View {
        self.transformEnvironment(\.miniWindowActions) { value in
            value.onTopClick = action
        }
    }
    func onCloseClick(_ action: @escaping () -> Void) -> some View {
        self.transformEnvironment(\.miniWindowActions) { value in
            value.onCloseClick = action
        }
    }
    func onRotateLeftClick(_ action: @escaping () -> Void) -> some View {
        self.transformEnvironment(\.miniWindowActions) { value in
            value.onRotateLeftClick = action
        }
    }
    func onRotateRightClick(_ action: @escaping () -> Void) -> some View {
        self.transformEnvironment(\.miniWindowActions) { value in
            value.onRotateRightClick = action
        }
    }
    func onCameraRotateClick(_ action: @escaping () -> Void) -> some View {
        self.transformEnvironment(\.miniWindowActions) { value in
            value.onCameraRotateClick = action
        }
    }
    func onHomeClick(_ action: @escaping () -> Void) -> some View {
        self.transformEnvironment(\.miniWindowActions) { value in
            value.onHomeClick = action
        }
    }
    func onImageClick(_ action: @escaping () -> Void) -> some View {
        self.transformEnvironment(\.miniWindowActions) { value in
            value.onImageClick = action
        }
    }
    func onMicClick(_ action: @escaping () -> Void) -> some View {
        self.transformEnvironment(\.miniWindowActions) { value in
            value.onMicClick = action
        }
    }
    func onSpeakerClick(_ action: @escaping () -> Void) -> some View {
        self.transformEnvironment(\.miniWindowActions) { value in
            value.onSpeakerClick = action
        }
    }
    func onPaintClick(_ action: @escaping () -> Void) -> some View {
        self.transformEnvironment(\.miniWindowActions) { value in
            value.onPaintClick = action
        }
    }
    func onMoreClick(_ action: @escaping () -> Void) -> some View {
        self.transformEnvironment(\.miniWindowActions) { value in
            value.onMoreClick = action
        }
    }
}
struct MiniWindow_BottomBtns_Previews: PreviewProvider{
    @EnvironmentObject var miniData:MiniWindowData
    static var previews: some View {
        var miniData = MiniWindowData(streamType: StreamType.CAMERA)
        var miniDataBinding = Binding<MiniWindowData> {
            return miniData
        } set: { MiniWindowData, Transaction in
        }
        MiniWindow(miniData:miniData).environmentObject(miniData);
    }
}
LiveProject/enum/Icons.swift
@@ -9,16 +9,21 @@
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: 20, 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 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: "photo",size: CGSize(width: 25, height: 23))
    static let IMAGE_MUTE = IconInfo(name: "photo.slash",size: CGSize(width: 25, height: 23))
    static let ROTATE_LEFT = IconInfo(name: "rotate.left",size: CGSize(width: 25, height: 25))
    static let ROTATE_RIGHT = IconInfo(name: "rotate.right",size: CGSize(width: 25, height: 25))
    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))
}
LiveProject/enum/LiveState.swift
New file
@@ -0,0 +1,13 @@
//
//  LiveState.swift
//  LiveProject
//
//  Created by 倪路朋 on 7/5/25.
//
enum LiveState{
    case
    IN_LIVE,OUT_LIVE
    //直播中,未直播
}
LiveProject/enum/MuteState.swift
New file
@@ -0,0 +1,11 @@
//
//  MuteState.swift
//  LiveProject
//
//  Created by 倪路朋 on 7/5/25.
//
enum MuteState {
    case MUTE,UN_MUTE
}
LiveProject/enum/StreamType.swift
@@ -8,6 +8,7 @@
enum StreamType : Int{
    case
    NONE = -1,
    //系统
    SYSTEM = 0,
    //本地类型
LiveProject/listener/MiniWindowListener.swift
New file
@@ -0,0 +1,32 @@
//
//  MiniWindowListener.swift
//  LiveProject
//
//  Created by 倪路朋 on 7/5/25.
//
class MiniWindowListener{
   /**
    * 执行加载,连接成功
    */
   var onStarted : (() -> Void?)? = nil
   /**
    * 执行加载,连接失败
    */
   var onFailed : ((Int) -> Void?)? = nil
   /**
    * 中途执行出错断开
    */
   var onBroken : ((Int) -> Void?)? = nil
   /**
    * 画面变化
    */
   var onViewUpdate : (()->Void)? = nil
   /**
    * 停止
    */
   var onStop:(() -> Void?)? = nil;
}
LiveProject/shape/RoundedCorner.swift
New file
@@ -0,0 +1,22 @@
//
//  Untitled.swift
//  LiveProject
//
//  Created by 倪路朋 on 7/5/25.
//
import SwiftUI
struct RoundedCorner: Shape {
    var radius: CGFloat = 10
    var corners: UIRectCorner = .allCorners
    func path(in rect: CGRect) -> Path {
        let path = UIBezierPath(
            roundedRect: rect,
            byRoundingCorners: corners,
            cornerRadii: CGSize(width: radius, height: radius)
        )
        return Path(path.cgPath)
    }
}