Add live room preview thumbnails
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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':
|
||||||
|
|||||||
Reference in New Issue
Block a user