feat(screencast,chat): roteamento read-only, avisos de sessão e TCP privado

SessionNoticeMessage encerra abas de recebimento; abas ↑/↓; chat unifica bucket peer e conexão TCP.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Gabriel Felipe Souza SantosandCursor committed 2026-10-27 18:48:16 -03:00
1 parent fc657345ba
commit 112b6edaeb
14 files changed
+406 -36

No files matched your search

+1
View File
@@ -21,5 +21,6 @@
| D21 | `onShellTabClosed` | Fechar aba do plugin na shell notifica o plugin (ex.: parar screencast) | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) | | D21 | `onShellTabClosed` | Fechar aba do plugin na shell notifica o plugin (ex.: parar screencast) | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) |
| D22 | `PeerIpv4` para IP de peer na UI | No Windows, `InetAddress.getHostAddress()` pode devolver IPv6-mapeado e sufixo de zona (`%n`); chat e roteamento usam IPv4 pontilhado | [appendix_portas_e_multicast.md](documentation/appendix_portas_e_multicast.md) | | D22 | `PeerIpv4` para IP de peer na UI | No Windows, `InetAddress.getHostAddress()` pode devolver IPv6-mapeado e sufixo de zona (`%n`); chat e roteamento usam IPv4 pontilhado | [appendix_portas_e_multicast.md](documentation/appendix_portas_e_multicast.md) |
| D23 | Foco de atividade e badges | **Adiado na v1** — núcleo (`PeerActivityFocus` / `PeerActivityInbox`) mantido; UI sem badges; recebimento passivo em sub-abas (`addTabIfAbsent`) | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) | | D23 | Foco de atividade e badges | **Adiado na v1** — núcleo (`PeerActivityFocus` / `PeerActivityInbox`) mantido; UI sem badges; recebimento passivo em sub-abas (`addTabIfAbsent`) | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) |
| D24 | Avisos de sessão (`SessionNoticeMessage`) | Início/fim de atividades (ex. `screen_cast` END ao parar share); extensível por plugin/canal | [09_sistema_de_plugins.md](documentation/09_sistema_de_plugins.md) |
Novas decisões: adicionar linha nesta tabela e detalhar no arquivo técnico correspondente. Novas decisões: adicionar linha nesta tabela e detalhar no arquivo técnico correspondente.
@@ -77,3 +77,9 @@ Na `MainShellFrame`, `createPlugin(ConnectionManager, PluginWorkspace)` registra
`PluginRoutingPanel` (núcleo) padroniza destino, forma de envio e dica embaixo para chat, screencast e futuros plugins. `PluginRoutingPanel` (núcleo) padroniza destino, forma de envio e dica embaixo para chat, screencast e futuros plugins.
`MessageDispatcher` envia com `TransportMode` e `SendTarget`. `PluginTransportCatalog` define quais transportes cada plugin mostra na UI (sala geral vs um IP). Os combos usam textos didáticos (`describeForUi`) com o termo técnico entre parênteses (multicast UDP, UDP unicast, TCP). Na sala geral o campo IP fica desativado; um resumo **“Como esta mensagem vai”** (chat) deixa explícito o transporte escolhido — na sala pode ser multicast ou cópias unicast/TCP para cada pessoa, conforme o plugin. Para um peer use o IP manualmente ou a peer action na lista lateral. `MessageDispatcher` envia com `TransportMode` e `SendTarget`. `PluginTransportCatalog` define quais transportes cada plugin mostra na UI (sala geral vs um IP). Os combos usam textos didáticos (`describeForUi`) com o termo técnico entre parênteses (multicast UDP, UDP unicast, TCP). Na sala geral o campo IP fica desativado; um resumo **“Como esta mensagem vai”** (chat) deixa explícito o transporte escolhido — na sala pode ser multicast ou cópias unicast/TCP para cada pessoa, conforme o plugin. Para um peer use o IP manualmente ou a peer action na lista lateral.
Em abas de **recebimento** (ex. screencast `view:`), `PluginRoutingPanel.bindReceiveSession` deixa IP e forma de envio somente leitura, refletindo o que o remetente usou.
### Avisos de sessão (D24)
`SessionNoticeMessage` (núcleo) sinaliza fases `START` / `END` por `pluginId` e canal (`SessionNoticeChannel`, ex. `screen_cast`). Plugins registram o tipo em `getMessages()` e reagem na UI (ex. fechar aba de recebimento ao `END` de compartilhamento). Campos incluem remetente, escopo (broadcast/direto) e `TransportMode` observado.
@@ -0,0 +1,30 @@
package pitiupi.application.session;
/**
* Canal lógico de um aviso de sessão (extensível por plugins).
*/
public enum SessionNoticeChannel {
SCREEN_CAST("screen_cast");
private final String wireId;
SessionNoticeChannel(String wireId) {
this.wireId = wireId;
}
public String wireId() {
return wireId;
}
public static SessionNoticeChannel fromWireId(String id) {
if (id == null) {
return null;
}
for (SessionNoticeChannel channel : values()) {
if (channel.wireId.equals(id)) {
return channel;
}
}
return null;
}
}
@@ -0,0 +1,6 @@
package pitiupi.application.session;
public enum SessionNoticePhase {
START,
END
}
@@ -0,0 +1,6 @@
package pitiupi.application.session;
public enum SessionNoticeScope {
BROADCAST,
DIRECT
}
@@ -0,0 +1,69 @@
package pitiupi.models.Messages;
import pitiupi.application.messaging.TransportMode;
import pitiupi.application.session.SessionNoticeChannel;
import pitiupi.application.session.SessionNoticePhase;
import pitiupi.application.session.SessionNoticeScope;
/**
* Aviso de início/fim de atividade de sessão (compartilhamento, canais, etc.).
*/
public class SessionNoticeMessage extends Message {
private static final long serialVersionUID = 1L;
private final String pluginId;
private final SessionNoticePhase phase;
private final String channelWireId;
private final String senderDisplayName;
private final String senderIp;
private final SessionNoticeScope scope;
private final TransportMode transportMode;
public SessionNoticeMessage(
String pluginId,
SessionNoticePhase phase,
SessionNoticeChannel channel,
String senderDisplayName,
String senderIp,
SessionNoticeScope scope,
TransportMode transportMode
) {
super(true);
this.pluginId = pluginId;
this.phase = phase;
this.channelWireId = channel != null ? channel.wireId() : null;
this.senderDisplayName = senderDisplayName;
this.senderIp = senderIp;
this.scope = scope;
this.transportMode = transportMode;
}
public String getPluginId() {
return pluginId;
}
public SessionNoticePhase getPhase() {
return phase;
}
public SessionNoticeChannel getChannel() {
return SessionNoticeChannel.fromWireId(channelWireId);
}
public String getSenderDisplayName() {
return senderDisplayName;
}
public String getSenderIp() {
return senderIp;
}
public SessionNoticeScope getScope() {
return scope;
}
public TransportMode getTransportMode() {
return transportMode;
}
}
@@ -48,6 +48,8 @@ public final class PluginRoutingPanel extends JPanel {
private Consumer<Boolean> privateTcpListener = on -> { private Consumer<Boolean> privateTcpListener = on -> {
}; };
private boolean everyoneInRoom = true; private boolean everyoneInRoom = true;
private boolean receiveOnly;
private TransportMode observedReceiveTransport;
private TransportMode lastRoomTransport = TransportMode.MULTICAST_UDP; private TransportMode lastRoomTransport = TransportMode.MULTICAST_UDP;
private TransportMode lastPeerTransport = TransportMode.UNICAST_UDP; private TransportMode lastPeerTransport = TransportMode.UNICAST_UDP;
@@ -82,6 +84,8 @@ public final class PluginRoutingPanel extends JPanel {
} }
public void bindRoomSession() { public void bindRoomSession() {
receiveOnly = false;
observedReceiveTransport = null;
everyoneInRoom = true; everyoneInRoom = true;
ipField.setText(""); ipField.setText("");
sessionBanner.setText("<html><b>Sessão:</b> Sala geral (Todos)</html>"); sessionBanner.setText("<html><b>Sessão:</b> Sala geral (Todos)</html>");
@@ -94,6 +98,8 @@ public final class PluginRoutingPanel extends JPanel {
} }
public void bindPeerSession(String ip, String displayName, String receiveScopeHtml) { public void bindPeerSession(String ip, String displayName, String receiveScopeHtml) {
receiveOnly = false;
observedReceiveTransport = null;
everyoneInRoom = false; everyoneInRoom = false;
String normalized = PeerIpv4.normalize(ip != null ? ip : ""); String normalized = PeerIpv4.normalize(ip != null ? ip : "");
if (!normalized.isEmpty()) { if (!normalized.isEmpty()) {
@@ -107,6 +113,30 @@ public final class PluginRoutingPanel extends JPanel {
applyRoutingState(); applyRoutingState();
} }
/**
* Sessão de recebimento: IP e transporte só leitura (definidos pelo remetente).
*/
public void bindReceiveSession(
String ip,
String displayName,
String receiveScopeHtml,
TransportMode observedMode
) {
receiveOnly = true;
everyoneInRoom = false;
observedReceiveTransport = observedMode != null ? observedMode : TransportMode.UNICAST_UDP;
String normalized = PeerIpv4.normalize(ip != null ? ip : "");
if (!normalized.isEmpty()) {
ipField.setText(normalized);
} else if (ip != null) {
ipField.setText(ip);
}
String who = displayName != null && !displayName.isBlank() ? displayName : normalized;
sessionBanner.setText(SessionPeerLabels.sessionBannerHtml(who, normalized.isEmpty() ? ip : normalized));
setReceiveScopeHint(receiveScopeHtml);
applyRoutingState();
}
public void setReceiveScopeHint(String html) { public void setReceiveScopeHint(String html) {
if (html == null || html.isBlank()) { if (html == null || html.isBlank()) {
receiveScopeHint.setText(""); receiveScopeHint.setText("");
@@ -181,9 +211,20 @@ public final class PluginRoutingPanel extends JPanel {
private void applyRoutingState() { private void applyRoutingState() {
ipLabel.setVisible(!everyoneInRoom); ipLabel.setVisible(!everyoneInRoom);
ipField.setVisible(!everyoneInRoom); ipField.setVisible(!everyoneInRoom);
rowHint.setVisible(!everyoneInRoom);
if (receiveOnly) {
ipField.setEnabled(false);
privateTcpToggle.setEnabled(false);
transportCombo.setEnabled(false);
DefaultComboBoxModel<TransportMode> readOnly = new DefaultComboBoxModel<>();
readOnly.addElement(observedReceiveTransport);
transportCombo.setModel(readOnly);
transportCombo.setSelectedItem(observedReceiveTransport);
updateDestinationModeHint();
return;
}
ipField.setEnabled(!everyoneInRoom); ipField.setEnabled(!everyoneInRoom);
privateTcpToggle.setEnabled(!everyoneInRoom); privateTcpToggle.setEnabled(!everyoneInRoom);
rowHint.setVisible(!everyoneInRoom);
if (everyoneInRoom) { if (everyoneInRoom) {
privateTcpToggle.setSelected(false); privateTcpToggle.setSelected(false);
updatePrivateToggleLabel(); updatePrivateToggleLabel();
@@ -0,0 +1,20 @@
package pitiupi.presentation.shell;
/**
* Sufixos visuais para distinguir abas de envio e recebimento no mesmo plugin.
*/
public final class SessionTabDirectionLabels {
private SessionTabDirectionLabels() {
}
public static String outboundTitle(String displayName) {
String base = displayName != null && !displayName.isBlank() ? displayName.trim() : "?";
return base + " ↑";
}
public static String inboundTitle(String displayName) {
String base = displayName != null && !displayName.isBlank() ? displayName.trim() : "?";
return base + " ↓";
}
}
@@ -0,0 +1,14 @@
package pitiupi.presentation.shell;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class SessionTabDirectionLabelsTest {
@Test
void outboundAndInboundTitles() {
assertEquals("Vostro ↑", SessionTabDirectionLabels.outboundTitle("Vostro"));
assertEquals("Vostro ↓", SessionTabDirectionLabels.inboundTitle("Vostro"));
}
}
@@ -305,8 +305,8 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink {
@Override @Override
public void onPrivateChannelReady(String peerIp) { public void onPrivateChannelReady(String peerIp) {
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
appendSystemLine(privateKey(peerIp), "Canal TCP privado conectado com " + peerIp + "."); appendSystemLine(peerKey(canonicalPeerIp(peerIp)), "Canal TCP privado conectado com " + peerIp + ".");
if (privateKey(peerIp).equals(activeConversationKey)) { if (peerKey(canonicalPeerIp(peerIp)).equals(activeConversationKey)) {
flushTranscriptToPane(); flushTranscriptToPane();
} }
}); });
@@ -315,8 +315,8 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink {
@Override @Override
public void onPrivateChannelClosed(String peerIp) { public void onPrivateChannelClosed(String peerIp) {
SwingUtilities.invokeLater(() -> { SwingUtilities.invokeLater(() -> {
appendSystemLine(privateKey(peerIp), "Canal TCP privado encerrado (" + peerIp + ")."); appendSystemLine(peerKey(canonicalPeerIp(peerIp)), "Canal TCP privado encerrado (" + peerIp + ").");
if (privateKey(peerIp).equals(activeConversationKey)) { if (peerKey(canonicalPeerIp(peerIp)).equals(activeConversationKey)) {
flushTranscriptToPane(); flushTranscriptToPane();
} }
}); });
@@ -324,7 +324,13 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink {
@Override @Override
public void appendPrivateLine(String peerIp, LegacyMpChatMessage message, boolean outgoing) { public void appendPrivateLine(String peerIp, LegacyMpChatMessage message, boolean outgoing) {
SwingUtilities.invokeLater(() -> appendLegacyLine(privateKey(peerIp), message)); SwingUtilities.invokeLater(() -> {
String canonical = canonicalPeerIp(peerIp);
if (!outgoing) {
ensurePeerTabForIncoming(canonical, message.getNome());
}
appendLegacyLine(peerKey(canonical), message);
});
} }
private void buildUi() { private void buildUi() {
@@ -418,8 +424,8 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink {
if (privateChatController != null) { if (privateChatController != null) {
privateChatController.openChat(user); privateChatController.openChat(user);
} }
appendSystemLine(privateKey(PeerIpv4.fromInetAddress(user.getAddress())), appendSystemLine(peerKey(canonicalPeerIp(PeerIpv4.fromInetAddress(user.getAddress()))),
"Canal TCP privado conectando… envie de novo em instantes."); "Canal TCP privado conectando…");
return; return;
} }
@@ -499,7 +505,7 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink {
public void receiveLegacyMessage(String tipo, String nome, String ip, String text) { public void receiveLegacyMessage(String tipo, String nome, String ip, String text) {
runOnEdt(() -> { runOnEdt(() -> {
LegacyMpChatMessage legacy = new LegacyMpChatMessage(nome, ip, tipo, text); LegacyMpChatMessage legacy = new LegacyMpChatMessage(nome, ip, tipo, text);
String bucket = ip != null && !ip.isBlank() ? privateKey(canonicalPeerIp(ip)) : ROOM_KEY; String bucket = ip != null && !ip.isBlank() ? peerKey(canonicalPeerIp(ip)) : ROOM_KEY;
appendLegacyLine(bucket, legacy); appendLegacyLine(bucket, legacy);
}); });
} }
@@ -551,7 +557,7 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink {
} }
if (key != null && key.startsWith("peer:") && !PluginSessionKeys.PEER_DRAFT.equals(key)) { if (key != null && key.startsWith("peer:") && !PluginSessionKeys.PEER_DRAFT.equals(key)) {
String tabIp = canonicalPeerIp(key.substring("peer:".length())); String tabIp = canonicalPeerIp(key.substring("peer:".length()));
return peerKey(tabIp).equals(bucket); return peerKey(tabIp).equals(bucket) || privateKey(tabIp).equals(bucket);
} }
return false; return false;
} }
@@ -609,9 +615,6 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink {
if (ip == null || ip.isEmpty()) { if (ip == null || ip.isEmpty()) {
return ROOM_KEY; return ROOM_KEY;
} }
if (routing.privateTcpOn()) {
return privateKey(ip);
}
return peerKey(ip); return peerKey(ip);
} }
@@ -18,6 +18,7 @@ public class PrivateChatController {
private final PrivateChatHost host; private final PrivateChatHost host;
private final Map<String, TcpConnection> connections = new HashMap<>(); private final Map<String, TcpConnection> connections = new HashMap<>();
private final Set<String> pendingConnections = new HashSet<>(); private final Set<String> pendingConnections = new HashSet<>();
private final Map<String, PendingSend> pendingTextByAddress = new HashMap<>();
private PrivateChatUiSink uiSink; private PrivateChatUiSink uiSink;
public PrivateChatController(ConnectionManager connectionManager, PrivateChatHost host) { public PrivateChatController(ConnectionManager connectionManager, PrivateChatHost host) {
@@ -38,10 +39,9 @@ public class PrivateChatController {
return; return;
} }
String address = PeerIpv4.fromInetAddress(user.getAddress()); String address = PeerIpv4.fromInetAddress(user.getAddress());
if (connections.containsKey(address)) { TcpConnection existing = connections.get(address);
if (uiSink != null) { if (existing != null && !existing.isClosed()) {
uiSink.onPrivateChannelReady(address); flushPendingSend(address, user);
}
return; return;
} }
if (pendingConnections.contains(address)) { if (pendingConnections.contains(address)) {
@@ -51,6 +51,17 @@ public class PrivateChatController {
host.openConnection(address); host.openConnection(address);
} }
public void registerOutgoingConnection(String address, TcpConnection connection) {
if (address == null || connection == null) {
return;
}
String canonical = PeerIpv4.normalize(address);
pendingConnections.remove(canonical);
replaceConnection(canonical, connection, false);
User user = new User("", connection.getAddress(), System.currentTimeMillis(), null);
flushPendingSend(canonical, user);
}
public void receiveMessage(TcpConnection connection, LegacyMpChatMessage message) { public void receiveMessage(TcpConnection connection, LegacyMpChatMessage message) {
if (uiSink != null) { if (uiSink != null) {
uiSink.appendPrivateLine(addressKey(connection), message, false); uiSink.appendPrivateLine(addressKey(connection), message, false);
@@ -60,8 +71,17 @@ public class PrivateChatController {
public void connectionReceived(TcpConnection connection) { public void connectionReceived(TcpConnection connection) {
String address = addressKey(connection); String address = addressKey(connection);
pendingConnections.remove(address); pendingConnections.remove(address);
replaceConnection(address, connection, true);
}
private void replaceConnection(String address, TcpConnection connection, boolean notifyReady) {
TcpConnection existing = connections.get(address);
if (existing != null && !existing.isClosed() && existing != connection) {
return;
}
boolean first = existing == null || existing.isClosed();
connections.put(address, connection); connections.put(address, connection);
if (uiSink != null) { if (notifyReady && first && uiSink != null) {
uiSink.onPrivateChannelReady(address); uiSink.onPrivateChannelReady(address);
} }
} }
@@ -69,6 +89,8 @@ public class PrivateChatController {
public void closeChat(TcpConnection connection) { public void closeChat(TcpConnection connection) {
String address = addressKey(connection); String address = addressKey(connection);
connections.remove(address); connections.remove(address);
pendingConnections.remove(address);
pendingTextByAddress.remove(address);
if (uiSink != null) { if (uiSink != null) {
uiSink.onPrivateChannelClosed(address); uiSink.onPrivateChannelClosed(address);
} }
@@ -80,9 +102,24 @@ public class PrivateChatController {
} }
String address = PeerIpv4.fromInetAddress(user.getAddress()); String address = PeerIpv4.fromInetAddress(user.getAddress());
TcpConnection connection = connections.get(address); TcpConnection connection = connections.get(address);
if (connection == null) { if (connection == null || connection.isClosed()) {
if (pendingConnections.contains(address)) {
pendingTextByAddress.put(address, new PendingSend(user, text));
return true;
}
return false; return false;
} }
return sendOnConnection(connection, address, text);
}
private void flushPendingSend(String address, User user) {
PendingSend pending = pendingTextByAddress.remove(address);
if (pending != null) {
sendText(user != null ? user : pending.user, pending.text);
}
}
private boolean sendOnConnection(TcpConnection connection, String address, String text) {
try { try {
LegacyMpChatMessage message = new LegacyMpChatMessage( LegacyMpChatMessage message = new LegacyMpChatMessage(
connectionManager.getUserName(), connectionManager.getUserName(),
@@ -105,6 +142,10 @@ public class PrivateChatController {
if (user == null || user.getAddress() == null) { if (user == null || user.getAddress() == null) {
return false; return false;
} }
return connections.containsKey(PeerIpv4.fromInetAddress(user.getAddress())); TcpConnection connection = connections.get(PeerIpv4.fromInetAddress(user.getAddress()));
return connection != null && !connection.isClosed();
}
private record PendingSend(User user, String text) {
} }
} }
@@ -139,11 +139,12 @@ public class ChatPlugin extends Plugin implements PrivateChatHost {
@Override @Override
public void openConnection(String ip) { public void openConnection(String ip) {
try { try {
connectionManager.connectPluginTcp( TcpConnection connection = connectionManager.connectPluginTcp(
InetAddress.getByName(ip), InetAddress.getByName(ip),
getIdentifier(), getIdentifier(),
getIdentifier() getIdentifier()
); );
privateChatController.registerOutgoingConnection(ip, connection);
} catch (Exception e) { } catch (Exception e) {
System.out.println("Error opening connection: " + e.getMessage()); System.out.println("Error opening connection: " + e.getMessage());
} }
@@ -7,6 +7,11 @@ import pitiupi.application.messaging.TransportMode;
import pitiupi.api.plugin.PeerActivityFocusTokens; import pitiupi.api.plugin.PeerActivityFocusTokens;
import pitiupi.application.presence.UserPeerView; import pitiupi.application.presence.UserPeerView;
import pitiupi.control.ConnectionManager; import pitiupi.control.ConnectionManager;
import pitiupi.application.session.SessionNoticeChannel;
import pitiupi.application.session.SessionNoticePhase;
import pitiupi.application.session.SessionNoticeScope;
import pitiupi.models.Messages.SessionNoticeMessage;
import pitiupi.presentation.shell.LocalNetworkInfo;
import pitiupi.presentation.shell.PeerDraftPanel; import pitiupi.presentation.shell.PeerDraftPanel;
import pitiupi.presentation.shell.PeerIpv4; import pitiupi.presentation.shell.PeerIpv4;
import pitiupi.presentation.shell.PluginRoutingPanel; import pitiupi.presentation.shell.PluginRoutingPanel;
@@ -14,6 +19,7 @@ import pitiupi.presentation.shell.PluginSessionKeys;
import pitiupi.presentation.shell.PluginSessionWorkspace; import pitiupi.presentation.shell.PluginSessionWorkspace;
import pitiupi.presentation.shell.SessionPeerLabels; import pitiupi.presentation.shell.SessionPeerLabels;
import pitiupi.presentation.shell.SessionTabBar; import pitiupi.presentation.shell.SessionTabBar;
import pitiupi.presentation.shell.SessionTabDirectionLabels;
import pitiupi.presentation.shell.ui.ShellUi; import pitiupi.presentation.shell.ui.ShellUi;
import pitiupi.screenCast.control.AlarmClock; import pitiupi.screenCast.control.AlarmClock;
@@ -74,6 +80,8 @@ public class ScreenCastPanel extends JPanel {
private final Map<String, String> peerIpsByViewKey = new HashMap<>(); private final Map<String, String> peerIpsByViewKey = new HashMap<>();
private final Map<String, String> peerDisplayNames = new HashMap<>(); private final Map<String, String> peerDisplayNames = new HashMap<>();
private final Map<String, ScreenCastReceiveScope> receiveScopes = new HashMap<>(); private final Map<String, ScreenCastReceiveScope> receiveScopes = new HashMap<>();
private final Map<String, TransportMode> receiveTransportByViewKey = new HashMap<>();
private PluginRoutingPanel.RoutingSnapshot lastShareRoutingSnapshot;
private final CardLayout bodyLayout = new CardLayout(); private final CardLayout bodyLayout = new CardLayout();
private final JPanel bodyCards = new JPanel(bodyLayout); private final JPanel bodyCards = new JPanel(bodyLayout);
private final Map<String, JPanel> bodyPanels = new HashMap<>(); private final Map<String, JPanel> bodyPanels = new HashMap<>();
@@ -105,7 +113,11 @@ public class ScreenCastPanel extends JPanel {
if (!ScreenCastOutboundTabs.isOutboundShareTabKey(key)) { if (!ScreenCastOutboundTabs.isOutboundShareTabKey(key)) {
return; return;
} }
sharingByTabKey.put(key, shareToggle.isSelected()); boolean nowOn = shareToggle.isSelected();
if (!nowOn) {
sendShareEndedNoticeForActiveTab();
}
sharingByTabKey.put(key, nowOn);
updateShareToggleLabel(); updateShareToggleLabel();
applySharingForActiveTab(); applySharingForActiveTab();
}); });
@@ -136,7 +148,7 @@ public class ScreenCastPanel extends JPanel {
private void initSessionTabs() { private void initSessionTabs() {
sessionTabs.addOrSelectTab( sessionTabs.addOrSelectTab(
PluginSessionKeys.ROOM, PluginSessionKeys.ROOM,
"Todos", SessionTabDirectionLabels.outboundTitle("Todos"),
"Transmissão para todos na rede (multicast)", "Transmissão para todos na rede (multicast)",
new JPanel(), new JPanel(),
false false
@@ -164,10 +176,15 @@ public class ScreenCastPanel extends JPanel {
if (key != null && key.startsWith("view:")) { if (key != null && key.startsWith("view:")) {
peerIpsByViewKey.remove(key); peerIpsByViewKey.remove(key);
receiveScopes.remove(key); receiveScopes.remove(key);
receiveTransportByViewKey.remove(key);
bodyPanels.remove(key); bodyPanels.remove(key);
return; return;
} }
boolean wasSharing = isSharingForTab(key); boolean wasSharing = isSharingForTab(key);
if (wasSharing) {
applyRoutingForSessionKey(key);
sendShareEndedNotice(routingPanel.snapshot());
}
sharingByTabKey.remove(key); sharingByTabKey.remove(key);
if (wasSharing && key != null && key.equals(shareContextTabKey)) { if (wasSharing && key != null && key.equals(shareContextTabKey)) {
cancelShareTimer(); cancelShareTimer();
@@ -304,6 +321,7 @@ public class ScreenCastPanel extends JPanel {
return; return;
} }
syncRoutingFromActiveSessionTab(); syncRoutingFromActiveSessionTab();
lastShareRoutingSnapshot = routingPanel.snapshot();
timer = new Timer(); timer = new Timer();
alarmClock = new AlarmClock(this); alarmClock = new AlarmClock(this);
timer.scheduleAtFixedRate(alarmClock, 0, TIME); timer.scheduleAtFixedRate(alarmClock, 0, TIME);
@@ -356,14 +374,25 @@ public class ScreenCastPanel extends JPanel {
String ip = peerIpsByViewKey.get(key); String ip = peerIpsByViewKey.get(key);
String title = key.substring("view:".length()); String title = key.substring("view:".length());
ScreenCastReceiveScope scope = receiveScopes.getOrDefault(key, ScreenCastReceiveScope.BROADCAST); ScreenCastReceiveScope scope = receiveScopes.getOrDefault(key, ScreenCastReceiveScope.BROADCAST);
if (ip != null && !ip.isBlank()) { TransportMode mode = receiveTransportByViewKey.getOrDefault(
routingPanel.bindPeerSession(ip, title, scope.bannerHtml()); key,
} else { transportForReceiveScope(scope)
routingPanel.bindPeerSession("", title, scope.bannerHtml()); );
} routingPanel.bindReceiveSession(
ip != null ? ip : "",
title,
scope.bannerHtml(),
mode
);
} }
} }
private static TransportMode transportForReceiveScope(ScreenCastReceiveScope scope) {
return scope == ScreenCastReceiveScope.DIRECT
? TransportMode.UNICAST_UDP
: TransportMode.MULTICAST_UDP;
}
private void openDraftPeerTab() { private void openDraftPeerTab() {
String previous = sessionTabs.selectedKey(); String previous = sessionTabs.selectedKey();
if (previous != null && !PluginSessionKeys.PEER_DRAFT.equals(previous)) { if (previous != null && !PluginSessionKeys.PEER_DRAFT.equals(previous)) {
@@ -401,11 +430,12 @@ public class ScreenCastPanel extends JPanel {
peerDisplayNames.put(canonical, resolved); peerDisplayNames.put(canonical, resolved);
ensureBodyPanel(key); ensureBodyPanel(key);
setBodyMessage(key, ShellUi.mutedLabel(IDLE_PEER_HTML)); setBodyMessage(key, ShellUi.mutedLabel(IDLE_PEER_HTML));
String tabTitle = SessionTabDirectionLabels.outboundTitle(resolved);
if (sessionTabs.hasTab(key)) { if (sessionTabs.hasTab(key)) {
sessionTabs.setTabTitle(key, resolved, canonical); sessionTabs.setTabTitle(key, tabTitle, canonical);
sessionTabs.selectTab(key); sessionTabs.selectTab(key);
} else { } else {
sessionTabs.addOrSelectTab(key, resolved, canonical, new JPanel(), true); sessionTabs.addOrSelectTab(key, tabTitle, canonical, new JPanel(), true);
} }
routingPanel.bindPeerSession(canonical, resolved); routingPanel.bindPeerSession(canonical, resolved);
showBodyCard(key); showBodyCard(key);
@@ -440,7 +470,7 @@ public class ScreenCastPanel extends JPanel {
if (!sessionTabs.hasTab(PluginSessionKeys.ROOM)) { if (!sessionTabs.hasTab(PluginSessionKeys.ROOM)) {
sessionTabs.addOrSelectTab( sessionTabs.addOrSelectTab(
PluginSessionKeys.ROOM, PluginSessionKeys.ROOM,
"Todos", SessionTabDirectionLabels.outboundTitle("Todos"),
"Transmissão para todos na rede (multicast)", "Transmissão para todos na rede (multicast)",
new JPanel(), new JPanel(),
false false
@@ -464,6 +494,9 @@ public class ScreenCastPanel extends JPanel {
} }
public void stopSharing() { public void stopSharing() {
if (isSharingForTab(sessionTabs.selectedKey())) {
sendShareEndedNoticeForActiveTab();
}
sharingByTabKey.replaceAll((k, v) -> false); sharingByTabKey.replaceAll((k, v) -> false);
setShareToggleSelectedSilently(false); setShareToggleSelectedSilently(false);
cancelShareTimer(); cancelShareTimer();
@@ -560,9 +593,12 @@ public class ScreenCastPanel extends JPanel {
updateTabError(viewKey, message.getName(), "Não foi possível decodificar a imagem.", scope); updateTabError(viewKey, message.getName(), "Não foi possível decodificar a imagem.", scope);
return; return;
} }
receiveScopes.put(viewKey, scope != null ? scope : ScreenCastReceiveScope.BROADCAST); ScreenCastReceiveScope resolvedScope = scope != null ? scope : ScreenCastReceiveScope.BROADCAST;
receiveScopes.put(viewKey, resolvedScope);
receiveTransportByViewKey.put(viewKey, transportForReceiveScope(resolvedScope));
resolveSenderIp(message.getName(), viewKey); resolveSenderIp(message.getName(), viewKey);
sessionTabs.addTabIfAbsent(viewKey, message.getName(), message.getName(), new JPanel(), true); String tabTitle = SessionTabDirectionLabels.inboundTitle(message.getName());
sessionTabs.addTabIfAbsent(viewKey, tabTitle, message.getName(), new JPanel(), true);
setBodyContent(viewKey, new JScrollPane(new JLabel(new ImageIcon(image)))); setBodyContent(viewKey, new JScrollPane(new JLabel(new ImageIcon(image))));
if (viewKey.equals(sessionTabs.selectedKey())) { if (viewKey.equals(sessionTabs.selectedKey())) {
showBodyCard(viewKey); showBodyCard(viewKey);
@@ -586,15 +622,83 @@ public class ScreenCastPanel extends JPanel {
} }
private void updateTabError(String tabKey, String title, String error, ScreenCastReceiveScope scope) { private void updateTabError(String tabKey, String title, String error, ScreenCastReceiveScope scope) {
receiveScopes.put(tabKey, scope != null ? scope : ScreenCastReceiveScope.BROADCAST); ScreenCastReceiveScope resolvedScope = scope != null ? scope : ScreenCastReceiveScope.BROADCAST;
receiveScopes.put(tabKey, resolvedScope);
receiveTransportByViewKey.put(tabKey, transportForReceiveScope(resolvedScope));
JLabel label = new JLabel("<html><p style='color:#c62828'>" + error + "</p></html>"); JLabel label = new JLabel("<html><p style='color:#c62828'>" + error + "</p></html>");
setBodyContent(tabKey, label); setBodyContent(tabKey, label);
sessionTabs.addTabIfAbsent(tabKey, title, title, new JPanel(), true); String tabTitle = SessionTabDirectionLabels.inboundTitle(title);
sessionTabs.addTabIfAbsent(tabKey, tabTitle, title, new JPanel(), true);
if (tabKey.equals(sessionTabs.selectedKey())) { if (tabKey.equals(sessionTabs.selectedKey())) {
showBodyCard(tabKey); showBodyCard(tabKey);
} }
} }
public void closeReceiveTab(String senderDisplayName) {
if (senderDisplayName == null || senderDisplayName.isBlank()) {
return;
}
String viewKey = "view:" + senderDisplayName;
if (!sessionTabs.hasTab(viewKey)) {
return;
}
SwingUtilities.invokeLater(() -> {
if (viewKey.equals(sessionTabs.selectedKey())) {
String fallback = sessionTabs.hasTab(PluginSessionKeys.ROOM)
? PluginSessionKeys.ROOM
: lastSessionKey;
if (fallback != null && sessionTabs.hasTab(fallback) && !fallback.startsWith("view:")) {
sessionTabs.selectTabSilently(fallback);
onSessionTabSelected(fallback);
}
}
peerIpsByViewKey.remove(viewKey);
receiveScopes.remove(viewKey);
receiveTransportByViewKey.remove(viewKey);
bodyPanels.remove(viewKey);
sessionTabs.closeTab(viewKey);
});
}
private void sendShareEndedNoticeForActiveTab() {
PluginRoutingPanel.RoutingSnapshot snap = lastShareRoutingSnapshot != null
? lastShareRoutingSnapshot
: routingPanel.snapshot();
sendShareEndedNotice(snap);
}
private void sendShareEndedNotice(PluginRoutingPanel.RoutingSnapshot routing) {
if (pluginId == null || routing == null) {
return;
}
TransportMode mode = routing.transport();
if (mode == null) {
return;
}
try {
SessionNoticeScope scope = routing.everyoneInRoom()
? SessionNoticeScope.BROADCAST
: SessionNoticeScope.DIRECT;
String localIp = PeerIpv4.normalize(LocalNetworkInfo.bestLocalIpv4());
SessionNoticeMessage notice = new SessionNoticeMessage(
pluginId,
SessionNoticePhase.END,
SessionNoticeChannel.SCREEN_CAST,
connectionManager.getUserName(),
localIp,
scope,
mode
);
SendTarget target = resolveSendTarget(routing);
if (target != null) {
MessageDispatcher.send(connectionManager, notice, target, mode);
}
} catch (Exception ex) {
Logger.getLogger(ScreenCastPanel.class.getName()).log(Level.WARNING, ex.getMessage());
}
lastShareRoutingSnapshot = null;
}
private void ensureBodyPanel(String key) { private void ensureBodyPanel(String key) {
if (!bodyPanels.containsKey(key)) { if (!bodyPanels.containsKey(key)) {
JPanel panel = new JPanel(new BorderLayout()); JPanel panel = new JPanel(new BorderLayout());
@@ -11,7 +11,10 @@ import pitiupi.application.peeractions.PeerTarget;
import pitiupi.control.ConnectionManager; import pitiupi.control.ConnectionManager;
import pitiupi.control.GuiManager; import pitiupi.control.GuiManager;
import pitiupi.models.Menus.MenuCategory; import pitiupi.models.Menus.MenuCategory;
import pitiupi.application.session.SessionNoticeChannel;
import pitiupi.application.session.SessionNoticePhase;
import pitiupi.models.Messages.Message; import pitiupi.models.Messages.Message;
import pitiupi.models.Messages.SessionNoticeMessage;
import pitiupi.models.Plugin; import pitiupi.models.Plugin;
import pitiupi.screenCast.GUI.ScreenCastMenu; import pitiupi.screenCast.GUI.ScreenCastMenu;
import pitiupi.screenCast.GUI.ScreenCastPanel; import pitiupi.screenCast.GUI.ScreenCastPanel;
@@ -98,11 +101,15 @@ public class ScreenCastPlugin extends Plugin {
@Override @Override
public List<Class<? extends Message>> getMessages() { public List<Class<? extends Message>> getMessages() {
return List.of(ScreenCastMessage.class); return List.of(ScreenCastMessage.class, SessionNoticeMessage.class);
} }
@Override @Override
public void receiveMessage(Message message) { public void receiveMessage(Message message) {
if (message instanceof SessionNoticeMessage notice && screenCastPanel != null) {
handleSessionNotice(notice);
return;
}
if (message instanceof ScreenCastMessage msg && screenCastPanel != null) { if (message instanceof ScreenCastMessage msg && screenCastPanel != null) {
if (isOwnOutboundFrame(msg)) { if (isOwnOutboundFrame(msg)) {
return; return;
@@ -134,4 +141,25 @@ public class ScreenCastPlugin extends Plugin {
return ScreenCastReceiveScope.BROADCAST; return ScreenCastReceiveScope.BROADCAST;
} }
private void handleSessionNotice(SessionNoticeMessage notice) {
if (notice.getChannel() != SessionNoticeChannel.SCREEN_CAST) {
return;
}
if (notice.getPhase() != SessionNoticePhase.END) {
return;
}
if (!getIdentifier().equals(notice.getPluginId())) {
return;
}
String sender = notice.getSenderDisplayName();
if (sender == null) {
return;
}
String localName = connectionManager != null ? connectionManager.getUserName() : null;
if (localName != null && localName.equals(sender.trim())) {
return;
}
screenCastPanel.closeReceiveTab(sender);
}
} }