From f3169b2eba033a53269d75993797f91c44d20ec3 Mon Sep 17 00:00:00 2001 From: xiao12feng8 <16507319+xiao12feng8@user.noreply.gitee.com> Date: Sat, 3 Jan 2026 12:24:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=EF=BC=9A=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E5=92=8C=E4=B8=BB=E6=92=AD=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Zhibo/admin/src/api/room.js | 37 + Zhibo/admin/src/router/modules/liveManage.js | 14 +- Zhibo/admin/src/views/room/list/index.vue | 418 +++++++--- .../zbkj/admin/controller/RoomController.java | 158 +++- .../common/model/live/LiveRoomCategory.java | 48 ++ .../common/token/FrontTokenComponent.java | 23 +- .../front/controller/LiveRoomController.java | 8 +- .../front/controller/StreamerController.java | 240 +++++- .../zbkj/service/dao/LiveRoomCategoryDao.java | 10 + .../service/LiveRoomCategoryService.java | 17 + .../impl/LiveRoomCategoryServiceImpl.java | 26 + android-app/app/build.gradle.kts | 3 + android-app/app/src/main/AndroidManifest.xml | 13 + .../livestreaming/BroadcastActivity.java | 777 ++++++++++++++++++ .../example/livestreaming/LoginActivity.java | 31 +- .../example/livestreaming/MainActivity.java | 83 +- .../example/livestreaming/PlayerActivity.java | 141 +++- .../livestreaming/ProfileActivity.java | 81 +- .../livestreaming/RoomDetailActivity.java | 131 ++- .../livestreaming/SettingsPageActivity.java | 43 + .../livestreaming/StreamerCenterActivity.java | 226 +++++ .../example/livestreaming/net/ApiService.java | 20 + .../example/livestreaming/net/AuthStore.java | 56 ++ .../livestreaming/net/CreateRoomRequest.java | 89 +- .../src/main/res/drawable/bg_circle_red.xml | 1 - .../drawable/bg_circle_semi_transparent.xml | 5 + .../main/res/drawable/bg_gradient_bottom.xml | 7 + .../src/main/res/drawable/bg_gradient_top.xml | 7 + .../main/res/drawable/bg_purple_button.xml | 6 + .../drawable/bg_rounded_semi_transparent.xml | 5 + .../app/src/main/res/drawable/ic_close_24.xml | 3 +- .../app/src/main/res/drawable/ic_live_24.xml | 16 + .../src/main/res/drawable/ic_person_24.xml | 4 +- .../src/main/res/drawable/ic_settings_24.xml | 10 + .../main/res/drawable/ic_switch_camera_24.xml | 10 + .../main/res/layout/activity_broadcast.xml | 183 +++++ .../src/main/res/layout/activity_profile.xml | 21 +- .../res/layout/activity_streamer_center.xml | 362 ++++++++ delete_room_menus.sql | 11 + live-streaming/docker/srs/srs.conf | 21 +- live-streaming/docker启动配置文件.md | 54 ++ room_category_table.sql | 22 + 服务管理指南.md | 228 +++++ 43 files changed, 3425 insertions(+), 244 deletions(-) create mode 100644 Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/live/LiveRoomCategory.java create mode 100644 Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/dao/LiveRoomCategoryDao.java create mode 100644 Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/LiveRoomCategoryService.java create mode 100644 Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/LiveRoomCategoryServiceImpl.java create mode 100644 android-app/app/src/main/java/com/example/livestreaming/BroadcastActivity.java create mode 100644 android-app/app/src/main/java/com/example/livestreaming/StreamerCenterActivity.java create mode 100644 android-app/app/src/main/res/drawable/bg_circle_semi_transparent.xml create mode 100644 android-app/app/src/main/res/drawable/bg_gradient_bottom.xml create mode 100644 android-app/app/src/main/res/drawable/bg_gradient_top.xml create mode 100644 android-app/app/src/main/res/drawable/bg_purple_button.xml create mode 100644 android-app/app/src/main/res/drawable/bg_rounded_semi_transparent.xml create mode 100644 android-app/app/src/main/res/drawable/ic_live_24.xml create mode 100644 android-app/app/src/main/res/drawable/ic_settings_24.xml create mode 100644 android-app/app/src/main/res/drawable/ic_switch_camera_24.xml create mode 100644 android-app/app/src/main/res/layout/activity_broadcast.xml create mode 100644 android-app/app/src/main/res/layout/activity_streamer_center.xml create mode 100644 delete_room_menus.sql create mode 100644 live-streaming/docker启动配置文件.md create mode 100644 room_category_table.sql create mode 100644 服务管理指南.md diff --git a/Zhibo/admin/src/api/room.js b/Zhibo/admin/src/api/room.js index afd84d9d..cbd6998a 100644 --- a/Zhibo/admin/src/api/room.js +++ b/Zhibo/admin/src/api/room.js @@ -147,3 +147,40 @@ export function roomUpdateApi(data) { data }) } + +// ========== 房间分类管理 ========== + +// 分类列表 +export function roomCategoryListApi(params) { + return request({ + url: '/admin/room/category/list', + method: 'get', + params + }) +} + +// 保存分类(新增/编辑) +export function roomCategorySaveApi(data) { + return request({ + url: '/admin/room/category/save', + method: 'post', + data + }) +} + +// 删除分类 +export function roomCategoryDeleteApi(id) { + return request({ + url: `/admin/room/category/delete/${id}`, + method: 'post' + }) +} + +// 更新分类状态 +export function roomCategoryUpdateStatusApi(id, status) { + return request({ + url: '/admin/room/category/updateStatus', + method: 'post', + params: { id, status } + }) +} diff --git a/Zhibo/admin/src/router/modules/liveManage.js b/Zhibo/admin/src/router/modules/liveManage.js index f646be0d..5547477f 100644 --- a/Zhibo/admin/src/router/modules/liveManage.js +++ b/Zhibo/admin/src/router/modules/liveManage.js @@ -15,25 +15,13 @@ const liveManageRouter = { icon: 'el-icon-video-camera', }, children: [ - // 房间管理 + // 房间管理(包含分类管理) { path: 'room/list', component: () => import('@/views/room/list/index'), name: 'RoomList', meta: { title: '房间列表', icon: '' }, }, - { - path: 'room/type', - component: () => import('@/views/room/type/index'), - name: 'RoomType', - meta: { title: '房间类型', icon: '' }, - }, - { - path: 'room/background', - component: () => import('@/views/room/background/index'), - name: 'RoomBackground', - meta: { title: '房间背景', icon: '' }, - }, // 家族管理 { path: 'family/list', diff --git a/Zhibo/admin/src/views/room/list/index.vue b/Zhibo/admin/src/views/room/list/index.vue index 722d1fdf..6dbfc3d2 100644 --- a/Zhibo/admin/src/views/room/list/index.vue +++ b/Zhibo/admin/src/views/room/list/index.vue @@ -2,100 +2,141 @@
- -
-
-
- 批量删除 - 新增直播间 -
- 刷新 -
- - - - - - - - - - - - - - - - - - - 查询 - - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
+ 刷新 +
+ + + + + + + + + + + + + + + 查询 + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+ + + + +
+
+ 新增分类 + 刷新 +
+ + + + + + + + + + + + + + + + + + + +
+
+ - - + + {{ detailData.id }} {{ detailData.title }} + {{ detailData.categoryName || '未分类' }} {{ detailData.streamerName }} {{ detailData.streamKey }} {{ detailData.isLive ? '直播中' : '未开播' }} {{ detailData.createTime }} - {{ detailData.streamUrls && detailData.streamUrls.rtmp }} - {{ detailData.streamUrls && detailData.streamUrls.flv }} - {{ detailData.streamUrls && detailData.streamUrls.hls }} + {{ detailData.streamUrls && detailData.streamUrls.rtmp }} + {{ detailData.streamUrls && detailData.streamUrls.flv }} + {{ detailData.streamUrls && detailData.streamUrls.hls }} @@ -117,34 +158,42 @@ - - - - - - - + + + + + + + + + - - - - + + + + + - - + + - + + + + + +
@@ -161,23 +210,52 @@ 创建
+ + + + + + + + + + + +
支持 Element UI 图标类名(如 el-icon-video-camera)或图片URL
+
+ + + + + + +
+ +
diff --git a/Zhibo/zhibo-h/crmeb-admin/src/main/java/com/zbkj/admin/controller/RoomController.java b/Zhibo/zhibo-h/crmeb-admin/src/main/java/com/zbkj/admin/controller/RoomController.java index 6db7353b..c0af23eb 100644 --- a/Zhibo/zhibo-h/crmeb-admin/src/main/java/com/zbkj/admin/controller/RoomController.java +++ b/Zhibo/zhibo-h/crmeb-admin/src/main/java/com/zbkj/admin/controller/RoomController.java @@ -5,6 +5,7 @@ import com.github.pagehelper.PageHelper; import com.github.pagehelper.PageInfo; import com.zbkj.common.model.live.LiveChat; import com.zbkj.common.model.live.LiveRoom; +import com.zbkj.common.model.live.LiveRoomCategory; import com.zbkj.common.model.room.Room; import com.zbkj.common.page.CommonPage; import com.zbkj.common.request.PageParamRequest; @@ -14,6 +15,7 @@ import com.zbkj.common.result.CommonResult; import com.zbkj.service.service.RoomService; import com.zbkj.service.service.LiveRoomService; import com.zbkj.service.service.LiveChatService; +import com.zbkj.service.service.LiveRoomCategoryService; import io.swagger.annotations.Api; import io.swagger.annotations.ApiOperation; import lombok.Data; @@ -48,6 +50,9 @@ public class RoomController { @Autowired private LiveChatService liveChatService; + @Autowired(required = false) + private LiveRoomCategoryService liveRoomCategoryService; + @Value("${LIVE_PUBLIC_SRS_HOST:}") private String publicHost; @@ -111,7 +116,17 @@ public class RoomController { public CommonResult createLiveRoom(@RequestBody @Validated LiveRoomCreateRequest body, HttpServletRequest request) { Integer uid = body.getUid() == null ? 0 : body.getUid(); - LiveRoom room = liveRoomService.createRoom(uid, body.getTitle(), body.getStreamerName()); + LiveRoom room = liveRoomService.createRoom( + uid, + body.getTitle(), + body.getStreamerName(), + null, + body.getCategoryId(), + null, + null, + null, + null + ); String host = resolveHost(request); int rtmpPort = parsePort(publicRtmpPort, 25002); int httpPort = parsePort(publicHttpPort, 25003); @@ -126,6 +141,7 @@ public class RoomController { if (room == null) return CommonResult.failed("房间不存在"); room.setTitle(body.getTitle()); room.setStreamerName(body.getStreamerName()); + room.setCategoryId(body.getCategoryId()); if (!liveRoomService.updateById(room)) { return CommonResult.failed("保存失败"); } @@ -199,7 +215,16 @@ public class RoomController { resp.setStreamerName(room.getStreamerName()); resp.setStreamKey(room.getStreamKey()); resp.setIsLive(room.getIsLive() != null && room.getIsLive() == 1); + resp.setCategoryId(room.getCategoryId()); resp.setCreateTime(room.getCreateTime()); + + // 获取分类名称 + if (room.getCategoryId() != null && liveRoomCategoryService != null) { + LiveRoomCategory category = liveRoomCategoryService.getById(room.getCategoryId()); + if (category != null) { + resp.setCategoryName(category.getName()); + } + } LiveRoomStreamUrlsResponse urls = new LiveRoomStreamUrlsResponse(); urls.setRtmp(String.format("rtmp://%s:%d/live/%s", host, rtmpPort, room.getStreamKey())); @@ -248,6 +273,8 @@ public class RoomController { private String streamerName; private String streamKey; private Boolean isLive; + private Integer categoryId; + private String categoryName; private Date createTime; private LiveRoomStreamUrlsResponse streamUrls; } @@ -261,6 +288,8 @@ public class RoomController { @javax.validation.constraints.NotBlank private String streamerName; + + private Integer categoryId; } @Data @@ -273,6 +302,8 @@ public class RoomController { @javax.validation.constraints.NotBlank private String streamerName; + + private Integer categoryId; } @Data @@ -281,4 +312,129 @@ public class RoomController { private String flv; private String hls; } + + // ==================== 房间分类管理 ==================== + + @ApiOperation(value = "分类列表") + @RequestMapping(value = "/category/list", method = RequestMethod.GET) + public CommonResult> getCategoryList() { + if (liveRoomCategoryService == null) { + return CommonResult.success(new java.util.ArrayList()); + } + List list = liveRoomCategoryService.list( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .orderByAsc(LiveRoomCategory::getSort) + .orderByDesc(LiveRoomCategory::getId) + ); + List respList = list.stream().map(cat -> { + LiveRoomCategoryResponse resp = new LiveRoomCategoryResponse(); + resp.setId(cat.getId()); + resp.setName(cat.getName()); + resp.setIcon(cat.getIcon()); + resp.setSort(cat.getSort()); + resp.setStatus(cat.getStatus()); + resp.setCreateTime(cat.getCreateTime()); + // 统计该分类下的房间数 + long count = liveRoomService.count( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(LiveRoom::getCategoryId, cat.getId()) + ); + resp.setRoomCount((int) count); + return resp; + }).collect(Collectors.toList()); + return CommonResult.success(respList); + } + + @ApiOperation(value = "保存分类") + @RequestMapping(value = "/category/save", method = RequestMethod.POST) + public CommonResult saveCategory(@RequestBody LiveRoomCategorySaveRequest body) { + if (liveRoomCategoryService == null) { + return CommonResult.failed("分类服务未启用"); + } + if (StrUtil.isBlank(body.getName())) { + return CommonResult.failed("分类名称不能为空"); + } + LiveRoomCategory category; + if (body.getId() != null && body.getId() > 0) { + category = liveRoomCategoryService.getById(body.getId()); + if (category == null) { + return CommonResult.failed("分类不存在"); + } + } else { + category = new LiveRoomCategory(); + category.setCreateTime(new Date()); + } + category.setName(body.getName()); + category.setIcon(body.getIcon()); + category.setSort(body.getSort() != null ? body.getSort() : 0); + category.setStatus(body.getStatus() != null ? body.getStatus() : 1); + category.setUpdateTime(new Date()); + + if (body.getId() != null && body.getId() > 0) { + liveRoomCategoryService.updateById(category); + } else { + liveRoomCategoryService.save(category); + } + return CommonResult.success("保存成功"); + } + + @ApiOperation(value = "删除分类") + @RequestMapping(value = "/category/delete/{id}", method = RequestMethod.POST) + public CommonResult deleteCategory(@PathVariable Integer id) { + if (liveRoomCategoryService == null) { + return CommonResult.failed("分类服务未启用"); + } + if (id == null) { + return CommonResult.failed("参数错误"); + } + // 检查是否有房间使用该分类 + long count = liveRoomService.count( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .eq(LiveRoom::getCategoryId, id) + ); + if (count > 0) { + return CommonResult.failed("该分类下有 " + count + " 个房间,无法删除"); + } + liveRoomCategoryService.removeById(id); + return CommonResult.success("删除成功"); + } + + @ApiOperation(value = "更新分类状态") + @RequestMapping(value = "/category/updateStatus", method = RequestMethod.POST) + public CommonResult updateCategoryStatus(@RequestParam Integer id, @RequestParam Integer status) { + if (liveRoomCategoryService == null) { + return CommonResult.failed("分类服务未启用"); + } + if (id == null) { + return CommonResult.failed("参数错误"); + } + LiveRoomCategory category = liveRoomCategoryService.getById(id); + if (category == null) { + return CommonResult.failed("分类不存在"); + } + category.setStatus(status); + category.setUpdateTime(new Date()); + liveRoomCategoryService.updateById(category); + return CommonResult.success("更新成功"); + } + + @Data + public static class LiveRoomCategoryResponse { + private Integer id; + private String name; + private String icon; + private Integer sort; + private Integer status; + private Integer roomCount; + private Date createTime; + } + + @Data + public static class LiveRoomCategorySaveRequest { + private Integer id; + private String name; + private String icon; + private Integer sort; + private Integer status; + } } diff --git a/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/live/LiveRoomCategory.java b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/live/LiveRoomCategory.java new file mode 100644 index 00000000..164515af --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/model/live/LiveRoomCategory.java @@ -0,0 +1,48 @@ +package com.zbkj.common.model.live; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.annotations.ApiModel; +import io.swagger.annotations.ApiModelProperty; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.experimental.Accessors; + +import java.io.Serializable; +import java.util.Date; + +/** + * 直播间分类 + */ +@Data +@EqualsAndHashCode(callSuper = false) +@Accessors(chain = true) +@TableName("eb_live_room_category") +@ApiModel(value = "LiveRoomCategory", description = "直播间分类") +public class LiveRoomCategory implements Serializable { + + private static final long serialVersionUID = 1L; + + @ApiModelProperty(value = "分类ID") + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + @ApiModelProperty(value = "分类名称") + private String name; + + @ApiModelProperty(value = "图标") + private String icon; + + @ApiModelProperty(value = "排序") + private Integer sort; + + @ApiModelProperty(value = "状态:0-禁用,1-启用") + private Integer status; + + @ApiModelProperty(value = "创建时间") + private Date createTime; + + @ApiModelProperty(value = "更新时间") + private Date updateTime; +} diff --git a/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/token/FrontTokenComponent.java b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/token/FrontTokenComponent.java index 22a61cdd..795c6783 100644 --- a/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/token/FrontTokenComponent.java +++ b/Zhibo/zhibo-h/crmeb-common/src/main/java/com/zbkj/common/token/FrontTokenComponent.java @@ -183,10 +183,29 @@ public class FrontTokenComponent { "api/front/bargain/header", "api/front/bargain/detail", "api/front/seckill/header", - "api/front/seckill/detail" + "api/front/seckill/detail", + // 缘池/社区相关接口放行 + "api/front/community/categories", + "api/front/community/messages", + "api/front/community/nearby-users", + "api/front/community/user-count", + // 直播间公开接口 + "api/front/live/public" }; - return ArrayUtils.contains(routerList, uri); + // 检查精确匹配 + if (ArrayUtils.contains(routerList, uri)) { + return true; + } + + // 检查前缀匹配(支持子路径) + for (String route : routerList) { + if (uri.startsWith(route)) { + return true; + } + } + + return false; } public Boolean check(String token, HttpServletRequest request){ diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/LiveRoomController.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/LiveRoomController.java index 4b8eb863..2bdea000 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/LiveRoomController.java +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/LiveRoomController.java @@ -51,10 +51,10 @@ public class LiveRoomController { @Value("${LIVE_PUBLIC_SRS_HOST:}") private String publicHost; - @Value("${LIVE_PUBLIC_SRS_RTMP_PORT:}") + @Value("${LIVE_PUBLIC_SRS_RTMP_PORT:1935}") private String publicRtmpPort; - @Value("${LIVE_PUBLIC_SRS_HTTP_PORT:}") + @Value("${LIVE_PUBLIC_SRS_HTTP_PORT:8080}") private String publicHttpPort; @ApiOperation(value = "公开:直播间列表(只返回直播中的房间)") @@ -464,8 +464,8 @@ public class LiveRoomController { } String host = (publicHost != null && !publicHost.trim().isEmpty()) ? publicHost.trim() : requestHost; - int rtmpPort = parsePort(publicRtmpPort, 25002); - int httpPort = parsePort(publicHttpPort, 25003); + int rtmpPort = parsePort(publicRtmpPort, 1935); + int httpPort = parsePort(publicHttpPort, 8080); StreamUrlsResponse urls = new StreamUrlsResponse(); urls.setRtmp(String.format("rtmp://%s:%d/live/%s", host, rtmpPort, room.getStreamKey())); diff --git a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/StreamerController.java b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/StreamerController.java index c237784d..ac7c1e01 100644 --- a/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/StreamerController.java +++ b/Zhibo/zhibo-h/crmeb-front/src/main/java/com/zbkj/front/controller/StreamerController.java @@ -33,6 +33,7 @@ public class StreamerController { /** * 检查当前用户是否是认证主播 + * 直接从 eb_user 表读取 is_streamer 字段 */ @ApiOperation(value = "检查主播资格") @GetMapping("/check") @@ -42,55 +43,97 @@ public class StreamerController { return CommonResult.failed("请先登录"); } + log.info("=== 检查主播资格开始 === userId={}", userId); + Map result = new HashMap<>(); + result.put("userId", userId); + try { - String sql = "SELECT is_streamer, streamer_level, streamer_intro, streamer_certified_time FROM eb_user WHERE uid = ?"; + // 直接查询用户的主播状态 + String sql = "SELECT uid, nickname, is_streamer, streamer_level, streamer_intro, streamer_certified_time FROM eb_user WHERE uid = ?"; List> results = jdbcTemplate.queryForList(sql, userId); - Map result = new HashMap<>(); + log.info("查询结果: userId={}, 记录数={}", userId, results.size()); + if (results.isEmpty()) { + log.warn("用户不存在: userId={}", userId); result.put("isStreamer", false); result.put("streamerLevel", 0); - } else { - Map user = results.get(0); - Integer isStreamer = user.get("is_streamer") != null ? ((Number) user.get("is_streamer")).intValue() : 0; - result.put("isStreamer", isStreamer == 1); - result.put("streamerLevel", user.get("streamer_level")); - result.put("streamerIntro", user.get("streamer_intro")); - result.put("certifiedTime", user.get("streamer_certified_time")); - } - - // 检查是否有待审核的申请 - String pendingSql = "SELECT id, status, create_time FROM eb_streamer_application WHERE user_id = ? ORDER BY create_time DESC LIMIT 1"; - List> applications = jdbcTemplate.queryForList(pendingSql, userId); - if (!applications.isEmpty()) { - Map app = applications.get(0); - result.put("hasApplication", true); - result.put("applicationStatus", app.get("status")); - result.put("applicationTime", app.get("create_time")); - } else { result.put("hasApplication", false); + result.put("isBanned", false); + return CommonResult.success(result); } - // 检查是否被封禁 - String banSql = "SELECT ban_reason, ban_end_time FROM eb_streamer_ban WHERE user_id = ? AND is_active = 1 AND (ban_end_time IS NULL OR ban_end_time > NOW()) LIMIT 1"; - List> bans = jdbcTemplate.queryForList(banSql, userId); - if (!bans.isEmpty()) { - result.put("isBanned", true); - result.put("banReason", bans.get(0).get("ban_reason")); - result.put("banEndTime", bans.get(0).get("ban_end_time")); - } else { - result.put("isBanned", false); + Map user = results.get(0); + log.info("用户数据: {}", user); + + // 读取 is_streamer 字段 + Object isStreamerObj = user.get("is_streamer"); + boolean isStreamer = false; + int streamerLevel = 0; + + if (isStreamerObj != null) { + if (isStreamerObj instanceof Number) { + isStreamer = ((Number) isStreamerObj).intValue() == 1; + } else if (isStreamerObj instanceof Boolean) { + isStreamer = (Boolean) isStreamerObj; + } else { + isStreamer = "1".equals(isStreamerObj.toString()); + } + } + + Object levelObj = user.get("streamer_level"); + if (levelObj != null && levelObj instanceof Number) { + streamerLevel = ((Number) levelObj).intValue(); + } + + log.info("主播状态解析: userId={}, isStreamer={}, streamerLevel={}", userId, isStreamer, streamerLevel); + + result.put("isStreamer", isStreamer); + result.put("streamerLevel", streamerLevel); + result.put("streamerIntro", user.get("streamer_intro")); + result.put("certifiedTime", user.get("streamer_certified_time")); + result.put("nickname", user.get("nickname")); + + // 检查是否有待审核的申请(可选,表可能不存在) + result.put("hasApplication", false); + try { + String pendingSql = "SELECT id, status, create_time FROM eb_streamer_application WHERE user_id = ? ORDER BY create_time DESC LIMIT 1"; + List> applications = jdbcTemplate.queryForList(pendingSql, userId); + if (!applications.isEmpty()) { + Map app = applications.get(0); + result.put("hasApplication", true); + result.put("applicationStatus", app.get("status")); + result.put("applicationTime", app.get("create_time")); + } + } catch (Exception e) { + log.debug("查询申请记录失败(表可能不存在): {}", e.getMessage()); + } + + // 检查是否被封禁(可选,表可能不存在) + result.put("isBanned", false); + try { + String banSql = "SELECT ban_reason, ban_end_time FROM eb_streamer_ban WHERE user_id = ? AND is_active = 1 AND (ban_end_time IS NULL OR ban_end_time > NOW()) LIMIT 1"; + List> bans = jdbcTemplate.queryForList(banSql, userId); + if (!bans.isEmpty()) { + result.put("isBanned", true); + result.put("banReason", bans.get(0).get("ban_reason")); + result.put("banEndTime", bans.get(0).get("ban_end_time")); + } + } catch (Exception e) { + log.debug("查询封禁记录失败(表可能不存在): {}", e.getMessage()); } + log.info("=== 检查主播资格完成 === userId={}, result={}", userId, result); return CommonResult.success(result); + } catch (Exception e) { - log.error("检查主播资格失败", e); - // 如果字段不存在,返回默认值 - Map result = new HashMap<>(); + log.error("检查主播资格异常: userId={}", userId, e); + // 出错时返回详细错误信息 result.put("isStreamer", false); result.put("streamerLevel", 0); result.put("hasApplication", false); result.put("isBanned", false); + result.put("error", e.getMessage()); return CommonResult.success(result); } } @@ -178,4 +221,137 @@ public class StreamerController { return CommonResult.failed("获取申请记录失败"); } } + + /** + * 获取主播统计数据 + */ + @ApiOperation(value = "获取主播统计数据") + @GetMapping("/stats") + public CommonResult> getStreamerStats() { + Integer userId = frontTokenComponent.getUserId(); + if (userId == null) { + return CommonResult.failed("请先登录"); + } + + try { + Map stats = new HashMap<>(); + + // 获取用户基本信息 + String userSql = "SELECT nickname, avatar, streamer_level FROM eb_user WHERE uid = ?"; + List> users = jdbcTemplate.queryForList(userSql, userId); + if (!users.isEmpty()) { + Map user = users.get(0); + stats.put("nickname", user.get("nickname")); + stats.put("avatar", user.get("avatar")); + stats.put("streamerLevel", user.get("streamer_level") != null ? user.get("streamer_level") : 1); + } + + // 获取粉丝数 + try { + String fansSql = "SELECT COUNT(*) FROM eb_follow_record WHERE follow_user_id = ?"; + Integer fansCount = jdbcTemplate.queryForObject(fansSql, Integer.class, userId); + stats.put("fansCount", fansCount != null ? fansCount : 0); + } catch (Exception e) { + stats.put("fansCount", 0); + } + + // 获取获赞数(从直播间和作品中统计) + try { + String likesSql = "SELECT COALESCE(SUM(like_count), 0) FROM eb_live_room WHERE uid = ?"; + Integer likesCount = jdbcTemplate.queryForObject(likesSql, Integer.class, userId); + stats.put("likesCount", likesCount != null ? likesCount : 0); + } catch (Exception e) { + stats.put("likesCount", 0); + } + + // 获取收到的礼物数 + try { + String giftsSql = "SELECT COUNT(*) FROM eb_gift_record WHERE receiver_id = ?"; + Integer giftsCount = jdbcTemplate.queryForObject(giftsSql, Integer.class, userId); + stats.put("giftsCount", giftsCount != null ? giftsCount : 0); + } catch (Exception e) { + stats.put("giftsCount", 0); + } + + // 获取总收益(礼物价值) + try { + String incomeSql = "SELECT COALESCE(SUM(g.price * gr.quantity), 0) FROM eb_gift_record gr " + + "JOIN eb_gift g ON gr.gift_id = g.id WHERE gr.receiver_id = ?"; + Integer totalIncome = jdbcTemplate.queryForObject(incomeSql, Integer.class, userId); + stats.put("totalIncome", totalIncome != null ? totalIncome : 0); + } catch (Exception e) { + stats.put("totalIncome", 0); + } + + // 检查是否有活跃的直播间 + try { + String roomSql = "SELECT COUNT(*) FROM eb_live_room WHERE uid = ? AND is_live = 1"; + Integer activeRooms = jdbcTemplate.queryForObject(roomSql, Integer.class, userId); + stats.put("hasActiveRoom", activeRooms != null && activeRooms > 0); + } catch (Exception e) { + stats.put("hasActiveRoom", false); + } + + return CommonResult.success(stats); + } catch (Exception e) { + log.error("获取主播统计数据失败", e); + return CommonResult.failed("获取统计数据失败"); + } + } + + /** + * 获取收到的礼物列表 + */ + @ApiOperation(value = "获取收到的礼物列表") + @GetMapping("/gifts/received") + public CommonResult>> getReceivedGifts( + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int pageSize) { + Integer userId = frontTokenComponent.getUserId(); + if (userId == null) { + return CommonResult.failed("请先登录"); + } + + try { + int offset = (page - 1) * pageSize; + String sql = "SELECT gr.id, gr.gift_id as giftId, g.name as giftName, g.icon as giftIcon, " + + "g.price as giftPrice, gr.quantity, gr.sender_id as senderId, " + + "u.nickname as senderName, u.avatar as senderAvatar, gr.create_time as createTime " + + "FROM eb_gift_record gr " + + "LEFT JOIN eb_gift g ON gr.gift_id = g.id " + + "LEFT JOIN eb_user u ON gr.sender_id = u.uid " + + "WHERE gr.receiver_id = ? " + + "ORDER BY gr.create_time DESC " + + "LIMIT ? OFFSET ?"; + List> gifts = jdbcTemplate.queryForList(sql, userId, pageSize, offset); + return CommonResult.success(gifts); + } catch (Exception e) { + log.error("获取收到的礼物列表失败", e); + return CommonResult.failed("获取礼物列表失败"); + } + } + + /** + * 获取我的直播间列表 + */ + @ApiOperation(value = "获取我的直播间列表") + @GetMapping("/rooms") + public CommonResult>> getMyRooms() { + Integer userId = frontTokenComponent.getUserId(); + if (userId == null) { + return CommonResult.failed("请先登录"); + } + + try { + String sql = "SELECT id, title, stream_key as streamKey, is_live as isLive, " + + "view_count as viewCount, like_count as likeCount, online_count as onlineCount, " + + "create_time as createTime, started_at as startedAt " + + "FROM eb_live_room WHERE uid = ? ORDER BY create_time DESC"; + List> rooms = jdbcTemplate.queryForList(sql, userId); + return CommonResult.success(rooms); + } catch (Exception e) { + log.error("获取我的直播间列表失败", e); + return CommonResult.failed("获取直播间列表失败"); + } + } } diff --git a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/dao/LiveRoomCategoryDao.java b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/dao/LiveRoomCategoryDao.java new file mode 100644 index 00000000..975325c5 --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/dao/LiveRoomCategoryDao.java @@ -0,0 +1,10 @@ +package com.zbkj.service.dao; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.zbkj.common.model.live.LiveRoomCategory; + +/** + * 直播间分类 Mapper + */ +public interface LiveRoomCategoryDao extends BaseMapper { +} diff --git a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/LiveRoomCategoryService.java b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/LiveRoomCategoryService.java new file mode 100644 index 00000000..3198ce69 --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/LiveRoomCategoryService.java @@ -0,0 +1,17 @@ +package com.zbkj.service.service; + +import com.baomidou.mybatisplus.extension.service.IService; +import com.zbkj.common.model.live.LiveRoomCategory; + +import java.util.List; + +/** + * 直播间分类 Service + */ +public interface LiveRoomCategoryService extends IService { + + /** + * 获取启用的分类列表 + */ + List getEnabledList(); +} diff --git a/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/LiveRoomCategoryServiceImpl.java b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/LiveRoomCategoryServiceImpl.java new file mode 100644 index 00000000..b3a466c9 --- /dev/null +++ b/Zhibo/zhibo-h/crmeb-service/src/main/java/com/zbkj/service/service/impl/LiveRoomCategoryServiceImpl.java @@ -0,0 +1,26 @@ +package com.zbkj.service.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.zbkj.common.model.live.LiveRoomCategory; +import com.zbkj.service.dao.LiveRoomCategoryDao; +import com.zbkj.service.service.LiveRoomCategoryService; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 直播间分类 Service 实现 + */ +@Service +public class LiveRoomCategoryServiceImpl extends ServiceImpl implements LiveRoomCategoryService { + + @Override + public List getEnabledList() { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.eq(LiveRoomCategory::getStatus, 1); + wrapper.orderByAsc(LiveRoomCategory::getSort); + wrapper.orderByDesc(LiveRoomCategory::getId); + return list(wrapper); + } +} diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index d76c0dcc..07ad6f8d 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -126,4 +126,7 @@ dependencies { // WebRTC for voice/video calls // 使用 Google 官方 WebRTC 库 implementation("io.getstream:stream-webrtc-android:1.1.1") + + // RTMP 推流 SDK(手机开播)- RootEncoder 2.2.2 + implementation("com.github.pedroSG94.rtmp-rtsp-stream-client-java:rtplibrary:2.2.2") } diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index 46f3dec9..7bb7ee19 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -249,6 +249,19 @@ android:exported="false" android:screenOrientation="portrait" /> + + + + + + diff --git a/android-app/app/src/main/java/com/example/livestreaming/BroadcastActivity.java b/android-app/app/src/main/java/com/example/livestreaming/BroadcastActivity.java new file mode 100644 index 00000000..3e2e274f --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/BroadcastActivity.java @@ -0,0 +1,777 @@ +package com.example.livestreaming; + +import android.Manifest; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; +import android.util.Log; +import android.util.Size; +import android.view.SurfaceHolder; +import android.view.View; +import android.view.WindowManager; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; + +import com.example.livestreaming.databinding.ActivityBroadcastBinding; +import com.example.livestreaming.net.ApiClient; +import com.example.livestreaming.net.ApiResponse; +import com.example.livestreaming.net.AuthStore; +import com.example.livestreaming.net.CreateRoomRequest; +import com.example.livestreaming.net.Room; +import com.pedro.encoder.input.video.CameraHelper; +import com.pedro.rtmp.utils.ConnectCheckerRtmp; +import com.pedro.rtplibrary.rtmp.RtmpCamera1; +import com.pedro.rtplibrary.rtmp.RtmpCamera2; + +import java.util.Locale; +import java.util.Map; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +/** + * 手机开播界面 + * 使用 RootEncoder 进行 RTMP 推流 + * 优先使用 Camera2 API (RtmpCamera2),兼容性更好 + */ +public class BroadcastActivity extends AppCompatActivity implements ConnectCheckerRtmp, SurfaceHolder.Callback { + + private static final String TAG = "BroadcastActivity"; + private static final int REQUEST_PERMISSIONS = 100; + private static final String[] REQUIRED_PERMISSIONS = { + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO + }; + + private ActivityBroadcastBinding binding; + + // 使用 Camera2 API 的推流器 + private RtmpCamera2 rtmpCamera2; + // 备用:使用 Camera1 API 的推流器 + private RtmpCamera1 rtmpCamera1; + private boolean useCamera2 = true; + + private Room currentRoom; + private boolean isStreaming = false; + private boolean isFrontCamera = true; + private boolean surfaceReady = false; + private boolean streamerVerified = false; + private boolean cameraInitialized = false; + + // 推流参数 + private static final int VIDEO_WIDTH = 640; + private static final int VIDEO_HEIGHT = 480; + private static final int VIDEO_FPS = 25; + private static final int VIDEO_BITRATE = 1500 * 1024; // 1.5Mbps + private static final int AUDIO_BITRATE = 64 * 1024; + private static final int AUDIO_SAMPLE_RATE = 32000; + + // 直播计时 + private Handler timerHandler = new Handler(Looper.getMainLooper()); + private Handler mainHandler = new Handler(Looper.getMainLooper()); + private long startTime = 0; + private Runnable timerRunnable; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // 保持屏幕常亮 + getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + + binding = ActivityBroadcastBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + // 检查登录状态 + if (!AuthHelper.isLoggedIn(this)) { + Toast.makeText(this, "请先登录", Toast.LENGTH_SHORT).show(); + finish(); + return; + } + + setupUI(); + setupSurface(); + + // 先检查主播资格,通过后再检查权限 + checkStreamerStatus(); + } + + /** + * 检查主播资格 + */ + private void checkStreamerStatus() { + binding.progressBar.setVisibility(View.VISIBLE); + binding.btnStartLive.setEnabled(false); + + ApiClient.getService(this).checkStreamerStatus() + .enqueue(new Callback>>() { + @Override + public void onResponse(Call>> call, + Response>> response) { + binding.progressBar.setVisibility(View.GONE); + + if (!response.isSuccessful() || response.body() == null) { + // 接口调用失败,可能是旧版本后端,允许继续 + Log.w(TAG, "检查主播资格接口失败,允许继续"); + streamerVerified = true; + binding.btnStartLive.setEnabled(true); + checkPermissions(); + return; + } + + ApiResponse> body = response.body(); + if (body.getCode() != 200 || body.getData() == null) { + // 接口返回错误,可能是旧版本后端,允许继续 + Log.w(TAG, "检查主播资格返回错误,允许继续"); + streamerVerified = true; + binding.btnStartLive.setEnabled(true); + checkPermissions(); + return; + } + + Map data = body.getData(); + Boolean isStreamer = data.get("isStreamer") != null && (Boolean) data.get("isStreamer"); + Boolean isBanned = data.get("isBanned") != null && (Boolean) data.get("isBanned"); + Boolean hasApplication = data.get("hasApplication") != null && (Boolean) data.get("hasApplication"); + Object appStatusObj = data.get("applicationStatus"); + Integer applicationStatus = appStatusObj != null ? ((Number) appStatusObj).intValue() : null; + + if (isBanned) { + // 被封禁 + String banReason = (String) data.get("banReason"); + showBlockedDialog("您的主播资格已被封禁" + (banReason != null ? ":" + banReason : "")); + return; + } + + if (!isStreamer) { + // 不是主播 + if (hasApplication && applicationStatus != null && applicationStatus == 0) { + // 有待审核的申请 + showBlockedDialog("您的主播认证申请正在审核中,请耐心等待"); + } else { + // 没有申请或申请被拒绝,提示申请认证 + showApplyStreamerDialog(); + } + return; + } + + // 是认证主播,可以开播 + streamerVerified = true; + binding.btnStartLive.setEnabled(true); + checkPermissions(); + } + + @Override + public void onFailure(Call>> call, Throwable t) { + binding.progressBar.setVisibility(View.GONE); + // 网络错误,可能是旧版本后端,允许继续 + Log.w(TAG, "检查主播资格网络错误,允许继续", t); + streamerVerified = true; + binding.btnStartLive.setEnabled(true); + checkPermissions(); + } + }); + } + + /** + * 显示被阻止的对话框 + */ + private void showBlockedDialog(String message) { + new AlertDialog.Builder(this) + .setTitle("无法开播") + .setMessage(message) + .setPositiveButton("确定", (dialog, which) -> finish()) + .setCancelable(false) + .show(); + } + + /** + * 显示申请主播认证的对话框 + */ + private void showApplyStreamerDialog() { + new AlertDialog.Builder(this) + .setTitle("需要主播认证") + .setMessage("只有认证主播才能开播,是否现在申请主播认证?") + .setPositiveButton("去申请", (dialog, which) -> { + // 跳转到主播认证申请页面 + Intent intent = new Intent(this, StreamerApplyActivity.class); + startActivity(intent); + finish(); + }) + .setNegativeButton("取消", (dialog, which) -> finish()) + .setCancelable(false) + .show(); + } + + private void setupUI() { + // 关闭按钮 + binding.btnClose.setOnClickListener(v -> { + if (isStreaming) { + showStopConfirmDialog(); + } else { + finish(); + } + }); + + // 切换摄像头 + binding.btnSwitchCamera.setOnClickListener(v -> switchCamera()); + + // 设置按钮 + binding.btnSettings.setOnClickListener(v -> { + Toast.makeText(this, "设置功能开发中", Toast.LENGTH_SHORT).show(); + }); + + // 开始直播 + binding.btnStartLive.setOnClickListener(v -> startLive()); + + // 停止直播 + binding.btnStopLive.setOnClickListener(v -> showStopConfirmDialog()); + } + + private void setupSurface() { + binding.surfaceView.getHolder().addCallback(this); + } + + private void checkPermissions() { + boolean allGranted = true; + for (String permission : REQUIRED_PERMISSIONS) { + if (ContextCompat.checkSelfPermission(this, permission) != PackageManager.PERMISSION_GRANTED) { + allGranted = false; + break; + } + } + + if (allGranted) { + initCamera(); + } else { + ActivityCompat.requestPermissions(this, REQUIRED_PERMISSIONS, REQUEST_PERMISSIONS); + } + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + if (requestCode == REQUEST_PERMISSIONS) { + boolean allGranted = true; + for (int result : grantResults) { + if (result != PackageManager.PERMISSION_GRANTED) { + allGranted = false; + break; + } + } + if (allGranted) { + initCamera(); + } else { + Toast.makeText(this, "需要摄像头和麦克风权限才能开播", Toast.LENGTH_LONG).show(); + finish(); + } + } + } + + private void initCamera() { + if (!surfaceReady) { + Log.d(TAG, "Surface 未就绪,等待..."); + return; + } + + if (cameraInitialized) { + Log.d(TAG, "摄像头已初始化"); + return; + } + + // 检查是否有摄像头 + if (!hasCamera()) { + Log.e(TAG, "设备没有摄像头"); + Toast.makeText(this, "设备没有摄像头,无法开播", Toast.LENGTH_LONG).show(); + return; + } + + Log.d(TAG, "开始初始化摄像头..."); + + // 延迟初始化,确保 Surface 完全准备好 + mainHandler.postDelayed(() -> { + try { + initCameraInternal(); + } catch (Exception e) { + Log.e(TAG, "摄像头初始化异常: " + e.getMessage(), e); + Toast.makeText(this, "摄像头初始化失败", Toast.LENGTH_LONG).show(); + } + }, 500); + } + + private void initCameraInternal() { + // 优先尝试 Camera2 API (Android 5.0+) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + try { + Log.d(TAG, "尝试使用 Camera2 API..."); + rtmpCamera2 = new RtmpCamera2(binding.surfaceView, this); + + // 准备编码器 + boolean audioReady = rtmpCamera2.prepareAudio(AUDIO_BITRATE, AUDIO_SAMPLE_RATE, false); + boolean videoReady = rtmpCamera2.prepareVideo(VIDEO_WIDTH, VIDEO_HEIGHT, VIDEO_FPS, VIDEO_BITRATE, 0); + + Log.d(TAG, "Camera2 编码器准备: audio=" + audioReady + ", video=" + videoReady); + + if (videoReady) { + // 开始预览 + String cameraId = isFrontCamera ? "1" : "0"; + rtmpCamera2.startPreview(cameraId); + useCamera2 = true; + cameraInitialized = true; + Log.d(TAG, "Camera2 预览已开始"); + return; + } + } catch (Exception e) { + Log.w(TAG, "Camera2 初始化失败: " + e.getMessage()); + rtmpCamera2 = null; + } + } + + // 回退到 Camera1 API + try { + Log.d(TAG, "尝试使用 Camera1 API..."); + rtmpCamera1 = new RtmpCamera1(binding.surfaceView, this); + + boolean audioReady = rtmpCamera1.prepareAudio(); + boolean videoReady = rtmpCamera1.prepareVideo(); + + Log.d(TAG, "Camera1 编码器准备: audio=" + audioReady + ", video=" + videoReady); + + if (videoReady) { + CameraHelper.Facing facing = isFrontCamera ? CameraHelper.Facing.FRONT : CameraHelper.Facing.BACK; + rtmpCamera1.startPreview(facing); + useCamera2 = false; + cameraInitialized = true; + Log.d(TAG, "Camera1 预览已开始"); + return; + } + } catch (Exception e) { + Log.e(TAG, "Camera1 初始化也失败: " + e.getMessage()); + rtmpCamera1 = null; + } + + Toast.makeText(this, "摄像头初始化失败,请检查权限或重启应用", Toast.LENGTH_LONG).show(); + } + + /** + * 检查设备是否有摄像头 + */ + private boolean hasCamera() { + return getPackageManager().hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY); + } + + private void switchCamera() { + isFrontCamera = !isFrontCamera; + + if (useCamera2 && rtmpCamera2 != null) { + try { + String cameraId = isFrontCamera ? "1" : "0"; + rtmpCamera2.switchCamera(); + Toast.makeText(this, isFrontCamera ? "前置摄像头" : "后置摄像头", Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + Log.e(TAG, "切换摄像头失败: " + e.getMessage()); + } + } else if (rtmpCamera1 != null) { + try { + rtmpCamera1.switchCamera(); + Toast.makeText(this, isFrontCamera ? "前置摄像头" : "后置摄像头", Toast.LENGTH_SHORT).show(); + } catch (Exception e) { + Log.e(TAG, "切换摄像头失败: " + e.getMessage()); + } + } + } + + private void startLive() { + // 检查主播资格是否已验证 + if (!streamerVerified) { + Toast.makeText(this, "正在验证主播资格...", Toast.LENGTH_SHORT).show(); + checkStreamerStatus(); + return; + } + + String title = binding.etTitle.getText() != null ? + binding.etTitle.getText().toString().trim() : ""; + + if (TextUtils.isEmpty(title)) { + Toast.makeText(this, "请输入直播标题", Toast.LENGTH_SHORT).show(); + return; + } + + if (!cameraInitialized) { + Toast.makeText(this, "摄像头未初始化,请稍候", Toast.LENGTH_SHORT).show(); + return; + } + + binding.progressBar.setVisibility(View.VISIBLE); + binding.btnStartLive.setEnabled(false); + + // 先创建直播间 + createRoom(title); + } + + private void createRoom(String title) { + String nickname = AuthStore.getNickname(this); + if (TextUtils.isEmpty(nickname)) { + nickname = "主播"; + } + + CreateRoomRequest request = new CreateRoomRequest(); + request.setTitle(title); + request.setStreamerName(nickname); + + ApiClient.getService(this).createRoom(request).enqueue(new Callback>() { + @Override + public void onResponse(Call> call, Response> response) { + if (response.isSuccessful() && response.body() != null && response.body().isOk()) { + currentRoom = response.body().getData(); + Log.d(TAG, "直播间创建成功: " + currentRoom.getId()); + startStreaming(); + } else { + binding.progressBar.setVisibility(View.GONE); + binding.btnStartLive.setEnabled(true); + String msg = response.body() != null ? response.body().getMessage() : "创建直播间失败"; + Toast.makeText(BroadcastActivity.this, msg, Toast.LENGTH_SHORT).show(); + } + } + + @Override + public void onFailure(Call> call, Throwable t) { + binding.progressBar.setVisibility(View.GONE); + binding.btnStartLive.setEnabled(true); + Toast.makeText(BroadcastActivity.this, "网络错误: " + t.getMessage(), Toast.LENGTH_SHORT).show(); + } + }); + } + + private void startStreaming() { + if (currentRoom == null) { + Log.e(TAG, "currentRoom 为空"); + binding.progressBar.setVisibility(View.GONE); + binding.btnStartLive.setEnabled(true); + Toast.makeText(this, "获取房间信息失败", Toast.LENGTH_SHORT).show(); + return; + } + + if (currentRoom.getStreamUrls() == null) { + Log.e(TAG, "streamUrls 为空, roomId=" + currentRoom.getId() + ", streamKey=" + currentRoom.getStreamKey()); + binding.progressBar.setVisibility(View.GONE); + binding.btnStartLive.setEnabled(true); + Toast.makeText(this, "获取推流地址失败", Toast.LENGTH_SHORT).show(); + return; + } + + String rtmpUrl = currentRoom.getStreamUrls().getRtmp(); + String flvUrl = currentRoom.getStreamUrls().getFlv(); + String hlsUrl = currentRoom.getStreamUrls().getHls(); + + Log.d(TAG, "========== 流地址信息 =========="); + Log.d(TAG, "房间ID: " + currentRoom.getId()); + Log.d(TAG, "StreamKey: " + currentRoom.getStreamKey()); + Log.d(TAG, "RTMP推流地址: " + rtmpUrl); + Log.d(TAG, "FLV播放地址: " + flvUrl); + Log.d(TAG, "HLS播放地址: " + hlsUrl); + Log.d(TAG, "================================"); + + if (TextUtils.isEmpty(rtmpUrl)) { + Log.e(TAG, "RTMP推流地址为空"); + binding.progressBar.setVisibility(View.GONE); + binding.btnStartLive.setEnabled(true); + Toast.makeText(this, "推流地址无效", Toast.LENGTH_SHORT).show(); + return; + } + + Log.d(TAG, "开始推流到: " + rtmpUrl); + + try { + if (useCamera2 && rtmpCamera2 != null && !rtmpCamera2.isStreaming()) { + Log.d(TAG, "使用 Camera2 API 推流"); + rtmpCamera2.startStream(rtmpUrl); + } else if (rtmpCamera1 != null && !rtmpCamera1.isStreaming()) { + Log.d(TAG, "使用 Camera1 API 推流"); + rtmpCamera1.startStream(rtmpUrl); + } else { + Log.e(TAG, "没有可用的摄像头推流器"); + binding.progressBar.setVisibility(View.GONE); + binding.btnStartLive.setEnabled(true); + Toast.makeText(this, "摄像头未初始化", Toast.LENGTH_SHORT).show(); + } + } catch (Exception e) { + Log.e(TAG, "推流失败: " + e.getMessage(), e); + binding.progressBar.setVisibility(View.GONE); + binding.btnStartLive.setEnabled(true); + Toast.makeText(this, "推流失败: " + e.getMessage(), Toast.LENGTH_SHORT).show(); + } + } + + private void stopStreaming() { + try { + if (useCamera2 && rtmpCamera2 != null && rtmpCamera2.isStreaming()) { + rtmpCamera2.stopStream(); + } else if (rtmpCamera1 != null && rtmpCamera1.isStreaming()) { + rtmpCamera1.stopStream(); + } + } catch (Exception e) { + Log.e(TAG, "停止推流失败: " + e.getMessage()); + } + + isStreaming = false; + stopTimer(); + updateUI(false); + + // 删除直播间 + if (currentRoom != null) { + ApiClient.getService(this).deleteRoom(currentRoom.getId()).enqueue(new Callback>() { + @Override + public void onResponse(Call> call, Response> response) { + Log.d(TAG, "直播间已删除"); + } + + @Override + public void onFailure(Call> call, Throwable t) { + Log.e(TAG, "删除直播间失败: " + t.getMessage()); + } + }); + currentRoom = null; + } + } + + private void showStopConfirmDialog() { + new com.google.android.material.dialog.MaterialAlertDialogBuilder(this) + .setTitle("结束直播") + .setMessage("确定要结束直播吗?") + .setPositiveButton("结束", (dialog, which) -> { + stopStreaming(); + finish(); + }) + .setNegativeButton("取消", null) + .show(); + } + + private void updateUI(boolean streaming) { + if (streaming) { + binding.cardLiveInfo.setVisibility(View.GONE); + binding.btnStartLive.setVisibility(View.GONE); + binding.btnStopLive.setVisibility(View.VISIBLE); + binding.liveStatusBar.setVisibility(View.VISIBLE); + } else { + binding.cardLiveInfo.setVisibility(View.VISIBLE); + binding.btnStartLive.setVisibility(View.VISIBLE); + binding.btnStopLive.setVisibility(View.GONE); + binding.liveStatusBar.setVisibility(View.GONE); + } + binding.progressBar.setVisibility(View.GONE); + binding.btnStartLive.setEnabled(true); + } + + private void startTimer() { + startTime = System.currentTimeMillis(); + timerRunnable = new Runnable() { + @Override + public void run() { + long elapsed = System.currentTimeMillis() - startTime; + int seconds = (int) (elapsed / 1000) % 60; + int minutes = (int) (elapsed / 1000 / 60) % 60; + int hours = (int) (elapsed / 1000 / 60 / 60); + binding.tvLiveTime.setText(String.format(Locale.getDefault(), "%02d:%02d:%02d", hours, minutes, seconds)); + timerHandler.postDelayed(this, 1000); + } + }; + timerHandler.post(timerRunnable); + } + + private void stopTimer() { + if (timerRunnable != null) { + timerHandler.removeCallbacks(timerRunnable); + timerRunnable = null; + } + } + + // ========== SurfaceHolder.Callback ========== + + @Override + public void surfaceCreated(@NonNull SurfaceHolder holder) { + Log.d(TAG, "Surface created"); + surfaceReady = true; + initCamera(); + } + + @Override + public void surfaceChanged(@NonNull SurfaceHolder holder, int format, int width, int height) { + Log.d(TAG, "Surface changed: " + width + "x" + height); + } + + @Override + public void surfaceDestroyed(@NonNull SurfaceHolder holder) { + Log.d(TAG, "Surface destroyed"); + surfaceReady = false; + + try { + if (rtmpCamera2 != null) { + if (rtmpCamera2.isStreaming()) { + rtmpCamera2.stopStream(); + } + if (rtmpCamera2.isOnPreview()) { + rtmpCamera2.stopPreview(); + } + } + if (rtmpCamera1 != null) { + if (rtmpCamera1.isStreaming()) { + rtmpCamera1.stopStream(); + } + if (rtmpCamera1.isOnPreview()) { + rtmpCamera1.stopPreview(); + } + } + } catch (Exception e) { + Log.e(TAG, "停止预览失败: " + e.getMessage()); + } + } + + // ========== ConnectCheckerRtmp 回调 ========== + + @Override + public void onConnectionStartedRtmp(String rtmpUrl) { + Log.d(TAG, "========== RTMP连接开始 =========="); + Log.d(TAG, "正在连接: " + rtmpUrl); + } + + @Override + public void onConnectionSuccessRtmp() { + runOnUiThread(() -> { + Log.d(TAG, "========== RTMP连接成功 =========="); + Log.d(TAG, "推流已成功连接到服务器"); + isStreaming = true; + updateUI(true); + startTimer(); + Toast.makeText(this, "直播已开始", Toast.LENGTH_SHORT).show(); + }); + } + + @Override + public void onConnectionFailedRtmp(String reason) { + runOnUiThread(() -> { + Log.e(TAG, "========== RTMP连接失败 =========="); + Log.e(TAG, "失败原因: " + reason); + Log.e(TAG, "请检查:"); + Log.e(TAG, "1. SRS服务器是否运行"); + Log.e(TAG, "2. RTMP端口(1935/25002)是否开放"); + Log.e(TAG, "3. 手机网络是否能访问服务器"); + isStreaming = false; + updateUI(false); + Toast.makeText(this, "连接失败: " + reason, Toast.LENGTH_LONG).show(); + }); + } + + @Override + public void onNewBitrateRtmp(long bitrate) { + // 码率变化回调,可用于显示当前上传速度 + Log.v(TAG, "当前码率: " + (bitrate / 1024) + " kbps"); + } + + @Override + public void onDisconnectRtmp() { + runOnUiThread(() -> { + Log.d(TAG, "推流断开"); + if (isStreaming) { + isStreaming = false; + updateUI(false); + Toast.makeText(this, "直播已断开", Toast.LENGTH_SHORT).show(); + } + }); + } + + @Override + public void onAuthErrorRtmp() { + runOnUiThread(() -> { + Log.e(TAG, "推流认证失败"); + Toast.makeText(this, "推流认证失败", Toast.LENGTH_SHORT).show(); + }); + } + + @Override + public void onAuthSuccessRtmp() { + Log.d(TAG, "推流认证成功"); + } + + @Override + protected void onResume() { + super.onResume(); + if (cameraInitialized && surfaceReady && !isStreaming) { + try { + if (useCamera2 && rtmpCamera2 != null && !rtmpCamera2.isOnPreview()) { + String cameraId = isFrontCamera ? "1" : "0"; + rtmpCamera2.startPreview(cameraId); + } else if (rtmpCamera1 != null && !rtmpCamera1.isOnPreview()) { + CameraHelper.Facing facing = isFrontCamera ? CameraHelper.Facing.FRONT : CameraHelper.Facing.BACK; + rtmpCamera1.startPreview(facing); + } + } catch (Exception e) { + Log.e(TAG, "恢复预览失败: " + e.getMessage()); + } + } + } + + @Override + protected void onPause() { + super.onPause(); + // 如果正在直播,不停止预览 + if (!isStreaming) { + try { + if (rtmpCamera2 != null && rtmpCamera2.isOnPreview()) { + rtmpCamera2.stopPreview(); + } + if (rtmpCamera1 != null && rtmpCamera1.isOnPreview()) { + rtmpCamera1.stopPreview(); + } + } catch (Exception e) { + Log.e(TAG, "暂停预览失败: " + e.getMessage()); + } + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + stopTimer(); + + try { + if (rtmpCamera2 != null) { + if (rtmpCamera2.isStreaming()) { + rtmpCamera2.stopStream(); + } + if (rtmpCamera2.isOnPreview()) { + rtmpCamera2.stopPreview(); + } + } + if (rtmpCamera1 != null) { + if (rtmpCamera1.isStreaming()) { + rtmpCamera1.stopStream(); + } + if (rtmpCamera1.isOnPreview()) { + rtmpCamera1.stopPreview(); + } + } + } catch (Exception e) { + Log.e(TAG, "销毁时清理失败: " + e.getMessage()); + } + } + + @Override + public void onBackPressed() { + if (isStreaming) { + showStopConfirmDialog(); + } else { + super.onBackPressed(); + } + } +} diff --git a/android-app/app/src/main/java/com/example/livestreaming/LoginActivity.java b/android-app/app/src/main/java/com/example/livestreaming/LoginActivity.java index 44c00a7c..401dad58 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/LoginActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/LoginActivity.java @@ -162,6 +162,9 @@ public class LoginActivity extends AppCompatActivity { } } + // 检查主播状态 + checkStreamerStatus(); + // 登录成功 Toast.makeText(LoginActivity.this, "登录成功", Toast.LENGTH_SHORT).show(); @@ -193,5 +196,31 @@ public class LoginActivity extends AppCompatActivity { }); } -} + /** + * 检查用户是否是认证主播 + */ + private void checkStreamerStatus() { + ApiClient.getService(getApplicationContext()).checkStreamerStatus() + .enqueue(new Callback>>() { + @Override + public void onResponse(Call>> call, + Response>> response) { + if (response.isSuccessful() && response.body() != null && response.body().isOk()) { + java.util.Map data = response.body().getData(); + if (data != null) { + Object isStreamerObj = data.get("isStreamer"); + boolean isStreamer = isStreamerObj != null && (Boolean) isStreamerObj; + AuthStore.setIsStreamer(getApplicationContext(), isStreamer); + android.util.Log.d("LoginActivity", "主播状态: " + isStreamer); + } + } + } + @Override + public void onFailure(Call>> call, Throwable t) { + android.util.Log.e("LoginActivity", "检查主播状态失败", t); + } + }); + } + +} \ No newline at end of file diff --git a/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java b/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java index 82a78895..22c36ae1 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/MainActivity.java @@ -185,8 +185,9 @@ public class MainActivity extends AppCompatActivity { items.add(new DrawerCardItem(DrawerCardItem.ACTION_GROUPS, "我的群组", "群聊与群组管理", R.drawable.ic_group_24)); items.add(new DrawerCardItem(DrawerCardItem.ACTION_FISH_POND, "缘池", "附近与社交圈", R.drawable.ic_people_24)); items.add(new DrawerCardItem(DrawerCardItem.ACTION_FOLLOWING, "我的关注", "你关注的主播", R.drawable.ic_people_24)); - items.add(new DrawerCardItem(DrawerCardItem.ACTION_FANS, "粉丝", "关注你的人", R.drawable.ic_people_24)); - items.add(new DrawerCardItem(DrawerCardItem.ACTION_LIKES, "获赞", "收到的点赞", R.drawable.ic_heart_24)); + // 隐藏粉丝和获赞菜单项 + // items.add(new DrawerCardItem(DrawerCardItem.ACTION_FANS, "粉丝", "关注你的人", R.drawable.ic_people_24)); + // items.add(new DrawerCardItem(DrawerCardItem.ACTION_LIKES, "获赞", "收到的点赞", R.drawable.ic_heart_24)); items.add(new DrawerCardItem(DrawerCardItem.ACTION_HISTORY, "观看历史", "最近看过的直播", R.drawable.ic_grid_24)); items.add(new DrawerCardItem(DrawerCardItem.ACTION_SEARCH, "搜索", "找主播/房间/标签", R.drawable.ic_search_24)); items.add(new DrawerCardItem(DrawerCardItem.ACTION_SETTINGS, "设置", "账号、隐私、通知", R.drawable.ic_menu_24)); @@ -854,6 +855,66 @@ public class MainActivity extends AppCompatActivity { // 确保通话信令 WebSocket 保持连接(用于接收来电通知) LiveStreamingApplication app = (LiveStreamingApplication) getApplication(); app.connectCallSignalingIfLoggedIn(); + + // 检查主播状态并显示/隐藏开播按钮 + checkAndUpdateStreamerButton(); + } + + /** + * 检查主播状态并更新开播按钮的显示 + */ + private void checkAndUpdateStreamerButton() { + // 如果用户未登录,隐藏开播按钮 + if (!AuthHelper.isLoggedIn(this)) { + if (binding.fabAddLive != null) { + binding.fabAddLive.setVisibility(View.GONE); + } + return; + } + + // 检查主播资格 + ApiClient.getService(getApplicationContext()).checkStreamerStatus() + .enqueue(new Callback>>() { + @Override + public void onResponse(Call>> call, + Response>> response) { + if (!response.isSuccessful() || response.body() == null) { + // 接口调用失败,隐藏按钮 + if (binding.fabAddLive != null) { + binding.fabAddLive.setVisibility(View.GONE); + } + return; + } + + ApiResponse> body = response.body(); + if (body.getCode() != 200 || body.getData() == null) { + // 接口返回错误,隐藏按钮 + if (binding.fabAddLive != null) { + binding.fabAddLive.setVisibility(View.GONE); + } + return; + } + + Map data = body.getData(); + Boolean isStreamer = data.get("isStreamer") != null && (Boolean) data.get("isStreamer"); + Boolean isBanned = data.get("isBanned") != null && (Boolean) data.get("isBanned"); + + // 只有认证主播且未被封禁才显示开播按钮 + if (isStreamer && !isBanned && binding.fabAddLive != null) { + binding.fabAddLive.setVisibility(View.VISIBLE); + } else if (binding.fabAddLive != null) { + binding.fabAddLive.setVisibility(View.GONE); + } + } + + @Override + public void onFailure(Call>> call, Throwable t) { + // 网络错误,隐藏按钮 + if (binding.fabAddLive != null) { + binding.fabAddLive.setVisibility(View.GONE); + } + } + }); } /** @@ -1033,6 +1094,24 @@ public class MainActivity extends AppCompatActivity { } private void showCreateRoomDialogInternal() { + // 显示选择对话框:手机开播 或 OBS推流 + new AlertDialog.Builder(this) + .setTitle("选择开播方式") + .setItems(new String[]{"📱 手机开播", "💻 OBS推流"}, (dialog, which) -> { + if (which == 0) { + // 手机开播 - 跳转到 BroadcastActivity + Intent intent = new Intent(MainActivity.this, BroadcastActivity.class); + startActivity(intent); + } else { + // OBS推流 - 显示创建直播间对话框 + showOBSCreateRoomDialog(); + } + }) + .setNegativeButton("取消", null) + .show(); + } + + private void showOBSCreateRoomDialog() { View dialogView = getLayoutInflater().inflate(R.layout.dialog_create_room, null); DialogCreateRoomBinding dialogBinding = DialogCreateRoomBinding.bind(dialogView); diff --git a/android-app/app/src/main/java/com/example/livestreaming/PlayerActivity.java b/android-app/app/src/main/java/com/example/livestreaming/PlayerActivity.java index 288bc73c..0f9c9461 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/PlayerActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/PlayerActivity.java @@ -1,5 +1,6 @@ package com.example.livestreaming; +import android.graphics.SurfaceTexture; import android.os.Bundle; import android.view.Surface; import android.view.TextureView; @@ -78,14 +79,14 @@ public class PlayerActivity extends AppCompatActivity { releaseExoPlayer(); triedAltUrl = false; - // 优化缓冲配置,平衡延迟和流畅度 + // 优化缓冲配置 - 针对低延迟 HLS(1秒分片) androidx.media3.exoplayer.DefaultLoadControl loadControl = new androidx.media3.exoplayer.DefaultLoadControl.Builder() .setBufferDurationsMs( - 3000, // 最小缓冲 3秒 - 15000, // 最大缓冲 15秒 + 2500, // 最小缓冲 2.5秒 + 10000, // 最大缓冲 10秒 1500, // 播放前缓冲 1.5秒 - 3000 // 重新缓冲 3秒 + 2500 // 重新缓冲 2.5秒 ) .setPrioritizeTimeOverSizeThresholds(true) .build(); @@ -142,33 +143,137 @@ public class PlayerActivity extends AppCompatActivity { } private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) { - // 禁用 IjkPlayer,直接使用 HLS 播放(IjkPlayer 在某些设备上会崩溃) - android.util.Log.d("PlayerActivity", "FLV流转为HLS播放: " + flvUrl); + android.util.Log.d("PlayerActivity", "使用IJK播放FLV流: " + flvUrl); - // 将 FLV 地址转换为 HLS 地址 - String hlsUrl = fallbackHlsUrl; - if (hlsUrl == null || hlsUrl.trim().isEmpty()) { - hlsUrl = flvUrl.replace(".flv", ".m3u8"); + // 释放 ExoPlayer + releaseExoPlayer(); + releaseIjkPlayer(); + + // 确保 IJK 库已加载 + ensureIjkLibsLoaded(); + + // 保存回退地址 + ijkUrl = flvUrl; + ijkFallbackHlsUrl = fallbackHlsUrl; + if (ijkFallbackHlsUrl == null || ijkFallbackHlsUrl.trim().isEmpty()) { + ijkFallbackHlsUrl = flvUrl.replace(".flv", ".m3u8"); } + ijkFallbackTried = false; - startHls(hlsUrl, null); + // 显示 TextureView,隐藏 ExoPlayer 的 PlayerView + if (binding != null) { + binding.playerView.setVisibility(android.view.View.GONE); + binding.flvTextureView.setVisibility(android.view.View.VISIBLE); + + // 设置 TextureView 监听器 + binding.flvTextureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() { + @Override + public void onSurfaceTextureAvailable(android.graphics.SurfaceTexture surface, int width, int height) { + android.util.Log.d("PlayerActivity", "SurfaceTexture可用,准备IJK播放器"); + ijkSurface = new Surface(surface); + prepareIjk(flvUrl); + } + + @Override + public void onSurfaceTextureSizeChanged(android.graphics.SurfaceTexture surface, int width, int height) { + // 尺寸变化时不需要处理 + } + + @Override + public boolean onSurfaceTextureDestroyed(android.graphics.SurfaceTexture surface) { + android.util.Log.d("PlayerActivity", "SurfaceTexture销毁"); + releaseIjkPlayer(); + return true; + } + + @Override + public void onSurfaceTextureUpdated(android.graphics.SurfaceTexture surface) { + // 更新时不需要处理 + } + }); + + // 如果 TextureView 已经可用,直接准备播放 + if (binding.flvTextureView.isAvailable()) { + android.util.Log.d("PlayerActivity", "TextureView已可用,直接准备IJK播放器"); + ijkSurface = new Surface(binding.flvTextureView.getSurfaceTexture()); + prepareIjk(flvUrl); + } + } } private void prepareIjk(String url) { if (ijkSurface == null) return; IjkMediaPlayer p = new IjkMediaPlayer(); + + // ========== 超低延迟核心配置 ========== + // 禁用数据包缓冲,直接播放 p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "packet-buffering", 0); + // 准备好立即播放 p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "start-on-prepared", 1); - p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "fflags", "nobuffer"); - p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "analyzeduration", 1); - p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "probesize", 1024); - p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "framedrop", 1); - p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "max_cached_duration", 300); + // 无限缓冲模式(配合max_cached_duration使用) p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "infbuf", 1); + // 最大缓存时长 200ms(极低延迟) + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "max_cached_duration", 200); + // 最小帧数 2(快速启动) + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "min-frames", 2); + // 允许丢帧追赶延迟 + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "framedrop", 5); + // 同步类型:视频为主 + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "sync-av-start", 0); + + // ========== 格式/解复用配置 ========== + // 禁用缓冲 + p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "fflags", "nobuffer"); + // 分析时长 100us(极短) + p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "analyzeduration", 100); + // 探测大小 1KB(极小) + p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "probesize", 1024); + // 刷新数据包 + p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "flush_packets", 1); + // 禁用DNS缓存 + p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "dns_cache_clear", 1); + + // ========== 编解码配置 ========== + // 使用硬件解码(如果可用) + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "mediacodec", 1); + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "mediacodec-auto-rotate", 1); + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "mediacodec-handle-resolution-change", 1); + // 跳过循环滤波(加速解码) + p.setOption(IjkMediaPlayer.OPT_CATEGORY_CODEC, "skip_loop_filter", 48); + // 跳过帧(加速解码) + p.setOption(IjkMediaPlayer.OPT_CATEGORY_CODEC, "skip_frame", 0); + + // ========== 网络配置 ========== + // 重连次数 + p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "reconnect", 1); + // 超时时间 3秒 + p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "timeout", 3000000); + + // ========== 音频配置 ========== + // 开启音频 + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "an", 0); + // OpenSL ES 音频输出(低延迟) + p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "opensles", 1); - p.setOnPreparedListener(mp -> mp.start()); + p.setOnPreparedListener(mp -> { + android.util.Log.d("PlayerActivity", "IJK播放器准备完成,开始播放"); + mp.start(); + }); + + p.setOnInfoListener((mp, what, extra) -> { + if (what == IMediaPlayer.MEDIA_INFO_VIDEO_RENDERING_START) { + android.util.Log.d("PlayerActivity", "IJK首帧渲染完成"); + } else if (what == IMediaPlayer.MEDIA_INFO_BUFFERING_START) { + android.util.Log.d("PlayerActivity", "IJK开始缓冲"); + } else if (what == IMediaPlayer.MEDIA_INFO_BUFFERING_END) { + android.util.Log.d("PlayerActivity", "IJK缓冲结束"); + } + return false; + }); + p.setOnErrorListener((IMediaPlayer mp, int what, int extra) -> { + android.util.Log.e("PlayerActivity", "IJK播放错误: what=" + what + ", extra=" + extra); if (ijkFallbackTried || ijkFallbackHlsUrl == null || ijkFallbackHlsUrl.trim().isEmpty()) return true; ijkFallbackTried = true; startHls(ijkFallbackHlsUrl, null); @@ -179,8 +284,10 @@ public class PlayerActivity extends AppCompatActivity { try { p.setSurface(ijkSurface); p.setDataSource(url); + android.util.Log.d("PlayerActivity", "IJK开始准备播放: " + url); p.prepareAsync(); } catch (Exception e) { + android.util.Log.e("PlayerActivity", "IJK播放异常: " + e.getMessage()); if (ijkFallbackHlsUrl != null && !ijkFallbackHlsUrl.trim().isEmpty()) { startHls(ijkFallbackHlsUrl, null); } diff --git a/android-app/app/src/main/java/com/example/livestreaming/ProfileActivity.java b/android-app/app/src/main/java/com/example/livestreaming/ProfileActivity.java index c41766aa..9915b744 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ProfileActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ProfileActivity.java @@ -429,6 +429,12 @@ public class ProfileActivity extends AppCompatActivity { } showShareProfileDialog(); }); + + // 主播中心按钮点击事件 + binding.streamerCenterBtn.setOnClickListener(v -> { + StreamerCenterActivity.start(this); + }); + binding.addFriendBtn.setOnClickListener(v -> { // 检查登录状态,添加好友需要登录 if (!AuthHelper.requireLogin(this, "添加好友需要登录")) { @@ -480,14 +486,14 @@ public class ProfileActivity extends AppCompatActivity { PublishWorkActivity.start(this); }); - // 悬浮按钮(固定显示) - binding.fabPublishWork.setOnClickListener(v -> { - // 检查登录状态,发布作品需要登录 - if (!AuthHelper.requireLogin(this, "发布作品需要登录")) { - return; - } - PublishWorkActivity.start(this); - }); + // 悬浮按钮(固定显示)- 已隐藏 + // binding.fabPublishWork.setOnClickListener(v -> { + // // 检查登录状态,发布作品需要登录 + // if (!AuthHelper.requireLogin(this, "发布作品需要登录")) { + // return; + // } + // PublishWorkActivity.start(this); + // }); binding.likedGoBrowseBtn.setOnClickListener(v -> startActivity(new Intent(this, MainActivity.class))); binding.favGoBrowseBtn.setOnClickListener(v -> startActivity(new Intent(this, MainActivity.class))); binding.profileEditFromTab.setOnClickListener(v -> { @@ -617,9 +623,68 @@ public class ProfileActivity extends AppCompatActivity { bottomNav.setSelectedItemId(R.id.nav_profile); // 更新未读消息徽章 UnreadMessageManager.updateBadge(bottomNav); + // 检查主播状态并显示/隐藏主播中心按钮 + checkAndUpdateStreamerButton(); } } + /** + * 检查主播状态并更新主播中心按钮的显示 + */ + private void checkAndUpdateStreamerButton() { + // 如果用户未登录,隐藏主播中心按钮 + if (!AuthHelper.isLoggedIn(this)) { + if (binding.streamerCenterBtn != null) { + binding.streamerCenterBtn.setVisibility(View.GONE); + } + return; + } + + // 检查主播资格 + ApiClient.getService(getApplicationContext()).checkStreamerStatus() + .enqueue(new Callback>>() { + @Override + public void onResponse(Call>> call, + Response>> response) { + if (!response.isSuccessful() || response.body() == null) { + // 接口调用失败,隐藏按钮 + if (binding.streamerCenterBtn != null) { + binding.streamerCenterBtn.setVisibility(View.GONE); + } + return; + } + + ApiResponse> body = response.body(); + if (body.getCode() != 200 || body.getData() == null) { + // 接口返回错误,隐藏按钮 + if (binding.streamerCenterBtn != null) { + binding.streamerCenterBtn.setVisibility(View.GONE); + } + return; + } + + java.util.Map data = body.getData(); + Boolean isStreamer = data.get("isStreamer") != null && (Boolean) data.get("isStreamer"); + Boolean isBanned = data.get("isBanned") != null && (Boolean) data.get("isBanned"); + + // 只有认证主播且未被封禁才显示主播中心按钮 + if (isStreamer && !isBanned && binding.streamerCenterBtn != null) { + binding.streamerCenterBtn.setVisibility(View.VISIBLE); + } else if (binding.streamerCenterBtn != null) { + binding.streamerCenterBtn.setVisibility(View.GONE); + } + } + + @Override + public void onFailure(Call>> call, Throwable t) { + // 网络错误,隐藏按钮 + if (binding.streamerCenterBtn != null) { + binding.streamerCenterBtn.setVisibility(View.GONE); + } + } + }); + } + private void loadAndDisplayTags() { String location = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_LOCATION, ""); String gender = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_GENDER, ""); diff --git a/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java b/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java index 7c6759b6..616a6f83 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/RoomDetailActivity.java @@ -944,19 +944,38 @@ public class RoomDetailActivity extends AppCompatActivity { } // 获取播放地址 + // 优先使用 HTTP-FLV(IjkPlayer),延迟更低(2-3秒) + // HLS 作为备用(延迟 6-10秒) String playUrl = null; - String fallbackHlsUrl = null; + String fallbackUrl = null; if (r.getStreamUrls() != null) { - // 优先使用HTTP-FLV,延迟更低 + // 打印流地址信息用于调试 + android.util.Log.d("RoomDetail", "流地址信息:"); + android.util.Log.d("RoomDetail", " FLV: " + r.getStreamUrls().getFlv()); + android.util.Log.d("RoomDetail", " HLS: " + r.getStreamUrls().getHls()); + android.util.Log.d("RoomDetail", " RTMP: " + r.getStreamUrls().getRtmp()); + + // 优先使用 FLV(低延迟) playUrl = r.getStreamUrls().getFlv(); - fallbackHlsUrl = r.getStreamUrls().getHls(); - if (TextUtils.isEmpty(playUrl)) playUrl = fallbackHlsUrl; + fallbackUrl = r.getStreamUrls().getHls(); + // 如果 FLV 不可用,使用 HLS + if (TextUtils.isEmpty(playUrl)) { + android.util.Log.w("RoomDetail", "FLV 地址为空,使用 HLS"); + playUrl = fallbackUrl; + fallbackUrl = null; + } + } else { + android.util.Log.e("RoomDetail", "streamUrls 为 null"); } + android.util.Log.d("RoomDetail", "最终播放地址: " + playUrl); + android.util.Log.d("RoomDetail", "备用地址: " + fallbackUrl); + if (!TextUtils.isEmpty(playUrl)) { - ensurePlayer(playUrl, fallbackHlsUrl); + ensurePlayer(playUrl, fallbackUrl); } else { // 没有播放地址时显示离线状态 + android.util.Log.e("RoomDetail", "没有可用的播放地址,显示离线状态"); binding.offlineLayout.setVisibility(View.VISIBLE); releasePlayer(); } @@ -1012,15 +1031,15 @@ public class RoomDetailActivity extends AppCompatActivity { triedAltUrl = false; hasShownConnectedMessage = false; // 重置连接消息标志 - // 优化缓冲配置 - 增大缓冲区以减少卡顿 - // HLS 直播通常有 2-3 秒的切片延迟,需要足够的缓冲 + // 优化缓冲配置 - 针对低延迟 HLS(1秒分片) + // 平衡延迟和流畅度 androidx.media3.exoplayer.DefaultLoadControl loadControl = new androidx.media3.exoplayer.DefaultLoadControl.Builder() .setBufferDurationsMs( - 10000, // 最小缓冲 10秒(保证流畅播放) - 30000, // 最大缓冲 30秒(足够应对网络波动) - 5000, // 播放前缓冲 5秒(确保有足够数据再开始) - 10000 // 重新缓冲 10秒(卡顿后充分缓冲再继续) + 2500, // 最小缓冲 2.5秒(约2-3个分片) + 10000, // 最大缓冲 10秒 + 1500, // 播放前缓冲 1.5秒(快速起播) + 2500 // 重新缓冲 2.5秒 ) .setPrioritizeTimeOverSizeThresholds(true) .build(); @@ -1029,6 +1048,9 @@ public class RoomDetailActivity extends AppCompatActivity { ExoPlayer exo = new ExoPlayer.Builder(this) .setLoadControl(loadControl) .build(); + + // 关键:设置为直播模式,自动跳到最新位置 + exo.setPlayWhenReady(true); // 设置播放器视图 binding.playerView.setPlayer(exo); @@ -1087,6 +1109,12 @@ public class RoomDetailActivity extends AppCompatActivity { if (playbackState == Player.STATE_READY) { binding.offlineLayout.setVisibility(View.GONE); retryCount = 0; + + // 关键:跳到直播流的最新位置,减少延迟 + if (exo.isCurrentMediaItemLive()) { + exo.seekToDefaultPosition(); + } + // 只显示一次连接消息 if (!hasShownConnectedMessage) { hasShownConnectedMessage = true; @@ -1106,16 +1134,70 @@ public class RoomDetailActivity extends AppCompatActivity { } private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) { - android.util.Log.d("RoomDetail", "开始播放FLV流: " + flvUrl); + android.util.Log.d("RoomDetail", "尝试使用IjkPlayer播放FLV: " + flvUrl); - // 直接使用 HLS 播放,避免 IjkPlayer 崩溃问题 - // HLS 虽然延迟稍高,但稳定性更好 - String hlsUrl = fallbackHlsUrl; - if (TextUtils.isEmpty(hlsUrl)) { - hlsUrl = flvUrl.replace(".flv", ".m3u8"); + // 先尝试加载 IjkPlayer 库 + ensureIjkLibsLoaded(); + + // 如果 IjkPlayer 加载失败,直接使用 HLS + if (ijkLibLoadFailed) { + android.util.Log.w("RoomDetail", "IjkPlayer 不可用,回退到 HLS 播放"); + String hlsUrl = fallbackHlsUrl; + if (TextUtils.isEmpty(hlsUrl)) { + hlsUrl = flvUrl.replace(".flv", ".m3u8"); + } + startHls(hlsUrl, null); + return; + } + + android.util.Log.d("RoomDetail", "使用IjkPlayer播放FLV(低延迟): " + flvUrl); + + // 释放之前的播放器 + releaseExoPlayer(); + releaseIjkPlayer(); + + // 保存备用地址 + ijkUrl = flvUrl; + ijkFallbackHlsUrl = fallbackHlsUrl; + if (TextUtils.isEmpty(ijkFallbackHlsUrl)) { + ijkFallbackHlsUrl = flvUrl.replace(".flv", ".m3u8"); + } + ijkFallbackTried = false; + hasShownConnectedMessage = false; + + // 显示 FLV 播放视图 + if (binding != null) { + binding.playerView.setVisibility(View.GONE); + binding.flvTextureView.setVisibility(View.VISIBLE); + } + + // 设置 TextureView 监听 + binding.flvTextureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() { + @Override + public void onSurfaceTextureAvailable(android.graphics.SurfaceTexture surface, int width, int height) { + android.util.Log.d("IjkPlayer", "Surface 可用,开始准备播放"); + ijkSurface = new Surface(surface); + prepareIjk(flvUrl); + } + + @Override + public void onSurfaceTextureSizeChanged(android.graphics.SurfaceTexture surface, int width, int height) {} + + @Override + public boolean onSurfaceTextureDestroyed(android.graphics.SurfaceTexture surface) { + return true; + } + + @Override + public void onSurfaceTextureUpdated(android.graphics.SurfaceTexture surface) {} + }); + + // 如果 Surface 已经可用,直接开始播放 + if (binding.flvTextureView.isAvailable()) { + android.util.Log.d("IjkPlayer", "Surface 已可用,直接开始播放"); + ijkSurface = new Surface(binding.flvTextureView.getSurfaceTexture()); + prepareIjk(flvUrl); } - android.util.Log.d("RoomDetail", "使用 HLS 播放: " + hlsUrl); - startHls(hlsUrl, null); } private void prepareIjk(String url) { @@ -1195,13 +1277,18 @@ public class RoomDetailActivity extends AppCompatActivity { private static boolean ijkLibLoadFailed = false; private static void ensureIjkLibsLoaded() { - if (ijkLibLoaded || ijkLibLoadFailed) return; + if (ijkLibLoaded || ijkLibLoadFailed) { + android.util.Log.d("IjkPlayer", "IjkPlayer 库状态: loaded=" + ijkLibLoaded + ", failed=" + ijkLibLoadFailed); + return; + } try { // 检查设备 CPU 架构 String[] abis = android.os.Build.SUPPORTED_ABIS; + android.util.Log.d("IjkPlayer", "设备 CPU 架构: " + java.util.Arrays.toString(abis)); + boolean supported = false; for (String abi : abis) { - if ("armeabi-v7a".equals(abi) || "arm64-v8a".equals(abi)) { + if ("armeabi-v7a".equals(abi) || "arm64-v8a".equals(abi) || "x86".equals(abi) || "x86_64".equals(abi)) { supported = true; break; } @@ -1217,7 +1304,7 @@ public class RoomDetailActivity extends AppCompatActivity { ijkLibLoaded = true; android.util.Log.d("IjkPlayer", "IjkPlayer 库加载成功"); } catch (Throwable e) { - android.util.Log.e("IjkPlayer", "加载IjkPlayer库失败: " + e.getMessage()); + android.util.Log.e("IjkPlayer", "加载IjkPlayer库失败: " + e.getMessage(), e); ijkLibLoadFailed = true; } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/SettingsPageActivity.java b/android-app/app/src/main/java/com/example/livestreaming/SettingsPageActivity.java index 63afa67a..5de8fa52 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/SettingsPageActivity.java +++ b/android-app/app/src/main/java/com/example/livestreaming/SettingsPageActivity.java @@ -107,6 +107,33 @@ public class SettingsPageActivity extends AppCompatActivity { return; } + // 处理主播相关点击 + if ("主播中心".equals(t)) { + StreamerCenterActivity.start(this); + return; + } + + if ("切换到主播模式".equals(t)) { + com.example.livestreaming.net.AuthStore.setStreamerMode(this, true); + Toast.makeText(this, "已切换到主播模式", Toast.LENGTH_SHORT).show(); + StreamerCenterActivity.start(this); + refresh(); + return; + } + + if ("切换回普通用户".equals(t)) { + com.example.livestreaming.net.AuthStore.setStreamerMode(this, false); + Toast.makeText(this, "已切换回普通用户模式", Toast.LENGTH_SHORT).show(); + refresh(); + return; + } + + if ("申请成为主播".equals(t)) { + Intent intent = new Intent(this, StreamerApplyActivity.class); + startActivity(intent); + return; + } + // 处理其他页面的点击事件 if (PAGE_SERVER.equals(page)) { // 服务器设置页面的其他项目已在上面处理 @@ -342,6 +369,22 @@ public class SettingsPageActivity extends AppCompatActivity { list.add(MoreItem.section("通用")); list.add(MoreItem.row("服务器设置", "切换API与直播流地址", R.drawable.ic_globe_24)); list.add(MoreItem.row("关于", "版本信息、协议", R.drawable.ic_menu_24)); + + // 主播相关入口 + list.add(MoreItem.section("主播")); + if (com.example.livestreaming.net.AuthStore.isStreamer(this)) { + // 已认证主播 + if (com.example.livestreaming.net.AuthStore.isStreamerMode(this)) { + list.add(MoreItem.row("主播中心", "管理直播间、查看数据", R.drawable.ic_live_24)); + list.add(MoreItem.row("切换回普通用户", "退出主播模式", R.drawable.ic_person_24)); + } else { + list.add(MoreItem.row("切换到主播模式", "进入主播中心", R.drawable.ic_live_24)); + } + } else { + // 未认证主播,显示申请入口 + list.add(MoreItem.row("申请成为主播", "认证后可开播", R.drawable.ic_live_24)); + } + return list; } diff --git a/android-app/app/src/main/java/com/example/livestreaming/StreamerCenterActivity.java b/android-app/app/src/main/java/com/example/livestreaming/StreamerCenterActivity.java new file mode 100644 index 00000000..a1370593 --- /dev/null +++ b/android-app/app/src/main/java/com/example/livestreaming/StreamerCenterActivity.java @@ -0,0 +1,226 @@ +package com.example.livestreaming; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.View; +import android.widget.Toast; + +import androidx.appcompat.app.AppCompatActivity; +import androidx.recyclerview.widget.LinearLayoutManager; + +import com.bumptech.glide.Glide; +import com.example.livestreaming.databinding.ActivityStreamerCenterBinding; +import com.example.livestreaming.net.ApiClient; +import com.example.livestreaming.net.ApiResponse; +import com.example.livestreaming.net.AuthStore; + +import java.util.List; +import java.util.Map; + +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.Response; + +/** + * 主播中心页面 + * 显示主播的直播间、粉丝、收到的礼物等信息 + */ +public class StreamerCenterActivity extends AppCompatActivity { + + private ActivityStreamerCenterBinding binding; + + public static void start(Context context) { + Intent intent = new Intent(context, StreamerCenterActivity.class); + context.startActivity(intent); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + binding = ActivityStreamerCenterBinding.inflate(getLayoutInflater()); + setContentView(binding.getRoot()); + + setupToolbar(); + setupClickListeners(); + loadStreamerData(); + } + + private void setupToolbar() { + binding.toolbar.setNavigationOnClickListener(v -> finish()); + } + + private void setupClickListeners() { + // 开始直播 + binding.btnStartLive.setOnClickListener(v -> { + Intent intent = new Intent(this, BroadcastActivity.class); + startActivity(intent); + }); + + // 粉丝列表 + binding.layoutFans.setOnClickListener(v -> { + Intent intent = new Intent(this, FansListActivity.class); + startActivity(intent); + }); + + // 获赞列表 + binding.layoutLikes.setOnClickListener(v -> { + Intent intent = new Intent(this, LikesListActivity.class); + startActivity(intent); + }); + + // 礼物列表 + binding.layoutGifts.setOnClickListener(v -> { + // TODO: 跳转到礼物详情页 + Toast.makeText(this, "礼物详情功能开发中", Toast.LENGTH_SHORT).show(); + }); + + // 收益 + binding.layoutIncome.setOnClickListener(v -> { + // TODO: 跳转到收益页面 + Toast.makeText(this, "收益详情功能开发中", Toast.LENGTH_SHORT).show(); + }); + + // 查看全部礼物 + binding.tvViewAllGifts.setOnClickListener(v -> { + // TODO: 跳转到礼物列表页 + Toast.makeText(this, "礼物列表功能开发中", Toast.LENGTH_SHORT).show(); + }); + + // 我的直播间 + binding.cardMyRoom.setOnClickListener(v -> { + // TODO: 跳转到直播间管理页 + Toast.makeText(this, "直播间管理功能开发中", Toast.LENGTH_SHORT).show(); + }); + + // 切换回普通用户 + binding.btnSwitchToUser.setOnClickListener(v -> { + AuthStore.setStreamerMode(this, false); + Toast.makeText(this, "已切换回普通用户模式", Toast.LENGTH_SHORT).show(); + finish(); + }); + } + + private void loadStreamerData() { + binding.progressBar.setVisibility(View.VISIBLE); + + // 设置基本信息 + String nickname = AuthStore.getNickname(this); + binding.tvNickname.setText(nickname); + + // 加载主播数据 + loadStreamerStats(); + loadReceivedGifts(); + } + + private void loadStreamerStats() { + ApiClient.getService(this).getStreamerStats() + .enqueue(new Callback>>() { + @Override + public void onResponse(Call>> call, + Response>> response) { + binding.progressBar.setVisibility(View.GONE); + + if (response.isSuccessful() && response.body() != null && response.body().isOk()) { + Map data = response.body().getData(); + updateStats(data); + } + } + + @Override + public void onFailure(Call>> call, Throwable t) { + binding.progressBar.setVisibility(View.GONE); + } + }); + } + + private void updateStats(Map data) { + if (data == null) return; + + // 粉丝数 + Object fansObj = data.get("fansCount"); + if (fansObj != null) { + binding.tvFansCount.setText(formatCount(((Number) fansObj).intValue())); + } + + // 获赞数 + Object likesObj = data.get("likesCount"); + if (likesObj != null) { + binding.tvLikesCount.setText(formatCount(((Number) likesObj).intValue())); + } + + // 礼物数 + Object giftsObj = data.get("giftsCount"); + if (giftsObj != null) { + binding.tvGiftsCount.setText(formatCount(((Number) giftsObj).intValue())); + } + + // 收益 + Object incomeObj = data.get("totalIncome"); + if (incomeObj != null) { + binding.tvIncomeCount.setText(formatCount(((Number) incomeObj).intValue())); + } + + // 主播等级 + Object levelObj = data.get("streamerLevel"); + if (levelObj != null) { + binding.tvStreamerLevel.setText("主播等级: Lv." + ((Number) levelObj).intValue()); + } + + // 头像 + Object avatarObj = data.get("avatar"); + if (avatarObj != null && !avatarObj.toString().isEmpty()) { + Glide.with(this) + .load(avatarObj.toString()) + .placeholder(R.drawable.ic_person_24) + .into(binding.ivAvatar); + } + + // 直播间状态 + Object roomStatusObj = data.get("hasActiveRoom"); + if (roomStatusObj != null && (Boolean) roomStatusObj) { + binding.tvRoomStatus.setText("直播间已创建"); + } else { + binding.tvRoomStatus.setText("暂无直播间,点击开始直播创建"); + } + } + + private void loadReceivedGifts() { + ApiClient.getService(this).getReceivedGifts(1, 10) + .enqueue(new Callback>>>() { + @Override + public void onResponse(Call>>> call, + Response>>> response) { + if (response.isSuccessful() && response.body() != null && response.body().isOk()) { + List> gifts = response.body().getData(); + if (gifts != null && !gifts.isEmpty()) { + binding.tvNoGifts.setVisibility(View.GONE); + binding.rvGifts.setVisibility(View.VISIBLE); + // TODO: 设置礼物列表适配器 + } else { + binding.tvNoGifts.setVisibility(View.VISIBLE); + binding.rvGifts.setVisibility(View.GONE); + } + } else { + binding.tvNoGifts.setVisibility(View.VISIBLE); + binding.rvGifts.setVisibility(View.GONE); + } + } + + @Override + public void onFailure(Call>>> call, Throwable t) { + binding.tvNoGifts.setVisibility(View.VISIBLE); + binding.rvGifts.setVisibility(View.GONE); + } + }); + } + + private String formatCount(int count) { + if (count >= 10000) { + return String.format("%.1fw", count / 10000.0); + } else if (count >= 1000) { + return String.format("%.1fk", count / 1000.0); + } + return String.valueOf(count); + } +} \ No newline at end of file diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java b/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java index 12b00147..d1c94ff6 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java +++ b/android-app/app/src/main/java/com/example/livestreaming/net/ApiService.java @@ -517,6 +517,26 @@ public interface ApiService { @GET("api/front/streamer/applications") Call>>> getStreamerApplications(); + /** + * 获取主播统计数据(粉丝数、获赞数、礼物数、收益等) + */ + @GET("api/front/streamer/stats") + Call>> getStreamerStats(); + + /** + * 获取收到的礼物列表 + */ + @GET("api/front/streamer/gifts/received") + Call>>> getReceivedGifts( + @Query("page") int page, + @Query("pageSize") int pageSize); + + /** + * 获取我的直播间列表 + */ + @GET("api/front/streamer/rooms") + Call>> getMyRooms(); + // ==================== 社区/缘池接口 ==================== /** diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/AuthStore.java b/android-app/app/src/main/java/com/example/livestreaming/net/AuthStore.java index cbc551d0..df402e0f 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/net/AuthStore.java +++ b/android-app/app/src/main/java/com/example/livestreaming/net/AuthStore.java @@ -48,6 +48,8 @@ public final class AuthStore { private static final String KEY_USER_ID = "user_id"; private static final String KEY_NICKNAME = "nickname"; + private static final String KEY_IS_STREAMER = "is_streamer"; + private static final String KEY_STREAMER_MODE = "streamer_mode"; public static void setUserInfo(Context context, @Nullable String userId, @Nullable String nickname) { if (context == null) return; @@ -79,4 +81,58 @@ public final class AuthStore { .getString(KEY_NICKNAME, null); return nickname != null ? nickname : "用户"; } + + /** + * 设置用户是否是认证主播 + */ + public static void setIsStreamer(Context context, boolean isStreamer) { + if (context == null) return; + Log.d(TAG, "setIsStreamer: " + isStreamer); + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putBoolean(KEY_IS_STREAMER, isStreamer) + .apply(); + } + + /** + * 获取用户是否是认证主播 + */ + public static boolean isStreamer(Context context) { + if (context == null) return false; + return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_IS_STREAMER, false); + } + + /** + * 设置是否处于主播模式 + */ + public static void setStreamerMode(Context context, boolean streamerMode) { + if (context == null) return; + Log.d(TAG, "setStreamerMode: " + streamerMode); + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .putBoolean(KEY_STREAMER_MODE, streamerMode) + .apply(); + } + + /** + * 获取是否处于主播模式 + */ + public static boolean isStreamerMode(Context context) { + if (context == null) return false; + return context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .getBoolean(KEY_STREAMER_MODE, false); + } + + /** + * 清除所有用户数据(退出登录时调用) + */ + public static void clearAll(Context context) { + if (context == null) return; + Log.d(TAG, "clearAll: clearing all auth data"); + context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + .edit() + .clear() + .apply(); + } } diff --git a/android-app/app/src/main/java/com/example/livestreaming/net/CreateRoomRequest.java b/android-app/app/src/main/java/com/example/livestreaming/net/CreateRoomRequest.java index 6348b714..6104ece5 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/net/CreateRoomRequest.java +++ b/android-app/app/src/main/java/com/example/livestreaming/net/CreateRoomRequest.java @@ -2,16 +2,44 @@ package com.example.livestreaming.net; import com.google.gson.annotations.SerializedName; +/** + * 创建直播间请求 + * 对应后端 CreateLiveRoomRequest + */ public class CreateRoomRequest { @SerializedName("title") - private final String title; + private String title; @SerializedName("streamerName") - private final String streamerName; + private String streamerName; @SerializedName("type") - private final String type; + private String type; + + @SerializedName("categoryId") + private Integer categoryId; + + @SerializedName("description") + private String description; + + @SerializedName("coverImage") + private String coverImage; + + @SerializedName("tags") + private String tags; + + @SerializedName("notice") + private String notice; + + public CreateRoomRequest() { + } + + public CreateRoomRequest(String title, String streamerName) { + this.title = title; + this.streamerName = streamerName; + this.type = "live"; + } public CreateRoomRequest(String title, String streamerName, String type) { this.title = title; @@ -19,15 +47,68 @@ public class CreateRoomRequest { this.type = type; } + // Getters and Setters public String getTitle() { return title; } + public void setTitle(String title) { + this.title = title; + } + public String getStreamerName() { return streamerName; } + public void setStreamerName(String streamerName) { + this.streamerName = streamerName; + } + public String getType() { return type; } -} \ No newline at end of file + + public void setType(String type) { + this.type = type; + } + + public Integer getCategoryId() { + return categoryId; + } + + public void setCategoryId(Integer categoryId) { + this.categoryId = categoryId; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getCoverImage() { + return coverImage; + } + + public void setCoverImage(String coverImage) { + this.coverImage = coverImage; + } + + public String getTags() { + return tags; + } + + public void setTags(String tags) { + this.tags = tags; + } + + public String getNotice() { + return notice; + } + + public void setNotice(String notice) { + this.notice = notice; + } +} diff --git a/android-app/app/src/main/res/drawable/bg_circle_red.xml b/android-app/app/src/main/res/drawable/bg_circle_red.xml index 63b4037c..aa99c8bc 100644 --- a/android-app/app/src/main/res/drawable/bg_circle_red.xml +++ b/android-app/app/src/main/res/drawable/bg_circle_red.xml @@ -3,4 +3,3 @@ android:shape="oval"> - diff --git a/android-app/app/src/main/res/drawable/bg_circle_semi_transparent.xml b/android-app/app/src/main/res/drawable/bg_circle_semi_transparent.xml new file mode 100644 index 00000000..3f787a7a --- /dev/null +++ b/android-app/app/src/main/res/drawable/bg_circle_semi_transparent.xml @@ -0,0 +1,5 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/bg_gradient_bottom.xml b/android-app/app/src/main/res/drawable/bg_gradient_bottom.xml new file mode 100644 index 00000000..1cc72762 --- /dev/null +++ b/android-app/app/src/main/res/drawable/bg_gradient_bottom.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/bg_gradient_top.xml b/android-app/app/src/main/res/drawable/bg_gradient_top.xml new file mode 100644 index 00000000..96f386ca --- /dev/null +++ b/android-app/app/src/main/res/drawable/bg_gradient_top.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/bg_purple_button.xml b/android-app/app/src/main/res/drawable/bg_purple_button.xml new file mode 100644 index 00000000..35913988 --- /dev/null +++ b/android-app/app/src/main/res/drawable/bg_purple_button.xml @@ -0,0 +1,6 @@ + + + + + diff --git a/android-app/app/src/main/res/drawable/bg_rounded_semi_transparent.xml b/android-app/app/src/main/res/drawable/bg_rounded_semi_transparent.xml new file mode 100644 index 00000000..6e974158 --- /dev/null +++ b/android-app/app/src/main/res/drawable/bg_rounded_semi_transparent.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android-app/app/src/main/res/drawable/ic_close_24.xml b/android-app/app/src/main/res/drawable/ic_close_24.xml index 76ab5724..df029b8e 100644 --- a/android-app/app/src/main/res/drawable/ic_close_24.xml +++ b/android-app/app/src/main/res/drawable/ic_close_24.xml @@ -5,7 +5,6 @@ android:viewportWidth="24" android:viewportHeight="24"> - diff --git a/android-app/app/src/main/res/drawable/ic_live_24.xml b/android-app/app/src/main/res/drawable/ic_live_24.xml new file mode 100644 index 00000000..6a5f4ac7 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_live_24.xml @@ -0,0 +1,16 @@ + + + + + + \ No newline at end of file diff --git a/android-app/app/src/main/res/drawable/ic_person_24.xml b/android-app/app/src/main/res/drawable/ic_person_24.xml index d356bee7..b16173c3 100644 --- a/android-app/app/src/main/res/drawable/ic_person_24.xml +++ b/android-app/app/src/main/res/drawable/ic_person_24.xml @@ -5,6 +5,6 @@ android:viewportWidth="24" android:viewportHeight="24"> + android:fillColor="#FFFFFF" + android:pathData="M12,12c2.21,0 4,-1.79 4,-4s-1.79,-4 -4,-4 -4,1.79 -4,4 1.79,4 4,4zM12,14c-2.67,0 -8,1.34 -8,4v2h16v-2c0,-2.66 -5.33,-4 -8,-4z" /> diff --git a/android-app/app/src/main/res/drawable/ic_settings_24.xml b/android-app/app/src/main/res/drawable/ic_settings_24.xml new file mode 100644 index 00000000..ce778bc3 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_settings_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/drawable/ic_switch_camera_24.xml b/android-app/app/src/main/res/drawable/ic_switch_camera_24.xml new file mode 100644 index 00000000..07112ad2 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_switch_camera_24.xml @@ -0,0 +1,10 @@ + + + + diff --git a/android-app/app/src/main/res/layout/activity_broadcast.xml b/android-app/app/src/main/res/layout/activity_broadcast.xml new file mode 100644 index 00000000..1ba7bd5d --- /dev/null +++ b/android-app/app/src/main/res/layout/activity_broadcast.xml @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/res/layout/activity_profile.xml b/android-app/app/src/main/res/layout/activity_profile.xml index d556892c..1dccc4dd 100644 --- a/android-app/app/src/main/res/layout/activity_profile.xml +++ b/android-app/app/src/main/res/layout/activity_profile.xml @@ -221,6 +221,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:orientation="horizontal" + android:visibility="gone" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent"> @@ -549,10 +550,24 @@ android:gravity="center" android:text="分享主页" android:textColor="#111111" - app:layout_constraintEnd_toStartOf="@id/addFriendBtn" + app:layout_constraintEnd_toStartOf="@id/streamerCenterBtn" app:layout_constraintStart_toEndOf="@id/editProfile" app:layout_constraintTop_toTopOf="parent" /> + + @@ -573,6 +588,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="12dp" + android:visibility="gone" app:tabIndicatorColor="@color/purple_500" app:tabIndicatorFullWidth="false" app:tabIndicatorHeight="3dp" @@ -605,6 +621,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginTop="12dp" + android:visibility="gone" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@id/profileTabs"> diff --git a/android-app/app/src/main/res/layout/activity_streamer_center.xml b/android-app/app/src/main/res/layout/activity_streamer_center.xml new file mode 100644 index 00000000..dd842d3d --- /dev/null +++ b/android-app/app/src/main/res/layout/activity_streamer_center.xml @@ -0,0 +1,362 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/delete_room_menus.sql b/delete_room_menus.sql new file mode 100644 index 00000000..1dfa0a6d --- /dev/null +++ b/delete_room_menus.sql @@ -0,0 +1,11 @@ +-- 删除"房间类型"和"房间背景"菜单 + +-- 先查询表结构 +-- DESCRIBE eb_system_menu; + +-- 查询这两个菜单 +SELECT id, name FROM eb_system_menu WHERE name IN ('房间类型', '房间背景'); + +-- 删除菜单(根据名称) +DELETE FROM eb_system_menu WHERE name = '房间类型'; +DELETE FROM eb_system_menu WHERE name = '房间背景'; diff --git a/live-streaming/docker/srs/srs.conf b/live-streaming/docker/srs/srs.conf index fdd9ad0e..097882a4 100644 --- a/live-streaming/docker/srs/srs.conf +++ b/live-streaming/docker/srs/srs.conf @@ -38,15 +38,20 @@ vhost __defaultVhost__ { chunk_size 4096; } - # HLS 配置 - 优化延迟 + # HLS 配置 - 低延迟模式 hls { enabled on; hls_path ./objs/nginx/html; - # 减少分片时长,降低延迟 - hls_fragment 2; - hls_window 6; - # 启用低延迟模式 - hls_dispose 30; + # 最小分片时长 1秒,降低延迟 + hls_fragment 1; + # 保留 3 个分片 + hls_window 3; + # 快速清理过期分片 + hls_dispose 10; + # 启用 ts 文件清理 + hls_cleanup on; + # 等待关键帧 + hls_wait_keyframe on; } # HTTP-FLV 配置 - 低延迟播放 @@ -64,12 +69,14 @@ vhost __defaultVhost__ { # 播放配置 - 优化延迟 play { - # 减少GOP缓存 + # 关闭 GOP 缓存,降低延迟 gop_cache off; # 启用时间校正 time_jitter full; # 减少队列长度 queue_length 10; + # 降低首帧等待 + mw_latency 100; } # 发布配置 - 优化延迟 diff --git a/live-streaming/docker启动配置文件.md b/live-streaming/docker启动配置文件.md new file mode 100644 index 00000000..9858c984 --- /dev/null +++ b/live-streaming/docker启动配置文件.md @@ -0,0 +1,54 @@ +docker stop srs-server +docker rm srs-server + +cat > /opt/live-streaming/docker/srs/srs.conf << 'EOF' +listen 1935; +max_connections 1000; +daemon off; +srs_log_tank console; + +http_server { + enabled on; + listen 8080; + dir ./objs/nginx/html; + crossdomain on; +} + +http_api { + enabled on; + listen 1985; + crossdomain on; +} + +vhost __defaultVhost__ { + # HLS 作为备用 + hls { + enabled on; + hls_path ./objs/nginx/html; + hls_fragment 2; + hls_window 4; + hls_cleanup on; + } + + # HTTP-FLV 低延迟(主要使用) + http_remux { + enabled on; + mount [vhost]/[app]/[stream].flv; + } + + # 低延迟播放配置 + play { + gop_cache on; + queue_length 10; + mw_latency 100; + } +} +EOF + +docker run -d --name srs-server \ + -p 25002:1935 \ + -p 25003:8080 \ + -p 1985:1985 \ + -v /opt/live-streaming/docker/srs/srs.conf:/usr/local/srs/conf/srs.conf \ + --restart unless-stopped \ + ossrs/srs:5 diff --git a/room_category_table.sql b/room_category_table.sql new file mode 100644 index 00000000..503c2680 --- /dev/null +++ b/room_category_table.sql @@ -0,0 +1,22 @@ +-- 直播间分类表 +CREATE TABLE IF NOT EXISTS `eb_live_room_category` ( + `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '分类ID', + `name` varchar(50) NOT NULL COMMENT '分类名称', + `icon` varchar(255) DEFAULT NULL COMMENT '图标(类名或URL)', + `sort` int(11) DEFAULT 0 COMMENT '排序', + `status` tinyint(1) DEFAULT 1 COMMENT '状态:0-禁用,1-启用', + `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='直播间分类表'; + +-- 初始化一些默认分类 +INSERT INTO `eb_live_room_category` (`name`, `icon`, `sort`, `status`) VALUES +('娱乐', 'el-icon-video-camera', 1, 1), +('游戏', 'el-icon-coordinate', 2, 1), +('音乐', 'el-icon-headset', 3, 1), +('户外', 'el-icon-location', 4, 1), +('聊天', 'el-icon-chat-dot-round', 5, 1); + +-- 给 eb_live_room 表添加 category_id 字段(如果不存在) +-- ALTER TABLE `eb_live_room` ADD COLUMN `category_id` int(11) DEFAULT NULL COMMENT '分类ID' AFTER `streamer_name`; diff --git a/服务管理指南.md b/服务管理指南.md new file mode 100644 index 00000000..7365c870 --- /dev/null +++ b/服务管理指南.md @@ -0,0 +1,228 @@ +# 直播系统服务管理指南 + +## 📍 服务器信息 +- **IP地址**: 1.15.149.240 +- **SSH登录**: `ssh root@1.15.149.240` + +--- + +## 🗂️ 服务清单 + +| 服务 | 端口 | 部署位置 | 说明 | +|------|------|----------|------| +| Admin API | 30003 | /opt/zhibo/admin-api | 管理后台API | +| Front API | 8083 | /opt/zhibo/front-api | APP端API | +| Admin Web | 30002 | /opt/zhibo/admin-web | 管理后台网页 | +| SRS 直播 | 25002/25003 | Docker | RTMP推流/HTTP拉流 | +| TURN 服务 | 3478 | Docker | WebRTC中继 | + +--- + +## 🔧 一、Java 后端服务管理 + +### 登录服务器 +```bash +ssh root@1.15.149.240 +``` + +### 查看服务状态 +```bash +# 查看所有Java进程 +ps aux | grep -E "Crmeb-admin|Crmeb-front" + +# 查看端口占用 +netstat -tlnp | grep -E "30003|8083" +``` + +### 停止服务 +```bash +# 停止 Admin API +pkill -f "Crmeb-admin.jar" + +# 停止 Front API +pkill -f "Crmeb-front.jar" + +# 或者一键停止 +/opt/zhibo/scripts/stop-all.sh +``` + +### 启动服务 +```bash +# 一键启动所有服务 +/opt/zhibo/scripts/start-all.sh + +# 或者单独启动 +/opt/zhibo/scripts/start-admin-api.sh +/opt/zhibo/scripts/start-front-api.sh +``` + +### 查看日志 +```bash +# Admin API 日志 +tail -100f /opt/zhibo/logs/admin-api.log + +# Front API 日志 +tail -100f /opt/zhibo/logs/front-api.log +``` + +--- + +## 🎬 二、SRS 直播服务管理 + +### 查看 SRS 状态 +```bash +# 查看 Docker 容器状态 +docker ps | grep srs + +# 查看 SRS 日志 +docker logs srs-server --tail 100 +``` + +### 停止 SRS +```bash +# 方法1: 使用 docker-compose(如果有) +cd /opt/live-streaming # 或者 SRS 部署目录 +docker-compose down + +# 方法2: 直接停止容器 +docker stop srs-server +docker rm srs-server +``` + +### 启动 SRS +```bash +# 方法1: 使用 docker-compose +cd /opt/live-streaming +docker-compose up -d + +# 方法2: 直接运行 +docker run -d --name srs-server \ + -p 25002:1935 \ + -p 25003:8080 \ + -p 1985:1985 \ + -v /opt/live-streaming/docker/srs/srs.conf:/usr/local/srs/conf/srs.conf \ + ossrs/srs:5 +``` + +### 重启 SRS(应用新配置) +```bash +docker restart srs-server +``` + +--- + +## 🔄 三、完整重新部署流程 + +### 步骤 1: 停止所有服务 +```bash +ssh root@1.15.149.240 + +# 停止 Java 服务 +pkill -f "Crmeb-admin.jar" +pkill -f "Crmeb-front.jar" + +# 停止 SRS +docker stop srs-server 2>/dev/null +docker rm srs-server 2>/dev/null +``` + +### 步骤 2: 上传新文件(在本地执行) +```bash +# 上传 Java JAR 包 +scp Zhibo/zhibo-h/crmeb-admin/target/Crmeb-admin.jar root@1.15.149.240:/opt/zhibo/admin-api/ +scp Zhibo/zhibo-h/crmeb-front/target/Crmeb-front.jar root@1.15.149.240:/opt/zhibo/front-api/ + +# 上传 SRS 配置 +scp live-streaming/docker/srs/srs.conf root@1.15.149.240:/opt/live-streaming/docker/srs/ +``` + +### 步骤 3: 启动所有服务 +```bash +ssh root@1.15.149.240 + +# 启动 Java 服务 +/opt/zhibo/scripts/start-all.sh + +# 启动 SRS +cd /opt/live-streaming +docker-compose up -d +``` + +### 步骤 4: 验证服务 +```bash +# 检查端口 +netstat -tlnp | grep -E "30003|8083|25002|25003" + +# 测试 API +curl http://localhost:8083/api/front/index +curl http://localhost:30003/api/admin/version + +# 测试 SRS +curl http://localhost:1985/api/v1/versions +``` + +--- + +## 🚨 四、常见问题排查 + +### 服务启动失败 +```bash +# 查看详细日志 +cat /opt/zhibo/logs/admin-api.log +cat /opt/zhibo/logs/front-api.log + +# 检查 Java 版本 +java -version + +# 检查 Redis 是否运行 +redis-cli ping +``` + +### 端口被占用 +```bash +# 查看端口占用 +lsof -i :8083 +lsof -i :30003 + +# 杀掉占用进程 +kill -9 +``` + +### SRS 无法推流 +```bash +# 检查防火墙 +firewall-cmd --list-ports +# 开放端口 +firewall-cmd --add-port=25002/tcp --permanent +firewall-cmd --add-port=25003/tcp --permanent +firewall-cmd --reload +``` + +--- + +## 📱 五、Android APP 配置 + +APP 连接的服务器地址配置在 `android-app/local.properties`: + +```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 +``` + +修改后需要重新编译 APK。 + +--- + +## 📋 六、快速命令参考 + +| 操作 | 命令 | +|------|------| +| 登录服务器 | `ssh root@1.15.149.240` | +| 查看所有服务 | `ps aux \| grep -E "Crmeb\|srs"` | +| 停止所有 Java | `pkill -f "Crmeb"` | +| 启动所有 Java | `/opt/zhibo/scripts/start-all.sh` | +| 查看 SRS 日志 | `docker logs srs-server --tail 50` | +| 重启 SRS | `docker restart srs-server` | +| 查看端口 | `netstat -tlnp \| grep -E "8083\|30003\|25002\|25003"` |