Add live room preview thumbnails

This commit is contained in:
2026-04-22 11:01:32 +08:00
parent 425ea363f8
commit b07f243c88
5 changed files with 208 additions and 18 deletions

View File

@@ -25,6 +25,7 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine {
r.POST("/api/login", Login) r.POST("/api/login", Login)
r.POST("/api/admin/login", AdminLogin) r.POST("/api/admin/login", AdminLogin)
r.GET("/api/rooms/active", GetActiveRooms) r.GET("/api/rooms/active", GetActiveRooms)
r.GET("/api/rooms/:room_id/thumbnail", streamServer.HandleThumbnail)
r.GET("/live/:room_id", streamServer.HandleHTTPFLV) r.GET("/live/:room_id", streamServer.HandleHTTPFLV)
// WebSocket endpoint for live chat // WebSocket endpoint for live chat

View File

@@ -7,7 +7,9 @@ import (
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -35,7 +37,9 @@ type RTMPServer struct {
server *rtmp.Server server *rtmp.Server
channels map[string]*pubsub.Queue channels map[string]*pubsub.Queue
transcoders map[string][]*variantTranscoder transcoders map[string][]*variantTranscoder
thumbnailJobs map[string]context.CancelFunc
internalPublishKey string internalPublishKey string
thumbnailDir string
mutex sync.RWMutex mutex sync.RWMutex
} }
@@ -53,6 +57,8 @@ type qualityProfile struct {
var qualityOrder = []string{"source", "720p", "480p"} var qualityOrder = []string{"source", "720p", "480p"}
const thumbnailCaptureInterval = 12 * time.Second
var supportedQualities = map[string]qualityProfile{ var supportedQualities = map[string]qualityProfile{
"720p": { "720p": {
scale: "1280:-2", scale: "1280:-2",
@@ -81,7 +87,9 @@ func NewRTMPServer() *RTMPServer {
s := &RTMPServer{ s := &RTMPServer{
channels: make(map[string]*pubsub.Queue), channels: make(map[string]*pubsub.Queue),
transcoders: make(map[string][]*variantTranscoder), transcoders: make(map[string][]*variantTranscoder),
thumbnailJobs: make(map[string]context.CancelFunc),
internalPublishKey: generateInternalPublishKey(), internalPublishKey: generateInternalPublishKey(),
thumbnailDir: filepath.Join(os.TempDir(), "hightube-thumbnails"),
server: &rtmp.Server{}, server: &rtmp.Server{},
} }
@@ -123,6 +131,7 @@ func NewRTMPServer() *RTMPServer {
db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": true}) db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": true})
} }
s.startVariantTranscoders(roomID) s.startVariantTranscoders(roomID)
s.startThumbnailCapture(roomID)
} }
// 3. Cleanup on end // 3. Cleanup on end
@@ -134,6 +143,7 @@ func NewRTMPServer() *RTMPServer {
if isSource { if isSource {
s.stopVariantTranscoders(roomID) s.stopVariantTranscoders(roomID)
s.stopThumbnailCapture(roomID)
roomIDUint := parseRoomID(roomID) roomIDUint := parseRoomID(roomID)
if roomIDUint != 0 { if roomIDUint != 0 {
db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": false}) db.DB.Model(&model.Room{}).Where("id = ?", roomIDUint).Updates(map[string]interface{}{"is_active": false})
@@ -241,6 +251,17 @@ func (s *RTMPServer) HandleHTTPFLV(c *gin.Context) {
} }
} }
func (s *RTMPServer) HandleThumbnail(c *gin.Context) {
thumbnailPath := s.thumbnailPath(c.Param("room_id"))
if _, err := os.Stat(thumbnailPath); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "Thumbnail not found"})
return
}
c.Header("Cache-Control", "no-store, no-cache, must-revalidate")
c.File(thumbnailPath)
}
func (s *RTMPServer) resolvePublishPath(parts []string) (roomID string, channelPath string, isSource bool, ok bool) { func (s *RTMPServer) resolvePublishPath(parts []string) (roomID string, channelPath string, isSource bool, ok bool) {
if parts[1] == "live" && len(parts) == 3 { if parts[1] == "live" && len(parts) == 3 {
var room model.Room var room model.Room
@@ -333,6 +354,95 @@ func (s *RTMPServer) stopVariantTranscoders(roomID string) {
} }
} }
func (s *RTMPServer) startThumbnailCapture(roomID string) {
s.stopThumbnailCapture(roomID)
if err := os.MkdirAll(s.thumbnailDir, 0o755); err != nil {
monitor.Errorf("Failed to create thumbnail directory: %v", err)
return
}
ctx, cancel := context.WithCancel(context.Background())
s.mutex.Lock()
s.thumbnailJobs[roomID] = cancel
s.mutex.Unlock()
go func() {
select {
case <-ctx.Done():
return
case <-time.After(3 * time.Second):
}
s.captureThumbnail(roomID)
ticker := time.NewTicker(thumbnailCaptureInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.captureThumbnail(roomID)
}
}
}()
}
func (s *RTMPServer) stopThumbnailCapture(roomID string) {
s.mutex.Lock()
cancel := s.thumbnailJobs[roomID]
delete(s.thumbnailJobs, roomID)
s.mutex.Unlock()
if cancel != nil {
cancel()
}
_ = os.Remove(s.thumbnailPath(roomID))
}
func (s *RTMPServer) captureThumbnail(roomID string) {
outputPath := s.thumbnailPath(roomID)
tempPath := outputPath + ".tmp.jpg"
defer os.Remove(tempPath)
ctx, cancel := context.WithTimeout(context.Background(), 8*time.Second)
defer cancel()
cmd := exec.CommandContext(
ctx,
"ffmpeg",
"-y",
"-loglevel", "error",
"-rtmp_live", "live",
"-i", fmt.Sprintf("rtmp://127.0.0.1:1935/live/%s", roomID),
"-frames:v", "1",
"-q:v", "4",
tempPath,
)
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
monitor.Warnf("Thumbnail capture timed out for room_id=%s", roomID)
return
}
monitor.Warnf("Thumbnail capture failed for room_id=%s: %v", roomID, err)
return
}
_ = os.Remove(outputPath)
if err := os.Rename(tempPath, outputPath); err != nil {
monitor.Warnf("Failed to store thumbnail for room_id=%s: %v", roomID, err)
}
}
func (s *RTMPServer) thumbnailPath(roomID string) string {
return filepath.Join(s.thumbnailDir, fmt.Sprintf("%s.jpg", roomID))
}
func normalizeQuality(value string) string { func normalizeQuality(value string) string {
value = strings.ToLower(strings.TrimSpace(value)) value = strings.ToLower(strings.TrimSpace(value))
if _, ok := supportedQualities[value]; ok { if _, ok := supportedQualities[value]; ok {

View File

@@ -10,8 +10,10 @@ import 'player_page.dart';
import 'my_stream_page.dart'; import 'my_stream_page.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
const HomePage({super.key});
@override @override
_HomePageState createState() => _HomePageState(); State<HomePage> createState() => _HomePageState();
} }
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> {
@@ -21,7 +23,7 @@ class _HomePageState extends State<HomePage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
bool isWide = MediaQuery.of(context).size.width > 600; bool isWide = MediaQuery.of(context).size.width > 600;
final List<Widget> _pages = [ final List<Widget> pages = [
_ExploreView(onGoLive: () => setState(() => _selectedIndex = 1)), _ExploreView(onGoLive: () => setState(() => _selectedIndex = 1)),
MyStreamPage(), MyStreamPage(),
SettingsPage(), SettingsPage(),
@@ -51,7 +53,7 @@ class _HomePageState extends State<HomePage> {
), ),
], ],
), ),
Expanded(child: _pages[_selectedIndex]), Expanded(child: pages[_selectedIndex]),
], ],
), ),
bottomNavigationBar: !isWide bottomNavigationBar: !isWide
@@ -91,6 +93,8 @@ class _ExploreViewState extends State<_ExploreView> {
List<dynamic> _activeRooms = []; List<dynamic> _activeRooms = [];
bool _isLoading = false; bool _isLoading = false;
Timer? _refreshTimer; Timer? _refreshTimer;
String _thumbnailCacheBuster = DateTime.now().millisecondsSinceEpoch
.toString();
@override @override
void initState() { void initState() {
@@ -117,13 +121,20 @@ class _ExploreViewState extends State<_ExploreView> {
final response = await api.getActiveRooms(); final response = await api.getActiveRooms();
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(response.body); final data = jsonDecode(response.body);
if (mounted) setState(() => _activeRooms = data['active_rooms'] ?? []); if (mounted) {
setState(() {
_activeRooms = data['active_rooms'] ?? [];
_thumbnailCacheBuster = DateTime.now().millisecondsSinceEpoch
.toString();
});
}
} }
} catch (e) { } catch (e) {
if (!isAuto && mounted) if (!isAuto && mounted) {
ScaffoldMessenger.of( ScaffoldMessenger.of(
context, context,
).showSnackBar(SnackBar(content: Text("Failed to load rooms"))); ).showSnackBar(SnackBar(content: Text("Failed to load rooms")));
}
} finally { } finally {
if (!isAuto && mounted) setState(() => _isLoading = false); if (!isAuto && mounted) setState(() => _isLoading = false);
} }
@@ -198,20 +209,21 @@ class _ExploreViewState extends State<_ExploreView> {
} }
Widget _buildRoomCard(dynamic room, SettingsProvider settings) { Widget _buildRoomCard(dynamic room, SettingsProvider settings) {
final roomId = room['room_id'].toString();
return Card( return Card(
elevation: 4, elevation: 4,
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: InkWell( child: InkWell(
onTap: () { onTap: () {
final playbackUrl = settings.playbackUrl(room['room_id'].toString()); final playbackUrl = settings.playbackUrl(roomId);
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => PlayerPage( builder: (_) => PlayerPage(
title: room['title'], title: room['title'],
playbackUrl: playbackUrl, playbackUrl: playbackUrl,
roomId: room['room_id'].toString(), roomId: roomId,
), ),
), ),
); );
@@ -224,16 +236,37 @@ class _ExploreViewState extends State<_ExploreView> {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
Container( settings.livePreviewThumbnailsEnabled
color: Theme.of(context).colorScheme.primaryContainer, ? Image.network(
child: Center( settings.thumbnailUrl(
child: Icon( roomId,
Icons.live_tv, cacheBuster: _thumbnailCacheBuster,
size: 50, ),
color: Theme.of(context).colorScheme.primary, fit: BoxFit.cover,
), errorBuilder: (context, error, stackTrace) =>
), _buildRoomPreviewFallback(),
), loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) {
return child;
}
return Stack(
fit: StackFit.expand,
children: [
_buildRoomPreviewFallback(),
const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
),
),
),
],
);
},
)
: _buildRoomPreviewFallback(),
Positioned( Positioned(
top: 8, top: 8,
left: 8, left: 8,
@@ -306,4 +339,17 @@ class _ExploreViewState extends State<_ExploreView> {
), ),
); );
} }
Widget _buildRoomPreviewFallback() {
return Container(
color: Theme.of(context).colorScheme.primaryContainer,
child: Center(
child: Icon(
Icons.live_tv,
size: 50,
color: Theme.of(context).colorScheme.primary,
),
),
);
}
} }

