fenetre 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,225 @@
1
+ /* Fenetre Video Chat Styles - Dark and Light Themes */
2
+ .fenetre-video-chat-container {
3
+ border-radius: 12px;
4
+ box-shadow: 0 2px 16px rgba(0,0,0,0.18);
5
+ padding: 1.5rem;
6
+ max-width: 700px;
7
+ margin: 2rem auto;
8
+ font-family: 'Inter', 'Segoe UI', Arial, sans-serif;
9
+ transition: background 0.2s, color 0.2s;
10
+ }
11
+
12
+ .fenetre-theme-dark {
13
+ background: #181c20;
14
+ color: #f3f6fa;
15
+ }
16
+ .fenetre-theme-dark h3 {
17
+ color: #e0e6ef;
18
+ }
19
+ .fenetre-theme-dark ul#fenetre-participant-list,
20
+ .fenetre-theme-dark #fenetre-chat-box {
21
+ background: #23272e;
22
+ color: #f3f6fa;
23
+ }
24
+ .fenetre-theme-dark ul#fenetre-participant-list li {
25
+ border-bottom: 1px solid #23272e;
26
+ }
27
+ .fenetre-theme-dark video {
28
+ border: 2px solid #23272e;
29
+ background: #101214;
30
+ }
31
+
32
+ .fenetre-theme-light {
33
+ background: #f7fafd;
34
+ color: #23272e;
35
+ }
36
+ .fenetre-theme-light h3 {
37
+ color: #23272e;
38
+ }
39
+ .fenetre-theme-light ul#fenetre-participant-list,
40
+ .fenetre-theme-light #fenetre-chat-box {
41
+ background: #e9eef3;
42
+ color: #23272e;
43
+ }
44
+ .fenetre-theme-light ul#fenetre-participant-list li {
45
+ border-bottom: 1px solid #e9eef3;
46
+ }
47
+ .fenetre-theme-light video {
48
+ border: 2px solid #e9eef3;
49
+ background: #fff;
50
+ }
51
+
52
+ .fenetre-video-chat-container h3 {
53
+ margin-top: 1.2em;
54
+ margin-bottom: 0.5em;
55
+ font-weight: 600;
56
+ letter-spacing: 0.01em;
57
+ }
58
+ .fenetre-video-chat-container ul#fenetre-participant-list {
59
+ border-radius: 8px;
60
+ padding: 0.5em 1em;
61
+ margin-bottom: 1em;
62
+ list-style: none;
63
+ min-height: 2em;
64
+ }
65
+ .fenetre-video-chat-container ul#fenetre-participant-list li {
66
+ padding: 0.2em 0;
67
+ }
68
+ .fenetre-video-chat-container .fenetre-video-section {
69
+ display: flex;
70
+ gap: 2em;
71
+ flex-wrap: wrap;
72
+ }
73
+ .fenetre-video-chat-container video {
74
+ border-radius: 8px;
75
+ }
76
+ .fenetre-video-chat-container #fenetre-chat-box {
77
+ border-radius: 8px;
78
+ padding: 0.5em 1em;
79
+ min-height: 2em;
80
+ margin-top: 1em;
81
+ font-size: 1em;
82
+ max-height: 180px;
83
+ overflow-y: auto;
84
+ }
85
+ .fenetre-video-chat-container #fenetre-chat-box div {
86
+ margin-bottom: 0.3em;
87
+ }
88
+
89
+ /* Connection status styles */
90
+ .fenetre-connection-status {
91
+ display: inline-block;
92
+ padding: 4px 8px;
93
+ border-radius: 12px;
94
+ font-size: 0.8rem;
95
+ font-weight: 500;
96
+ margin-bottom: 1rem;
97
+ }
98
+
99
+ .fenetre-status-connecting {
100
+ background-color: #ffeeba;
101
+ color: #856404;
102
+ }
103
+
104
+ .fenetre-status-connected {
105
+ background-color: #d4edda;
106
+ color: #155724;
107
+ }
108
+
109
+ .fenetre-status-disconnected {
110
+ background-color: #f8d7da;
111
+ color: #721c24;
112
+ }
113
+
114
+ .fenetre-status-reconnecting {
115
+ background-color: #e2e3e5;
116
+ color: #383d41;
117
+ animation: pulse 1.5s infinite;
118
+ }
119
+
120
+ .fenetre-status-error {
121
+ background-color: #f8d7da;
122
+ color: #721c24;
123
+ font-weight: 600;
124
+ }
125
+
126
+ /* Peer connection status indicators */
127
+ .fenetre-peer-status {
128
+ position: absolute;
129
+ top: 5px;
130
+ right: 5px;
131
+ border-radius: 8px;
132
+ padding: 2px 6px;
133
+ font-size: 0.7rem;
134
+ background: rgba(0, 0, 0, 0.5);
135
+ color: white;
136
+ }
137
+
138
+ .fenetre-peer-connecting {
139
+ background-color: rgba(255, 193, 7, 0.8);
140
+ color: #212529;
141
+ }
142
+
143
+ .fenetre-peer-connected {
144
+ background-color: rgba(40, 167, 69, 0.8);
145
+ color: white;
146
+ }
147
+
148
+ .fenetre-peer-disconnected, .fenetre-peer-failed {
149
+ background-color: rgba(220, 53, 69, 0.8);
150
+ color: white;
151
+ }
152
+
153
+ /* Remote video container styling */
154
+ .fenetre-remote-video-container {
155
+ position: relative;
156
+ border-radius: 8px;
157
+ overflow: hidden;
158
+ }
159
+
160
+ /* Screen sharing button styles */
161
+ .fenetre-control-button.screen-sharing {
162
+ background-color: #17a2b8;
163
+ color: white;
164
+ }
165
+
166
+ .fenetre-theme-dark .fenetre-control-button.screen-sharing {
167
+ background-color: #138496;
168
+ }
169
+
170
+ .fenetre-theme-light .fenetre-control-button.screen-sharing {
171
+ background-color: #17a2b8;
172
+ }
173
+
174
+ /* Animation for reconnecting status */
175
+ @keyframes pulse {
176
+ 0% {
177
+ opacity: 1;
178
+ }
179
+ 50% {
180
+ opacity: 0.5;
181
+ }
182
+ 100% {
183
+ opacity: 1;
184
+ }
185
+ }
186
+
187
+ /* Control buttons styles */
188
+ .fenetre-controls {
189
+ display: flex;
190
+ gap: 10px;
191
+ margin-top: 1rem;
192
+ flex-wrap: wrap;
193
+ }
194
+
195
+ .fenetre-control-button {
196
+ padding: 8px 12px;
197
+ border-radius: 20px;
198
+ border: none;
199
+ font-size: 0.9rem;
200
+ cursor: pointer;
201
+ display: flex;
202
+ align-items: center;
203
+ justify-content: center;
204
+ transition: all 0.2s;
205
+ }
206
+
207
+ .fenetre-theme-dark .fenetre-control-button {
208
+ background-color: #343a40;
209
+ color: #f8f9fa;
210
+ }
211
+
212
+ .fenetre-theme-light .fenetre-control-button {
213
+ background-color: #e9ecef;
214
+ color: #212529;
215
+ }
216
+
217
+ .fenetre-control-button:hover {
218
+ opacity: 0.9;
219
+ }
220
+
221
+ .fenetre-control-button.video-off,
222
+ .fenetre-control-button.audio-off {
223
+ background-color: #dc3545;
224
+ color: white;
225
+ }
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fenetre
4
+ class VideoChatChannel < ActionCable::Channel::Base
5
+ # Handles signaling messages for WebRTC
6
+ @@participants = Hash.new { |h, k| h[k] = [] }
7
+
8
+ def subscribed
9
+ return reject unless current_user
10
+
11
+ @room_id = params[:room_id]
12
+ return reject if @room_id.blank?
13
+
14
+ init_participants(@room_id)
15
+ return reject if params[:room_locked] && user_role != :host
16
+ return reject if params[:max_participants] && participants(@room_id).size >= params[:max_participants].to_i
17
+
18
+ stream_from room_stream(@room_id)
19
+ end
20
+
21
+ def unsubscribed
22
+ return unless @room_id
23
+
24
+ remove_participant(@room_id, current_user.id)
25
+ broadcast_leave(@room_id, current_user.id)
26
+ log_analytics(:leave, current_user.id, @room_id)
27
+ end
28
+
29
+ def signal(data)
30
+ return unless @room_id
31
+
32
+ init_participants(@room_id)
33
+ type = data['type'] || data[:type]
34
+ payload = data['payload'] || data[:payload]
35
+ return if type == 'screen_share' && !params[:enable_screen_sharing]
36
+
37
+ broadcast_signal(@room_id, type, current_user.id, payload)
38
+ end
39
+
40
+ def join_room(_data)
41
+ return unless @room_id
42
+
43
+ add_participant(@room_id, current_user.id)
44
+ message = {
45
+ 'type' => 'join',
46
+ 'from' => current_user.id,
47
+ 'participants' => participants(@room_id).dup
48
+ }
49
+ message['topic'] = params[:room_topic] if params[:room_topic]
50
+ message['max_participants'] = params[:max_participants] if params[:max_participants]
51
+ message['turbo_stream'] = '<turbo-stream action="append">...</turbo-stream>'
52
+ ActionCable.server.broadcast(room_stream(@room_id), message)
53
+ log_analytics(:join, current_user.id, @room_id)
54
+ end
55
+
56
+ def kick(data)
57
+ return unless user_role == :host
58
+
59
+ user_id = data['user_id'] || data[:user_id]
60
+ ActionCable.server.broadcast(room_stream(@room_id),
61
+ { 'type' => 'kick', 'from' => current_user.id,
62
+ 'payload' => { 'user_id' => user_id } })
63
+ end
64
+
65
+ def mute(data)
66
+ return unless user_role == :host
67
+
68
+ user_id = data['user_id'] || data[:user_id]
69
+ ActionCable.server.broadcast(room_stream(@room_id),
70
+ { 'type' => 'mute', 'from' => current_user.id,
71
+ 'payload' => { 'user_id' => user_id } })
72
+ end
73
+
74
+ def unmute(data)
75
+ return unless user_role == :host
76
+
77
+ user_id = data['user_id'] || data[:user_id]
78
+ ActionCable.server.broadcast(room_stream(@room_id),
79
+ { 'type' => 'unmute', 'from' => current_user.id,
80
+ 'payload' => { 'user_id' => user_id } })
81
+ end
82
+
83
+ def chat(data)
84
+ return unless @room_id
85
+
86
+ init_participants(@room_id)
87
+ return if (data['to'] || data[:to]) && !params[:enable_private_chat]
88
+
89
+ message = data['message'] || data[:message]
90
+ to = data['to'] || data[:to]
91
+ ActionCable.server.broadcast(room_stream(@room_id),
92
+ { 'type' => 'chat', 'from' => current_user.id,
93
+ 'payload' => { 'message' => message, 'to' => to } })
94
+ end
95
+
96
+ def perform(action, data = {})
97
+ raise NoMethodError, "undefined action '#{action}' for #{self.class}" unless respond_to?(action)
98
+
99
+ public_send(action, data)
100
+ end
101
+
102
+ def reject
103
+ @_rejected = true
104
+ super if defined?(super)
105
+ end
106
+
107
+ def rejected?
108
+ !!@_rejected
109
+ end
110
+
111
+ def confirmed?
112
+ !rejected?
113
+ end
114
+
115
+ private
116
+
117
+ def user_role
118
+ connection.respond_to?(:user_role) ? connection.user_role : nil
119
+ end
120
+
121
+ def room_stream(room_id)
122
+ "fenetre_video_chat_#{room_id}"
123
+ end
124
+
125
+ def init_participants(room_id)
126
+ @@participants[room_id] ||= []
127
+ end
128
+
129
+ def participants(room_id)
130
+ @@participants[room_id] ||= []
131
+ end
132
+
133
+ def add_participant(room_id, user_id)
134
+ @@participants[room_id] << user_id unless @@participants[room_id].include?(user_id)
135
+ end
136
+
137
+ def remove_participant(room_id, user_id)
138
+ @@participants[room_id]&.delete(user_id)
139
+ end
140
+
141
+ def broadcast_leave(room_id, user_id)
142
+ ActionCable.server.broadcast(room_stream(room_id),
143
+ { 'type' => 'leave', 'from' => user_id,
144
+ 'participants' => participants(room_id).dup })
145
+ end
146
+
147
+ def broadcast_signal(room_id, type, from, payload)
148
+ ActionCable.server.broadcast(room_stream(room_id), { 'type' => type, 'from' => from, 'payload' => payload })
149
+ end
150
+
151
+ def log_analytics(event, user_id, room_id)
152
+ $fenetre_analytics ||= []
153
+ $fenetre_analytics << [event, user_id, room_id]
154
+ end
155
+
156
+ def stringify_keys(hash)
157
+ hash.transform_keys(&:to_s)
158
+ end
159
+
160
+ def broadcast_to_room(message)
161
+ ActionCable.server.broadcast(room_stream(@room_id), stringify_keys(message))
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Fenetre
4
+ module VideoChatHelper
5
+ # Helper method to render the video chat container with proper data attributes
6
+ def fenetre_video_chat_container(room_id, user_id, theme: 'dark')
7
+ content = stylesheet_link_tag('fenetre/video_chat', media: 'all')
8
+ js = javascript_include_tag('fenetre.js', type: 'module')
9
+ content + js + video_chat_main_container(room_id, user_id, theme)
10
+ end
11
+
12
+ private
13
+
14
+ def video_chat_main_container(room_id, user_id, theme)
15
+ content_tag(:div, class: "fenetre-video-chat-container fenetre-theme-#{theme}", data: {
16
+ controller: 'fenetre--video-chat',
17
+ fenetre_video_chat_user_id_value: user_id.to_s,
18
+ fenetre_theme: theme
19
+ }) do
20
+ hidden_room_id_input(room_id) +
21
+ connection_status_indicator +
22
+ section_heading('Participants') +
23
+ participants_list +
24
+ section_heading('My Video') +
25
+ local_video_section +
26
+ media_control_section +
27
+ section_heading('Remote Videos') +
28
+ remote_videos_section +
29
+ section_heading('Chat') +
30
+ chat_section
31
+ end
32
+ end
33
+
34
+ # Add connection status indicator
35
+ def connection_status_indicator
36
+ content_tag(:div, 'Connecting...',
37
+ class: 'fenetre-connection-status',
38
+ data: { fenetre_video_chat_target: 'connectionStatus' })
39
+ end
40
+
41
+ def hidden_room_id_input(room_id)
42
+ content_tag(:input, nil, type: 'hidden', value: room_id, data: { fenetre_video_chat_target: 'roomId' })
43
+ end
44
+
45
+ def section_heading(text)
46
+ content_tag(:h3, text)
47
+ end
48
+
49
+ def participants_list
50
+ content_tag(:ul, '', id: 'fenetre-participant-list')
51
+ end
52
+
53
+ def local_video_section
54
+ content_tag(:div, class: 'fenetre-video-section') do
55
+ content_tag(:video, '', data: { fenetre_video_chat_target: 'localVideo' }, autoplay: true, playsinline: true,
56
+ muted: true, style: 'width: 200px; height: 150px;')
57
+ end
58
+ end
59
+
60
+ def media_control_section
61
+ content_tag(:div, class: 'fenetre-controls') do
62
+ toggle_button('Toggle Video', 'toggleVideo') +
63
+ toggle_button('Toggle Audio', 'toggleAudio') +
64
+ toggle_button('Share Screen', 'toggleScreenShare')
65
+ end
66
+ end
67
+
68
+ def toggle_button(label, action)
69
+ content_tag(:button, label, data: { action: "fenetre--video-chat##{action}" }, class: 'fenetre-control-button')
70
+ end
71
+
72
+ def remote_videos_section
73
+ content_tag(:div, class: 'fenetre-video-section') do
74
+ content_tag(:div, '', data: { fenetre_video_chat_target: 'remoteVideos' })
75
+ end
76
+ end
77
+
78
+ def chat_section
79
+ content_tag(:div, class: 'fenetre-chat-container') do
80
+ chat_messages + chat_input_container
81
+ end
82
+ end
83
+
84
+ def chat_messages
85
+ content_tag(:div, '', data: { fenetre_video_chat_target: 'chatMessages' }, class: 'fenetre-chat-messages')
86
+ end
87
+
88
+ def chat_input_container
89
+ content_tag(:div, class: 'fenetre-chat-input-container') do
90
+ content_tag(:input, nil, type: 'text', placeholder: 'Type your message...',
91
+ data: { fenetre_video_chat_target: 'chatInput' }) +
92
+ content_tag(:button, 'Send', data: { action: 'fenetre--video-chat#sendChat' },
93
+ class: 'fenetre-chat-send-button')
94
+ end
95
+ end
96
+ end
97
+ end