语音、图片的发送功能编写,设置聊天室的封面

This commit is contained in:
ShiQi 2026-01-08 18:04:03 +08:00
parent 124026ba62
commit 987e67dcb0
10 changed files with 907 additions and 32 deletions

View File

@ -14,6 +14,7 @@ import com.zbkj.service.service.ChatRoomService;
import com.zbkj.service.service.ChatRoomEntryRecordService;
import com.zbkj.service.service.UserService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiModelProperty;
import io.swagger.annotations.ApiOperation;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
@ -202,6 +203,84 @@ public class ChatRoomFrontController {
return CommonResult.success(page);
}
@ApiOperation(value = "创建聊天室")
@PostMapping("/create")
public CommonResult<ChatRoomResponse> createChatRoom(@RequestBody CreateChatRoomRequest request) {
Integer uid = null;
try {
uid = frontTokenComponent.getUserId();
} catch (Exception e) {
return CommonResult.failed("请先登录");
}
if (uid == null) {
return CommonResult.failed("请先登录");
}
// 验证参数
if (request.getRoomTitle() == null || request.getRoomTitle().trim().isEmpty()) {
return CommonResult.failed("房间名称不能为空");
}
// 构建聊天室对象
ChatRoom chatRoom = new ChatRoom();
chatRoom.setOwnerId(uid);
chatRoom.setRoomTitle(request.getRoomTitle().trim());
chatRoom.setRoomDescription(request.getRoomDescription());
chatRoom.setRoomImage(request.getRoomImage());
chatRoom.setRoomType(request.getRoomType() != null ? request.getRoomType() : 0);
chatRoom.setEntryFee(request.getEntryFee() != null ? request.getEntryFee() : 0);
chatRoom.setMaxMembers(request.getMaxMembers() != null ? Math.min(request.getMaxMembers(), 24) : 24);
chatRoom.setCurrentMembers(0);
chatRoom.setStatus(1);
chatRoom.setIsRecommended(0);
chatRoom.setIsPrivate(0);
chatRoom.setTotalMessages(0);
chatRoom.setTotalMembers(0);
chatRoom.setCreateTime(new Date());
chatRoom.setUpdateTime(new Date());
// 生成房间号
chatRoom.setRoomCode("CR" + System.currentTimeMillis() % 100000000);
// 如果是收费房间必须设置费用
if (chatRoom.getRoomType() == 1 && (chatRoom.getEntryFee() == null || chatRoom.getEntryFee() <= 0)) {
return CommonResult.failed("收费房间必须设置入场费用");
}
boolean saved = chatRoomService.save(chatRoom);
if (!saved) {
return CommonResult.failed("创建聊天室失败");
}
log.info("用户 {} 创建聊天室: {}", uid, chatRoom.getRoomTitle());
// 返回创建的聊天室信息
ChatRoomResponse response = chatRoomService.getDetail(chatRoom.getId());
return CommonResult.success(response);
}
@Data
public static class CreateChatRoomRequest {
@ApiModelProperty(value = "房间名称", required = true)
private String roomTitle;
@ApiModelProperty(value = "房间描述")
private String roomDescription;
@ApiModelProperty(value = "房间封面图")
private String roomImage;
@ApiModelProperty(value = "房间类型0=免费1=收费")
private Integer roomType;
@ApiModelProperty(value = "进入房间费用(金币)")
private Integer entryFee;
@ApiModelProperty(value = "房间最大人数不能超过24")
private Integer maxMembers;
}
@Data
public static class PayEntryResponse {
private Boolean success;

View File

@ -98,8 +98,9 @@ public class UploadServiceImpl implements UploadService {
logger.info("文件是否为空: {}", multipartFile.isEmpty());
// 判断是否使用远程上传服务器
// works-作品, cover-封面, avatar-头像 都使用远程服务器
if ("works".equals(model) || "cover".equals(model) || "avatar".equals(model)) {
// works-作品, cover-封面, avatar-头像, chatroom-聊天室, chat-聊天 都使用远程服务器
if ("works".equals(model) || "cover".equals(model) || "avatar".equals(model)
|| "chatroom".equals(model) || "chat".equals(model)) {
logger.info("使用远程上传服务器 - model: {}", model);
fileResultVo = remoteUploadService.uploadToRemoteServer(multipartFile, model);
} else {
@ -293,10 +294,23 @@ public class UploadServiceImpl implements UploadService {
logger.info("文件是否已存在: {}", file.exists());
try {
multipartFile.transferTo(file);
// 使用绝对路径的File对象避免transferTo使用相对路径
File absoluteFile = file.getAbsoluteFile();
logger.info("使用绝对路径: {}", absoluteFile.getAbsolutePath());
// 使用输入流方式写入文件避免transferTo的路径问题
try (java.io.InputStream inputStream = multipartFile.getInputStream();
java.io.FileOutputStream outputStream = new java.io.FileOutputStream(absoluteFile)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
outputStream.flush();
}
logger.info("文件保存成功!");
} catch (IOException e) {
logger.error("transferTo失败", e);
logger.error("文件保存失败!", e);
logger.error("文件路径: {}", file.getAbsolutePath());
logger.error("文件大小: {} bytes", multipartFile.getSize());
logger.error("文件名: {}", multipartFile.getOriginalFilename());
@ -312,7 +326,17 @@ public class UploadServiceImpl implements UploadService {
logger.info("云存储模式,先保存到本地: {}", file.getAbsolutePath());
try {
multipartFile.transferTo(file);
// 使用输入流方式写入文件
File absoluteFile = file.getAbsoluteFile();
try (java.io.InputStream inputStream = multipartFile.getInputStream();
java.io.FileOutputStream outputStream = new java.io.FileOutputStream(absoluteFile)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
outputStream.flush();
}
logger.info("本地文件保存成功!");
} catch (IOException e) {
logger.error("云存储模式下本地保存失败!", e);
@ -484,8 +508,17 @@ public class UploadServiceImpl implements UploadService {
systemAttachment.setImageType(1);
systemAttachment.setPid(pid);
// 保存文件
multipartFile.transferTo(file);
// 保存文件 - 使用输入流方式避免transferTo路径问题
File absoluteFile = file.getAbsoluteFile();
try (java.io.InputStream inputStream = multipartFile.getInputStream();
java.io.FileOutputStream outputStream = new java.io.FileOutputStream(absoluteFile)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
outputStream.flush();
}
systemAttachmentService.save(systemAttachment);
logger.info("视频上传成功: {}, 大小: {} MB", fileName, String.format("%.2f", fileSize));
@ -520,10 +553,20 @@ public class UploadServiceImpl implements UploadService {
throw new CrmebException(CommonResultCode.VALIDATE_FAILED, String.format("语音文件最大允许 10 MB当前文件大小为 %.2f MB", fileSize));
}
if (fileName.length() > 99) {
fileName = StrUtil.subPre(fileName, 90).concat(".").concat(extName);
// 使用远程上传服务器上传语音
logger.info("=== 开始上传语音 ===");
logger.info("文件名: {}, 大小: {} MB, 格式: {}", fileName, String.format("%.2f", fileSize), extName);
FileResultVo resultFile = remoteUploadService.uploadToRemoteServer(multipartFile, "voice");
logger.info("语音上传成功: {}", resultFile.getUrl());
return resultFile;
}
/**
* 语音上传本地存储 - 备用方法
*/
private FileResultVo voiceUploadLocal(MultipartFile multipartFile, String model, Integer pid, String fileName, String extName, float fileSize) throws IOException {
// 服务器存储地址 - 使用绝对路径
String rootPath = crmebConfig.getAbsoluteImagePath();
String modelPath = "public/" + model + "/";
@ -552,15 +595,20 @@ public class UploadServiceImpl implements UploadService {
systemAttachment.setImageType(1);
systemAttachment.setPid(pid);
// 保存文件
multipartFile.transferTo(file);
// 保存文件 - 使用输入流方式避免transferTo路径问题
File absoluteFile = file.getAbsoluteFile();
try (java.io.InputStream inputStream = multipartFile.getInputStream();
java.io.FileOutputStream outputStream = new java.io.FileOutputStream(absoluteFile)) {
byte[] buffer = new byte[8192];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
outputStream.flush();
}
systemAttachmentService.save(systemAttachment);
// TODO: 提取语音时长
// 可以使用 FFmpeg 或其他音频处理库来提取音频时长
// resultFile.setDuration(extractAudioDuration(file));
logger.info("语音上传成功: {}, 大小: {} MB", fileName, String.format("%.2f", fileSize));
logger.info("语音本地上传成功: {}, 大小: {} MB", fileName, String.format("%.2f", fileSize));
return resultFile;
}
}

View File

@ -414,7 +414,18 @@ public class ChatRoomDetailActivity extends AppCompatActivity {
}
try {
voiceFilePath = getCacheDir() + "/voice_" + System.currentTimeMillis() + ".m4a";
// 使用 File 对象正确拼接路径
File cacheDir = getCacheDir();
if (!cacheDir.exists()) {
cacheDir.mkdirs();
}
File voiceFile = new File(cacheDir, "voice_" + System.currentTimeMillis() + ".m4a");
voiceFilePath = voiceFile.getAbsolutePath();
Log.d(TAG, "录音文件路径: " + voiceFilePath);
Log.d(TAG, "缓存目录存在: " + cacheDir.exists() + ", 可写: " + cacheDir.canWrite());
mediaRecorder = new MediaRecorder();
mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4);
@ -429,7 +440,11 @@ public class ChatRoomDetailActivity extends AppCompatActivity {
binding.voiceButton.setAlpha(0.5f);
Toast.makeText(this, "正在录音,松开发送", Toast.LENGTH_SHORT).show();
} catch (IOException e) {
Toast.makeText(this, "录音失败", Toast.LENGTH_SHORT).show();
Log.e(TAG, "录音失败", e);
Toast.makeText(this, "录音失败: " + e.getMessage(), Toast.LENGTH_SHORT).show();
} catch (Exception e) {
Log.e(TAG, "录音异常", e);
Toast.makeText(this, "录音异常: " + e.getMessage(), Toast.LENGTH_SHORT).show();
}
}
@ -447,12 +462,35 @@ public class ChatRoomDetailActivity extends AppCompatActivity {
int duration = (int) ((System.currentTimeMillis() - voiceStartTime) / 1000);
if (duration < 1) {
Toast.makeText(this, "录音时间太短", Toast.LENGTH_SHORT).show();
if (voiceFilePath != null) {
new File(voiceFilePath).delete();
}
return;
}
// 检查录音文件是否成功创建
File voiceFile = new File(voiceFilePath);
if (!voiceFile.exists() || voiceFile.length() == 0) {
Log.e(TAG, "录音文件不存在或为空: " + voiceFilePath);
Toast.makeText(this, "录音失败:文件未生成", Toast.LENGTH_SHORT).show();
return;
}
Log.d(TAG, "录音完成,文件大小: " + voiceFile.length() + " bytes, 时长: " + duration + "s");
uploadAndSendVoice(voiceFilePath, duration);
} catch (RuntimeException e) {
Log.e(TAG, "停止录音失败", e);
Toast.makeText(this, "录音失败: " + e.getMessage(), Toast.LENGTH_SHORT).show();
isRecording = false;
binding.voiceButton.setAlpha(1.0f);
if (mediaRecorder != null) {
try {
mediaRecorder.release();
} catch (Exception ignored) {}
mediaRecorder = null;
}
} catch (Exception e) {
Log.e(TAG, "停止录音异常", e);
isRecording = false;
binding.voiceButton.setAlpha(1.0f);
}
@ -460,12 +498,22 @@ public class ChatRoomDetailActivity extends AppCompatActivity {
private void uploadAndSendVoice(String filePath, int duration) {
File voiceFile = new File(filePath);
RequestBody requestFile = RequestBody.create(MediaType.parse("audio/*"), voiceFile);
// 检查文件是否存在
if (!voiceFile.exists()) {
Log.e(TAG, "语音文件不存在: " + filePath);
Toast.makeText(this, "发送失败:录音文件不存在", Toast.LENGTH_SHORT).show();
return;
}
Log.d(TAG, "上传语音文件: " + filePath + ", 大小: " + voiceFile.length() + " bytes");
RequestBody requestFile = RequestBody.create(MediaType.parse("audio/mp4"), voiceFile);
MultipartBody.Part body = MultipartBody.Part.createFormData("file", voiceFile.getName(), requestFile);
RequestBody model = RequestBody.create(MediaType.parse("text/plain"), "chatroom");
RequestBody model = RequestBody.create(MediaType.parse("text/plain"), "chat");
RequestBody pid = RequestBody.create(MediaType.parse("text/plain"), "0");
ApiClient.getService(this).uploadImage(body, model, pid)
ApiClient.getService(this).uploadVoice(body, model, pid)
.enqueue(new Callback<ApiResponse<FileUploadResponse>>() {
@Override
public void onResponse(Call<ApiResponse<FileUploadResponse>> call,
@ -475,16 +523,23 @@ public class ChatRoomDetailActivity extends AppCompatActivity {
FileUploadResponse data = response.body().getData();
if (data != null && data.getUrl() != null) {
sendVoiceMessage(data.getUrl(), duration);
} else {
Toast.makeText(ChatRoomDetailActivity.this, "发送失败:上传返回为空", Toast.LENGTH_SHORT).show();
}
} else {
Toast.makeText(ChatRoomDetailActivity.this, "发送失败", Toast.LENGTH_SHORT).show();
String errorMsg = "发送失败";
if (response.body() != null && response.body().getMessage() != null) {
errorMsg = response.body().getMessage();
}
Toast.makeText(ChatRoomDetailActivity.this, errorMsg, Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<ApiResponse<FileUploadResponse>> call, Throwable t) {
voiceFile.delete();
Toast.makeText(ChatRoomDetailActivity.this, "发送失败", Toast.LENGTH_SHORT).show();
Toast.makeText(ChatRoomDetailActivity.this, "发送失败:" + t.getMessage(), Toast.LENGTH_SHORT).show();
Log.e(TAG, "语音上传失败", t);
}
});
}
@ -542,6 +597,10 @@ public class ChatRoomDetailActivity extends AppCompatActivity {
if (mediaRecorder != null) {
try { mediaRecorder.release(); } catch (Exception ignored) {}
}
// 释放消息适配器资源停止语音播放
if (messageAdapter != null) {
messageAdapter.release();
}
}
public static class ChatRoomMessage {

View File

@ -1,16 +1,20 @@
package com.example.livestreaming;
import android.media.MediaPlayer;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.LinearLayout;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import com.bumptech.glide.Glide;
import com.example.livestreaming.util.UrlUtils;
import java.text.SimpleDateFormat;
import java.util.Date;
@ -24,6 +28,7 @@ import de.hdodenhof.circleimageview.CircleImageView;
*/
public class ChatRoomMessageAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
private static final String TAG = "ChatRoomMsgAdapter";
private static final int TYPE_TEXT_LEFT = 0;
private static final int TYPE_TEXT_RIGHT = 1;
private static final int TYPE_SYSTEM = 2;
@ -31,6 +36,10 @@ public class ChatRoomMessageAdapter extends RecyclerView.Adapter<RecyclerView.Vi
private final List<ChatRoomDetailActivity.ChatRoomMessage> messages;
private final SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm", Locale.getDefault());
// 语音播放器
private MediaPlayer mediaPlayer;
private int currentPlayingPosition = -1;
public ChatRoomMessageAdapter(List<ChatRoomDetailActivity.ChatRoomMessage> messages) {
this.messages = messages;
}
@ -101,6 +110,8 @@ public class ChatRoomMessageAdapter extends RecyclerView.Adapter<RecyclerView.Vi
}
void bind(ChatRoomDetailActivity.ChatRoomMessage msg) {
final int position = getAdapterPosition();
if (nickname != null) {
nickname.setText(msg.nickname != null ? msg.nickname : "用户" + msg.userId);
}
@ -108,11 +119,13 @@ public class ChatRoomMessageAdapter extends RecyclerView.Adapter<RecyclerView.Vi
time.setText(timeFormat.format(new Date(msg.timestamp)));
}
// 加载头像
// 加载头像 - 处理URL
if (avatar != null) {
if (msg.avatar != null && !msg.avatar.isEmpty()) {
String avatarUrl = UrlUtils.getFullUrl(msg.avatar);
if (avatarUrl != null && !avatarUrl.isEmpty()) {
Log.d(TAG, "加载头像: " + avatarUrl);
Glide.with(itemView.getContext())
.load(msg.avatar)
.load(avatarUrl)
.placeholder(R.drawable.ic_person_24)
.error(R.drawable.ic_person_24)
.circleCrop()
@ -129,8 +142,10 @@ public class ChatRoomMessageAdapter extends RecyclerView.Adapter<RecyclerView.Vi
if (content != null) content.setVisibility(View.GONE);
if (imageContent != null) {
imageContent.setVisibility(View.VISIBLE);
String imageUrl = UrlUtils.getFullUrl(msg.imageUrl);
Log.d(TAG, "加载图片: " + imageUrl);
Glide.with(itemView.getContext())
.load(msg.imageUrl)
.load(imageUrl)
.into(imageContent);
}
if (voiceContainer != null) voiceContainer.setVisibility(View.GONE);
@ -142,6 +157,16 @@ public class ChatRoomMessageAdapter extends RecyclerView.Adapter<RecyclerView.Vi
if (voiceDuration != null) {
voiceDuration.setText(msg.voiceDuration + "s");
}
// 设置语音点击播放
voiceContainer.setOnClickListener(v -> {
String voiceUrl = UrlUtils.getFullUrl(msg.voiceUrl);
Log.d(TAG, "播放语音: " + voiceUrl);
playVoice(voiceUrl, position, voiceContainer);
});
// 更新播放状态UI
updateVoicePlayingState(voiceContainer, position == currentPlayingPosition);
}
} else {
if (content != null) {
@ -152,6 +177,12 @@ public class ChatRoomMessageAdapter extends RecyclerView.Adapter<RecyclerView.Vi
if (voiceContainer != null) voiceContainer.setVisibility(View.GONE);
}
}
private void updateVoicePlayingState(View container, boolean isPlaying) {
if (container != null) {
container.setAlpha(isPlaying ? 0.7f : 1.0f);
}
}
}
class SystemViewHolder extends RecyclerView.ViewHolder {
@ -188,4 +219,92 @@ public class ChatRoomMessageAdapter extends RecyclerView.Adapter<RecyclerView.Vi
}
}
}
/**
* 播放语音消息
*/
private void playVoice(String voiceUrl, int position, View container) {
if (voiceUrl == null || voiceUrl.isEmpty()) {
Log.e(TAG, "语音URL为空");
return;
}
// 如果正在播放同一条语音则停止
if (currentPlayingPosition == position && mediaPlayer != null && mediaPlayer.isPlaying()) {
stopVoice();
return;
}
// 停止之前的播放
stopVoice();
try {
currentPlayingPosition = position;
mediaPlayer = new MediaPlayer();
mediaPlayer.setDataSource(voiceUrl);
mediaPlayer.setOnPreparedListener(mp -> {
Log.d(TAG, "语音准备完成,开始播放");
mp.start();
if (container != null) {
container.setAlpha(0.7f);
}
});
mediaPlayer.setOnCompletionListener(mp -> {
Log.d(TAG, "语音播放完成");
stopVoice();
if (container != null) {
container.setAlpha(1.0f);
}
notifyItemChanged(position);
});
mediaPlayer.setOnErrorListener((mp, what, extra) -> {
Log.e(TAG, "语音播放错误: what=" + what + ", extra=" + extra);
stopVoice();
if (container != null && container.getContext() != null) {
Toast.makeText(container.getContext(), "播放失败", Toast.LENGTH_SHORT).show();
}
return true;
});
mediaPlayer.prepareAsync();
Log.d(TAG, "开始准备语音: " + voiceUrl);
} catch (Exception e) {
Log.e(TAG, "播放语音异常", e);
stopVoice();
}
}
/**
* 停止语音播放
*/
private void stopVoice() {
if (mediaPlayer != null) {
try {
if (mediaPlayer.isPlaying()) {
mediaPlayer.stop();
}
mediaPlayer.release();
} catch (Exception e) {
Log.e(TAG, "停止语音异常", e);
}
mediaPlayer = null;
}
int oldPosition = currentPlayingPosition;
currentPlayingPosition = -1;
if (oldPosition >= 0 && oldPosition < messages.size()) {
notifyItemChanged(oldPosition);
}
}
/**
* 释放资源在Activity销毁时调用
*/
public void release() {
stopVoice();
}
}

View File

@ -7,10 +7,12 @@ import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
import android.provider.MediaStore;
import android.speech.RecognitionListener;
import android.speech.RecognizerIntent;
import android.speech.SpeechRecognizer;
@ -20,9 +22,15 @@ import android.text.TextWatcher;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.ArrayAdapter;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.RadioGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
@ -39,6 +47,7 @@ import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import com.bumptech.glide.Glide;
import com.example.livestreaming.databinding.ActivityMainBinding;
import com.example.livestreaming.databinding.DialogCreateRoomBinding;
import com.example.livestreaming.net.FileUploadResponse;
import com.google.android.material.bottomnavigation.BottomNavigationView;
import com.google.android.material.tabs.TabLayout;
import com.google.android.material.textfield.MaterialAutoCompleteTextView;
@ -46,6 +55,7 @@ import com.google.android.material.textfield.TextInputLayout;
import com.example.livestreaming.net.ApiClient;
import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.ApiService;
import com.example.livestreaming.net.ChatRoomResponse;
import com.example.livestreaming.net.CommunityResponse;
import com.example.livestreaming.net.ConversationResponse;
import com.example.livestreaming.net.CreateRoomRequest;
@ -54,14 +64,21 @@ import com.example.livestreaming.net.Room;
import com.example.livestreaming.net.StreamConfig;
import com.example.livestreaming.net.WorksResponse;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import okhttp3.RequestBody;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
@ -112,6 +129,13 @@ public class MainActivity extends AppCompatActivity {
private boolean isFetching;
private long lastFetchMs;
// 聊天室封面上传相关
private Uri chatRoomCoverUri;
private String chatRoomCoverUrl;
private ImageView chatRoomCoverImageView;
private View chatRoomCoverPlaceholder;
private ActivityResultLauncher<Intent> chatRoomCoverPickerLauncher;
private static final int REQUEST_RECORD_AUDIO_PERMISSION = 200;
private static final int REQUEST_LOCATION_PERMISSION = 201;
private SpeechRecognizer speechRecognizer;
@ -140,6 +164,9 @@ public class MainActivity extends AppCompatActivity {
Log.d(TAG, "BuildConfig DEVICE: " + com.example.livestreaming.BuildConfig.API_BASE_URL_DEVICE);
Log.d(TAG, "==============================");
// 初始化聊天室封面选择器
setupChatRoomCoverPicker();
// 立即显示缓存数据提升启动速度
setupRecyclerView();
setupUI();
@ -1136,17 +1163,20 @@ public class MainActivity extends AppCompatActivity {
}
private void showCreateRoomDialogInternal() {
// 显示选择对话框手机开播 OBS推流
// 显示选择对话框手机开播OBS推流 创建聊天室
new AlertDialog.Builder(this)
.setTitle("选择开播方式")
.setItems(new String[]{"📱 手机开播", "💻 OBS推流"}, (dialog, which) -> {
.setItems(new String[]{"📱 手机开播", "💻 OBS推流", "💬 创建聊天室"}, (dialog, which) -> {
if (which == 0) {
// 手机开播 - 跳转到 BroadcastActivity
Intent intent = new Intent(MainActivity.this, BroadcastActivity.class);
startActivity(intent);
} else {
} else if (which == 1) {
// OBS推流 - 显示创建直播间对话框
showOBSCreateRoomDialog();
} else {
// 创建聊天室
showCreateChatRoomDialog();
}
})
.setNegativeButton("取消", null)
@ -1339,6 +1369,266 @@ public class MainActivity extends AppCompatActivity {
dialog.show();
}
/**
* 显示创建聊天室对话框
*/
private void showCreateChatRoomDialog() {
View dialogView = getLayoutInflater().inflate(R.layout.dialog_create_chatroom, null);
EditText titleEdit = dialogView.findViewById(R.id.chatroom_title_edit);
EditText descEdit = dialogView.findViewById(R.id.chatroom_desc_edit);
RadioGroup typeGroup = dialogView.findViewById(R.id.chatroom_type_group);
EditText feeEdit = dialogView.findViewById(R.id.chatroom_fee_edit);
EditText maxMembersEdit = dialogView.findViewById(R.id.chatroom_max_members_edit);
View feeLayout = dialogView.findViewById(R.id.chatroom_fee_layout);
// 封面相关视图
FrameLayout coverContainer = dialogView.findViewById(R.id.chatroom_cover_container);
chatRoomCoverImageView = dialogView.findViewById(R.id.chatroom_cover_image);
chatRoomCoverPlaceholder = dialogView.findViewById(R.id.chatroom_cover_placeholder);
// 重置封面状态
chatRoomCoverUri = null;
chatRoomCoverUrl = null;
if (chatRoomCoverImageView != null) {
chatRoomCoverImageView.setVisibility(View.GONE);
}
if (chatRoomCoverPlaceholder != null) {
chatRoomCoverPlaceholder.setVisibility(View.VISIBLE);
}
// 设置封面点击事件
if (coverContainer != null) {
coverContainer.setOnClickListener(v -> {
// 打开图片选择器
Intent intent = new Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI);
intent.setType("image/*");
chatRoomCoverPickerLauncher.launch(intent);
});
}
// 默认隐藏费用输入
feeLayout.setVisibility(View.GONE);
// 监听房间类型切换
typeGroup.setOnCheckedChangeListener((group, checkedId) -> {
if (checkedId == R.id.radio_paid) {
feeLayout.setVisibility(View.VISIBLE);
} else {
feeLayout.setVisibility(View.GONE);
}
});
AlertDialog dialog = new AlertDialog.Builder(this)
.setTitle("💬 创建聊天室")
.setView(dialogView)
.setNegativeButton("取消", null)
.setPositiveButton("创建", null)
.create();
dialog.setOnShowListener(d -> {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener(v -> {
String title = titleEdit.getText().toString().trim();
String desc = descEdit.getText().toString().trim();
int roomType = typeGroup.getCheckedRadioButtonId() == R.id.radio_paid ? 1 : 0;
String feeStr = feeEdit.getText().toString().trim();
String maxMembersStr = maxMembersEdit.getText().toString().trim();
// 验证
if (TextUtils.isEmpty(title)) {
titleEdit.setError("请输入聊天室名称");
return;
}
int entryFee = 0;
if (roomType == 1) {
if (TextUtils.isEmpty(feeStr)) {
feeEdit.setError("请输入入场费用");
return;
}
try {
entryFee = Integer.parseInt(feeStr);
if (entryFee <= 0) {
feeEdit.setError("费用必须大于0");
return;
}
} catch (NumberFormatException e) {
feeEdit.setError("请输入有效数字");
return;
}
}
int maxMembers = 24;
if (!TextUtils.isEmpty(maxMembersStr)) {
try {
maxMembers = Integer.parseInt(maxMembersStr);
if (maxMembers < 2 || maxMembers > 24) {
maxMembersEdit.setError("人数范围2-24");
return;
}
} catch (NumberFormatException e) {
maxMembersEdit.setError("请输入有效数字");
return;
}
}
// 构建请求
Map<String, Object> request = new HashMap<>();
request.put("roomTitle", title);
request.put("roomDescription", desc);
request.put("roomType", roomType);
request.put("entryFee", entryFee);
request.put("roomImage", chatRoomCoverUrl != null ? chatRoomCoverUrl : "");
request.put("maxMembers", maxMembers);
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(false);
ApiClient.getService(getApplicationContext()).createChatRoom(request)
.enqueue(new Callback<ApiResponse<ChatRoomResponse>>() {
@Override
public void onResponse(Call<ApiResponse<ChatRoomResponse>> call,
Response<ApiResponse<ChatRoomResponse>> response) {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(true);
if (response.isSuccessful() && response.body() != null && response.body().isOk()) {
ChatRoomResponse chatRoom = response.body().getData();
dialog.dismiss();
Toast.makeText(MainActivity.this, "聊天室创建成功", Toast.LENGTH_SHORT).show();
// 刷新列表
fetchDiscoverRooms();
// 直接进入聊天室
if (chatRoom != null && chatRoom.getId() != null) {
ChatRoomDetailActivity.start(MainActivity.this, chatRoom.getId());
}
} else {
String errorMsg = "创建失败";
if (response.body() != null && response.body().getMessage() != null) {
errorMsg = response.body().getMessage();
}
Toast.makeText(MainActivity.this, errorMsg, Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<ApiResponse<ChatRoomResponse>> call, Throwable t) {
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(true);
Toast.makeText(MainActivity.this, "网络错误: " + t.getMessage(), Toast.LENGTH_SHORT).show();
}
});
});
});
dialog.show();
}
/**
* 初始化聊天室封面选择器
*/
private void setupChatRoomCoverPicker() {
chatRoomCoverPickerLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
if (result.getResultCode() == RESULT_OK && result.getData() != null) {
Uri selectedImageUri = result.getData().getData();
if (selectedImageUri != null) {
chatRoomCoverUri = selectedImageUri;
// 显示选中的图片
if (chatRoomCoverImageView != null) {
chatRoomCoverImageView.setVisibility(View.VISIBLE);
Glide.with(this)
.load(selectedImageUri)
.centerCrop()
.into(chatRoomCoverImageView);
}
if (chatRoomCoverPlaceholder != null) {
chatRoomCoverPlaceholder.setVisibility(View.GONE);
}
// 上传图片
uploadChatRoomCover(selectedImageUri);
}
}
});
}
/**
* 上传聊天室封面图片
*/
private void uploadChatRoomCover(Uri imageUri) {
try {
// 从Uri获取文件
File imageFile = getFileFromUri(imageUri);
if (imageFile == null) {
Toast.makeText(this, "无法读取图片文件", Toast.LENGTH_SHORT).show();
return;
}
// 创建RequestBody
RequestBody requestFile = RequestBody.create(MediaType.parse("image/*"), imageFile);
MultipartBody.Part filePart = MultipartBody.Part.createFormData("file", imageFile.getName(), requestFile);
RequestBody modelPart = RequestBody.create(MediaType.parse("text/plain"), "chatroom");
RequestBody pidPart = RequestBody.create(MediaType.parse("text/plain"), "0");
// 调用上传接口
ApiClient.getService(getApplicationContext()).uploadImage(filePart, modelPart, pidPart)
.enqueue(new Callback<ApiResponse<FileUploadResponse>>() {
@Override
public void onResponse(Call<ApiResponse<FileUploadResponse>> call,
Response<ApiResponse<FileUploadResponse>> response) {
if (response.isSuccessful() && response.body() != null && response.body().isOk()) {
FileUploadResponse uploadResponse = response.body().getData();
if (uploadResponse != null && uploadResponse.getUrl() != null) {
chatRoomCoverUrl = uploadResponse.getUrl();
Log.d(TAG, "聊天室封面上传成功: " + chatRoomCoverUrl);
Toast.makeText(MainActivity.this, "封面上传成功", Toast.LENGTH_SHORT).show();
}
} else {
Log.e(TAG, "聊天室封面上传失败");
Toast.makeText(MainActivity.this, "封面上传失败,请重试", Toast.LENGTH_SHORT).show();
}
}
@Override
public void onFailure(Call<ApiResponse<FileUploadResponse>> call, Throwable t) {
Log.e(TAG, "聊天室封面上传网络错误: " + t.getMessage());
Toast.makeText(MainActivity.this, "网络错误,封面上传失败", Toast.LENGTH_SHORT).show();
}
});
} catch (Exception e) {
Log.e(TAG, "上传封面异常: " + e.getMessage());
Toast.makeText(this, "上传封面失败: " + e.getMessage(), Toast.LENGTH_SHORT).show();
}
}
/**
* 从Uri获取File对象
*/
private File getFileFromUri(Uri uri) {
try {
InputStream inputStream = getContentResolver().openInputStream(uri);
if (inputStream == null) return null;
// 创建临时文件
File tempFile = new File(getCacheDir(), "chatroom_cover_" + System.currentTimeMillis() + ".jpg");
FileOutputStream outputStream = new FileOutputStream(tempFile);
byte[] buffer = new byte[4096];
int bytesRead;
while ((bytesRead = inputStream.read(buffer)) != -1) {
outputStream.write(buffer, 0, bytesRead);
}
outputStream.close();
inputStream.close();
return tempFile;
} catch (IOException e) {
Log.e(TAG, "从Uri获取文件失败: " + e.getMessage());
return null;
}
}
private void showOptimizedStreamInfo(Room room) {
String streamKey = room != null ? room.getStreamKey() : null;
String rtmp = room != null && room.getStreamUrls() != null ? room.getStreamUrls().getRtmp() : null;

View File

@ -1059,4 +1059,20 @@ public interface ApiService {
Call<ApiResponse<PageResponse<Map<String, Object>>>> getMyChatRoomEntryRecords(
@Query("page") int page,
@Query("limit") int limit);
/**
* 语音上传
*/
@Multipart
@POST("api/front/upload/chat/voice")
Call<ApiResponse<FileUploadResponse>> uploadVoice(
@Part MultipartBody.Part file,
@Part("model") RequestBody model,
@Part("pid") RequestBody pid);
/**
* 创建聊天室
*/
@POST("api/front/chatroom/create")
Call<ApiResponse<ChatRoomResponse>> createChatRoom(@Body Map<String, Object> request);
}

View File

@ -0,0 +1,75 @@
package com.example.livestreaming.util;
import android.content.Context;
import android.text.TextUtils;
import com.example.livestreaming.net.ApiClient;
/**
* URL工具类处理相对路径和完整URL的转换
*/
public class UrlUtils {
// 文件服务器基础URL
private static final String FILE_SERVER_BASE = "http://1.15.149.240";
/**
* 获取完整的文件URL
* 如果是相对路径则补全为完整URL
*/
public static String getFullUrl(String url) {
if (TextUtils.isEmpty(url)) {
return null;
}
// 已经是完整URL
if (url.startsWith("http://") || url.startsWith("https://")) {
return url;
}
// 相对路径补全为完整URL
if (url.startsWith("/")) {
return FILE_SERVER_BASE + url;
}
// 没有斜杠开头的相对路径
return FILE_SERVER_BASE + "/" + url;
}
/**
* 获取完整的文件URL使用API服务器地址
*/
public static String getFullUrl(Context context, String url) {
if (TextUtils.isEmpty(url)) {
return null;
}
// 已经是完整URL
if (url.startsWith("http://") || url.startsWith("https://")) {
return url;
}
// 使用API服务器地址
String baseUrl = ApiClient.getCurrentBaseUrl(context);
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.substring(0, baseUrl.length() - 1);
}
// 相对路径补全为完整URL
if (url.startsWith("/")) {
return baseUrl + url;
}
return baseUrl + "/" + url;
}
/**
* 检查URL是否有效
*/
public static boolean isValidUrl(String url) {
if (TextUtils.isEmpty(url)) {
return false;
}
return url.startsWith("http://") || url.startsWith("https://");
}
}

View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="8dp" />
<stroke
android:width="1dp"
android:color="#CCCCCC"
android:dashWidth="4dp"
android:dashGap="2dp" />
<solid android:color="#F5F5F5" />
</shape>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#757575"
android:pathData="M19,7v2.99s-1.99,0.01 -2,0L17,7h-3s0.01,-1.99 0,-2h3L17,2h2v3h3v2h-3zM16,11L16,8h-3L13,5L5,5c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2v-8h-3zM5,19l3,-4 2,3 3,-4 4,5L5,19z"/>
</vector>

View File

@ -0,0 +1,168 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<!-- 封面图片选择 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="聊天室封面"
android:textColor="@android:color/darker_gray"
android:textSize="12sp" />
<FrameLayout
android:id="@+id/chatroom_cover_container"
android:layout_width="match_parent"
android:layout_height="120dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="16dp"
android:background="@drawable/bg_dashed_border"
android:clickable="true"
android:focusable="true">
<ImageView
android:id="@+id/chatroom_cover_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerCrop"
android:visibility="gone" />
<LinearLayout
android:id="@+id/chatroom_cover_placeholder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:orientation="vertical">
<ImageView
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@drawable/ic_add_photo"
android:tint="@android:color/darker_gray" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="点击上传封面"
android:textColor="@android:color/darker_gray"
android:textSize="12sp" />
</LinearLayout>
</FrameLayout>
<!-- 聊天室名称 -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="聊天室名称 *"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/chatroom_title_edit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLength="20"
android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout>
<!-- 聊天室描述 -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="聊天室描述(可选)"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/chatroom_desc_edit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLength="100"
android:inputType="textMultiLine"
android:minLines="2"
android:maxLines="3" />
</com.google.android.material.textfield.TextInputLayout>
<!-- 房间类型 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="房间类型"
android:textColor="@android:color/darker_gray"
android:textSize="12sp" />
<RadioGroup
android:id="@+id/chatroom_type_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginTop="8dp">
<RadioButton
android:id="@+id/radio_free"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="免费"
android:checked="true" />
<RadioButton
android:id="@+id/radio_paid"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="收费" />
</RadioGroup>
<!-- 入场费用(收费时显示) -->
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/chatroom_fee_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="入场费用(金币)"
android:visibility="gone"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/chatroom_fee_edit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number" />
</com.google.android.material.textfield.TextInputLayout>
<!-- 最大人数 -->
<com.google.android.material.textfield.TextInputLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:hint="最大人数2-24默认24"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/chatroom_max_members_edit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:text="24" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>
</ScrollView>