From f321f198f62c78e492635ef6a47439c41b25d3e6 Mon Sep 17 00:00:00 2001 From: xiao12feng8 <16507319+xiao12feng8@user.noreply.gitee.com> Date: Tue, 23 Dec 2025 18:31:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9F=BA=E6=9C=AC=E9=97=AE?= =?UTF-8?q?=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../front/controller/LiveRoomController.java | 87 ++- .../example/livestreaming/MainActivity.java | 59 +- .../livestreaming/RoomDetailActivity.java | 555 ++++++++++++------ .../example/livestreaming/net/ApiService.java | 18 + .../res/layout/activity_room_detail_new.xml | 2 +- 5 files changed, 523 insertions(+), 198 deletions(-) diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/LiveRoomController.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/LiveRoomController.java index d909f337..24ddf995 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/LiveRoomController.java +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/LiveRoomController.java @@ -1,12 +1,15 @@ package com.zbkj.front.controller; +import com.zbkj.common.model.live.LiveChat; import com.zbkj.common.model.live.LiveRoom; import com.zbkj.common.result.CommonResult; import com.zbkj.common.token.FrontTokenComponent; import com.zbkj.front.request.live.CreateLiveRoomRequest; import com.zbkj.front.response.live.LiveRoomResponse; import com.zbkj.front.response.live.StreamUrlsResponse; +import com.zbkj.front.response.live.ChatMessageResponse; import com.zbkj.service.service.LiveRoomService; +import com.zbkj.service.service.LiveChatService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import org.springframework.beans.factory.annotation.Autowired; @@ -28,6 +31,9 @@ public class LiveRoomController { @Autowired private LiveRoomService liveRoomService; + @Autowired + private LiveChatService liveChatService; + @Autowired private FrontTokenComponent frontTokenComponent; @@ -51,15 +57,12 @@ public class LiveRoomController { .collect(Collectors.toList())); } - @ApiOperation(value = "公开:直播间详情(只返回直播中的房间)") + @ApiOperation(value = "公开:直播间详情") @GetMapping("/public/rooms/{id}") public CommonResult publicRoom(@PathVariable Integer id, HttpServletRequest request) { LiveRoom room = liveRoomService.getById(id); if (room == null) return CommonResult.failed("房间不存在"); - // 只返回直播中的房间 - if (room.getIsLive() == null || room.getIsLive() != 1) { - return CommonResult.failed("直播已结束"); - } + // 返回房间信息,isLive 字段标识是否正在直播 return CommonResult.success(toResponse(room, resolveHost(request))); } @@ -84,6 +87,80 @@ public class LiveRoomController { return CommonResult.success(); } + // ========== 弹幕消息接口 ========== + + @ApiOperation(value = "公开:获取直播间弹幕消息") + @GetMapping("/public/rooms/{roomId}/messages") + public CommonResult> getMessages( + @PathVariable Integer roomId, + @RequestParam(defaultValue = "50") Integer limit) { + if (roomId == null) return CommonResult.failed("参数错误"); + List messages = liveChatService.getRoomMessages(roomId, limit); + List result = messages.stream() + .map(ChatMessageResponse::from) + .collect(Collectors.toList()); + return CommonResult.success(result); + } + + @ApiOperation(value = "公开:发送弹幕消息") + @PostMapping("/public/rooms/{roomId}/messages") + public CommonResult sendMessage( + @PathVariable Integer roomId, + @RequestBody Map body) { + if (roomId == null) return CommonResult.failed("参数错误"); + String content = body.get("message"); + String visitorId = body.get("visitorId"); + String nickname = body.get("nickname"); + if (content == null || content.trim().isEmpty()) { + return CommonResult.failed("消息内容不能为空"); + } + if (nickname == null || nickname.trim().isEmpty()) { + nickname = "游客"; + } + liveChatService.saveMessage(roomId, visitorId, nickname, content.trim()); + + // 返回保存的消息 + ChatMessageResponse resp = new ChatMessageResponse(); + resp.setVisitorId(visitorId); + resp.setNickname(nickname); + resp.setContent(content.trim()); + resp.setTimestamp(System.currentTimeMillis()); + return CommonResult.success(resp); + } + + @ApiOperation(value = "公开:获取观看人数") + @GetMapping("/public/rooms/{roomId}/viewers/count") + public CommonResult> getViewerCount(@PathVariable Integer roomId) { + // TODO: 实现真实的观看人数统计(可通过 SRS 回调或 Redis 计数) + // 目前返回模拟数据 + int viewerCount = 100 + (int)(Math.random() * 500); + return CommonResult.success(Collections.singletonMap("viewerCount", viewerCount)); + } + + // ========== 关注主播接口 ========== + + @ApiOperation(value = "关注/取消关注主播") + @PostMapping("/follow") + public CommonResult> followStreamer(@RequestBody Map body) { + Integer streamerId = body.get("streamerId") != null ? + Integer.valueOf(body.get("streamerId").toString()) : null; + String action = (String) body.get("action"); + + if (streamerId == null) { + return CommonResult.failed("主播ID不能为空"); + } + if (action == null || (!action.equals("follow") && !action.equals("unfollow"))) { + return CommonResult.failed("操作类型错误"); + } + + // TODO: 实现真实的关注逻辑(需要用户登录和关注表) + // 目前返回成功响应 + Map result = new java.util.HashMap<>(); + result.put("success", true); + result.put("message", action.equals("follow") ? "关注成功" : "取消关注成功"); + return CommonResult.success(result); + } + private LiveRoomResponse toResponse(LiveRoom room, String requestHost) { LiveRoomResponse resp = new LiveRoomResponse(); resp.setId(room.getId() == null ? null : String.valueOf(room.getId())); 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 dd525d04..e43d7d37 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 @@ -1400,18 +1400,69 @@ public class MainActivity extends AppCompatActivity { * 初始化顶部标签页数据 */ private void initializeTopTabData() { - // 初始化关注页面数据(已关注主播的直播) + // 初始化关注页面数据(已关注主播的直播)- 使用演示数据 followRooms.clear(); followRooms.addAll(buildFollowRooms()); - // 初始化发现页面数据(推荐算法) - discoverRooms.clear(); - discoverRooms.addAll(buildDiscoverRooms()); + // 初始化发现页面数据 - 从后端获取真实直播间 + fetchDiscoverRooms(); // 初始化附近页面数据(模拟位置数据) nearbyUsers.clear(); nearbyUsers.addAll(buildNearbyUsers()); } + + /** + * 从后端获取发现页面的直播间列表 + */ + private void fetchDiscoverRooms() { + // 显示加载状态 + if (adapter != null && adapter.getItemCount() == 0) { + LoadingStateManager.showSkeleton(binding.roomsRecyclerView, 6); + } + + ApiClient.getService(getApplicationContext()).getRooms().enqueue(new Callback>>() { + @Override + public void onResponse(Call>> call, Response>> response) { + ApiResponse> body = response.body(); + List rooms = response.isSuccessful() && body != null && body.isOk() && body.getData() != null + ? body.getData() + : Collections.emptyList(); + + discoverRooms.clear(); + if (rooms != null && !rooms.isEmpty()) { + discoverRooms.addAll(rooms); + hideEmptyState(); + } else { + // 没有直播间时显示空状态 + showNoRoomsState(); + } + + // 如果当前在发现页,刷新显示 + if ("发现".equals(currentTopTab)) { + allRooms.clear(); + allRooms.addAll(discoverRooms); + binding.roomsRecyclerView.setAdapter(adapter); + applyCategoryFilterWithAnimation(currentCategory); + } + } + + @Override + public void onFailure(Call>> call, Throwable t) { + // 网络错误,显示错误状态(不使用演示数据,避免ID类型不匹配) + discoverRooms.clear(); + + if ("发现".equals(currentTopTab)) { + allRooms.clear(); + binding.roomsRecyclerView.setAdapter(adapter); + applyCategoryFilterWithAnimation(currentCategory); + showNetworkErrorState(); + } + + ErrorHandler.handleApiError(binding.getRoot(), t, () -> fetchDiscoverRooms()); + } + }); + } /** * 切换顶部标签页内容 diff --git a/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java b/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java index 6187189f..1b9ce9c0 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java @@ -6,6 +6,7 @@ import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.text.TextUtils; +import android.util.Log; import android.view.Surface; import android.view.TextureView; import android.view.KeyEvent; @@ -26,10 +27,15 @@ import androidx.recyclerview.widget.LinearLayoutManager; import com.example.livestreaming.databinding.ActivityRoomDetailNewBinding; import com.example.livestreaming.net.ApiClient; import com.example.livestreaming.net.ApiResponse; +import com.example.livestreaming.net.ChatMessageResponse; import com.example.livestreaming.net.Room; import com.example.livestreaming.net.StreamConfig; import com.example.livestreaming.ShareUtils; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + import tv.danmaku.ijk.media.player.IMediaPlayer; import tv.danmaku.ijk.media.player.IjkMediaPlayer; @@ -43,14 +49,17 @@ import retrofit2.Response; public class RoomDetailActivity extends AppCompatActivity { + private static final String TAG = "RoomDetailActivity"; public static final String EXTRA_ROOM_ID = "extra_room_id"; private ActivityRoomDetailNewBinding binding; private final Handler handler = new Handler(Looper.getMainLooper()); private Runnable pollRunnable; private Runnable chatSimulationRunnable; + private Runnable chatPollRunnable; private String roomId; + private String visitorId; // 游客ID private Room room; private ExoPlayer player; @@ -84,25 +93,33 @@ public class RoomDetailActivity extends AppCompatActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); + Log.d(TAG, "onCreate called"); - // 隐藏ActionBar,使用自定义顶部栏 - if (getSupportActionBar() != null) { - getSupportActionBar().hide(); + try { + // 隐藏ActionBar,使用自定义顶部栏 + if (getSupportActionBar() != null) { + getSupportActionBar().hide(); + } + + binding = ActivityRoomDetailNewBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + ApiClient.getService(getApplicationContext()); + + roomId = getIntent().getStringExtra(EXTRA_ROOM_ID); + visitorId = UUID.randomUUID().toString().substring(0, 8); // 生成游客ID + triedAltUrl = false; + + setupUI(); + setupChat(); + + // 添加欢迎消息 + addChatMessage(new ChatMessage("欢迎来到直播间!", true)); + } catch (Exception e) { + Log.e(TAG, "onCreate exception, calling finish(): " + e.getMessage(), e); + Toast.makeText(this, "初始化失败: " + e.getMessage(), Toast.LENGTH_LONG).show(); + finish(); } - - binding = ActivityRoomDetailNewBinding.inflate(getLayoutInflater()); - setContentView(binding.getRoot()); - - ApiClient.getService(getApplicationContext()); - - roomId = getIntent().getStringExtra(EXTRA_ROOM_ID); - triedAltUrl = false; - - setupUI(); - setupChat(); - - // 添加欢迎消息 - addChatMessage(new ChatMessage("欢迎来到直播间!", true)); } private void setupUI() { @@ -113,6 +130,7 @@ public class RoomDetailActivity extends AppCompatActivity { if (isFullscreen) { toggleFullscreen(); } else { + Log.d(TAG, "exitFullscreenButton clicked in non-fullscreen mode, calling finish()"); finish(); } }); @@ -131,18 +149,7 @@ public class RoomDetailActivity extends AppCompatActivity { binding.fullscreenButton.setOnClickListener(v -> toggleFullscreen()); // 关注按钮 - // TODO: 接入后端接口 - 关注/取消关注主播 - // 接口路径: POST /api/follow 或 DELETE /api/follow - // 请求参数: - // - streamerId: 主播用户ID - // - action: "follow" 或 "unfollow" - // 返回数据格式: ApiResponse<{success: boolean, message: string}> - // 关注成功后,更新按钮状态为"已关注",并禁用按钮 - binding.followButton.setOnClickListener(v -> { - Toast.makeText(this, "已关注主播", Toast.LENGTH_SHORT).show(); - binding.followButton.setText("已关注"); - binding.followButton.setEnabled(false); - }); + binding.followButton.setOnClickListener(v -> followStreamer()); // 分享按钮 binding.shareButton.setOnClickListener(v -> shareRoom()); @@ -171,52 +178,105 @@ public class RoomDetailActivity extends AppCompatActivity { } private void sendMessage() { - // TODO: 接入后端接口 - 发送直播间弹幕消息 - // 接口路径: POST /api/rooms/{roomId}/messages - // 请求参数: - // - roomId: 房间ID(路径参数) - // - message: 消息内容 - // - userId: 发送者用户ID(从token中获取) - // 返回数据格式: ApiResponse - // ChatMessage对象应包含: messageId, userId, username, avatarUrl, message, timestamp等字段 - // 发送成功后,将消息添加到本地列表并显示 String message = binding.chatInput.getText() != null ? binding.chatInput.getText().toString().trim() : ""; - if (!TextUtils.isEmpty(message)) { - addChatMessage(new ChatMessage("我", message)); - binding.chatInput.setText(""); - - // TODO: 接入后端接口 - 接收直播间弹幕消息(WebSocket或轮询) - // 方案1: WebSocket实时推送 - // - 连接: ws://api.example.com/rooms/{roomId}/chat - // - 接收消息格式: {type: "message", data: ChatMessage} - // 方案2: 轮询获取新消息 - // - 接口路径: GET /api/rooms/{roomId}/messages?lastMessageId={lastId} - // - 返回数据格式: ApiResponse> - // - 每3-5秒轮询一次,获取lastMessageId之后的新消息 - // 模拟其他用户回复 - handler.postDelayed(() -> { - if (random.nextFloat() < 0.3f) { // 30%概率有人回复 - String user = simulatedUsers[random.nextInt(simulatedUsers.length)]; - String reply = simulatedMessages[random.nextInt(simulatedMessages.length)]; - addChatMessage(new ChatMessage(user, reply)); - } - }, 1000 + random.nextInt(3000)); + if (TextUtils.isEmpty(message) || TextUtils.isEmpty(roomId)) return; + + // 先清空输入框,显示本地消息 + binding.chatInput.setText(""); + addChatMessage(new ChatMessage("我", message)); + + // 发送到后端 + Map body = new HashMap<>(); + body.put("message", message); + body.put("visitorId", visitorId); + body.put("nickname", "游客" + visitorId); + + ApiClient.getService(getApplicationContext()) + .sendMessage(roomId, body) + .enqueue(new Callback>() { + @Override + public void onResponse(Call> call, + Response> response) { + // 发送成功,无需额外处理(已在本地显示) + if (!response.isSuccessful()) { + Log.w(TAG, "sendMessage failed: " + response.code()); + } + } + + @Override + public void onFailure(Call> call, Throwable t) { + Log.e(TAG, "sendMessage error: " + t.getMessage()); + } + }); + } + + private boolean isFollowing = false; + + private void followStreamer() { + if (TextUtils.isEmpty(roomId)) return; + + String action = isFollowing ? "unfollow" : "follow"; + + Map body = new HashMap<>(); + body.put("streamerId", roomId); + body.put("action", action); + + ApiClient.getService(getApplicationContext()) + .followStreamer(body) + .enqueue(new Callback>>() { + @Override + public void onResponse(Call>> call, + Response>> response) { + if (isFinishing() || isDestroyed() || binding == null) return; + + if (response.isSuccessful() && response.body() != null && response.body().isOk()) { + isFollowing = !isFollowing; + updateFollowButton(); + String msg = isFollowing ? "关注成功" : "已取消关注"; + Toast.makeText(RoomDetailActivity.this, msg, Toast.LENGTH_SHORT).show(); + } else { + Toast.makeText(RoomDetailActivity.this, "操作失败", Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure(Call>> call, Throwable t) { + if (isFinishing() || isDestroyed()) return; + Toast.makeText(RoomDetailActivity.this, "网络错误", Toast.LENGTH_SHORT).show(); + } + }); + } + + private void updateFollowButton() { + if (binding == null) return; + if (isFollowing) { + binding.followButton.setText("已关注"); + binding.followButton.setIconResource(0); + } else { + binding.followButton.setText("关注"); + binding.followButton.setIconResource(R.drawable.ic_heart_24); } } private void addChatMessage(ChatMessage message) { - // 限制消息数量,防止内存溢出 - if (chatMessages.size() > 100) { - chatMessages.remove(0); - } - chatMessages.add(message); - chatAdapter.submitList(new ArrayList<>(chatMessages)); - - // 滚动到最新消息 - if (binding != null && binding.chatRecyclerView != null && chatMessages.size() > 0) { - binding.chatRecyclerView.scrollToPosition(chatMessages.size() - 1); + try { + // 限制消息数量,防止内存溢出 + if (chatMessages.size() > 100) { + chatMessages.remove(0); + } + chatMessages.add(message); + if (chatAdapter != null) { + chatAdapter.submitList(new ArrayList<>(chatMessages)); + } + + // 滚动到最新消息 + if (binding != null && binding.chatRecyclerView != null && chatMessages.size() > 0) { + binding.chatRecyclerView.scrollToPosition(chatMessages.size() - 1); + } + } catch (Exception e) { + e.printStackTrace(); } } @@ -246,16 +306,17 @@ public class RoomDetailActivity extends AppCompatActivity { @Override protected void onStart() { super.onStart(); + Log.d(TAG, "onStart called"); startPolling(); - startChatSimulation(); + startChatPolling(); } @Override protected void onStop() { super.onStop(); + Log.d(TAG, "onStop called"); stopPolling(); - stopChatSimulation(); - releasePlayer(); + stopChatPolling(); } @Override @@ -278,18 +339,26 @@ public class RoomDetailActivity extends AppCompatActivity { } private void startPolling() { - stopPolling(); - // 首次立即获取房间信息 - fetchRoom(); - - pollRunnable = () -> { - if (!isFinishing() && !isDestroyed()) { - fetchRoom(); - handler.postDelayed(pollRunnable, 15000); // 15秒轮询一次,减少压力 - } - }; - // 延迟15秒后开始轮询 - handler.postDelayed(pollRunnable, 15000); + try { + stopPolling(); + // 首次立即获取房间信息 + fetchRoom(); + + // 暂时禁用轮询,避免重复请求导致的问题 + // pollRunnable = () -> { + // try { + // if (!isFinishing() && !isDestroyed()) { + // fetchRoom(); + // handler.postDelayed(pollRunnable, 15000); + // } + // } catch (Exception e) { + // e.printStackTrace(); + // } + // }; + // handler.postDelayed(pollRunnable, 15000); + } catch (Exception e) { + e.printStackTrace(); + } } private void stopPolling() { @@ -335,57 +404,140 @@ public class RoomDetailActivity extends AppCompatActivity { } } + // 弹幕轮询功能 + private long lastMessageTimestamp = 0; + + private void startChatPolling() { + stopChatPolling(); + if (TextUtils.isEmpty(roomId)) return; + + // 首次获取历史弹幕 + fetchMessages(); + + chatPollRunnable = () -> { + if (!isFinishing() && !isDestroyed()) { + fetchMessages(); + handler.postDelayed(chatPollRunnable, 5000); // 每5秒轮询一次 + } + }; + handler.postDelayed(chatPollRunnable, 5000); + } + + private void stopChatPolling() { + if (chatPollRunnable != null) { + handler.removeCallbacks(chatPollRunnable); + chatPollRunnable = null; + } + } + + private void fetchMessages() { + if (TextUtils.isEmpty(roomId)) return; + + ApiClient.getService(getApplicationContext()) + .getMessages(roomId, 50) + .enqueue(new Callback>>() { + @Override + public void onResponse(Call>> call, + Response>> response) { + if (isFinishing() || isDestroyed()) return; + if (!response.isSuccessful() || response.body() == null) return; + + List messages = response.body().getData(); + if (messages == null || messages.isEmpty()) return; + + // 只显示新消息(时间戳大于上次的) + for (ChatMessageResponse msg : messages) { + if (msg.getTimestamp() > lastMessageTimestamp) { + // 不显示自己发的消息(已经本地显示过了) + if (!visitorId.equals(msg.getVisitorId())) { + String nickname = msg.getNickname() != null ? msg.getNickname() : "游客"; + addChatMessage(new ChatMessage(nickname, msg.getContent())); + } + lastMessageTimestamp = msg.getTimestamp(); + } + } + } + + @Override + public void onFailure(Call>> call, Throwable t) { + Log.e(TAG, "fetchMessages error: " + t.getMessage()); + } + }); + } + private boolean isFirstLoad = true; private void fetchRoom() { - // TODO: 接入后端接口 - 获取房间详情 - // 接口路径: GET /api/rooms/{roomId} - // 请求参数: roomId (路径参数) - // 返回数据格式: ApiResponse - // Room对象应包含: id, title, streamerName, streamerId, type, isLive, coverUrl, viewerCount, - // streamUrls (包含flv, hls, rtmp地址), description, startTime等字段 - if (TextUtils.isEmpty(roomId)) { - Toast.makeText(this, "房间不存在", Toast.LENGTH_SHORT).show(); - finish(); - return; - } + try { + // TODO: 接入后端接口 - 获取房间详情 + if (TextUtils.isEmpty(roomId)) { + Log.e(TAG, "fetchRoom: roomId is empty, calling finish()"); + Toast.makeText(this, "房间不存在", Toast.LENGTH_SHORT).show(); + finish(); + return; + } - // 只在首次加载时显示loading - if (isFirstLoad) { - binding.loading.setVisibility(View.VISIBLE); - } + // 只在首次加载时显示loading + if (isFirstLoad && binding != null) { + binding.loading.setVisibility(View.VISIBLE); + } - ApiClient.getService(getApplicationContext()).getRoom(roomId).enqueue(new Callback>() { - @Override - public void onResponse(Call> call, Response> response) { - if (isFinishing() || isDestroyed()) return; + ApiClient.getService(getApplicationContext()).getRoom(roomId).enqueue(new Callback>() { + @Override + public void onResponse(Call> call, Response> response) { + try { + if (isFinishing() || isDestroyed()) return; - binding.loading.setVisibility(View.GONE); - boolean firstLoad = isFirstLoad; - isFirstLoad = false; + if (binding != null) { + binding.loading.setVisibility(View.GONE); + } + boolean firstLoad = isFirstLoad; + isFirstLoad = false; - ApiResponse body = response.body(); - Room data = body != null ? body.getData() : null; - if (!response.isSuccessful() || body == null || !body.isOk() || data == null) { - if (firstLoad) { - String msg = body != null && !TextUtils.isEmpty(body.getMessage()) ? body.getMessage() : "房间不存在"; - Toast.makeText(RoomDetailActivity.this, msg, Toast.LENGTH_SHORT).show(); - finish(); + ApiResponse body = response.body(); + Room data = body != null ? body.getData() : null; + if (!response.isSuccessful() || body == null || !body.isOk() || data == null) { + if (firstLoad) { + String msg = body != null && !TextUtils.isEmpty(body.getMessage()) ? body.getMessage() : "暂无直播"; + // 显示离线状态而不是退出 + if (binding != null) { + binding.offlineLayout.setVisibility(View.VISIBLE); + binding.topTitle.setText("直播间"); + } + Toast.makeText(RoomDetailActivity.this, msg, Toast.LENGTH_SHORT).show(); + } + return; + } + + room = data; + bindRoom(room); + } catch (Exception e) { + e.printStackTrace(); } - return; } - room = data; - bindRoom(room); - } - - @Override - public void onFailure(Call> call, Throwable t) { - if (isFinishing() || isDestroyed()) return; - binding.loading.setVisibility(View.GONE); - isFirstLoad = false; - } - }); + @Override + public void onFailure(Call> call, Throwable t) { + try { + if (isFinishing() || isDestroyed()) return; + if (binding != null) { + binding.loading.setVisibility(View.GONE); + // 网络错误时显示离线状态 + if (isFirstLoad) { + binding.offlineLayout.setVisibility(View.VISIBLE); + binding.topTitle.setText("直播间"); + Toast.makeText(RoomDetailActivity.this, "网络连接失败", Toast.LENGTH_SHORT).show(); + } + } + isFirstLoad = false; + } catch (Exception e) { + e.printStackTrace(); + } + } + }); + } catch (Exception e) { + e.printStackTrace(); + } } private void shareRoom() { @@ -401,63 +553,68 @@ public class RoomDetailActivity extends AppCompatActivity { } private void bindRoom(Room r) { - String title = r.getTitle() != null ? r.getTitle() : "直播间"; - String streamer = r.getStreamerName() != null ? r.getStreamerName() : "主播"; - - // 设置顶部标题栏 - binding.topTitle.setText(title); - - // 设置房间信息区域 - binding.roomTitle.setText(title); - binding.streamerName.setText(streamer); - - // 设置直播状态 - if (r.isLive()) { - binding.liveTag.setVisibility(View.VISIBLE); - binding.offlineLayout.setVisibility(View.GONE); + try { + if (r == null || binding == null) return; - // TODO: 接入后端接口 - 获取实时观看人数 - // 接口路径: GET /api/rooms/{roomId}/viewers/count - // 请求参数: roomId (路径参数) - // 返回数据格式: ApiResponse<{viewerCount: number}> - // 建议使用WebSocket实时推送观看人数变化,或每10-15秒轮询一次 - // 设置观看人数(模拟) - int viewerCount = r.getViewerCount() > 0 ? r.getViewerCount() : - 100 + random.nextInt(500); - binding.topViewerCount.setText(String.valueOf(viewerCount)); - } else { - binding.liveTag.setVisibility(View.GONE); - binding.offlineLayout.setVisibility(View.VISIBLE); - releasePlayer(); - return; - } + String title = r.getTitle() != null ? r.getTitle() : "直播间"; + String streamer = r.getStreamerName() != null ? r.getStreamerName() : "主播"; + + // 设置顶部标题栏 + binding.topTitle.setText(title); + + // 设置房间信息区域 + binding.roomTitle.setText(title); + binding.streamerName.setText(streamer); - // 获取播放地址 - String playUrl = null; - String fallbackHlsUrl = null; - if (r.getStreamUrls() != null) { - // 优先使用HTTP-FLV,延迟更低 - playUrl = r.getStreamUrls().getFlv(); - fallbackHlsUrl = r.getStreamUrls().getHls(); - if (TextUtils.isEmpty(playUrl)) playUrl = fallbackHlsUrl; - } + // 设置直播状态 + if (r.isLive()) { + binding.liveTag.setVisibility(View.VISIBLE); + binding.offlineLayout.setVisibility(View.GONE); + + // 设置观看人数 + int viewerCount = r.getViewerCount() > 0 ? r.getViewerCount() : + 100 + random.nextInt(500); + binding.topViewerCount.setText(String.valueOf(viewerCount)); + } else { + binding.liveTag.setVisibility(View.GONE); + binding.offlineLayout.setVisibility(View.VISIBLE); + releasePlayer(); + return; + } - if (!TextUtils.isEmpty(playUrl)) { - ensurePlayer(playUrl, fallbackHlsUrl); - } else { - // 没有播放地址时显示离线状态 - binding.offlineLayout.setVisibility(View.VISIBLE); - releasePlayer(); + // 获取播放地址 + String playUrl = null; + String fallbackHlsUrl = null; + if (r.getStreamUrls() != null) { + // 优先使用HTTP-FLV,延迟更低 + playUrl = r.getStreamUrls().getFlv(); + fallbackHlsUrl = r.getStreamUrls().getHls(); + if (TextUtils.isEmpty(playUrl)) playUrl = fallbackHlsUrl; + } + + if (!TextUtils.isEmpty(playUrl)) { + ensurePlayer(playUrl, fallbackHlsUrl); + } else { + // 没有播放地址时显示离线状态 + binding.offlineLayout.setVisibility(View.VISIBLE); + releasePlayer(); + } + } catch (Exception e) { + e.printStackTrace(); } } private void ensurePlayer(String url, String fallbackHlsUrl) { if (TextUtils.isEmpty(url)) return; + + Log.d(TAG, "ensurePlayer: url=" + url + ", fallbackHlsUrl=" + fallbackHlsUrl); - if (url.endsWith(".flv")) { - if (TextUtils.equals(ijkUrl, url) && ijkPlayer != null) return; - startFlv(url, fallbackHlsUrl); - return; + // 暂时禁用 ijkplayer,使用 ExoPlayer 播放 HLS + // 如果是 FLV 流,使用 fallback HLS 地址 + String playUrl = url; + if (url.endsWith(".flv") && !TextUtils.isEmpty(fallbackHlsUrl)) { + playUrl = fallbackHlsUrl; + Log.d(TAG, "ensurePlayer: FLV detected, using HLS fallback: " + playUrl); } if (player != null) { @@ -466,32 +623,36 @@ public class RoomDetailActivity extends AppCompatActivity { ? current.localConfiguration.uri.toString() : null; - if (currentUri != null && currentUri.equals(url)) return; + if (currentUri != null && currentUri.equals(playUrl)) return; } - startHls(url, null); + startHls(playUrl, null); } private void startHls(String url, @Nullable String altUrl) { + if (binding == null) return; + releaseIjkPlayer(); - if (binding != null) { - binding.flvTextureView.setVisibility(View.GONE); - binding.playerView.setVisibility(View.VISIBLE); - } + binding.flvTextureView.setVisibility(View.GONE); + binding.playerView.setVisibility(View.VISIBLE); releaseExoPlayer(); triedAltUrl = false; + // 低延迟配置:最小缓冲,快速开始播放 ExoPlayer exo = new ExoPlayer.Builder(this) .setLoadControl(new androidx.media3.exoplayer.DefaultLoadControl.Builder() .setBufferDurationsMs( - 1000, - 3000, - 500, - 1000 + 500, // 最小缓冲时间 + 1500, // 最大缓冲时间 + 250, // 播放前缓冲 + 500 // 重新缓冲时间 ) .build()) .build(); + + // 设置为直播模式,跳到最新位置 + exo.setPlayWhenReady(true); binding.playerView.setPlayer(exo); @@ -502,6 +663,7 @@ public class RoomDetailActivity extends AppCompatActivity { exo.addListener(new Player.Listener() { @Override public void onPlayerError(PlaybackException error) { + if (isFinishing() || isDestroyed() || binding == null) return; if (triedAltUrl || TextUtils.isEmpty(finalComputedAltUrl)) { binding.offlineLayout.setVisibility(View.VISIBLE); return; @@ -514,6 +676,7 @@ public class RoomDetailActivity extends AppCompatActivity { @Override public void onPlaybackStateChanged(int playbackState) { + if (isFinishing() || isDestroyed() || binding == null) return; if (playbackState == Player.STATE_READY) { binding.offlineLayout.setVisibility(View.GONE); addChatMessage(new ChatMessage("直播已连接,开始观看吧!", true)); @@ -521,13 +684,25 @@ public class RoomDetailActivity extends AppCompatActivity { } }); - exo.setMediaItem(MediaItem.fromUri(url)); + // 使用直播配置,自动跳到最新位置 + MediaItem mediaItem = new MediaItem.Builder() + .setUri(url) + .setLiveConfiguration(new MediaItem.LiveConfiguration.Builder() + .setMaxPlaybackSpeed(1.02f) // 轻微加速追赶直播 + .build()) + .build(); + exo.setMediaItem(mediaItem); exo.prepare(); + + // 跳到直播最新位置 + exo.seekToDefaultPosition(); exo.setPlayWhenReady(true); player = exo; } private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) { + if (binding == null) return; + ensureIjkLibsLoaded(); releaseExoPlayer(); releaseIjkPlayer(); @@ -536,10 +711,8 @@ public class RoomDetailActivity extends AppCompatActivity { ijkFallbackHlsUrl = fallbackHlsUrl; ijkFallbackTried = false; - if (binding != null) { - binding.playerView.setVisibility(View.GONE); - binding.flvTextureView.setVisibility(View.VISIBLE); - } + binding.playerView.setVisibility(View.GONE); + binding.flvTextureView.setVisibility(View.VISIBLE); TextureView view = binding.flvTextureView; TextureView.SurfaceTextureListener listener = new TextureView.SurfaceTextureListener() { @@ -555,6 +728,7 @@ public class RoomDetailActivity extends AppCompatActivity { @Override public boolean onSurfaceTextureDestroyed(@NonNull android.graphics.SurfaceTexture surface) { + Log.w(TAG, "onSurfaceTextureDestroyed called"); releaseIjkPlayer(); return true; } @@ -585,12 +759,16 @@ public class RoomDetailActivity extends AppCompatActivity { p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "infbuf", 1); p.setOnPreparedListener(mp -> { + Log.d(TAG, "IjkPlayer onPrepared"); + if (isFinishing() || isDestroyed() || binding == null) return; binding.offlineLayout.setVisibility(View.GONE); mp.start(); addChatMessage(new ChatMessage("直播已连接,开始观看吧!", true)); }); p.setOnErrorListener((IMediaPlayer mp, int what, int extra) -> { + Log.e(TAG, "IjkPlayer onError: what=" + what + ", extra=" + extra); + if (isFinishing() || isDestroyed() || binding == null) return true; if (ijkFallbackTried || TextUtils.isEmpty(ijkFallbackHlsUrl)) { binding.offlineLayout.setVisibility(View.VISIBLE); return true; @@ -608,7 +786,7 @@ public class RoomDetailActivity extends AppCompatActivity { } catch (Exception e) { if (!TextUtils.isEmpty(ijkFallbackHlsUrl)) { startHls(ijkFallbackHlsUrl, null); - } else { + } else if (binding != null) { binding.offlineLayout.setVisibility(View.VISIBLE); } } @@ -666,6 +844,7 @@ public class RoomDetailActivity extends AppCompatActivity { @Override protected void onDestroy() { super.onDestroy(); + Log.d(TAG, "onDestroy called"); // 确保Handler回调被清理,防止内存泄漏 if (handler != 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 d447ea44..deda0860 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 @@ -3,11 +3,14 @@ package com.example.livestreaming.net; import java.util.List; import retrofit2.Call; +import java.util.Map; + import retrofit2.http.Body; import retrofit2.http.DELETE; import retrofit2.http.GET; import retrofit2.http.Path; import retrofit2.http.POST; +import retrofit2.http.Query; public interface ApiService { @POST("api/front/login") @@ -24,4 +27,19 @@ public interface ApiService { @DELETE("api/front/live/rooms/{id}") Call> deleteRoom(@Path("id") String id); + + // 弹幕消息 API + @GET("api/front/live/public/rooms/{roomId}/messages") + Call>> getMessages( + @Path("roomId") String roomId, + @Query("limit") int limit); + + @POST("api/front/live/public/rooms/{roomId}/messages") + Call> sendMessage( + @Path("roomId") String roomId, + @Body Map body); + + // 关注主播 API + @POST("api/front/live/follow") + Call>> followStreamer(@Body Map body); } diff --git a/android-app/app/src/main/res/layout/activity_room_detail_new.xml b/android-app/app/src/main/res/layout/activity_room_detail_new.xml index 9e12b5c0..bf848955 100644 --- a/android-app/app/src/main/res/layout/activity_room_detail_new.xml +++ b/android-app/app/src/main/res/layout/activity_room_detail_new.xml @@ -104,7 +104,7 @@ android:id="@+id/playerView" android:layout_width="match_parent" android:layout_height="match_parent" - app:use_controller="true" + app:use_controller="false" app:show_buffering="when_playing" />