Merge pull request #145 from jr-k/feature/composition-content-type

Composition content type
This commit is contained in:
JRK 2024-08-27 03:08:44 +02:00 committed by GitHub
commit e3ca756aa7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
74 changed files with 2847 additions and 389 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -30,6 +30,10 @@ const hideDropdowns = function () {
$('.dropdown').removeClass('dropdown-show');
};
const classColorXor = function(color, fallback) {
return color === 'gscaleF' ? 'gscale0' : (color === 'gscale0' ? 'gscaleF' : fallback);
};
const showToast = function (text) {
const $toast = $(".toast");
$toast.addClass('show');
@ -162,5 +166,12 @@ jQuery(document).ready(function ($) {
showToast(l.js_common_copied);
});
$(window).on('beforeunload', function(event) {
$('.modal').each(function() {
$(this).find('button[type=submit]').removeClass('hidden');
$(this).find('.btn-loading').addClass('hidden');
});
});
});

84
data/www/js/lib/jquery-more.js vendored Normal file
View File

@ -0,0 +1,84 @@
jQuery(function () {
$(document).ready(function () {
function adjustValue(inputElement, delta) {
const currentValue = parseInt(inputElement.value) || 0;
const newValue = currentValue + delta;
if (("" + newValue).length <= inputElement.maxLength) {
inputElement.value = newValue >= 0 ? newValue : 0;
$(inputElement).trigger('input');
}
}
$('.numeric-input').on('input', function () {
this.value = this.value.replace(/[^0-9]/g, '');
});
$('.numeric-input').on('keydown', function (e) {
switch (e.key) {
case 'ArrowUp':
e.preventDefault();
adjustValue(this, e.shiftKey ? 10 : 1);
break;
case 'ArrowDown':
e.preventDefault();
adjustValue(this, e.shiftKey ? -10 : -1);
break;
}
});
function updateRadioActiveClass() {
$('.radio-group label').removeClass('active');
$('input[type="radio"]:checked').next('label').addClass('active');
}
updateRadioActiveClass();
$('.radio-group input[type="radio"]').change(function() {
updateRadioActiveClass();
});
function updateCheckboxActiveClass() {
$('.checkbox-group label').each(function() {
const checkbox = $(this).prev('input[type="checkbox"]');
if (checkbox.is(':checked')) {
$(this).addClass('active');
} else {
$(this).removeClass('active');
}
});
}
updateCheckboxActiveClass();
$('.checkbox-group input[type="checkbox"]').change(function() {
updateCheckboxActiveClass();
});
$.fn.serializeObject = function() {
const obj = {};
this.find('input, select, textarea').each(function() {
const field = $(this);
const name = field.attr('name');
if (!name) return; // Ignore fields without a name
if (field.is(':checkbox')) {
const isOnOff = field.val() === 'on' || field.val() === '1';
obj[name] = field.is(':checked') ? field.val() : (isOnOff ? false : null);
} else if (field.is(':radio')) {
if (field.is(':checked')) {
obj[name] = field.val();
} else if (!(name in obj)) {
obj[name] = false;
}
} else {
const tryInt = parseInt(field.val());
obj[name] = isNaN(tryInt) ? field.val() : tryInt;
}
});
return obj;
};
});
});

File diff suppressed because one or more lines are too long

1
data/www/js/lib/jscolor.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,471 @@
jQuery(document).ready(function ($) {
const DEFAULT_RATIO = "16/9";
const contentData = JSON.parse($('#content-edit-location').val() || `{"ratio":"${DEFAULT_RATIO}", "layers":{}}`);
let currentElement = null;
let elementCounter = 0;
let screenRatio = 16/9;
const setRatio = function () {
const ratioString = $('#elem-screen-ratio').val() || DEFAULT_RATIO;
$('.ratio-value').text(ratioString.replace('/', ' / '));
screenRatio = evalStringRatio(ratioString);
$('.screen-holder').css({ 'padding-top': ( 1/ ( screenRatio ) * 100) + '%' });
$('.ratio-value').val(screenRatio);
$('#screen').css({
width: $('#screen').width(),
height: $('#screen').width() * (1/screenRatio),
position: 'relative',
}).parents('.screen-holder:eq(0)').css({
width: 'auto',
'padding-top': '0px'
});
};
setRatio();
$(document).on('input', '#elem-screen-ratio', function() {
setRatio();
});
function createElement(config = null) {
const screen = $('#screen');
const screenWidth = screen.width();
const screenHeight = screen.height();
const elementWidth = config ? (config.widthPercent / 100) * screenWidth : 100;
const elementHeight = config ? (config.heightPercent / 100) * screenHeight : 50;
let x = config ? (config.xPercent / 100) * screenWidth : Math.round(Math.random() * (screenWidth - elementWidth));
let y = config ? (config.yPercent / 100) * screenHeight : Math.round(Math.random() * (screenHeight - elementHeight));
const zIndex = config ? config.zIndex : elementCounter++;
//x = Math.round(Math.max(0, Math.min(x, screenWidth - elementWidth)));
//y = Math.round(Math.max(0, Math.min(y, screenHeight - elementHeight)));
const elementId = zIndex;
const element = $('<div class="element" id="element-' + zIndex + '" data-id="' + zIndex + '"><i class="fa fa-cog"></i></div>');
// const element = $('<div class="element" id="' + elementId + '"><button>Button</button><div class="rotate-handle"></div></div>');
element.css({
left: x,
top: y,
width: elementWidth,
height: elementHeight,
zIndex: zIndex,
transform: `rotate(0deg)`
});
element.draggable({
// containment: "#screen",
start: function (event, ui) {
focusElement(ui.helper);
},
drag: function (event, ui) {
updateForm(ui.helper);
}
});
element.resizable({
// containment: "#screen",
handles: 'n, s, e, w, nw, ne, sw, se',
start: function (event, ui) {
focusElement(ui.element);
},
resize: function (event, ui) {
updateForm(ui.element);
}
});
/*
element.rotatable({
handle: element.find('.rotate-handle'),
rotate: function(event, ui) {
updateForm(ui.element);
}
});
*/
element.click(function () {
focusElement($(this));
});
screen.append(element);
addElementToList(elementId);
if (config !== null && config.contentId !== null) {
element.attr('data-content-id', config.contentId);
element.attr('data-content-name', config.contentName);
element.attr('data-content-type', config.contentType);
element.attr('data-content-metadata', config.contentMetadata);
applyContentToElement({
id: config.contentId,
name: config.contentName,
type: config.contentType,
metadata: config.contentMetadata,
}, element);
updateForm(element);
unfocusElements();
} else {
setTimeout(function () {
focusElement(element);
}, 10);
}
return element;
}
$(document).on('click', '.element-adjust-aspect-ratio', function(){
const metadata = currentElement.data('content-metadata');
const ratio = metadata.height / metadata.width;
$('#elem-height').val($('#elem-width').val() * ratio).trigger('input');
$('#elem-width').val($('#elem-width').val()).trigger('input');
});
$(document).on('click', '.element-list-item', function(){
focusElement($('#element-' + $(this).attr('data-id')));
});
$(document).on('click', '.remove-element', function(){
if (confirm(l.js_common_are_you_sure)) {
removeElementById($(this).attr('data-id'));
}
});
function removeElementById(elementId) {
$('.element[data-id='+elementId+'], .element-list-item[data-id='+elementId+']').remove();
updateZIndexes();
}
function addElementToList(elementId) {
const listItem = `<div class="element-list-item" data-id="__ID__">
<i class="fa fa-cog"></i>
<div class="inner">
<label>__EMPTY__ __ID__ </label>
<button type="button" class="btn btn-naked remove-element" data-id="__ID__">
<i class="fa fa-trash"></i>
</button>
<button type="button" class="btn btn-neutral configure-element content-explr-picker" data-id="__ID__">
<i class="fa fa-cog"></i>
</button>
</div>
</div>`;
$('#elementList').append(
$(listItem
.replace(/__ID__/g, elementId)
.replace(/__EMPTY__/g, l.js_common_empty)
)
);
updateZIndexes();
}
function unfocusElements() {
$('.element, .element-list-item').removeClass('focused');
currentElement = null;
updateForm(null);
}
function focusElement($element) {
unfocusElements();
currentElement = $element;
$element.addClass('focused');
const listElement = $('.element-list-item[data-id="' + $element.attr('data-id') + '"]');
listElement.addClass('focused');
updateForm($element);
const contentType = $element.attr('data-content-type');
$('.element-tool').addClass('hidden');
if (contentType) {
if (contentType === 'picture' || contentType === 'video') {
const contentMetadata = $element.data('content-metadata');
if (contentMetadata.width && contentMetadata.height) {
$('.element-tool.element-adjust-aspect-ratio-container').removeClass('hidden');
}
}
}
}
function updateForm($element) {
if (!$element) {
$('form#elementForm input').val('').prop('disabled', true);
$('.form-element-properties').addClass('hidden');
return;
}
$('.form-element-properties').removeClass('hidden');
$('form#elementForm input').prop('disabled', false);
const offset = $element.position();
if (offset !== undefined) {
$('#elem-x').val(offset.left);
$('#elem-y').val(offset.top);
$('#elem-width').val($element.width());
$('#elem-height').val($element.height());
}
$element.find('i').css('font-size', Math.min($element.width(), $element.height()) / 3);
/*
const rotation = $element.css('transform');
const values = rotation.split('(')[1].split(')')[0].split(',');
const angle = Math.round(Math.atan2(values[1], values[0]) * (180/Math.PI));
$('#elem-rotate').val(angle);
*/
}
$(document).on('input', '#elementForm input', function () {
if (!currentElement) {
return;
}
const screenWidth = $('#screen').width();
const screenHeight = $('#screen').height();
let x = Math.round(parseInt($('#elem-x').val()));
let y = Math.round(parseInt($('#elem-y').val()));
let width = Math.round(parseInt($('#elem-width').val()));
let height = Math.round(parseInt($('#elem-height').val()));
// let rotation = parseInt($('#elem-rotate').val());
// Constrain x and y
// x = Math.max(0, Math.min(x, screenWidth - width));
// y = Math.max(0, Math.min(y, screenHeight - height));
// Constrain width and height
width = Math.min(width, screenWidth - x);
height = Math.min(height, screenHeight - y);
currentElement.css({
left: x,
top: y,
width: width,
height: height
// transform: `rotate(${rotation}deg)`
});
// Update form values to reflect clamped values
$('#elem-x').val(x);
$('#elem-y').val(y);
$('#elem-width').val(width);
$('#elem-height').val(height);
});
// $(document).on('click', '#addElement', function () {
// createElement();
// });
$(document).on('click', '#removeAllElements', function () {
if (confirm(l.js_common_are_you_sure)) {
$('.element, .element-list-item').remove();
updateZIndexes();
}
});
$(document).on('dblclick', '.element', function (e) {
$('.content-explr-picker[data-id='+$(this).attr('data-id')+']').click();
});
$(document).on('mousedown', function (e) {
const keepFocusedElement = $(e.target).hasClass('element')
|| $(e.target).hasClass('element-list-item')
|| $(e.target).parents('.element:eq(0)').length !== 0
|| $(e.target).parents('.element-list-item:eq(0)').length !== 0
|| $(e.target).is('input,select,textarea')
|| $(e.target).is('.page-panel.right-panel button,a,.btn')
if (!keepFocusedElement) {
unfocusElements();
}
});
$(document).on('click', '#presetGrid2x2', function () {
const screenWidth = $('#screen').width();
const screenHeight = $('#screen').height();
let elements = $('.element');
if (elements.length < 4) {
while (elements.length < 4) {
createElement();
elements = $('.element');
}
}
elements = $('.element-list-item').map(function() {
return $('.element[data-id='+$(this).attr('data-id')+']');
}).slice(0, 4);
const gridPositions = [
{x: 0, y: 0},
{x: screenWidth / 2, y: 0},
{x: 0, y: screenHeight / 2},
{x: screenWidth / 2, y: screenHeight / 2}
];
elements.each(function (index) {
const position = gridPositions[index];
$(this).css({
left: position.x,
top: position.y,
width: screenWidth / 2,
height: screenHeight / 2
});
updateForm($(this));
});
unfocusElements();
});
$(document).on('click', '#presetTvNews1x1', function () {
const screenWidth = $('#screen').width();
const screenHeight = $('#screen').height();
let elements = $('.element');
if (elements.length === 0) {
createElement();
}
if (!currentElement) {
return;
}
const height = (screenHeight / 7);
currentElement.css({
left: 0,
top: screenHeight - height,
width: screenWidth,
height: height
});
updateForm(currentElement);
unfocusElements();
});
$(document).keydown(function (e) {
if (e.key === "Escape") {
unfocusElements();
}
const hasFocusInInput = $('input,textarea').is(':focus');
if (!currentElement || hasFocusInInput) {
return;
}
if (e.key === "ArrowLeft") {
$('#elem-x').val(parseInt($('#elem-x').val()) - (e.shiftKey ? 10 : 1)).trigger('input');
} else if (e.key === "ArrowRight") {
$('#elem-x').val(parseInt($('#elem-x').val()) + (e.shiftKey ? 10 : 1)).trigger('input');
} else if (e.key === "ArrowUp") {
$('#elem-y').val(parseInt($('#elem-y').val()) - (e.shiftKey ? 10 : 1)).trigger('input');
} else if (e.key === "ArrowDown") {
$('#elem-y').val(parseInt($('#elem-y').val()) + (e.shiftKey ? 10 : 1)).trigger('input');
} else if (e.key === "Backspace") {
if (confirm(l.js_common_are_you_sure)) {
removeElementById(currentElement.attr('data-id'));
}
}
});
$(document).on('click', '.content-explr-picker', function () {
const elementId = $(this).attr('data-id');
const isNew = !elementId;
const $element = isNew ? $(createElement()) : $('#element-'+elementId);
showPickers('modal-content-explr-picker', function (content) {
applyContentToElement(content, $element)
});
});
const applyContentToElement = function (content, $element) {
$element.attr('data-content-id', content.id);
$element.attr('data-content-name', content.name);
$element.attr('data-content-type', content.type);
$element.data('content-metadata', content.metadata);
const $elementList = $('.element-list-item[data-id='+$element.attr('data-id')+']');
const iconClasses = [
'fa',
content_type_icon_classes[content.type],
content_type_color_classes[content.type]
].join(' ');
$element.find('i').get(0).classList = iconClasses;
$elementList.find('label').text(content.name);
$elementList.find('i:eq(0)').get(0).classList = iconClasses;
};
$(document).on('submit', 'form.form', function (e) {
unfocusElements();
const location = getLocationPayload();
$('#content-edit-location').val(JSON.stringify(location));
});
function updateZIndexes() {
const zindex = $('.element-list-item').length + 1;
$('.element-list-item').each(function(index) {
const id = $(this).attr('data-id');
$('#element-' + id).css('z-index', zindex - index);
});
}
$('#elementList').sortable({
update: function(event, ui) {
updateZIndexes();
}
});
const applyElementsFromContent = function() {
for (let i = 0; i < contentData.layers.length; i++) {
createElement(contentData.layers[i]);
}
};
applyElementsFromContent();
const getLocationPayload = function() {
const screen = $('#screen');
const screenWidth = screen.width();
const screenHeight = screen.height();
const layers = [];
$('.element').each(function () {
const $element = $(this);
const offset = $element.position();
const x = offset.left;
const y = offset.top;
const width = $element.width();
const height = $element.height();
const xPercent = (x / screenWidth) * 100;
const yPercent = (y / screenHeight) * 100;
const widthPercent = (width / screenWidth) * 100;
const heightPercent = (height / screenHeight) * 100;
const contentId = $element.attr('data-content-id');
const contentName = $element.attr('data-content-name');
const contentType = $element.attr('data-content-type');
const contentMetadata = $element.data('content-metadata');
const layer = {
xPercent: xPercent,
yPercent: yPercent,
widthPercent: widthPercent,
heightPercent: heightPercent,
zIndex: parseInt($element.css('zIndex')),
contentId: contentId ? parseInt(contentId) : null,
contentName: contentName ? contentName : null,
contentType: contentType ? contentType : null,
contentMetadata: contentMetadata && contentMetadata !== "null" ? contentMetadata : null,
};
layers.push(layer);
});
layers.sort(function(a, b) {
return parseInt(b.zIndex) - parseInt(a.zIndex);
});
return {
ratio: $('#elem-screen-ratio').val(),
layers: layers
};
};
});

View File

