Merge branch 'modify-back' of https://gitee.com/xiao12feng/zhibo_1 into Added-a-button

# Conflicts:
#	android-app/app/src/main/java/com/example/livestreaming/ChatAdapter.java
#	android-app/app/src/main/java/com/example/livestreaming/ChatMessage.java
#	android-app/app/src/main/res/drawable/ic_arrow_back_24.xml
#	android-app/app/src/main/res/layout/activity_room_detail_new.xml
#	android-app/app/src/main/res/layout/dialog_stream_info.xml
#	android-app/app/src/main/res/layout/item_chat_message.xml
This commit is contained in:
ShiQi 2025-12-18 16:10:15 +08:00
commit e234adf929
38 changed files with 1514 additions and 359 deletions

View File

@ -10,67 +10,69 @@ import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
public class ChatAdapter extends ListAdapter<ChatMessage, ChatAdapter.VH> {
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
public class ChatAdapter extends ListAdapter<ChatMessage, ChatAdapter.ChatViewHolder> {
private static final SimpleDateFormat TIME_FORMAT = new SimpleDateFormat("HH:mm", Locale.getDefault());
public ChatAdapter() {
super(DIFF);
super(DIFF_CALLBACK);
}
private static final DiffUtil.ItemCallback<ChatMessage> DIFF = new DiffUtil.ItemCallback<ChatMessage>() {
@NonNull
@Override
public ChatViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View view = LayoutInflater.from(parent.getContext())
.inflate(R.layout.item_chat_message, parent, false);
return new ChatViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull ChatViewHolder holder, int position) {
holder.bind(getItem(position));
}
static class ChatViewHolder extends RecyclerView.ViewHolder {
private final TextView usernameText;
private final TextView messageText;
private final TextView timeText;
public ChatViewHolder(@NonNull View itemView) {
super(itemView);
usernameText = itemView.findViewById(R.id.usernameText);
messageText = itemView.findViewById(R.id.messageText);
timeText = itemView.findViewById(R.id.timeText);
}
public void bind(ChatMessage message) {
if (message.isSystemMessage()) {
usernameText.setVisibility(View.GONE);
messageText.setText(message.getMessage());
messageText.setTextColor(itemView.getContext().getColor(R.color.purple_500));
} else {
usernameText.setVisibility(View.VISIBLE);
usernameText.setText(message.getUsername() + ":");
messageText.setText(message.getMessage());
messageText.setTextColor(itemView.getContext().getColor(android.R.color.black));
}
timeText.setText(TIME_FORMAT.format(new Date(message.getTimestamp())));
}
}
private static final DiffUtil.ItemCallback<ChatMessage> DIFF_CALLBACK = new DiffUtil.ItemCallback<ChatMessage>() {
@Override
public boolean areItemsTheSame(@NonNull ChatMessage oldItem, @NonNull ChatMessage newItem) {
// Chat messages have no stable id; treat position as identity.
return oldItem == newItem;
// 使用时间戳作为唯一标识
return oldItem.getTimestamp() == newItem.getTimestamp();
}
@Override
public boolean areContentsTheSame(@NonNull ChatMessage oldItem, @NonNull ChatMessage newItem) {
String ou = oldItem.getUser();
String nu = newItem.getUser();
if (ou == null ? nu != null : !ou.equals(nu)) return false;
String om = oldItem.getMessage();
String nm = newItem.getMessage();
if (om == null ? nm != null : !om.equals(nm)) return false;
return oldItem.isSystem() == newItem.isSystem();
return oldItem.getTimestamp() == newItem.getTimestamp();
}
};
@NonNull
@Override
public VH onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
View v = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_chat_message, parent, false);
return new VH(v);
}
@Override
public void onBindViewHolder(@NonNull VH holder, int position) {
ChatMessage m = getItem(position);
if (m == null) {
holder.messageText.setText("");
return;
}
if (m.isSystem()) {
holder.messageText.setText(m.getMessage() != null ? m.getMessage() : "");
holder.messageText.setAlpha(0.8f);
} else {
String user = m.getUser();
String msg = m.getMessage();
if (user == null || user.trim().isEmpty()) {
holder.messageText.setText(msg != null ? msg : "");
} else {
holder.messageText.setText(user + ": " + (msg != null ? msg : ""));
}
holder.messageText.setAlpha(1.0f);
}
}
static class VH extends RecyclerView.ViewHolder {
final TextView messageText;
VH(@NonNull View itemView) {
super(itemView);
messageText = itemView.findViewById(R.id.messageText);
}
}
}
}

View File

