Compare commits

..

3 Commits

Author SHA1 Message Date
883b5702b6 Update version.txt
All checks were successful
Release build and push docker image / build-and-push-release (push) Successful in 13m1s
2024-10-14 19:26:36 +00:00
22ebe4939c Update .github/actions/common-docker-build/action.yml 2024-10-14 19:26:03 +00:00
601f493188 Update .github/workflows/build-release.yml 2024-10-14 19:24:03 +00:00
82 changed files with 413 additions and 2871 deletions

View File

@ -34,7 +34,7 @@ jobs:
- name: Call common build workflow
uses: ./.github/actions/common-docker-build
with:
build_tags: csmith1865/obscreen:nightly
build_tags: jierka/obscreen:nightly
manifest_tags: type=semver,pattern=nightly
flavor: ""
docker_username: ${{ secrets.DOCKER_USERNAME }}

View File

@ -21,7 +21,7 @@ jobs:
- name: Call common build workflow
uses: ./.github/actions/common-docker-build
with:
build_tags: csmith1865/obscreen:pr-${{ github.event.pull_request.number }}
build_tags: jierka/obscreen:pr-${{ github.event.pull_request.number }}
manifest_tags: type=semver,pattern=pr
flavor: ""
docker_username: ${{ secrets.DOCKER_USERNAME }}

View File

@ -24,7 +24,7 @@ jobs:
with:
build_tags: |
csmith1865/obscreen:v${{ steps.version.outputs.VERSION }}
csmith1865/obscreen:latest
csmith1865/obscreen:latest
manifest_tags: type=semver,pattern=v${{ steps.version.outputs.VERSION }}
flavor: latest=true
docker_username: ${{ secrets.DOCKER_USERNAME }}

View File

