Merge branch 'v2.0.0-dev'

This commit is contained in:
Jonathan Baecker 2020-05-07 15:01:39 +02:00
commit ab0be5446c
144 changed files with 19063 additions and 8831 deletions

26
.gitignore vendored
View File

@ -1,15 +1,15 @@
clips/
media
media/
public
public/
ADtvMedia
ADtvMedia/
live
live/
playlists
playlists/
tmp
tmp/
.ropeproject
**temp
*.log*
.DS_Store
__pycache__/
venv/
*-orig.*
tests/
._*
._**
*.sqlite3
**/migrations/*
!**/migrations/__init__.py
**/ffplayout/apps/*
!**/ffplayout/apps/api_player/

View File

@ -1,50 +1,36 @@
ffplayout-gui
=====
Web-based graphical user interface for [ffplayout-engine](https://github.com/ffplayout/ffplayout-engine).
This is the fresh version v2.0.0-beta from ffplayout GUI.
![ffplayout-gui](./ffplayout-gui.png)
At the moment this version is more for testing. If you want to test it check [install.md](docs/install.md) for installation.
**Warning:** This GUI is **not** made with security in mind. It have some potential risks and should only be used in local environments.
A old working version you found in v1.0.0 branch, or under releases.
The GUI uses CSS Grid, so it only works in modern Browsers, in Internet Explorer 11 for example it will not work.
**This version is not production ready!!**
Minimum Screen Resolution is 1920*1080.
## some impressions below:
In some parts, like list_op.php the code is a bit messy. I use here formating options for our file names.
#### Login
![login](/assets/login.png)
Help in code inprovements are very welcome!
#### Landing Page
![landing-page](/assets/landing-page.png)
ffplayout and ffplayout-gui is only tested on linux. My setup runs on debian, so the instruction is made for it to.
#### Control Page
![control](/assets/control.png)
For previewing the clips, they must have a format what video.js can read. I use here h264/aac/mp4.
#### Media Page
![media](/assets/media.png)
Used Modules:
-----
- https://github.com/RubaXa/Sortable
- https://github.com/DirectoryLister/DirectoryLister
- https://github.com/KennethanCeyer/pg-calendar
- https://github.com/videojs
- https://github.com/videojs/videojs-contrib-hls
- https://github.com/videojs/videojs-flash
- http://jquery.com/
- https://jqueryui.com/
- https://momentjs.com/
#### Media Page / Upload
![media-upload](/assets/media-upload.png)
#### Message Page
![message](/assets/message.png)
Installation:
----
- ffplayout should be setup already
- install a webserver with php support (Apache or nginx)
- install [srs](https://github.com/ossrs/srs) or something similar for previewing your stream (example for srs you found in the wiki)
- we need some visudo entries:
- www-data ALL = NOPASSWD: /bin/systemctl start srs, /bin/systemctl stop srs, /bin/systemctl status srs
- www-data ALL = NOPASSWD: /bin/systemctl start ffplayout, /bin/systemctl stop ffplayout, /bin/systemctl status ffplayout, /bin/journalctl -u ffplayout.service -n 1000
- place ffplayout-gui in your **www** folder and make it reachable for your network
- symlink your media folder to the root folder (the author from DirectoryLister don't recommend this way...):
- ln -s /AtvMedia /var/www/ffplayout/
- the ffplayout-gui need read access to /etc/ffplayout/ffplayout.conf
- the gui needs also read access to the ffplayout log file
- that's it
#### Logging Page
![logging](/assets/logging.png)
I hope the GUI is self explaining, when not I can write something to for it.
#### Configuration Page / GUI
![config-gui](/assets/config-gui.png)

BIN
assets/config-gui.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

BIN
assets/control.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

BIN
assets/landing-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
assets/logging.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

BIN
assets/login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
assets/media-upload.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
assets/media.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

BIN
assets/message.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

83
docs/db_data.json Normal file
View File

@ -0,0 +1,83 @@
[{
"model": "api_player.guisettings",
"pk": 1,
"fields": {
"player_url": "http://localhost/live/stream.m3u8",
"playout_config": "/etc/ffplayout/ffplayout.yml",
"net_interface": "ens3",
"media_disk": "/",
"extra_extensions": ".jpg .jpeg .png"
}
}, {
"model": "api_player.messengepresets",
"pk": 1,
"fields": {
"name": "default",
"message": "Wellcome to ffplayout!",
"x": "(w-text_w)/2",
"y": "(h-text_h)/2",
"font_size": 24,
"font_spacing": 4,
"font_color": "#ffffff",
"font_alpha": 1.0,
"show_box": true,
"box_color": "#000000",
"box_alpha": 0.8,
"border_width": 4,
"overall_alpha": "1"
}
}, {
"model": "api_player.messengepresets",
"pk": 2,
"fields": {
"name": "empty-text",
"message": null,
"x": null,
"y": null,
"font_size": 24,
"font_spacing": 4,
"font_color": "#ffffff",
"font_alpha": 1.0,
"show_box": false,
"box_color": "#000000",
"box_alpha": 0.8,
"border_width": 0,
"overall_alpha": "0"
}
}, {
"model": "api_player.messengepresets",
"pk": 3,
"fields": {
"name": "bottom-text-fade-in",
"message": "The upcoming event will be delayed by a few minutes.",
"x": "(w-text_w)/2",
"y": "(h-line_h)*0.9",
"font_size": 24,
"font_spacing": 4,
"font_color": "#ffffff",
"font_alpha": 1.0,
"show_box": true,
"box_color": "#000000",
"box_alpha": 0.8,
"border_width": 4,
"overall_alpha": "ifnot(ld(1),st(1,t));if(lt(t,ld(1)+1),0,if(lt(t,ld(1)+2),(t-(ld(1)+1))/1,if(lt(t,ld(1)+8),1,if(lt(t,ld(1)+9),(1-(t-(ld(1)+8)))/1,0))))"
}
}, {
"model": "api_player.messengepresets",
"pk": 4,
"fields": {
"name": "scrolling-text",
"message": "We have a very important announcement to make.",
"x": "ifnot(ld(1),st(1,t));if(lt(t,ld(1)+1),w+4,w-w/12*mod(t-ld(1),12*(w+tw)/w))",
"y": "(h-line_h)*0.9",
"font_size": 24,
"font_spacing": 4,
"font_color": "#ffffff",
"font_alpha": 1.0,
"show_box": true,
"box_color": "#000000",
"box_alpha": 0.8,
"border_width": 4,
"overall_alpha": "1"
}
}]

15
docs/developer-info.md Normal file
View File

@ -0,0 +1,15 @@
### Backend Apps
If you planing to extend the backend with your own apps (api endpoints),
just add your app in folder: **ffplayout/apps**.
If you planing to us a DB, put a **settings.py** file in your app. With this object:
```python
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db-name.sqlite3'),
}
}
```

View File

@ -0,0 +1,14 @@
[Unit]
Description=ffplayout api daemon
After=network.target
[Service]
WorkingDirectory=/var/www/ffplayout/ffplayout
Environment=DJANGO_SETTINGS_MODULE=ffplayout.settings.production
ExecStart=/var/www/ffplayout/venv/bin/gunicorn --workers 6 --timeout 300 --log-level=info --log-file=- --access-logfile=- --bind unix:/var/www/ffplayout/ffplayout/ffplayout.sock ffplayout.wsgi:application
KillMode=process
User=www-data
Group=www-data
[Install]
WantedBy=multi-user.target

65
docs/ffplayout.conf Normal file
View File

@ -0,0 +1,65 @@
map $sent_http_content_type $expires {
"text/html" 1h; # set this to your needs
"text/html; charset=utf-8" 1h; # set this to your needs
default 1d; # set this to your needs
}
server {
listen 127.0.1.4;
server_name ffplayout.local ffplayout;
gzip on;
gzip_types text/plain application/xml text/css application/javascript;
gzip_min_length 1000;
charset utf-8;
client_max_body_size 7000M; # should be desirable value
add_header X-Frame-Options SAMEORIGIN;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
location / {
if ($http_origin ~ '^https?://(localhost|ffplayout\.local|ffplayout)') {
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Requested-With' always;
}
if ($request_method = OPTIONS ) {
add_header 'Access-Control-Max-Age' 1728000;
add_header 'Content-Type' 'text/plain charset=UTF-8';
add_header 'Content-Length' 0;
return 204;
}
root /var/www/ffplayout/ffplayout/frontend/dist/;
}
location ~ ^/(api|admin) {
if ($http_origin ~ '^https?://(localhost|ffplayout\.local|ffplayout)') {
add_header 'Access-Control-Allow-Origin' "$http_origin" always;
add_header 'Access-Control-Allow-Credentials' 'true' always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS' always;
add_header 'Access-Control-Allow-Headers' 'Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Requested-With' always;
}
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://unix:/var/www/ffplayout/ffplayout/ffplayout.sock;
}
location /static/ {
alias /var/www/ffplayout/ffplayout/static/;
}
access_log /var/log/nginx/ffplayout_access.log;
error_log /var/log/nginx/ffplayout_error.log warn;
}

64
docs/install.md Normal file
View File

@ -0,0 +1,64 @@
# Manuel Installation Guide
**We are assuming that the system user `www-data` will run all processes!**
### API Setup
##### Preparation
- clone repo to `/var/www/ffplayout`
- cd in root folder from repo
- add virtual environment: `virtualenv -p python3 venv`
- run `source ./venv/bin/activate`
- install dependencies: `pip install -r requirements-base.txt`
- cd in `ffplayout`
- generate and copy secret: `python manage.py shell -c 'from django.core.management import utils; print(utils.get_random_secret_key())'`
- open **ffplayout/settings/production.py**
- past secret key in variable `SECRET_KEY`
- set `ALLOWED_HOSTS` with correct URL
- set URL in `CORS_ORIGIN_WHITELIST`
- migrate database: `python manage.py makemigrations && python manage.py migrate`
- collect static files: `python manage.py collectstatic`
- add super user to db: `python manage.py createsuperuser`
- populate some data to db: `python manage.py loaddata ../docs/db_data.json`
- run: `chown www-data. -R /var/www/ffplayout`
##### System Setup
- copy **docs/ffplayout-api.service** from root folder to **/etc/systemd/system/**
- enable service and start it: `systemctl enable ffplayout-api.service && systemctl start ffplayout-api.service`
- install **nginx**
- edit **docs/ffplayout.conf**
- set correct IP and `server_name`
- add domain `http_origin` test value
- add https redirection and SSL if is necessary
- copy **docs/ffplayout.conf** to **/etc/nginx/sites-available/**
- symlink config: `ln -s /etc/nginx/sites-available/ffplayout.conf /etc/nginx/sites-enabled/`
- restart nginx
- run `visudo` and add:
```
www-data ALL = NOPASSWD: /bin/systemctl start ffplayout-engine.service, /bin/systemctl stop ffplayout-engine.service, /bin/systemctl reload ffplayout-engine.service, /bin/systemctl restart ffplayout-engine.service, /bin/systemctl status ffplayout-engine.service, /bin/systemctl is-active ffplayout-engine.service, /bin/journalctl -n 1000 -u ffplayout-engine.service
```
### Frontend
**We need a recent version of npm**
- go to folder **/var/www/ffplayout/ffplayout/frontend**
- install dependencies: `npm install`
- build app: `npm run build`
Your frontend should be now in **/var/www/ffplayout/ffplayout/frontend/dist** folder, which we are included already in the nginx config. You can serve now the GUI under your domain URL.
### OS Specific
On debian 10 you need to install:
```
apt install -y curl
```
```
curl -sL https://deb.nodesource.com/setup_14.x | bash -
```
```
apt install -y sudo net-tools git python3-dev build-essential virtualenv python3-virtualenv nodejs nginx autoconf automake libtool pkg-config texi2html yasm cmake curl mercurial git wget gperf
```

Binary file not shown.

Before

Width:  |  Height:  |  Size: 274 KiB

View File

View File

@ -0,0 +1,21 @@
from apps.api_player.models import GuiSettings, MessengePresets
from django.contrib import admin
class GuiSettingsAdmin(admin.ModelAdmin):
class Meta:
model = GuiSettings
fields = '__all__'
class MessengePresetsAdmin(admin.ModelAdmin):
list_display = ('name',)
class Meta:
model = MessengePresets
fields = '__all__'
admin.site.register(GuiSettings, GuiSettingsAdmin)
admin.site.register(MessengePresets, MessengePresetsAdmin)

View File

@ -0,0 +1,5 @@
from django.apps import AppConfig
class ApiPlayerConfig(AppConfig):
name = 'api_player'

View File

@ -0,0 +1,70 @@
import psutil
from django.db import models
class GuiSettings(models.Model):
"""
Here we manage the settings for the web GUI:
- Player URL
- settings for the statistics
"""
addrs = psutil.net_if_addrs()
addrs = [(i, i) for i in addrs.keys()]
player_url = models.CharField(max_length=255)
playout_config = models.CharField(
max_length=255,
default='/etc/ffplayout/ffplayout.yml')
net_interface = models.CharField(
max_length=20,
choices=addrs,
default=None,
)
media_disk = models.CharField(
max_length=255,
help_text="should be a mount point, for statistics",
default='/')
extra_extensions = models.CharField(
max_length=255,
help_text="file extensions, that are only visible in GUI",
blank=True, null=True, default='')
def save(self, *args, **kwargs):
if self.pk is not None or GuiSettings.objects.count() == 0:
super(GuiSettings, self).save(*args, **kwargs)
def delete(self, *args, **kwargs):
if not self.related_query.all():
super(GuiSettings, self).delete(*args, **kwargs)
class Meta:
verbose_name_plural = "guisettings"
class MessengePresets(models.Model):
name = models.CharField(max_length=255, help_text="the preset name")
message = models.CharField(
max_length=1024, blank=True, null=True, default='')
x = models.CharField(
max_length=512, blank=True, null=True, default='')
y = models.CharField(
max_length=512, blank=True, null=True, default='')
font_size = models.IntegerField(default=24)
font_spacing = models.IntegerField(default=4)
font_color = models.CharField(max_length=12, default='#ffffff')
font_alpha = models.FloatField(default=1.0)
show_box = models.BooleanField(default=True)
box_color = models.CharField(max_length=12, default='#000000')
box_alpha = models.FloatField(default=0.8)
border_width = models.IntegerField(default=4)
overall_alpha = models.CharField(
max_length=255, blank=True, null=True, default='')
class Meta:
verbose_name_plural = "messengepresets"

View File

@ -0,0 +1,61 @@
from apps.api_player.models import GuiSettings, MessengePresets
from django.contrib.auth.models import User
from rest_framework import serializers
class UserSerializer(serializers.ModelSerializer):
new_password = serializers.CharField(write_only=True, required=False)
old_password = serializers.CharField(write_only=True, required=False)
class Meta:
model = User
fields = ['id', 'username', 'old_password',
'new_password', 'email']
def update(self, instance, validated_data):
print(validated_data)
instance.password = validated_data.get('password', instance.password)
if 'new_password' in validated_data and \
'old_password' in validated_data:
if not validated_data['new_password']:
raise serializers.ValidationError({'new_password': 'not found'})
if not validated_data['old_password']:
raise serializers.ValidationError({'old_password': 'not found'})
if not instance.check_password(validated_data['old_password']):
raise serializers.ValidationError(
{'old_password': 'wrong password'})
if validated_data['new_password'] and \
instance.check_password(validated_data['old_password']):
# instance.password = validated_data['new_password']
instance.set_password(validated_data['new_password'])
instance.save()
return instance
elif 'email' in validated_data:
instance.email = validated_data['email']
instance.save()
return instance
return instance
class GuiSettingsSerializer(serializers.ModelSerializer):
class Meta:
model = GuiSettings
fields = '__all__'
def get_fields(self, *args, **kwargs):
fields = super().get_fields(*args, **kwargs)
request = self.context.get('request')
if request is not None and not request.parser_context.get('kwargs'):
fields.pop('id', None)
return fields
class MessengerSerializer(serializers.ModelSerializer):
class Meta:
model = MessengePresets
fields = '__all__'

View File

@ -0,0 +1,10 @@
import os
BASE_DIR = os.path.dirname(os.path.abspath(os.path.join(__file__)))
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
}
}

View File

@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@ -0,0 +1,26 @@
from django.urls import include, path, re_path
from rest_framework import routers
from . import views
router = routers.DefaultRouter()
router.register(r'user/users', views.UserViewSet)
router.register(r'guisettings', views.GuiSettingsViewSet, 'guisettings')
router.register(r'messenger', views.MessengerViewSet, 'messenger')
app_name = 'api_player'
urlpatterns = [
path('player/', include(router.urls)),
path('player/config/', views.Config.as_view()),
path('player/log/', views.LogReader.as_view()),
path('player/media/', views.Media.as_view()),
path('player/media/op/', views.FileOperations.as_view()),
re_path(r'^player/media/upload/(?P<filename>[^/]+)$',
views.FileUpload.as_view()),
path('player/messenger/send/', views.MessegeSender.as_view()),
path('player/playlist/', views.Playlist.as_view()),
path('player/stats/', views.Statistics.as_view()),
path('player/user/current/', views.CurrentUserView.as_view()),
path('player/system/', views.SystemCtl.as_view()),
]

View File

@ -0,0 +1,293 @@
import json
import os
from platform import uname
from subprocess import PIPE, STDOUT, run
from time import sleep
import psutil
import yaml
import zmq
from apps.api_player.models import GuiSettings
from django.conf import settings
from natsort import natsorted
from pymediainfo import MediaInfo
def read_yaml():
config = GuiSettings.objects.filter(id=1).values()[0]
if config and os.path.isfile(config['playout_config']):
with open(config['playout_config'], 'r') as config_file:
return yaml.safe_load(config_file)
def write_yaml(data):
config = GuiSettings.objects.filter(id=1).values()[0]
if os.path.isfile(config['playout_config']):
with open(config['playout_config'], 'w') as outfile:
yaml.dump(data, outfile, default_flow_style=False,
sort_keys=False, indent=4)
def read_json(date):
config = read_yaml()['playlist']['path']
y, m, d = date.split('-')
input = os.path.join(config, y, m, '{}.json'.format(date))
if os.path.isfile(input):
with open(input, 'r') as playlist:
return json.load(playlist)
def write_json(data):
config = read_yaml()['playlist']['path']
y, m, d = data['date'].split('-')
output = os.path.join(config, y, m, '{}.json'.format(data['date']))
with open(output, "w") as outfile:
json.dump(data, outfile, indent=4)
def read_log(type):
config = read_yaml()
log_path = config['logging']['log_path']
log_file = os.path.join(log_path, '{}.log'.format(type))
if os.path.isfile(log_file):
with open(log_file, 'r') as log:
return log.read().strip()
def send_message(data):
config = read_yaml()
address, port = config['text']['bind_address'].split(':')
context = zmq.Context(1)
client = context.socket(zmq.REQ)
client.connect('tcp://{}:{}'.format(address, port))
poll = zmq.Poller()
poll.register(client, zmq.POLLIN)
request = ''
reply_msg = ''
for key, value in data.items():
request += "{}='{}':".format(key, value)
request = "{} reinit {}".format(settings.DRAW_TEXT_NODE, request.rstrip(':'))
client.send_string(request)
socks = dict(poll.poll(settings.REQUEST_TIMEOUT))
if socks.get(client) == zmq.POLLIN:
reply = client.recv()
if reply and reply.decode() == '0 Success':
reply_msg = reply.decode()
else:
reply_msg = reply.decode()
else:
reply_msg = 'No response from server'
client.setsockopt(zmq.LINGER, 0)
client.close()
poll.unregister(client)
context.term()
return {'Success': reply_msg}
def sizeof_fmt(num, suffix='B'):
for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']:
if abs(num) < 1024.0:
return "%3.1f%s%s" % (num, unit, suffix)
num /= 1024.0
return "%.1f%s%s" % (num, 'Yi', suffix)
class PlayoutService:
def __init__(self):
self.service = ['ffplayout-engine.service']
self.cmd = ['sudo', '/bin/systemctl']
self.proc = None
def run_cmd(self):
self.proc = run(self.cmd + self.service, stdout=PIPE, stderr=STDOUT,
encoding="utf-8").stdout
def start(self):
self.cmd.append('start')
self.run_cmd()
def stop(self):
self.cmd.append('stop')
self.run_cmd()
def reload(self):
self.cmd.append('reload')
self.run_cmd()
def restart(self):
self.cmd.append('restart')
self.run_cmd()
def status(self):
self.cmd.append('is-active')
self.run_cmd()
return self.proc.replace('\n', '')
def log(self):
self.cmd = ['sudo', '/bin/journalctl', '-n', '1000', '-u']
self.run_cmd()
return self.proc
class SystemStats:
def __init__(self):
self.config = GuiSettings.objects.filter(id=1).values()[0]
def all(self):
return {
**self.system(),
**self.cpu(), **self.ram(), **self.swap(),
**self.disk(), **self.net(), **self.net_speed()
}
def system(self):
return {
'system': uname().system,
'node': uname().node,
'machine': uname().machine
}
def cpu(self):
return {
'cpu_usage': psutil.cpu_percent(interval=1),
'cpu_load': list(psutil.getloadavg())
}
def ram(self):
mem = psutil.virtual_memory()
return {
'ram_total': [mem.total, sizeof_fmt(mem.total)],
'ram_used': [mem.used, sizeof_fmt(mem.used)],
'ram_free': [mem.free, sizeof_fmt(mem.free)],
'ram_cached': [mem.cached, sizeof_fmt(mem.cached)]
}
def swap(self):
swap = psutil.swap_memory()
return {
'swap_total': [swap.total, sizeof_fmt(swap.total)],
'swap_used': [swap.used, sizeof_fmt(swap.used)],
'swap_free': [swap.free, sizeof_fmt(swap.free)]
}
def disk(self):
root = psutil.disk_usage(self.config['media_disk'])
return {
'disk_total': [root.total, sizeof_fmt(root.total)],
'disk_used': [root.used, sizeof_fmt(root.used)],
'disk_free': [root.free, sizeof_fmt(root.free)]
}
def net(self):
net = psutil.net_io_counters()
return {
'net_send': [net.bytes_sent, sizeof_fmt(net.bytes_sent)],
'net_recv': [net.bytes_recv, sizeof_fmt(net.bytes_recv)],
'net_errin': net.errin,
'net_errout': net.errout
}
def net_speed(self):
net = psutil.net_if_stats()
if self.config['net_interface'] not in net:
return {
'net_speed_send': 'no network interface set!',
'net_speed_recv': 'no network interface set!'
}
net = psutil.net_io_counters(pernic=True)[self.config['net_interface']]
send_start = net.bytes_sent
recv_start = net.bytes_recv
sleep(1)
net = psutil.net_io_counters(pernic=True)[self.config['net_interface']]
send_end = net.bytes_sent
recv_end = net.bytes_recv
send_sec = send_end - send_start
recv_sec = recv_end - recv_start
return {
'net_speed_send': [send_sec, sizeof_fmt(send_sec)],
'net_speed_recv': [recv_sec, sizeof_fmt(recv_sec)]
}
def get_media_path(extensions, dir=None):
config = read_yaml()
extensions = extensions.split(' ')
playout_extensions = config['storage']['extensions']
gui_extensions = [x for x in extensions if x not in playout_extensions]
media_path = config['storage']['path'].replace('\\', '/').rstrip('/')
media_dir = media_path.split('/')[-1]
media_root = os.path.dirname(media_path)
if not dir:
dir = media_path
else:
if '/..' in dir:
# remove last folder to navigate in upper directory
dir = '/'.join(dir.split('/')[:-2])
dir = dir.lstrip('/')
if dir.startswith(media_dir):
dir = dir[len(media_dir):]
dir = os.path.join(
media_root, media_dir, os.path.abspath('/' + dir).strip('/'))
for root, dirs, files in os.walk(dir, topdown=True):
root = root.rstrip('/')
media_files = []
for file in files:
ext = os.path.splitext(file)[1]
if ext in playout_extensions:
media_info = MediaInfo.parse(os.path.join(root, file))
duration = 0
for track in media_info.tracks:
if track.track_type == 'General':
try:
duration = float(
track.to_data()["duration"]) / 1000
break
except KeyError:
pass
media_files.append({'file': file, 'duration': duration})
elif ext in gui_extensions:
media_files.append({'file': file, 'duration': ''})
dirs = natsorted(dirs)
if root != media_path:
dirs.insert(0, '..')
if not dirs:
dirs = ['..']
if root.startswith(media_root):
root = root[len(media_root):]
return [root, dirs, natsorted(media_files, key=lambda x: x['file'])]

View File

@ -0,0 +1,268 @@
import os
import shutil
from urllib.parse import unquote
from apps.api_player.models import GuiSettings, MessengePresets
from apps.api_player.serializers import (GuiSettingsSerializer, MessengerSerializer,
UserSerializer)
from django.contrib.auth.models import User
from django_filters import rest_framework as filters
from rest_framework import viewsets
from rest_framework.parsers import FileUploadParser, JSONParser
from rest_framework.response import Response
from rest_framework.views import APIView
from .utils import (PlayoutService, SystemStats, get_media_path, read_json,
read_log, read_yaml, send_message, write_json, write_yaml)
class CurrentUserView(APIView):
def get(self, request):
serializer = UserSerializer(request.user)
return Response(serializer.data)
class UserFilter(filters.FilterSet):
class Meta:
model = User
fields = ['username']
class UserViewSet(viewsets.ModelViewSet):
queryset = User.objects.all()
serializer_class = UserSerializer
filter_backends = (filters.DjangoFilterBackend,)
filterset_class = UserFilter
class GuiSettingsViewSet(viewsets.ModelViewSet):
"""
API endpoint that allows media to be viewed.
"""
queryset = GuiSettings.objects.all()
serializer_class = GuiSettingsSerializer
class MessengerFilter(filters.FilterSet):
class Meta:
model = MessengePresets
fields = ['name']
class MessengerViewSet(viewsets.ModelViewSet):
queryset = MessengePresets.objects.all()
serializer_class = MessengerSerializer
filter_backends = (filters.DjangoFilterBackend,)
filterset_class = MessengerFilter
class MessegeSender(APIView):
def post(self, request, *args, **kwargs):
if 'data' in request.data:
response = send_message(request.data['data'])
return Response({"success": True, 'status': response})
return Response({"success": False})
class Config(APIView):
"""
read and write config from ffplayout engine
for reading endpoint is: http://127.0.0.1:8000/api/player/config/?config
"""
parser_classes = [JSONParser]
def get(self, request, *args, **kwargs):
if 'configPlayout' in request.GET.dict():
yaml_input = read_yaml()
if yaml_input:
return Response(yaml_input)
else:
return Response({
"success": False,
"error": "ffpayout engine config file not found!"})
else:
return Response({"success": False})
def post(self, request, *args, **kwargs):
if 'data' in request.data:
write_yaml(request.data['data'])
return Response({"success": True})
return Response({"success": False})
class SystemCtl(APIView):
"""
controlling the ffplayout-engine systemd services
"""
def post(self, request, *args, **kwargs):
if 'run' in request.data:
service = PlayoutService()
if request.data['run'] == 'start':
service.start()
return Response({"success": True})
elif request.data['run'] == 'stop':
service.stop()
return Response({"success": True})
elif request.data['run'] == 'reload':
service.reload()
return Response({"success": True})
elif request.data['run'] == 'restart':
service.restart()
return Response({"success": True})
elif request.data['run'] == 'status':
status = service.status()
return Response({"data": status})
elif request.data['run'] == 'log':
log = service.log()
return Response({"data": log})
else:
return Response({"success": False})
return Response({"success": False})
class LogReader(APIView):
def get(self, request, *args, **kwargs):
if 'type' in request.GET.dict():
type = request.GET.dict()['type']
log = read_log(type)
if log:
return Response({'log': log})
else:
return Response({
"success": False,
"error": "PLayout log file not found!"})
else:
return Response({"success": False})
class Playlist(APIView):
"""
read and write config from ffplayout engine
for reading endpoint:
http://127.0.0.1:8000/api/player/playlist/?date=2020-04-12
"""
def get(self, request, *args, **kwargs):
if 'date' in request.GET.dict():
date = request.GET.dict()['date']
json_input = read_json(date)
if json_input:
return Response(json_input)
else:
return Response({
"success": False,
"error": "Playlist from {} not found!".format(date)})
else:
return Response({"success": False})
def post(self, request, *args, **kwargs):
if 'data' in request.data:
write_json(request.data['data'])
return Response({"success": True})
return Response({"success": False})
class Statistics(APIView):
"""
get system statistics: cpu, ram, etc.
for reading, endpoint is: http://127.0.0.1:8000/api/player/stats/?stats=all
"""
def get(self, request, *args, **kwargs):
stats = SystemStats()
if 'stats' in request.GET.dict() and request.GET.dict()['stats'] \
and hasattr(stats, request.GET.dict()['stats']):
return Response(
getattr(stats, request.GET.dict()['stats'])())
else:
return Response({"success": False})
class Media(APIView):
"""
get folder/files tree, for building a file explorer
for reading, endpoint is: http://127.0.0.1:8000/api/player/media/?path
"""
def get(self, request, *args, **kwargs):
if 'extensions' in request.GET.dict():
extensions = request.GET.dict()['extensions']
if 'path' in request.GET.dict() and request.GET.dict()['path']:
return Response({'tree': get_media_path(
extensions, request.GET.dict()['path']
)})
elif 'path' in request.GET.dict():
return Response({'tree': get_media_path(extensions)})
else:
return Response({"success": False})
else:
return Response({"success": False})
class FileUpload(APIView):
parser_classes = [FileUploadParser]
def put(self, request, filename, format=None):
root = read_yaml()['storage']['path']
file_obj = request.data['file']
filename = unquote(filename)
path = unquote(request.query_params['path']).split('/')[1:]
with open(os.path.join(root, *path, filename), 'wb') as outfile:
for chunk in file_obj.chunks():
outfile.write(chunk)
return Response(status=204)
class FileOperations(APIView):
def delete(self, request, *args, **kwargs):
if 'file' in request.GET.dict() and 'path' in request.GET.dict():
root = read_yaml()['storage']['path']
_file = request.GET.dict()['file']
_path = os.path.join(
*(request.GET.dict()['path'].split(os.path.sep)[2:]))
fullPath = os.path.join(root, _path)
if not _file or _file == 'null':
if os.path.isdir(fullPath):
shutil.rmtree(fullPath, ignore_errors=True)
return Response(status=200)
else:
Response(status=404)
elif os.path.isfile(os.path.join(fullPath, _file)):
os.remove(os.path.join(fullPath, _file))
return Response(status=200)
else:
Response(status=404)
else:
return Response(status=404)
def post(self, request, *args, **kwargs):
if 'folder' in request.data and 'path' in request.data:
root = read_yaml()['storage']['path']
folder = request.data['folder']
_path = request.data['path'].split(os.path.sep)
_path = '' if len(_path) == 1 else os.path.join(*_path[1:])
fullPath = os.path.join(root, _path, folder)
try:
os.mkdir(fullPath)
return Response(status=200)
except OSError:
Response(status=500)
else:
return Response(status=404)

View File

View File

@ -0,0 +1,17 @@
"""
ASGI config for ffplayout project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault(
'DJANGO_SETTINGS_MODULE', 'ffplayout.settings.development')
application = get_asgi_application()

View File

View File

@ -0,0 +1,141 @@
"""
Django settings for ffplayout project.
Generated by 'django-admin startproject' using Django 3.0.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.0/ref/settings/
"""
import os
from datetime import timedelta
from pydoc import locate
# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
BASE_DIR = os.path.dirname(
os.path.dirname(os.path.abspath(os.path.join(__file__, '..'))))
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'django_filters',
'rest_framework',
'corsheaders'
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'ffplayout.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'ffplayout.wsgi.application'
# Database
# https://docs.djangoproject.com/en/3.0/ref/settings/#databases
DATABASES = {}
# Password validation
# https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
}
]
# simple JWT auth settings
SIMPLE_JWT = {
'ACCESS_TOKEN_LIFETIME': timedelta(minutes=5),
'REFRESH_TOKEN_LIFETIME': timedelta(days=7),
}
# Internationalization
# https://docs.djangoproject.com/en/3.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_L10N = True
USE_TZ = True
# dynamic app loader
APPS_DIR = os.path.join(BASE_DIR, 'apps/')
for dir in os.listdir(APPS_DIR):
if os.path.isdir(os.path.join(APPS_DIR, dir)):
app_name = 'apps.{}'.format(dir)
if app_name not in INSTALLED_APPS:
# add app to installed apps
INSTALLED_APPS += (app_name, )
if os.path.isfile(os.path.join(APPS_DIR, dir, 'settings.py')):
db = locate('{}.settings.DATABASES'.format(app_name))
for key in db:
if key not in DATABASES:
# add app db to DATABASES
DATABASES.update({key: db[key]})
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.0/howto/static-files/
STATIC_URL = '/static/'
STATIC_ROOT = '/var/www/ffplayout/ffplayout/static/'
# ffmpeg filter node, needs to be edit only when the filter chain changes
DRAW_TEXT_NODE = 'Parsed_drawtext_2'
# zmq settings
REQUEST_TIMEOUT = 1000

View File

@ -0,0 +1,26 @@
from ffplayout.settings.common import *
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'dhgfk(gl&16krnt_7*dp(9b3w*ft%nbsg-h2)&ihbte4le#o4f'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ['*']
# REST API
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
)
}
CORS_ORIGIN_WHITELIST = (
'http://localhost:3000',
'http://localhost:8000',
'http://ffplayout.local'
)

View File

@ -0,0 +1,25 @@
from ffplayout.settings.common import *
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = '---a-very-important-secret-key:-generate-it-new---'
DEBUG = False
ALLOWED_HOSTS = ['localhost']
# REST API
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_RENDERER_CLASSES': (
'rest_framework.renderers.JSONRenderer',
),
'DEFAULT_PERMISSION_CLASSES': (
'rest_framework.permissions.IsAuthenticated',
)
}
CORS_ORIGIN_WHITELIST = (
'http://ffplayout.local',
)

View File

@ -0,0 +1,44 @@
"""ffplayout URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/3.0/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
import os
from django.contrib import admin
from django.conf import settings
from django.urls import include, path
from rest_framework_simplejwt.views import (TokenObtainPairView,
TokenRefreshView)
urlpatterns = [
path('admin/', admin.site.urls),
path('api-auth/', include(
'rest_framework.urls', namespace='rest_framework')),
path('auth/token/', TokenObtainPairView.as_view(),
name='token_obtain_pair'),
path('auth/token/refresh/', TokenRefreshView.as_view(),
name='token_refresh')
]
# dynamic url loader
for dir in os.listdir(settings.APPS_DIR):
if os.path.isdir(os.path.join(settings.APPS_DIR, dir)):
app_name = 'apps.{}'.format(dir)
_path = path('api/', include(
'{}.urls'.format(app_name),
namespace='{}'.format(app_name.split('.')[1])))
if _path not in urlpatterns:
urlpatterns += (_path, )

View File

@ -0,0 +1,17 @@
"""
WSGI config for ffplayout project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault(
'DJANGO_SETTINGS_MODULE', 'ffplayout.settings.development')
application = get_wsgi_application()

View File

@ -0,0 +1,13 @@
# editorconfig.org
root = true
[*]
indent_style = space
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

View File

@ -0,0 +1,23 @@
module.exports = {
root: true,
env: {
browser: true,
node: true
},
parserOptions: {
parser: 'babel-eslint'
},
extends: [
'@nuxtjs',
'plugin:nuxt/recommended'
],
// add your custom rules here
rules: {
'vue/html-indent': ['error', 4],
'vue/html-closing-bracket-newline': 'off',
'indent': [2, 4],
'no-tabs': 'off',
"no-console": 0,
"camelcase": ["error", {properties: "never"}]
}
}

90
ffplayout/frontend/.gitignore vendored Normal file
View File

@ -0,0 +1,90 @@
# Created by .ignore support plugin (hsz.mobi)
### Node template
# Logs
/logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
# nyc test coverage
.nyc_output
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# Nuxt generate
dist
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# IDE / Editor
.idea
# Service worker
sw.*
# Mac OSX
.DS_Store
# Vim swap files
*.swp

View File

@ -0,0 +1,27 @@
# ffplayout
> web GUI for ffplayout engine
## Build Setup
create `.env`file with `API_URL`, for example:
```
API_URL="http://localhost:8000"
```
``` bash
# install dependencies
$ npm run install
# serve with hot reload at localhost:3000
$ npm run dev
# build for production and launch server
$ npm run build
$ npm run start
# generate static project
$ npm run generate
```
For detailed explanation on how things work, check out [Nuxt.js docs](https://nuxtjs.org).

View File

@ -0,0 +1,7 @@
# ASSETS
**This directory is not required, you can delete it if you don't want to use it.**
This directory contains your un-compiled assets such as LESS, SASS, or JavaScript.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#webpacked).

View File

@ -0,0 +1,497 @@
// Slate 4.3.1
// Bootswatch
// Variables ===================================================================
@mixin btn-shadow($color){
@include gradient-y-three-colors(lighten($color, 6%), $color, 60%, darken($color, 4%));
filter: none;
}
@mixin btn-shadow-inverse($color){
@include gradient-y-three-colors(darken($color, 18%), darken($color, 15%), 40%, darken($color, 13%));
filter: none;
}
// Navbar ======================================================================
.navbar {
border: 1px solid rgba(0, 0, 0, 0.6);
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3);
.container {
padding: 0;
}
.navbar-toggler {
border-color: rgba(0, 0, 0, 0.6);
}
&-fixed-top {
border-width: 0 0 1px 0;
}
&-fixed-bottom {
border-width: 1px 0 0 0;
}
.nav-link {
padding: 1rem;
border-left: 1px solid rgba(255, 255, 255, 0.1);
border-right: 1px solid rgba(0, 0, 0, 0.2);
&:hover,
&:focus {
@include btn-shadow-inverse($gray-800);
border-left: 1px solid rgba(0, 0, 0, 0.2);
}
}
&-brand {
padding: 0.75rem 1rem calc(54px - 0.75rem - 30px);
margin-right: 0;
border-right: 1px solid rgba(0, 0, 0, 0.2);
}
.nav-item.active .nav-link {
background-color: rgba(0, 0, 0, 0.3);
border-left: 1px solid rgba(0, 0, 0, 0.2);
}
&-nav .nav-item + .nav-item {
margin-left: 0;
}
&.bg-light {
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.1);
.nav-link {
&:hover,
&:focus {
@include btn-shadow-inverse($gray-600);
border-left: 1px solid rgba(0, 0, 0, 0.2);
}
}
}
}
@media (max-width: 576px) {
.navbar-expand-sm {
.navbar-brand,
.nav-link {
border: none !important;
}
}
}
@media (max-width: 768px) {
.navbar-expand-md {
.navbar-brand,
.nav-link {
border: none !important;
}
}
}
@media (max-width: 992px) {
.navbar-expand-lg {
.navbar-brand,
.nav-link {
border: none !important;
}
}
}
// Buttons =====================================================================
.btn {
border-color: rgba(0, 0, 0, 0.6);
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3);
&:not([disabled]):not(.disabled).active,
&.disabled {
border-color: rgba(0, 0, 0, 0.6);
box-shadow: none;
}
&:hover,
&:focus,
&:not([disabled]):not(.disabled):active,
&:not([disabled]):not(.disabled):active:hover,
&:not([disabled]):not(.disabled).active:hover {
border-color: rgba(0, 0, 0, 0.6);
}
}
.btn-primary {
@include btn-shadow($primary);
&:not([disabled]):not(.disabled):hover,
&:not([disabled]):not(.disabled):focus,
&:not([disabled]):not(.disabled):active:hover,
&:not([disabled]):not(.disabled).active:hover {
@include btn-shadow-inverse($primary);
}
}
.btn-secondary {
@include btn-shadow($secondary);
&:not([disabled]):not(.disabled):hover,
&:not([disabled]):not(.disabled):focus,
&:not([disabled]):not(.disabled):active,
&:not([disabled]):not(.disabled).active {
@include btn-shadow-inverse($secondary);
}
}
.btn-success {
@include btn-shadow($success);
color: $white;
&:not([disabled]):not(.disabled):hover,
&:not([disabled]):not(.disabled):focus,
&:not([disabled]):not(.disabled):active,
&:not([disabled]):not(.disabled).active {
@include btn-shadow-inverse($success);
}
}
.btn-info {
@include btn-shadow($info);
color: $white;
&:not([disabled]):not(.disabled):hover,
&:not([disabled]):not(.disabled):focus,
&:not([disabled]):not(.disabled):active,
&:not([disabled]):not(.disabled).active {
@include btn-shadow-inverse($info);
}
}
.btn-warning {
@include btn-shadow($warning);
color: $white;
&:not([disabled]):not(.disabled):hover,
&:not([disabled]):not(.disabled):focus,
&:not([disabled]):not(.disabled):active,
&:not([disabled]):not(.disabled).active {
@include btn-shadow-inverse($warning);
}
}
.btn-danger {
@include btn-shadow($danger);
&:not([disabled]):not(.disabled):hover,
&:not([disabled]):not(.disabled):focus,
&:not([disabled]):not(.disabled):active,
&:not([disabled]):not(.disabled).active {
@include btn-shadow-inverse($danger);
}
}
.btn-link,
.btn-link:hover {
border-color: transparent;
}
.btn-group,
.btn-group-vertical {
.btn.active {
border-color: rgba(0, 0, 0, 0.6);
}
}
// Typography ==================================================================
h1, h2, h3, h4, h5, h6 {
text-shadow: -1px -1px 0 rgba(0, 0, 0, 0.3);
}
// Tables ======================================================================
.table {
&-primary,
&-secondary,
&-success,
&-info,
&-warning,
&-danger {
color: #fff;
}
&-primary {
&, > th, > td {
background-color: $primary;
}
}
&-secondary {
&, > th, > td {
background-color: $secondary;
}
}
&-light {
&, > th, > td {
background-color: $light;
}
}
&-dark {
&, > th, > td {
background-color: $dark;
}
}
&-success {
&, > th, > td {
background-color: $success;
}
}
&-info {
&, > th, > td {
background-color: $info;
}
}
&-danger {
&, > th, > td {
background-color: $danger;
}
}
&-warning {
&, > th, > td {
background-color: $warning;
}
}
&-active {
&, > th, > td {
background-color: $table-active-bg;
}
}
&-hover {
.table-primary:hover {
&, > th, > td {
background-color: darken($primary, 5%);
}
}
.table-secondary:hover {
&, > th, > td {
background-color: darken($secondary, 5%);
}
}
.table-light:hover {
&, > th, > td {
background-color: darken($light, 5%);
}
}
.table-dark:hover {
&, > th, > td {
background-color: darken($dark, 5%);
}
}
.table-success:hover {
&, > th, > td {
background-color: darken($success, 5%);
}
}
.table-info:hover {
&, > th, > td {
background-color: darken($info, 5%);
}
}
.table-danger:hover {
&, > th, > td {
background-color: darken($danger, 5%);
}
}
.table-warning:hover {
&, > th, > td {
background-color: darken($warning, 5%);
}
}
.table-active:hover {
&, > th, > td {
background-color: $table-active-bg;
}
}
}
}
// Forms =======================================================================
legend {
color: #fff;
}
.input-group-addon {
@include btn-shadow($secondary);
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3);
color: $white;
}
// Navs ========================================================================
.nav-tabs {
.nav-link {
@include btn-shadow-inverse($gray-800);
border: 1px solid rgba(0, 0, 0, 0.6);
&:not([disabled]):not(.disabled):hover,
&:not([disabled]):not(.disabled):focus,
&:not([disabled]):not(.disabled):active,
&:not([disabled]):not(.disabled).active {
@include btn-shadow($gray-800);
}
&.disabled {
border: 1px solid rgba(0, 0, 0, 0.6);
}
}
.nav-link,
.nav-link:hover {
color: #fff;
}
}
.nav-pills {
.nav-link {
@include btn-shadow($gray-800);
border: 1px solid rgba(0, 0, 0, 0.6);
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3);
color: #fff;
&:hover {
@include btn-shadow-inverse($gray-800);
border: 1px solid rgba(0, 0, 0, 0.6);
}
}
.nav-link.active,
.nav-link:hover {
background-color: transparent;
@include btn-shadow-inverse($gray-800);
border: 1px solid rgba(0, 0, 0, 0.6);
}
.nav-link.disabled,
.nav-link.disabled:hover {
@include btn-shadow($gray-800);
color: $nav-link-disabled-color;
}
}
.pagination {
.page-link {
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3);
@include btn-shadow($gray-800);
&:hover {
@include btn-shadow-inverse($gray-800);
text-decoration: none;
}
}
.page-item.active .page-link {
@include btn-shadow-inverse($gray-800);
}
.page-item.disabled .page-link {
@include btn-shadow($gray-800);
}
}
.breadcrumb {
border: 1px solid rgba(0, 0, 0, 0.6);
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3);
background-color: transparent;
@include btn-shadow($gray-800);
a,
a:hover {
color: #fff;
}
}
// Indicators ==================================================================
.alert {
.close {
color: $close-color;
text-decoration: none;
}
}
.alert {
border: none;
color: $white;
a,
.alert-link {
color: #fff;
text-decoration: underline;
}
@each $color, $value in $theme-colors {
&-#{$color} {
background-color: $value;
}
}
&-light {
&,
& a:not(.btn),
& .alert-link {
color: $body-bg;
}
}
}
.badge {
&-success,
&-warning,
&-info {
color: $white;
}
}
// Progress bars ===============================================================
// Containers ==================================================================
.jumbotron {
border: 1px solid rgba(0, 0, 0, 0.6);
}
.list-group {
&-item:hover {
background-color: darken($gray-900, 5%);
}
}

View File

@ -0,0 +1,164 @@
// Slate 4.3.1
// Bootswatch
//
// Color system
//
$white: #fff !default;
$gray-100: #f8f9fa !default;
$gray-200: #e9ecef !default;
$gray-300: #dee2e6 !default;
$gray-400: #ced4da !default;
$gray-500: #999 !default;
$gray-600: #7A8288 !default;
$gray-700: #52575C !default;
$gray-800: #3A3F44 !default;
$gray-900: #272B30 !default;
$black: #000 !default;
$blue: #007bff !default;
$indigo: #6610f2 !default;
$purple: #6f42c1 !default;
$pink: #e83e8c !default;
$red: #ee5f5b !default;
$orange: #fd7e14 !default;
$yellow: #f89406 !default;
$green: #62c462 !default;
$teal: #20c997 !default;
$cyan: #5bc0de !default;
$primary: $gray-800 !default;
$secondary: $gray-600 !default;
$success: $green !default;
$info: $cyan !default;
$warning: $yellow !default;
$danger: $red !default;
$light: $gray-200 !default;
$dark: $gray-900 !default;
$yiq-contrasted-threshold: 170 !default;
// Body
$body-bg: $gray-900 !default;
$body-color: #aaa !default;
// Links
$link-color: $white !default;
// Fonts
$font-size-base: 0.9375rem !default;
// Tables
$table-color: $white !default;
$table-accent-bg: rgba($white,.05) !default;
$table-hover-bg: rgba($white,.075) !default;
$table-border-color: rgba($black,.6) !default;
$table-dark-border-color: $table-border-color !default;
$table-dark-color: $white !default;
// Buttons
$input-btn-padding-y: .75rem !default;
$input-btn-padding-x: 1rem !default;
// Forms
$input-disabled-bg: #ccc !default;
// Dropdowns
$dropdown-bg: $gray-800 !default;
$dropdown-border-color: rgba($black, .6) !default;
$dropdown-divider-bg: rgba($black,.15) !default;
$dropdown-link-color: $body-color !default;
$dropdown-link-hover-color: $white !default;
$dropdown-link-hover-bg: $body-bg !default;
// Navs
$nav-tabs-border-color: rgba($black, 0.6) !default;
$nav-tabs-link-hover-border-color: $nav-tabs-border-color !default;
$nav-tabs-link-active-color: $white !default;
$nav-tabs-link-active-border-color: $nav-tabs-border-color !default;
// Navbar
$navbar-padding-y: 0 !default;
$navbar-dark-hover-color: $white !default;
$navbar-light-hover-color: $gray-800 !default;
$navbar-light-active-color: $gray-800 !default;
// Pagination
$pagination-color: $white !default;
$pagination-bg: transparent !default;
$pagination-border-color: rgba($black, 0.6) !default;
$pagination-hover-color: $white !default;
$pagination-hover-bg: transparent !default;
$pagination-hover-border-color: rgba($black, 0.6) !default;
$pagination-active-bg: transparent !default;
$pagination-active-border-color: rgba($black, 0.6) !default;
$pagination-disabled-bg: transparent !default;
$pagination-disabled-border-color: rgba($black, 0.6) !default;
// Jumbotron
$jumbotron-bg: darken($gray-900, 5%) !default;
// Cards
$card-border-color: rgba($black, 0.6) !default;
$card-cap-bg: lighten($gray-800, 10%) !default;
$card-bg: lighten($body-bg, 5%) !default;
// Popovers
$popover-bg: lighten($body-bg, 5%) !default;
// Modals
$modal-content-bg: lighten($body-bg, 5%) !default;
$modal-header-border-color: rgba(0,0,0,.2) !default;
// Progress bars
$progress-bg: darken($gray-900, 5%) !default;
$progress-bar-color: $gray-600 !default;
// List group
$list-group-bg: lighten($body-bg, 5%) !default;
$list-group-border-color: rgba($black, 0.6) !default;
$list-group-hover-bg: lighten($body-bg, 10%) !default;
$list-group-active-color: $white !default;
$list-group-active-bg: $list-group-hover-bg !default;
$list-group-active-border-color: $list-group-border-color !default;
$list-group-disabled-color: $gray-700 !default;
$list-group-action-color: $white !default;
// Breadcrumbs
$breadcrumb-active-color: $gray-500 !default;
// Code
$pre-color: inherit !default;

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,93 @@
Copyright (c) 2015, Stephan Ahlf (https://github.com/s-a/digital-numbers-font stephan.ahlf@googlemail.com)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@ -0,0 +1,156 @@
#__nuxt, #__layout, #__layout > div, #__layout > div > div {
height: 100%
}
@font-face{
font-family: "DigitalNumbers-Regular";
src: url("~@/assets/fonts/DigitalNumbers-Regular.woff2") format("woff2");
font-weight: normal;
font-style: normal;
}
.breadcrumb {
margin-bottom: .2em;
}
.browser-item {
background: none;
padding: .1em;
border: none;
}
.browser-icons {
margin-right: .5em;
}
.breadcrumb {
background-color: #32383E;
background-image: none;
}
.duration {
float: right;
margin-right: 1em;
}
.form-control, .tags-list ul li div input, .custom-select, .custom-control-label::before {
color: #e4e4e4;
background-color: #32383E;
}
.form-control:disabled, .form-control[readonly] {
color: #b4b4b4;
background-color:#485159;
}
.custom-select {
background: #32383E url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='rgb(228, 228, 228)' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") no-repeat right 1rem center/8px 10px;
}
.form-control:focus, .form-control.focus, .b-form-tags.focus, .custom-select:focus {
color: #f5f5f5;
background-color: #424a53;
}
.custom-file-label {
background-color: #424a53;
color: #f5f5f5;
}
.custom-file-label::after {
background-color: #32383E;
color: #f5f5f5;
}
.alert {
margin-bottom: 0;
}
.browser-div {
width: 100%;
max-height: 100%;
}
.browser-div .ps {
padding-left: .4em;
}
.ps__thumb-x {
display: none;
}
.splitpanes__pane {
width: 30%;
}
.splitpanes.default-theme .splitpanes__pane {
background-color: $dark;
box-shadow: 0 0 10px rgba(0, 0, 0, .2) inset;
justify-content: center;
align-items: center;
display: flex;
position: relative;
}
.default-theme.splitpanes--vertical > .splitpanes__splitter, .default-theme .splitpanes--vertical > .splitpanes__splitter {
border-left: 1px solid $dark;
}
.splitpanes.default-theme .splitpanes__splitter {
background-color: $dark;
}
.splitpanes.default-theme .splitpanes__splitter::after, .splitpanes.default-theme .splitpanes__splitter::before {
background-color: rgba(136, 136, 136, 0.38);
}
.ps .ps__rail-x:hover, .ps .ps__rail-y:hover, .ps .ps__rail-x:focus, .ps .ps__rail-y:focus, .ps .ps__rail-x.ps--clicking, .ps .ps__rail-y.ps--clicking {
background-color: transparent;
}
.browser-icons-col {
max-width: 10px;
}
.browser-play-col {
max-width: 15px;
text-align: center;
margin: 0;
padding: 0;
}
.browser-dur-col {
min-width: 95px;
margin: 0;
padding: 0 10px 0 0;
}
.browser-div .ps {
height: 100%;
}
.playlist-container .ps {
height: 94.5%;
}
.browser-list {
max-height: 93%;
overflow-y: scroll;
}
.browser-item-text {
display: inline-block;
max-width: 95%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.progress-bar {
background-color: #ff9c36
}
.grabbing {
cursor: -webkit-grabbing;
cursor: grabbing;
}

View File

@ -0,0 +1,62 @@
<template>
<div>
<div class="menu">
<b-button-group size="sm">
<b-button to="/" variant="primary">
Home
</b-button>
<b-button to="/player" variant="primary">
Player
</b-button>
<b-button to="/media" variant="primary">
Media
</b-button>
<b-button to="/message" variant="primary">
Message
</b-button>
<b-button to="/logging" variant="primary">
Logging
</b-button>
<b-button to="/configure" variant="primary">
Configure
</b-button>
<b-button to="/" variant="primary" @click="logout()">
Logout
</b-button>
</b-button-group>
</div>
</div>
</template>
<script>
export default {
name: 'Menu',
computed: {
},
methods: {
async logout () {
try {
await this.$store.commit('auth/REMOVE_TOKEN')
await this.$store.commit('auth/UPDATE_IS_LOGIN', false)
} catch (e) {
this.formError = e.message
}
}
}
}
</script>
<style lang="scss" >
.menu {
width: 100%;
height: 40px;
margin: 0;
padding: .5em;
}
.menu div {
float: right;
}
</style>

View File

@ -0,0 +1,7 @@
# COMPONENTS
**This directory is not required, you can delete it if you don't want to use it.**
The components directory contains your Vue.js Components.
_Nuxt.js doesn't supercharge these components._

View File

@ -0,0 +1,56 @@
<template>
<div>
<div v-if="options.sources">
<video
:id="reference"
class="video-js vjs-default-skin vjs-big-play-centered vjs-16-9"
width="1024"
height="576"
/>
</div>
</div>
</template>
<script>
/* eslint-disable camelcase */
import videojs from 'video.js'
require('video.js/dist/video-js.css')
export default {
name: 'VideoPlayer',
props: {
options: {
type: Object,
default () {
return {}
}
},
reference: {
type: String,
default () {
return ''
}
}
},
data () {
return {
player: null
}
},
mounted () {
this.player = videojs(this.reference, this.options, function onPlayerReady () {
// console.log('onPlayerReady', this);
})
},
beforeDestroy () {
if (this.player) {
this.player.dispose()
}
},
methods: {
}
}
</script>

View File

@ -0,0 +1,7 @@
# LAYOUTS
**This directory is not required, you can delete it if you don't want to use it.**
This directory contains your Application Layouts.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/views#layouts).

View File

@ -0,0 +1,27 @@
<template>
<div>
<nuxt />
</div>
</template>
<style>
html, body {
font-family: 'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI',
Roboto, 'Helvetica Neue', Arial, sans-serif;
color: #c4c4c4;
font-size: 15px;
word-spacing: 1px;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
-moz-osx-font-smoothing: grayscale;
-webkit-font-smoothing: antialiased;
box-sizing: border-box;
height: 100%;
padding: 0;
margin: 0;
}
a {
color: #c4c4c4;
}
</style>

View File

@ -0,0 +1,8 @@
# MIDDLEWARE
**This directory is not required, you can delete it if you don't want to use it.**
This directory contains your application middleware.
Middleware let you define custom functions that can be run before rendering either a page or a group of pages.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing#middleware).

View File

@ -0,0 +1,7 @@
export default async function ({ store, redirect }) {
await store.dispatch('auth/inspectToken')
if (!store.state.auth.isLogin) {
return redirect('/')
}
}

View File

@ -0,0 +1,104 @@
require('dotenv').config()
export default {
mode: 'spa',
/*
** Headers of the page
*/
head: {
title: process.env.npm_package_name || '',
meta: [{
charset: 'utf-8'
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1'
},
{
hid: 'description',
name: 'description',
content: process.env.npm_package_description || ''
}
],
link: [{
rel: 'icon',
type: 'image/x-icon',
href: '/favicon.ico'
}]
},
/*
** Customize the progress-bar color
*/
loading: {
color: '#ff9c36'
},
/*
** Global CSS
*/
css: [
'@/assets/css/bootstrap.min.css'
],
/*
** Plugins to load before mounting the App
*/
plugins: [
{ src: '~/plugins/axios' },
{ src: '~/plugins/filters' },
{ src: '~plugins/video.js', ssr: false },
{ src: '~plugins/scrollbar.js', ssr: false },
{ src: '~plugins/splitpanes.js', ssr: false },
{ src: '~plugins/loading.js', ssr: false },
{ src: '~/plugins/helpers.js' },
{ src: '~plugins/draggable.js', ssr: false }
],
/*
** Nuxt.js dev-modules
*/
buildModules: [
// Doc: https://github.com/nuxt-community/eslint-module
'@nuxtjs/eslint-module'
],
/*
** Nuxt.js modules
*/
modules: [
// Doc: https://bootstrap-vue.js.org
'bootstrap-vue/nuxt',
'@nuxtjs/axios',
'@nuxtjs/style-resources',
// Doc: https://github.com/nuxt-community/dotenv-module
'@nuxtjs/dotenv',
'cookie-universal-nuxt',
'nuxt-dayjs-module'
],
/*
** Axios module configuration
** See https://axios.nuxtjs.org/options
*/
axios: {
baseURL: process.env.API_URL
},
styleResources: {
scss: [
'@/assets/css/_variables.scss',
'@/assets/scss/globals.scss'
]
},
bootstrapVue: {
bootstrapCSS: false,
icons: true
},
/*
** Build configuration
*/
build: {
/*
** You can extend webpack config here
*/
extend(config, ctx) {}
}
}

13413
ffplayout/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,39 @@
{
"name": "ffplayout",
"version": "2.0.0",
"description": "web GUI for ffplayout engine",
"author": "Jonathan Baecker",
"private": true,
"scripts": {
"dev": "nuxt",
"build": "nuxt build",
"start": "nuxt start",
"generate": "nuxt generate",
"lint": "eslint --ext .js,.vue --ignore-path .gitignore ."
},
"dependencies": {
"@nuxtjs/axios": "^5.10.3",
"@nuxtjs/dotenv": "^1.4.1",
"bootstrap": "^4.4.1",
"bootstrap-vue": "^2.13.0",
"cookie-universal-nuxt": "^2.1.3",
"jwt-decode": "^2.2.0",
"nuxt": "^2.12.2",
"nuxt-dayjs-module": "^1.1.2",
"splitpanes": "^2.2.1",
"video.js": "^7.7.5",
"vue-loading-overlay": "^3.3.2",
"vue2-perfect-scrollbar": "^1.5.0",
"vuedraggable": "^2.23.2"
},
"devDependencies": {
"@nuxtjs/eslint-config": "^2.0.2",
"@nuxtjs/eslint-module": "^1.2.0",
"@nuxtjs/style-resources": "^1.0.0",
"babel-eslint": "^10.1.0",
"eslint": "^6.8.0",
"eslint-plugin-nuxt": ">=0.5.2",
"node-sass": "^4.14.0",
"sass-loader": "^8.0.2"
}
}

View File

@ -0,0 +1,6 @@
# PAGES
This directory contains your Application Views and Routes.
The framework reads all the `*.vue` files inside this directory and creates the router of your application.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing).

View File

@ -0,0 +1,332 @@
<template>
<div>
<Menu />
<b-card no-body>
<b-tabs pills card vertical>
<b-tab title="GUI" active @click="resetAlert()">
<b-container class="config-container">
<b-form v-if="configGui" @submit="onSubmitGui">
<b-form-group
label-cols-lg="2"
label="GUI Configuration"
label-size="lg"
label-class="font-weight-bold pt-0"
class="config-group"
>
<b-form-group
v-for="(prop, name, idx) in configGui"
:key="idx"
label-cols-sm="2"
:label="name"
label-align-sm="right"
:label-for="name"
>
<b-form-tags
v-if="name === 'extra_extensions'"
v-model="configGui[name]"
:input-id="name"
separator=" ,;"
:placeholder="`add ${name}...`"
class="mb-2 tags-list"
/>
<b-form-text v-if="name === 'extra_extensions'">
Visible extensions only for the GUI and not the playout
</b-form-text>
<b-form-select v-else-if="name === 'net_interface'" :id="name" v-model="configGui[name]" :options="netChoices" :value="prop" />
<b-form-input v-else :id="name" v-model="configGui[name]" :value="prop" />
</b-form-group>
</b-form-group>
<b-row>
<b-col cols="1" style="min-width: 85px">
<b-button type="submit" variant="primary">
Save
</b-button>
</b-col>
<b-col>
<b-alert v-model="showAlert" :variant="alertVariant" dismissible>
{{ alertMsg }}
</b-alert>
</b-col>
</b-row>
</b-form>
</b-container>
</b-tab>
<b-tab title="Playout" @click="resetAlert()">
<b-container class="config-container">
<b-form v-if="configPlayout" @submit="onSubmitPlayout">
<b-form-group
v-for="(item, key, index) in configPlayout"
:key="index"
label-cols-lg="2"
:label="key"
label-size="lg"
label-class="font-weight-bold pt-0"
class="config-group"
>
<b-form-group
v-for="(prop, name, idx) in item"
:key="idx"
label-cols-sm="2"
:label="(typeof prop === 'boolean' || name === 'helptext') ? '' : name"
label-align-sm="right"
:label-for="name"
>
<b-form-textarea
v-if="name === 'helptext'"
id="textarea-plaintext"
plaintext
:value="prop"
rows="2"
max-rows="8"
class="text-area"
/>
<b-form-checkbox
v-else-if="typeof prop === 'boolean'"
:id="name"
v-model="configPlayout[key][name]"
:name="name"
>
{{ name }}
</b-form-checkbox>
<b-form-input
v-else-if="prop && prop.toString().match(/^-?\d+[.,]\d+$/)"
:id="name"
v-model="configPlayout[key][name]"
type="number"
step="0.001"
class="input-field"
/>
<b-form-input
v-else-if="prop && !isNaN(prop)"
:id="name"
v-model="configPlayout[key][name]"
type="number"
step="1"
class="input-field"
/>
<b-form-tags
v-else-if="Array.isArray(prop)"
v-model="configPlayout[key][name]"
:input-id="name"
separator=" ,;"
:placeholder="`add ${name}...`"
class="mb-2 tags-list"
/>
<b-form-input
v-else-if="name.includes('pass')"
:id="name"
v-model="configPlayout[key][name]"
type="password"
:value="prop"
/>
<b-form-input v-else :id="name" v-model="configPlayout[key][name]" :value="prop" />
</b-form-group>
</b-form-group>
<b-row>
<b-col cols="1" style="min-width: 85px">
<b-button type="submit" variant="primary">
Save
</b-button>
</b-col>
<b-col>
<b-alert v-model="showAlert" :variant="alertVariant" dismissible>
{{ alertMsg }}
</b-alert>
</b-col>
</b-row>
</b-form>
</b-container>
</b-tab>
<b-tab title="User" @click="resetAlert()">
<b-card-text>
<b-container class="config-container">
<b-form v-if="configUser" @submit="onSubmitUser">
<b-form-group
label-cols-lg="2"
label="User Configuration"
label-size="lg"
label-class="font-weight-bold pt-0"
class="config-group"
>
<b-form-group
label-cols-sm="2"
:label="'username'"
label-align-sm="right"
:label-for="'username'"
>
<b-form-input id="username" v-model="configUser['username']" :value="configUser['username']" disabled />
</b-form-group>
<b-form-group
label-cols-sm="2"
:label="'email'"
label-align-sm="right"
:label-for="'email'"
>
<b-form-input id="email" v-model="configUser['email']" :value="configUser['email']" />
</b-form-group>
<b-form-group
label-cols-sm="2"
label="old password"
label-align-sm="right"
label-for="oldPass"
>
<b-form-input id="oldPass" v-model="oldPass" type="password" />
</b-form-group>
<b-form-group
label-cols-sm="2"
label="new password"
label-align-sm="right"
label-for="newPass"
>
<b-form-input id="newPass" v-model="newPass" type="password" />
</b-form-group>
<b-form-group
label-cols-sm="2"
label="confirm password"
label-align-sm="right"
label-for="confirmPass"
>
<b-form-input id="confirmPass" v-model="confirmPass" type="password" />
</b-form-group>
</b-form-group>
<b-row>
<b-col cols="1" style="min-width: 85px">
<b-button type="submit" variant="primary">
Save
</b-button>
</b-col>
<b-col>
<b-alert v-model="showAlert" :variant="alertVariant" dismissible>
{{ alertMsg }}
</b-alert>
</b-col>
</b-row>
</b-form>
</b-container>
</b-card-text>
</b-tab>
</b-tabs>
</b-card>
</div>
</template>
<script>
import Menu from '@/components/Menu.vue'
export default {
name: 'Configure',
middleware: 'auth',
components: {
Menu
},
async asyncData ({ app, store }) {
if (store.state.auth.isLogin) {
await store.dispatch('config/getGuiConfig')
await store.dispatch('config/getPlayoutConfig')
await store.dispatch('config/getUserConfig')
}
return {
configGui: store.state.config.configGui,
netChoices: store.state.config.netChoices,
configPlayout: store.state.config.configPlayout,
configUser: store.state.config.configUser,
oldPass: null,
newPass: null,
confirmPass: null,
showAlert: false,
alertVariant: 'success',
alertMsg: ''
}
},
data () {
return {
}
},
methods: {
async onSubmitGui (evt) {
evt.preventDefault()
await this.$store.dispatch('auth/inspectToken')
const update = await this.$store.dispatch('config/setGuiConfig', this.configGui)
if (update.status === 200) {
this.alertVariant = 'success'
this.alertMsg = 'Update GUI config success!'
} else {
this.alertVariant = 'danger'
this.alertMsg = 'Update GUI config failed!'
}
this.showAlert = true
},
async onSubmitPlayout (evt) {
evt.preventDefault()
await this.$store.dispatch('auth/inspectToken')
const update = await this.$store.dispatch('config/setPlayoutConfig', this.configPlayout)
if (update.status === 200) {
this.alertVariant = 'success'
this.alertMsg = 'Update playout config success!'
} else {
this.alertVariant = 'danger'
this.alertMsg = 'Update playout config failed!'
}
this.showAlert = true
},
async onSubmitUser (evt) {
evt.preventDefault()
if (this.oldPass && this.newPass && this.newPass === this.confirmPass) {
this.configUser.old_password = this.oldPass
this.configUser.new_password = this.newPass
}
await this.$store.dispatch('auth/inspectToken')
const update = await this.$store.dispatch('config/setUserConfig', this.configUser)
if (update.status === 200) {
this.alertVariant = 'success'
this.alertMsg = 'Update user profil success!'
} else {
this.alertVariant = 'danger'
this.alertMsg = 'Update user profil failed!'
}
this.showAlert = true
this.oldPass = null
this.newPass = null
this.confirmPass = null
},
resetAlert () {
this.showAlert = false
this.alertVariant = 'success'
this.alertMsg = ''
}
}
}
</script>
<style lang="scss">
.config-container {
margin: 2em auto 2em auto;
padding: 0;
}
.config-group {
margin-bottom: 2em;
}
.input-field {
max-width: 200px;
}
.text-area {
overflow-y: hidden !important;
}
</style>

View File

@ -0,0 +1,302 @@
<template>
<div>
<div v-if="!$store.state.auth.isLogin">
<div class="logout-div" />
<b-container class="login-container">
<div>
<div class="header">
<h1>ffplayout</h1>
</div>
<b-form class="login-form" @submit.prevent="login">
<p v-if="formError" class="error">
{{ formError }}
</p>
<b-form-group id="input-group-1" label="User:" label-for="input-user">
<b-form-input id="input-user" v-model="formUsername" type="text" required placeholder="Username" />
</b-form-group>
<b-form-group id="input-group-1" label="Password:" label-for="input-pass">
<b-form-input id="input-pass" v-model="formPassword" type="password" required placeholder="Password" />
</b-form-group>
<b-button type="submit" variant="primary">
Login
</b-button>
</b-form>
</div>
</b-container>
</div>
<div v-else>
<b-container class="login-container">
<div>
<b-row cols="3">
<b-col cols="4" class="chart-col chart1">
<br>
<div class="stat-div">
<div class="stat-center" style="text-align: left;">
<h1>ffplayout</h1>
<h3 v-if="stat.system">
{{ stat.system }}<br>
{{ stat.node }}<br>
{{ stat.machine }}
</h3>
</div>
</div>
</b-col>
<b-col cols="4" class="chart-col chart2">
<div v-if="stat.cpu_usage || stat.cpu_usage >= 0">
<div>
<strong>CPU</strong>
</div>
<div class="stat-div">
<div class="stat-center">
<div style="text-align: left;">
<strong>Usage: </strong>{{ stat.cpu_usage }}%<br>
<strong>Load: </strong> {{ stat.cpu_load[0] }} {{ stat.cpu_load[1] }} {{ stat.cpu_load[2] }}
</div>
</div>
</div>
</div>
</b-col>
<b-col cols="4" class="chart-col chart3">
<div v-if="stat.ram_total">
<div>
<strong>RAM</strong>
</div>
<div class="stat-div">
<div class="stat-center">
<div style="text-align: left;">
<strong>Total: </strong> {{ stat.ram_total[1] }}<br>
<strong>Used: </strong> {{ stat.ram_used[1] }}<br>
<strong>Free: </strong> {{ stat.ram_free[1] }}<br>
<strong>Cached: </strong> {{ stat.ram_cached[1] }}
</div>
</div>
</div>
</div>
</b-col>
<b-col cols="4" class="chart-col chart4">
<div v-if="stat.swap_total">
<div>
<strong>SWAP</strong>
</div>
<div class="stat-div">
<div class="stat-center">
<div style="text-align: left;">
<strong>Total: </strong> {{ stat.swap_total[1] }}<br>
<strong>Used: </strong> {{ stat.swap_used[1] }}<br>
<strong>Free: </strong> {{ stat.swap_free[1] }}
</div>
</div>
</div>
</div>
</b-col>
<b-col cols="4" class="chart-col chart5">
<div v-if="stat.disk_total">
<div>
<strong>DISK</strong>
</div>
<div class="stat-div">
<div class="stat-center">
<div style="text-align: left;">
<strong>Total: </strong> {{ stat.disk_total[1] }}<br>
<strong>Used: </strong> {{ stat.disk_used[1] }}<br>
<strong>Free: </strong> {{ stat.disk_free[1] }}
</div>
</div>
</div>
</div>
</b-col>
<b-col cols="4" class="chart-col chart6">
<div v-if="stat.net_send">
<div>
<strong>NET</strong>
</div>
<div class="stat-div">
<div class="stat-center">
<div style="text-align: left;">
<strong>Download: </strong> {{ stat.net_speed_recv[1] }}/s<br>
<strong>Upload: </strong> {{ stat.net_speed_send[1] }}/s<br>
<strong>Downloaded: </strong> {{ stat.net_recv[1] }}<br>
<strong>Uploaded: </strong> {{ stat.net_send[1] }}<br>
<strong>Recived Errors: </strong> {{ stat.net_errin }}<br>
<strong>Sended Errors: </strong> {{ stat.net_errout }}
</div>
</div>
</div>
</div>
</b-col>
</b-row>
<div class="actions">
<b-button-group class="actions-grp">
<b-button to="/player" variant="primary">
Player
</b-button>
<b-button to="/media" variant="primary">
Media
</b-button>
<b-button to="/message" variant="primary">
Message
</b-button>
<b-button to="logging" variant="primary">
Logging
</b-button>
<b-button to="/configure" variant="primary">
Configure
</b-button>
<b-button variant="primary" @click="logout()">
Logout
</b-button>
</b-button-group>
</div>
</div>
</b-container>
</div>
</div>
</template>
<script>
export default {
components: {},
data () {
return {
formError: null,
formUsername: '',
formPassword: '',
interval: null,
stat: {}
}
},
created () {
this.init()
},
beforeDestroy () {
clearInterval(this.interval)
},
methods: {
async init () {
await this.$store.dispatch('auth/inspectToken')
this.checkLogin()
},
async login () {
try {
await this.$store.dispatch('auth/obtainToken', {
username: this.formUsername,
password: this.formPassword
})
this.formUsername = ''
this.formPassword = ''
this.formError = null
this.checkLogin()
} catch (e) {
this.formError = e.message
}
},
async logout () {
clearInterval(this.interval)
try {
await this.$store.commit('auth/REMOVE_TOKEN')
await this.$store.commit('auth/UPDATE_IS_LOGIN', false)
} catch (e) {
this.formError = e.message
}
},
checkLogin () {
if (this.$store.state.auth.isLogin) {
this.sysStats()
}
},
async sysStats () {
const response = await this.$axios.get('api/player/stats/?stats=all')
this.stat = response.data
if (process.browser) {
this.interval = setInterval(async () => {
await this.$store.dispatch('auth/inspectToken')
const response = await this.$axios.get('api/player/stats/?stats=all')
this.stat = response.data
}, 2000)
}
}
}
}
</script>
<style>
.login-container {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
}
.header {
text-align: center;
margin-bottom: 3em;
}
.login-form {
min-width: 300px;
}
.manage-btn {
margin: 0 auto 0 auto;
}
.chart-col {
text-align: center;
min-width: 10em;
min-height: 15em;
border: solid #c3c3c3;
}
.stat-div {
padding-top: .5em;
position: relative;
height: 12em;
}
.stat-center {
margin: 0;
position: absolute;
width: 100%;
top: 50%;
-ms-transform: translateY(-50%);
transform: translateY(-50%);
}
.chart1 {
background: rgba(210, 85, 23, 0.1);
}
.chart2 {
background: rgba(122, 210, 23, 0.1);
}
.chart3 {
background: rgba(23, 210, 149, 0.1);
}
.chart4 {
background: rgba(23, 160, 210, 0.1);
}
.chart5 {
background: rgba(122, 23, 210, 0.1);
}
.chart6 {
background: rgba(210, 23, 74, 0.1);
}
.actions {
text-align: center;
margin-top: 1em;
}
@media (max-width: 380px) {
.actions-grp {
display: flex;
flex-direction: column;
margin: 0 2em 0 2em;
}
}
</style>

View File

@ -0,0 +1,146 @@
<template>
<div>
<Menu />
<b-card no-body>
<b-tabs pills card vertical>
<b-tab title="Playout" active @click="getLog('ffplayout')">
<b-container class="log-container">
<!-- eslint-disable-next-line -->
<pre v-if="currentLog" :inner-html.prop="currentLog | formatStr" class="log-content" />
</b-container>
</b-tab>
<b-tab title="Decoder" @click="getLog('decoder')">
<b-container class="log-container">
<!-- eslint-disable-next-line -->
<pre v-if="currentLog" :inner-html.prop="currentLog | formatStr" class="log-content" />
</b-container>
</b-tab>
<b-tab title="Encoder" @click="getLog('encoder')">
<b-container class="log-container">
<!-- eslint-disable-next-line -->
<pre v-if="currentLog" :inner-html.prop="currentLog | formatStr" class="log-content" />
</b-container>
</b-tab>
<b-tab title="System" @click="getSystemLog()">
<b-container class="log-container">
<!-- eslint-disable-next-line -->
<pre v-if="currentLog" :inner-html.prop="currentLog | formatStr" class="log-content" />
</b-container>
</b-tab>
</b-tabs>
</b-card>
</div>
</template>
<script>
// import { mapState } from 'vuex'
import Menu from '@/components/Menu.vue'
export default {
name: 'Logging',
middleware: 'auth',
components: {
Menu
},
filters: {
formatStr (text) {
return text
.replace(/("\[.*")/g, '<span class="log-cmd">$1</span>')
.replace(/("\/.*")/g, '<span class="log-path">$1</span>')
.replace(/(\/[\w\d.\-/]+\n)/g, '<span class="log-path">$1</span>')
.replace(/((tcp|https?):\/\/[\w\d.:]+)/g, '<span class="log-url">$1</span>')
.replace(/(\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}[0-9,.]+\])/g, '<span class="log-time">$1</span>')
.replace(/\[INFO\]/g, '<span class="log-info">[INFO]</span>')
.replace(/\[WARNING\]/g, '<span class="log-warning">[WARNING]</span>')
.replace(/\[ERROR\]/g, '<span class="log-error">[ERROR]</span>')
.replace(/\[DEBUG\]/g, '<span class="log-debug">[DEBUG]</span>')
}
},
data () {
return {
currentLog: null
}
},
computed: {
},
async created () {
await this.getLog('ffplayout')
},
methods: {
async getLog (type) {
await this.$store.dispatch('auth/inspectToken')
const response = await this.$axios.get(`api/player/log/?type=${type}`)
if (response.data.log) {
this.currentLog = response.data.log
}
},
async getSystemLog () {
await this.$store.dispatch('auth/inspectToken')
const response = await this.$axios.post('api/player/system/', { run: 'log' })
if (response.data.data) {
this.currentLog = response.data.data
}
}
}
}
</script>
<style>
.col-auto {
width: 122px;
}
.tab-content {
max-width: calc(100% - 122px);
}
.log-container {
background: #1d2024;
max-width: 95%;
padding: 1em;
}
.log-content {
color: #ececec;
}
.log-time {
color: #a7a7a7;
}
.log-info {
color: #51d1de;
}
.log-warning {
color: #e4a428;
}
.log-error {
color: #e42e28;
}
.log-debug {
color: #23e493;
}
.log-path {
color: #e366cf;
}
.log-url {
color: #e3d666;
}
.log-cmd {
color: #f1aa77;
}
</style>

View File

@ -0,0 +1,571 @@
<template>
<div>
<Menu />
<b-container
class="browser-container"
@drop.prevent="addFile"
@dragover.prevent
@dragenter.prevent="dragEnter"
@dragleave.prevent="dragLeave"
>
<div class="drag-file" :class="fileDragClass">
<span>
<b-icon-box-arrow-in-down />
</span>
</div>
<div v-if="folderTree.tree" class="browser">
<div class="bread-div">
<b-breadcrumb>
<b-breadcrumb-item
v-for="(crumb, index) in crumbs"
:key="crumb.key"
:active="index === crumbs.length - 1"
@click="getPath(extensions, crumb.path)"
>
{{ crumb.text }}
</b-breadcrumb-item>
</b-breadcrumb>
</div>
<splitpanes class="browser-row default-theme pane-row">
<pane min-size="20" size="24">
<div class="browser-div">
<perfect-scrollbar>
<b-list-group class="folder-list">
<b-list-group-item
v-for="folder in folderTree.tree[1]"
:key="folder.key"
class="browser-item folder"
>
<b-row>
<b-col cols="1" class="browser-icons-col">
<b-icon-folder-fill class="browser-icons" />
</b-col>
<b-col class="browser-item-text">
<b-link @click="getPath(extensions, `/${folderTree.tree[0]}/${folder}`)">
{{ folder }}
</b-link>
</b-col>
<b-col v-if="folder !== '..'" cols="1" class="folder-delete">
<b-link @click="showDeleteModal('Folder', `/${folderTree.tree[0]}/${folder}`)">
<b-icon-x-circle-fill />
</b-link>
</b-col>
</b-row>
</b-list-group-item>
</b-list-group>
</perfect-scrollbar>
</div>
</pane>
<pane class="files-col">
<loading
:active.sync="isLoading"
:can-cancel="false"
:is-full-page="false"
background-color="#485159"
color="#ff9c36"
/>
<div class="browser-div">
<perfect-scrollbar>
<b-list-group class="files-list">
<b-list-group-item
v-for="file in folderTree.tree[2]"
:key="file.key"
class="browser-item"
>
<b-row>
<b-col cols="1" class="browser-icons-col">
<b-icon-film class="browser-icons" />
</b-col>
<b-col class="browser-item-text">
{{ file.file }}
</b-col>
<b-col cols="1" class="browser-play-col">
<b-link @click="showPreviewModal(`/${folderTree.tree[0]}/${file.file}`)">
<b-icon-play-fill />
</b-link>
</b-col>
<b-col cols="1" class="browser-dur-col">
<span class="duration">{{ file.duration | toMin }}</span>
</b-col>
<b-col cols="1" class="text-center">
<b-link @click="showDeleteModal('File', `/${folderTree.tree[0]}/${file.file}`)">
<b-icon-x-circle-fill />
</b-link>
</b-col>
</b-row>
</b-list-group-item>
</b-list-group>
</perfect-scrollbar>
</div>
</pane>
</splitpanes>
<b-button-group class="media-button">
<b-button title="Create Folder" variant="primary" @click="showCreateFolderModal()">
<b-icon-folder-plus />
</b-button>
<b-button title="Upload File" variant="primary" @click="showUploadModal()">
<b-icon-upload />
</b-button>
</b-button-group>
</div>
</b-container>
<b-modal
id="preview-modal"
ref="prev-modal"
size="xl"
centered
:title="`Preview: ${previewName}`"
hide-footer
>
<b-img v-if="isImage" :src="previewSource" fluid :alt="previewName" />
<video-player v-else-if="!isImage && previewOptions" reference="previewPlayer" :options="previewOptions" />
</b-modal>
<b-modal
id="folder-modal"
ref="folder-modal"
size="xl"
centered
title="Create Folder"
hide-footer
>
<b-form @submit="onSubmitCreateFolder" @reset="onCancelCreateFolder">
<b-form-input
id="folder-name"
v-model="folderName"
type="text"
required
placeholder="Enter a unique folder name"
/>
<div class="media-button">
<b-button type="submit" variant="primary">
Create
</b-button>
<b-button type="reset" variant="primary">
Cancel
</b-button>
</div>
</b-form>
</b-modal>
<b-modal
id="upload-modal"
ref="up-modal"
size="xl"
centered
title="File Upload"
hide-footer
>
<b-form @submit="onSubmitUpload" @reset="onResetUpload">
<b-form-file
v-model="inputFiles"
:state="Boolean(inputFiles)"
:placeholder="inputPlaceholder"
drop-placeholder="Drop files here..."
multiple
:accept="extensions.replace(/ /g, ', ')"
:file-name-formatter="formatNames"
/>
<b-row>
<b-col cols="10">
<b-row class="progress-row">
<b-col cols="1">
Overall:
</b-col>
<b-col cols="10">
<b-progress :value="overallProgress" />
</b-col>
<div class="w-100" />
<b-col cols="1">
Current:
</b-col>
<b-col cols="10">
<b-progress :value="currentProgress" />
</b-col>
<div class="w-100" />
<b-col cols="1">
Uploading:
</b-col>
<b-col cols="10">
<strong>{{ uploadTask }}</strong>
</b-col>
</b-row>
</b-col>
<b-col cols="2">
<div class="media-button">
<b-button type="submit" variant="primary">
Upload
</b-button>
<b-button type="reset" variant="primary">
Cancel
</b-button>
</div>
</b-col>
</b-row>
</b-form>
</b-modal>
<b-modal id="delete-modal" :title="`Delete ${deleteType}`" centered hide-footer>
<p>
Are you sure that you want to delete:<br>
<strong>{{ previewName }}</strong>
</p>
<div class="media-button">
<b-button variant="primary" @click="deleteFileOrFolder()">
Ok
</b-button>
<b-button variant="primary" @click="cancelDelete()">
Cancel
</b-button>
</div>
</b-modal>
</div>
</template>
<script>
import { mapState } from 'vuex'
import Menu from '@/components/Menu.vue'
export default {
name: 'Media',
middleware: 'auth',
components: {
Menu
},
data () {
return {
isLoading: false,
fileDragClass: '',
extensions: '',
folderName: '',
inputFiles: [],
inputPlaceholder: 'Choose files or drop them here...',
previewOptions: {},
previewComp: null,
previewName: '',
previewSource: '',
deleteType: 'File',
deleteSource: '',
isImage: false,
uploadTask: '',
overallProgress: 0,
currentProgress: 0,
cancelTokenSource: this.$axios.CancelToken.source(),
lastPath: ''
}
},
computed: {
...mapState('config', ['configGui', 'configPlayout']),
...mapState('media', ['crumbs', 'folderTree'])
},
async created () {
await this.$store.dispatch('auth/inspectToken')
await this.$store.dispatch('config/getGuiConfig')
await this.$store.dispatch('config/getPlayoutConfig')
this.extensions = [...this.configPlayout.storage.extensions, ...this.configGui.extra_extensions].join(' ')
this.getPath(this.extensions, '')
},
methods: {
async getPath (extensions, path) {
this.lastPath = path
this.isLoading = true
await this.$store.dispatch('auth/inspectToken')
await this.$store.dispatch('media/getTree', { extensions, path })
this.isLoading = false
},
dragEnter (evt) {
evt.preventDefault()
this.fileDragClass = 'drop-file-visible'
},
dragLeave (evt) {
evt.preventDefault()
this.fileDragClass = ''
this.inputPlaceholder = 'Choose files or drop them here...'
},
addFile (evt) {
evt.preventDefault()
const droppedFiles = evt.dataTransfer.files
if (!droppedFiles) {
return
}
([...droppedFiles]).forEach((f) => {
this.inputFiles.push(f)
})
if (this.inputFiles.length === 1) {
this.inputPlaceholder = this.inputFiles[0].name
} else {
this.inputPlaceholder = `${this.inputFiles.length} files selected`
}
this.fileDragClass = ''
this.showUploadModal()
},
showCreateFolderModal () {
this.$root.$emit('bv::show::modal', 'folder-modal')
},
async onSubmitCreateFolder (evt) {
evt.preventDefault()
await this.$store.dispatch('auth/inspectToken')
await this.$axios.post(
'api/player/media/op/',
{ folder: this.folderName, path: this.crumbs.map(e => e.text).join('/') }
)
this.$root.$emit('bv::hide::modal', 'folder-modal')
this.getPath(this.extensions, this.lastPath)
},
onCancelCreateFolder (evt) {
evt.preventDefault()
this.$root.$emit('bv::hide::modal', 'folder-modal')
},
showUploadModal () {
this.uploadTask = ''
this.currentProgress = 0
this.overallProgress = 0
this.$root.$emit('bv::show::modal', 'upload-modal')
},
formatNames (files) {
if (files.length === 1) {
return files[0].name
} else {
return `${files.length} files selected`
}
},
async onSubmitUpload (evt) {
// console.log(evt)
evt.preventDefault()
const uploadProgress = fileName => (progressEvent) => {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
this.currentProgress = progress
}
for (const [i, file] of this.inputFiles.entries()) {
await this.$store.dispatch('auth/inspectToken')
this.uploadTask = file.name
const config = {
onUploadProgress: uploadProgress(file.name),
cancelToken: this.cancelTokenSource.token,
headers: { Authorization: 'Bearer ' + this.$store.state.auth.jwtToken }
}
await this.$axios.put(
`api/player/media/upload/${encodeURIComponent(file.name)}?path=${encodeURIComponent(this.crumbs.map(e => e.text).join('/'))}`,
file,
config
)
.then(
this.overallProgress = (i + 1) * 100 / this.inputFiles.length,
this.currentProgress = 0
)
.catch(err => console.log(err))
}
this.uploadTask = 'Done...'
this.inputPlaceholder = 'Choose files or drop them here...'
this.inputFiles = []
this.getPath(this.extensions, this.lastPath)
this.$root.$emit('bv::hide::modal', 'upload-modal')
},
onResetUpload (evt) {
evt.preventDefault()
this.inputFiles = []
this.overallProgress = 0
this.currentProgress = 0
this.uploadTask = ''
this.inputPlaceholder = 'Choose files or drop them here...'
this.cancelTokenSource.cancel('Upload cancelled')
this.getPath(this.extensions, this.lastPath)
this.$root.$emit('bv::hide::modal', 'upload-modal')
},
showPreviewModal (src) {
this.previewSource = src
this.previewName = src.split('/').slice(-1)[0]
const ext = this.previewName.split('.').slice(-1)[0]
if (this.configPlayout.storage.extensions.includes(`.${ext}`)) {
this.isImage = false
this.previewOptions = {
liveui: false,
controls: true,
suppressNotSupportedError: true,
autoplay: false,
preload: 'auto',
sources: [
{
type: `video/${ext}`,
src: encodeURIComponent(src)
}
]
}
} else {
this.isImage = true
}
this.$root.$emit('bv::show::modal', 'preview-modal')
},
showDeleteModal (type, src) {
this.deleteSource = src
if (type === 'File') {
this.previewName = src.split('/').slice(-1)[0]
} else {
this.previewName = src
}
this.deleteType = type
this.$root.$emit('bv::show::modal', 'delete-modal')
},
async deleteFileOrFolder () {
await this.$store.dispatch('auth/inspectToken')
let file
let pathName
if (this.deleteType === 'File') {
file = this.deleteSource.split('/').slice(-1)[0]
pathName = this.deleteSource.substring(0, this.deleteSource.lastIndexOf('/') + 1)
} else {
file = null
pathName = this.deleteSource
}
await this.$axios.delete(`api/player/media/op/?file=${encodeURIComponent(file)}&path=${encodeURIComponent(pathName)}`)
.catch(err => console.log(err))
this.$root.$emit('bv::hide::modal', 'delete-modal')
this.getPath(this.extensions, this.lastPath)
},
cancelDelete () {
this.deleteSource = ''
this.$root.$emit('bv::hide::modal', 'delete-modal')
}
}
}
</script>
<style>
.browser-container {
position: relative;
width: 100%;
max-width: 100%;
height: calc(100% - 40px);
}
.browser {
position: absolute;
width: calc(100% - 30px);
height: calc(100% - 40px);
}
.drag-file {
position: absolute;
display: none;
background: rgba(48, 54, 61, 0.75);
width: calc(100% - 30px);
height: calc(100% - 80px);
text-align: center;
border: 2px solid #ddd;
border-radius: .25em;
z-index: 2;
margin: auto;
}
.drop-file-visible {
display: table;
}
.drag-file span {
display: table-cell;
vertical-align: middle;
font-size: 10em;
}
.bread-div {
height: 50px;
}
.browser-div {
background: #30363d;
height: 100%;
border: 1px solid #000;
border-radius: 5px;
}
.browser-row {
height: calc(100% - 90px);
min-height: 50px;
}
.folder-col {
min-width: 320px;
max-width: 460px;
height: 100%;
}
.folder:hover > div > .folder-delete {
display: inline;
}
.folder-list {
height: 100%;
padding: .5em;
}
.folder-delete {
margin-right: .5em;
display: none;
}
.files-col {
min-width: 320px;
height: 100%;
}
.files-list {
width: 99.5%;
height: 100%;
padding: .5em;
}
.media-button {
float: right;
margin-top: 1em;
}
.progress-row {
margin-top: 1em;
}
.progress-row .col-1 {
min-width: 60px
}
.progress-row .col-10 {
margin: auto 0 auto 0
}
</style>

View File

@ -0,0 +1,435 @@
<template>
<div>
<Menu />
<b-container class="messege-container">
<div class="preset-div">
<b-row>
<b-col>
<b-form-select v-model="selected" :options="presets" />
</b-col>
<b-col cols="2">
<b-button-group class="mr-1">
<b-button title="Save Preset" variant="primary" @click="savePreset()">
<b-icon icon="cloud-upload" />
</b-button>
<b-button title="New Preset" variant="primary" @click="openDialog()">
<b-icon-file-plus />
</b-button>
<b-button title="Delete Preset" variant="primary" @click="deleteDialog()">
<b-icon-file-minus />
</b-button>
</b-button-group>
</b-col>
</b-row>
</div>
<b-form @submit.prevent="submitMessage">
<b-form-group>
<b-form-textarea
v-model="form.text"
placeholder="Message"
rows="7"
class="message"
/>
</b-form-group>
<b-row>
<b-col>
<b-form-group>
<b-form-input
id="input-1"
v-model="form.x"
type="text"
required
placeholder="X"
/>
</b-form-group>
<b-form-group>
<b-form-input
id="input-2"
v-model="form.y"
type="text"
required
placeholder="Y"
/>
</b-form-group>
<b-row>
<b-col>
<b-form-group
label="Size"
label-for="input-3"
>
<b-form-input
id="input-3"
v-model="form.fontSize"
type="number"
required
value="24"
/>
</b-form-group>
</b-col>
<b-col>
<b-form-group
label="Spacing"
label-for="input-4"
>
<b-form-input
id="input-4"
v-model="form.fontSpacing"
type="number"
required
value="4"
/>
</b-form-group>
</b-col>
</b-row>
<b-row>
<b-col>
<b-form-group
label="Font Color"
label-for="input-5"
>
<b-form-input
id="input-5"
v-model="form.fontColor"
type="color"
required
/>
</b-form-group>
</b-col>
<b-col>
<b-form-group
label="Font Alpha"
label-for="input-6"
>
<b-form-input
id="input-6"
v-model="form.fontAlpha"
type="number"
min="0"
max="1"
step="0.01"
/>
</b-form-group>
</b-col>
</b-row>
</b-col>
<b-col>
<b-form-checkbox
v-model="form.showBox"
style="margin-bottom: 8px;"
>
Show Box
</b-form-checkbox>
<b-row>
<b-col>
<b-form-group
label="Box Color"
label-for="input-7"
>
<b-form-input
id="input-7"
v-model="form.boxColor"
type="color"
required
/>
</b-form-group>
</b-col>
<b-col>
<b-form-group
label="Box Alpha"
label-for="input-8"
>
<b-form-input
id="input-8"
v-model="form.boxAlpha"
type="number"
min="0"
max="1"
step="0.01"
/>
</b-form-group>
</b-col>
</b-row>
<b-form-group
label="Border Width"
label-for="input-9"
>
<b-form-input
id="input-9"
v-model="form.border"
type="number"
required
value="4"
/>
</b-form-group>
</b-col>
</b-row>
<b-form-group
label="Overall Alpha"
label-for="input-10"
>
<b-form-input
id="input-10"
v-model="form.overallAlpha"
type="text"
required
value="1"
/>
</b-form-group>
<b-row>
<b-col class="sub-btn">
<b-button type="submit" class="send-btn" variant="primary">
Send
</b-button>
</b-col>
<b-col>
<b-alert variant="success" :show="success" dismissible @dismissed="success=false">
Sending success...
</b-alert>
<b-alert variant="warning" :show="failed" dismissible @dismissed="success=failed">
Sending failed...
</b-alert>
</b-col>
</b-row>
</b-form>
</b-container>
<b-modal
id="create-modal"
ref="create-modal"
title="Create Preset"
@ok="handleCreate"
>
<form ref="form" @submit.stop.prevent="createPreset">
<b-form-group label="Name" label-for="name-input" invalid-feedback="Name is required">
<b-form-input id="name-input" v-model="newPresetName" required />
</b-form-group>
</form>
</b-modal>
<b-modal
id="delete-modal"
ref="delete-modal"
title="Delete Preset"
@ok="handleDelete"
>
<strong>Delete: "{{ selected }}"?</strong>
</b-modal>
</div>
</template>
<script>
// import { mapState } from 'vuex'
import Menu from '@/components/Menu.vue'
export default {
name: 'Media',
middleware: 'auth',
components: {
Menu
},
data () {
return {
form: {
id: 0,
name: '',
text: '',
x: '0',
y: '0',
fontSize: 24,
fontSpacing: 4,
fontColor: '#ffffff',
fontAlpha: 1.0,
showBox: true,
boxColor: '#000000',
boxAlpha: 0.8,
border: 4,
overallAlpha: 1
},
selected: null,
newPresetName: '',
presets: [],
success: false,
failed: false
}
},
computed: {
},
watch: {
selected (name) {
this.getPreset(name)
}
},
created () {
this.getPreset('')
},
methods: {
async getPreset (preset) {
await this.$store.dispatch('auth/inspectToken')
let req = ''
if (preset) {
req = `?name=${preset}`
}
const response = await this.$axios.get(`api/player/messenger/${req}`)
if (response.data && !preset) {
for (const item of response.data) {
this.presets.push({ value: item.name, text: item.name })
}
} else if (response.data) {
this.form = {
id: response.data[0].id,
name: response.data[0].name,
text: response.data[0].message,
x: response.data[0].x,
y: response.data[0].y,
fontSize: response.data[0].font_size,
fontSpacing: response.data[0].font_spacing,
fontColor: response.data[0].font_color,
fontAlpha: response.data[0].font_alpha,
showBox: response.data[0].show_box,
boxColor: response.data[0].box_color,
boxAlpha: response.data[0].box_alpha,
border: response.data[0].border_width,
overallAlpha: response.data[0].overall_alpha
}
}
},
openDialog () {
this.$bvModal.show('create-modal')
},
handleCreate (bvModalEvt) {
// Prevent modal from closing
bvModalEvt.preventDefault()
// Trigger submit handler
this.createPreset()
},
async createPreset () {
await this.$store.dispatch('auth/inspectToken')
const preset = {
name: this.newPresetName,
message: this.form.text,
x: this.form.x,
y: this.form.y,
font_size: this.form.fontSize,
font_spacing: this.form.fontSpacing,
font_color: this.form.fontColor,
font_alpha: this.form.fontAlpha,
show_box: this.form.showBox,
box_color: this.form.boxColor,
box_alpha: this.form.boxAlpha,
border_width: this.form.border,
overall_alpha: this.form.overallAlpha
}
const response = await this.$axios.post('api/player/messenger/', preset)
if (response.status === 201) {
this.success = true
} else {
this.failed = true
}
this.$nextTick(() => {
this.$bvModal.hide('create-modal')
})
},
async savePreset () {
await this.$store.dispatch('auth/inspectToken')
if (this.selected) {
const preset = {
id: this.form.id,
name: this.form.name,
message: this.form.text,
x: this.form.x,
y: this.form.y,
font_size: this.form.fontSize,
font_spacing: this.form.fontSpacing,
font_color: this.form.fontColor,
font_alpha: this.form.fontAlpha,
show_box: this.form.showBox,
box_color: this.form.boxColor,
box_alpha: this.form.boxAlpha,
border_width: this.form.border,
overall_alpha: this.form.overallAlpha
}
const response = await this.$axios.put(`api/player/messenger/${this.form.id}/`, preset)
if (response.status === 200) {
this.success = true
} else {
this.failed = true
}
}
},
deleteDialog () {
this.$bvModal.show('delete-modal')
},
handleDelete (evt) {
evt.preventDefault()
this.deletePreset()
},
async deletePreset () {
await this.$store.dispatch('auth/inspectToken')
if (this.selected) {
await this.$axios.delete(`api/player/messenger/${this.form.id}/`)
}
this.$bvModal.hide('delete-modal')
this.getPreset('')
},
async submitMessage () {
await this.$store.dispatch('auth/inspectToken')
function aToHex (num) {
return '0x' + Math.round(num * 255).toString(16)
}
const obj = {
text: this.form.text,
x: this.form.x,
y: this.form.y,
fontsize: this.form.fontSize,
line_spacing: this.form.fontSpacing,
fontcolor: this.form.fontColor + '@' + aToHex(this.form.fontAlpha),
alpha: this.form.overallAlpha,
box: (this.form.showBox) ? 1 : 0,
boxcolor: this.form.boxColor + '@' + aToHex(this.form.boxAlpha),
boxborderw: this.form.border
}
const response = await this.$axios.post('api/player/messenger/send/', { data: obj })
if (response.data && response.data.status.Success && response.data.status.Success === '0 Success') {
this.success = true
} else {
this.failed = true
}
}
}
}
</script>
<style>
.messege-container {
margin-top: 5em;
}
.preset-div {
width: 50%;
margin-bottom: 2em;
}
</style>

View File

@ -0,0 +1,673 @@
<template>
<div style="height:100%;">
<Menu />
<b-container class="control-container">
<b-row class="control-row">
<b-col cols="3" class="player-col">
<b-aspect aspect="16:9">
<video-player v-if="videoOptions.sources" reference="videoPlayer" :options="videoOptions" />
</b-aspect>
</b-col>
<b-col class="control-col">
<b-row class="control-col">
<b-col cols="8" class="status-col">
<b-row class="status-row">
<b-col class="time-col clock-col">
<div class="time-str">
{{ timeStr }}
</div>
</b-col>
<b-col class="time-col counter-col">
<div class="time-str">
{{ timeLeft }}
</div>
</b-col>
<div class="w-100" />
<b-col class="current-clip" align-self="end">
<div class="current-clip-text">
{{ currentClip | filename }}
</div>
<div class="current-clip-progress">
<b-progress :value="progressValue" variant="warning" />
</div>
</b-col>
</b-row>
</b-col>
<b-col cols="4" class="control-unit-col">
<b-row class="control-unit-row">
<b-col>
<div>
<b-button
title="Start Playout Service"
class="control-button control-button-play"
:class="isPlaying"
variant="primary"
@click="playoutControl('start')"
>
<b-icon-play />
</b-button>
</div>
</b-col>
<b-col>
<div>
<b-button
title="Stop Playout Service"
class="control-button control-button-stop"
variant="primary"
@click="playoutControl('stop')"
>
<b-icon-stop />
</b-button>
</div>
</b-col>
<div class="w-100" />
<b-col>
<div>
<b-button
title="Reload Playout Service"
class="control-button control-button-reload"
variant="primary"
@click="playoutControl('reload')"
>
<b-icon-arrow-repeat />
</b-button>
</div>
</b-col>
<b-col>
<div>
<b-button
title="Restart Playout Service"
class="control-button control-button-restart"
variant="primary"
@click="playoutControl('restart')"
>
<b-icon-arrow-clockwise />
</b-button>
</div>
</b-col>
</b-row>
</b-col>
</b-row>
</b-col>
</b-row>
<b-row class="date-row">
<b-col>
<b-datepicker v-model="listDate" size="sm" class="date-div" offset="-35px" />
</b-col>
</b-row>
<splitpanes class="list-row default-theme pane-row">
<pane min-size="20" size="24">
<loading
:active.sync="isLoading"
:can-cancel="false"
:is-full-page="false"
background-color="#485159"
color="#ff9c36"
/>
<div v-if="folderTree.tree" class="browser-div">
<div>
<b-breadcrumb>
<b-breadcrumb-item
v-for="(crumb, index) in crumbs"
:key="crumb.key"
:active="index === crumbs.length - 1"
@click="getPath(extensions, crumb.path)"
>
{{ crumb.text }}
</b-breadcrumb-item>
</b-breadcrumb>
</div>
<perfect-scrollbar>
<b-list-group>
<b-list-group-item
v-for="folder in folderTree.tree[1]"
:key="folder.key"
class="browser-item"
>
<b-link @click="getPath(extensions, `/${folderTree.tree[0]}/${folder}`)">
<b-icon-folder-fill class="browser-icons" /> {{ folder }}
</b-link>
</b-list-group-item>
<draggable
:list="folderTree.tree[2]"
:clone="cloneClip"
:group="{ name: 'playlist', pull: 'clone', put: false }"
:sort="false"
>
<b-list-group-item
v-for="file in folderTree.tree[2]"
:key="file.key"
class="browser-item"
>
<b-row>
<b-col cols="1" class="browser-icons-col">
<b-icon-film class="browser-icons" />
</b-col>
<b-col class="browser-item-text grabbing">
{{ file.file }}
</b-col>
<b-col cols="1" class="browser-play-col">
<b-link @click="showModal(`/${folderTree.tree[0]}/${file.file}`)">
<b-icon-play-fill />
</b-link>
</b-col>
<b-col cols="1" class="browser-dur-col">
<span class="duration">{{ file.duration | toMin }}</span>
</b-col>
</b-row>
</b-list-group-item>
</draggable>
</b-list-group>
</perfect-scrollbar>
</div>
</pane>
<pane>
<div class="playlist-container">
<b-list-group>
<b-list-group-item>
<b-row class="playlist-row">
<b-col cols="1" class="timecode">
Start
</b-col>
<b-col>
File
</b-col>
<b-col cols="1" class="text-center playlist-input">
Play
</b-col>
<b-col cols="1" class="timecode">
Duration
</b-col>
<b-col cols="1" class="timecode">
In
</b-col>
<b-col cols="1" class="timecode">
Out
</b-col>
<b-col cols="1" class="text-center playlist-input">
Ad
</b-col>
<b-col cols="1" class="text-center playlist-input">
Delete
</b-col>
</b-row>
</b-list-group-item>
</b-list-group>
<perfect-scrollbar>
<b-list-group>
<draggable
v-model="playlist"
group="playlist"
@start="drag=true"
@end="drag=false"
>
<b-list-group-item v-for="(item, index) in playlist" :key="item.key" class="playlist-item">
<b-row class="playlist-row">
<b-col cols="1" class="timecode">
{{ item.begin | secondsToTime }}
</b-col>
<b-col class="grabbing">
{{ item.source | filename }}
</b-col>
<b-col cols="1" class="text-center playlist-input">
<b-link @click="showModal(item.source)">
<b-icon-play-fill />
</b-link>
</b-col>
<b-col cols="1" text class="timecode">
{{ item.duration | secondsToTime }}
</b-col>
<b-col cols="1" class="timecode">
<b-form-input :value="item.in | secondsToTime" size="sm" @input="changeTime('in', index, $event)" />
</b-col>
<b-col cols="1" class="timecode">
<b-form-input :value="item.out | secondsToTime" size="sm" @input="changeTime('out', index, $event)" />
</b-col>
<b-col cols="1" class="text-center playlist-input">
<b-form-checkbox
v-model="item.category"
value="advertisement"
:unchecked-value="item.category"
/>
</b-col>
<b-col cols="1" class="text-center playlist-input">
<b-link @click="removeItemFromPlaylist(index)">
<b-icon-x-circle-fill />
</b-link>
</b-col>
</b-row>
</b-list-group-item>
</draggable>
</b-list-group>
</perfect-scrollbar>
</div>
</pane>
</splitpanes>
<b-button-group class="media-button">
<b-button v-b-tooltip.hover title="Reset Playlist" variant="primary" @click="resetPlaylist()">
<b-icon-arrow-counterclockwise />
</b-button>
<b-button v-b-tooltip.hover title="Save Playlist" variant="primary" @click="savePlaylist()">
<b-icon-download />
</b-button>
</b-button-group>
</b-container>
<b-modal
id="preview-modal"
ref="prev-modal"
size="xl"
centered
:title="`Preview: ${previewSource}`"
hide-footer
>
<video-player v-if="previewOptions" reference="previewPlayer" :options="previewOptions" />
</b-modal>
</div>
</template>
<script>
import { mapState } from 'vuex'
import Menu from '@/components/Menu.vue'
export default {
name: 'Player',
middleware: 'auth',
components: {
Menu
},
filters: {
secondsToTime (sec) {
return new Date(sec * 1000).toISOString().substr(11, 8)
}
},
data () {
return {
isLoading: false,
isPlaying: '',
listDate: this.$dayjs().format('YYYY-MM-DD'),
interval: null,
extensions: '',
videoOptions: {},
previewOptions: {},
previewComp: null,
previewSource: ''
}
},
computed: {
...mapState('config', ['configGui', 'configPlayout']),
...mapState('media', ['crumbs', 'folderTree']),
...mapState('playlist', ['timeStr', 'timeLeft', 'currentClip', 'progressValue']),
playlist: {
get () {
return this.$store.state.playlist.playlist
},
set (list) {
this.$store.commit('playlist/UPDATE_PLAYLIST', this.$processPlaylist(
this.configPlayout.playlist.day_start, list))
}
}
},
watch: {
listDate (date) {
this.getPlaylist()
}
},
async created () {
await this.getConfig()
await this.getStatus()
this.extensions = this.configPlayout.storage.extensions.join(' ')
await this.getPath(this.extensions, '')
this.videoOptions = {
liveui: true,
controls: true,
suppressNotSupportedError: true,
autoplay: false,
preload: 'auto',
sources: [
{
type: 'application/x-mpegURL',
src: this.configGui.player_url
}
]
}
await this.getPlaylist()
if (!process.env.DEV) {
this.interval = setInterval(() => {
this.$store.dispatch('playlist/animClock', { dayStart: this.configPlayout.playlist.day_start })
}, 5000)
} else {
this.$store.dispatch('playlist/animClock', { dayStart: this.configPlayout.playlist.day_start })
}
},
beforeDestroy () {
clearInterval(this.interval)
},
methods: {
async getConfig () {
await this.$store.dispatch('auth/inspectToken')
await this.$store.dispatch('config/getGuiConfig')
await this.$store.dispatch('config/getPlayoutConfig')
},
async getPath (extensions, path) {
this.isLoading = true
await this.$store.dispatch('auth/inspectToken')
await this.$store.dispatch('media/getTree', { extensions, path })
this.isLoading = false
},
async getStatus () {
await this.$store.dispatch('auth/inspectToken')
const status = await this.$axios.post('api/player/system/', { run: 'status' })
if (status.data.data && status.data.data === 'active') {
this.isPlaying = 'is-playing'
} else {
this.isPlaying = ''
}
},
async playoutControl (state) {
await this.$store.dispatch('auth/inspectToken')
await this.$axios.post('api/player/system/', { run: state })
setTimeout(() => { this.getStatus() }, 1000)
},
async getPlaylist () {
await this.$store.dispatch('auth/inspectToken')
await this.$store.dispatch('playlist/getPlaylist', { dayStart: this.configPlayout.playlist.day_start, date: this.listDate })
},
showModal (src) {
this.previewSource = src.split('/').slice(-1)[0]
const ext = this.previewSource.split('.').slice(-1)[0]
this.previewOptions = {
liveui: false,
controls: true,
suppressNotSupportedError: true,
autoplay: false,
preload: 'auto',
sources: [
{
type: `video/${ext}`,
src: encodeURIComponent(src)
}
]
}
this.$root.$emit('bv::show::modal', 'preview-modal')
},
cloneClip ({ file, duration }) {
return {
source: `/${this.folderTree.tree[0]}/${file}`,
in: 0,
out: duration,
duration
}
},
changeTime (pos, index, input) {
if (input.match(/(?:[01]\d|2[0123]):(?:[012345]\d):(?:[012345]\d)/gm)) {
const sec = this.$timeToSeconds(input)
if (pos === 'in') {
this.playlist[index].in = sec
} else if (pos === 'out') {
this.playlist[index].out = sec
}
this.$store.commit('playlist/UPDATE_PLAYLIST', this.$processPlaylist(
this.configPlayout.playlist.day_start, this.playlist))
}
},
removeItemFromPlaylist (index) {
this.playlist.splice(index, 1)
this.$store.commit('playlist/UPDATE_PLAYLIST', this.$processPlaylist(
this.configPlayout.playlist.day_start, this.playlist))
},
async resetPlaylist () {
await this.$store.dispatch('playlist/getPlaylist', { dayStart: this.configPlayout.playlist.day_start, date: this.listDate })
},
async savePlaylist () {
await this.$store.dispatch('auth/inspectToken')
this.$store.commit('playlist/UPDATE_PLAYLIST', this.$processPlaylist(
this.configPlayout.playlist.day_start, this.playlist))
const saveList = this.playlist.map(({ begin, ...item }) => item)
await this.$axios.post(
'api/player/playlist/',
{ data: { channel: this.$store.state.playlist.playlistChannel, date: this.listDate, program: saveList } }
)
}
}
}
</script>
<style lang="scss" scoped>
.control-container {
width: auto;
max-width: 100%;
height: calc(100% - 40px);
}
.control-row {
min-height: 254px;
}
.player-col {
max-width: 542px;
min-width: 380px;
margin-bottom: 6px;
}
.control-col {
height: 100%;
min-height: 254px;
}
.status-col {
padding-right: 30px;
}
.control-unit-col {
min-width: 380px;
}
.control-unit-row {
background: #32383E;
height: 100%;
margin-right: 0;
border-radius: 0.25rem;
text-align: center;
}
.control-unit-row .col {
position: relative;
height: 50%;
min-height: 90px;
}
.control-unit-row .col div {
position: relative;
top: 50%;
-webkit-transform: translateY(-50%);
-ms-transform: translateY(-50%);
transform: translateY(-50%);
height: 80%;
}
.control-button {
font-size: 3em;
line-height: 0;
width: 80%;
height: 100%;
}
.status-row {
height: 100%;
min-width: 370px;
}
.clock-col {
margin-right: 3px;
}
.counter-col {
margin-left: 3px;
}
.time-col {
position: relative;
background: #32383E;
padding: .5em;
text-align: center;
border-radius: .25rem;
}
.time-str {
position: relative;
top: 50%;
-webkit-transform: translateY(-50%);
-ms-transform: translateY(-50%);
transform: translateY(-50%);
font-family: 'DigitalNumbers-Regular';
font-size: 4.5em;
letter-spacing: -.18em;
padding-right: 14px;
}
.current-clip {
background: #32383E;
height: calc(50% - 3px);
padding: 10px;
border-radius: 0.25rem;
}
.current-clip-text {
height: 70%;
padding-top: .5em;
text-align: left;
font-weight: bold;
}
.current-clip-progress {
top: 80%;
margin-top: .2em;
}
.control-button:hover {
background-image: linear-gradient(#3b4046, #2c3034 60%, #24272a) !important;
}
.control-button-play {
color: #43c32e;
}
.is-playing {
box-shadow: 0 0 15px #43c32e;
}
.control-button-stop {
color: #d01111;
}
.control-button-reload {
color: #ed7c06;
}
.control-button-restart {
color: #f6e502;
}
@media (max-width: 1555px) {
.control-col {
height: 100%;
min-height: 294px;
}
.status-col {
padding-right: 0;
height: 100%;
}
.time-str {
font-size: 3.5em;
}
.time-col {
margin-bottom: 6px;
}
.control-unit-row {
margin-right: -30px;
}
.control-unit-col {
flex: 0 0 66.6666666667%;
max-width: 66.6666666667%;
margin: 6px 0 0 0;
}
}
@media (max-width: 1225px) {
.clock-col {
margin-right: 0;
}
.counter-col {
margin-left: 0;
}
}
.date-row {
height: 44px;
padding-top: 5px;
}
.list-row {
height: calc(100% - 40px - 254px - 46px - 70px);
min-height: 300px;
}
.pane-row {
margin: 0;
}
.date-div {
width: 250px;
float: right;
}
.playlist-container {
width: 100%;
height: 100%;
}
.timecode {
min-width: 56px;
max-width: 90px;
}
.playlist-input {
min-width: 35px;
max-width: 60px;
}
.timecode input {
border-color: #515763;
}
.playlist-item:nth-of-type(even), .playlist-item:nth-of-type(even) div .timecode input {
background-color: #3b424a;
}
.playlist-item:nth-of-type(even):hover {
background-color: #1C1E22;
}
</style>

View File

@ -0,0 +1,7 @@
# PLUGINS
**This directory is not required, you can delete it if you don't want to use it.**
This directory contains Javascript plugins that you want to run before mounting the root Vue.js application.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/plugins).

View File

@ -0,0 +1,21 @@
export default function ({ $axios, store, redirect }) {
$axios.onRequest((config) => {
const token = store.state.auth.jwtToken
if (token) {
config.headers.common.Authorization = `Bearer ${token}`
}
// disable progress on auth and stats
if (config.url.includes('stats') || config.url.includes('auth') || config.url.includes('system')) {
config.progress = false
}
})
$axios.onError((error) => {
const code = parseInt(error.response && error.response.status)
if (code === 400) {
redirect('/')
}
})
}

View File

@ -0,0 +1,5 @@
import Vue from 'vue'
import draggable from 'vuedraggable'
Vue.use(draggable)
Vue.component('draggable', draggable)

View File

@ -0,0 +1,20 @@
import Vue from 'vue'
Vue.filter('toMin', function (sec) {
if (sec) {
const minutes = Math.floor(sec / 60)
const seconds = Math.round(sec - minutes * 60)
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')} min`
} else {
return ''
}
})
Vue.filter('filename', function (path) {
if (path) {
const pathArr = path.split('/')
return pathArr[pathArr.length - 1]
} else {
return ''
}
})

View File

@ -0,0 +1,34 @@
export default ({ app }, inject) => {
inject('processPlaylist', (dayStart, list) => {
const [h, m, s] = dayStart.split(':')
let begin = parseFloat(h) * 3600 + parseFloat(m) * 60 + parseFloat(s)
for (const item of list) {
item.begin = begin
if (!item.category) {
item.category = ''
}
begin += (item.out - item.in)
}
return list
})
// convert time (00:00:00) string to seconds
inject('timeToSeconds', (time) => {
const t = time.split(':')
return parseInt(t[0]) * 3600 + parseInt(t[1]) * 60 + parseInt(t[2])
})
inject('secToHMS', (sec) => {
let hours = Math.floor(sec / 3600)
sec %= 3600
let minutes = Math.floor(sec / 60)
let seconds = sec % 60
minutes = String(minutes).padStart(2, '0')
hours = String(hours).padStart(2, '0')
seconds = String(parseInt(seconds)).padStart(2, '0')
return hours + ':' + minutes + ':' + seconds
})
}

View File

@ -0,0 +1,6 @@
import Vue from 'vue'
import Loading from 'vue-loading-overlay'
import 'vue-loading-overlay/dist/vue-loading.css'
Vue.use(Loading)
Vue.component('loading', Loading)

View File

@ -0,0 +1,5 @@
import Vue from 'vue'
import PerfectScrollbar from 'vue2-perfect-scrollbar'
import 'vue2-perfect-scrollbar/dist/vue2-perfect-scrollbar.css'
Vue.use(PerfectScrollbar)

View File

@ -0,0 +1,6 @@
import Vue from 'vue'
import { Splitpanes, Pane } from 'splitpanes'
import 'splitpanes/dist/splitpanes.css'
Vue.component('splitpanes', Splitpanes)
Vue.component('pane', Pane)

View File

@ -0,0 +1,4 @@
import Vue from 'vue'
import VideoPlayer from '@/components/VideoPlayer.vue'
Vue.component('video-player', VideoPlayer)

View File

@ -0,0 +1,11 @@
# STATIC
**This directory is not required, you can delete it if you don't want to use it.**
This directory contains your static files.
Each file inside this directory is mapped to `/`.
Thus you'd want to delete this README.md before deploying to production.
Example: `/static/robots.txt` is mapped as `/robots.txt`.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#static).

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@ -0,0 +1,10 @@
# STORE
**This directory is not required, you can delete it if you don't want to use it.**
This directory contains your Vuex Store files.
Vuex Store option is implemented in the Nuxt.js framework.
Creating a file in this directory automatically activates the option in the framework.
More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/vuex-store).

View File

@ -0,0 +1,88 @@
/* eslint-disable camelcase */
import jwt_decode from 'jwt-decode'
export const state = () => ({
jwtToken: '',
jwtRefresh: '',
isLogin: false
})
// mutate values in state
export const mutations = {
UPADTE_TOKEN (state, obj) {
state.jwtToken = obj.token
if (obj.refresh) {
state.jwtRefresh = obj.refresh
}
},
REMOVE_TOKEN (state) {
this.$cookies.remove('token')
this.$cookies.remove('refresh')
state.jwtToken = null
state.jwtRefresh = null
},
UPDATE_IS_LOGIN (state, bool) {
state.isLogin = bool
}
}
export const actions = {
async obtainToken ({ commit, state }, { username, password }) {
const payload = {
username,
password
}
await this.$axios.post('auth/token/', payload)
.then((response) => {
commit('UPADTE_TOKEN', { token: response.data.access, refresh: response.data.refresh })
commit('UPDATE_IS_LOGIN', true)
this.$cookies.set('token', response.data.access, {
path: '/',
maxAge: 60 * 60 * 24 * 365
})
this.$cookies.set('refresh', response.data.refresh, {
path: '/',
maxAge: 60 * 60 * 24 * 365
})
})
.catch((error) => {
console.log(error)
})
},
async refreshToken ({ commit, state }) {
const payload = {
refresh: state.jwtRefresh,
progress: false
}
const response = await this.$axios.post('auth/token/refresh/', payload)
commit('UPADTE_TOKEN', { token: response.data.access })
commit('UPDATE_IS_LOGIN', true)
},
async inspectToken ({ commit, dispatch, state }) {
const token = this.$cookies.get('token')
const refresh = this.$cookies.get('refresh')
if (token && refresh) {
commit('UPADTE_TOKEN', { token, refresh })
const decoded_token = jwt_decode(token)
const decoded_refresh = jwt_decode(refresh)
const timestamp = Date.now() / 1000
const expire_token = decoded_token.exp
const expire_refresh = decoded_refresh.exp
if (expire_token - timestamp > 0) {
// DO NOTHING, DO NOT REFRESH
commit('UPDATE_IS_LOGIN', true)
} else if (expire_refresh - timestamp > 0) {
await dispatch('refreshToken')
} else {
// PROMPT USER TO RE-LOGIN, THIS ELSE CLAUSE COVERS THE CONDITION WHERE A TOKEN IS EXPIRED AS WELL
commit('UPDATE_IS_LOGIN', false)
}
} else {
commit('UPDATE_IS_LOGIN', false)
}
}
}

View File

@ -0,0 +1,82 @@
export const state = () => ({
configGui: null,
netChoices: [],
configPlayout: null,
currentUser: null,
configUser: null
})
export const mutations = {
UPDATE_GUI_CONFIG (state, config) {
state.configGui = config
},
UPDATE_NET_CHOICES (state, list) {
state.netChoices = list
},
UPDATE_PLAYLOUT_CONFIG (state, config) {
state.configPlayout = config
},
SET_CURRENT_USER (state, user) {
state.currentUser = user
},
UPDATE_USER_CONFIG (state, config) {
state.configUser = config
}
}
export const actions = {
async getGuiConfig ({ commit, state }) {
const options = await this.$axios.options('api/player/guisettings/')
const response = await this.$axios.get('api/player/guisettings/')
if (options.data) {
const choices = options.data.actions.POST.net_interface.choices.map(function (obj) {
obj.text = obj.display_name
delete obj.display_name
return obj
})
commit('UPDATE_NET_CHOICES', choices)
}
if (response.data) {
response.data[0].extra_extensions = response.data[0].extra_extensions.split(' ')
commit('UPDATE_GUI_CONFIG', response.data[0])
}
},
async setGuiConfig ({ commit, state }, obj) {
const stringObj = JSON.parse(JSON.stringify(obj))
stringObj.extra_extensions = obj.extra_extensions.join(' ')
const update = await this.$axios.put('api/player/guisettings/1/', stringObj)
return update
},
async getPlayoutConfig ({ commit, state }) {
const response = await this.$axios.get('api/player/config/?configPlayout')
if (response.data) {
commit('UPDATE_PLAYLOUT_CONFIG', response.data)
}
},
async setPlayoutConfig ({ commit, state }, obj) {
const update = await this.$axios.post('api/player/config/?configPlayout', { data: obj })
return update
},
async getUserConfig ({ commit, state }) {
const user = await this.$axios.get('api/player/user/current/')
const response = await this.$axios.get(`api/player/user/users/?username=${user.data.username}`)
if (user.data) {
commit('SET_CURRENT_USER', user.data.username)
}
if (response.data) {
commit('UPDATE_USER_CONFIG', response.data[0])
}
},
async setUserConfig ({ commit, state }, obj) {
const update = await this.$axios.put(`api/player/user/users/${obj.id}/`, obj)
return update
}
}

View File

@ -0,0 +1 @@
export const strict = false

View File

@ -0,0 +1,49 @@
export const state = () => ({
currentPath: null,
crumbs: [],
folderTree: {}
})
export const mutations = {
UPDATE_CURRENT_PATH (state, path) {
state.currentPath = path
},
UPDATE_CRUMBS (state, crumbs) {
state.crumbs = crumbs
},
UPDATE_FOLDER_TREE (state, tree) {
state.folderTree = tree
}
}
export const actions = {
async getTree ({ commit, dispatch, state }, { extensions, path }) {
const crumbs = []
let root = '/'
const response = await this.$axios.get(`api/player/media/?extensions=${extensions}&path=${path}`)
if (response.data.tree) {
const pathArr = response.data.tree[0].split('/')
if (response.data.tree[1].length === 0) {
response.data.tree[1].push(pathArr[pathArr.length - 1])
}
if (path) {
for (const crumb of pathArr) {
if (crumb) {
root += crumb + '/'
crumbs.push({ text: crumb, path: root })
}
}
} else {
crumbs.push({ text: pathArr[pathArr.length - 1], path: '' })
}
// console.log(crumbs)
commit('UPDATE_CURRENT_PATH', path)
commit('UPDATE_CRUMBS', crumbs)
commit('UPDATE_FOLDER_TREE', response.data)
}
}
}

View File

@ -0,0 +1,84 @@
function secToHMS (sec) {
let hours = Math.floor(sec / 3600)
sec %= 3600
let minutes = Math.floor(sec / 60)
let seconds = sec % 60
minutes = String(minutes).padStart(2, '0')
hours = String(hours).padStart(2, '0')
seconds = String(parseInt(seconds)).padStart(2, '0')
return hours + ':' + minutes + ':' + seconds
}
export const state = () => ({
playlist: null,
playlistToday: [],
playlistChannel: 'Channel 1',
progressValue: 0,
currentClip: 'No clips is playing',
timeStr: '00:00:00',
timeLeft: '00:00:00'
})
export const mutations = {
UPDATE_PLAYLIST (state, list) {
state.playlist = list
},
UPDATE_TODAYS_PLAYLIST (state, list) {
state.playlistToday = list
},
UPDATE_PLAYLIST_CHANNEL (state, channel) {
state.playlistChannel = channel
},
SET_PROGRESS_VALUE (state, value) {
state.progressValue = value
},
SET_CURRENT_CLIP (state, clip) {
state.currentClip = clip
},
SET_TIME (state, time) {
state.timeStr = time
},
SET_TIME_LEFT (state, time) {
state.timeLeft = time
}
}
export const actions = {
async getPlaylist ({ commit, dispatch, state }, { dayStart, date }) {
const response = await this.$axios.get(`api/player/playlist/?date=${date}`)
if (response.data && response.data.program) {
commit('UPDATE_PLAYLIST_CHANNEL', response.data.channel)
commit('UPDATE_PLAYLIST', this.$processPlaylist(dayStart, response.data.program))
if (date === this.$dayjs().format('YYYY-MM-DD')) {
commit('UPDATE_TODAYS_PLAYLIST', JSON.parse(JSON.stringify(response.data.program)))
}
} else {
commit('UPDATE_PLAYLIST', [])
}
},
animClock ({ commit, dispatch, state }, { dayStart }) {
let start = this.$timeToSeconds(dayStart)
// loop over clips in program list from today
for (let i = 0; i < state.playlistToday.length; i++) {
const duration = state.playlistToday[i].out - state.playlistToday[i].in
const time = this.$dayjs().add(1, 'seconds').format('HH:mm:ss')
const playTime = this.$timeToSeconds(time) - start
// set current clip and progressbar value
if (playTime <= duration) {
commit('SET_CURRENT_CLIP', state.playlistToday[i].source)
commit('SET_PROGRESS_VALUE', playTime * 100 / duration)
commit('SET_TIME', time)
commit('SET_TIME_LEFT', secToHMS(duration - playTime))
break
}
start += duration
}
}
}

22
ffplayout/manage.py Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE',
'ffplayout.settings.development')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

View File

@ -1,110 +0,0 @@
<?php
// Include the DirectoryLister class
require_once('resources/DirectoryLister.php');
// Initialize the DirectoryLister object
$lister = new DirectoryLister();
// Initialize the directory array
if (isset($_GET['dir'])) {
$dirArray = $lister->listDirectory($_GET['dir']);
} else {
$dirArray = $lister->listDirectory('.');
}
?>
<?php
if(!empty($_GET['head'])) {
$breadcrumbs = $lister->listBreadcrumbs(); ?>
<p class="navbar-text">
<?php foreach ($breadcrumbs as $breadcrumb): ?>
<?php if ($breadcrumb != end($breadcrumbs)): ?>
<a href="<?php echo $breadcrumb['link']; ?>" class="breadcumb"><?php echo $breadcrumb['text']; ?></a>
<span class="divider">/</span>
<?php else: ?>
<?php echo $breadcrumb['text']; ?>
<?php endif; ?>
<?php endforeach; ?>
</p>
<div id="directory-list-header">
<div class="row">
</div>
</div>
<?php } ?>
<?php
if(!empty($_GET['ul'])) {
$get_type = $_GET['dir'];
foreach($dirArray as $name => $fileInfo):
if ($get_type === "ADtvMedia" and $name === "..") {
continue;
}
if($fileInfo['icon_class'] === "fa-folder") {
$type = "folder";
} else if($fileInfo['icon_class'] === "fa-level-up") {
$type = "level";
} else {
$type = "file";
// file extension to filter
// for make it nicer
$except = array(
'avi',
'mp4',
'mov',
'mkv',
'mpg',
'mpeg',
);
$ext = implode('|', $except);
// formating filenames for the browser
if (preg_match('/^02 #/', $name) === 1) {
$name_exp = explode('#' , $name);
$name_end = preg_split("/[-.]/", $name_exp[4]);
$in_sec = $name_end[0] * 60 + $name_end[1];
$len = gmdate("H:i:s", $in_sec);
$name = $len . ' | ' . $name_exp[2] . '|' . $name_exp[3];
} elseif (preg_match_all('/#/', $name) === 2) {
$name_exp = explode('#' , $name);
$name_end = preg_split("/[-.]/", $name_exp[2]);
$in_sec = $name_end[0] * 60 + $name_end[1];
$len = gmdate("H:i:s", $in_sec);
$name = $len . ' | ' . $name_exp[0] . '|' . $name_exp[1];
} elseif (preg_match('/# [0-9-]+.('.$ext.')$/', $name) === 1) {
$name_exp = explode('#' , $name);
$name_end = preg_split("/[-.]/", end($name_exp));
$in_sec = $name_end[0] * 60 + $name_end[1];
$len = gmdate("H:i:s", $in_sec);
$name_pre = preg_replace('/# [0-9-]+.('.$ext.')$/', '', $name);
$name = $len . ' | ' . $name_pre;
}
$name = str_replace('§', '?', $name);
}?>
<li data-name="<?php echo $name; ?>" class="<?php echo $type ." ". $get_type ?>" data-href="<?php echo $fileInfo['url_path']; ?>">
<a href="<?php echo $fileInfo['url_path']; ?>" class="clearfix" data-name="<?php echo $name; ?>">
<div class="row">
<span class="file-name">
<i class="fa <?php echo $fileInfo['icon_class']; ?> fa-fw"></i>
<?php echo $name; ?>
</span>
</div>
</a>
<?php if (is_file($fileInfo['file_path'])): ?>
<a href="javascript:void(0)" class="file-info-button">
<i class="fa fa-play-circle"></i>
</a>
<?php else: ?>
<?php if ($lister->containsIndex($fileInfo['file_path'])): ?>
<a href="<?php echo $fileInfo['file_path']; ?>" class="web-link-button" <?php if($lister->externalLinksNewWindow()): ?>target="_blank"<?php endif; ?>>
<i class="fa fa-external-link"></i>
</a>
<?php endif; ?>
<?php endif; ?>
</li>
<?php endforeach;
}
?>

View File

@ -1,106 +0,0 @@
<!doctype html>
<html>
<head>
<title>ffplayout</title>
<link rel="shortcut icon" type="image/x-icon" href="">
<link rel="stylesheet" href="./resources/css/font-awesome.min.css">
<link rel="stylesheet" href="./resources/css/jquery-ui.min.css">
<link rel="stylesheet" href="./resources/css/style.css">
<link rel="stylesheet" href="./resources/css/video-js.min.css">
<link rel="stylesheet" href="./resources/css/pignose.calendar.css">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="utf-8">
</head>
<body>
<header id="pageHeader">
<div id="headerCtr">
<div id="current-track" class="header-element">
<div id="track">
<div id="clock"></div>
<div id="countdown"></div>
<div id="title"></div>
</div>
</div>
<div id="streamer-ctr" class="header-element">
<div id=start-stop>
<div id="bt_start" class="fa fa-play-circle-o controll"></div>
<div id="bt_stop" class="fa fa-stop-circle-o controll"></div>
</div>
</div>
</div>
</header>
<nav id="mainNav">
<div id="browser">
<div id="browserHead"></div>
<ul id="rootDirectory" class="nav nav-pills nav-stacked"></ul>
</div>
</nav>
<article id="playlist-editor">
<ul id="playlistTable" class="inner-item">
<li class="row-start pad-left">Start</li>
<li class="row-file">File</li>
<li class="row-preview">Play</li>
<li class="row-duration">Duration</li>
<li class="row-in">In</li>
<li class="row-out">Out</li>
<li class="row-del-head">Delete</li>
</ul>
<div id="container">
<div id="list-container">
<ul id="playlistBody" class="list-group">
<li>...</li>
</ul>
</div>
</div>
</article>
<div id="cal">
<div class="calender"></div>
</div>
<div id="pagePlayer">
<video id="myStream" class="video-js vjs-default-skin vjs-16-9 vjs-big-play-centered" autoplay controls preload="auto">
<!-- change address to your server -->
<source src="rtmp://192.168.3.22/live/stream" type="rtmp/mp4">
<source src="live/stream.m3u8" type="application/x-mpegURL">
</video>
</div>
<footer id="pageFooter">
<div id="footer-area">
<div id="log-area" class="foot logging">
<div class="log-tabs">
<div id="bt_play" class="active logwindow">Playing</div>
<div id="bt_dec" class="logwindow">Decoder</div>
<div id="bt_enc" class="logwindow">Encoder</div>
<div id="bt_sys" class="logwindow">System</div>
</div>
<div id="log-output" class="log-content">
<div id="output">ffplayout logging...</div>
</div>
</div>
<div id="playlistCtr" class="foot playlist-op">
<div class="list-save-reset">
<p>Playlist Operations</p>
<button id="bt_save" class="logwindow">Save</button>
<button id="bt_reset" class="logwindow">Reset</button>
</div>
</div>
</div>
</footer>
<div id="dialog-confirm" title=""></div>
</body>
<!-- SCRIPTS -->
<script src="./resources/js/jquery.min.js"></script>
<script src="./resources/js/jquery-ui.min.js"></script>
<script src="./resources/js/Sortable.min.js"></script>
<script src="./resources/js/video.min.js"></script>
<script src="./resources/js/videojs-contrib-hls.min.js"></script>
<script src="./resources/js/videojs-flash.min.js"></script>
<script src="./resources/js/moment.min.js"></script>
<script src="./resources/js/pignose.calendar.js"></script>
<script src="./resources/js/custom.js"></script>
</html>

11
requirements-base.txt Normal file
View File

@ -0,0 +1,11 @@
Django<=3.1
django-filter
django-cors-headers
djangorestframework
djangorestframework-simplejwt
gunicorn
natsort
psutil
pymediainfo
pyyaml
zmq

16
requirements.txt Normal file
View File

@ -0,0 +1,16 @@
asgiref==3.2.7
Django==3.0.5
django-cors-headers==3.2.1
django-filter==2.2.0
djangorestframework==3.11.0
djangorestframework-simplejwt==4.4.0
gunicorn==20.0.4
natsort==7.0.1
psutil==5.7.0
PyJWT==1.7.1
pymediainfo==4.1
pytz==2020.1
PyYAML==5.3.1
pyzmq==19.0.0
sqlparse==0.3.1
zmq==0.0.0

View File

@ -1,738 +0,0 @@
<?php
/**
* A simple PHP based directory lister that lists the contents
* of a directory and all it's sub-directories and allows easy
* navigation of the files within.
*
* This software distributed under the MIT License
* http://www.opensource.org/licenses/mit-license.php
*
* More info available at http://www.directorylister.com
*
* @author Chris Kankiewicz (http://www.chriskankiewicz.com)
* @copyright 2015 Chris Kankiewicz
*/
class DirectoryLister {
// Define application version
const VERSION = '2.6.1';
// Reserve some variables
protected $_themeName = null;
protected $_directory = null;
protected $_appDir = null;
protected $_appURL = null;
protected $_config = null;
protected $_fileTypes = null;
protected $_systemMessage = null;
/**
* DirectoryLister construct function. Runs on object creation.
*/
public function __construct() {
// Set class directory constant
if(!defined('__DIR__')) {
define('__DIR__', dirname(__FILE__));
}
// Set application directory
$this->_appDir = __DIR__;
// Build the application URL
$this->_appURL = $this->_getAppUrl();
// Load the configuration file
$configFile = $this->_appDir . '/config.php';
// Set the config array to a global variable
if (file_exists($configFile)) {
$this->_config = include($configFile);
} else {
die('ERROR: Missing application config file at ' . $configFile);
}
// Set the file types array to a global variable
$this->_fileTypes = require_once($this->_appDir . '/fileTypes.php');
// Set the theme name
$this->_themeName = $this->_config['theme_name'];
}
/**
* Creates the directory listing and returns the formatted XHTML
*
* @param string $directory Relative path of directory to list
* @return array Array of directory being listed
* @access public
*/
public function listDirectory($directory) {
// Set directory
$directory = $this->setDirectoryPath($directory);
// Set directory variable if left blank
if ($directory === null) {
$directory = $this->_directory;
}
// Get the directory array
$directoryArray = $this->_readDirectory($directory);
// Return the array
return $directoryArray;
}
/**
* Parses and returns an array of breadcrumbs
*
* @param string $directory Path to be breadcrumbified
* @return array Array of breadcrumbs
* @access public
*/
public function listBreadcrumbs($directory = null) {
// Set directory variable if left blank
if ($directory === null) {
$directory = $this->_directory;
}
// Explode the path into an array
$dirArray = explode('/', $directory);
// Statically set the Home breadcrumb
/*
$breadcrumbsArray[] = array(
'link' => $this->_appURL,
'text' => 'Home'
);
*/
// Generate breadcrumbs
foreach ($dirArray as $key => $dir) {
if ($dir != '.') {
$dirPath = null;
// Build the directory path
for ($i = 0; $i <= $key; $i++) {
$dirPath = $dirPath . $dirArray[$i] . '/';
}
// Remove trailing slash
if(substr($dirPath, -1) == '/') {
$dirPath = substr($dirPath, 0, -1);
}
// Combine the base path and dir path
$link = $this->_appURL . '?dir=' . rawurlencode($dirPath);
$breadcrumbsArray[] = array(
'link' => $link,
'text' => $dir
);
}
}
// Return the breadcrumb array
return $breadcrumbsArray;
}
/**
* Determines if a directory contains an index file
*
* @param string $dirPath Path to directory to be checked for an index
* @return boolean Returns true if directory contains a valid index file, false if not
* @access public
*/
public function containsIndex($dirPath) {
// Check if directory contains an index file
foreach ($this->_config['index_files'] as $indexFile) {
if (file_exists($dirPath . '/' . $indexFile)) {
return true;
}
}
return false;
}
/**
* Get path of the listed directory
*
* @return string Path of the listed directory
* @access public
*/
public function getListedPath() {
// Build the path
if ($this->_directory == '.') {
$path = $this->_appURL;
} else {
$path = $this->_appURL . $this->_directory;
}
// Return the path
return $path;
}
/**
* Returns the theme name.
*
* @return string Theme name
* @access public
*/
public function getThemeName() {
// Return the theme name
return $this->_config['theme_name'];
}
/**
* Returns open links in another window
*
* @return boolean Returns true if in config is enabled open links in another window, false if not
* @access public
*/
public function externalLinksNewWindow() {
return $this->_config['external_links_new_window'];
}
/**
* Returns the path to the chosen theme directory
*
* @param bool $absolute Whether or not the path returned is absolute (default = false).
* @return string Path to theme
* @access public
*/
public function getThemePath($absolute = false) {
if ($absolute) {
// Set the theme path
$themePath = $this->_appDir . '/themes/' . $this->_themeName;
} else {
// Get relative path to application dir
$realtivePath = $this->_getRelativePath(getcwd(), $this->_appDir);
// Set the theme path
$themePath = $realtivePath . '/themes/' . $this->_themeName;
}
return $themePath;
}
/**
* Get an array of error messages or false when empty
*
* @return array|bool Array of error messages or false
* @access public
*/
public function getSystemMessages() {
if (isset($this->_systemMessage) && is_array($this->_systemMessage)) {
return $this->_systemMessage;
} else {
return false;
}
}
/**
* Set directory path variable
*
* @param string $path Path to directory
* @return string Sanitizd path to directory
* @access public
*/
public function setDirectoryPath($path = null) {
// Set the directory global variable
$this->_directory = $this->_setDirectoryPath($path);
return $this->_directory;
}
/**
* Get directory path variable
*
* @return string Sanitizd path to directory
* @access public
*/
public function getDirectoryPath() {
return $this->_directory;
}
/**
* Add a message to the system message array
*
* @param string $type The type of message (ie - error, success, notice, etc.)
* @param string $message The message to be displayed to the user
* @return bool true on success
* @access public
*/
public function setSystemMessage($type, $text) {
// Create empty message array if it doesn't already exist
if (isset($this->_systemMessage) && !is_array($this->_systemMessage)) {
$this->_systemMessage = array();
}
// Set the error message
$this->_systemMessage[] = array(
'type' => $type,
'text' => $text
);
return true;
}
/**
* Validates and returns the directory path
*
* @param string $dir Directory path
* @return string Directory path to be listed
* @access protected
*/
protected function _setDirectoryPath($dir) {
// Check for an empty variable
if (empty($dir) || $dir == '.') {
return '.';
}
// Eliminate double slashes
while (strpos($dir, '//')) {
$dir = str_replace('//', '/', $dir);
}
// Remove trailing slash if present
if(substr($dir, -1, 1) == '/') {
$dir = substr($dir, 0, -1);
}
// Verify file path exists and is a directory
if (!file_exists($dir) || !is_dir($dir)) {
// Set the error message
$this->setSystemMessage('danger', '<b>ERROR:</b> File path does not exist');
// Return the web root
return '.';
}
// Prevent access to hidden files
if ($this->_isHidden($dir)) {
// Set the error message
$this->setSystemMessage('danger', '<b>ERROR:</b> Access denied');
// Set the directory to web root
return '.';
}
// Prevent access to parent folders
if (strpos($dir, '<') !== false || strpos($dir, '>') !== false
|| strpos($dir, '..') !== false || strpos($dir, '/') === 0) {
// Set the error message
$this->setSystemMessage('danger', '<b>ERROR:</b> An invalid path string was detected');
// Set the directory to web root
return '.';
} else {
// Should stop all URL wrappers (Thanks to Hexatex)
$directoryPath = $dir;
}
// Return
return $directoryPath;
}
/**
* Loop through directory and return array with file info, including
* file path, icon and sort order.
*
* @param string $directory Directory path
* @param string $sort Sort method (default = natcase)
* @return array Array of the directory contents
* @access protected
*/
protected function _readDirectory($directory, $sort = 'natcase') {
// Initialize array
$directoryArray = array();
// Get directory contents
$files = scandir($directory);
// Read files/folders from the directory
foreach ($files as $file) {
if ($file != '.') {
// Get files relative path
$relativePath = $directory . '/' . $file;
if (substr($relativePath, 0, 2) == './') {
$relativePath = substr($relativePath, 2);
}
// Don't check parent dir if we're in the root dir
if ($this->_directory == '.' && $file == '..'){
continue;
} else {
$realPath = realpath($relativePath);
// Determine file type by extension
if (is_dir($realPath)) {
$iconClass = 'fa-folder';
$sort = 1;
} else {
// Get file extension
$fileExt = strtolower(pathinfo($realPath, PATHINFO_EXTENSION));
if (isset($this->_fileTypes[$fileExt])) {
$iconClass = $this->_fileTypes[$fileExt];
} else {
$iconClass = $this->_fileTypes['blank'];
}
$sort = 2;
}
}
if ($file == '..') {
if ($this->_directory != '.') {
// Get parent directory path
$pathArray = explode('/', $relativePath);
unset($pathArray[count($pathArray)-1]);
unset($pathArray[count($pathArray)-1]);
$directoryPath = implode('/', $pathArray);
if (!empty($directoryPath)) {
$directoryPath = '?dir=' . rawurlencode($directoryPath);
}
// Add file info to the array
$directoryArray['..'] = array(
'file_path' => $this->_appURL . $directoryPath,
'url_path' => $this->_appURL . $directoryPath,
'icon_class' => 'fa-level-up',
'sort' => 0
);
}
} elseif (!$this->_isHidden($relativePath)) {
// Add all non-hidden files to the array
if ($this->_directory != '.' || $file != 'index.php') {
// Build the file path
$urlPath = implode('/', array_map('rawurlencode', explode('/', $relativePath)));
if (is_dir($relativePath)) {
$urlPath = '?dir=' . $urlPath;
} else {
$urlPath = $urlPath;
}
// Add the info to the main array
$directoryArray[pathinfo($relativePath, PATHINFO_BASENAME)] = array(
'file_path' => $relativePath,
'url_path' => $urlPath,
'icon_class' => $iconClass,
'sort' => $sort
);
}
}
}
}
// Sort the array
$reverseSort = in_array($this->_directory, $this->_config['reverse_sort']);
$sortedArray = $this->_arraySort($directoryArray, $this->_config['list_sort_order'], $reverseSort);
// Return the array
return $sortedArray;
}
/**
* Sorts an array by the provided sort method.
*
* @param array $array Array to be sorted
* @param string $sortMethod Sorting method (acceptable inputs: natsort, natcasesort, etc.)
* @param boolen $reverse Reverse the sorted array order if true (default = false)
* @return array
* @access protected
*/
protected function _arraySort($array, $sortMethod, $reverse = false) {
// Create empty arrays
$sortedArray = array();
$finalArray = array();
// Create new array of just the keys and sort it
$keys = array_keys($array);
switch ($sortMethod) {
case 'asort':
asort($keys);
break;
case 'arsort':
arsort($keys);
break;
case 'ksort':
ksort($keys);
break;
case 'krsort':
krsort($keys);
break;
case 'natcasesort':
natcasesort($keys);
break;
case 'natsort':
natsort($keys);
break;
case 'shuffle':
shuffle($keys);
break;
}
// Loop through the sorted values and move over the data
if ($this->_config['list_folders_first']) {
foreach ($keys as $key) {
if ($array[$key]['sort'] == 0) {
$sortedArray['0'][$key] = $array[$key];
}
}
foreach ($keys as $key) {
if ($array[$key]['sort'] == 1) {
$sortedArray[1][$key] = $array[$key];
}
}
foreach ($keys as $key) {
if ($array[$key]['sort'] == 2) {
$sortedArray[2][$key] = $array[$key];
}
}
if ($reverse) {
$sortedArray[1] = array_reverse($sortedArray[1]);
$sortedArray[2] = array_reverse($sortedArray[2]);
}
} else {
foreach ($keys as $key) {
if ($array[$key]['sort'] == 0) {
$sortedArray[0][$key] = $array[$key];
}
}
foreach ($keys as $key) {
if ($array[$key]['sort'] > 0) {
$sortedArray[1][$key] = $array[$key];
}
}
if ($reverse) {
$sortedArray[1] = array_reverse($sortedArray[1]);
}
}
// Merge the arrays
foreach ($sortedArray as $array) {
if (empty($array)) continue;
foreach ($array as $key => $value) {
$finalArray[$key] = $value;
}
}
// Return sorted array
return $finalArray;
}
/**
* Determines if a file is specified as hidden
*
* @param string $filePath Path to file to be checked if hidden
* @return boolean Returns true if file is in hidden array, false if not
* @access protected
*/
protected function _isHidden($filePath) {
// Add dot files to hidden files array
if ($this->_config['hide_dot_files']) {
$this->_config['hidden_files'] = array_merge(
$this->_config['hidden_files'],
array('.*', '*/.*')
);
}
// Compare path array to all hidden file paths
foreach ($this->_config['hidden_files'] as $hiddenPath) {
if (fnmatch($hiddenPath, $filePath)) {
return true;
}
}
return false;
}
/**
* Builds the root application URL from server variables.
*
* @return string The application URL
* @access protected
*/
protected function _getAppUrl() {
// Get the server protocol
if (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') {
$protocol = 'https://';
} else {
$protocol = 'http://';
}
// Get the server hostname
$host = $_SERVER['HTTP_HOST'];
// Get the URL path
$pathParts = pathinfo($_SERVER['PHP_SELF']);
$path = $pathParts['dirname'];
// Remove backslash from path (Windows fix)
if (substr($path, -1) == '\\') {
$path = substr($path, 0, -1);
}
// Ensure the path ends with a forward slash
if (substr($path, -1) != '/') {
$path = $path . '/';
}
// Build the application URL
$appUrl = $protocol . $host . $path;
// Return the URL
return $appUrl;
}
/**
* Compares two paths and returns the relative path from one to the other
*
* @param string $fromPath Starting path
* @param string $toPath Ending path
* @return string $relativePath Relative path from $fromPath to $toPath
* @access protected
*/
protected function _getRelativePath($fromPath, $toPath) {
// Define the OS specific directory separator
if (!defined('DS')) define('DS', DIRECTORY_SEPARATOR);
// Remove double slashes from path strings
$fromPath = str_replace(DS . DS, DS, $fromPath);
$toPath = str_replace(DS . DS, DS, $toPath);
// Explode working dir and cache dir into arrays
$fromPathArray = explode(DS, $fromPath);
$toPathArray = explode(DS, $toPath);
// Remove last fromPath array element if it's empty
$x = count($fromPathArray) - 1;
if(!trim($fromPathArray[$x])) {
array_pop($fromPathArray);
}
// Remove last toPath array element if it's empty
$x = count($toPathArray) - 1;
if(!trim($toPathArray[$x])) {
array_pop($toPathArray);
}
// Get largest array count
$arrayMax = max(count($fromPathArray), count($toPathArray));
// Set some default variables
$diffArray = array();
$samePath = true;
$key = 1;
// Generate array of the path differences
while ($key <= $arrayMax) {
// Get to path variable
$toPath = isset($toPathArray[$key]) ? $toPathArray[$key] : null;
// Get from path variable
$fromPath = isset($fromPathArray[$key]) ? $fromPathArray[$key] : null;
if ($toPath !== $fromPath || $samePath !== true) {
// Prepend '..' for every level up that must be traversed
if (isset($fromPathArray[$key])) {
array_unshift($diffArray, '..');
}
// Append directory name for every directory that must be traversed
if (isset($toPathArray[$key])) {
$diffArray[] = $toPathArray[$key];
}
// Directory paths have diverged
$samePath = false;
}
// Increment key
$key++;
}
// Set the relative thumbnail directory path
$relativePath = implode('/', $diffArray);
// Return the relative path
return $relativePath;
}
}

View File

@ -1,41 +0,0 @@
<?php
return array(
// Basic settings
'hide_dot_files' => true,
'list_folders_first' => true,
'list_sort_order' => 'natcasesort',
'theme_name' => 'bootstrap',
'external_links_new_window' => true,
// Hidden files
'hidden_files' => array(
'.ht*',
'*/.ht*',
'resources',
'resources/*',
'live',
'live/*',
'functions.php',
'process.sh',
'info.php',
'ffplayout-gui.png',
'README.md',
'.gitignore',
'.git',
'.git/*',
),
// Files that, if present in a directory, make the directory
// a direct link rather than a browse link.
'index_files' => array(
'index.htm',
'index.html',
'index.php'
),
// Custom sort order
'reverse_sort' => array(
// 'path/to/folder'
),
);

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,114 +0,0 @@
/*
* CSS Styles that are needed by jScrollPane for it to operate correctly.
*
* Include this stylesheet in your site or copy and paste the styles below into your stylesheet - jScrollPane
* may not operate correctly without them.
*/
.scroll-pane {
width: 100%;
overflow: auto;
}
.jspContainer
{
overflow: hidden;
position: relative;
}
.jspPane
{
position: absolute;
}
.jspVerticalBar
{
position: absolute;
top: 0;
right: 0;
width: 10px;
height: 100%;
}
/*
.jspHorizontalBar
{
display: none;
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 16px;
background: black;
}
*/
.jspTrack
{
background: #4d4c4c;
position: relative;
}
.jspDrag
{
background: #0d0d0d;
position: relative;
top: 0;
left: 0;
cursor: pointer;
}
.jspHorizontalBar .jspTrack,
.jspHorizontalBar .jspDrag
{
float: left;
height: 100%;
}
.jspArrow
{
background: #50506d;
text-indent: -20000px;
display: block;
cursor: pointer;
padding: 0;
margin: 0;
}
.jspArrow.jspDisabled
{
cursor: default;
background: #80808d;
}
.jspVerticalBar .jspArrow
{
height: 16px;
}
.jspHorizontalBar .jspArrow
{
width: 16px;
float: left;
height: 100%;
}
.jspVerticalBar .jspArrow:focus
{
outline: none;
}
.jspCorner
{
background: #eeeef4;
float: left;
height: 100%;
}
/* Yuk! CSS Hack for IE6 3 pixel bug :( */
* html .jspCorner
{
margin: 0 -3px 0 0;
}

View File

@ -1,380 +0,0 @@
@font-face {
font-family: 'pignose-calendar-icon';
src: url("../fonts/pignose.calendar.eot?gpa4vl");
src: url("../fonts/pignose.calendar.eot?gpa4vl#iefix") format('embedded-opentype'), url("../fonts/pignose.calendar.ttf?gpa4vl") format('truetype'), url("../fonts/pignose.calendar.woff?gpa4vl") format('woff'), url("../fonts/pignose.calendar.svg?gpa4vl#pignose.calendar") format('svg');
font-weight: normal;
font-style: normal;
}
.pignose-calendar .icon-arrow-left,
.pignose-calendar .icon-arrow-right {
font-family: 'pignose-calendar-icon' !important;
speak: none;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
}
.pignose-calendar .icon-arrow-left:before {
content: '\e90b';
}
.pignose-calendar .icon-arrow-right:before {
content: '\e90a';
}
.pignose-calendar-wrapper {
display: none;
position: fixed;
width: 90%;
max-width: 360px;
top: 50%;
left: 50%;
z-index: 50001;
overflow: hidden;
transform: translate3d(0, 160px, 0);
opacity: 0;
transition: opacity 0.3s ease, transform 0.5s ease-out;
}
.pignose-calendar-wrapper.pignose-calendar-wrapper-active {
opacity: 1;
transform: translate3d(0, 0, 0);
}
.pignose-calendar-wrapper .pignose-calendar {
max-width: auto;
width: 100%;
border: none;
}
.pignose-calendar-wrapper .pignose-calendar .pignose-calendar-button-group {
border-top: 1px solid #e2e2e2;
overflow: hidden;
}
.pignose-calendar-wrapper .pignose-calendar .pignose-calendar-button-group .pignose-calendar-button {
width: 50%;
display: block;
float: left;
height: 3.2em;
text-align: center;
line-height: 3.2em;
color: #333333;
font-weight: 600;
text-decoration: none;
transition: background-color 0.3s ease;
box-sizing: border-box;
}
.pignose-calendar-wrapper .pignose-calendar .pignose-calendar-button-group .pignose-calendar-button:hover {
background-color: #efefef;
}
.pignose-calendar-wrapper .pignose-calendar .pignose-calendar-button-group .pignose-calendar-button-apply {
color: #111111;
background-color: #2fabb7;
}
.pignose-calendar-wrapper .pignose-calendar .pignose-calendar-button-group .pignose-calendar-button-apply:hover {
background-color: #49c4d0;
}
.pignose-calendar-wrapper-overlay {
background-color: #000000;
opacity: 0;
transition: opacity .3s ease;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100%;
height: 100%;
z-index: 50000;
}
.pignose-calendar-wrapper-overlay.pignose-calendar-wrapper-overlay-active {
opacity: .7;
}
.pignose-calendar {
width: 90%;
max-width: 360px;
background-color: #111111;
font-size: 100%;
margin: 0 auto;
}
.pignose-calendar .pignose-calendar-top {
padding: 2.6em 0;
background-color: #fafafa;
position: relative;
overflow: hidden;
}
.pignose-calendar .pignose-calendar-top .pignose-calendar-top-date {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
padding: 1.8em 0;
text-align: center;
text-transform: uppercase;
box-sizing: border-box;
}
.pignose-calendar .pignose-calendar-top .pignose-calendar-top-year,
.pignose-calendar .pignose-calendar-top .pignose-calendar-top-month {
display: block;
text-align: center;
}
.pignose-calendar .pignose-calendar-top .pignose-calendar-top-year {
font-size: 115%;
color: rgba(0, 0, 0, 0.5);
}
.pignose-calendar .pignose-calendar-top .pignose-calendar-top-month {
margin-bottom: .4em;
font-size: 130%;
font-weight: 600;
}
.pignose-calendar .pignose-calendar-top .pignose-calendar-top-nav {
display: inline-block;
width: 1.6em;
height: 1.6em;
position: relative;
z-index: 5;
text-decoration: none;
}
.pignose-calendar .pignose-calendar-top .pignose-calendar-top-nav .pignose-calendar-top-value {
display: inline-block;
color: #777777;
font-size: 115%;
font-weight: 600;
vertical-align: middle;
margin-top: -10px;
}
.pignose-calendar .pignose-calendar-top .pignose-calendar-top-nav .pignose-calendar-top-icon {
color: #555555;
font-size: 160%;
}
.pignose-calendar .pignose-calendar-top .pignose-calendar-top-nav.pignose-calendar-top-prev {
float: left;
margin-left: 1.6em;
}
.pignose-calendar .pignose-calendar-top .pignose-calendar-top-nav.pignose-calendar-top-prev .pignose-calendar-top-value {
margin-left: .2em;
}
.pignose-calendar .pignose-calendar-top .pignose-calendar-top-nav.pignose-calendar-top-next {
float: right;
margin-right: 1.6em;
}
.pignose-calendar .pignose-calendar-top .pignose-calendar-top-nav.pignose-calendar-top-next .pignose-calendar-top-value {
margin-right: .2em;
}
.pignose-calendar .pignose-calendar-header {
padding: 0 1.2em;
margin-top: 1.2em;
font-weight: 600;
overflow: hidden;
}
.pignose-calendar .pignose-calendar-header .pignose-calendar-week {
float: left;
width: 14.28%;
height: 2.8em;
text-align: center;
line-height: 2.8em;
box-sizing: border-box;
}
.pignose-calendar .pignose-calendar-header .pignose-calendar-week.pignose-calendar-week-sun,
.pignose-calendar .pignose-calendar-header .pignose-calendar-week.pignose-calendar-week-sat {
color: #fa4832;
}
.pignose-calendar .pignose-calendar-header .pignose-calendar-week:last-child {
width: 14.32%;
}
.pignose-calendar .pignose-calendar-body {
padding: 1.2em;
}
.pignose-calendar .pignose-calendar-body .pignose-calendar-row {
overflow: hidden;
}
.pignose-calendar .pignose-calendar-unit {
float: left;
display: block;
height: 3.8em;
width: 14.28%;
text-align: center;
line-height: 2.8em;
box-sizing: border-box;
}
.pignose-calendar .pignose-calendar-unit:last-child {
width: 14.32%;
}
.pignose-calendar .pignose-calendar-unit .pignose-calendar-button-schedule-container {
line-height: .5em;
}
.pignose-calendar .pignose-calendar-unit .pignose-calendar-button-schedule-container .pignose-calendar-button-schedule-pin {
display: inline-block;
background-color: #777777;
width: .5em;
height: .5em;
border-radius: 50%;
margin-right: .2em;
}
.pignose-calendar .pignose-calendar-unit .pignose-calendar-button-schedule-container .pignose-calendar-button-schedule-pin:last-child {
margin-right: 0;
}
.pignose-calendar .pignose-calendar-unit a {
display: inline-block;
width: 2.4em;
height: 2.4em;
border-radius: 50%;
color: #49c4d0;
line-height: 2.4em;
text-align: center;
text-decoration: none;
transition: background-color 0.3s ease, color 0.3s ease;
}
.pignose-calendar .pignose-calendar-unit a:active {
background-color: #d8d8d8;
}
.pignose-calendar .pignose-calendar-unit.pignose-calendar-unit-disabled a {
opacity: .5;
background-color: #efefef;
}
.pignose-calendar .pignose-calendar-unit.pignose-calendar-unit-active a {
background-color: #2fabb7;
color: #111111;
font-weight: 600;
}
.pignose-calendar .pignose-calendar-unit.pignose-calendar-unit-active.pignose-calendar-unit-sun a,
.pignose-calendar .pignose-calendar-unit.pignose-calendar-unit-active.pignose-calendar-unit-sat a {
color: #111111;
}
.pignose-calendar .pignose-calendar-unit.pignose-calendar-unit-range a {
background-color: #efefef;
border-radius: 0;
width: 100%;
}
.pignose-calendar .pignose-calendar-unit.pignose-calendar-unit-range.pignose-calendar-unit-disabled a {
color: #b2b9bb;
background-color: #e1e1e1;
}
.pignose-calendar .pignose-calendar-unit.pignose-calendar-unit-range.pignose-calendar-unit-range-first a {
border-top-left-radius: 1.2em;
border-bottom-left-radius: 1.2em;
}
.pignose-calendar .pignose-calendar-unit.pignose-calendar-unit-range.pignose-calendar-unit-range-last a {
border-top-right-radius: 1.2em;
border-bottom-right-radius: 1.2em;
}
.pignose-calendar .pignose-calendar-unit.pignose-calendar-unit-sun a,
.pignose-calendar .pignose-calendar-unit.pignose-calendar-unit-sat a {
color: #fa4832;
}
.pignose-calendar.pignose-calendar-default .pignose-calendar-body .pignose-calendar-row .pignose-calendar-unit.pignose-calendar-unit-toggle-active a {
color: #cccccc !important;
}
.pignose-calendar.pignose-calendar-default.pignose-calendar-reverse .pignose-calendar-body .pignose-calendar-row .pignose-calendar-unit.pignose-calendar-unit-toggle-inactive a {
color: #cccccc !important;
}
.pignose-calendar.pignose-calendar-dark {
background-color: #b0b0b0;
}
.pignose-calendar.pignose-calendar-dark .pignose-calendar-top {
background-color: #8c8c8c;
}
.pignose-calendar.pignose-calendar-dark .pignose-calendar-top .pignose-calendar-top-month {
color: #111111;
}
.pignose-calendar.pignose-calendar-dark .pignose-calendar-top .pignose-calendar-top-year {
color: #111111;
}
.pignose-calendar.pignose-calendar-dark .pignose-calendar-top .pignose-calendar-top-nav .pignose-calendar-top-value {
color: #111111;
}
.pignose-calendar.pignose-calendar-dark .pignose-calendar-top .pignose-calendar-top-nav .pignose-calendar-top-icon {
color: #111111;
}
.pignose-calendar.pignose-calendar-dark .pignose-calendar-header .pignose-calendar-week {
color: #111111;
}
.pignose-calendar.pignose-calendar-dark .pignose-calendar-header.pignose-calendar-week-sun,
.pignose-calendar.pignose-calendar-dark .pignose-calendar-header.pignose-calendar-week-sat {
color: #444444;
}
.pignose-calendar.pignose-calendar-dark .pignose-calendar-body .pignose-calendar-row .pignose-calendar-unit a {
color: #111111;
}
.pignose-calendar.pignose-calendar-dark .pignose-calendar-body .pignose-calendar-row .pignose-calendar-unit.pignose-calendar-unit-sun a,
.pignose-calendar.pignose-calendar-dark .pignose-calendar-body .pignose-calendar-row .pignose-calendar-unit.pignose-calendar-unit-sat a {
color: #444444;
}
.pignose-calendar.pignose-calendar-dark .pignose-calendar-body .pignose-calendar-row .pignose-calendar-unit.pignose-calendar-unit-disabled a {
color: #868e8f;
background-color: #5d6365;
}
.pignose-calendar.pignose-calendar-dark .pignose-calendar-body .pignose-calendar-row .pignose-calendar-unit.pignose-calendar-unit-active a {
color: #111111;
background-color: #B38E8E;
}
.pignose-calendar.pignose-calendar-dark .pignose-calendar-body .pignose-calendar-row .pignose-calendar-unit.pignose-calendar-unit-toggle a {
color: #8b8f94;
}
.pignose-calendar.pignose-calendar-dark .pignose-calendar-body .pignose-calendar-row .pignose-calendar-unit.pignose-calendar-unit-range a {
background-color: #5a5d62;
}
.pignose-calendar.pignose-calendar-dark .pignose-calendar-body .pignose-calendar-row .pignose-calendar-unit.pignose-calendar-unit-range.pignose-calendar-unit-disabled a {
color: #727a7c;
background-color: #4f5558;
}
.pignose-calendar.pignose-calendar-dark .pignose-calendar-button-group {
border-top: 1px solid #323537;
overflow: hidden;
}
.pignose-calendar.pignose-calendar-dark .pignose-calendar-button-group .pignose-calendar-button {
color: #111111;
}
.pignose-calendar.pignose-calendar-dark .pignose-calendar-button-group .pignose-calendar-button:hover {
background-color: #5a5d62;
}
.pignose-calendar.pignose-calendar-dark .pignose-calendar-button-group .pignose-calendar-button-apply {
color: #111111;
background-color: #B38E8E;
}
.pignose-calendar.pignose-calendar-blue {
background-color: #fafafa;
}
.pignose-calendar.pignose-calendar-blue .pignose-calendar-top {
background-color: #009fe3;
border-bottom-color: #e1e1e1;
}
.pignose-calendar.pignose-calendar-blue .pignose-calendar-top .pignose-calendar-top-month {
color: #111111;
}
.pignose-calendar.pignose-calendar-blue .pignose-calendar-top .pignose-calendar-top-year {
color: #111111;
}
.pignose-calendar.pignose-calendar-blue .pignose-calendar-top .pignose-calendar-top-nav .pignose-calendar-top-value {
color: #111111;
}
.pignose-calendar.pignose-calendar-blue .pignose-calendar-top .pignose-calendar-top-nav .pignose-calendar-top-icon {
color: #111111;
}
.pignose-calendar.pignose-calendar-blue .pignose-calendar-header .pignose-calendar-week {
color: #5c6270;
}
.pignose-calendar.pignose-calendar-blue .pignose-calendar-header .pignose-calendar-week.pignose-calendar-week-sun,
.pignose-calendar.pignose-calendar-blue .pignose-calendar-header .pignose-calendar-week.pignose-calendar-week-sat {
color: #fa4832;
}
.pignose-calendar.pignose-calendar-blue .pignose-calendar-body .pignose-calendar-row .pignose-calendar-unit a {
color: #5c6270;
}
.pignose-calendar.pignose-calendar-blue .pignose-calendar-body .pignose-calendar-row .pignose-calendar-unit.pignose-calendar-unit-sun a,
.pignose-calendar.pignose-calendar-blue .pignose-calendar-body .pignose-calendar-row .pignose-calendar-unit.pignose-calendar-unit-sat a {
color: #fa4832;
}
.pignose-calendar.pignose-calendar-blue .pignose-calendar-body .pignose-calendar-row .pignose-calendar-unit.pignose-calendar-unit-disabled a {
background-color: #efefef;
}
.pignose-calendar.pignose-calendar-blue .pignose-calendar-body .pignose-calendar-row .pignose-calendar-unit.pignose-calendar-unit-active a {
color: #111111;
background-color: #009fe3;
}
.pignose-calendar.pignose-calendar-blue .pignose-calendar-body .pignose-calendar-row .pignose-calendar-unit.pignose-calendar-unit-toggle a {
color: #cccccc;
}
.pignose-calendar.pignose-calendar-blue .pignose-calendar-body .pignose-calendar-row .pignose-calendar-unit.pignose-calendar-unit-range a {
background-color: #efefef;
}
.pignose-calendar.pignose-calendar-blue .pignose-calendar-body .pignose-calendar-row .pignose-calendar-unit.pignose-calendar-unit-range.pignose-calendar-unit-disabled a {
background-color: #efefef;
}

Some files were not shown because too many files have changed in this diff Show More