@ -1,32 +1,54 @@
package com.example.livestreaming;
public class ChatMessage {
private String username;
private String message;
private long timestamp;
private boolean isSystemMessage;
private final String user;
private final String message;
private final boolean system;
public ChatMessage(String systemMessage, boolean system) {
this.user = null;
this.message = systemMessage;
this.system = system;
}
public ChatMessage(String user, String message) {
this.user = user;
public ChatMessage(String username, String message) {
this.username = username;
this.message = message;
this.system = false;
this.timestamp = System.currentTimeMillis();
this.isSystemMessage = false;
}
public String getUser() {
return user;
public ChatMessage(String message, boolean isSystemMessage) {
this.message = message;
this.timestamp = System.currentTimeMillis();
this.isSystemMessage = isSystemMessage;
this.username = isSystemMessage ? "系统" : "匿名用户";
}
public String getUsername() {
return username;
}
public String getMessage() {
return message;
}
public boolean isSystem() {
return system;
public long getTimestamp() {
return timestamp;
}
}
public boolean isSystemMessage() {
return isSystemMessage;
}
public void setUsername(String username) {
this.username = username;
}
public void setMessage(String message) {
this.message = message;
}
public void setTimestamp(long timestamp) {
this.timestamp = timestamp;
}
public void setSystemMessage(boolean systemMessage) {
isSystemMessage = systemMessage;
}
}

View File

@ -1,12 +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">
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="#111111"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8l8,8l1.41,-1.41L7.83,13H20v-2z" />
android:fillColor="@android:color/white"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
</vector>

View File

@ -2,8 +2,280 @@
<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:layout_height="match_parent"
android:background="#000000"
android:fitsSystemWindows="true">
<!-- 顶部标题栏 -->
<LinearLayout
android:id="@+id/topBar"
android:layout_width="0dp"
android:layout_height="48dp"
android:background="#1A1A1A"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="4dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<ImageButton
android:id="@+id/backButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="返回"
android:src="@drawable/ic_arrow_back_24"
app:tint="@android:color/white" />
<TextView
android:id="@+id/topTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="8dp"
android:layout_weight="1"
android:ellipsize="end"
android:maxLines="1"
android:text="直播间"
android:textColor="@android:color/white"
android:textSize="16sp"
android:textStyle="bold" />
<!-- 直播状态标签 -->
<TextView
android:id="@+id/liveTag"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:background="@drawable/live_badge_background"
android:paddingHorizontal="8dp"
android:paddingVertical="3dp"
android:text="● 直播中"
android:textColor="@android:color/white"
android:textSize="11sp"
android:visibility="gone" />
<!-- 观看人数 -->
<LinearLayout
android:id="@+id/topViewerLayout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="12dp"
android:gravity="center_vertical"
android:orientation="horizontal">
<ImageView
android:layout_width="14dp"
android:layout_height="14dp"
android:layout_marginEnd="4dp"
android:src="@drawable/ic_people_24"
app:tint="#AAAAAA" />
<TextView
android:id="@+id/topViewerCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="0"
android:textColor="#AAAAAA"
android:textSize="12sp" />
</LinearLayout>
</LinearLayout>
<!-- 播放器容器 -->
<FrameLayout
android:id="@+id/playerContainer"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="#000000"
app:layout_constraintDimensionRatio="16:9"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/topBar">
<androidx.media3.ui.PlayerView
android:id="@+id/playerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:use_controller="true"
app:show_buffering="when_playing" />
<!-- 横屏按钮 -->
<ImageButton
android:id="@+id/fullscreenButton"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_gravity="bottom|end"
android:layout_margin="12dp"
android:background="@drawable/bg_circle_white_60"
android:contentDescription="全屏"
android:src="@drawable/ic_fullscreen_24"
android:tint="@android:color/white" />
<!-- 离线提示 -->
<LinearLayout
android:id="@+id/offlineLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#1A1A1A"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone">
<ImageView
android:layout_width="64dp"
android:layout_height="64dp"
android:layout_marginBottom="16dp"
android:src="@drawable/ic_voice_24"
android:tint="#666666" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="主播暂未开播"
android:textColor="#999999"
android:textSize="16sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:text="请稍后再试"
android:textColor="#666666"
android:textSize="14sp" />
</LinearLayout>
</FrameLayout>
<!-- 直播信息区域 -->
<LinearLayout
android:id="@+id/roomInfoLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="@android:color/white"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingHorizontal="12dp"
android:paddingVertical="10dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/playerContainer">
<ImageView
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_marginEnd="10dp"
android:background="@drawable/bg_avatar_circle"
android:src="@drawable/ic_person_24"
android:tint="@android:color/white" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<TextView
android:id="@+id/roomTitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="直播间标题"
android:textColor="#333333"
android:textSize="15sp"
android:textStyle="bold" />
<TextView
android:id="@+id/streamerName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:text="主播名称"
android:textColor="#999999"
android:textSize="12sp" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/followButton"
style="@style/Widget.Material3.Button.TonalButton"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:text="关注"
android:textSize="12sp"
app:icon="@drawable/ic_heart_24"
app:iconSize="14dp" />
</LinearLayout>
<!-- 弹幕区域 -->
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/chatLayout"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@android:color/white"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/roomInfoLayout">
<!-- 弹幕列表 -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chatRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginBottom="8dp"
android:clipToPadding="false"
android:paddingHorizontal="16dp"
android:paddingTop="8dp"
app:layout_constraintBottom_toTopOf="@id/chatInputLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<!-- 弹幕输入区域 -->
<LinearLayout
android:id="@+id/chatInputLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:background="#F5F5F5"
android:gravity="center_vertical"
android:orientation="horizontal"
android:padding="12dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/chatInput"
android:layout_width="0dp"
android:layout_height="40dp"
android:layout_weight="1"
android:background="@drawable/bg_white_16"
android:hint="说点什么..."
android:imeOptions="actionSend"
android:inputType="text"
android:maxLines="1"
android:paddingHorizontal="12dp"
android:textSize="14sp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/sendButton"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginStart="8dp"
android:text="发送"
android:textSize="12sp"
app:backgroundTint="@color/purple_500" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<!-- 加载指示器 -->
<ProgressBar
android:id="@+id/loading"
android:layout_width="wrap_content"
@ -14,229 +286,4 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/topBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingVertical="10dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<com.google.android.material.button.MaterialButton
android:id="@+id/backButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:insetTop="0dp"
android:insetBottom="0dp"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="返回"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/topTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="直播间"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/topRightGroup"
app:layout_constraintStart_toEndOf="@id/backButton"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/topRightGroup"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/topViewerCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="10dp"
android:text="0"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/fullscreenButton"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/fullscreenButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:insetTop="0dp"
android:insetBottom="0dp"
android:minHeight="0dp"
android:minWidth="0dp"
android:text="全屏"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/playerArea"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintDimensionRatio="16:9"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/topBar">
<androidx.media3.ui.PlayerView
android:id="@+id/playerView"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<LinearLayout
android:id="@+id/offlineLayout"
android:layout_width="0dp"
android:layout_height="0dp"
android:gravity="center"
android:orientation="vertical"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="主播暂未开播"
android:textColor="#666666" />
</LinearLayout>
<TextView
android:id="@+id/liveTag"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="10dp"
android:paddingHorizontal="10dp"
android:paddingVertical="6dp"
android:text="LIVE"
android:textColor="@android:color/white"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/roomInfoLayout"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:paddingHorizontal="12dp"
android:paddingVertical="10dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/playerArea">
<TextView
android:id="@+id/roomTitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:text="直播间"
android:textSize="16sp"
android:textStyle="bold"
app:layout_constraintEnd_toStartOf="@id/followButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/streamerName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="主播"
android:textColor="#666666"
app:layout_constraintEnd_toStartOf="@id/followButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/roomTitle" />
<com.google.android.material.button.MaterialButton
android:id="@+id/followButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="关注"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/chatLayout"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/roomInfoLayout">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/chatRecyclerView"
android:layout_width="0dp"
android:layout_height="0dp"
android:overScrollMode="never"
android:paddingHorizontal="12dp"
android:paddingTop="8dp"
android:paddingBottom="8dp"
app:layout_constraintBottom_toTopOf="@id/chatInputBar"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/chatInputBar"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:padding="10dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/chatInput"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:hint="说点什么..."
android:imeOptions="actionSend"
android:inputType="text"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/sendButton"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.button.MaterialButton
android:id="@+id/sendButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="发送"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -1,67 +1,152 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout 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="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<!-- 说明文字 -->
<TextView
android:id="@+id/addressLabel"
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="推流地址"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/addressText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="请使用 OBS 等推流软件配置以下信息:"
android:textColor="#666666"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/addressLabel" />
android:textSize="14sp"
android:layout_marginBottom="16dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/copyAddressBtn"
android:layout_width="wrap_content"
<!-- 推流地址 -->
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="复制地址"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/addressText" />
android:layout_marginBottom="12dp"
app:cardCornerRadius="8dp"
app:cardElevation="2dp"
app:strokeColor="#E0E0E0"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="推流地址"
android:textColor="#333333"
android:textSize="14sp"
android:textStyle="bold" />
<com.google.android.material.button.MaterialButton
android:id="@+id/copyAddressBtn"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:text="复制"
android:textSize="12sp"
android:minWidth="60dp"
app:icon="@drawable/ic_copy_24"
app:iconSize="16dp" />
</LinearLayout>
<TextView
android:id="@+id/addressText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:background="@drawable/bg_gray_12"
android:padding="8dp"
android:text="rtmp://localhost:1935/live"
android:textColor="#666666"
android:textSize="12sp"
android:fontFamily="monospace"
android:textIsSelectable="true" />
</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="8dp"
app:cardElevation="2dp"
app:strokeColor="#E0E0E0"
app:strokeWidth="1dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="推流密钥"
android:textColor="#333333"
android:textSize="14sp"
android:textStyle="bold" />
<com.google.android.material.button.MaterialButton
android:id="@+id/copyKeyBtn"
style="@style/Widget.Material3.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="32dp"
android:text="复制"
android:textSize="12sp"
android:minWidth="60dp"
app:icon="@drawable/ic_copy_24"
app:iconSize="16dp" />
</LinearLayout>
<TextView
android:id="@+id/keyText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:background="@drawable/bg_gray_12"
android:padding="8dp"
android:text="stream_key_here"
android:textColor="#666666"
android:textSize="12sp"
android:fontFamily="monospace"
android:textIsSelectable="true" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<!-- 提示信息 -->
<TextView
android:id="@+id/keyLabel"
android:layout_width="0dp"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:text="推流密钥"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/copyAddressBtn" />
<TextView
android:id="@+id/keyText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="💡 配置完成后点击「开始直播」进入直播间"
android:textColor="#666666"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/keyLabel" />
android:textSize="12sp"
android:gravity="center"
android:background="@drawable/bg_purple_20"
android:padding="8dp"
android:drawablePadding="4dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/copyKeyBtn"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="复制密钥"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/keyText" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

View File

@ -1,8 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/messageText"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingVertical="4dp"
android:textColor="@android:color/white"
android:textSize="14sp" />
android:orientation="horizontal"
android:padding="8dp">
<TextView
android:id="@+id/timeText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:text="12:34"
android:textColor="#999999"
android:textSize="10sp" />
<TextView
android:id="@+id/usernameText"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="4dp"
android:text="用户名:"
android:textColor="@color/purple_500"
android:textSize="12sp"
android:textStyle="bold" />
<TextView
android:id="@+id/messageText"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="这是一条弹幕消息"
android:textColor="#333333"
android:textSize="12sp" />
</LinearLayout>

126
java-backend/README.md Normal file
View File

@ -0,0 +1,126 @@
# 直播系统 Java 后端
基于 Spring Boot 3.2 + JPA + H2/MySQL 的直播系统后端服务。
## 技术栈
- Java 17
- Spring Boot 3.2
- Spring Data JPA
- H2 Database (开发) / MySQL (生产)
- Lombok
## 快速开始
### 1. 使用 IDEA 打开项目
1. 打开 IntelliJ IDEA
2. 选择 `File` -> `Open`
3. 选择 `java-backend` 文件夹
4. 等待 Maven 依赖下载完成
### 2. 运行项目
方式一:直接运行
- 找到 `LivestreamingApplication.java`
- 右键点击 -> `Run 'LivestreamingApplication'`
方式二Maven 命令
```bash
cd java-backend
mvn spring-boot:run
```
### 3. 访问服务
- API 地址: http://localhost:3001/api
- H2 控制台: http://localhost:3001/h2-console
- JDBC URL: `jdbc:h2:file:./data/livestream`
- 用户名: `sa`
- 密码: (空)
## API 接口
### 直播间接口
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | /api/rooms | 获取所有直播间 |
| GET | /api/rooms/{id} | 获取单个直播间 |
| POST | /api/rooms | 创建直播间 |
| DELETE | /api/rooms/{id} | 删除直播间 |
### 创建直播间请求示例
```json
POST /api/rooms
{
"title": "我的直播间",
"streamerName": "主播名称"
}
```
### 响应示例
```json
{
"success": true,
"data": {
"id": "uuid",
"title": "我的直播间",
"streamerName": "主播名称",
"streamKey": "uuid",
"isLive": false,
"viewerCount": 0,
"streamUrls": {
"rtmp": "rtmp://localhost:1935/live/uuid",
"flv": "http://localhost:8080/live/uuid.flv",
"hls": "http://localhost:8080/live/uuid/index.m3u8"
}
}
}
```
## 切换到 MySQL
1. 安装 MySQL 并创建数据库:
```sql
CREATE DATABASE livestream CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
```
2. 修改 `application.yml`
```yaml
spring:
datasource:
url: jdbc:mysql://localhost:3306/livestream?useSSL=false&serverTimezone=Asia/Shanghai
driver-class-name: com.mysql.cj.jdbc.Driver
username: root
password: your_password
```
## 项目结构
```
java-backend/
├── src/main/java/com/example/livestreaming/
│ ├── LivestreamingApplication.java # 启动类
│ ├── config/ # 配置类
│ ├── controller/ # 控制器
│ ├── dto/ # 数据传输对象
│ ├── entity/ # 实体类
│ ├── exception/ # 异常处理
│ ├── repository/ # 数据访问层
│ └── service/ # 业务逻辑层
├── src/main/resources/
│ └── application.yml # 配置文件
└── pom.xml # Maven 配置
```
## Android App 配置
确保 Android App 的 API 地址指向此服务:
```kotlin
// build.gradle.kts
buildConfigField("String", "API_BASE_URL_DEVICE", "\"http://你的电脑IP:3001/api/\"")
```

Binary file not shown.

88
java-backend/pom.xml Normal file
View File

@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>livestreaming-backend</artifactId>
<version>1.0.0</version>
<name>livestreaming-backend</name>
<description>直播系统后端服务</description>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- MySQL Driver -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- H2 Database (开发测试用,可选) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@ -0,0 +1,17 @@
package com.example.livestreaming;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class LivestreamingApplication {
public static void main(String[] args) {
SpringApplication.run(LivestreamingApplication.class, args);
System.out.println("========================================");
System.out.println(" 直播系统后端服务启动成功!");
System.out.println(" API地址: http://localhost:3001/api");
System.out.println(" H2控制台: http://localhost:3001/h2-console");
System.out.println("========================================");
}
}

View File

@ -0,0 +1,18 @@
package com.example.livestreaming.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.maxAge(3600);
}
}

