将项目改成java程序
This commit is contained in:
parent
000b3e6607
commit
65d92ce0ac
|
|
@ -35,7 +35,9 @@
|
|||
|
||||
<activity
|
||||
android:name="com.example.livestreaming.RoomDetailActivity"
|
||||
android:exported="false" />
|
||||
android:exported="false"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:screenOrientation="portrait" />
|
||||
|
||||
<activity
|
||||
android:name="com.example.livestreaming.MainActivity"
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import android.os.Handler;
|
|||
import android.os.Looper;
|
||||
import android.text.TextUtils;
|
||||
import android.view.View;
|
||||
import android.widget.TextView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
|
@ -165,12 +166,15 @@ public class MainActivity extends AppCompatActivity {
|
|||
private void startPolling() {
|
||||
stopPolling();
|
||||
pollRunnable = () -> {
|
||||
fetchRooms();
|
||||
// 增加轮询间隔,减少网络请求频率
|
||||
handler.postDelayed(pollRunnable, 10000);
|
||||
// 检查Activity是否还在前台
|
||||
if (!isFinishing() && !isDestroyed()) {
|
||||
fetchRooms();
|
||||
// 增加轮询间隔,减少网络请求频率
|
||||
handler.postDelayed(pollRunnable, 15000);
|
||||
}
|
||||
};
|
||||
// 延迟首次请求,让界面先显示
|
||||
handler.postDelayed(pollRunnable, 1000);
|
||||
handler.postDelayed(pollRunnable, 2000);
|
||||
}
|
||||
|
||||
private void stopPolling() {
|
||||
|
|
@ -227,8 +231,8 @@ public class MainActivity extends AppCompatActivity {
|
|||
dialog.dismiss();
|
||||
fetchRooms();
|
||||
|
||||
// 显示推流信息
|
||||
showStreamInfo(room);
|
||||
// 显示优化的推流信息弹窗
|
||||
showOptimizedStreamInfo(room);
|
||||
|
||||
// 可选:跳转到房间详情
|
||||
// Intent intent = new Intent(MainActivity.this, RoomDetailActivity.class);
|
||||
|
|
@ -239,7 +243,19 @@ public class MainActivity extends AppCompatActivity {
|
|||
@Override
|
||||
public void onFailure(Call<ApiResponse<Room>> call, Throwable t) {
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(true);
|
||||
Toast.makeText(MainActivity.this, "网络错误:" + (t != null ? t.getMessage() : ""), Toast.LENGTH_SHORT).show();
|
||||
String errorMsg = "网络错误";
|
||||
if (t != null) {
|
||||
if (t.getMessage() != null && t.getMessage().contains("Unable to resolve host")) {
|
||||
errorMsg = "无法连接服务器,请检查网络";
|
||||
} else if (t.getMessage() != null && t.getMessage().contains("timeout")) {
|
||||
errorMsg = "连接超时,请检查服务器是否运行";
|
||||
} else if (t.getMessage() != null && t.getMessage().contains("Connection refused")) {
|
||||
errorMsg = "连接被拒绝,请确保后端服务已启动";
|
||||
} else {
|
||||
errorMsg = "网络错误:" + t.getMessage();
|
||||
}
|
||||
}
|
||||
Toast.makeText(MainActivity.this, errorMsg, Toast.LENGTH_LONG).show();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -248,44 +264,62 @@ public class MainActivity extends AppCompatActivity {
|
|||
dialog.show();
|
||||
}
|
||||
|
||||
private void showStreamInfo(Room room) {
|
||||
private void showOptimizedStreamInfo(Room room) {
|
||||
String streamKey = room != null ? room.getStreamKey() : null;
|
||||
String rtmp = room != null && room.getStreamUrls() != null ? room.getStreamUrls().getRtmp() : null;
|
||||
|
||||
String rtmpForObs = null;
|
||||
if (!TextUtils.isEmpty(rtmp) && rtmp.contains("10.0.2.2")) {
|
||||
// 将RTMP地址中的10.0.2.2或192.168.x.x替换为localhost供OBS使用
|
||||
String rtmpForObs = rtmp;
|
||||
if (!TextUtils.isEmpty(rtmp)) {
|
||||
try {
|
||||
Uri u = Uri.parse(rtmp);
|
||||
int port = u.getPort();
|
||||
String path = u.getPath();
|
||||
if (port > 0 && !TextUtils.isEmpty(path)) {
|
||||
// OBS推流时使用localhost
|
||||
rtmpForObs = "rtmp://localhost:" + port + path;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
String msg = "";
|
||||
if (!TextUtils.isEmpty(rtmp)) {
|
||||
msg += "推流地址(服务器):\n" + rtmp + "\n\n";
|
||||
}
|
||||
if (!TextUtils.isEmpty(rtmpForObs)) {
|
||||
msg += "电脑本机 OBS 可用(等价地址):\n" + rtmpForObs + "\n\n";
|
||||
}
|
||||
if (!TextUtils.isEmpty(streamKey)) {
|
||||
msg += "推流密钥(Stream Key):\n" + streamKey + "\n\n";
|
||||
}
|
||||
msg += "提示:用 OBS 推流时,服务器填上面的推流地址,密钥填 streamKey。";
|
||||
// 创建自定义弹窗布局
|
||||
View dialogView = getLayoutInflater().inflate(R.layout.dialog_stream_info, null);
|
||||
|
||||
// 找到视图组件
|
||||
TextView addressText = dialogView.findViewById(R.id.addressText);
|
||||
TextView keyText = dialogView.findViewById(R.id.keyText);
|
||||
|
||||
// 设置文本内容
|
||||
String displayAddress = !TextUtils.isEmpty(rtmpForObs) ? rtmpForObs : rtmp;
|
||||
addressText.setText(displayAddress != null ? displayAddress : "");
|
||||
keyText.setText(streamKey != null ? streamKey : "");
|
||||
|
||||
String copyRtmp = rtmp;
|
||||
String copyKey = streamKey;
|
||||
AlertDialog dialog = new AlertDialog.Builder(this)
|
||||
.setTitle("🎉 直播间创建成功")
|
||||
.setView(dialogView)
|
||||
.setPositiveButton("开始直播", (d, w) -> {
|
||||
d.dismiss();
|
||||
// 跳转到直播播放页面
|
||||
Intent intent = new Intent(MainActivity.this, RoomDetailActivity.class);
|
||||
intent.putExtra(RoomDetailActivity.EXTRA_ROOM_ID, room.getId());
|
||||
startActivity(intent);
|
||||
})
|
||||
.setNegativeButton("稍后开始", (d, w) -> d.dismiss())
|
||||
.create();
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("已创建直播间")
|
||||
.setMessage(msg)
|
||||
.setNegativeButton("复制地址", (d, w) -> copyToClipboard("rtmp", copyRtmp))
|
||||
.setPositiveButton("复制密钥", (d, w) -> copyToClipboard("streamKey", copyKey))
|
||||
.show();
|
||||
dialog.show();
|
||||
|
||||
// 设置复制按钮点击事件
|
||||
dialogView.findViewById(R.id.copyAddressBtn).setOnClickListener(v -> {
|
||||
copyToClipboard("推流地址", displayAddress);
|
||||
Toast.makeText(this, "推流地址已复制", Toast.LENGTH_SHORT).show();
|
||||
});
|
||||
|
||||
dialogView.findViewById(R.id.copyKeyBtn).setOnClickListener(v -> {
|
||||
copyToClipboard("推流密钥", streamKey);
|
||||
Toast.makeText(this, "推流密钥已复制", Toast.LENGTH_SHORT).show();
|
||||
});
|
||||
}
|
||||
|
||||
private void copyToClipboard(String label, String text) {
|
||||
|
|
@ -300,9 +334,16 @@ public class MainActivity extends AppCompatActivity {
|
|||
}
|
||||
|
||||
private void fetchRooms() {
|
||||
// 避免重复请求
|
||||
if (isFetching) return;
|
||||
|
||||
isFetching = true;
|
||||
lastFetchMs = System.currentTimeMillis();
|
||||
binding.loading.setVisibility(View.VISIBLE);
|
||||
|
||||
// 只在没有数据时显示loading
|
||||
if (adapter.getItemCount() == 0) {
|
||||
binding.loading.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
ApiClient.getService().getRooms().enqueue(new Callback<ApiResponse<List<Room>>>() {
|
||||
@Override
|
||||
|
|
|
|||
|
|
@ -1,29 +1,35 @@
|
|||
package com.example.livestreaming;
|
||||
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.net.Uri;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.content.res.Configuration;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.text.TextUtils;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
import android.view.inputmethod.EditorInfo;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.media3.common.MediaItem;
|
||||
import androidx.media3.common.PlaybackException;
|
||||
import androidx.media3.common.Player;
|
||||
import androidx.media3.exoplayer.ExoPlayer;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
|
||||
import com.example.livestreaming.databinding.ActivityRoomDetailBinding;
|
||||
import com.example.livestreaming.databinding.ActivityRoomDetailNewBinding;
|
||||
import com.example.livestreaming.net.ApiClient;
|
||||
import com.example.livestreaming.net.ApiResponse;
|
||||
import com.example.livestreaming.net.Room;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
|
||||
import retrofit2.Call;
|
||||
import retrofit2.Callback;
|
||||
import retrofit2.Response;
|
||||
|
|
@ -32,60 +38,191 @@ public class RoomDetailActivity extends AppCompatActivity {
|
|||
|
||||
public static final String EXTRA_ROOM_ID = "extra_room_id";
|
||||
|
||||
private ActivityRoomDetailBinding binding;
|
||||
private ActivityRoomDetailNewBinding binding;
|
||||
private final Handler handler = new Handler(Looper.getMainLooper());
|
||||
private Runnable pollRunnable;
|
||||
private Runnable chatSimulationRunnable;
|
||||
|
||||
private String roomId;
|
||||
private Room room;
|
||||
|
||||
private ExoPlayer player;
|
||||
private boolean triedAltUrl;
|
||||
private boolean isFullscreen = false;
|
||||
|
||||
private ChatAdapter chatAdapter;
|
||||
private List<ChatMessage> chatMessages = new ArrayList<>();
|
||||
private Random random = new Random();
|
||||
|
||||
// 模拟用户名列表
|
||||
private final String[] simulatedUsers = {
|
||||
"游戏达人", "直播观众", "路过的小伙伴", "老铁666", "主播加油",
|
||||
"夜猫子", "学生党", "上班族", "游戏爱好者", "直播粉丝"
|
||||
};
|
||||
|
||||
// 模拟弹幕内容
|
||||
private final String[] simulatedMessages = {
|
||||
"主播666", "这个操作厉害了", "学到了学到了", "主播加油!",
|
||||
"太强了", "这波操作可以", "牛牛牛", "厉害厉害", "支持主播",
|
||||
"精彩精彩", "继续继续", "好看好看", "赞赞赞", "棒棒棒"
|
||||
};
|
||||
|
||||
@Override
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
binding = ActivityRoomDetailBinding.inflate(getLayoutInflater());
|
||||
|
||||
// 隐藏ActionBar,使用自定义顶部栏
|
||||
if (getSupportActionBar() != null) {
|
||||
getSupportActionBar().hide();
|
||||
}
|
||||
|
||||
binding = ActivityRoomDetailNewBinding.inflate(getLayoutInflater());
|
||||
setContentView(binding.getRoot());
|
||||
|
||||
roomId = getIntent().getStringExtra(EXTRA_ROOM_ID);
|
||||
|
||||
binding.backButton.setOnClickListener(v -> finish());
|
||||
binding.copyRtmpButton.setOnClickListener(v -> {
|
||||
String alt = binding.rtmpAltValue.getText() != null ? binding.rtmpAltValue.getText().toString() : null;
|
||||
if (!TextUtils.isEmpty(alt) && binding.rtmpAltGroup.getVisibility() == View.VISIBLE) {
|
||||
copyToClipboard("rtmp", alt);
|
||||
return;
|
||||
}
|
||||
String raw = binding.rtmpValue.getText() != null ? binding.rtmpValue.getText().toString() : null;
|
||||
copyToClipboard("rtmp", raw);
|
||||
});
|
||||
binding.copyKeyButton.setOnClickListener(v -> copyToClipboard("streamKey", binding.keyValue.getText() != null ? binding.keyValue.getText().toString() : null));
|
||||
binding.deleteButton.setOnClickListener(v -> confirmDelete());
|
||||
|
||||
triedAltUrl = false;
|
||||
|
||||
setupUI();
|
||||
setupChat();
|
||||
|
||||
// 添加欢迎消息
|
||||
addChatMessage(new ChatMessage("欢迎来到直播间!", true));
|
||||
}
|
||||
|
||||
private void setupUI() {
|
||||
// 返回按钮
|
||||
binding.backButton.setOnClickListener(v -> finish());
|
||||
|
||||
// 全屏按钮
|
||||
binding.fullscreenButton.setOnClickListener(v -> toggleFullscreen());
|
||||
|
||||
// 关注按钮
|
||||
binding.followButton.setOnClickListener(v -> {
|
||||
Toast.makeText(this, "已关注主播", Toast.LENGTH_SHORT).show();
|
||||
binding.followButton.setText("已关注");
|
||||
binding.followButton.setEnabled(false);
|
||||
});
|
||||
}
|
||||
|
||||
private void setupChat() {
|
||||
// 设置弹幕适配器
|
||||
chatAdapter = new ChatAdapter();
|
||||
LinearLayoutManager layoutManager = new LinearLayoutManager(this);
|
||||
layoutManager.setStackFromEnd(true); // 从底部开始显示
|
||||
binding.chatRecyclerView.setLayoutManager(layoutManager);
|
||||
binding.chatRecyclerView.setAdapter(chatAdapter);
|
||||
|
||||
// 发送按钮点击事件
|
||||
binding.sendButton.setOnClickListener(v -> sendMessage());
|
||||
|
||||
// 输入框回车发送
|
||||
binding.chatInput.setOnEditorActionListener((v, actionId, event) -> {
|
||||
if (actionId == EditorInfo.IME_ACTION_SEND ||
|
||||
(event != null && event.getKeyCode() == KeyEvent.KEYCODE_ENTER)) {
|
||||
sendMessage();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
private void sendMessage() {
|
||||
String message = binding.chatInput.getText() != null ?
|
||||
binding.chatInput.getText().toString().trim() : "";
|
||||
|
||||
if (!TextUtils.isEmpty(message)) {
|
||||
addChatMessage(new ChatMessage("我", message));
|
||||
binding.chatInput.setText("");
|
||||
|
||||
// 模拟其他用户回复
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private void toggleFullscreen() {
|
||||
if (isFullscreen) {
|
||||
// 退出全屏
|
||||
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
|
||||
getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
|
||||
getSupportActionBar().show();
|
||||
isFullscreen = false;
|
||||
} else {
|
||||
// 进入全屏
|
||||
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE);
|
||||
getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
|
||||
WindowManager.LayoutParams.FLAG_FULLSCREEN);
|
||||
if (getSupportActionBar() != null) {
|
||||
getSupportActionBar().hide();
|
||||
}
|
||||
isFullscreen = true;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
startPolling();
|
||||
startChatSimulation();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
stopPolling();
|
||||
stopChatSimulation();
|
||||
releasePlayer();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onConfigurationChanged(@NonNull Configuration newConfig) {
|
||||
super.onConfigurationChanged(newConfig);
|
||||
|
||||
if (newConfig.orientation == Configuration.ORIENTATION_LANDSCAPE) {
|
||||
// 横屏时隐藏其他UI元素,只显示播放器
|
||||
binding.topBar.setVisibility(View.GONE);
|
||||
binding.roomInfoLayout.setVisibility(View.GONE);
|
||||
binding.chatLayout.setVisibility(View.GONE);
|
||||
} else {
|
||||
// 竖屏时显示所有UI元素
|
||||
binding.topBar.setVisibility(View.VISIBLE);
|
||||
binding.roomInfoLayout.setVisibility(View.VISIBLE);
|
||||
binding.chatLayout.setVisibility(View.VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
private void startPolling() {
|
||||
stopPolling();
|
||||
// 首次立即获取房间信息
|
||||
fetchRoom();
|
||||
|
||||
pollRunnable = () -> {
|
||||
fetchRoom();
|
||||
handler.postDelayed(pollRunnable, 5000);
|
||||
if (!isFinishing() && !isDestroyed()) {
|
||||
fetchRoom();
|
||||
handler.postDelayed(pollRunnable, 15000); // 15秒轮询一次,减少压力
|
||||
}
|
||||
};
|
||||
handler.post(pollRunnable);
|
||||
// 延迟15秒后开始轮询
|
||||
handler.postDelayed(pollRunnable, 15000);
|
||||
}
|
||||
|
||||
private void stopPolling() {
|
||||
|
|
@ -95,6 +232,36 @@ public class RoomDetailActivity extends AppCompatActivity {
|
|||
}
|
||||
}
|
||||
|
||||
private void startChatSimulation() {
|
||||
stopChatSimulation();
|
||||
chatSimulationRunnable = () -> {
|
||||
if (isFinishing() || isDestroyed()) return;
|
||||
|
||||
// 随机生成弹幕,降低概率
|
||||
if (random.nextFloat() < 0.25f) { // 25%概率生成弹幕
|
||||
String user = simulatedUsers[random.nextInt(simulatedUsers.length)];
|
||||
String message = simulatedMessages[random.nextInt(simulatedMessages.length)];
|
||||
addChatMessage(new ChatMessage(user, message));
|
||||
}
|
||||
|
||||
// 随机间隔5-12秒,减少频率
|
||||
int delay = 5000 + random.nextInt(7000);
|
||||
handler.postDelayed(chatSimulationRunnable, delay);
|
||||
};
|
||||
|
||||
// 首次延迟3秒开始
|
||||
handler.postDelayed(chatSimulationRunnable, 3000);
|
||||
}
|
||||
|
||||
private void stopChatSimulation() {
|
||||
if (chatSimulationRunnable != null) {
|
||||
handler.removeCallbacks(chatSimulationRunnable);
|
||||
chatSimulationRunnable = null;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isFirstLoad = true;
|
||||
|
||||
private void fetchRoom() {
|
||||
if (TextUtils.isEmpty(roomId)) {
|
||||
Toast.makeText(this, "房间不存在", Toast.LENGTH_SHORT).show();
|
||||
|
|
@ -102,18 +269,26 @@ public class RoomDetailActivity extends AppCompatActivity {
|
|||
return;
|
||||
}
|
||||
|
||||
binding.loading.setVisibility(View.VISIBLE);
|
||||
// 只在首次加载时显示loading
|
||||
if (isFirstLoad) {
|
||||
binding.loading.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
ApiClient.getService().getRoom(roomId).enqueue(new Callback<ApiResponse<Room>>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiResponse<Room>> call, Response<ApiResponse<Room>> response) {
|
||||
if (isFinishing() || isDestroyed()) return;
|
||||
|
||||
binding.loading.setVisibility(View.GONE);
|
||||
isFirstLoad = false;
|
||||
|
||||
ApiResponse<Room> body = response.body();
|
||||
Room data = body != null ? body.getData() : null;
|
||||
if (!response.isSuccessful() || data == null) {
|
||||
Toast.makeText(RoomDetailActivity.this, "房间不存在", Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
if (isFirstLoad) {
|
||||
Toast.makeText(RoomDetailActivity.this, "房间不存在", Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -123,69 +298,60 @@ public class RoomDetailActivity extends AppCompatActivity {
|
|||
|
||||
@Override
|
||||
public void onFailure(Call<ApiResponse<Room>> call, Throwable t) {
|
||||
if (isFinishing() || isDestroyed()) return;
|
||||
binding.loading.setVisibility(View.GONE);
|
||||
isFirstLoad = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void bindRoom(Room r) {
|
||||
setTitle(r.getTitle() != null ? r.getTitle() : "Room");
|
||||
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);
|
||||
|
||||
binding.titleText.setText(r.getTitle() != null ? r.getTitle() : "(Untitled)");
|
||||
binding.streamerText.setText(r.getStreamerName() != null ? ("主播: " + r.getStreamerName()) : "");
|
||||
|
||||
binding.liveBadge.setText(r.isLive() ? "直播中" : "未开播");
|
||||
int badgeColor = ContextCompat.getColor(this, r.isLive() ? R.color.live_red : android.R.color.darker_gray);
|
||||
binding.liveBadge.setBackgroundColor(badgeColor);
|
||||
|
||||
String rtmp = r.getStreamUrls() != null ? r.getStreamUrls().getRtmp() : null;
|
||||
String key = r.getStreamKey();
|
||||
|
||||
binding.rtmpValue.setText(rtmp != null ? rtmp : "");
|
||||
binding.keyValue.setText(key != null ? key : "");
|
||||
|
||||
String altRtmp = getAltRtmpForObs(rtmp);
|
||||
binding.rtmpAltValue.setText(altRtmp != null ? altRtmp : "");
|
||||
binding.rtmpAltGroup.setVisibility(!TextUtils.isEmpty(altRtmp) ? View.VISIBLE : View.GONE);
|
||||
|
||||
if (!r.isLive()) {
|
||||
binding.playerContainer.setVisibility(View.GONE);
|
||||
binding.offlineHint.setVisibility(View.VISIBLE);
|
||||
// 设置直播状态
|
||||
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;
|
||||
}
|
||||
|
||||
binding.offlineHint.setVisibility(View.GONE);
|
||||
binding.playerContainer.setVisibility(View.VISIBLE);
|
||||
|
||||
// 获取播放地址
|
||||
String playUrl = null;
|
||||
if (r.getStreamUrls() != null) {
|
||||
playUrl = r.getStreamUrls().getHls();
|
||||
if (playUrl == null || playUrl.trim().isEmpty()) {
|
||||
playUrl = r.getStreamUrls().getFlv();
|
||||
// 优先使用HTTP-FLV,延迟更低
|
||||
playUrl = r.getStreamUrls().getFlv();
|
||||
if (TextUtils.isEmpty(playUrl)) {
|
||||
playUrl = r.getStreamUrls().getHls();
|
||||
}
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(playUrl)) {
|
||||
if (!TextUtils.isEmpty(playUrl)) {
|
||||
ensurePlayer(playUrl);
|
||||
} else {
|
||||
// 没有播放地址时显示离线状态
|
||||
binding.offlineLayout.setVisibility(View.VISIBLE);
|
||||
releasePlayer();
|
||||
return;
|
||||
}
|
||||
|
||||
if (r.getStreamUrls() != null) {
|
||||
String hls = r.getStreamUrls().getHls();
|
||||
String flv = r.getStreamUrls().getFlv();
|
||||
if (TextUtils.isEmpty(hls) && !TextUtils.isEmpty(flv)) {
|
||||
Toast.makeText(this, "提示:当前没有 HLS 地址,Android 可能无法播放 FLV。建议在后端启用 FFmpeg/HLS。", Toast.LENGTH_LONG).show();
|
||||
}
|
||||
}
|
||||
|
||||
ensurePlayer(playUrl);
|
||||
}
|
||||
|
||||
private void ensurePlayer(String url) {
|
||||
if (!TextUtils.isEmpty(url) && url.endsWith(".flv")) {
|
||||
Toast.makeText(this, "提示:Android 原生播放器可能无法播放 FLV,如黑屏请启用 HLS。", Toast.LENGTH_LONG).show();
|
||||
}
|
||||
if (player != null) {
|
||||
MediaItem current = player.getCurrentMediaItem();
|
||||
String currentUri = current != null && current.localConfiguration != null && current.localConfiguration.uri != null
|
||||
|
|
@ -196,24 +362,49 @@ public class RoomDetailActivity extends AppCompatActivity {
|
|||
}
|
||||
|
||||
releasePlayer();
|
||||
|
||||
triedAltUrl = false;
|
||||
|
||||
ExoPlayer exo = new ExoPlayer.Builder(this).build();
|
||||
// 创建低延迟播放器配置
|
||||
ExoPlayer exo = new ExoPlayer.Builder(this)
|
||||
.setLoadControl(new androidx.media3.exoplayer.DefaultLoadControl.Builder()
|
||||
// 减少缓冲区大小,降低延迟
|
||||
.setBufferDurationsMs(
|
||||
1000, // 最小缓冲时长 1秒
|
||||
3000, // 最大缓冲时长 3秒
|
||||
500, // 播放缓冲时长 0.5秒
|
||||
1000 // 播放后缓冲时长 1秒
|
||||
)
|
||||
.build())
|
||||
.build();
|
||||
|
||||
binding.playerView.setPlayer(exo);
|
||||
|
||||
|
||||
// 设置播放器监听器
|
||||
String altUrl = getAltHlsUrl(url);
|
||||
exo.addListener(new Player.Listener() {
|
||||
@Override
|
||||
public void onPlayerError(PlaybackException error) {
|
||||
if (triedAltUrl) return;
|
||||
if (TextUtils.isEmpty(altUrl)) return;
|
||||
if (triedAltUrl || TextUtils.isEmpty(altUrl)) {
|
||||
// 播放失败,显示离线状态
|
||||
binding.offlineLayout.setVisibility(View.VISIBLE);
|
||||
return;
|
||||
}
|
||||
triedAltUrl = true;
|
||||
|
||||
exo.setMediaItem(MediaItem.fromUri(altUrl));
|
||||
exo.prepare();
|
||||
exo.setPlayWhenReady(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlaybackStateChanged(int playbackState) {
|
||||
if (playbackState == Player.STATE_READY) {
|
||||
// 播放成功,隐藏离线状态
|
||||
binding.offlineLayout.setVisibility(View.GONE);
|
||||
|
||||
// 添加系统消息
|
||||
addChatMessage(new ChatMessage("直播已连接,开始观看吧!", true));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
exo.setMediaItem(MediaItem.fromUri(url));
|
||||
|
|
@ -237,65 +428,5 @@ public class RoomDetailActivity extends AppCompatActivity {
|
|||
return url.substring(0, url.length() - ".m3u8".length()) + "/index.m3u8";
|
||||
}
|
||||
|
||||
private String getAltRtmpForObs(String rtmp) {
|
||||
if (TextUtils.isEmpty(rtmp)) return null;
|
||||
if (!rtmp.contains("10.0.2.2")) return null;
|
||||
|
||||
try {
|
||||
Uri u = Uri.parse(rtmp);
|
||||
int port = u.getPort();
|
||||
String path = u.getPath();
|
||||
if (port > 0 && !TextUtils.isEmpty(path)) {
|
||||
return "rtmp://localhost:" + port + path;
|
||||
}
|
||||
return null;
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void copyToClipboard(String label, String text) {
|
||||
if (TextUtils.isEmpty(text)) {
|
||||
Toast.makeText(this, "内容为空", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
ClipboardManager cm = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
|
||||
if (cm == null) return;
|
||||
cm.setPrimaryClip(ClipData.newPlainText(label, text));
|
||||
Toast.makeText(this, "已复制", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
|
||||
private void confirmDelete() {
|
||||
if (TextUtils.isEmpty(roomId)) return;
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("删除房间")
|
||||
.setMessage("确定删除该直播间吗?")
|
||||
.setNegativeButton("取消", null)
|
||||
.setPositiveButton("删除", (d, w) -> deleteRoom())
|
||||
.show();
|
||||
}
|
||||
|
||||
private void deleteRoom() {
|
||||
binding.loading.setVisibility(View.VISIBLE);
|
||||
|
||||
ApiClient.getService().deleteRoom(roomId).enqueue(new Callback<ApiResponse<Object>>() {
|
||||
@Override
|
||||
public void onResponse(Call<ApiResponse<Object>> call, Response<ApiResponse<Object>> response) {
|
||||
binding.loading.setVisibility(View.GONE);
|
||||
if (!response.isSuccessful()) {
|
||||
Toast.makeText(RoomDetailActivity.this, "删除失败", Toast.LENGTH_SHORT).show();
|
||||
return;
|
||||
}
|
||||
Toast.makeText(RoomDetailActivity.this, "已删除", Toast.LENGTH_SHORT).show();
|
||||
finish();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onFailure(Call<ApiResponse<Object>> call, Throwable t) {
|
||||
binding.loading.setVisibility(View.GONE);
|
||||
Toast.makeText(RoomDetailActivity.this, "网络错误", Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
package com.example.livestreaming.net;
|
||||
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import com.example.livestreaming.BuildConfig;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
|
|
@ -9,12 +12,40 @@ import retrofit2.converter.gson.GsonConverterFactory;
|
|||
|
||||
public final class ApiClient {
|
||||
|
||||
private static final String TAG = "ApiClient";
|
||||
private static volatile Retrofit retrofit;
|
||||
private static volatile ApiService service;
|
||||
|
||||
private ApiClient() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否在模拟器中运行
|
||||
*/
|
||||
private static boolean isEmulator() {
|
||||
return Build.FINGERPRINT.startsWith("generic")
|
||||
|| Build.FINGERPRINT.startsWith("unknown")
|
||||
|| Build.MODEL.contains("google_sdk")
|
||||
|| Build.MODEL.contains("Emulator")
|
||||
|| Build.MODEL.contains("Android SDK built for x86")
|
||||
|| Build.MANUFACTURER.contains("Genymotion")
|
||||
|| (Build.BRAND.startsWith("generic") && Build.DEVICE.startsWith("generic"))
|
||||
|| "google_sdk".equals(Build.PRODUCT);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取API基础地址,自动根据设备类型选择
|
||||
*/
|
||||
private static String getBaseUrl() {
|
||||
if (isEmulator()) {
|
||||
Log.d(TAG, "检测到模拟器,使用模拟器API地址");
|
||||
return BuildConfig.API_BASE_URL_EMULATOR;
|
||||
} else {
|
||||
Log.d(TAG, "检测到真机,使用真机API地址");
|
||||
return BuildConfig.API_BASE_URL_DEVICE;
|
||||
}
|
||||
}
|
||||
|
||||
public static ApiService getService() {
|
||||
if (service != null) return service;
|
||||
synchronized (ApiClient.class) {
|
||||
|
|
@ -23,15 +54,20 @@ public final class ApiClient {
|
|||
HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
|
||||
logging.setLevel(HttpLoggingInterceptor.Level.BASIC);
|
||||
|
||||
OkHttpClient client = new OkHttpClient.Builder()
|
||||
OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder()
|
||||
.addInterceptor(logging)
|
||||
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.writeTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build();
|
||||
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.writeTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.retryOnConnectionFailure(true);
|
||||
|
||||
OkHttpClient client = clientBuilder.build();
|
||||
|
||||
String baseUrl = getBaseUrl();
|
||||
Log.d(TAG, "API Base URL: " + baseUrl);
|
||||
|
||||
retrofit = new Retrofit.Builder()
|
||||
.baseUrl(BuildConfig.API_BASE_URL)
|
||||
.baseUrl(baseUrl)
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.build();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="#FFFFFF" />
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@android:color/white" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
||||
<stroke android:width="1dp" android:color="#E0E0E0" />
|
||||
</shape>
|
||||
|
|
@ -18,5 +18,15 @@
|
|||
"viewerCount": 0,
|
||||
"createdAt": "2025-12-16T09:50:13.396Z",
|
||||
"startedAt": null
|
||||
},
|
||||
{
|
||||
"id": "704351b7-1ea9-445e-ba6f-08cbb2cf0b18",
|
||||
"title": "V哈哈",
|
||||
"streamerName": "刚刚",
|
||||
"streamKey": "704351b7-1ea9-445e-ba6f-08cbb2cf0b18",
|
||||
"isLive": false,
|
||||
"viewerCount": 0,
|
||||
"createdAt": "2025-12-17T10:44:50.964Z",
|
||||
"startedAt": null
|
||||
}
|
||||
]
|
||||
Loading…
Reference in New Issue
Block a user