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:
commit
e234adf929
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
126
java-backend/README.md
Normal 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/\"")
|
||||
```
|
||||
BIN
java-backend/data/livestream.mv.db
Normal file
BIN
java-backend/data/livestream.mv.db
Normal file
Binary file not shown.
88
java-backend/pom.xml
Normal file
88
java-backend/pom.xml
Normal 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>
|
||||
|
|
@ -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("========================================");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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("服务器内部错误"));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
50
java-backend/src/main/resources/application.yml
Normal file
50
java-backend/src/main/resources/application.yml
Normal 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
|
||||
50
java-backend/target/classes/application.yml
Normal file
50
java-backend/target/classes/application.yml
Normal 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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
92
live-streaming/docker/srs/srs-emulator.conf
Normal file
92
live-streaming/docker/srs/srs-emulator.conf
Normal 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
89
模拟器优化指南.md
Normal 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. **生产环境**: 使用标准配置,确保稳定性
|
||||
|
||||
---
|
||||
|
||||
*注意:模拟器环境的延迟主要来自虚拟化开销,真机环境下延迟会显著降低。*
|
||||
Loading…
Reference in New Issue
Block a user