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