View File

@ -0,0 +1,20 @@
package com.example.livestreaming.controller;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.util.Map;
@RestController
public class HealthController {
@GetMapping("/health")
public ResponseEntity<Map<String, Object>> health() {
return ResponseEntity.ok(Map.of(
"status", "ok",
"timestamp", LocalDateTime.now().toString()
));
}
}

View File

@ -0,0 +1,64 @@
package com.example.livestreaming.controller;
import com.example.livestreaming.dto.ApiResponse;
import com.example.livestreaming.dto.CreateRoomRequest;
import com.example.livestreaming.dto.RoomResponse;
import com.example.livestreaming.service.RoomService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/rooms")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class RoomController {
private final RoomService roomService;
/**
* 获取所有直播间
* GET /api/rooms
*/
@GetMapping
public ResponseEntity<ApiResponse<List<RoomResponse>>> getAllRooms() {
List<RoomResponse> rooms = roomService.getAllRooms();
return ResponseEntity.ok(ApiResponse.success(rooms));
}
/**
* 获取单个直播间
* GET /api/rooms/{id}
*/
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<RoomResponse>> getRoomById(@PathVariable String id) {
return roomService.getRoomById(id)
.map(room -> ResponseEntity.ok(ApiResponse.success(room)))
.orElse(ResponseEntity.notFound().build());
}
/**
* 创建直播间
* POST /api/rooms
*/
@PostMapping
public ResponseEntity<ApiResponse<RoomResponse>> createRoom(@Valid @RequestBody CreateRoomRequest request) {
RoomResponse room = roomService.createRoom(request);
return ResponseEntity.ok(ApiResponse.success(room, "直播间创建成功"));
}
/**
* 删除直播间
* DELETE /api/rooms/{id}
*/
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> deleteRoom(@PathVariable String id) {
if (roomService.deleteRoom(id)) {
return ResponseEntity.ok(ApiResponse.success(null, "删除成功"));
}
return ResponseEntity.notFound().build();
}
}

