fix: 完善直播结束通知与 WebSocket 连接

This commit is contained in:
2026-06-25 10:50:35 +08:00
parent 2281c98b1b
commit 80d4f692e0
12 changed files with 130 additions and 14 deletions

View File

@@ -18,7 +18,7 @@ const (
) )
type Message struct { type Message struct {
Type string `json:"type"` // "chat", "system", "danmaku" Type string `json:"type"` // "chat", "system", "danmaku", "stream_end"
Username string `json:"username"` Username string `json:"username"`
Content string `json:"content"` Content string `json:"content"`
RoomID string `json:"room_id"` RoomID string `json:"room_id"`
@@ -190,7 +190,15 @@ func (r *roomHub) handleBroadcast(message Message) {
delete(r.clients, client) delete(r.clients, client)
} }
} }
shouldDeleteIfIdle := message.Type == "stream_end"
if shouldDeleteIfIdle {
r.history = nil
}
r.mutex.Unlock() r.mutex.Unlock()
if shouldDeleteIfIdle {
r.manager.deleteRoomIfIdle(r)
}
} }
func (r *roomHub) handleClearHistory() { func (r *roomHub) handleClearHistory() {
@@ -223,6 +231,15 @@ func (h *Hub) BroadcastToRoom(msg Message) {
h.getOrCreateRoom(msg.RoomID).broadcast <- msg h.getOrCreateRoom(msg.RoomID).broadcast <- msg
} }
func (h *Hub) NotifyStreamEnded(roomID string) {
h.BroadcastToRoom(Message{
Type: "stream_end",
Username: "System",
Content: "The host has ended the live stream.",
RoomID: roomID,
})
}
func (c *Client) ReadPump() { func (c *Client) ReadPump() {
defer func() { defer func() {
c.Hub.UnregisterClient(c) c.Hub.UnregisterClient(c)

View File

@@ -172,7 +172,7 @@ func NewRTMPServer(rtmpPort string) *RTMPServer {
monitor.Warnf("Failed to mark room inactive room_id=%s: %v", roomID, err) monitor.Warnf("Failed to mark room inactive room_id=%s: %v", roomID, err)
} }
} }
chat.MainHub.ClearRoomHistory(roomID) chat.MainHub.NotifyStreamEnded(roomID)
monitor.Infof("Publishing ended for room_id=%s", roomID) monitor.Infof("Publishing ended for room_id=%s", roomID)
} else { } else {
monitor.Infof("Variant publishing ended for room_id=%s path=%s", roomID, channelPath) monitor.Infof("Variant publishing ended for room_id=%s path=%s", roomID, channelPath)

View File

@@ -50,6 +50,8 @@
"availableNow": "Available now", "availableNow": "Available now",
"waitingForTranscoding": "Waiting for backend transcoding output", "waitingForTranscoding": "Waiting for backend transcoding output",
"sendMessage": "Send a message...", "sendMessage": "Send a message...",
"liveStreamEnded": "The host has ended the live stream.",
"liveStreamEndedShort": "Live stream ended",
"liveChat": "Live Chat", "liveChat": "Live Chat",
"refresh": "Refresh", "refresh": "Refresh",
"volume": "Volume", "volume": "Volume",

View File

@@ -50,6 +50,8 @@
"availableNow": "利用可能", "availableNow": "利用可能",
"waitingForTranscoding": "バックエンドのトランスコード出力を待機中", "waitingForTranscoding": "バックエンドのトランスコード出力を待機中",
"sendMessage": "メッセージを送信...", "sendMessage": "メッセージを送信...",
"liveStreamEnded": "配信者が退出したため、ライブ配信は終了しました。",
"liveStreamEndedShort": "ライブ配信は終了しました",
"liveChat": "ライブチャット", "liveChat": "ライブチャット",
"refresh": "更新", "refresh": "更新",
"volume": "音量", "volume": "音量",

View File

@@ -401,6 +401,18 @@ abstract class AppLocalizations {
/// **'Send a message...'** /// **'Send a message...'**
String get sendMessage; String get sendMessage;
/// No description provided for @liveStreamEnded.
///
/// In en, this message translates to:
/// **'The host has ended the live stream.'**
String get liveStreamEnded;
/// No description provided for @liveStreamEndedShort.
///
/// In en, this message translates to:
/// **'Live stream ended'**
String get liveStreamEndedShort;
/// No description provided for @liveChat. /// No description provided for @liveChat.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

View File

@@ -159,6 +159,12 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get sendMessage => 'Send a message...'; String get sendMessage => 'Send a message...';
@override
String get liveStreamEnded => 'The host has ended the live stream.';
@override
String get liveStreamEndedShort => 'Live stream ended';
@override @override
String get liveChat => 'Live Chat'; String get liveChat => 'Live Chat';

View File

@@ -159,6 +159,12 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get sendMessage => 'メッセージを送信...'; String get sendMessage => 'メッセージを送信...';
@override
String get liveStreamEnded => '配信者が退出したため、ライブ配信は終了しました。';
@override
String get liveStreamEndedShort => 'ライブ配信は終了しました';
@override @override
String get liveChat => 'ライブチャット'; String get liveChat => 'ライブチャット';

View File

@@ -158,6 +158,12 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get sendMessage => '发送消息...'; String get sendMessage => '发送消息...';
@override
String get liveStreamEnded => '主播已退出,直播已结束。';
@override
String get liveStreamEndedShort => '直播已结束';
@override @override
String get liveChat => '实时聊天'; String get liveChat => '实时聊天';
@@ -364,6 +370,12 @@ class AppLocalizationsZhHant extends AppLocalizationsZh {
@override @override
String get sendMessage => '發送訊息...'; String get sendMessage => '發送訊息...';
@override
String get liveStreamEnded => '主播已退出,直播已結束。';
@override
String get liveStreamEndedShort => '直播已結束';
@override @override
String get liveChat => '即時聊天'; String get liveChat => '即時聊天';

View File

@@ -50,6 +50,8 @@
"availableNow": "当前可用", "availableNow": "当前可用",
"waitingForTranscoding": "正在等待后端转码输出", "waitingForTranscoding": "正在等待后端转码输出",
"sendMessage": "发送消息...", "sendMessage": "发送消息...",
"liveStreamEnded": "主播已退出,直播已结束。",
"liveStreamEndedShort": "直播已结束",
"liveChat": "实时聊天", "liveChat": "实时聊天",
"refresh": "刷新", "refresh": "刷新",
"volume": "音量", "volume": "音量",

View File

@@ -50,6 +50,8 @@
"availableNow": "目前可用", "availableNow": "目前可用",
"waitingForTranscoding": "正在等待後端轉碼輸出", "waitingForTranscoding": "正在等待後端轉碼輸出",
"sendMessage": "發送訊息...", "sendMessage": "發送訊息...",
"liveStreamEnded": "主播已退出,直播已結束。",
"liveStreamEndedShort": "直播已結束",
"liveChat": "即時聊天", "liveChat": "即時聊天",
"refresh": "重新整理", "refresh": "重新整理",
"volume": "音量", "volume": "音量",

View File

@@ -43,6 +43,7 @@ class _PlayerPageState extends State<PlayerPage> {
bool _isRefreshing = false; bool _isRefreshing = false;
bool _isFullscreen = false; bool _isFullscreen = false;
bool _controlsVisible = true; bool _controlsVisible = true;
bool _streamEnded = false;
double _volume = kIsWeb ? 0.0 : 1.0; double _volume = kIsWeb ? 0.0 : 1.0;
int _playerVersion = 0; int _playerVersion = 0;
String _selectedResolution = 'Source'; String _selectedResolution = 'Source';
@@ -146,6 +147,10 @@ class _PlayerPageState extends State<PlayerPage> {
_chatService.messages.listen((msg) { _chatService.messages.listen((msg) {
if (mounted) { if (mounted) {
if (msg.type == "stream_end") {
_handleStreamEnded(msg.content);
return;
}
setState(() { setState(() {
_messages.insert(0, msg); _messages.insert(0, msg);
if (!msg.isHistory && (msg.type == "chat" || msg.type == "danmaku")) { if (!msg.isHistory && (msg.type == "chat" || msg.type == "danmaku")) {
@@ -158,6 +163,44 @@ class _PlayerPageState extends State<PlayerPage> {
}); });
} }
void _handleStreamEnded(String message) {
if (_streamEnded) {
return;
}
final l10n = AppLocalizations.of(context)!;
final streamEndedMessage = l10n.liveStreamEnded;
setState(() {
_streamEnded = true;
_isRefreshing = false;
_danmakus.clear();
_messages.insert(
0,
ChatMessage(
type: "system",
username: "System",
content: streamEndedMessage,
roomId: widget.roomId,
),
);
if (!kIsWeb) {
_isError = true;
_errorMessage = streamEndedMessage;
}
});
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(streamEndedMessage)));
Future<void>.delayed(const Duration(seconds: 2), () {
if (mounted && Navigator.canPop(context)) {
Navigator.pop(context);
}
});
}
void _addDanmaku(String text) { void _addDanmaku(String text) {
final key = UniqueKey(); final key = UniqueKey();
final lane = DateTime.now().millisecondsSinceEpoch % 8; final lane = DateTime.now().millisecondsSinceEpoch % 8;
@@ -180,7 +223,7 @@ class _PlayerPageState extends State<PlayerPage> {
} }
void _sendMsg() { void _sendMsg() {
if (_msgController.text.isNotEmpty) { if (!_streamEnded && _msgController.text.isNotEmpty) {
final auth = context.read<AuthProvider>(); final auth = context.read<AuthProvider>();
_chatService.sendMessage( _chatService.sendMessage(
_msgController.text, _msgController.text,
@@ -195,6 +238,9 @@ class _PlayerPageState extends State<PlayerPage> {
if (_isRefreshing) { if (_isRefreshing) {
return; return;
} }
if (_streamEnded) {
return;
}
await _loadPlaybackOptions(); await _loadPlaybackOptions();
@@ -625,7 +671,7 @@ class _PlayerPageState extends State<PlayerPage> {
_buildControlButton( _buildControlButton(
icon: Icons.refresh, icon: Icons.refresh,
label: l10n.refresh, label: l10n.refresh,
onPressed: _refreshPlayer, onPressed: _streamEnded ? null : _refreshPlayer,
), ),
_buildControlButton( _buildControlButton(
icon: _volume == 0 icon: _volume == 0
@@ -645,7 +691,9 @@ class _PlayerPageState extends State<PlayerPage> {
icon: _isFullscreen icon: _isFullscreen
? Icons.fullscreen_exit ? Icons.fullscreen_exit
: Icons.fullscreen, : Icons.fullscreen,
label: _isFullscreen ? l10n.exitFullscreen : l10n.fullscreen, label: _isFullscreen
? l10n.exitFullscreen
: l10n.fullscreen,
onPressed: _toggleFullscreen, onPressed: _toggleFullscreen,
), ),
_buildControlButton( _buildControlButton(
@@ -665,13 +713,15 @@ class _PlayerPageState extends State<PlayerPage> {
Widget _buildControlButton({ Widget _buildControlButton({
required IconData icon, required IconData icon,
required String label, required String label,
required FutureOr<void> Function() onPressed, required FutureOr<void> Function()? onPressed,
}) { }) {
return FilledButton.tonalIcon( return FilledButton.tonalIcon(
onPressed: () async { onPressed: onPressed == null
_showControls(); ? null
await onPressed(); : () async {
}, _showControls();
await onPressed();
},
icon: Icon(icon, size: 18), icon: Icon(icon, size: 18),
label: Text(label), label: Text(label),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
@@ -693,7 +743,10 @@ class _PlayerPageState extends State<PlayerPage> {
children: [ children: [
const Icon(Icons.chat_bubble_outline, size: 16), const Icon(Icons.chat_bubble_outline, size: 16),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(l10n.liveChat, style: const TextStyle(fontWeight: FontWeight.bold)), Text(
l10n.liveChat,
style: const TextStyle(fontWeight: FontWeight.bold),
),
], ],
), ),
), ),
@@ -716,8 +769,11 @@ class _PlayerPageState extends State<PlayerPage> {
Expanded( Expanded(
child: TextField( child: TextField(
controller: _msgController, controller: _msgController,
enabled: !_streamEnded,
decoration: InputDecoration( decoration: InputDecoration(
hintText: l10n.sendMessage, hintText: _streamEnded
? l10n.liveStreamEndedShort
: l10n.sendMessage,
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
@@ -734,7 +790,7 @@ class _PlayerPageState extends State<PlayerPage> {
Icons.send, Icons.send,
color: Theme.of(context).colorScheme.primary, color: Theme.of(context).colorScheme.primary,
), ),
onPressed: _sendMsg, onPressed: _streamEnded ? null : _sendMsg,
), ),
], ],
), ),

View File

@@ -46,7 +46,6 @@ class ChatService {
void connect(String baseUrl, String roomId, String username) { void connect(String baseUrl, String roomId, String username) {
final wsUri = _webSocketUri(baseUrl).replace( final wsUri = _webSocketUri(baseUrl).replace(
scheme: 'ws',
path: '/api/ws/room/$roomId', path: '/api/ws/room/$roomId',
queryParameters: {'username': username}, queryParameters: {'username': username},
); );