@ -0,0 +1,79 @@
jQuery(document).ready(function ($) {
const contentData = JSON.parse($('#content-edit-location').val() || '{}');
const screenRatio = 16/9;
$('.screen-holder').css({
'padding-top': ( 1/ ( screenRatio ) * 100) + '%'
});
$('.ratio-value').val(screenRatio);
$('#screen').css({
width: $('#screen').width(),
height: $('#screen').height(),
position: 'relative',
}).parents('.screen-holder:eq(0)').css({
width: 'auto',
'padding-top': '0px'
});
const draw = function() {
const $screen = $('#screen');
const $text = $('<div class="text">');
let insideText = $('#elem-text').val();
if ($('#elem-scroll-enable').is(':checked')) {
const $wrapper = $('<marquee>');
$wrapper.attr({
scrollamount: $('#elem-scroll-speed').val(),
direction: $('[name=scrollDirection]:checked').val(),
behavior: 'scroll',
loop: -1
});
$wrapper.append(insideText);
insideText = $wrapper;
}
$text.append(insideText);
let justifyContent = 'center';
switch($('[name=textAlign]:checked').val()) {
case 'left': justifyContent = 'flex-start'; break;
case 'right': justifyContent = 'flex-end'; break;
}
$text.css({
padding: $('#elem-container-margin').val() + 'px',
color: $('#elem-fg-color').val(),
textAlign: $('[name=textAlign]:checked').val(),
textDecoration: $('#elem-text-underline').is(':checked') ? 'underline' : 'normal',
fontSize: $('#elem-font-size').val() + 'px',
fontWeight: $('#elem-font-bold').is(':checked') ? 'bold' : 'normal',
fontStyle: $('#elem-font-italic').is(':checked') ? 'italic' : 'normal',
fontFamily: $('#elem-font-family').val() + ", 'Arial', 'sans-serif'",
whiteSpace: $('#elem-single-line').is(':checked') ? 'nowrap' : 'normal',
justifyContent: justifyContent
});
$screen.css({
backgroundColor: $('#elem-bg-color').val(),
});
$screen.html($text);
};
$(document).on('input', '#elementForm input, #elementForm select', function () {
draw();
});
draw();
$(document).on('submit', 'form.form', function (e) {
const location = $('form#elementForm').serializeObject();
$('#content-edit-location').val(JSON.stringify(location));
});
});

View File