View File

@ -0,0 +1,84 @@
package com.example.livestreaming.controller;
import com.example.livestreaming.service.RoomService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.HashMap;
import java.util.Map;
/**
* SRS/NodeMediaServer 回调接口
* 用于接收推流开始/结束的通知
*/
@Slf4j
@RestController
@RequestMapping("/api/srs")
@RequiredArgsConstructor
@CrossOrigin(origins = "*")
public class SrsCallbackController {
private final RoomService roomService;
/**
* 推流开始回调
* POST /api/srs/on_publish
*/
@PostMapping("/on_publish")
public ResponseEntity<Map<String, Object>> onPublish(@RequestBody Map<String, Object> body) {
String stream = (String) body.get("stream");
log.info("推流开始: stream={}", stream);
if (stream != null) {
roomService.setLiveStatus(stream, true);
}
return ResponseEntity.ok(successResponse());
}
/**
* 推流结束回调
* POST /api/srs/on_unpublish
*/
@PostMapping("/on_unpublish")
public ResponseEntity<Map<String, Object>> onUnpublish(@RequestBody Map<String, Object> body) {
String stream = (String) body.get("stream");
log.info("推流结束: stream={}", stream);
if (stream != null) {
roomService.setLiveStatus(stream, false);
}
return ResponseEntity.ok(successResponse());
}
/**
* 播放开始回调
* POST /api/srs/on_play
*/
@PostMapping("/on_play")
public ResponseEntity<Map<String, Object>> onPlay(@RequestBody Map<String, Object> body) {
String stream = (String) body.get("stream");
log.info("观众进入: stream={}", stream);
return ResponseEntity.ok(successResponse());
}
/**
* 播放结束回调
* POST /api/srs/on_stop
*/
@PostMapping("/on_stop")
public ResponseEntity<Map<String, Object>> onStop(@RequestBody Map<String, Object> body) {
String stream = (String) body.get("stream");
log.info("观众离开: stream={}", stream);
return ResponseEntity.ok(successResponse());
}
private Map<String, Object> successResponse() {
Map<String, Object> result = new HashMap<>();
result.put("code", 0);
return result;
}
}