@ -6,9 +6,9 @@
Obscreen is a user-friendly self-hosted digital signage tool leveraging chromium browser.
<a target="_blank" href="https://git.sumisu.xyz/csmith1865/obscreen"><img src="https://img.shields.io/gitea/stars/csmith1865/obscreen?gitea_url=https%3A%2F%2Fgit.sumisu.xyz&style=flat" /></a> <a target="_blank" href="https://hub.docker.com/r/csmith1865/obscreen"><img src="https://img.shields.io/docker/pulls/csmith1865/obscreen" /></a> <a target="_blank" href="https://hub.docker.com/r/csmith1865/obscreen"><img src="https://img.shields.io/docker/v/csmith1865/obscreen/latest?label=docker%20image%20ver." /></a> <a target="_blank" href="https://git.sumisu.xyz/csmith1865/obscreen"><img src="https://img.shields.io/gitea/last-commit/csmith1865/obscreen?gitea_url=https%3A%2F%2Fgit.sumisu.xyz&style=flat" /></a>
<a target="_blank" href="https://github.com/jr-k/obscreen"><img src="https://img.shields.io/github/stars/jr-k/obscreen?style=flat" /></a> <a target="_blank" href="https://hub.docker.com/r/jierka/obscreen"><img src="https://img.shields.io/docker/pulls/jierka/obscreen" /></a> <a target="_blank" href="https://hub.docker.com/r/jierka/obscreen"><img src="https://img.shields.io/docker/v/jierka/obscreen/latest?label=docker%20image%20ver." /></a> <a target="_blank" href="https://github.com/jr-k/obscreen"><img src="https://img.shields.io/github/last-commit/jr-k/obscreen" /></a>
<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/screenshot-playlist-edit.png" width="700" alt="" />
<img src="https://github.com/jr-k/obscreen/blob/master/docs/screenshot-playlist-edit.png" width="700" alt="" />
🧑‍🎄 Open to feature request and pull request. [Cast your vote for your preferred ones on the Canny platform](https://obscreen.canny.io/feature-requests)
@ -34,7 +34,7 @@ It is a temporary live demo, all data will be deleted after 30 minutes (~30secs
- Authentication management
- Plays content from flashdrive in offline mode
- Core API & Plugin system to extend capabilities
- [Multi Languages](https://git.sumisu.xyz/csmith1865/obscreen/src/branch/master/lang)
- [Multi Languages](https://github.com/jr-k/obscreen/tree/master/lang)
- Cast pictures and iframes to Chromecast
- No costly monthly pricing plan per screen or whatever, no cloud, no telemetry
@ -47,19 +47,19 @@ It is a temporary live demo, all data will be deleted after 30 minutes (~30secs
Light Mode:
<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/screenshot-light-mode.png" width="512" alt="" />
<img src="https://github.com/jr-k/obscreen/blob/master/docs/screenshot-light-mode.png" width="512" alt="" />
Content Explorer:
<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/screenshot-content-explorer.png" width="512" alt="" />
<img src="https://github.com/jr-k/obscreen/blob/master/docs/screenshot-content-explorer.png" width="512" alt="" />
Settings Page:
<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/screenshot-settings.png" width="512" alt="" />
<img src="https://github.com/jr-k/obscreen/blob/master/docs/screenshot-settings.png" width="512" alt="" />
Add Content Modal:
<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/screenshot-add-content.png" width="512" alt="" />
<img src="https://github.com/jr-k/obscreen/blob/master/docs/screenshot-add-content.png" width="512" alt="" />
## 🫡 Motivation
@ -81,10 +81,10 @@ If you value this project, please think about awarding it a ⭐. Thanks ! 🙏
## 🛟 Discussion / Need help ?
### Join our Discord
[<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/img/discord.png" width="64">](https://discord.obscreen.io)
[<img src="https://github.com/jr-k/obscreen/blob/master/docs/img/discord.png" width="64">](https://discord.obscreen.io)
### Open an Issue
[<img src="https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/docs/img/github.png" width="64">](https://git.sumisu.xyz/csmith1865/obscreen/issues/new/choose)
[<img src="https://github.com/jr-k/obscreen/blob/master/docs/img/github.png" width="64">](https://github.com/jr-k/obscreen/issues/new/choose)
### Troubleshoot
@ -112,7 +112,7 @@ Check out the latest beta release here: https://github.com/jr-k/obscreen/release
### Translations
If you want to translate Obscreen into your language, please visit [Languages Files](https://git.sumisu.xyz/csmith1865/obscreen/src/branch/master/lang).
If you want to translate Obscreen into your language, please visit [Languages Files](https://github.com/jr-k/obscreen/blob/master/lang).
### Spelling & Grammar

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -31,20 +31,13 @@ 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');
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}`);
if (response.status != 200) {
const $alert = $('.alert-danger').removeClass('hidden');
if (response.status == 413) {
$alert.text(l.js_common_http_error_413);
} else {
$alert.html(`<i class="fa fa-warning"></i>${l.js_common_http_error_occured.replace('%code%', statusCode)}`);
$alert.text(l.js_common_http_error_occured.replace('%code%', response.status));
}
} else {
document.location.reload();

View File

@ -30,10 +30,6 @@ 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');
@ -166,12 +162,5 @@ 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');
});
});
});

View File

@ -1,84 +0,0 @@
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

File diff suppressed because one or more lines are too long

View File

@ -1,471 +0,0 @@
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

@ -1,79 +0,0 @@
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,11 +21,7 @@ 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}`,
classColorXor(color, '')
].join(' '));
$form.find('button[type=submit]').attr('class', 'btn ' + ('btn-' + color));
};
const main = function () {

View File

@ -81,9 +81,3 @@ 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,16 +31,8 @@ main {
margin-right: 20px;
}
.context-tail {
margin-right: 30px;
.btn {
margin-right: 0;
}
}
.context-tail-auth {
margin-right: 10px;
.contex-tail {
margin-right: 20px;
.btn {
margin-right: 0;

View File

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

View File

@ -1,12 +0,0 @@
@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,10 +1,32 @@
.badge-inset {
display: inline;
color: $gscaleA;
font-size: 12px;
margin-left: 5px;
background: $gscale0;
border: 1px solid $gscale3;
border-radius: $baseRadius;
padding: 3px 7px;
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;
}

View File

@ -1,4 +1,3 @@
button,
.btn {
$shadowOffset: 2px;
@ -57,7 +56,6 @@ 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%);
@ -143,3 +141,4 @@ button,
cursor: default;
}
}

View File

@ -81,24 +81,6 @@ 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;
@ -118,11 +100,12 @@ form {
}
}
input + .btn + .btn {
.btn {
margin-left: 10px;
}
&.widget-unit {
select,
input {
flex-grow: 0;
@ -148,33 +131,6 @@ 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;
@ -199,17 +155,23 @@ 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,20 +18,19 @@
@import 'components/modals';
@import 'components/toast';
@import 'components/dragdrop';
@import 'components/animation';
// Legacy
@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,25 +43,6 @@ 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

@ -1,364 +0,0 @@
.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

@ -1,155 +0,0 @@
.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,29 +23,6 @@
.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;
@ -61,11 +38,32 @@
align-self: stretch;
display: flex;
flex-direction: column;
overflow: auto;
overflow: hidden;
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;
@ -89,3 +87,7 @@
}
}

View File

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

View File

@ -2,7 +2,7 @@ services:
webapp:
container_name: obscreen
restart: unless-stopped
image: csmith1865/obscreen:latest
image: jierka/obscreen:latest
environment:
- DEMO=false
- DEBUG=false

View File

@ -1,6 +1,6 @@
# <img src="https://raw.githubusercontent.com/csmith1865/obscreen/refs/heads/master/docs/img/obscreen.png" width="22"> Obscreen - Autorun on RaspberryPi
# <img src="https://github.com/jr-k/obscreen/blob/master/docs/img/obscreen.png" width="22"> Obscreen - Autorun on RaspberryPi
> #### 👈 [back to readme](../README.md)
> #### 👈 [back to readme](/README.md)
#### 🔴 You want to power RaspberryPi and automatically see your slideshow on a screen connected to it and manage your slideshow ? You're in the right place.
@ -22,12 +22,12 @@
##### Linux
```bash
curl -fsSL https://raw.githubusercontent.com/csmith1865/obscreen/master/system/install-studio.sh -o /tmp/install-studio.sh && chmod +x /tmp/install-studio.sh && sudo /bin/bash /tmp/install-studio.sh $USER $HOME
curl -fsSL https://raw.githubusercontent.com/jr-k/obscreen/master/system/install-studio.sh -o /tmp/install-studio.sh && chmod +x /tmp/install-studio.sh && sudo /bin/bash /tmp/install-studio.sh $USER $HOME
sudo reboot
```
##### Windows & MacOS
```bash
git clone https://github.com/csmith1865/obscreen.git
git clone https://github.com/jr-k/obscreen.git
cd obscreen
python3 -m venv venv
source ./venv/bin/activate
@ -82,7 +82,7 @@ docker run --restart=always --name obscreen --pull=always \
cd ~ && mkdir -p obscreen/data/db obscreen/data/uploads && cd obscreen
# Download docker-compose.yml
curl https://raw.githubusercontent.com/csmith1865/obscreen/master/docker-compose.yml > docker-compose.yml
curl https://raw.githubusercontent.com/jr-k/obscreen/master/docker-compose.yml > docker-compose.yml
# Run
docker compose up --detach --pull=always
@ -106,7 +106,7 @@ docker compose up --detach --pull=always
#### How to install
- Install player autorun by executing following script (will install chromium, x11, pulseaudio and obscreen-player systemd service)
```bash
curl -fsSL https://raw.githubusercontent.com/csmith1865/obscreen/master/system/install-player-rpi.sh -o /tmp/install-player-rpi.sh && chmod +x /tmp/install-player-rpi.sh && sudo /bin/bash /tmp/install-player-rpi.sh $USER $HOME
curl -fsSL https://raw.githubusercontent.com/jr-k/obscreen/master/system/install-player-rpi.sh -o /tmp/install-player-rpi.sh && chmod +x /tmp/install-player-rpi.sh && sudo /bin/bash /tmp/install-player-rpi.sh $USER $HOME
sudo reboot
```

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 '%contentName%' is referenced in a slide, remove slide first",
"slideshow_content_referenced_in_slide_error": "Content 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,27 +255,12 @@
"common_apply": "Apply",
"common_saved": "Changes have been saved",
"common_new_folder": "New Folder",
"common_folder_not_empty_error": "Folder '%folderName%' isn't empty, you must delete its content first",
"common_folder_not_empty_error": "Folder 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",
@ -307,10 +292,6 @@
"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 '%contentName%' en una diapositiva; elimine la diapositiva primero",
"slideshow_content_referenced_in_slide_error": "Se hace referencia al contenido 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,27 +256,12 @@
"common_apply": "Aplicar",
"common_saved": "Los cambios se han guardado",
"common_new_folder": "Nuevo Carpeta",
"common_folder_not_empty_error": "La carpeta '%folderName%' no está vacía, primero debes eliminar su contenido",
"common_folder_not_empty_error": "La carpeta 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",
@ -308,10 +293,6 @@
"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 '%contentName%' est référencé dans une slide, supprimez d'abord la slide",
"slideshow_content_referenced_in_slide_error": "Le contenu 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,27 +257,12 @@
"common_apply": "Appliquer",
"common_saved": "Les modifications ont été enregistrées",
"common_new_folder": "Nouveau Dossier",
"common_folder_not_empty_error": "Le dossier '%folderName%' n'est pas vide, vous devez d'abord supprimer son contenu",
"common_folder_not_empty_error": "Le dossier 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",
@ -309,10 +294,6 @@
"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 '%contentName%' in una diapositiva, rimuovere prima la diapositiva",
"slideshow_content_referenced_in_slide_error": "Si fa riferimento al contenuto 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,27 +256,12 @@
"common_apply": "Applica",
"common_saved": "Le modifiche sono state salvate",
"common_new_folder": "Nuovo Cartella",
"common_folder_not_empty_error": "La cartella '%folderName%' non è vuota, devi prima eliminarne il contenuto",
"common_folder_not_empty_error": "La cartella 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",
@ -308,10 +293,6 @@
"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,7 +22,8 @@
{% endwith %}
{% endblock %}
{% block top_page %}
{% block page %}
<div class="top-content">
<div class="top-actions">
@ -31,9 +32,7 @@
</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, flash
from flask import Flask, render_template, redirect, request, url_for, jsonify
from flask_login import login_user, logout_user, current_user
from src.service.ModelStore import ModelStore
from src.model.entity.User import User
@ -26,6 +26,8 @@ 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'))
@ -39,12 +41,13 @@ class AuthController(ObController):
login_user(user)
return redirect(url_for('playlist'))
else:
flash(self.t('login_error_bad_credentials'), 'error')
login_error = 'bad_credentials'
else:
flash(self.t('login_error_not_found'), 'error')
login_error = 'not_found'
return render_template(
'auth/login.jinja.html',
login_error=login_error,
last_username=request.form['username'] if 'username' in request.form else None
)
@ -64,6 +67,7 @@ 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()
)
@ -92,12 +96,10 @@ class AuthController(ObController):
return redirect(url_for('auth_user_list'))
if user.id == str(current_user.id):
flash(self.t('auth_user_delete_cant_delete_yourself'), 'error')
return redirect(url_for('auth_user_list'))
return redirect(url_for('auth_user_list', error='auth_user_delete_cant_delete_yourself'))
if self._model_store.user().count_all_enabled() == 1:
flash(self.t('auth_user_delete_at_least_one_account'), 'error')
return redirect(url_for('auth_user_list'))
return redirect(url_for('auth_user_list', error='auth_user_delete_at_least_one_account'))
self._model_store.user().delete(user_id)
return redirect(url_for('auth_user_list'))

View File

@ -2,12 +2,11 @@ import json
import os
import time
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify, abort, flash
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify, abort
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
@ -28,6 +27,7 @@ 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,26 +110,15 @@ 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(
edit_view,
'slideshow/contents/edit.jinja.html',
content=content,
working_folder_path=working_folder_path,
working_folder=working_folder,
enum_content_type=ContentType,
enum_content_metadata=ContentMetadata,
external_storage_mountpoint=self._model_store.config().map().get('external_storage_mountpoint'),
**vargs
external_storage_mountpoint=self._model_store.config().map().get('external_storage_mountpoint')
)
def slideshow_content_save(self, content_id: int = 0):
@ -146,19 +135,17 @@ class ContentController(ObController):
)
self._post_update()
flash(self.t('common_saved'), 'success')
return redirect(url_for('slideshow_content_edit', content_id=content_id))
return redirect(url_for('slideshow_content_edit', content_id=content_id, saved=1))
def slideshow_content_delete(self):
working_folder_path, working_folder = self.get_folder_context()
error = self.delete_content_by_id(request.args.get('id'))
error_tuple = self.delete_content_by_id(request.args.get('id'))
route_args = {
"path": working_folder_path,
}
if error:
flash(error, 'error')
if error_tuple:
route_args[error_tuple[0]] = error_tuple[1]
return redirect(url_for('slideshow_content_list', **route_args))
@ -238,16 +225,24 @@ class ContentController(ObController):
def slideshow_content_folder_delete(self):
working_folder_path, working_folder = self.get_folder_context()
error = self.delete_folder_by_id(request.args.get('id'))
error_tuple = self.delete_folder_by_id(request.args.get('id'))
route_args = {
"path": working_folder_path,
}
if error:
flash(self.t(error), 'error')
if error_tuple:
route_args[error_tuple[0]] = error_tuple[1]
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(',')
@ -256,17 +251,17 @@ class ContentController(ObController):
for id in entity_ids:
if id:
error = self.delete_content_by_id(id)
error_tuple = self.delete_content_by_id(id)
if error:
flash(error, 'error')
if error_tuple:
route_args_dict[error_tuple[0]] = error_tuple[1]
for id in folder_ids:
if id:
error = self.delete_folder_by_id(id)
error_tuple = self.delete_folder_by_id(id)
if error:
flash(error, 'error')
if error_tuple:
route_args_dict[error_tuple[0]] = error_tuple[1]
return redirect(url_for('slideshow_content_list', **route_args_dict))
@ -277,7 +272,7 @@ class ContentController(ObController):
return None
if self._model_store.slide().count_slides_for_content(content.id) > 0:
return 'slideshow_content_referenced_in_slide_error'.replace('%contentName%', content.name)
return 'referenced_in_slide_error', content.name
self._model_store.content().delete(content.id)
self._post_update()
@ -293,7 +288,7 @@ class ContentController(ObController):
folder_counter = self._model_store.folder().count_subfolders_for_folder(folder.id)
if content_counter > 0 or folder_counter:
return self.t('common_folder_not_empty_error').replace('%folderName%', folder.name)
return 'folder_not_empty_error', 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, flash
from flask import Flask, render_template, redirect, request, url_for, jsonify, abort
from src.service.ModelStore import ModelStore
from src.model.entity.NodePlayer import NodePlayer
from src.interface.ObController import ObController
@ -108,19 +108,18 @@ class FleetNodePlayerController(ObController):
)
self._post_update()
flash(self.t('common_saved'), 'success')
# return redirect(url_for('fleet_node_player_edit', node_player_id=node_player_id, saved=1))
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 = self.delete_node_player_by_id(request.args.get('id'))
error_tuple = self.delete_node_player_by_id(request.args.get('id'))
route_args = {
"path": working_folder_path,
}
if error:
flash(error, 'error')
if error_tuple:
route_args[error_tuple[0]] = error_tuple[1]
return redirect(url_for('fleet_node_player_list', **route_args))
@ -201,13 +200,13 @@ class FleetNodePlayerController(ObController):
def fleet_node_player_folder_delete(self):
working_folder_path, working_folder = self.get_working_folder()
error = self.delete_folder_by_id(request.args.get('id'))
error_tuple = self.delete_folder_by_id(request.args.get('id'))
route_args = {
"path": working_folder_path,
}
if error:
flash(error, 'error')
if error_tuple:
route_args[error_tuple[0]] = error_tuple[1]
return redirect(url_for('fleet_node_player_list', **route_args))
@ -219,17 +218,17 @@ class FleetNodePlayerController(ObController):
for id in entity_ids:
if id:
error = self.delete_node_player_by_id(id)
error_tuple = self.delete_node_player_by_id(id)
if error:
flash(error, 'error')
if error_tuple:
route_args_dict[error_tuple[0]] = error_tuple[1]
for id in folder_ids:
if id:
error = self.delete_folder_by_id(id)
error_tuple = self.delete_folder_by_id(id)
if error:
flash(error, 'error')
if error_tuple:
route_args_dict[error_tuple[0]] = error_tuple[1]
return redirect(url_for('fleet_node_player_list', **route_args_dict))
@ -253,7 +252,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 self.t('common_folder_not_empty_error').replace('%folderName%', folder.name)
return 'folder_not_empty_error', 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, flash
from flask import Flask, render_template, redirect, request, url_for, jsonify
from src.service.ModelStore import ModelStore
from src.model.entity.NodePlayerGroup import NodePlayerGroup
from src.model.enum.FolderEntity import FolderEntity
@ -43,6 +43,7 @@ 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,
@ -85,8 +86,7 @@ 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:
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))
return redirect(url_for('fleet_node_player_group_list', player_group_id=player_group_id, error='node_player_group_delete_has_node_player'))
self._model_store.node_player_group().delete(player_group_id)
return redirect(url_for('fleet_node_player_group'))

View File

@ -28,31 +28,28 @@ 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)
if not preview_content_id:
query = " (slug = ? OR id = ?) "
query_args = {
"slug": playlist_slug_or_id,
"id": playlist_slug_or_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)
@ -66,8 +63,6 @@ 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,
@ -127,7 +122,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 not preview_mode and (playlist_id == 0 or not playlist_id):
if playlist_id == 0 or not playlist_id:
playlist = self._model_store.playlist().get_one_by(query="fallback = 1")
if playlist:
@ -137,9 +132,8 @@ 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)
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
contents = self._model_store.content().get_all_indexed()
playlist = self._model_store.playlist().get(playlist_id)
position = 9999
playlist_loop = []
@ -254,14 +248,3 @@ 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, flash
from flask import Flask, render_template, redirect, request, url_for, jsonify, abort
from src.service.ModelStore import ModelStore
from src.model.entity.Playlist import Playlist
from src.model.enum.FolderEntity import FolderEntity
@ -35,6 +35,7 @@ class PlaylistController(ObController):
return render_template(
'playlist/list.jinja.html',
error=request.args.get('error', None),
current_playlist=current_playlist,
playlists=playlists,
durations=durations,
@ -82,12 +83,10 @@ class PlaylistController(ObController):
abort(404)
if self._model_store.slide().count_slides_for_playlist(playlist_id) > 0:
flash(self.t('playlist_delete_has_slides'), 'error')
return redirect(url_for('playlist_list', playlist_id=playlist_id))
return redirect(url_for('playlist_list', playlist_id=playlist_id, error='playlist_delete_has_slides'))
if self._model_store.node_player_group().count_node_player_groups_for_playlist(playlist_id) > 0:
flash(self.t('playlist_delete_has_node_player_groups'), 'error')
return redirect(url_for('playlist_list', playlist_id=playlist_id))
return redirect(url_for('playlist_list', playlist_id=playlist_id, error='playlist_delete_has_node_player_groups'))
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, flash
from flask import Flask, render_template, redirect, request, url_for
from typing import Optional
from src.service.ModelStore import ModelStore
@ -40,8 +40,7 @@ class SettingsController(ObController):
error = self._pre_update(request.form['id'])
if error:
flash(error, 'error')
return redirect(url_for('settings_variable_list'))
return redirect(url_for('settings_variable_list', error=error))
self._model_store.variable().update_form(request.form['id'], request.form['value'])
redirect_response = self._post_update(request.form['id'])
@ -55,8 +54,7 @@ class SettingsController(ObController):
error = self._pre_update(request.form['id'])
if error:
flash(error, 'error')
return redirect(url_for('settings_variable_plugin_list'))
return redirect(url_for('settings_variable_plugin_list', error=error))
self._model_store.variable().update_form(request.form['id'], request.form['value'])
redirect_response = self._post_update(request.form['id'])
@ -81,8 +79,7 @@ class SettingsController(ObController):
if variable.name == 'slide_upload_limit':
self.reload_web_server()
flash(self.t('common_restart_needed'), 'warning')
return redirect(url_for('settings_variable_list'))
return redirect(url_for('settings_variable_list', warning='common_restart_needed'))
if variable.name == 'fleet_player_enabled':
self.reload_web_server()
@ -101,10 +98,7 @@ class SettingsController(ObController):
thread = threading.Thread(target=self.plugin_update)
thread.daemon = True
thread.start()
flash(self.t('common_restart_needed'), 'warning')
return redirect(url_for('settings_variable_plugin_list'))
flash(self.t('common_saved'), 'success')
return redirect(url_for('settings_variable_plugin_list', warning='common_restart_needed'))
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, flash
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify, abort
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())
return redirect(referrer_path)
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))
def _post_update(self):
self._model_store.variable().update_by_name("last_slide_update", time.time())

View File

@ -1,7 +1,6 @@
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
@ -52,19 +51,3 @@ 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,7 +6,6 @@ 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
@ -17,8 +16,7 @@ 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 get_video_metadata
from src.util.UtilPicture import get_picture_metadata
from src.util.UtilVideo import mp4_duration_with_ffprobe
from src.util.utils import encode_uri_component
@ -31,7 +29,6 @@ class ContentManager(ModelManager):
"type CHAR(30)",
"location TEXT",
"duration FLOAT",
"metadata TEXT",
"folder_id INTEGER",
"created_by CHAR(255)",
"updated_by CHAR(255)",
@ -43,12 +40,6 @@ 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:
@ -82,12 +73,10 @@ 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, query: str = None) -> Dict[str, Content]:
def get_all_indexed(self, attribute: str = 'id', multiple=False) -> Dict[str, Content]:
index = {}
items = self.get_by(query) if query else self.get_contents()
for item in items:
for item in self.get_contents():
id = getattr(item, attribute)
if multiple:
if id not in index:
@ -143,15 +132,14 @@ class ContentManager(ModelManager):
def post_delete(self, content_id: str) -> str:
return content_id
def update_form(self, id: int, name: Optional[str] = None, location: Optional[str] = None, metadata: Optional[str] = None) -> Optional[Content]:
def update_form(self, id: int, name: str, location: Optional[str] = None) -> Optional[Content]:
content = self.get(id)
if not content:
return
form = {
"name": name if isinstance(name, str) else content.name,
"metadata": metadata if isinstance(metadata, str) else content.metadata
"name": name,
}
if location is not None and location:
@ -210,29 +198,16 @@ class ContentManager(ModelManager):
object_path = os.path.join(upload_dir, object_name)
object.save(object_path)
content.location = object_path
self.set_metadata(content)
if type == ContentType.VIDEO:
content.duration = mp4_duration_with_ffprobe(content.location)
else:
content.location = ContentType.get_initial_location(content.type, location)
content.location = location if location else ''
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)
@ -262,16 +237,6 @@ 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 "",
@ -283,13 +248,6 @@ class ContentManager(ModelManager):
).strip('/')
)
elif content.type == ContentType.URL:
location = 'http://' + content.location if content.location and not content.location.startswith('http') else content.location
location = 'http://' + content.location if not content.location.startswith('http') else content.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)
)
return location

View File

@ -218,14 +218,13 @@ class UserManager:
user_id = self.get_logged_user("id")
now = time.time()
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 '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,7 +149,6 @@ 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,17 +4,15 @@ 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 = '', 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):
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):
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
@ -41,14 +39,6 @@ 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
@ -134,7 +124,6 @@ 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:
@ -158,7 +147,6 @@ class Content:
"updated_at": self.updated_at,
"folder_id": self.folder_id,
"duration": self.duration,
"metadata": self.metadata,
}
if with_virtual:
@ -177,32 +165,3 @@ 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

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

View File

@ -1,4 +1,3 @@
import json
import mimetypes
from enum import Enum
@ -11,9 +10,7 @@ class ContentInputType(Enum):
UPLOAD = 'upload'
TEXT = 'text'
HIDDEN = 'hidden'
STORAGE = 'storage'
COMPOSITION = 'composition'
@staticmethod
def is_editable(value: Enum) -> bool:
@ -23,8 +20,6 @@ class ContentInputType(Enum):
return True
elif value == ContentInputType.STORAGE:
return True
elif value == ContentInputType.COMPOSITION:
return True
class ContentType(Enum):
@ -34,8 +29,6 @@ class ContentType(Enum):
YOUTUBE = 'youtube'
VIDEO = 'video'
EXTERNAL_STORAGE = 'external_storage'
COMPOSITION = 'composition'
TEXT = 'text'
@staticmethod
def guess_content_type_file(filename: str):
@ -68,10 +61,6 @@ 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:
@ -88,10 +77,6 @@ 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'
@ -110,39 +95,5 @@ 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,10 +1,6 @@
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,7 +54,6 @@ 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,6 +50,7 @@ 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:
@ -57,7 +58,6 @@ 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,19 +160,6 @@ 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':
@ -184,16 +171,14 @@ class WebServer:
})
return make_response(response, error.code)
return self._template_renderer.render_view(
'@core/http-error.html',
error_code=error.code,
error_message=error.description
)
if error.code == 404:
return send_from_directory(self.get_template_dir(), 'core/error404.html'), 404
return error
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,9 +1,5 @@
try:
import psutil
except:
pass
import os
import psutil
import platform
import logging
import os

View File

@ -1,133 +0,0 @@
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 get_video_metadata(filename):
def mp4_duration_with_ffprobe(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,10 +16,7 @@ def get_video_metadata(filename):
elif 'duration' in fields:
duration = round(float(fields['duration']), 2)
width = fields.get('width', 0)
height = fields.get('height', 0)
return width, height, duration
return duration
except (subprocess.CalledProcessError, FileNotFoundError):
logging.warn("ffprobe not found or an error occurred. Using pymediainfo instead.")
@ -28,13 +25,11 @@ def get_video_metadata(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 width, height, duration
return 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, 0, 0
return 0

View File

@ -1,9 +1,3 @@
#!/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
@ -13,15 +7,10 @@ xset s noblank
unclutter -display :0 -noevents -grab &
# Modify Chromium preferences to avoid restore messages
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
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
# 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)
@ -40,9 +29,9 @@ chromium-browser \
--noerrdialogs \
--kiosk \
--incognito \
--user-data-dir=/tmp/obscreen/chromium \
--user-data-dir=/home/pi/.config/chromium \
--no-sandbox \
--window-position=0,0 \
--window-size=${WIDTH},${HEIGHT} \
--display=:0 \
${STUDIO_URL}
http://localhost:5000

View File

@ -96,7 +96,7 @@ grep -qxF "allowed_users=anybody" /etc/X11/Xwrapper.config || echo "allowed_user
grep -qxF "needs_root_rights=yes" /etc/X11/Xwrapper.config || echo "needs_root_rights=yes" | tee -a /etc/X11/Xwrapper.config
# Create the systemd service to start Chromium in kiosk mode
curl https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/system/obscreen-player.service | sed "s#/home/pi#$WORKING_DIR#g" | sed "s#=pi#=$OWNER#g" | tee /etc/systemd/system/obscreen-player.service
curl https://raw.githubusercontent.com/jr-k/obscreen/master/system/obscreen-player.service | sed "s#/home/pi#$WORKING_DIR#g" | sed "s#=pi#=$OWNER#g" | tee /etc/systemd/system/obscreen-player.service
# Reload systemd, enable and start the service
systemctl daemon-reload
@ -108,7 +108,7 @@ systemctl set-default graphical.target
# ============================================================
mkdir -p "$WORKING_DIR/obscreen/var/run"
curl https://git.sumisu.xyz/csmith1865/obscreen/raw/branch/master/system/autostart-browser-x11.sh | sed "s#/home/pi#$WORKING_DIR#g" | sed "s#=pi#=$OWNER#g" | sed "s#chromium-browser#$CHROMIUM#g" | sed "s#http://localhost:5000#$obscreen_studio_url#g" | tee "$WORKING_DIR/obscreen/var/run/play"
curl https://raw.githubusercontent.com/jr-k/obscreen/master/system/autostart-browser-x11.sh | sed "s#/home/pi#$WORKING_DIR#g" | sed "s#=pi#=$OWNER#g" | sed "s#chromium-browser#$CHROMIUM#g" | sed "s#http://localhost:5000#$obscreen_studio_url#g" | tee "$WORKING_DIR/obscreen/var/run/play"
chmod +x "$WORKING_DIR/obscreen/var/run/play"
chown -R $OWNER:$OWNER "$WORKING_DIR/obscreen"

View File

@ -23,7 +23,7 @@ apt-get install -y git build-essential gcc python3-dev python3-pip python3-venv
# Get files
cd $WORKING_DIR
git clone https://github.com/csmith1865/obscreen.git
git clone https://github.com/jr-k/obscreen.git
cd obscreen
# Install application dependencies
@ -44,7 +44,7 @@ chown -R $OWNER:$OWNER ./
# Automount script for external storage
# ============================================================
curl https://raw.githubusercontent.com/csmith1865/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

@ -1 +1 @@
2.4.4
2.5.0

View File

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

View File

@ -14,7 +14,14 @@
{% block body_class %}view-login{% endblock %}
{% block main_page %}
{% block page %}
{% if login_error %}
<div class="alert alert-error">
{{ t('login_error_' ~ login_error) }}
</div>
{% endif %}
<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 %}context-tail{% else %}context-tail-auth{% endif %}">
<div class="{% if not AUTH_ENABLED %}contex-tail{% 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 and AUTH_ENABLED %}
{% if fully_authenticated_view %}
<div class="context-divider"></div>
<div class="context-user">
<div class="dropdown">
@ -213,41 +213,14 @@
</div>
{% endif %}
<div class="main-container">
{% block top_page %}{% endblock %}
{% 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 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 %}
{% block page %}{% endblock %}
</div>
</main>
@ -256,7 +229,6 @@
<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 }}',
@ -271,7 +243,6 @@
};
</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,7 +10,8 @@
{% block body_class %}view-logs-list{% endblock %}
{% block main_page %}
{% block page %}
<div class="bottom-content">
<div class="page-content">
<div class="inner">

View File

@ -11,7 +11,20 @@
{% block body_class %}view-plugins-list{% endblock %}
{% block main_page %}
{% 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 %}
<div class="bottom-content">
<div class="page-content">
<div class="inner">

View File

@ -11,7 +11,20 @@
{% block body_class %}view-settings-list{% endblock %}
{% block main_page %}
{% 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 %}
<div class="bottom-content">
<div class="page-content">
<div class="inner">
@ -29,4 +42,6 @@
</div>
</div>
</div>
{% endblock %}

View File

@ -14,7 +14,7 @@
{% block body_class %}view-sysinfo-list{% endblock %}
{% block top_page %}
{% block page %}
<div class="top-content">
<div class="top-actions align-right">
{{ HOOK(H_SYSINFO_TOOLBAR_ACTIONS_START) }}
@ -24,9 +24,7 @@
{{ 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,10 +6,13 @@
{% 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) }}
@ -17,15 +20,20 @@
{% block body_class %}view-node-player-edit edit-page{% endblock %}
{% block top_page %}
{% block page %}
<div class="top-content">
<h1>
{{ l.fleet_node_player_form_edit_title }}
</h1>
</div>
{% endblock %}
{% block main_page %}
{% 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 %}
<div class="bottom-content">
<div class="page-content">
<div class="inner dirview">

View File

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

View File

@ -1,125 +0,0 @@
<!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,37 +5,17 @@
<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: {{ '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' }}; }
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; }
.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 { {% 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 img, .slide video { height: 100%; }
.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>
@ -67,7 +47,6 @@
// 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
@ -360,18 +339,12 @@
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;
@ -388,65 +361,6 @@
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() {});
@ -461,7 +375,7 @@
}
if (element.innerHTML === 'youtube') {
const autoplay = disableAutoplay ? '0' : '1';
const autoplay = previewMode ? '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>`;
}
}
@ -469,7 +383,7 @@
};
const loadVideo = function(element, callbackReady, item) {
element.innerHTML = `<video ${disableAutoplay ? 'controls' : ''}><source src=${item.location} type="video/mp4" /></video>`;
element.innerHTML = `<video ${previewMode ? 'controls' : ''}><source src=${item.location} type="video/mp4" /></video>`;
const video = element.querySelector('video');
callbackReady(function() {});
@ -493,7 +407,7 @@
}
if (element.innerHTML.match('<video>')) {
if (!disableAutoplay) {
if (!previewMode) {
setTimeout(function() {
video.play();
pausableContent = video;

View File

@ -82,7 +82,7 @@
{% block body_class %}view-playlist-list{% endblock %}
{% block top_page %}
{% block page %}
<div class="top-content">
<div class="top-actions">
{{ HOOK(H_PLAYLIST_TOOLBAR_ACTIONS_START) }}
@ -119,9 +119,13 @@
('<a href="javascript:void(0);" class="item-add playlist-add">'~l.playlist_button_add~'</a>')|safe
) }}
</div>
{% endblock %}
{% block main_page %}
{% if error %}
<div class="alert alert-danger">
{{ l[error] }}
</div>
{% endif %}
<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([]) if slides_with_content else [] %}
{% set slides = slides_with_content[content.id]|default([]) %}
{% 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({'classIcon': icon, 'classColor': color, 'metadata': json_loads(content.metadata)}) }}">
<li class="explr-item" data-entity-json="{{ content.to_json() }}">
<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, path=folder.path) }}{% else %}javascript:void(0);{% endif %}"
<a href="{% if use_href %}{{ url_for('slideshow_content_edit', content_id=content.id) }}{% else %}javascript:void(0);{% endif %}"
class="{{ 'explr-pick-element' if not use_href }}">
{{ content.name }}
</a>

View File

@ -1,248 +0,0 @@
{% 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

@ -1,311 +0,0 @@
{% 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,10 +6,13 @@
{% 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) }}
@ -17,24 +20,20 @@
{% block body_class %}view-content-edit edit-page{% endblock %}
{% block top_page %}
{% block 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 %}
{% block main_page %}
{% 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 %}
<div class="bottom-content">
<div class="page-content">
<div class="inner dirview">
@ -81,7 +80,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 %}
@ -90,24 +89,7 @@
</div>
</div>
{% 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">
<div class="actions actions-left">
<button type="submit" class="btn btn-info">
<i class="fa fa-save icon-left"></i>
{{ l.common_save }}
@ -125,6 +107,14 @@
<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,14 +22,16 @@
<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/super-upload.js"></script>
<script src="{{ STATIC_PREFIX }}js/dragdrop.js"></script>
{{ HOOK(H_SLIDESHOW_CONTENT_JAVASCRIPT) }}
{% endblock %}
{% block body_class %}view-content-list{% endblock %}
{% block top_page %}
{% block page %}
{% set explr_limit_chars = 35 %}
<div class="top-content">
<div class="top-actions">
{{ HOOK(H_SLIDESHOW_CONTENT_TOOLBAR_ACTIONS_START) }}
@ -83,11 +85,25 @@
</div>
</div>
<div class="alert alert-upload alert-danger hidden"></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>
{% 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 %}
{% 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,28 +52,6 @@
</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_edit', content_id='__id__') }}">
<button type="button" class="btn btn-neutral hidden slide-content-show" data-route="{{ url_for('slideshow_content_show', content_id='__id__') }}">
<i class="fa-solid fa-up-right-from-square"></i>
</button>
</div>
@ -88,23 +88,21 @@
</div>
</div>
<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 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>
</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="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 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>
</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_edit', content_id='__id__') }}">
<button type="button" class="btn btn-neutral hidden slide-content-show" data-route="{{ url_for('slideshow_content_show', content_id='__id__') }}">
<i class="fa-solid fa-up-right-from-square"></i>
</button>
</div>
@ -86,22 +86,21 @@
</div>
</div>
<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 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>
</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="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 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>
</div>