修复基本问题

This commit is contained in:
xiao12feng8 2025-12-23 18:31:57 +08:00
parent 8c4c8ef4b1
commit f321f198f6
5 changed files with 523 additions and 198 deletions

View File

@ -1,12 +1,15 @@
package com.zbkj.front.controller; package com.zbkj.front.controller;
import com.zbkj.common.model.live.LiveChat;
import com.zbkj.common.model.live.LiveRoom; import com.zbkj.common.model.live.LiveRoom;
import com.zbkj.common.result.CommonResult; import com.zbkj.common.result.CommonResult;
import com.zbkj.common.token.FrontTokenComponent; import com.zbkj.common.token.FrontTokenComponent;
import com.zbkj.front.request.live.CreateLiveRoomRequest; import com.zbkj.front.request.live.CreateLiveRoomRequest;
import com.zbkj.front.response.live.LiveRoomResponse; import com.zbkj.front.response.live.LiveRoomResponse;
import com.zbkj.front.response.live.StreamUrlsResponse; 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.LiveRoomService;
import com.zbkj.service.service.LiveChatService;
import io.swagger.annotations.Api; import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation; import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -28,6 +31,9 @@ public class LiveRoomController {
@Autowired @Autowired
private LiveRoomService liveRoomService; private LiveRoomService liveRoomService;
@Autowired
private LiveChatService liveChatService;
@Autowired @Autowired
private FrontTokenComponent frontTokenComponent; private FrontTokenComponent frontTokenComponent;
@ -51,15 +57,12 @@ public class LiveRoomController {
.collect(Collectors.toList())); .collect(Collectors.toList()));
} }
@ApiOperation(value = "公开:直播间详情(只返回直播中的房间)") @ApiOperation(value = "公开:直播间详情")
@GetMapping("/public/rooms/{id}") @GetMapping("/public/rooms/{id}")
public CommonResult<LiveRoomResponse> publicRoom(@PathVariable Integer id, HttpServletRequest request) { public CommonResult<LiveRoomResponse> publicRoom(@PathVariable Integer id, HttpServletRequest request) {
LiveRoom room = liveRoomService.getById(id); LiveRoom room = liveRoomService.getById(id);
if (room == null) return CommonResult.failed("房间不存在"); if (room == null) return CommonResult.failed("房间不存在");
// 只返回直播中的房间 // 返回房间信息isLive 字段标识是否正在直播
if (room.getIsLive() == null || room.getIsLive() != 1) {
return CommonResult.failed("直播已结束");
}
return CommonResult.success(toResponse(room, resolveHost(request))); return CommonResult.success(toResponse(room, resolveHost(request)));
} }
@ -84,6 +87,80 @@ public class LiveRoomController {
return CommonResult.success(); return CommonResult.success();
} }
// ========== 弹幕消息接口 ==========
@ApiOperation(value = "公开:获取直播间弹幕消息")
@GetMapping("/public/rooms/{roomId}/messages")
public CommonResult<List<ChatMessageResponse>> getMessages(
@PathVariable Integer roomId,
@RequestParam(defaultValue = "50") Integer limit) {
if (roomId == null) return CommonResult.failed("参数错误");
List<LiveChat> messages = liveChatService.getRoomMessages(roomId, limit);
List<ChatMessageResponse> result = messages.stream()
.map(ChatMessageResponse::from)
.collect(Collectors.toList());
return CommonResult.success(result);
}
@ApiOperation(value = "公开:发送弹幕消息")
@PostMapping("/public/rooms/{roomId}/messages")
public CommonResult<ChatMessageResponse> sendMessage(
@PathVariable Integer roomId,
@RequestBody Map<String, String> 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<Map<String, Object>> 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<Map<String, Object>> followStreamer(@RequestBody Map<String, Object> 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<String, Object> 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) { private LiveRoomResponse toResponse(LiveRoom room, String requestHost) {
LiveRoomResponse resp = new LiveRoomResponse(); LiveRoomResponse resp = new LiveRoomResponse();
resp.setId(room.getId() == null ? null : String.valueOf(room.getId())); resp.setId(room.getId() == null ? null : String.valueOf(room.getId()));

View File

@ -1400,19 +1400,70 @@ public class MainActivity extends AppCompatActivity {
* 初始化顶部标签页数据 * 初始化顶部标签页数据
*/ */
private void initializeTopTabData() { private void initializeTopTabData() {
// 初始化关注页面数据已关注主播的直播 // 初始化关注页面数据已关注主播的直播- 使用演示数据
followRooms.clear(); followRooms.clear();
followRooms.addAll(buildFollowRooms()); followRooms.addAll(buildFollowRooms());
// 初始化发现页面数据推荐算法 // 初始化发现页面数据 - 从后端获取真实直播间
discoverRooms.clear(); fetchDiscoverRooms();
discoverRooms.addAll(buildDiscoverRooms());
// 初始化附近页面数据模拟位置数据 // 初始化附近页面数据模拟位置数据
nearbyUsers.clear(); nearbyUsers.clear();
nearbyUsers.addAll(buildNearbyUsers()); nearbyUsers.addAll(buildNearbyUsers());
} }
/**
* 从后端获取发现页面的直播间列表
*/
private void fetchDiscoverRooms() {
// 显示加载状态
if (adapter != null && adapter.getItemCount() == 0) {
LoadingStateManager.showSkeleton(binding.roomsRecyclerView, 6);
}
ApiClient.getService(getApplicationContext()).getRooms().enqueue(new Callback<ApiResponse<List<Room>>>() {
@Override
public void onResponse(Call<ApiResponse<List<Room>>> call, Response<ApiResponse<List<Room>>> response) {
ApiResponse<List<Room>> body = response.body();
List<Room> 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<ApiResponse<List<Room>>> call, Throwable t) {
// 网络错误显示错误状态不使用演示数据避免ID类型不匹配
discoverRooms.clear();
if ("发现".equals(currentTopTab)) {
allRooms.clear();
binding.roomsRecyclerView.setAdapter(adapter);
applyCategoryFilterWithAnimation(currentCategory);
showNetworkErrorState();
}
ErrorHandler.handleApiError(binding.getRoot(), t, () -> fetchDiscoverRooms());
}
});
}
/** /**
* 切换顶部标签页内容 * 切换顶部标签页内容
*/ */

View File

@ -6,6 +6,7 @@ import android.os.Bundle;
import android.os.Handler; import android.os.Handler;
import android.os.Looper; import android.os.Looper;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log;
import android.view.Surface; import android.view.Surface;
import android.view.TextureView; import android.view.TextureView;
import android.view.KeyEvent; import android.view.KeyEvent;
@ -26,10 +27,15 @@ import androidx.recyclerview.widget.LinearLayoutManager;
import com.example.livestreaming.databinding.ActivityRoomDetailNewBinding; import com.example.livestreaming.databinding.ActivityRoomDetailNewBinding;
import com.example.livestreaming.net.ApiClient; import com.example.livestreaming.net.ApiClient;
import com.example.livestreaming.net.ApiResponse; import com.example.livestreaming.net.ApiResponse;
import com.example.livestreaming.net.ChatMessageResponse;
import com.example.livestreaming.net.Room; import com.example.livestreaming.net.Room;
import com.example.livestreaming.net.StreamConfig; import com.example.livestreaming.net.StreamConfig;
import com.example.livestreaming.ShareUtils; 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.IMediaPlayer;
import tv.danmaku.ijk.media.player.IjkMediaPlayer; import tv.danmaku.ijk.media.player.IjkMediaPlayer;
@ -43,14 +49,17 @@ import retrofit2.Response;
public class RoomDetailActivity extends AppCompatActivity { public class RoomDetailActivity extends AppCompatActivity {
private static final String TAG = "RoomDetailActivity";
public static final String EXTRA_ROOM_ID = "extra_room_id"; public static final String EXTRA_ROOM_ID = "extra_room_id";
private ActivityRoomDetailNewBinding binding; private ActivityRoomDetailNewBinding binding;
private final Handler handler = new Handler(Looper.getMainLooper()); private final Handler handler = new Handler(Looper.getMainLooper());
private Runnable pollRunnable; private Runnable pollRunnable;
private Runnable chatSimulationRunnable; private Runnable chatSimulationRunnable;
private Runnable chatPollRunnable;
private String roomId; private String roomId;
private String visitorId; // 游客ID
private Room room; private Room room;
private ExoPlayer player; private ExoPlayer player;
@ -84,7 +93,9 @@ public class RoomDetailActivity extends AppCompatActivity {
@Override @Override
protected void onCreate(@Nullable Bundle savedInstanceState) { protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
Log.d(TAG, "onCreate called");
try {
// 隐藏ActionBar使用自定义顶部栏 // 隐藏ActionBar使用自定义顶部栏
if (getSupportActionBar() != null) { if (getSupportActionBar() != null) {
getSupportActionBar().hide(); getSupportActionBar().hide();
@ -96,6 +107,7 @@ public class RoomDetailActivity extends AppCompatActivity {
ApiClient.getService(getApplicationContext()); ApiClient.getService(getApplicationContext());
roomId = getIntent().getStringExtra(EXTRA_ROOM_ID); roomId = getIntent().getStringExtra(EXTRA_ROOM_ID);
visitorId = UUID.randomUUID().toString().substring(0, 8); // 生成游客ID
triedAltUrl = false; triedAltUrl = false;
setupUI(); setupUI();
@ -103,6 +115,11 @@ public class RoomDetailActivity extends AppCompatActivity {
// 添加欢迎消息 // 添加欢迎消息
addChatMessage(new ChatMessage("欢迎来到直播间!", true)); 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();
}
} }
private void setupUI() { private void setupUI() {
@ -113,6 +130,7 @@ public class RoomDetailActivity extends AppCompatActivity {
if (isFullscreen) { if (isFullscreen) {
toggleFullscreen(); toggleFullscreen();
} else { } else {
Log.d(TAG, "exitFullscreenButton clicked in non-fullscreen mode, calling finish()");
finish(); finish();
} }
}); });
@ -131,18 +149,7 @@ public class RoomDetailActivity extends AppCompatActivity {
binding.fullscreenButton.setOnClickListener(v -> toggleFullscreen()); binding.fullscreenButton.setOnClickListener(v -> toggleFullscreen());
// 关注按钮 // 关注按钮
// TODO: 接入后端接口 - 关注/取消关注主播 binding.followButton.setOnClickListener(v -> followStreamer());
// 接口路径: 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.shareButton.setOnClickListener(v -> shareRoom()); binding.shareButton.setOnClickListener(v -> shareRoom());
@ -171,53 +178,106 @@ public class RoomDetailActivity extends AppCompatActivity {
} }
private void sendMessage() { private void sendMessage() {
// TODO: 接入后端接口 - 发送直播间弹幕消息
// 接口路径: POST /api/rooms/{roomId}/messages
// 请求参数:
// - roomId: 房间ID路径参数
// - message: 消息内容
// - userId: 发送者用户ID从token中获取
// 返回数据格式: ApiResponse<ChatMessage>
// ChatMessage对象应包含: messageId, userId, username, avatarUrl, message, timestamp等字段
// 发送成功后将消息添加到本地列表并显示
String message = binding.chatInput.getText() != null ? String message = binding.chatInput.getText() != null ?
binding.chatInput.getText().toString().trim() : ""; binding.chatInput.getText().toString().trim() : "";
if (!TextUtils.isEmpty(message)) { if (TextUtils.isEmpty(message) || TextUtils.isEmpty(roomId)) return;
addChatMessage(new ChatMessage("", message));
binding.chatInput.setText("");
// TODO: 接入后端接口 - 接收直播间弹幕消息WebSocket或轮询 // 先清空输入框显示本地消息
// 方案1: WebSocket实时推送 binding.chatInput.setText("");
// - 连接: ws://api.example.com/rooms/{roomId}/chat addChatMessage(new ChatMessage("", message));
// - 接收消息格式: {type: "message", data: ChatMessage}
// 方案2: 轮询获取新消息 // 发送到后端
// - 接口路径: GET /api/rooms/{roomId}/messages?lastMessageId={lastId} Map<String, String> body = new HashMap<>();
// - 返回数据格式: ApiResponse<List<ChatMessage>> body.put("message", message);
// - 每3-5秒轮询一次获取lastMessageId之后的新消息 body.put("visitorId", visitorId);
// 模拟其他用户回复 body.put("nickname", "游客" + visitorId);
handler.postDelayed(() -> {
if (random.nextFloat() < 0.3f) { // 30%概率有人回复 ApiClient.getService(getApplicationContext())
String user = simulatedUsers[random.nextInt(simulatedUsers.length)]; .sendMessage(roomId, body)
String reply = simulatedMessages[random.nextInt(simulatedMessages.length)]; .enqueue(new Callback<ApiResponse<ChatMessageResponse>>() {
addChatMessage(new ChatMessage(user, reply)); @Override
public void onResponse(Call<ApiResponse<ChatMessageResponse>> call,
Response<ApiResponse<ChatMessageResponse>> response) {
// 发送成功无需额外处理已在本地显示
if (!response.isSuccessful()) {
Log.w(TAG, "sendMessage failed: " + response.code());
} }
}, 1000 + random.nextInt(3000)); }
@Override
public void onFailure(Call<ApiResponse<ChatMessageResponse>> 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<String, Object> body = new HashMap<>();
body.put("streamerId", roomId);
body.put("action", action);
ApiClient.getService(getApplicationContext())
.followStreamer(body)
.enqueue(new Callback<ApiResponse<Map<String, Object>>>() {
@Override
public void onResponse(Call<ApiResponse<Map<String, Object>>> call,
Response<ApiResponse<Map<String, Object>>> 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<ApiResponse<Map<String, Object>>> 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) { private void addChatMessage(ChatMessage message) {
try {
// 限制消息数量防止内存溢出 // 限制消息数量防止内存溢出
if (chatMessages.size() > 100) { if (chatMessages.size() > 100) {
chatMessages.remove(0); chatMessages.remove(0);
} }
chatMessages.add(message); chatMessages.add(message);
if (chatAdapter != null) {
chatAdapter.submitList(new ArrayList<>(chatMessages)); chatAdapter.submitList(new ArrayList<>(chatMessages));
}
// 滚动到最新消息 // 滚动到最新消息
if (binding != null && binding.chatRecyclerView != null && chatMessages.size() > 0) { if (binding != null && binding.chatRecyclerView != null && chatMessages.size() > 0) {
binding.chatRecyclerView.scrollToPosition(chatMessages.size() - 1); binding.chatRecyclerView.scrollToPosition(chatMessages.size() - 1);
} }
} catch (Exception e) {
e.printStackTrace();
}
} }
private void toggleFullscreen() { private void toggleFullscreen() {
@ -246,16 +306,17 @@ public class RoomDetailActivity extends AppCompatActivity {
@Override @Override
protected void onStart() { protected void onStart() {
super.onStart(); super.onStart();
Log.d(TAG, "onStart called");
startPolling(); startPolling();
startChatSimulation(); startChatPolling();
} }
@Override @Override
protected void onStop() { protected void onStop() {
super.onStop(); super.onStop();
Log.d(TAG, "onStop called");
stopPolling(); stopPolling();
stopChatSimulation(); stopChatPolling();
releasePlayer();
} }
@Override @Override
@ -278,18 +339,26 @@ public class RoomDetailActivity extends AppCompatActivity {
} }
private void startPolling() { private void startPolling() {
try {
stopPolling(); stopPolling();
// 首次立即获取房间信息 // 首次立即获取房间信息
fetchRoom(); fetchRoom();
pollRunnable = () -> { // 暂时禁用轮询避免重复请求导致的问题
if (!isFinishing() && !isDestroyed()) { // pollRunnable = () -> {
fetchRoom(); // try {
handler.postDelayed(pollRunnable, 15000); // 15秒轮询一次减少压力 // if (!isFinishing() && !isDestroyed()) {
// fetchRoom();
// handler.postDelayed(pollRunnable, 15000);
// }
// } catch (Exception e) {
// e.printStackTrace();
// }
// };
// handler.postDelayed(pollRunnable, 15000);
} catch (Exception e) {
e.printStackTrace();
} }
};
// 延迟15秒后开始轮询
handler.postDelayed(pollRunnable, 15000);
} }
private void stopPolling() { private void stopPolling() {
@ -335,32 +404,93 @@ 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<ApiResponse<List<ChatMessageResponse>>>() {
@Override
public void onResponse(Call<ApiResponse<List<ChatMessageResponse>>> call,
Response<ApiResponse<List<ChatMessageResponse>>> response) {
if (isFinishing() || isDestroyed()) return;
if (!response.isSuccessful() || response.body() == null) return;
List<ChatMessageResponse> 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<ApiResponse<List<ChatMessageResponse>>> call, Throwable t) {
Log.e(TAG, "fetchMessages error: " + t.getMessage());
}
});
}
private boolean isFirstLoad = true; private boolean isFirstLoad = true;
private void fetchRoom() { private void fetchRoom() {
try {
// TODO: 接入后端接口 - 获取房间详情 // TODO: 接入后端接口 - 获取房间详情
// 接口路径: GET /api/rooms/{roomId}
// 请求参数: roomId (路径参数)
// 返回数据格式: ApiResponse<Room>
// Room对象应包含: id, title, streamerName, streamerId, type, isLive, coverUrl, viewerCount,
// streamUrls (包含flv, hls, rtmp地址), description, startTime等字段
if (TextUtils.isEmpty(roomId)) { if (TextUtils.isEmpty(roomId)) {
Log.e(TAG, "fetchRoom: roomId is empty, calling finish()");
Toast.makeText(this, "房间不存在", Toast.LENGTH_SHORT).show(); Toast.makeText(this, "房间不存在", Toast.LENGTH_SHORT).show();
finish(); finish();
return; return;
} }
// 只在首次加载时显示loading // 只在首次加载时显示loading
if (isFirstLoad) { if (isFirstLoad && binding != null) {
binding.loading.setVisibility(View.VISIBLE); binding.loading.setVisibility(View.VISIBLE);
} }
ApiClient.getService(getApplicationContext()).getRoom(roomId).enqueue(new Callback<ApiResponse<Room>>() { ApiClient.getService(getApplicationContext()).getRoom(roomId).enqueue(new Callback<ApiResponse<Room>>() {
@Override @Override
public void onResponse(Call<ApiResponse<Room>> call, Response<ApiResponse<Room>> response) { public void onResponse(Call<ApiResponse<Room>> call, Response<ApiResponse<Room>> response) {
try {
if (isFinishing() || isDestroyed()) return; if (isFinishing() || isDestroyed()) return;
if (binding != null) {
binding.loading.setVisibility(View.GONE); binding.loading.setVisibility(View.GONE);
}
boolean firstLoad = isFirstLoad; boolean firstLoad = isFirstLoad;
isFirstLoad = false; isFirstLoad = false;
@ -368,24 +498,46 @@ public class RoomDetailActivity extends AppCompatActivity {
Room data = body != null ? body.getData() : null; Room data = body != null ? body.getData() : null;
if (!response.isSuccessful() || body == null || !body.isOk() || data == null) { if (!response.isSuccessful() || body == null || !body.isOk() || data == null) {
if (firstLoad) { if (firstLoad) {
String msg = body != null && !TextUtils.isEmpty(body.getMessage()) ? body.getMessage() : "房间不存在"; 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(); Toast.makeText(RoomDetailActivity.this, msg, Toast.LENGTH_SHORT).show();
finish();
} }
return; return;
} }
room = data; room = data;
bindRoom(room); bindRoom(room);
} catch (Exception e) {
e.printStackTrace();
}
} }
@Override @Override
public void onFailure(Call<ApiResponse<Room>> call, Throwable t) { public void onFailure(Call<ApiResponse<Room>> call, Throwable t) {
try {
if (isFinishing() || isDestroyed()) return; if (isFinishing() || isDestroyed()) return;
if (binding != null) {
binding.loading.setVisibility(View.GONE); 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; isFirstLoad = false;
} catch (Exception e) {
e.printStackTrace();
}
} }
}); });
} catch (Exception e) {
e.printStackTrace();
}
} }
private void shareRoom() { private void shareRoom() {
@ -401,6 +553,9 @@ public class RoomDetailActivity extends AppCompatActivity {
} }
private void bindRoom(Room r) { private void bindRoom(Room r) {
try {
if (r == null || binding == null) return;
String title = r.getTitle() != null ? r.getTitle() : "直播间"; String title = r.getTitle() != null ? r.getTitle() : "直播间";
String streamer = r.getStreamerName() != null ? r.getStreamerName() : "主播"; String streamer = r.getStreamerName() != null ? r.getStreamerName() : "主播";
@ -416,12 +571,7 @@ public class RoomDetailActivity extends AppCompatActivity {
binding.liveTag.setVisibility(View.VISIBLE); binding.liveTag.setVisibility(View.VISIBLE);
binding.offlineLayout.setVisibility(View.GONE); binding.offlineLayout.setVisibility(View.GONE);
// TODO: 接入后端接口 - 获取实时观看人数 // 设置观看人数
// 接口路径: GET /api/rooms/{roomId}/viewers/count
// 请求参数: roomId (路径参数)
// 返回数据格式: ApiResponse<{viewerCount: number}>
// 建议使用WebSocket实时推送观看人数变化或每10-15秒轮询一次
// 设置观看人数模拟
int viewerCount = r.getViewerCount() > 0 ? r.getViewerCount() : int viewerCount = r.getViewerCount() > 0 ? r.getViewerCount() :
100 + random.nextInt(500); 100 + random.nextInt(500);
binding.topViewerCount.setText(String.valueOf(viewerCount)); binding.topViewerCount.setText(String.valueOf(viewerCount));
@ -449,15 +599,22 @@ public class RoomDetailActivity extends AppCompatActivity {
binding.offlineLayout.setVisibility(View.VISIBLE); binding.offlineLayout.setVisibility(View.VISIBLE);
releasePlayer(); releasePlayer();
} }
} catch (Exception e) {
e.printStackTrace();
}
} }
private void ensurePlayer(String url, String fallbackHlsUrl) { private void ensurePlayer(String url, String fallbackHlsUrl) {
if (TextUtils.isEmpty(url)) return; if (TextUtils.isEmpty(url)) return;
if (url.endsWith(".flv")) { Log.d(TAG, "ensurePlayer: url=" + url + ", fallbackHlsUrl=" + fallbackHlsUrl);
if (TextUtils.equals(ijkUrl, url) && ijkPlayer != null) return;
startFlv(url, fallbackHlsUrl); // 暂时禁用 ijkplayer使用 ExoPlayer 播放 HLS
return; // 如果是 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) { if (player != null) {
@ -466,33 +623,37 @@ public class RoomDetailActivity extends AppCompatActivity {
? current.localConfiguration.uri.toString() ? current.localConfiguration.uri.toString()
: null; : 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) { private void startHls(String url, @Nullable String altUrl) {
if (binding == null) return;
releaseIjkPlayer(); releaseIjkPlayer();
if (binding != null) {
binding.flvTextureView.setVisibility(View.GONE); binding.flvTextureView.setVisibility(View.GONE);
binding.playerView.setVisibility(View.VISIBLE); binding.playerView.setVisibility(View.VISIBLE);
}
releaseExoPlayer(); releaseExoPlayer();
triedAltUrl = false; triedAltUrl = false;
// 低延迟配置最小缓冲快速开始播放
ExoPlayer exo = new ExoPlayer.Builder(this) ExoPlayer exo = new ExoPlayer.Builder(this)
.setLoadControl(new androidx.media3.exoplayer.DefaultLoadControl.Builder() .setLoadControl(new androidx.media3.exoplayer.DefaultLoadControl.Builder()
.setBufferDurationsMs( .setBufferDurationsMs(
1000, 500, // 最小缓冲时间
3000, 1500, // 最大缓冲时间
500, 250, // 播放前缓冲
1000 500 // 重新缓冲时间
) )
.build()) .build())
.build(); .build();
// 设置为直播模式跳到最新位置
exo.setPlayWhenReady(true);
binding.playerView.setPlayer(exo); binding.playerView.setPlayer(exo);
String computedAltUrl = altUrl; String computedAltUrl = altUrl;
@ -502,6 +663,7 @@ public class RoomDetailActivity extends AppCompatActivity {
exo.addListener(new Player.Listener() { exo.addListener(new Player.Listener() {
@Override @Override
public void onPlayerError(PlaybackException error) { public void onPlayerError(PlaybackException error) {
if (isFinishing() || isDestroyed() || binding == null) return;
if (triedAltUrl || TextUtils.isEmpty(finalComputedAltUrl)) { if (triedAltUrl || TextUtils.isEmpty(finalComputedAltUrl)) {
binding.offlineLayout.setVisibility(View.VISIBLE); binding.offlineLayout.setVisibility(View.VISIBLE);
return; return;
@ -514,6 +676,7 @@ public class RoomDetailActivity extends AppCompatActivity {
@Override @Override
public void onPlaybackStateChanged(int playbackState) { public void onPlaybackStateChanged(int playbackState) {
if (isFinishing() || isDestroyed() || binding == null) return;
if (playbackState == Player.STATE_READY) { if (playbackState == Player.STATE_READY) {
binding.offlineLayout.setVisibility(View.GONE); binding.offlineLayout.setVisibility(View.GONE);
addChatMessage(new ChatMessage("直播已连接,开始观看吧!", true)); 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.prepare();
// 跳到直播最新位置
exo.seekToDefaultPosition();
exo.setPlayWhenReady(true); exo.setPlayWhenReady(true);
player = exo; player = exo;
} }
private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) { private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) {
if (binding == null) return;
ensureIjkLibsLoaded(); ensureIjkLibsLoaded();
releaseExoPlayer(); releaseExoPlayer();
releaseIjkPlayer(); releaseIjkPlayer();
@ -536,10 +711,8 @@ public class RoomDetailActivity extends AppCompatActivity {
ijkFallbackHlsUrl = fallbackHlsUrl; ijkFallbackHlsUrl = fallbackHlsUrl;
ijkFallbackTried = false; ijkFallbackTried = false;
if (binding != null) {
binding.playerView.setVisibility(View.GONE); binding.playerView.setVisibility(View.GONE);
binding.flvTextureView.setVisibility(View.VISIBLE); binding.flvTextureView.setVisibility(View.VISIBLE);
}
TextureView view = binding.flvTextureView; TextureView view = binding.flvTextureView;
TextureView.SurfaceTextureListener listener = new TextureView.SurfaceTextureListener() { TextureView.SurfaceTextureListener listener = new TextureView.SurfaceTextureListener() {
@ -555,6 +728,7 @@ public class RoomDetailActivity extends AppCompatActivity {
@Override @Override
public boolean onSurfaceTextureDestroyed(@NonNull android.graphics.SurfaceTexture surface) { public boolean onSurfaceTextureDestroyed(@NonNull android.graphics.SurfaceTexture surface) {
Log.w(TAG, "onSurfaceTextureDestroyed called");
releaseIjkPlayer(); releaseIjkPlayer();
return true; return true;
} }
@ -585,12 +759,16 @@ public class RoomDetailActivity extends AppCompatActivity {
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "infbuf", 1); p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "infbuf", 1);
p.setOnPreparedListener(mp -> { p.setOnPreparedListener(mp -> {
Log.d(TAG, "IjkPlayer onPrepared");
if (isFinishing() || isDestroyed() || binding == null) return;
binding.offlineLayout.setVisibility(View.GONE); binding.offlineLayout.setVisibility(View.GONE);
mp.start(); mp.start();
addChatMessage(new ChatMessage("直播已连接,开始观看吧!", true)); addChatMessage(new ChatMessage("直播已连接,开始观看吧!", true));
}); });
p.setOnErrorListener((IMediaPlayer mp, int what, int extra) -> { 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)) { if (ijkFallbackTried || TextUtils.isEmpty(ijkFallbackHlsUrl)) {
binding.offlineLayout.setVisibility(View.VISIBLE); binding.offlineLayout.setVisibility(View.VISIBLE);
return true; return true;
@ -608,7 +786,7 @@ public class RoomDetailActivity extends AppCompatActivity {
} catch (Exception e) { } catch (Exception e) {
if (!TextUtils.isEmpty(ijkFallbackHlsUrl)) { if (!TextUtils.isEmpty(ijkFallbackHlsUrl)) {
startHls(ijkFallbackHlsUrl, null); startHls(ijkFallbackHlsUrl, null);
} else { } else if (binding != null) {
binding.offlineLayout.setVisibility(View.VISIBLE); binding.offlineLayout.setVisibility(View.VISIBLE);
} }
} }
@ -666,6 +844,7 @@ public class RoomDetailActivity extends AppCompatActivity {
@Override @Override
protected void onDestroy() { protected void onDestroy() {
super.onDestroy(); super.onDestroy();
Log.d(TAG, "onDestroy called");
// 确保Handler回调被清理防止内存泄漏 // 确保Handler回调被清理防止内存泄漏
if (handler != null) { if (handler != null) {

View File

@ -3,11 +3,14 @@ package com.example.livestreaming.net;
import java.util.List; import java.util.List;
import retrofit2.Call; import retrofit2.Call;
import java.util.Map;
import retrofit2.http.Body; import retrofit2.http.Body;
import retrofit2.http.DELETE; import retrofit2.http.DELETE;
import retrofit2.http.GET; import retrofit2.http.GET;
import retrofit2.http.Path; import retrofit2.http.Path;
import retrofit2.http.POST; import retrofit2.http.POST;
import retrofit2.http.Query;
public interface ApiService { public interface ApiService {
@POST("api/front/login") @POST("api/front/login")
@ -24,4 +27,19 @@ public interface ApiService {
@DELETE("api/front/live/rooms/{id}") @DELETE("api/front/live/rooms/{id}")
Call<ApiResponse<Object>> deleteRoom(@Path("id") String id); Call<ApiResponse<Object>> deleteRoom(@Path("id") String id);
// 弹幕消息 API
@GET("api/front/live/public/rooms/{roomId}/messages")
Call<ApiResponse<List<ChatMessageResponse>>> getMessages(
@Path("roomId") String roomId,
@Query("limit") int limit);
@POST("api/front/live/public/rooms/{roomId}/messages")
Call<ApiResponse<ChatMessageResponse>> sendMessage(
@Path("roomId") String roomId,
@Body Map<String, String> body);
// 关注主播 API
@POST("api/front/live/follow")
Call<ApiResponse<Map<String, Object>>> followStreamer(@Body Map<String, Object> body);
} }

View File

@ -104,7 +104,7 @@
android:id="@+id/playerView" android:id="@+id/playerView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
app:use_controller="true" app:use_controller="false"
app:show_buffering="when_playing" /> app:show_buffering="when_playing" />
<ImageButton <ImageButton