View File

@ -0,0 +1,27 @@
package com.example.livestreaming.dto;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
private boolean success;
private T data;
private String message;
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(true, data, null);
}
public static <T> ApiResponse<T> success(T data, String message) {
return new ApiResponse<>(true, data, message);
}
public static <T> ApiResponse<T> error(String message) {
return new ApiResponse<>(false, null, message);
}
}

View File

@ -0,0 +1,14 @@
package com.example.livestreaming.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class CreateRoomRequest {
@NotBlank(message = "标题不能为空")
private String title;
@NotBlank(message = "主播名称不能为空")
private String streamerName;
}

View File

@ -0,0 +1,47 @@
package com.example.livestreaming.dto;
import com.example.livestreaming.entity.Room;
import lombok.Data;
@Data
public class RoomResponse {
private String id;
private String title;
private String streamerName;
private String streamKey;
private boolean isLive;
private int viewerCount;
private String createdAt;
private String startedAt;
private StreamUrls streamUrls;
@Data
public static class StreamUrls {
private String rtmp;
private String flv;
private String hls;
}
public static RoomResponse fromEntity(Room room, String rtmpHost, int rtmpPort, String httpHost, int httpPort) {
RoomResponse response = new RoomResponse();
response.setId(room.getId());
response.setTitle(room.getTitle());
response.setStreamerName(room.getStreamerName());
response.setStreamKey(room.getStreamKey());
response.setLive(room.isLive());
response.setViewerCount(room.getViewerCount());
response.setCreatedAt(room.getCreatedAt() != null ? room.getCreatedAt().toString() : null);
response.setStartedAt(room.getStartedAt() != null ? room.getStartedAt().toString() : null);
// 构建流地址
StreamUrls urls = new StreamUrls();
String streamKey = room.getStreamKey();
urls.setRtmp(String.format("rtmp://%s:%d/live/%s", rtmpHost, rtmpPort, streamKey));
urls.setFlv(String.format("http://%s:%d/live/%s.flv", httpHost, httpPort, streamKey));
urls.setHls(String.format("http://%s:%d/live/%s/index.m3u8", httpHost, httpPort, streamKey));
response.setStreamUrls(urls);
return response;
}
}

