更新:许愿树对话框、通话功能、环境配置等多项优化
This commit is contained in:
parent
20f2e342ce
commit
750f4c8c1e
|
|
@ -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: '数据统计' },
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
# 配置的环境
|
# 配置的环境
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
5
android-app/app/src/main/res/drawable/bg_btn_cancel.xml
Normal file
5
android-app/app/src/main/res/drawable/bg_btn_cancel.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="#F0F0F0" />
|
||||||
|
<corners android:radius="22dp" />
|
||||||
|
</shape>
|
||||||
5
android-app/app/src/main/res/drawable/bg_btn_primary.xml
Normal file
5
android-app/app/src/main/res/drawable/bg_btn_primary.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="#FF6B9D" />
|
||||||
|
<corners android:radius="22dp" />
|
||||||
|
</shape>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="#FFFFFF" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
</shape>
|
||||||
6
android-app/app/src/main/res/drawable/bg_edit_text.xml
Normal file
6
android-app/app/src/main/res/drawable/bg_edit_text.xml
Normal 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="#F5F5F5" />
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
<stroke android:width="1dp" android:color="#E0E0E0" />
|
||||||
|
</shape>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="16dp">
|
android:background="@drawable/bg_dialog_rounded"
|
||||||
|
android:padding="20dp">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
|
|
@ -11,6 +12,7 @@
|
||||||
android:text="许下你的愿望"
|
android:text="许下你的愿望"
|
||||||
android:textSize="18sp"
|
android:textSize="18sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
|
android:textColor="#333333"
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:layout_marginBottom="16dp" />
|
android:layout_marginBottom="16dp" />
|
||||||
|
|
||||||
|
|
@ -20,5 +22,49 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:hint="写下你的愿望..."
|
android:hint="写下你的愿望..."
|
||||||
android:minLines="3"
|
android:minLines="3"
|
||||||
android:gravity="top" />
|
android:maxLength="50"
|
||||||
|
android:gravity="top"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:background="@drawable/bg_edit_text" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvCharCount"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="0/50"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:textColor="#999999"
|
||||||
|
android:gravity="end"
|
||||||
|
android:layout_marginTop="4dp" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginTop="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/btnCancel"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="取消"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textColor="#666666"
|
||||||
|
android:gravity="center"
|
||||||
|
android:background="@drawable/bg_btn_cancel"
|
||||||
|
android:layout_marginEnd="8dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/btnMakeWish"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="许愿"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:gravity="center"
|
||||||
|
android:background="@drawable/bg_btn_primary"
|
||||||
|
android:layout_marginStart="8dp" />
|
||||||
|
</LinearLayout>
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
|
||||||
74
android-app/app/src/main/res/layout/dialog_view_wish.xml
Normal file
74
android-app/app/src/main/res/layout/dialog_view_wish.xml
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@drawable/bg_dialog_rounded"
|
||||||
|
android:padding="20dp">
|
||||||
|
|
||||||
|
<RelativeLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="我的心愿"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:textColor="#333333"
|
||||||
|
android:gravity="center" />
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/btnClose"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:layout_alignParentEnd="true"
|
||||||
|
android:src="@android:drawable/ic_menu_close_clear_cancel"
|
||||||
|
android:contentDescription="关闭" />
|
||||||
|
</RelativeLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvWishContent"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="20dp"
|
||||||
|
android:layout_marginBottom="20dp"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textColor="#333333"
|
||||||
|
android:gravity="center"
|
||||||
|
android:minHeight="60dp"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:background="@drawable/bg_wish_content" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:layout_marginTop="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/btnDeleteWish"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="删除心愿"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textColor="#FF6B6B"
|
||||||
|
android:gravity="center"
|
||||||
|
android:background="@drawable/bg_btn_cancel"
|
||||||
|
android:layout_marginEnd="8dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/btnCompleteWish"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="愿望达成"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:gravity="center"
|
||||||
|
android:background="@drawable/bg_btn_primary"
|
||||||
|
android:layout_marginStart="8dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
</LinearLayout>
|
||||||
60
friend_tables.sql
Normal file
60
friend_tables.sql
Normal 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
177
环境配置指南.md
Normal 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
141
环境配置汇总.md
Normal 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` 目录到服务器
|
||||||
Loading…
Reference in New Issue
Block a user