Send Audio file
This commit is contained in:
@@ -16,6 +16,7 @@ module org.to.telegramfinalproject {
|
|||||||
requires javax.servlet.api;
|
requires javax.servlet.api;
|
||||||
requires mp3agic;
|
requires mp3agic;
|
||||||
requires jdk.internal.le;
|
requires jdk.internal.le;
|
||||||
|
requires javafx.media;
|
||||||
|
|
||||||
// FXML کنترلرها در این پکیجاند:
|
// FXML کنترلرها در این پکیجاند:
|
||||||
opens org.to.telegramfinalproject.UI to javafx.fxml;
|
opens org.to.telegramfinalproject.UI to javafx.fxml;
|
||||||
|
|||||||
@@ -19,10 +19,13 @@ import javafx.scene.layout.HBox;
|
|||||||
import javafx.scene.layout.Priority;
|
import javafx.scene.layout.Priority;
|
||||||
import javafx.scene.layout.Region;
|
import javafx.scene.layout.Region;
|
||||||
import javafx.scene.layout.VBox;
|
import javafx.scene.layout.VBox;
|
||||||
|
import javafx.scene.media.Media;
|
||||||
|
import javafx.scene.media.MediaPlayer;
|
||||||
import javafx.scene.shape.Circle;
|
import javafx.scene.shape.Circle;
|
||||||
import javafx.stage.FileChooser;
|
import javafx.stage.FileChooser;
|
||||||
import javafx.stage.Modality;
|
import javafx.stage.Modality;
|
||||||
import javafx.stage.Stage;
|
import javafx.stage.Stage;
|
||||||
|
import javafx.util.Duration;
|
||||||
import org.json.JSONArray;
|
import org.json.JSONArray;
|
||||||
import org.json.JSONObject;
|
import org.json.JSONObject;
|
||||||
import org.to.telegramfinalproject.Client.ActionHandler;
|
import org.to.telegramfinalproject.Client.ActionHandler;
|
||||||
@@ -33,6 +36,7 @@ import org.to.telegramfinalproject.Models.ChatEntry;
|
|||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
|
import java.nio.file.Paths;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
@@ -583,7 +587,6 @@ public class ChatPageController {
|
|||||||
String name = f.getName().toLowerCase();
|
String name = f.getName().toLowerCase();
|
||||||
if (name.matches(".*\\.(png|jpg|jpeg|gif|bmp|webp)$")) return "IMAGE";
|
if (name.matches(".*\\.(png|jpg|jpeg|gif|bmp|webp)$")) return "IMAGE";
|
||||||
if (name.matches(".*\\.(mp3|wav|m4a|ogg|aac)$")) return "AUDIO";
|
if (name.matches(".*\\.(mp3|wav|m4a|ogg|aac)$")) return "AUDIO";
|
||||||
// اگر خواستی دقیقتر: با Files.probeContentType هم تست کن
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,11 +819,13 @@ public class ChatPageController {
|
|||||||
leaveGroupItem.setOnAction(e -> onLeaveGroupMenuClicked(entry));
|
leaveGroupItem.setOnAction(e -> onLeaveGroupMenuClicked(entry));
|
||||||
}
|
}
|
||||||
case "channel" -> {
|
case "channel" -> {
|
||||||
|
archiveItem.setVisible(true);
|
||||||
viewChannelItem.setVisible(true);
|
viewChannelItem.setVisible(true);
|
||||||
leaveChannelItem.setVisible(true);
|
leaveChannelItem.setVisible(true);
|
||||||
|
|
||||||
|
archiveItem.setOnAction(e -> toggleArchive(entry));
|
||||||
viewChannelItem.setOnAction(e -> openInfoScene(entry));
|
viewChannelItem.setOnAction(e -> openInfoScene(entry));
|
||||||
leaveChannelItem.setOnAction(e -> leaveChannelButton(entry));
|
leaveChannelItem.setOnAction(e -> onLeaveChannelMenuClicked(entry));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -972,7 +977,8 @@ public class ChatPageController {
|
|||||||
|
|
||||||
JSONObject resp = ActionHandler.sendWithResponse(req);
|
JSONObject resp = ActionHandler.sendWithResponse(req);
|
||||||
if (resp != null && "success".equalsIgnoreCase(resp.optString("status"))) {
|
if (resp != null && "success".equalsIgnoreCase(resp.optString("status"))) {
|
||||||
// TODO: close chat + refresh sidebar
|
MainController.getInstance().refreshChatListUI();
|
||||||
|
AppRouter.showMain();
|
||||||
MainController.getInstance().closeCurrentChat();
|
MainController.getInstance().closeCurrentChat();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1236,15 +1242,13 @@ public class ChatPageController {
|
|||||||
bubble.getChildren().add(imageNode);
|
bubble.getChildren().add(imageNode);
|
||||||
|
|
||||||
} else if ("AUDIO".equals(t)) {
|
} else if ("AUDIO".equals(t)) {
|
||||||
Label ph = new Label("🎵 Audio");
|
final MediaPathResolver resolver =
|
||||||
ph.setWrapText(true);
|
new MediaPathResolver(Paths.get(System.getProperty("user.dir"), "uploads"));
|
||||||
String bg = outgoing ? mine : theirs;
|
|
||||||
ph.setStyle("-fx-background-color:" + bg + ";" +
|
|
||||||
"-fx-padding:8 12;" +
|
|
||||||
"-fx-background-radius:12;" +
|
|
||||||
"-fx-max-width: 520;");
|
|
||||||
bubble.getChildren().add(ph);
|
|
||||||
|
|
||||||
|
String playable = resolver.toFileUri(fileUrl);
|
||||||
|
|
||||||
|
Node audioNode = buildAudioNodeLocal(playable, content);
|
||||||
|
bubble.getChildren().add(audioNode);
|
||||||
} else {
|
} else {
|
||||||
Label ph = new Label("[" + t + "]");
|
Label ph = new Label("[" + t + "]");
|
||||||
ph.setWrapText(true);
|
ph.setWrapText(true);
|
||||||
@@ -1679,6 +1683,114 @@ public class ChatPageController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// imports لازم:
|
||||||
|
// import javafx.scene.media.Media;
|
||||||
|
// import javafx.scene.media.MediaPlayer;
|
||||||
|
// import javafx.util.Duration;
|
||||||
|
|
||||||
|
|
||||||
|
private Node buildAudioNodeLocal(String localPathOrUri, String caption) {
|
||||||
|
HBox root = new HBox(8);
|
||||||
|
root.setAlignment(Pos.CENTER_LEFT);
|
||||||
|
|
||||||
|
Button btn = new Button("▶");
|
||||||
|
btn.setMinWidth(32);
|
||||||
|
|
||||||
|
Slider seek = new Slider(0, 1, 0);
|
||||||
|
seek.setPrefWidth(180);
|
||||||
|
|
||||||
|
Label time = new Label("0:00");
|
||||||
|
VBox right = new VBox(4, new HBox(8, btn, seek, time));
|
||||||
|
if (caption != null && !caption.isBlank()) {
|
||||||
|
Label cap = new Label(caption);
|
||||||
|
cap.setWrapText(true);
|
||||||
|
right.getChildren().add(cap);
|
||||||
|
}
|
||||||
|
root.getChildren().add(right);
|
||||||
|
|
||||||
|
// مسیر را به file URI تبدیل کن
|
||||||
|
String playable = toFileUri(localPathOrUri);
|
||||||
|
if (playable == null) {
|
||||||
|
btn.setDisable(true); seek.setDisable(true); time.setText("no audio");
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
// قبل از ساخت Media، وجود فایل را چک کن
|
||||||
|
try {
|
||||||
|
java.net.URI uri = java.net.URI.create(playable);
|
||||||
|
java.nio.file.Path p = java.nio.file.Paths.get(uri);
|
||||||
|
if (!java.nio.file.Files.exists(p)) {
|
||||||
|
btn.setDisable(true); seek.setDisable(true); time.setText("not found");
|
||||||
|
System.err.println("Audio file not found: " + p);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
} catch (Exception ignore) { /* اگر نشد، ادامه میدهیم */ }
|
||||||
|
|
||||||
|
MediaPlayer[] holder = new MediaPlayer[1];
|
||||||
|
try {
|
||||||
|
Media media = new Media(playable);
|
||||||
|
MediaPlayer player = new MediaPlayer(media);
|
||||||
|
holder[0] = player;
|
||||||
|
|
||||||
|
player.setOnReady(() -> {
|
||||||
|
double secs = player.getMedia().getDuration().toSeconds();
|
||||||
|
if (secs <= 0) secs = 1;
|
||||||
|
seek.setMax(secs);
|
||||||
|
time.setText(fmtClock(secs));
|
||||||
|
});
|
||||||
|
|
||||||
|
player.currentTimeProperty().addListener((o, ov, nv) -> {
|
||||||
|
if (!seek.isValueChanging()) seek.setValue(nv.toSeconds());
|
||||||
|
});
|
||||||
|
|
||||||
|
seek.valueChangingProperty().addListener((obs, was, is) -> {
|
||||||
|
if (!is && holder[0] != null) holder[0].seek(Duration.seconds(seek.getValue()));
|
||||||
|
});
|
||||||
|
seek.setOnMouseReleased(e -> {
|
||||||
|
if (holder[0] != null) holder[0].seek(Duration.seconds(seek.getValue()));
|
||||||
|
});
|
||||||
|
|
||||||
|
btn.setOnAction(e -> {
|
||||||
|
if (holder[0] == null) return;
|
||||||
|
if (holder[0].getStatus() == MediaPlayer.Status.PLAYING) {
|
||||||
|
holder[0].pause(); btn.setText("▶");
|
||||||
|
} else {
|
||||||
|
holder[0].play(); btn.setText("⏸");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
player.setOnEndOfMedia(() -> { btn.setText("▶"); player.seek(Duration.ZERO); });
|
||||||
|
|
||||||
|
media.setOnError(() -> System.err.println("Media error: " + media.getError()));
|
||||||
|
player.setOnError(() -> System.err.println("Player error: " + player.getError()));
|
||||||
|
|
||||||
|
root.sceneProperty().addListener((obs, old, nw) -> {
|
||||||
|
if (old != null && nw == null && holder[0] != null) { holder[0].dispose(); holder[0] = null; }
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (Exception ex) {
|
||||||
|
btn.setDisable(true); seek.setDisable(true); time.setText("error");
|
||||||
|
System.err.println("Build media failed: " + ex.getMessage() + " | uri=" + playable);
|
||||||
|
}
|
||||||
|
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private String toFileUri(String pathOrUri) {
|
||||||
|
if (pathOrUri == null || pathOrUri.isBlank()) return null;
|
||||||
|
if (pathOrUri.startsWith("file:/")) return pathOrUri;
|
||||||
|
|
||||||
|
java.io.File f = new java.io.File(pathOrUri);
|
||||||
|
return f.toURI().toString(); // -> file:///C:/... یا file:///home/...
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private String fmtClock(double secsD) {
|
||||||
|
int secs = (int)Math.round(secsD);
|
||||||
|
return (secs/60) + ":" + String.format("%02d", secs%60);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public void onRealTimeMessageDeleted(JSONObject ev) {
|
public void onRealTimeMessageDeleted(JSONObject ev) {
|
||||||
String msgId = str(ev, "message_id");
|
String msgId = str(ev, "message_id");
|
||||||
@@ -2727,8 +2839,7 @@ public class ChatPageController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Owner case ==========
|
|
||||||
// گرفتن لیست ادمینها
|
|
||||||
JSONObject req = new JSONObject()
|
JSONObject req = new JSONObject()
|
||||||
.put("action", "view_group_admins")
|
.put("action", "view_group_admins")
|
||||||
.put("group_id", entry.getId().toString());
|
.put("group_id", entry.getId().toString());
|
||||||
@@ -2802,7 +2913,6 @@ public class ChatPageController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ارسال درخواست انتقال مالکیت
|
|
||||||
JSONObject promoteReq = new JSONObject()
|
JSONObject promoteReq = new JSONObject()
|
||||||
.put("action", "transfer_group_ownership")
|
.put("action", "transfer_group_ownership")
|
||||||
.put("group_id", entry.getId().toString());
|
.put("group_id", entry.getId().toString());
|
||||||
@@ -2820,7 +2930,6 @@ public class ChatPageController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// بعد از انتقال، لفت بده
|
|
||||||
sendLeaveRequest(entry.getId(), myUuidStr);
|
sendLeaveRequest(entry.getId(), myUuidStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2858,4 +2967,143 @@ public class ChatPageController {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// === CHANNEL: Leave/Transfer logic ===
|
||||||
|
private void onLeaveChannelMenuClicked(ChatEntry entry) {
|
||||||
|
if (entry == null || !"channel".equalsIgnoreCase(entry.getType())) {
|
||||||
|
alert(Alert.AlertType.INFORMATION, "This action is only available for channels.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String myUuidStr = Session.getUserUUID();
|
||||||
|
if (myUuidStr == null || myUuidStr.isBlank()) {
|
||||||
|
alert(Alert.AlertType.ERROR, "Cannot determine your identity.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If I'm not the owner → simple leave
|
||||||
|
if (!entry.isOwner()) {
|
||||||
|
if (confirm("Leave Channel", "Are you sure you want to leave this channel?")) {
|
||||||
|
sendLeaveChannelRequest(entry.getId(), myUuidStr);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// I'm the owner → must pick a new owner among existing admins
|
||||||
|
JSONObject req = new JSONObject()
|
||||||
|
.put("action", "view_channel_admins")
|
||||||
|
.put("channel_id", entry.getId().toString()); // server expects channel_id here
|
||||||
|
|
||||||
|
JSONObject res = ActionHandler.sendWithResponse(req);
|
||||||
|
if (res == null || !"success".equalsIgnoreCase(res.optString("status"))) {
|
||||||
|
alert(Alert.AlertType.ERROR, "Failed to fetch channel admins.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONArray admins = res.getJSONObject("data").optJSONArray("admins");
|
||||||
|
if (admins == null || admins.isEmpty()) {
|
||||||
|
alert(Alert.AlertType.WARNING, "No other admins available. Promote someone first.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build candidate list (exclude myself)
|
||||||
|
List<JSONObject> candidates = new ArrayList<>();
|
||||||
|
for (int i = 0; i < admins.length(); i++) {
|
||||||
|
JSONObject a = admins.optJSONObject(i);
|
||||||
|
if (a == null) continue;
|
||||||
|
String uuid = a.optString("internal_uuid", "");
|
||||||
|
if (!uuid.isBlank() && uuid.equals(myUuidStr)) continue; // skip myself
|
||||||
|
candidates.add(a);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (candidates.isEmpty()) {
|
||||||
|
alert(Alert.AlertType.WARNING, "No other admins available.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialog to choose the new owner
|
||||||
|
Dialog<JSONObject> dialog = new Dialog<>();
|
||||||
|
dialog.setTitle("Transfer Ownership");
|
||||||
|
dialog.setHeaderText("Select a new owner before leaving the channel");
|
||||||
|
dialog.getDialogPane().getButtonTypes().addAll(ButtonType.OK, ButtonType.CANCEL);
|
||||||
|
|
||||||
|
ListView<JSONObject> listView = new ListView<>();
|
||||||
|
listView.getItems().addAll(candidates);
|
||||||
|
listView.setCellFactory(v -> new ListCell<>() {
|
||||||
|
@Override protected void updateItem(JSONObject item, boolean empty) {
|
||||||
|
super.updateItem(item, empty);
|
||||||
|
if (empty || item == null) {
|
||||||
|
setText(null);
|
||||||
|
} else {
|
||||||
|
String displayName = item.optString("profile_name", "Unknown");
|
||||||
|
String displayId = item.optString("user_id", "");
|
||||||
|
setText(displayName + (displayId.isBlank() ? "" : " (" + displayId + ")"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.getDialogPane().setContent(listView);
|
||||||
|
Node okBtn = dialog.getDialogPane().lookupButton(ButtonType.OK);
|
||||||
|
okBtn.setDisable(true);
|
||||||
|
|
||||||
|
listView.getSelectionModel().selectedItemProperty().addListener((obs, oldV, sel) -> {
|
||||||
|
okBtn.setDisable(sel == null);
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.setResultConverter(bt ->
|
||||||
|
bt == ButtonType.OK ? listView.getSelectionModel().getSelectedItem() : null);
|
||||||
|
|
||||||
|
Optional<JSONObject> pick = dialog.showAndWait();
|
||||||
|
if (pick.isEmpty()) return;
|
||||||
|
|
||||||
|
JSONObject selected = pick.get();
|
||||||
|
String newOwnerUuid = selected.optString("internal_uuid", "").trim();
|
||||||
|
String newOwnerUserId = selected.optString("user_id", "").trim();
|
||||||
|
|
||||||
|
if (newOwnerUuid.isEmpty() && newOwnerUserId.isEmpty()) {
|
||||||
|
alert(Alert.AlertType.ERROR, "Selected admin has no valid id.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transfer ownership
|
||||||
|
JSONObject promoteReq = new JSONObject()
|
||||||
|
.put("action", "transfer_channel_ownership")
|
||||||
|
.put("channel_id", entry.getId().toString());
|
||||||
|
|
||||||
|
// Prefer internal UUID; include user_id only if UUID missing (server quirks!)
|
||||||
|
if (!newOwnerUuid.isEmpty()) {
|
||||||
|
promoteReq.put("new_owner_id", newOwnerUuid);
|
||||||
|
} else {
|
||||||
|
promoteReq.put("new_owner_user_id", newOwnerUserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
JSONObject promoteRes = ActionHandler.sendWithResponse(promoteReq);
|
||||||
|
if (promoteRes == null || !"success".equalsIgnoreCase(promoteRes.optString("status"))) {
|
||||||
|
String msg = (promoteRes != null) ? promoteRes.optString("message", "Ownership transfer failed.") : "null response";
|
||||||
|
alert(Alert.AlertType.ERROR, msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// After successful transfer, leave the channel myself
|
||||||
|
sendLeaveChannelRequest(entry.getId(), myUuidStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendLeaveChannelRequest(UUID channelId, String myUuidStr) {
|
||||||
|
JSONObject req = new JSONObject()
|
||||||
|
.put("action", "leave_chat")
|
||||||
|
.put("user_id", myUuidStr)
|
||||||
|
.put("chat_id", channelId.toString())
|
||||||
|
.put("chat_type", "channel");
|
||||||
|
|
||||||
|
JSONObject res = ActionHandler.sendWithResponse(req);
|
||||||
|
if (res != null && "success".equalsIgnoreCase(res.optString("status"))) {
|
||||||
|
alert(Alert.AlertType.INFORMATION, "You left the channel.");
|
||||||
|
MainController.getInstance().refreshChatListUI();
|
||||||
|
AppRouter.showMain();
|
||||||
|
} else {
|
||||||
|
String msg = (res != null) ? res.optString("message", "Leave failed.") : "null response";
|
||||||
|
alert(Alert.AlertType.ERROR, msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package org.to.telegramfinalproject.UI;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.nio.file.Paths;
|
||||||
|
|
||||||
|
public final class MediaPathResolver {
|
||||||
|
private final Path uploadsRoot;
|
||||||
|
|
||||||
|
public MediaPathResolver(Path uploadsRoot) {
|
||||||
|
this.uploadsRoot = uploadsRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String toFileUri(String raw) {
|
||||||
|
if (raw == null || raw.isBlank()) return null;
|
||||||
|
if (raw.startsWith("file:/")) return raw;
|
||||||
|
|
||||||
|
Path p;
|
||||||
|
if (raw.startsWith("/")) {
|
||||||
|
String sub = raw.substring(1).replace("/", File.separator);
|
||||||
|
p = uploadsRoot.resolve(sub).normalize();
|
||||||
|
} else {
|
||||||
|
p = Paths.get(raw).normalize();
|
||||||
|
}
|
||||||
|
return p.toUri().toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user