将项目改成java程序
This commit is contained in:
parent
000b3e6607
commit
65d92ce0ac
|
|
@ -35,7 +35,9 @@
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name="com.example.livestreaming.RoomDetailActivity"
|
android:name="com.example.livestreaming.RoomDetailActivity"
|
||||||
android:exported="false" />
|
android:exported="false"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name="com.example.livestreaming.MainActivity"
|
android:name="com.example.livestreaming.MainActivity"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
|
import android.widget.TextView;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
|
@ -165,12 +166,15 @@ public class MainActivity extends AppCompatActivity {
|
||||||
private void startPolling() {
|
private void startPolling() {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
pollRunnable = () -> {
|
pollRunnable = () -> {
|
||||||
fetchRooms();
|
// 检查Activity是否还在前台
|
||||||
// 增加轮询间隔,减少网络请求频率
|
if (!isFinishing() && !isDestroyed()) {
|
||||||
handler.postDelayed(pollRunnable, 10000);
|
fetchRooms();
|
||||||
|
// 增加轮询间隔,减少网络请求频率
|
||||||
|
handler.postDelayed(pollRunnable, 15000);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
// 延迟首次请求,让界面先显示
|
// 延迟首次请求,让界面先显示
|
||||||
handler.postDelayed(pollRunnable, 1000);
|
handler.postDelayed(pollRunnable, 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void stopPolling() {
|
private void stopPolling() {
|
||||||
|
|
@ -227,8 +231,8 @@ public class MainActivity extends AppCompatActivity {
|
||||||
dialog.dismiss();
|
dialog.dismiss();
|
||||||
fetchRooms();
|
fetchRooms();
|
||||||
|
|
||||||
// 显示推流信息
|
// 显示优化的推流信息弹窗
|
||||||
showStreamInfo(room);
|
showOptimizedStreamInfo(room);
|
||||||
|
|
||||||
// 可选:跳转到房间详情
|
// 可选:跳转到房间详情
|
||||||
// Intent intent = new Intent(MainActivity.this, RoomDetailActivity.class);
|
// Intent intent = new Intent(MainActivity.this, RoomDetailActivity.class);
|
||||||
|
|
@ -239,7 +243,19 @@ public class MainActivity extends AppCompatActivity {
|
||||||
@Override
|
@Override
|
||||||
public void onFailure(Call<ApiResponse<Room>> call, Throwable t) {
|
public void onFailure(Call<ApiResponse<Room>> call, Throwable t) {
|
||||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).setEnabled(true);
|
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();
|
dialog.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showStreamInfo(Room room) {
|
private void showOptimizedStreamInfo(Room room) {
|
||||||
String streamKey = room != null ? room.getStreamKey() : null;
|
String streamKey = room != null ? room.getStreamKey() : null;
|
||||||
String rtmp = room != null && room.getStreamUrls() != null ? room.getStreamUrls().getRtmp() : null;
|
String rtmp = room != null && room.getStreamUrls() != null ? room.getStreamUrls().getRtmp() : null;
|
||||||
|
|
||||||
String rtmpForObs = null;
|
// 将RTMP地址中的10.0.2.2或192.168.x.x替换为localhost供OBS使用
|
||||||
if (!TextUtils.isEmpty(rtmp) && rtmp.contains("10.0.2.2")) {
|
String rtmpForObs = rtmp;
|
||||||
|
if (!TextUtils.isEmpty(rtmp)) {
|
||||||
try {
|
try {
|
||||||
Uri u = Uri.parse(rtmp);
|
Uri u = Uri.parse(rtmp);
|
||||||
int port = u.getPort();
|
int port = u.getPort();
|
||||||
String path = u.getPath();
|
String path = u.getPath();
|
||||||
if (port > 0 && !TextUtils.isEmpty(path)) {
|
if (port > 0 && !TextUtils.isEmpty(path)) {
|
||||||
|
// OBS推流时使用localhost
|
||||||
rtmpForObs = "rtmp://localhost:" + port + path;
|
rtmpForObs = "rtmp://localhost:" + port + path;
|
||||||
}
|
}
|
||||||
} catch (Exception ignored) {
|
} catch (Exception ignored) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String msg = "";
|
// 创建自定义弹窗布局
|
||||||
if (!TextUtils.isEmpty(rtmp)) {
|
View dialogView = getLayoutInflater().inflate(R.layout.dialog_stream_info, null);
|
||||||
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。";
|
|
||||||
|
|
||||||
String copyRtmp = rtmp;
|
// 找到视图组件
|
||||||
String copyKey = streamKey;
|
TextView addressText = dialogView.findViewById(R.id.addressText);
|
||||||
|
TextView keyText = dialogView.findViewById(R.id.keyText);
|
||||||
|
|
||||||
new AlertDialog.Builder(this)
|
// 设置文本内容
|
||||||
.setTitle("已创建直播间")
|
String displayAddress = !TextUtils.isEmpty(rtmpForObs) ? rtmpForObs : rtmp;
|
||||||
.setMessage(msg)
|
addressText.setText(displayAddress != null ? displayAddress : "");
|
||||||
.setNegativeButton("复制地址", (d, w) -> copyToClipboard("rtmp", copyRtmp))
|
keyText.setText(streamKey != null ? streamKey : "");
|
||||||
.setPositiveButton("复制密钥", (d, w) -> copyToClipboard("streamKey", copyKey))
|
|
||||||
.show();
|
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();
|
||||||
|
|
||||||
|
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) {
|
private void copyToClipboard(String label, String text) {
|
||||||
|
|
@ -300,9 +334,16 @@ public class MainActivity extends AppCompatActivity {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void fetchRooms() {
|
private void fetchRooms() {
|
||||||
|
// 避免重复请求
|
||||||
|
if (isFetching) return;
|
||||||
|
|
||||||
isFetching = true;
|
isFetching = true;
|
||||||
lastFetchMs = System.currentTimeMillis();
|
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>>>() {
|
ApiClient.getService().getRooms().enqueue(new Callback<ApiResponse<List<Room>>>() {
|
||||||
@Override
|
@Override
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,35 @@
|
||||||
package com.example.livestreaming;
|
package com.example.livestreaming;
|
||||||
|
|
||||||
import android.content.ClipData;
|
import android.content.pm.ActivityInfo;
|
||||||
import android.content.ClipboardManager;
|
import android.content.res.Configuration;
|
||||||
import android.net.Uri;
|
|
||||||
import android.os.Bundle;
|
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.view.KeyEvent;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
|
import android.view.WindowManager;
|
||||||
|
import android.view.inputmethod.EditorInfo;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.appcompat.app.AlertDialog;
|
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
import androidx.core.content.ContextCompat;
|
|
||||||
import androidx.media3.common.MediaItem;
|
import androidx.media3.common.MediaItem;
|
||||||
import androidx.media3.common.PlaybackException;
|
import androidx.media3.common.PlaybackException;
|
||||||
import androidx.media3.common.Player;
|
import androidx.media3.common.Player;
|
||||||
import androidx.media3.exoplayer.ExoPlayer;
|
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.ApiClient;
|
||||||
import com.example.livestreaming.net.ApiResponse;
|
import com.example.livestreaming.net.ApiResponse;
|
||||||
import com.example.livestreaming.net.Room;
|
import com.example.livestreaming.net.Room;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Random;
|
||||||
|
|
||||||
import retrofit2.Call;
|
import retrofit2.Call;
|
||||||
import retrofit2.Callback;
|
import retrofit2.Callback;
|
||||||
import retrofit2.Response;
|
import retrofit2.Response;
|
||||||
|
|
@ -32,60 +38,191 @@ public class RoomDetailActivity extends AppCompatActivity {
|
||||||
|
|
||||||
public static final String EXTRA_ROOM_ID = "extra_room_id";
|
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 final Handler handler = new Handler(Looper.getMainLooper());
|
||||||
private Runnable pollRunnable;
|
private Runnable pollRunnable;
|
||||||
|
private Runnable chatSimulationRunnable;
|
||||||
|
|
||||||
private String roomId;
|
private String roomId;
|
||||||
private Room room;
|
private Room room;
|
||||||
|
|
||||||
private ExoPlayer player;
|
private ExoPlayer player;
|
||||||
private boolean triedAltUrl;
|
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
|
@Override
|
||||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
binding = ActivityRoomDetailBinding.inflate(getLayoutInflater());
|
|
||||||
|
// 隐藏ActionBar,使用自定义顶部栏
|
||||||
|
if (getSupportActionBar() != null) {
|
||||||
|
getSupportActionBar().hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
binding = ActivityRoomDetailNewBinding.inflate(getLayoutInflater());
|
||||||
setContentView(binding.getRoot());
|
setContentView(binding.getRoot());
|
||||||
|
|
||||||
roomId = getIntent().getStringExtra(EXTRA_ROOM_ID);
|
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;
|
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
|
@Override
|
||||||
protected void onStart() {
|
protected void onStart() {
|
||||||
super.onStart();
|
super.onStart();
|
||||||
startPolling();
|
startPolling();
|
||||||
|
startChatSimulation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onStop() {
|
protected void onStop() {
|
||||||
super.onStop();
|
super.onStop();
|
||||||
stopPolling();
|
stopPolling();
|
||||||
|
stopChatSimulation();
|
||||||
releasePlayer();
|
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() {
|
private void startPolling() {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
|
// 首次立即获取房间信息
|
||||||
|
fetchRoom();
|
||||||
|
|
||||||
pollRunnable = () -> {
|
pollRunnable = () -> {
|
||||||
fetchRoom();
|
if (!isFinishing() && !isDestroyed()) {
|
||||||
handler.postDelayed(pollRunnable, 5000);
|
fetchRoom();
|
||||||
|
handler.postDelayed(pollRunnable, 15000); // 15秒轮询一次,减少压力
|
||||||
|
}
|
||||||
};
|
};
|
||||||
handler.post(pollRunnable);
|
// 延迟15秒后开始轮询
|
||||||
|
handler.postDelayed(pollRunnable, 15000);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void stopPolling() {
|
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() {
|
private void fetchRoom() {
|
||||||
if (TextUtils.isEmpty(roomId)) {
|
if (TextUtils.isEmpty(roomId)) {
|
||||||
Toast.makeText(this, "房间不存在", Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, "房间不存在", Toast.LENGTH_SHORT).show();
|
||||||
|
|
@ -102,18 +269,26 @@ public class RoomDetailActivity extends AppCompatActivity {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.loading.setVisibility(View.VISIBLE);
|
// 只在首次加载时显示loading
|
||||||
|
if (isFirstLoad) {
|
||||||
|
binding.loading.setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
|
||||||
ApiClient.getService().getRoom(roomId).enqueue(new Callback<ApiResponse<Room>>() {
|
ApiClient.getService().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) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
|
|
||||||
binding.loading.setVisibility(View.GONE);
|
binding.loading.setVisibility(View.GONE);
|
||||||
|
isFirstLoad = false;
|
||||||
|
|
||||||
ApiResponse<Room> body = response.body();
|
ApiResponse<Room> body = response.body();
|
||||||
Room data = body != null ? body.getData() : null;
|
Room data = body != null ? body.getData() : null;
|
||||||
if (!response.isSuccessful() || data == null) {
|
if (!response.isSuccessful() || data == null) {
|
||||||
Toast.makeText(RoomDetailActivity.this, "房间不存在", Toast.LENGTH_SHORT).show();
|
if (isFirstLoad) {
|
||||||
finish();
|
Toast.makeText(RoomDetailActivity.this, "房间不存在", Toast.LENGTH_SHORT).show();
|
||||||
|
finish();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -123,69 +298,60 @@ public class RoomDetailActivity extends AppCompatActivity {
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onFailure(Call<ApiResponse<Room>> call, Throwable t) {
|
public void onFailure(Call<ApiResponse<Room>> call, Throwable t) {
|
||||||
|
if (isFinishing() || isDestroyed()) return;
|
||||||
binding.loading.setVisibility(View.GONE);
|
binding.loading.setVisibility(View.GONE);
|
||||||
|
isFirstLoad = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void bindRoom(Room r) {
|
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.titleText.setText(r.getTitle() != null ? r.getTitle() : "(Untitled)");
|
// 设置顶部标题栏
|
||||||
binding.streamerText.setText(r.getStreamerName() != null ? ("主播: " + r.getStreamerName()) : "");
|
binding.topTitle.setText(title);
|
||||||
|
|
||||||
binding.liveBadge.setText(r.isLive() ? "直播中" : "未开播");
|
// 设置房间信息区域
|
||||||
int badgeColor = ContextCompat.getColor(this, r.isLive() ? R.color.live_red : android.R.color.darker_gray);
|
binding.roomTitle.setText(title);
|
||||||
binding.liveBadge.setBackgroundColor(badgeColor);
|
binding.streamerName.setText(streamer);
|
||||||
|
|
||||||
String rtmp = r.getStreamUrls() != null ? r.getStreamUrls().getRtmp() : null;
|
// 设置直播状态
|
||||||
String key = r.getStreamKey();
|
if (r.isLive()) {
|
||||||
|
binding.liveTag.setVisibility(View.VISIBLE);
|
||||||
|
binding.offlineLayout.setVisibility(View.GONE);
|
||||||
|
|
||||||
binding.rtmpValue.setText(rtmp != null ? rtmp : "");
|
// 设置观看人数(模拟)
|
||||||
binding.keyValue.setText(key != null ? key : "");
|
int viewerCount = r.getViewerCount() > 0 ? r.getViewerCount() :
|
||||||
|
100 + random.nextInt(500);
|
||||||
String altRtmp = getAltRtmpForObs(rtmp);
|
binding.topViewerCount.setText(String.valueOf(viewerCount));
|
||||||
binding.rtmpAltValue.setText(altRtmp != null ? altRtmp : "");
|
} else {
|
||||||
binding.rtmpAltGroup.setVisibility(!TextUtils.isEmpty(altRtmp) ? View.VISIBLE : View.GONE);
|
binding.liveTag.setVisibility(View.GONE);
|
||||||
|
binding.offlineLayout.setVisibility(View.VISIBLE);
|
||||||
if (!r.isLive()) {
|
|
||||||
binding.playerContainer.setVisibility(View.GONE);
|
|
||||||
binding.offlineHint.setVisibility(View.VISIBLE);
|
|
||||||
releasePlayer();
|
releasePlayer();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.offlineHint.setVisibility(View.GONE);
|
// 获取播放地址
|
||||||
binding.playerContainer.setVisibility(View.VISIBLE);
|
|
||||||
|
|
||||||
String playUrl = null;
|
String playUrl = null;
|
||||||
if (r.getStreamUrls() != null) {
|
if (r.getStreamUrls() != null) {
|
||||||
playUrl = r.getStreamUrls().getHls();
|
// 优先使用HTTP-FLV,延迟更低
|
||||||
if (playUrl == null || playUrl.trim().isEmpty()) {
|
playUrl = r.getStreamUrls().getFlv();
|
||||||
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();
|
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) {
|
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) {
|
if (player != null) {
|
||||||
MediaItem current = player.getCurrentMediaItem();
|
MediaItem current = player.getCurrentMediaItem();
|
||||||
String currentUri = current != null && current.localConfiguration != null && current.localConfiguration.uri != null
|
String currentUri = current != null && current.localConfiguration != null && current.localConfiguration.uri != null
|
||||||
|
|
@ -196,24 +362,49 @@ public class RoomDetailActivity extends AppCompatActivity {
|
||||||
}
|
}
|
||||||
|
|
||||||
releasePlayer();
|
releasePlayer();
|
||||||
|
|
||||||
triedAltUrl = false;
|
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);
|
binding.playerView.setPlayer(exo);
|
||||||
|
|
||||||
|
// 设置播放器监听器
|
||||||
String altUrl = getAltHlsUrl(url);
|
String altUrl = getAltHlsUrl(url);
|
||||||
exo.addListener(new Player.Listener() {
|
exo.addListener(new Player.Listener() {
|
||||||
@Override
|
@Override
|
||||||
public void onPlayerError(PlaybackException error) {
|
public void onPlayerError(PlaybackException error) {
|
||||||
if (triedAltUrl) return;
|
if (triedAltUrl || TextUtils.isEmpty(altUrl)) {
|
||||||
if (TextUtils.isEmpty(altUrl)) return;
|
// 播放失败,显示离线状态
|
||||||
|
binding.offlineLayout.setVisibility(View.VISIBLE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
triedAltUrl = true;
|
triedAltUrl = true;
|
||||||
|
|
||||||
exo.setMediaItem(MediaItem.fromUri(altUrl));
|
exo.setMediaItem(MediaItem.fromUri(altUrl));
|
||||||
exo.prepare();
|
exo.prepare();
|
||||||
exo.setPlayWhenReady(true);
|
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));
|
exo.setMediaItem(MediaItem.fromUri(url));
|
||||||
|
|
@ -237,65 +428,5 @@ public class RoomDetailActivity extends AppCompatActivity {
|
||||||
return url.substring(0, url.length() - ".m3u8".length()) + "/index.m3u8";
|
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;
|
package com.example.livestreaming.net;
|
||||||
|
|
||||||
|
import android.os.Build;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
import com.example.livestreaming.BuildConfig;
|
import com.example.livestreaming.BuildConfig;
|
||||||
|
|
||||||
import okhttp3.OkHttpClient;
|
import okhttp3.OkHttpClient;
|
||||||
|
|
@ -9,12 +12,40 @@ import retrofit2.converter.gson.GsonConverterFactory;
|
||||||
|
|
||||||
public final class ApiClient {
|
public final class ApiClient {
|
||||||
|
|
||||||
|
private static final String TAG = "ApiClient";
|
||||||
private static volatile Retrofit retrofit;
|
private static volatile Retrofit retrofit;
|
||||||
private static volatile ApiService service;
|
private static volatile ApiService service;
|
||||||
|
|
||||||
private ApiClient() {
|
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() {
|
public static ApiService getService() {
|
||||||
if (service != null) return service;
|
if (service != null) return service;
|
||||||
synchronized (ApiClient.class) {
|
synchronized (ApiClient.class) {
|
||||||
|
|
@ -23,15 +54,20 @@ public final class ApiClient {
|
||||||
HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
|
HttpLoggingInterceptor logging = new HttpLoggingInterceptor();
|
||||||
logging.setLevel(HttpLoggingInterceptor.Level.BASIC);
|
logging.setLevel(HttpLoggingInterceptor.Level.BASIC);
|
||||||
|
|
||||||
OkHttpClient client = new OkHttpClient.Builder()
|
OkHttpClient.Builder clientBuilder = new OkHttpClient.Builder()
|
||||||
.addInterceptor(logging)
|
.addInterceptor(logging)
|
||||||
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
|
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
.readTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
.writeTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
.writeTimeout(15, java.util.concurrent.TimeUnit.SECONDS)
|
||||||
.build();
|
.retryOnConnectionFailure(true);
|
||||||
|
|
||||||
|
OkHttpClient client = clientBuilder.build();
|
||||||
|
|
||||||
|
String baseUrl = getBaseUrl();
|
||||||
|
Log.d(TAG, "API Base URL: " + baseUrl);
|
||||||
|
|
||||||
retrofit = new Retrofit.Builder()
|
retrofit = new Retrofit.Builder()
|
||||||
.baseUrl(BuildConfig.API_BASE_URL)
|
.baseUrl(baseUrl)
|
||||||
.client(client)
|
.client(client)
|
||||||
.addConverterFactory(GsonConverterFactory.create())
|
.addConverterFactory(GsonConverterFactory.create())
|
||||||
.build();
|
.build();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
<solid android:color="#FFFFFF" />
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@android:color/white" />
|
||||||
<corners android:radius="16dp" />
|
<corners android:radius="16dp" />
|
||||||
|
<stroke android:width="1dp" android:color="#E0E0E0" />
|
||||||
</shape>
|
</shape>
|
||||||
|
|
@ -18,5 +18,15 @@
|
||||||
"viewerCount": 0,
|
"viewerCount": 0,
|
||||||
"createdAt": "2025-12-16T09:50:13.396Z",
|
"createdAt": "2025-12-16T09:50:13.396Z",
|
||||||
"startedAt": null
|
"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