Compare commits

..

3 Commits

Author SHA1 Message Date
068eb92959 Update version.txt
All checks were successful
Release build and push docker image / build-and-push-release (push) Successful in 12m7s
2024-10-14 19:18:14 +00:00
b1059f8265 Update .github/actions/common-docker-build/action.yml 2024-10-14 19:16:45 +00:00
1ca066e461 Update .github/workflows/build-release.yml 2024-10-14 19:16:28 +00:00
90 changed files with 439 additions and 3025 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 }}

5
.gitignore vendored
View File

@ -24,7 +24,4 @@ tmp.py
/data/www/plugins/*
!/data/www/plugins/.gitkeep
/var/run/storage/*
!/var/run/storage/.gitkeep
*.egg-info
/build/
/dist/
!/var/run/storage/.gitkeep

View File

@ -1,4 +0,0 @@
include README.md
include LICENSE
docs/setup-run-on-rpi.md
docs/setup-run-headless.md

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
@ -71,20 +71,13 @@ Add Content Modal:
If you value this project, please think about awarding it a ⭐. Thanks ! 🙏
## 🗺️ Short-term roadmap
- New `Composition` content type: Check out a [video demo here](https://demo.obscreen.io/data/uploads/compositions.mp4)
- New `Text` Content Type: Display text with customizable styles, including options for scrolling effects.
- New `HTML` Content Type: Display HTML snippets for more powerful text customization, giving you full control over the content.
- Fleet Studio Management: Reviving a legacy feature
- Remote Player Server: A new way to manage a player from the studio without needing SSH access to player
## 🛟 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
@ -94,7 +87,7 @@ If you value this project, please think about awarding it a ⭐. Thanks ! 🙏
This is "normal" behavior. Videos do not play automatically in Chrome because it requires user interaction with the page (a simple click inside the webpage is enough). If you open the console, you'll see the error: [Uncaught (in promise) DOMException: play() failed because the user didn't interact with the document first...](https://goo.gl/xX8pDD)
To resolve this, you need to use the Chrome flag `--autoplay-policy=no-user-gesture-required`. When connecting a Raspberry Pi with Obscreen Player autorun, this issue doesn't occur because the flag is handled automatically for you. You need to enable this flag yourself otherwise.
To resolve this, you need to use the Chrome flag --autoplay-policy=no-user-gesture-required. When connecting a Raspberry Pi with Obscreen Player autorun, this issue doesn't occur because the flag is handled automatically for you.You need to enable this flag yourself otherwise.
---
@ -112,7 +105,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

@ -62,20 +62,10 @@ docker compose up --detach --pull=always
#### Install
- Install studio by executing following script
##### Linux
```bash
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/jr-k/obscreen.git
cd obscreen
python3 -m venv venv
source ./venv/bin/activate
pip install .
cp .env.dist .env
```
#### Configure
- Server configuration is editable in `.env` file.

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.
@ -20,20 +20,10 @@
#### Install
- Install studio by executing following script
##### 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
cd obscreen
python3 -m venv venv
source ./venv/bin/activate
pip install .
cp .env.dist .env
```
#### Configure
- Server configuration is editable in `.env` file.
@ -82,7 +72,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 +96,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

@ -6,4 +6,3 @@ waitress
flask-login
pysqlite3
psutil
pymediainfo

View File

@ -1,56 +0,0 @@
# obscreen
# ---------------
# A fancy self-hosted digital signage tool. Free, simple and working.
#
# Author: jr-k (c) 2024
# Website: https://github.com/jr-k/obscreen
# License: GPLv2 (see LICENSE file)
import os
import sys
import logging
from setuptools import setup, find_packages
common_dependencies = [
'flask==2.3.3',
'flask-restx==1.3.0',
'python-dotenv',
'cron-descriptor',
'waitress',
'flask-login',
'psutil',
'pymediainfo',
'pysqlite3',
]
if sys.platform == "win32":
common_dependencies.remove('pysqlite3')
if sys.platform == "darwin":
common_dependencies.remove('pysqlite3')
os.environ['PYTHONUTF8'] = '1'
os.environ['PYTHONIOENCODING'] = 'utf-8'
setup(
name='obscreen',
version=open('version.txt').read(),
description='A fancy self-hosted digital signage tool. Free, simple and working.',
long_description=open('README.md').read(),
long_description_content_type='text/markdown',
author='JRK',
author_email='jrk@jierka.com',
url='https://github.com/jr-k/obscreen',
packages=find_packages(),
platforms='any',
classifiers=[
'Programming Language :: Python :: 3',
'License :: OSI Approved :: GNU General Public License v2 (GPLv2)',
'Operating System :: OS Independent',
'Topic :: Desktop Environment :: Screen Savers',
'Topic :: Multimedia :: Graphics'
],
python_requires='>=3.6',
install_requires=common_dependencies,
)

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,
@ -86,8 +81,7 @@ class PlayerController(ObController):
interfaces=[iface['ip_address'] for iface in get_network_interfaces()],
external_url=self._model_store.variable().get_one_by_name('external_url').as_string().strip(),
time_with_seconds=self._model_store.variable().get_one_by_name('default_slide_time_with_seconds'),
noplaylist=request.args.get('noplaylist', '0') == '1',
hard_refresh_request=self._model_store.variable().get_one_by_name("refresh_player_request").as_int()
noplaylist=request.args.get('noplaylist', '0') == '1'
)
def player_playlist(self, playlist_slug_or_id: str = ''):
@ -127,7 +121,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 +131,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 +247,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,
@ -70,8 +71,7 @@ class PlaylistController(ObController):
id=request.form['id'],
name=request.form['name'],
time_sync=True if 'time_sync' in request.form else False,
enabled=True if 'enabled' in request.form else False,
fallback=True if self._model_store.playlist().count_fallbacks() == 0 else None
enabled=True if 'enabled' in request.form else False
)
return redirect(url_for('playlist_list', playlist_id=request.form['id']))
@ -82,12 +82,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

@ -69,6 +69,5 @@ class SysinfoController(ObController):
def sysinfo_get_ipaddr(self):
return jsonify({
'external_url': self._model_store.variable().get_one_by_name('external_url').as_string().strip(),
'interfaces': [iface['ip_address'] for iface in get_network_interfaces()],
'hard_refresh_request': self._model_store.variable().get_one_by_name("refresh_player_request").as_int()
'interfaces': [iface['ip_address'] for iface in get_network_interfaces()]
})

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)
@ -261,17 +236,7 @@ class ContentManager(ModelManager):
location = content.location
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('/')
)
location = "https://www.youtube.com/watch?v={}".format(content.location)
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

@ -163,7 +163,7 @@ GROUP BY playlist_id;
def post_delete(self, playlist_id: str) -> str:
return playlist_id
def update_form(self, id: int, name: Optional[str] = None, time_sync: Optional[bool] = None, enabled: Optional[bool] = None, fallback: Optional[bool] = None) -> None:
def update_form(self, id: int, name: Optional[str] = None, time_sync: Optional[bool] = None, enabled: Optional[bool] = None) -> None:
playlist = self.get(id)
if not playlist:
@ -173,7 +173,6 @@ GROUP BY playlist_id;
"name": name if isinstance(name, str) else playlist.name,
"time_sync": time_sync if isinstance(time_sync, bool) else playlist.time_sync,
"enabled": enabled if isinstance(enabled, bool) else playlist.enabled,
"fallback": fallback if isinstance(fallback, bool) else playlist.fallback,
}
if name != playlist.name:

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

@ -1,40 +1,18 @@
import struct
import logging
import subprocess
import json
from pymediainfo import MediaInfo
def get_video_metadata(filename):
try:
result = subprocess.check_output(f'ffprobe -v quiet -show_streams -select_streams v:0 -of json "{filename}"', shell=True).decode()
fields = json.loads(result)['streams'][0]
duration = 0
def mp4_duration_with_ffprobe(filename):
import subprocess, json
if 'tags' in fields and 'DURATION' in fields['tags']:
duration = round(float(fields['tags']['DURATION']), 2)
elif 'duration' in fields:
duration = round(float(fields['duration']), 2)
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]
width = fields.get('width', 0)
height = fields.get('height', 0)
if 'tags' in fields and 'DURATION' in fields['tags']:
return round(float(fields['tags']['DURATION']), 2)
return width, height, duration
except (subprocess.CalledProcessError, FileNotFoundError):
logging.warn("ffprobe not found or an error occurred. Using pymediainfo instead.")
if 'duration' in fields:
return round(float(fields['duration']), 2)
try:
media_info = MediaInfo.parse(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
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 \
--no-sandbox \
--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

@ -67,10 +67,10 @@ CHROMIUM=""
# Attempt to install chromium-browser
if sudo apt-get install -y chromium-browser; then
CHROMIUM="chromium-browser"
else
if sudo apt-get install -y chromium; then
CHROMIUM="chromium"
fi
fi
if sudo apt-get install -y chromium; then
CHROMIUM="chromium"
fi
if [ -z "$CHROMIUM" ]; then
@ -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.4.3

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

@ -3,8 +3,6 @@
<head>
<script type="text/javascript">
const time_with_seconds = {{ 'true' if time_with_seconds.as_bool() else 'false' }};
const last_hard_refresh_request = {{ hard_refresh_request }};
const no_playlist = {% if noplaylist %}true{% else %}false{% endif %};
let external_url = '{{ external_url.strip() }}';
function updateTime() {
@ -37,15 +35,7 @@
if (xhr.readyState === 4 && xhr.status === 200) {
const json_response = JSON.parse(xhr.responseText);
external_url = json_response.external_url;
setIps(json_response.interfaces);
if (no_playlist) {
setTimeout(function () {
if (last_hard_refresh_request != json_response.hard_refresh_request) {
document.location.href = '{{ url_for('player') }}';
}
}, 2000);
}
setIps(json_response.interfaces)
}
};
xhr.send();

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>
@ -62,12 +42,11 @@
const playlistCheckResolutionMs = {{ polling_interval * 1000 }};
// Backend flag updates
const lastHardRefreshRequest = items.hard_refresh_request;
let needHardRefresh = null;
// 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
@ -128,7 +107,9 @@
items = data;
itemsLoadedProcess();
if (lastHardRefreshRequest != items.hard_refresh_request) {
if (needHardRefresh === null) {
needHardRefresh = items.hard_refresh_request;
} else if (needHardRefresh != items.hard_refresh_request) {
document.location.reload();
}
}).catch(function(err) {
@ -360,18 +341,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 +363,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 +377,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 +385,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 +409,7 @@
}
if (element.innerHTML.match('<video>')) {
if (!disableAutoplay) {
if (!previewMode) {
setTimeout(function() {
video.play();
pausableContent = video;

View File

@ -54,18 +54,10 @@
var script_sender = document.createElement('script');
script_sender.src = "{{ STATIC_PREFIX }}js/lib/cast-sender.js";
document.body.appendChild(script_sender);
const isHttps = document.location.protocol.indexOf('https') === 0;
const isLoopback = document.location.host.indexOf('localhost') === 0 || document.location.host.indexOf('127.0.0.1') === 0;
if (isLoopback || !isHttps) {
var script_caster = document.createElement('script');
script_caster.src = "{{ STATIC_PREFIX }}js/cast-url.js";
document.body.appendChild(script_caster);
} else {
$(document).on('click', '.cast-url', function() {
window.open('https://cast.obscreen.io/sender.html?url=' + encodeURIComponent($('#' + $(this).attr('data-target-id')).val()));
});
}
var script_caster = document.createElement('script');
script_caster.src = "{{ STATIC_PREFIX }}js/cast-url.js";
document.body.appendChild(script_caster);
}
});
</script>
@ -82,7 +74,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 +111,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>