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.
- checksums.yaml +7 -0
- data/README.md +83 -0
- data/Rakefile +33 -0
- data/app/assets/javascripts/fenetre/application.js +14 -0
- data/app/assets/javascripts/fenetre/controllers/index.js +19 -0
- data/app/assets/javascripts/fenetre/controllers/video_chat_controller.js +662 -0
- data/app/assets/javascripts/fenetre/vendor/stimulus.min.js +2588 -0
- data/app/assets/javascripts/fenetre/vendor/stimulus.umd.js +2588 -0
- data/app/assets/javascripts/fenetre.js +10 -0
- data/app/assets/javascripts/stimulus/stimulus.min.js +2588 -0
- data/app/assets/stylesheets/fenetre/video_chat.css +225 -0
- data/app/channels/fenetre/video_chat_channel.rb +164 -0
- data/app/helpers/fenetre/video_chat_helper.rb +97 -0
- data/app/javascript/controllers/fenetre/video_chat_controller.js +662 -0
- data/app/javascript/test/test_runner.js +15 -0
- data/app/javascript/test/video_chat_controller_test.js +215 -0
- data/config/importmap.rb +24 -0
- data/lib/fenetre/engine.rb +190 -0
- data/lib/fenetre/version.rb +5 -0
- data/lib/fenetre/video_chat_channel.rb +36 -0
- data/lib/fenetre.rb +9 -0
- data/lib/tasks/javascript_test.rake +69 -0
- metadata +262 -0
@@ -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
|