@ -21,7 +21,11 @@ jQuery(document).ready(function ($) {
$form.find('.object-label:visible').html(optionAttributes['data-object-label'].value);
$('.type-icon').attr('class', 'type-icon fa ' + optionAttributes['data-icon'].value);
$('.tab-select .widget').attr('class', 'widget ' + ('border-' + color) + ' ' + color);
$form.find('button[type=submit]').attr('class', 'btn ' + ('btn-' + color));
$form.find('button[type=submit]').attr('class', [
'btn',
`btn-${color}`,
classColorXor(color, '')
].join(' '));
};
const main = function () {

View File

@ -31,13 +31,20 @@ jQuery(function ($) {
},
always: function (e, data) {
const response = data._response.jqXHR;
let statusCode = response.status;
$button.removeClass('uploading').removeClass('btn-naked btn-super-upload-busy').addClass('btn-info btn-super-upload');
if (response.status != 200) {
const $alert = $('.alert-danger').removeClass('hidden');
if (response.status == 413) {
$alert.text(l.js_common_http_error_413);
let errorComment = response.responseText.match(/<!--\s*error=(\d+);\s*-->/);
if (errorComment && errorComment[1]) {
statusCode = parseInt(errorComment[1], 10);
}
if (statusCode !== 200) {
const $alert = $('.alert-upload').removeClass('hidden');
if (statusCode === 413) {
$alert.html(`<i class="fa fa-warning"></i>${l.js_common_http_error_413}`);
} else {
$alert.text(l.js_common_http_error_occured.replace('%code%', response.status));
$alert.html(`<i class="fa fa-warning"></i>${l.js_common_http_error_occured.replace('%code%', statusCode)}`);
}
} else {
document.location.reload();

View File

@ -81,3 +81,9 @@ const secondsToHHMMSS = function (seconds) {
const secs = seconds % 60;
return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const evalStringRatio = function(str) {
return str.replace(/(\d+)\/(\d+)/g, function(match, p1, p2) {
return (parseInt(p1) / parseInt(p2)).toString();
});
};

View File

@ -31,8 +31,16 @@ main {
margin-right: 20px;
}
.contex-tail {
margin-right: 20px;
.context-tail {
margin-right: 30px;
.btn {
margin-right: 0;
}
}
.context-tail-auth {
margin-right: 10px;
.btn {
margin-right: 0;

View File

@ -26,6 +26,10 @@ body, html {
align-items: flex-start;
flex: 1;
align-self: stretch;
&.fx-end {
justify-content: flex-end;
}
}
.vertical {

View File

@ -0,0 +1,12 @@
@keyframes blink{50%{opacity:0;}}
.cfx-blink{animation:1.5s linear infinite blink;}
.cfx-ffff-speed {animation-delay: 0.1s;}
.cfx-fff-speed {animation-delay: 0.3s;}
.cfx-ff-speed {animation-delay: 0.5s;}
.cfx-f-speed {animation-delay: 0.8s;}
.cfx-m-speed {animation-delay: 1s;}
.cfx-s-speed {animation-delay: 1.3s;}
.cfx-ss-speed {animation-delay: 1.5s;}
.cfx-sss-speed {animation-delay: 1.8s;}
.cfx-ssss-speed {animation-delay: 2s;}
.cfx-sssss-speed {animation-delay: 3s;}

View File

@ -1,32 +1,10 @@
a.badge,
.badge {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
padding: 5px 5px;
border-radius: $baseRadius;
font-size: 12px;
background: rgba($gscaleF, .1);
border: 1px solid transparent;
color: $gscaleF;
}
a.badge:hover {
color: $gscaleF;
border: 1px solid rgba($gscaleF, .4);
}
.panel-inactive .badge {
background: rgba($gscale7, .1);
color: $gscale7;
}
.panel-inactive a.badge:hover {
color: $gscale7;
border: 1px solid rgba($gscale7,.2);
}
.badge.anonymous {
opacity: .2;
.badge-inset {
display: inline;
color: $gscaleA;
font-size: 12px;
margin-left: 5px;
background: $gscale0;
border: 1px solid $gscale3;
border-radius: $baseRadius;
padding: 3px 7px;
}

View File

@ -1,3 +1,4 @@
button,
.btn {
$shadowOffset: 2px;
@ -56,6 +57,7 @@ button,
box-shadow: 0 $shadowOffset 0 0 darken($gscale5, 10%);
border: 1px solid transparent;
&.active,
&:hover {
box-shadow: 0 $shadowOffset 0 1px $gkscale2 inset;
background: darken($gscale5, 10%);
@ -141,4 +143,3 @@ button,
cursor: default;
}
}

View File

@ -81,6 +81,24 @@ form {
}
}
.checkbox-group,
.radio-group {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
input {
display: none;
}
label {
margin: 0 5px 0 0 !important;
justify-content: center !important;
text-align: center;
}
}
.widget {
margin-top: 10px;
align-self: stretch;
@ -100,12 +118,11 @@ form {
}
}
.btn {
input + .btn + .btn {
margin-left: 10px;
}
&.widget-unit {
select,
input {
flex-grow: 0;
@ -131,6 +148,33 @@ form {
}
}
select,
input {
&.size-m {
max-width: 122px;
}
&.color-picker {
max-width: 125px;
}
&.chars-4 {
max-width: 50px;
}
&.chars-3 {
max-width: 40px;
}
&.chars-2 {
max-width: 20px;
}
&.chars-1 {
max-width: 15px;
}
}
div {
color: rgba($gscaleF, .7);
font-size: 14px;
@ -155,23 +199,17 @@ form {
color: $gscale5;
background: none;
box-shadow: none;
border: none;
border-bottom: 1px solid $gscale3;
border-radius: 0;
}
&.input-naked {
padding-left: 0;
color: $gscaleB;
}
&.disabled,
&[disabled] {
border: none;
background: $gscale0;
border-radius: $baseRadius;
padding-left: 10px;
padding-right: 10px;
}
}
}

View File

@ -18,19 +18,20 @@
@import 'components/modals';
@import 'components/toast';
@import 'components/dragdrop';
// Legacy
@import 'components/animation';
@import 'components/panes';
@import 'components/tiles';
@import 'components/empty';
@import 'components/switches';
//@import 'components/badges';
@import 'components/badges';
// Import form styles
@import 'forms/forms';
// Import pages styles
@import 'pages/content';
@import 'pages/content-composition';
@import 'pages/content-text';
@import 'pages/logs';
@import 'pages/node-player';
@import 'pages/playlist';

View File

@ -43,6 +43,25 @@ button,
&.btn-neutral:hover {
box-shadow: 0 2px 0 1px $gkscale6 inset;
}
&.btn-neutral {
$shadowOffset: 2;
color: $gkscale5;
background: $white;
box-shadow: none !important;
border: 1px solid transparent;
&.active,
&:hover {
box-shadow: 0 $shadowOffset 0 1px $gkscale2 inset;
background: $gkscaleC;
}
&:focus {
background: darken($gscale5, 20%);
border: 1px solid $gscaleA;
}
}
}
.tiles .tiles-inner .tile-item {

View File

@ -0,0 +1,364 @@
.view-content-edit.view-content-edit-composition main .main-container {
.page-panel.left-panel {
flex: 1;
.form-holder {
margin: 20px 20px 20px 10px;
flex: 1;
}
}
.page-content {
flex: 2;
}
.page-panel.right-panel {
flex: 1;
}
h3.main {
font-size: 16px;
font-weight: 500;
color: $gscaleD;
text-decoration: none;
margin-top: 5px;
border-bottom: 1px solid $gscale2;
margin-bottom: 20px;
padding-bottom: 10px;
align-self: stretch;
}
.toolbar {
margin-bottom: 20px;
}
.presets {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
margin-bottom: 20px;
h4 {
margin-right: 5px;
font-weight: normal;
font-size: 14px;
text-decoration: underline;
}
button:focus,
button {
padding: 3px 15px;
margin:0 3px;
font-size: 12px;
font-weight: normal;
min-height: initial;
border: 1px solid $gkscale3;
}
}
.screen-holder {
//display: flex;
//flex-direction: row;
display: flex;
flex-direction: column;
width: 100%;
position: relative;
padding-top: 56.25%; /* 16:9 aspect ratio */
overflow: hidden;
border-radius: $baseRadius;
outline: 4px solid rgba($gscaleF, .1);
.screen {
background-color: #ddd;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
.element {
position: absolute !important;
background-color: $gkscaleE;
outline: 1px solid $gkscaleC;
text-align: center;
box-sizing: border-box;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
&.focused {
border: none;
outline: 2px solid $seaBlue;
z-index: 89 !important;
.ui-resizable-handle {
display: block;
}
}
i {
font-size: 20px;
color: $gkscaleC;
&.fa-cog {
text-shadow: 0 -2px $gkscaleB, 0 0px 2px $gkscaleB;
}
&.gscaleF {
color: black !important;
}
}
.rotate-handle {
width: 10px;
height: 10px;
background-color: red;
position: absolute;
top: 50%;
right: -15px;
cursor: pointer;
transform: translateY(-50%);
}
.ui-resizable-handle {
$size: 10px;
$sizeOffset: -1*calc($size/2);
background: $gkscaleA;
border: 1px solid $gkscale5;
width: $size;
height: $size;
z-index: 90;
display: none;
position: absolute;
&.ui-resizable-n {
cursor: n-resize;
top: $sizeOffset;
left: 50%;
margin-left: $sizeOffset;
}
&.ui-resizable-s {
cursor: s-resize;
bottom: $sizeOffset;
left: 50%;
margin-left: $sizeOffset;
}
&.ui-resizable-w {
cursor: w-resize;
left: $sizeOffset;
top: 50%;
margin-top: $sizeOffset;
}
&.ui-resizable-e {
cursor: e-resize;
right: $sizeOffset;
top: 50%;
margin-top: $sizeOffset;
}
&.ui-resizable-nw {
cursor: nw-resize;
top: $sizeOffset;
left: $sizeOffset;
}
&.ui-resizable-ne {
cursor: ne-resize;
top: $sizeOffset;
right: $sizeOffset;
}
&.ui-resizable-sw {
cursor: sw-resize;
bottom: $sizeOffset;
left: $sizeOffset;
}
&.ui-resizable-se {
cursor: se-resize;
bottom: $sizeOffset;
right: $sizeOffset;
}
}
}
}
}
.elements-holder {
align-self: stretch;
h3 {
font-size: 16px;
font-weight: 500;
color: $gscaleD;
text-decoration: none;
margin: 0 0 20px 0;
&.divide {
border-top: 1px solid $gscale2;
margin-top: 10px;
padding-top: 20px;
}
}
.form-elements-list {
padding: 10px;
background: $gscale2;
border-radius: $baseRadius;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-self: flex-start;
.element-list-item {
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
> i {
color: $gscaleE;
margin:0 10px 0 0;
cursor: move;
width: 30px;
text-align: center;
}
.inner:hover,
&.focused .inner {
background-color: $seaBlue;
color: white;
font-weight: bold;
button.btn-naked {
color: $white;
}
}
.inner {
cursor: pointer;
padding: 5px 5px 5px 10px;
margin-bottom: 5px;
background: $gkscaleE;
border-radius: $baseRadius;
display: flex;
flex-direction: row;
justify-content: flex-start;
align-items: center;
align-self: stretch;
color: $gkscale2;
min-height: 46px;
flex: 1;
label {
flex: 1;
cursor: pointer;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 219px;
overflow: hidden;
}
button {
display: none;
margin-left: 5px;
}
button.btn-naked {
color: $gscale5;
}
&:hover {
label {
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
button {
display: block;
}
}
}
}
}
}
.form-element-properties {
flex: 1;
align-self: stretch;
form {
display: flex;
flex-direction: column;
h3 {
font-size: 16px;
font-weight: 500;
color: $gscaleD;
text-decoration: none;
border-bottom: 1px solid $gscale2;
margin-bottom: 20px;
padding-bottom: 10px;
align-self: stretch;
}
.divide {
margin-top: 30px;
margin-bottom: 10px;
}
.form-group {
flex-direction: row;
justify-content: flex-start;
align-items: center;
display: flex;
label {
flex-grow: 1;
flex-direction: row;
justify-content: flex-start;
align-items: center;
display: flex;
font-weight: bold;
margin-right: 10px;
}
.widget {
flex-grow: 1;
flex-direction: row;
justify-content: flex-start;
align-items: center;
display: flex;
margin: 0;
input {
flex: 1;
margin: 0;
&[disabled] {
padding: 8px 0 5px 8px;
border: 1px solid rgba(255, 255, 255, .05);
}
}
}
}
}
}
}

View File

@ -0,0 +1,155 @@
.view-content-edit.view-content-edit-text main .main-container {
.page-panel.left-panel {
flex: 1;
.form-holder {
margin: 20px 20px 20px 10px;
flex: 1;
}
}
.page-content {
flex: 2;
}
.page-panel.right-panel {
flex: 1;
}
h3.main {
font-size: 16px;
font-weight: 500;
color: $gscaleD;
text-decoration: none;
margin-top: 5px;
border-bottom: 1px solid $gscale2;
margin-bottom: 20px;
padding-bottom: 10px;
align-self: stretch;
}
.toolbar {
margin-bottom: 20px;
}
.screen-holder {
//display: flex;
//flex-direction: row;
display: flex;
flex-direction: column;
width: 100%;
position: relative;
padding-top: 56.25%; /* 16:9 aspect ratio */
overflow: hidden;
border-radius: $baseRadius;
outline: 4px solid rgba($gscaleF, .1);
background: repeating-conic-gradient(#EEE 0% 25%, white 0% 50%) 50% / 20px 20px;
.screen {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
display: flex;
.text {
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
flex: 1;
align-self: stretch;
text-align: center;
max-width: 100%;
word-break: break-all;
marquee {
display: flex;
align-items: center;
justify-content: flex-start;
flex-direction: row;
flex: 1;
height: 100%;
width: 100%;
}
}
}
}
.form-element-properties {
flex: 1;
align-self: stretch;
form {
display: flex;
flex-direction: column;
h3 {
font-size: 16px;
font-weight: 500;
color: $gscaleD;
text-decoration: none;
border-bottom: 1px solid $gscale2;
margin-bottom: 20px;
padding-bottom: 10px;
align-self: stretch;
}
.divide {
margin-top: 30px;
margin-bottom: 10px;
}
.bar {
width: 100%;
height: 1px;
background: #333;
margin-bottom: 20px;
}
.form-group {
label {
flex-grow: 1;
flex-direction: row;
justify-content: flex-start;
align-items: center;
display: flex;
font-weight: bold;
margin-right: 10px;
margin-bottom: 5px;
}
.widget {
flex-grow: 1;
flex-direction: row;
justify-content: flex-start;
align-items: center;
display: flex;
margin: 0;
input {
flex: 1;
margin: 0;
&[disabled] {
padding: 8px 0 5px 8px;
border: 1px solid rgba(255, 255, 255, .05);
}
}
}
}
}
}
}

View File

@ -23,6 +23,29 @@
.view-content-edit main .main-container {
.top-content {
h3 {
color: $gscaleF;
padding: 10px 10px 10px 0;
font-size: 16px;
align-self: stretch;
flex: 1;
text-align: right;
span {
border-width: 1px;
border-style: solid;
border-radius: $baseRadius;
padding: 4px 10px;
margin-left: 5px;
}
i {
font-size: 16px;
}
}
}
.bottom-content {
.page-content {
flex: 1;
@ -38,32 +61,11 @@
align-self: stretch;
display: flex;
flex-direction: column;
overflow: hidden;
overflow: auto;
justify-content: flex-start;
align-items: center;
padding: 20px;
h3 {
color: $gscaleF;
padding: 10px 10px 10px 0;
margin-bottom: 20px;
font-size: 16px;
align-self: stretch;
margin-left: -8px;
span {
border-width: 1px;
border-style: solid;
border-radius: $baseRadius;
padding: 4px 10px;
margin-left: 5px;
}
i {
font-size: 16px;
}
}
.iframe-wrapper {
display: flex;
flex-direction: column;
@ -87,7 +89,3 @@
}
}

View File

@ -14,6 +14,7 @@ $layoutBorder: 1px solid $gscale2;
// Packs
$colors: (
warning: $warning,
orange: $orange,
info: $info,
info-alt: $bitterBlue,
success: $success,
@ -39,6 +40,8 @@ $colors: (
redhat:$redhat,
centos:$centos,
other:$other,
gscale0:$gscale0,
gscaleF:$gscaleF,
);
// Classes

View File

@ -59,7 +59,7 @@
"js_slideshow_slide_delete_confirmation": "Are you sure?",
"slideshow_content_page_title": "Content Library",
"slideshow_content_button_add": "New Content",
"slideshow_content_referenced_in_slide_error": "Content is referenced in a slide, remove slide first",
"slideshow_content_referenced_in_slide_error": "Content '%contentName%' is referenced in a slide, remove slide first",
"slideshow_content_panel_active": "Content",
"slideshow_content_panel_empty": "Currently, there are no content. %link% now.",
"slideshow_content_panel_th_name": "Name",
@ -255,12 +255,27 @@
"common_apply": "Apply",
"common_saved": "Changes have been saved",
"common_new_folder": "New Folder",
"common_folder_not_empty_error": "Folder isn't empty, you must delete its content first",
"common_folder_not_empty_error": "Folder '%folderName%' isn't empty, you must delete its content first",
"common_copied": "Element copied in clipboard!",
"common_host_placeholder": "raspberrypi.local or 192.168.1.85",
"common_reachable_at": "Host",
"common_http_error_occured": "Error %code% occured",
"common_http_error_413": "Files are too large",
"common_width": "Width",
"common_height": "Height",
"common_position": "Position",
"common_angle": "Angle",
"common_size": "Dimensions",
"composition_elements_heading": "Elements",
"composition_element_add": "Add element",
"composition_elements_delete_all": "Delete all",
"composition_presets": "Presets",
"composition_presets_grid_2x2": "Grid 2x2",
"composition_presets_tvnews_1x1": "TV news 1x1",
"composition_monitor": "Screen",
"composition_element_x_axis": "X axis",
"composition_element_y_axis": "Y axis",
"composition_element_match_content_aspect_ratio": "Match content aspect ratio",
"logout": "Logout",
"login_error_not_found": "Bad credentials",
"login_error_bad_credentials": "Bad credentials",
@ -292,6 +307,10 @@
"enum_content_type_external_storage": "External Storage",
"enum_content_type_external_storage_object_label": "Specify an existing directory relative to the following path",
"enum_content_type_external_storage_flashdrive_label": "Path relative to a removeable device",
"enum_content_type_composition": "Composition",
"enum_content_type_composition_object_label": "Screen aspect ratio",
"enum_content_type_text": "Text",
"enum_content_type_text_object_label": "Displayed text",
"enum_content_type_url": "URL",
"enum_content_type_video": "Video",
"enum_content_type_picture": "Picture",

View File

@ -59,7 +59,7 @@
"js_slideshow_slide_delete_confirmation": "¿Estás seguro?",
"slideshow_content_page_title": "Biblioteca de contenidos",
"slideshow_content_button_add": "Nuevo Contenido",
"slideshow_content_referenced_in_slide_error": "Se hace referencia al contenido en una diapositiva; elimine la diapositiva primero",
"slideshow_content_referenced_in_slide_error": "Se hace referencia al contenido '%contentName%' en una diapositiva; elimine la diapositiva primero",
"slideshow_content_panel_active": "Contenido",
"slideshow_content_panel_empty": "Actualmente, no hay contenido. %link% ahora.",
"slideshow_content_panel_th_name": "Nombre",
@ -256,12 +256,27 @@
"common_apply": "Aplicar",
"common_saved": "Los cambios se han guardado",
"common_new_folder": "Nuevo Carpeta",
"common_folder_not_empty_error": "La carpeta no está vacía, primero debes eliminar su contenido",
"common_folder_not_empty_error": "La carpeta '%folderName%' no está vacía, primero debes eliminar su contenido",
"common_copied": "¡Elemento copiado!",
"common_host_placeholder": "raspberrypi.local o 192.168.1.85",
"common_reachable_at": "Host",
"common_http_error_occured": "Se ha producido un error %code%",
"common_http_error_413": "Los archivos son demasiado grandes",
"common_width": "Ancho",
"common_height": "Altura",
"common_position": "Posición",
"common_angle": "Ángulo",
"common_size": "Dimensiones",
"composition_elements_heading": "Elementos",
"composition_element_add": "Añadir elemento",
"composition_elements_delete_all": "Eliminar todo",
"composition_presets": "Preajustes",
"composition_presets_grid_2x2": "Cuadrícula 2x2",
"composition_presets_tvnews_1x1": "TV news 1x1",
"composition_monitor": "Pantalla",
"composition_element_x_axis": "Eje X",
"composition_element_y_axis": "Eje Y",
"composition_element_match_content_aspect_ratio": "Ajustar la escala del contenido",
"logout": "Cerrar sesión",
"login_error_not_found": "Credenciales incorrectas",
"login_error_bad_credentials": "Credenciales incorrectas",
@ -293,6 +308,10 @@
"enum_content_type_external_storage": "Almacenamiento externo",
"enum_content_type_external_storage_object_label": "Especifique un directorio existente relativo a la siguiente ruta",
"enum_content_type_external_storage_flashdrive_label": "Ruta relativa a un dispositivo extraíble",
"enum_content_type_composition": "Composición",
"enum_content_type_composition_object_label": "Relación de aspecto de la pantalla",
"enum_content_type_text": "Texto",
"enum_content_type_text_object_label": "Texto mostrado",
"enum_content_type_url": "URL",
"enum_content_type_video": "Video",
"enum_content_type_picture": "Imagen",

View File

@ -59,7 +59,7 @@
"js_slideshow_slide_delete_confirmation": "Êtes-vous sûr ?",
"slideshow_content_page_title": "Bibliothèque de contenus",
"slideshow_content_button_add": "Nouveau Contenu",
"slideshow_content_referenced_in_slide_error": "Le contenu est référencé dans une slide, supprimez d'abord la slide",
"slideshow_content_referenced_in_slide_error": "Le contenu '%contentName%' est référencé dans une slide, supprimez d'abord la slide",
"slideshow_content_panel_active": "Contenus",
"slideshow_content_panel_empty": "Actuellement, il n'y a aucun contenu. %link% maintenant.",
"slideshow_content_panel_th_name": "Nom",
@ -257,12 +257,27 @@
"common_apply": "Appliquer",
"common_saved": "Les modifications ont été enregistrées",
"common_new_folder": "Nouveau Dossier",
"common_folder_not_empty_error": "Le dossier n'est pas vide, vous devez d'abord supprimer son contenu",
"common_folder_not_empty_error": "Le dossier '%folderName%' n'est pas vide, vous devez d'abord supprimer son contenu",
"common_copied": "Element copié !",
"common_host_placeholder": "raspberrypi.local ou 192.168.1.85",
"common_reachable_at": "Hôte",
"common_http_error_occured": "Une erreur %code% est apparue",
"common_http_error_413": "Les fichiers sont trop volumineux",
"common_width": "Largeur",
"common_height": "Hauteur",
"common_position": "Position",
"common_angle": "Angle",
"common_size": "Dimensions",
"composition_elements_heading": "Éléments",
"composition_element_add": "Ajouter un élément",
"composition_elements_delete_all": "Tout supprimer",
"composition_presets": "Préréglages",
"composition_presets_grid_2x2": "Grille 2x2",
"composition_presets_tvnews_1x1": "TV news 1x1",
"composition_monitor": "Écran",
"composition_element_x_axis": "Axe X",
"composition_element_y_axis": "Axe Y",
"composition_element_match_content_aspect_ratio": "Ajuster l'échelle du contenu",
"logout": "Déconnexion",
"login_error_not_found": "Identifiants invalides",
"login_error_bad_credentials": "Identifiants invalides",
@ -294,6 +309,10 @@
"enum_content_type_external_storage": "Stockage externe",
"enum_content_type_external_storage_object_label": "Spécifiez un répertoire existant par rapport au chemin suivant",
"enum_content_type_external_storage_flashdrive_label": "Chemin relatif à un périphérique amovible",
"enum_content_type_composition": "Composition",
"enum_content_type_composition_object_label": "Rapport hauteur/largeur de l'écran",
"enum_content_type_text": "Texte",
"enum_content_type_text_object_label": "Texte affiché",
"enum_content_type_url": "URL",
"enum_content_type_video": "Vidéo",
"enum_content_type_picture": "Image",

View File

@ -59,7 +59,7 @@
"js_slideshow_slide_delete_confirmation": "Sei sicuro?",
"slideshow_content_page_title": "Libreria dei contenuti",
"slideshow_content_button_add": "Nuovo Contenuto",
"slideshow_content_referenced_in_slide_error": "Si fa riferimento al contenuto in una diapositiva, rimuovere prima la diapositiva",
"slideshow_content_referenced_in_slide_error": "Si fa riferimento al contenuto '%contentName%' in una diapositiva, rimuovere prima la diapositiva",
"slideshow_content_panel_active": "Contenuti",
"slideshow_content_panel_empty": "Attualmente non ci sono contenuti. %link% adesso.",
"slideshow_content_panel_th_name": "Nome",
@ -256,12 +256,27 @@
"common_apply": "Applica",
"common_saved": "Le modifiche sono state salvate",
"common_new_folder": "Nuovo Cartella",
"common_folder_not_empty_error": "La cartella non è vuota, devi prima eliminarne il contenuto",
"common_folder_not_empty_error": "La cartella '%folderName%' non è vuota, devi prima eliminarne il contenuto",
"common_copied": "Elemento copiato!",
"common_host_placeholder": "raspberrypi.local o 192.168.1.85",
"common_reachable_at": "Host",
"common_http_error_occured": "Si è verificato un errore %code%",
"common_http_error_413": "I file sono troppo grandi",
"common_width": "Larghezza",
"common_height": "Altezza",
"common_position": "Posizione",
"common_angle": "Angolo",
"common_size": "Dimensioni",
"composition_elements_heading": "Elementi",
"composition_element_add": "Aggiungi elemento",
"composition_elements_delete_all": "Elimina tutto",
"composition_presets": "Preimpostazioni",
"composition_presets_grid_2x2": "Griglia 2x2",
"composition_presets_tvnews_1x1": "TV news 1x1",
"composition_monitor": "Schermo",
"composition_element_x_axis": "Asse X",
"composition_element_y_axis": "Asse Y",
"composition_element_match_content_aspect_ratio": "Regola la scala del contenuto",
"logout": "Logout",
"login_error_not_found": "Credenziali errate",
"login_error_bad_credentials": "Credenziali errate",
@ -293,6 +308,10 @@
"enum_content_type_external_storage": "Archiviazione esterna",
"enum_content_type_external_storage_object_label": "Specificare una directory esistente relativi al seguente percorso",
"enum_content_type_external_storage_flashdrive_label": "Percorso relativo ad un dispositivo rimovibile",
"enum_content_type_composition": "Composizione",
"enum_content_type_composition_object_label": "Rapporto di aspetto dello schermo",
"enum_content_type_text": "Testo",
"enum_content_type_text_object_label": "Testo visualizzato",
"enum_content_type_url": "URL",
"enum_content_type_video": "Video",
"enum_content_type_picture": "Immagine",

View File

@ -22,8 +22,7 @@
{% endwith %}
{% endblock %}
{% block page %}
{% block top_page %}
<div class="top-content">
<div class="top-actions">
@ -32,7 +31,9 @@
</button>
</div>
</div>
{% endblock %}
{% block main_page %}
<div class="bottom-content">
<div class="page-content">

View File

@ -1,6 +1,6 @@
import json
from flask import Flask, render_template, redirect, request, url_for, jsonify
from flask import Flask, render_template, redirect, request, url_for, jsonify, flash
from flask_login import login_user, logout_user, current_user
from src.service.ModelStore import ModelStore
from src.model.entity.User import User
@ -26,8 +26,6 @@ class AuthController(ObController):
self._app.add_url_rule('/auth/user/delete/<user_id>', 'auth_user_delete', self.guard_auth(self._auth(self.auth_user_delete)), methods=['GET'])
def login(self):
login_error = None
if current_user.is_authenticated:
return redirect(url_for('playlist'))
@ -41,13 +39,12 @@ class AuthController(ObController):
login_user(user)
return redirect(url_for('playlist'))
else:
login_error = 'bad_credentials'
flash(self.t('login_error_bad_credentials'), 'error')
else:
login_error = 'not_found'
flash(self.t('login_error_not_found'), 'error')
return render_template(
'auth/login.jinja.html',
login_error=login_error,
last_username=request.form['username'] if 'username' in request.form else None
)
@ -67,7 +64,6 @@ class AuthController(ObController):
return render_template(
'auth/list.jinja.html',
error=request.args.get('error', None),
users=self._model_store.user().get_users(exclude=User.DEFAULT_USER if demo else None),
plugin_core_api_enabled=self._model_store.variable().map().get('plugin_core_api_enabled').as_bool()
)
@ -96,10 +92,12 @@ class AuthController(ObController):
return redirect(url_for('auth_user_list'))
if user.id == str(current_user.id):
return redirect(url_for('auth_user_list', error='auth_user_delete_cant_delete_yourself'))
flash(self.t('auth_user_delete_cant_delete_yourself'), 'error')
return redirect(url_for('auth_user_list'))
if self._model_store.user().count_all_enabled() == 1:
return redirect(url_for('auth_user_list', error='auth_user_delete_at_least_one_account'))
flash(self.t('auth_user_delete_at_least_one_account'), 'error')
return redirect(url_for('auth_user_list'))
self._model_store.user().delete(user_id)
return redirect(url_for('auth_user_list'))

View File

@ -2,11 +2,12 @@ import json
import os
import time
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify, abort
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify, abort, flash
from werkzeug.utils import secure_filename
from src.service.ModelStore import ModelStore
from src.model.entity.Content import Content
from src.model.enum.ContentType import ContentType
from src.model.enum.ContentMetadata import ContentMetadata
from src.model.enum.FolderEntity import FolderEntity, FOLDER_ROOT_PATH
from src.interface.ObController import ObController
from src.util.utils import str_to_enum, get_optional_string
@ -27,7 +28,6 @@ class ContentController(ObController):
self._app.add_url_rule('/slideshow/content/move-folder', 'slideshow_content_folder_move', self._auth(self.slideshow_content_folder_move), methods=['POST'])
self._app.add_url_rule('/slideshow/content/rename-folder', 'slideshow_content_folder_rename', self._auth(self.slideshow_content_folder_rename), methods=['POST'])
self._app.add_url_rule('/slideshow/content/delete-folder', 'slideshow_content_folder_delete', self._auth(self.slideshow_content_folder_delete), methods=['GET'])
self._app.add_url_rule('/slideshow/content/show/<content_id>', 'slideshow_content_show', self._auth(self.slideshow_content_show), methods=['GET'])
self._app.add_url_rule('/slideshow/content/upload-bulk', 'slideshow_content_upload_bulk', self._auth(self.slideshow_content_upload_bulk), methods=['POST'])
self._app.add_url_rule('/slideshow/content/delete-bulk-explr', 'slideshow_content_delete_bulk_explr', self._auth(self.slideshow_content_delete_bulk_explr), methods=['GET'])
@ -110,15 +110,26 @@ class ContentController(ObController):
if not content:
return abort(404)
vargs = {}
working_folder_path, working_folder = self.get_folder_context()
edit_view = 'slideshow/contents/edit.jinja.html'
if content.type == ContentType.COMPOSITION:
edit_view = 'slideshow/contents/edit-composition.jinja.html'
vargs['folders_tree'] = self._model_store.folder().get_folder_tree(FolderEntity.CONTENT)
vargs['foldered_contents'] = self._model_store.content().get_all_indexed('folder_id', multiple=True)
elif content.type == ContentType.TEXT:
edit_view = 'slideshow/contents/edit-text.jinja.html'
return render_template(
'slideshow/contents/edit.jinja.html',
edit_view,
content=content,
working_folder_path=working_folder_path,
working_folder=working_folder,
enum_content_type=ContentType,
external_storage_mountpoint=self._model_store.config().map().get('external_storage_mountpoint')
enum_content_metadata=ContentMetadata,
external_storage_mountpoint=self._model_store.config().map().get('external_storage_mountpoint'),
**vargs
)
def slideshow_content_save(self, content_id: int = 0):
@ -135,17 +146,19 @@ class ContentController(ObController):
)
self._post_update()
return redirect(url_for('slideshow_content_edit', content_id=content_id, saved=1))
flash(self.t('common_saved'), 'success')
return redirect(url_for('slideshow_content_edit', content_id=content_id))
def slideshow_content_delete(self):
working_folder_path, working_folder = self.get_folder_context()
error_tuple = self.delete_content_by_id(request.args.get('id'))
error = self.delete_content_by_id(request.args.get('id'))
route_args = {
"path": working_folder_path,
}
if error_tuple:
route_args[error_tuple[0]] = error_tuple[1]
if error:
flash(error, 'error')
return redirect(url_for('slideshow_content_list', **route_args))
@ -225,24 +238,16 @@ class ContentController(ObController):
def slideshow_content_folder_delete(self):
working_folder_path, working_folder = self.get_folder_context()
error_tuple = self.delete_folder_by_id(request.args.get('id'))
error = self.delete_folder_by_id(request.args.get('id'))
route_args = {
"path": working_folder_path,
}
if error_tuple:
route_args[error_tuple[0]] = error_tuple[1]
if error:
flash(self.t(error), 'error')
return redirect(url_for('slideshow_content_list', **route_args))
def slideshow_content_show(self, content_id: int = 0):
content = self._model_store.content().get(content_id)
if not content:
return abort(404)
return redirect(self._model_store.content().resolve_content_location(content))
def slideshow_content_delete_bulk_explr(self):
working_folder_path, working_folder = self.get_folder_context()
entity_ids = request.args.get('entity_ids', '').split(',')
@ -251,17 +256,17 @@ class ContentController(ObController):
for id in entity_ids:
if id:
error_tuple = self.delete_content_by_id(id)
error = self.delete_content_by_id(id)
if error_tuple:
route_args_dict[error_tuple[0]] = error_tuple[1]
if error:
flash(error, 'error')
for id in folder_ids:
if id:
error_tuple = self.delete_folder_by_id(id)
error = self.delete_folder_by_id(id)
if error_tuple:
route_args_dict[error_tuple[0]] = error_tuple[1]
if error:
flash(error, 'error')
return redirect(url_for('slideshow_content_list', **route_args_dict))
@ -272,7 +277,7 @@ class ContentController(ObController):
return None
if self._model_store.slide().count_slides_for_content(content.id) > 0:
return 'referenced_in_slide_error', content.name
return 'slideshow_content_referenced_in_slide_error'.replace('%contentName%', content.name)
self._model_store.content().delete(content.id)
self._post_update()
@ -288,7 +293,7 @@ class ContentController(ObController):
folder_counter = self._model_store.folder().count_subfolders_for_folder(folder.id)
if content_counter > 0 or folder_counter:
return 'folder_not_empty_error', folder.name
return self.t('common_folder_not_empty_error').replace('%folderName%', folder.name)
self._model_store.folder().delete(id=folder.id)
self._post_update()

View File

@ -1,6 +1,6 @@
import json
from flask import Flask, render_template, redirect, request, url_for, jsonify, abort
from flask import Flask, render_template, redirect, request, url_for, jsonify, abort, flash
from src.service.ModelStore import ModelStore
from src.model.entity.NodePlayer import NodePlayer
from src.interface.ObController import ObController
@ -108,18 +108,19 @@ class FleetNodePlayerController(ObController):
)
self._post_update()
# return redirect(url_for('fleet_node_player_edit', node_player_id=node_player_id, saved=1))
flash(self.t('common_saved'), 'success')
return redirect(url_for('fleet_node_player_list', path=working_folder_path))
def fleet_node_player_delete(self):
working_folder_path, working_folder = self.get_working_folder()
error_tuple = self.delete_node_player_by_id(request.args.get('id'))
error = self.delete_node_player_by_id(request.args.get('id'))
route_args = {
"path": working_folder_path,
}
if error_tuple:
route_args[error_tuple[0]] = error_tuple[1]
if error:
flash(error, 'error')
return redirect(url_for('fleet_node_player_list', **route_args))
@ -200,13 +201,13 @@ class FleetNodePlayerController(ObController):
def fleet_node_player_folder_delete(self):
working_folder_path, working_folder = self.get_working_folder()
error_tuple = self.delete_folder_by_id(request.args.get('id'))
error = self.delete_folder_by_id(request.args.get('id'))
route_args = {
"path": working_folder_path,
}
if error_tuple:
route_args[error_tuple[0]] = error_tuple[1]
if error:
flash(error, 'error')
return redirect(url_for('fleet_node_player_list', **route_args))
@ -218,17 +219,17 @@ class FleetNodePlayerController(ObController):
for id in entity_ids:
if id:
error_tuple = self.delete_node_player_by_id(id)
error = self.delete_node_player_by_id(id)
if error_tuple:
route_args_dict[error_tuple[0]] = error_tuple[1]
if error:
flash(error, 'error')
for id in folder_ids:
if id:
error_tuple = self.delete_folder_by_id(id)
error = self.delete_folder_by_id(id)
if error_tuple:
route_args_dict[error_tuple[0]] = error_tuple[1]
if error:
flash(error, 'error')
return redirect(url_for('fleet_node_player_list', **route_args_dict))
@ -252,7 +253,7 @@ class FleetNodePlayerController(ObController):
folder_counter = self._model_store.folder().count_subfolders_for_folder(folder.id)
if node_player_counter > 0 or folder_counter:
return 'folder_not_empty_error', folder.name
return self.t('common_folder_not_empty_error').replace('%folderName%', folder.name)
self._model_store.folder().delete(id=folder.id)

View File

@ -1,6 +1,6 @@
import json
from flask import Flask, render_template, redirect, request, url_for, jsonify
from flask import Flask, render_template, redirect, request, url_for, jsonify, flash
from src.service.ModelStore import ModelStore
from src.model.entity.NodePlayerGroup import NodePlayerGroup
from src.model.enum.FolderEntity import FolderEntity
@ -43,7 +43,6 @@ class FleetNodePlayerGroupController(ObController):
return render_template(
'fleet/player-group/list.jinja.html',
error=request.args.get('error', None),
current_player_group=current_player_group,
node_player_groups=node_player_groups,
pcounters=pcounters,
@ -86,7 +85,8 @@ class FleetNodePlayerGroupController(ObController):
def fleet_node_player_group_delete(self, player_group_id: int):
if self._model_store.node_player().count_node_players_for_group(player_group_id) > 0:
return redirect(url_for('fleet_node_player_group_list', player_group_id=player_group_id, error='node_player_group_delete_has_node_player'))
flash(self.t('node_player_group_delete_has_node_player'), 'error')
return redirect(url_for('fleet_node_player_group_list', player_group_id=player_group_id))
self._model_store.node_player_group().delete(player_group_id)
return redirect(url_for('fleet_node_player_group'))

View File

@ -28,28 +28,31 @@ class PlayerController(ObController):
self._app.add_url_rule('/player/playlist', 'player_playlist', self.player_playlist, methods=['GET'])
self._app.add_url_rule('/player/playlist/use/<playlist_slug_or_id>', 'player_playlist_use', self.player_playlist, methods=['GET'])
self._app.add_url_rule('/serve/content/<content_type>/<content_id>/<content_location>', 'serve_content_file', self.serve_content_file, methods=['GET'])
self._app.add_url_rule('/serve/content/composition/<content_id>', 'serve_content_composition', self.serve_content_composition, methods=['GET'])
def player(self, playlist_slug_or_id: str = ''):
preview_playlist = request.args.get('preview_playlist')
preview_content_id = request.args.get('preview_content_id')
playlist_id = None
playlist_slug_or_id = self._get_dynamic_playlist_id(playlist_slug_or_id)
query = " (slug = ? OR id = ?) "
query_args = {
"slug": playlist_slug_or_id,
"id": playlist_slug_or_id,
}
if not preview_content_id:
query = " (slug = ? OR id = ?) "
query_args = {
"slug": playlist_slug_or_id,
"id": playlist_slug_or_id,
}
if not preview_playlist:
query = query + " AND enabled = ? "
query_args["enabled"] = True
if not preview_playlist:
query = query + " AND enabled = ? "
query_args["enabled"] = True
current_playlist = self._model_store.playlist().get_one_by(query, query_args)
current_playlist = self._model_store.playlist().get_one_by(query, query_args)
if playlist_slug_or_id and not current_playlist:
return abort(404)
if playlist_slug_or_id and not current_playlist:
return abort(404)
playlist_id = current_playlist.id if current_playlist else None
playlist_id = current_playlist.id if current_playlist else None
try:
items = self._get_playlist(playlist_id=playlist_id, preview_content_id=preview_content_id)
@ -63,6 +66,8 @@ class PlayerController(ObController):
slide_animation_entrance_effect = request.args.get('animation_effect', self._model_store.variable().get_one_by_name('slide_animation_entrance_effect').eval())
slide_animation_exit_effect = request.args.get('slide_animation_exit_effect', self._model_store.variable().get_one_by_name('slide_animation_exit_effect').eval())
return render_template(
'player/player.jinja.html',
items=items,
@ -122,7 +127,7 @@ class PlayerController(ObController):
preview_content = self._model_store.content().get(preview_content_id) if preview_content_id else None
preview_mode = preview_content is not None
if playlist_id == 0 or not playlist_id:
if not preview_mode and (playlist_id == 0 or not playlist_id):
playlist = self._model_store.playlist().get_one_by(query="fallback = 1")
if playlist:
@ -132,8 +137,9 @@ class PlayerController(ObController):
enabled_slides = [Slide(content_id=preview_content.id, duration=1000000)] if preview_mode else self._model_store.slide().get_slides(enabled=True, playlist_id=playlist_id)
slides = self._model_store.slide().to_dict(enabled_slides)
contents = self._model_store.content().get_all_indexed()
playlist = self._model_store.playlist().get(playlist_id)
content_ids = [str(slide['content_id']) for slide in slides if slide['content_id'] is not None]
contents = self._model_store.content().get_all_indexed(query="id IN ({})".format(','.join(content_ids)))
playlist = self._model_store.playlist().get(playlist_id) if not preview_mode else None
position = 9999
playlist_loop = []
@ -248,3 +254,14 @@ class PlayerController(ObController):
response.headers['ETag'] = etag
return response
def serve_content_composition(self, content_id):
content = self._model_store.content().get(content_id)
if not content or content.type != ContentType.COMPOSITION:
abort(404, 'Content not found')
return render_template(
'player/content/composition.jinja.html',
content=content,
)

View File

@ -1,6 +1,6 @@
import json
from flask import Flask, render_template, redirect, request, url_for, jsonify, abort
from flask import Flask, render_template, redirect, request, url_for, jsonify, abort, flash
from src.service.ModelStore import ModelStore
from src.model.entity.Playlist import Playlist
from src.model.enum.FolderEntity import FolderEntity
@ -35,7 +35,6 @@ class PlaylistController(ObController):
return render_template(
'playlist/list.jinja.html',
error=request.args.get('error', None),
current_playlist=current_playlist,
playlists=playlists,
durations=durations,
@ -83,10 +82,12 @@ class PlaylistController(ObController):
abort(404)
if self._model_store.slide().count_slides_for_playlist(playlist_id) > 0:
return redirect(url_for('playlist_list', playlist_id=playlist_id, error='playlist_delete_has_slides'))
flash(self.t('playlist_delete_has_slides'), 'error')
return redirect(url_for('playlist_list', playlist_id=playlist_id))
if self._model_store.node_player_group().count_node_player_groups_for_playlist(playlist_id) > 0:
return redirect(url_for('playlist_list', playlist_id=playlist_id, error='playlist_delete_has_node_player_groups'))
flash(self.t('playlist_delete_has_node_player_groups'), 'error')
return redirect(url_for('playlist_list', playlist_id=playlist_id))
self._model_store.playlist().delete(playlist_id)
return redirect(url_for('playlist'))

View File

@ -2,7 +2,7 @@ import time
import json
import threading
from flask import Flask, render_template, redirect, request, url_for
from flask import Flask, render_template, redirect, request, url_for, flash
from typing import Optional
from src.service.ModelStore import ModelStore
@ -40,7 +40,8 @@ class SettingsController(ObController):
error = self._pre_update(request.form['id'])
if error:
return redirect(url_for('settings_variable_list', error=error))
flash(error, 'error')
return redirect(url_for('settings_variable_list'))
self._model_store.variable().update_form(request.form['id'], request.form['value'])
redirect_response = self._post_update(request.form['id'])
@ -54,7 +55,8 @@ class SettingsController(ObController):
error = self._pre_update(request.form['id'])
if error:
return redirect(url_for('settings_variable_plugin_list', error=error))
flash(error, 'error')
return redirect(url_for('settings_variable_plugin_list'))
self._model_store.variable().update_form(request.form['id'], request.form['value'])
redirect_response = self._post_update(request.form['id'])
@ -79,7 +81,8 @@ class SettingsController(ObController):
if variable.name == 'slide_upload_limit':
self.reload_web_server()
return redirect(url_for('settings_variable_list', warning='common_restart_needed'))
flash(self.t('common_restart_needed'), 'warning')
return redirect(url_for('settings_variable_list'))
if variable.name == 'fleet_player_enabled':
self.reload_web_server()
@ -98,7 +101,10 @@ class SettingsController(ObController):
thread = threading.Thread(target=self.plugin_update)
thread.daemon = True
thread.start()
return redirect(url_for('settings_variable_plugin_list', warning='common_restart_needed'))
flash(self.t('common_restart_needed'), 'warning')
return redirect(url_for('settings_variable_plugin_list'))
flash(self.t('common_saved'), 'success')
def plugin_update(self) -> None:
restart()

View File

@ -2,7 +2,7 @@ import json
import os
import time
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify, abort
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify, abort, flash
from werkzeug.utils import secure_filename
from src.service.ModelStore import ModelStore
from src.model.entity.Slide import Slide
@ -102,11 +102,11 @@ class SlideController(ObController):
return jsonify({'status': 'ok'})
def slideshow_player_refresh(self):
referrer_path = self.get_referrer_path()
max_timeout_value = self._model_store.variable().get_one_by_name('polling_interval').as_string()
flash(self.t('slideshow_slide_refresh_player_success').replace('%time%', max_timeout_value), 'success:refresh')
self._model_store.variable().update_by_name("refresh_player_request", time.time())
max_timeout_value = self._model_store.variable().get_one_by_name('polling_interval').as_int()
query_params = '{}={}'.format('refresh_player', max_timeout_value)
next_url = request.args.get('next')
return redirect('{}{}{}'.format(next_url, '&' if '?' in next_url else '?', query_params))
return redirect(referrer_path)
def _post_update(self):
self._model_store.variable().update_by_name("last_slide_update", time.time())

View File

@ -1,6 +1,7 @@
import abc
from typing import Optional, List, Dict, Union
from flask import request
from src.service.TemplateRenderer import TemplateRenderer
from src.service.ModelStore import ModelStore
from src.interface.ObPlugin import ObPlugin
@ -51,3 +52,19 @@ class ObController(abc.ABC):
def api(self):
return self._web_server.api
def get_referrer_path(self):
referer_url = request.referrer
if referer_url:
return '/' + referer_url.replace(request.host_url, '').split('?')[0]
return None
def get_referrer_rule(self):
referer_path = self.get_referrer_path()
if referer_path:
for rule in self._app.url_map.iter_rules():
if referer_path == rule.rule.split('/<')[0]:
return rule.rule
return None

View File

@ -6,6 +6,7 @@ from flask import url_for
from src.model.entity.Content import Content
from src.model.entity.Playlist import Playlist
from src.model.enum.ContentMetadata import ContentMetadata
from src.model.enum.ContentType import ContentType
from src.util.utils import get_yt_video_id
from src.manager.DatabaseManager import DatabaseManager
@ -16,7 +17,8 @@ from src.manager.VariableManager import VariableManager
from src.service.ModelManager import ModelManager
from src.util.UtilFile import randomize_filename
from src.util.UtilNetwork import get_preferred_ip_address
from src.util.UtilVideo import mp4_duration_with_ffprobe
from src.util.UtilVideo import get_video_metadata
from src.util.UtilPicture import get_picture_metadata
from src.util.utils import encode_uri_component
@ -29,6 +31,7 @@ class ContentManager(ModelManager):
"type CHAR(30)",
"location TEXT",
"duration FLOAT",
"metadata TEXT",
"folder_id INTEGER",
"created_by CHAR(255)",
"updated_by CHAR(255)",
@ -40,6 +43,12 @@ class ContentManager(ModelManager):
super().__init__(lang_manager, database_manager, user_manager, variable_manager)
self._config_manager = config_manager
self._db = database_manager.open(self.TABLE_NAME, self.TABLE_MODEL)
self.pre_migrate()
def pre_migrate(self):
if not self._variable_manager.get_one_by_name('refresh_all_metadata').as_bool():
self.refresh_all_metadata()
self._variable_manager.update_by_name('refresh_all_metadata', True)
def hydrate_object(self, raw_content: dict, id: int = None) -> Content:
if id:
@ -73,10 +82,12 @@ class ContentManager(ModelManager):
def get_all(self, sort: Optional[str] = 'created_at', ascending=False) -> List[Content]:
return self.hydrate_list(self._db.get_all(table_name=self.TABLE_NAME, sort=sort, ascending=ascending))
def get_all_indexed(self, attribute: str = 'id', multiple=False) -> Dict[str, Content]:
def get_all_indexed(self, attribute: str = 'id', multiple=False, query: str = None) -> Dict[str, Content]:
index = {}
for item in self.get_contents():
items = self.get_by(query) if query else self.get_contents()
for item in items:
id = getattr(item, attribute)
if multiple:
if id not in index:
@ -132,14 +143,15 @@ class ContentManager(ModelManager):
def post_delete(self, content_id: str) -> str:
return content_id
def update_form(self, id: int, name: str, location: Optional[str] = None) -> Optional[Content]:
def update_form(self, id: int, name: Optional[str] = None, location: Optional[str] = None, metadata: Optional[str] = None) -> Optional[Content]:
content = self.get(id)
if not content:
return
form = {
"name": name,
"name": name if isinstance(name, str) else content.name,
"metadata": metadata if isinstance(metadata, str) else content.metadata
}
if location is not None and location:
@ -198,16 +210,29 @@ class ContentManager(ModelManager):
object_path = os.path.join(upload_dir, object_name)
object.save(object_path)
content.location = object_path
if type == ContentType.VIDEO:
content.duration = mp4_duration_with_ffprobe(content.location)
self.set_metadata(content)
else:
content.location = location if location else ''
content.location = ContentType.get_initial_location(content.type, location)
self.add_form(content)
return self.get_one_by(query="uuid = '{}'".format(content.uuid))
def set_metadata(self, content: Content) -> str:
if content.type == ContentType.VIDEO:
width, height, duration = get_video_metadata(content.location)
content.duration = duration
content.set_metadata(ContentMetadata.DURATION, duration)
content.set_metadata(ContentMetadata.WIDTH, width)
content.set_metadata(ContentMetadata.HEIGHT, height)
elif content.type == ContentType.PICTURE:
width, height = get_picture_metadata(content.location)
content.set_metadata(ContentMetadata.WIDTH, width)
content.set_metadata(ContentMetadata.HEIGHT, height)
else:
content.init_metadata()
return content.metadata
def delete(self, id: int) -> None:
content = self.get(id)
@ -237,6 +262,16 @@ class ContentManager(ModelManager):
if content.type == ContentType.YOUTUBE:
location = content.location
elif content.type == ContentType.TEXT:
pass
elif content.type == ContentType.COMPOSITION:
location = "{}/{}".format(
var_external_url if len(var_external_url) > 0 else "",
url_for(
'serve_content_composition',
content_id=content.id
).strip('/')
)
elif content.has_file() or content.type == ContentType.EXTERNAL_STORAGE:
location = "{}/{}".format(
var_external_url if len(var_external_url) > 0 else "",
@ -248,6 +283,13 @@ class ContentManager(ModelManager):
).strip('/')
)
elif content.type == ContentType.URL:
location = 'http://' + content.location if not content.location.startswith('http') else content.location
location = 'http://' + content.location if content.location and not content.location.startswith('http') else content.location
return location
return location
def refresh_all_metadata(self):
for content in self.get_all():
self.update_form(
id=content.id,
metadata=self.set_metadata(content)
)

View File

@ -218,13 +218,14 @@ class UserManager:
user_id = self.get_logged_user("id")
now = time.time()
if 'created_by' not in object or not object['created_by']:
object["created_by"] = user_id
edits['created_by'] = object['created_by']
if user_id:
if 'created_by' not in object or not object['created_by']:
object["created_by"] = user_id
edits['created_by'] = object['created_by']
if 'updated_by' not in object or not object['updated_by']:
object["updated_by"] = user_id
edits['updated_by'] = object['updated_by']
if 'updated_by' not in object or not object['updated_by']:
object["updated_by"] = user_id
edits['updated_by'] = object['updated_by']
if 'created_at' not in object or not object['created_at']:
object["created_at"] = now

View File

@ -149,6 +149,7 @@ class VariableManager:
{"name": "last_restart", "value": time.time(), "type": VariableType.TIMESTAMP, "editable": False, "description": self.t('settings_variable_desc_ro_editable')},
{"name": "last_slide_update", "value": time.time(), "type": VariableType.TIMESTAMP, "editable": False, "description": self.t('settings_variable_desc_ro_last_slide_update')},
{"name": "refresh_player_request", "value": time.time(), "type": VariableType.TIMESTAMP, "editable": False, "description": self.t('settings_variable_desc_ro_refresh_player_request')},
{"name": "refresh_all_metadata", "value": False, "type": VariableType.BOOL, "editable": False, "description": None},
]
for default_var in default_vars:

View File

@ -4,15 +4,17 @@ import uuid
from typing import Optional, Union
from src.model.enum.ContentType import ContentType, ContentInputType
from src.model.enum.ContentMetadata import ContentMetadata
from src.util.utils import str_to_enum
class Content:
def __init__(self, uuid: str = '', location: str = '', type: Union[ContentType, str] = ContentType.URL, name: str = 'Untitled', id: Optional[int] = None, duration: Optional[float] = None, created_by: Optional[str] = None, updated_by: Optional[str] = None, created_at: Optional[int] = None, updated_at: Optional[int] = None, folder_id: Optional[int] = None):
def __init__(self, uuid: str = '', location: str = '', metadata: str = '', type: Union[ContentType, str] = ContentType.URL, name: str = 'Untitled', id: Optional[int] = None, duration: Optional[float] = None, created_by: Optional[str] = None, updated_by: Optional[str] = None, created_at: Optional[int] = None, updated_at: Optional[int] = None, folder_id: Optional[int] = None):
self._uuid = uuid if uuid else self.generate_and_set_uuid()
self._id = id if id else None
self._location = location
self._metadata = metadata if metadata else self.init_metadata()
self._type = str_to_enum(type, ContentType) if isinstance(type, str) else type
self._name = name
self._folder_id = folder_id
@ -39,6 +41,14 @@ class Content:
def uuid(self, value: str):
self._uuid = value
@property
def metadata(self) -> str:
return self._metadata
@metadata.setter
def metadata(self, value: str):
self._metadata = value
@property
def location(self) -> str:
return self._location
@ -124,6 +134,7 @@ class Content:
f"updated_at='{self.updated_at}',\n" \
f"folder_id='{self.folder_id}',\n" \
f"duration='{self.duration}',\n" \
f"metadata='{self.metadata}',\n" \
f")"
def to_json(self, edits: dict = {}) -> str:
@ -147,6 +158,7 @@ class Content:
"updated_at": self.updated_at,
"folder_id": self.folder_id,
"duration": self.duration,
"metadata": self.metadata,
}
if with_virtual:
@ -165,3 +177,32 @@ class Content:
def is_editable(self) -> bool:
return ContentInputType.is_editable(self.get_input_type())
def init_metadata(self):
self.metadata = '{}'
return self.metadata
def get_metadata(self, key: ContentMetadata, default=''):
if not self.metadata:
self.init_metadata()
metadata_obj = json.loads(self.metadata)
return metadata_obj.get(key.value, default)
def set_metadata(self, key: ContentMetadata, value=None):
if not self.metadata:
self.init_metadata()
metadata_obj = json.loads(self.metadata)
metadata_obj[key.value] = value
self.metadata = json.dumps(metadata_obj)
def clear_metadata(self, key: ContentMetadata):
if not self.metadata:
self.init_metadata()
metadata_obj = json.loads(self.metadata)
if key.value in metadata_obj:
del metadata_obj[key.value]
self.metadata = json.dumps(metadata_obj)

View File

@ -0,0 +1,8 @@
from enum import Enum
class ContentMetadata(Enum):
DURATION = 'duration'
WIDTH = 'width'
HEIGHT = 'height'

View File

@ -1,3 +1,4 @@
import json
import mimetypes
from enum import Enum
@ -10,7 +11,9 @@ class ContentInputType(Enum):
UPLOAD = 'upload'
TEXT = 'text'
HIDDEN = 'hidden'
STORAGE = 'storage'
COMPOSITION = 'composition'
@staticmethod
def is_editable(value: Enum) -> bool:
@ -20,6 +23,8 @@ class ContentInputType(Enum):
return True
elif value == ContentInputType.STORAGE:
return True
elif value == ContentInputType.COMPOSITION:
return True
class ContentType(Enum):
@ -29,6 +34,8 @@ class ContentType(Enum):
YOUTUBE = 'youtube'
VIDEO = 'video'
EXTERNAL_STORAGE = 'external_storage'
COMPOSITION = 'composition'
TEXT = 'text'
@staticmethod
def guess_content_type_file(filename: str):
@ -61,6 +68,10 @@ class ContentType(Enum):
return ContentInputType.TEXT
elif value == ContentType.EXTERNAL_STORAGE:
return ContentInputType.STORAGE
elif value == ContentType.COMPOSITION:
return ContentInputType.COMPOSITION
elif value == ContentType.TEXT:
return ContentInputType.TEXT
@staticmethod
def get_fa_icon(value: Union[Enum, str]) -> str:
@ -77,6 +88,10 @@ class ContentType(Enum):
return 'fa-link'
elif value == ContentType.EXTERNAL_STORAGE:
return 'fa-brands fa-usb'
elif value == ContentType.COMPOSITION:
return 'fa-solid fa-clone'
elif value == ContentType.TEXT:
return 'fa-solid fa-font'
return 'fa-file'
@ -95,5 +110,39 @@ class ContentType(Enum):
return 'danger'
elif value == ContentType.EXTERNAL_STORAGE:
return 'other'
elif value == ContentType.COMPOSITION:
return 'purple'
elif value == ContentType.TEXT:
return 'gscaleF'
return 'neutral'
@staticmethod
def get_initial_location(value: Enum, location: Optional[str] = None) -> str:
if isinstance(value, str):
value = str_to_enum(value, ContentType)
if value == ContentType.COMPOSITION:
return json.dumps({
"ratio": location if location else '16/9',
"layers": {}
})
elif value == ContentType.TEXT:
return json.dumps({
"textLabel": location if location else 'Hello',
"fontSize": 20,
"color": '#FFFFFFFF',
"fontFamily": "Arial",
"fontBold": None,
"fontItalic": None,
"fontUnderline": None,
"textAlign": "center",
"backgroundColor": '#000000FF',
"scrollEnable": False,
"scrollDirection": "left",
"scrollSpeed": "10",
"singleLine": False,
"margin": 0
})
return location

View File

@ -1,6 +1,10 @@
try:
import psutil
except:
pass
import os
import platform
import psutil
import socket
from src.util.utils import get_working_directory

View File

@ -39,9 +39,9 @@ class TemplateRenderer:
AUTH_ENABLED=self._model_store.variable().map().get('auth_enabled').as_bool(),
last_pillmenu_slideshow=self._model_store.variable().map().get('last_pillmenu_slideshow').as_string(),
last_pillmenu_configuration=self._model_store.variable().map().get('last_pillmenu_configuration').as_string(),
external_url=self._model_store.variable().map().get('external_url').as_string().strip('/'),
last_pillmenu_fleet=self._model_store.variable().map().get('last_pillmenu_fleet').as_string(),
last_pillmenu_security=self._model_store.variable().map().get('last_pillmenu_security').as_string(),
external_url=self._model_store.variable().map().get('external_url').as_string(),
track_created=self._model_store.user().track_user_created,
track_updated=self._model_store.user().track_user_updated,
PORT=self._model_store.config().map().get('port'),
@ -54,6 +54,7 @@ class TemplateRenderer:
is_cron_in_datetime_moment=is_cron_in_datetime_moment,
is_cron_in_week_moment=is_cron_in_week_moment,
json_dumps=json.dumps,
json_loads=json.loads,
merge_dicts=merge_dicts,
dictsort=dictsort,
truncate=truncate,

View File

@ -50,7 +50,6 @@ class WebServer:
host=self._model_store.config().map().get('bind'),
port=self._model_store.config().map().get('port'),
threads=100,
max_request_body_size=self.get_max_upload_size(),
)
def reload(self) -> None:
@ -58,6 +57,7 @@ class WebServer:
def setup(self) -> None:
self._setup_flask_app()
self._setup_flask_headers()
self._setup_web_globals()
self._setup_web_errors()
self._setup_web_controllers()
@ -160,6 +160,19 @@ class WebServer:
def inject_global_vars() -> dict:
return self._template_renderer.get_view_globals()
def _setup_flask_headers(self) -> None:
@self._app.after_request
def modify_headers(response):
# Supprimer les en-têtes de sécurité
response.headers.pop('X-Frame-Options', None)
response.headers.pop('X-Content-Type-Options', None)
response.headers.pop('X-XSS-Protection', None)
# Modifier ou supprimer la politique CSP
response.headers['Content-Security-Policy'] = "frame-ancestors *"
return response
def _setup_web_errors(self) -> None:
def handle_error(error):
if request.headers.get('Content-Type') == 'application/json' or request.headers.get('Accept') == 'application/json':
@ -171,14 +184,16 @@ class WebServer:
})
return make_response(response, error.code)
if error.code == 404:
return send_from_directory(self.get_template_dir(), 'core/error404.html'), 404
return error
return self._template_renderer.render_view(
'@core/http-error.html',
error_code=error.code,
error_message=error.description
)
self._app.register_error_handler(400, handle_error)
self._app.register_error_handler(404, handle_error)
self._app.register_error_handler(409, handle_error)
self._app.register_error_handler(413, handle_error)
self._app.register_error_handler(HttpClientException, handle_error)

View File

@ -1,5 +1,9 @@
try:
import psutil
except:
pass
import os
import psutil
import platform
import logging
import os

133
src/util/UtilPicture.py Normal file
View File

@ -0,0 +1,133 @@
import struct
import imghdr
import os
def get_png_size(file):
file.seek(16)
width, height = struct.unpack('>ii', file.read(8))
return width, height
def get_jpeg_size(file):
file.seek(0)
size = 2
ftype = 0
while not 0xC0 <= ftype <= 0xCF:
file.seek(size, 1)
byte = file.read(1)
while ord(byte) == 0xFF:
byte = file.read(1)
ftype = ord(byte)
size = struct.unpack('>H', file.read(2))[0] - 2
file.seek(1, 1)
height, width = struct.unpack('>HH', file.read(4))
return width, height
def get_gif_size(file):
file.seek(6)
width, height = struct.unpack('<HH', file.read(4))
return width, height
def get_webp_size(file):
file.seek(12)
chunk_header = file.read(8)
while chunk_header:
chunk_size = struct.unpack('<I', chunk_header[4:])[0]
if chunk_header[0:4] == b'VP8 ':
vp8_header = file.read(10)
width, height = struct.unpack('<HH', vp8_header[6:10])
return width, height
elif chunk_header[0:4] == b'VP8L':
vp8l_header = file.read(5)
b1, b2, b3, b4 = struct.unpack('<BBBB', vp8l_header[1:])
width = (b1 | ((b2 & 0x3F) << 8)) + 1
height = ((b2 >> 6) | (b3 << 2) | ((b4 & 0x0F) << 10)) + 1
return width, height
elif chunk_header[0:4] == b'VP8X':
vp8x_header = file.read(10)
width, height = struct.unpack('<HH', vp8x_header[4:8])
width = (width + 1) & 0xFFFFFF
height = (height + 1) & 0xFFFFFF
return width, height
else:
file.seek(chunk_size, 1)
chunk_header = file.read(8)
raise ValueError("Not a valid WebP file")
def get_bmp_size(file):
file.seek(18)
width, height = struct.unpack('<ii', file.read(8))
return width, height
def get_tiff_size(file):
file.seek(0)
byte_order = file.read(2)
if byte_order == b'II':
endian = '<'
elif byte_order == b'MM':
endian = '>'
else:
raise ValueError("Not a valid TIFF file")
file.seek(4)
offset = struct.unpack(endian + 'I', file.read(4))[0]
file.seek(offset)
while True:
num_tags = struct.unpack(endian + 'H', file.read(2))[0]
for _ in range(num_tags):
tag = file.read(12)
if struct.unpack(endian + 'H', tag[:2])[0] == 256:
width = struct.unpack(endian + 'I', tag[8:])[0]
elif struct.unpack(endian + 'H', tag[:2])[0] == 257:
height = struct.unpack(endian + 'I', tag[8:])[0]
next_ifd_offset = struct.unpack(endian + 'I', file.read(4))[0]
if next_ifd_offset == 0:
break
file.seek(next_ifd_offset)
return width, height
def get_ico_size(file):
file.seek(6)
num_images = struct.unpack('<H', file.read(2))[0]
largest_size = (0, 0)
for _ in range(num_images):
width, height = struct.unpack('<BB', file.read(2))
width = width if width != 0 else 256
height = height if height != 0 else 256
if width * height > largest_size[0] * largest_size[1]:
largest_size = (width, height)
file.seek(14, 1) # Skip over the rest of the directory entry
return largest_size
def get_picture_metadata(image_path):
# Determine the image type using imghdr and file extension as a fallback
img_type = imghdr.what(image_path)
if not img_type:
_, ext = os.path.splitext(image_path)
img_type = ext.lower().replace('.', '')
with open(image_path, 'rb') as file:
if img_type == 'png':
return get_png_size(file)
elif img_type == 'jpeg' or img_type == 'jpg':
return get_jpeg_size(file)
elif img_type == 'gif':
return get_gif_size(file)
elif img_type == 'webp':
return get_webp_size(file)
elif img_type == 'bmp':
return get_bmp_size(file)
elif img_type == 'tiff' or img_type == 'tif':
return get_tiff_size(file)
elif img_type == 'ico':
return get_ico_size(file)
else:
raise ValueError("Unsupported image format or corrupted file")

View File

@ -5,7 +5,7 @@ import json
from pymediainfo import MediaInfo
def mp4_duration_with_ffprobe(filename):
def get_video_metadata(filename):
try:
result = subprocess.check_output(f'ffprobe -v quiet -show_streams -select_streams v:0 -of json "{filename}"', shell=True).decode()
fields = json.loads(result)['streams'][0]
@ -16,7 +16,10 @@ def mp4_duration_with_ffprobe(filename):
elif 'duration' in fields:
duration = round(float(fields['duration']), 2)
return duration
width = fields.get('width', 0)
height = fields.get('height', 0)
return width, height, duration
except (subprocess.CalledProcessError, FileNotFoundError):
logging.warn("ffprobe not found or an error occurred. Using pymediainfo instead.")
@ -25,11 +28,13 @@ def mp4_duration_with_ffprobe(filename):
for track in media_info.tracks:
if track.track_type == "Video":
duration = round(track.duration / 1000, 2) if track.duration else None
width = track.width
height = track.height
return duration
return width, height, duration
except OSError:
logging.warn("Fail to get video metadata from pymediainfo.")
except json.JSONDecodeError:
logging.warn("Fail to get video metadata from ffprobe.")
return 0
return 0, 0, 0

View File

@ -1,3 +1,9 @@
#!/bin/bash
# Configuration
STUDIO_URL=http://localhost:5000 # Main Obscreen Studio instance URL (could be a specific playlist /use/[playlist-id] or let obscreen manage playlist routing with /)
TARGET_RESOLUTION=auto # e.g. 1920x1080 - Force specific resolution (supported list available with command `DISPLAY=:0 xrandr`)
# Disable screensaver and DPMS
xset s off
xset -dpms
@ -7,10 +13,15 @@ xset s noblank
unclutter -display :0 -noevents -grab &
# Modify Chromium preferences to avoid restore messages
mkdir -p /home/pi/.config/chromium/Default 2>/dev/null
touch /home/pi/.config/chromium/Default/Preferences
sed -i 's/"exited_cleanly": false/"exited_cleanly": true/' /home/pi/.config/chromium/Default/Preferences
mkdir -p /tmp/obscreen/chromium/Default 2>/dev/null
touch /tmp/obscreen/chromium/Default/Preferences
sed -i 's/"exited_cleanly": false/"exited_cleanly": true/' /tmp/obscreen/chromium/Default/Preferences
# Resolution setup
if [ "$TARGET_RESOLUTION" != "auto" ]; then
FIRST_CONNECTED_SCREEN=$(xrandr | grep " connected" | awk '{print $1}' | head -n 1)
xrandr --output $FIRST_CONNECTED_SCREEN --mode $TARGET_RESOLUTION
fi
RESOLUTION=$(DISPLAY=:0 xrandr | grep '*' | awk '{print $1}')
WIDTH=$(echo $RESOLUTION | cut -d 'x' -f 1)
HEIGHT=$(echo $RESOLUTION | cut -d 'x' -f 2)
@ -29,9 +40,9 @@ chromium-browser \
--noerrdialogs \
--kiosk \
--incognito \
--user-data-dir=/home/pi/.config/chromium \
--user-data-dir=/tmp/obscreen/chromium \
--no-sandbox \
--window-position=0,0 \
--window-size=${WIDTH},${HEIGHT} \
--display=:0 \
http://localhost:5000
${STUDIO_URL}

View File

@ -44,7 +44,7 @@ chown -R $OWNER:$OWNER ./
# Automount script for external storage
# ============================================================
curl https://raw.githubusercontent.com/jr-k/obscreen/master/system/external-storage/10-obscreen-media-automount.rules | sed "s#/home/pi#$WORKING_DIR#g" | tee /etc/udev/rules.d/10-obscreen-media-automount.rules
curl https://raw.githubusercontent.com/jr-k/obscreen/master/system/external-storage/10-obscreen-media-automount.rules | sed "s#/home/pi#$WORKING_DIR#g" | tee /etc/udev/rules.d/10-obscreen-media-automount.rules
udevadm control --reload-rules
systemctl restart udev
udevadm trigger

View File

@ -15,8 +15,7 @@
{% block body_class %}view-auth-user-list{% endblock %}
{% block page %}
{% block top_page %}
<div class="top-content">
<div class="top-actions">
{{ HOOK(H_AUTH_TOOLBAR_ACTIONS_START) }}
@ -26,12 +25,9 @@
{{ HOOK(H_AUTH_TOOLBAR_ACTIONS_END) }}
</div>
</div>
{% endblock %}
{% if error %}
<div class="alert alert-danger">
{{ l[error] }}
</div>
{% endif %}
{% block main_page %}
<div class="bottom-content">
<div class="page-content">

View File

@ -14,14 +14,7 @@
{% block body_class %}view-login{% endblock %}
{% block page %}
{% if login_error %}
<div class="alert alert-error">
{{ t('login_error_' ~ login_error) }}
</div>
{% endif %}
{% block main_page %}
<div class="login-content">
<div class="form-holder">
<div class="card">

View File

@ -180,14 +180,14 @@
<div class="context-divider"></div>
<div class="{% if not AUTH_ENABLED %}contex-tail{% endif %}">
<div class="{% if not AUTH_ENABLED %}context-tail{% else %}context-tail-auth{% endif %}">
<a href="{{ url_for('slideshow_player_refresh', next=request.full_path) }}" class="btn btn-naked btn-double-icon">
<i class="fa fa-display main"></i>
<sub><i class="fa fa-refresh"></i></sub>
</a>
</div>
{% if fully_authenticated_view %}
{% if fully_authenticated_view and AUTH_ENABLED %}
<div class="context-divider"></div>
<div class="context-user">
<div class="dropdown">
@ -213,14 +213,41 @@
</div>
{% endif %}
<div class="main-container">
{% if request.args.get('refresh_player') %}
<div class="alert alert-success">
<i class="fa fa-refresh icon-left"></i>
{{ l.slideshow_slide_refresh_player_success|replace('%time%', request.args.get('refresh_player')) }}
</div>
{% endif %}
{% block top_page %}{% endblock %}
{% block page %}{% endblock %}
{% block toolbar_page %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
{% set alert_class = '' %}
{% set alert_icon = '' %}
{% if category == 'success' %}
{% set alert_class = 'success' %}
{% set alert_icon = 'fa-check' %}
{% elif category == 'success:refresh' %}
{% set alert_class = 'success' %}
{% set alert_icon = 'fa-refresh' %}
{% elif category == 'error' %}
{% set alert_class = 'danger' %}
{% set alert_icon = 'fa-warning' %}
{% elif category == 'warning' %}
{% set alert_class = 'yellow' %}
{% set alert_icon = 'fa-warning' %}
{% endif %}
{% if alert_class %}
<div class="alert alert-{{ alert_class }} alert-timeout">
<i class="fa {{ alert_icon }} icon-left"></i>
{{ message }}
</div>
{% endif %}
{% endfor %}
{% endif %}
{% endwith %}
{% endblock %}
{% block main_page %}{% endblock %}
</div>
</main>
@ -229,6 +256,7 @@
<script>
var secret_key = '{{ SECRET_KEY }}';
var l = {
'js_common_empty': '{{ l.common_empty }}',
'js_common_are_you_sure': '{{ l.common_are_you_sure }}',
'js_playlist_delete_confirmation': '{{ l.js_playlist_delete_confirmation }}',
'js_slideshow_slide_delete_confirmation': '{{ l.js_slideshow_slide_delete_confirmation }}',
@ -243,6 +271,7 @@
};
</script>
<script src="{{ STATIC_PREFIX }}js/lib/jquery.min.js"></script>
<script src="{{ STATIC_PREFIX }}js/lib/jquery-more.js"></script>
<script src="{{ STATIC_PREFIX }}js/utils.js"></script>
<script src="{{ STATIC_PREFIX }}js/global.js"></script>
{{ HOOK(H_ROOT_JAVASCRIPT) }}

View File

@ -10,8 +10,7 @@
{% block body_class %}view-logs-list{% endblock %}
{% block page %}
{% block main_page %}
<div class="bottom-content">
<div class="page-content">
<div class="inner">

View File

@ -11,20 +11,7 @@
{% block body_class %}view-plugins-list{% endblock %}
{% block page %}
{% if request.args.get('error') %}
<div class="alert alert-error">
{{ t(request.args.get('error')) }}
</div>
{% endif %}
{% if request.args.get('info') %}
<div class="alert alert-info">
{{ t(request.args.get('info')) }}
</div>
{% endif %}
{% block main_page %}
<div class="bottom-content">
<div class="page-content">
<div class="inner">

View File

@ -11,20 +11,7 @@
{% block body_class %}view-settings-list{% endblock %}
{% block page %}
{% if request.args.get('error') %}
<div class="alert alert-error">
{{ t(request.args.get('error')) }}
</div>
{% endif %}
{% if request.args.get('warning') %}
<div class="alert alert-yellow">
<i class="fa fa-warning"></i>{{ t(request.args.get('warning')) }}
</div>
{% endif %}
{% block main_page %}
<div class="bottom-content">
<div class="page-content">
<div class="inner">
@ -42,6 +29,4 @@
</div>
</div>
</div>
{% endblock %}

View File

@ -14,7 +14,7 @@
{% block body_class %}view-sysinfo-list{% endblock %}
{% block page %}
{% block top_page %}
<div class="top-content">
<div class="top-actions align-right">
{{ HOOK(H_SYSINFO_TOOLBAR_ACTIONS_START) }}
@ -24,7 +24,9 @@
{{ HOOK(H_SYSINFO_TOOLBAR_ACTIONS_END) }}
</div>
</div>
{% endblock %}
{% block main_page %}
<div class="bottom-content">
<div class="page-content">
<div class="inner">

File diff suppressed because one or more lines are too long

View File

@ -6,13 +6,10 @@
{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{{ STATIC_PREFIX }}css/lib/flatpickr.min.css"/>
<link rel="stylesheet" href="{{ STATIC_PREFIX }}css/lib/jquery-explr-1.4.css"/>
{{ HOOK(H_SLIDESHOW_CONTENT_CSS) }}
{% endblock %}
{% block add_js %}
<script src="{{ STATIC_PREFIX }}js/lib/jquery-explr-1.4.js"></script>
<script src="{{ STATIC_PREFIX }}js/fleet/node-players.js"></script>
{{ HOOK(H_SLIDESHOW_CONTENT_JAVASCRIPT) }}
@ -20,20 +17,15 @@
{% block body_class %}view-node-player-edit edit-page{% endblock %}
{% block page %}
{% block top_page %}
<div class="top-content">
<h1>
{{ l.fleet_node_player_form_edit_title }}
</h1>
</div>
{% endblock %}
{% if request.args.get('saved') %}
<div class="alert alert-success alert-timeout">
<i class="fa fa-check icon-left"></i>
{{ l.common_saved }}
</div>
{% endif %}
{% block main_page %}
<div class="bottom-content">
<div class="page-content">
<div class="inner dirview">

View File

@ -20,9 +20,7 @@
{% block body_class %}view-node-player-list{% endblock %}
{% block page %}
{% set explr_limit_chars = 35 %}
{% block top_page %}
<div class="top-content">
<div class="top-actions">
{{ HOOK(H_FLEET_NODE_PLAYER_TOOLBAR_ACTIONS_START) }}
@ -61,20 +59,10 @@
{{ HOOK(H_FLEET_NODE_PLAYER_TOOLBAR_ACTIONS_END) }}
</div>
</div>
{% endblock %}
{% if request.args.get('folder_not_empty_error') %}
<div class="alert alert-danger">
<i class="fa fa-warning icon-left"></i>
{{ l.common_folder_not_empty_error }}
</div>
{% endif %}
{% if request.args.get('referenced_in_node_player_group_error') %}
<div class="alert alert-danger">
<i class="fa fa-warning icon-left"></i>
{{ l.fleet_node_player_referenced_in_node_player_group_error }}
</div>
{% endif %}
{% block main_page %}
{% set explr_limit_chars = 35 %}
<div class="bottom-content">
<div class="page-panel left-panel explr-explorer">

View File

@ -2,11 +2,11 @@
<h2>
{{ l.common_pick_element }}
</h2>
{% with use_href=False %}
{% include 'fleet/node-players/component/explr-sidebar.jinja.html' %}
{% endwith %}
<div class="actions">
<button type="button" class="btn btn-naked picker-close">
<i class="fa fa-close icon-left"></i>{{ l.common_close }}

View File

@ -19,7 +19,7 @@
{% block body_class %}view-player-group-list{% endblock %}
{% block page %}
{% block top_page %}
<div class="top-content">
<div class="top-actions">
{{ HOOK(H_FLEET_NODE_PLAYER_GROUP_TOOLBAR_ACTIONS_START) }}
@ -46,13 +46,9 @@
('<a href="javascript:void(0);" class="item-add node-player-group-add">'~l.fleet_node_player_group_button_add~'</a>')|safe
) }}
</div>
{% endblock %}
{% if error %}
<div class="alert alert-danger">
{{ l[error] }}
</div>
{% endif %}
{% block main_page %}
<div class="bottom-content">
<div class="page-panel left-panel">
{% with node_player_groups=node_player_groups %}

View File

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html>
<head>
{% set preview_mode = request.args.get('preview') == '1' %}
<style>
html, body, #screen {
margin: 0;
padding: 0;
background: black;
width: 100vw;
height: 100vh;
overflow: hidden;
}
iframe {
border: none;
outline: none;
}
{% if preview_mode %}
html, body, #screen {
display: flex;
justify-content: center;
align-items: center
}
#screen {
width: 1280px;
height: 720px;
outline: 5px solid white;
}
{% endif %}
</style>
</head>
<body>
<div id="screen"></div>
<script src="{{ STATIC_PREFIX }}js/lib/jquery.min.js"></script>
<script src="{{ STATIC_PREFIX }}js/utils.js"></script>
<script>
const contentData = JSON.parse({{ json_dumps(content.location) | safe }});
const baseIframeRoute = '{{ url_for('player', preview_content_id='!c!', autoplay=1, cover=1, transparent=1) }}';
jQuery(function($) {
function setOptimalSize() {
const ratio = evalStringRatio(contentData.ratio);
const bodyWidth = $('body').width() - 100;
const bodyHeight = $('body').height() - 100;
let width = bodyWidth;
let height = bodyWidth / ratio;
if (height > bodyHeight) {
height = bodyHeight;
width = bodyHeight * ratio;
}
const screenSizes = {
width: Math.floor(width),
height: Math.floor(height)
};
$('#screen').css({
'width': screenSizes.width,
'height': screenSizes.height,
});
}
function createElement(config = null) {
const screen = $('#screen');
const offsetX = screen.position().left;
const offsetY = screen.position().top;
const screenWidth = screen.width();
const screenHeight = screen.height();
const elementWidth = (config.widthPercent / 100) * screenWidth
const elementHeight = (config.heightPercent / 100) * screenHeight;
let x = offsetX + (config.xPercent / 100) * screenWidth;
let y = offsetY + (config.yPercent / 100) * screenHeight;
const zIndex = config.zIndex;
//x = Math.round(Math.max(0, Math.min(x, screenWidth - elementWidth)));
//y = Math.round(Math.max(0, Math.min(y, screenHeight - elementHeight)));
const element = $('<iframe class="element" id="element-' + zIndex + '" data-id="' + zIndex + '" src="'+baseIframeRoute.replace('!c!', config.contentId)+'" frameborder="0" allowtransparency="1"></iframe>');
element.css({
left: x,
top: y,
width: elementWidth,
height: elementHeight,
zIndex: zIndex,
display: 'block',
position: 'absolute',
transform: `rotate(0deg)`
});
screen.append(element);
}
const applyElementsFromContent = function() {
$('#screen').html('');
for (let i = 0; i < contentData.layers.length; i++) {
if (contentData.layers[i].contentId !== null) {
createElement(contentData.layers[i]);
}
}
};
const main = function() {
{% if preview_mode %}
setOptimalSize();
{% endif %}
applyElementsFromContent();
};
$(window).on('resize', function() {
main();
});
main();
});
</script>
</body>
</html>

View File

@ -5,17 +5,37 @@
<meta name="robots" content="noindex, nofollow">
<meta name="google" content="notranslate">
<link rel="shortcut icon" href="{{ STATIC_PREFIX }}/favicon.ico">
{% set force_cover = request.args.get('cover') == '1' %}
{% set transparent = request.args.get('transparent') == '1' %}
{% if slide_animation_enabled %}
<link rel="stylesheet" href="{{ STATIC_PREFIX }}css/lib/animate.min.css" />
{% endif %}
<style>
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background-color: white; display: flex; flex-direction: row; justify-content: center; align-items: center; }
.slide { display: flex; flex-direction: row; justify-content: center; align-items: center; background: black; }
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background-color: {{ 'transparent' if transparent else 'white' }}; display: flex; flex-direction: row; justify-content: center; align-items: center; }
.slide { display: flex; flex-direction: row; justify-content: center; align-items: center; background: {{ 'transparent' if transparent else 'black' }}; }
.slide, iframe { position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: none; padding-top: 0; box-sizing: border-box; -moz-box-sizing: border-box; -webkit-box-sizing: border-box; }
.slide iframe { background: white; }
.slide img, .slide video { height: 100%; }
.slide img, .slide video { {% if force_cover %}width: 100%;{% endif %} height: 100%; }
.slide .text-holder { width: 100%; height: 100%; display: flex; flex-direction: row; justify-content: center; align-items: center; box-sizing: border-box; }
.slide .text {
width: 100%; height: 100%; color: white; font-family: 'Arial', 'sans-serif'; font-size: 20px; display: flex; flex-direction: row; justify-content: center; align-items: center;
word-break: break-all; flex: 1; align-self: stretch; text-align: center; max-width: 100%; box-sizing: border-box;
}
.slide video { width: 100%; height: 100%; }
@keyframes blink{50%{opacity:0;}}
.cfx-blink{animation:1.5s linear infinite blinker;}
.cfx-ffff-speed {animation-delay: 0.1s;}
.cfx-fff-speed {animation-delay: 0.3s;}
.cfx-ff-speed {animation-delay: 0.5s;}
.cfx-f-speed {animation-delay: 0.8s;}
.cfx-m-speed {animation-delay: 1s;}
.cfx-s-speed {animation-delay: 1.3s;}
.cfx-ss-speed {animation-delay: 1.5s;}
.cfx-sss-speed {animation-delay: 1.8s;}
.cfx-ssss-speed {animation-delay: 2s;}
.cfx-sssss-speed {animation-delay: 3s;}
</style>
<script type="application/javascript" src="{{ STATIC_PREFIX }}js/lib/jquery.min.js"></script>
<script type="application/javascript" src="{{ STATIC_PREFIX }}js/utils.js"></script>
<script type="application/javascript" src="{{ STATIC_PREFIX }}js/lib/is-cron-now.js"></script>
</head>
@ -47,6 +67,7 @@
// Frontend config
const syncWithTime = items['time_sync'];
const previewMode = items['preview_mode'];
const disableAutoplay = {{ 'false' if request.args.get('autoplay') == '1' else 'previewMode' }};
const tickRefreshResolutionMs = 100;
// Frontend flag updates
@ -339,12 +360,18 @@
case 'picture':
loadPicture(element, callbackReady, item);
break;
case 'text':
loadText(element, callbackReady, item);
break;
case 'video':
loadVideo(element, callbackReady, item);
break;
case 'youtube':
loadYoutube(element, callbackReady, item);
break;
case 'composition':
loadComposition(element, callbackReady, item);
break;
default:
loadUrl(element, callbackReady, item);
break;
@ -361,6 +388,65 @@
callbackReady(function() {});
};
const loadText = function(element, callbackReady, item) {
const contentData = JSON.parse(item.location);
const $textHolder = $('<div class="text-holder">');
const $text = $('<div class="text">');
let insideText = contentData.textLabel;
if (contentData.scrollEnable) {
const $wrapper = $('<marquee>');
$wrapper.attr({ scrollamount: contentData.scrollSpeed, direction: contentData.scrollDirection, behavior: 'scroll', loop: -1 });
$wrapper.append(insideText);
insideText = $wrapper;
}
$text.append(insideText);
let justifyContent = 'center';
switch(contentData.textAlign) {
case 'left': justifyContent = 'flex-start'; break;
case 'right': justifyContent = 'flex-end'; break;
}
$text.css({
padding: contentData.margin + 'px',
color: contentData.color,
textAlign: contentData.textAlign,
textDecoration: contentData.textUnderline ? 'underline' : 'normal',
fontSize: contentData.fontSize + 'px',
fontWeight: contentData.fontBold ? 'bold' : 'normal',
fontStyle: contentData.fontItalic ? 'italic' : 'normal',
fontFamily: contentData.fontFamily + ", 'Arial', 'sans-serif'",
whiteSpace: contentData.singleLine ? 'nowrap' : 'normal',
justifyContent: justifyContent
});
$textHolder.css({ backgroundColor: contentData.backgroundColor }).html($text);
element.innerHTML = $('<div>').html($textHolder).html();
callbackReady(function() {});
};
const loadComposition = function(element, callbackReady, item) {
element.innerHTML = `composition`;
callbackReady(function() {});
const loadingDelayMs = 1000;
let delayNoisyContentJIT = Math.max(100, (lookupCurrentItem().duration * 1000) - loadingDelayMs);
delayNoisyContentJIT = lookupCurrentItem().id !== item.id ? delayNoisyContentJIT : 0;
const autoplayLoader = function() {
if (secondsBeforeNext * 1000 > loadingDelayMs) {
return setTimeout(autoplayLoader, 500);
}
if (element.innerHTML === 'composition') {
element.innerHTML = `<iframe src="${item.location}" frameborder="0" allow="autoplay" allowfullscreen></iframe>`;
}
}
setTimeout(autoplayLoader, delayNoisyContentJIT);
};
const loadYoutube = function(element, callbackReady, item) {
element.innerHTML = `youtube`;
callbackReady(function() {});
@ -375,7 +461,7 @@
}
if (element.innerHTML === 'youtube') {
const autoplay = previewMode ? '0' : '1';
const autoplay = disableAutoplay ? '0' : '1';
element.innerHTML = `<iframe src="https://www.youtube.com/embed/${item.location}?version=3&autoplay=${autoplay}&showinfo=0&controls=0&modestbranding=1&fs=1&rel=0" frameborder="0" allow="autoplay" allowfullscreen></iframe>`;
}
}
@ -383,7 +469,7 @@
};
const loadVideo = function(element, callbackReady, item) {
element.innerHTML = `<video ${previewMode ? 'controls' : ''}><source src=${item.location} type="video/mp4" /></video>`;
element.innerHTML = `<video ${disableAutoplay ? 'controls' : ''}><source src=${item.location} type="video/mp4" /></video>`;
const video = element.querySelector('video');
callbackReady(function() {});
@ -407,7 +493,7 @@
}
if (element.innerHTML.match('<video>')) {
if (!previewMode) {
if (!disableAutoplay) {
setTimeout(function() {
video.play();
pausableContent = video;

View File

@ -82,7 +82,7 @@
{% block body_class %}view-playlist-list{% endblock %}
{% block page %}
{% block top_page %}
<div class="top-content">
<div class="top-actions">
{{ HOOK(H_PLAYLIST_TOOLBAR_ACTIONS_START) }}
@ -119,13 +119,9 @@
('<a href="javascript:void(0);" class="item-add playlist-add">'~l.playlist_button_add~'</a>')|safe
) }}
</div>
{% endblock %}
{% if error %}
<div class="alert alert-danger">
{{ l[error] }}
</div>
{% endif %}
{% block main_page %}
<div class="bottom-content">
<div class="page-panel left-panel">
{% with playlists=playlists %}

View File

@ -14,17 +14,17 @@
{{ render_folder(child) }}
{% endfor %}
{% for content in content_children %}
{% set slides = slides_with_content[content.id]|default([]) %}
{% set slides = slides_with_content[content.id]|default([]) if slides_with_content else [] %}
{% set icon = enum_content_type.get_fa_icon(content.type) %}
{% set color = enum_content_type.get_color_icon(content.type) %}
<li class="explr-item" data-entity-json="{{ content.to_json() }}">
<li class="explr-item" data-entity-json="{{ content.to_json({'classIcon': icon, 'classColor': color, 'metadata': json_loads(content.metadata)}) }}">
<i class="fa {{ icon }} {{ color }}"></i>
{% if slides|length > 0 %}
<sub>
<i class="fa fa-play"></i>
</sub>
{% endif %}
<a href="{% if use_href %}{{ url_for('slideshow_content_edit', content_id=content.id) }}{% else %}javascript:void(0);{% endif %}"
<a href="{% if use_href %}{{ url_for('slideshow_content_edit', content_id=content.id, path=folder.path) }}{% else %}javascript:void(0);{% endif %}"
class="{{ 'explr-pick-element' if not use_href }}">
{{ content.name }}
</a>

View File

@ -0,0 +1,248 @@
{% set active_pill_route='slideshow_content_list' %}
{% extends 'base.jinja.html' %}
{% block page_title %}
{{ l.slideshow_content_page_title }}
{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{{ STATIC_PREFIX }}css/lib/jquery-explr-1.4.css"/>
{{ HOOK(H_SLIDESHOW_CONTENT_CSS) }}
{% endblock %}
{% block add_js %}
<script>
const content_type_icon_classes = {
{% for type in enum_content_type %}
'{{ type.value }}': '{{ enum_content_type.get_fa_icon(type) }}',
{% endfor %}
};
const content_type_color_classes = {
{% for type in enum_content_type %}
'{{ type.value }}': '{{ enum_content_type.get_color_icon(type) }}',
{% endfor %}
};
</script>
<script src="{{ STATIC_PREFIX }}js/lib/jquery-explr-1.4.js"></script>
<script src="{{ STATIC_PREFIX }}js/slideshow/contents.js"></script>
<script src="{{ STATIC_PREFIX }}js/lib/jquery-ui.min.js"></script>
{# <script src="{{ STATIC_PREFIX }} js/lib/jquery-ui-rotatable.min.js"></script> #}
<script src="{{ STATIC_PREFIX }}js/slideshow/content-composition.js"></script>
<script src="{{ STATIC_PREFIX }}js/explorer.js"></script>
{{ HOOK(H_SLIDESHOW_CONTENT_JAVASCRIPT) }}
{% endblock %}
{% block body_class %}view-content-edit view-content-edit-composition edit-page{% endblock %}
{% block top_page %}
<div class="top-content">
<h1>
{{ l.slideshow_content_form_edit_title }}
</h1>
{% set icon = enum_content_type.get_fa_icon(content.type) %}
{% set color = enum_content_type.get_color_icon(content.type) %}
<h3>
<span class="{{ color }} border-{{ color }}">
<i class="fa {{ icon }} {{ color }}"></i> {{ t(content.type) }}
</span>
</h3>
<a href="{{ url_for('serve_content_composition', content_id=content.id, autoplay=1, preview=1) }}" target="_blank" class="btn btn-naked">
<i class="fa fa-external-link"></i>
</a>
</div>
{% endblock %}
{% block main_page %}
<div class="bottom-content">
<div class="page-panel left-panel">
<div class="inner dirview">
<div class="breadcrumb-container">
<ul class="breadcrumb">
{% set ns = namespace(breadpath='') %}
{% for dir in working_folder_path[1:].split('/') %}
{% set ns.breadpath = ns.breadpath ~ '/' ~ dir %}
<li>
<a href="{{ url_for('slideshow_content_cd', path=ns.breadpath) }}">
<i class="explr-icon explr-icon-folder"></i>
{{ truncate(dir, 25, '...') }}
</a>
</li>
{% if not loop.last %}
<li class="divider">
<i class="fa fa-chevron-right"></i>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
{% set contentData = json_loads(content.location) %}
<div class="horizontal">
<div class="form-holder">
<form class="form"
action="{{ url_for('slideshow_content_save', content_id=content.id) }}?path={{ working_folder_path }}"
method="POST">
<div class="form-group">
<label for="content-edit-name">{{ l.slideshow_content_form_label_name }}</label>
<div class="widget">
<input type="text" name="name" id="content-edit-name" required="required"
value="{{ content.name }}"/>
</div>
</div>
<div class="form-group">
<label for="elem-screen-ratio">{{ l.enum_content_type_composition_object_label }}</label>
<div class="widget">
{% set ratios = [
"4/3",
"16/9",
"16/10",
"3/4",
"9/16",
"10/16",
] %}
<select name="name" id="elem-screen-ratio" required="required" class="size-m">
{% for ratio in ratios %}
<option value="{{ ratio }}" {% if ratio == contentData.ratio %}selected="selected"{% endif %}>
{{ ratio }}
</option>
{% endfor %}
</select>
</div>
</div>
{# <div class="form-group">#}
{# <label for="">Ratio</label>#}
{# <div class="horizontal">#}
{# <div class="widget">#}
{# <input type="text" value="16" />#}
{# </div>#}
{# <div>#}
{# /#}
{# </div>#}
{# <div class="widget">#}
{# <input type="text" value="9" />#}
{# </div>#}
{# </div>#}
{# </div>#}
<input type="hidden" name="location" id="content-edit-location" value="{{ content.location }}" />
<div class="elements-holder">
<h3 class="divide">{{ l.composition_elements_heading }}</h3>
<div class="form-elements-list" id="elementList">
</div>
</div>
<div class="actions actions-right">
<button type="submit" class="btn btn-info">
<i class="fa fa-save icon-left"></i>
{{ l.common_save }}
</button>
<a href="{{ url_for('slideshow_content_list') }}" class="btn btn-naked">
<i class="fa fa-rectangle-xmark icon-left"></i>
{{ l.common_cancel }}
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="page-content">
<div class="inner">
<h3 class="main">
{{ l.composition_monitor }} <span class="ratio-value badge-inset"></span>
</h3>
<div class="toolbar">
<button id="addElement" class="content-explr-picker"><i class="fa fa-plus icon-left"></i>{{ l.composition_element_add }}</button>
<button id="removeAllElements" class="btn btn-danger"><i class="fa fa-trash icon-left"></i> {{ l.composition_elements_delete_all }}</button>
</div>
<div class="presets">
<h4 class="divide">
{{ l.composition_presets }}:
</h4>
<button type="button" id="presetGrid2x2" class="btn btn-wire-neutral">{{ l.composition_presets_grid_2x2 }}</button>
<button type="button" id="presetTvNews1x1" class="btn btn-wire-neutral">{{ l.composition_presets_tvnews_1x1 }}</button>
</div>
<div class="screen-holder">
<div class="screen" id="screen">
<!-- Elements will be dynamically added here -->
</div>
</div>
</div>
</div>
<div class="page-panel right-panel">
<div class="form-element-properties hidden">
<form id="elementForm">
<h3>
{{ l.common_position }}
</h3>
<div class="form-group">
<label for="elem-x">{{ l.composition_element_x_axis }}</label>
<div class="widget">
<input type="number" id="elem-x" name="elem-x">
</div>
</div>
<div class="form-group">
<label for="elem-y">{{ l.composition_element_y_axis }}</label>
<div class="widget">
<input type="number" id="elem-y" name="elem-y">
</div>
</div>
<h3 class="divide">
{{ l.common_size }}
</h3>
<div class="form-group">
<label for="elem-width">{{ l.common_width }}</label>
<div class="widget">
<input type="number" id="elem-width" name="elem-width">
</div>
</div>
<div class="form-group">
<label for="elem-height">{{ l.common_height }}</label>
<div class="widget">
<input type="number" id="elem-height" name="elem-height">
</div>
</div>
<div class="horizontal fx-end element-tool element-adjust-aspect-ratio-container hidden">
<button type="button" class="btn btn-wire-neutral element-adjust-aspect-ratio">
<i class="fa fa-solid fa-down-left-and-up-right-to-center icon-left"></i> {{ l.composition_element_match_content_aspect_ratio }}
</button>
</div>
{# <div class="form-group">#}
{# <label for="elem-rotate">{{ l.common_angle }} (deg)</label>#}
{# <div class="widget">#}
{# <input type="number" id="elem-rotate" name="elem-rotate">#}
{# </div>#}
{# </div>#}
</form>
</div>
</div>
</div>
<div class="pickers hidden">
<div class="modals-outer">
<div class="modals-inner">
{% include 'slideshow/contents/modal/explr-picker.jinja.html' %}
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,311 @@
{% set active_pill_route='slideshow_content_list' %}
{% extends 'base.jinja.html' %}
{% block page_title %}
{{ l.slideshow_content_page_title }}
{% endblock %}
{% block add_css %}
{{ HOOK(H_SLIDESHOW_CONTENT_CSS) }}
{% endblock %}
{% block add_js %}
<script src="{{ STATIC_PREFIX }}js/lib/jscolor.min.js"></script>
<script src="{{ STATIC_PREFIX }}js/slideshow/contents.js"></script>
<script src="{{ STATIC_PREFIX }}js/slideshow/content-text.js"></script>
{{ HOOK(H_SLIDESHOW_CONTENT_JAVASCRIPT) }}
{% endblock %}
{% block body_class %}view-content-edit view-content-edit-text edit-page{% endblock %}
{% block top_page %}
<div class="top-content">
<h1>
{{ l.slideshow_content_form_edit_title }}
</h1>
{% set icon = enum_content_type.get_fa_icon(content.type) %}
{% set color = enum_content_type.get_color_icon(content.type) %}
<h3>
<span class="{{ color }} border-{{ color }}">
<i class="fa {{ icon }} {{ color }}"></i> {{ t(content.type) }}
</span>
</h3>
<a href="{{ url_for('player', preview_content_id=content.id) }}" target="_blank" class="btn btn-naked">
<i class="fa fa-external-link"></i>
</a>
</div>
{% endblock %}
{% block main_page %}
<div class="bottom-content">
<div class="page-panel left-panel">
<div class="inner dirview">
<div class="breadcrumb-container">
<ul class="breadcrumb">
{% set ns = namespace(breadpath='') %}
{% for dir in working_folder_path[1:].split('/') %}
{% set ns.breadpath = ns.breadpath ~ '/' ~ dir %}
<li>
<a href="{{ url_for('slideshow_content_cd', path=ns.breadpath) }}">
<i class="explr-icon explr-icon-folder"></i>
{{ truncate(dir, 25, '...') }}
</a>
</li>
{% if not loop.last %}
<li class="divider">
<i class="fa fa-chevron-right"></i>
</li>
{% endif %}
{% endfor %}
</ul>
</div>
<div class="horizontal">
<div class="form-holder">
<form class="form"
action="{{ url_for('slideshow_content_save', content_id=content.id) }}?path={{ working_folder_path }}"
method="POST">
<div class="form-group">
<label for="content-edit-name">{{ l.slideshow_content_form_label_name }}</label>
<div class="widget">
<input type="text" name="name" id="content-edit-name" required="required"
value="{{ content.name }}"/>
</div>
</div>
<input type="hidden" name="location" id="content-edit-location"
value="{{ content.location }}"/>
<div class="actions actions-right">
<button type="submit" class="btn btn-info">
<i class="fa fa-save icon-left"></i>
{{ l.common_save }}
</button>
<a href="{{ url_for('slideshow_content_list') }}" class="btn btn-naked">
<i class="fa fa-rectangle-xmark icon-left"></i>
{{ l.common_cancel }}
</a>
</div>
</form>
</div>
</div>
</div>
</div>
<div class="page-content">
<div class="inner">
<h3 class="main">
{{ l.composition_monitor }}
</h3>
<div class="screen-holder">
<div class="screen" id="screen">
<!-- Elements will be dynamically added here -->
</div>
</div>
</div>
</div>
<div class="page-panel right-panel">
<div class="form-element-properties">
<form id="elementForm">
<h3>
Text
</h3>
{% set contentStyles = json_loads(content.location) %}
<div class="form-group">
<label for="elem-text">Text Label</label>
<div class="widget">
<input type="text" id="elem-text" name="textLabel"
value="{{ contentStyles.textLabel }}">
</div>
</div>
<h3 class="divide">
Style
</h3>
<div class="horizontal">
<div class="form-group">
<label for="elem-font-size">Font Size</label>
<div class="widget widget-unit">
<input type="text" id="elem-font-size" name="fontSize" maxlength="3"
class="numeric-input chars-3" value="{{ contentStyles.fontSize }}">
<span>pt</span>
</div>
</div>
<div class="form-group">
<label for="elem-fg-color">Text Color</label>
<div class="widget">
<input type="text" id="elem-fg-color" name="color" class="color-picker"
data-jscolor="{ value: '#{{ contentStyles.color }}', backgroundColor: '#333333', shadowColor: '#000000', width: 120, height: 120 }"/>
</div>
</div>
</div>
<div class="form-group">
<label for="elem-font-family">Text Font Type</label>
<div class="widget">
{% set fonts = [
{"value": "Arial", "name": "Arial"},
{"value": "Arial Black", "name": "Arial Black"},
{"value": "Verdana", "name": "Verdana"},
{"value": "Trebuchet MS", "name": "Trebuchet MS"},
{"value": "Georgia", "name": "Georgia"},
{"value": "Times New Roman", "name": "Times New Roman"},
{"value": "Courier New", "name": "Courier New"},
{"value": "Comic Sans MS", "name": "Comic Sans MS"},
{"value": "Impact", "name": "Impact"},
{"value": "Tahoma", "name": "Tahoma"},
{"value": "Gill Sans", "name": "Gill Sans"},
{"value": "Helvetica", "name": "Helvetica"},
{"value": "Optima", "name": "Optima"},
{"value": "Garamond", "name": "Garamond"},
{"value": "Baskerville", "name": "Baskerville"},
{"value": "Copperplate", "name": "Copperplate"},
{"value": "Futura", "name": "Futura"},
{"value": "Monaco", "name": "Monaco"},
{"value": "Andale Mono", "name": "Andale Mono"}
] %}
<select name="fontFamily" id="elem-font-family" class="size-m">
{% for font in fonts %}
<option value="{{ font.value }}" {% if font.value == contentStyles.fontFamily %}selected="selected"{% endif %}>
{{ font.name }}
</option>
{% endfor %}
</select>
</div>
</div>
<div class="horizontal">
<div class="form-group">
<label for="elem-fg-color">Text Style</label>
<div class="widget">
<div class="checkbox-group">
<input type="checkbox" id="elem-font-bold" name="fontBold" value="bold" {{ 'checked' if contentStyles.fontBold }}>
<label for="elem-font-bold" class="btn btn-neutral">
<i class="fa fa-bold"></i>
</label>
<input type="checkbox" id="elem-font-italic" name="fontItalic" value="italic" {{ 'checked' if contentStyles.fontItalic }}>
<label for="elem-font-italic" class="btn btn-neutral">
<i class="fa fa-italic"></i>
</label>
<input type="checkbox" id="elem-text-underline" name="textUnderline" value="underline" {{ 'checked' if contentStyles.textUnderline }}>
<label for="elem-text-underline" class="btn btn-neutral">
<i class="fa fa-underline"></i>
</label>
</div>
</div>
</div>
<div class="form-group">
<label for="elem-fg-color">Text Alignment</label>
<div class="widget">
<div class="radio-group">
<input type="radio" id="elem-text-align-left" name="textAlign" value="left" {{ 'checked' if contentStyles.textAlign == 'left' }}>
<label for="elem-text-align-left" class="btn btn-neutral">
<i class="fa fa-align-left"></i>
</label>
<input type="radio" id="elem-text-align-center" name="textAlign" value="center" {{ 'checked' if contentStyles.textAlign == 'center' }}>
<label for="elem-text-align-center" class="btn btn-neutral">
<i class="fa fa-align-center"></i>
</label>
<input type="radio" id="elem-text-align-right" name="textAlign" value="right" {{ 'checked' if contentStyles.textAlign == 'right' }}>
<label for="elem-text-align-right" class="btn btn-neutral">
<i class="fa fa-align-right"></i>
</label>
</div>
</div>
</div>
</div>
<h3 class="divide">
Background
</h3>
<div class="form-group">
<label for="elem-bg-color">Background Color</label>
<div class="widget">
<input type="text" id="elem-bg-color" name="backgroundColor" class="color-picker"
data-jscolor="{ value: '#{{ contentStyles.backgroundColor }}', backgroundColor: '#333333', shadowColor: '#000000', width: 120, height: 120 }"/>
</div>
</div>
<h3 class="divide">
Scrolling Effect
</h3>
<div class="horizontal">
<div class="form-group">
<label for="elem-scroll-enable">Enable</label>
<div class="widget">
<div class="toggle">
<input type="checkbox" name="scrollEnable" id="elem-scroll-enable" {{ 'checked' if contentStyles.scrollEnable }} />
<label for="elem-scroll-enable"></label>
</div>
</div>
</div>
<div class="form-group">
<label for="elem-scroll-direction">Direction</label>
<div class="widget">
<div class="radio-group">
<input type="radio" id="elem-scroll-direction-left" name="scrollDirection" value="left" {{ 'checked' if contentStyles.scrollDirection == 'left' }}>
<label for="elem-scroll-direction-left" class="btn btn-neutral">
<i class="fa fa-arrow-left"></i>
</label>
<input type="radio" id="elem-scroll-direction-right" name="scrollDirection" value="right" {{ 'checked' if contentStyles.scrollDirection == 'right' }}>
<label for="elem-scroll-direction-right" class="btn btn-neutral">
<i class="fa fa-arrow-right"></i>
</label>
</div>
</div>
</div>
<div class="form-group">
<label for="elem-scroll-speed">Speed</label>
<div class="widget widget-unit">
<input type="text" id="elem-scroll-speed" name="scrollSpeed" maxlength="3" class="numeric-input chars-3" value="{{ contentStyles.scrollSpeed }}">
</div>
</div>
</div>
<h3 class="divide">
Layout
</h3>
<div class="horizontal">
<div class="form-group">
<label for="elem-single-line">Single Line Only</label>
<div class="widget">
<div class="toggle">
<input type="checkbox" name="singleLine" id="elem-single-line" {{ 'checked' if contentStyles.singleLine }} />
<label for="elem-single-line"></label>
</div>
</div>
</div>
<div class="form-group">
<label for="elem-container-margin">Container Margin</label>
<div class="widget widget-unit">
<input type="text" id="elem-container-margin" name="margin" maxlength="3" class="numeric-input chars-3" value="{{ contentStyles.margin }}">
<span>pt</span>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
{% endblock %}

View File

@ -6,13 +6,10 @@
{% endblock %}
{% block add_css %}
<link rel="stylesheet" href="{{ STATIC_PREFIX }}css/lib/flatpickr.min.css"/>
<link rel="stylesheet" href="{{ STATIC_PREFIX }}css/lib/jquery-explr-1.4.css"/>
{{ HOOK(H_SLIDESHOW_CONTENT_CSS) }}
{% endblock %}
{% block add_js %}
<script src="{{ STATIC_PREFIX }}js/lib/jquery-explr-1.4.js"></script>
<script src="{{ STATIC_PREFIX }}js/slideshow/contents.js"></script>
{{ HOOK(H_SLIDESHOW_CONTENT_JAVASCRIPT) }}
@ -20,20 +17,24 @@
{% block body_class %}view-content-edit edit-page{% endblock %}
{% block page %}
{% block top_page %}
<div class="top-content">
<h1>
{{ l.slideshow_content_form_edit_title }}
</h1>
{% set icon = enum_content_type.get_fa_icon(content.type) %}
{% set color = enum_content_type.get_color_icon(content.type) %}
<h3>
<span class="{{ color }} border-{{ color }}">
<i class="fa {{ icon }} {{ color }}"></i> {{ t(content.type) }}
</span>
</h3>
</div>
{% endblock %}
{% if request.args.get('saved') %}
<div class="alert alert-success alert-timeout">
<i class="fa fa-check icon-left"></i>
{{ l.common_saved }}
</div>
{% endif %}
{% block main_page %}
<div class="bottom-content">
<div class="page-content">
<div class="inner dirview">
@ -80,7 +81,7 @@
{% if content.type == enum_content_type.EXTERNAL_STORAGE %}
<input type="text" class="disabled" disabled value="{{ external_storage_mountpoint }}/" />
{% endif %}
{% set location = content.location %}
{% if content.type == enum_content_type.YOUTUBE %}
{% set location = 'https://www.youtube.com/watch?v=' ~ content.location %}
@ -89,7 +90,24 @@
</div>
</div>
<div class="actions actions-left">
{% if content.type == enum_content_type.VIDEO or content.type == enum_content_type.PICTURE %}
<div class="horizontal">
<div class="form-group">
<label for="">{{ l.common_width }}</label>
<div class="widget">
<input type="text" class="size-m" value="{{ content.get_metadata(enum_content_metadata.WIDTH) }}" disabled="disabled" />
</div>
</div>
<div class="form-group">
<label for="">{{ l.common_height }}</label>
<div class="widget">
<input type="text" class="size-m" value="{{ content.get_metadata(enum_content_metadata.HEIGHT) }}" disabled="disabled" />
</div>
</div>
</div>
{% endif %}
<div class="actions actions-right">
<button type="submit" class="btn btn-info">
<i class="fa fa-save icon-left"></i>
{{ l.common_save }}
@ -107,14 +125,6 @@
<div class="page-panel right-panel">
{% set icon = enum_content_type.get_fa_icon(content.type) %}
{% set color = enum_content_type.get_color_icon(content.type) %}
<h3>
<span class="{{ color }} border-{{ color }}">
<i class="fa {{ icon }} {{ color }}"></i> {{ t(content.type) }}
</span>
</h3>
<div class="iframe-wrapper">
<iframe src="{{ url_for('player', preview_content_id=content.id) }}"></iframe>
</div>

View File

@ -22,16 +22,14 @@
<script src="{{ STATIC_PREFIX }}js/lib/jquery-ui.min.js"></script>
<script src="{{ STATIC_PREFIX }}js/lib/jquery-fileupload.js"></script>
<script src="{{ STATIC_PREFIX }}js/lib/jquery-multidraggable.js"></script>
<script src="{{ STATIC_PREFIX }}js/dragdrop.js"></script>
<script src="{{ STATIC_PREFIX }}js/super-upload.js"></script>
{{ HOOK(H_SLIDESHOW_CONTENT_JAVASCRIPT) }}
{% endblock %}
{% block body_class %}view-content-list{% endblock %}
{% block page %}
{% set explr_limit_chars = 35 %}
{% block top_page %}
<div class="top-content">
<div class="top-actions">
{{ HOOK(H_SLIDESHOW_CONTENT_TOOLBAR_ACTIONS_START) }}
@ -85,25 +83,11 @@
</div>
</div>
{% if request.args.get('folder_not_empty_error') %}
<div class="alert alert-danger">
<i class="fa fa-warning icon-left"></i>
{{ l.common_folder_not_empty_error }}
</div>
{% elif request.args.get('referenced_in_slide_error') %}
<div class="alert alert-danger">
<i class="fa fa-warning icon-left"></i>
{{ l.slideshow_content_referenced_in_slide_error }}
</div>
{% elif request.args.get('error') %}
<div class="alert alert-danger">
<i class="fa fa-warning icon-left"></i>
{{ t(request.args.get('error')) }}
</div>
{% else %}
<div class="alert alert-danger hidden"></div>
{% endif %}
<div class="alert alert-upload alert-danger hidden"></div>
{% endblock %}
{% block main_page %}
{% set explr_limit_chars = 35 %}
<div class="bottom-content">
<div class="page-panel left-panel explr-explorer">
{% with use_href=True %}

View File

@ -52,6 +52,28 @@
</div>
</div>
<div class="from-group-condition hidden">
<div class="form-group">
<label for="" class="object-label"></label>
<div class="widget">
{% set ratios = [
"16/9",
"16/10",
"4/3",
"9/16",
"10/16",
"3/4",
] %}
<select name="object" data-input-type="composition" class="content-object-input size-m">
{% for ratio in ratios %}
<option value="{{ ratio }}">
{{ ratio }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
<div class="from-group-condition hidden">
<div class="form-group">

View File

@ -39,7 +39,7 @@
<button type="button" class="btn btn-naked content-explr-picker">
<i class="fa fa-crosshairs"></i>
</button>
<button type="button" class="btn btn-neutral hidden slide-content-show" data-route="{{ url_for('slideshow_content_show', content_id='__id__') }}">
<button type="button" class="btn btn-neutral hidden slide-content-show" data-route="{{ url_for('slideshow_content_edit', content_id='__id__') }}">
<i class="fa-solid fa-up-right-from-square"></i>
</button>
</div>
@ -88,21 +88,23 @@
</div>
</div>
<div class="form-group form-group-horizontal slide-delegate-duration-group">
<label for="">{{ l.slideshow_slide_form_label_delegate_duration }}</label>
<div class="widget">
<div class="toggle">
<input type="checkbox" name="delegate_duration" class="slide-delegate-duration" id="{{ tclass }}-delegate-duration" value="1" disabled />
<label for="{{ tclass }}-delegate-duration"></label>
<div class="horizontal">
<div class="form-group slide-delegate-duration-group">
<label for="">{{ l.slideshow_slide_form_label_delegate_duration }}</label>
<div class="widget">
<div class="toggle">
<input type="checkbox" name="delegate_duration" class="slide-delegate-duration" id="{{ tclass }}-delegate-duration" value="1" disabled />
<label for="{{ tclass }}-delegate-duration"></label>
</div>
</div>
</div>
</div>
<div class="form-group slide-duration-group">
<label for="{{ tclass }}-duration">{{ l.slideshow_slide_form_label_duration }}</label>
<div class="widget widget-unit">
<input type="number" name="duration" id="{{ tclass }}-duration" required="required" value="3" min="0" />
<span class="unit">{{ l.slideshow_slide_form_label_duration_unit }}</span>
<div class="form-group slide-duration-group">
<label for="{{ tclass }}-duration">{{ l.slideshow_slide_form_label_duration }}</label>
<div class="widget widget-unit">
<input type="text" name="duration" id="{{ tclass }}-duration" required="required" value="3" min="0" class="numeric-input" />
<span class="unit">{{ l.slideshow_slide_form_label_duration_unit }}</span>
</div>
</div>
</div>

View File

@ -37,7 +37,7 @@
<button type="button" class="btn btn-naked content-explr-picker">
<i class="fa fa-crosshairs"></i>
</button>
<button type="button" class="btn btn-neutral hidden slide-content-show" data-route="{{ url_for('slideshow_content_show', content_id='__id__') }}">
<button type="button" class="btn btn-neutral hidden slide-content-show" data-route="{{ url_for('slideshow_content_edit', content_id='__id__') }}">
<i class="fa-solid fa-up-right-from-square"></i>
</button>
</div>
@ -86,21 +86,22 @@
</div>
</div>
<div class="form-group form-group-horizontal slide-delegate-duration-group">
<label for="">{{ l.slideshow_slide_form_label_delegate_duration }}</label>
<div class="widget">
<div class="toggle">
<input type="checkbox" name="delegate_duration" class="slide-delegate-duration" id="{{ tclass }}-delegate-duration" value="1" disabled />
<label for="{{ tclass }}-delegate-duration"></label>
<div class="horizontal">
<div class="form-group slide-delegate-duration-group">
<label for="">{{ l.slideshow_slide_form_label_delegate_duration }}</label>
<div class="widget">
<div class="toggle">
<input type="checkbox" name="delegate_duration" class="slide-delegate-duration" id="{{ tclass }}-delegate-duration" value="1" disabled />
<label for="{{ tclass }}-delegate-duration"></label>
</div>
</div>
</div>
</div>
<div class="form-group slide-duration-group">
<label for="{{ tclass }}-duration">{{ l.slideshow_slide_form_label_duration }}</label>
<div class="widget widget-unit">
<input type="number" name="duration" id="{{ tclass }}-duration" required="required" min="0" />
<span class="unit">{{ l.slideshow_slide_form_label_duration_unit }}</span>
<div class="form-group slide-duration-group">
<label for="{{ tclass }}-duration">{{ l.slideshow_slide_form_label_duration }}</label>
<div class="widget widget-unit">
<input type="text" name="duration" id="{{ tclass }}-duration" required="required" min="0" class="numeric-input" />
<span class="unit">{{ l.slideshow_slide_form_label_duration_unit }}</span>
</div>
</div>
</div>