diff --git a/android-app/app/src/main/java/com/example/livestreaming/ChatAdapter.java b/android-app/app/src/main/java/com/example/livestreaming/ChatAdapter.java index 32f59af4..0b8c9179 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ChatAdapter.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ChatAdapter.java @@ -10,67 +10,69 @@ import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; -public class ChatAdapter extends ListAdapter { +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; + +public class ChatAdapter extends ListAdapter { + + private static final SimpleDateFormat TIME_FORMAT = new SimpleDateFormat("HH:mm", Locale.getDefault()); public ChatAdapter() { - super(DIFF); + super(DIFF_CALLBACK); } - private static final DiffUtil.ItemCallback DIFF = new DiffUtil.ItemCallback() { + @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 DIFF_CALLBACK = new DiffUtil.ItemCallback() { @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); - } - } -} +} \ No newline at end of file diff --git a/android-app/app/src/main/java/com/example/livestreaming/ChatMessage.java b/android-app/app/src/main/java/com/example/livestreaming/ChatMessage.java index 2674a91f..5278445a 100644 --- a/android-app/app/src/main/java/com/example/livestreaming/ChatMessage.java +++ b/android-app/app/src/main/java/com/example/livestreaming/ChatMessage.java @@ -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; + } +} \ No newline at end of file diff --git a/android-app/app/src/main/res/drawable/ic_arrow_back_24.xml b/android-app/app/src/main/res/drawable/ic_arrow_back_24.xml index c76877da..e2e8516f 100644 --- a/android-app/app/src/main/res/drawable/ic_arrow_back_24.xml +++ b/android-app/app/src/main/res/drawable/ic_arrow_back_24.xml @@ -1,12 +1,10 @@ - - + android:viewportHeight="24" + android:tint="?attr/colorControlNormal"> - + 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"/> diff --git a/android-app/app/src/main/res/layout/activity_room_detail_new.xml b/android-app/app/src/main/res/layout/activity_room_detail_new.xml index 49290ac8..47f3634f 100644 --- a/android-app/app/src/main/res/layout/activity_room_detail_new.xml +++ b/android-app/app/src/main/res/layout/activity_room_detail_new.xml @@ -2,8 +2,280 @@ + android:layout_height="match_parent" + android:background="#000000" + android:fitsSystemWindows="true"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/android-app/app/src/main/res/layout/dialog_stream_info.xml b/android-app/app/src/main/res/layout/dialog_stream_info.xml index cdd2386b..4d6d56c5 100644 --- a/android-app/app/src/main/res/layout/dialog_stream_info.xml +++ b/android-app/app/src/main/res/layout/dialog_stream_info.xml @@ -1,67 +1,152 @@ - + - - + android:textSize="14sp" + android:layout_marginBottom="16dp" /> - + + android:layout_marginBottom="12dp" + app:cardCornerRadius="8dp" + app:cardElevation="2dp" + app:strokeColor="#E0E0E0" + app:strokeWidth="1dp"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + android:textSize="12sp" + android:gravity="center" + android:background="@drawable/bg_purple_20" + android:padding="8dp" + android:drawablePadding="4dp" /> - - - + \ No newline at end of file diff --git a/android-app/app/src/main/res/layout/item_chat_message.xml b/android-app/app/src/main/res/layout/item_chat_message.xml index 2f072f83..712fd214 100644 --- a/android-app/app/src/main/res/layout/item_chat_message.xml +++ b/android-app/app/src/main/res/layout/item_chat_message.xml @@ -1,8 +1,36 @@ - + android:orientation="horizontal" + android:padding="8dp"> + + + + + + + + \ No newline at end of file diff --git a/java-backend/README.md b/java-backend/README.md new file mode 100644 index 00000000..bfee9255 --- /dev/null +++ b/java-backend/README.md @@ -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/\"") +``` diff --git a/java-backend/data/livestream.mv.db b/java-backend/data/livestream.mv.db new file mode 100644 index 00000000..1473e2fc Binary files /dev/null and b/java-backend/data/livestream.mv.db differ diff --git a/java-backend/pom.xml b/java-backend/pom.xml new file mode 100644 index 00000000..c88f930c --- /dev/null +++ b/java-backend/pom.xml @@ -0,0 +1,88 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + com.example + livestreaming-backend + 1.0.0 + livestreaming-backend + 直播系统后端服务 + + + 17 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + com.mysql + mysql-connector-j + runtime + + + + + com.h2database + h2 + runtime + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/java-backend/src/main/java/com/example/livestreaming/LivestreamingApplication.java b/java-backend/src/main/java/com/example/livestreaming/LivestreamingApplication.java new file mode 100644 index 00000000..a43a87e4 --- /dev/null +++ b/java-backend/src/main/java/com/example/livestreaming/LivestreamingApplication.java @@ -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("========================================"); + } +} diff --git a/java-backend/src/main/java/com/example/livestreaming/config/WebConfig.java b/java-backend/src/main/java/com/example/livestreaming/config/WebConfig.java new file mode 100644 index 00000000..f61c87e5 --- /dev/null +++ b/java-backend/src/main/java/com/example/livestreaming/config/WebConfig.java @@ -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); + } +} diff --git a/java-backend/src/main/java/com/example/livestreaming/controller/HealthController.java b/java-backend/src/main/java/com/example/livestreaming/controller/HealthController.java new file mode 100644 index 00000000..af0d0c58 --- /dev/null +++ b/java-backend/src/main/java/com/example/livestreaming/controller/HealthController.java @@ -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> health() { + return ResponseEntity.ok(Map.of( + "status", "ok", + "timestamp", LocalDateTime.now().toString() + )); + } +} diff --git a/java-backend/src/main/java/com/example/livestreaming/controller/RoomController.java b/java-backend/src/main/java/com/example/livestreaming/controller/RoomController.java new file mode 100644 index 00000000..b0f9d4ed --- /dev/null +++ b/java-backend/src/main/java/com/example/livestreaming/controller/RoomController.java @@ -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>> getAllRooms() { + List rooms = roomService.getAllRooms(); + return ResponseEntity.ok(ApiResponse.success(rooms)); + } + + /** + * 获取单个直播间 + * GET /api/rooms/{id} + */ + @GetMapping("/{id}") + public ResponseEntity> 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> createRoom(@Valid @RequestBody CreateRoomRequest request) { + RoomResponse room = roomService.createRoom(request); + return ResponseEntity.ok(ApiResponse.success(room, "直播间创建成功")); + } + + /** + * 删除直播间 + * DELETE /api/rooms/{id} + */ + @DeleteMapping("/{id}") + public ResponseEntity> deleteRoom(@PathVariable String id) { + if (roomService.deleteRoom(id)) { + return ResponseEntity.ok(ApiResponse.success(null, "删除成功")); + } + return ResponseEntity.notFound().build(); + } +} diff --git a/java-backend/src/main/java/com/example/livestreaming/controller/SrsCallbackController.java b/java-backend/src/main/java/com/example/livestreaming/controller/SrsCallbackController.java new file mode 100644 index 00000000..0ad76528 --- /dev/null +++ b/java-backend/src/main/java/com/example/livestreaming/controller/SrsCallbackController.java @@ -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> onPublish(@RequestBody Map 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> onUnpublish(@RequestBody Map 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> onPlay(@RequestBody Map 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> onStop(@RequestBody Map body) { + String stream = (String) body.get("stream"); + log.info("观众离开: stream={}", stream); + return ResponseEntity.ok(successResponse()); + } + + private Map successResponse() { + Map result = new HashMap<>(); + result.put("code", 0); + return result; + } +} diff --git a/java-backend/src/main/java/com/example/livestreaming/dto/ApiResponse.java b/java-backend/src/main/java/com/example/livestreaming/dto/ApiResponse.java new file mode 100644 index 00000000..380968de --- /dev/null +++ b/java-backend/src/main/java/com/example/livestreaming/dto/ApiResponse.java @@ -0,0 +1,27 @@ +package com.example.livestreaming.dto; + +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ApiResponse { + + private boolean success; + private T data; + private String message; + + public static ApiResponse success(T data) { + return new ApiResponse<>(true, data, null); + } + + public static ApiResponse success(T data, String message) { + return new ApiResponse<>(true, data, message); + } + + public static ApiResponse error(String message) { + return new ApiResponse<>(false, null, message); + } +} diff --git a/java-backend/src/main/java/com/example/livestreaming/dto/CreateRoomRequest.java b/java-backend/src/main/java/com/example/livestreaming/dto/CreateRoomRequest.java new file mode 100644 index 00000000..a4963b80 --- /dev/null +++ b/java-backend/src/main/java/com/example/livestreaming/dto/CreateRoomRequest.java @@ -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; +} diff --git a/java-backend/src/main/java/com/example/livestreaming/dto/RoomResponse.java b/java-backend/src/main/java/com/example/livestreaming/dto/RoomResponse.java new file mode 100644 index 00000000..0aff8ad3 --- /dev/null +++ b/java-backend/src/main/java/com/example/livestreaming/dto/RoomResponse.java @@ -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; + } +} diff --git a/java-backend/src/main/java/com/example/livestreaming/entity/Room.java b/java-backend/src/main/java/com/example/livestreaming/entity/Room.java new file mode 100644 index 00000000..9a328f00 --- /dev/null +++ b/java-backend/src/main/java/com/example/livestreaming/entity/Room.java @@ -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(); + } + } +} diff --git a/java-backend/src/main/java/com/example/livestreaming/exception/GlobalExceptionHandler.java b/java-backend/src/main/java/com/example/livestreaming/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..c5579c19 --- /dev/null +++ b/java-backend/src/main/java/com/example/livestreaming/exception/GlobalExceptionHandler.java @@ -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> 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> handleException(Exception ex) { + log.error("服务器错误", ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error("服务器内部错误")); + } +} diff --git a/java-backend/src/main/java/com/example/livestreaming/repository/RoomRepository.java b/java-backend/src/main/java/com/example/livestreaming/repository/RoomRepository.java new file mode 100644 index 00000000..50791f55 --- /dev/null +++ b/java-backend/src/main/java/com/example/livestreaming/repository/RoomRepository.java @@ -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 { + + Optional findByStreamKey(String streamKey); +} diff --git a/java-backend/src/main/java/com/example/livestreaming/service/RoomService.java b/java-backend/src/main/java/com/example/livestreaming/service/RoomService.java new file mode 100644 index 00000000..5a371881 --- /dev/null +++ b/java-backend/src/main/java/com/example/livestreaming/service/RoomService.java @@ -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 getAllRooms() { + return roomRepository.findAll().stream() + .map(room -> RoomResponse.fromEntity(room, rtmpHost, rtmpPort, httpHost, httpPort)) + .collect(Collectors.toList()); + } + + /** + * 根据ID获取直播间 + */ + public Optional 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 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; + } +} diff --git a/java-backend/src/main/resources/application.yml b/java-backend/src/main/resources/application.yml new file mode 100644 index 00000000..97b87083 --- /dev/null +++ b/java-backend/src/main/resources/application.yml @@ -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 diff --git a/java-backend/target/classes/application.yml b/java-backend/target/classes/application.yml new file mode 100644 index 00000000..97b87083 --- /dev/null +++ b/java-backend/target/classes/application.yml @@ -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 diff --git a/java-backend/target/classes/com/example/livestreaming/LivestreamingApplication.class b/java-backend/target/classes/com/example/livestreaming/LivestreamingApplication.class new file mode 100644 index 00000000..5e0c686d Binary files /dev/null and b/java-backend/target/classes/com/example/livestreaming/LivestreamingApplication.class differ diff --git a/java-backend/target/classes/com/example/livestreaming/config/WebConfig.class b/java-backend/target/classes/com/example/livestreaming/config/WebConfig.class new file mode 100644 index 00000000..8fa0ee92 Binary files /dev/null and b/java-backend/target/classes/com/example/livestreaming/config/WebConfig.class differ diff --git a/java-backend/target/classes/com/example/livestreaming/controller/HealthController.class b/java-backend/target/classes/com/example/livestreaming/controller/HealthController.class new file mode 100644 index 00000000..d3d98e7e Binary files /dev/null and b/java-backend/target/classes/com/example/livestreaming/controller/HealthController.class differ diff --git a/java-backend/target/classes/com/example/livestreaming/controller/RoomController.class b/java-backend/target/classes/com/example/livestreaming/controller/RoomController.class new file mode 100644 index 00000000..14d1f047 Binary files /dev/null and b/java-backend/target/classes/com/example/livestreaming/controller/RoomController.class differ diff --git a/java-backend/target/classes/com/example/livestreaming/controller/SrsCallbackController.class b/java-backend/target/classes/com/example/livestreaming/controller/SrsCallbackController.class new file mode 100644 index 00000000..3563227a Binary files /dev/null and b/java-backend/target/classes/com/example/livestreaming/controller/SrsCallbackController.class differ diff --git a/java-backend/target/classes/com/example/livestreaming/dto/ApiResponse.class b/java-backend/target/classes/com/example/livestreaming/dto/ApiResponse.class new file mode 100644 index 00000000..c3c4529a Binary files /dev/null and b/java-backend/target/classes/com/example/livestreaming/dto/ApiResponse.class differ diff --git a/java-backend/target/classes/com/example/livestreaming/dto/CreateRoomRequest.class b/java-backend/target/classes/com/example/livestreaming/dto/CreateRoomRequest.class new file mode 100644 index 00000000..ab53885f Binary files /dev/null and b/java-backend/target/classes/com/example/livestreaming/dto/CreateRoomRequest.class differ diff --git a/java-backend/target/classes/com/example/livestreaming/dto/RoomResponse$StreamUrls.class b/java-backend/target/classes/com/example/livestreaming/dto/RoomResponse$StreamUrls.class new file mode 100644 index 00000000..7475ae1e Binary files /dev/null and b/java-backend/target/classes/com/example/livestreaming/dto/RoomResponse$StreamUrls.class differ diff --git a/java-backend/target/classes/com/example/livestreaming/dto/RoomResponse.class b/java-backend/target/classes/com/example/livestreaming/dto/RoomResponse.class new file mode 100644 index 00000000..ec292076 Binary files /dev/null and b/java-backend/target/classes/com/example/livestreaming/dto/RoomResponse.class differ diff --git a/java-backend/target/classes/com/example/livestreaming/entity/Room.class b/java-backend/target/classes/com/example/livestreaming/entity/Room.class new file mode 100644 index 00000000..0c307cac Binary files /dev/null and b/java-backend/target/classes/com/example/livestreaming/entity/Room.class differ diff --git a/java-backend/target/classes/com/example/livestreaming/exception/GlobalExceptionHandler.class b/java-backend/target/classes/com/example/livestreaming/exception/GlobalExceptionHandler.class new file mode 100644 index 00000000..360ed730 Binary files /dev/null and b/java-backend/target/classes/com/example/livestreaming/exception/GlobalExceptionHandler.class differ diff --git a/java-backend/target/classes/com/example/livestreaming/repository/RoomRepository.class b/java-backend/target/classes/com/example/livestreaming/repository/RoomRepository.class new file mode 100644 index 00000000..e09b4f70 Binary files /dev/null and b/java-backend/target/classes/com/example/livestreaming/repository/RoomRepository.class differ diff --git a/java-backend/target/classes/com/example/livestreaming/service/RoomService.class b/java-backend/target/classes/com/example/livestreaming/service/RoomService.class new file mode 100644 index 00000000..6b910a10 Binary files /dev/null and b/java-backend/target/classes/com/example/livestreaming/service/RoomService.class differ diff --git a/live-streaming/docker/srs/srs-emulator.conf b/live-streaming/docker/srs/srs-emulator.conf new file mode 100644 index 00000000..3f3ae90a --- /dev/null +++ b/live-streaming/docker/srs/srs-emulator.conf @@ -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; +} \ No newline at end of file diff --git a/模拟器优化指南.md b/模拟器优化指南.md new file mode 100644 index 00000000..189275d8 --- /dev/null +++ b/模拟器优化指南.md @@ -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. **生产环境**: 使用标准配置,确保稳定性 + +--- + +*注意:模拟器环境的延迟主要来自虚拟化开销,真机环境下延迟会显著降低。* \ No newline at end of file