更新:添加用户和主播切换功能
This commit is contained in:
parent
7042137e5b
commit
f3169b2eba
|
|
@ -147,3 +147,40 @@ export function roomUpdateApi(data) {
|
||||||
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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,25 +15,13 @@ const liveManageRouter = {
|
||||||
icon: 'el-icon-video-camera',
|
icon: 'el-icon-video-camera',
|
||||||
},
|
},
|
||||||
children: [
|
children: [
|
||||||
// 房间管理
|
// 房间管理(包含分类管理)
|
||||||
{
|
{
|
||||||
path: 'room/list',
|
path: 'room/list',
|
||||||
component: () => import('@/views/room/list/index'),
|
component: () => import('@/views/room/list/index'),
|
||||||
name: 'RoomList',
|
name: 'RoomList',
|
||||||
meta: { title: '房间列表', icon: '' },
|
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',
|
path: 'family/list',
|
||||||
|
|
|
||||||
|
|
@ -2,100 +2,141 @@
|
||||||
<div class="divBox">
|
<div class="divBox">
|
||||||
<el-card shadow="never" class="ivu-mt">
|
<el-card shadow="never" class="ivu-mt">
|
||||||
<div class="padding-add">
|
<div class="padding-add">
|
||||||
<el-page-header @back="goBack" content="房间管理列表"></el-page-header>
|
<el-page-header @back="goBack" content="房间管理"></el-page-header>
|
||||||
<div class="mt20">
|
|
||||||
<div class="mb20" style="display: flex; justify-content: space-between; align-items: center;">
|
<!-- Tab切换:房间列表 / 分类管理 -->
|
||||||
<div>
|
<el-tabs v-model="activeTab" class="mt20">
|
||||||
<el-button type="danger" icon="el-icon-delete" :disabled="!multipleSelection.length" @click="handleBatchDelete">批量删除</el-button>
|
<el-tab-pane label="房间列表" name="rooms">
|
||||||
<el-button type="primary" icon="el-icon-plus" @click="openCreate">新增直播间</el-button>
|
<div class="mt20">
|
||||||
</div>
|
<div class="mb20" style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
<el-button type="text" icon="el-icon-refresh" @click="getList">刷新</el-button>
|
<div>
|
||||||
</div>
|
<el-button type="danger" icon="el-icon-delete" :disabled="!multipleSelection.length" @click="handleBatchDelete">批量删除</el-button>
|
||||||
<!-- 搜索表单 -->
|
<el-button type="primary" icon="el-icon-plus" @click="openCreate">新增直播间</el-button>
|
||||||
<el-form inline size="small" :model="searchForm" class="mb20 search-form-inline">
|
|
||||||
<el-form-item>
|
|
||||||
<el-input v-model="searchForm.streamerName" placeholder="请输入主播名称" clearable style="width: 150px" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-input v-model="searchForm.title" placeholder="请输入直播标题" clearable style="width: 150px" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-input v-model="searchForm.streamKey" placeholder="请输入 streamKey" clearable style="width: 150px" />
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-date-picker v-model="searchForm.startTime" type="date" placeholder="开始时间" style="width: 150px"></el-date-picker>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-date-picker v-model="searchForm.endTime" type="date" placeholder="截止时间" style="width: 150px"></el-date-picker>
|
|
||||||
</el-form-item>
|
|
||||||
<el-form-item>
|
|
||||||
<el-button type="primary" icon="el-icon-search" @click="handleSearch">查询</el-button>
|
|
||||||
</el-form-item>
|
|
||||||
</el-form>
|
|
||||||
|
|
||||||
<!-- 表格 -->
|
|
||||||
<el-table :data="tableData" v-loading="loading" border style="width: 100%" @selection-change="handleSelectionChange">
|
|
||||||
<el-table-column type="selection" width="55" align="center" />
|
|
||||||
<el-table-column prop="id" label="房间ID" width="80" align="center" sortable />
|
|
||||||
<el-table-column prop="title" label="标题" min-width="180" />
|
|
||||||
<el-table-column prop="streamerName" label="主播" width="120" />
|
|
||||||
<el-table-column prop="streamKey" label="streamKey" min-width="220" />
|
|
||||||
<el-table-column label="直播状态" width="100" align="center">
|
|
||||||
<template slot-scope="scope">
|
|
||||||
<span>{{ scope.row.isLive ? '直播中' : '未开播' }}</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column prop="createTime" label="创建时间" width="160" align="center" />
|
|
||||||
<el-table-column label="播放地址(FLV)" min-width="260">
|
|
||||||
<template slot-scope="scope">
|
|
||||||
<span>{{ scope.row.streamUrls && scope.row.streamUrls.flv }}</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="播放地址(HLS)" min-width="260">
|
|
||||||
<template slot-scope="scope">
|
|
||||||
<span>{{ scope.row.streamUrls && scope.row.streamUrls.hls }}</span>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column label="操作" width="320" align="center" fixed="right">
|
|
||||||
<template slot-scope="scope">
|
|
||||||
<div class="action-btns">
|
|
||||||
<el-button :type="scope.row.isLive ? 'danger' : 'success'" size="mini" @click="handleToggleStatus(scope.row)">{{ scope.row.isLive ? '停播' : '开播' }}</el-button>
|
|
||||||
<el-button type="primary" size="mini" @click="handleEdit(scope.row)">编辑</el-button>
|
|
||||||
<el-button type="warning" size="mini" @click="handleDetail(scope.row)">详情</el-button>
|
|
||||||
<el-popconfirm title="确定删除吗?" @confirm="handleDelete(scope.row)">
|
|
||||||
<el-button slot="reference" type="danger" size="mini">删除</el-button>
|
|
||||||
</el-popconfirm>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<el-button type="text" icon="el-icon-refresh" @click="getList">刷新</el-button>
|
||||||
</el-table-column>
|
</div>
|
||||||
</el-table>
|
<!-- 搜索表单 -->
|
||||||
<div class="acea-row row-right page mt20">
|
<el-form inline size="small" :model="searchForm" class="mb20 search-form-inline">
|
||||||
<el-pagination
|
<el-form-item>
|
||||||
@size-change="handleSizeChange"
|
<el-input v-model="searchForm.streamerName" placeholder="请输入主播名称" clearable style="width: 150px" />
|
||||||
@current-change="handleCurrentChange"
|
</el-form-item>
|
||||||
:current-page="searchForm.page"
|
<el-form-item>
|
||||||
:page-sizes="[10, 20, 50, 100]"
|
<el-input v-model="searchForm.title" placeholder="请输入直播标题" clearable style="width: 150px" />
|
||||||
:page-size="searchForm.limit"
|
</el-form-item>
|
||||||
layout="total, sizes, prev, pager, next, jumper"
|
<el-form-item>
|
||||||
:total="total"
|
<el-select v-model="searchForm.categoryId" placeholder="选择分类" clearable style="width: 120px">
|
||||||
></el-pagination>
|
<el-option v-for="cat in categoryList" :key="cat.id" :label="cat.name" :value="cat.id" />
|
||||||
</div>
|
</el-select>
|
||||||
</div>
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" icon="el-icon-search" @click="handleSearch">查询</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<!-- 表格 -->
|
||||||
|
<el-table :data="tableData" v-loading="loading" border style="width: 100%" @selection-change="handleSelectionChange">
|
||||||
|
<el-table-column type="selection" width="55" align="center" />
|
||||||
|
<el-table-column prop="id" label="房间ID" width="80" align="center" sortable />
|
||||||
|
<el-table-column prop="title" label="标题" min-width="180" />
|
||||||
|
<el-table-column prop="categoryName" label="分类" width="100" align="center">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<el-tag size="small" v-if="scope.row.categoryName">{{ scope.row.categoryName }}</el-tag>
|
||||||
|
<span v-else style="color: #999;">未分类</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="streamerName" label="主播" width="120" />
|
||||||
|
<el-table-column prop="streamKey" label="streamKey" min-width="200" />
|
||||||
|
<el-table-column label="直播状态" width="100" align="center">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<el-tag :type="scope.row.isLive ? 'success' : 'info'" size="small">{{ scope.row.isLive ? '直播中' : '未开播' }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createTime" label="创建时间" width="160" align="center" />
|
||||||
|
<el-table-column label="操作" width="280" align="center" fixed="right">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<div class="action-btns">
|
||||||
|
<el-button :type="scope.row.isLive ? 'danger' : 'success'" size="mini" @click="handleToggleStatus(scope.row)">{{ scope.row.isLive ? '停播' : '开播' }}</el-button>
|
||||||
|
<el-button type="primary" size="mini" @click="handleEdit(scope.row)">编辑</el-button>
|
||||||
|
<el-button type="warning" size="mini" @click="handleDetail(scope.row)">详情</el-button>
|
||||||
|
<el-popconfirm title="确定删除吗?" @confirm="handleDelete(scope.row)">
|
||||||
|
<el-button slot="reference" type="danger" size="mini">删除</el-button>
|
||||||
|
</el-popconfirm>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<div class="acea-row row-right page mt20">
|
||||||
|
<el-pagination
|
||||||
|
@size-change="handleSizeChange"
|
||||||
|
@current-change="handleCurrentChange"
|
||||||
|
:current-page="searchForm.page"
|
||||||
|
:page-sizes="[10, 20, 50, 100]"
|
||||||
|
:page-size="searchForm.limit"
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
:total="total"
|
||||||
|
></el-pagination>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<!-- 分类管理 Tab -->
|
||||||
|
<el-tab-pane label="分类管理" name="categories">
|
||||||
|
<div class="mt20">
|
||||||
|
<div class="mb20" style="display: flex; justify-content: space-between; align-items: center;">
|
||||||
|
<el-button type="primary" icon="el-icon-plus" @click="openCategoryDialog()">新增分类</el-button>
|
||||||
|
<el-button type="text" icon="el-icon-refresh" @click="loadCategories">刷新</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="categoryList" v-loading="categoryLoading" border style="width: 100%">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||||
|
<el-table-column prop="name" label="分类名称" min-width="150" />
|
||||||
|
<el-table-column prop="icon" label="图标" width="100" align="center">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<i :class="scope.row.icon" style="font-size: 20px;" v-if="scope.row.icon && scope.row.icon.startsWith('el-icon')"></i>
|
||||||
|
<img :src="scope.row.icon" style="width: 30px; height: 30px;" v-else-if="scope.row.icon" />
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="sort" label="排序" width="80" align="center" />
|
||||||
|
<el-table-column label="状态" width="100" align="center">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<el-switch v-model="scope.row.status" :active-value="1" :inactive-value="0" @change="handleCategoryStatusChange(scope.row)" />
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="roomCount" label="房间数" width="100" align="center">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<el-tag size="small">{{ scope.row.roomCount || 0 }}</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="createTime" label="创建时间" width="160" align="center" />
|
||||||
|
<el-table-column label="操作" width="150" align="center">
|
||||||
|
<template slot-scope="scope">
|
||||||
|
<el-button type="primary" size="mini" @click="openCategoryDialog(scope.row)">编辑</el-button>
|
||||||
|
<el-popconfirm title="确定删除该分类吗?" @confirm="handleDeleteCategory(scope.row)">
|
||||||
|
<el-button slot="reference" type="danger" size="mini">删除</el-button>
|
||||||
|
</el-popconfirm>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
|
|
||||||
<!-- 详情弹窗 -->
|
<!-- 详情弹窗 -->
|
||||||
<el-dialog title="详情" :visible.sync="detailVisible" width="800px">
|
<el-dialog title="房间详情" :visible.sync="detailVisible" width="800px">
|
||||||
<el-descriptions :column="3" border>
|
<el-descriptions :column="2" border>
|
||||||
<el-descriptions-item label="房间ID">{{ detailData.id }}</el-descriptions-item>
|
<el-descriptions-item label="房间ID">{{ detailData.id }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="标题">{{ detailData.title }}</el-descriptions-item>
|
<el-descriptions-item label="标题">{{ detailData.title }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="分类">{{ detailData.categoryName || '未分类' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="主播">{{ detailData.streamerName }}</el-descriptions-item>
|
<el-descriptions-item label="主播">{{ detailData.streamerName }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="streamKey">{{ detailData.streamKey }}</el-descriptions-item>
|
<el-descriptions-item label="streamKey">{{ detailData.streamKey }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="直播状态">{{ detailData.isLive ? '直播中' : '未开播' }}</el-descriptions-item>
|
<el-descriptions-item label="直播状态">{{ detailData.isLive ? '直播中' : '未开播' }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="创建时间">{{ detailData.createTime }}</el-descriptions-item>
|
<el-descriptions-item label="创建时间">{{ detailData.createTime }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="RTMP">{{ detailData.streamUrls && detailData.streamUrls.rtmp }}</el-descriptions-item>
|
<el-descriptions-item label="RTMP推流地址" :span="2">{{ detailData.streamUrls && detailData.streamUrls.rtmp }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="FLV">{{ detailData.streamUrls && detailData.streamUrls.flv }}</el-descriptions-item>
|
<el-descriptions-item label="FLV播放地址" :span="2">{{ detailData.streamUrls && detailData.streamUrls.flv }}</el-descriptions-item>
|
||||||
<el-descriptions-item label="HLS">{{ detailData.streamUrls && detailData.streamUrls.hls }}</el-descriptions-item>
|
<el-descriptions-item label="HLS播放地址" :span="2">{{ detailData.streamUrls && detailData.streamUrls.hls }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
<!-- 弹幕记录 -->
|
<!-- 弹幕记录 -->
|
||||||
|
|
@ -117,34 +158,42 @@
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
<!-- 编辑弹窗 -->
|
<!-- 编辑房间弹窗 -->
|
||||||
<el-dialog title="编辑" :visible.sync="editVisible" width="500px">
|
<el-dialog title="编辑房间" :visible.sync="editVisible" width="500px">
|
||||||
<el-form :model="editForm" label-width="120px">
|
<el-form :model="editForm" label-width="100px">
|
||||||
<el-form-item label="房间状态">
|
<el-form-item label="直播标题">
|
||||||
<el-select v-model="editForm.status" placeholder="请选择" style="width: 100%">
|
<el-input v-model="editForm.title" placeholder="请输入直播标题" />
|
||||||
<el-option label="正常" value="正常"></el-option>
|
</el-form-item>
|
||||||
<el-option label="禁用" value="禁用"></el-option>
|
<el-form-item label="房间分类">
|
||||||
|
<el-select v-model="editForm.categoryId" placeholder="请选择分类" style="width: 100%" clearable>
|
||||||
|
<el-option v-for="cat in categoryList" :key="cat.id" :label="cat.name" :value="cat.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="推荐状态">
|
<el-form-item label="房间状态">
|
||||||
<el-select v-model="editForm.recommendStatus" placeholder="请选择" style="width: 100%">
|
<el-select v-model="editForm.status" placeholder="请选择" style="width: 100%">
|
||||||
<el-option label="未推荐" value="未推荐"></el-option>
|
<el-option label="正常" :value="1"></el-option>
|
||||||
<el-option label="已推荐" value="已推荐"></el-option>
|
<el-option label="禁用" :value="0"></el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<div slot="footer" class="dialog-footer">
|
<div slot="footer" class="dialog-footer">
|
||||||
<el-button @click="editVisible = false">返回</el-button>
|
<el-button @click="editVisible = false">取消</el-button>
|
||||||
<el-button type="primary" @click="handleSaveEdit">保存</el-button>
|
<el-button type="primary" @click="handleSaveEdit">保存</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 新增直播间弹窗 -->
|
||||||
<el-dialog title="新增直播间" :visible.sync="createVisible" width="520px">
|
<el-dialog title="新增直播间" :visible.sync="createVisible" width="520px">
|
||||||
<el-form :model="createForm" label-width="120px">
|
<el-form :model="createForm" label-width="100px">
|
||||||
<el-form-item label="直播标题">
|
<el-form-item label="直播标题" required>
|
||||||
<el-input v-model="createForm.title" placeholder="请输入直播标题" />
|
<el-input v-model="createForm.title" placeholder="请输入直播标题" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="选择主播">
|
<el-form-item label="房间分类">
|
||||||
|
<el-select v-model="createForm.categoryId" placeholder="请选择分类" style="width: 100%" clearable>
|
||||||
|
<el-option v-for="cat in categoryList" :key="cat.id" :label="cat.name" :value="cat.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="选择主播" required>
|
||||||
<el-select v-model="createForm.uid" filterable placeholder="请选择主播" style="width: 100%" @focus="loadStreamerOptions">
|
<el-select v-model="createForm.uid" filterable placeholder="请选择主播" style="width: 100%" @focus="loadStreamerOptions">
|
||||||
<el-option v-for="item in streamerOptions" :key="item.userId" :label="item.nickname + ' (ID:' + item.userId + ')'" :value="item.userId">
|
<el-option v-for="item in streamerOptions" :key="item.userId" :label="item.nickname + ' (ID:' + item.userId + ')'" :value="item.userId">
|
||||||
<div style="display: flex; align-items: center;">
|
<div style="display: flex; align-items: center;">
|
||||||
|
|
@ -161,23 +210,52 @@
|
||||||
<el-button type="primary" :loading="createLoading" @click="handleCreate">创建</el-button>
|
<el-button type="primary" :loading="createLoading" @click="handleCreate">创建</el-button>
|
||||||
</div>
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<!-- 分类编辑弹窗 -->
|
||||||
|
<el-dialog :title="categoryForm.id ? '编辑分类' : '新增分类'" :visible.sync="categoryDialogVisible" width="450px">
|
||||||
|
<el-form :model="categoryForm" label-width="80px">
|
||||||
|
<el-form-item label="分类名称" required>
|
||||||
|
<el-input v-model="categoryForm.name" placeholder="请输入分类名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="图标">
|
||||||
|
<el-input v-model="categoryForm.icon" placeholder="图标类名或图片URL">
|
||||||
|
<template slot="prepend">
|
||||||
|
<i :class="categoryForm.icon" v-if="categoryForm.icon && categoryForm.icon.startsWith('el-icon')"></i>
|
||||||
|
<span v-else>图标</span>
|
||||||
|
</template>
|
||||||
|
</el-input>
|
||||||
|
<div class="icon-tips">支持 Element UI 图标类名(如 el-icon-video-camera)或图片URL</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="排序">
|
||||||
|
<el-input-number v-model="categoryForm.sort" :min="0" :max="999" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-switch v-model="categoryForm.status" :active-value="1" :inactive-value="0" active-text="启用" inactive-text="禁用" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<div slot="footer" class="dialog-footer">
|
||||||
|
<el-button @click="categoryDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="categorySaving" @click="handleSaveCategory">保存</el-button>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { roomListApi, liveRoomCreateApi, liveRoomToggleStatusApi, liveRoomChatHistoryApi } from '@/api/room';
|
import { roomListApi, liveRoomCreateApi, liveRoomToggleStatusApi, liveRoomChatHistoryApi, liveRoomUpdateApi } from '@/api/room';
|
||||||
import { getStreamerOptions } from '@/api/streamer';
|
import { getStreamerOptions } from '@/api/streamer';
|
||||||
|
import { roomCategoryListApi, roomCategorySaveApi, roomCategoryDeleteApi, roomCategoryUpdateStatusApi } from '@/api/room';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'RoomList',
|
name: 'RoomList',
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
activeTab: 'rooms',
|
||||||
|
// 房间列表
|
||||||
searchForm: {
|
searchForm: {
|
||||||
title: '',
|
title: '',
|
||||||
streamerName: '',
|
streamerName: '',
|
||||||
streamKey: '',
|
categoryId: null,
|
||||||
startTime: '',
|
|
||||||
endTime: '',
|
|
||||||
page: 1,
|
page: 1,
|
||||||
limit: 20
|
limit: 20
|
||||||
},
|
},
|
||||||
|
|
@ -193,23 +271,41 @@ export default {
|
||||||
chatLoading: false,
|
chatLoading: false,
|
||||||
multipleSelection: [],
|
multipleSelection: [],
|
||||||
editForm: {
|
editForm: {
|
||||||
status: '正常',
|
id: null,
|
||||||
recommendStatus: '未推荐',
|
title: '',
|
||||||
|
categoryId: null,
|
||||||
|
status: 1,
|
||||||
},
|
},
|
||||||
createForm: {
|
createForm: {
|
||||||
title: '',
|
title: '',
|
||||||
|
categoryId: null,
|
||||||
uid: null,
|
uid: null,
|
||||||
},
|
},
|
||||||
streamerOptions: [],
|
streamerOptions: [],
|
||||||
|
// 分类管理
|
||||||
|
categoryList: [],
|
||||||
|
categoryLoading: false,
|
||||||
|
categoryDialogVisible: false,
|
||||||
|
categorySaving: false,
|
||||||
|
categoryForm: {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
icon: '',
|
||||||
|
sort: 0,
|
||||||
|
status: 1,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.getList();
|
this.getList();
|
||||||
|
this.loadCategories();
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
// ========== 房间列表相关 ==========
|
||||||
openCreate() {
|
openCreate() {
|
||||||
this.createForm = {
|
this.createForm = {
|
||||||
title: '',
|
title: '',
|
||||||
|
categoryId: null,
|
||||||
uid: null,
|
uid: null,
|
||||||
};
|
};
|
||||||
this.createVisible = true;
|
this.createVisible = true;
|
||||||
|
|
@ -228,7 +324,6 @@ export default {
|
||||||
this.$message.error('请填写直播标题并选择主播');
|
this.$message.error('请填写直播标题并选择主播');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 获取选中主播的名称
|
|
||||||
const selectedStreamer = this.streamerOptions.find(s => s.userId === this.createForm.uid);
|
const selectedStreamer = this.streamerOptions.find(s => s.userId === this.createForm.uid);
|
||||||
const streamerName = selectedStreamer ? selectedStreamer.nickname : '';
|
const streamerName = selectedStreamer ? selectedStreamer.nickname : '';
|
||||||
|
|
||||||
|
|
@ -236,10 +331,12 @@ export default {
|
||||||
try {
|
try {
|
||||||
const res = await liveRoomCreateApi({
|
const res = await liveRoomCreateApi({
|
||||||
title: this.createForm.title,
|
title: this.createForm.title,
|
||||||
|
categoryId: this.createForm.categoryId,
|
||||||
uid: this.createForm.uid,
|
uid: this.createForm.uid,
|
||||||
streamerName: streamerName,
|
streamerName: streamerName,
|
||||||
});
|
});
|
||||||
this.createVisible = false;
|
this.createVisible = false;
|
||||||
|
this.$message.success('创建成功');
|
||||||
await this.getList();
|
await this.getList();
|
||||||
this.handleDetail(res);
|
this.handleDetail(res);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -256,7 +353,7 @@ export default {
|
||||||
limit: this.searchForm.limit,
|
limit: this.searchForm.limit,
|
||||||
title: this.searchForm.title || undefined,
|
title: this.searchForm.title || undefined,
|
||||||
streamerName: this.searchForm.streamerName || undefined,
|
streamerName: this.searchForm.streamerName || undefined,
|
||||||
streamKey: this.searchForm.streamKey || undefined,
|
categoryId: this.searchForm.categoryId || undefined,
|
||||||
};
|
};
|
||||||
const res = await roomListApi(params);
|
const res = await roomListApi(params);
|
||||||
this.tableData = (res && res.list) || [];
|
this.tableData = (res && res.list) || [];
|
||||||
|
|
@ -309,21 +406,26 @@ export default {
|
||||||
handleEdit(row) {
|
handleEdit(row) {
|
||||||
this.editForm = {
|
this.editForm = {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
status: row.status === 1 ? '正常' : '禁用',
|
title: row.title,
|
||||||
recommendStatus: row.is_recommended === 1 ? '已推荐' : '未推荐',
|
categoryId: row.categoryId,
|
||||||
|
status: row.status || 1,
|
||||||
};
|
};
|
||||||
this.editVisible = true;
|
this.editVisible = true;
|
||||||
},
|
},
|
||||||
async handleSaveEdit() {
|
async handleSaveEdit() {
|
||||||
|
if (!this.editForm.title) {
|
||||||
|
this.$message.error('请输入直播标题');
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
this.$message.info('当前列表为直播房间数据源,暂不支持在此页面编辑');
|
await liveRoomUpdateApi(this.editForm);
|
||||||
|
this.$message.success('保存成功');
|
||||||
|
this.editVisible = false;
|
||||||
|
await this.getList();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.$message.error(error.message || '保存失败');
|
this.$message.error(error.message || '保存失败');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
handleMore(command) {
|
|
||||||
this.$message.info(`执行${command.type}操作`);
|
|
||||||
},
|
|
||||||
handleSizeChange(val) {
|
handleSizeChange(val) {
|
||||||
this.searchForm.limit = val;
|
this.searchForm.limit = val;
|
||||||
this.getList();
|
this.getList();
|
||||||
|
|
@ -340,7 +442,6 @@ export default {
|
||||||
},
|
},
|
||||||
async handleDelete(row) {
|
async handleDelete(row) {
|
||||||
try {
|
try {
|
||||||
// 调用删除API
|
|
||||||
this.$message.success('删除成功');
|
this.$message.success('删除成功');
|
||||||
await this.getList();
|
await this.getList();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -358,7 +459,6 @@ export default {
|
||||||
cancelButtonText: '取消',
|
cancelButtonText: '取消',
|
||||||
type: 'warning'
|
type: 'warning'
|
||||||
});
|
});
|
||||||
// 调用批量删除API
|
|
||||||
this.$message.success('批量删除成功');
|
this.$message.success('批量删除成功');
|
||||||
await this.getList();
|
await this.getList();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -367,30 +467,93 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ========== 分类管理相关 ==========
|
||||||
|
async loadCategories() {
|
||||||
|
this.categoryLoading = true;
|
||||||
|
try {
|
||||||
|
const res = await roomCategoryListApi();
|
||||||
|
this.categoryList = res || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载分类失败', error);
|
||||||
|
this.categoryList = [];
|
||||||
|
} finally {
|
||||||
|
this.categoryLoading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
openCategoryDialog(row) {
|
||||||
|
if (row) {
|
||||||
|
this.categoryForm = { ...row };
|
||||||
|
} else {
|
||||||
|
this.categoryForm = {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
icon: '',
|
||||||
|
sort: 0,
|
||||||
|
status: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.categoryDialogVisible = true;
|
||||||
|
},
|
||||||
|
async handleSaveCategory() {
|
||||||
|
if (!this.categoryForm.name) {
|
||||||
|
this.$message.error('请输入分类名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.categorySaving = true;
|
||||||
|
try {
|
||||||
|
await roomCategorySaveApi(this.categoryForm);
|
||||||
|
this.$message.success('保存成功');
|
||||||
|
this.categoryDialogVisible = false;
|
||||||
|
await this.loadCategories();
|
||||||
|
} catch (error) {
|
||||||
|
this.$message.error(error.message || '保存失败');
|
||||||
|
} finally {
|
||||||
|
this.categorySaving = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async handleDeleteCategory(row) {
|
||||||
|
try {
|
||||||
|
await roomCategoryDeleteApi(row.id);
|
||||||
|
this.$message.success('删除成功');
|
||||||
|
await this.loadCategories();
|
||||||
|
} catch (error) {
|
||||||
|
this.$message.error(error.message || '删除失败');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async handleCategoryStatusChange(row) {
|
||||||
|
try {
|
||||||
|
await roomCategoryUpdateStatusApi(row.id, row.status);
|
||||||
|
this.$message.success('状态更新成功');
|
||||||
|
} catch (error) {
|
||||||
|
this.$message.error(error.message || '状态更新失败');
|
||||||
|
row.status = row.status === 1 ? 0 : 1;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.selWidth {
|
.mt20 {
|
||||||
width: 200px;
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb20 {
|
||||||
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-form-inline {
|
.search-form-inline {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
.el-form-item {
|
.el-form-item {
|
||||||
margin-right: 10px;
|
margin-right: 10px;
|
||||||
margin-bottom: 0;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.mt20 {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-header {
|
.chat-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
@ -408,10 +571,17 @@ export default {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 5px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
.el-button {
|
.el-button {
|
||||||
margin: 0 !important;
|
margin: 2px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-tips {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import com.github.pagehelper.PageHelper;
|
||||||
import com.github.pagehelper.PageInfo;
|
import com.github.pagehelper.PageInfo;
|
||||||
import com.zbkj.common.model.live.LiveChat;
|
import com.zbkj.common.model.live.LiveChat;
|
||||||
import com.zbkj.common.model.live.LiveRoom;
|
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.model.room.Room;
|
||||||
import com.zbkj.common.page.CommonPage;
|
import com.zbkj.common.page.CommonPage;
|
||||||
import com.zbkj.common.request.PageParamRequest;
|
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.RoomService;
|
||||||
import com.zbkj.service.service.LiveRoomService;
|
import com.zbkj.service.service.LiveRoomService;
|
||||||
import com.zbkj.service.service.LiveChatService;
|
import com.zbkj.service.service.LiveChatService;
|
||||||
|
import com.zbkj.service.service.LiveRoomCategoryService;
|
||||||
import io.swagger.annotations.Api;
|
import io.swagger.annotations.Api;
|
||||||
import io.swagger.annotations.ApiOperation;
|
import io.swagger.annotations.ApiOperation;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
@ -48,6 +50,9 @@ public class RoomController {
|
||||||
@Autowired
|
@Autowired
|
||||||
private LiveChatService liveChatService;
|
private LiveChatService liveChatService;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private LiveRoomCategoryService liveRoomCategoryService;
|
||||||
|
|
||||||
@Value("${LIVE_PUBLIC_SRS_HOST:}")
|
@Value("${LIVE_PUBLIC_SRS_HOST:}")
|
||||||
private String publicHost;
|
private String publicHost;
|
||||||
|
|
||||||
|
|
@ -111,7 +116,17 @@ public class RoomController {
|
||||||
public CommonResult<LiveRoomAdminResponse> createLiveRoom(@RequestBody @Validated LiveRoomCreateRequest body,
|
public CommonResult<LiveRoomAdminResponse> createLiveRoom(@RequestBody @Validated LiveRoomCreateRequest body,
|
||||||
HttpServletRequest request) {
|
HttpServletRequest request) {
|
||||||
Integer uid = body.getUid() == null ? 0 : body.getUid();
|
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);
|
String host = resolveHost(request);
|
||||||
int rtmpPort = parsePort(publicRtmpPort, 25002);
|
int rtmpPort = parsePort(publicRtmpPort, 25002);
|
||||||
int httpPort = parsePort(publicHttpPort, 25003);
|
int httpPort = parsePort(publicHttpPort, 25003);
|
||||||
|
|
@ -126,6 +141,7 @@ public class RoomController {
|
||||||
if (room == null) return CommonResult.failed("房间不存在");
|
if (room == null) return CommonResult.failed("房间不存在");
|
||||||
room.setTitle(body.getTitle());
|
room.setTitle(body.getTitle());
|
||||||
room.setStreamerName(body.getStreamerName());
|
room.setStreamerName(body.getStreamerName());
|
||||||
|
room.setCategoryId(body.getCategoryId());
|
||||||
if (!liveRoomService.updateById(room)) {
|
if (!liveRoomService.updateById(room)) {
|
||||||
return CommonResult.failed("保存失败");
|
return CommonResult.failed("保存失败");
|
||||||
}
|
}
|
||||||
|
|
@ -199,7 +215,16 @@ public class RoomController {
|
||||||
resp.setStreamerName(room.getStreamerName());
|
resp.setStreamerName(room.getStreamerName());
|
||||||
resp.setStreamKey(room.getStreamKey());
|
resp.setStreamKey(room.getStreamKey());
|
||||||
resp.setIsLive(room.getIsLive() != null && room.getIsLive() == 1);
|
resp.setIsLive(room.getIsLive() != null && room.getIsLive() == 1);
|
||||||
|
resp.setCategoryId(room.getCategoryId());
|
||||||
resp.setCreateTime(room.getCreateTime());
|
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();
|
LiveRoomStreamUrlsResponse urls = new LiveRoomStreamUrlsResponse();
|
||||||
urls.setRtmp(String.format("rtmp://%s:%d/live/%s", host, rtmpPort, room.getStreamKey()));
|
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 streamerName;
|
||||||
private String streamKey;
|
private String streamKey;
|
||||||
private Boolean isLive;
|
private Boolean isLive;
|
||||||
|
private Integer categoryId;
|
||||||
|
private String categoryName;
|
||||||
private Date createTime;
|
private Date createTime;
|
||||||
private LiveRoomStreamUrlsResponse streamUrls;
|
private LiveRoomStreamUrlsResponse streamUrls;
|
||||||
}
|
}
|
||||||
|
|
@ -261,6 +288,8 @@ public class RoomController {
|
||||||
|
|
||||||
@javax.validation.constraints.NotBlank
|
@javax.validation.constraints.NotBlank
|
||||||
private String streamerName;
|
private String streamerName;
|
||||||
|
|
||||||
|
private Integer categoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
|
|
@ -273,6 +302,8 @@ public class RoomController {
|
||||||
|
|
||||||
@javax.validation.constraints.NotBlank
|
@javax.validation.constraints.NotBlank
|
||||||
private String streamerName;
|
private String streamerName;
|
||||||
|
|
||||||
|
private Integer categoryId;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
|
|
@ -281,4 +312,129 @@ public class RoomController {
|
||||||
private String flv;
|
private String flv;
|
||||||
private String hls;
|
private String hls;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 房间分类管理 ====================
|
||||||
|
|
||||||
|
@ApiOperation(value = "分类列表")
|
||||||
|
@RequestMapping(value = "/category/list", method = RequestMethod.GET)
|
||||||
|
public CommonResult<List<LiveRoomCategoryResponse>> getCategoryList() {
|
||||||
|
if (liveRoomCategoryService == null) {
|
||||||
|
return CommonResult.success(new java.util.ArrayList<LiveRoomCategoryResponse>());
|
||||||
|
}
|
||||||
|
List<LiveRoomCategory> list = liveRoomCategoryService.list(
|
||||||
|
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<LiveRoomCategory>()
|
||||||
|
.orderByAsc(LiveRoomCategory::getSort)
|
||||||
|
.orderByDesc(LiveRoomCategory::getId)
|
||||||
|
);
|
||||||
|
List<LiveRoomCategoryResponse> 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<LiveRoom>()
|
||||||
|
.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<String> 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<String> 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<LiveRoom>()
|
||||||
|
.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<String> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -183,10 +183,29 @@ public class FrontTokenComponent {
|
||||||
"api/front/bargain/header",
|
"api/front/bargain/header",
|
||||||
"api/front/bargain/detail",
|
"api/front/bargain/detail",
|
||||||
"api/front/seckill/header",
|
"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){
|
public Boolean check(String token, HttpServletRequest request){
|
||||||
|
|
|
||||||
|
|
@ -51,10 +51,10 @@ public class LiveRoomController {
|
||||||
@Value("${LIVE_PUBLIC_SRS_HOST:}")
|
@Value("${LIVE_PUBLIC_SRS_HOST:}")
|
||||||
private String publicHost;
|
private String publicHost;
|
||||||
|
|
||||||
@Value("${LIVE_PUBLIC_SRS_RTMP_PORT:}")
|
@Value("${LIVE_PUBLIC_SRS_RTMP_PORT:1935}")
|
||||||
private String publicRtmpPort;
|
private String publicRtmpPort;
|
||||||
|
|
||||||
@Value("${LIVE_PUBLIC_SRS_HTTP_PORT:}")
|
@Value("${LIVE_PUBLIC_SRS_HTTP_PORT:8080}")
|
||||||
private String publicHttpPort;
|
private String publicHttpPort;
|
||||||
|
|
||||||
@ApiOperation(value = "公开:直播间列表(只返回直播中的房间)")
|
@ApiOperation(value = "公开:直播间列表(只返回直播中的房间)")
|
||||||
|
|
@ -464,8 +464,8 @@ public class LiveRoomController {
|
||||||
}
|
}
|
||||||
|
|
||||||
String host = (publicHost != null && !publicHost.trim().isEmpty()) ? publicHost.trim() : requestHost;
|
String host = (publicHost != null && !publicHost.trim().isEmpty()) ? publicHost.trim() : requestHost;
|
||||||
int rtmpPort = parsePort(publicRtmpPort, 25002);
|
int rtmpPort = parsePort(publicRtmpPort, 1935);
|
||||||
int httpPort = parsePort(publicHttpPort, 25003);
|
int httpPort = parsePort(publicHttpPort, 8080);
|
||||||
|
|
||||||
StreamUrlsResponse urls = new StreamUrlsResponse();
|
StreamUrlsResponse urls = new StreamUrlsResponse();
|
||||||
urls.setRtmp(String.format("rtmp://%s:%d/live/%s", host, rtmpPort, room.getStreamKey()));
|
urls.setRtmp(String.format("rtmp://%s:%d/live/%s", host, rtmpPort, room.getStreamKey()));
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ public class StreamerController {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查当前用户是否是认证主播
|
* 检查当前用户是否是认证主播
|
||||||
|
* 直接从 eb_user 表读取 is_streamer 字段
|
||||||
*/
|
*/
|
||||||
@ApiOperation(value = "检查主播资格")
|
@ApiOperation(value = "检查主播资格")
|
||||||
@GetMapping("/check")
|
@GetMapping("/check")
|
||||||
|
|
@ -42,55 +43,97 @@ public class StreamerController {
|
||||||
return CommonResult.failed("请先登录");
|
return CommonResult.failed("请先登录");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info("=== 检查主播资格开始 === userId={}", userId);
|
||||||
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
result.put("userId", userId);
|
||||||
|
|
||||||
try {
|
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<Map<String, Object>> results = jdbcTemplate.queryForList(sql, userId);
|
List<Map<String, Object>> results = jdbcTemplate.queryForList(sql, userId);
|
||||||
|
|
||||||
Map<String, Object> result = new HashMap<>();
|
log.info("查询结果: userId={}, 记录数={}", userId, results.size());
|
||||||
|
|
||||||
if (results.isEmpty()) {
|
if (results.isEmpty()) {
|
||||||
|
log.warn("用户不存在: userId={}", userId);
|
||||||
result.put("isStreamer", false);
|
result.put("isStreamer", false);
|
||||||
result.put("streamerLevel", 0);
|
result.put("streamerLevel", 0);
|
||||||
} else {
|
|
||||||
Map<String, Object> 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<Map<String, Object>> applications = jdbcTemplate.queryForList(pendingSql, userId);
|
|
||||||
if (!applications.isEmpty()) {
|
|
||||||
Map<String, Object> 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("hasApplication", false);
|
||||||
|
result.put("isBanned", false);
|
||||||
|
return CommonResult.success(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否被封禁
|
Map<String, Object> user = results.get(0);
|
||||||
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";
|
log.info("用户数据: {}", user);
|
||||||
List<Map<String, Object>> bans = jdbcTemplate.queryForList(banSql, userId);
|
|
||||||
if (!bans.isEmpty()) {
|
// 读取 is_streamer 字段
|
||||||
result.put("isBanned", true);
|
Object isStreamerObj = user.get("is_streamer");
|
||||||
result.put("banReason", bans.get(0).get("ban_reason"));
|
boolean isStreamer = false;
|
||||||
result.put("banEndTime", bans.get(0).get("ban_end_time"));
|
int streamerLevel = 0;
|
||||||
} else {
|
|
||||||
result.put("isBanned", false);
|
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<Map<String, Object>> applications = jdbcTemplate.queryForList(pendingSql, userId);
|
||||||
|
if (!applications.isEmpty()) {
|
||||||
|
Map<String, Object> 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<Map<String, Object>> 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);
|
return CommonResult.success(result);
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("检查主播资格失败", e);
|
log.error("检查主播资格异常: userId={}", userId, e);
|
||||||
// 如果字段不存在,返回默认值
|
// 出错时返回详细错误信息
|
||||||
Map<String, Object> result = new HashMap<>();
|
|
||||||
result.put("isStreamer", false);
|
result.put("isStreamer", false);
|
||||||
result.put("streamerLevel", 0);
|
result.put("streamerLevel", 0);
|
||||||
result.put("hasApplication", false);
|
result.put("hasApplication", false);
|
||||||
result.put("isBanned", false);
|
result.put("isBanned", false);
|
||||||
|
result.put("error", e.getMessage());
|
||||||
return CommonResult.success(result);
|
return CommonResult.success(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -178,4 +221,137 @@ public class StreamerController {
|
||||||
return CommonResult.failed("获取申请记录失败");
|
return CommonResult.failed("获取申请记录失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取主播统计数据
|
||||||
|
*/
|
||||||
|
@ApiOperation(value = "获取主播统计数据")
|
||||||
|
@GetMapping("/stats")
|
||||||
|
public CommonResult<Map<String, Object>> getStreamerStats() {
|
||||||
|
Integer userId = frontTokenComponent.getUserId();
|
||||||
|
if (userId == null) {
|
||||||
|
return CommonResult.failed("请先登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Map<String, Object> stats = new HashMap<>();
|
||||||
|
|
||||||
|
// 获取用户基本信息
|
||||||
|
String userSql = "SELECT nickname, avatar, streamer_level FROM eb_user WHERE uid = ?";
|
||||||
|
List<Map<String, Object>> users = jdbcTemplate.queryForList(userSql, userId);
|
||||||
|
if (!users.isEmpty()) {
|
||||||
|
Map<String, Object> 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<List<Map<String, Object>>> 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<Map<String, Object>> 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<List<Map<String, Object>>> 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<Map<String, Object>> rooms = jdbcTemplate.queryForList(sql, userId);
|
||||||
|
return CommonResult.success(rooms);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("获取我的直播间列表失败", e);
|
||||||
|
return CommonResult.failed("获取直播间列表失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<LiveRoomCategory> {
|
||||||
|
}
|
||||||
|
|
@ -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<LiveRoomCategory> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取启用的分类列表
|
||||||
|
*/
|
||||||
|
List<LiveRoomCategory> getEnabledList();
|
||||||
|
}
|
||||||
|
|
@ -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<LiveRoomCategoryDao, LiveRoomCategory> implements LiveRoomCategoryService {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<LiveRoomCategory> getEnabledList() {
|
||||||
|
LambdaQueryWrapper<LiveRoomCategory> wrapper = new LambdaQueryWrapper<>();
|
||||||
|
wrapper.eq(LiveRoomCategory::getStatus, 1);
|
||||||
|
wrapper.orderByAsc(LiveRoomCategory::getSort);
|
||||||
|
wrapper.orderByDesc(LiveRoomCategory::getId);
|
||||||
|
return list(wrapper);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -126,4 +126,7 @@ dependencies {
|
||||||
// WebRTC for voice/video calls
|
// WebRTC for voice/video calls
|
||||||
// 使用 Google 官方 WebRTC 库
|
// 使用 Google 官方 WebRTC 库
|
||||||
implementation("io.getstream:stream-webrtc-android:1.1.1")
|
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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,19 @@
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:screenOrientation="portrait" />
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<!-- 主播中心Activity -->
|
||||||
|
<activity
|
||||||
|
android:name="com.example.livestreaming.StreamerCenterActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait" />
|
||||||
|
|
||||||
|
<!-- 手机开播Activity -->
|
||||||
|
<activity
|
||||||
|
android:name="com.example.livestreaming.BroadcastActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:configChanges="orientation|screenSize" />
|
||||||
|
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|
|
||||||
|
|
@ -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<ApiResponse<Map<String, Object>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<ApiResponse<Map<String, Object>>> call,
|
||||||
|
Response<ApiResponse<Map<String, Object>>> response) {
|
||||||
|
binding.progressBar.setVisibility(View.GONE);
|
||||||
|
|
||||||
|
if (!response.isSuccessful() || response.body() == null) {
|
||||||
|
// 接口调用失败,可能是旧版本后端,允许继续
|
||||||
|
Log.w(TAG, "检查主播资格接口失败,允许继续");
|
||||||
|
streamerVerified = true;
|
||||||
|
binding.btnStartLive.setEnabled(true);
|
||||||
|
checkPermissions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse<Map<String, Object>> body = response.body();
|
||||||
|
if (body.getCode() != 200 || body.getData() == null) {
|
||||||
|
// 接口返回错误,可能是旧版本后端,允许继续
|
||||||
|
Log.w(TAG, "检查主播资格返回错误,允许继续");
|
||||||
|
streamerVerified = true;
|
||||||
|
binding.btnStartLive.setEnabled(true);
|
||||||
|
checkPermissions();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> 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<ApiResponse<Map<String, Object>>> 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<ApiResponse<Room>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<ApiResponse<Room>> call, Response<ApiResponse<Room>> 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<ApiResponse<Room>> 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<ApiResponse<Object>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<ApiResponse<Object>> call, Response<ApiResponse<Object>> response) {
|
||||||
|
Log.d(TAG, "直播间已删除");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<ApiResponse<Object>> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -162,6 +162,9 @@ public class LoginActivity extends AppCompatActivity {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查主播状态
|
||||||
|
checkStreamerStatus();
|
||||||
|
|
||||||
// 登录成功
|
// 登录成功
|
||||||
Toast.makeText(LoginActivity.this, "登录成功", Toast.LENGTH_SHORT).show();
|
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<ApiResponse<java.util.Map<String, Object>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<ApiResponse<java.util.Map<String, Object>>> call,
|
||||||
|
Response<ApiResponse<java.util.Map<String, Object>>> response) {
|
||||||
|
if (response.isSuccessful() && response.body() != null && response.body().isOk()) {
|
||||||
|
java.util.Map<String, Object> 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<ApiResponse<java.util.Map<String, Object>>> call, Throwable t) {
|
||||||
|
android.util.Log.e("LoginActivity", "检查主播状态失败", t);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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_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_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_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_HISTORY, "观看历史", "最近看过的直播", R.drawable.ic_grid_24));
|
||||||
items.add(new DrawerCardItem(DrawerCardItem.ACTION_SEARCH, "搜索", "找主播/房间/标签", R.drawable.ic_search_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));
|
items.add(new DrawerCardItem(DrawerCardItem.ACTION_SETTINGS, "设置", "账号、隐私、通知", R.drawable.ic_menu_24));
|
||||||
|
|
@ -854,6 +855,66 @@ public class MainActivity extends AppCompatActivity {
|
||||||
// 确保通话信令 WebSocket 保持连接(用于接收来电通知)
|
// 确保通话信令 WebSocket 保持连接(用于接收来电通知)
|
||||||
LiveStreamingApplication app = (LiveStreamingApplication) getApplication();
|
LiveStreamingApplication app = (LiveStreamingApplication) getApplication();
|
||||||
app.connectCallSignalingIfLoggedIn();
|
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<ApiResponse<Map<String, Object>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<ApiResponse<Map<String, Object>>> call,
|
||||||
|
Response<ApiResponse<Map<String, Object>>> response) {
|
||||||
|
if (!response.isSuccessful() || response.body() == null) {
|
||||||
|
// 接口调用失败,隐藏按钮
|
||||||
|
if (binding.fabAddLive != null) {
|
||||||
|
binding.fabAddLive.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse<Map<String, Object>> body = response.body();
|
||||||
|
if (body.getCode() != 200 || body.getData() == null) {
|
||||||
|
// 接口返回错误,隐藏按钮
|
||||||
|
if (binding.fabAddLive != null) {
|
||||||
|
binding.fabAddLive.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> 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<ApiResponse<Map<String, Object>>> call, Throwable t) {
|
||||||
|
// 网络错误,隐藏按钮
|
||||||
|
if (binding.fabAddLive != null) {
|
||||||
|
binding.fabAddLive.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1033,6 +1094,24 @@ public class MainActivity extends AppCompatActivity {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showCreateRoomDialogInternal() {
|
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);
|
View dialogView = getLayoutInflater().inflate(R.layout.dialog_create_room, null);
|
||||||
DialogCreateRoomBinding dialogBinding = DialogCreateRoomBinding.bind(dialogView);
|
DialogCreateRoomBinding dialogBinding = DialogCreateRoomBinding.bind(dialogView);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
package com.example.livestreaming;
|
package com.example.livestreaming;
|
||||||
|
|
||||||
|
import android.graphics.SurfaceTexture;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.view.Surface;
|
import android.view.Surface;
|
||||||
import android.view.TextureView;
|
import android.view.TextureView;
|
||||||
|
|
@ -78,14 +79,14 @@ public class PlayerActivity extends AppCompatActivity {
|
||||||
releaseExoPlayer();
|
releaseExoPlayer();
|
||||||
triedAltUrl = false;
|
triedAltUrl = false;
|
||||||
|
|
||||||
// 优化缓冲配置,平衡延迟和流畅度
|
// 优化缓冲配置 - 针对低延迟 HLS(1秒分片)
|
||||||
androidx.media3.exoplayer.DefaultLoadControl loadControl =
|
androidx.media3.exoplayer.DefaultLoadControl loadControl =
|
||||||
new androidx.media3.exoplayer.DefaultLoadControl.Builder()
|
new androidx.media3.exoplayer.DefaultLoadControl.Builder()
|
||||||
.setBufferDurationsMs(
|
.setBufferDurationsMs(
|
||||||
3000, // 最小缓冲 3秒
|
2500, // 最小缓冲 2.5秒
|
||||||
15000, // 最大缓冲 15秒
|
10000, // 最大缓冲 10秒
|
||||||
1500, // 播放前缓冲 1.5秒
|
1500, // 播放前缓冲 1.5秒
|
||||||
3000 // 重新缓冲 3秒
|
2500 // 重新缓冲 2.5秒
|
||||||
)
|
)
|
||||||
.setPrioritizeTimeOverSizeThresholds(true)
|
.setPrioritizeTimeOverSizeThresholds(true)
|
||||||
.build();
|
.build();
|
||||||
|
|
@ -142,33 +143,137 @@ public class PlayerActivity extends AppCompatActivity {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) {
|
private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) {
|
||||||
// 禁用 IjkPlayer,直接使用 HLS 播放(IjkPlayer 在某些设备上会崩溃)
|
android.util.Log.d("PlayerActivity", "使用IJK播放FLV流: " + flvUrl);
|
||||||
android.util.Log.d("PlayerActivity", "FLV流转为HLS播放: " + flvUrl);
|
|
||||||
|
|
||||||
// 将 FLV 地址转换为 HLS 地址
|
// 释放 ExoPlayer
|
||||||
String hlsUrl = fallbackHlsUrl;
|
releaseExoPlayer();
|
||||||
if (hlsUrl == null || hlsUrl.trim().isEmpty()) {
|
releaseIjkPlayer();
|
||||||
hlsUrl = flvUrl.replace(".flv", ".m3u8");
|
|
||||||
|
// 确保 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) {
|
private void prepareIjk(String url) {
|
||||||
if (ijkSurface == null) return;
|
if (ijkSurface == null) return;
|
||||||
|
|
||||||
IjkMediaPlayer p = new IjkMediaPlayer();
|
IjkMediaPlayer p = new IjkMediaPlayer();
|
||||||
|
|
||||||
|
// ========== 超低延迟核心配置 ==========
|
||||||
|
// 禁用数据包缓冲,直接播放
|
||||||
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "packet-buffering", 0);
|
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "packet-buffering", 0);
|
||||||
|
// 准备好立即播放
|
||||||
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "start-on-prepared", 1);
|
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "start-on-prepared", 1);
|
||||||
p.setOption(IjkMediaPlayer.OPT_CATEGORY_FORMAT, "fflags", "nobuffer");
|
// 无限缓冲模式(配合max_cached_duration使用)
|
||||||
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);
|
|
||||||
p.setOption(IjkMediaPlayer.OPT_CATEGORY_PLAYER, "infbuf", 1);
|
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) -> {
|
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;
|
if (ijkFallbackTried || ijkFallbackHlsUrl == null || ijkFallbackHlsUrl.trim().isEmpty()) return true;
|
||||||
ijkFallbackTried = true;
|
ijkFallbackTried = true;
|
||||||
startHls(ijkFallbackHlsUrl, null);
|
startHls(ijkFallbackHlsUrl, null);
|
||||||
|
|
@ -179,8 +284,10 @@ public class PlayerActivity extends AppCompatActivity {
|
||||||
try {
|
try {
|
||||||
p.setSurface(ijkSurface);
|
p.setSurface(ijkSurface);
|
||||||
p.setDataSource(url);
|
p.setDataSource(url);
|
||||||
|
android.util.Log.d("PlayerActivity", "IJK开始准备播放: " + url);
|
||||||
p.prepareAsync();
|
p.prepareAsync();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
android.util.Log.e("PlayerActivity", "IJK播放异常: " + e.getMessage());
|
||||||
if (ijkFallbackHlsUrl != null && !ijkFallbackHlsUrl.trim().isEmpty()) {
|
if (ijkFallbackHlsUrl != null && !ijkFallbackHlsUrl.trim().isEmpty()) {
|
||||||
startHls(ijkFallbackHlsUrl, null);
|
startHls(ijkFallbackHlsUrl, null);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -429,6 +429,12 @@ public class ProfileActivity extends AppCompatActivity {
|
||||||
}
|
}
|
||||||
showShareProfileDialog();
|
showShareProfileDialog();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 主播中心按钮点击事件
|
||||||
|
binding.streamerCenterBtn.setOnClickListener(v -> {
|
||||||
|
StreamerCenterActivity.start(this);
|
||||||
|
});
|
||||||
|
|
||||||
binding.addFriendBtn.setOnClickListener(v -> {
|
binding.addFriendBtn.setOnClickListener(v -> {
|
||||||
// 检查登录状态,添加好友需要登录
|
// 检查登录状态,添加好友需要登录
|
||||||
if (!AuthHelper.requireLogin(this, "添加好友需要登录")) {
|
if (!AuthHelper.requireLogin(this, "添加好友需要登录")) {
|
||||||
|
|
@ -480,14 +486,14 @@ public class ProfileActivity extends AppCompatActivity {
|
||||||
PublishWorkActivity.start(this);
|
PublishWorkActivity.start(this);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 悬浮按钮(固定显示)
|
// 悬浮按钮(固定显示)- 已隐藏
|
||||||
binding.fabPublishWork.setOnClickListener(v -> {
|
// binding.fabPublishWork.setOnClickListener(v -> {
|
||||||
// 检查登录状态,发布作品需要登录
|
// // 检查登录状态,发布作品需要登录
|
||||||
if (!AuthHelper.requireLogin(this, "发布作品需要登录")) {
|
// if (!AuthHelper.requireLogin(this, "发布作品需要登录")) {
|
||||||
return;
|
// return;
|
||||||
}
|
// }
|
||||||
PublishWorkActivity.start(this);
|
// PublishWorkActivity.start(this);
|
||||||
});
|
// });
|
||||||
binding.likedGoBrowseBtn.setOnClickListener(v -> startActivity(new Intent(this, MainActivity.class)));
|
binding.likedGoBrowseBtn.setOnClickListener(v -> startActivity(new Intent(this, MainActivity.class)));
|
||||||
binding.favGoBrowseBtn.setOnClickListener(v -> startActivity(new Intent(this, MainActivity.class)));
|
binding.favGoBrowseBtn.setOnClickListener(v -> startActivity(new Intent(this, MainActivity.class)));
|
||||||
binding.profileEditFromTab.setOnClickListener(v -> {
|
binding.profileEditFromTab.setOnClickListener(v -> {
|
||||||
|
|
@ -617,9 +623,68 @@ public class ProfileActivity extends AppCompatActivity {
|
||||||
bottomNav.setSelectedItemId(R.id.nav_profile);
|
bottomNav.setSelectedItemId(R.id.nav_profile);
|
||||||
// 更新未读消息徽章
|
// 更新未读消息徽章
|
||||||
UnreadMessageManager.updateBadge(bottomNav);
|
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<ApiResponse<java.util.Map<String, Object>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<ApiResponse<java.util.Map<String, Object>>> call,
|
||||||
|
Response<ApiResponse<java.util.Map<String, Object>>> response) {
|
||||||
|
if (!response.isSuccessful() || response.body() == null) {
|
||||||
|
// 接口调用失败,隐藏按钮
|
||||||
|
if (binding.streamerCenterBtn != null) {
|
||||||
|
binding.streamerCenterBtn.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ApiResponse<java.util.Map<String, Object>> body = response.body();
|
||||||
|
if (body.getCode() != 200 || body.getData() == null) {
|
||||||
|
// 接口返回错误,隐藏按钮
|
||||||
|
if (binding.streamerCenterBtn != null) {
|
||||||
|
binding.streamerCenterBtn.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
java.util.Map<String, Object> 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<ApiResponse<java.util.Map<String, Object>>> call, Throwable t) {
|
||||||
|
// 网络错误,隐藏按钮
|
||||||
|
if (binding.streamerCenterBtn != null) {
|
||||||
|
binding.streamerCenterBtn.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void loadAndDisplayTags() {
|
private void loadAndDisplayTags() {
|
||||||
String location = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_LOCATION, "");
|
String location = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_LOCATION, "");
|
||||||
String gender = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_GENDER, "");
|
String gender = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_GENDER, "");
|
||||||
|
|
|
||||||
|
|
@ -944,19 +944,38 @@ public class RoomDetailActivity extends AppCompatActivity {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取播放地址
|
// 获取播放地址
|
||||||
|
// 优先使用 HTTP-FLV(IjkPlayer),延迟更低(2-3秒)
|
||||||
|
// HLS 作为备用(延迟 6-10秒)
|
||||||
String playUrl = null;
|
String playUrl = null;
|
||||||
String fallbackHlsUrl = null;
|
String fallbackUrl = null;
|
||||||
if (r.getStreamUrls() != 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();
|
playUrl = r.getStreamUrls().getFlv();
|
||||||
fallbackHlsUrl = r.getStreamUrls().getHls();
|
fallbackUrl = r.getStreamUrls().getHls();
|
||||||
if (TextUtils.isEmpty(playUrl)) playUrl = fallbackHlsUrl;
|
// 如果 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)) {
|
if (!TextUtils.isEmpty(playUrl)) {
|
||||||
ensurePlayer(playUrl, fallbackHlsUrl);
|
ensurePlayer(playUrl, fallbackUrl);
|
||||||
} else {
|
} else {
|
||||||
// 没有播放地址时显示离线状态
|
// 没有播放地址时显示离线状态
|
||||||
|
android.util.Log.e("RoomDetail", "没有可用的播放地址,显示离线状态");
|
||||||
binding.offlineLayout.setVisibility(View.VISIBLE);
|
binding.offlineLayout.setVisibility(View.VISIBLE);
|
||||||
releasePlayer();
|
releasePlayer();
|
||||||
}
|
}
|
||||||
|
|
@ -1012,15 +1031,15 @@ public class RoomDetailActivity extends AppCompatActivity {
|
||||||
triedAltUrl = false;
|
triedAltUrl = false;
|
||||||
hasShownConnectedMessage = false; // 重置连接消息标志
|
hasShownConnectedMessage = false; // 重置连接消息标志
|
||||||
|
|
||||||
// 优化缓冲配置 - 增大缓冲区以减少卡顿
|
// 优化缓冲配置 - 针对低延迟 HLS(1秒分片)
|
||||||
// HLS 直播通常有 2-3 秒的切片延迟,需要足够的缓冲
|
// 平衡延迟和流畅度
|
||||||
androidx.media3.exoplayer.DefaultLoadControl loadControl =
|
androidx.media3.exoplayer.DefaultLoadControl loadControl =
|
||||||
new androidx.media3.exoplayer.DefaultLoadControl.Builder()
|
new androidx.media3.exoplayer.DefaultLoadControl.Builder()
|
||||||
.setBufferDurationsMs(
|
.setBufferDurationsMs(
|
||||||
10000, // 最小缓冲 10秒(保证流畅播放)
|
2500, // 最小缓冲 2.5秒(约2-3个分片)
|
||||||
30000, // 最大缓冲 30秒(足够应对网络波动)
|
10000, // 最大缓冲 10秒
|
||||||
5000, // 播放前缓冲 5秒(确保有足够数据再开始)
|
1500, // 播放前缓冲 1.5秒(快速起播)
|
||||||
10000 // 重新缓冲 10秒(卡顿后充分缓冲再继续)
|
2500 // 重新缓冲 2.5秒
|
||||||
)
|
)
|
||||||
.setPrioritizeTimeOverSizeThresholds(true)
|
.setPrioritizeTimeOverSizeThresholds(true)
|
||||||
.build();
|
.build();
|
||||||
|
|
@ -1029,6 +1048,9 @@ public class RoomDetailActivity extends AppCompatActivity {
|
||||||
ExoPlayer exo = new ExoPlayer.Builder(this)
|
ExoPlayer exo = new ExoPlayer.Builder(this)
|
||||||
.setLoadControl(loadControl)
|
.setLoadControl(loadControl)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// 关键:设置为直播模式,自动跳到最新位置
|
||||||
|
exo.setPlayWhenReady(true);
|
||||||
|
|
||||||
// 设置播放器视图
|
// 设置播放器视图
|
||||||
binding.playerView.setPlayer(exo);
|
binding.playerView.setPlayer(exo);
|
||||||
|
|
@ -1087,6 +1109,12 @@ public class RoomDetailActivity extends AppCompatActivity {
|
||||||
if (playbackState == Player.STATE_READY) {
|
if (playbackState == Player.STATE_READY) {
|
||||||
binding.offlineLayout.setVisibility(View.GONE);
|
binding.offlineLayout.setVisibility(View.GONE);
|
||||||
retryCount = 0;
|
retryCount = 0;
|
||||||
|
|
||||||
|
// 关键:跳到直播流的最新位置,减少延迟
|
||||||
|
if (exo.isCurrentMediaItemLive()) {
|
||||||
|
exo.seekToDefaultPosition();
|
||||||
|
}
|
||||||
|
|
||||||
// 只显示一次连接消息
|
// 只显示一次连接消息
|
||||||
if (!hasShownConnectedMessage) {
|
if (!hasShownConnectedMessage) {
|
||||||
hasShownConnectedMessage = true;
|
hasShownConnectedMessage = true;
|
||||||
|
|
@ -1106,16 +1134,70 @@ public class RoomDetailActivity extends AppCompatActivity {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startFlv(String flvUrl, @Nullable String fallbackHlsUrl) {
|
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 崩溃问题
|
// 先尝试加载 IjkPlayer 库
|
||||||
// HLS 虽然延迟稍高,但稳定性更好
|
ensureIjkLibsLoaded();
|
||||||
String hlsUrl = fallbackHlsUrl;
|
|
||||||
if (TextUtils.isEmpty(hlsUrl)) {
|
// 如果 IjkPlayer 加载失败,直接使用 HLS
|
||||||
hlsUrl = flvUrl.replace(".flv", ".m3u8");
|
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) {
|
private void prepareIjk(String url) {
|
||||||
|
|
@ -1195,13 +1277,18 @@ public class RoomDetailActivity extends AppCompatActivity {
|
||||||
private static boolean ijkLibLoadFailed = false;
|
private static boolean ijkLibLoadFailed = false;
|
||||||
|
|
||||||
private static void ensureIjkLibsLoaded() {
|
private static void ensureIjkLibsLoaded() {
|
||||||
if (ijkLibLoaded || ijkLibLoadFailed) return;
|
if (ijkLibLoaded || ijkLibLoadFailed) {
|
||||||
|
android.util.Log.d("IjkPlayer", "IjkPlayer 库状态: loaded=" + ijkLibLoaded + ", failed=" + ijkLibLoadFailed);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
// 检查设备 CPU 架构
|
// 检查设备 CPU 架构
|
||||||
String[] abis = android.os.Build.SUPPORTED_ABIS;
|
String[] abis = android.os.Build.SUPPORTED_ABIS;
|
||||||
|
android.util.Log.d("IjkPlayer", "设备 CPU 架构: " + java.util.Arrays.toString(abis));
|
||||||
|
|
||||||
boolean supported = false;
|
boolean supported = false;
|
||||||
for (String abi : abis) {
|
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;
|
supported = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -1217,7 +1304,7 @@ public class RoomDetailActivity extends AppCompatActivity {
|
||||||
ijkLibLoaded = true;
|
ijkLibLoaded = true;
|
||||||
android.util.Log.d("IjkPlayer", "IjkPlayer 库加载成功");
|
android.util.Log.d("IjkPlayer", "IjkPlayer 库加载成功");
|
||||||
} catch (Throwable e) {
|
} catch (Throwable e) {
|
||||||
android.util.Log.e("IjkPlayer", "加载IjkPlayer库失败: " + e.getMessage());
|
android.util.Log.e("IjkPlayer", "加载IjkPlayer库失败: " + e.getMessage(), e);
|
||||||
ijkLibLoadFailed = true;
|
ijkLibLoadFailed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,33 @@ public class SettingsPageActivity extends AppCompatActivity {
|
||||||
return;
|
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)) {
|
if (PAGE_SERVER.equals(page)) {
|
||||||
// 服务器设置页面的其他项目已在上面处理
|
// 服务器设置页面的其他项目已在上面处理
|
||||||
|
|
@ -342,6 +369,22 @@ public class SettingsPageActivity extends AppCompatActivity {
|
||||||
list.add(MoreItem.section("通用"));
|
list.add(MoreItem.section("通用"));
|
||||||
list.add(MoreItem.row("服务器设置", "切换API与直播流地址", R.drawable.ic_globe_24));
|
list.add(MoreItem.row("服务器设置", "切换API与直播流地址", R.drawable.ic_globe_24));
|
||||||
list.add(MoreItem.row("关于", "版本信息、协议", R.drawable.ic_menu_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;
|
return list;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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<ApiResponse<Map<String, Object>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<ApiResponse<Map<String, Object>>> call,
|
||||||
|
Response<ApiResponse<Map<String, Object>>> response) {
|
||||||
|
binding.progressBar.setVisibility(View.GONE);
|
||||||
|
|
||||||
|
if (response.isSuccessful() && response.body() != null && response.body().isOk()) {
|
||||||
|
Map<String, Object> data = response.body().getData();
|
||||||
|
updateStats(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFailure(Call<ApiResponse<Map<String, Object>>> call, Throwable t) {
|
||||||
|
binding.progressBar.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void updateStats(Map<String, Object> 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<ApiResponse<List<Map<String, Object>>>>() {
|
||||||
|
@Override
|
||||||
|
public void onResponse(Call<ApiResponse<List<Map<String, Object>>>> call,
|
||||||
|
Response<ApiResponse<List<Map<String, Object>>>> response) {
|
||||||
|
if (response.isSuccessful() && response.body() != null && response.body().isOk()) {
|
||||||
|
List<Map<String, Object>> 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<ApiResponse<List<Map<String, Object>>>> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -517,6 +517,26 @@ public interface ApiService {
|
||||||
@GET("api/front/streamer/applications")
|
@GET("api/front/streamer/applications")
|
||||||
Call<ApiResponse<List<Map<String, Object>>>> getStreamerApplications();
|
Call<ApiResponse<List<Map<String, Object>>>> getStreamerApplications();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取主播统计数据(粉丝数、获赞数、礼物数、收益等)
|
||||||
|
*/
|
||||||
|
@GET("api/front/streamer/stats")
|
||||||
|
Call<ApiResponse<Map<String, Object>>> getStreamerStats();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取收到的礼物列表
|
||||||
|
*/
|
||||||
|
@GET("api/front/streamer/gifts/received")
|
||||||
|
Call<ApiResponse<List<Map<String, Object>>>> getReceivedGifts(
|
||||||
|
@Query("page") int page,
|
||||||
|
@Query("pageSize") int pageSize);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取我的直播间列表
|
||||||
|
*/
|
||||||
|
@GET("api/front/streamer/rooms")
|
||||||
|
Call<ApiResponse<List<Room>>> getMyRooms();
|
||||||
|
|
||||||
// ==================== 社区/缘池接口 ====================
|
// ==================== 社区/缘池接口 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,8 @@ public final class AuthStore {
|
||||||
|
|
||||||
private static final String KEY_USER_ID = "user_id";
|
private static final String KEY_USER_ID = "user_id";
|
||||||
private static final String KEY_NICKNAME = "nickname";
|
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) {
|
public static void setUserInfo(Context context, @Nullable String userId, @Nullable String nickname) {
|
||||||
if (context == null) return;
|
if (context == null) return;
|
||||||
|
|
@ -79,4 +81,58 @@ public final class AuthStore {
|
||||||
.getString(KEY_NICKNAME, null);
|
.getString(KEY_NICKNAME, null);
|
||||||
return nickname != null ? nickname : "用户";
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,44 @@ package com.example.livestreaming.net;
|
||||||
|
|
||||||
import com.google.gson.annotations.SerializedName;
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建直播间请求
|
||||||
|
* 对应后端 CreateLiveRoomRequest
|
||||||
|
*/
|
||||||
public class CreateRoomRequest {
|
public class CreateRoomRequest {
|
||||||
|
|
||||||
@SerializedName("title")
|
@SerializedName("title")
|
||||||
private final String title;
|
private String title;
|
||||||
|
|
||||||
@SerializedName("streamerName")
|
@SerializedName("streamerName")
|
||||||
private final String streamerName;
|
private String streamerName;
|
||||||
|
|
||||||
@SerializedName("type")
|
@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) {
|
public CreateRoomRequest(String title, String streamerName, String type) {
|
||||||
this.title = title;
|
this.title = title;
|
||||||
|
|
@ -19,15 +47,68 @@ public class CreateRoomRequest {
|
||||||
this.type = type;
|
this.type = type;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Getters and Setters
|
||||||
public String getTitle() {
|
public String getTitle() {
|
||||||
return title;
|
return title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setTitle(String title) {
|
||||||
|
this.title = title;
|
||||||
|
}
|
||||||
|
|
||||||
public String getStreamerName() {
|
public String getStreamerName() {
|
||||||
return streamerName;
|
return streamerName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void setStreamerName(String streamerName) {
|
||||||
|
this.streamerName = streamerName;
|
||||||
|
}
|
||||||
|
|
||||||
public String getType() {
|
public String getType() {
|
||||||
return type;
|
return type;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,3 @@
|
||||||
android:shape="oval">
|
android:shape="oval">
|
||||||
<solid android:color="#FF4444" />
|
<solid android:color="#FF4444" />
|
||||||
</shape>
|
</shape>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="#66000000" />
|
||||||
|
</shape>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<gradient
|
||||||
|
android:angle="90"
|
||||||
|
android:startColor="#99000000"
|
||||||
|
android:endColor="#00000000" />
|
||||||
|
</shape>
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<gradient
|
||||||
|
android:angle="270"
|
||||||
|
android:startColor="#99000000"
|
||||||
|
android:endColor="#00000000" />
|
||||||
|
</shape>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@color/purple_500" />
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
</shape>
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<solid android:color="#66000000" />
|
||||||
|
<corners android:radius="20dp" />
|
||||||
|
</shape>
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
android:viewportWidth="24"
|
android:viewportWidth="24"
|
||||||
android:viewportHeight="24">
|
android:viewportHeight="24">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#666666"
|
android:fillColor="#FFFFFF"
|
||||||
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
|
android:pathData="M19,6.41L17.59,5 12,10.59 6.41,5 5,6.41 10.59,12 5,17.59 6.41,19 12,13.41 17.59,19 19,17.59 13.41,12z" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|
||||||
|
|
|
||||||
16
android-app/app/src/main/res/drawable/ic_live_24.xml
Normal file
16
android-app/app/src/main/res/drawable/ic_live_24.xml
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF5252"
|
||||||
|
android:pathData="M12,12m-3,0a3,3 0,1 1,6 0a3,3 0,1 1,-6 0" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#666666"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.42,0 -8,-3.58 -8,-8s3.58,-8 8,-8 8,3.58 8,8 -3.58,8 -8,8z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#666666"
|
||||||
|
android:pathData="M12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6 -2.69,-6 -6,-6zM12,16c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4z" />
|
||||||
|
</vector>
|
||||||
|
|
@ -5,6 +5,6 @@
|
||||||
android:viewportWidth="24"
|
android:viewportWidth="24"
|
||||||
android:viewportHeight="24">
|
android:viewportHeight="24">
|
||||||
<path
|
<path
|
||||||
android:fillColor="#777777"
|
android:fillColor="#FFFFFF"
|
||||||
android:pathData="M12,12a4,4 0,1 0,-4 -4a4,4 0,0 0,4 4zm0,2c-4.42,0 -8,2.24 -8,5v1h16v-1c0,-2.76 -3.58,-5 -8,-5z" />
|
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" />
|
||||||
</vector>
|
</vector>
|
||||||
|
|
|
||||||
10
android-app/app/src/main/res/drawable/ic_settings_24.xml
Normal file
10
android-app/app/src/main/res/drawable/ic_settings_24.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M19.14,12.94c0.04,-0.31 0.06,-0.63 0.06,-0.94c0,-0.31 -0.02,-0.63 -0.06,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.37 4.8,11.69 4.8,12s0.02,0.63 0.06,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z" />
|
||||||
|
</vector>
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M9,12c0,1.66 1.34,3 3,3s3,-1.34 3,-3s-1.34,-3 -3,-3S9,10.34 9,12zM7,7V4H5v3H2v2h3v3h2V9h3V7H7zM17,17v3h2v-3h3v-2h-3v-3h-2v3h-3v2H17z" />
|
||||||
|
</vector>
|
||||||
183
android-app/app/src/main/res/layout/activity_broadcast.xml
Normal file
183
android-app/app/src/main/res/layout/activity_broadcast.xml
Normal file
|
|
@ -0,0 +1,183 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="#000000">
|
||||||
|
|
||||||
|
<!-- 使用 SurfaceView 作为摄像头预览,支持 RootEncoder RTMP 推流 -->
|
||||||
|
<SurfaceView
|
||||||
|
android:id="@+id/surfaceView"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<!-- 顶部工具栏 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/topBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/bg_gradient_top"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="16dp"
|
||||||
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnClose"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="@drawable/bg_circle_semi_transparent"
|
||||||
|
android:contentDescription="关闭"
|
||||||
|
android:src="@drawable/ic_close_24"
|
||||||
|
app:tint="@android:color/white" />
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnSwitchCamera"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:background="@drawable/bg_circle_semi_transparent"
|
||||||
|
android:contentDescription="切换摄像头"
|
||||||
|
android:src="@drawable/ic_switch_camera_24"
|
||||||
|
app:tint="@android:color/white" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btnSettings"
|
||||||
|
android:layout_width="40dp"
|
||||||
|
android:layout_height="40dp"
|
||||||
|
android:background="@drawable/bg_circle_semi_transparent"
|
||||||
|
android:contentDescription="设置"
|
||||||
|
android:src="@drawable/ic_settings_24"
|
||||||
|
app:tint="@android:color/white" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 直播信息卡片(开播前显示) -->
|
||||||
|
<androidx.cardview.widget.CardView
|
||||||
|
android:id="@+id/cardLiveInfo"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_margin="16dp"
|
||||||
|
app:cardBackgroundColor="#CC000000"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:layout_constraintBottom_toTopOf="@id/bottomBar"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:hint="直播标题"
|
||||||
|
app:boxBackgroundColor="#33FFFFFF"
|
||||||
|
app:hintTextColor="@android:color/white">
|
||||||
|
|
||||||
|
<com.google.android.material.textfield.TextInputEditText
|
||||||
|
android:id="@+id/etTitle"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:maxLength="30"
|
||||||
|
android:textColor="@android:color/white" />
|
||||||
|
</com.google.android.material.textfield.TextInputLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
</androidx.cardview.widget.CardView>
|
||||||
|
|
||||||
|
<!-- 直播状态信息(开播后显示) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/liveStatusBar"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginTop="80dp"
|
||||||
|
android:background="@drawable/bg_rounded_semi_transparent"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:padding="8dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
||||||
|
<View
|
||||||
|
android:layout_width="8dp"
|
||||||
|
android:layout_height="8dp"
|
||||||
|
android:background="@drawable/bg_circle_red" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvLiveTime"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="8dp"
|
||||||
|
android:text="00:00:00"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvViewerCount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="16dp"
|
||||||
|
android:drawableStart="@drawable/ic_person_24"
|
||||||
|
android:drawablePadding="4dp"
|
||||||
|
android:text="0"
|
||||||
|
android:textColor="@android:color/white"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 底部控制栏 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/bottomBar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@drawable/bg_gradient_bottom"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="24dp"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent">
|
||||||
|
|
||||||
|
<!-- 开播按钮 -->
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnStartLive"
|
||||||
|
android:layout_width="200dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:text="开始直播"
|
||||||
|
android:textSize="18sp"
|
||||||
|
app:cornerRadius="28dp"
|
||||||
|
app:backgroundTint="#FF4444" />
|
||||||
|
|
||||||
|
<!-- 停止直播按钮(开播后显示) -->
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnStopLive"
|
||||||
|
android:layout_width="200dp"
|
||||||
|
android:layout_height="56dp"
|
||||||
|
android:text="结束直播"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:cornerRadius="28dp"
|
||||||
|
app:backgroundTint="#666666" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- 加载指示器 -->
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressBar"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
@ -221,6 +221,7 @@
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
|
android:visibility="gone"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent">
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
@ -549,10 +550,24 @@
|
||||||
android:gravity="center"
|
android:gravity="center"
|
||||||
android:text="分享主页"
|
android:text="分享主页"
|
||||||
android:textColor="#111111"
|
android:textColor="#111111"
|
||||||
app:layout_constraintEnd_toStartOf="@id/addFriendBtn"
|
app:layout_constraintEnd_toStartOf="@id/streamerCenterBtn"
|
||||||
app:layout_constraintStart_toEndOf="@id/editProfile"
|
app:layout_constraintStart_toEndOf="@id/editProfile"
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/streamerCenterBtn"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="44dp"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:background="@drawable/bg_purple_button"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="主播中心"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:visibility="gone"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/addFriendBtn"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/shareHome"
|
||||||
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/addFriendBtn"
|
android:id="@+id/addFriendBtn"
|
||||||
android:layout_width="44dp"
|
android:layout_width="44dp"
|
||||||
|
|
@ -563,7 +578,7 @@
|
||||||
android:src="@drawable/ic_person_24"
|
android:src="@drawable/ic_person_24"
|
||||||
android:tint="@color/purple_500"
|
android:tint="@color/purple_500"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toEndOf="@id/shareHome"
|
app:layout_constraintStart_toEndOf="@id/streamerCenterBtn"
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
@ -573,6 +588,7 @@
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="12dp"
|
android:layout_marginTop="12dp"
|
||||||
|
android:visibility="gone"
|
||||||
app:tabIndicatorColor="@color/purple_500"
|
app:tabIndicatorColor="@color/purple_500"
|
||||||
app:tabIndicatorFullWidth="false"
|
app:tabIndicatorFullWidth="false"
|
||||||
app:tabIndicatorHeight="3dp"
|
app:tabIndicatorHeight="3dp"
|
||||||
|
|
@ -605,6 +621,7 @@
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginTop="12dp"
|
android:layout_marginTop="12dp"
|
||||||
|
android:visibility="gone"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@id/profileTabs">
|
app:layout_constraintTop_toBottomOf="@id/profileTabs">
|
||||||
|
|
|
||||||
362
android-app/app/src/main/res/layout/activity_streamer_center.xml
Normal file
362
android-app/app/src/main/res/layout/activity_streamer_center.xml
Normal file
|
|
@ -0,0 +1,362 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:background="@color/background_color">
|
||||||
|
|
||||||
|
<com.google.android.material.appbar.AppBarLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:background="@color/white"
|
||||||
|
app:elevation="0dp">
|
||||||
|
|
||||||
|
<androidx.appcompat.widget.Toolbar
|
||||||
|
android:id="@+id/toolbar"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="?attr/actionBarSize"
|
||||||
|
app:navigationIcon="@drawable/ic_arrow_back_24"
|
||||||
|
app:title="主播中心"
|
||||||
|
app:titleTextColor="@color/text_primary" />
|
||||||
|
|
||||||
|
</com.google.android.material.appbar.AppBarLayout>
|
||||||
|
|
||||||
|
<androidx.core.widget.NestedScrollView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
app:layout_behavior="@string/appbar_scrolling_view_behavior">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<!-- 主播信息卡片 -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="2dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<de.hdodenhof.circleimageview.CircleImageView
|
||||||
|
android:id="@+id/ivAvatar"
|
||||||
|
android:layout_width="64dp"
|
||||||
|
android:layout_height="64dp"
|
||||||
|
android:src="@drawable/ic_person_24" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:orientation="vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvNickname"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="主播昵称"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="18sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvStreamerLevel"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:text="主播等级: Lv.1"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnStartLive"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="开始直播"
|
||||||
|
app:cornerRadius="20dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- 数据统计卡片 -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="2dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="数据统计"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/layoutFans"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvFansCount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="0"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="粉丝"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/layoutLikes"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvLikesCount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="0"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="获赞"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/layoutGifts"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvGiftsCount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="0"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="礼物"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/layoutIncome"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:gravity="center"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="8dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvIncomeCount"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="0"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="20sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="收益"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="12sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- 我的直播间 -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardMyRoom"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="2dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="我的直播间"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvRoomStatus"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:text="暂无直播间"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- 收到的礼物 -->
|
||||||
|
<com.google.android.material.card.MaterialCardView
|
||||||
|
android:id="@+id/cardReceivedGifts"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
app:cardCornerRadius="12dp"
|
||||||
|
app:cardElevation="2dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:orientation="horizontal">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="收到的礼物"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvViewAllGifts"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="查看全部 >"
|
||||||
|
android:textColor="@color/primary"
|
||||||
|
android:textSize="14sp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<androidx.recyclerview.widget.RecyclerView
|
||||||
|
android:id="@+id/rvGifts"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:orientation="horizontal" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/tvNoGifts"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:text="暂无收到的礼物"
|
||||||
|
android:textColor="@color/text_hint"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</com.google.android.material.card.MaterialCardView>
|
||||||
|
|
||||||
|
<!-- 切换回普通用户 -->
|
||||||
|
<com.google.android.material.button.MaterialButton
|
||||||
|
android:id="@+id/btnSwitchToUser"
|
||||||
|
style="@style/Widget.MaterialComponents.Button.OutlinedButton"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:text="切换回普通用户"
|
||||||
|
app:cornerRadius="8dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
</androidx.core.widget.NestedScrollView>
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressBar"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_gravity="center"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||||
11
delete_room_menus.sql
Normal file
11
delete_room_menus.sql
Normal file
|
|
@ -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 = '房间背景';
|
||||||
|
|
@ -38,15 +38,20 @@ vhost __defaultVhost__ {
|
||||||
chunk_size 4096;
|
chunk_size 4096;
|
||||||
}
|
}
|
||||||
|
|
||||||
# HLS 配置 - 优化延迟
|
# HLS 配置 - 低延迟模式
|
||||||
hls {
|
hls {
|
||||||
enabled on;
|
enabled on;
|
||||||
hls_path ./objs/nginx/html;
|
hls_path ./objs/nginx/html;
|
||||||
# 减少分片时长,降低延迟
|
# 最小分片时长 1秒,降低延迟
|
||||||
hls_fragment 2;
|
hls_fragment 1;
|
||||||
hls_window 6;
|
# 保留 3 个分片
|
||||||
# 启用低延迟模式
|
hls_window 3;
|
||||||
hls_dispose 30;
|
# 快速清理过期分片
|
||||||
|
hls_dispose 10;
|
||||||
|
# 启用 ts 文件清理
|
||||||
|
hls_cleanup on;
|
||||||
|
# 等待关键帧
|
||||||
|
hls_wait_keyframe on;
|
||||||
}
|
}
|
||||||
|
|
||||||
# HTTP-FLV 配置 - 低延迟播放
|
# HTTP-FLV 配置 - 低延迟播放
|
||||||
|
|
@ -64,12 +69,14 @@ vhost __defaultVhost__ {
|
||||||
|
|
||||||
# 播放配置 - 优化延迟
|
# 播放配置 - 优化延迟
|
||||||
play {
|
play {
|
||||||
# 减少GOP缓存
|
# 关闭 GOP 缓存,降低延迟
|
||||||
gop_cache off;
|
gop_cache off;
|
||||||
# 启用时间校正
|
# 启用时间校正
|
||||||
time_jitter full;
|
time_jitter full;
|
||||||
# 减少队列长度
|
# 减少队列长度
|
||||||
queue_length 10;
|
queue_length 10;
|
||||||
|
# 降低首帧等待
|
||||||
|
mw_latency 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 发布配置 - 优化延迟
|
# 发布配置 - 优化延迟
|
||||||
|
|
|
||||||
54
live-streaming/docker启动配置文件.md
Normal file
54
live-streaming/docker启动配置文件.md
Normal file
|
|
@ -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
|
||||||
22
room_category_table.sql
Normal file
22
room_category_table.sql
Normal file
|
|
@ -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`;
|
||||||
228
服务管理指南.md
Normal file
228
服务管理指南.md
Normal file
|
|
@ -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 <PID>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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"` |
|
||||||
Loading…
Reference in New Issue
Block a user