Skip to content
Closed
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
17 changes: 17 additions & 0 deletions mirroringBooth/mirroringBooth.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,22 @@
8C796EAA2EF52FB200280FED /* mirroringBooth.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = mirroringBooth.app; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */

/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
8CC632842EFB1909006EA0E1 /* Exceptions for "mirroringBooth" folder in "mirroringBooth" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 8C796EA92EF52FB200280FED /* mirroringBooth */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */

/* Begin PBXFileSystemSynchronizedRootGroup section */
8C796EAC2EF52FB200280FED /* mirroringBooth */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
8CC632842EFB1909006EA0E1 /* Exceptions for "mirroringBooth" folder in "mirroringBooth" target */,
);
path = mirroringBooth;
sourceTree = "<group>";
};
Expand Down Expand Up @@ -254,7 +267,9 @@
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = mirroringBooth/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Mirroring Booth";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "이 앱은 같은 네트워크의 기기와 연결하기 위해 로컬 네트워크 접근이 필요합니다.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
Expand Down Expand Up @@ -302,7 +317,9 @@
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = mirroringBooth/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = "Mirroring Booth";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "이 앱은 같은 네트워크의 기기와 연결하기 위해 로컬 네트워크 접근이 필요합니다.";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
//
// BrowserView.swift
// mirroringBooth
//
// Created by 이상유 on 2025-12-24.
//

import SwiftUI

struct BrowserView: View {

private var router: Router
private var sender: StreamSender
@State private var isConnecting = false

init(_ router: Router, _ sender: StreamSender) {
self.router = router
self.sender = sender
}

var body: some View {
VStack {
Button {
router.pop()
} label: {
Text("뒤로가기")
.font(.headline)
.padding(5)
}

if isConnecting {
ProgressView()
.padding()
Text("연결 중...")
.font(.subheadline)
}

Text(sender.connectionState.values.joined(separator: "\n"))
.font(.subheadline)

ForEach(sender.peers, id: \.self) { peer in
deviceRow(peer)
}
}
.onAppear {
sender.startBrowsing()
}
.onDisappear {
sender.stopBrowsing()
}
.onChange(of: sender.connectionState) { _, newValue in
// 연결이 완료되면 카메라 화면으로 이동
if newValue.values.contains(where: { $0.contains("연결 완료") }) && isConnecting {
isConnecting = false
router.push(to: .camera)
}
}
}

@ViewBuilder
func deviceRow(_ peer: String) -> some View {
Button {
sender.invite(to: peer)
isConnecting = true
} label: {
Text(peer)
.padding(5)
.overlay {
RoundedRectangle(cornerRadius: 12)
.stroke(style: StrokeStyle(lineWidth: 1))
}
}
.disabled(isConnecting)
}
}

#Preview {
BrowserView(Router(), StreamSender())
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
//
// StreamSender.swift
// mirroringBooth
//
// Created by 이상유 on 2025-12-28.
//

import Foundation
import MultipeerConnectivity