View File

@ -0,0 +1,53 @@
package com.example.livestreaming.entity;
import jakarta.persistence.*;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.AllArgsConstructor;
import java.time.LocalDateTime;
import java.util.UUID;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Entity
@Table(name = "rooms")
public class Room {
@Id
private String id;
@Column(nullable = false)
private String title;
@Column(name = "streamer_name", nullable = false)
private String streamerName;
@Column(name = "stream_key", unique = true, nullable = false)
private String streamKey;
@Column(name = "is_live")
private boolean isLive = false;
@Column(name = "viewer_count")
private int viewerCount = 0;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "started_at")
private LocalDateTime startedAt;
@PrePersist
public void prePersist() {
if (id == null) {
id = UUID.randomUUID().toString();
}
if (streamKey == null) {
streamKey = id;
}
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}

View File

@ -0,0 +1,32 @@
package com.example.livestreaming.exception;
import com.example.livestreaming.dto.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidationException(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
return ResponseEntity.badRequest().body(ApiResponse.error(message));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleException(Exception ex) {
log.error("服务器错误", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error("服务器内部错误"));
}
}

View File

@ -0,0 +1,13 @@
package com.example.livestreaming.repository;
import com.example.livestreaming.entity.Room;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface RoomRepository extends JpaRepository<Room, String> {
Optional<Room> findByStreamKey(String streamKey);
}

