hls hacks
This commit is contained in:
parent
5c5bd11a0c
commit
e540ac9c54
|
@ -0,0 +1,243 @@
|
|||
|
||||
OP25 HTTP live streaming December 2020
|
||||
=====================================================================
|
||||
|
||||
These hacks ("OP25-hls hacks") add a new option for audio reception
|
||||
and playback in OP25; namely, via an HTTP live stream to any remote
|
||||
client using a standard Web browser*. The web server software used
|
||||
(nginx) is industrial-strength and immediately scalable to dozens or
|
||||
hundreds of simultaneous remote users with zero added effort. More
|
||||
than one upstream source (in parallel) can be served simultaneously.
|
||||
|
||||
OP25's liquidsoap script is hacked to pipe output PCM audio data
|
||||
to ffmpeg, which also reads the www/images/status.png image file
|
||||
that makes up the video portion of the encoded live stream. The
|
||||
image png file is kept updated by rx.py.
|
||||
|
||||
The selection of ffmpeg codecs ("libx264" for video and "aac" for
|
||||
audio) allows us directly to send the encoded data stream from
|
||||
ffmpeg to the web server (nginx) utilizing RTMP. Individual
|
||||
MPEG-TS segments are stored as files in nginx web server URL-space,
|
||||
and served to web clients via standard HTTP GET requests. The
|
||||
hls.js package is used at the client.
|
||||
|
||||
The entire effort mostly involved assembling existing off-the-shelf
|
||||
building blocks. The ffmpeg package was built manually from source
|
||||
to enable the "libx264" codec, and a modified nginx config was
|
||||
used.
|
||||
|
||||
*the web browser must support the "MediaSource Extensions" API.
|
||||
All recent broswer versions should qualify.
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
1. nginx installation
|
||||
|
||||
The libnginx-mod-rtmp package must be installed (in addition to
|
||||
nginx itself). You can copy the sample nginx configuration at the
|
||||
end of this README file to /etc/nginx/nginx.conf, followed by
|
||||
restarting the web server
|
||||
sudo systemctl stop nginx
|
||||
sudo systemctl start nginx
|
||||
With this configuration the web server should listen on HTTP port
|
||||
8081 and RTMP port 1935.
|
||||
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
2. ffmpeg installation
|
||||
git clone https://code.videolan.org/videolan/x264.git
|
||||
git clone https://git.ffmpeg.org/ffmpeg.git
|
||||
cd x264
|
||||
./configure
|
||||
make
|
||||
sudo make install
|
||||
cd ../ffmpeg
|
||||
./configure --enable-shared --enable-libx264 --enable-gpl
|
||||
make
|
||||
sudo make install
|
||||
|
||||
To confirm the installation run the "ffmpeg" command and
|
||||
verify the presence of "--enable-shared" and "--enable-libx264"
|
||||
in the "configuration:" section of the output.
|
||||
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
3. liquidsoap installation
|
||||
Both packages "liquidsoap" and "liquidsoap-plugin-all" were
|
||||
installed, but not tested whether the plugins are required for
|
||||
this application.
|
||||
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
4. nginx setup
|
||||
with the custom config installed as per step 1, copy the files
|
||||
from op25/gr_op25_repeater/www to /var/www/html as follows:
|
||||
|
||||
live.html
|
||||
live.m3u8
|
||||
hls.js
|
||||
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
5. liquidsoap setup
|
||||
in the op25/gr_op25_repeater/apps directory, note the ffmpeg.liq
|
||||
script. Overall the filtering and buffering options should be
|
||||
similar to those in op25.liq. The default version of ffmpeg.liq
|
||||
should be OK for most uses.
|
||||
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
6. operation
|
||||
With OP25 rx.py started using the options -V -w (and -2 if using
|
||||
TDMA) and with op25.liq started (both from the apps directory),
|
||||
you should be able to connect to http://hostip:8081/live.html
|
||||
and click the Play button to begin.
|
||||
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
7. troubleshooting
|
||||
A. with the op25.liq script running ffmpeg should start sending
|
||||
rtmp data over port 1935 to nginx. You should see files
|
||||
start being populated in /var/www/html/hls/ .
|
||||
B. If /var/www/html/hls is empty, check ffmpeg message output
|
||||
for possible errors, and also check the nginx access and
|
||||
error logs. Note that the /var/www/html/hls directory should
|
||||
start receiving files a few seconds after op25.liq is started
|
||||
(regardless of whether OP25 is actively receiving a station,
|
||||
or is not receiving).
|
||||
C. js debug can be enabled for hls.js by editing that file as
|
||||
follows; locate the lines of code and change the "debug"
|
||||
setting to "true"
|
||||
|
||||
var hlsDefaultConfig = _objectSpread(_objectSpread({
|
||||
autoStartLoad: true,
|
||||
// used by stream-controller
|
||||
startPosition: -1,
|
||||
// used by stream-controller
|
||||
defaultAudioCodec: void 0,
|
||||
// used by stream-controller
|
||||
debug: true, ///// <<<=== change this line from
|
||||
///// "false" to "true"
|
||||
|
||||
D. after reloading the page and with the web browser js console
|
||||
opened (and with all message types enabled), debug messages
|
||||
should now start appearing in the console. As before another
|
||||
place to look for messages is in the nginx access and error
|
||||
logs.
|
||||
E. if you are doing heavy client-side debugging it may be helpful
|
||||
to obtain a copy of the hls.js distribution and to populate
|
||||
the hls.js.map file (with updated hls.js) in /var/www/html.
|
||||
|
||||
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
8. notes
|
||||
A. due to the propagation delay inherent in the streaming
|
||||
process, there is a latency of several seconds from when
|
||||
the transmissions are receieved before they are played in
|
||||
the remote web browser. OP25 attempts to keep the video
|
||||
and audio synchronized but the usual user controls (hold,
|
||||
lockout, etc). are not available (in this release) because
|
||||
the several-second delay could cause the commands to operate
|
||||
on stale talkgroup data (without additional work).
|
||||
B. in keeping with the current OP25 liquidsoap setup, the audio
|
||||
stream is converted to mono prior to streaming. It might be
|
||||
possible to retain the stereo data (in cases where the L and
|
||||
R channels contain separate information), but this has not
|
||||
been tested. The op25.liq script would need to be changed to
|
||||
use "output" instead of "mean(output)" and the ffmpeg script
|
||||
would need to change "-ac 1" to "-ac 2". In addition the
|
||||
options stereo=true and channels=2 would need to be set in the
|
||||
%wav specification parameters.
|
||||
C. multiple independent streams can be served simultaneously by
|
||||
invoking a separate ffmpeg.sh script for each stream and by
|
||||
changing the last component of the rtmp URL to a unique
|
||||
value; for example:
|
||||
rtmp://localhost/live/stream2
|
||||
A unified (parameterized) version of ffmpeg.sh could also be
|
||||
used.
|
||||
Also, new versions of live.html and live.m3u8 in /var/www/html
|
||||
(reflecting the above modification) would need to be added.
|
||||
D. note that pausing and seeking etc. in the media feed isn't
|
||||
possible when doing live streaming.
|
||||
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
########################################################################
|
||||
####### tested on ubuntu 18.04 #######
|
||||
####### sample nginx conf file - copy everything below this line #######
|
||||
|
||||
user www-data;
|
||||
worker_processes auto;
|
||||
pid /run/nginx.pid;
|
||||
include /etc/nginx/modules-enabled/*.conf;
|
||||
|
||||
events {
|
||||
worker_connections 768;
|
||||
# multi_accept on;
|
||||
}
|
||||
|
||||
# RTMP configuration
|
||||
rtmp {
|
||||
server {
|
||||
listen 1935; # Listen on standard RTMP port
|
||||
chunk_size 4000;
|
||||
|
||||
application live {
|
||||
live on;
|
||||
# Turn on HLS
|
||||
hls on;
|
||||
hls_path /var/www/html/hls/;
|
||||
hls_fragment 3;
|
||||
hls_playlist_length 60;
|
||||
# disable consuming the stream from nginx as rtmp
|
||||
deny play all;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
http {
|
||||
sendfile off;
|
||||
tcp_nopush on;
|
||||
#aio on;
|
||||
directio 512;
|
||||
default_type application/octet-stream;
|
||||
|
||||
server {
|
||||
listen 8081;
|
||||
|
||||
location / {
|
||||
# Disable cache
|
||||
add_header 'Cache-Control' 'no-cache';
|
||||
|
||||
# CORS setup
|
||||
add_header 'Access-Control-Allow-Origin' '*' always;
|
||||
add_header 'Access-Control-Expose-Headers' 'Content-Length';
|
||||
|
||||
# allow CORS preflight requests
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Max-Age' 1728000;
|
||||
add_header 'Content-Type' 'text/plain charset=UTF-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
|
||||
# include /etc/nginx/mime.types;
|
||||
types {
|
||||
text/html html;
|
||||
text/css css;
|
||||
application/javascript js;
|
||||
application/dash+xml mpd;
|
||||
application/vnd.apple.mpegurl m3u8;
|
||||
video/mp2t ts;
|
||||
}
|
||||
|
||||
access_log /var/log/nginx/access.log;
|
||||
error_log /var/log/nginx/error.log;
|
||||
|
||||
root /var/www/html;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -40,13 +40,14 @@ parser.add_option("-u", "--wireshark-port", type="int", default=23456, help="Wir
|
|||
parser.add_option("-2", "--two-channel", action="store_true", default=False, help="single or two channel audio")
|
||||
parser.add_option("-x", "--audio-gain", type="float", default="1.0", help="audio gain (default = 1.0)")
|
||||
parser.add_option("-s", "--stdout", action="store_true", default=False, help="write to stdout instead of audio device")
|
||||
parser.add_option("-S", "--silence", action="store_true", default=False, help="suppress output of zeros after timeout")
|
||||
|
||||
(options, args) = parser.parse_args()
|
||||
if len(args) != 0:
|
||||
parser.print_help()
|
||||
sys.exit(1)
|
||||
|
||||
audio_handler = socket_audio(options.host_ip, options.wireshark_port, options.audio_output, options.two_channel, options.audio_gain, options.stdout)
|
||||
audio_handler = socket_audio(options.host_ip, options.wireshark_port, options.audio_output, options.two_channel, options.audio_gain, options.stdout, silent_flag=options.silence)
|
||||
|
||||
if __name__ == "__main__":
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
#
|
||||
# (c) Copyright 2020, OP25
|
||||
#
|
||||
# This file is part of OP25 and part of GNU Radio
|
||||
#
|
||||
# OP25 is free software; you can redistribute it and/or modify it
|
||||
# under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3, or (at your option)
|
||||
# any later version.
|
||||
#
|
||||
# OP25 is distributed in the hope that it will be useful, but WITHOUT
|
||||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
|
||||
# License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with OP25; see the file COPYING. If not, write to the Free
|
||||
# Software Foundation, Inc., 51 Franklin Street, Boston, MA
|
||||
# 02110-1301, USA.
|
||||
|
||||
""" generate named image file consisting of multi-line text """
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
|
||||
_TTF_FILE = '/usr/share/fonts/truetype/freefont/FreeSerif.ttf'
|
||||
|
||||
def create_image(textlist=["Blank"], imgfile="test.png", bgcolor='red', fgcolor='black', windowsize=(400,300)):
|
||||
global _TTF_FILE
|
||||
width=windowsize[0]
|
||||
height=windowsize[1]
|
||||
|
||||
margin = 4
|
||||
if not os.access(_TTF_FILE, os.R_OK):
|
||||
font = ImageFont.load_default()
|
||||
else:
|
||||
font = ImageFont.truetype(_TTF_FILE, 16)
|
||||
img = Image.new('RGB', (width, height), bgcolor)
|
||||
draw = ImageDraw.Draw(img)
|
||||
cursor = 0
|
||||
for line in textlist:
|
||||
w,h = draw.textsize(line, font)
|
||||
# TODO: overwidth check needed?
|
||||
if cursor+h >= height:
|
||||
break
|
||||
draw.text((margin, cursor), line,'black',font)
|
||||
cursor += h + margin // 2
|
||||
|
||||
img.save(imgfile)
|
||||
|
||||
if __name__ == '__main__':
|
||||
s = []
|
||||
s.append('Starting...')
|
||||
|
||||
create_image(textlist=s, bgcolor='#c0c0c0')
|
|
@ -0,0 +1,15 @@
|
|||
#!/usr/bin/liquidsoap
|
||||
|
||||
# Example liquidsoap hls streaming from op25 to nginx
|
||||
# (c) 2019, 2020 gnorbury@bondcar.com, wllmbecks@gmail.com
|
||||
# (c) 2020 KA1RBI
|
||||
|
||||
set("log.stdout", true)
|
||||
set("log.file", false)
|
||||
set("log.level", 1)
|
||||
|
||||
set("frame.audio.samplerate", 8000)
|
||||
|
||||
input = mksafe(input.external(buffer=0.02, channels=2, samplerate=8000, restart_on_error=false, "./audio.py -x 2 -s -S"))
|
||||
|
||||
output.external(%wav(stereo=false, channels=1, samplesize=16, header=false, samplerate=8000), fallible=false, flush=true, "./ffmpeg.sh", mean(input))
|
|
@ -0,0 +1,42 @@
|
|||
#! /bin/sh
|
||||
|
||||
# Copyright (c) 2020 OP25
|
||||
#
|
||||
# This file is part of OP25 and part of GNU Radio
|
||||
#
|
||||
# OP25 is free software; you can redistribute it and/or modify it
|
||||
# under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation; either version 3, or (at your option)
|
||||
# any later version.
|
||||
#
|
||||
# OP25 is distributed in the hope that it will be useful, but WITHOUT
|
||||
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
|
||||
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
|
||||
# License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with OP25; see the file COPYING. If not, write to the Free
|
||||
# Software Foundation, Inc., 51 Franklin Street, Boston, MA
|
||||
# 02110-1301, USA.
|
||||
|
||||
#
|
||||
# this script should not be run directly - run ffmpeg.liq instead
|
||||
#
|
||||
# requires ffmpeg configured with --enable-libx264
|
||||
#
|
||||
|
||||
ffmpeg \
|
||||
-ar 8000 \
|
||||
-ac 1 \
|
||||
-acodec pcm_s16le \
|
||||
-f s16le \
|
||||
-i pipe:0 \
|
||||
-f image2 \
|
||||
-loop 1 \
|
||||
-i ../www/images/status.png \
|
||||
-vcodec libx264 \
|
||||
-pix_fmt yuv420p \
|
||||
-f flv \
|
||||
-acodec aac \
|
||||
-b:a 48k \
|
||||
rtmp://localhost/live/stream
|
|
@ -70,6 +70,8 @@ from gr_gnuplot import setup_correlation
|
|||
from terminal import op25_terminal
|
||||
from sockaudio import audio_thread
|
||||
|
||||
from create_image import create_image
|
||||
|
||||
#speeds = [300, 600, 900, 1200, 1440, 1800, 1920, 2400, 2880, 3200, 3600, 3840, 4000, 4800, 6000, 6400, 7200, 8000, 9600, 14400, 19200]
|
||||
speeds = [4800, 6000]
|
||||
|
||||
|
@ -130,6 +132,7 @@ class p25_rx_block (gr.top_block):
|
|||
self.last_change_freq = 0
|
||||
self.last_change_freq_at = time.time()
|
||||
self.last_freq_params = {'freq' : 0.0, 'tgid' : None, 'tag' : "", 'tdma' : None}
|
||||
self.next_status_png = time.time()
|
||||
|
||||
self.src = None
|
||||
if (not options.input) and (not options.audio) and (not options.audio_if) and (not options.args.startswith('udp:')):
|
||||
|
@ -676,6 +679,23 @@ class p25_rx_block (gr.top_block):
|
|||
msg = gr.message().make_from_string(json.dumps(d), -4, 0, 0)
|
||||
self.input_q.insert_tail(msg)
|
||||
|
||||
def make_status_png(self):
|
||||
PNG_UPDATE_INTERVAL = 1.0
|
||||
output_file = '../www/images/status.png'
|
||||
tmp_output_file = '../www/images/tmp-status.png'
|
||||
if time.time() < self.next_status_png:
|
||||
return
|
||||
self.next_status_png = time.time() + PNG_UPDATE_INTERVAL
|
||||
if self.trunk_rx is None:
|
||||
return ## possible race cond - just ignore
|
||||
status_str = 'OP25-hls hacks (c) Copyright 2020, KA1RBI\n'
|
||||
status_str += 'F %f TG %s %s at %s\n' % ( self.last_freq_params['freq'] / 1000000.0, self.last_freq_params['tgid'], self.last_freq_params['tag'], time.asctime())
|
||||
status_str += self.trunk_rx.to_string()
|
||||
status = status_str.split('\n')
|
||||
status = [s for s in status if not s.startswith('tbl-id')]
|
||||
create_image(status, imgfile=tmp_output_file, bgcolor="#c0c0c0", windowsize=(640,480))
|
||||
os.rename(tmp_output_file, output_file)
|
||||
|
||||
def process_qmsg(self, msg):
|
||||
# return true = end top block
|
||||
RX_COMMANDS = 'skip lockout hold'.split()
|
||||
|
@ -696,6 +716,7 @@ class p25_rx_block (gr.top_block):
|
|||
msg = gr.message().make_from_string(js, -4, 0, 0)
|
||||
self.input_q.insert_tail(msg)
|
||||
self.process_ajax()
|
||||
self.make_status_png()
|
||||
elif s == 'set_freq':
|
||||
freq = msg.arg1()
|
||||
self.last_freq_params['freq'] = freq
|
||||
|
|
|
@ -369,7 +369,7 @@ class stdout_wrapper(object):
|
|||
|
||||
# Main class that receives UDP audio samples and sends them to a PCM subsystem (currently ALSA or STDOUT)
|
||||
class socket_audio(object):
|
||||
def __init__(self, udp_host, udp_port, pcm_device, two_channels = False, audio_gain = 1.0, dest_stdout = False, **kwds):
|
||||
def __init__(self, udp_host, udp_port, pcm_device, two_channels = False, audio_gain = 1.0, dest_stdout = False, silent_flag=False, **kwds):
|
||||
self.keep_running = True
|
||||
self.two_channels = two_channels
|
||||
self.audio_gain = audio_gain
|
||||
|
@ -377,6 +377,7 @@ class socket_audio(object):
|
|||
self.sock_a = None
|
||||
self.sock_b = None
|
||||
self.pcm = None
|
||||
self.silent_flag = silent_flag
|
||||
if dest_stdout:
|
||||
pcm_device = "stdout"
|
||||
sys.stdout = os.fdopen(sys.stdout.fileno(), 'w', 0) # reopen stdout with buffering disabled
|
||||
|
@ -417,7 +418,10 @@ class socket_audio(object):
|
|||
|
||||
# Check for select() polling timeout and pcm self-check
|
||||
if (not readable) and (not writable) and (not exceptional):
|
||||
rc = self.pcm.check()
|
||||
if self.silent_flag:
|
||||
rc = 0 # suppress additional zeros to preserve timing
|
||||
else:
|
||||
rc = self.pcm.check()
|
||||
if isinstance(rc, ctypes.c_int):
|
||||
rc = rc.value
|
||||
continue
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 2.3 KiB |
|
@ -0,0 +1,50 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>OP25 Live</title>
|
||||
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
|
||||
<script src="http://localhost:8081/hls.js"></script>
|
||||
<script>
|
||||
var videoSrc = 'http://localhost:8081/live.m3u8';
|
||||
var hls;
|
||||
|
||||
function onload1() {
|
||||
attach_hls();
|
||||
}
|
||||
function attach_hls() {
|
||||
var video = document.getElementById('video');
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls();
|
||||
hls.loadSource(videoSrc);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, function() {
|
||||
video.play();
|
||||
});
|
||||
}
|
||||
// hls.js is not supported on platforms that do not have Media Source
|
||||
// Extensions (MSE) enabled.
|
||||
//
|
||||
// When the browser has built-in HLS support (check using `canPlayType`),
|
||||
// we can provide an HLS manifest (i.e. .m3u8 URL) directly to the video
|
||||
// element through the `src` property. This is using the built-in support
|
||||
// of the plain video element, without using hls.js.
|
||||
//
|
||||
// Note: it would be more normal to wait on the 'canplay' event below however
|
||||
// on Safari (where you are most likely to find built-in HLS support) the
|
||||
// video.src URL must be on the user-driven white-list before a 'canplay'
|
||||
// event will be emitted; the last video event that can be reliably
|
||||
// listened-for when the URL is not on the white-list is 'loadedmetadata'.
|
||||
else if (video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||
video.src = videoSrc;
|
||||
video.addEventListener('loadedmetadata', function() {
|
||||
video.play();
|
||||
});
|
||||
}
|
||||
} // end of attach_hls()
|
||||
</script>
|
||||
</head>
|
||||
<body onload="javascript:onload1();">
|
||||
<video id="video" controls height=600 width=800></video>
|
||||
<br>
|
||||
<hr>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,3 @@
|
|||
#EXTM3U
|
||||
#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=68800,CODECS="mp4a.40.5,avc1.42000d",RESOLUTION=400x300,NAME="240"
|
||||
http://localhost:8081/hls/stream.m3u8
|
Loading…
Reference in New Issue