修复基本问题
This commit is contained in:
parent
8c4c8ef4b1
commit
f321f198f6
|
|
@ -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<LiveRoomResponse> 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<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) {
|
||||
LiveRoomResponse resp = new LiveRoomResponse();
|
||||
resp.setId(room.getId() == null ? null : String.valueOf(room.getId()));
|
||||
|
|
|
|||
|
|
@ -1400,19 +1400,70 @@ 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<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());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换顶部标签页内容
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
// 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("");
|
||||
if (TextUtils.isEmpty(message) || TextUtils.isEmpty(roomId)) return;
|
||||
|
||||
// TODO: 接入后端接口 - 接收直播间弹幕消息(WebSocket或轮询)
|
||||
// 方案1: WebSocket实时推送
|
||||
// - 连接: ws://api.example.com/rooms/{roomId}/chat
|
||||
// - 接收消息格式: {type: "message", data: ChatMessage}
|
||||
// 方案2: 轮询获取新消息
|
||||
// - 接口路径: GET /api/rooms/{roomId}/messages?lastMessageId={lastId}
|
||||
// - 返回数据格式: ApiResponse<List<ChatMessage>>
|
||||
// - 每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));
|
||||
// 先清空输入框,显示本地消息
|
||||
binding.chatInput.setText("");
|
||||
addChatMessage(new ChatMessage("我", message));
|
||||
|
||||
// 发送到后端
|
||||
Map<String, String> body = new HashMap<>();
|
||||
body.put("message", message);
|
||||
body.put("visitorId", visitorId);
|
||||
body.put("nickname", "游客" + visitorId);
|
||||
|
||||
ApiClient.getService(getApplicationContext())
|
||||
.sendMessage(roomId, body)
|
||||
.enqueue(new Callback<ApiResponse<ChatMessageResponse>>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiResponse<ChatMessageResponse>> call,
|
||||
Response<ApiResponse<ChatMessageResponse>> response) {
|
||||
// 发送成功,无需额外处理(已在本地显示)
|
||||
if (!response.isSuccessful()) {
|
||||
Log.w(TAG, "sendMessage failed: " + response.code());
|
||||
}
|
||||
}
|
||||
|
||||
@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) {
|
||||
// 限制消息数量,防止内存溢出
|
||||
if (chatMessages.size() > 100) {
|
||||
chatMessages.remove(0);
|
||||
}
|
||||
chatMessages.add(message);
|
||||
chatAdapter.submitList(new ArrayList<>(chatMessages));
|
||||
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);
|
||||
// 滚动到最新消息
|
||||
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();
|
||||
try {
|
||||
stopPolling();
|
||||
// 首次立即获取房间信息
|
||||
fetchRoom();
|
||||
|
||||
pollRunnable = () -> {
|
||||
if (!isFinishing() && !isDestroyed()) {
|
||||
fetchRoom();
|
||||
handler.postDelayed(pollRunnable, 15000); // 15秒轮询一次,减少压力
|
||||
}
|
||||
};
|
||||
// 延迟15秒后开始轮询
|
||||
handler.postDelayed(pollRunnable, 15000);
|
||||
// 暂时禁用轮询,避免重复请求导致的问题
|
||||
// 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<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 void fetchRoom() {
|
||||
// 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)) {
|
||||
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<ApiResponse<Room>>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiResponse<Room>> call, Response<ApiResponse<Room>> response) {
|
||||
if (isFinishing() || isDestroyed()) return;
|
||||
ApiClient.getService(getApplicationContext()).getRoom(roomId).enqueue(new Callback<ApiResponse<Room>>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiResponse<Room>> call, Response<ApiResponse<Room>> 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<Room> 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<Room> 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<ApiResponse<Room>> call, Throwable t) {
|
||||
if (isFinishing() || isDestroyed()) return;
|
||||
binding.loading.setVisibility(View.GONE);
|
||||
isFirstLoad = false;
|
||||
}
|
||||
});
|
||||
@Override
|
||||
public void onFailure(Call<ApiResponse<Room>> 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() : "主播";
|
||||
try {
|
||||
if (r == null || binding == null) return;
|
||||
|
||||
// 设置顶部标题栏
|
||||
binding.topTitle.setText(title);
|
||||
String title = r.getTitle() != null ? r.getTitle() : "直播间";
|
||||
String streamer = r.getStreamerName() != null ? r.getStreamerName() : "主播";
|
||||
|
||||
// 设置房间信息区域
|
||||
binding.roomTitle.setText(title);
|
||||
binding.streamerName.setText(streamer);
|
||||
// 设置顶部标题栏
|
||||
binding.topTitle.setText(title);
|
||||
|
||||
// 设置直播状态
|
||||
if (r.isLive()) {
|
||||
binding.liveTag.setVisibility(View.VISIBLE);
|
||||
binding.offlineLayout.setVisibility(View.GONE);
|
||||
// 设置房间信息区域
|
||||
binding.roomTitle.setText(title);
|
||||
binding.streamerName.setText(streamer);
|
||||
|
||||
// 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;
|
||||
}
|
||||
// 设置直播状态
|
||||
if (r.isLive()) {
|
||||
binding.liveTag.setVisibility(View.VISIBLE);
|
||||
binding.offlineLayout.setVisibility(View.GONE);
|
||||
|
||||
// 获取播放地址
|
||||
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;
|
||||
}
|
||||
// 设置观看人数
|
||||
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;
|
||||
|
||||
if (url.endsWith(".flv")) {
|
||||
if (TextUtils.equals(ijkUrl, url) && ijkPlayer != null) return;
|
||||
startFlv(url, fallbackHlsUrl);
|
||||
return;
|
||||
Log.d(TAG, "ensurePlayer: url=" + url + ", fallbackHlsUrl=" + fallbackHlsUrl);
|
||||
|
||||
// 暂时禁用 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,33 +623,37 @@ 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);
|
||||
|
||||
String computedAltUrl = altUrl;
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
||||
<ImageButton
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user