diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/ChatRoomFrontController.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/ChatRoomFrontController.java index 7e3582c4..4b8e5c21 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/ChatRoomFrontController.java +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/ChatRoomFrontController.java @@ -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 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; diff --git a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/UploadServiceImpl.java b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/UploadServiceImpl.java index 636dc77e..f7b35db8 100644 --- a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/UploadServiceImpl.java +++ b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/UploadServiceImpl.java @@ -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; } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/ChatRoomDetailActivity.java b/android-app/app/src/main/java/com/example/livestreaming/ChatRoomDetailActivity.java index 05b07991..6f39efcb 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ChatRoomDetailActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ChatRoomDetailActivity.java @@ -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(); - new File(voiceFilePath).delete(); + 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>() { @Override public void onResponse(Call> 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> 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 { diff --git a/android-app/app/src/main/java/com/example/livestreaming/ChatRoomMessageAdapter.java b/android-app/app/src/main/java/com/example/livestreaming/ChatRoomMessageAdapter.java index 4f120274..7b5a6c87 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ChatRoomMessageAdapter.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ChatRoomMessageAdapter.java @@ -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,12 +28,17 @@ import de.hdodenhof.circleimageview.CircleImageView; */ public class ChatRoomMessageAdapter extends RecyclerView.Adapter { + 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; private final List messages; private final SimpleDateFormat timeFormat = new SimpleDateFormat("HH:mm", Locale.getDefault()); + + // 语音播放器 + private MediaPlayer mediaPlayer; + private int currentPlayingPosition = -1; public ChatRoomMessageAdapter(List messages) { this.messages = messages; @@ -101,6 +110,8 @@ public class ChatRoomMessageAdapter extends RecyclerView.Adapter { + 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 { + 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(); + } } + diff --git a/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java b/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java index 9a179115..ef6fc259 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java @@ -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 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 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>() { + @Override + public void onResponse(Call> call, + Response> 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> 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>() { + @Override + public void onResponse(Call> call, + Response> 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> 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; diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java b/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java index 462769ea..ed8f2a54 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java +++ b/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java @@ -1059,4 +1059,20 @@ public interface ApiService { Call>>> getMyChatRoomEntryRecords( @Query("page") int page, @Query("limit") int limit); + + /** + * 语音上传 + */ + @Multipart + @POST("api/front/upload/chat/voice") + Call> uploadVoice( + @Part MultipartBody.Part file, + @Part("model") RequestBody model, + @Part("pid") RequestBody pid); + + /** + * 创建聊天室 + */ + @POST("api/front/chatroom/create") + Call> createChatRoom(@Body Map request); } diff --git a/android-app/app/src/main/java/com/example/livestreaming/util/UrlUtils.java b/android-app/app/src/main/java/com/example/livestreaming/util/UrlUtils.java new file mode 100644 index 00000000..ea482e4f --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/util/UrlUtils.java @@ -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://"); + } +} diff --git a/android-app/app/src/main/res/drawable/bg_dashed_border.xml b/android-app/app/src/main/res/drawable/bg_dashed_border.xml new file mode 100644 index 00000000..37c3b663 --- /dev/null +++ b/android-app/app/src/main/res/drawable/bg_dashed_border.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/android-app/app/src/main/res/drawable/ic_add_photo.xml b/android-app/app/src/main/res/drawable/ic_add_photo.xml new file mode 100644 index 00000000..ac911531 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_add_photo.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/layout/dialog_create_chatroom.xml b/android-app/app/src/main/res/layout/dialog_create_chatroom.xml new file mode 100644 index 00000000..846ce850 --- /dev/null +++ b/android-app/app/src/main/res/layout/dialog_create_chatroom.xml @@ -0,0 +1,168 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +