将项目改成java程序

This commit is contained in:
xiao12feng@outlook.com 2025-12-17 19:06:57 +08:00
parent 000b3e6607
commit 65d92ce0ac
6 changed files with 398 additions and 176 deletions

View File

@ -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"

View File

@ -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 = () -> {
// 检查Activity是否还在前台
if (!isFinishing() && !isDestroyed()) {
fetchRooms(); fetchRooms();
// 增加轮询间隔减少网络请求频率 // 增加轮询间隔减少网络请求频率
handler.postDelayed(pollRunnable, 10000); 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();
// 只在没有数据时显示loading
if (adapter.getItemCount() == 0) {
binding.loading.setVisibility(View.VISIBLE); 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

View File

@ -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();
pollRunnable = () -> { // 首次立即获取房间信息
fetchRoom(); fetchRoom();
handler.postDelayed(pollRunnable, 5000);
pollRunnable = () -> {
if (!isFinishing() && !isDestroyed()) {
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;
} }
// 只在首次加载时显示loading
if (isFirstLoad) {
binding.loading.setVisibility(View.VISIBLE); 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) {
if (isFirstLoad) {
Toast.makeText(RoomDetailActivity.this, "房间不存在", Toast.LENGTH_SHORT).show(); Toast.makeText(RoomDetailActivity.this, "房间不存在", Toast.LENGTH_SHORT).show();
finish(); 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)) { if (TextUtils.isEmpty(playUrl)) {
releasePlayer(); playUrl = r.getStreamUrls().getHls();
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();
} }
} }
if (!TextUtils.isEmpty(playUrl)) {
ensurePlayer(playUrl); ensurePlayer(playUrl);
} else {
// 没有播放地址时显示离线状态
binding.offlineLayout.setVisibility(View.VISIBLE);
releasePlayer();
}
} }
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();
}
});
}
} }

View File

@ -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();

View File

@ -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>

View File

@ -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
} }
] ]