/// 스트림 송신 측 (iPhone)
/// 다른 기기를 탐색하고 연결하여 스트림 데이터(비디오/사진)를 전송
@Observable
final class StreamSender: NSObject {

/// 연결된 피어들의 상태 정보
var connectionState: [String: String] = [:]
/// 발견된 피어 목록
var peers: [String] = []

/// 촬영 요청 수신 콜백
var onCaptureRequest: (() -> Void)?

private let serviceType: String
/// 현재 기기의 식별자
private let identifier: MCPeerID
/// Multipeer 연결 세션
private let session: MCSession
/// 서비스 탐색 (송신 측)
private let browser: MCNearbyServiceBrowser
/// 발견된 피어 ID 매핑
private var discoveredPeers: [String: MCPeerID] = [:]

init(serviceType: String = "mirroringbooth") {
self.serviceType = serviceType
self.identifier = MCPeerID(displayName: UIDevice.current.name)
self.session = MCSession(
peer: identifier,
securityIdentity: nil,
encryptionPreference: .none
)
self.browser = MCNearbyServiceBrowser(peer: identifier, serviceType: serviceType)

super.init()
setup()
}

private func setup() {
session.delegate = self
browser.delegate = self
}

func startBrowsing() {
peers.removeAll()
discoveredPeers.removeAll()
browser.startBrowsingForPeers()
}

func stopBrowsing() {
browser.stopBrowsingForPeers()
}

func invite(to id: String) {
guard let peerID = discoveredPeers[id] else { return }
browser.invitePeer(peerID, to: session, withContext: nil, timeout: 30)
}

func sendPacket(_ data: Data) {
// 연결된 피어가 없으면 전송하지 않음
guard !session.connectedPeers.isEmpty else {
return
}

// 패킷 타입에 따라 전송 모드 결정
// SPS/PPS와 Photo는 반드시 전달되어야 하므로 reliable 모드 사용
// 프레임 데이터는 실시간성이 중요하므로 unreliable 모드 사용
let sendMode: MCSessionSendDataMode = {
guard data.count > 0 else { return .unreliable }

let packetType = data[0]
// SPS(0x01), PPS(0x02), Photo(0x05)인 경우 reliable 모드
if packetType == 0x01 || packetType == 0x02 || packetType == 0x05 {
return .reliable
}
return .unreliable
}()

do {
try session.send(data, toPeers: session.connectedPeers, with: sendMode)
} catch {
print("Failed to send packet data: \(error)")
}
}

}

// MARK: - Session Delegate
extension StreamSender: MCSessionDelegate {

func session(_ session: MCSession, peer peerID: MCPeerID, didChange state: MCSessionState) {
switch state {
case .connected:
connectionState[peerID.displayName] = "✅ \(peerID.displayName)와 연결 완료"
case .connecting:
connectionState[peerID.displayName] = "⏳ \(peerID.displayName)와 연결 중"
case .notConnected:
connectionState.removeValue(forKey: peerID.displayName)
default:
break
}
}

func session(_ session: MCSession, didReceive data: Data, fromPeer peerID: MCPeerID) {
// 수신된 데이터가 촬영 요청 패킷인지 확인
guard let packet = MediaPacket.deserialize(data),
packet.type == .captureRequest else {
return
}

// 촬영 요청 콜백 호출
onCaptureRequest?()
}

func session(_ session: MCSession, didReceive stream: InputStream, withName streamName: String, fromPeer peerID: MCPeerID) { }

func session(_ session: MCSession, didStartReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, with progress: Progress) { }

func session(_ session: MCSession, didFinishReceivingResourceWithName resourceName: String, fromPeer peerID: MCPeerID, at localURL: URL?, withError error: (any Error)?) { }

}

// MARK: - Browser Delegate
extension StreamSender: MCNearbyServiceBrowserDelegate {

func browser(
_ browser: MCNearbyServiceBrowser,
foundPeer peerID: MCPeerID,
withDiscoveryInfo info: [String : String]?
) {
let displayName = peerID.displayName
discoveredPeers[displayName] = peerID
guard !peers.contains(displayName) else { return }
peers.append(displayName)
}

func browser(
_ browser: MCNearbyServiceBrowser,
lostPeer peerID: MCPeerID
) {

}

}
39 changes: 39 additions & 0 deletions mirroringBooth/mirroringBooth/CameraDevice/Home/HomeView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//
// HomeView.swift
// mirroringBooth
//
// Created by 이상유 on 2025-12-24.
//

import SwiftUI

struct HomeView: View {

@State private var router: Router = .init()
private let sender = StreamSender()

var body: some View {
NavigationStack(path: $router.path) {
Button {
router.push(to: .connection)
} label: {
Text("촬영하기")
.font(.headline)
.padding(5)
}
.navigationDestination(for: Route.self) { viewType in
switch viewType {
case .connection:
BrowserView(router, sender)
case .camera:
StreamingView(sender)
}
}
}
}

}

#Preview {
HomeView()
}
Loading