View File

@ -0,0 +1,89 @@
package com.example.livestreaming.service;
import com.example.livestreaming.dto.CreateRoomRequest;
import com.example.livestreaming.dto.RoomResponse;
import com.example.livestreaming.entity.Room;
import com.example.livestreaming.repository.RoomRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class RoomService {
private final RoomRepository roomRepository;
@Value("${livestream.rtmp.host:localhost}")
private String rtmpHost;
@Value("${livestream.rtmp.port:1935}")
private int rtmpPort;
@Value("${livestream.http.host:localhost}")
private String httpHost;
@Value("${livestream.http.port:8080}")
private int httpPort;
/**
* 获取所有直播间
*/
public List<RoomResponse> getAllRooms() {
return roomRepository.findAll().stream()
.map(room -> RoomResponse.fromEntity(room, rtmpHost, rtmpPort, httpHost, httpPort))
.collect(Collectors.toList());
}
/**
* 根据ID获取直播间
*/
public Optional<RoomResponse> getRoomById(String id) {
return roomRepository.findById(id)
.map(room -> RoomResponse.fromEntity(room, rtmpHost, rtmpPort, httpHost, httpPort));
}
/**
* 创建直播间
*/
@Transactional
public RoomResponse createRoom(CreateRoomRequest request) {
Room room = new Room();
room.setTitle(request.getTitle());
room.setStreamerName(request.getStreamerName());
Room saved = roomRepository.save(room);
return RoomResponse.fromEntity(saved, rtmpHost, rtmpPort, httpHost, httpPort);
}
/**
* 更新直播状态供SRS回调使用
*/
@Transactional
public Optional<Room> setLiveStatus(String streamKey, boolean isLive) {
return roomRepository.findByStreamKey(streamKey)
.map(room -> {
room.setLive(isLive);
room.setStartedAt(isLive ? LocalDateTime.now() : null);
return roomRepository.save(room);
});
}
/**
* 删除直播间
*/
@Transactional
public boolean deleteRoom(String id) {
if (roomRepository.existsById(id)) {
roomRepository.deleteById(id);
return true;
}
return false;
}
}

View File

@ -0,0 +1,50 @@
server:
port: 3001
spring:
application:
name: livestreaming-backend
# 数据库配置 - 默认使用H2内存数据库开发测试
# 如需使用MySQL请取消下方注释并配置
datasource:
# H2 内存数据库(开发测试用,无需安装)
url: jdbc:h2:file:./data/livestream;AUTO_SERVER=TRUE
driver-class-name: org.h2.Driver
username: sa
password:
# MySQL 配置(生产环境使用)
# url: jdbc:mysql://localhost:3306/livestream?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
# driver-class-name: com.mysql.cj.jdbc.Driver
# username: root
# password: your_password
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
# H2 控制台(开发时可访问 http://localhost:3001/h2-console
h2:
console:
enabled: true
path: /h2-console
# 直播服务配置
livestream:
rtmp:
host: localhost
port: 1935
http:
host: localhost
port: 8080
# 日志配置
logging:
level:
com.example.livestreaming: DEBUG
org.hibernate.SQL: DEBUG

View File

@ -0,0 +1,50 @@
server:
port: 3001
spring:
application:
name: livestreaming-backend
# 数据库配置 - 默认使用H2内存数据库开发测试
# 如需使用MySQL请取消下方注释并配置
datasource:
# H2 内存数据库(开发测试用,无需安装)
url: jdbc:h2:file:./data/livestream;AUTO_SERVER=TRUE
driver-class-name: org.h2.Driver
username: sa
password:
# MySQL 配置(生产环境使用)
# url: jdbc:mysql://localhost:3306/livestream?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
# driver-class-name: com.mysql.cj.jdbc.Driver
# username: root
# password: your_password
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
# H2 控制台(开发时可访问 http://localhost:3001/h2-console
h2:
console:
enabled: true
path: /h2-console
# 直播服务配置
livestream:
rtmp:
host: localhost
port: 1935
http:
host: localhost
port: 8080
# 日志配置
logging:
level:
com.example.livestreaming: DEBUG
org.hibernate.SQL: DEBUG

