From 112b6edaeb2926152f2b5ad925b6601b9e53512e Mon Sep 17 00:00:00 2001 From: Gabriel Felipe Souza Santos Date: Tue, 27 Oct 2026 18:48:16 -0300 Subject: [PATCH] =?UTF-8?q?feat(screencast,chat):=20roteamento=20read-only?= =?UTF-8?q?,=20avisos=20de=20sess=C3=A3o=20e=20TCP=20privado?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionNoticeMessage encerra abas de recebimento; abas ↑/↓; chat unifica bucket peer e conexão TCP. Co-authored-by: Cursor --- docs/decision-register.md | 1 + docs/documentation/09_sistema_de_plugins.md | 6 + .../session/SessionNoticeChannel.java | 30 ++++ .../session/SessionNoticePhase.java | 6 + .../session/SessionNoticeScope.java | 6 + .../models/Messages/SessionNoticeMessage.java | 69 +++++++++ .../shell/PluginRoutingPanel.java | 43 +++++- .../shell/SessionTabDirectionLabels.java | 20 +++ .../shell/SessionTabDirectionLabelsTest.java | 14 ++ .../chat/src/pitiupi/chat/GUI/ChatPanel.java | 27 ++-- .../chat/control/PrivateChatController.java | 55 +++++++- .../src/pitiupi/chat/plugin/ChatPlugin.java | 3 +- .../screenCast/GUI/ScreenCastPanel.java | 132 ++++++++++++++++-- .../screenCast/plugin/ScreenCastPlugin.java | 30 +++- 14 files changed, 406 insertions(+), 36 deletions(-) create mode 100644 pitiupi/src/pitiupi/application/session/SessionNoticeChannel.java create mode 100644 pitiupi/src/pitiupi/application/session/SessionNoticePhase.java create mode 100644 pitiupi/src/pitiupi/application/session/SessionNoticeScope.java create mode 100644 pitiupi/src/pitiupi/models/Messages/SessionNoticeMessage.java create mode 100644 pitiupi/src/pitiupi/presentation/shell/SessionTabDirectionLabels.java create mode 100644 pitiupi/test/pitiupi/presentation/shell/SessionTabDirectionLabelsTest.java diff --git a/docs/decision-register.md b/docs/decision-register.md index 669e095..7f101b1 100644 --- a/docs/decision-register.md +++ b/docs/decision-register.md @@ -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) | | 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) | +| 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. diff --git a/docs/documentation/09_sistema_de_plugins.md b/docs/documentation/09_sistema_de_plugins.md index 9c1a71e..0b8cdb2 100644 --- a/docs/documentation/09_sistema_de_plugins.md +++ b/docs/documentation/09_sistema_de_plugins.md @@ -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. `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. diff --git a/pitiupi/src/pitiupi/application/session/SessionNoticeChannel.java b/pitiupi/src/pitiupi/application/session/SessionNoticeChannel.java new file mode 100644 index 0000000..585ca1d --- /dev/null +++ b/pitiupi/src/pitiupi/application/session/SessionNoticeChannel.java @@ -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; + } +} diff --git a/pitiupi/src/pitiupi/application/session/SessionNoticePhase.java b/pitiupi/src/pitiupi/application/session/SessionNoticePhase.java new file mode 100644 index 0000000..a0ab87b --- /dev/null +++ b/pitiupi/src/pitiupi/application/session/SessionNoticePhase.java @@ -0,0 +1,6 @@ +package pitiupi.application.session; + +public enum SessionNoticePhase { + START, + END +} diff --git a/pitiupi/src/pitiupi/application/session/SessionNoticeScope.java b/pitiupi/src/pitiupi/application/session/SessionNoticeScope.java new file mode 100644 index 0000000..58926e2 --- /dev/null +++ b/pitiupi/src/pitiupi/application/session/SessionNoticeScope.java @@ -0,0 +1,6 @@ +package pitiupi.application.session; + +public enum SessionNoticeScope { + BROADCAST, + DIRECT +} diff --git a/pitiupi/src/pitiupi/models/Messages/SessionNoticeMessage.java b/pitiupi/src/pitiupi/models/Messages/SessionNoticeMessage.java new file mode 100644 index 0000000..55431fb --- /dev/null +++ b/pitiupi/src/pitiupi/models/Messages/SessionNoticeMessage.java @@ -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; + } +} diff --git a/pitiupi/src/pitiupi/presentation/shell/PluginRoutingPanel.java b/pitiupi/src/pitiupi/presentation/shell/PluginRoutingPanel.java index f559c94..0e892e0 100644 --- a/pitiupi/src/pitiupi/presentation/shell/PluginRoutingPanel.java +++ b/pitiupi/src/pitiupi/presentation/shell/PluginRoutingPanel.java @@ -48,6 +48,8 @@ public final class PluginRoutingPanel extends JPanel { private Consumer privateTcpListener = on -> { }; private boolean everyoneInRoom = true; + private boolean receiveOnly; + private TransportMode observedReceiveTransport; private TransportMode lastRoomTransport = TransportMode.MULTICAST_UDP; private TransportMode lastPeerTransport = TransportMode.UNICAST_UDP; @@ -82,6 +84,8 @@ public final class PluginRoutingPanel extends JPanel { } public void bindRoomSession() { + receiveOnly = false; + observedReceiveTransport = null; everyoneInRoom = true; ipField.setText(""); sessionBanner.setText("Sessão: Sala geral (Todos)"); @@ -94,6 +98,8 @@ public final class PluginRoutingPanel extends JPanel { } public void bindPeerSession(String ip, String displayName, String receiveScopeHtml) { + receiveOnly = false; + observedReceiveTransport = null; everyoneInRoom = false; String normalized = PeerIpv4.normalize(ip != null ? ip : ""); if (!normalized.isEmpty()) { @@ -107,6 +113,30 @@ public final class PluginRoutingPanel extends JPanel { 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) { if (html == null || html.isBlank()) { receiveScopeHint.setText(""); @@ -181,9 +211,20 @@ public final class PluginRoutingPanel extends JPanel { private void applyRoutingState() { ipLabel.setVisible(!everyoneInRoom); ipField.setVisible(!everyoneInRoom); + rowHint.setVisible(!everyoneInRoom); + if (receiveOnly) { + ipField.setEnabled(false); + privateTcpToggle.setEnabled(false); + transportCombo.setEnabled(false); + DefaultComboBoxModel readOnly = new DefaultComboBoxModel<>(); + readOnly.addElement(observedReceiveTransport); + transportCombo.setModel(readOnly); + transportCombo.setSelectedItem(observedReceiveTransport); + updateDestinationModeHint(); + return; + } ipField.setEnabled(!everyoneInRoom); privateTcpToggle.setEnabled(!everyoneInRoom); - rowHint.setVisible(!everyoneInRoom); if (everyoneInRoom) { privateTcpToggle.setSelected(false); updatePrivateToggleLabel(); diff --git a/pitiupi/src/pitiupi/presentation/shell/SessionTabDirectionLabels.java b/pitiupi/src/pitiupi/presentation/shell/SessionTabDirectionLabels.java new file mode 100644 index 0000000..dd312f4 --- /dev/null +++ b/pitiupi/src/pitiupi/presentation/shell/SessionTabDirectionLabels.java @@ -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 + " ↓"; + } +} diff --git a/pitiupi/test/pitiupi/presentation/shell/SessionTabDirectionLabelsTest.java b/pitiupi/test/pitiupi/presentation/shell/SessionTabDirectionLabelsTest.java new file mode 100644 index 0000000..db9bbdf --- /dev/null +++ b/pitiupi/test/pitiupi/presentation/shell/SessionTabDirectionLabelsTest.java @@ -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")); + } +} diff --git a/plugins/chat/src/pitiupi/chat/GUI/ChatPanel.java b/plugins/chat/src/pitiupi/chat/GUI/ChatPanel.java index adc7658..5f2bdb2 100644 --- a/plugins/chat/src/pitiupi/chat/GUI/ChatPanel.java +++ b/plugins/chat/src/pitiupi/chat/GUI/ChatPanel.java @@ -305,8 +305,8 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink { @Override public void onPrivateChannelReady(String peerIp) { SwingUtilities.invokeLater(() -> { - appendSystemLine(privateKey(peerIp), "Canal TCP privado conectado com " + peerIp + "."); - if (privateKey(peerIp).equals(activeConversationKey)) { + appendSystemLine(peerKey(canonicalPeerIp(peerIp)), "Canal TCP privado conectado com " + peerIp + "."); + if (peerKey(canonicalPeerIp(peerIp)).equals(activeConversationKey)) { flushTranscriptToPane(); } }); @@ -315,8 +315,8 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink { @Override public void onPrivateChannelClosed(String peerIp) { SwingUtilities.invokeLater(() -> { - appendSystemLine(privateKey(peerIp), "Canal TCP privado encerrado (" + peerIp + ")."); - if (privateKey(peerIp).equals(activeConversationKey)) { + appendSystemLine(peerKey(canonicalPeerIp(peerIp)), "Canal TCP privado encerrado (" + peerIp + ")."); + if (peerKey(canonicalPeerIp(peerIp)).equals(activeConversationKey)) { flushTranscriptToPane(); } }); @@ -324,7 +324,13 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink { @Override 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() { @@ -418,8 +424,8 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink { if (privateChatController != null) { privateChatController.openChat(user); } - appendSystemLine(privateKey(PeerIpv4.fromInetAddress(user.getAddress())), - "Canal TCP privado conectando… envie de novo em instantes."); + appendSystemLine(peerKey(canonicalPeerIp(PeerIpv4.fromInetAddress(user.getAddress()))), + "Canal TCP privado conectando…"); return; } @@ -499,7 +505,7 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink { public void receiveLegacyMessage(String tipo, String nome, String ip, String text) { runOnEdt(() -> { 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); }); } @@ -551,7 +557,7 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink { } if (key != null && key.startsWith("peer:") && !PluginSessionKeys.PEER_DRAFT.equals(key)) { String tabIp = canonicalPeerIp(key.substring("peer:".length())); - return peerKey(tabIp).equals(bucket); + return peerKey(tabIp).equals(bucket) || privateKey(tabIp).equals(bucket); } return false; } @@ -609,9 +615,6 @@ public class ChatPanel extends JPanel implements PrivateChatUiSink { if (ip == null || ip.isEmpty()) { return ROOM_KEY; } - if (routing.privateTcpOn()) { - return privateKey(ip); - } return peerKey(ip); } diff --git a/plugins/chat/src/pitiupi/chat/control/PrivateChatController.java b/plugins/chat/src/pitiupi/chat/control/PrivateChatController.java index ab42b48..b8c89d1 100644 --- a/plugins/chat/src/pitiupi/chat/control/PrivateChatController.java +++ b/plugins/chat/src/pitiupi/chat/control/PrivateChatController.java @@ -18,6 +18,7 @@ public class PrivateChatController { private final PrivateChatHost host; private final Map connections = new HashMap<>(); private final Set pendingConnections = new HashSet<>(); + private final Map pendingTextByAddress = new HashMap<>(); private PrivateChatUiSink uiSink; public PrivateChatController(ConnectionManager connectionManager, PrivateChatHost host) { @@ -38,10 +39,9 @@ public class PrivateChatController { return; } String address = PeerIpv4.fromInetAddress(user.getAddress()); - if (connections.containsKey(address)) { - if (uiSink != null) { - uiSink.onPrivateChannelReady(address); - } + TcpConnection existing = connections.get(address); + if (existing != null && !existing.isClosed()) { + flushPendingSend(address, user); return; } if (pendingConnections.contains(address)) { @@ -51,6 +51,17 @@ public class PrivateChatController { 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) { if (uiSink != null) { uiSink.appendPrivateLine(addressKey(connection), message, false); @@ -60,8 +71,17 @@ public class PrivateChatController { public void connectionReceived(TcpConnection connection) { String address = addressKey(connection); 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); - if (uiSink != null) { + if (notifyReady && first && uiSink != null) { uiSink.onPrivateChannelReady(address); } } @@ -69,6 +89,8 @@ public class PrivateChatController { public void closeChat(TcpConnection connection) { String address = addressKey(connection); connections.remove(address); + pendingConnections.remove(address); + pendingTextByAddress.remove(address); if (uiSink != null) { uiSink.onPrivateChannelClosed(address); } @@ -80,9 +102,24 @@ public class PrivateChatController { } String address = PeerIpv4.fromInetAddress(user.getAddress()); 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 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 { LegacyMpChatMessage message = new LegacyMpChatMessage( connectionManager.getUserName(), @@ -105,6 +142,10 @@ public class PrivateChatController { if (user == null || user.getAddress() == null) { 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) { } } diff --git a/plugins/chat/src/pitiupi/chat/plugin/ChatPlugin.java b/plugins/chat/src/pitiupi/chat/plugin/ChatPlugin.java index 640ffd2..24bb79d 100644 --- a/plugins/chat/src/pitiupi/chat/plugin/ChatPlugin.java +++ b/plugins/chat/src/pitiupi/chat/plugin/ChatPlugin.java @@ -139,11 +139,12 @@ public class ChatPlugin extends Plugin implements PrivateChatHost { @Override public void openConnection(String ip) { try { - connectionManager.connectPluginTcp( + TcpConnection connection = connectionManager.connectPluginTcp( InetAddress.getByName(ip), getIdentifier(), getIdentifier() ); + privateChatController.registerOutgoingConnection(ip, connection); } catch (Exception e) { System.out.println("Error opening connection: " + e.getMessage()); } diff --git a/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastPanel.java b/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastPanel.java index 9c57fc4..8646dad 100644 --- a/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastPanel.java +++ b/plugins/screencast/src/pitiupi/screenCast/GUI/ScreenCastPanel.java @@ -7,6 +7,11 @@ import pitiupi.application.messaging.TransportMode; import pitiupi.api.plugin.PeerActivityFocusTokens; import pitiupi.application.presence.UserPeerView; 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.PeerIpv4; import pitiupi.presentation.shell.PluginRoutingPanel; @@ -14,6 +19,7 @@ import pitiupi.presentation.shell.PluginSessionKeys; import pitiupi.presentation.shell.PluginSessionWorkspace; import pitiupi.presentation.shell.SessionPeerLabels; import pitiupi.presentation.shell.SessionTabBar; +import pitiupi.presentation.shell.SessionTabDirectionLabels; import pitiupi.presentation.shell.ui.ShellUi; import pitiupi.screenCast.control.AlarmClock; @@ -74,6 +80,8 @@ public class ScreenCastPanel extends JPanel { private final Map peerIpsByViewKey = new HashMap<>(); private final Map peerDisplayNames = new HashMap<>(); private final Map receiveScopes = new HashMap<>(); + private final Map receiveTransportByViewKey = new HashMap<>(); + private PluginRoutingPanel.RoutingSnapshot lastShareRoutingSnapshot; private final CardLayout bodyLayout = new CardLayout(); private final JPanel bodyCards = new JPanel(bodyLayout); private final Map bodyPanels = new HashMap<>(); @@ -105,7 +113,11 @@ public class ScreenCastPanel extends JPanel { if (!ScreenCastOutboundTabs.isOutboundShareTabKey(key)) { return; } - sharingByTabKey.put(key, shareToggle.isSelected()); + boolean nowOn = shareToggle.isSelected(); + if (!nowOn) { + sendShareEndedNoticeForActiveTab(); + } + sharingByTabKey.put(key, nowOn); updateShareToggleLabel(); applySharingForActiveTab(); }); @@ -136,7 +148,7 @@ public class ScreenCastPanel extends JPanel { private void initSessionTabs() { sessionTabs.addOrSelectTab( PluginSessionKeys.ROOM, - "Todos", + SessionTabDirectionLabels.outboundTitle("Todos"), "Transmissão para todos na rede (multicast)", new JPanel(), false @@ -164,10 +176,15 @@ public class ScreenCastPanel extends JPanel { if (key != null && key.startsWith("view:")) { peerIpsByViewKey.remove(key); receiveScopes.remove(key); + receiveTransportByViewKey.remove(key); bodyPanels.remove(key); return; } boolean wasSharing = isSharingForTab(key); + if (wasSharing) { + applyRoutingForSessionKey(key); + sendShareEndedNotice(routingPanel.snapshot()); + } sharingByTabKey.remove(key); if (wasSharing && key != null && key.equals(shareContextTabKey)) { cancelShareTimer(); @@ -304,6 +321,7 @@ public class ScreenCastPanel extends JPanel { return; } syncRoutingFromActiveSessionTab(); + lastShareRoutingSnapshot = routingPanel.snapshot(); timer = new Timer(); alarmClock = new AlarmClock(this); timer.scheduleAtFixedRate(alarmClock, 0, TIME); @@ -356,14 +374,25 @@ public class ScreenCastPanel extends JPanel { String ip = peerIpsByViewKey.get(key); String title = key.substring("view:".length()); ScreenCastReceiveScope scope = receiveScopes.getOrDefault(key, ScreenCastReceiveScope.BROADCAST); - if (ip != null && !ip.isBlank()) { - routingPanel.bindPeerSession(ip, title, scope.bannerHtml()); - } else { - routingPanel.bindPeerSession("", title, scope.bannerHtml()); - } + TransportMode mode = receiveTransportByViewKey.getOrDefault( + key, + transportForReceiveScope(scope) + ); + 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() { String previous = sessionTabs.selectedKey(); if (previous != null && !PluginSessionKeys.PEER_DRAFT.equals(previous)) { @@ -401,11 +430,12 @@ public class ScreenCastPanel extends JPanel { peerDisplayNames.put(canonical, resolved); ensureBodyPanel(key); setBodyMessage(key, ShellUi.mutedLabel(IDLE_PEER_HTML)); + String tabTitle = SessionTabDirectionLabels.outboundTitle(resolved); if (sessionTabs.hasTab(key)) { - sessionTabs.setTabTitle(key, resolved, canonical); + sessionTabs.setTabTitle(key, tabTitle, canonical); sessionTabs.selectTab(key); } else { - sessionTabs.addOrSelectTab(key, resolved, canonical, new JPanel(), true); + sessionTabs.addOrSelectTab(key, tabTitle, canonical, new JPanel(), true); } routingPanel.bindPeerSession(canonical, resolved); showBodyCard(key); @@ -440,7 +470,7 @@ public class ScreenCastPanel extends JPanel { if (!sessionTabs.hasTab(PluginSessionKeys.ROOM)) { sessionTabs.addOrSelectTab( PluginSessionKeys.ROOM, - "Todos", + SessionTabDirectionLabels.outboundTitle("Todos"), "Transmissão para todos na rede (multicast)", new JPanel(), false @@ -464,6 +494,9 @@ public class ScreenCastPanel extends JPanel { } public void stopSharing() { + if (isSharingForTab(sessionTabs.selectedKey())) { + sendShareEndedNoticeForActiveTab(); + } sharingByTabKey.replaceAll((k, v) -> false); setShareToggleSelectedSilently(false); cancelShareTimer(); @@ -560,9 +593,12 @@ public class ScreenCastPanel extends JPanel { updateTabError(viewKey, message.getName(), "Não foi possível decodificar a imagem.", scope); 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); - 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)))); if (viewKey.equals(sessionTabs.selectedKey())) { showBodyCard(viewKey); @@ -586,15 +622,83 @@ public class ScreenCastPanel extends JPanel { } 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("

" + error + "

"); 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())) { 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) { if (!bodyPanels.containsKey(key)) { JPanel panel = new JPanel(new BorderLayout()); diff --git a/plugins/screencast/src/pitiupi/screenCast/plugin/ScreenCastPlugin.java b/plugins/screencast/src/pitiupi/screenCast/plugin/ScreenCastPlugin.java index 9cb030f..f124fba 100644 --- a/plugins/screencast/src/pitiupi/screenCast/plugin/ScreenCastPlugin.java +++ b/plugins/screencast/src/pitiupi/screenCast/plugin/ScreenCastPlugin.java @@ -11,7 +11,10 @@ import pitiupi.application.peeractions.PeerTarget; import pitiupi.control.ConnectionManager; import pitiupi.control.GuiManager; import pitiupi.models.Menus.MenuCategory; +import pitiupi.application.session.SessionNoticeChannel; +import pitiupi.application.session.SessionNoticePhase; import pitiupi.models.Messages.Message; +import pitiupi.models.Messages.SessionNoticeMessage; import pitiupi.models.Plugin; import pitiupi.screenCast.GUI.ScreenCastMenu; import pitiupi.screenCast.GUI.ScreenCastPanel; @@ -98,11 +101,15 @@ public class ScreenCastPlugin extends Plugin { @Override public List> getMessages() { - return List.of(ScreenCastMessage.class); + return List.of(ScreenCastMessage.class, SessionNoticeMessage.class); } @Override public void receiveMessage(Message message) { + if (message instanceof SessionNoticeMessage notice && screenCastPanel != null) { + handleSessionNotice(notice); + return; + } if (message instanceof ScreenCastMessage msg && screenCastPanel != null) { if (isOwnOutboundFrame(msg)) { return; @@ -134,4 +141,25 @@ public class ScreenCastPlugin extends Plugin { 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); + } + }