migrate to nuxtjs v3 and bootstrap 5
@ -1,13 +0,0 @@
|
|||||||
# 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
|
|
27
.eslintrc.js
@ -1,27 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
root: true,
|
|
||||||
env: {
|
|
||||||
browser: true,
|
|
||||||
node: true
|
|
||||||
},
|
|
||||||
parserOptions: {
|
|
||||||
parser: '@babel/eslint-parser',
|
|
||||||
requireConfigFile: false,
|
|
||||||
babelOptions: {
|
|
||||||
presets: ['@babel/preset-react']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
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' }]
|
|
||||||
}
|
|
||||||
}
|
|
92
.gitignore
vendored
@ -1,56 +1,11 @@
|
|||||||
# Created by .ignore support plugin (hsz.mobi)
|
node_modules
|
||||||
### Node template
|
*.log*
|
||||||
# Logs
|
.nuxt
|
||||||
/logs
|
.nitro
|
||||||
*.log
|
.cache
|
||||||
npm-debug.log*
|
.output
|
||||||
yarn-debug.log*
|
.env
|
||||||
yarn-error.log*
|
dist
|
||||||
|
|
||||||
# Runtime data
|
|
||||||
pids
|
|
||||||
*.pid
|
|
||||||
*.seed
|
|
||||||
*.pid.lock
|
|
||||||
|
|
||||||
jsconfig.json
|
|
||||||
|
|
||||||
# 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'
|
# Output of 'npm pack'
|
||||||
*.tgz
|
*.tgz
|
||||||
@ -58,30 +13,6 @@ typings/
|
|||||||
# Yarn Integrity file
|
# Yarn Integrity file
|
||||||
.yarn-integrity
|
.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
|
# Service worker
|
||||||
sw.*
|
sw.*
|
||||||
|
|
||||||
@ -96,5 +27,10 @@ tv-media
|
|||||||
tv-media/
|
tv-media/
|
||||||
Videos
|
Videos
|
||||||
Videos/
|
Videos/
|
||||||
public/
|
|
||||||
*.tar*
|
*.tar*
|
||||||
|
live
|
||||||
|
live/
|
||||||
|
.vscode
|
||||||
|
.vscode/
|
||||||
|
home
|
||||||
|
home/
|
||||||
|
26
README.md
@ -1,6 +1,12 @@
|
|||||||
ffplayout-frontend
|
ffplayout-frontend
|
||||||
=====
|
=====
|
||||||
|
|
||||||
|
----------------------------
|
||||||
|
### **Waring: The frontend uses bootstrap 5.3 Alpha and for that is not production ready!**
|
||||||
|
**Use [nuxt2-v5.1.0](https://github.com/ffplayout/ffplayout-frontend/tree/nuxt2-v5.1.0) branch, if you need a production ready version.**
|
||||||
|
|
||||||
|
----------------------------
|
||||||
|
|
||||||
This web GUI is for managing [ffplayout](https://github.com/ffplayout/ffplayout).
|
This web GUI is for managing [ffplayout](https://github.com/ffplayout/ffplayout).
|
||||||
|
|
||||||
**The Interface is mostly made for 24/7 streaming.** Other scenarios like streaming in folder mode or playlists with no starting time will work, but is not shown correctly.
|
**The Interface is mostly made for 24/7 streaming.** Other scenarios like streaming in folder mode or playlists with no starting time will work, but is not shown correctly.
|
||||||
@ -12,23 +18,23 @@ Read [install.md](docs/INSTALL.md) for manual installation.
|
|||||||
After installations you have to setup ssl for your **https** connections.
|
After installations you have to setup ssl for your **https** connections.
|
||||||
|
|
||||||
## Some Impressions:
|
## Some Impressions:
|
||||||
#### Login
|
### Login
|
||||||
![login](/docs/images/login.png)
|
![login](/docs/images/login.png)
|
||||||
|
|
||||||
#### Control Page
|
### Landing Page
|
||||||
![control](/docs/images/control.png)
|
![landing](/docs/images/landing.png)
|
||||||
|
|
||||||
#### Media Page
|
### Control Page
|
||||||
|
![player](/docs/images/player.png)
|
||||||
|
|
||||||
|
### Media Page
|
||||||
![media](/docs/images/media.png)
|
![media](/docs/images/media.png)
|
||||||
|
|
||||||
#### Media Page / Upload
|
### Message Page
|
||||||
![media-upload](/docs/images/media-upload.png)
|
|
||||||
|
|
||||||
#### Message Page
|
|
||||||
![message](/docs/images/message.png)
|
![message](/docs/images/message.png)
|
||||||
|
|
||||||
#### Logging Page
|
### Logging Page
|
||||||
![logging](/docs/images/logging.png)
|
![logging](/docs/images/logging.png)
|
||||||
|
|
||||||
#### Configuration Page / GUI
|
### Configuration Page
|
||||||
![config-gui](/docs/images/config-gui.png)
|
![config-gui](/docs/images/config-gui.png)
|
||||||
|
@ -1,7 +0,0 @@
|
|||||||
# 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).
|
|
@ -1,497 +0,0 @@
|
|||||||
// 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%);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,164 +0,0 @@
|
|||||||
// 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
assets/css/bootstrap.min.css
vendored
BIN
assets/images/ffplayout-small.png
Normal file
After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
47
assets/scss/_variables.scss
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
$gray-800: #3a3f44 !default;
|
||||||
|
$gray-900: #262a2c !default;
|
||||||
|
|
||||||
|
$primary: $gray-800 !default;
|
||||||
|
$secondary: $gray-900 !default;
|
||||||
|
|
||||||
|
$accent: #ff9c36;
|
||||||
|
$bg-primary: #212529;
|
||||||
|
$bg-secondary: #1d2024;
|
||||||
|
$link-color: #c4c4c4;
|
||||||
|
$link-color-hover: #e7e7e7;
|
||||||
|
$item-hover: rgba(31, 31, 31, 0.8);
|
||||||
|
$border-color: black;
|
||||||
|
$overlay-bg: rgba(61, 61, 61, 0.534);
|
||||||
|
|
||||||
|
$success: #438d3c;
|
||||||
|
$danger: #972929;
|
||||||
|
$warning: #a78c14;
|
||||||
|
$alert-color: #ffffff;
|
||||||
|
|
||||||
|
$control-button-play: #43c32e;
|
||||||
|
$control-button-play-hover: #339924;
|
||||||
|
$control-button-stop: #d01111;
|
||||||
|
$control-button-stop-hover: #a70e0e;
|
||||||
|
$control-button-restart: #d1c410;
|
||||||
|
$control-button-restart-hover: #b4a90e;
|
||||||
|
$control-button-control: #06aad3;
|
||||||
|
$control-button-control-hover: #068bac;
|
||||||
|
|
||||||
|
$log-time: #666864;
|
||||||
|
$log-number: #e2c317;
|
||||||
|
$log-addr: #ad7fa8;
|
||||||
|
$log-cmd: #6c95c2;
|
||||||
|
$log-content: #ececec;
|
||||||
|
$log-info: #8ae234;
|
||||||
|
$log-warning: #ff8700;
|
||||||
|
$log-error: #d32828;
|
||||||
|
$log-debug: #6e99c7;
|
||||||
|
$log-decoder: #56efff;
|
||||||
|
$log-encoder: #45ccee;
|
||||||
|
$log-server: #23cbdd;
|
||||||
|
|
||||||
|
$theme-colors: (
|
||||||
|
'primary': $primary,
|
||||||
|
);
|
||||||
|
|
||||||
|
$b-radius: 3px;
|
@ -1,227 +0,0 @@
|
|||||||
#__nuxt,
|
|
||||||
#__layout,
|
|
||||||
#__layout > div,
|
|
||||||
#__layout > div > div {
|
|
||||||
height: 100%;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: "DigitalNumbers-Regular";
|
|
||||||
src: url("~@/assets/fonts/DigitalNumbers-Regular.woff2") format("woff2");
|
|
||||||
font-weight: normal;
|
|
||||||
font-style: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-row {
|
|
||||||
height: 40px;
|
|
||||||
width: 100%;
|
|
||||||
padding-top: 7px;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.date-div {
|
|
||||||
width: 250px;
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-dark {
|
|
||||||
color: #aab1b9 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rounded-circle {
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-outline-primary {
|
|
||||||
color: #e1e9f4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb {
|
|
||||||
margin-bottom: 0.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-item {
|
|
||||||
background: none;
|
|
||||||
padding: 0.1em;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-icons {
|
|
||||||
margin-right: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.breadcrumb {
|
|
||||||
background-color: #32383e;
|
|
||||||
background-image: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.duration {
|
|
||||||
float: right;
|
|
||||||
margin-right: 0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
width: 100%;
|
|
||||||
float: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-div {
|
|
||||||
width: 100%;
|
|
||||||
max-height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-div .ps {
|
|
||||||
padding-left: 0.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, 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.player-browser-scroll {
|
|
||||||
height: calc(100% - 50px);
|
|
||||||
overflow: auto;
|
|
||||||
scrollbar-width: medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
.browser-div .media-browser-scroll {
|
|
||||||
height: 100%;
|
|
||||||
overflow: auto;
|
|
||||||
scrollbar-width: medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar {
|
|
||||||
width: 9px;
|
|
||||||
height: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-track,
|
|
||||||
::-webkit-scrollbar-corner {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background-color: rgba(60, 65, 65, 0.705);
|
|
||||||
border-radius: 5px;
|
|
||||||
border: transparent;
|
|
||||||
min-height: 40px;
|
|
||||||
}
|
|
214
assets/scss/main.scss
Normal file
@ -0,0 +1,214 @@
|
|||||||
|
@import '_variables.scss';
|
||||||
|
@import 'bootstrap/scss/bootstrap';
|
||||||
|
|
||||||
|
#__nuxt,
|
||||||
|
main,
|
||||||
|
main > div {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: 'DigitalNumbers-Regular';
|
||||||
|
src: url('@/assets/fonts/DigitalNumbers-Regular.woff2') format('woff2');
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
font-family: 'Source Sans Pro', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial,
|
||||||
|
sans-serif;
|
||||||
|
background-color: $bg-primary;
|
||||||
|
background: $bg-primary;
|
||||||
|
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;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $link-color;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: $link-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
--bs-navbar-padding-y: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group-item {
|
||||||
|
border-color: $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group {
|
||||||
|
--bs-list-group-border-radius: $b-radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-success {
|
||||||
|
background-color: $success;
|
||||||
|
color: $alert-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-danger {
|
||||||
|
background-color: $danger;
|
||||||
|
color: $alert-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-warning {
|
||||||
|
background-color: $warning;
|
||||||
|
color: $alert-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-alert {
|
||||||
|
position: absolute;
|
||||||
|
top: 80px;
|
||||||
|
right: 12px;
|
||||||
|
max-width: 400px;
|
||||||
|
max-height: 60px;
|
||||||
|
z-index: 11;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-warning {
|
||||||
|
background-color: $warning;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb {
|
||||||
|
background-color: $gray-900;
|
||||||
|
--bs-breadcrumb-margin-bottom: 0;
|
||||||
|
height: 39px;
|
||||||
|
padding: .5em;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-bottom: 0;
|
||||||
|
border-radius: $b-radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-div {
|
||||||
|
width: 250px;
|
||||||
|
height: 37px;
|
||||||
|
float: right;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-item {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 0 0 .5em;
|
||||||
|
min-height: 26px;
|
||||||
|
min-height: 26px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-div {
|
||||||
|
background-color: $gray-900;
|
||||||
|
border: 1px solid $border-color;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-browser-scroll {
|
||||||
|
height: calc(100% - 3px);
|
||||||
|
overflow: auto;
|
||||||
|
scrollbar-width: medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-container .player-browser-scroll {
|
||||||
|
position: relative;
|
||||||
|
height: calc(100% - 50px);
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-icons {
|
||||||
|
margin-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-icons-col {
|
||||||
|
max-width: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-play-col {
|
||||||
|
max-width: 30px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-dur-col {
|
||||||
|
min-width: 95px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0 10px 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-item-text {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 95%;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-button {
|
||||||
|
float: right;
|
||||||
|
margin: 1em 0.8em 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitpanes__pane {
|
||||||
|
background-color: rgba(34, 34, 34, 0.233);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitpanes--vertical > .splitpanes__splitter {
|
||||||
|
position: relative;
|
||||||
|
border-left: 1px solid $border-color;
|
||||||
|
border-right: 1px solid $border-color;
|
||||||
|
width: 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.splitpanes--vertical > .splitpanes__splitter::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
background-color: $border-color;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 2px;
|
||||||
|
height: 30px;
|
||||||
|
transition: background-color 0.3s;
|
||||||
|
margin-left: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-row .grabbing {
|
||||||
|
cursor: -webkit-grabbing;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
background-color: $overlay-bg;
|
||||||
|
z-index: 11;
|
||||||
|
position: absolute;
|
||||||
|
width: calc(100% - 25px);
|
||||||
|
height: calc(100% - 36px);
|
||||||
|
|
||||||
|
.spinner-border {
|
||||||
|
position: absolute;
|
||||||
|
margin: auto;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
}
|
||||||
|
}
|
407
components/Browser.vue
Normal file
@ -0,0 +1,407 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="h-100">
|
||||||
|
<div>
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li
|
||||||
|
class="breadcrumb-item"
|
||||||
|
v-for="(crumb, index) in mediaStore.crumbs"
|
||||||
|
:key="index"
|
||||||
|
:active="index === mediaStore.crumbs.length - 1"
|
||||||
|
@click="getPath(crumb.path)"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
v-if="mediaStore.crumbs.length > 1 && mediaStore.crumbs.length - 1 > index"
|
||||||
|
class="link-secondary"
|
||||||
|
href="#"
|
||||||
|
>
|
||||||
|
{{ crumb.text }}
|
||||||
|
</a>
|
||||||
|
<span v-else>{{ crumb.text }}</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="browser-div">
|
||||||
|
<div v-if="browserIsLoading" class="d-flex justify-content-center loading-overlay">
|
||||||
|
<div class="spinner-border" role="status" />
|
||||||
|
</div>
|
||||||
|
<splitpanes
|
||||||
|
class="pane-row"
|
||||||
|
:class="$route.path === '/player' ? 'browser-splitter' : ''"
|
||||||
|
:horizontal="$route.path === '/player'"
|
||||||
|
>
|
||||||
|
<pane
|
||||||
|
min-size="14"
|
||||||
|
max-size="80"
|
||||||
|
size="24"
|
||||||
|
:style="
|
||||||
|
$route.path === '/player'
|
||||||
|
? `height: ${mediaStore.folderTree.folders.length * 47 + 2}px`
|
||||||
|
: ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ul v-if="mediaStore.folderTree.parent" class="list-group media-browser-scroll m-1">
|
||||||
|
<li
|
||||||
|
class="list-group-item browser-item"
|
||||||
|
v-for="folder in mediaStore.folderTree.folders"
|
||||||
|
:key="folder"
|
||||||
|
>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-1 browser-icons-col">
|
||||||
|
<i class="bi-folder-fill browser-icons" />
|
||||||
|
</div>
|
||||||
|
<div class="col browser-item-text">
|
||||||
|
<a
|
||||||
|
class="link-light"
|
||||||
|
href="#"
|
||||||
|
@click="getPath(`/${mediaStore.folderTree.source}/${folder}`)"
|
||||||
|
>
|
||||||
|
{{ folder }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 folder-delete">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="btn-link"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#deleteModal"
|
||||||
|
@click="
|
||||||
|
deleteName = `/${mediaStore.folderTree.source}/${folder}`.replace(
|
||||||
|
/\/[/]+/g,
|
||||||
|
'/'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i class="bi-x-circle-fill" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</pane>
|
||||||
|
<pane
|
||||||
|
:style="
|
||||||
|
$route.path === '/player' ? `height: ${mediaStore.folderTree.files.length * 26 + 2}px` : ''
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<ul v-if="mediaStore.folderTree.parent" class="list-group media-browser-scroll m-1">
|
||||||
|
<li
|
||||||
|
v-for="(element, index) in mediaStore.folderTree.files"
|
||||||
|
:id="`file_${index}`"
|
||||||
|
class="draggable list-group-item browser-item"
|
||||||
|
:key="element.name"
|
||||||
|
>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-1 browser-icons-col">
|
||||||
|
<i class="bi-film browser-icons" />
|
||||||
|
</div>
|
||||||
|
<div class="col browser-item-text grabbing">
|
||||||
|
{{ element.name }}
|
||||||
|
</div>
|
||||||
|
<div class="col-1 browser-play-col">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="btn-link"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#previewModal"
|
||||||
|
@click="setPreviewData(element.name)"
|
||||||
|
>
|
||||||
|
<i class="bi-play-fill" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 browser-dur-col">
|
||||||
|
<span class="duration">{{ toMin(element.duration) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 file-rename">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="btn-link"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#renameModal"
|
||||||
|
@click="
|
||||||
|
setRenameValues(
|
||||||
|
`/${mediaStore.folderTree.source}/${element.name}`.replace(
|
||||||
|
/\/[/]+/g,
|
||||||
|
'/'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i class="bi-pencil-square" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="col-1 file-delete">
|
||||||
|
<a
|
||||||
|
href="#"
|
||||||
|
class="btn-link"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#deleteModal"
|
||||||
|
@click="
|
||||||
|
deleteName = `/${mediaStore.folderTree.source}/${element.name}`.replace(
|
||||||
|
/\/[/]+/g,
|
||||||
|
'/'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<i class="bi-x-circle-fill" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</pane>
|
||||||
|
</splitpanes>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="previewModal" class="modal" tabindex="-1" aria-labelledby="previewModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="previewModalLabel">Preview: {{ previewName }}</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
data-bs-dismiss="modal"
|
||||||
|
aria-label="Cancel"
|
||||||
|
@click="closePlayer()"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<VideoPlayer v-if="isVideo && previewOpt" reference="previewPlayer" :options="previewOpt" />
|
||||||
|
<img v-else :src="previewUrl" class="img-fluid" :alt="previewName" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="deleteModal" class="modal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="deleteModalLabel">Delete File/Folder</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cancel"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>
|
||||||
|
Are you sure that you want to delete:<br />
|
||||||
|
<strong>{{ deleteName }}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button
|
||||||
|
type="reset"
|
||||||
|
class="btn btn-primary"
|
||||||
|
data-bs-dismiss="modal"
|
||||||
|
aria-label="Cancel"
|
||||||
|
@click="deleteName = ''"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-primary"
|
||||||
|
data-bs-dismiss="modal"
|
||||||
|
@click="deleteFileOrFolder"
|
||||||
|
>
|
||||||
|
Ok
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="renameModal" class="modal" tabindex="-1" aria-labelledby="renameModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="renameModalLabel">Rename File</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cancel"></button>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="onSubmitRenameFile" @reset="onCancelRenameFile">
|
||||||
|
<div class="modal-body">
|
||||||
|
<input type="text" class="form-control" v-model="renameNewName" />
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="reset" class="btn btn-primary" data-bs-dismiss="modal" aria-label="Cancel">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" data-bs-dismiss="modal">Ok</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Splitpanes, Pane } from 'splitpanes'
|
||||||
|
import 'splitpanes/dist/splitpanes.css'
|
||||||
|
|
||||||
|
import { useAuth } from '~/stores/auth'
|
||||||
|
import { useConfig } from '~/stores/config'
|
||||||
|
import { useIndex } from '~/stores/index'
|
||||||
|
import { useMedia } from '~/stores/media'
|
||||||
|
|
||||||
|
const authStore = useAuth()
|
||||||
|
const configStore = useConfig()
|
||||||
|
const indexStore = useIndex()
|
||||||
|
const mediaStore = useMedia()
|
||||||
|
const { toMin } = stringFormatter()
|
||||||
|
const contentType = { 'content-type': 'application/json;charset=UTF-8' }
|
||||||
|
|
||||||
|
const browserIsLoading = ref(false)
|
||||||
|
const deleteName = ref('')
|
||||||
|
const renameOldName = ref('')
|
||||||
|
const renameNewName = ref('')
|
||||||
|
const previewName = ref('')
|
||||||
|
const previewUrl = ref('')
|
||||||
|
const previewOpt = ref()
|
||||||
|
const isVideo = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!mediaStore.folderTree.parent) {
|
||||||
|
getPath('')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function getPath(path: string) {
|
||||||
|
browserIsLoading.value = true
|
||||||
|
await mediaStore.getTree(path)
|
||||||
|
browserIsLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function setPreviewData(path: string) {
|
||||||
|
/*
|
||||||
|
Set path and player options for video preview.
|
||||||
|
*/
|
||||||
|
let fullPath = path
|
||||||
|
if (!path.includes('/')) {
|
||||||
|
fullPath = `/${mediaStore.folderTree.parent}/${mediaStore.folderTree.source}/${path}`.replace(/\/[/]+/g, '/')
|
||||||
|
}
|
||||||
|
|
||||||
|
previewName.value = fullPath.split('/').slice(-1)[0]
|
||||||
|
previewUrl.value = encodeURIComponent(`${fullPath}`).replace(/%2F/g, '/')
|
||||||
|
|
||||||
|
const ext = previewName.value.split('.').slice(-1)[0].toLowerCase()
|
||||||
|
|
||||||
|
if (configStore.configPlayout.storage.extensions.includes(`${ext}`)) {
|
||||||
|
isVideo.value = true
|
||||||
|
previewOpt.value = {
|
||||||
|
liveui: false,
|
||||||
|
controls: true,
|
||||||
|
suppressNotSupportedError: true,
|
||||||
|
autoplay: false,
|
||||||
|
preload: 'auto',
|
||||||
|
sources: [
|
||||||
|
{
|
||||||
|
type: `video/${ext}`,
|
||||||
|
src: previewUrl.value,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
isVideo.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFileOrFolder() {
|
||||||
|
/*
|
||||||
|
Delete function, works for files and folders.
|
||||||
|
*/
|
||||||
|
await fetch(`api/file/${configStore.configGui[configStore.configID].id}/remove/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...contentType, ...authStore.authHeader },
|
||||||
|
body: JSON.stringify({ source: deleteName.value }),
|
||||||
|
})
|
||||||
|
.then(async (response) => {
|
||||||
|
if (response.status !== 200) {
|
||||||
|
indexStore.alertVariant = 'alert-danger'
|
||||||
|
indexStore.alertMsg = `${await response.text()}`
|
||||||
|
indexStore.showAlert = true
|
||||||
|
}
|
||||||
|
getPath(mediaStore.folderTree.source)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
indexStore.alertVariant = 'alert-danger'
|
||||||
|
indexStore.alertMsg = `Delete error: ${e}`
|
||||||
|
indexStore.showAlert = true
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
indexStore.alertMsg = ''
|
||||||
|
indexStore.showAlert = false
|
||||||
|
}, 5000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRenameValues(path: string) {
|
||||||
|
renameNewName.value = path
|
||||||
|
renameOldName.value = path
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmitRenameFile(evt: any) {
|
||||||
|
/*
|
||||||
|
Form submit for file rename request.
|
||||||
|
*/
|
||||||
|
evt.preventDefault()
|
||||||
|
|
||||||
|
await fetch(`api/file/${configStore.configGui[configStore.configID].id}/rename/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...contentType, ...authStore.authHeader },
|
||||||
|
body: JSON.stringify({ source: renameOldName.value, target: renameNewName.value }),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
getPath(mediaStore.folderTree.source)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
indexStore.alertVariant = 'alert-danger'
|
||||||
|
indexStore.alertMsg = `Delete error: ${e}`
|
||||||
|
indexStore.showAlert = true
|
||||||
|
})
|
||||||
|
|
||||||
|
renameOldName.value = ''
|
||||||
|
renameNewName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCancelRenameFile(evt: any) {
|
||||||
|
evt.preventDefault()
|
||||||
|
|
||||||
|
renameOldName.value = ''
|
||||||
|
renameNewName.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePlayer() {
|
||||||
|
isVideo.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.browser-container .browser-item:hover {
|
||||||
|
background-color: $item-hover;
|
||||||
|
|
||||||
|
div > .folder-delete {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.browser-div {
|
||||||
|
height: calc(100% - 34px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.folder-delete {
|
||||||
|
margin-right: 0.8em;
|
||||||
|
display: none;
|
||||||
|
min-width: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-delete,
|
||||||
|
.file-rename {
|
||||||
|
margin-right: 0.8em;
|
||||||
|
max-width: 35px !important;
|
||||||
|
min-width: 35px !important;
|
||||||
|
}
|
||||||
|
</style>
|
558
components/Control.vue
Normal file
@ -0,0 +1,558 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row control-row">
|
||||||
|
<div class="col-3 player-col">
|
||||||
|
<div>
|
||||||
|
<video
|
||||||
|
v-if="configStore.configGui[configStore.configID].preview_url.split('.').pop() === 'flv'"
|
||||||
|
id="httpStream"
|
||||||
|
ref="httpStream"
|
||||||
|
width="100%"
|
||||||
|
controls
|
||||||
|
/>
|
||||||
|
<VideoPlayer
|
||||||
|
class="live-player"
|
||||||
|
v-else-if="videoOptions.sources.length > 0"
|
||||||
|
:key="configStore.configID"
|
||||||
|
reference="httpStream"
|
||||||
|
:options="videoOptions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="row control-col">
|
||||||
|
<div class="col-8 status-col">
|
||||||
|
<div class="row status-row">
|
||||||
|
<div class="col time-col clock-col">
|
||||||
|
<div class="time-str">
|
||||||
|
{{ playlistStore.timeStr }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col time-col counter-col">
|
||||||
|
<div class="time-str">
|
||||||
|
{{ secToHMS(playlistStore.remainingSec) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col current-clip">
|
||||||
|
<div class="current-clip-text" :title="filename(playlistStore.currentClip)">
|
||||||
|
{{ filename(playlistStore.currentClip) }}
|
||||||
|
</div>
|
||||||
|
<div class="current-clip-meta">
|
||||||
|
<strong>Duration:</strong> {{ secToHMS(playlistStore.currentClipDuration) }} |
|
||||||
|
<strong>In:</strong> {{ secToHMS(playlistStore.currentClipIn) }} |
|
||||||
|
<strong>Out:</strong> {{ secToHMS(playlistStore.currentClipOut) }}
|
||||||
|
</div>
|
||||||
|
<div class="current-clip-progress progress">
|
||||||
|
<div
|
||||||
|
class="progress-bar bg-warning"
|
||||||
|
:aria-valuenow="playlistStore.progressValue"
|
||||||
|
:style="`width: ${playlistStore.progressValue}%;`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-4 control-unit-col">
|
||||||
|
<div class="row control-unit-row">
|
||||||
|
<div class="col">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
title="Start Playout Service"
|
||||||
|
class="btn btn-primary control-button control-button-play"
|
||||||
|
:class="isPlaying"
|
||||||
|
@click="controlProcess('start')"
|
||||||
|
>
|
||||||
|
<i class="bi-play" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
title="Stop Playout Service"
|
||||||
|
class="btn btn-primary control-button control-button-stop"
|
||||||
|
@click="controlProcess('stop')"
|
||||||
|
>
|
||||||
|
<i class="bi-stop" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
title="Restart Playout Service"
|
||||||
|
class="btn btn-primary control-button control-button-restart"
|
||||||
|
@click="controlProcess('restart')"
|
||||||
|
>
|
||||||
|
<i class="bi-arrow-clockwise" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-100" />
|
||||||
|
<div class="col">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
title="Jump to last Clip"
|
||||||
|
class="btn btn-primary control-button control-button-control"
|
||||||
|
@click="controlPlayout('back')"
|
||||||
|
>
|
||||||
|
<i class="bi-skip-start" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
title="Reset Playout State"
|
||||||
|
class="btn btn-primary control-button control-button-control"
|
||||||
|
@click="controlPlayout('reset')"
|
||||||
|
>
|
||||||
|
<i class="bi-arrow-repeat" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
title="Jump to next Clip"
|
||||||
|
class="btn btn-primary control-button control-button-control"
|
||||||
|
@click="controlPlayout('next')"
|
||||||
|
>
|
||||||
|
<i class="bi-skip-end" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import mpegts from 'mpegts.js'
|
||||||
|
import { useAuth } from '~/stores/auth'
|
||||||
|
import { useConfig } from '~/stores/config'
|
||||||
|
import { usePlaylist } from '~/stores/playlist'
|
||||||
|
|
||||||
|
const { $dayjs } = useNuxtApp()
|
||||||
|
const authStore = useAuth()
|
||||||
|
const configStore = useConfig()
|
||||||
|
const playlistStore = usePlaylist()
|
||||||
|
const { filename, secToHMS, timeToSeconds } = stringFormatter()
|
||||||
|
const contentType = { 'content-type': 'application/json;charset=UTF-8' }
|
||||||
|
|
||||||
|
const isPlaying = ref('')
|
||||||
|
const breakStatusCheck = ref(false)
|
||||||
|
const timer = ref()
|
||||||
|
const streamExtension = ref(configStore.configGui[configStore.configID].preview_url.split('.').pop())
|
||||||
|
const httpStream = ref()
|
||||||
|
const videoOptions = ref({
|
||||||
|
liveui: true,
|
||||||
|
controls: true,
|
||||||
|
suppressNotSupportedError: true,
|
||||||
|
autoplay: false,
|
||||||
|
preload: 'auto',
|
||||||
|
sources: [] as SourceObject[],
|
||||||
|
})
|
||||||
|
const httpFlvSource = ref({
|
||||||
|
type: 'flv',
|
||||||
|
isLive: true,
|
||||||
|
url: '',
|
||||||
|
})
|
||||||
|
const mpegtsOptions = ref({
|
||||||
|
enableWorker: true,
|
||||||
|
lazyLoadMaxDuration: 3 * 60,
|
||||||
|
liveBufferLatencyChasing: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
breakStatusCheck.value = false
|
||||||
|
|
||||||
|
videoOptions.value.sources = [
|
||||||
|
{
|
||||||
|
type: 'application/x-mpegURL',
|
||||||
|
src: configStore.configGui[configStore.configID].preview_url,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
let player: any
|
||||||
|
|
||||||
|
if (streamExtension.value === 'flv') {
|
||||||
|
httpFlvSource.value.url = configStore.configGui[configStore.configID].preview_url
|
||||||
|
|
||||||
|
if (typeof player !== 'undefined') {
|
||||||
|
if (player != null) {
|
||||||
|
player.unload()
|
||||||
|
player.detachMediaElement()
|
||||||
|
player.destroy()
|
||||||
|
player = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
player = mpegts.createPlayer(httpFlvSource.value, mpegtsOptions.value)
|
||||||
|
player.attachMediaElement(httpStream.value)
|
||||||
|
player.load()
|
||||||
|
}
|
||||||
|
|
||||||
|
status()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
breakStatusCheck.value = true
|
||||||
|
|
||||||
|
if (timer.value) {
|
||||||
|
clearTimeout(timer.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function status() {
|
||||||
|
/*
|
||||||
|
Get playout state and information's from current clip.
|
||||||
|
- animate timers
|
||||||
|
- when clip end is reached call API again and set new values
|
||||||
|
*/
|
||||||
|
playoutStatus()
|
||||||
|
playlistStore.playoutStat()
|
||||||
|
|
||||||
|
async function setStatus(resolve: any) {
|
||||||
|
/*
|
||||||
|
recursive function as a endless loop
|
||||||
|
*/
|
||||||
|
playlistStore.timeStr = $dayjs().utcOffset(configStore.utcOffset).format('HH:mm:ss')
|
||||||
|
const timeInSec = timeToSeconds(playlistStore.timeStr)
|
||||||
|
playlistStore.remainingSec = playlistStore.currentClipStart + playlistStore.currentClipOut - timeInSec
|
||||||
|
const playedSec = playlistStore.currentClipOut - playlistStore.remainingSec
|
||||||
|
playlistStore.progressValue = (playedSec * 100) / playlistStore.currentClipOut
|
||||||
|
|
||||||
|
if (breakStatusCheck.value) {
|
||||||
|
return
|
||||||
|
} else if ((playlistStore.playoutIsRunning && playlistStore.remainingSec < 0) || timeInSec % 30 === 0) {
|
||||||
|
// When 30 seconds a passed, get new status.
|
||||||
|
playoutStatus()
|
||||||
|
playlistStore.playoutStat()
|
||||||
|
} else if (!playlistStore.playoutIsRunning) {
|
||||||
|
playlistStore.remainingSec = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
timer.value = setTimeout(() => setStatus(resolve), 1000)
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => setStatus(resolve))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function playoutStatus() {
|
||||||
|
/*
|
||||||
|
Check if playout is running, when yes set css class.
|
||||||
|
*/
|
||||||
|
const channel = configStore.configGui[configStore.configID].id
|
||||||
|
|
||||||
|
await $fetch(`api/control/${channel}/process/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...contentType, ...authStore.authHeader },
|
||||||
|
body: JSON.stringify({ command: 'status' }),
|
||||||
|
})
|
||||||
|
.then((response: any) => {
|
||||||
|
if (response === 'active') {
|
||||||
|
isPlaying.value = 'is-playing'
|
||||||
|
} else {
|
||||||
|
playlistStore.playoutIsRunning = false
|
||||||
|
isPlaying.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
isPlaying.value = ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function controlProcess(state: string) {
|
||||||
|
/*
|
||||||
|
Control playout systemd service (start, stop, restart)
|
||||||
|
*/
|
||||||
|
const channel = configStore.configGui[configStore.configID].id
|
||||||
|
|
||||||
|
await $fetch(`api/control/${channel}/process/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...contentType, ...authStore.authHeader },
|
||||||
|
body: JSON.stringify({ command: state }),
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
playoutStatus()
|
||||||
|
playlistStore.playoutStat()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function controlPlayout(state: string) {
|
||||||
|
/*
|
||||||
|
Control playout:
|
||||||
|
- jump to next clip
|
||||||
|
- jump to last clip
|
||||||
|
- reset playout state
|
||||||
|
*/
|
||||||
|
const channel = configStore.configGui[configStore.configID].id
|
||||||
|
|
||||||
|
await $fetch(`api/control/${channel}/playout/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...contentType, ...authStore.authHeader },
|
||||||
|
body: JSON.stringify({ command: state }),
|
||||||
|
})
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
playoutStatus()
|
||||||
|
playlistStore.playoutStat()
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.control-row {
|
||||||
|
min-height: 254px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-col {
|
||||||
|
max-width: 542px;
|
||||||
|
min-width: 380px;
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-col > div {
|
||||||
|
background-color: black;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-player {
|
||||||
|
position: relative;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-col {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 254px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-col {
|
||||||
|
padding-right: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-unit-col {
|
||||||
|
min-width: 250px;
|
||||||
|
padding: 2px 17px 2px 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-unit-row {
|
||||||
|
background: $gray-900;
|
||||||
|
height: 100%;
|
||||||
|
margin-right: 0;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-unit-row .col {
|
||||||
|
height: 50%;
|
||||||
|
min-height: 90px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-unit-row .col div {
|
||||||
|
height: 80%;
|
||||||
|
margin: 0.6em 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button {
|
||||||
|
font-size: 4em;
|
||||||
|
line-height: 0;
|
||||||
|
width: 80%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 325px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-row .col {
|
||||||
|
margin: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-col {
|
||||||
|
position: relative;
|
||||||
|
background: $gray-900;
|
||||||
|
padding: 0.5em;
|
||||||
|
text-align: center;
|
||||||
|
border-radius: 0.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: -0.18em;
|
||||||
|
padding-right: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-clip {
|
||||||
|
background: $gray-900;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
min-width: 700px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-clip-text {
|
||||||
|
height: 40%;
|
||||||
|
padding-top: 0.5em;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: bold;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-clip-meta {
|
||||||
|
margin-bottom: 0.7em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-clip-progress {
|
||||||
|
top: 80%;
|
||||||
|
margin-top: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button-play {
|
||||||
|
color: $control-button-play;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $control-button-play-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.is-playing {
|
||||||
|
box-shadow: 0 0 15px $control-button-play;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button-stop {
|
||||||
|
color: $control-button-stop;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $control-button-stop-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button-restart {
|
||||||
|
color: $control-button-restart;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $control-button-restart-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-button-control {
|
||||||
|
color: $control-button-control;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $control-button-control-hover;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.clip-progress {
|
||||||
|
height: 5px;
|
||||||
|
padding-top: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1555px) {
|
||||||
|
.control-row {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-col {
|
||||||
|
height: 100%;
|
||||||
|
min-height: inherit;
|
||||||
|
}
|
||||||
|
.status-col {
|
||||||
|
padding-right: 0;
|
||||||
|
height: 100%;
|
||||||
|
flex: 0 0 60%;
|
||||||
|
max-width: 60%;
|
||||||
|
}
|
||||||
|
.current-clip {
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
.time-str {
|
||||||
|
font-size: 3.5em;
|
||||||
|
}
|
||||||
|
.control-unit-row {
|
||||||
|
margin-right: -30px;
|
||||||
|
}
|
||||||
|
.control-unit-col {
|
||||||
|
flex: 0 0 35%;
|
||||||
|
max-width: 35%;
|
||||||
|
margin: 0 0 0 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1337px) {
|
||||||
|
.status-col {
|
||||||
|
flex: 0 0 47%;
|
||||||
|
max-width: 47%;
|
||||||
|
height: 68%;
|
||||||
|
}
|
||||||
|
.control-unit-col {
|
||||||
|
flex: 0 0 47%;
|
||||||
|
max-width: 47%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1102px) {
|
||||||
|
.control-unit-row .col {
|
||||||
|
min-height: 70px;
|
||||||
|
padding-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
.control-button {
|
||||||
|
font-size: 2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 889px) {
|
||||||
|
.control-row {
|
||||||
|
min-height: 540px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-col {
|
||||||
|
flex: 0 0 94%;
|
||||||
|
max-width: 94%;
|
||||||
|
height: 68%;
|
||||||
|
}
|
||||||
|
.control-unit-col {
|
||||||
|
flex: 0 0 94%;
|
||||||
|
max-width: 94%;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 17px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 689px) {
|
||||||
|
.player-col {
|
||||||
|
flex: 0 0 98%;
|
||||||
|
max-width: 98%;
|
||||||
|
padding-top: 30px;
|
||||||
|
}
|
||||||
|
.control-row {
|
||||||
|
min-height: 830px;
|
||||||
|
}
|
||||||
|
.control-col {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.control-unit-col,
|
||||||
|
.status-col {
|
||||||
|
flex: 0 0 96%;
|
||||||
|
max-width: 96%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
176
components/GuiConfig.vue
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="pb-4 pt-3">Channel Configuration</h2>
|
||||||
|
<div style="width: 100%; height: 43px">
|
||||||
|
<div class="float-end">
|
||||||
|
<button class="btn btn-primary" @click="addChannel()">Add new Channel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
v-if="configStore.configGui && configStore.configGui[configStore.configID]"
|
||||||
|
@submit.prevent="onSubmitGui"
|
||||||
|
>
|
||||||
|
<div class="mb-3 row">
|
||||||
|
<label for="configName" class="col-sm-2 col-form-label">Name</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="configName"
|
||||||
|
v-model="configStore.configGui[configStore.configID].name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 row">
|
||||||
|
<label for="configUrl" class="col-sm-2 col-form-label">Preview URL</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="configUrl"
|
||||||
|
v-model="configStore.configGui[configStore.configID].preview_url"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 row">
|
||||||
|
<label for="configPath" class="col-sm-2 col-form-label">Config Path</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="configPath"
|
||||||
|
v-model="configStore.configGui[configStore.configID].config_path"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 row">
|
||||||
|
<label for="configExtensions" class="col-sm-2 col-form-label">Extra Extensions</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="configExtensions"
|
||||||
|
v-model="configStore.configGui[configStore.configID].extra_extensions"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 row">
|
||||||
|
<label for="configService" class="col-sm-2 col-form-label">Service</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="configService"
|
||||||
|
v-model="configStore.configGui[configStore.configID].service"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-1" style="min-width: 158px">
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-primary" type="submit">Save</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-danger"
|
||||||
|
v-if="
|
||||||
|
configStore.configGui.length > 1 &&
|
||||||
|
configStore.configGui[configStore.configID].id > 1
|
||||||
|
"
|
||||||
|
@click="deleteChannel()"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuth } from '~/stores/auth'
|
||||||
|
import { useConfig } from '~/stores/config'
|
||||||
|
import { useIndex } from '~~/stores'
|
||||||
|
|
||||||
|
const { $_ } = useNuxtApp()
|
||||||
|
const authStore = useAuth()
|
||||||
|
const configStore = useConfig()
|
||||||
|
const indexStore = useIndex()
|
||||||
|
|
||||||
|
async function addChannel() {
|
||||||
|
const channels = $_.cloneDeep(configStore.configGui)
|
||||||
|
const newChannel = $_.cloneDeep(configStore.configGui[configStore.configGui.length - 1])
|
||||||
|
|
||||||
|
const playoutConfigPath = newChannel.config_path.match(/.*\//)
|
||||||
|
const confName = `channel${String(channels.length + 1).padStart(3, '0')}`
|
||||||
|
|
||||||
|
newChannel.id = channels.length + 1
|
||||||
|
newChannel.name = `Channel ${Math.random().toString(36).substring(7)}`
|
||||||
|
newChannel.config_path = `${playoutConfigPath}${confName}.yml`
|
||||||
|
newChannel.service = `ffplayout@${confName}.service`
|
||||||
|
|
||||||
|
channels.push(newChannel)
|
||||||
|
configStore.updateGuiConfig(channels)
|
||||||
|
configStore.updateConfigID(configStore.configGui.length - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmitGui() {
|
||||||
|
/*
|
||||||
|
Save GUI settings.
|
||||||
|
*/
|
||||||
|
const update = await configStore.setGuiConfig(configStore.configGui[configStore.configID])
|
||||||
|
|
||||||
|
if (update.status) {
|
||||||
|
indexStore.alertVariant = 'alert-success'
|
||||||
|
indexStore.alertMsg = 'Update GUI config success!'
|
||||||
|
} else {
|
||||||
|
indexStore.alertVariant = 'alert-danger'
|
||||||
|
indexStore.alertMsg = 'Update GUI config failed!'
|
||||||
|
}
|
||||||
|
|
||||||
|
indexStore.showAlert = true
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
indexStore.showAlert = false
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteChannel() {
|
||||||
|
const config = $_.cloneDeep(configStore.configGui)
|
||||||
|
const id = config[configStore.configID].id
|
||||||
|
|
||||||
|
if (id === 1) {
|
||||||
|
indexStore.alertVariant = 'alert-warning'
|
||||||
|
indexStore.alertMsg = 'First channel can not be deleted!'
|
||||||
|
indexStore.showAlert = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`api/channel/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: authStore.authHeader,
|
||||||
|
})
|
||||||
|
|
||||||
|
config.splice(configStore.configID, 1)
|
||||||
|
configStore.updateGuiConfig(config)
|
||||||
|
configStore.updateConfigID(configStore.configGui.length - 1)
|
||||||
|
await configStore.getPlayoutConfig()
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
indexStore.alertVariant = 'alert-success'
|
||||||
|
indexStore.alertMsg = 'Delete GUI config success!'
|
||||||
|
} else {
|
||||||
|
indexStore.alertVariant = 'alert-danger'
|
||||||
|
indexStore.alertMsg = 'Delete GUI config failed!'
|
||||||
|
}
|
||||||
|
|
||||||
|
indexStore.showAlert = true
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
indexStore.showAlert = false
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
</script>
|
@ -1,113 +1,129 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="menu">
|
<div class="menu">
|
||||||
<b-nav align="right">
|
<nav class="navbar navbar-expand-sm fixed-top custom-nav">
|
||||||
<b-nav-item to="/" class="nav-item" exact-active-class="active-menu-item">
|
<div class="container-fluid">
|
||||||
Home
|
<NuxtLink class="navbar-brand p-2" href="/"
|
||||||
</b-nav-item>
|
><img
|
||||||
<b-nav-item to="/player" exact-active-class="active-menu-item">
|
src="~/assets/images/ffplayout-small.png"
|
||||||
Player
|
class="img-fluid"
|
||||||
</b-nav-item>
|
alt="Logo"
|
||||||
<b-nav-item to="/media" exact-active-class="active-menu-item">
|
width="30"
|
||||||
Media
|
height="30"
|
||||||
</b-nav-item>
|
/></NuxtLink>
|
||||||
<b-nav-item to="/message" exact-active-class="active-menu-item">
|
<button
|
||||||
Message
|
class="navbar-toggler"
|
||||||
</b-nav-item>
|
type="button"
|
||||||
<b-nav-item to="/logging" exact-active-class="active-menu-item">
|
data-bs-toggle="collapse"
|
||||||
Logging
|
data-bs-target="#navbarNavDropdown"
|
||||||
</b-nav-item>
|
aria-controls="navbarNavDropdown"
|
||||||
<b-nav-item to="/configure" exact-active-class="active-menu-item">
|
aria-expanded="false"
|
||||||
Configure
|
aria-label="Toggle navigation"
|
||||||
</b-nav-item>
|
>
|
||||||
<b-nav-text v-if="configGui.length > 1">
|
<span class="navbar-toggler-icon"> </span>
|
||||||
|
</button>
|
||||||
</b-nav-text>
|
<div class="collapse navbar-collapse justify-content-end" id="navbarNavDropdown">
|
||||||
<b-nav-item-dropdown v-if="configGui.length > 1" :text="configGui[configID].name" right>
|
<ul class="navbar-nav">
|
||||||
<b-dropdown-item v-for="(channel, index) in configGui" :key="index" @click="selectChannel(index)">
|
<li class="nav-item">
|
||||||
{{ channel.name }}
|
<NuxtLink class="btn btn-primary btn-sm" to="/player">Player</NuxtLink>
|
||||||
</b-dropdown-item>
|
</li>
|
||||||
</b-nav-item-dropdown>
|
<li class="nav-item">
|
||||||
<b-nav-text v-if="configGui.length > 1">
|
<NuxtLink class="btn btn-primary btn-sm" to="/media">Media</NuxtLink>
|
||||||
|
</li>
|
||||||
</b-nav-text>
|
<li class="nav-item">
|
||||||
<b-nav-item @click="logout()">
|
<NuxtLink class="btn btn-primary btn-sm" to="/message">Message</NuxtLink>
|
||||||
Logout
|
</li>
|
||||||
</b-nav-item>
|
<li class="nav-item">
|
||||||
</b-nav>
|
<NuxtLink class="btn btn-primary btn-sm" to="/logging">Logging</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<NuxtLink class="btn btn-primary btn-sm" to="/configure">Configure</NuxtLink>
|
||||||
|
</li>
|
||||||
|
<li v-if="configStore.configGui.length > 1" class="nav-item dropdown">
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="btn btn-primary btn-sm dropdown-toggle"
|
||||||
|
href="#"
|
||||||
|
role="button"
|
||||||
|
data-bs-toggle="dropdown"
|
||||||
|
aria-expanded="false"
|
||||||
|
>
|
||||||
|
{{ configStore.configGui[configStore.configID].name }}
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu dropdown-menu-dark dropdown-menu-end">
|
||||||
|
<li v-for="(channel, index) in configStore.configGui" :key="index">
|
||||||
|
<a class="dropdown-item" @click="selectChannel(index)">{{ channel.name }}</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="btn btn-primary btn-sm" @click="logout()">Logout</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
import { mapState } from 'vuex'
|
import { useAuth } from '~/stores/auth'
|
||||||
|
import { useConfig } from '~/stores/config'
|
||||||
|
|
||||||
export default {
|
const authStore = useAuth()
|
||||||
name: 'Menu',
|
const configStore = useConfig()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
computed: {
|
function logout() {
|
||||||
...mapState('config', ['configID', 'configGui'])
|
authStore.removeToken()
|
||||||
},
|
authStore.updateIsLogin(false)
|
||||||
|
router.push({ path: '/' })
|
||||||
|
}
|
||||||
|
|
||||||
methods: {
|
function selectChannel(index: number) {
|
||||||
logout () {
|
configStore.updateConfigID(index)
|
||||||
try {
|
configStore.getPlayoutConfig()
|
||||||
this.$store.commit('auth/REMOVE_TOKEN')
|
|
||||||
this.$store.commit('auth/UPDATE_IS_LOGIN', false)
|
|
||||||
this.$router.push('/')
|
|
||||||
} catch (e) {
|
|
||||||
this.formError = e.message
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
selectChannel (index) {
|
|
||||||
this.$store.commit('config/UPDATE_CONFIG_ID', index)
|
|
||||||
this.$store.dispatch('config/getPlayoutConfig')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" >
|
<style lang="scss">
|
||||||
.menu {
|
.menu {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 40px;
|
height: 60px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: .5em;
|
|
||||||
|
div {
|
||||||
|
padding: 0.3em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item {
|
.custom-nav {
|
||||||
background-image: linear-gradient(#484e55, #3A3F44 60%, #313539);
|
background-color: $bg-primary;
|
||||||
background-repeat: no-repeat;
|
|
||||||
height: 28px;
|
|
||||||
margin: .05em;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: .95em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover {
|
.nav-item .btn {
|
||||||
background-image: linear-gradient(#5a636c, #4c545b 60%, #42484e);
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item a {
|
|
||||||
padding: .2em .6em .2em .6em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.active-menu-item {
|
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-menu-item::after {
|
.router-link-exact-active::after {
|
||||||
background: #ff9c36;
|
background: $accent;
|
||||||
content: " ";
|
content: ' ';
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 2px;
|
height: 2px;
|
||||||
color: red;
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
display: block;
|
display: block;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 575px) {
|
||||||
|
.nav-item .btn {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
margin-bottom: .3em;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
94
components/PlayoutConfig.vue
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="pb-4 pt-3">Playout Configuration</h2>
|
||||||
|
<form v-if="configStore.configPlayout" @submit.prevent="onSubmitPlayout">
|
||||||
|
<div v-for="(item, key) in configStore.configPlayout" class="mb-2 row" :key="key">
|
||||||
|
<div class="col-sm-1">
|
||||||
|
<strong>{{ key }}:</strong>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-11 pb-4 mt-4">
|
||||||
|
<div v-for="(prop, name) in (item as Record<string, any>)" class="mb-2 row">
|
||||||
|
<label :for="name" class="col-sm-2 col-form-label">{{ name }}:</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<div v-if="name.toString() === 'help_text'" class="pt-2 pb-2">{{ prop }}</div>
|
||||||
|
<input
|
||||||
|
v-else-if="name.toString() === 'sender_pass'"
|
||||||
|
type="password"
|
||||||
|
class="form-control"
|
||||||
|
:id="name"
|
||||||
|
v-model="item[name]"
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
v-else-if="
|
||||||
|
name.toString() === 'output_param' || name.toString() === 'custom_filter'
|
||||||
|
"
|
||||||
|
class="form-control"
|
||||||
|
:id="name"
|
||||||
|
v-model="item[name]"
|
||||||
|
rows="5"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else-if="typeof prop === 'number' && prop % 1 === 0"
|
||||||
|
type="number"
|
||||||
|
class="form-control"
|
||||||
|
:id="name"
|
||||||
|
v-model="item[name]"
|
||||||
|
style="max-width: 250px"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else-if="typeof prop === 'number'"
|
||||||
|
type="number"
|
||||||
|
class="form-control"
|
||||||
|
:id="name"
|
||||||
|
v-model="item[name]"
|
||||||
|
step="0.0001"
|
||||||
|
style="max-width: 250px"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else-if="typeof prop === 'boolean'"
|
||||||
|
type="checkbox"
|
||||||
|
class="form-check-input mt-2"
|
||||||
|
:id="name"
|
||||||
|
v-model="item[name]"
|
||||||
|
/>
|
||||||
|
<input v-else type="text" class="form-control" :id="name" v-model="item[name]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-1" style="min-width: 85px">
|
||||||
|
<button class="btn btn-primary" type="submit" variant="primary">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useConfig } from '~/stores/config'
|
||||||
|
import { useIndex } from '~/stores/index'
|
||||||
|
|
||||||
|
const configStore = useConfig()
|
||||||
|
const indexStore = useIndex()
|
||||||
|
|
||||||
|
async function onSubmitPlayout() {
|
||||||
|
const update = await configStore.setPlayoutConfig(configStore.configPlayout)
|
||||||
|
|
||||||
|
if (update.status === 200) {
|
||||||
|
indexStore.alertVariant = 'alert-success'
|
||||||
|
indexStore.alertMsg = 'Update playout config success!'
|
||||||
|
} else {
|
||||||
|
indexStore.alertVariant = 'alert-danger'
|
||||||
|
indexStore.alertMsg = 'Update playout config failed!'
|
||||||
|
}
|
||||||
|
|
||||||
|
indexStore.showAlert = true
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
indexStore.showAlert = false
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
</script>
|
@ -1,7 +0,0 @@
|
|||||||
# 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._
|
|
83
components/UserConfig.vue
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="pb-4 pt-3">User Configuration</h2>
|
||||||
|
<form v-if="configStore.configUser" @submit.prevent="onSubmitUser">
|
||||||
|
<div class="mb-3 row">
|
||||||
|
<label for="userName" class="col-sm-2 col-form-label">Username</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
id="userName"
|
||||||
|
v-model="configStore.configUser.username"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 row">
|
||||||
|
<label for="userMail" class="col-sm-2 col-form-label">mail</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" class="form-control" id="userMail" v-model="configStore.configUser.mail" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 row">
|
||||||
|
<label for="userPass1" class="col-sm-2 col-form-label">New Password</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="password" class="form-control" id="userPass1" v-model="newPass" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3 row">
|
||||||
|
<label for="userPass2" class="col-sm-2 col-form-label">Confirm Password</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="password" class="form-control" id="userPass2" v-model="confirmPass" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-1" style="min-width: 85px">
|
||||||
|
<button class="btn btn-primary" type="submit">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuth } from '~/stores/auth'
|
||||||
|
import { useConfig } from '~/stores/config'
|
||||||
|
import { useIndex } from '~/stores/index'
|
||||||
|
|
||||||
|
const authStore = useAuth()
|
||||||
|
const configStore = useConfig()
|
||||||
|
const indexStore = useIndex()
|
||||||
|
|
||||||
|
const newPass = ref('')
|
||||||
|
const confirmPass = ref('')
|
||||||
|
|
||||||
|
async function onSubmitUser() {
|
||||||
|
if (newPass && newPass.value === confirmPass.value) {
|
||||||
|
configStore.configUser.password = newPass.value
|
||||||
|
}
|
||||||
|
|
||||||
|
authStore.inspectToken()
|
||||||
|
const update = await configStore.setUserConfig(configStore.configUser)
|
||||||
|
|
||||||
|
if (update.status === 200) {
|
||||||
|
indexStore.alertVariant = 'alert-success'
|
||||||
|
indexStore.alertMsg = 'Update user profile success!'
|
||||||
|
} else {
|
||||||
|
indexStore.alertVariant = 'alert-danger'
|
||||||
|
indexStore.alertMsg = 'Update user profile failed!'
|
||||||
|
}
|
||||||
|
|
||||||
|
indexStore.showAlert = true
|
||||||
|
|
||||||
|
newPass.value = ''
|
||||||
|
confirmPass.value = ''
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
indexStore.showAlert = false
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
</script>
|
@ -11,46 +11,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
/* eslint-disable camelcase */
|
|
||||||
import videojs from 'video.js'
|
import videojs from 'video.js'
|
||||||
import 'video.js/dist/video-js.css'
|
import 'video.js/dist/video-js.css'
|
||||||
|
|
||||||
export default {
|
const player = ref()
|
||||||
name: 'VideoPlayer',
|
|
||||||
props: {
|
|
||||||
options: {
|
|
||||||
type: Object,
|
|
||||||
default () {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
reference: {
|
|
||||||
type: String,
|
|
||||||
default () {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
player: null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted () {
|
const props = defineProps({
|
||||||
this.player = videojs(this.reference, this.options, function onPlayerReady () {
|
options: {
|
||||||
// console.log('onPlayerReady', this);
|
type: Object,
|
||||||
})
|
required: true,
|
||||||
},
|
},
|
||||||
|
reference: {
|
||||||
beforeDestroy () {
|
type: String,
|
||||||
if (this.player) {
|
required: true,
|
||||||
this.player.dispose()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
})
|
||||||
|
|
||||||
methods: {
|
onMounted(() => {
|
||||||
|
player.value = videojs(props.reference, props.options, function onPlayerReady() {
|
||||||
|
// console.log('onPlayerReady', this);
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (player.value) {
|
||||||
|
player.value.dispose()
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
</script>
|
</script>
|
||||||
|
128
composables/helper.ts
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
export const stringFormatter = () => {
|
||||||
|
function formatLog(text: string) {
|
||||||
|
return (
|
||||||
|
text
|
||||||
|
.replace(/\x1B\[33m(.*?)\x1B\[0m/g, '<span class="log-number">$1</span>')
|
||||||
|
.replace(/\x1B\[1m\x1B\[35m(.*?)\x1B\[0m\x1B\[22m/g, '<span class="log-addr">$1</span>')
|
||||||
|
.replace(/\x1B\[94m(.*?)\x1B\[0m/g, '<span class="log-cmd">$1</span>')
|
||||||
|
.replace(/\x1B\[90m(.*?)\x1B\[0m/g, '<span class="log-debug">$1</span>')
|
||||||
|
.replace(/(\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.[\d]+\])/g, '<span class="log-time">$1</span>')
|
||||||
|
.replace(/\[ INFO\]/g, '<span class="log-info">[ INFO]</span>')
|
||||||
|
.replace(/\[ WARN\]/g, '<span class="log-warning">[ WARN]</span>')
|
||||||
|
.replace(/\[ERROR\]/g, '<span class="log-error">[ERROR]</span>')
|
||||||
|
.replace(/\[DEBUG\]/g, '<span class="log-debug">[DEBUG]</span>')
|
||||||
|
.replace(/\[Decoder\]/g, '<span class="log-decoder">[Decoder]</span>')
|
||||||
|
.replace(/\[Encoder\]/g, '<span class="log-encoder">[Encoder]</span>')
|
||||||
|
.replace(/\[Server\]/g, '<span class="log-server">[Server]</span>')
|
||||||
|
.replace(/\[Validator\]/g, '<span class="log-server">[Validator]</span>')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeToSeconds(time: string) {
|
||||||
|
const t = time.split(':')
|
||||||
|
return parseInt(t[0]) * 3600 + parseInt(t[1]) * 60 + parseInt(t[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
function secToHMS(sec: number) {
|
||||||
|
let hours = Math.floor(sec / 3600)
|
||||||
|
sec %= 3600
|
||||||
|
let minutes = Math.floor(sec / 60)
|
||||||
|
let seconds = Math.round(sec % 60)
|
||||||
|
|
||||||
|
const m = String(minutes).padStart(2, '0')
|
||||||
|
const h = String(hours).padStart(2, '0')
|
||||||
|
const s = String(seconds).padStart(2, '0')
|
||||||
|
return `${h}:${m}:${s}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function numberToHex(num: number) {
|
||||||
|
return '0x' + Math.round(num * 255).toString(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexToNumber(num: string): number {
|
||||||
|
return parseFloat((parseFloat(parseInt(num, 16).toString()) / 255).toFixed(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
function filename(path: string) {
|
||||||
|
if (path) {
|
||||||
|
const pathArr = path.split('/')
|
||||||
|
return pathArr[pathArr.length - 1]
|
||||||
|
} else {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMin(sec: number) {
|
||||||
|
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 ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function secondsToTime(sec: number) {
|
||||||
|
return new Date(sec * 1000).toISOString().substr(11, 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { formatLog, timeToSeconds, secToHMS, numberToHex, hexToNumber, filename, toMin, secondsToTime }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const playlistOperations = () => {
|
||||||
|
function genUID() {
|
||||||
|
return String(
|
||||||
|
Date.now().toString(32) +
|
||||||
|
Math.random().toString(16)
|
||||||
|
).replace(/\./g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function processPlaylist(dayStart: number, length: number, list: PlaylistItem[], forSave: boolean) {
|
||||||
|
if (!dayStart) {
|
||||||
|
dayStart = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let begin = dayStart
|
||||||
|
const newList = []
|
||||||
|
|
||||||
|
for (const item of list) {
|
||||||
|
if (!item.uid) {
|
||||||
|
item.uid = genUID()
|
||||||
|
}
|
||||||
|
|
||||||
|
item.begin = begin
|
||||||
|
|
||||||
|
if (!item.audio) {
|
||||||
|
delete item.audio
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.category) {
|
||||||
|
delete item.category
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.custom_filter) {
|
||||||
|
delete item.custom_filter
|
||||||
|
}
|
||||||
|
|
||||||
|
if (begin + (item.out - item.in) > length + dayStart) {
|
||||||
|
item.class = 'overLength'
|
||||||
|
|
||||||
|
if (forSave) {
|
||||||
|
item.out = length + dayStart - begin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (forSave && begin >= length + dayStart) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
newList.push(item)
|
||||||
|
|
||||||
|
begin += item.out - item.in
|
||||||
|
}
|
||||||
|
|
||||||
|
return newList
|
||||||
|
}
|
||||||
|
|
||||||
|
return { processPlaylist, genUID }
|
||||||
|
}
|
@ -2,26 +2,46 @@
|
|||||||
|
|
||||||
> web GUI for ffplayout engine
|
> web GUI for ffplayout engine
|
||||||
|
|
||||||
## Build Setup
|
# Nuxt 3 Minimal Starter
|
||||||
|
|
||||||
create `.env`file with `API_URL`, for example:
|
Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||||
```
|
|
||||||
API_URL="http://localhost:8787"
|
## Setup
|
||||||
|
|
||||||
|
Make sure to install the dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# yarn
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# npm
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm install --shamefully-hoist
|
||||||
```
|
```
|
||||||
|
|
||||||
``` bash
|
## Development Server
|
||||||
# install dependencies
|
|
||||||
$ npm run install
|
|
||||||
|
|
||||||
# serve with hot reload at localhost:3000
|
Start the development server on http://localhost:3000
|
||||||
$ npm run dev
|
|
||||||
|
|
||||||
# build for production and launch server
|
```bash
|
||||||
$ npm run build
|
npm run dev
|
||||||
$ npm run start
|
|
||||||
|
|
||||||
# generate static project
|
|
||||||
$ npm run generate
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For detailed explanation on how things work, check out [Nuxt.js docs](https://nuxtjs.org).
|
## Production
|
||||||
|
|
||||||
|
Build the application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Locally preview production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||||
|
|
||||||
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 56 KiB |
Before Width: | Height: | Size: 326 KiB |
BIN
docs/images/landing.png
Normal file
After Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 122 KiB After Width: | Height: | Size: 169 KiB |
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 27 KiB |
Before Width: | Height: | Size: 187 KiB |
Before Width: | Height: | Size: 209 KiB After Width: | Height: | Size: 224 KiB |
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 48 KiB |
BIN
docs/images/player.png
Normal file
After Width: | Height: | Size: 332 KiB |
@ -1,7 +0,0 @@
|
|||||||
# 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).
|
|
@ -1,67 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<main>
|
||||||
<nuxt />
|
<NuxtPage />
|
||||||
<b-alert
|
<div
|
||||||
:show="showErrorAlert"
|
v-if="indexStore.showAlert"
|
||||||
dismissible
|
class="alert show alert-dismissible fade media-alert"
|
||||||
:variant="variant"
|
:class="indexStore.alertVariant"
|
||||||
class="status-alert"
|
role="alert"
|
||||||
@dismissed="showErrorAlert=!showErrorAlert"
|
|
||||||
>
|
>
|
||||||
<p>{{ ErrorAlertMessage }}</p>
|
{{ indexStore.alertMsg }}
|
||||||
</b-alert>
|
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||||
</div>
|
</div>
|
||||||
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
import { mapState } from 'vuex'
|
import { useConfig } from '~/stores/config'
|
||||||
|
import { useIndex } from '~/stores/index'
|
||||||
|
|
||||||
export default {
|
const configStore = useConfig()
|
||||||
computed: {
|
const indexStore = useIndex()
|
||||||
...mapState(['ErrorAlertMessage', 'variant']),
|
|
||||||
|
|
||||||
showErrorAlert: {
|
useHead({
|
||||||
get () {
|
htmlAttrs: {
|
||||||
return this.$store.state.showErrorAlert
|
lang: 'en',
|
||||||
},
|
"data-bs-theme": "dark"
|
||||||
set () {
|
|
||||||
this.$store.commit('UPDATE_SHOW_ERROR_ALERT', false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
|
await configStore.nuxtClientInit()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<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;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: #c4c4c4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-alert {
|
|
||||||
position: fixed;
|
|
||||||
z-index: 1001;
|
|
||||||
top: 55px;
|
|
||||||
right: 10px;
|
|
||||||
width: 400px;
|
|
||||||
max-width: 500px;
|
|
||||||
height: 100px;
|
|
||||||
max-height: 100px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
@ -1,8 +0,0 @@
|
|||||||
# 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).
|
|
@ -1,7 +0,0 @@
|
|||||||
export default async function ({ store, redirect }) {
|
|
||||||
await store.dispatch('auth/inspectToken')
|
|
||||||
|
|
||||||
if (!store.state.auth.isLogin) {
|
|
||||||
return redirect('/')
|
|
||||||
}
|
|
||||||
}
|
|
9
middleware/auth.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import { useAuth } from '~/stores/auth'
|
||||||
|
|
||||||
|
export default defineNuxtRouteMiddleware((to, from) => {
|
||||||
|
const auth = useAuth()
|
||||||
|
auth.inspectToken()
|
||||||
|
if (!auth.isLogin) {
|
||||||
|
return navigateTo('/')
|
||||||
|
}
|
||||||
|
})
|
114
nuxt.config.js
@ -1,114 +0,0 @@
|
|||||||
export default {
|
|
||||||
server: {
|
|
||||||
port: 3000, // default: 3000
|
|
||||||
host: '127.0.0.1' // default: localhost
|
|
||||||
},
|
|
||||||
|
|
||||||
ssr: false,
|
|
||||||
/*
|
|
||||||
** 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/helpers.js' },
|
|
||||||
{ src: '~/plugins/nuxt-client-init.js', ssr: false },
|
|
||||||
{ src: '~plugins/video.js', ssr: false },
|
|
||||||
{ src: '~plugins/splitpanes.js', ssr: false },
|
|
||||||
{ src: '~plugins/loading.js', ssr: false },
|
|
||||||
{ src: '~plugins/draggable.js', ssr: false },
|
|
||||||
{ src: '~plugins/lodash.js', ssr: false }
|
|
||||||
],
|
|
||||||
/*
|
|
||||||
** Nuxt.js modules
|
|
||||||
*/
|
|
||||||
modules: [
|
|
||||||
// Doc: https://bootstrap-vue.js.org
|
|
||||||
'bootstrap-vue/nuxt',
|
|
||||||
'@nuxtjs/axios',
|
|
||||||
'@nuxtjs/dayjs',
|
|
||||||
'@nuxtjs/eslint-module',
|
|
||||||
'@nuxtjs/style-resources',
|
|
||||||
'cookie-universal-nuxt'
|
|
||||||
],
|
|
||||||
|
|
||||||
/*
|
|
||||||
** Axios module configuration
|
|
||||||
** See https://axios.nuxtjs.org/options
|
|
||||||
*/
|
|
||||||
axios: {
|
|
||||||
baseURL: '/'
|
|
||||||
},
|
|
||||||
|
|
||||||
dayjs: {
|
|
||||||
locales: ['en', 'de'],
|
|
||||||
defaultLocale: 'en',
|
|
||||||
defaultTimeZone: 'UTC',
|
|
||||||
plugins: ['utc', 'timezone']
|
|
||||||
},
|
|
||||||
|
|
||||||
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) {},
|
|
||||||
babel: { compact: true },
|
|
||||||
loaders: {
|
|
||||||
sass: {
|
|
||||||
implementation: require('sass')
|
|
||||||
},
|
|
||||||
scss: {
|
|
||||||
implementation: require('sass')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
57
nuxt.config.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
devServer: {
|
||||||
|
port: 3000, // default: 3000
|
||||||
|
host: '127.0.0.1', // default: localhost
|
||||||
|
},
|
||||||
|
|
||||||
|
nitro: {
|
||||||
|
devProxy: {
|
||||||
|
'/api': { target: 'http://127.0.0.1:8787/api' },
|
||||||
|
'/auth': { target: 'http://127.0.0.1:8787/auth' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
ignore: ['**/public/tv-media**', '**/public/Videos**', '**/public/live**', '**/public/home**'],
|
||||||
|
|
||||||
|
ssr: false,
|
||||||
|
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
title: 'ffplayout',
|
||||||
|
meta: [
|
||||||
|
{
|
||||||
|
charset: 'utf-8',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'viewport',
|
||||||
|
content: 'width=device-width, initial-scale=1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
hid: 'description',
|
||||||
|
name: 'description',
|
||||||
|
content: 'Frontend for ffplayout, the 24/7 Rust and playlist based streaming solution.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
link: [
|
||||||
|
{
|
||||||
|
rel: 'icon',
|
||||||
|
type: 'image/x-icon',
|
||||||
|
href: '/favicon.ico',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
modules: ['@pinia/nuxt'],
|
||||||
|
|
||||||
|
vite: {
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
additionalData: '@import "@/assets/scss/main.scss";',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
17470
package-lock.json
generated
49
package.json
@ -1,40 +1,41 @@
|
|||||||
{
|
{
|
||||||
"name": "ffplayout-frontend",
|
|
||||||
"version": "5.3.1",
|
|
||||||
"description": "Web GUI for ffplayout",
|
|
||||||
"author": "Jonathan Baecker",
|
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "NODE_OPTIONS=--openssl-legacy-provider nuxt",
|
|
||||||
"build": "nuxt build",
|
"build": "nuxt build",
|
||||||
"start": "nuxt start",
|
"dev": "nuxt dev",
|
||||||
"generate": "nuxt generate",
|
"generate": "nuxt generate",
|
||||||
"lint": "eslint --ext .js,.vue --ignore-path .gitignore ."
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/axios": "^5.13.6",
|
"@nuxt/types": "^2.15.8",
|
||||||
"@nuxtjs/dayjs": "^1.4.1",
|
"@pinia/nuxt": "^0.4.6",
|
||||||
"bootstrap": "^4.6.2",
|
"@popperjs/core": "^2.11.6",
|
||||||
"bootstrap-vue": "^2.23.1",
|
"@vueuse/core": "^9.10.0",
|
||||||
|
"bootstrap": "^5.3.0-alpha1",
|
||||||
|
"bootstrap-icons": "^1.10.3",
|
||||||
"cookie-universal-nuxt": "^2.2.2",
|
"cookie-universal-nuxt": "^2.2.2",
|
||||||
|
"dayjs": "^1.11.7",
|
||||||
"jwt-decode": "^3.1.2",
|
"jwt-decode": "^3.1.2",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
"mpegts.js": "^1.7.1",
|
"mpegts.js": "^1.7.2",
|
||||||
"nuxt": "^2.15.8",
|
"pinia": "^2.0.28",
|
||||||
"splitpanes": "^2.4.1",
|
"sortablejs": "^1.15.0",
|
||||||
|
"sortablejs-vue3": "^1.2.5",
|
||||||
|
"splitpanes": "^3.1.5",
|
||||||
"video.js": "^7.20.3",
|
"video.js": "^7.20.3",
|
||||||
"vue-loading-overlay": "^3.4.2",
|
"vuedraggable": "^4.1.0"
|
||||||
"vuedraggable": "^2.24.3"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/eslint-parser": "^7.19.1",
|
"@nuxtjs/eslint-config": "^12.0.0",
|
||||||
"@babel/preset-react": "^7.18.6",
|
"@types/bootstrap": "^5.2.6",
|
||||||
"@nuxtjs/eslint-config": "^5.0.0",
|
"@types/lodash": "^4.14.191",
|
||||||
"@nuxtjs/eslint-module": "^3.1.0",
|
"@types/splitpanes": "^2.2.1",
|
||||||
"@nuxtjs/style-resources": "^1.2.1",
|
"@types/video.js": "^7.3.50",
|
||||||
"eslint": "^7.32.0",
|
"eslint": "^8.31.0",
|
||||||
"eslint-plugin-nuxt": "3.2.0",
|
"eslint-plugin-nuxt": "^4.0.0",
|
||||||
|
"nuxt": "3.0.0",
|
||||||
"sass": "^1.57.1",
|
"sass": "^1.57.1",
|
||||||
"sass-loader": "^10.3.1"
|
"sass-loader": "^13.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
# 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).
|
|
@ -1,412 +1,113 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<Menu />
|
<Menu />
|
||||||
<b-card no-body>
|
<div class="container-fluid configure-container">
|
||||||
<b-tabs pills card vertical>
|
<div class="d-flex align-items-start config-tab">
|
||||||
<b-tab title="GUI" active @click="resetAlert()">
|
<div class="nav flex-column nav-pills me-3" id="v-pills-tab" role="tablist" aria-orientation="vertical">
|
||||||
<b-container class="config-container">
|
<button
|
||||||
<b-form v-if="configGui" @submit="onSubmitGui">
|
class="nav-link active"
|
||||||
<b-form-group
|
id="v-pills-gui-tab"
|
||||||
label-cols-lg="2"
|
data-bs-toggle="pill"
|
||||||
label="Channel Configuration"
|
data-bs-target="#v-pills-gui"
|
||||||
label-size="lg"
|
type="button"
|
||||||
label-class="font-weight-bold pt-0"
|
role="tab"
|
||||||
class="config-group"
|
aria-controls="v-pills-gui"
|
||||||
>
|
aria-selected="true"
|
||||||
<div style="width: 100%; height: 43px;">
|
@click="indexStore.resetAlert()"
|
||||||
<div class="float-right">
|
>
|
||||||
<b-button size="sm" variant="primary" class="m-md-2" @click="addChannel()">
|
GUI
|
||||||
Add new Channel
|
</button>
|
||||||
</b-button>
|
<button
|
||||||
</div>
|
class="nav-link"
|
||||||
</div>
|
id="v-pills-playout-tab"
|
||||||
<div v-for="(prop, name, idx) in configGui[configID]" :key="idx">
|
data-bs-toggle="pill"
|
||||||
<b-form-group
|
data-bs-target="#v-pills-playout"
|
||||||
v-if="name !== 'id' && name !== 'utc_offset'"
|
type="button"
|
||||||
label-cols-sm="2"
|
role="tab"
|
||||||
:label="name"
|
aria-controls="v-pills-playout"
|
||||||
label-align-sm="right"
|
aria-selected="false"
|
||||||
:label-for="name"
|
@click="indexStore.resetAlert()"
|
||||||
>
|
>
|
||||||
<b-form-tags
|
Playout
|
||||||
v-if="name === 'extra_extensions'"
|
</button>
|
||||||
v-model="configGui[configID][name]"
|
<button
|
||||||
:input-id="name"
|
class="nav-link"
|
||||||
separator=" ,;"
|
id="v-pills-user-tab"
|
||||||
:placeholder="`add ${name}...`"
|
data-bs-toggle="pill"
|
||||||
class="mb-2 tags-list"
|
data-bs-target="#v-pills-user"
|
||||||
/>
|
type="button"
|
||||||
<b-form-text v-if="name === 'extra_extensions'">
|
role="tab"
|
||||||
Visible extensions only for the GUI and not the playout
|
aria-controls="v-pills-user"
|
||||||
</b-form-text>
|
aria-selected="false"
|
||||||
<b-form-select v-else-if="name === 'net_interface'" :id="name" v-model="configGui[configID][name]" :options="netChoices" :value="prop" />
|
@click="indexStore.resetAlert()"
|
||||||
<b-form-input
|
>
|
||||||
v-else-if="name === 'service' || name === 'config_path'"
|
User
|
||||||
:id="name"
|
</button>
|
||||||
v-model="configGui[configID][name]"
|
</div>
|
||||||
:value="prop"
|
<div class="tab-content" id="v-pills-tabContent">
|
||||||
readonly
|
<div
|
||||||
/>
|
class="tab-pane show active"
|
||||||
<b-form-input
|
id="v-pills-gui"
|
||||||
v-else
|
role="tabpanel"
|
||||||
:id="name"
|
aria-labelledby="v-pills-gui-tab"
|
||||||
v-model="configGui[configID][name]"
|
>
|
||||||
:value="prop"
|
<div class="config-container">
|
||||||
/>
|
<GuiConfig />
|
||||||
</b-form-group>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</b-form-group>
|
<div class="tab-pane" id="v-pills-playout" role="tabpanel" aria-labelledby="v-pills-playout-tab">
|
||||||
<b-row>
|
<div class="config-container">
|
||||||
<b-col cols="1" style="min-width:158px">
|
<PlayoutConfig />
|
||||||
<b-button-group>
|
</div>
|
||||||
<b-button type="submit" variant="primary">
|
</div>
|
||||||
Save
|
<div class="tab-pane" id="v-pills-user" role="tabpanel" aria-labelledby="v-pills-user-tab">
|
||||||
</b-button>
|
<div class="config-container">
|
||||||
<b-button v-if="configGui.length > 1 && configGui[configID].id > 1" variant="danger" @click="deleteChannel()">
|
<UserConfig />
|
||||||
Delete
|
</div>
|
||||||
</b-button>
|
</div>
|
||||||
</b-button-group>
|
</div>
|
||||||
</b-col>
|
</div>
|
||||||
<b-col>
|
</div>
|
||||||
<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 === 'help_text') ? '' : name"
|
|
||||||
label-align-sm="right"
|
|
||||||
:label-for="name"
|
|
||||||
>
|
|
||||||
<b-form-textarea
|
|
||||||
v-if="name === 'helptext' || name === 'help_text'"
|
|
||||||
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.number="configPlayout[key][name]"
|
|
||||||
type="number"
|
|
||||||
step="0.001"
|
|
||||||
class="input-field"
|
|
||||||
/>
|
|
||||||
<b-form-input
|
|
||||||
v-else-if="prop && !isNaN(prop)"
|
|
||||||
:id="name"
|
|
||||||
v-model.number="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-textarea
|
|
||||||
v-else-if="name === 'preview_param' || name === 'output_param'"
|
|
||||||
:id="name"
|
|
||||||
v-model="configPlayout[key][name]"
|
|
||||||
rows="4"
|
|
||||||
: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="'mail'"
|
|
||||||
label-align-sm="right"
|
|
||||||
:label-for="'mail'"
|
|
||||||
>
|
|
||||||
<b-form-input id="mail" v-model="configUser['mail']" :value="configUser['mail']" />
|
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
import { mapState } from 'vuex'
|
import { useIndex } from '~/stores/index'
|
||||||
import Menu from '@/components/Menu.vue'
|
|
||||||
|
|
||||||
export default {
|
definePageMeta({
|
||||||
name: 'Configure',
|
middleware: ['auth'],
|
||||||
|
})
|
||||||
|
|
||||||
components: {
|
useHead({
|
||||||
Menu
|
title: 'Configuration | ffplayout'
|
||||||
},
|
})
|
||||||
|
|
||||||
middleware: 'auth',
|
const indexStore = useIndex()
|
||||||
|
|
||||||
data () {
|
onBeforeUnmount(() => {
|
||||||
return {
|
indexStore.resetAlert()
|
||||||
newPass: null,
|
})
|
||||||
confirmPass: null,
|
|
||||||
showAlert: false,
|
|
||||||
alertVariant: 'success',
|
|
||||||
alertMsg: ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapState('config', ['configID', 'netChoices']),
|
|
||||||
configGui: {
|
|
||||||
get () {
|
|
||||||
return this.$store.state.config.configGui
|
|
||||||
},
|
|
||||||
set (config) {
|
|
||||||
this.$store.commit('config/UPDATE_GUI_CONFIG', config)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
configPlayout: {
|
|
||||||
get () {
|
|
||||||
return this.$store.state.config.configPlayout
|
|
||||||
},
|
|
||||||
set (config) {
|
|
||||||
this.$store.commit('config/UPDATE_PLAYOUT_CONFIG', config)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
configUser: {
|
|
||||||
get () {
|
|
||||||
return this.$store.state.config.configUser
|
|
||||||
},
|
|
||||||
set (config) {
|
|
||||||
this.$store.commit('config/UPDATE_USER_CONFIG', config)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
addChannel () {
|
|
||||||
const channels = this.$_.cloneDeep(this.configGui)
|
|
||||||
const newChannel = this.$_.cloneDeep(this.configGui[this.configGui.length - 1])
|
|
||||||
|
|
||||||
const playoutConfigPath = newChannel.config_path.match(/.*\//)
|
|
||||||
const confName = `channel${String(channels.length + 1).padStart(3, '0')}`
|
|
||||||
|
|
||||||
newChannel.id = channels.length + 1
|
|
||||||
newChannel.name = `Channel ${Math.random().toString(36).substring(7)}`
|
|
||||||
newChannel.config_path = `${playoutConfigPath}${confName}.yml`
|
|
||||||
newChannel.service = `ffplayout@${confName}.service`
|
|
||||||
|
|
||||||
channels.push(newChannel)
|
|
||||||
|
|
||||||
this.$store.commit('config/UPDATE_GUI_CONFIG', channels)
|
|
||||||
this.$store.commit('config/UPDATE_CONFIG_ID', this.configGui.length - 1)
|
|
||||||
},
|
|
||||||
async onSubmitGui (evt) {
|
|
||||||
evt.preventDefault()
|
|
||||||
const update = await this.$store.dispatch('config/setGuiConfig', this.configGui[this.configID])
|
|
||||||
|
|
||||||
if (update.status === 200 || update.status === 201) {
|
|
||||||
this.alertVariant = 'success'
|
|
||||||
this.alertMsg = 'Update GUI config success!'
|
|
||||||
} else {
|
|
||||||
this.alertVariant = 'danger'
|
|
||||||
this.alertMsg = 'Update GUI config failed!'
|
|
||||||
}
|
|
||||||
|
|
||||||
this.showAlert = true
|
|
||||||
},
|
|
||||||
async deleteChannel () {
|
|
||||||
const config = this.$_.cloneDeep(this.configGui)
|
|
||||||
const id = config[this.configID].id
|
|
||||||
|
|
||||||
if (id === 1) {
|
|
||||||
this.alertVariant = 'warning'
|
|
||||||
this.alertMsg = 'First channel can not be deleted!'
|
|
||||||
this.showAlert = true
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const response = await this.$axios.delete(`api/channel/${id}`)
|
|
||||||
|
|
||||||
config.splice(this.configID, 1)
|
|
||||||
|
|
||||||
this.$store.commit('config/UPDATE_GUI_CONFIG', config)
|
|
||||||
this.$store.commit('config/UPDATE_CONFIG_ID', this.configGui.length - 1)
|
|
||||||
await this.$store.dispatch('config/getPlayoutConfig')
|
|
||||||
|
|
||||||
if (response.status === 200) {
|
|
||||||
this.alertVariant = 'success'
|
|
||||||
this.alertMsg = 'Delete GUI config success!'
|
|
||||||
} else {
|
|
||||||
this.alertVariant = 'danger'
|
|
||||||
this.alertMsg = 'Delete 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.newPass && this.newPass === this.confirmPass) {
|
|
||||||
this.configUser.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 profile success!'
|
|
||||||
} else {
|
|
||||||
this.alertVariant = 'danger'
|
|
||||||
this.alertMsg = 'Update user profile failed!'
|
|
||||||
}
|
|
||||||
|
|
||||||
this.showAlert = true
|
|
||||||
|
|
||||||
this.newPass = null
|
|
||||||
this.confirmPass = null
|
|
||||||
},
|
|
||||||
|
|
||||||
resetAlert () {
|
|
||||||
this.showAlert = false
|
|
||||||
this.alertVariant = 'success'
|
|
||||||
this.alertMsg = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
.configure-container {
|
||||||
|
height: calc(100% - 90px);
|
||||||
|
}
|
||||||
|
|
||||||
.config-container {
|
.config-container {
|
||||||
margin: 2em auto 2em auto;
|
margin: 2em auto 2em auto;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
width: 90vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-group {
|
.config-tab,
|
||||||
margin-bottom: 2em;
|
.tab-content,
|
||||||
|
.nav-pills {
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-field {
|
.tab-pane {
|
||||||
max-width: 200px;
|
height: 100%;
|
||||||
}
|
overflow-y: auto;
|
||||||
|
|
||||||
.text-area {
|
|
||||||
overflow-y: hidden !important;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
263
pages/index.vue
@ -1,132 +1,131 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div v-if="!$store.state.auth.isLogin">
|
<div v-if="authStore.isLogin">
|
||||||
|
<div class="container login-container">
|
||||||
|
<div>
|
||||||
|
<div class="logo-div">
|
||||||
|
<img
|
||||||
|
src="~/assets/images/ffplayout.png"
|
||||||
|
class="img-fluid"
|
||||||
|
alt="Logo"
|
||||||
|
width="256"
|
||||||
|
height="256"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<div class="btn-group actions-grp btn-group-lg" role="group">
|
||||||
|
<NuxtLink to="/player" class="btn btn-primary">Player</NuxtLink>
|
||||||
|
<NuxtLink to="/media" class="btn btn-primary">Media</NuxtLink>
|
||||||
|
<NuxtLink to="/message" class="btn btn-primary">Message</NuxtLink>
|
||||||
|
<NuxtLink to="logging" class="btn btn-primary">Logging</NuxtLink>
|
||||||
|
<NuxtLink to="/configure" class="btn btn-primary"> Configure </NuxtLink>
|
||||||
|
<button class="btn btn-primary" @click="logout()">Logout</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
<div class="logout-div" />
|
<div class="logout-div" />
|
||||||
<b-container class="login-container">
|
<div class="container login-container">
|
||||||
<div>
|
<div>
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<h1>ffplayout</h1>
|
<h1>ffplayout</h1>
|
||||||
</div>
|
</div>
|
||||||
<b-form class="login-form" @submit.prevent="login">
|
|
||||||
<b-form-group id="input-group-1" label="User:" label-for="input-user">
|
<form class="login-form" @submit.prevent="login">
|
||||||
<b-form-input id="input-user" v-model="formUsername" type="text" required placeholder="Username" />
|
<div id="input-group-1" class="mb-3">
|
||||||
</b-form-group>
|
<label for="input-user" class="form-label">User:</label>
|
||||||
<b-form-group id="input-group-1" label="Password:" label-for="input-pass">
|
<input
|
||||||
<b-form-input id="input-pass" v-model="formPassword" type="password" required placeholder="Password" />
|
type="text"
|
||||||
</b-form-group>
|
id="input-user"
|
||||||
<b-row>
|
class="form-control"
|
||||||
<b-col cols="3">
|
v-model="formUsername"
|
||||||
<b-button type="submit" variant="primary">
|
aria-describedby="Username"
|
||||||
Login
|
required
|
||||||
</b-button>
|
/>
|
||||||
</b-col>
|
</div>
|
||||||
<b-col cols="9">
|
<div class="mb-3">
|
||||||
<b-alert variant="danger" :show="showError" dismissible @dismissed="showError=false">
|
<label for="input-pass" class="form-label">Password:</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="input-pass"
|
||||||
|
class="form-control"
|
||||||
|
v-model="formPassword"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-3">
|
||||||
|
<button class="btn btn-primary" type="submit">Login</button>
|
||||||
|
</div>
|
||||||
|
<div class="col-9">
|
||||||
|
<div
|
||||||
|
class="alert alert-danger alert-dismissible fade login-alert"
|
||||||
|
:class="{ show: showError }"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
{{ formError }}
|
{{ formError }}
|
||||||
</b-alert>
|
<button
|
||||||
</b-col>
|
type="button"
|
||||||
</b-row>
|
class="btn-close"
|
||||||
</b-form>
|
data-bs-dismiss="alert"
|
||||||
|
aria-label="Close"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</b-container>
|
</div>
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<b-container class="login-container">
|
|
||||||
<div>
|
|
||||||
<div class="logo-div">
|
|
||||||
<b-img-lazy
|
|
||||||
src="/images/ffplayout.png"
|
|
||||||
alt="Logo"
|
|
||||||
fluid
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
export default {
|
import { useAuth } from '~/stores/auth'
|
||||||
components: {},
|
import { useConfig } from '~/stores/config'
|
||||||
|
|
||||||
data () {
|
const authStore = useAuth()
|
||||||
return {
|
const configStore = useConfig()
|
||||||
showError: false,
|
|
||||||
formError: null,
|
const formError = ref('')
|
||||||
formUsername: '',
|
const showError = ref(false)
|
||||||
formPassword: '',
|
const formUsername = ref('')
|
||||||
interval: null,
|
const formPassword = ref('')
|
||||||
stat: {}
|
|
||||||
|
authStore.inspectToken()
|
||||||
|
|
||||||
|
async function login() {
|
||||||
|
try {
|
||||||
|
const status = await authStore.obtainToken(formUsername.value, formPassword.value)
|
||||||
|
|
||||||
|
formUsername.value = ''
|
||||||
|
formPassword.value = ''
|
||||||
|
formError.value = ''
|
||||||
|
|
||||||
|
if (status === 401 || status === 400 || status === 403) {
|
||||||
|
formError.value = 'Wrong User/Password!'
|
||||||
|
showError.value = true
|
||||||
}
|
}
|
||||||
},
|
|
||||||
created () {
|
|
||||||
this.init()
|
|
||||||
},
|
|
||||||
beforeDestroy () {
|
|
||||||
clearInterval(this.interval)
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async init () {
|
|
||||||
await this.$store.dispatch('auth/inspectToken')
|
|
||||||
},
|
|
||||||
async login () {
|
|
||||||
try {
|
|
||||||
const status = await this.$store.dispatch('auth/obtainToken', {
|
|
||||||
username: this.formUsername,
|
|
||||||
password: this.formPassword
|
|
||||||
})
|
|
||||||
this.formUsername = ''
|
|
||||||
this.formPassword = ''
|
|
||||||
this.formError = null
|
|
||||||
|
|
||||||
if (status === 401 || status === 400) {
|
await configStore.nuxtClientInit()
|
||||||
this.formError = 'Wrong user or password!'
|
} catch (e) {
|
||||||
this.showError = true
|
formError.value = e as string
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.$store.dispatch('config/nuxtClientInit')
|
async function logout() {
|
||||||
} catch (e) {
|
try {
|
||||||
this.formError = e.message
|
authStore.removeToken()
|
||||||
}
|
authStore.updateIsLogin(false)
|
||||||
},
|
} catch (e) {
|
||||||
logout () {
|
formError.value = e as string
|
||||||
clearInterval(this.interval)
|
|
||||||
|
|
||||||
try {
|
|
||||||
this.$store.commit('auth/REMOVE_TOKEN')
|
|
||||||
this.$store.commit('auth/UPDATE_IS_LOGIN', false)
|
|
||||||
} catch (e) {
|
|
||||||
this.formError = e.message
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style lang="scss">
|
||||||
.login-container {
|
.login-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@ -149,49 +148,13 @@ export default {
|
|||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.manage-btn {
|
.login-alert {
|
||||||
margin: 0 auto 0 auto;
|
padding: 0.4em;
|
||||||
}
|
--bs-alert-margin-bottom: 0;
|
||||||
|
|
||||||
.chart-col {
|
.btn-close {
|
||||||
text-align: center;
|
padding: 0.65rem 0.5rem;
|
||||||
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 {
|
.actions {
|
||||||
|
@ -1,134 +1,100 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<Menu />
|
<Menu />
|
||||||
<b-row class="date-row">
|
<div class="date-container">
|
||||||
<b-col>
|
<div class="date-div">
|
||||||
<b-datepicker v-model="listDate" size="sm" class="date-div" offset="-35px" />
|
<input type="date" class="form-control" v-model="listDate" />
|
||||||
</b-col>
|
</div>
|
||||||
</b-row>
|
</div>
|
||||||
<b-container class="log-container">
|
<div class="log-container mt-2">
|
||||||
<div
|
<div class="log-content" v-html="formatLog(currentLog)" />
|
||||||
v-if="currentLog"
|
</div>
|
||||||
class="log-content"
|
|
||||||
:inner-html.prop="currentLog | formatStr"
|
|
||||||
/>
|
|
||||||
</b-container>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
import { mapState } from 'vuex'
|
import { useAuth } from '~/stores/auth'
|
||||||
import Menu from '@/components/Menu.vue'
|
import { useConfig } from '~/stores/config'
|
||||||
|
|
||||||
export default {
|
definePageMeta({
|
||||||
name: 'Logging',
|
middleware: ['auth'],
|
||||||
|
})
|
||||||
|
|
||||||
components: {
|
useHead({
|
||||||
Menu
|
title: 'Logging | ffplayout'
|
||||||
},
|
})
|
||||||
|
|
||||||
filters: {
|
const { $dayjs } = useNuxtApp()
|
||||||
formatStr (text) {
|
const authStore = useAuth()
|
||||||
return text
|
const configStore = useConfig()
|
||||||
/* eslint-disable no-control-regex */
|
const currentLog = ref('')
|
||||||
.replace(/\x1B\[33m(.*?)\x1B\[0m/g, '<span class="log-number">$1</span>')
|
const listDate = ref($dayjs().utcOffset(configStore.utcOffset).format('YYYY-MM-DD'))
|
||||||
.replace(/\x1B\[1m\x1B\[35m(.*?)\x1B\[0m\x1B\[22m/g, '<span class="log-addr">$1</span>')
|
const configID = ref(configStore.configID)
|
||||||
.replace(/\x1B\[94m(.*?)\x1B\[0m/g, '<span class="log-cmd">$1</span>')
|
const { formatLog } = stringFormatter()
|
||||||
.replace(/\x1B\[90m(.*?)\x1B\[0m/g, '<span class="log-debug">$1</span>')
|
|
||||||
.replace(/(\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}.[\d]+\])/g, '<span class="log-time">$1</span>')
|
|
||||||
.replace(/\[ INFO\]/g, '<span class="log-info">[ INFO]</span>')
|
|
||||||
.replace(/\[ WARN\]/g, '<span class="log-warning">[ WARN]</span>')
|
|
||||||
.replace(/\[ERROR\]/g, '<span class="log-error">[ERROR]</span>')
|
|
||||||
.replace(/\[DEBUG\]/g, '<span class="log-debug">[DEBUG]</span>')
|
|
||||||
.replace(/\[Decoder\]/g, '<span class="log-decoder">[Decoder]</span>')
|
|
||||||
.replace(/\[Encoder\]/g, '<span class="log-encoder">[Encoder]</span>')
|
|
||||||
.replace(/\[Server\]/g, '<span class="log-server">[Server]</span>')
|
|
||||||
.replace(/\[Validator\]/g, '<span class="log-server">[Validator]</span>')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
middleware: 'auth',
|
async function getLog() {
|
||||||
|
let date = listDate.value
|
||||||
|
|
||||||
data () {
|
if (date === $dayjs().utcOffset(configStore.utcOffset).format('YYYY-MM-DD')) {
|
||||||
return {
|
date = ''
|
||||||
currentLog: null,
|
|
||||||
listDate: this.$dayjs().utcOffset(0).format('YYYY-MM-DD')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapState('config', ['configID', 'utcOffset'])
|
|
||||||
},
|
|
||||||
|
|
||||||
watch: {
|
|
||||||
listDate () {
|
|
||||||
this.getLog()
|
|
||||||
},
|
|
||||||
|
|
||||||
configID () {
|
|
||||||
this.getLog()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async created () {
|
|
||||||
this.listDate = this.$dayjs().utcOffset(this.utcOffset).format('YYYY-MM-DD')
|
|
||||||
await this.getLog()
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
async getLog () {
|
|
||||||
let date = this.listDate
|
|
||||||
|
|
||||||
if (date === this.$dayjs().utcOffset(this.utcOffset).format('YYYY-MM-DD')) {
|
|
||||||
date = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.$axios.get(
|
|
||||||
`api/log/${this.$store.state.config.configGui[this.$store.state.config.configID].id}?date=${date}`)
|
|
||||||
|
|
||||||
if (response.data) {
|
|
||||||
this.currentLog = response.data
|
|
||||||
} else {
|
|
||||||
this.currentLog = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await fetch(`api/log/${configStore.configGui[configStore.configID].id}?date=${date}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: authStore.authHeader,
|
||||||
|
})
|
||||||
|
.then((response) => response.text())
|
||||||
|
.then((data) => {
|
||||||
|
currentLog.value = data
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
currentLog.value = ''
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
getLog()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch([listDate, configID], () => {
|
||||||
|
getLog()
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style lang="scss">
|
||||||
.ps__thumb-x {
|
.date-container {
|
||||||
display: inherit !important;
|
width: 100%;
|
||||||
|
height: 37px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-container {
|
.log-container {
|
||||||
background: #1d2024;
|
background: $bg-secondary;
|
||||||
max-width: 99%;
|
height: calc(100% - 120px);
|
||||||
width: 99%;
|
margin: 1em;
|
||||||
height: calc(100% - 90px);
|
padding: .5em;
|
||||||
padding: 1em;
|
overflow: hidden;
|
||||||
overflow: hidden
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-time {
|
.log-time {
|
||||||
color: #666864;
|
color: $log-time;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-number {
|
.log-number {
|
||||||
color: #e2c317;
|
color: $log-number;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-addr {
|
.log-addr {
|
||||||
color: #ad7fa8;
|
color: $log-addr ;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-cmd {
|
.log-cmd {
|
||||||
color: #6c95c2;
|
color: $log-cmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-content {
|
.log-content {
|
||||||
color: #ececec;
|
color: $log-content;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
font-family: monospace;
|
font-family: monospace;
|
||||||
@ -139,31 +105,30 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.log-info {
|
.log-info {
|
||||||
color: #8ae234;
|
color: $log-info;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-warning {
|
.log-warning {
|
||||||
color: #ff8700;
|
color: $log-warning;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-error {
|
.log-error {
|
||||||
color: #d32828;
|
color: $log-error;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-debug {
|
.log-debug {
|
||||||
color: #6e99c7;
|
color: $log-debug;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-decoder {
|
.log-decoder {
|
||||||
color: #56efff;
|
color: $log-decoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-encoder {
|
.log-encoder {
|
||||||
color: #45ccee;
|
color: $log-encoder;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-server {
|
.log-server {
|
||||||
color: #23cbdd;
|
color: $log-server;
|
||||||
}
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
867
pages/media.vue
@ -1,642 +1,301 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<Menu />
|
<Menu />
|
||||||
<b-container
|
<div class="container-fluid browser-container">
|
||||||
class="browser-container"
|
<Browser ref="browser" />
|
||||||
@drop.prevent="addFile"
|
</div>
|
||||||
@dragover.prevent
|
<div class="btn-group media-button">
|
||||||
@dragenter.prevent="dragEnter"
|
<button
|
||||||
@dragleave.prevent="dragLeave"
|
type="button"
|
||||||
>
|
class="btn btn-primary"
|
||||||
<div class="drag-file" :class="fileDragClass">
|
title="Create Folder"
|
||||||
<span>
|
data-bs-toggle="modal"
|
||||||
<b-icon-box-arrow-in-down />
|
data-bs-target="#folderModal"
|
||||||
</span>
|
>
|
||||||
</div>
|
<i class="bi-folder-plus" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
title="Upload File"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#uploadModal"
|
||||||
|
>
|
||||||
|
<i class="bi-upload" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="folderTree" class="browser">
|
<div id="folderModal" class="modal" tabindex="-1" aria-labelledby="folderModalLabel" aria-hidden="true">
|
||||||
<div class="bread-div">
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
<b-breadcrumb>
|
<div class="modal-content">
|
||||||
<b-breadcrumb-item
|
<div class="modal-header">
|
||||||
v-for="(crumb, index) in crumbs"
|
<h1 class="modal-title fs-5" id="folderModalLabel">Create Folder</h1>
|
||||||
:key="crumb.key"
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cancel"></button>
|
||||||
:active="index === crumbs.length - 1"
|
</div>
|
||||||
@click="getPath(crumb.path)"
|
<form @submit.prevent="onSubmitCreateFolder" @reset="onCancelCreateFolder">
|
||||||
>
|
<div class="modal-body">
|
||||||
{{ crumb.text }}
|
<input type="text" class="form-control" v-model="folderName" />
|
||||||
</b-breadcrumb-item>
|
|
||||||
</b-breadcrumb>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<splitpanes class="browser-row default-theme pane-row">
|
|
||||||
<pane min-size="20" size="24">
|
|
||||||
<div class="browser-div">
|
|
||||||
<div class="media-browser-scroll">
|
|
||||||
<b-list-group class="folder-list">
|
|
||||||
<b-list-group-item
|
|
||||||
v-for="folder in folderTree.folders"
|
|
||||||
:key="folder"
|
|
||||||
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(`/${folderTree.source}/${folder}`)">
|
|
||||||
{{ folder }}
|
|
||||||
</b-link>
|
|
||||||
</b-col>
|
|
||||||
<b-col v-if="folder !== '..'" cols="1" class="folder-delete">
|
|
||||||
<b-link @click="showDeleteModal('Folder', `/${folderTree.source}/${folder}`)">
|
|
||||||
<b-icon-x-circle-fill />
|
|
||||||
</b-link>
|
|
||||||
</b-col>
|
|
||||||
</b-row>
|
|
||||||
</b-list-group-item>
|
|
||||||
</b-list-group>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</pane>
|
<div class="modal-footer">
|
||||||
<pane class="files-col">
|
<button type="reset" class="btn btn-primary" data-bs-dismiss="modal" aria-label="Cancel">
|
||||||
<loading
|
|
||||||
:active.sync="isLoading"
|
|
||||||
:can-cancel="false"
|
|
||||||
:is-full-page="false"
|
|
||||||
background-color="#485159"
|
|
||||||
color="#ff9c36"
|
|
||||||
/>
|
|
||||||
<div class="browser-div">
|
|
||||||
<div class="media-browser-scroll">
|
|
||||||
<b-list-group class="files-list">
|
|
||||||
<b-list-group-item
|
|
||||||
v-for="file in folderTree.files"
|
|
||||||
:key="file.name"
|
|
||||||
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.name }}
|
|
||||||
</b-col>
|
|
||||||
<b-col cols="1" class="browser-play-col">
|
|
||||||
<b-link title="Preview" @click="showPreviewModal(`/${folderTree.parent}/${folderTree.source}/${file.name}`)">
|
|
||||||
<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="small-col">
|
|
||||||
<b-link title="Rename File" @click="showRenameModal(file.name)">
|
|
||||||
<b-icon-pencil-square />
|
|
||||||
</b-link>
|
|
||||||
</b-col>
|
|
||||||
<b-col cols="1" class="small-col">
|
|
||||||
<b-link title="Delete File" @click="showDeleteModal('File', `/${folderTree.parent}/${folderTree.source}/${file.name}`)">
|
|
||||||
<b-icon-x-circle-fill />
|
|
||||||
</b-link>
|
|
||||||
</b-col>
|
|
||||||
</b-row>
|
|
||||||
</b-list-group-item>
|
|
||||||
</b-list-group>
|
|
||||||
</div>
|
|
||||||
</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
|
|
||||||
autofocus
|
|
||||||
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
|
|
||||||
no-close-on-backdrop
|
|
||||||
>
|
|
||||||
<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" style="min-width: 125px">
|
|
||||||
Overall ({{ currentNumber }}/{{ inputFiles.length }}):
|
|
||||||
</b-col>
|
|
||||||
<b-col cols="10">
|
|
||||||
<b-progress :value="overallProgress" />
|
|
||||||
</b-col>
|
|
||||||
<div class="w-100" />
|
|
||||||
<b-col cols="1" style="min-width: 125px">
|
|
||||||
Current:
|
|
||||||
</b-col>
|
|
||||||
<b-col cols="10">
|
|
||||||
<b-progress :value="currentProgress" />
|
|
||||||
</b-col>
|
|
||||||
<div class="w-100" />
|
|
||||||
<b-col cols="1" style="min-width: 125px">
|
|
||||||
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
|
Cancel
|
||||||
</b-button>
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary" data-bs-dismiss="modal">Ok</button>
|
||||||
</div>
|
</div>
|
||||||
</b-col>
|
</form>
|
||||||
</b-row>
|
|
||||||
</b-form>
|
|
||||||
</b-modal>
|
|
||||||
<b-modal id="rename-modal" title="Rename File" centered hide-footer>
|
|
||||||
<b-form @submit="renameFile">
|
|
||||||
<b-form-group
|
|
||||||
id="input-group-1"
|
|
||||||
label-for="input-1"
|
|
||||||
>
|
|
||||||
<b-form-input
|
|
||||||
id="input-1"
|
|
||||||
v-model="renameNewName"
|
|
||||||
type="text"
|
|
||||||
placeholder=""
|
|
||||||
/>
|
|
||||||
</b-form-group>
|
|
||||||
<div class="media-button">
|
|
||||||
<b-button type="submit" variant="primary">
|
|
||||||
Rename
|
|
||||||
</b-button>
|
|
||||||
<b-button variant="primary" @click="cancelRename()">
|
|
||||||
Cancel
|
|
||||||
</b-button>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</b-modal>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
id="uploadModal"
|
||||||
|
ref="uploadModal"
|
||||||
|
class="modal"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-labelledby="uploadModalLabel"
|
||||||
|
data-bs-backdrop="static"
|
||||||
|
>
|
||||||
|
<div class="modal-dialog modal-dialog-centered modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="uploadModalLabel">Upload Files</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cancel"></button>
|
||||||
|
</div>
|
||||||
|
<form @submit.prevent="onSubmitUpload" @reset="onResetUpload">
|
||||||
|
<div class="modal-body">
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
type="file"
|
||||||
|
:accept="extensions"
|
||||||
|
v-on:change="onFileChange"
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-10">
|
||||||
|
<div class="row progress-row">
|
||||||
|
<div class="col-1" style="min-width: 125px">
|
||||||
|
Overall ({{ currentNumber }}/{{ inputFiles.length }}):
|
||||||
|
</div>
|
||||||
|
<div class="col-10 progress">
|
||||||
|
<div
|
||||||
|
class="progress-bar bg-warning"
|
||||||
|
role="progressbar"
|
||||||
|
:aria-valuenow="overallProgress"
|
||||||
|
:style="`width: ${overallProgress}%`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-100" />
|
||||||
|
<div class="col-1" style="min-width: 125px">Current:</div>
|
||||||
|
<div class="col-10 progress">
|
||||||
|
<div
|
||||||
|
class="progress-bar bg-warning"
|
||||||
|
role="progressbar"
|
||||||
|
:aria-valuenow="currentProgress"
|
||||||
|
:style="`width: ${currentProgress}%`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-100" />
|
||||||
|
<div class="col-1" style="min-width: 125px">Uploading:</div>
|
||||||
|
<div class="col-10">
|
||||||
|
<strong>{{ uploadTask }}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-2">
|
||||||
|
<div class="media-button">
|
||||||
|
<button type="reset" class="btn btn-primary me-2" data-bs-dismiss="modal">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="btn btn-primary">Upload</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
/* eslint-disable vue/custom-event-name-casing */
|
import { useAuth } from '~/stores/auth'
|
||||||
import { mapState } from 'vuex'
|
import { useConfig } from '~/stores/config'
|
||||||
import Menu from '@/components/Menu.vue'
|
import { useIndex } from '~/stores/index'
|
||||||
|
import { useMedia } from '~/stores/media'
|
||||||
|
import Browser from '../components/Browser.vue'
|
||||||
|
|
||||||
export default {
|
const { $bootstrap } = useNuxtApp()
|
||||||
name: 'Media',
|
const authStore = useAuth()
|
||||||
|
const configStore = useConfig()
|
||||||
|
const indexStore = useIndex()
|
||||||
|
const mediaStore = useMedia()
|
||||||
|
const contentType = { 'content-type': 'application/json;charset=UTF-8' }
|
||||||
|
|
||||||
components: {
|
definePageMeta({
|
||||||
Menu
|
middleware: ['auth'],
|
||||||
},
|
})
|
||||||
|
|
||||||
middleware: 'auth',
|
useHead({
|
||||||
|
title: 'Media | ffplayout'
|
||||||
|
})
|
||||||
|
|
||||||
data () {
|
const browser = ref()
|
||||||
return {
|
const uploadModal = ref()
|
||||||
isLoading: false,
|
const extensions = ref('')
|
||||||
fileDragClass: '',
|
const folderName = ref('')
|
||||||
extensions: '',
|
const inputFiles = ref([] as File[])
|
||||||
folderName: '',
|
const currentNumber = ref(0)
|
||||||
inputFiles: [],
|
const uploadTask = ref('')
|
||||||
currentNumber: 0,
|
const overallProgress = ref(0)
|
||||||
inputPlaceholder: 'Choose files or drop them here...',
|
const currentProgress = ref(0)
|
||||||
previewOptions: {},
|
const lastPath = ref('')
|
||||||
previewComp: null,
|
const thisUploadModal = ref()
|
||||||
previewName: '',
|
const xhr = ref(new XMLHttpRequest())
|
||||||
previewSource: '',
|
|
||||||
renamePath: '',
|
|
||||||
renameOldName: '',
|
|
||||||
renameNewName: '',
|
|
||||||
deleteType: 'File',
|
|
||||||
deleteSource: '',
|
|
||||||
isImage: false,
|
|
||||||
uploadTask: '',
|
|
||||||
overallProgress: 0,
|
|
||||||
currentProgress: 0,
|
|
||||||
cancelTokenSource: this.$axios.CancelToken.source(),
|
|
||||||
lastPath: ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
onMounted(async () => {
|
||||||
...mapState('config', ['configID', 'configGui', 'configPlayout']),
|
const exts = [
|
||||||
...mapState('media', ['crumbs', 'folderTree'])
|
...configStore.configPlayout.storage.extensions,
|
||||||
},
|
...configStore.configGui[configStore.configID].extra_extensions.split(','),
|
||||||
|
].map((ext) => {
|
||||||
|
return `.${ext}`
|
||||||
|
})
|
||||||
|
|
||||||
watch: {
|
extensions.value = exts.join(', ')
|
||||||
configID () {
|
thisUploadModal.value = $bootstrap.Modal.getOrCreateInstance(uploadModal.value)
|
||||||
this.getPath('')
|
})
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted () {
|
async function onSubmitCreateFolder(evt: any) {
|
||||||
const exts = [...this.configPlayout.storage.extensions, ...this.configGui[this.configID].extra_extensions].map((ext) => {
|
evt.preventDefault()
|
||||||
return `.${ext}`
|
const path = `${mediaStore.folderTree.source}/${folderName.value}`.replace(/\/[/]+/g, '/')
|
||||||
|
lastPath.value = mediaStore.folderTree.source
|
||||||
|
|
||||||
|
if (mediaStore.folderTree.folders.includes(folderName.value)) {
|
||||||
|
indexStore.alertVariant = 'alert-warning'
|
||||||
|
indexStore.alertMsg = `Folder "${folderName.value}" exists already!`
|
||||||
|
indexStore.showAlert = true
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await $fetch(`api/file/${configStore.configGui[configStore.configID].id}/create-folder/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...contentType, ...authStore.authHeader },
|
||||||
|
body: JSON.stringify({ source: path }),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
indexStore.alertVariant = 'alert-success'
|
||||||
|
indexStore.alertMsg = 'Folder create done...'
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
indexStore.alertVariant = 'alert-danger'
|
||||||
|
indexStore.alertMsg = `Folder create error: ${e}`
|
||||||
})
|
})
|
||||||
|
|
||||||
this.extensions = exts.join(',')
|
indexStore.showAlert = true
|
||||||
this.getPath('')
|
folderName.value = ''
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
setTimeout(() => {
|
||||||
async getPath (path) {
|
indexStore.alertMsg = ''
|
||||||
this.lastPath = path
|
indexStore.showAlert = false
|
||||||
this.isLoading = true
|
}, 2000)
|
||||||
await this.$store.dispatch('media/getTree', { path })
|
|
||||||
this.isLoading = false
|
|
||||||
},
|
|
||||||
|
|
||||||
dragEnter (evt) {
|
browser.value.getPath(lastPath.value)
|
||||||
evt.preventDefault()
|
}
|
||||||
this.fileDragClass = 'drop-file-visible'
|
|
||||||
},
|
|
||||||
|
|
||||||
dragLeave (evt) {
|
function onCancelCreateFolder(evt: any) {
|
||||||
evt.preventDefault()
|
evt.preventDefault()
|
||||||
this.fileDragClass = ''
|
folderName.value = ''
|
||||||
this.inputPlaceholder = 'Choose files or drop them here...'
|
}
|
||||||
},
|
|
||||||
|
|
||||||
addFile (evt) {
|
function onFileChange(evt: any) {
|
||||||
evt.preventDefault()
|
const files = evt.target.files || evt.dataTransfer.files
|
||||||
const droppedFiles = evt.dataTransfer.files
|
|
||||||
if (!droppedFiles) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
([...droppedFiles]).forEach((f) => {
|
|
||||||
this.inputFiles.push(f)
|
|
||||||
})
|
|
||||||
|
|
||||||
if (this.inputFiles.length === 1) {
|
if (!files.length) {
|
||||||
this.inputPlaceholder = this.inputFiles[0].name
|
return
|
||||||
} 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()
|
|
||||||
const path = (this.crumbs[this.crumbs.length - 1].path + '/' + this.folderName).replace(/\/[/]+/, '/')
|
|
||||||
|
|
||||||
await this.$axios.post(
|
|
||||||
`api/file/${this.configGui[this.configID].id}/create-folder/`, { source: path }
|
|
||||||
)
|
|
||||||
|
|
||||||
this.$root.$emit('bv::hide::modal', 'folder-modal')
|
|
||||||
this.getPath(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) {
|
|
||||||
evt.preventDefault()
|
|
||||||
const uploadProgress = fileName => (progressEvent) => {
|
|
||||||
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total)
|
|
||||||
this.$store.dispatch('auth/inspectToken')
|
|
||||||
this.currentProgress = progress
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [i, file] of this.inputFiles.entries()) {
|
|
||||||
this.uploadTask = file.name
|
|
||||||
this.currentNumber = i + 1
|
|
||||||
|
|
||||||
const formData = new FormData()
|
|
||||||
formData.append(file.name, file)
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
onUploadProgress: uploadProgress(file.name),
|
|
||||||
cancelToken: this.cancelTokenSource.token,
|
|
||||||
headers: { Authorization: 'Bearer ' + this.$store.state.auth.jwtToken }
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.$axios.put(
|
|
||||||
`api/file/${this.configGui[this.configID].id}/upload/?path=${encodeURIComponent(this.crumbs[this.crumbs.length - 1].path)}`,
|
|
||||||
formData,
|
|
||||||
config
|
|
||||||
)
|
|
||||||
.then((res) => {
|
|
||||||
this.overallProgress = this.currentNumber * 100 / this.inputFiles.length
|
|
||||||
this.currentProgress = 0
|
|
||||||
})
|
|
||||||
.catch(err => console.log(err))
|
|
||||||
}
|
|
||||||
|
|
||||||
this.uploadTask = 'Done...'
|
|
||||||
this.currentNumber = 0
|
|
||||||
this.inputPlaceholder = 'Choose files or drop them here...'
|
|
||||||
this.inputFiles = []
|
|
||||||
this.getPath(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.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.replace(/^[/]+/, '').replace(/[/]+/, '/')).replace(/%2F/g, '/')
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.isImage = true
|
|
||||||
}
|
|
||||||
this.$root.$emit('bv::show::modal', 'preview-modal')
|
|
||||||
},
|
|
||||||
|
|
||||||
showRenameModal (file) {
|
|
||||||
this.renameOldName = file
|
|
||||||
this.renameNewName = file
|
|
||||||
this.$root.$emit('bv::show::modal', 'rename-modal')
|
|
||||||
},
|
|
||||||
|
|
||||||
async renameFile (evt) {
|
|
||||||
evt.preventDefault()
|
|
||||||
|
|
||||||
await this.$axios.post(
|
|
||||||
`api/file/${this.configGui[this.configID].id}/rename/`, {
|
|
||||||
source: `/${this.folderTree.parent}/${this.folderTree.source}/${this.renameOldName}`.replace('//', '/'),
|
|
||||||
target: `/${this.folderTree.parent}/${this.folderTree.source}/${this.renameNewName}`.replace('//', '/')
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
this.getPath(this.lastPath)
|
|
||||||
|
|
||||||
this.renamePath = ''
|
|
||||||
this.renameOldName = ''
|
|
||||||
this.renameNewName = ''
|
|
||||||
this.$root.$emit('bv::hide::modal', 'rename-modal')
|
|
||||||
},
|
|
||||||
|
|
||||||
cancelRename () {
|
|
||||||
this.renamePath = ''
|
|
||||||
this.renameOldName = ''
|
|
||||||
this.renameNewName = ''
|
|
||||||
this.$root.$emit('bv::hide::modal', 'rename-modal')
|
|
||||||
},
|
|
||||||
|
|
||||||
showDeleteModal (type, src) {
|
|
||||||
this.deleteSource = src
|
|
||||||
|
|
||||||
if (type === 'File') {
|
|
||||||
this.previewName = src.split('/').slice(-1)[0].replace('//', '/')
|
|
||||||
} else {
|
|
||||||
this.previewName = src.replace('//', '/')
|
|
||||||
}
|
|
||||||
|
|
||||||
this.deleteType = type
|
|
||||||
this.$root.$emit('bv::show::modal', 'delete-modal')
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteFileOrFolder () {
|
|
||||||
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 = ''
|
|
||||||
pathName = this.deleteSource
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = `${pathName}/${file}`.replace('//', '/')
|
|
||||||
|
|
||||||
await this.$axios.post(
|
|
||||||
`api/file/${this.configGui[this.configID].id}/remove/`, { source })
|
|
||||||
.catch(err => console.log(err))
|
|
||||||
|
|
||||||
this.$root.$emit('bv::hide::modal', 'delete-modal')
|
|
||||||
|
|
||||||
this.getPath(this.lastPath)
|
|
||||||
},
|
|
||||||
|
|
||||||
cancelDelete () {
|
|
||||||
this.deleteSource = ''
|
|
||||||
this.$root.$emit('bv::hide::modal', 'delete-modal')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inputFiles.value = files
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmitUpload(evt: any) {
|
||||||
|
evt.preventDefault()
|
||||||
|
|
||||||
|
lastPath.value = mediaStore.folderTree.source
|
||||||
|
|
||||||
|
for (let i = 0; i < inputFiles.value.length; i++) {
|
||||||
|
const file = inputFiles.value[i]
|
||||||
|
uploadTask.value = file.name
|
||||||
|
currentNumber.value = i + 1
|
||||||
|
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append(file.name, file)
|
||||||
|
|
||||||
|
xhr.value = new XMLHttpRequest()
|
||||||
|
|
||||||
|
xhr.value.open(
|
||||||
|
'PUT',
|
||||||
|
`api/file/${configStore.configGui[configStore.configID].id}/upload/?path=${encodeURIComponent(
|
||||||
|
mediaStore.crumbs[mediaStore.crumbs.length - 1].path
|
||||||
|
)}`
|
||||||
|
)
|
||||||
|
|
||||||
|
xhr.value.setRequestHeader('Authorization', `Bearer ${authStore.jwtToken}`)
|
||||||
|
|
||||||
|
xhr.value.upload.onprogress = function (event) {
|
||||||
|
currentProgress.value = Math.round((100 * event.loaded) / event.total)
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.value.upload.onerror = function () {
|
||||||
|
indexStore.alertVariant = 'alert-danger'
|
||||||
|
indexStore.alertMsg = `Upload error: ${xhr.value.status}`
|
||||||
|
indexStore.showAlert = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload completed successfully
|
||||||
|
xhr.value.onload = function () {
|
||||||
|
overallProgress.value = (currentNumber.value * 100) / inputFiles.value.length
|
||||||
|
currentProgress.value = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
xhr.value.send(formData)
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadTask.value = 'Done...'
|
||||||
|
browser.value.getPath(lastPath.value)
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
thisUploadModal.value.hide()
|
||||||
|
currentNumber.value = 0
|
||||||
|
currentProgress.value = 0
|
||||||
|
overallProgress.value = 0
|
||||||
|
inputFiles.value = []
|
||||||
|
indexStore.showAlert = false
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResetUpload(evt: any) {
|
||||||
|
evt.preventDefault()
|
||||||
|
inputFiles.value = []
|
||||||
|
overallProgress.value = 0
|
||||||
|
currentProgress.value = 0
|
||||||
|
uploadTask.value = ''
|
||||||
|
|
||||||
|
xhr.value.abort()
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style lang="scss">
|
||||||
.browser-container {
|
.browser-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
height: calc(100% - 40px);
|
height: calc(100% - 140px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.browser {
|
.browser-container > div {
|
||||||
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%;
|
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;
|
|
||||||
width: 98%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.folder-delete {
|
|
||||||
margin-right: .5em;
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-col {
|
|
||||||
min-width: 320px;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.small-col {
|
|
||||||
max-width: 50px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-list {
|
|
||||||
width: 99.5%;
|
|
||||||
height: 100%;
|
|
||||||
padding: .5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.media-button {
|
|
||||||
float: right;
|
|
||||||
margin-top: 1em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-row {
|
.progress-row {
|
||||||
@ -644,10 +303,10 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-row .col-1 {
|
.progress-row .col-1 {
|
||||||
min-width: 60px
|
min-width: 60px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-row .col-10 {
|
.progress-row .col-10 {
|
||||||
margin: auto 0 auto 0
|
margin: auto 0 auto 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,433 +1,492 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<Menu />
|
<Menu />
|
||||||
<b-container class="messege-container">
|
<div class="container mt-5">
|
||||||
<div class="preset-div">
|
<div class="preset-div">
|
||||||
<b-row>
|
<div class="row">
|
||||||
<b-col>
|
<div class="col">
|
||||||
<b-form-select v-model="selected" :options="presets" />
|
<select class="form-select" v-model="selected" @change="onChange($event)">
|
||||||
</b-col>
|
<option v-for="item in presets">{{ item.name }}</option>
|
||||||
<b-col cols="2">
|
</select>
|
||||||
<b-button-group class="mr-1">
|
</div>
|
||||||
<b-button title="Save Preset" variant="primary" @click="savePreset()">
|
<div class="col-2">
|
||||||
<b-icon icon="cloud-upload" />
|
<div class="btn-group" role="group">
|
||||||
</b-button>
|
<button class="btn btn-primary" title="Save Preset" @click="savePreset()">
|
||||||
<b-button title="New Preset" variant="primary" @click="openDialog()">
|
<i class="bi-cloud-upload" />
|
||||||
<b-icon-file-plus />
|
</button>
|
||||||
</b-button>
|
<button
|
||||||
<b-button title="Delete Preset" variant="primary" @click="deleteDialog()">
|
class="btn btn-primary"
|
||||||
<b-icon-file-minus />
|
title="New Preset"
|
||||||
</b-button>
|
data-bs-toggle="modal"
|
||||||
</b-button-group>
|
data-bs-target="#createModal"
|
||||||
</b-col>
|
>
|
||||||
</b-row>
|
<i class="bi-file-plus" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
title="Delete Preset"
|
||||||
|
data-bs-toggle="modal"
|
||||||
|
data-bs-target="#deleteModal"
|
||||||
|
>
|
||||||
|
<i class="bi-file-minus" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</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>
|
<form @submit.prevent="submitMessage">
|
||||||
<b-col>
|
<textarea class="form-control message" v-model="form.text" rows="7" placeholder="Message" />
|
||||||
<b-form-group>
|
|
||||||
<b-form-input
|
|
||||||
id="input-1"
|
|
||||||
v-model="form.x"
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
placeholder="X"
|
|
||||||
/>
|
|
||||||
</b-form-group>
|
|
||||||
|
|
||||||
<b-form-group>
|
<div class="row mt-3">
|
||||||
<b-form-input
|
<div class="col">
|
||||||
id="input-2"
|
<input
|
||||||
v-model="form.y"
|
class="form-control mt-1"
|
||||||
type="text"
|
v-model="form.x"
|
||||||
required
|
type="text"
|
||||||
placeholder="Y"
|
title="X Axis"
|
||||||
/>
|
placeholder="X"
|
||||||
</b-form-group>
|
required
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
class="form-control mt-2"
|
||||||
|
v-model="form.y"
|
||||||
|
type="text"
|
||||||
|
title="Y Axis"
|
||||||
|
placeholder="Y"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
<b-row>
|
<div class="row mt-2">
|
||||||
<b-col>
|
<div class="col">
|
||||||
<b-form-group
|
<label for="input-size">Size</label>
|
||||||
label="Size"
|
<input
|
||||||
label-for="input-3"
|
id="input-size"
|
||||||
>
|
class="form-control mt-2"
|
||||||
<b-form-input
|
v-model="form.fontSize"
|
||||||
id="input-3"
|
type="number"
|
||||||
v-model="form.fontSize"
|
required
|
||||||
type="number"
|
/>
|
||||||
required
|
</div>
|
||||||
value="24"
|
<div class="col">
|
||||||
/>
|
<label for="input-spacing">Spacing</label>
|
||||||
</b-form-group>
|
<input
|
||||||
</b-col>
|
id="input-spacing"
|
||||||
<b-col>
|
class="form-control mt-2"
|
||||||
<b-form-group
|
v-model="form.fontSpacing"
|
||||||
label="Spacing"
|
type="number"
|
||||||
label-for="input-4"
|
required
|
||||||
>
|
/>
|
||||||
<b-form-input
|
</div>
|
||||||
id="input-4"
|
</div>
|
||||||
v-model="form.fontSpacing"
|
|
||||||
type="number"
|
|
||||||
required
|
|
||||||
value="4"
|
|
||||||
/>
|
|
||||||
</b-form-group>
|
|
||||||
</b-col>
|
|
||||||
</b-row>
|
|
||||||
|
|
||||||
<b-row>
|
<div class="row mt-2">
|
||||||
<b-col>
|
<div class="col">
|
||||||
<b-form-group
|
<label for="input-color">Font Color</label>
|
||||||
label="Font Color"
|
<input
|
||||||
label-for="input-5"
|
id="input-color"
|
||||||
>
|
class="form-control mt-2"
|
||||||
<b-form-input
|
v-model="form.fontColor"
|
||||||
id="input-5"
|
type="color"
|
||||||
v-model="form.fontColor"
|
required
|
||||||
type="color"
|
/>
|
||||||
required
|
</div>
|
||||||
/>
|
<div class="col">
|
||||||
</b-form-group>
|
<label for="input-alpha">Font Alpha</label>
|
||||||
</b-col>
|
<input
|
||||||
<b-col>
|
id="input-alpha"
|
||||||
<b-form-group
|
class="form-control mt-2"
|
||||||
label="Font Alpha"
|
v-model="form.fontAlpha"
|
||||||
label-for="input-6"
|
type="number"
|
||||||
>
|
min="0"
|
||||||
<b-form-input
|
max="1"
|
||||||
id="input-6"
|
step="0.01"
|
||||||
v-model="form.fontAlpha"
|
/>
|
||||||
type="number"
|
</div>
|
||||||
min="0"
|
</div>
|
||||||
max="1"
|
</div>
|
||||||
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>
|
<div class="col">
|
||||||
<b-col>
|
<div class="form-check">
|
||||||
<b-form-group
|
<input id="input-box" type="checkbox" class="form-check-input" v-model="form.showBox" />
|
||||||
label="Box Color"
|
<label for="input-box" class="form-check-label">Show Box</label>
|
||||||
label-for="input-7"
|
</div>
|
||||||
>
|
|
||||||
<b-form-input
|
<div class="row">
|
||||||
id="input-7"
|
<div class="col">
|
||||||
v-model="form.boxColor"
|
<label for="input-box-color">Box Color</label>
|
||||||
type="color"
|
<input
|
||||||
required
|
id="input-box-color"
|
||||||
/>
|
class="form-control mt-2"
|
||||||
</b-form-group>
|
v-model="form.boxColor"
|
||||||
</b-col>
|
type="color"
|
||||||
<b-col>
|
required
|
||||||
<b-form-group
|
/>
|
||||||
label="Box Alpha"
|
</div>
|
||||||
label-for="input-8"
|
<div class="col">
|
||||||
>
|
<label for="input-box-alpha" class="form-check-label">Box Alpha</label>
|
||||||
<b-form-input
|
<input
|
||||||
id="input-8"
|
id="input-box-alpha"
|
||||||
v-model="form.boxAlpha"
|
class="form-control mt-2"
|
||||||
type="number"
|
v-model="form.boxAlpha"
|
||||||
min="0"
|
type="number"
|
||||||
max="1"
|
min="0"
|
||||||
step="0.01"
|
max="1"
|
||||||
/>
|
step="0.01"
|
||||||
</b-form-group>
|
/>
|
||||||
</b-col>
|
</div>
|
||||||
</b-row>
|
<label for="input-border-w" class="form-check-label">Border Width</label>
|
||||||
<b-form-group
|
<input
|
||||||
label="Border Width"
|
id="input-border-w"
|
||||||
label-for="input-9"
|
class="form-control mt-2"
|
||||||
>
|
|
||||||
<b-form-input
|
|
||||||
id="input-9"
|
|
||||||
v-model="form.border"
|
v-model="form.border"
|
||||||
type="number"
|
type="number"
|
||||||
required
|
required
|
||||||
value="4"
|
|
||||||
/>
|
/>
|
||||||
</b-form-group>
|
<label for="input-overall-alpha" class="form-check-label mt-2">Overall Alpha</label>
|
||||||
</b-col>
|
<input
|
||||||
</b-row>
|
id="input-overall-alpha"
|
||||||
|
class="form-control mt-2"
|
||||||
|
v-model="form.overallAlpha"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<b-form-group
|
<div class="row">
|
||||||
label="Overall Alpha"
|
<div class="col sub-btn">
|
||||||
label-for="input-10"
|
<button class="btn btn-primary send-btn" type="submit">Send</button>
|
||||||
>
|
</div>
|
||||||
<b-form-input
|
<div class="col">
|
||||||
id="input-10"
|
<div
|
||||||
v-model="form.overallAlpha"
|
v-if="indexStore.showAlert"
|
||||||
type="text"
|
class="alert show alert-dismissible fade login-alert"
|
||||||
required
|
:class="indexStore.alertVariant"
|
||||||
value="1"
|
role="alert"
|
||||||
/>
|
>
|
||||||
</b-form-group>
|
{{ indexStore.alertMsg }}
|
||||||
|
<button
|
||||||
<b-row>
|
type="button"
|
||||||
<b-col class="sub-btn">
|
class="btn-close"
|
||||||
<b-button type="submit" class="send-btn" variant="primary">
|
data-bs-dismiss="alert"
|
||||||
Send
|
aria-label="Close"
|
||||||
</b-button>
|
@click="indexStore.resetAlert()"
|
||||||
</b-col>
|
></button>
|
||||||
<b-col>
|
</div>
|
||||||
<b-alert variant="success" :show="success" dismissible @dismissed="success=false">
|
</div>
|
||||||
Sending success...
|
</div>
|
||||||
</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>
|
</form>
|
||||||
</b-modal>
|
</div>
|
||||||
<b-modal
|
|
||||||
id="delete-modal"
|
<div class="modal fade" id="createModal" tabindex="-1" aria-labelledby="createModalLabel" aria-hidden="true">
|
||||||
ref="delete-modal"
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
title="Delete Preset"
|
<div class="modal-content">
|
||||||
@ok="handleDelete"
|
<div class="modal-header">
|
||||||
>
|
<h1 class="modal-title fs-5" id="createModalLabel">New Preset</h1>
|
||||||
<strong>Delete: "{{ selected }}"?</strong>
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cancel"></button>
|
||||||
</b-modal>
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="preset-name" class="col-form-label">Name:</label>
|
||||||
|
<input type="text" class="form-control" id="preset-name" v-model="newPresetName" />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary"
|
||||||
|
@click="createNewPreset()"
|
||||||
|
data-bs-dismiss="modal"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h1 class="modal-title fs-5" id="deleteModalLabel">Delete Preset</h1>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cancel"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">Are you sure that you want to delete preset: "{{ selected }}"?</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" @click="deletePreset()" data-bs-dismiss="modal">
|
||||||
|
Ok
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
import { mapState } from 'vuex'
|
import { useAuth } from '~/stores/auth'
|
||||||
import Menu from '@/components/Menu.vue'
|
import { useConfig } from '~/stores/config'
|
||||||
|
import { useIndex } from '~/stores/index'
|
||||||
|
|
||||||
export default {
|
const authStore = useAuth()
|
||||||
name: 'Media',
|
const configStore = useConfig()
|
||||||
|
const indexStore = useIndex()
|
||||||
|
const { numberToHex, hexToNumber } = stringFormatter()
|
||||||
|
const contentType = { 'content-type': 'application/json;charset=UTF-8' }
|
||||||
|
|
||||||
components: {
|
definePageMeta({
|
||||||
Menu
|
middleware: ['auth'],
|
||||||
},
|
})
|
||||||
|
|
||||||
middleware: 'auth',
|
useHead({
|
||||||
|
title: 'Messages | ffplayout'
|
||||||
|
})
|
||||||
|
|
||||||
data () {
|
interface Preset {
|
||||||
return {
|
id: number
|
||||||
form: {
|
name: string
|
||||||
id: 0,
|
text: string
|
||||||
name: '',
|
x: string
|
||||||
text: '',
|
y: string
|
||||||
x: '0',
|
fontSize: number
|
||||||
y: '0',
|
fontSpacing: number
|
||||||
fontSize: 24,
|
fontColor: string
|
||||||
fontSpacing: 4,
|
fontAlpha: number
|
||||||
fontColor: '#ffffff',
|
showBox: boolean
|
||||||
fontAlpha: 1.0,
|
boxColor: string
|
||||||
showBox: true,
|
boxAlpha: number
|
||||||
boxColor: '#000000',
|
border: number
|
||||||
boxAlpha: 0.8,
|
overallAlpha: number
|
||||||
border: 4,
|
}
|
||||||
overallAlpha: 1
|
|
||||||
},
|
|
||||||
selected: null,
|
|
||||||
newPresetName: '',
|
|
||||||
presets: [],
|
|
||||||
success: false,
|
|
||||||
failed: false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
interface PresetName {
|
||||||
...mapState('config', ['configID', 'configGui'])
|
name: string
|
||||||
},
|
value: number
|
||||||
|
}
|
||||||
|
|
||||||
watch: {
|
const form = ref({
|
||||||
selected (index) {
|
id: 0,
|
||||||
this.getPreset(index)
|
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',
|
||||||
|
})
|
||||||
|
|
||||||
created () {
|
const selected = ref(null)
|
||||||
this.getPreset(null)
|
const newPresetName = ref('')
|
||||||
},
|
const presets = ref([] as PresetName[])
|
||||||
|
|
||||||
methods: {
|
onMounted(() => {
|
||||||
decToHex (num) {
|
getPreset(-1)
|
||||||
return '0x' + Math.round(num * 255).toString(16)
|
})
|
||||||
},
|
|
||||||
|
|
||||||
hexToDec (num) {
|
onBeforeUnmount(() => {
|
||||||
return (parseFloat(parseInt(num, 16)) / 255).toFixed(2)
|
indexStore.resetAlert()
|
||||||
},
|
})
|
||||||
|
|
||||||
async getPreset (index) {
|
async function getPreset(index: number) {
|
||||||
const response = await this.$axios.get(`api/presets/${this.configGui[this.configID].id}`)
|
fetch(`api/presets/${configStore.configGui[configStore.configID].id}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: authStore.authHeader,
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (index === -1) {
|
||||||
|
presets.value = [{ value: -1, name: '' }]
|
||||||
|
|
||||||
if (response.data && index === null) {
|
for (let i = 0; i < data.length; i++) {
|
||||||
this.presets = []
|
const elem = data[i]
|
||||||
for (let index = 0; index < response.data.length; index++) {
|
presets.value.push({ value: i, name: elem.name })
|
||||||
const elem = response.data[index]
|
|
||||||
this.presets.push({ value: index, text: elem.name })
|
|
||||||
}
|
}
|
||||||
} else if (response.data) {
|
|
||||||
const fColor = response.data[index].fontcolor.split('@')
|
|
||||||
const bColor = response.data[index].boxcolor.split('@')
|
|
||||||
|
|
||||||
this.form = {
|
form.value = {
|
||||||
id: response.data[index].id,
|
id: 0,
|
||||||
name: response.data[index].name,
|
name: '',
|
||||||
text: response.data[index].text,
|
text: '',
|
||||||
x: response.data[index].x,
|
x: '0',
|
||||||
y: response.data[index].y,
|
y: '0',
|
||||||
fontSize: response.data[index].fontsize,
|
fontSize: 24,
|
||||||
fontSpacing: response.data[index].line_spacing,
|
fontSpacing: 4,
|
||||||
|
fontColor: '#ffffff',
|
||||||
|
fontAlpha: 1.0,
|
||||||
|
showBox: true,
|
||||||
|
boxColor: '#000000',
|
||||||
|
boxAlpha: 0.8,
|
||||||
|
border: 4,
|
||||||
|
overallAlpha: '1',
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const fColor = data[index].fontcolor.split('@')
|
||||||
|
const bColor = data[index].boxcolor.split('@')
|
||||||
|
|
||||||
|
form.value = {
|
||||||
|
id: data[index].id,
|
||||||
|
name: data[index].name,
|
||||||
|
text: data[index].text,
|
||||||
|
x: data[index].x,
|
||||||
|
y: data[index].y,
|
||||||
|
fontSize: data[index].fontsize,
|
||||||
|
fontSpacing: data[index].line_spacing,
|
||||||
fontColor: fColor[0],
|
fontColor: fColor[0],
|
||||||
fontAlpha: (fColor[1]) ? this.hexToDec(fColor[1]) : 1.0,
|
fontAlpha: fColor[1] ? hexToNumber(fColor[1]) : 1.0,
|
||||||
showBox: response.data[index].box,
|
showBox: data[index].box === '1' ? true : false,
|
||||||
boxColor: bColor[0],
|
boxColor: bColor[0],
|
||||||
boxAlpha: (bColor[1]) ? this.hexToDec(bColor[1]) : 1.0,
|
boxAlpha: bColor[1] ? hexToNumber(bColor[1]) : 1.0,
|
||||||
border: response.data[index].boxborderw,
|
border: data[index].boxborderw,
|
||||||
overallAlpha: response.data[index].alpha
|
overallAlpha: data[index].alpha,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
openDialog () {
|
}
|
||||||
this.$bvModal.show('create-modal')
|
|
||||||
},
|
|
||||||
handleCreate (bvModalEvt) {
|
|
||||||
// Prevent modal from closing
|
|
||||||
bvModalEvt.preventDefault()
|
|
||||||
// Trigger submit handler
|
|
||||||
this.createPreset()
|
|
||||||
},
|
|
||||||
async createPreset () {
|
|
||||||
const preset = {
|
|
||||||
name: this.newPresetName,
|
|
||||||
text: this.form.text,
|
|
||||||
x: this.form.x.toString(),
|
|
||||||
y: this.form.y.toString(),
|
|
||||||
fontsize: this.form.fontSize.toString(),
|
|
||||||
line_spacing: this.form.fontSpacing.toString(),
|
|
||||||
fontcolor: (this.form.fontAlpha === 1) ? this.form.fontColor : this.form.fontColor + '@' + this.decToHex(this.form.fontAlpha),
|
|
||||||
box: (this.form.showBox) ? '1' : '0',
|
|
||||||
boxcolor: (this.form.boxAlpha === 1) ? this.form.boxColor : this.form.boxColor + '@' + this.decToHex(this.form.boxAlpha),
|
|
||||||
boxborderw: this.form.border.toString(),
|
|
||||||
alpha: this.form.overallAlpha.toString(),
|
|
||||||
channel_id: this.configGui[this.configID].id
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.$axios.post('api/presets/', preset)
|
function onChange(event: any) {
|
||||||
|
selected.value = event.target.value
|
||||||
|
|
||||||
if (response.status === 200) {
|
getPreset(event.target.selectedIndex - 1)
|
||||||
this.success = true
|
}
|
||||||
this.getPreset(null)
|
|
||||||
} else {
|
|
||||||
this.failed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$nextTick(() => {
|
async function savePreset() {
|
||||||
this.$bvModal.hide('create-modal')
|
if (selected.value) {
|
||||||
})
|
const preset = {
|
||||||
},
|
id: form.value.id,
|
||||||
async savePreset () {
|
name: form.value.name,
|
||||||
if (this.selected) {
|
text: form.value.text,
|
||||||
const preset = {
|
x: form.value.x,
|
||||||
id: this.form.id,
|
y: form.value.y,
|
||||||
name: this.form.name,
|
fontsize: form.value.fontSize,
|
||||||
text: this.form.text,
|
line_spacing: form.value.fontSpacing,
|
||||||
x: this.form.x,
|
fontcolor:
|
||||||
y: this.form.y,
|
form.value.fontAlpha === 1
|
||||||
fontsize: this.form.fontSize,
|
? form.value.fontColor
|
||||||
line_spacing: this.form.fontSpacing,
|
: form.value.fontColor + '@' + numberToHex(form.value.fontAlpha),
|
||||||
fontcolor: (this.form.fontAlpha === 1) ? this.form.fontColor : this.form.fontColor + '@' + this.decToHex(this.form.fontAlpha),
|
box: form.value.showBox ? '1' : '0',
|
||||||
box: (this.form.showBox) ? '1' : '0',
|
boxcolor:
|
||||||
boxcolor: (this.form.boxAlpha === 1) ? this.form.boxColor : this.form.boxColor + '@' + this.decToHex(this.form.boxAlpha),
|
form.value.boxAlpha === 1
|
||||||
boxborderw: this.form.border,
|
? form.value.boxColor
|
||||||
alpha: this.form.overallAlpha,
|
: form.value.boxColor + '@' + numberToHex(form.value.boxAlpha),
|
||||||
channel_id: this.configGui[this.configID].id
|
boxborderw: form.value.border,
|
||||||
}
|
alpha: form.value.overallAlpha,
|
||||||
|
channel_id: configStore.configGui[configStore.configID].id,
|
||||||
const response = await this.$axios.put(`api/presets/${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 () {
|
|
||||||
if (this.selected) {
|
|
||||||
await this.$axios.delete(`api/presets/${this.form.id}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$bvModal.hide('delete-modal')
|
|
||||||
this.getPreset(null)
|
|
||||||
},
|
|
||||||
|
|
||||||
async submitMessage () {
|
|
||||||
const obj = {
|
|
||||||
text: this.form.text,
|
|
||||||
x: this.form.x.toString(),
|
|
||||||
y: this.form.y.toString(),
|
|
||||||
fontsize: this.form.fontSize.toString(),
|
|
||||||
line_spacing: this.form.fontSpacing.toString(),
|
|
||||||
fontcolor: this.form.fontColor + '@' + this.decToHex(this.form.fontAlpha),
|
|
||||||
alpha: this.form.overallAlpha.toString(),
|
|
||||||
box: (this.form.showBox) ? '1' : '0',
|
|
||||||
boxcolor: this.form.boxColor + '@' + this.decToHex(this.form.boxAlpha),
|
|
||||||
boxborderw: this.form.border.toString()
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.$axios.post(`api/control/${this.configGui[this.configID].id}/text/`, obj)
|
|
||||||
|
|
||||||
if (response.data && response.status === 200) {
|
|
||||||
this.success = true
|
|
||||||
} else {
|
|
||||||
this.failed = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`api/presets/${form.value.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...contentType, ...authStore.authHeader },
|
||||||
|
body: JSON.stringify(preset),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
indexStore.alertVariant = 'alert-success'
|
||||||
|
indexStore.alertMsg = 'Save Preset done!'
|
||||||
|
} else {
|
||||||
|
indexStore.alertVariant = 'alert-danger'
|
||||||
|
indexStore.alertMsg = 'Save Preset failed!'
|
||||||
|
}
|
||||||
|
|
||||||
|
indexStore.showAlert = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createNewPreset() {
|
||||||
|
const preset = {
|
||||||
|
name: newPresetName.value,
|
||||||
|
text: form.value.text,
|
||||||
|
x: form.value.x.toString(),
|
||||||
|
y: form.value.y.toString(),
|
||||||
|
fontsize: form.value.fontSize.toString(),
|
||||||
|
line_spacing: form.value.fontSpacing.toString(),
|
||||||
|
fontcolor:
|
||||||
|
form.value.fontAlpha === 1
|
||||||
|
? form.value.fontColor
|
||||||
|
: form.value.fontColor + '@' + numberToHex(form.value.fontAlpha),
|
||||||
|
box: form.value.showBox ? '1' : '0',
|
||||||
|
boxcolor:
|
||||||
|
form.value.boxAlpha === 1
|
||||||
|
? form.value.boxColor
|
||||||
|
: form.value.boxColor + '@' + numberToHex(form.value.boxAlpha),
|
||||||
|
boxborderw: form.value.border.toString(),
|
||||||
|
alpha: form.value.overallAlpha.toString(),
|
||||||
|
channel_id: configStore.configGui[configStore.configID].id,
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('api/presets/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...contentType, ...authStore.authHeader },
|
||||||
|
body: JSON.stringify(preset),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
indexStore.alertVariant = 'alert-success'
|
||||||
|
indexStore.alertMsg = 'Save Preset done!'
|
||||||
|
getPreset(-1)
|
||||||
|
} else {
|
||||||
|
indexStore.alertVariant = 'alert-danger'
|
||||||
|
indexStore.alertMsg = 'Save Preset failed!'
|
||||||
|
}
|
||||||
|
|
||||||
|
indexStore.showAlert = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePreset() {
|
||||||
|
if (selected.value && selected.value !== '') {
|
||||||
|
await fetch(`api/presets/${form.value.id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: authStore.authHeader,
|
||||||
|
})
|
||||||
|
|
||||||
|
getPreset(-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitMessage() {
|
||||||
|
const obj = {
|
||||||
|
text: form.value.text,
|
||||||
|
x: form.value.x.toString(),
|
||||||
|
y: form.value.y.toString(),
|
||||||
|
fontsize: form.value.fontSize.toString(),
|
||||||
|
line_spacing: form.value.fontSpacing.toString(),
|
||||||
|
fontcolor: form.value.fontColor + '@' + numberToHex(form.value.fontAlpha),
|
||||||
|
alpha: form.value.overallAlpha.toString(),
|
||||||
|
box: form.value.showBox ? '1' : '0',
|
||||||
|
boxcolor: form.value.boxColor + '@' + numberToHex(form.value.boxAlpha),
|
||||||
|
boxborderw: form.value.border.toString(),
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`api/control/${configStore.configGui[configStore.configID].id}/text/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...contentType, ...authStore.authHeader },
|
||||||
|
body: JSON.stringify(obj),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
indexStore.alertVariant = 'alert-success'
|
||||||
|
indexStore.alertMsg = 'Sending success...'
|
||||||
|
} else {
|
||||||
|
indexStore.alertVariant = 'alert-danger'
|
||||||
|
indexStore.alertMsg = 'Sending failed...'
|
||||||
|
}
|
||||||
|
|
||||||
|
indexStore.showAlert = true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.messege-container {
|
|
||||||
margin-top: 5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.preset-div {
|
.preset-div {
|
||||||
width: 50%;
|
width: 50%;
|
||||||
margin-bottom: 2em;
|
margin-bottom: 2em;
|
||||||
|
1958
pages/player.vue
@ -1,7 +0,0 @@
|
|||||||
# 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).
|
|
@ -1,49 +0,0 @@
|
|||||||
export default function ({ $axios, store, redirect, route }) {
|
|
||||||
$axios.onRequest((config) => {
|
|
||||||
const token = store.state.auth.jwtToken
|
|
||||||
if (token) {
|
|
||||||
config.headers.common.Authorization = `Bearer ${token}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// disable progress on auth
|
|
||||||
if (config.url.includes('auth') || config.url.includes('process') || config.url.includes('current')) {
|
|
||||||
config.progress = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
$axios.interceptors.response.use((response) => {
|
|
||||||
return response
|
|
||||||
}, (error) => {
|
|
||||||
const originalRequest = error.config
|
|
||||||
|
|
||||||
// prevent infinite loop
|
|
||||||
if (error.response.status === 401 && route.path !== '/') {
|
|
||||||
store.commit('auth/REMOVE_TOKEN')
|
|
||||||
redirect('/')
|
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error.response.status === 401 && !originalRequest._retry && !originalRequest.url.includes('auth/token')) {
|
|
||||||
originalRequest._retry = true
|
|
||||||
|
|
||||||
store.commit('auth/REMOVE_TOKEN')
|
|
||||||
store.commit('auth/UPDATE_IS_LOGIN', false)
|
|
||||||
redirect('/')
|
|
||||||
}
|
|
||||||
return Promise.reject(error)
|
|
||||||
})
|
|
||||||
|
|
||||||
$axios.onError((error) => {
|
|
||||||
const code = parseInt(error.response && error.response.status)
|
|
||||||
|
|
||||||
if (code === 401 && route.path !== '/') {
|
|
||||||
redirect('/')
|
|
||||||
} else if (code !== 401 && code !== 409 && code !== 503 !== (code === 408 && route.path.includes('/media/current'))) {
|
|
||||||
store.commit('UPDATE_VARIANT', 'danger')
|
|
||||||
store.commit('UPDATE_SHOW_ERROR_ALERT', true)
|
|
||||||
store.commit('UPDATE_ERROR_ALERT_MESSAGE', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return error.response
|
|
||||||
})
|
|
||||||
}
|
|
6
plugins/bootstrap.client.js
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import bootstrap from 'bootstrap/dist/js/bootstrap.bundle.js'
|
||||||
|
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
nuxtApp.provide('bootstrap', bootstrap)
|
||||||
|
})
|
21
plugins/dayjs.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import * as dayjs from 'dayjs'
|
||||||
|
import utc from 'dayjs/plugin/utc.js'
|
||||||
|
import timezone from 'dayjs/plugin/timezone.js'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
dayjs.extend(utc)
|
||||||
|
dayjs.extend(timezone)
|
||||||
|
nuxtApp.provide('dayjs', dayjs)
|
||||||
|
})
|
||||||
|
|
||||||
|
// declare module '#app' {
|
||||||
|
// interface NuxtApp {
|
||||||
|
// $dayjs: dayjs.Dayjs
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// declare module '@vue/runtime-core' {
|
||||||
|
// interface ComponentCustomProperties {
|
||||||
|
// $dayjs(date?: dayjs.ConfigType): dayjs.Dayjs
|
||||||
|
// }
|
||||||
|
// }
|
@ -1,6 +0,0 @@
|
|||||||
import Vue from 'vue'
|
|
||||||
import draggable from 'vuedraggable'
|
|
||||||
|
|
||||||
Vue.use(draggable)
|
|
||||||
/* eslint-disable-next-line */
|
|
||||||
Vue.component('draggable', draggable)
|
|
@ -1,20 +0,0 @@
|
|||||||
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 ''
|
|
||||||
}
|
|
||||||
})
|
|
@ -1,62 +0,0 @@
|
|||||||
export default ({ app }, inject) => {
|
|
||||||
inject('processPlaylist', (dayStart, length, list, forSave) => {
|
|
||||||
if (!dayStart) {
|
|
||||||
dayStart = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
let begin = dayStart
|
|
||||||
const newList = []
|
|
||||||
|
|
||||||
for (const item of list) {
|
|
||||||
item.begin = begin
|
|
||||||
|
|
||||||
if (!item.audio) {
|
|
||||||
delete item.audio
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.category) {
|
|
||||||
delete item.category
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.custom_filter) {
|
|
||||||
delete item.custom_filter
|
|
||||||
}
|
|
||||||
|
|
||||||
if (begin + (item.out - item.in) > length + dayStart) {
|
|
||||||
item.class = 'overLength'
|
|
||||||
|
|
||||||
if (forSave) {
|
|
||||||
item.out = (length + dayStart) - begin
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (forSave && begin >= length + dayStart) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
newList.push(item)
|
|
||||||
|
|
||||||
begin += (item.out - item.in)
|
|
||||||
}
|
|
||||||
|
|
||||||
return newList
|
|
||||||
})
|
|
||||||
|
|
||||||
// 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
|
|
||||||
})
|
|
||||||
}
|
|
@ -1,7 +0,0 @@
|
|||||||
import Vue from 'vue'
|
|
||||||
import Loading from 'vue-loading-overlay'
|
|
||||||
import 'vue-loading-overlay/dist/vue-loading.css'
|
|
||||||
|
|
||||||
Vue.use(Loading)
|
|
||||||
/* eslint-disable-next-line */
|
|
||||||
Vue.component('loading', Loading)
|
|
5
plugins/lodash.client.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import _ from 'lodash'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
nuxtApp.provide('_', _)
|
||||||
|
})
|
@ -1,4 +0,0 @@
|
|||||||
import Vue from 'vue'
|
|
||||||
import _ from 'lodash'
|
|
||||||
|
|
||||||
Object.defineProperty(Vue.prototype, '$_', { value: _ })
|
|
@ -1,3 +0,0 @@
|
|||||||
export default async (context) => {
|
|
||||||
await context.store.dispatch('config/nuxtClientInit', context)
|
|
||||||
}
|
|
6
plugins/sortable.client.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { defineNuxtPlugin } from '#app'
|
||||||
|
import { Sortable } from 'sortablejs-vue3'
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
nuxtApp.vueApp.component('Sortable', Sortable)
|
||||||
|
})
|
@ -1,7 +0,0 @@
|
|||||||
import Vue from 'vue'
|
|
||||||
import { Splitpanes, Pane } from 'splitpanes'
|
|
||||||
import 'splitpanes/dist/splitpanes.css'
|
|
||||||
|
|
||||||
/* eslint-disable */
|
|
||||||
Vue.component('splitpanes', Splitpanes)
|
|
||||||
Vue.component('pane', Pane)
|
|
@ -1,5 +0,0 @@
|
|||||||
import Vue from 'vue'
|
|
||||||
import VideoPlayer from '@/components/VideoPlayer.vue'
|
|
||||||
|
|
||||||
/* eslint-disable-next-line */
|
|
||||||
Vue.component('video-player', VideoPlayer)
|
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
@ -1,3 +0,0 @@
|
|||||||
This folder can be used for HLS streaming playlist.
|
|
||||||
|
|
||||||
In production it is recommend to serve ***.m3u8** and ***.ts** with nginx or another web server.
|
|
@ -1,10 +0,0 @@
|
|||||||
# 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).
|
|
@ -1,67 +0,0 @@
|
|||||||
/* eslint-disable camelcase */
|
|
||||||
import jwt_decode from 'jwt-decode'
|
|
||||||
|
|
||||||
export const state = () => ({
|
|
||||||
jwtToken: '',
|
|
||||||
isLogin: false
|
|
||||||
})
|
|
||||||
|
|
||||||
// mutate values in state
|
|
||||||
export const mutations = {
|
|
||||||
UPDATE_TOKEN (state, obj) {
|
|
||||||
state.jwtToken = obj.token
|
|
||||||
this.$cookies.set('token', obj.token, {
|
|
||||||
path: '/',
|
|
||||||
maxAge: 60 * 60 * 24 * 365,
|
|
||||||
sameSite: 'lax'
|
|
||||||
})
|
|
||||||
},
|
|
||||||
UPDATE_IS_LOGIN (state, bool) {
|
|
||||||
state.isLogin = bool
|
|
||||||
},
|
|
||||||
REMOVE_TOKEN (state) {
|
|
||||||
this.$cookies.remove('token')
|
|
||||||
state.jwtToken = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const actions = {
|
|
||||||
async obtainToken ({ commit }, { username, password }) {
|
|
||||||
const payload = {
|
|
||||||
username,
|
|
||||||
password
|
|
||||||
}
|
|
||||||
let code = null
|
|
||||||
await this.$axios.post('auth/login/', payload)
|
|
||||||
.then((response) => {
|
|
||||||
commit('UPDATE_TOKEN', { token: response.data.user.token })
|
|
||||||
commit('UPDATE_IS_LOGIN', true)
|
|
||||||
code = response.status
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
code = error.response.status
|
|
||||||
})
|
|
||||||
|
|
||||||
return code
|
|
||||||
},
|
|
||||||
|
|
||||||
inspectToken ({ commit, state }) {
|
|
||||||
const token = this.$cookies.get('token')
|
|
||||||
|
|
||||||
if (token) {
|
|
||||||
commit('UPDATE_TOKEN', { token })
|
|
||||||
const decoded_token = jwt_decode(token)
|
|
||||||
const timestamp = Date.now() / 1000
|
|
||||||
const expire_token = decoded_token.exp
|
|
||||||
|
|
||||||
if (state.jwtToken && expire_token - timestamp > 15) {
|
|
||||||
commit('UPDATE_IS_LOGIN', true)
|
|
||||||
} 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
158
store/config.js
@ -1,158 +0,0 @@
|
|||||||
import _ from 'lodash'
|
|
||||||
|
|
||||||
export const state = () => ({
|
|
||||||
configID: 0,
|
|
||||||
configCount: 0,
|
|
||||||
configGui: null,
|
|
||||||
configGuiRaw: null,
|
|
||||||
startInSec: 0,
|
|
||||||
playlistLength: 86400.0,
|
|
||||||
configPlayout: {},
|
|
||||||
currentUser: null,
|
|
||||||
configUser: null,
|
|
||||||
utcOffset: 0
|
|
||||||
})
|
|
||||||
|
|
||||||
export const mutations = {
|
|
||||||
UPDATE_CONFIG_ID (state, id) {
|
|
||||||
state.configID = id
|
|
||||||
},
|
|
||||||
UPDATE_CONFIG_COUNT (state, count) {
|
|
||||||
state.configCount = count
|
|
||||||
},
|
|
||||||
UPDATE_GUI_CONFIG (state, config) {
|
|
||||||
state.configGui = config
|
|
||||||
},
|
|
||||||
UPDATE_GUI_CONFIG_RAW (state, config) {
|
|
||||||
state.configGuiRaw = config
|
|
||||||
},
|
|
||||||
UPDATE_START_TIME (state, sec) {
|
|
||||||
state.startInSec = sec
|
|
||||||
},
|
|
||||||
UPDATE_PLAYLIST_LENGTH (state, sec) {
|
|
||||||
state.playlistLength = sec
|
|
||||||
},
|
|
||||||
UPDATE_PLAYOUT_CONFIG (state, config) {
|
|
||||||
state.configPlayout = config
|
|
||||||
},
|
|
||||||
SET_CURRENT_USER (state, user) {
|
|
||||||
state.currentUser = user
|
|
||||||
},
|
|
||||||
UPDATE_USER_CONFIG (state, config) {
|
|
||||||
state.configUser = config
|
|
||||||
},
|
|
||||||
UPDATE_UTC_OFFSET (state, offset) {
|
|
||||||
state.utcOffset = offset
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const actions = {
|
|
||||||
async nuxtClientInit ({ dispatch, rootState }) {
|
|
||||||
await dispatch('auth/inspectToken', null, { root: true })
|
|
||||||
if (rootState.auth.isLogin) {
|
|
||||||
await dispatch('getGuiConfig')
|
|
||||||
await dispatch('getPlayoutConfig')
|
|
||||||
await dispatch('getUserConfig')
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async getGuiConfig ({ commit }) {
|
|
||||||
const response = await this.$axios.get('api/channels')
|
|
||||||
|
|
||||||
if (response.data) {
|
|
||||||
for (const data of response.data) {
|
|
||||||
if (data.extra_extensions) {
|
|
||||||
data.extra_extensions = data.extra_extensions.split(',')
|
|
||||||
} else {
|
|
||||||
data.extra_extensions = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
commit('UPDATE_UTC_OFFSET', response.data[0].utc_offset)
|
|
||||||
commit('UPDATE_GUI_CONFIG', response.data)
|
|
||||||
commit('UPDATE_GUI_CONFIG_RAW', _.cloneDeep(response.data))
|
|
||||||
commit('UPDATE_CONFIG_COUNT', response.data.length)
|
|
||||||
} else {
|
|
||||||
commit('UPDATE_GUI_CONFIG', [{
|
|
||||||
id: 1,
|
|
||||||
channel: '',
|
|
||||||
preview_url: '',
|
|
||||||
playout_config: '',
|
|
||||||
extra_extensions: [],
|
|
||||||
utc_offset: 0
|
|
||||||
}])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async setGuiConfig ({ commit, state, dispatch }, obj) {
|
|
||||||
const stringObj = _.cloneDeep(obj)
|
|
||||||
stringObj.extra_extensions = stringObj.extra_extensions.join(',')
|
|
||||||
let response
|
|
||||||
|
|
||||||
if (state.configGuiRaw.some(e => e.id === stringObj.id)) {
|
|
||||||
response = await this.$axios.patch(`api/channel/${obj.id}`, stringObj)
|
|
||||||
} else {
|
|
||||||
response = await this.$axios.post('api/channel/', stringObj)
|
|
||||||
const guiConfigs = []
|
|
||||||
|
|
||||||
for (const obj of state.configGui) {
|
|
||||||
if (obj.name === stringObj.name) {
|
|
||||||
response.data.extra_extensions = response.data.extra_extensions.split(',')
|
|
||||||
guiConfigs.push(response.data)
|
|
||||||
} else {
|
|
||||||
guiConfigs.push(obj)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
commit('UPDATE_GUI_CONFIG', guiConfigs)
|
|
||||||
commit('UPDATE_GUI_CONFIG_RAW', _.cloneDeep(guiConfigs))
|
|
||||||
commit('UPDATE_CONFIG_COUNT', guiConfigs.length)
|
|
||||||
|
|
||||||
await dispatch('getPlayoutConfig')
|
|
||||||
}
|
|
||||||
|
|
||||||
return response
|
|
||||||
},
|
|
||||||
|
|
||||||
async getPlayoutConfig ({ commit, state, rootState }) {
|
|
||||||
const channel = state.configGui[state.configID].id
|
|
||||||
const response = await this.$axios.get(`api/playout/config/${channel}`)
|
|
||||||
|
|
||||||
if (response.data) {
|
|
||||||
if (response.data.playlist.day_start) {
|
|
||||||
commit('UPDATE_START_TIME', this.$timeToSeconds(response.data.playlist.day_start))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.data.playlist.length) {
|
|
||||||
commit('UPDATE_PLAYLIST_LENGTH', this.$timeToSeconds(response.data.playlist.length))
|
|
||||||
}
|
|
||||||
|
|
||||||
commit('UPDATE_PLAYOUT_CONFIG', response.data)
|
|
||||||
} else {
|
|
||||||
rootState.showErrorAlert = true
|
|
||||||
rootState.ErrorAlertMessage = 'No playout config found!'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async setPlayoutConfig ({ state }, obj) {
|
|
||||||
const channel = state.configGui[state.configID].id
|
|
||||||
const update = await this.$axios.put(`api/playout/config/${channel}`, obj)
|
|
||||||
return update
|
|
||||||
},
|
|
||||||
|
|
||||||
async getUserConfig ({ commit }) {
|
|
||||||
const user = await this.$axios.get('api/user')
|
|
||||||
|
|
||||||
if (user.data) {
|
|
||||||
commit('SET_CURRENT_USER', user.data.username)
|
|
||||||
}
|
|
||||||
if (user.data) {
|
|
||||||
commit('UPDATE_USER_CONFIG', user.data)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async setUserConfig (obj) {
|
|
||||||
const update = await this.$axios.put(`api/user/${obj.id}`, obj)
|
|
||||||
return update
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,19 +0,0 @@
|
|||||||
export const strict = false
|
|
||||||
|
|
||||||
export const state = () => ({
|
|
||||||
showErrorAlert: false,
|
|
||||||
variant: 'danger',
|
|
||||||
ErrorAlertMessage: ''
|
|
||||||
})
|
|
||||||
|
|
||||||
export const mutations = {
|
|
||||||
UPDATE_SHOW_ERROR_ALERT (state, show) {
|
|
||||||
state.showErrorAlert = show
|
|
||||||
},
|
|
||||||
UPDATE_VARIANT (state, variant) {
|
|
||||||
state.variant = variant
|
|
||||||
},
|
|
||||||
UPDATE_ERROR_ALERT_MESSAGE (state, message) {
|
|
||||||
state.ErrorAlertMessage = message
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,49 +0,0 @@
|
|||||||
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, rootState }, { path }) {
|
|
||||||
const crumbs = []
|
|
||||||
let root = '/'
|
|
||||||
const channel = rootState.config.configGui[rootState.config.configID].id
|
|
||||||
const response = await this.$axios.post(
|
|
||||||
`api/file/${channel}/browse/`, { source: path })
|
|
||||||
|
|
||||||
if (response.data) {
|
|
||||||
const pathStr = 'Home/' + response.data.source
|
|
||||||
const pathArr = pathStr.split('/')
|
|
||||||
|
|
||||||
if (path) {
|
|
||||||
for (const crumb of pathArr) {
|
|
||||||
if (crumb === 'Home') {
|
|
||||||
crumbs.push({ text: crumb, path: root })
|
|
||||||
} else if (crumb) {
|
|
||||||
root += crumb + '/'
|
|
||||||
crumbs.push({ text: crumb, path: root })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
crumbs.push({ text: 'Home', path: '' })
|
|
||||||
}
|
|
||||||
|
|
||||||
commit('UPDATE_CURRENT_PATH', path)
|
|
||||||
commit('UPDATE_CRUMBS', crumbs)
|
|
||||||
commit('UPDATE_FOLDER_TREE', response.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,108 +0,0 @@
|
|||||||
import _ from 'lodash'
|
|
||||||
|
|
||||||
export const state = () => ({
|
|
||||||
playlist: null,
|
|
||||||
playlistToday: [],
|
|
||||||
progressValue: 0,
|
|
||||||
currentClip: 'No clips is playing',
|
|
||||||
currentClipIndex: null,
|
|
||||||
currentClipStart: null,
|
|
||||||
currentClipDuration: null,
|
|
||||||
currentClipIn: null,
|
|
||||||
currentClipOut: null,
|
|
||||||
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
|
|
||||||
},
|
|
||||||
SET_PROGRESS_VALUE (state, value) {
|
|
||||||
state.progressValue = value
|
|
||||||
},
|
|
||||||
SET_CURRENT_CLIP (state, clip) {
|
|
||||||
state.currentClip = clip
|
|
||||||
},
|
|
||||||
SET_CURRENT_CLIP_INDEX (state, index) {
|
|
||||||
state.currentClipIndex = index
|
|
||||||
},
|
|
||||||
SET_CURRENT_CLIP_START (state, start) {
|
|
||||||
state.currentClipStart = start
|
|
||||||
},
|
|
||||||
SET_CURRENT_CLIP_DURATION (state, dur) {
|
|
||||||
state.currentClipDuration = dur
|
|
||||||
},
|
|
||||||
SET_CURRENT_CLIP_IN (state, _in) {
|
|
||||||
state.currentClipIn = _in
|
|
||||||
},
|
|
||||||
SET_CURRENT_CLIP_OUT (state, out) {
|
|
||||||
state.currentClipOut = out
|
|
||||||
},
|
|
||||||
SET_TIME (state, time) {
|
|
||||||
state.timeStr = time
|
|
||||||
},
|
|
||||||
SET_TIME_LEFT (state, time) {
|
|
||||||
state.timeLeft = time
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const actions = {
|
|
||||||
async getPlaylist ({ commit, rootState }, { date }) {
|
|
||||||
const timeInSec = this.$timeToSeconds(this.$dayjs().utcOffset(rootState.config.utcOffset).format('HH:mm:ss'))
|
|
||||||
const channel = rootState.config.configGui[rootState.config.configID].id
|
|
||||||
let dateToday = this.$dayjs().utcOffset(rootState.config.utcOffset).format('YYYY-MM-DD')
|
|
||||||
|
|
||||||
if (rootState.config.startInSec > timeInSec) {
|
|
||||||
dateToday = this.$dayjs(dateToday).utcOffset(rootState.config.utcOffset).subtract(1, 'day').format('YYYY-MM-DD')
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.$axios.get(`api/playlist/${channel}?date=${date}`)
|
|
||||||
|
|
||||||
if (response.data && response.data.program) {
|
|
||||||
commit('UPDATE_PLAYLIST', this.$processPlaylist(rootState.config.startInSec, rootState.config.playlistLength, response.data.program, false))
|
|
||||||
|
|
||||||
if (date === dateToday) {
|
|
||||||
commit('UPDATE_TODAYS_PLAYLIST', _.cloneDeep(response.data.program))
|
|
||||||
} else {
|
|
||||||
commit('SET_CURRENT_CLIP_INDEX', null)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
commit('UPDATE_PLAYLIST', [])
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async playoutStat ({ commit, state, rootState }) {
|
|
||||||
const channel = rootState.config.configGui[rootState.config.configID].id
|
|
||||||
const time = this.$dayjs().utcOffset(rootState.config.utcOffset).format('HH:mm:ss')
|
|
||||||
let timeSec = this.$timeToSeconds(time)
|
|
||||||
|
|
||||||
commit('SET_TIME', time)
|
|
||||||
|
|
||||||
if (timeSec < rootState.config.startInSec) {
|
|
||||||
timeSec += rootState.config.playlistLength
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timeSec < state.currentClipStart) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await this.$axios.get(`api/control/${channel}/media/current`)
|
|
||||||
|
|
||||||
if (response.data && response.data.result && response.data.result.played_sec) {
|
|
||||||
const obj = response.data.result
|
|
||||||
const progValue = obj.played_sec * 100 / obj.current_media.out
|
|
||||||
commit('SET_PROGRESS_VALUE', progValue)
|
|
||||||
commit('SET_CURRENT_CLIP', obj.current_media.source)
|
|
||||||
commit('SET_CURRENT_CLIP_INDEX', obj.index)
|
|
||||||
commit('SET_CURRENT_CLIP_START', obj.start_sec)
|
|
||||||
commit('SET_CURRENT_CLIP_DURATION', obj.current_media.duration)
|
|
||||||
commit('SET_CURRENT_CLIP_IN', obj.current_media.seek)
|
|
||||||
commit('SET_CURRENT_CLIP_OUT', obj.current_media.out)
|
|
||||||
commit('SET_TIME_LEFT', this.$secToHMS(obj.remaining_sec))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
90
stores/auth.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import jwtDecode, { JwtPayload } from 'jwt-decode'
|
||||||
|
|
||||||
|
export const useAuth = defineStore('auth', {
|
||||||
|
state: () => ({
|
||||||
|
isLogin: false,
|
||||||
|
jwtToken: '',
|
||||||
|
authHeader: {},
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {},
|
||||||
|
actions: {
|
||||||
|
updateToken(token: string) {
|
||||||
|
const cookie = useCookie('token', {
|
||||||
|
path: '/',
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
sameSite: 'lax',
|
||||||
|
})
|
||||||
|
|
||||||
|
cookie.value = token
|
||||||
|
this.jwtToken = token
|
||||||
|
this.authHeader = { Authorization: `Bearer ${token}` }
|
||||||
|
},
|
||||||
|
|
||||||
|
updateIsLogin(bool: boolean) {
|
||||||
|
this.isLogin = bool
|
||||||
|
},
|
||||||
|
|
||||||
|
removeToken() {
|
||||||
|
const cookie = useCookie('token')
|
||||||
|
cookie.value = null
|
||||||
|
this.jwtToken = ''
|
||||||
|
this.authHeader = {}
|
||||||
|
},
|
||||||
|
|
||||||
|
async obtainToken(username: string, password: string) {
|
||||||
|
let code = 0
|
||||||
|
const payload = {
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch('auth/login/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: new Headers([['content-type', 'application/json;charset=UTF-8']]),
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
code = response.status
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((response) => {
|
||||||
|
this.updateToken(response.user.token)
|
||||||
|
this.updateIsLogin(true)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (error.status) {
|
||||||
|
code = error.status
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return code
|
||||||
|
},
|
||||||
|
|
||||||
|
inspectToken() {
|
||||||
|
let token = useCookie('token').value
|
||||||
|
|
||||||
|
if (token === null) {
|
||||||
|
token = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
this.updateToken(token)
|
||||||
|
const decodedToken = jwtDecode<JwtPayload>(token)
|
||||||
|
const timestamp = Date.now() / 1000
|
||||||
|
const expireToken = decodedToken.exp
|
||||||
|
|
||||||
|
if (expireToken && this.jwtToken && expireToken - timestamp > 15) {
|
||||||
|
this.updateIsLogin(true)
|
||||||
|
} else {
|
||||||
|
// Prompt user to re login.
|
||||||
|
this.updateIsLogin(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.updateIsLogin(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
240
stores/config.ts
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
import _ from 'lodash'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
const { timeToSeconds } = stringFormatter()
|
||||||
|
|
||||||
|
import { useAuth } from '~/stores/auth'
|
||||||
|
import { useIndex } from '~/stores/index'
|
||||||
|
const authStore = useAuth()
|
||||||
|
const indexStore = useIndex()
|
||||||
|
|
||||||
|
interface GuiConfig {
|
||||||
|
id: number
|
||||||
|
config_path: string
|
||||||
|
extra_extensions: string
|
||||||
|
name: string
|
||||||
|
preview_url: string
|
||||||
|
service: string
|
||||||
|
uts_offset?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
username: string
|
||||||
|
mail: string
|
||||||
|
password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useConfig = defineStore('config', {
|
||||||
|
state: () => ({
|
||||||
|
configID: 0,
|
||||||
|
configCount: 0,
|
||||||
|
configGui: [] as GuiConfig[],
|
||||||
|
configGuiRaw: [] as GuiConfig[],
|
||||||
|
startInSec: 0,
|
||||||
|
playlistLength: 86400.0,
|
||||||
|
configPlayout: {} as any,
|
||||||
|
currentUser: '',
|
||||||
|
configUser: {} as User,
|
||||||
|
utcOffset: 0,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {},
|
||||||
|
actions: {
|
||||||
|
updateConfigID(id: number) {
|
||||||
|
this.configID = id
|
||||||
|
},
|
||||||
|
|
||||||
|
updateConfigCount(count: number) {
|
||||||
|
this.configCount = count
|
||||||
|
},
|
||||||
|
|
||||||
|
updateGuiConfig(config: GuiConfig[]) {
|
||||||
|
this.configGui = config
|
||||||
|
},
|
||||||
|
|
||||||
|
updateGuiConfigRaw(config: GuiConfig[]) {
|
||||||
|
this.configGuiRaw = config
|
||||||
|
},
|
||||||
|
|
||||||
|
updateStartTime(sec: number) {
|
||||||
|
this.startInSec = sec
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePlaylistLength(sec: number) {
|
||||||
|
this.playlistLength = sec
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePlayoutConfig(config: any) {
|
||||||
|
this.configPlayout = config
|
||||||
|
},
|
||||||
|
|
||||||
|
setCurrentUser(user: string) {
|
||||||
|
this.currentUser = user
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUserConfig(config: User) {
|
||||||
|
this.configUser = config
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUtcOffset(offset: number) {
|
||||||
|
this.utcOffset = offset
|
||||||
|
},
|
||||||
|
|
||||||
|
async nuxtClientInit() {
|
||||||
|
authStore.inspectToken()
|
||||||
|
|
||||||
|
if (authStore.isLogin) {
|
||||||
|
await this.getGuiConfig()
|
||||||
|
await this.getPlayoutConfig()
|
||||||
|
await this.getUserConfig()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async getGuiConfig() {
|
||||||
|
let statusCode = 0
|
||||||
|
await fetch('api/channels', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: authStore.authHeader,
|
||||||
|
})
|
||||||
|
.then(response => {
|
||||||
|
statusCode = response.status
|
||||||
|
|
||||||
|
return response
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((objs) => {
|
||||||
|
this.updateUtcOffset(objs[0].utc_offset)
|
||||||
|
this.updateGuiConfig(objs)
|
||||||
|
this.updateGuiConfigRaw(_.cloneDeep(objs))
|
||||||
|
this.updateConfigCount(objs.length)
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
if (statusCode === 401) {
|
||||||
|
const cookie = useCookie('token')
|
||||||
|
cookie.value = null
|
||||||
|
authStore.isLogin = false
|
||||||
|
|
||||||
|
navigateTo('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateGuiConfig([
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
config_path: '',
|
||||||
|
extra_extensions: '',
|
||||||
|
name: 'Channel 1',
|
||||||
|
preview_url: '',
|
||||||
|
service: '',
|
||||||
|
uts_offset: 0,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
indexStore.errorAlertMessage = e
|
||||||
|
indexStore.showErrorAlert = true
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async setGuiConfig(obj: GuiConfig): Promise<any> {
|
||||||
|
const stringObj = _.cloneDeep(obj)
|
||||||
|
const contentType = { 'content-type': 'application/json;charset=UTF-8' }
|
||||||
|
let response
|
||||||
|
|
||||||
|
if (this.configGuiRaw.some((e) => e.id === stringObj.id)) {
|
||||||
|
response = await fetch(`api/channel/${obj.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { ...contentType, ...authStore.authHeader },
|
||||||
|
body: JSON.stringify(stringObj),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
response = await fetch('api/channel/', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...contentType, ...authStore.authHeader },
|
||||||
|
body: JSON.stringify(stringObj),
|
||||||
|
})
|
||||||
|
|
||||||
|
const json = await response.json()
|
||||||
|
const guiConfigs = []
|
||||||
|
|
||||||
|
for (const obj of this.configGui) {
|
||||||
|
if (obj.name === stringObj.name) {
|
||||||
|
guiConfigs.push(json)
|
||||||
|
} else {
|
||||||
|
guiConfigs.push(obj)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateGuiConfig(guiConfigs)
|
||||||
|
this.updateGuiConfigRaw(_.cloneDeep(guiConfigs))
|
||||||
|
this.updateConfigCount(guiConfigs.length)
|
||||||
|
|
||||||
|
await this.getPlayoutConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
return response
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPlayoutConfig() {
|
||||||
|
const channel = this.configGui[this.configID].id
|
||||||
|
|
||||||
|
await fetch(`api/playout/config/${channel}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: authStore.authHeader,
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.playlist.day_start) {
|
||||||
|
const start = timeToSeconds(data.playlist.day_start)
|
||||||
|
this.updateStartTime(start)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.playlist.length) {
|
||||||
|
const length = timeToSeconds(data.playlist.length)
|
||||||
|
this.updatePlaylistLength(length)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updatePlayoutConfig(data)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
indexStore.showErrorAlert = true
|
||||||
|
indexStore.errorAlertMessage = 'No playout config found!'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async setPlayoutConfig(obj: any) {
|
||||||
|
const channel = this.configGui[this.configID].id
|
||||||
|
const contentType = { 'content-type': 'application/json;charset=UTF-8' }
|
||||||
|
|
||||||
|
const update = await fetch(`api/playout/config/${channel}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...contentType, ...authStore.authHeader },
|
||||||
|
body: JSON.stringify(obj),
|
||||||
|
})
|
||||||
|
|
||||||
|
return update
|
||||||
|
},
|
||||||
|
|
||||||
|
async getUserConfig() {
|
||||||
|
await fetch('api/user', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: authStore.authHeader,
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
this.setCurrentUser(data.username)
|
||||||
|
this.updateUserConfig(data)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async setUserConfig(obj: any) {
|
||||||
|
const contentType = { 'content-type': 'application/json;charset=UTF-8' }
|
||||||
|
|
||||||
|
const update = await fetch(`api/user/${obj.id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...contentType, ...authStore.authHeader },
|
||||||
|
body: JSON.stringify(obj),
|
||||||
|
})
|
||||||
|
|
||||||
|
return update
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
18
stores/index.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export const useIndex = defineStore('index', {
|
||||||
|
state: () => ({
|
||||||
|
showAlert: false,
|
||||||
|
alertVariant: 'success',
|
||||||
|
alertMsg: '',
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {},
|
||||||
|
actions: {
|
||||||
|
resetAlert() {
|
||||||
|
this.showAlert = false
|
||||||
|
this.alertVariant = 'success'
|
||||||
|
this.alertMsg = ''
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
52
stores/media.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
import { useAuth } from '~/stores/auth'
|
||||||
|
import { useConfig } from '~/stores/config'
|
||||||
|
const authStore = useAuth()
|
||||||
|
const configStore = useConfig()
|
||||||
|
|
||||||
|
export const useMedia = defineStore('media', {
|
||||||
|
state: () => ({
|
||||||
|
currentPath: '',
|
||||||
|
crumbs: [] as Crumb[],
|
||||||
|
folderTree: {} as FolderObject,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {},
|
||||||
|
actions: {
|
||||||
|
async getTree(path: string) {
|
||||||
|
const contentType = { 'content-type': 'application/json;charset=UTF-8' }
|
||||||
|
const channel = configStore.configGui[configStore.configID].id
|
||||||
|
const crumbs: Crumb[] = []
|
||||||
|
let root = '/'
|
||||||
|
|
||||||
|
await fetch(`api/file/${channel}/browse/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...contentType, ...authStore.authHeader },
|
||||||
|
body: JSON.stringify({ source: path }),
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
const pathStr = 'Home/' + data.source
|
||||||
|
const pathArr = pathStr.split('/')
|
||||||
|
|
||||||
|
if (path && path !== '/') {
|
||||||
|
for (const crumb of pathArr) {
|
||||||
|
if (crumb === 'Home') {
|
||||||
|
crumbs.push({ text: crumb, path: root })
|
||||||
|
} else if (crumb) {
|
||||||
|
root += crumb + '/'
|
||||||
|
crumbs.push({ text: crumb, path: root })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
crumbs.push({ text: 'Home', path: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentPath = path
|
||||||
|
this.crumbs = crumbs
|
||||||
|
this.folderTree = data
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
96
stores/playlist.ts
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
import * as dayjs from 'dayjs'
|
||||||
|
import utc from 'dayjs/plugin/utc.js'
|
||||||
|
import timezone from 'dayjs/plugin/timezone.js'
|
||||||
|
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
dayjs.extend(utc)
|
||||||
|
dayjs.extend(timezone)
|
||||||
|
|
||||||
|
import { useAuth } from '~/stores/auth'
|
||||||
|
import { useConfig } from '~/stores/config'
|
||||||
|
|
||||||
|
const authStore = useAuth()
|
||||||
|
const configStore = useConfig()
|
||||||
|
const { timeToSeconds, secToHMS } = stringFormatter()
|
||||||
|
const { processPlaylist } = playlistOperations()
|
||||||
|
|
||||||
|
export const usePlaylist = defineStore('playlist', {
|
||||||
|
state: () => ({
|
||||||
|
playlist: [] as PlaylistItem[],
|
||||||
|
progressValue: 0,
|
||||||
|
currentClip: 'No clip is playing',
|
||||||
|
currentClipIndex: -1,
|
||||||
|
currentClipStart: 0,
|
||||||
|
currentClipDuration: 0,
|
||||||
|
currentClipIn: 0,
|
||||||
|
currentClipOut: 0,
|
||||||
|
timeStr: '00:00:00',
|
||||||
|
remainingSec: 0,
|
||||||
|
playoutIsRunning: true,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {},
|
||||||
|
actions: {
|
||||||
|
updatePlaylist(list: any) {
|
||||||
|
this.playlist = list
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPlaylist(date: string) {
|
||||||
|
const timeInSec = timeToSeconds(dayjs().utcOffset(configStore.utcOffset).format('HH:mm:ss'))
|
||||||
|
const channel = configStore.configGui[configStore.configID].id
|
||||||
|
let dateToday = dayjs().utcOffset(configStore.utcOffset).format('YYYY-MM-DD')
|
||||||
|
|
||||||
|
if (configStore.startInSec > timeInSec) {
|
||||||
|
dateToday = dayjs(dateToday).utcOffset(configStore.utcOffset).subtract(1, 'day').format('YYYY-MM-DD')
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch(`api/playlist/${channel}?date=${date}`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: authStore.authHeader,
|
||||||
|
})
|
||||||
|
.then((response) => response.json())
|
||||||
|
.then((data) => {
|
||||||
|
if (data.program) {
|
||||||
|
this.updatePlaylist(
|
||||||
|
processPlaylist(configStore.startInSec, configStore.playlistLength, data.program, false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.updatePlaylist([])
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
async playoutStat() {
|
||||||
|
const channel = configStore.configGui[configStore.configID].id
|
||||||
|
|
||||||
|
await fetch(`api/control/${channel}/media/current`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: authStore.authHeader,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.status === 503) {
|
||||||
|
this.playoutIsRunning = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
})
|
||||||
|
.then((data) => {
|
||||||
|
if (data.result && data.result.played_sec) {
|
||||||
|
this.playoutIsRunning = true
|
||||||
|
const obj = data.result
|
||||||
|
this.currentClip = obj.current_media.source
|
||||||
|
this.currentClipIndex = obj.index
|
||||||
|
this.currentClipStart = obj.start_sec
|
||||||
|
this.currentClipDuration = obj.current_media.duration
|
||||||
|
this.currentClipIn = obj.current_media.seek
|
||||||
|
this.currentClipOut = obj.current_media.out
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
this.playoutIsRunning = false
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"exclude": [
|
||||||
|
"./public"
|
||||||
|
],
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"extends": "./.nuxt/tsconfig.json"
|
||||||
|
}
|
38
types/intex.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
export {}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Crumb {
|
||||||
|
text: string
|
||||||
|
path: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PlaylistItem {
|
||||||
|
uid: string
|
||||||
|
begin: number
|
||||||
|
source: string
|
||||||
|
duration: number
|
||||||
|
in: number
|
||||||
|
out: number
|
||||||
|
audio?: string
|
||||||
|
category?: string
|
||||||
|
custom_filter?: string
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileObject {
|
||||||
|
name: string
|
||||||
|
duration: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FolderObject {
|
||||||
|
source: string
|
||||||
|
parent: string
|
||||||
|
folders: string[]
|
||||||
|
files: FileObject[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SourceObject {
|
||||||
|
type: string
|
||||||
|
src: string
|
||||||
|
}
|
||||||
|
}
|