Merge branch 'v2.0.0-dev'
26
.gitignore
vendored
@ -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/
|
||||
|
56
README.md
@ -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
After Width: | Height: | Size: 55 KiB |
BIN
assets/control.png
Normal file
After Width: | Height: | Size: 160 KiB |
BIN
assets/landing-page.png
Normal file
After Width: | Height: | Size: 77 KiB |
BIN
assets/logging.png
Normal file
After Width: | Height: | Size: 378 KiB |
BIN
assets/login.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
assets/media-upload.png
Normal file
After Width: | Height: | Size: 63 KiB |
BIN
assets/media.png
Normal file
After Width: | Height: | Size: 53 KiB |
BIN
assets/message.png
Normal file
After Width: | Height: | Size: 61 KiB |
83
docs/db_data.json
Normal 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
@ -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'),
|
||||
}
|
||||
}
|
||||
```
|
14
docs/ffplayout-api.service
Normal 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
@ -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
@ -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
|
||||
```
|
Before Width: | Height: | Size: 274 KiB |
0
ffplayout/apps/api_player/__init__.py
Normal file
21
ffplayout/apps/api_player/admin.py
Normal 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)
|
5
ffplayout/apps/api_player/apps.py
Normal file
@ -0,0 +1,5 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApiPlayerConfig(AppConfig):
|
||||
name = 'api_player'
|
0
ffplayout/apps/api_player/migrations/__init__.py
Normal file
70
ffplayout/apps/api_player/models.py
Normal 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"
|
61
ffplayout/apps/api_player/serializers.py
Normal 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__'
|
10
ffplayout/apps/api_player/settings.py
Normal 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'),
|
||||
}
|
||||
}
|
3
ffplayout/apps/api_player/tests.py
Normal file
@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
26
ffplayout/apps/api_player/urls.py
Normal 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()),
|
||||
]
|
293
ffplayout/apps/api_player/utils.py
Normal 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'])]
|
268
ffplayout/apps/api_player/views.py
Normal 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)
|
0
ffplayout/ffplayout/__init__.py
Normal file
17
ffplayout/ffplayout/asgi.py
Normal 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()
|
0
ffplayout/ffplayout/settings/__init__.py
Normal file
141
ffplayout/ffplayout/settings/common.py
Normal 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
|
26
ffplayout/ffplayout/settings/development.py
Normal 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'
|
||||
)
|
25
ffplayout/ffplayout/settings/production.py
Normal 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',
|
||||
)
|
44
ffplayout/ffplayout/urls.py
Normal 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, )
|
17
ffplayout/ffplayout/wsgi.py
Normal 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()
|
13
ffplayout/frontend/.editorconfig
Normal 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
|
23
ffplayout/frontend/.eslintrc.js
Normal 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
@ -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
|
27
ffplayout/frontend/README.md
Normal 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).
|
7
ffplayout/frontend/assets/README.md
Normal 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).
|
497
ffplayout/frontend/assets/css/_bootswatch.scss
Normal 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%);
|
||||
}
|
||||
}
|
164
ffplayout/frontend/assets/css/_variables.scss
Executable 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;
|
12
ffplayout/frontend/assets/css/bootstrap.min.css
vendored
Normal file
BIN
ffplayout/frontend/assets/fonts/DigitalNumbers-Regular.woff2
Normal file
93
ffplayout/frontend/assets/fonts/font-license.txt
Normal 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.
|
156
ffplayout/frontend/assets/scss/globals.scss
Normal 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;
|
||||
}
|
62
ffplayout/frontend/components/Menu.vue
Normal 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>
|
7
ffplayout/frontend/components/README.md
Normal 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._
|
56
ffplayout/frontend/components/VideoPlayer.vue
Normal 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>
|
7
ffplayout/frontend/layouts/README.md
Normal 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).
|
27
ffplayout/frontend/layouts/default.vue
Normal 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>
|
8
ffplayout/frontend/middleware/README.md
Normal 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).
|
7
ffplayout/frontend/middleware/auth.js
Normal file
@ -0,0 +1,7 @@
|
||||
export default async function ({ store, redirect }) {
|
||||
await store.dispatch('auth/inspectToken')
|
||||
|
||||
if (!store.state.auth.isLogin) {
|
||||
return redirect('/')
|
||||
}
|
||||
}
|
104
ffplayout/frontend/nuxt.config.js
Normal 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
39
ffplayout/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
6
ffplayout/frontend/pages/README.md
Normal 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).
|
332
ffplayout/frontend/pages/configure.vue
Normal 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>
|
302
ffplayout/frontend/pages/index.vue
Normal 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>
|
146
ffplayout/frontend/pages/logging.vue
Normal 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>
|
571
ffplayout/frontend/pages/media.vue
Normal 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>
|
435
ffplayout/frontend/pages/message.vue
Normal 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>
|
673
ffplayout/frontend/pages/player.vue
Normal 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>
|
7
ffplayout/frontend/plugins/README.md
Normal 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).
|
21
ffplayout/frontend/plugins/axios.js
Normal 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('/')
|
||||
}
|
||||
})
|
||||
}
|
5
ffplayout/frontend/plugins/draggable.js
Normal file
@ -0,0 +1,5 @@
|
||||
import Vue from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
|
||||
Vue.use(draggable)
|
||||
Vue.component('draggable', draggable)
|
20
ffplayout/frontend/plugins/filters.js
Normal 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 ''
|
||||
}
|
||||
})
|
34
ffplayout/frontend/plugins/helpers.js
Normal 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
|
||||
})
|
||||
}
|
6
ffplayout/frontend/plugins/loading.js
Normal 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)
|
5
ffplayout/frontend/plugins/scrollbar.js
Normal 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)
|
6
ffplayout/frontend/plugins/splitpanes.js
Normal 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)
|
4
ffplayout/frontend/plugins/video.js
Normal file
@ -0,0 +1,4 @@
|
||||
import Vue from 'vue'
|
||||
import VideoPlayer from '@/components/VideoPlayer.vue'
|
||||
|
||||
Vue.component('video-player', VideoPlayer)
|
11
ffplayout/frontend/static/README.md
Normal 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).
|
BIN
ffplayout/frontend/static/favicon.ico
Normal file
After Width: | Height: | Size: 8.5 KiB |
2
ffplayout/frontend/static/robots.txt
Normal file
@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
10
ffplayout/frontend/store/README.md
Normal 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).
|
88
ffplayout/frontend/store/auth.js
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
82
ffplayout/frontend/store/config.js
Normal 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
|
||||
}
|
||||
}
|
1
ffplayout/frontend/store/index.js
Normal file
@ -0,0 +1 @@
|
||||
export const strict = false
|
49
ffplayout/frontend/store/media.js
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
84
ffplayout/frontend/store/playlist.js
Normal 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
@ -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()
|
110
functions.php
@ -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;
|
||||
}
|
||||
?>
|
106
index.html
@ -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
@ -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
@ -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
|
@ -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;
|
||||
|
||||
}
|
||||
|
||||
}
|
@ -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'
|
||||
),
|
||||
);
|
4
resources/css/font-awesome.min.css
vendored
Before Width: | Height: | Size: 6.8 KiB |
Before Width: | Height: | Size: 6.9 KiB |
Before Width: | Height: | Size: 4.6 KiB |
Before Width: | Height: | Size: 6.8 KiB |
Before Width: | Height: | Size: 4.5 KiB |
Before Width: | Height: | Size: 6.2 KiB |
7
resources/css/jquery-ui.min.css
vendored
5
resources/css/jquery-ui.structure.min.css
vendored
5
resources/css/jquery-ui.theme.min.css
vendored
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|