From b07f243c8862b3cc4217076d35f93fd1fe2796f6 Mon Sep 17 00:00:00 2001 From: CGH0S7 <776459475@qq.com> Date: Wed, 22 Apr 2026 11:01:32 +0800 Subject: [PATCH] Add live room preview thumbnails --- backend/internal/api/router.go | 1 + backend/internal/stream/server.go | 110 ++++++++++++++++++ frontend/lib/pages/home_page.dart | 80 ++++++++++--- frontend/lib/pages/settings_page.dart | 14 ++- frontend/lib/providers/settings_provider.dart | 21 ++++ 5 files changed, 208 insertions(+), 18 deletions(-) diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 0e57d67..0f49746 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -25,6 +25,7 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine { r.POST("/api/login", Login) r.POST("/api/admin/login", AdminLogin) r.GET("/api/rooms/active", GetActiveRooms) + r.GET("/api/rooms/:room_id/thumbnail", streamServer.HandleThumbnail) r.GET("/live/:room_id", streamServer.HandleHTTPFLV) // WebSocket endpoint for live chat diff --git a/backend/internal/stream/server.go b/backend/internal/stream/server.go index e09988e..c4c03c1 100644 --- a/backend/internal/stream/server.go +++ b/backend/internal/stream/server.go @@ -7,7 +7,9 @@ import ( "fmt" "io" "net/http" + "os" "os/exec" + "path/filepath" "strings" "sync" "time" @@ -35,7 +37,9 @@ type RTMPServer struct { server *rtmp.Server channels map[string]*pubsub.Queue transcoders map[string][]*variantTranscoder + thumbnailJobs map[string]context.CancelFunc internalPublishKey string + thumbnailDir string mutex sync.RWMutex } @@ -53,6 +57,8 @@ type qualityProfile struct { var qualityOrder = []string{"source", "720p", "480p"} +const thumbnailCaptureInterval = 12 * time.Second + var supportedQualities = map[string]qualityProfile{ "720p": { scale: "1280:-2", @@ -81,7 +87,9 @@ func NewRTMPServer() *RTMPServer { s := &RTMPServer{ channels: make(map[string]*pubsub.Queue), transcoders: make(map[string][]*variantTranscoder), + thumbnailJobs: make(map[string]context.CancelFunc), internalPublishKey: generateInternalPublishKey(), + thumbnailDir: filepath.Join(os.TempDir(), "hightube-thumbnails"), 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}) } s.startVariantTranscoders(roomID) + s.startThumbnailCapture(roomID) } // 3. Cleanup on end @@ -134,6 +143,7 @@ func NewRTMPServer() *RTMPServer { if isSource { s.stopVariantTranscoders(roomID) + s.stopThumbnailCapture(roomID) roomIDUint := parseRoomID(roomID) if roomIDUint != 0 { 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) { if parts[1] == "live" && len(parts) == 3 { 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 { value = strings.ToLower(strings.TrimSpace(value)) if _, ok := supportedQualities[value]; ok { diff --git a/frontend/lib/pages/home_page.dart b/frontend/lib/pages/home_page.dart index 2a5234c..8127d73 100644 --- a/frontend/lib/pages/home_page.dart +++ b/frontend/lib/pages/home_page.dart @@ -10,8 +10,10 @@ import 'player_page.dart'; import 'my_stream_page.dart'; class HomePage extends StatefulWidget { + const HomePage({super.key}); + @override - _HomePageState createState() => _HomePageState(); + State createState() => _HomePageState(); } class _HomePageState extends State { @@ -21,7 +23,7 @@ class _HomePageState extends State { Widget build(BuildContext context) { bool isWide = MediaQuery.of(context).size.width > 600; - final List _pages = [ + final List pages = [ _ExploreView(onGoLive: () => setState(() => _selectedIndex = 1)), MyStreamPage(), SettingsPage(), @@ -51,7 +53,7 @@ class _HomePageState extends State { ), ], ), - Expanded(child: _pages[_selectedIndex]), + Expanded(child: pages[_selectedIndex]), ], ), bottomNavigationBar: !isWide @@ -91,6 +93,8 @@ class _ExploreViewState extends State<_ExploreView> { List _activeRooms = []; bool _isLoading = false; Timer? _refreshTimer; + String _thumbnailCacheBuster = DateTime.now().millisecondsSinceEpoch + .toString(); @override void initState() { @@ -117,13 +121,20 @@ class _ExploreViewState extends State<_ExploreView> { final response = await api.getActiveRooms(); if (response.statusCode == 200) { 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) { - if (!isAuto && mounted) + if (!isAuto && mounted) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text("Failed to load rooms"))); + } } finally { if (!isAuto && mounted) setState(() => _isLoading = false); } @@ -198,20 +209,21 @@ class _ExploreViewState extends State<_ExploreView> { } Widget _buildRoomCard(dynamic room, SettingsProvider settings) { + final roomId = room['room_id'].toString(); return Card( elevation: 4, clipBehavior: Clip.antiAlias, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: InkWell( onTap: () { - final playbackUrl = settings.playbackUrl(room['room_id'].toString()); + final playbackUrl = settings.playbackUrl(roomId); Navigator.push( context, MaterialPageRoute( builder: (_) => PlayerPage( title: room['title'], playbackUrl: playbackUrl, - roomId: room['room_id'].toString(), + roomId: roomId, ), ), ); @@ -224,16 +236,37 @@ class _ExploreViewState extends State<_ExploreView> { child: Stack( fit: StackFit.expand, children: [ - Container( - color: Theme.of(context).colorScheme.primaryContainer, - child: Center( - child: Icon( - Icons.live_tv, - size: 50, - color: Theme.of(context).colorScheme.primary, - ), - ), - ), + settings.livePreviewThumbnailsEnabled + ? Image.network( + settings.thumbnailUrl( + roomId, + cacheBuster: _thumbnailCacheBuster, + ), + 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( top: 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, + ), + ), + ); + } } diff --git a/frontend/lib/pages/settings_page.dart b/frontend/lib/pages/settings_page.dart index d2fb3df..4a50c2e 100644 --- a/frontend/lib/pages/settings_page.dart +++ b/frontend/lib/pages/settings_page.dart @@ -11,7 +11,7 @@ class SettingsPage extends StatefulWidget { const SettingsPage({super.key}); @override - _SettingsPageState createState() => _SettingsPageState(); + State createState() => _SettingsPageState(); } class _SettingsPageState extends State { @@ -221,6 +221,18 @@ class _SettingsPageState extends State { ); }).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) ...[ const SizedBox(height: 32), _buildSectionTitle("Security"), diff --git a/frontend/lib/providers/settings_provider.dart b/frontend/lib/providers/settings_provider.dart index 53558c1..5a8329a 100644 --- a/frontend/lib/providers/settings_provider.dart +++ b/frontend/lib/providers/settings_provider.dart @@ -12,10 +12,12 @@ class SettingsProvider with ChangeNotifier { String _baseUrl = _defaultUrl; Color _themeColor = Colors.blue; ThemeMode _themeMode = ThemeMode.system; + bool _livePreviewThumbnailsEnabled = false; String get baseUrl => _baseUrl; Color get themeColor => _themeColor; ThemeMode get themeMode => _themeMode; + bool get livePreviewThumbnailsEnabled => _livePreviewThumbnailsEnabled; SettingsProvider() { _loadSettings(); @@ -32,6 +34,8 @@ class SettingsProvider with ChangeNotifier { if (savedThemeMode != null) { _themeMode = _themeModeFromString(savedThemeMode); } + _livePreviewThumbnailsEnabled = + prefs.getBool('livePreviewThumbnailsEnabled') ?? false; notifyListeners(); } @@ -56,6 +60,13 @@ class SettingsProvider with ChangeNotifier { 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 String get rtmpUrl { final uri = Uri.parse(_baseUrl); @@ -84,6 +95,16 @@ class SettingsProvider with ChangeNotifier { 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) { switch (value) { case 'light':