diff --git a/Kino.xcodeproj/project.pbxproj b/Kino.xcodeproj/project.pbxproj index 6c3b500..78b6192 100644 --- a/Kino.xcodeproj/project.pbxproj +++ b/Kino.xcodeproj/project.pbxproj @@ -232,7 +232,6 @@ mainGroup = 6DDA078C2CDA96850093CA06; minimizedProjectReferenceProxies = 1; packageReferences = ( - 6D3565262CDB87CA00297A87 /* XCRemoteSwiftPackageReference "WebRTC" */, ); preferredProjectObjectVersion = 77; productRefGroup = 6DDA07962CDA96850093CA06 /* Products */; @@ -471,6 +470,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Kino/Kino.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Kino/Preview Content\""; @@ -513,6 +513,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Kino/Kino.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Kino/Preview Content\""; @@ -674,17 +675,6 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ - -/* Begin XCRemoteSwiftPackageReference section */ - 6D3565262CDB87CA00297A87 /* XCRemoteSwiftPackageReference "WebRTC" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/stasel/WebRTC.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 130.0.0; - }; - }; -/* End XCRemoteSwiftPackageReference section */ }; rootObject = 6DDA078D2CDA96850093CA06 /* Project object */; } diff --git a/Kino.xcodeproj/xcuserdata/nitesh.xcuserdatad/xcschemes/xcschememanagement.plist b/Kino.xcodeproj/xcuserdata/nitesh.xcuserdatad/xcschemes/xcschememanagement.plist index c1668e3..fcbd242 100644 --- a/Kino.xcodeproj/xcuserdata/nitesh.xcuserdatad/xcschemes/xcschememanagement.plist +++ b/Kino.xcodeproj/xcuserdata/nitesh.xcuserdatad/xcschemes/xcschememanagement.plist @@ -7,7 +7,7 @@ Kino.xcscheme_^#shared#^_ orderHint - 2 + 3 diff --git a/Kino.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Kino.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 857dbdf..0000000 --- a/Kino.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "7ca243407a3b132834ec3b117a3aea85a8f743151ef868a872c8bbf8082921f6", - "pins" : [ - { - "identity" : "webrtc", - "kind" : "remoteSourceControl", - "location" : "https://github.com/stasel/WebRTC.git", - "state" : { - "revision" : "1048f8396529c10e259f8240d0c2cd607a13defd", - "version" : "130.0.0" - } - } - ], - "version" : 3 -} diff --git a/Kino/Info.plist b/Kino/Info.plist index da5d81c..0764a02 100644 --- a/Kino/Info.plist +++ b/Kino/Info.plist @@ -7,5 +7,7 @@ NSAllowsArbitraryLoads + LSMultipleInstancesProhibited + diff --git a/Kino/KinoApp.swift b/Kino/KinoApp.swift index fd99ee1..7d6c044 100644 --- a/Kino/KinoApp.swift +++ b/Kino/KinoApp.swift @@ -6,8 +6,85 @@ // import SwiftUI +import VLCKit + +enum KinoScreen { + case home + case player +} + +@Observable +class RoomViewModel { + private let webRTCService: WebRTCService + private var lastSyncTime: TimeInterval = 0 + var isInternalStateChange = false + + var roomCode: String = "" + var isHost: Bool = false + var isConnected: Bool = false + var error: String? + + init() { + webRTCService = WebRTCService() + } + + // Create a new room + func createRoom() async { + do { + isHost = true + roomCode = try await webRTCService.createRoom() + } catch { + self.error = "Failed to create room: \(error.localizedDescription)" + } + } + + // Join existing room + func joinRoom(code: String) async { + do { + isHost = false + roomCode = code + try await webRTCService.joinRoom(code: code) + } catch { + self.error = "Failed to join room: \(error.localizedDescription)" + } + } + // Handle player state changes + func handlePlayerStateChange(state: PlayerState) { + guard !isInternalStateChange else { return } + let currentTime = Date().timeIntervalSince1970 + webRTCService.sendPlayerState(state) + lastSyncTime = currentTime + } + + func setPlayerDelegate(_ delegate: WebRTCServiceDelegate) { + webRTCService.delegate = delegate + } +} + +@Observable +class KinoViewModel { + var roomViewModel = RoomViewModel() + + var currentScreen: KinoScreen = .home + var showNewRoomSheet = false + var showJoinSheet = false + var roomName = "" + var displayName = "" + + var isInRoom: Bool { + !roomViewModel.roomCode.isEmpty + } + + // Leave room function + func leaveRoom() { + roomViewModel.roomCode = "" + roomViewModel.isHost = false + roomViewModel.isConnected = false + currentScreen = .home + } +} @main struct KinoApp: App { diff --git a/Kino/Service/SignalingService.swift b/Kino/Service/SignalingService.swift new file mode 100644 index 0000000..2d7a557 --- /dev/null +++ b/Kino/Service/SignalingService.swift @@ -0,0 +1,262 @@ +// +// SignalingService.swift +// Kino +// +// Created by Nitesh on 06/11/24. +// + +import Foundation +import WebRTC + +// MARK: - Signaling Models +struct SignalingMessage: Codable { + enum MessageType: String, Codable { + case offer + case answer + case iceCandidate + case join + case leave + } + + let type: MessageType + let roomCode: String + let payload: SignalingPayload // JSON encoded content +} + +enum SignalingPayload: Codable { + case sdp(SDPMessage) + case ice(ICEMessage) + case plain(String) + + private enum CodingKeys: String, CodingKey { + case type, data + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let type = try container.decode(String.self, forKey: .type) + + switch type { + case "sdp": + let sdp = try container.decode(SDPMessage.self, forKey: .data) + self = .sdp(sdp) + case "ice": + let ice = try container.decode(ICEMessage.self, forKey: .data) + self = .ice(ice) + default: + let string = try container.decode(String.self, forKey: .data) + self = .plain(string) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + switch self { + case .sdp(let sdp): + try container.encode("sdp", forKey: .type) + try container.encode(sdp, forKey: .data) + case .ice(let ice): + try container.encode("ice", forKey: .type) + try container.encode(ice, forKey: .data) + case .plain(let string): + try container.encode("plain", forKey: .type) + try container.encode(string, forKey: .data) + } + } +} + +struct SDPMessage: Codable { + let sdp: String + let type: SDPType +} + +enum SDPType: String, Codable { + case offer + case answer +} + +struct ICEMessage: Codable { + let candidate: String + let sdpMLineIndex: Int32 + let sdpMid: String? +} + +// MARK: - Signaling Service +class SignalingService { + private var webSocket: URLSessionWebSocketTask? + weak var delegate: SignalingServiceDelegate? + private let serverURL = "wss://kino-rooms.niranjannitesh.workers.dev" + private var currentRoomCode: String? + + func connect(roomCode: String) { + RTCLogger.shared.log("Signaling", "Connecting to room: \(roomCode)") + currentRoomCode = roomCode + + guard let url = URL(string: "\(serverURL)/\(roomCode)") else { + RTCLogger.shared.log("Signaling", "Invalid server URL") + return + } + + var request = URLRequest(url: url) + request.timeoutInterval = 30 + request.setValue("websocket", forHTTPHeaderField: "Upgrade") + request.setValue("Upgrade", forHTTPHeaderField: "Connection") + request.setValue("13", forHTTPHeaderField: "Sec-WebSocket-Version") + + let session = URLSession(configuration: .default) + webSocket = session.webSocketTask(with: request) + + receiveMessage() + webSocket?.resume() + setupKeepAlive() + + // Send join message + send(type: .join, roomCode: roomCode, payload: .plain("Joining room")) + } + + private func setupKeepAlive() { + // Send a ping every 30 seconds to keep the connection alive + DispatchQueue.global().asyncAfter(deadline: .now() + 30) { [weak self] in + guard let self = self, + let webSocket = self.webSocket, + webSocket.state == .running + else { return } + + webSocket.sendPing { error in + if let error = error { + RTCLogger.shared.log("Signaling", "Keep-alive failed: \(error)") + self.handleDisconnection() + } else { + self.setupKeepAlive() + } + } + } + } + + private func handleDisconnection() { + RTCLogger.shared.log("Signaling", "Handling disconnection") + + // Wait 5 seconds before attempting to reconnect + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in + guard let self = self, + let roomCode = self.currentRoomCode + else { return } + + RTCLogger.shared.log("Signaling", "Attempting to reconnect") + self.connect(roomCode: roomCode) + } + } + + private func receiveMessage() { + webSocket?.receive { [weak self] result in + switch result { + case .success(let message): + switch message { + case .string(let text): + RTCLogger.shared.log("Signaling", "Received: \(text)") + self?.handleMessage(text) + case .data(let data): + if let text = String(data: data, encoding: .utf8) { + self?.handleMessage(text) + } + @unknown default: + break + } + self?.receiveMessage() + + case .failure(let error): + RTCLogger.shared.log("Signaling", "WebSocket error: \(error)") + self?.handleDisconnection() + } + } + } + + private func handleMessage(_ text: String) { + RTCLogger.shared.log("Signaling", "Received message: \(text)") + + guard let data = text.data(using: .utf8) else { + RTCLogger.shared.log("Signaling", "Failed to convert message to data") + return + } + + do { + if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let type = json["type"] as? String + { + + if type == "clientCount" { + RTCLogger.shared.log("Signaling", "Received client count update") + return + } + + // Handle forwarded offer/ice messages + if type == "offer" { + if let payload = json["payload"] as? [String: Any], + let sdpData = payload["data"] as? [String: Any], + let sdp = sdpData["sdp"] as? String + { + RTCLogger.shared.log("Signaling", "Processing forwarded offer") + let sdpMessage = SDPMessage(sdp: sdp, type: .offer) + delegate?.signaling(didReceiveOffer: sdpMessage, for: currentRoomCode!) + } + } else if type == "answer" { // Add answer handling + if let payload = json["payload"] as? [String: Any], + let sdpData = payload["data"] as? [String: Any], + let sdp = sdpData["sdp"] as? String + { + RTCLogger.shared.log("Signaling", "Processing forwarded answer") + let sdpMessage = SDPMessage(sdp: sdp, type: .answer) + delegate?.signaling(didReceiveAnswer: sdpMessage, for: currentRoomCode!) + } + } else if type == "iceCandidate" { + if let payload = json["payload"] as? [String: Any], + let iceData = payload["data"] as? [String: Any], + let candidate = iceData["candidate"] as? String, + let sdpMLineIndex = iceData["sdpMLineIndex"] as? Int32, + let sdpMid = iceData["sdpMid"] as? String + { + RTCLogger.shared.log("Signaling", "Processing forwarded ICE candidate") + let iceMessage = ICEMessage( + candidate: candidate, + sdpMLineIndex: sdpMLineIndex, + sdpMid: sdpMid + ) + delegate?.signaling(didReceiveIceCandidate: iceMessage, for: currentRoomCode!) + } + } else if type == "join" { + if let payload = json["payload"] as? [String: Any], + let data = payload["data"] as? String { + RTCLogger.shared.log("Signaling", "Processing join message") + delegate?.signaling(didReceiveJoin: data, for: currentRoomCode!) + } + } + } + } catch { + RTCLogger.shared.log("Signaling", "Failed to parse message: \(error)") + } + } + + func send(type: SignalingMessage.MessageType, roomCode: String, payload: SignalingPayload) { + let message = SignalingMessage(type: type, roomCode: roomCode, payload: payload) + guard let data = try? JSONEncoder().encode(message), + let string = String(data: data, encoding: .utf8) + else { + print("Failed to encode message") + return + } + + webSocket?.send(.string(string)) { error in + if let error = error { + print("WebSocket send error: \(error)") + } + } + } +} + +protocol SignalingServiceDelegate: AnyObject { + func signaling(didReceiveOffer: SDPMessage, for roomCode: String) + func signaling(didReceiveAnswer: SDPMessage, for roomCode: String) + func signaling(didReceiveIceCandidate: ICEMessage, for roomCode: String) + func signaling(didReceiveJoin: String, for roomCode: String) +} diff --git a/Kino/Service/WebRTCService.swift b/Kino/Service/WebRTCService.swift new file mode 100644 index 0000000..8423851 --- /dev/null +++ b/Kino/Service/WebRTCService.swift @@ -0,0 +1,509 @@ +// +// RTC.swift +// Kino +// +// Created by Nitesh on 06/11/24. +// +import OSLog +import WebRTC + +// Custom logger for WebRTC events +class RTCLogger { + static let shared = RTCLogger() + private let logger = Logger(subsystem: "com.kino.app", category: "WebRTC") +// private let fileLogger: FileHandle? + +// init() { +// // Create unique log file for this instance +// let fileName = "kino_webrtc_\(UUID().uuidString).log" +// let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] +// let logPath = documentsPath.appendingPathComponent(fileName) +// +// FileManager.default.createFile(atPath: logPath.path, contents: nil) +// fileLogger = try? FileHandle(forWritingTo: logPath) +// +// log("Logger", "Logging to file: \(logPath.path)") +// } + + func log(_ type: String, _ message: String) { +// let timestamp = ISO8601DateFormatter().string(from: Date()) +// let logMessage = "[\(timestamp)] [\(type)] \(message)\n" + +#if DEBUG + logger.debug("[\(type)] \(message)") +#endif + +// fileLogger?.write(logMessage.data(using: .utf8) ?? Data()) + } + +// deinit { +// fileLogger?.closeFile() +// } +} + +class WebRTCService: NSObject, ObservableObject { + private let instanceId = UUID().uuidString + + private let factory: RTCPeerConnectionFactory + private let config: RTCConfiguration + private var peerConnection: RTCPeerConnection? + private var dataChannel: RTCDataChannel? + + private var pendingICECandidates: [RTCIceCandidate] = [] + private var isInitiator = false + + private let signaling = SignalingService() + private var currentRoomCode: String? + + var delegate: WebRTCServiceDelegate? + + // Published properties for UI updates + @Published var connectionState: RTCPeerConnectionState = .new + @Published var isConnected: Bool = false + @Published var dataChannelState: RTCDataChannelState = .closed + + override init() { + RTCLogger.shared.log("Init", "Initializing WebRTC Service instance: \(instanceId)") + + // Initialize WebRTC + RTCInitializeSSL() + RTCInitFieldTrialDictionary([:]) + factory = RTCPeerConnectionFactory() + + // Configure ICE servers + let iceServer = RTCIceServer(urlStrings: ["stun:stun.l.google.com:19302"]) + config = RTCConfiguration() + config.iceServers = [iceServer] + config.sdpSemantics = .unifiedPlan + config.continualGatheringPolicy = .gatherContinually + + super.init() + + signaling.delegate = self + + let iceServersStr = config.iceServers.flatMap { $0.urlStrings }.joined(separator: ", ") + RTCLogger.shared.log("Init", "WebRTC Service initialized with ICE servers: \(iceServersStr)") + } + + deinit { + RTCLogger.shared.log("Deinit", "Cleaning up WebRTC Service") + RTCCleanupSSL() + } + + // Create a room as host + func createRoom() async throws -> String { + RTCLogger.shared.log("Room", "Creating new room") + + isInitiator = true + + // Generate room code + let characters = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" + let length = 5 + let randomString = (0.. RTCSessionDescription { + let constraints = RTCMediaConstraints( + mandatoryConstraints: [ + "OfferToReceiveAudio": "false", + "OfferToReceiveVideo": "false", + ], + optionalConstraints: nil + ) + + return try await withCheckedThrowingContinuation { continuation in + peerConnection?.offer(for: constraints) { sdp, error in + if let error = error { + continuation.resume(throwing: error) + } else if let sdp = sdp { + RTCLogger.shared.log("SDP", "Created offer: \(sdp.sdp)") + continuation.resume(returning: sdp) + } else { + continuation.resume(throwing: WebRTCError.sdpCreationFailed) + } + } + } + } + + private func sendOffer(_ offer: RTCSessionDescription) { + guard let roomCode = currentRoomCode else { return } + RTCLogger.shared.log("SDP", "Sending offer") + + let sdpMessage = SDPMessage(sdp: offer.sdp, type: .offer) + signaling.send(type: .offer, roomCode: roomCode, payload: .sdp(sdpMessage)) + } + + private func sendAnswer(_ answer: RTCSessionDescription) { + guard let roomCode = currentRoomCode else { return } + RTCLogger.shared.log("SDP", "Sending answer") + + let sdpMessage = SDPMessage(sdp: answer.sdp, type: .answer) + signaling.send(type: .answer, roomCode: roomCode, payload: .sdp(sdpMessage)) + } + + private func sendIceCandidate(_ candidate: RTCIceCandidate) { + guard let roomCode = currentRoomCode else { return } + RTCLogger.shared.log("ICE", "Sending ICE candidate") + + let iceMessage = ICEMessage( + candidate: candidate.sdp, + sdpMLineIndex: candidate.sdpMLineIndex, + sdpMid: candidate.sdpMid + ) + signaling.send(type: .iceCandidate, roomCode: roomCode, payload: .ice(iceMessage)) + } + + func handlePeerJoined() { + RTCLogger.shared.log("WebRTC", "New peer joined, creating offer") + + guard isInitiator else { + RTCLogger.shared.log("WebRTC", "Not initiator, skipping offer creation") + return + } + + Task { + do { + let offer = try await createOffer() + try await peerConnection?.setLocalDescription(offer) + sendOffer(offer) + } catch { + RTCLogger.shared.log("WebRTC", "Failed to create and send offer: \(error)") + } + } + } + + private func handlePendingCandidates() { + guard let peerConnection = peerConnection, + peerConnection.remoteDescription != nil + else { return } + + RTCLogger.shared.log("ICE", "Processing \(pendingICECandidates.count) pending candidates") + + pendingICECandidates.forEach { candidate in + peerConnection.add(candidate) + } + pendingICECandidates.removeAll() + } + + // Join existing room + func joinRoom(code: String) async throws { + RTCLogger.shared.log("Room", "Joining room with code: \(code)") + isInitiator = false + currentRoomCode = code + + try await setupPeerConnection() + guard let peerConnection = peerConnection else { + throw WebRTCError.peerConnectionFailed + } + signaling.connect(roomCode: code) + + RTCLogger.shared.log("DataChannel", "Initial state: \(dataChannel?.readyState.rawValue ?? -1)") + } + + // Send player state through data channel + func sendPlayerState(_ state: PlayerState) { + guard let dataChannel = dataChannel else { + RTCLogger.shared.log("DataChannel", "Cannot send state: data channel is nil") + return + } + + guard dataChannel.readyState == .open else { + RTCLogger.shared.log( + "DataChannel", + "Cannot send state: data channel not open (state: \(dataChannel.readyState.rawValue))") + return + } + + do { + let data = try JSONEncoder().encode(state) + let buffer = RTCDataBuffer(data: data, isBinary: true) + dataChannel.sendData(buffer) + RTCLogger.shared.log( + "DataChannel", "Sent player isPlaying: \(state.isPlaying) at position \(state.position)") + } catch { + RTCLogger.shared.log("DataChannel", "Failed to send player state: \(error)") + } + } +} + +// MARK: - RTCPeerConnectionDelegate +extension WebRTCService: RTCPeerConnectionDelegate { + func peerConnection( + _ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState + ) { + RTCLogger.shared.log("PeerConnection", "Signaling state changed to: \(stateChanged.rawValue)") + } + + func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) { + RTCLogger.shared.log("PeerConnection", "Negotiation required") + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange state: RTCPeerConnectionState) + { + DispatchQueue.main.async { + self.connectionState = state + self.isConnected = (state == .connected) + } + RTCLogger.shared.log("PeerConnection", "Connection state changed to: \(state.rawValue)") + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) { + RTCLogger.shared.log("PeerConnection", "Stream added: \(stream.streamId)") + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didRemove stream: RTCMediaStream) { + RTCLogger.shared.log("PeerConnection", "Stream removed: \(stream.streamId)") + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didGenerate candidate: RTCIceCandidate) { + RTCLogger.shared.log("ICE", "Generated candidate: \(candidate.sdp)") + sendIceCandidate(candidate) + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didRemove candidates: [RTCIceCandidate]) + { + RTCLogger.shared.log("ICE", "Removed \(candidates.count) candidates") + } + + func peerConnection( + _ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState + ) { + RTCLogger.shared.log("ICE", "Connection state changed to: \(newState.rawValue)") + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceGatheringState) + { + RTCLogger.shared.log("ICE", "Gathering state changed to: \(newState.rawValue)") + } + + func peerConnection(_ peerConnection: RTCPeerConnection, didOpen dataChannel: RTCDataChannel) { + RTCLogger.shared.log( + "DataChannel", + """ + Received data channel: + Label: \(dataChannel.label) + State: \(dataChannel.readyState.rawValue) + IsOrdered: \(dataChannel.isOrdered) + """) + self.dataChannel = dataChannel + dataChannel.delegate = self + RTCLogger.shared.log("DataChannel", "Delegate set for received channel") + } +} + +// MARK: - RTCDataChannelDelegate +extension WebRTCService: RTCDataChannelDelegate { + func dataChannelDidChangeState(_ dataChannel: RTCDataChannel) { + DispatchQueue.main.async { + self.dataChannelState = dataChannel.readyState + self.isConnected = (dataChannel.readyState == .open) + } + + RTCLogger.shared.log( + "DataChannel", + """ + State changed to: \(dataChannel.readyState.rawValue) + Label: \(dataChannel.label) + IsOrdered: \(dataChannel.isOrdered) + ChannelId: \(dataChannel.channelId) + BufferedAmount: \(dataChannel.bufferedAmount) + MaxRetransmits: \(String(describing: dataChannel.maxRetransmits)) + IsNegotiated: \(dataChannel.isNegotiated) + """) + } + + func dataChannel(_ dataChannel: RTCDataChannel, didReceiveMessageWith buffer: RTCDataBuffer) { + if let data = try? JSONDecoder().decode(PlayerState.self, from: buffer.data) { + RTCLogger.shared.log( + "DataChannel", "Received player is Playing: \(data.isPlaying) at position \(data.position)" + ) + DispatchQueue.main.async { + self.delegate?.webRTC(didReceivePlayerState: data) + } + } else { + RTCLogger.shared.log("DataChannel", "Received message but failed to decode") + } + } +} + +extension WebRTCService: SignalingServiceDelegate { + func signaling(didReceiveOffer sdpMessage: SDPMessage, for roomCode: String) { + RTCLogger.shared.log("WebRTC", "Received offer for room: \(roomCode)") + + Task { + do { + if peerConnection == nil { + RTCLogger.shared.log("WebRTC", "Setting up peer connection for receiver") + try await setupPeerConnection() + } + + guard let peerConnection = peerConnection else { + throw WebRTCError.peerConnectionFailed + } + + let sdp = RTCSessionDescription(type: .offer, sdp: sdpMessage.sdp) + RTCLogger.shared.log("WebRTC", "Setting remote description") + try await peerConnection.setRemoteDescription(sdp) + + RTCLogger.shared.log("WebRTC", "Creating answer") + let constraints = RTCMediaConstraints( + mandatoryConstraints: [ + "OfferToReceiveAudio": "false", + "OfferToReceiveVideo": "false", + ], + optionalConstraints: nil + ) + let answer = try await peerConnection.answer(for: constraints) + + RTCLogger.shared.log("WebRTC", "Setting local description") + try await peerConnection.setLocalDescription(answer) + + RTCLogger.shared.log("WebRTC", "Sending answer") + signaling.send( + type: .answer, + roomCode: roomCode, + payload: .sdp(SDPMessage(sdp: answer.sdp, type: .answer)) + ) + handlePendingCandidates() + } catch { + RTCLogger.shared.log("WebRTC", "Error processing offer: \(error)") + } + } + } + + func signaling(didReceiveAnswer sdpMessage: SDPMessage, for roomCode: String) { + RTCLogger.shared.log("SDP", "Processing received answer") + guard let peerConnection = peerConnection else { return } + + Task { + do { + let sdp = RTCSessionDescription(type: .answer, sdp: sdpMessage.sdp) + RTCLogger.shared.log("SDP", "Setting remote description (answer)") + try await peerConnection.setRemoteDescription(sdp) + RTCLogger.shared.log("SDP", "Remote description set successfully") + handlePendingCandidates() + } catch { + RTCLogger.shared.log("SDP", "Error handling answer: \(error)") + } + } + } + + func signaling(didReceiveIceCandidate iceMessage: ICEMessage, for roomCode: String) { + RTCLogger.shared.log("ICE", "Received ICE candidate") + + let candidate = RTCIceCandidate( + sdp: iceMessage.candidate, + sdpMLineIndex: iceMessage.sdpMLineIndex, + sdpMid: iceMessage.sdpMid + ) + + if peerConnection?.remoteDescription == nil { + pendingICECandidates.append(candidate) + RTCLogger.shared.log("ICE", "Stored pending ICE candidate") + } else { + peerConnection?.add(candidate) + RTCLogger.shared.log("ICE", "Added ICE candidate immediately") + } + } + + func signaling(didReceiveJoin message: String, for roomCode: String) { + RTCLogger.shared.log("WebRTC", "Received join message: \(message)") + handlePeerJoined() + } +} + +struct PlayerState: Codable { + let isPlaying: Bool + let position: Float +} + +// MARK: - Custom Errors +enum WebRTCError: Error { + case peerConnectionFailed + case dataChannelFailed + case sdpCreationFailed + case invalidState +} + +protocol WebRTCServiceDelegate { + func webRTC(didReceivePlayerState state: PlayerState) +} diff --git a/Kino/Views/ContentView.swift b/Kino/Views/ContentView.swift index d67c1ad..85cd881 100644 --- a/Kino/Views/ContentView.swift +++ b/Kino/Views/ContentView.swift @@ -8,18 +8,6 @@ import SwiftUI import AVKit -enum KinoScreen { - case home - case player -} - -@Observable -class KinoViewModel { - var currentScreen: KinoScreen = .player - var showNewRoomSheet = false - var showJoinSheet = false -} - struct ContentView: View { @State private var viewModel = KinoViewModel() diff --git a/Kino/Views/HomeScreen.swift b/Kino/Views/HomeScreen.swift index 3fc21a6..aabdfb0 100644 --- a/Kino/Views/HomeScreen.swift +++ b/Kino/Views/HomeScreen.swift @@ -11,16 +11,17 @@ import AVKit struct HomeScreen: View { @Bindable var viewModel: KinoViewModel - + var body: some View { ZStack { -#if os(macOS) + #if os(macOS) // Base background color with material Rectangle() .fill(.background.opacity(0.5)) .background(VisualEffectView()) .ignoresSafeArea() -#endif + #endif + ZStack { // Background gradient blurs Circle() @@ -29,14 +30,14 @@ struct HomeScreen: View { .blur(radius: 160) .opacity(0.1) .offset(x: 150, y: -150) - + Circle() .fill(Color(hex: "8A7AFF")) .frame(width: 300, height: 300) .blur(radius: 160) .opacity(0.1) .offset(x: -150, y: 150) - + // Content VStack(spacing: 48) { HStack(spacing: 20) { @@ -46,7 +47,7 @@ struct HomeScreen: View { badge: "New Room", action: { viewModel.showNewRoomSheet = true } ) - + ActionCard( emoji: "🎟️", title: "Join Party", @@ -59,23 +60,23 @@ struct HomeScreen: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } -#if os(macOS) - // .sheet(isPresented: $viewModel.showNewRoomSheet) { - // NewRoomSheet(viewModel: viewModel) - // .frame(width: 560, height: 620) - // } - // .sheet(isPresented: $viewModel.showJoinSheet) { - // JoinRoomSheet(viewModel: viewModel) - // .frame(width: 420, height: 480) - // } -#else - // .sheet(isPresented: $viewModel.showNewRoomSheet) { - // NewRoomSheet(viewModel: viewModel) - // } - // .sheet(isPresented: $viewModel.showJoinSheet) { - // JoinRoomSheet(viewModel: viewModel) - // } -#endif + #if os(macOS) + .sheet(isPresented: $viewModel.showNewRoomSheet) { + NewRoomSheet(viewModel: viewModel) + .frame(width: 560, height: 680) + } + .sheet(isPresented: $viewModel.showJoinSheet) { + JoinRoomSheet(viewModel: viewModel) + .frame(width: 420, height: 480) + } + #else + .sheet(isPresented: $viewModel.showNewRoomSheet) { + NewRoomSheet(viewModel: viewModel) + } + .sheet(isPresented: $viewModel.showJoinSheet) { + JoinRoomSheet(viewModel: viewModel) + } + #endif } } diff --git a/Kino/Views/JoinRoomSheet.swift b/Kino/Views/JoinRoomSheet.swift new file mode 100644 index 0000000..b04f059 --- /dev/null +++ b/Kino/Views/JoinRoomSheet.swift @@ -0,0 +1,192 @@ +// +// JoinRoomSheet.swift +// Kino +// +// Created by Nitesh on 06/11/24. +// +import SwiftUI + +struct RoomPreview { + let name: String + let movie: String + let participants: [Participant] +} + + +struct JoinRoomSheet: View { + @Bindable var viewModel: KinoViewModel + @Environment(\.dismiss) private var dismiss + @State private var displayName: String = "" + @State private var roomCode: String = "" + + // Mock data for room preview + private let roomPreview = RoomPreview( + name: "Movie Night", + movie: "Blade Runner 2049", + participants: [ + Participant(name: "Nitesh", status: "Host", avatar: "N"), + Participant(name: "Kriti", status: "Watching", avatar: "K"), + ] + ) + + private func handleJoinRoom() { + // Save the display name + viewModel.displayName = displayName + + // Join the room + Task { + await viewModel.roomViewModel.joinRoom(code: roomCode) + await MainActor.run { + viewModel.currentScreen = .player + dismiss() + } + } + } + + var body: some View { + VStack(spacing: 0) { + HStack { + Text("Join Watch Party") + .font(.custom("OpenSauceTwo-Bold", size: 16)) + + Spacer() + + Button(action: { dismiss() }) { + Image(systemName: "xmark") + .font(.system(size: 14)) + .frame(width: 24, height: 24) + .background(KinoTheme.bgTertiary) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 24) + .padding(.vertical, 20) + .background(KinoTheme.bgTertiary) + + ScrollView { + VStack(spacing: 24) { + InputFields(displayName: $displayName, roomCode: $roomCode) + RoomPreviewCard(preview: roomPreview) + } + .padding(24) + } + + SheetFooter( + onCancel: { dismiss() }, + onAction: { + handleJoinRoom() + } + ) + } + .background(KinoTheme.bgSecondary) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } +} + + +private struct InputFields: View { + @Binding var displayName: String + @Binding var roomCode: String + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + InputField( + label: "Your Display Name", + placeholder: "Enter your name", + text: $displayName + ) + + InputField( + label: "Room Code", + placeholder: "KINO-XXXXX", + text: $roomCode + ) + .textCase(.uppercase) + .font(.custom("SF Mono", size: 16)) + .kerning(1) + } + } +} + +private struct RoomPreviewCard: View { + let preview: RoomPreview + + var body: some View { + VStack(spacing: 16) { + RoomInfo(preview: preview) + ParticipantsList(participants: preview.participants) + } + .padding(16) + .background(KinoTheme.bgTertiary) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } +} + +private struct RoomInfo: View { + let preview: RoomPreview + + var body: some View { + HStack(spacing: 12) { + Text("🎬") + .font(.system(size: 20)) + .frame(width: 40, height: 40) + .background(KinoTheme.accent.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + + VStack(alignment: .leading, spacing: 4) { + Text(preview.name) + .font(.custom("OpenSauceTwo-Bold", size: 14)) + .foregroundStyle(KinoTheme.textPrimary) + + Text("\(preview.movie) • \(preview.participants.count) watching") + .font(.custom("OpenSauceTwo-Regular", size: 12)) + .foregroundStyle(KinoTheme.textSecondary) + } + + Spacer() + } + .padding(.bottom, 16) + .overlay(alignment: .bottom) { + Rectangle() + .fill(KinoTheme.surfaceBorder) + .frame(height: 1) + } + } +} + +private struct ParticipantsList: View { + let participants: [Participant] + + var body: some View { + HStack(spacing: 8) { + ForEach(participants) { participant in + Text(participant.avatar) + .font(.custom("OpenSauceTwo-Medium", size: 12)) + .foregroundStyle(.white) + .frame(width: 28, height: 28) + .background(KinoTheme.accentGradient) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } + } +} + +private struct SheetFooter: View { + let onCancel: () -> Void + let onAction: () -> Void + + var body: some View { + HStack(spacing: 12) { + Button("Cancel", action: onCancel) + .buttonStyle(SecondaryButtonStyle()) + + Button("Join Room", action: onAction) + .buttonStyle(PrimaryButtonStyle()) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 24) + .padding(.vertical, 20) + .background(KinoTheme.bgTertiary) + } +} diff --git a/Kino/Views/KinoVideoPlayer.swift b/Kino/Views/KinoVideoPlayer.swift new file mode 100644 index 0000000..0b4e58f --- /dev/null +++ b/Kino/Views/KinoVideoPlayer.swift @@ -0,0 +1,191 @@ +// +// VideoPlayer.swift +// Kino +// +// Created by Nitesh on 06/11/24. +// + +import SwiftUI +import VLCKit + +struct KinoVideoPlayer: View { + let player: VLCMediaPlayer + let shouldHideControls: Bool + @Bindable var viewModel: KinoViewModel + @Binding var isBuffering: Bool + let onStateChange: (Bool, Float) -> Void + + + private let bufferingDebounceInterval: TimeInterval = 0.5 + @State private var bufferingDebounceTimer: Timer? + + var body: some View { + GeometryReader { geometry in + ZStack { + // Video Content + VLCPlayerView(player: player) + .ignoresSafeArea() + .onAppear { + // Set up buffering detection + NotificationCenter.default.addObserver( + forName: NSNotification.Name(rawValue: "VLCMediaPlayerStateChanged"), + object: player, + queue: .main + ) { notification in + handlePlayerStateChange() + } + } + + if isBuffering { + ProgressView() + .progressViewStyle(.circular) + .scaleEffect(1.5) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + VideoControls( + player: player, viewModel: viewModel + ) + .opacity(shouldHideControls ? 0 : 1) + .offset(y: shouldHideControls ? 20 : 0) + .animation(.easeInOut(duration: 0.2), value: shouldHideControls) + + } + .background(Color.black) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: shouldHideControls) + } + } + + private func handlePlayerStateChange() { + print("[VLC] Player State \(player.state) at \(player.position)") + onStateChange(player.isPlaying, player.position) + } + +} + +struct VideoControls: View { + let player: VLCMediaPlayer + @State private var isPlaying = false + @State private var progress: Float = 0 + @State private var volume: Int = 100 + @State private var timeString = "00:00 / 00:00" + @Bindable var viewModel: KinoViewModel + + + // Timer just for UI updates, not for sync + let timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect() + + private func formatTime(_ time: Int) -> String { + let hours = time / 3600 + let minutes = (time % 3600) / 60 + let seconds = time % 60 + + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } else { + return String(format: "%02d:%02d", minutes, seconds) + } + } + + private func updateTimeString() { + let current = formatTime(Int(player.time.intValue / 1000)) + let total = formatTime(Int(player.media?.length.intValue ?? 0) / 1000) + timeString = "\(current) / \(total)" + } + + var body: some View { + VStack { + Spacer() + + // Controls background gradient + LinearGradient( + gradient: Gradient(colors: [ + .black.opacity(0), + .black.opacity(0.5), + .black.opacity(0.8), + ]), + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 120) + .overlay { + VStack(spacing: 12) { + // Progress bar + Slider( + value: Binding( + get: { progress }, + set: { newValue in + progress = newValue + player.position = newValue + } + ), in: 0...1 + ) + .tint(KinoTheme.accent) + + HStack(spacing: 20) { + // Play/Pause button + Button(action: { + if player.isPlaying { + player.pause() + } else { + player.play() + } + isPlaying.toggle() + }) { + Image(systemName: isPlaying ? "pause.fill" : "play.fill") + .font(.system(size: 20)) + .frame(width: 36, height: 36) + .background(KinoTheme.accent) + .clipShape(Circle()) + } + .buttonStyle(.plain) + + // Time + Text(timeString) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white) + + Spacer() + + // Volume control + HStack(spacing: 8) { + Image(systemName: "speaker.wave.3.fill") + .font(.system(size: 12)) + + Slider( + value: Binding( + get: { Double(volume) }, + set: { newValue in + volume = Int(newValue) + player.audio?.volume = Int32(newValue) + } + ), in: 0...100 + ) + .frame(width: 100) + .tint(KinoTheme.accent) + } + } + } + .padding(.horizontal, 20) + .padding(.bottom, 20) + } + } + .onReceive(timer) { _ in + progress = player.position + updateTimeString() + isPlaying = player.isPlaying + } + } +} + +struct VLCPlayerView: NSViewRepresentable { + let player: VLCMediaPlayer + + func makeNSView(context: Context) -> NSView { + let view = NSView() + player.drawable = view + return view + } + + func updateNSView(_ nsView: NSView, context: Context) {} +} diff --git a/Kino/Views/NewRoomSheet.swift b/Kino/Views/NewRoomSheet.swift new file mode 100644 index 0000000..730e6e7 --- /dev/null +++ b/Kino/Views/NewRoomSheet.swift @@ -0,0 +1,295 @@ +// +// NewRoomSheet.swift +// Kino +// +// Created by Nitesh on 06/11/24. +// + +import SwiftUI + +struct NewRoomSheet: View { + @Bindable var viewModel: KinoViewModel + @Environment(\.dismiss) private var dismiss + @State private var roomName: String = "" + @State private var displayName: String = "" + @State private var isCameraEnabled = true + @State private var isMicEnabled = true + @State private var isChatEnabled = true + @State private var isPrivateRoom = false + @State private var isCreatingRoom = false + @State private var showJoinButton = false + + private func handleCreateRoom() { + isCreatingRoom = true + showJoinButton = false + + // Save the input values + viewModel.roomName = roomName + viewModel.displayName = displayName + + // Create the room + Task { + await viewModel.roomViewModel.createRoom() + await MainActor.run { + isCreatingRoom = false + // Show the join button if we have a room code + if !viewModel.roomViewModel.roomCode.isEmpty { + withAnimation(.spring(response: 0.3)) { + showJoinButton = true + } + } + } + } + } + + private func handleJoinCreatedRoom() { + viewModel.currentScreen = .player + dismiss() + } + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Text("New Watch Party") + .font(.custom("OpenSauceTwo-Bold", size: 16)) + + Spacer() + + Button(action: { dismiss() }) { + Image(systemName: "xmark") + .font(.system(size: 14)) + .frame(width: 24, height: 24) + .background(KinoTheme.bgTertiary) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + .buttonStyle(.plain) + } + .padding(.horizontal, 24) + .padding(.vertical, 20) + .background(KinoTheme.bgTertiary) + + ScrollView { + VStack(spacing: 24) { + // Input Fields + VStack(alignment: .leading, spacing: 20) { + InputField( + label: "Room Name", + placeholder: "Movie Night", + text: $roomName + ) + + InputField( + label: "Your Display Name", + placeholder: "Enter your name", + text: $displayName + ) + } + + // Video Select + Button(action: {}) { + VStack(spacing: 16) { + Image(systemName: "folder") + .font(.system(size: 24)) + .frame(width: 48, height: 48) + .background(KinoTheme.accent.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + VStack(spacing: 8) { + Text("Choose a video file") + .font(.custom("OpenSauceTwo-Medium", size: 14)) + .foregroundStyle(KinoTheme.textPrimary) + + Text("Drop file here or click to browse") + .font(.custom("OpenSauceTwo-Regular", size: 13)) + .foregroundStyle(KinoTheme.textSecondary) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .background { + RoundedRectangle(cornerRadius: 12) + .strokeBorder(style: StrokeStyle(lineWidth: 2, dash: [6])) + .foregroundStyle(KinoTheme.surfaceBorder) + } + } + .buttonStyle(.plain) + + // Room Code + VStack(spacing: 8) { + Text("Room Code") + .font(.custom("OpenSauceTwo-Regular", size: 13)) + .foregroundStyle(KinoTheme.textSecondary) + + if isCreatingRoom { + ProgressView() + .scaleEffect(0.8) + .frame(height: 36) + } else if !viewModel.roomViewModel.roomCode.isEmpty { + CopyableRoomCode(code: viewModel.roomViewModel.roomCode) + .transition(.scale.combined(with: .opacity)) + } else { + Text("▯▯▯▯-▯▯▯▯▯") + .font(.custom("SF Mono", size: 24)) + .fontWeight(.semibold) + .foregroundStyle(KinoTheme.textSecondary) + .kerning(2) + } + + Text("Share this code with friends to invite them") + .font(.custom("OpenSauceTwo-Regular", size: 12)) + .foregroundStyle(KinoTheme.textSecondary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 24) + .background(KinoTheme.bgTertiary) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + .padding(24) + } + + + // Footer + HStack(spacing: 12) { + Button("Cancel") { + dismiss() + } + .buttonStyle(SecondaryButtonStyle()) + + if showJoinButton { + Button("Join Room") { + handleJoinCreatedRoom() + } + .buttonStyle(PrimaryButtonStyle()) + .transition(.scale.combined(with: .opacity)) + } else { + Button("Create Room") { + handleCreateRoom() + } + .buttonStyle(PrimaryButtonStyle()) + .disabled(isCreatingRoom) + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 24) + .padding(.vertical, 20) + .background(KinoTheme.bgTertiary) + } + .animation(.spring(response: 0.3), value: isCreatingRoom) + .animation(.spring(response: 0.3), value: viewModel.roomViewModel.roomCode) + .background(KinoTheme.bgSecondary) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } +} + +struct CopyableRoomCode: View { + let code: String + @State private var isCopied = false + + var body: some View { + HStack(spacing: 12) { + Text(code) + .font(.custom("SF Mono", size: 24)) + .fontWeight(.semibold) + .foregroundStyle(KinoTheme.accent) + .kerning(2) + + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(code, forType: .string) + + withAnimation { + isCopied = true + } + + // Reset copy state after 2 seconds + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + withAnimation { + isCopied = false + } + } + } label: { + Image(systemName: isCopied ? "checkmark.circle.fill" : "doc.on.doc") + .foregroundStyle(isCopied ? .green : KinoTheme.textSecondary) + .font(.system(size: 16)) + } + .buttonStyle(.plain) + } + } +} + +// Supporting Views +struct InputField: View { + let label: String + let placeholder: String + @Binding var text: String + @FocusState private var isFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text(label) + .font(.custom("OpenSauceTwo-Medium", size: 13)) + .foregroundStyle(KinoTheme.textSecondary) + + TextField("", text: $text) + .font(.custom("OpenSauceTwo-Regular", size: 14)) + .textFieldStyle(PlainTextFieldStyle()) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(KinoTheme.bgTertiary) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay { + RoundedRectangle(cornerRadius: 8) + .strokeBorder( + isFocused ? KinoTheme.accent : + KinoTheme.surfaceBorder) + } + .placeholder(when: text.isEmpty) { + Text(placeholder) + .font(.custom("OpenSauceTwo-Regular", size: 14)) + .foregroundStyle(KinoTheme.textSecondary) + .padding(.leading, 12) + }.focused($isFocused) + } + } +} + +struct PrimaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.custom("OpenSauceTwo-Medium", size: 13)) + .foregroundStyle(.white) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(KinoTheme.accent) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .opacity(configuration.isPressed ? 0.9 : 1) + } +} + +struct SecondaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.custom("OpenSauceTwo-Medium", size: 13)) + .foregroundStyle(KinoTheme.textPrimary) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(KinoTheme.bgTertiary) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .opacity(configuration.isPressed ? 0.9 : 1) + } +} + +// Helper View Extension +extension View { + func placeholder( + when shouldShow: Bool, + alignment: Alignment = .leading, + @ViewBuilder placeholder: () -> Content + ) -> some View { + ZStack(alignment: alignment) { + placeholder().opacity(shouldShow ? 1 : 0) + self + } + } +} diff --git a/Kino/Views/PlayerScreen.swift b/Kino/Views/PlayerScreen.swift index 7ef23e0..9060bb6 100644 --- a/Kino/Views/PlayerScreen.swift +++ b/Kino/Views/PlayerScreen.swift @@ -8,24 +8,6 @@ import SwiftUI import VLCKit -struct VLCPlayerView: NSViewRepresentable { - let player: VLCMediaPlayer - - init(player: VLCMediaPlayer) { - self.player = player - } - - func makeNSView(context: Context) -> NSView { - let view = NSView() - player.drawable = view - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - // Update logic if needed - } -} - struct PlayerScreen: View { @Bindable var viewModel: KinoViewModel @State private var player: VLCMediaPlayer = VLCMediaPlayer() @@ -35,6 +17,55 @@ struct PlayerScreen: View { @State private var showChat = true @State private var isCollapsed = false + @State private var lastActivityTime = Date() + @State private var shouldHideControls = false + @State private var isMouseInView = false + @State private var isCursorHidden = false + + let inactivityTimer = Timer.publish(every: 0.1, on: .main, in: .common).autoconnect() + let inactivityThreshold: TimeInterval = 3.0 + + @State private var lastSyncTime = Date() + @State private var syncDebounceTimer: Timer? + private let syncDebounceInterval: TimeInterval = 0.5 + + + @State private var isBuffering = false + + private func showCursor() { + if isCursorHidden { + DispatchQueue.main.async { + NSCursor.unhide() + isCursorHidden = false + } + } + } + + private func hideCursor() { + if !isCursorHidden && !isCollapsed && !isDragging { + DispatchQueue.main.async { + NSCursor.hide() + isCursorHidden = true + } + } + } + + private func handleActivity() { + lastActivityTime = Date() + shouldHideControls = false + + showCursor() + } + + private func checkInactivity() { + guard isMouseInView else { return } + + let timeSinceLastActivity = Date().timeIntervalSince(lastActivityTime) + if timeSinceLastActivity >= inactivityThreshold { + shouldHideControls = true + hideCursor() + } + } private func getWindowSize() -> CGSize { guard let window = NSApp.windows.first else { return .zero } @@ -45,7 +76,7 @@ struct PlayerScreen: View { private func calculatePanelPosition() -> CGPoint { let windowSize = getWindowSize() let width = isCollapsed ? 200.0 : 320.0 - let height = isCollapsed ? 120.0 : 480.0 + let height = isCollapsed ? 120.0 : 430.0 let paddingX: CGFloat = 20 let paddingY: CGFloat = 80 @@ -55,19 +86,44 @@ struct PlayerScreen: View { ) } - var body: some View { GeometryReader { geometry in - ZStack (alignment: .topLeading) { - VLCPlayerView(player: player) - .ignoresSafeArea() - .onAppear { - player.media = VLCMedia(url: URL(string: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4")!) - player.play() // Auto-play when view appears + ZStack(alignment: .topLeading) { + KinoVideoPlayer( + player: player, + shouldHideControls: shouldHideControls, + viewModel: viewModel, + isBuffering: $isBuffering, + onStateChange: { isPlaying, position in + if !viewModel.roomViewModel.isInternalStateChange { + viewModel.roomViewModel.handlePlayerStateChange( + state: + PlayerState( + isPlaying: isPlaying, + position: position + ) + ) + } } - .onDisappear { - player.stop() // Clean up when view disappears + ) + .ignoresSafeArea() + .onAppear { + player.media = VLCMedia( + url: URL( + string: + "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4" + )!) + } + .onDisappear { + player.stop() + }.onContinuousHover(coordinateSpace: .local) { phase in + switch phase { + case .active: + handleActivity() + case .ended: + break } + } FloatingPanel( position: $panelPosition, @@ -76,6 +132,13 @@ struct PlayerScreen: View { ) { ChatPanel(showChat: $showChat, isCollapsed: $isCollapsed) }.zIndex(1) + .onHover { hovering in + // Always show cursor when hovering over chat panel + if hovering && isCursorHidden { + NSCursor.unhide() + isCursorHidden = false + } + } } .background(Color.black) .onAppear { @@ -84,6 +147,43 @@ struct PlayerScreen: View { .onChange(of: geometry.size) { oldSize, newSize in panelPosition = calculatePanelPosition() } + .onKeyPress(.space) { + guard let window = NSApp.keyWindow, + !(window.firstResponder is NSTextView), + !isBuffering // Don't toggle if buffering + else { return .ignored } + + if player.isPlaying { + player.pause() + } else { + player.play() + } + + return .handled + } + .onHover { isHovering in + isMouseInView = isHovering + if isHovering { + handleActivity() + } else { + // Show cursor when mouse leaves the view + if isCursorHidden { + NSCursor.unhide() + isCursorHidden = false + } + } + } + .onReceive(inactivityTimer) { _ in + checkInactivity() + } + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + handleActivity() + } + ) + }.onAppear { + viewModel.roomViewModel.setPlayerDelegate(self) } } } @@ -97,7 +197,10 @@ struct FloatingPanel: View { @State private var dragOffset = CGSize.zero @State private var opacity: Double = 1.0 - init(position: Binding, isDragging: Binding, isCollapsed: Binding, @ViewBuilder content: () -> Content) { + init( + position: Binding, isDragging: Binding, isCollapsed: Binding, + @ViewBuilder content: () -> Content + ) { self._position = position self._isDragging = isDragging self._isCollapsed = isCollapsed @@ -110,7 +213,7 @@ struct FloatingPanel: View { let paddingY: CGFloat = isCollapsed ? 60 : 80 let paddingX: CGFloat = isCollapsed ? 10 : 20 let panelWidth = isCollapsed ? 200.0 : 320.0 - let panelHeight = isCollapsed ? 120.0 : 480.0 + let panelHeight = isCollapsed ? 120.0 : 430.0 // Constrain x position let maxX = frame.width - panelWidth - paddingX @@ -139,7 +242,7 @@ struct FloatingPanel: View { } } .shadow(color: Color.black.opacity(isDragging ? 0.3 : 0.15), radius: isDragging ? 30 : 20) - .frame(width: isCollapsed ? 200 : 320, height: isCollapsed ? 120 : 480) + .frame(width: isCollapsed ? 200 : 320, height: isCollapsed ? 120 : 430) .offset(x: position.x + dragOffset.width, y: position.y + dragOffset.height) .gesture( DragGesture() @@ -168,7 +271,7 @@ struct FloatingPanel: View { // Constrain y position to screen bounds let minY = 0.0 - let maxY = frame.height - (isCollapsed ? 120 : 480) - paddingY + let maxY = frame.height - (isCollapsed ? 120 : 430) - paddingY position.y = max(minY, min(maxY, position.y)) // Dock to right edge if near @@ -237,9 +340,13 @@ struct Participant: Identifiable { class ChatViewModel: ObservableObject { let messages = [ ChatMessage(text: "This scene is amazing!", sender: "Sarah", time: "2m ago", isSent: false), - ChatMessage(text: "Yeah, the cinematography is incredible", sender: "You", time: "1m ago", isSent: true), - ChatMessage(text: "The score really adds to the tension", sender: "Alex", time: "Just now", isSent: false), - ChatMessage(text: "Definitely! This is my favorite part coming up", sender: "You", time: "Just now", isSent: true) + ChatMessage( + text: "Yeah, the cinematography is incredible", sender: "You", time: "1m ago", isSent: true), + ChatMessage( + text: "The score really adds to the tension", sender: "Alex", time: "Just now", isSent: false), + ChatMessage( + text: "Definitely! This is my favorite part coming up", sender: "You", time: "Just now", + isSent: true), ] let participants = [ @@ -266,7 +373,6 @@ struct ChatPanel: View { .foregroundStyle(KinoTheme.textPrimary) } - Spacer() HStack(spacing: 8) { @@ -368,7 +474,7 @@ struct MessageBubble: View { VStack(alignment: message.isSent ? .trailing : .leading, spacing: 4) { Text(message.text) - .font(.system(size: 13)) + .font(.custom("OpenSauceTwo-Regular", size: 13)) .foregroundColor(message.isSent ? .white : KinoTheme.textPrimary) .padding(.horizontal, 12) .padding(.vertical, 8) @@ -385,11 +491,11 @@ struct MessageBubble: View { HStack(spacing: 4) { Text(message.sender) - .fontWeight(.medium) + .font(.custom("OpenSauceTwo-Medium", size: 11)) Text("•") Text(message.time) } - .font(.system(size: 11)) + .font(.custom("OpenSauceTwo-Regular", size: 11)) .foregroundStyle(KinoTheme.textSecondary) } @@ -406,7 +512,7 @@ struct CompactParticipantGrid: View { LazyVGrid( columns: [ GridItem(.flexible(), spacing: 4), - GridItem(.flexible(), spacing: 4) + GridItem(.flexible(), spacing: 4), ], spacing: 4 ) { @@ -427,7 +533,7 @@ struct CompactParticipantCell: View { // Video placeholder Rectangle() .fill(Color.black) - .aspectRatio(16/9, contentMode: .fit) + .aspectRatio(16 / 9, contentMode: .fit) .overlay { Text(participant.avatar) .font(.system(size: 14, weight: .semibold)) @@ -492,7 +598,6 @@ struct ParticipantsView: View { } } - struct ParticipantCell: View { let participant: Participant let isCollapsed: Bool @@ -503,8 +608,8 @@ struct ParticipantCell: View { ZStack { Rectangle() .fill(Color.black) - .aspectRatio(16/9, contentMode: .fit) // Always keep 16:9 - .frame(width: isCollapsed ? 200 : nil) // Width for collapsed state + .aspectRatio(16 / 9, contentMode: .fit) // Always keep 16:9 + .frame(width: isCollapsed ? 200 : nil) // Width for collapsed state Text(participant.avatar) .font(.system(size: isCollapsed ? 14 : 18, weight: .semibold)) @@ -530,13 +635,16 @@ extension Color { let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) var int: UInt64 = 0 Scanner(string: hex).scanHexInt64(&int) - let a, r, g, b: UInt64 + let a: UInt64 + let r: UInt64 + let g: UInt64 + let b: UInt64 switch hex.count { - case 3: // RGB (12-bit) + case 3: // RGB (12-bit) (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) - case 6: // RGB (24-bit) + case 6: // RGB (24-bit) (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) - case 8: // ARGB (32-bit) + case 8: // ARGB (32-bit) (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) default: (a, r, g, b) = (1, 1, 1, 0) @@ -545,8 +653,34 @@ extension Color { .sRGB, red: Double(r) / 255, green: Double(g) / 255, - blue: Double(b) / 255, + blue: Double(b) / 255, opacity: Double(a) / 255 ) } } + +extension PlayerScreen: WebRTCServiceDelegate { + func webRTC(didReceivePlayerState state: PlayerState) { + DispatchQueue.main.async { + viewModel.roomViewModel.isInternalStateChange = true + + if state.isPlaying != player.isPlaying { + state.isPlaying ? player.play() : player.pause() + } + + let positionDiff = abs(state.position - player.position) + if positionDiff > 0.05 { // 5% threshold + player.position = state.position + } + + viewModel.roomViewModel.isInternalStateChange = false + } + } + + private func handleReceivedPlayerState(_ state: PlayerState) { + player.position = state.position + if state.isPlaying != player.isPlaying { + state.isPlaying ? player.play() : player.pause() + } + } +} diff --git a/Podfile b/Podfile index c40eada..95cb37a 100644 --- a/Podfile +++ b/Podfile @@ -4,6 +4,7 @@ platform :macos, '14.6' target 'Kino' do use_frameworks! pod 'VLCKit' + pod 'WebRTC-lib' end diff --git a/Podfile.lock b/Podfile.lock index 1b2c43a..bc18434 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,16 +1,20 @@ PODS: - VLCKit (3.6.0) + - WebRTC-lib (130.0.0) DEPENDENCIES: - VLCKit + - WebRTC-lib SPEC REPOS: trunk: - VLCKit + - WebRTC-lib SPEC CHECKSUMS: VLCKit: 0249cc10be7094917f2230468957dbd36f378291 + WebRTC-lib: 73de4fee6337c39be0ab9ddd516dc64bb73cefc5 -PODFILE CHECKSUM: 85417830af75db7a568bb408ea6d481aca27fcc8 +PODFILE CHECKSUM: 1296bfbd166d7f785fc8b85bc081b0216501026c COCOAPODS: 1.16.2 diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/server/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/server/src/worker.js b/server/src/worker.js new file mode 100644 index 0000000..6cf9ae8 --- /dev/null +++ b/server/src/worker.js @@ -0,0 +1,139 @@ +export default { + async fetch(request, env) { + return await handleErrors(request, async () => { + const url = new URL(request.url) + const roomCode = url.pathname.slice(1) + + if (!roomCode) { + return new Response("Room code required", { status: 400 }) + } + + // Get room DO instance + const id = env.ROOM.idFromName(roomCode) + const room = env.ROOM.get(id) + return room.fetch(request) + }) + }, +} + +async function handleErrors(request, func) { + try { + return await func() + } catch (err) { + if (request.headers.get("Upgrade") == "websocket") { + let pair = new WebSocketPair() + pair[1].accept() + pair[1].send(JSON.stringify({ error: err.stack })) + pair[1].close(1011, "Uncaught exception during session setup") + return new Response(null, { status: 101, webSocket: pair[0] }) + } else { + return new Response(err.stack, { status: 500 }) + } + } +} + +export class Room { + constructor(state, env) { + this.state = state + this.env = env + this.storage = state.storage + this.sessions = new Map() + + // Restore any existing WebSocket sessions + this.state.getWebSockets().forEach((webSocket) => { + const meta = webSocket.deserializeAttachment() || {} + this.sessions.set(webSocket, meta) + }) + } + + async fetch(request) { + const upgradeHeader = request.headers.get("Upgrade") + if (!upgradeHeader || upgradeHeader !== "websocket") { + return new Response("Expected WebSocket", { status: 426 }) + } + + const pair = new WebSocketPair() + await this.handleSession(pair[1]) + + return new Response(null, { + status: 101, + webSocket: pair[0], + }) + } + + async handleSession(webSocket) { + this.state.acceptWebSocket(webSocket) + const clientId = crypto.randomUUID() + + // Store client metadata + const meta = { clientId } + webSocket.serializeAttachment(meta) + this.sessions.set(webSocket, meta) + + this.broadcast({ + type: "clientCount", + count: this.sessions.size, + }) + + this.log( + `Client ${clientId} connected. Total clients: ${this.sessions.size}` + ) + } + + async webSocketMessage(webSocket, message) { + try { + const data = JSON.parse(message) + const session = this.sessions.get(webSocket) + + this.log( + `Client ${session.clientId} sent message type: ${data.type}`, + data + ) + + // Forward signaling messages to other clients + this.broadcast(data, webSocket) // Don't send back to sender + } catch (err) { + this.log(`Error handling message: ${err.stack}`) + webSocket.send(JSON.stringify({ error: err.stack })) + } + } + + async webSocketClose(webSocket) { + const session = this.sessions.get(webSocket) + if (session) { + this.log(`Client ${session.clientId} disconnected`) + this.sessions.delete(webSocket) + this.broadcast({ + type: "clientCount", + count: this.sessions.size, + }) + } + } + + async webSocketError(webSocket, error) { + const session = this.sessions.get(webSocket) + this.log(`WebSocket error for client ${session?.clientId}: ${error}`) + this.sessions.delete(webSocket) + } + + broadcast(message, skipWebSocket = null) { + const data = JSON.stringify(message) + this.sessions.forEach((session, webSocket) => { + if (webSocket !== skipWebSocket) { + try { + webSocket.send(data) + } catch (err) { + this.log(`Error sending to client ${session.clientId}: ${err}`) + this.sessions.delete(webSocket) + } + } + }) + } + + log(message, data = null) { + const logMessage = data + ? `[Room ${this.state.id}] ${message} ${JSON.stringify(data)}` + : `[Room ${this.state.id}] ${message}` + console.log(logMessage) + } +} diff --git a/server/wrangler.toml b/server/wrangler.toml new file mode 100644 index 0000000..327199a --- /dev/null +++ b/server/wrangler.toml @@ -0,0 +1,14 @@ +name = "kino-rooms" +main = "src/worker.js" +compatibility_date = "2024-11-06" + +[durable_objects] +bindings = [{ name = "ROOM", class_name = "Room" }] + +[[migrations]] +tag = "v2" +new_classes = ["Room"] + +[observability] +enabled = true +head_sampling_rate = 1