Compare commits

...

2 Commits

Author SHA1 Message Date
xiao12feng8
8345dca1a1 解决合并冲突:许愿树对话框布局文件 2025-12-31 14:47:07 +08:00
xiao12feng8
750f4c8c1e 更新:许愿树对话框、通话功能、环境配置等多项优化 2025-12-31 14:44:51 +08:00
14 changed files with 767 additions and 296 deletions

View File

@ -13,25 +13,25 @@ const wishtreeManageRouter = {
children: [ children: [
{ {
path: 'festival', path: 'festival',
component: () => import('@/views/wishtree/festival/index'), component: () => import('@/views/wishTree/festival/index'),
name: 'WishtreeFestival', name: 'WishtreeFestival',
meta: { title: '节日管理' }, meta: { title: '节日管理' },
}, },
{ {
path: 'wish', path: 'wish',
component: () => import('@/views/wishtree/wish/index'), component: () => import('@/views/wishTree/wish/index'),
name: 'WishtreeWish', name: 'WishtreeWish',
meta: { title: '心愿管理' }, meta: { title: '心愿管理' },
}, },
{ {
path: 'background', path: 'background',
component: () => import('@/views/wishtree/background/index'), component: () => import('@/views/wishTree/background/index'),
name: 'WishtreeBackground', name: 'WishtreeBackground',
meta: { title: '背景素材' }, meta: { title: '背景素材' },
}, },
{ {
path: 'statistics', path: 'statistics',
component: () => import('@/views/wishtree/statistics/index'), component: () => import('@/views/wishTree/statistics/index'),
name: 'WishtreeStatistics', name: 'WishtreeStatistics',
meta: { title: '数据统计' }, meta: { title: '数据统计' },
}, },

View File

@ -38,18 +38,27 @@ public class LiveStatusSyncTask {
private final ObjectMapper objectMapper = new ObjectMapper(); private final ObjectMapper objectMapper = new ObjectMapper();
/** /**
* 同步直播状态 5 秒执行一次 * 同步直播状态 30 秒执行一次减少频率
*/ */
@Scheduled(fixedRate = 5000) @Scheduled(fixedRate = 30000)
public void syncLiveStatus() { public void syncLiveStatus() {
try { try {
Set<String> liveStreamKeys = fetchLiveStreamKeysFromSrs(); Set<String> liveStreamKeys = fetchLiveStreamKeysFromSrs();
updateLiveStatus(liveStreamKeys); // 只有成功获取到 SRS 数据时才更新状态
// 如果 SRS 查询失败返回空集合不修改现有状态
if (liveStreamKeys != null && !liveStreamKeys.isEmpty()) {
updateLiveStatus(liveStreamKeys);
} else {
logger.debug("SRS 返回空流列表,保持现有直播状态不变");
}
} catch (Exception e) { } catch (Exception e) {
logger.error("LiveStatusSyncTask 执行失败: {}", e.getMessage()); logger.error("LiveStatusSyncTask 执行失败: {}", e.getMessage());
} }
} }
// 标记是否成功连接过 SRS
private boolean srsConnected = false;
/** /**
* SRS API 获取当前正在推流的 streamKey 列表 * SRS API 获取当前正在推流的 streamKey 列表
*/ */
@ -63,6 +72,7 @@ public class LiveStatusSyncTask {
conn.setReadTimeout(3000); conn.setReadTimeout(3000);
if (conn.getResponseCode() == 200) { if (conn.getResponseCode() == 200) {
srsConnected = true;
JsonNode root = objectMapper.readTree(conn.getInputStream()); JsonNode root = objectMapper.readTree(conn.getInputStream());
JsonNode streams = root.get("streams"); JsonNode streams = root.get("streams");
if (streams != null && streams.isArray()) { if (streams != null && streams.isArray()) {
@ -80,6 +90,8 @@ public class LiveStatusSyncTask {
conn.disconnect(); conn.disconnect();
} catch (Exception e) { } catch (Exception e) {
logger.warn("查询 SRS API 失败: {}", e.getMessage()); logger.warn("查询 SRS API 失败: {}", e.getMessage());
// 返回 null 表示查询失败不应该修改状态
return null;
} }
return streamKeys; return streamKeys;
} }
@ -88,6 +100,8 @@ public class LiveStatusSyncTask {
* 更新数据库中的直播状态 * 更新数据库中的直播状态
*/ */
private void updateLiveStatus(Set<String> liveStreamKeys) { private void updateLiveStatus(Set<String> liveStreamKeys) {
if (liveStreamKeys == null) return;
List<LiveRoom> allRooms = liveRoomService.list(new LambdaQueryWrapper<>()); List<LiveRoom> allRooms = liveRoomService.list(new LambdaQueryWrapper<>());
for (LiveRoom room : allRooms) { for (LiveRoom room : allRooms) {
String streamKey = room.getStreamKey(); String streamKey = room.getStreamKey();

View File

@ -29,6 +29,12 @@ server:
max-threads: 1000 # 最大线程数量 默认200 max-threads: 1000 # 最大线程数量 默认200
min-spare-threads: 30 # 初始化启动线程数量 min-spare-threads: 30 # 初始化启动线程数量
# ============ 直播流服务器配置 ============
# 直播流始终使用远程SRS服务器
LIVE_PUBLIC_SRS_HOST: 1.15.149.240
LIVE_PUBLIC_SRS_RTMP_PORT: 25002
LIVE_PUBLIC_SRS_HTTP_PORT: 25003
spring: spring:
profiles: profiles:
# 配置的环境 # 配置的环境

View File

@ -31,16 +31,25 @@ android {
} }
} }
// ============ 主API地址普通业务功能============
val apiBaseUrlEmulator = (localProps.getProperty("api.base_url_emulator") val apiBaseUrlEmulator = (localProps.getProperty("api.base_url_emulator")
?: "http://10.0.2.2:8081/").trim() ?: "http://10.0.2.2:8081/").trim()
val apiBaseUrlDevice = (localProps.getProperty("api.base_url_device") val apiBaseUrlDevice = (localProps.getProperty("api.base_url_device")
?: "http://192.168.1.164:8081/").trim() ?: "http://192.168.1.164:8081/").trim()
// 模拟器使用服务器地址
buildConfigField("String", "API_BASE_URL_EMULATOR", "\"$apiBaseUrlEmulator\"") buildConfigField("String", "API_BASE_URL_EMULATOR", "\"$apiBaseUrlEmulator\"")
// 真机使用服务器地址
buildConfigField("String", "API_BASE_URL_DEVICE", "\"$apiBaseUrlDevice\"") buildConfigField("String", "API_BASE_URL_DEVICE", "\"$apiBaseUrlDevice\"")
// ============ 直播/通话服务地址(始终远程)============
val liveServerHost = (localProps.getProperty("live.server_host") ?: "1.15.149.240").trim()
val liveServerPort = (localProps.getProperty("live.server_port") ?: "8083").trim()
val turnServerHost = (localProps.getProperty("turn.server_host") ?: "1.15.149.240").trim()
val turnServerPort = (localProps.getProperty("turn.server_port") ?: "3478").trim()
buildConfigField("String", "LIVE_SERVER_HOST", "\"$liveServerHost\"")
buildConfigField("String", "LIVE_SERVER_PORT", "\"$liveServerPort\"")
buildConfigField("String", "TURN_SERVER_HOST", "\"$turnServerHost\"")
buildConfigField("String", "TURN_SERVER_PORT", "\"$turnServerPort\"")
} }
buildTypes { buildTypes {

View File

@ -39,6 +39,7 @@ public class LiveStreamingApplication extends Application {
/** /**
* 如果用户已登录连接通话信令服务器 * 如果用户已登录连接通话信令服务器
* 延迟执行避免启动时阻塞
*/ */
public void connectCallSignalingIfLoggedIn() { public void connectCallSignalingIfLoggedIn() {
String userId = AuthStore.getUserId(this); String userId = AuthStore.getUserId(this);
@ -48,8 +49,15 @@ public class LiveStreamingApplication extends Application {
try { try {
int uid = (int) Double.parseDouble(userId); int uid = (int) Double.parseDouble(userId);
if (uid > 0) { if (uid > 0) {
Log.d(TAG, "用户已登录连接通话信令服务器userId: " + uid); Log.d(TAG, "用户已登录延迟连接通话信令服务器userId: " + uid);
CallManager.getInstance(this).connect(uid); // 延迟3秒连接避免启动时阻塞
new android.os.Handler(android.os.Looper.getMainLooper()).postDelayed(() -> {
try {
CallManager.getInstance(this).connect(uid);
} catch (Exception e) {
Log.e(TAG, "连接通话信令服务器失败", e);
}
}, 3000);
} }
} catch (NumberFormatException e) { } catch (NumberFormatException e) {
Log.e(TAG, "解析用户ID失败: " + userId, e); Log.e(TAG, "解析用户ID失败: " + userId, e);
@ -64,7 +72,11 @@ public class LiveStreamingApplication extends Application {
*/ */
public void onUserLoggedIn(int userId) { public void onUserLoggedIn(int userId) {
Log.d(TAG, "用户登录成功连接通话信令服务器userId: " + userId); Log.d(TAG, "用户登录成功连接通话信令服务器userId: " + userId);
CallManager.getInstance(this).connect(userId); try {
CallManager.getInstance(this).connect(userId);
} catch (Exception e) {
Log.e(TAG, "连接通话信令服务器失败", e);
}
} }
/** /**

View File

@ -103,9 +103,17 @@ public class MainActivity extends AppCompatActivity {
binding = ActivityMainBinding.inflate(getLayoutInflater()); binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot()); setContentView(binding.getRoot());
// 强制设置正确的 API 地址 // 清除之前缓存的自定义API地址使用BuildConfig中的配置
ApiClient.setCustomBaseUrl(getApplicationContext(), "http://192.168.1.164:8081/"); ApiClient.clearCustomBaseUrl(getApplicationContext());
ApiClient.getService(getApplicationContext()); ApiClient.getService(getApplicationContext());
// 调试打印当前使用的 API 地址
String currentApiUrl = ApiClient.getCurrentBaseUrl(getApplicationContext());
Log.d(TAG, "========== API 配置 ==========");
Log.d(TAG, "当前 API 地址: " + currentApiUrl);
Log.d(TAG, "BuildConfig EMULATOR: " + com.example.livestreaming.BuildConfig.API_BASE_URL_EMULATOR);
Log.d(TAG, "BuildConfig DEVICE: " + com.example.livestreaming.BuildConfig.API_BASE_URL_DEVICE);
Log.d(TAG, "==============================");
// 立即显示缓存数据提升启动速度 // 立即显示缓存数据提升启动速度
setupRecyclerView(); setupRecyclerView();

View File

@ -78,33 +78,36 @@ public class PlayerActivity extends AppCompatActivity {
releaseExoPlayer(); releaseExoPlayer();
triedAltUrl = false; triedAltUrl = false;
// 创建低延迟播放器配置 // 优化缓冲配置平衡延迟和流畅度
androidx.media3.exoplayer.DefaultLoadControl loadControl =
new androidx.media3.exoplayer.DefaultLoadControl.Builder()
.setBufferDurationsMs(
3000, // 最小缓冲 3秒
15000, // 最大缓冲 15秒
1500, // 播放前缓冲 1.5秒
3000 // 重新缓冲 3秒
)
.setPrioritizeTimeOverSizeThresholds(true)
.build();
ExoPlayer exo = new ExoPlayer.Builder(this) ExoPlayer exo = new ExoPlayer.Builder(this)
.setLoadControl(new androidx.media3.exoplayer.DefaultLoadControl.Builder() .setLoadControl(loadControl)
// 减少缓冲区大小降低延迟
.setBufferDurationsMs(
1000, // 最小缓冲时长 1秒
3000, // 最大缓冲时长 3秒
500, // 播放缓冲时长 0.5秒
1000 // 播放后缓冲时长 1秒
)
.build())
.build(); .build();
// 设置播放器视图
binding.playerView.setPlayer(exo); binding.playerView.setPlayer(exo);
// 启用低延迟模式
binding.playerView.setUseController(true); binding.playerView.setUseController(true);
binding.playerView.setControllerAutoShow(false); binding.playerView.setControllerAutoShow(false);
String computedAltUrl = altUrl; String computedAltUrl = altUrl;
if (computedAltUrl == null || computedAltUrl.trim().isEmpty()) computedAltUrl = getAltHlsUrl(url); if (computedAltUrl == null || computedAltUrl.trim().isEmpty()) {
computedAltUrl = getAltHlsUrl(url);
}
String finalComputedAltUrl = computedAltUrl; String finalComputedAltUrl = computedAltUrl;
exo.addListener(new Player.Listener() { exo.addListener(new Player.Listener() {
@Override @Override
public void onPlayerError(PlaybackException error) { public void onPlayerError(PlaybackException error) {
android.util.Log.e("PlayerActivity", "播放错误: " + error.getMessage());
if (triedAltUrl) return; if (triedAltUrl) return;
if (finalComputedAltUrl == null || finalComputedAltUrl.trim().isEmpty()) return; if (finalComputedAltUrl == null || finalComputedAltUrl.trim().isEmpty()) return;
triedAltUrl = true; triedAltUrl = true;
@ -115,6 +118,7 @@ public class PlayerActivity extends AppCompatActivity {
} }
}); });
android.util.Log.d("PlayerActivity", "开始播放: " + url);
exo.setMediaItem(MediaItem.fromUri(url)); exo.setMediaItem(MediaItem.fromUri(url));
exo.prepare(); exo.prepare();
exo.setPlayWhenReady(true); exo.setPlayWhenReady(true);
@ -138,47 +142,16 @@ public class PlayerActivity extends AppCompatActivity {
} }
private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) { private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) {
ensureIjkLibsLoaded(); // 禁用 IjkPlayer直接使用 HLS 播放IjkPlayer 在某些设备上会崩溃
releaseExoPlayer(); android.util.Log.d("PlayerActivity", "FLV流转为HLS播放: " + flvUrl);
releaseIjkPlayer();
// FLV 地址转换为 HLS 地址
ijkUrl = flvUrl; String hlsUrl = fallbackHlsUrl;
ijkFallbackHlsUrl = fallbackHlsUrl; if (hlsUrl == null || hlsUrl.trim().isEmpty()) {
ijkFallbackTried = false; hlsUrl = flvUrl.replace(".flv", ".m3u8");
if (binding != null) {
binding.playerView.setVisibility(android.view.View.GONE);
binding.flvTextureView.setVisibility(android.view.View.VISIBLE);
}
TextureView view = binding.flvTextureView;
TextureView.SurfaceTextureListener listener = new TextureView.SurfaceTextureListener() {
@Override
public void onSurfaceTextureAvailable(android.graphics.SurfaceTexture surfaceTexture, int width, int height) {
ijkSurface = new Surface(surfaceTexture);
prepareIjk(flvUrl);
}
@Override
public void onSurfaceTextureSizeChanged(android.graphics.SurfaceTexture surface, int width, int height) {
}
@Override
public boolean onSurfaceTextureDestroyed(android.graphics.SurfaceTexture surface) {
releaseIjkPlayer();
return true;
}
@Override
public void onSurfaceTextureUpdated(android.graphics.SurfaceTexture surface) {
}
};
view.setSurfaceTextureListener(listener);
if (view.isAvailable() && view.getSurfaceTexture() != null) {
ijkSurface = new Surface(view.getSurfaceTexture());
prepareIjk(flvUrl);
} }
startHls(hlsUrl, null);
} }
private void prepareIjk(String url) { private void prepareIjk(String url) {

View File

@ -24,6 +24,7 @@ import androidx.media3.exoplayer.ExoPlayer;
import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.GridLayoutManager;
import com.example.livestreaming.call.WebRTCConfig;
import com.example.livestreaming.databinding.ActivityRoomDetailNewBinding; import com.example.livestreaming.databinding.ActivityRoomDetailNewBinding;
import com.example.livestreaming.net.ApiClient; import com.example.livestreaming.net.ApiClient;
import com.example.livestreaming.net.ApiResponse; import com.example.livestreaming.net.ApiResponse;
@ -105,22 +106,25 @@ public class RoomDetailActivity extends AppCompatActivity {
private WebSocket onlineCountWebSocket; private WebSocket onlineCountWebSocket;
private OkHttpClient onlineCountWsClient; private OkHttpClient onlineCountWsClient;
// 动态获取WebSocket URL // 动态获取WebSocket URL - 直播服务使用远程服务器
private String getWsChatBaseUrl() { private String getWsChatBaseUrl() {
String baseUrl = ApiClient.getCurrentBaseUrl(this); try {
if (baseUrl == null || baseUrl.isEmpty()) { // 直播弹幕WebSocket使用远程服务器
baseUrl = "http://192.168.1.164:8081/"; return WebRTCConfig.getLiveWsUrl() + "ws/live/chat/";
} catch (Exception e) {
android.util.Log.e("RoomDetail", "获取WsChatBaseUrl失败", e);
return "ws://1.15.149.240:8083/ws/live/chat/";
} }
// http:// 转换为 ws://
return baseUrl.replace("http://", "ws://").replace("https://", "wss://") + "ws/live/chat/";
} }
private String getWsOnlineBaseUrl() { private String getWsOnlineBaseUrl() {
String baseUrl = ApiClient.getCurrentBaseUrl(this); try {
if (baseUrl == null || baseUrl.isEmpty()) { // 直播在线人数WebSocket使用远程服务器
baseUrl = "http://192.168.1.164:8081/"; return WebRTCConfig.getLiveWsUrl() + "ws/live/";
} catch (Exception e) {
android.util.Log.e("RoomDetail", "获取WsOnlineBaseUrl失败", e);
return "ws://1.15.149.240:8083/ws/live/";
} }
return baseUrl.replace("http://", "ws://").replace("https://", "wss://") + "ws/live/";
} }
// WebSocket 心跳检测 - 弹幕 // WebSocket 心跳检测 - 弹幕
@ -150,25 +154,37 @@ public class RoomDetailActivity extends AppCompatActivity {
protected void onCreate(@Nullable Bundle savedInstanceState) { protected void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
// 隐藏ActionBar使用自定义顶部栏 try {
if (getSupportActionBar() != null) { // 隐藏ActionBar使用自定义顶部栏
getSupportActionBar().hide(); if (getSupportActionBar() != null) {
getSupportActionBar().hide();
}
binding = ActivityRoomDetailNewBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
ApiClient.getService(getApplicationContext());
roomId = getIntent().getStringExtra(EXTRA_ROOM_ID);
if (TextUtils.isEmpty(roomId)) {
Toast.makeText(this, "直播间ID无效", Toast.LENGTH_SHORT).show();
finish();
return;
}
triedAltUrl = false;
setupUI();
setupChat();
setupGifts();
// 记录观看历史异步不阻塞
recordWatchHistory();
} catch (Exception e) {
android.util.Log.e("RoomDetail", "onCreate异常: " + e.getMessage(), e);
Toast.makeText(this, "加载直播间失败", Toast.LENGTH_SHORT).show();
finish();
} }
binding = ActivityRoomDetailNewBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
ApiClient.getService(getApplicationContext());
roomId = getIntent().getStringExtra(EXTRA_ROOM_ID);
triedAltUrl = false;
setupUI();
setupChat();
setupGifts();
// 记录观看历史
recordWatchHistory();
} }
private void setupUI() { private void setupUI() {
@ -744,36 +760,39 @@ public class RoomDetailActivity extends AppCompatActivity {
* 记录观看历史 * 记录观看历史
*/ */
private void recordWatchHistory() { private void recordWatchHistory() {
if (!AuthHelper.isLoggedIn(this)) { try {
return; // 未登录用户不记录 if (!AuthHelper.isLoggedIn(this)) {
} return; // 未登录用户不记录
}
if (TextUtils.isEmpty(roomId)) {
return; if (TextUtils.isEmpty(roomId)) {
} return;
}
ApiService apiService = ApiClient.getService(getApplicationContext());
ApiService apiService = ApiClient.getService(getApplicationContext());
java.util.Map<String, Object> body = new java.util.HashMap<>();
body.put("roomId", roomId); java.util.Map<String, Object> body = new java.util.HashMap<>();
body.put("watchTime", System.currentTimeMillis()); body.put("roomId", roomId);
body.put("watchTime", System.currentTimeMillis());
Call<ApiResponse<java.util.Map<String, Object>>> call = apiService.recordWatchHistory(body);
Call<ApiResponse<java.util.Map<String, Object>>> call = apiService.recordWatchHistory(body);
call.enqueue(new Callback<ApiResponse<java.util.Map<String, Object>>>() {
@Override call.enqueue(new Callback<ApiResponse<java.util.Map<String, Object>>>() {
public void onResponse(Call<ApiResponse<java.util.Map<String, Object>>> call, @Override
Response<ApiResponse<java.util.Map<String, Object>>> response) { public void onResponse(Call<ApiResponse<java.util.Map<String, Object>>> call,
if (response.isSuccessful() && response.body() != null) { Response<ApiResponse<java.util.Map<String, Object>>> response) {
android.util.Log.d("RoomDetail", "观看历史记录成功"); // 忽略结果接口可能不存在
} }
}
@Override @Override
public void onFailure(Call<ApiResponse<java.util.Map<String, Object>>> call, Throwable t) { public void onFailure(Call<ApiResponse<java.util.Map<String, Object>>> call, Throwable t) {
android.util.Log.e("RoomDetail", "观看历史记录失败: " + t.getMessage()); // 忽略错误接口可能不存在
} }
}); });
} catch (Exception e) {
// 忽略所有异常不影响直播观看
android.util.Log.w("RoomDetail", "记录观看历史失败: " + e.getMessage());
}
} }
/** /**
@ -896,73 +915,92 @@ public class RoomDetailActivity extends AppCompatActivity {
} }
private void bindRoom(Room r) { private void bindRoom(Room r) {
String title = r.getTitle() != null ? r.getTitle() : "直播间"; try {
String streamer = r.getStreamerName() != null ? r.getStreamerName() : "主播"; 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);
// 设置直播状态
if (r.isLive()) {
binding.liveTag.setVisibility(View.VISIBLE);
binding.offlineLayout.setVisibility(View.GONE);
// 在线人数已通过WebSocket实时更新这里只设置初始值 // 设置顶部标题栏
if (r.getViewerCount() > 0) { binding.topTitle.setText(title);
binding.topViewerCount.setText(String.valueOf(r.getViewerCount()));
// 设置房间信息区域
binding.roomTitle.setText(title);
binding.streamerName.setText(streamer);
// 设置直播状态
if (r.isLive()) {
binding.liveTag.setVisibility(View.VISIBLE);
binding.offlineLayout.setVisibility(View.GONE);
// 在线人数已通过WebSocket实时更新这里只设置初始值
if (r.getViewerCount() > 0) {
binding.topViewerCount.setText(String.valueOf(r.getViewerCount()));
}
// 如果后端返回的viewerCount为0保持UI不变等待WebSocket推送
} else {
binding.liveTag.setVisibility(View.GONE);
binding.offlineLayout.setVisibility(View.VISIBLE);
releasePlayer();
return;
} }
// 如果后端返回的viewerCount为0保持UI不变等待WebSocket推送
} else {
binding.liveTag.setVisibility(View.GONE);
binding.offlineLayout.setVisibility(View.VISIBLE);
releasePlayer();
return;
}
// 获取播放地址 // 获取播放地址
String playUrl = null; String playUrl = null;
String fallbackHlsUrl = null; String fallbackHlsUrl = null;
if (r.getStreamUrls() != null) { if (r.getStreamUrls() != null) {
// 优先使用HTTP-FLV延迟更低 // 优先使用HTTP-FLV延迟更低
playUrl = r.getStreamUrls().getFlv(); playUrl = r.getStreamUrls().getFlv();
fallbackHlsUrl = r.getStreamUrls().getHls(); fallbackHlsUrl = r.getStreamUrls().getHls();
if (TextUtils.isEmpty(playUrl)) playUrl = fallbackHlsUrl; if (TextUtils.isEmpty(playUrl)) playUrl = fallbackHlsUrl;
} }
if (!TextUtils.isEmpty(playUrl)) { if (!TextUtils.isEmpty(playUrl)) {
ensurePlayer(playUrl, fallbackHlsUrl); ensurePlayer(playUrl, fallbackHlsUrl);
} else { } else {
// 没有播放地址时显示离线状态 // 没有播放地址时显示离线状态
binding.offlineLayout.setVisibility(View.VISIBLE); binding.offlineLayout.setVisibility(View.VISIBLE);
releasePlayer(); releasePlayer();
}
} catch (Exception e) {
android.util.Log.e("RoomDetail", "bindRoom异常: " + e.getMessage(), e);
// 不要因为绑定失败就退出显示错误状态
if (binding != null && binding.offlineLayout != null) {
binding.offlineLayout.setVisibility(View.VISIBLE);
}
} }
} }
private void ensurePlayer(String url, String fallbackHlsUrl) { private void ensurePlayer(String url, String fallbackHlsUrl) {
if (TextUtils.isEmpty(url)) return; try {
if (TextUtils.isEmpty(url)) return;
if (url.endsWith(".flv")) { if (url.endsWith(".flv")) {
if (TextUtils.equals(ijkUrl, url) && ijkPlayer != null) return; if (TextUtils.equals(ijkUrl, url) && ijkPlayer != null) return;
startFlv(url, fallbackHlsUrl); startFlv(url, fallbackHlsUrl);
return; return;
}
if (player != null) {
MediaItem current = player.getCurrentMediaItem();
String currentUri = current != null && current.localConfiguration != null && current.localConfiguration.uri != null
? current.localConfiguration.uri.toString()
: null;
if (currentUri != null && currentUri.equals(url)) return;
}
startHls(url, null);
} catch (Exception e) {
android.util.Log.e("RoomDetail", "ensurePlayer异常: " + e.getMessage(), e);
// 播放器初始化失败显示离线状态
if (binding != null && binding.offlineLayout != null) {
binding.offlineLayout.setVisibility(View.VISIBLE);
}
} }
if (player != null) {
MediaItem current = player.getCurrentMediaItem();
String currentUri = current != null && current.localConfiguration != null && current.localConfiguration.uri != null
? current.localConfiguration.uri.toString()
: null;
if (currentUri != null && currentUri.equals(url)) return;
}
startHls(url, null);
} }
// 防止重复显示连接消息
private boolean hasShownConnectedMessage = false;
private void startHls(String url, @Nullable String altUrl) { private void startHls(String url, @Nullable String altUrl) {
releaseIjkPlayer(); releaseIjkPlayer();
if (binding != null) { if (binding != null) {
@ -972,46 +1010,95 @@ public class RoomDetailActivity extends AppCompatActivity {
releaseExoPlayer(); releaseExoPlayer();
triedAltUrl = false; triedAltUrl = false;
hasShownConnectedMessage = false; // 重置连接消息标志
ExoPlayer exo = new ExoPlayer.Builder(this) // 优化缓冲配置 - 增大缓冲区以减少卡顿
.setLoadControl(new androidx.media3.exoplayer.DefaultLoadControl.Builder() // HLS 直播通常有 2-3 秒的切片延迟需要足够的缓冲
.setBufferDurationsMs( androidx.media3.exoplayer.DefaultLoadControl loadControl =
1000, new androidx.media3.exoplayer.DefaultLoadControl.Builder()
3000, .setBufferDurationsMs(
500, 10000, // 最小缓冲 10秒保证流畅播放
1000 30000, // 最大缓冲 30秒足够应对网络波动
) 5000, // 播放前缓冲 5秒确保有足够数据再开始
.build()) 10000 // 重新缓冲 10秒卡顿后充分缓冲再继续
)
.setPrioritizeTimeOverSizeThresholds(true)
.build(); .build();
// 创建播放器
ExoPlayer exo = new ExoPlayer.Builder(this)
.setLoadControl(loadControl)
.build();
// 设置播放器视图
binding.playerView.setPlayer(exo); binding.playerView.setPlayer(exo);
binding.playerView.setUseController(true);
binding.playerView.setControllerAutoShow(false);
String computedAltUrl = altUrl; String computedAltUrl = altUrl;
if (TextUtils.isEmpty(computedAltUrl)) computedAltUrl = getAltHlsUrl(url); if (TextUtils.isEmpty(computedAltUrl)) computedAltUrl = getAltHlsUrl(url);
String finalComputedAltUrl = computedAltUrl; String finalComputedAltUrl = computedAltUrl;
final String finalUrl = url;
exo.addListener(new Player.Listener() { exo.addListener(new Player.Listener() {
private int retryCount = 0;
private static final int MAX_RETRY = 3;
@Override @Override
public void onPlayerError(PlaybackException error) { public void onPlayerError(PlaybackException error) {
if (triedAltUrl || TextUtils.isEmpty(finalComputedAltUrl)) { android.util.Log.e("ExoPlayer", "播放错误: " + error.getMessage());
binding.offlineLayout.setVisibility(View.VISIBLE);
// 先尝试备用地址
if (!triedAltUrl && !TextUtils.isEmpty(finalComputedAltUrl)) {
triedAltUrl = true;
android.util.Log.d("ExoPlayer", "尝试备用地址: " + finalComputedAltUrl);
exo.setMediaItem(MediaItem.fromUri(finalComputedAltUrl));
exo.prepare();
exo.setPlayWhenReady(true);
return; return;
} }
triedAltUrl = true;
exo.setMediaItem(MediaItem.fromUri(finalComputedAltUrl)); // 自动重试
exo.prepare(); if (retryCount < MAX_RETRY) {
exo.setPlayWhenReady(true); retryCount++;
android.util.Log.d("ExoPlayer", "自动重试 " + retryCount + "/" + MAX_RETRY);
handler.postDelayed(() -> {
if (!isFinishing() && !isDestroyed()) {
exo.setMediaItem(MediaItem.fromUri(finalUrl));
exo.prepare();
exo.setPlayWhenReady(true);
}
}, 2000);
return;
}
// 重试失败显示离线状态
binding.offlineLayout.setVisibility(View.VISIBLE);
handler.postDelayed(() -> {
if (!isFinishing() && !isDestroyed()) {
fetchRoom();
}
}, 5000);
} }
@Override @Override
public void onPlaybackStateChanged(int playbackState) { public void onPlaybackStateChanged(int playbackState) {
if (playbackState == Player.STATE_READY) { if (playbackState == Player.STATE_READY) {
binding.offlineLayout.setVisibility(View.GONE); binding.offlineLayout.setVisibility(View.GONE);
addChatMessage(new ChatMessage("直播已连接,开始观看吧!", true)); retryCount = 0;
// 只显示一次连接消息
if (!hasShownConnectedMessage) {
hasShownConnectedMessage = true;
addChatMessage(new ChatMessage("直播已连接,开始观看吧!", true));
}
} else if (playbackState == Player.STATE_BUFFERING) {
android.util.Log.d("ExoPlayer", "正在缓冲...");
} }
} }
}); });
android.util.Log.d("ExoPlayer", "开始播放: " + url);
exo.setMediaItem(MediaItem.fromUri(url)); exo.setMediaItem(MediaItem.fromUri(url));
exo.prepare(); exo.prepare();
exo.setPlayWhenReady(true); exo.setPlayWhenReady(true);
@ -1019,47 +1106,16 @@ public class RoomDetailActivity extends AppCompatActivity {
} }
private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) { private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) {
ensureIjkLibsLoaded(); android.util.Log.d("RoomDetail", "开始播放FLV流: " + flvUrl);
releaseExoPlayer();
releaseIjkPlayer(); // 直接使用 HLS 播放避免 IjkPlayer 崩溃问题
// HLS 虽然延迟稍高但稳定性更好
ijkUrl = flvUrl; String hlsUrl = fallbackHlsUrl;
ijkFallbackHlsUrl = fallbackHlsUrl; if (TextUtils.isEmpty(hlsUrl)) {
ijkFallbackTried = false; hlsUrl = flvUrl.replace(".flv", ".m3u8");
if (binding != null) {
binding.playerView.setVisibility(View.GONE);
binding.flvTextureView.setVisibility(View.VISIBLE);
}
TextureView view = binding.flvTextureView;
TextureView.SurfaceTextureListener listener = new TextureView.SurfaceTextureListener() {
@Override
public void onSurfaceTextureAvailable(@NonNull android.graphics.SurfaceTexture surfaceTexture, int width, int height) {
ijkSurface = new Surface(surfaceTexture);
prepareIjk(flvUrl);
}
@Override
public void onSurfaceTextureSizeChanged(@NonNull android.graphics.SurfaceTexture surface, int width, int height) {
}
@Override
public boolean onSurfaceTextureDestroyed(@NonNull android.graphics.SurfaceTexture surface) {
releaseIjkPlayer();
return true;
}
@Override
public void onSurfaceTextureUpdated(@NonNull android.graphics.SurfaceTexture surface) {
}
};
view.setSurfaceTextureListener(listener);
if (view.isAvailable() && view.getSurfaceTexture() != null) {
ijkSurface = new Surface(view.getSurfaceTexture());
prepareIjk(flvUrl);
} }
android.util.Log.d("RoomDetail", "使用 HLS 播放: " + hlsUrl);
startHls(hlsUrl, null);
} }
private void prepareIjk(String url) { private void prepareIjk(String url) {
@ -1067,36 +1123,39 @@ public class RoomDetailActivity extends AppCompatActivity {
IjkMediaPlayer p = new IjkMediaPlayer(); IjkMediaPlayer p = new IjkMediaPlayer();
// 优化缓冲设置减少卡顿 // 优化缓冲设置减少卡顿
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "packet-buffering", 1); // 开启缓冲 p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "packet-buffering", 1);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "start-on-prepared", 1); p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "start-on-prepared", 1);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "fflags", "nobuffer"); p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "fflags", "nobuffer");
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "analyzeduration", 100000); // 100ms分析时长 p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "analyzeduration", 100000);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "probesize", 10240); // 10KB探测大小 p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "probesize", 10240);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "framedrop", 5); // 允许丢帧 p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "framedrop", 5);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "max_cached_duration", 3000); // 3秒缓存 p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "max_cached_duration", 3000);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "min_frames", 50); // 最小缓冲帧数 p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "min_frames", 50);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "infbuf", 0); // 关闭无限缓冲 p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "infbuf", 0);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "reconnect", 1); // 断线重连 p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "reconnect", 1);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "reconnect_streamed", 1); p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "reconnect_streamed", 1);
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "reconnect_delay_max", 5); // 最大重连延迟5秒 p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "reconnect_delay_max", 5);
p.setOnPreparedListener(mp -> { p.setOnPreparedListener(mp -> {
binding.offlineLayout.setVisibility(View.GONE); binding.offlineLayout.setVisibility(View.GONE);
mp.start(); mp.start();
addChatMessage(new ChatMessage("直播已连接,开始观看吧!", true)); // 只显示一次连接消息
}); if (!hasShownConnectedMessage) {
hasShownConnectedMessage = true;
addChatMessage(new ChatMessage("直播已连接,开始观看吧!", true));
}
});
p.setOnErrorListener((IMediaPlayer mp, int what, int extra) -> { p.setOnErrorListener((IMediaPlayer mp, int what, int extra) -> {
android.util.Log.e("IjkPlayer", "播放错误: what=" + what + ", extra=" + extra); android.util.Log.e("IjkPlayer", "播放错误: what=" + what + ", extra=" + extra);
if (ijkFallbackTried || TextUtils.isEmpty(ijkFallbackHlsUrl)) { if (ijkFallbackTried || TextUtils.isEmpty(ijkFallbackHlsUrl)) {
binding.offlineLayout.setVisibility(View.VISIBLE); binding.offlineLayout.setVisibility(View.VISIBLE);
// 5秒后尝试重新连接 handler.postDelayed(() -> {
handler.postDelayed(() -> { if (!isFinishing() && !isDestroyed() && room != null && room.isLive()) {
if (!isFinishing() && !isDestroyed() && room != null && room.isLive()) { fetchRoom();
fetchRoom(); // 重新获取房间信息并播放 }
} }, 5000);
}, 5000); return true;
return true;
} }
ijkFallbackTried = true; ijkFallbackTried = true;
startHls(ijkFallbackHlsUrl, null); startHls(ijkFallbackHlsUrl, null);
@ -1133,14 +1192,34 @@ public class RoomDetailActivity extends AppCompatActivity {
} }
} }
private static boolean ijkLibLoadFailed = false;
private static void ensureIjkLibsLoaded() { private static void ensureIjkLibsLoaded() {
if (ijkLibLoaded) return; if (ijkLibLoaded || ijkLibLoadFailed) return;
try { try {
// 检查设备 CPU 架构
String[] abis = android.os.Build.SUPPORTED_ABIS;
boolean supported = false;
for (String abi : abis) {
if ("armeabi-v7a".equals(abi) || "arm64-v8a".equals(abi)) {
supported = true;
break;
}
}
if (!supported) {
android.util.Log.w("IjkPlayer", "设备 CPU 架构不支持 IjkPlayer: " + java.util.Arrays.toString(abis));
ijkLibLoadFailed = true;
return;
}
IjkMediaPlayer.loadLibrariesOnce(null); IjkMediaPlayer.loadLibrariesOnce(null);
IjkMediaPlayer.native_profileBegin("libijkplayer.so"); IjkMediaPlayer.native_profileBegin("libijkplayer.so");
} catch (Throwable ignored) { ijkLibLoaded = true;
android.util.Log.d("IjkPlayer", "IjkPlayer 库加载成功");
} catch (Throwable e) {
android.util.Log.e("IjkPlayer", "加载IjkPlayer库失败: " + e.getMessage());
ijkLibLoadFailed = true;
} }
ijkLibLoaded = true;
} }
private void releasePlayer() { private void releasePlayer() {

View File

@ -81,6 +81,7 @@ public class CallManager implements CallSignalingClient.SignalingListener {
/** /**
* 连接信令服务器 * 连接信令服务器
* 通话服务始终使用远程服务器地址
*/ */
public void connect(int userId) { public void connect(int userId) {
Log.d(TAG, "connect() called, userId: " + userId); Log.d(TAG, "connect() called, userId: " + userId);
@ -88,8 +89,9 @@ public class CallManager implements CallSignalingClient.SignalingListener {
Log.d(TAG, "已经连接,跳过"); Log.d(TAG, "已经连接,跳过");
return; return;
} }
String baseUrl = ApiClient.getCurrentBaseUrl(context); // 通话服务使用独立的远程服务器地址
Log.d(TAG, "连接信令服务器baseUrl: " + baseUrl); String baseUrl = WebRTCConfig.getLiveServerUrl();
Log.d(TAG, "连接信令服务器远程baseUrl: " + baseUrl);
signalingClient = new CallSignalingClient(baseUrl, userId); signalingClient = new CallSignalingClient(baseUrl, userId);
signalingClient.setListener(this); signalingClient.setListener(this);
signalingClient.connect(); signalingClient.connect();

View File

@ -1,24 +1,23 @@
package com.example.livestreaming.call; package com.example.livestreaming.call;
import com.example.livestreaming.BuildConfig;
/** /**
* WebRTC 配置 * WebRTC 配置
* 部署时修改这里的服务器地址 * 直播/通话服务始终连接远程服务器
* 配置在 local.properties 中修改
*/ */
public class WebRTCConfig { public class WebRTCConfig {
// ============ STUN 服务器 ============ // ============ STUN 服务器 ============
// STUN 用于获取设备的公网IP帮助建立P2P连接
public static final String[] STUN_SERVERS = { public static final String[] STUN_SERVERS = {
"stun:stun.l.google.com:19302", // Google STUN国内可能不稳定 "stun:stun.l.google.com:19302",
"stun:stun.qq.com:3478", // 腾讯 STUN国内推荐 "stun:stun.qq.com:3478",
"stun:stun.miwifi.com:3478" // 小米 STUN国内推荐 "stun:stun.miwifi.com:3478"
}; };
// ============ TURN 服务器 ============ // ============ TURN 服务器从BuildConfig读取============
// TURN 用于在P2P连接失败时进行中继转发 public static final String TURN_SERVER_URL = "turn:" + BuildConfig.TURN_SERVER_HOST + ":" + BuildConfig.TURN_SERVER_PORT;
// 你的服务器TURN地址
public static final String TURN_SERVER_URL = "turn:1.15.149.240:3478";
// TURN 服务器用户名 // TURN 服务器用户名
public static final String TURN_USERNAME = "turnuser"; public static final String TURN_USERNAME = "turnuser";
@ -26,27 +25,12 @@ public class WebRTCConfig {
// TURN 服务器密码 // TURN 服务器密码
public static final String TURN_PASSWORD = "TurnPass123456"; public static final String TURN_PASSWORD = "TurnPass123456";
// ============ 使用说明 ============ // ============ 直播/通话服务地址 ============
/* public static String getLiveServerUrl() {
* 局域网测试 return "http://" + BuildConfig.LIVE_SERVER_HOST + ":" + BuildConfig.LIVE_SERVER_PORT + "/";
* - 不需要修改当前配置即可使用 }
*
* 部署到公网服务器 public static String getLiveWsUrl() {
* 1. 在服务器安装 coturn (TURN服务器) return "ws://" + BuildConfig.LIVE_SERVER_HOST + ":" + BuildConfig.LIVE_SERVER_PORT + "/";
* 2. 修改上面的配置 }
* TURN_SERVER_URL = "turn:你的服务器IP:3478"
* TURN_USERNAME = "你设置的用户名"
* TURN_PASSWORD = "你设置的密码"
*
* 宝塔安装 coturn 步骤
* 1. SSH执行: yum install -y coturn (CentOS) apt install -y coturn (Ubuntu)
* 2. 编辑 /etc/turnserver.conf:
* listening-port=3478
* external-ip=你的公网IP
* realm=你的公网IP
* lt-cred-mech
* user=用户名:密码
* 3. 宝塔放行端口: 3478(TCP/UDP), 49152-65535(UDP)
* 4. 启动: systemctl start coturn
*/
} }

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
<solid android:color="#FFF8E1" />
<corners android:radius="8dp" />
<stroke android:width="1dp" android:color="#FFE082" />
</shape>

60
friend_tables.sql Normal file
View File

@ -0,0 +1,60 @@
-- 好友系统数据库表
-- 请在服务器数据库中执行此脚本
-- 1. 好友关系表
CREATE TABLE IF NOT EXISTS `eb_friend` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` int(11) NOT NULL COMMENT '用户ID',
`friend_id` int(11) NOT NULL COMMENT '好友ID',
`remark` varchar(50) DEFAULT NULL COMMENT '好友备注',
`status` int(11) NOT NULL DEFAULT 1 COMMENT '状态1-正常 0-已删除',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_friend` (`user_id`, `friend_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_friend_id` (`friend_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='好友关系表';
-- 2. 好友请求表
CREATE TABLE IF NOT EXISTS `eb_friend_request` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`from_user_id` int(11) NOT NULL COMMENT '发起请求的用户ID',
`to_user_id` int(11) NOT NULL COMMENT '接收请求的用户ID',
`message` varchar(200) DEFAULT NULL COMMENT '验证消息',
`status` int(11) NOT NULL DEFAULT 0 COMMENT '状态0-待处理 1-已接受 2-已拒绝',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '处理时间',
PRIMARY KEY (`id`),
KEY `idx_from_user` (`from_user_id`),
KEY `idx_to_user` (`to_user_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='好友请求表';
-- 3. 用户黑名单表
CREATE TABLE IF NOT EXISTS `eb_user_blacklist` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user_id` int(11) NOT NULL COMMENT '用户ID',
`blocked_user_id` int(11) NOT NULL COMMENT '被拉黑的用户ID',
`reason` varchar(200) DEFAULT NULL COMMENT '拉黑原因',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_blocked` (`user_id`, `blocked_user_id`),
KEY `idx_user_id` (`user_id`),
KEY `idx_blocked_user_id` (`blocked_user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户黑名单表';
-- 4. 私聊会话表(如果不存在)
CREATE TABLE IF NOT EXISTS `eb_conversation` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`user1_id` int(11) NOT NULL COMMENT '用户1 ID较小的ID',
`user2_id` int(11) NOT NULL COMMENT '用户2 ID较大的ID',
`last_message_id` bigint(20) DEFAULT NULL COMMENT '最后一条消息ID',
`last_message_time` datetime DEFAULT NULL COMMENT '最后消息时间',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_users` (`user1_id`, `user2_id`),
KEY `idx_user1` (`user1_id`),
KEY `idx_user2` (`user2_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='私聊会话表';

177
环境配置指南.md Normal file
View File

@ -0,0 +1,177 @@
# 环境配置指南
本项目采用分离配置架构:
- **普通业务功能**(用户、消息、商城等)→ 可切换本地/远程
- **直播/通话服务** → 始终连接远程服务器
---
## 一、APP 配置
### 配置文件位置
`android-app/local.properties`
### 配置说明
```properties
# ============ 主API地址普通业务功能============
# 本地开发时使用本地地址
api.base_url_emulator=http://10.0.2.2:8081/
api.base_url_device=http://192.168.1.164:8081/
# ============ 直播/通话服务地址(始终远程)============
live.server_host=1.15.149.240
live.server_port=8083
turn.server_host=1.15.149.240
turn.server_port=3478
```
### 切换环境
#### 本地开发(默认)
```properties
api.base_url_emulator=http://10.0.2.2:8081/
api.base_url_device=http://192.168.1.164:8081/
```
#### 全部连接远程
```properties
api.base_url_emulator=http://1.15.149.240:8083/
api.base_url_device=http://1.15.149.240:8083/
```
### 修改后需要重新编译
```bash
cd android-app
.\gradlew assembleRelease
```
---
## 二、前端管理端配置
### 配置文件位置
- 开发环境:`Zhibo/admin/.env.development`
- 生产环境:`Zhibo/admin/.env.production`
### 开发环境(本地)
```env
ENV = 'development'
VUE_APP_BASE_API = 'http://127.0.0.1:30001'
```
### 生产环境(远程)
```env
ENV = 'production'
VUE_APP_BASE_API = ''
```
> 生产环境使用空字符串由Nginx代理到后端
---
## 三、后端配置
### 配置文件位置
`Zhibo/zhibo-h/crmeb-front/src/main/resources/application.yml`
### 关键配置
```yaml
server:
port: 8081 # 本地开发端口
spring:
datasource:
url: jdbc:mysql://1.15.149.240:3306/zhibo # 数据库地址
redis:
host: 127.0.0.1 # Redis地址
```
### 服务器启动脚本
```bash
#!/bin/bash
JAR_PATH="/www/wwwroot/1.15.149.240_30002/Jar"
# Front API (带SRS配置)
nohup java -Xms512m -Xmx1024m -jar ${JAR_PATH}/Crmeb-front.jar \
--server.port=8083 \
--spring.redis.host=127.0.0.1 \
--LIVE_PUBLIC_SRS_HOST=1.15.149.240 \
--LIVE_PUBLIC_SRS_RTMP_PORT=25002 \
--LIVE_PUBLIC_SRS_HTTP_PORT=25003 \
> ${JAR_PATH}/logs/front.log 2>&1 &
```
---
## 四、服务架构图
```
┌─────────────────────────────────────────────────────────────┐
│ 远程服务器 1.15.149.240 │
├─────────────────────────────────────────────────────────────┤
│ Front API (8083) │
│ ├── WebSocket: /ws/call (通话信令) ←── APP直连 │
│ ├── WebSocket: /ws/live/* (直播弹幕) ←── APP直连 │
│ └── REST API: /api/front/* (可选) │
│ │
│ SRS流媒体服务器 │
│ ├── RTMP: 25002 │
│ └── HTTP-FLV: 25003 │
│ │
│ TURN服务器: 3478 │
└─────────────────────────────────────────────────────────────┘
直播/通话服务 │
─────────────────────┼─────────────────────
普通业务功能 │
┌─────────────────────────────────────────────────────────────┐
│ 本地开发环境 │
├─────────────────────────────────────────────────────────────┤
│ Front API (8081) │
│ └── REST API: /api/front/* ←── APP连接 │
│ │
│ Admin API (30001) │
│ └── REST API: /api/admin/* ←── 管理前端连接 │
└─────────────────────────────────────────────────────────────┘
```
---
## 五、快速切换命令
### APP切换到全远程
编辑 `android-app/local.properties`
```properties
api.base_url_device=http://1.15.149.240:8083/
```
### APP切换到本地开发
编辑 `android-app/local.properties`
```properties
api.base_url_device=http://192.168.1.164:8081/
```
### 前端切换
```bash
# 开发模式(连本地)
npm run dev
# 生产构建(连远程)
npm run build:prod
```
---
## 六、端口清单
| 服务 | 本地端口 | 远程端口 | 说明 |
|------|----------|----------|------|
| Front API | 8081 | 8083 | APP主API |
| Admin API | 30001 | 30003 | 管理后台API |
| SRS RTMP | - | 25002 | 直播推流 |
| SRS HTTP | - | 25003 | 直播播放 |
| TURN | - | 3478 | 视频通话中继 |
| MySQL | - | 3306 | 数据库 |
| Redis | 6379 | 6379 | 缓存 |

141
环境配置汇总.md Normal file
View File

@ -0,0 +1,141 @@
# 环境配置汇总
## 一、Android APP 配置
### 1. 主配置文件:`android-app/local.properties`
```properties
# ============ API 服务器地址配置 ============
# 主API地址 - 模拟器使用
api.base_url_emulator=http://1.15.149.240:8083/
# 主API地址 - 真机使用
api.base_url_device=http://1.15.149.240:8083/
# ============ 直播/通话服务地址配置 ============
# 直播/通话 WebSocket 服务地址
live.server_host=1.15.149.240
live.server_port=8083
# TURN 服务器地址(视频通话中继)
turn.server_host=1.15.149.240
turn.server_port=3478
```
**说明**
- `api.base_url_emulator` - 模拟器运行时使用的API地址
- `api.base_url_device` - 真机运行时使用的API地址
- `live.server_host/port` - 直播和通话WebSocket服务地址
- `turn.server_host/port` - WebRTC TURN中继服务器地址
### 2. 相关代码文件无需修改自动读取local.properties
| 文件 | 作用 |
|------|------|
| `app/build.gradle.kts` | 读取local.properties生成BuildConfig |
| `ApiConfig.java` | 提供统一的API地址获取方法 |
| `ApiClient.java` | 网络请求客户端,自动选择模拟器/真机地址 |
| `WebRTCConfig.java` | WebRTC/TURN服务器配置 |
---
## 二、前端 Admin 配置
### 1. 开发环境:`Zhibo/admin/.env.development`
```properties
ENV = 'development'
# 本地开发时连接本地后端
VUE_APP_BASE_API = 'http://127.0.0.1:30001'
```
### 2. 生产环境:`Zhibo/admin/.env.production`
```properties
ENV = 'production'
# 生产环境使用相对路径由Nginx代理
VUE_APP_BASE_API = ''
```
### 3. 预发布环境:`Zhibo/admin/.env.staging`
```properties
ENV = 'production'
VUE_APP_BASE_API = 'http://192.168.31.35:2500'
```
**说明**
- 开发环境:`npm run dev` 使用 `.env.development`
- 生产打包:`npm run build:prod` 使用 `.env.production`
- 预发布打包:`npm run build:stage` 使用 `.env.staging`
---
## 三、环境切换指南
### 切换到生产环境(服务器 1.15.149.240
**Android APP**
```properties
# android-app/local.properties
api.base_url_emulator=http://1.15.149.240:8083/
api.base_url_device=http://1.15.149.240:8083/
live.server_host=1.15.149.240
live.server_port=8083
turn.server_host=1.15.149.240
turn.server_port=3478
```
**前端 Admin**
```properties
# Zhibo/admin/.env.production
VUE_APP_BASE_API = ''
```
然后执行 `npm run build:prod`
---
### 切换到本地开发环境
**Android APP**
```properties
# android-app/local.properties
api.base_url_emulator=http://10.0.2.2:8081/
api.base_url_device=http://192.168.x.x:8081/
live.server_host=1.15.149.240
live.server_port=8083
turn.server_host=1.15.149.240
turn.server_port=3478
```
> 注意:直播/通话服务建议始终使用远程服务器
**前端 Admin**
```properties
# Zhibo/admin/.env.development
VUE_APP_BASE_API = 'http://127.0.0.1:30001'
```
然后执行 `npm run dev`
---
## 四、服务端口说明
| 服务 | 端口 | 说明 |
|------|------|------|
| Admin API | 30003 | 管理后台API |
| Front API | 8083 | 前端/APP API |
| Admin 前端 | 30002 | 管理后台网页 |
| TURN 服务 | 3478 | WebRTC中继 |
| SRS RTMP | 25002 | 直播推流 |
| SRS HTTP | 25003 | 直播拉流 |
---
## 五、修改后需要的操作
### Android APP
1. 修改 `local.properties`
2. 重新编译:`./gradlew assembleRelease`
### 前端 Admin
1. 修改对应的 `.env.*` 文件
2. 重新打包:`npm run build:prod`
3. 上传 `dist` 目录到服务器