View File

@ -0,0 +1,92 @@
listen 1935;
max_connections 1000;
daemon off;
srs_log_tank console;
# 模拟器专用优化配置
# 减少内存使用
mr_enabled off;
# 启用快速启动
fast_cache 5;
# 优化TCP配置
tcp_nodelay on;
# 减少线程数
work_threads 1;
http_server {
enabled on;
listen 8080;
dir ./objs/nginx/html;
# 启用跨域支持
crossdomain on;
}
http_api {
enabled on;
listen 1985;
# 启用跨域支持
crossdomain on;
}
stats {
network 0;
}
vhost __defaultVhost__ {
# RTMP 配置 - 模拟器优化
rtmp {
enabled on;
# 减少缓冲区大小,降低延迟
chunk_size 2048;
}
# HLS 配置 - 极低延迟模式
hls {
enabled on;
hls_path ./objs/nginx/html;
# 极短分片时长,适合模拟器测试
hls_fragment 1;
hls_window 3;
# 启用低延迟模式
hls_dispose 10;
}
# HTTP-FLV 配置 - 模拟器优化
http_remux {
enabled on;
mount [vhost]/[app]/[stream].flv;
# 启用快速启动
fast_cache 5;
}
# 转码配置(关闭以节省资源)
transcode {
enabled off;
}
# 播放配置 - 模拟器优化
play {
# 关闭GOP缓存
gop_cache off;
# 启用时间校正
time_jitter full;
# 减少队列长度
queue_length 5;
# 减少缓冲区
send_min_interval 10;
}
# 发布配置 - 模拟器优化
publish {
# 减少首帧等待时间
firstpkt_timeout 10000;
# 减少正常包超时
normal_timeout 3000;
}
# 模拟器专用配置
# 减少内存占用
chunk_size 2048;
# 快速丢弃过期数据
queue_length 5;
}

89
模拟器优化指南.md Normal file
View File

@ -0,0 +1,89 @@
# 模拟器环境优化指南
## 🚀 已完成的优化
### 1. 应用层优化
- ✅ 异步加载资源,避免主线程阻塞
- ✅ 减少网络请求频率15秒轮询
- ✅ 优化启动流程,立即显示内容
- ✅ 添加ANR防护机制
### 2. 网络层优化
- ✅ 模拟器专用超时配置3秒连接8秒读取
- ✅ 启用连接重试机制
- ✅ 优化HTTP客户端配置
### 3. 流媒体服务器优化
- ✅ 使用模拟器专用SRS配置
- ✅ HLS分片时长降至1秒极低延迟
- ✅ 减少缓冲区大小
- ✅ 关闭GOP缓存
## 📱 模拟器设置建议
### Android Studio模拟器优化
1. **硬件配置**
- RAM: 至少4GB
- VM heap: 512MB
- Graphics: Hardware - GLES 2.0
2. **高级设置**
- 启用 "Use Host GPU"
- 启用 "Snapshot" 快速启动
- 关闭不必要的传感器
### 推流设置OBS
```
服务器: rtmp://localhost:1935/live
密钥: 从应用获取的streamKey
```
**OBS优化设置**
- 关键帧间隔: 1秒
- 码率: 1000-2000 kbps模拟器环境建议较低
- 分辨率: 720p或更低
- 编码器: x264软编码兼容性更好
## ⚡ 延迟优化效果
| 配置项 | 优化前 | 优化后 |
|--------|--------|--------|
| HLS分片 | 10秒 | 1秒 |
| 缓冲时长 | 30秒 | 3秒 |
| 预期延迟 | 15-20秒 | 3-8秒 |
## 🔧 故障排除
### 如果仍然出现ANR
1. 重启模拟器
2. 清除应用数据
3. 检查电脑性能CPU/内存使用率)
### 如果延迟仍然很高
1. 确认使用HTTP-FLV播放延迟更低
2. 检查网络连接
3. 尝试降低推流码率
### 切换回生产配置
如需在真机上测试修改docker-compose.yml
```yaml
volumes:
- ./docker/srs/srs.conf:/usr/local/srs/conf/srs.conf
```
## 📊 性能监控
可以通过以下方式监控性能:
- SRS统计页面: http://localhost:8080/console/
- Android Studio Profiler
- 应用内的网络请求日志
## 💡 最佳实践
1. **开发阶段**: 使用模拟器配置,快速测试
2. **测试阶段**: 切换到真机,验证实际性能
3. **生产环境**: 使用标准配置,确保稳定性
---
*注意:模拟器环境的延迟主要来自虚拟化开销,真机环境下延迟会显著降低。*