View File

@@ -11,7 +11,7 @@ class SettingsPage extends StatefulWidget {
const SettingsPage({super.key}); const SettingsPage({super.key});
@override @override
_SettingsPageState createState() => _SettingsPageState(); State<SettingsPage> createState() => _SettingsPageState();
} }
class _SettingsPageState extends State<SettingsPage> { class _SettingsPageState extends State<SettingsPage> {
@@ -221,6 +221,18 @@ class _SettingsPageState extends State<SettingsPage> {
); );
}).toList(), }).toList(),
), ),
const SizedBox(height: 32),
_buildSectionTitle("Explore"),
const SizedBox(height: 8),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text("Live Preview Thumbnails"),
subtitle: const Text(
"Show cached snapshot covers for live rooms when available.",
),
value: settings.livePreviewThumbnailsEnabled,
onChanged: settings.setLivePreviewThumbnailsEnabled,
),
if (isAuthenticated) ...[ if (isAuthenticated) ...[
const SizedBox(height: 32), const SizedBox(height: 32),
_buildSectionTitle("Security"), _buildSectionTitle("Security"),

View File

@@ -12,10 +12,12 @@ class SettingsProvider with ChangeNotifier {
String _baseUrl = _defaultUrl; String _baseUrl = _defaultUrl;
Color _themeColor = Colors.blue; Color _themeColor = Colors.blue;
ThemeMode _themeMode = ThemeMode.system; ThemeMode _themeMode = ThemeMode.system;
bool _livePreviewThumbnailsEnabled = false;
String get baseUrl => _baseUrl; String get baseUrl => _baseUrl;
Color get themeColor => _themeColor; Color get themeColor => _themeColor;
ThemeMode get themeMode => _themeMode; ThemeMode get themeMode => _themeMode;
bool get livePreviewThumbnailsEnabled => _livePreviewThumbnailsEnabled;
SettingsProvider() { SettingsProvider() {
_loadSettings(); _loadSettings();
@@ -32,6 +34,8 @@ class SettingsProvider with ChangeNotifier {
if (savedThemeMode != null) { if (savedThemeMode != null) {
_themeMode = _themeModeFromString(savedThemeMode); _themeMode = _themeModeFromString(savedThemeMode);
} }
_livePreviewThumbnailsEnabled =
prefs.getBool('livePreviewThumbnailsEnabled') ?? false;
notifyListeners(); notifyListeners();
} }
@@ -56,6 +60,13 @@ class SettingsProvider with ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void setLivePreviewThumbnailsEnabled(bool enabled) async {
_livePreviewThumbnailsEnabled = enabled;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('livePreviewThumbnailsEnabled', enabled);
notifyListeners();
}
// Also provide the RTMP URL based on the same hostname // Also provide the RTMP URL based on the same hostname
String get rtmpUrl { String get rtmpUrl {
final uri = Uri.parse(_baseUrl); final uri = Uri.parse(_baseUrl);
@@ -84,6 +95,16 @@ class SettingsProvider with ChangeNotifier {
return "$rtmpUrl/$roomId/$normalizedQuality"; return "$rtmpUrl/$roomId/$normalizedQuality";
} }
String thumbnailUrl(String roomId, {String? cacheBuster}) {
final uri = Uri.parse(_baseUrl);
return uri
.replace(
path: '/api/rooms/$roomId/thumbnail',
queryParameters: cacheBuster == null ? null : {'t': cacheBuster},
)
.toString();
}
ThemeMode _themeModeFromString(String value) { ThemeMode _themeModeFromString(String value) {
switch (value) { switch (value) {
case 'light': case 'light':