mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-01-23 02:35:14 +00:00
merged in main
This commit is contained in:
commit
30b2a19eb0
12 changed files with 305 additions and 127 deletions
|
|
@ -432,10 +432,13 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
"name": name,
|
||||
"tvg_id": stream.tvg_id,
|
||||
"tvc_guide_stationid": tvc_guide_stationid,
|
||||
"channel_group_id": channel_group.id,
|
||||
"streams": [stream_id],
|
||||
}
|
||||
|
||||
# Only add channel_group_id if the stream has a channel group
|
||||
if channel_group:
|
||||
channel_data["channel_group_id"] = channel_group.id
|
||||
|
||||
if stream.logo_url:
|
||||
logo, _ = Logo.objects.get_or_create(
|
||||
url=stream.logo_url, defaults={"name": stream.name or stream.tvg_id}
|
||||
|
|
@ -584,9 +587,12 @@ class ChannelViewSet(viewsets.ModelViewSet):
|
|||
"name": name,
|
||||
"tvc_guide_stationid": tvc_guide_stationid,
|
||||
"tvg_id": stream.tvg_id,
|
||||
"channel_group_id": channel_group.id,
|
||||
}
|
||||
|
||||
# Only add channel_group_id if the stream has a channel group
|
||||
if channel_group:
|
||||
channel_data["channel_group_id"] = channel_group.id
|
||||
|
||||
# Attempt to find existing EPGs with the same tvg-id
|
||||
epgs = EPGData.objects.filter(tvg_id=stream.tvg_id)
|
||||
if epgs:
|
||||
|
|
|
|||
|
|
@ -172,6 +172,13 @@ def fetch_m3u_lines(account, use_cache=False):
|
|||
send_m3u_update(account.id, "downloading", 100, status="error", error=error_msg)
|
||||
return [], False
|
||||
|
||||
def get_case_insensitive_attr(attributes, key, default=""):
|
||||
"""Get attribute value using case-insensitive key lookup."""
|
||||
for attr_key, attr_value in attributes.items():
|
||||
if attr_key.lower() == key.lower():
|
||||
return attr_value
|
||||
return default
|
||||
|
||||
def parse_extinf_line(line: str) -> dict:
|
||||
"""
|
||||
Parse an EXTINF line from an M3U file.
|
||||
|
|
@ -193,7 +200,7 @@ def parse_extinf_line(line: str) -> dict:
|
|||
attributes_part, display_name = parts[0], parts[1].strip()
|
||||
attrs = dict(re.findall(r'([^\s]+)=["\']([^"\']+)["\']', attributes_part))
|
||||
# Use tvg-name attribute if available; otherwise, use the display name.
|
||||
name = attrs.get('tvg-name', display_name)
|
||||
name = get_case_insensitive_attr(attrs, 'tvg-name', display_name)
|
||||
return {
|
||||
'attributes': attrs,
|
||||
'display_name': display_name,
|
||||
|
|
@ -409,8 +416,8 @@ def process_m3u_batch(account_id, batch, groups, hash_keys):
|
|||
for stream_info in batch:
|
||||
try:
|
||||
name, url = stream_info["name"], stream_info["url"]
|
||||
tvg_id, tvg_logo = stream_info["attributes"].get("tvg-id", ""), stream_info["attributes"].get("tvg-logo", "")
|
||||
group_title = stream_info["attributes"].get("group-title", "Default Group")
|
||||
tvg_id, tvg_logo = get_case_insensitive_attr(stream_info["attributes"], "tvg-id", ""), get_case_insensitive_attr(stream_info["attributes"], "tvg-logo", "")
|
||||
group_title = get_case_insensitive_attr(stream_info["attributes"], "group-title", "Default Group")
|
||||
|
||||
# Filter out disabled groups for this account
|
||||
if group_title not in groups:
|
||||
|
|
@ -712,8 +719,9 @@ def refresh_m3u_groups(account_id, use_cache=False, full_refresh=False):
|
|||
extinf_count += 1
|
||||
parsed = parse_extinf_line(line)
|
||||
if parsed:
|
||||
if "group-title" in parsed["attributes"]:
|
||||
group_name = parsed["attributes"]["group-title"]
|
||||
group_title_attr = get_case_insensitive_attr(parsed["attributes"], "group-title", "")
|
||||
if group_title_attr:
|
||||
group_name = group_title_attr
|
||||
# Log new groups as they're discovered
|
||||
if group_name not in groups:
|
||||
logger.debug(f"Found new group for M3U account {account_id}: '{group_name}'")
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ class BaseConfig:
|
|||
BUFFER_CHUNK_SIZE = 188 * 1361 # ~256KB
|
||||
# Redis settings
|
||||
REDIS_CHUNK_TTL = 60 # Number in seconds - Chunks expire after 1 minute
|
||||
BUFFERING_TIMEOUT = 15 # Seconds to wait for buffering before switching streams
|
||||
BUFFER_SPEED = 1 # What speed to condsider the stream buffering, 1x is normal speed, 2x is double speed, etc.
|
||||
|
||||
class HLSConfig(BaseConfig):
|
||||
MIN_SEGMENTS = 12
|
||||
|
|
|
|||
|
|
@ -317,6 +317,9 @@ class ChannelStatus:
|
|||
ffmpeg_bitrate = metadata.get(ChannelMetadataField.FFMPEG_BITRATE.encode('utf-8'))
|
||||
if ffmpeg_bitrate:
|
||||
info['ffmpeg_bitrate'] = float(ffmpeg_bitrate.decode('utf-8'))
|
||||
stream_type = metadata.get(ChannelMetadataField.STREAM_TYPE.encode('utf-8'))
|
||||
if stream_type:
|
||||
info['stream_type'] = stream_type.decode('utf-8')
|
||||
|
||||
return info
|
||||
|
||||
|
|
@ -494,6 +497,12 @@ class ChannelStatus:
|
|||
audio_codec = metadata.get(ChannelMetadataField.AUDIO_CODEC.encode('utf-8'))
|
||||
if audio_codec:
|
||||
info['audio_codec'] = audio_codec.decode('utf-8')
|
||||
audio_channels = metadata.get(ChannelMetadataField.AUDIO_CHANNELS.encode('utf-8'))
|
||||
if audio_channels:
|
||||
info['audio_channels'] = audio_channels.decode('utf-8')
|
||||
stream_type = metadata.get(ChannelMetadataField.STREAM_TYPE.encode('utf-8'))
|
||||
if stream_type:
|
||||
info['stream_type'] = stream_type.decode('utf-8')
|
||||
|
||||
return info
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -85,3 +85,11 @@ class ConfigHelper:
|
|||
def failover_grace_period():
|
||||
"""Get extra time (in seconds) to allow for stream switching before disconnecting clients"""
|
||||
return ConfigHelper.get('FAILOVER_GRACE_PERIOD', 20) # Default to 20 seconds
|
||||
@staticmethod
|
||||
def buffering_timeout():
|
||||
"""Get buffering timeout in seconds"""
|
||||
return ConfigHelper.get('BUFFERING_TIMEOUT', 15) # Default to 15 seconds
|
||||
@staticmethod
|
||||
def buffering_speed():
|
||||
"""Get buffering speed in bytes per second"""
|
||||
return ConfigHelper.get('BUFFERING_SPEED',1) # Default to 1x
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ class ChannelState:
|
|||
ERROR = "error"
|
||||
STOPPING = "stopping"
|
||||
STOPPED = "stopped"
|
||||
BUFFERING = "buffering"
|
||||
|
||||
# Event types
|
||||
class EventType:
|
||||
|
|
@ -85,6 +86,8 @@ class ChannelMetadataField:
|
|||
AUDIO_CHANNELS = "audio_channels"
|
||||
AUDIO_BITRATE = "audio_bitrate"
|
||||
|
||||
# Stream format info
|
||||
STREAM_TYPE = "stream_type"
|
||||
# Stream info timestamp
|
||||
STREAM_INFO_UPDATED = "stream_info_updated"
|
||||
|
||||
|
|
|
|||
|
|
@ -420,7 +420,22 @@ class ChannelService:
|
|||
def parse_and_store_stream_info(channel_id, stream_info_line, stream_type="video"):
|
||||
"""Parse FFmpeg stream info line and store in Redis metadata"""
|
||||
try:
|
||||
if stream_type == "video":
|
||||
if stream_type == "input":
|
||||
# Example lines:
|
||||
# Input #0, mpegts, from 'http://example.com/stream.ts':
|
||||
# Input #0, hls, from 'http://example.com/stream.m3u8':
|
||||
|
||||
# Extract input format (e.g., "mpegts", "hls", "flv", etc.)
|
||||
input_match = re.search(r'Input #\d+,\s*([^,]+)', stream_info_line)
|
||||
input_format = input_match.group(1).strip() if input_match else None
|
||||
|
||||
# Store in Redis if we have valid data
|
||||
if input_format:
|
||||
ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, None, None, None, None, input_format)
|
||||
|
||||
logger.debug(f"Input format info - Format: {input_format} for channel {channel_id}")
|
||||
|
||||
elif stream_type == "video":
|
||||
# Example line:
|
||||
# Stream #0:0: Video: h264 (Main), yuv420p(tv, progressive), 1280x720 [SAR 1:1 DAR 16:9], q=2-31, 2000 kb/s, 29.97 fps, 90k tbn
|
||||
|
||||
|
|
@ -428,12 +443,17 @@ class ChannelService:
|
|||
codec_match = re.search(r'Video:\s*([a-zA-Z0-9_]+)', stream_info_line)
|
||||
video_codec = codec_match.group(1) if codec_match else None
|
||||
|
||||
# Extract resolution (e.g., "1280x720")
|
||||
resolution_match = re.search(r'(\d+)x(\d+)', stream_info_line)
|
||||
# Extract resolution (e.g., "1280x720") - be more specific to avoid hex values
|
||||
# Look for resolution patterns that are realistic video dimensions
|
||||
resolution_match = re.search(r'\b(\d{3,5})x(\d{3,5})\b', stream_info_line)
|
||||
if resolution_match:
|
||||
width = int(resolution_match.group(1))
|
||||
height = int(resolution_match.group(2))
|
||||
resolution = f"{width}x{height}"
|
||||
# Validate that these look like reasonable video dimensions
|
||||
if 100 <= width <= 10000 and 100 <= height <= 10000:
|
||||
resolution = f"{width}x{height}"
|
||||
else:
|
||||
width = height = resolution = None
|
||||
else:
|
||||
width = height = resolution = None
|
||||
|
||||
|
|
@ -459,7 +479,7 @@ class ChannelService:
|
|||
|
||||
# Store in Redis if we have valid data
|
||||
if any(x is not None for x in [video_codec, resolution, source_fps, pixel_format, video_bitrate]):
|
||||
ChannelService._update_stream_info_in_redis(channel_id, video_codec, resolution, width, height, source_fps, pixel_format, video_bitrate, None, None, None, None)
|
||||
ChannelService._update_stream_info_in_redis(channel_id, video_codec, resolution, width, height, source_fps, pixel_format, video_bitrate, None, None, None, None, None)
|
||||
|
||||
logger.info(f"Video stream info - Codec: {video_codec}, Resolution: {resolution}, "
|
||||
f"Source FPS: {source_fps}, Pixel Format: {pixel_format}, "
|
||||
|
|
@ -490,7 +510,7 @@ class ChannelService:
|
|||
|
||||
# Store in Redis if we have valid data
|
||||
if any(x is not None for x in [audio_codec, sample_rate, channels, audio_bitrate]):
|
||||
ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, audio_codec, sample_rate, channels, audio_bitrate)
|
||||
ChannelService._update_stream_info_in_redis(channel_id, None, None, None, None, None, None, None, audio_codec, sample_rate, channels, audio_bitrate, None)
|
||||
|
||||
logger.info(f"Audio stream info - Codec: {audio_codec}, Sample Rate: {sample_rate} Hz, "
|
||||
f"Channels: {channels}, Audio Bitrate: {audio_bitrate} kb/s")
|
||||
|
|
@ -499,7 +519,7 @@ class ChannelService:
|
|||
logger.debug(f"Error parsing FFmpeg {stream_type} stream info: {e}")
|
||||
|
||||
@staticmethod
|
||||
def _update_stream_info_in_redis(channel_id, codec, resolution, width, height, fps, pixel_format, video_bitrate, audio_codec=None, sample_rate=None, channels=None, audio_bitrate=None):
|
||||
def _update_stream_info_in_redis(channel_id, codec, resolution, width, height, fps, pixel_format, video_bitrate, audio_codec=None, sample_rate=None, channels=None, audio_bitrate=None, input_format=None):
|
||||
"""Update stream info in Redis metadata"""
|
||||
try:
|
||||
proxy_server = ProxyServer.get_instance()
|
||||
|
|
@ -545,6 +565,8 @@ class ChannelService:
|
|||
|
||||
if audio_bitrate is not None:
|
||||
update_data[ChannelMetadataField.AUDIO_BITRATE] = str(round(audio_bitrate, 1))
|
||||
if input_format is not None:
|
||||
update_data[ChannelMetadataField.STREAM_TYPE] = str(input_format)
|
||||
|
||||
proxy_server.redis_client.hset(metadata_key, mapping=update_data)
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import time
|
|||
import socket
|
||||
import requests
|
||||
import subprocess
|
||||
import gevent # Add this import
|
||||
import re # Add this import at the top
|
||||
import gevent
|
||||
import re
|
||||
from typing import Optional, List
|
||||
from django.shortcuts import get_object_or_404
|
||||
from apps.proxy.config import TSConfig as Config
|
||||
|
|
@ -40,6 +40,10 @@ class StreamManager:
|
|||
self.url_switching = False
|
||||
self.url_switch_start_time = 0
|
||||
self.url_switch_timeout = ConfigHelper.url_switch_timeout()
|
||||
self.buffering = False
|
||||
self.buffering_timeout = ConfigHelper.buffering_timeout()
|
||||
self.buffering_speed = ConfigHelper.buffering_speed()
|
||||
self.buffering_start_time = None
|
||||
# Store worker_id for ownership checks
|
||||
self.worker_id = worker_id
|
||||
|
||||
|
|
@ -502,6 +506,10 @@ class StreamManager:
|
|||
elif any(keyword in content_lower for keyword in ['input', 'output', 'stream', 'video', 'audio']):
|
||||
# Stream info - log at info level
|
||||
logger.info(f"FFmpeg info: {content}")
|
||||
if content.startswith('Input #0'):
|
||||
# If it's input 0, parse stream info
|
||||
from .services.channel_service import ChannelService
|
||||
ChannelService.parse_and_store_stream_info(self.channel_id, content, "input")
|
||||
else:
|
||||
# Everything else at debug level
|
||||
logger.debug(f"FFmpeg stderr: {content}")
|
||||
|
|
@ -541,7 +549,6 @@ class StreamManager:
|
|||
actual_fps = None
|
||||
if ffmpeg_fps is not None and ffmpeg_speed is not None and ffmpeg_speed > 0:
|
||||
actual_fps = ffmpeg_fps / ffmpeg_speed
|
||||
|
||||
# Store in Redis if we have valid data
|
||||
if any(x is not None for x in [ffmpeg_speed, ffmpeg_fps, actual_fps, ffmpeg_bitrate]):
|
||||
self._update_ffmpeg_stats_in_redis(ffmpeg_speed, ffmpeg_fps, actual_fps, ffmpeg_bitrate)
|
||||
|
|
@ -549,10 +556,51 @@ class StreamManager:
|
|||
# Fix the f-string formatting
|
||||
actual_fps_str = f"{actual_fps:.1f}" if actual_fps is not None else "N/A"
|
||||
ffmpeg_bitrate_str = f"{ffmpeg_bitrate:.1f}" if ffmpeg_bitrate is not None else "N/A"
|
||||
|
||||
# Log the stats
|
||||
logger.debug(f"FFmpeg stats - Speed: {ffmpeg_speed}x, FFmpeg FPS: {ffmpeg_fps}, "
|
||||
f"Actual FPS: {actual_fps_str}, "
|
||||
f"Bitrate: {ffmpeg_bitrate_str} kbps")
|
||||
# If we have a valid speed, check for buffering
|
||||
if ffmpeg_speed is not None and ffmpeg_speed < self.buffering_speed:
|
||||
if self.buffering:
|
||||
# Buffering is still ongoing, check for how long
|
||||
if self.buffering_start_time is None:
|
||||
self.buffering_start_time = time.time()
|
||||
else:
|
||||
buffering_duration = time.time() - self.buffering_start_time
|
||||
if buffering_duration > self.buffering_timeout:
|
||||
# Buffering timeout reached, log error and try next stream
|
||||
logger.error(f"Buffering timeout reached for channel {self.channel_id} after {buffering_duration:.1f} seconds")
|
||||
# Send next stream request
|
||||
if self._try_next_stream():
|
||||
logger.info(f"Switched to next stream for channel {self.channel_id} after buffering timeout")
|
||||
# Reset buffering state
|
||||
self.buffering = False
|
||||
self.buffering_start_time = None
|
||||
else:
|
||||
logger.error(f"Failed to switch to next stream for channel {self.channel_id} after buffering timeout")
|
||||
else:
|
||||
# Buffering just started, set the flag and start timer
|
||||
self.buffering = True
|
||||
self.buffering_start_time = time.time()
|
||||
logger.warning(f"Buffering started for channel {self.channel_id} - speed: {ffmpeg_speed}x")
|
||||
# Log buffering warning
|
||||
logger.debug(f"FFmpeg speed on channel {self.channel_id} is below {self.buffering_speed} ({ffmpeg_speed}x) - buffering detected")
|
||||
# Set channel state to buffering
|
||||
if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client:
|
||||
metadata_key = RedisKeys.channel_metadata(self.channel_id)
|
||||
self.buffer.redis_client.hset(metadata_key, ChannelMetadataField.STATE, ChannelState.BUFFERING)
|
||||
elif ffmpeg_speed is not None and ffmpeg_speed >= self.buffering_speed:
|
||||
# Speed is good, check if we were buffering
|
||||
if self.buffering:
|
||||
# Reset buffering state
|
||||
logger.info(f"Buffering ended for channel {self.channel_id} - speed: {ffmpeg_speed}x")
|
||||
self.buffering = False
|
||||
self.buffering_start_time = None
|
||||
# Set channel state to active if speed is good
|
||||
if hasattr(self.buffer, 'redis_client') and self.buffer.redis_client:
|
||||
metadata_key = RedisKeys.channel_metadata(self.channel_id)
|
||||
self.buffer.redis_client.hset(metadata_key, ChannelMetadataField.STATE, ChannelState.ACTIVE)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parsing FFmpeg stats: {e}")
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ def get_stream_info_for_switch(channel_id: str, target_stream_id: Optional[int]
|
|||
# Get transcode info from the channel's stream profile
|
||||
stream_profile = channel.get_stream_profile()
|
||||
transcode = not (stream_profile.is_proxy() or stream_profile is None)
|
||||
profile_value = str(stream_profile)
|
||||
profile_value = stream_profile.id
|
||||
|
||||
return {
|
||||
'url': stream_url,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import json
|
||||
import ipaddress
|
||||
import logging
|
||||
from rest_framework import viewsets, status
|
||||
from rest_framework.response import Response
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
|
@ -29,6 +30,9 @@ from apps.accounts.permissions import (
|
|||
from dispatcharr.utils import get_client_ip
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserAgentViewSet(viewsets.ModelViewSet):
|
||||
"""
|
||||
API endpoint that allows user agents to be viewed, created, edited, or deleted.
|
||||
|
|
@ -137,14 +141,32 @@ def environment(request):
|
|||
except Exception as e:
|
||||
local_ip = f"Error: {e}"
|
||||
|
||||
# 3) If we got a valid public_ip, fetch geo info from ipapi.co
|
||||
# 3) If we got a valid public_ip, fetch geo info from ipapi.co or ip-api.com
|
||||
if public_ip and "Error" not in public_ip:
|
||||
try:
|
||||
geo = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5).json()
|
||||
# ipapi returns fields like country_code, country_name, etc.
|
||||
country_code = geo.get("country_code", "") # e.g. "US"
|
||||
country_name = geo.get("country_name", "") # e.g. "United States"
|
||||
except requests.RequestException as e:
|
||||
# Attempt to get geo information from ipapi.co first
|
||||
r = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5)
|
||||
|
||||
if r.status_code == requests.codes.ok:
|
||||
geo = r.json()
|
||||
country_code = geo.get("country_code") # e.g. "US"
|
||||
country_name = geo.get("country_name") # e.g. "United States"
|
||||
|
||||
else:
|
||||
# If ipapi.co fails, fallback to ip-api.com
|
||||
# only supports http requests for free tier
|
||||
r = requests.get("http://ip-api.com/json/", timeout=5)
|
||||
|
||||
if r.status_code == requests.codes.ok:
|
||||
geo = r.json()
|
||||
country_code = geo.get("countryCode") # e.g. "US"
|
||||
country_name = geo.get("country") # e.g. "United States"
|
||||
|
||||
else:
|
||||
raise Exception("Geo lookup failed with both services")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during geo lookup: {e}")
|
||||
country_code = None
|
||||
country_name = None
|
||||
|
||||
|
|
|
|||
|
|
@ -73,72 +73,109 @@ export default function FloatingVideo() {
|
|||
console.log("Attempting to play stream:", streamUrl);
|
||||
|
||||
try {
|
||||
// If the browser supports MSE for live playback, initialize mpegts.js
|
||||
if (mpegts.getFeatureList().mseLivePlayback) {
|
||||
// Set loading flag
|
||||
setIsLoading(true);
|
||||
|
||||
const player = mpegts.createPlayer({
|
||||
type: 'mpegts', // MPEG-TS format
|
||||
url: streamUrl,
|
||||
isLive: true,
|
||||
enableWorker: true,
|
||||
enableStashBuffer: false, // Try disabling stash buffer for live streams
|
||||
liveBufferLatencyChasing: true,
|
||||
liveSync: true,
|
||||
cors: true, // Enable CORS for cross-domain requests
|
||||
// Add error recovery options
|
||||
autoCleanupSourceBuffer: true,
|
||||
autoCleanupMaxBackwardDuration: 10,
|
||||
autoCleanupMinBackwardDuration: 5,
|
||||
reuseRedirectedURL: true,
|
||||
});
|
||||
|
||||
player.attachMediaElement(videoRef.current);
|
||||
|
||||
// Add events to track loading state
|
||||
player.on(mpegts.Events.LOADING_COMPLETE, () => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
player.on(mpegts.Events.METADATA_ARRIVED, () => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
// Add error event handler
|
||||
player.on(mpegts.Events.ERROR, (errorType, errorDetail) => {
|
||||
setIsLoading(false);
|
||||
|
||||
// Filter out aborted errors
|
||||
if (errorType !== 'NetworkError' || !errorDetail?.includes('aborted')) {
|
||||
console.error('Player error:', errorType, errorDetail);
|
||||
setLoadError(`Error: ${errorType}${errorDetail ? ` - ${errorDetail}` : ''}`);
|
||||
}
|
||||
});
|
||||
|
||||
player.load();
|
||||
|
||||
// Don't auto-play until we've loaded properly
|
||||
player.on(mpegts.Events.MEDIA_INFO, () => {
|
||||
setIsLoading(false);
|
||||
try {
|
||||
player.play().catch(e => {
|
||||
console.log("Auto-play prevented:", e);
|
||||
setLoadError("Auto-play was prevented. Click play to start.");
|
||||
});
|
||||
} catch (e) {
|
||||
console.log("Error during play:", e);
|
||||
setLoadError(`Playback error: ${e.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Store player instance so we can clean up later
|
||||
playerRef.current = player;
|
||||
// Check for MSE support first
|
||||
if (!mpegts.getFeatureList().mseLivePlayback) {
|
||||
setIsLoading(false);
|
||||
setLoadError("Your browser doesn't support live video streaming. Please try Chrome or Edge.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for basic codec support
|
||||
const video = document.createElement('video');
|
||||
const h264Support = video.canPlayType('video/mp4; codecs="avc1.42E01E"');
|
||||
const aacSupport = video.canPlayType('audio/mp4; codecs="mp4a.40.2"');
|
||||
|
||||
console.log("Browser codec support - H264:", h264Support, "AAC:", aacSupport);
|
||||
|
||||
// If the browser supports MSE for live playback, initialize mpegts.js
|
||||
setIsLoading(true);
|
||||
|
||||
const player = mpegts.createPlayer({
|
||||
type: 'mpegts',
|
||||
url: streamUrl,
|
||||
isLive: true,
|
||||
enableWorker: true,
|
||||
enableStashBuffer: false,
|
||||
liveBufferLatencyChasing: true,
|
||||
liveSync: true,
|
||||
cors: true,
|
||||
autoCleanupSourceBuffer: true,
|
||||
autoCleanupMaxBackwardDuration: 10,
|
||||
autoCleanupMinBackwardDuration: 5,
|
||||
reuseRedirectedURL: true,
|
||||
});
|
||||
|
||||
player.attachMediaElement(videoRef.current);
|
||||
|
||||
// Add events to track loading state
|
||||
player.on(mpegts.Events.LOADING_COMPLETE, () => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
player.on(mpegts.Events.METADATA_ARRIVED, () => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
// Enhanced error event handler with codec-specific messages
|
||||
player.on(mpegts.Events.ERROR, (errorType, errorDetail) => {
|
||||
setIsLoading(false);
|
||||
|
||||
// Filter out aborted errors
|
||||
if (errorType !== 'NetworkError' || !errorDetail?.includes('aborted')) {
|
||||
console.error('Player error:', errorType, errorDetail);
|
||||
|
||||
// Provide specific error messages based on error type
|
||||
let errorMessage = `Error: ${errorType}`;
|
||||
|
||||
if (errorType === 'MediaError') {
|
||||
// Try to determine if it's an audio or video codec issue
|
||||
const errorString = errorDetail?.toLowerCase() || '';
|
||||
|
||||
if (errorString.includes('audio') || errorString.includes('ac3') || errorString.includes('ac-3')) {
|
||||
errorMessage = "Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support.";
|
||||
} else if (errorString.includes('video') || errorString.includes('h264') || errorString.includes('h.264')) {
|
||||
errorMessage = "Video codec not supported by your browser. Try Chrome or Edge for better video codec support.";
|
||||
} else if (errorString.includes('mse')) {
|
||||
errorMessage = "Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility.";
|
||||
} else {
|
||||
errorMessage = "Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.";
|
||||
}
|
||||
} else if (errorDetail) {
|
||||
errorMessage += ` - ${errorDetail}`;
|
||||
}
|
||||
|
||||
setLoadError(errorMessage);
|
||||
}
|
||||
});
|
||||
|
||||
player.load();
|
||||
|
||||
// Don't auto-play until we've loaded properly
|
||||
player.on(mpegts.Events.MEDIA_INFO, () => {
|
||||
setIsLoading(false);
|
||||
try {
|
||||
player.play().catch(e => {
|
||||
console.log("Auto-play prevented:", e);
|
||||
setLoadError("Auto-play was prevented. Click play to start.");
|
||||
});
|
||||
} catch (e) {
|
||||
console.log("Error during play:", e);
|
||||
setLoadError(`Playback error: ${e.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Store player instance so we can clean up later
|
||||
playerRef.current = player;
|
||||
} catch (error) {
|
||||
setIsLoading(false);
|
||||
setLoadError(`Initialization error: ${error.message}`);
|
||||
console.error("Error initializing player:", error);
|
||||
|
||||
// Provide helpful error message based on the error
|
||||
if (error.message?.includes('codec') || error.message?.includes('format')) {
|
||||
setLoadError("Codec not supported by your browser. Please try a different browser (Chrome/Edge recommended).");
|
||||
} else {
|
||||
setLoadError(`Initialization error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup when component unmounts or streamUrl changes
|
||||
|
|
@ -191,7 +228,7 @@ export default function FloatingVideo() {
|
|||
style={{ width: '100%', height: '180px', backgroundColor: '#000' }}
|
||||
/>
|
||||
|
||||
{/* Loading overlay */}
|
||||
{/* Loading overlay - only show when loading */}
|
||||
{isLoading && (
|
||||
<Box
|
||||
style={{
|
||||
|
|
@ -214,31 +251,22 @@ export default function FloatingVideo() {
|
|||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Error message overlay */}
|
||||
{!isLoading && loadError && (
|
||||
<Box
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 5,
|
||||
padding: '0 10px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
<Text color="red" size="sm">
|
||||
{loadError}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Error message below video - doesn't block controls */}
|
||||
{!isLoading && loadError && (
|
||||
<Box
|
||||
style={{
|
||||
padding: '10px',
|
||||
backgroundColor: '#2d1b2e',
|
||||
borderTop: '1px solid #444',
|
||||
}}
|
||||
>
|
||||
<Text color="red" size="xs" style={{ textAlign: 'center' }}>
|
||||
{loadError}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Draggable>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -570,28 +570,50 @@ const ChannelCard = ({
|
|||
|
||||
{/* Add stream information badges */}
|
||||
<Group gap="xs" mt="xs">
|
||||
{channel.video_codec && (
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
{channel.video_codec.toUpperCase()}
|
||||
</Badge>
|
||||
)}
|
||||
{channel.resolution && (
|
||||
<Badge size="sm" variant="light" color="green">
|
||||
{channel.resolution}
|
||||
</Badge>
|
||||
<Tooltip label="Video resolution">
|
||||
<Badge size="sm" variant="light" color="red">
|
||||
{channel.resolution}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{channel.source_fps && (
|
||||
<Badge size="sm" variant="light" color="orange">
|
||||
{channel.source_fps} FPS
|
||||
</Badge>
|
||||
<Tooltip label="Source frames per second">
|
||||
<Badge size="sm" variant="light" color="orange">
|
||||
{channel.source_fps} FPS
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{channel.video_codec && (
|
||||
<Tooltip label="Video codec">
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
{channel.video_codec.toUpperCase()}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{channel.audio_codec && (
|
||||
<Badge size="sm" variant="light" color="purple">
|
||||
{channel.audio_codec.toUpperCase()}
|
||||
</Badge>
|
||||
<Tooltip label="Audio codec">
|
||||
<Badge size="sm" variant="light" color="pink">
|
||||
{channel.audio_codec.toUpperCase()}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{channel.audio_channels && (
|
||||
<Tooltip label="Audio channel configuration">
|
||||
<Badge size="sm" variant="light" color="pink">
|
||||
{channel.audio_channels}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{channel.stream_type && (
|
||||
<Tooltip label="Stream type">
|
||||
<Badge size="sm" variant="light" color="cyan">
|
||||
{channel.stream_type.toUpperCase()}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{channel.ffmpeg_speed && (
|
||||
<Tooltip label={`Speed: ${channel.ffmpeg_speed}x realtime`}>
|
||||
<Tooltip label={`Current Speed: ${channel.ffmpeg_speed}x`}>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue