migrate to nuxtjs v3 and bootstrap 5

This commit is contained in:
jb-alvarado 2023-01-11 10:54:25 +01:00
parent 72ebb909a9
commit 5e48e047b2
78 changed files with 10502 additions and 16107 deletions

View File

@ -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

View File

@ -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
View File

@ -1,56 +1,11 @@
# Created by .ignore support plugin (hsz.mobi)
### Node template
# Logs
/logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
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
node_modules
*.log*
.nuxt
.nitro
.cache
.output
.env
dist
# Output of 'npm pack'
*.tgz
@ -58,30 +13,6 @@ typings/
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env
# parcel-bundler cache (https://parceljs.org/)
.cache
# next.js build output
.next
# nuxt.js build output
.nuxt
# Nuxt generate
dist
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless
# IDE / Editor
.idea
# Service worker
sw.*
@ -96,5 +27,10 @@ tv-media
tv-media/
Videos
Videos/
public/
*.tar*
live
live/
.vscode
.vscode/
home
home/

View File

@ -1,6 +1,12 @@
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).
**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.
## Some Impressions:
#### Login
### Login
![login](/docs/images/login.png)
#### Control Page
![control](/docs/images/control.png)
### Landing Page
![landing](/docs/images/landing.png)
#### Media Page
### Control Page
![player](/docs/images/player.png)
### Media Page
![media](/docs/images/media.png)
#### Media Page / Upload
![media-upload](/docs/images/media-upload.png)
#### Message Page
### Message Page
![message](/docs/images/message.png)
#### Logging Page
### Logging Page
![logging](/docs/images/logging.png)
#### Configuration Page / GUI
### Configuration Page
![config-gui](/docs/images/config-gui.png)

View File

@ -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).

View File

@ -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%);
}
}

View File

@ -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;

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View 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;

View File

@ -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
View 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
View 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
View 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
View 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>

View File

@ -1,113 +1,129 @@
<template>
<div>
<div class="menu">
<b-nav align="right">
<b-nav-item to="/" class="nav-item" exact-active-class="active-menu-item">
Home
</b-nav-item>
<b-nav-item to="/player" exact-active-class="active-menu-item">
Player
</b-nav-item>
<b-nav-item to="/media" exact-active-class="active-menu-item">
Media
</b-nav-item>
<b-nav-item to="/message" exact-active-class="active-menu-item">
Message
</b-nav-item>
<b-nav-item to="/logging" exact-active-class="active-menu-item">
Logging
</b-nav-item>
<b-nav-item to="/configure" exact-active-class="active-menu-item">
Configure
</b-nav-item>
<b-nav-text v-if="configGui.length > 1">
&nbsp;&nbsp;
</b-nav-text>
<b-nav-item-dropdown v-if="configGui.length > 1" :text="configGui[configID].name" right>
<b-dropdown-item v-for="(channel, index) in configGui" :key="index" @click="selectChannel(index)">
{{ channel.name }}
</b-dropdown-item>
</b-nav-item-dropdown>
<b-nav-text v-if="configGui.length > 1">
&nbsp;&nbsp;
</b-nav-text>
<b-nav-item @click="logout()">
Logout
</b-nav-item>
</b-nav>
<nav class="navbar navbar-expand-sm fixed-top custom-nav">
<div class="container-fluid">
<NuxtLink class="navbar-brand p-2" href="/"
><img
src="~/assets/images/ffplayout-small.png"
class="img-fluid"
alt="Logo"
width="30"
height="30"
/></NuxtLink>
<button
class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNavDropdown"
aria-controls="navbarNavDropdown"
aria-expanded="false"
aria-label="Toggle navigation"
>
<span class="navbar-toggler-icon"> </span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navbarNavDropdown">
<ul class="navbar-nav">
<li class="nav-item">
<NuxtLink class="btn btn-primary btn-sm" to="/player">Player</NuxtLink>
</li>
<li class="nav-item">
<NuxtLink class="btn btn-primary btn-sm" to="/media">Media</NuxtLink>
</li>
<li class="nav-item">
<NuxtLink class="btn btn-primary btn-sm" to="/message">Message</NuxtLink>
</li>
<li class="nav-item">
<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">
&nbsp;
<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>
&nbsp;
</li>
<li class="nav-item">
<a class="btn btn-primary btn-sm" @click="logout()">Logout</a>
</li>
</ul>
</div>
</div>
</nav>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex'
<script setup lang="ts">
import { useAuth } from '~/stores/auth'
import { useConfig } from '~/stores/config'
export default {
name: 'Menu',
const authStore = useAuth()
const configStore = useConfig()
const router = useRouter()
computed: {
...mapState('config', ['configID', 'configGui'])
},
function logout() {
authStore.removeToken()
authStore.updateIsLogin(false)
router.push({ path: '/' })
}
methods: {
logout () {
try {
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')
}
}
function selectChannel(index: number) {
configStore.updateConfigID(index)
configStore.getPlayoutConfig()
}
</script>
<style lang="scss" >
<style lang="scss">
.menu {
width: 100%;
height: 40px;
height: 60px;
margin: 0;
padding: .5em;
div {
padding: 0.3em;
}
}
.nav-item {
background-image: linear-gradient(#484e55, #3A3F44 60%, #313539);
background-repeat: no-repeat;
height: 28px;
margin: .05em;
border-radius: 3px;
font-size: .95em;
.custom-nav {
background-color: $bg-primary;
}
.nav-item:hover {
background-image: linear-gradient(#5a636c, #4c545b 60%, #42484e);
background-repeat: no-repeat;
}
.nav-item a {
padding: .2em .6em .2em .6em;
}
.active-menu-item {
.nav-item .btn {
position: relative;
}
.active-menu-item::after {
background: #ff9c36;
content: " ";
.router-link-exact-active::after {
background: $accent;
content: ' ';
width: 100%;
height: 2px;
color: red;
position: absolute;
display: block;
left: 0;
right: 0;
border-radius: 1px;
}
@media (max-width: 575px) {
.nav-item .btn {
width: 100%;
text-align: left;
margin-bottom: .3em;
}
}
</style>

View 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>

View File

@ -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
View 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>

View File

@ -11,46 +11,32 @@
</div>
</template>
<script>
/* eslint-disable camelcase */
<script setup lang="ts">
import videojs from 'video.js'
import 'video.js/dist/video-js.css'
export default {
name: 'VideoPlayer',
props: {
options: {
type: Object,
default () {
return {}
}
},
reference: {
type: String,
default () {
return ''
}
}
},
data () {
return {
player: null
}
},
const player = ref()
mounted () {
this.player = videojs(this.reference, this.options, function onPlayerReady () {
// console.log('onPlayerReady', this);
})
const props = defineProps({
options: {
type: Object,
required: true,
},
beforeDestroy () {
if (this.player) {
this.player.dispose()
}
reference: {
type: String,
required: true,
},
})
methods: {
onMounted(() => {
player.value = videojs(props.reference, props.options, function onPlayerReady() {
// console.log('onPlayerReady', this);
})
})
onBeforeUnmount(() => {
if (player.value) {
player.value.dispose()
}
}
})
</script>

128
composables/helper.ts Normal file
View 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 }
}

View File

@ -2,26 +2,46 @@
> web GUI for ffplayout engine
## Build Setup
# Nuxt 3 Minimal Starter
create `.env`file with `API_URL`, for example:
```
API_URL="http://localhost:8787"
Look at the [Nuxt 3 documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install the dependencies:
```bash
# yarn
yarn install
# npm
npm install
# pnpm
pnpm install --shamefully-hoist
```
``` bash
# install dependencies
$ npm run install
## Development Server
# serve with hot reload at localhost:3000
$ npm run dev
Start the development server on http://localhost:3000
# build for production and launch server
$ npm run build
$ npm run start
# generate static project
$ npm run generate
```bash
npm run dev
```
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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 326 KiB

BIN
docs/images/landing.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 48 KiB

BIN
docs/images/player.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

View File

@ -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).

View File

@ -1,67 +1,32 @@
<template>
<div>
<nuxt />
<b-alert
:show="showErrorAlert"
dismissible
:variant="variant"
class="status-alert"
@dismissed="showErrorAlert=!showErrorAlert"
<main>
<NuxtPage />
<div
v-if="indexStore.showAlert"
class="alert show alert-dismissible fade media-alert"
:class="indexStore.alertVariant"
role="alert"
>
<p>{{ ErrorAlertMessage }}</p>
</b-alert>
</div>
{{ indexStore.alertMsg }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</main>
</template>
<script>
import { mapState } from 'vuex'
<script setup lang="ts">
import { useConfig } from '~/stores/config'
import { useIndex } from '~/stores/index'
export default {
computed: {
...mapState(['ErrorAlertMessage', 'variant']),
const configStore = useConfig()
const indexStore = useIndex()
showErrorAlert: {
get () {
return this.$store.state.showErrorAlert
},
set () {
this.$store.commit('UPDATE_SHOW_ERROR_ALERT', false)
}
}
useHead({
htmlAttrs: {
lang: 'en',
"data-bs-theme": "dark"
}
}
})
await configStore.nuxtClientInit()
</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>

View File

@ -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).

View File

@ -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
View 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('/')
}
})

View File

@ -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
View 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

File diff suppressed because it is too large Load Diff

View File

@ -1,40 +1,41 @@
{
"name": "ffplayout-frontend",
"version": "5.3.1",
"description": "Web GUI for ffplayout",
"author": "Jonathan Baecker",
"private": true,
"scripts": {
"dev": "NODE_OPTIONS=--openssl-legacy-provider nuxt",
"build": "nuxt build",
"start": "nuxt start",
"dev": "nuxt dev",
"generate": "nuxt generate",
"lint": "eslint --ext .js,.vue --ignore-path .gitignore ."
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
"@nuxtjs/dayjs": "^1.4.1",
"bootstrap": "^4.6.2",
"bootstrap-vue": "^2.23.1",
"@nuxt/types": "^2.15.8",
"@pinia/nuxt": "^0.4.6",
"@popperjs/core": "^2.11.6",
"@vueuse/core": "^9.10.0",
"bootstrap": "^5.3.0-alpha1",
"bootstrap-icons": "^1.10.3",
"cookie-universal-nuxt": "^2.2.2",
"dayjs": "^1.11.7",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"mpegts.js": "^1.7.1",
"nuxt": "^2.15.8",
"splitpanes": "^2.4.1",
"mpegts.js": "^1.7.2",
"pinia": "^2.0.28",
"sortablejs": "^1.15.0",
"sortablejs-vue3": "^1.2.5",
"splitpanes": "^3.1.5",
"video.js": "^7.20.3",
"vue-loading-overlay": "^3.4.2",
"vuedraggable": "^2.24.3"
"vuedraggable": "^4.1.0"
},
"devDependencies": {
"@babel/eslint-parser": "^7.19.1",
"@babel/preset-react": "^7.18.6",
"@nuxtjs/eslint-config": "^5.0.0",
"@nuxtjs/eslint-module": "^3.1.0",
"@nuxtjs/style-resources": "^1.2.1",
"eslint": "^7.32.0",
"eslint-plugin-nuxt": "3.2.0",
"@nuxtjs/eslint-config": "^12.0.0",
"@types/bootstrap": "^5.2.6",
"@types/lodash": "^4.14.191",
"@types/splitpanes": "^2.2.1",
"@types/video.js": "^7.3.50",
"eslint": "^8.31.0",
"eslint-plugin-nuxt": "^4.0.0",
"nuxt": "3.0.0",
"sass": "^1.57.1",
"sass-loader": "^10.3.1"
"sass-loader": "^13.2.0"
}
}

View File

@ -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).

View File

@ -1,412 +1,113 @@
<template>
<div>
<Menu />
<b-card no-body>
<b-tabs pills card vertical>
<b-tab title="GUI" active @click="resetAlert()">
<b-container class="config-container">
<b-form v-if="configGui" @submit="onSubmitGui">
<b-form-group
label-cols-lg="2"
label="Channel Configuration"
label-size="lg"
label-class="font-weight-bold pt-0"
class="config-group"
>
<div style="width: 100%; height: 43px;">
<div class="float-right">
<b-button size="sm" variant="primary" class="m-md-2" @click="addChannel()">
Add new Channel
</b-button>
</div>
</div>
<div v-for="(prop, name, idx) in configGui[configID]" :key="idx">
<b-form-group
v-if="name !== 'id' && name !== 'utc_offset'"
label-cols-sm="2"
:label="name"
label-align-sm="right"
:label-for="name"
>
<b-form-tags
v-if="name === 'extra_extensions'"
v-model="configGui[configID][name]"
:input-id="name"
separator=" ,;"
:placeholder="`add ${name}...`"
class="mb-2 tags-list"
/>
<b-form-text v-if="name === 'extra_extensions'">
Visible extensions only for the GUI and not the playout
</b-form-text>
<b-form-select v-else-if="name === 'net_interface'" :id="name" v-model="configGui[configID][name]" :options="netChoices" :value="prop" />
<b-form-input
v-else-if="name === 'service' || name === 'config_path'"
:id="name"
v-model="configGui[configID][name]"
:value="prop"
readonly
/>
<b-form-input
v-else
:id="name"
v-model="configGui[configID][name]"
:value="prop"
/>
</b-form-group>
</div>
</b-form-group>
<b-row>
<b-col cols="1" style="min-width:158px">
<b-button-group>
<b-button type="submit" variant="primary">
Save
</b-button>
<b-button v-if="configGui.length > 1 && configGui[configID].id > 1" variant="danger" @click="deleteChannel()">
Delete
</b-button>
</b-button-group>
</b-col>
<b-col>
<b-alert v-model="showAlert" :variant="alertVariant" dismissible>
{{ alertMsg }}
</b-alert>
</b-col>
</b-row>
</b-form>
</b-container>
</b-tab>
<b-tab title="Playout" @click="resetAlert()">
<b-container class="config-container">
<b-form v-if="configPlayout" @submit="onSubmitPlayout">
<b-form-group
v-for="(item, key, index) in configPlayout"
:key="index"
label-cols-lg="2"
:label="key"
label-size="lg"
label-class="font-weight-bold pt-0"
class="config-group"
>
<b-form-group
v-for="(prop, name, idx) in item"
:key="idx"
label-cols-sm="2"
:label="(typeof prop === 'boolean' || name === 'helptext' || name === '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 class="container-fluid configure-container">
<div class="d-flex align-items-start config-tab">
<div class="nav flex-column nav-pills me-3" id="v-pills-tab" role="tablist" aria-orientation="vertical">
<button
class="nav-link active"
id="v-pills-gui-tab"
data-bs-toggle="pill"
data-bs-target="#v-pills-gui"
type="button"
role="tab"
aria-controls="v-pills-gui"
aria-selected="true"
@click="indexStore.resetAlert()"
>
GUI
</button>
<button
class="nav-link"
id="v-pills-playout-tab"
data-bs-toggle="pill"
data-bs-target="#v-pills-playout"
type="button"
role="tab"
aria-controls="v-pills-playout"
aria-selected="false"
@click="indexStore.resetAlert()"
>
Playout
</button>
<button
class="nav-link"
id="v-pills-user-tab"
data-bs-toggle="pill"
data-bs-target="#v-pills-user"
type="button"
role="tab"
aria-controls="v-pills-user"
aria-selected="false"
@click="indexStore.resetAlert()"
>
User
</button>
</div>
<div class="tab-content" id="v-pills-tabContent">
<div
class="tab-pane show active"
id="v-pills-gui"
role="tabpanel"
aria-labelledby="v-pills-gui-tab"
>
<div class="config-container">
<GuiConfig />
</div>
</div>
<div class="tab-pane" id="v-pills-playout" role="tabpanel" aria-labelledby="v-pills-playout-tab">
<div class="config-container">
<PlayoutConfig />
</div>
</div>
<div class="tab-pane" id="v-pills-user" role="tabpanel" aria-labelledby="v-pills-user-tab">
<div class="config-container">
<UserConfig />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { mapState } from 'vuex'
import Menu from '@/components/Menu.vue'
<script setup lang="ts">
import { useIndex } from '~/stores/index'
export default {
name: 'Configure',
definePageMeta({
middleware: ['auth'],
})
components: {
Menu
},
useHead({
title: 'Configuration | ffplayout'
})
middleware: 'auth',
const indexStore = useIndex()
data () {
return {
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 = ''
}
}
}
onBeforeUnmount(() => {
indexStore.resetAlert()
})
</script>
<style lang="scss">
.configure-container {
height: calc(100% - 90px);
}
.config-container {
margin: 2em auto 2em auto;
padding: 0;
width: 90vw;
}
.config-group {
margin-bottom: 2em;
.config-tab,
.tab-content,
.nav-pills {
height: 100%;
}
.input-field {
max-width: 200px;
}
.text-area {
overflow-y: hidden !important;
.tab-pane {
height: 100%;
overflow-y: auto;
}
</style>

View File

@ -1,132 +1,131 @@
<template>
<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" />
<b-container class="login-container">
<div class="container login-container">
<div>
<div class="header">
<h1>ffplayout</h1>
</div>
<b-form class="login-form" @submit.prevent="login">
<b-form-group id="input-group-1" label="User:" label-for="input-user">
<b-form-input id="input-user" v-model="formUsername" type="text" required placeholder="Username" />
</b-form-group>
<b-form-group id="input-group-1" label="Password:" label-for="input-pass">
<b-form-input id="input-pass" v-model="formPassword" type="password" required placeholder="Password" />
</b-form-group>
<b-row>
<b-col cols="3">
<b-button type="submit" variant="primary">
Login
</b-button>
</b-col>
<b-col cols="9">
<b-alert variant="danger" :show="showError" dismissible @dismissed="showError=false">
<form class="login-form" @submit.prevent="login">
<div id="input-group-1" class="mb-3">
<label for="input-user" class="form-label">User:</label>
<input
type="text"
id="input-user"
class="form-control"
v-model="formUsername"
aria-describedby="Username"
required
/>
</div>
<div class="mb-3">
<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 }}
</b-alert>
</b-col>
</b-row>
</b-form>
<button
type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close"
></button>
</div>
</div>
</div>
</form>
</div>
</b-container>
</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>
</template>
<script>
export default {
components: {},
<script setup lang="ts">
import { useAuth } from '~/stores/auth'
import { useConfig } from '~/stores/config'
data () {
return {
showError: false,
formError: null,
formUsername: '',
formPassword: '',
interval: null,
stat: {}
const authStore = useAuth()
const configStore = useConfig()
const formError = ref('')
const showError = ref(false)
const formUsername = ref('')
const formPassword = ref('')
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) {
this.formError = 'Wrong user or password!'
this.showError = true
}
await configStore.nuxtClientInit()
} catch (e) {
formError.value = e as string
}
}
await this.$store.dispatch('config/nuxtClientInit')
} catch (e) {
this.formError = e.message
}
},
logout () {
clearInterval(this.interval)
try {
this.$store.commit('auth/REMOVE_TOKEN')
this.$store.commit('auth/UPDATE_IS_LOGIN', false)
} catch (e) {
this.formError = e.message
}
}
async function logout() {
try {
authStore.removeToken()
authStore.updateIsLogin(false)
} catch (e) {
formError.value = e as string
}
}
</script>
<style>
<style lang="scss">
.login-container {
display: flex;
align-items: center;
@ -149,49 +148,13 @@ export default {
min-width: 300px;
}
.manage-btn {
margin: 0 auto 0 auto;
}
.login-alert {
padding: 0.4em;
--bs-alert-margin-bottom: 0;
.chart-col {
text-align: center;
min-width: 10em;
min-height: 15em;
border: solid #c3c3c3;
}
.stat-div {
padding-top: .5em;
position: relative;
height: 12em;
}
.stat-center {
margin: 0;
position: absolute;
width: 100%;
top: 50%;
-ms-transform: translateY(-50%);
transform: translateY(-50%);
}
.chart1 {
background: rgba(210, 85, 23, 0.1);
}
.chart2 {
background: rgba(122, 210, 23, 0.1);
}
.chart3 {
background: rgba(23, 210, 149, 0.1);
}
.chart4 {
background: rgba(23, 160, 210, 0.1);
}
.chart5 {
background: rgba(122, 23, 210, 0.1);
}
.chart6 {
background: rgba(210, 23, 74, 0.1);
.btn-close {
padding: 0.65rem 0.5rem;
}
}
.actions {

View File

@ -1,134 +1,100 @@
<template>
<div>
<Menu />
<b-row class="date-row">
<b-col>
<b-datepicker v-model="listDate" size="sm" class="date-div" offset="-35px" />
</b-col>
</b-row>
<b-container class="log-container">
<div
v-if="currentLog"
class="log-content"
:inner-html.prop="currentLog | formatStr"
/>
</b-container>
<div class="date-container">
<div class="date-div">
<input type="date" class="form-control" v-model="listDate" />
</div>
</div>
<div class="log-container mt-2">
<div class="log-content" v-html="formatLog(currentLog)" />
</div>
</div>
</template>
<script>
import { mapState } from 'vuex'
import Menu from '@/components/Menu.vue'
<script setup lang="ts">
import { useAuth } from '~/stores/auth'
import { useConfig } from '~/stores/config'
export default {
name: 'Logging',
definePageMeta({
middleware: ['auth'],
})
components: {
Menu
},
useHead({
title: 'Logging | ffplayout'
})
filters: {
formatStr (text) {
return text
/* eslint-disable no-control-regex */
.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>')
}
},
const { $dayjs } = useNuxtApp()
const authStore = useAuth()
const configStore = useConfig()
const currentLog = ref('')
const listDate = ref($dayjs().utcOffset(configStore.utcOffset).format('YYYY-MM-DD'))
const configID = ref(configStore.configID)
const { formatLog } = stringFormatter()
middleware: 'auth',
async function getLog() {
let date = listDate.value
data () {
return {
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 = ''
}
}
if (date === $dayjs().utcOffset(configStore.utcOffset).format('YYYY-MM-DD')) {
date = ''
}
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>
<style>
.ps__thumb-x {
display: inherit !important;
<style lang="scss">
.date-container {
width: 100%;
height: 37px;
}
.log-container {
background: #1d2024;
max-width: 99%;
width: 99%;
height: calc(100% - 90px);
padding: 1em;
overflow: hidden
background: $bg-secondary;
height: calc(100% - 120px);
margin: 1em;
padding: .5em;
overflow: hidden;
}
.log-time {
color: #666864;
color: $log-time;
}
.log-number {
color: #e2c317;
color: $log-number;
}
.log-addr {
color: #ad7fa8;
color: $log-addr ;
font-weight: 500;
}
.log-cmd {
color: #6c95c2;
color: $log-cmd;
}
.log-content {
color: #ececec;
color: $log-content;
width: 100%;
height: 100%;
font-family: monospace;
@ -139,31 +105,30 @@ export default {
}
.log-info {
color: #8ae234;
color: $log-info;
}
.log-warning {
color: #ff8700;
color: $log-warning;
}
.log-error {
color: #d32828;
color: $log-error;
}
.log-debug {
color: #6e99c7;
color: $log-debug;
}
.log-decoder {
color: #56efff;
color: $log-decoder;
}
.log-encoder {
color: #45ccee;
color: $log-encoder;
}
.log-server {
color: #23cbdd;
color: $log-server;
}
</style>

View File

@ -1,642 +1,301 @@
<template>
<div>
<Menu />
<b-container
class="browser-container"
@drop.prevent="addFile"
@dragover.prevent
@dragenter.prevent="dragEnter"
@dragleave.prevent="dragLeave"
>
<div class="drag-file" :class="fileDragClass">
<span>
<b-icon-box-arrow-in-down />
</span>
</div>
<div class="container-fluid browser-container">
<Browser ref="browser" />
</div>
<div class="btn-group media-button">
<button
type="button"
class="btn btn-primary"
title="Create Folder"
data-bs-toggle="modal"
data-bs-target="#folderModal"
>
<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 class="bread-div">
<b-breadcrumb>
<b-breadcrumb-item
v-for="(crumb, index) in crumbs"
:key="crumb.key"
:active="index === crumbs.length - 1"
@click="getPath(crumb.path)"
>
{{ crumb.text }}
</b-breadcrumb-item>
</b-breadcrumb>
</div>
<splitpanes class="browser-row default-theme pane-row">
<pane min-size="20" size="24">
<div class="browser-div">
<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 id="folderModal" class="modal" tabindex="-1" aria-labelledby="folderModalLabel" 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="folderModalLabel">Create Folder</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cancel"></button>
</div>
<form @submit.prevent="onSubmitCreateFolder" @reset="onCancelCreateFolder">
<div class="modal-body">
<input type="text" class="form-control" v-model="folderName" />
</div>
</pane>
<pane class="files-col">
<loading
:active.sync="isLoading"
:can-cancel="false"
:is-full-page="false"
background-color="#485159"
color="#ff9c36"
/>
<div class="browser-div">
<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">
<div class="modal-footer">
<button type="reset" class="btn btn-primary" data-bs-dismiss="modal" aria-label="Cancel">
Cancel
</b-button>
</button>
<button type="submit" class="btn btn-primary" data-bs-dismiss="modal">Ok</button>
</div>
</b-col>
</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>
</form>
</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>
</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>
</template>
<script>
/* eslint-disable vue/custom-event-name-casing */
import { mapState } from 'vuex'
import Menu from '@/components/Menu.vue'
<script setup lang="ts">
import { useAuth } from '~/stores/auth'
import { useConfig } from '~/stores/config'
import { useIndex } from '~/stores/index'
import { useMedia } from '~/stores/media'
import Browser from '../components/Browser.vue'
export default {
name: 'Media',
const { $bootstrap } = useNuxtApp()
const authStore = useAuth()
const configStore = useConfig()
const indexStore = useIndex()
const mediaStore = useMedia()
const contentType = { 'content-type': 'application/json;charset=UTF-8' }
components: {
Menu
},
definePageMeta({
middleware: ['auth'],
})
middleware: 'auth',
useHead({
title: 'Media | ffplayout'
})
data () {
return {
isLoading: false,
fileDragClass: '',
extensions: '',
folderName: '',
inputFiles: [],
currentNumber: 0,
inputPlaceholder: 'Choose files or drop them here...',
previewOptions: {},
previewComp: null,
previewName: '',
previewSource: '',
renamePath: '',
renameOldName: '',
renameNewName: '',
deleteType: 'File',
deleteSource: '',
isImage: false,
uploadTask: '',
overallProgress: 0,
currentProgress: 0,
cancelTokenSource: this.$axios.CancelToken.source(),
lastPath: ''
}
},
const browser = ref()
const uploadModal = ref()
const extensions = ref('')
const folderName = ref('')
const inputFiles = ref([] as File[])
const currentNumber = ref(0)
const uploadTask = ref('')
const overallProgress = ref(0)
const currentProgress = ref(0)
const lastPath = ref('')
const thisUploadModal = ref()
const xhr = ref(new XMLHttpRequest())
computed: {
...mapState('config', ['configID', 'configGui', 'configPlayout']),
...mapState('media', ['crumbs', 'folderTree'])
},
onMounted(async () => {
const exts = [
...configStore.configPlayout.storage.extensions,
...configStore.configGui[configStore.configID].extra_extensions.split(','),
].map((ext) => {
return `.${ext}`
})
watch: {
configID () {
this.getPath('')
}
},
extensions.value = exts.join(', ')
thisUploadModal.value = $bootstrap.Modal.getOrCreateInstance(uploadModal.value)
})
mounted () {
const exts = [...this.configPlayout.storage.extensions, ...this.configGui[this.configID].extra_extensions].map((ext) => {
return `.${ext}`
async function onSubmitCreateFolder(evt: any) {
evt.preventDefault()
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(',')
this.getPath('')
},
indexStore.showAlert = true
folderName.value = ''
methods: {
async getPath (path) {
this.lastPath = path
this.isLoading = true
await this.$store.dispatch('media/getTree', { path })
this.isLoading = false
},
setTimeout(() => {
indexStore.alertMsg = ''
indexStore.showAlert = false
}, 2000)
dragEnter (evt) {
evt.preventDefault()
this.fileDragClass = 'drop-file-visible'
},
browser.value.getPath(lastPath.value)
}
dragLeave (evt) {
evt.preventDefault()
this.fileDragClass = ''
this.inputPlaceholder = 'Choose files or drop them here...'
},
function onCancelCreateFolder(evt: any) {
evt.preventDefault()
folderName.value = ''
}
addFile (evt) {
evt.preventDefault()
const droppedFiles = evt.dataTransfer.files
if (!droppedFiles) {
return
}
([...droppedFiles]).forEach((f) => {
this.inputFiles.push(f)
})
function onFileChange(evt: any) {
const files = evt.target.files || evt.dataTransfer.files
if (this.inputFiles.length === 1) {
this.inputPlaceholder = this.inputFiles[0].name
} else {
this.inputPlaceholder = `${this.inputFiles.length} files selected`
}
this.fileDragClass = ''
this.showUploadModal()
},
showCreateFolderModal () {
this.$root.$emit('bv::show::modal', 'folder-modal')
},
async onSubmitCreateFolder (evt) {
evt.preventDefault()
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')
}
if (!files.length) {
return
}
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>
<style>
<style lang="scss">
.browser-container {
position: relative;
width: 100%;
max-width: 100%;
height: calc(100% - 40px);
height: calc(100% - 140px);
}
.browser {
position: absolute;
width: calc(100% - 30px);
height: calc(100% - 40px);
}
.drag-file {
position: absolute;
display: none;
background: rgba(48, 54, 61, 0.75);
width: calc(100% - 30px);
height: calc(100% - 80px);
text-align: center;
border: 2px solid #ddd;
border-radius: .25em;
z-index: 2;
margin: auto;
}
.drop-file-visible {
display: table;
}
.drag-file span {
display: table-cell;
vertical-align: middle;
font-size: 10em;
}
.bread-div {
height: 50px;
}
.browser-div {
background: #30363d;
.browser-container > div {
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 {
@ -644,10 +303,10 @@ export default {
}
.progress-row .col-1 {
min-width: 60px
min-width: 60px;
}
.progress-row .col-10 {
margin: auto 0 auto 0
margin: auto 0 auto 0;
}
</style>

View File

@ -1,433 +1,492 @@
<template>
<div>
<Menu />
<b-container class="messege-container">
<div class="container mt-5">
<div class="preset-div">
<b-row>
<b-col>
<b-form-select v-model="selected" :options="presets" />
</b-col>
<b-col cols="2">
<b-button-group class="mr-1">
<b-button title="Save Preset" variant="primary" @click="savePreset()">
<b-icon icon="cloud-upload" />
</b-button>
<b-button title="New Preset" variant="primary" @click="openDialog()">
<b-icon-file-plus />
</b-button>
<b-button title="Delete Preset" variant="primary" @click="deleteDialog()">
<b-icon-file-minus />
</b-button>
</b-button-group>
</b-col>
</b-row>
<div class="row">
<div class="col">
<select class="form-select" v-model="selected" @change="onChange($event)">
<option v-for="item in presets">{{ item.name }}</option>
</select>
</div>
<div class="col-2">
<div class="btn-group" role="group">
<button class="btn btn-primary" title="Save Preset" @click="savePreset()">
<i class="bi-cloud-upload" />
</button>
<button
class="btn btn-primary"
title="New Preset"
data-bs-toggle="modal"
data-bs-target="#createModal"
>
<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>
<b-form @submit.prevent="submitMessage">
<b-form-group>
<b-form-textarea
v-model="form.text"
placeholder="Message"
rows="7"
class="message"
/>
</b-form-group>
<b-row>
<b-col>
<b-form-group>
<b-form-input
id="input-1"
v-model="form.x"
type="text"
required
placeholder="X"
/>
</b-form-group>
<form @submit.prevent="submitMessage">
<textarea class="form-control message" v-model="form.text" rows="7" placeholder="Message" />
<b-form-group>
<b-form-input
id="input-2"
v-model="form.y"
type="text"
required
placeholder="Y"
/>
</b-form-group>
<div class="row mt-3">
<div class="col">
<input
class="form-control mt-1"
v-model="form.x"
type="text"
title="X Axis"
placeholder="X"
required
/>
<input
class="form-control mt-2"
v-model="form.y"
type="text"
title="Y Axis"
placeholder="Y"
required
/>
<b-row>
<b-col>
<b-form-group
label="Size"
label-for="input-3"
>
<b-form-input
id="input-3"
v-model="form.fontSize"
type="number"
required
value="24"
/>
</b-form-group>
</b-col>
<b-col>
<b-form-group
label="Spacing"
label-for="input-4"
>
<b-form-input
id="input-4"
v-model="form.fontSpacing"
type="number"
required
value="4"
/>
</b-form-group>
</b-col>
</b-row>
<div class="row mt-2">
<div class="col">
<label for="input-size">Size</label>
<input
id="input-size"
class="form-control mt-2"
v-model="form.fontSize"
type="number"
required
/>
</div>
<div class="col">
<label for="input-spacing">Spacing</label>
<input
id="input-spacing"
class="form-control mt-2"
v-model="form.fontSpacing"
type="number"
required
/>
</div>
</div>
<b-row>
<b-col>
<b-form-group
label="Font Color"
label-for="input-5"
>
<b-form-input
id="input-5"
v-model="form.fontColor"
type="color"
required
/>
</b-form-group>
</b-col>
<b-col>
<b-form-group
label="Font Alpha"
label-for="input-6"
>
<b-form-input
id="input-6"
v-model="form.fontAlpha"
type="number"
min="0"
max="1"
step="0.01"
/>
</b-form-group>
</b-col>
</b-row>
</b-col>
<b-col>
<b-form-checkbox
v-model="form.showBox"
style="margin-bottom: 8px;"
>
Show Box
</b-form-checkbox>
<div class="row mt-2">
<div class="col">
<label for="input-color">Font Color</label>
<input
id="input-color"
class="form-control mt-2"
v-model="form.fontColor"
type="color"
required
/>
</div>
<div class="col">
<label for="input-alpha">Font Alpha</label>
<input
id="input-alpha"
class="form-control mt-2"
v-model="form.fontAlpha"
type="number"
min="0"
max="1"
step="0.01"
/>
</div>
</div>
</div>
<b-row>
<b-col>
<b-form-group
label="Box Color"
label-for="input-7"
>
<b-form-input
id="input-7"
v-model="form.boxColor"
type="color"
required
/>
</b-form-group>
</b-col>
<b-col>
<b-form-group
label="Box Alpha"
label-for="input-8"
>
<b-form-input
id="input-8"
v-model="form.boxAlpha"
type="number"
min="0"
max="1"
step="0.01"
/>
</b-form-group>
</b-col>
</b-row>
<b-form-group
label="Border Width"
label-for="input-9"
>
<b-form-input
id="input-9"
<div class="col">
<div class="form-check">
<input id="input-box" type="checkbox" class="form-check-input" v-model="form.showBox" />
<label for="input-box" class="form-check-label">Show Box</label>
</div>
<div class="row">
<div class="col">
<label for="input-box-color">Box Color</label>
<input
id="input-box-color"
class="form-control mt-2"
v-model="form.boxColor"
type="color"
required
/>
</div>
<div class="col">
<label for="input-box-alpha" class="form-check-label">Box Alpha</label>
<input
id="input-box-alpha"
class="form-control mt-2"
v-model="form.boxAlpha"
type="number"
min="0"
max="1"
step="0.01"
/>
</div>
<label for="input-border-w" class="form-check-label">Border Width</label>
<input
id="input-border-w"
class="form-control mt-2"
v-model="form.border"
type="number"
required
value="4"
/>
</b-form-group>
</b-col>
</b-row>
<label for="input-overall-alpha" class="form-check-label mt-2">Overall Alpha</label>
<input
id="input-overall-alpha"
class="form-control mt-2"
v-model="form.overallAlpha"
type="text"
required
/>
</div>
</div>
</div>
<b-form-group
label="Overall Alpha"
label-for="input-10"
>
<b-form-input
id="input-10"
v-model="form.overallAlpha"
type="text"
required
value="1"
/>
</b-form-group>
<b-row>
<b-col class="sub-btn">
<b-button type="submit" class="send-btn" variant="primary">
Send
</b-button>
</b-col>
<b-col>
<b-alert variant="success" :show="success" dismissible @dismissed="success=false">
Sending success...
</b-alert>
<b-alert variant="warning" :show="failed" dismissible @dismissed="success=failed">
Sending failed...
</b-alert>
</b-col>
</b-row>
</b-form>
</b-container>
<b-modal
id="create-modal"
ref="create-modal"
title="Create Preset"
@ok="handleCreate"
>
<form ref="form" @submit.stop.prevent="createPreset">
<b-form-group label="Name" label-for="name-input" invalid-feedback="Name is required">
<b-form-input id="name-input" v-model="newPresetName" required />
</b-form-group>
<div class="row">
<div class="col sub-btn">
<button class="btn btn-primary send-btn" type="submit">Send</button>
</div>
<div class="col">
<div
v-if="indexStore.showAlert"
class="alert show alert-dismissible fade login-alert"
:class="indexStore.alertVariant"
role="alert"
>
{{ indexStore.alertMsg }}
<button
type="button"
class="btn-close"
data-bs-dismiss="alert"
aria-label="Close"
@click="indexStore.resetAlert()"
></button>
</div>
</div>
</div>
</form>
</b-modal>
<b-modal
id="delete-modal"
ref="delete-modal"
title="Delete Preset"
@ok="handleDelete"
>
<strong>Delete: "{{ selected }}"?</strong>
</b-modal>
</div>
<div class="modal fade" id="createModal" tabindex="-1" aria-labelledby="createModalLabel" 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="createModalLabel">New Preset</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cancel"></button>
</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>
</template>
<script>
import { mapState } from 'vuex'
import Menu from '@/components/Menu.vue'
<script setup lang="ts">
import { useAuth } from '~/stores/auth'
import { useConfig } from '~/stores/config'
import { useIndex } from '~/stores/index'
export default {
name: 'Media',
const authStore = useAuth()
const configStore = useConfig()
const indexStore = useIndex()
const { numberToHex, hexToNumber } = stringFormatter()
const contentType = { 'content-type': 'application/json;charset=UTF-8' }
components: {
Menu
},
definePageMeta({
middleware: ['auth'],
})
middleware: 'auth',
useHead({
title: 'Messages | ffplayout'
})
data () {
return {
form: {
id: 0,
name: '',
text: '',
x: '0',
y: '0',
fontSize: 24,
fontSpacing: 4,
fontColor: '#ffffff',
fontAlpha: 1.0,
showBox: true,
boxColor: '#000000',
boxAlpha: 0.8,
border: 4,
overallAlpha: 1
},
selected: null,
newPresetName: '',
presets: [],
success: false,
failed: false
}
},
interface Preset {
id: number
name: string
text: string
x: string
y: string
fontSize: number
fontSpacing: number
fontColor: string
fontAlpha: number
showBox: boolean
boxColor: string
boxAlpha: number
border: number
overallAlpha: number
}
computed: {
...mapState('config', ['configID', 'configGui'])
},
interface PresetName {
name: string
value: number
}
watch: {
selected (index) {
this.getPreset(index)
}
},
const form = ref({
id: 0,
name: '',
text: '',
x: '0',
y: '0',
fontSize: 24,
fontSpacing: 4,
fontColor: '#ffffff',
fontAlpha: 1.0,
showBox: true,
boxColor: '#000000',
boxAlpha: 0.8,
border: 4,
overallAlpha: '1',
})
created () {
this.getPreset(null)
},
const selected = ref(null)
const newPresetName = ref('')
const presets = ref([] as PresetName[])
methods: {
decToHex (num) {
return '0x' + Math.round(num * 255).toString(16)
},
onMounted(() => {
getPreset(-1)
})
hexToDec (num) {
return (parseFloat(parseInt(num, 16)) / 255).toFixed(2)
},
onBeforeUnmount(() => {
indexStore.resetAlert()
})
async getPreset (index) {
const response = await this.$axios.get(`api/presets/${this.configGui[this.configID].id}`)
async function getPreset(index: number) {
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) {
this.presets = []
for (let index = 0; index < response.data.length; index++) {
const elem = response.data[index]
this.presets.push({ value: index, text: elem.name })
for (let i = 0; i < data.length; i++) {
const elem = data[i]
presets.value.push({ value: i, name: elem.name })
}
} else if (response.data) {
const fColor = response.data[index].fontcolor.split('@')
const bColor = response.data[index].boxcolor.split('@')
this.form = {
id: response.data[index].id,
name: response.data[index].name,
text: response.data[index].text,
x: response.data[index].x,
y: response.data[index].y,
fontSize: response.data[index].fontsize,
fontSpacing: response.data[index].line_spacing,
form.value = {
id: 0,
name: '',
text: '',
x: '0',
y: '0',
fontSize: 24,
fontSpacing: 4,
fontColor: '#ffffff',
fontAlpha: 1.0,
showBox: true,
boxColor: '#000000',
boxAlpha: 0.8,
border: 4,
overallAlpha: '1',
}
} 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],
fontAlpha: (fColor[1]) ? this.hexToDec(fColor[1]) : 1.0,
showBox: response.data[index].box,
fontAlpha: fColor[1] ? hexToNumber(fColor[1]) : 1.0,
showBox: data[index].box === '1' ? true : false,
boxColor: bColor[0],
boxAlpha: (bColor[1]) ? this.hexToDec(bColor[1]) : 1.0,
border: response.data[index].boxborderw,
overallAlpha: response.data[index].alpha
boxAlpha: bColor[1] ? hexToNumber(bColor[1]) : 1.0,
border: data[index].boxborderw,
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) {
this.success = true
this.getPreset(null)
} else {
this.failed = true
}
getPreset(event.target.selectedIndex - 1)
}
this.$nextTick(() => {
this.$bvModal.hide('create-modal')
})
},
async savePreset () {
if (this.selected) {
const preset = {
id: this.form.id,
name: this.form.name,
text: this.form.text,
x: this.form.x,
y: this.form.y,
fontsize: this.form.fontSize,
line_spacing: this.form.fontSpacing,
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,
alpha: this.form.overallAlpha,
channel_id: this.configGui[this.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
}
async function savePreset() {
if (selected.value) {
const preset = {
id: form.value.id,
name: form.value.name,
text: form.value.text,
x: form.value.x,
y: form.value.y,
fontsize: form.value.fontSize,
line_spacing: form.value.fontSpacing,
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,
alpha: form.value.overallAlpha,
channel_id: configStore.configGui[configStore.configID].id,
}
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>
<style scoped>
.messege-container {
margin-top: 5em;
}
.preset-div {
width: 50%;
margin-bottom: 2em;

File diff suppressed because it is too large Load Diff

View File

@ -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).

View File

@ -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
View 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
View 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
// }
// }

View File

@ -1,6 +0,0 @@
import Vue from 'vue'
import draggable from 'vuedraggable'
Vue.use(draggable)
/* eslint-disable-next-line */
Vue.component('draggable', draggable)

View File

@ -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 ''
}
})

View File

@ -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
})
}

View File

@ -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
View File

@ -0,0 +1,5 @@
import _ from 'lodash'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.provide('_', _)
})

View File

@ -1,4 +0,0 @@
import Vue from 'vue'
import _ from 'lodash'
Object.defineProperty(Vue.prototype, '$_', { value: _ })

View File

@ -1,3 +0,0 @@
export default async (context) => {
await context.store.dispatch('config/nuxtClientInit', context)
}

View File

@ -0,0 +1,6 @@
import { defineNuxtPlugin } from '#app'
import { Sortable } from 'sortablejs-vue3'
export default defineNuxtPlugin((nuxtApp) => {
nuxtApp.vueApp.component('Sortable', Sortable)
})

View File

@ -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)

View File

@ -1,5 +0,0 @@
import Vue from 'vue'
import VideoPlayer from '@/components/VideoPlayer.vue'
/* eslint-disable-next-line */
Vue.component('video-player', VideoPlayer)

View File

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@ -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.

View File

@ -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).

View File

@ -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)
}
}
}

View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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)
}
}
}

View File

@ -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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
{
"exclude": [
"./public"
],
// https://nuxt.com/docs/guide/concepts/typescript
"extends": "./.nuxt/tsconfig.json"
}

38
types/intex.ts Normal file
View 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
}
}