add playlist management
This commit is contained in:
parent
dc0c975a36
commit
a7c986e1b4
@ -13,10 +13,10 @@ Use a RaspberryPi (Lite OS) to show a full-screen slideshow (Kiosk-mode)
|
|||||||
### Features:
|
### Features:
|
||||||
- Dead simple chromium webview
|
- Dead simple chromium webview
|
||||||
- Clear GUI
|
- Clear GUI
|
||||||
- Fleet view to manage many devices easily
|
|
||||||
- Very few dependencies
|
- Very few dependencies
|
||||||
- SQLite database
|
- SQLite database
|
||||||
- Plugin system
|
- Plugin system
|
||||||
|
- Feature flags to enable complex use cases (Fleet/User/Playlist management)
|
||||||
- No stupid pricing plan
|
- No stupid pricing plan
|
||||||
- No cloud
|
- No cloud
|
||||||
- No telemetry
|
- No telemetry
|
||||||
@ -25,6 +25,6 @@ Use a RaspberryPi (Lite OS) to show a full-screen slideshow (Kiosk-mode)
|
|||||||
|
|
||||||
# Two setups available
|
# Two setups available
|
||||||
|
|
||||||
### 🔴 [I want to power RaspberryPi and automatically see my slideshow on a screen connected to it and manage the slideshow](docs/setup-run-on-rpi.md)
|
### 🔴 [I want to power a RaspberryPi and automatically see my slideshow on a screen connected to it and manage the slideshow](docs/setup-run-on-rpi.md)
|
||||||
### 🔵 [I want to start browser and setup playlist url manually on my device and just want a slideshow manager](docs/setup-run-headless.md)
|
### 🔵 [I just want a slideshow manager and I'll deal with screen and browser myself](docs/setup-run-headless.md)
|
||||||
|
|
||||||
|
|||||||
@ -59,6 +59,14 @@ a {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.container.expand {
|
||||||
|
min-width: 100%;
|
||||||
|
|
||||||
|
@media only screen and (max-width: 1200px) {
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
header {
|
header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -346,11 +354,17 @@ button.purple:hover {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel td.infos .inner {
|
.panel td .inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel td div.badge {
|
||||||
|
margin-right: 5px;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel a {
|
.panel a {
|
||||||
@ -718,4 +732,102 @@ a.badge:hover {
|
|||||||
|
|
||||||
.badge.anonymous {
|
.badge.anonymous {
|
||||||
opacity: .2;
|
opacity: .2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.explorer .left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.explorer .right {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.panel-menu {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
align-self: stretch;
|
||||||
|
margin-right: 0;
|
||||||
|
min-width: 250px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.panel-menu ul {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 250px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-self: stretch;
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.panel-menu ul li {
|
||||||
|
margin: 3px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.panel-menu ul li a {
|
||||||
|
padding: 5px 15px 5px 15px;
|
||||||
|
color: inherit;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.panel.panel-menu ul li:hover {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.panel-menu ul li.active {
|
||||||
|
color: #fff;
|
||||||
|
background: rgba(255,255,255,.1);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-weight: bold;
|
||||||
|
border: 1px solid rgba(255,255,255,.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.explorer .panel-menu {
|
||||||
|
border-color: #692fbd;
|
||||||
|
}
|
||||||
|
.explorer .panel-active {
|
||||||
|
background: white;
|
||||||
|
color: #AAA;
|
||||||
|
}
|
||||||
|
.explorer .panel-active h3 {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.explorer .panel-inactive {
|
||||||
|
background: white;
|
||||||
|
color: #AAA;
|
||||||
|
border-color: #BBB;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.explorer .panel.panel-active th {
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
}
|
}
|
||||||
@ -92,7 +92,6 @@ jQuery(document).ready(function ($) {
|
|||||||
$(document).on('click', '.user-delete', function () {
|
$(document).on('click', '.user-delete', function () {
|
||||||
if (confirm(l.js_auth_user_delete_confirmation)) {
|
if (confirm(l.js_auth_user_delete_confirmation)) {
|
||||||
const $tr = $(this).parents('tr:eq(0)');
|
const $tr = $(this).parents('tr:eq(0)');
|
||||||
updateTable();
|
|
||||||
$.ajax({
|
$.ajax({
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
url: '/auth/user/delete',
|
url: '/auth/user/delete',
|
||||||
@ -100,6 +99,7 @@ jQuery(document).ready(function ($) {
|
|||||||
data: JSON.stringify({id: getId($(this))}),
|
data: JSON.stringify({id: getId($(this))}),
|
||||||
success: function(data) {
|
success: function(data) {
|
||||||
$tr.remove();
|
$tr.remove();
|
||||||
|
updateTable();
|
||||||
},
|
},
|
||||||
error: function(data) {
|
error: function(data) {
|
||||||
$('.alert-error').html(data.responseJSON.message).removeClass('hidden');
|
$('.alert-error').html(data.responseJSON.message).removeClass('hidden');
|
||||||
@ -16,7 +16,7 @@ jQuery(document).ready(function ($) {
|
|||||||
}
|
}
|
||||||
}).tableDnDUpdate();
|
}).tableDnDUpdate();
|
||||||
updatePositions();
|
updatePositions();
|
||||||
}
|
};
|
||||||
|
|
||||||
const showModal = function (modalClass) {
|
const showModal = function (modalClass) {
|
||||||
$modalsRoot.removeClass('hidden').find('form').trigger('reset');
|
$modalsRoot.removeClass('hidden').find('form').trigger('reset');
|
||||||
96
data/www/js/playlist/playlists.js
Normal file
96
data/www/js/playlist/playlists.js
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
jQuery(document).ready(function ($) {
|
||||||
|
const $tableActive = $('table.active-playlists');
|
||||||
|
const $tableInactive = $('table.inactive-playlists');
|
||||||
|
const $modalsRoot = $('.modals');
|
||||||
|
|
||||||
|
const getId = function ($el) {
|
||||||
|
return $el.is('tr') ? $el.attr('data-level') : $el.parents('tr:eq(0)').attr('data-level');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateTable = function () {
|
||||||
|
$('table').each(function () {
|
||||||
|
if ($(this).find('tbody tr.playlist-item:visible').length === 0) {
|
||||||
|
$(this).find('tr.empty-tr').removeClass('hidden');
|
||||||
|
} else {
|
||||||
|
$(this).find('tr.empty-tr').addClass('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showModal = function (modalClass) {
|
||||||
|
$modalsRoot.removeClass('hidden').find('form').trigger('reset');
|
||||||
|
$modalsRoot.find('.modal').addClass('hidden');
|
||||||
|
$modalsRoot.find('.modal.' + modalClass).removeClass('hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
const hideModal = function () {
|
||||||
|
$modalsRoot.addClass('hidden').find('form').trigger('reset');
|
||||||
|
};
|
||||||
|
|
||||||
|
const main = function () {
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
$(document).on('change', 'input[type=checkbox]', function () {
|
||||||
|
$.ajax({
|
||||||
|
url: '/playlist/toggle',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
data: JSON.stringify({id: getId($(this)), enabled: $(this).is(':checked')}),
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
|
||||||
|
const $tr = $(this).parents('tr:eq(0)').remove().clone();
|
||||||
|
|
||||||
|
if ($(this).is(':checked')) {
|
||||||
|
$tableActive.append($tr);
|
||||||
|
} else {
|
||||||
|
$tableInactive.append($tr);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('click', '.modal-close', function () {
|
||||||
|
hideModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('click', '.playlist-add', function () {
|
||||||
|
showModal('modal-playlist-add');
|
||||||
|
$('.modal-playlist-add input:eq(0)').focus().select();
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('click', '.playlist-edit', function () {
|
||||||
|
const playlist = JSON.parse($(this).parents('tr:eq(0)').attr('data-entity'));
|
||||||
|
showModal('modal-playlist-edit');
|
||||||
|
$('.modal-playlist-edit input:visible:eq(0)').focus().select();
|
||||||
|
$('#playlist-edit-name').val(playlist.name);
|
||||||
|
$('#playlist-edit-id').val(playlist.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).on('click', '.playlist-delete', function () {
|
||||||
|
if (confirm(l.js_playlist_delete_confirmation)) {
|
||||||
|
const $tr = $(this).parents('tr:eq(0)');
|
||||||
|
$.ajax({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: '/playlist/delete',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
data: JSON.stringify({id: getId($(this))}),
|
||||||
|
success: function(data) {
|
||||||
|
$tr.remove();
|
||||||
|
updateTable();
|
||||||
|
},
|
||||||
|
error: function(data) {
|
||||||
|
$('.alert-error').html(data.responseJSON.message).removeClass('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$(document).keyup(function (e) {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
hideModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
main();
|
||||||
|
});
|
||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> #### 👈 [back to readme](/README.md)
|
> #### 👈 [back to readme](/README.md)
|
||||||
|
|
||||||
#### 🔵 You want to start browser and setup playlist url manually on your device and just want a slideshow manager ? You're in the right place.
|
#### 🔵 You just want a slideshow manager and you'll deal with screen and browser yourself ? You're in the right place.
|
||||||
|
|
||||||
---
|
---
|
||||||
## 📡 Run the manager
|
## 📡 Run the manager
|
||||||
@ -73,16 +73,16 @@ python ./obscreen.py
|
|||||||
|
|
||||||
#### Start server forever with systemctl
|
#### Start server forever with systemctl
|
||||||
```bash
|
```bash
|
||||||
cat "$(pwd)/system/obscreen-manager.service" | sed "s#/home/pi#$HOME#g" | sed "s#=pi#=$USER#g" | sudo tee /etc/systemd/system/obscreen-manager.service
|
cat "$(pwd)/system/obscreen-composer.service" | sed "s#/home/pi#$HOME#g" | sed "s#=pi#=$USER#g" | sudo tee /etc/systemd/system/obscreen-composer.service
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable obscreen-manager.service
|
sudo systemctl enable obscreen-composer.service
|
||||||
sudo systemctl start obscreen-manager.service
|
sudo systemctl start obscreen-composer.service
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Troubleshoot
|
#### Troubleshoot
|
||||||
```bash
|
```bash
|
||||||
# Watch logs with following command
|
# Watch logs with following command
|
||||||
sudo journalctl -u obscreen-manager -f
|
sudo journalctl -u obscreen-composer -f
|
||||||
```
|
```
|
||||||
---
|
---
|
||||||
## 👌 Usage
|
## 👌 Usage
|
||||||
|
|||||||
@ -87,16 +87,16 @@ python ./obscreen.py
|
|||||||
|
|
||||||
#### Start server forever with systemctl
|
#### Start server forever with systemctl
|
||||||
```bash
|
```bash
|
||||||
cat "$(pwd)/system/obscreen-manager.service" | sed "s#/home/pi#$HOME#g" | sed "s#=pi#=$USER#g" | sudo tee /etc/systemd/system/obscreen-manager.service
|
cat "$(pwd)/system/obscreen-composer.service" | sed "s#/home/pi#$HOME#g" | sed "s#=pi#=$USER#g" | sudo tee /etc/systemd/system/obscreen-composer.service
|
||||||
sudo systemctl daemon-reload
|
sudo systemctl daemon-reload
|
||||||
sudo systemctl enable obscreen-manager.service
|
sudo systemctl enable obscreen-composer.service
|
||||||
sudo systemctl start obscreen-manager.service
|
sudo systemctl start obscreen-composer.service
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Troubleshoot
|
#### Troubleshoot
|
||||||
```bash
|
```bash
|
||||||
# Watch logs with following command
|
# Watch logs with following command
|
||||||
sudo journalctl -u obscreen-manager -f
|
sudo journalctl -u obscreen-composer -f
|
||||||
```
|
```
|
||||||
---
|
---
|
||||||
## 🏁 Finally
|
## 🏁 Finally
|
||||||
|
|||||||
25
lang/en.json
25
lang/en.json
@ -3,6 +3,8 @@
|
|||||||
"slideshow_goto_player": "Go to player",
|
"slideshow_goto_player": "Go to player",
|
||||||
"slideshow_refresh_player": "Refresh player",
|
"slideshow_refresh_player": "Refresh player",
|
||||||
"slideshow_refresh_player_success": "A player refresh has been schedueld, it should happen soon enough (%time% seconds maximum)",
|
"slideshow_refresh_player_success": "A player refresh has been schedueld, it should happen soon enough (%time% seconds maximum)",
|
||||||
|
"slideshow_playlist_panel_title": "Playlists",
|
||||||
|
"slideshow_playlist_panel_item_default": "Default Playlist",
|
||||||
"slideshow_slide_button_add": "Add a slide",
|
"slideshow_slide_button_add": "Add a slide",
|
||||||
"slideshow_slide_panel_active": "Active slides",
|
"slideshow_slide_panel_active": "Active slides",
|
||||||
"slideshow_slide_panel_inactive": "Inactive slides",
|
"slideshow_slide_panel_inactive": "Inactive slides",
|
||||||
@ -37,7 +39,25 @@
|
|||||||
"slideshow_slide_form_button_cancel": "Cancel",
|
"slideshow_slide_form_button_cancel": "Cancel",
|
||||||
"js_slideshow_slide_delete_confirmation": "Are you sure?",
|
"js_slideshow_slide_delete_confirmation": "Are you sure?",
|
||||||
|
|
||||||
"fleet_page_title": "Devices",
|
"playlist_page_title": "Playlists",
|
||||||
|
"playlist_button_add": "Add a playlist",
|
||||||
|
"playlist_panel_active": "Active playlists",
|
||||||
|
"playlist_panel_inactive": "Inactive playlists",
|
||||||
|
"playlist_panel_empty": "Currently, there are no playlists. %link% now.",
|
||||||
|
"playlist_panel_th_name": "Name",
|
||||||
|
"playlist_panel_th_duration": "Duration",
|
||||||
|
"playlist_panel_th_enabled": "Enabled",
|
||||||
|
"playlist_panel_th_activity": "Options",
|
||||||
|
"playlist_form_add_title": "Add Slide",
|
||||||
|
"playlist_form_add_submit": "Add",
|
||||||
|
"playlist_form_edit_title": "Edit Slide",
|
||||||
|
"playlist_form_edit_submit": "Save",
|
||||||
|
"playlist_form_label_name": "Name",
|
||||||
|
"playlist_form_button_cancel": "Cancel",
|
||||||
|
"js_playlist_delete_confirmation": "Are you sure?",
|
||||||
|
"playlist_delete_has_slides": "Playlist has slides, please remove them before and retry",
|
||||||
|
|
||||||
|
"fleet_page_title": "Composers",
|
||||||
"fleet_screen_button_add": "Add a screen",
|
"fleet_screen_button_add": "Add a screen",
|
||||||
"fleet_screen_button_fleetview": "Fleet view",
|
"fleet_screen_button_fleetview": "Fleet view",
|
||||||
"fleet_screen_panel_active": "Active screens",
|
"fleet_screen_panel_active": "Active screens",
|
||||||
@ -89,7 +109,8 @@
|
|||||||
"settings_variable_form_label_value": "Value",
|
"settings_variable_form_label_value": "Value",
|
||||||
"settings_variable_form_button_cancel": "Cancel",
|
"settings_variable_form_button_cancel": "Cancel",
|
||||||
"settings_variable_desc_lang": "Server language",
|
"settings_variable_desc_lang": "Server language",
|
||||||
"settings_variable_desc_fleet_enabled": "Enable fleet screen management view",
|
"settings_variable_desc_playlist_enabled": "Enable playlist management",
|
||||||
|
"settings_variable_desc_fleet_composer_enabled": "Enable fleet composer management",
|
||||||
"settings_variable_desc_auth_enabled": "Enable auth management",
|
"settings_variable_desc_auth_enabled": "Enable auth management",
|
||||||
"settings_variable_desc_edition_auth_enabled": "Default user credentials will be admin/admin",
|
"settings_variable_desc_edition_auth_enabled": "Default user credentials will be admin/admin",
|
||||||
"settings_variable_desc_external_url": "External url (i.e: https://screen-01.company.com or http://10.10.3.100)",
|
"settings_variable_desc_external_url": "External url (i.e: https://screen-01.company.com or http://10.10.3.100)",
|
||||||
|
|||||||
27
lang/fr.json
27
lang/fr.json
@ -3,6 +3,8 @@
|
|||||||
"slideshow_goto_player": "Voir le lecteur",
|
"slideshow_goto_player": "Voir le lecteur",
|
||||||
"slideshow_refresh_player": "Rafraîchir le lecteur",
|
"slideshow_refresh_player": "Rafraîchir le lecteur",
|
||||||
"slideshow_refresh_player_success": "Un rafraîchissement du lecteur a été programmé, il devrait avoir lieu sous peu (%time% secondes maximum)",
|
"slideshow_refresh_player_success": "Un rafraîchissement du lecteur a été programmé, il devrait avoir lieu sous peu (%time% secondes maximum)",
|
||||||
|
"slideshow_playlist_panel_title": "Playlists",
|
||||||
|
"slideshow_playlist_panel_item_default": "Playlist par défaut",
|
||||||
"slideshow_slide_button_add": "Ajouter une slide",
|
"slideshow_slide_button_add": "Ajouter une slide",
|
||||||
"slideshow_slide_panel_active": "Slides actives",
|
"slideshow_slide_panel_active": "Slides actives",
|
||||||
"slideshow_slide_panel_inactive": "Slides inactives",
|
"slideshow_slide_panel_inactive": "Slides inactives",
|
||||||
@ -15,7 +17,7 @@
|
|||||||
"slideshow_slide_panel_th_activity": "Options",
|
"slideshow_slide_panel_th_activity": "Options",
|
||||||
"slideshow_slide_panel_td_cron_scheduled_loop": "En boucle",
|
"slideshow_slide_panel_td_cron_scheduled_loop": "En boucle",
|
||||||
"slideshow_slide_panel_td_cron_scheduled_bad_cron": "Mauvaise valeur cron",
|
"slideshow_slide_panel_td_cron_scheduled_bad_cron": "Mauvaise valeur cron",
|
||||||
"slideshow_slide_form_add_title": "Ajouter d'une slide",
|
"slideshow_slide_form_add_title": "Ajout d'une slide",
|
||||||
"slideshow_slide_form_add_submit": "Ajouter",
|
"slideshow_slide_form_add_submit": "Ajouter",
|
||||||
"slideshow_slide_form_edit_title": "Modification d'une slide",
|
"slideshow_slide_form_edit_title": "Modification d'une slide",
|
||||||
"slideshow_slide_form_edit_submit": "Enregistrer",
|
"slideshow_slide_form_edit_submit": "Enregistrer",
|
||||||
@ -37,7 +39,25 @@
|
|||||||
"slideshow_slide_form_button_cancel": "Annuler",
|
"slideshow_slide_form_button_cancel": "Annuler",
|
||||||
"js_slideshow_slide_delete_confirmation": "Êtes-vous sûr ?",
|
"js_slideshow_slide_delete_confirmation": "Êtes-vous sûr ?",
|
||||||
|
|
||||||
"fleet_page_title": "Appareils",
|
"playlist_page_title": "Listes de lecture",
|
||||||
|
"playlist_button_add": "Ajouter une liste de lecture",
|
||||||
|
"playlist_panel_active": "Listes de lecture actives",
|
||||||
|
"playlist_panel_inactive": "Listes de lecture inactives",
|
||||||
|
"playlist_panel_empty": "Actuellement, il n'y a pas de liste de lecture. %link% maintenant.",
|
||||||
|
"playlist_panel_th_name": "Nom",
|
||||||
|
"playlist_panel_th_duration": "Durée",
|
||||||
|
"playlist_panel_th_enabled": "Activé",
|
||||||
|
"playlist_panel_th_activity": "Options",
|
||||||
|
"playlist_form_add_title": "Ajout d'une liste de lecture",
|
||||||
|
"playlist_form_add_submit": "Ajouter",
|
||||||
|
"playlist_form_edit_title": "Modification d'une liste de lecture",
|
||||||
|
"playlist_form_edit_submit": "Enregistrer",
|
||||||
|
"playlist_form_label_name": "Nom",
|
||||||
|
"playlist_form_button_cancel": "Annuler",
|
||||||
|
"js_playlist_delete_confirmation": "Êtes-vous sûr ?",
|
||||||
|
"playlist_delete_has_slides": "La liste de lecture contient des sldies, supprimez-les avant et réessayez",
|
||||||
|
|
||||||
|
"fleet_page_title": "Composeurs",
|
||||||
"fleet_screen_button_add": "Ajouter un écran",
|
"fleet_screen_button_add": "Ajouter un écran",
|
||||||
"fleet_screen_button_fleetview": "Vue flotte",
|
"fleet_screen_button_fleetview": "Vue flotte",
|
||||||
"fleet_screen_panel_active": "Écrans actifs",
|
"fleet_screen_panel_active": "Écrans actifs",
|
||||||
@ -89,7 +109,8 @@
|
|||||||
"settings_variable_form_label_value": "Valeur",
|
"settings_variable_form_label_value": "Valeur",
|
||||||
"settings_variable_form_button_cancel": "Annuler",
|
"settings_variable_form_button_cancel": "Annuler",
|
||||||
"settings_variable_desc_lang": "Langage de l'application",
|
"settings_variable_desc_lang": "Langage de l'application",
|
||||||
"settings_variable_desc_fleet_enabled": "Activer la gestion de flotte des écrans",
|
"settings_variable_desc_playlist_enabled": "Activer la gestion des playlists",
|
||||||
|
"settings_variable_desc_fleet_composer_enabled": "Activer la gestion de flotte des composeurs",
|
||||||
"settings_variable_desc_auth_enabled": "Activer la gestion de l'authentification",
|
"settings_variable_desc_auth_enabled": "Activer la gestion de l'authentification",
|
||||||
"settings_variable_desc_edition_auth_enabled": "Les identifiants de l'utilisateur par défaut seront admin/admin",
|
"settings_variable_desc_edition_auth_enabled": "Les identifiants de l'utilisateur par défaut seront admin/admin",
|
||||||
"settings_variable_desc_external_url": "URL externe (i.e: https://screen-01.company.com or http://10.10.3.100)",
|
"settings_variable_desc_external_url": "URL externe (i.e: https://screen-01.company.com or http://10.10.3.100)",
|
||||||
|
|||||||
@ -9,14 +9,22 @@ from src.interface.ObController import ObController
|
|||||||
|
|
||||||
class AuthController(ObController):
|
class AuthController(ObController):
|
||||||
|
|
||||||
|
def guard_auth(self, f):
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if not self._model_store.variable().map().get('auth_enabled').as_bool():
|
||||||
|
return redirect(url_for('manage'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
def register(self):
|
def register(self):
|
||||||
self._app.add_url_rule('/login', 'login', self.login, methods=['GET', 'POST'])
|
self._app.add_url_rule('/login', 'login', self.login, methods=['GET', 'POST'])
|
||||||
self._app.add_url_rule('/logout', 'logout', self.logout, methods=['GET'])
|
self._app.add_url_rule('/logout', 'logout', self.logout, methods=['GET'])
|
||||||
self._app.add_url_rule('/auth/user/list', 'auth_user_list', self._auth(self.auth_user_list), methods=['GET'])
|
self._app.add_url_rule('/auth/user/list', 'auth_user_list', self.guard_auth(self._auth(self.auth_user_list)), methods=['GET'])
|
||||||
self._app.add_url_rule('/auth/user/add', 'auth_user_add', self._auth(self.auth_user_add), methods=['POST'])
|
self._app.add_url_rule('/auth/user/add', 'auth_user_add', self.guard_auth(self._auth(self.auth_user_add)), methods=['POST'])
|
||||||
self._app.add_url_rule('/auth/user/edit', 'auth_user_edit', self._auth(self.auth_user_edit), methods=['POST'])
|
self._app.add_url_rule('/auth/user/edit', 'auth_user_edit', self.guard_auth(self._auth(self.auth_user_edit)), methods=['POST'])
|
||||||
self._app.add_url_rule('/auth/user/toggle', 'auth_user_toggle', self._auth(self.auth_user_toggle), methods=['POST'])
|
self._app.add_url_rule('/auth/user/toggle', 'auth_user_toggle', self.guard_auth(self._auth(self.auth_user_toggle)), methods=['POST'])
|
||||||
self._app.add_url_rule('/auth/user/delete', 'auth_user_delete', self._auth(self.auth_user_delete), methods=['DELETE'])
|
self._app.add_url_rule('/auth/user/delete', 'auth_user_delete', self.guard_auth(self._auth(self.auth_user_delete)), methods=['DELETE'])
|
||||||
|
|
||||||
def login(self):
|
def login(self):
|
||||||
login_error = None
|
login_error = None
|
||||||
|
|||||||
@ -8,14 +8,22 @@ from src.interface.ObController import ObController
|
|||||||
|
|
||||||
class FleetController(ObController):
|
class FleetController(ObController):
|
||||||
|
|
||||||
|
def guard_fleet(self, f):
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if not self._model_store.variable().map().get('fleet_composer_enabled').as_bool():
|
||||||
|
return redirect(url_for('manage'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
def register(self):
|
def register(self):
|
||||||
self._app.add_url_rule('/fleet', 'fleet', self._auth(self.fleet), methods=['GET'])
|
self._app.add_url_rule('/fleet', 'fleet', self.guard_fleet(self._auth(self.fleet)), methods=['GET'])
|
||||||
self._app.add_url_rule('/fleet/screen/list', 'fleet_screen_list', self._auth(self.fleet_screen_list), methods=['GET'])
|
self._app.add_url_rule('/fleet/screen/list', 'fleet_screen_list', self.guard_fleet(self._auth(self.fleet_screen_list)), methods=['GET'])
|
||||||
self._app.add_url_rule('/fleet/screen/add', 'fleet_screen_add', self._auth(self.fleet_screen_add), methods=['POST'])
|
self._app.add_url_rule('/fleet/screen/add', 'fleet_screen_add', self.guard_fleet(self._auth(self.fleet_screen_add)), methods=['POST'])
|
||||||
self._app.add_url_rule('/fleet/screen/edit', 'fleet_screen_edit', self._auth(self.fleet_screen_edit), methods=['POST'])
|
self._app.add_url_rule('/fleet/screen/edit', 'fleet_screen_edit', self.guard_fleet(self._auth(self.fleet_screen_edit)), methods=['POST'])
|
||||||
self._app.add_url_rule('/fleet/screen/toggle', 'fleet_screen_toggle', self._auth(self.fleet_screen_toggle), methods=['POST'])
|
self._app.add_url_rule('/fleet/screen/toggle', 'fleet_screen_toggle', self.guard_fleet(self._auth(self.fleet_screen_toggle)), methods=['POST'])
|
||||||
self._app.add_url_rule('/fleet/screen/delete', 'fleet_screen_delete', self._auth(self.fleet_screen_delete), methods=['DELETE'])
|
self._app.add_url_rule('/fleet/screen/delete', 'fleet_screen_delete', self.guard_fleet(self._auth(self.fleet_screen_delete)), methods=['DELETE'])
|
||||||
self._app.add_url_rule('/fleet/screen/position', 'fleet_screen_position', self._auth(self.fleet_screen_position), methods=['POST'])
|
self._app.add_url_rule('/fleet/screen/position', 'fleet_screen_position', self.guard_fleet(self._auth(self.fleet_screen_position)), methods=['POST'])
|
||||||
|
|
||||||
def fleet(self):
|
def fleet(self):
|
||||||
return render_template(
|
return render_template(
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify
|
from flask import Flask, render_template, redirect, request, url_for, send_from_directory, jsonify
|
||||||
|
|
||||||
from src.service.ModelStore import ModelStore
|
from src.service.ModelStore import ModelStore
|
||||||
from src.interface.ObController import ObController
|
from src.interface.ObController import ObController
|
||||||
from src.utils import get_ip_address, get_safe_cron_descriptor
|
from src.utils import get_ip_address, get_safe_cron_descriptor
|
||||||
@ -8,8 +10,8 @@ from src.utils import get_ip_address, get_safe_cron_descriptor
|
|||||||
|
|
||||||
class PlayerController(ObController):
|
class PlayerController(ObController):
|
||||||
|
|
||||||
def _get_playlist(self) -> dict:
|
def _get_playlist(self, playlist_id: Optional[int] = 0) -> dict:
|
||||||
enabled_slides = self._model_store.slide().get_enabled_slides()
|
enabled_slides = self._model_store.slide().get_slides(enabled=True, playlist_id=playlist_id)
|
||||||
slides = self._model_store.slide().to_dict(enabled_slides)
|
slides = self._model_store.slide().to_dict(enabled_slides)
|
||||||
|
|
||||||
playlist_loop = []
|
playlist_loop = []
|
||||||
@ -32,13 +34,21 @@ class PlayerController(ObController):
|
|||||||
|
|
||||||
def register(self):
|
def register(self):
|
||||||
self._app.add_url_rule('/', 'player', self.player, methods=['GET'])
|
self._app.add_url_rule('/', 'player', self.player, methods=['GET'])
|
||||||
|
self._app.add_url_rule('/use/<playlist_slug_or_id>', 'player_use', self.player, methods=['GET'])
|
||||||
self._app.add_url_rule('/player/default', 'player_default', self.player_default, methods=['GET'])
|
self._app.add_url_rule('/player/default', 'player_default', self.player_default, methods=['GET'])
|
||||||
self._app.add_url_rule('/player/playlist', 'player_playlist', self.player_playlist, methods=['GET'])
|
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'])
|
||||||
|
|
||||||
|
def player(self, playlist_slug_or_id: str = ''):
|
||||||
|
current_playlist = self._model_store.playlist().get_one_by("slug = ? OR id = ?", {
|
||||||
|
"slug": playlist_slug_or_id,
|
||||||
|
"id": playlist_slug_or_id
|
||||||
|
})
|
||||||
|
playlist_id = current_playlist.id if current_playlist else None
|
||||||
|
|
||||||
def player(self):
|
|
||||||
return render_template(
|
return render_template(
|
||||||
'player/player.jinja.html',
|
'player/player.jinja.html',
|
||||||
items=json.dumps(self._get_playlist()),
|
items=json.dumps(self._get_playlist(playlist_id=playlist_id)),
|
||||||
default_slide_duration=self._model_store.variable().get_one_by_name('default_slide_duration'),
|
default_slide_duration=self._model_store.variable().get_one_by_name('default_slide_duration'),
|
||||||
polling_interval=self._model_store.variable().get_one_by_name('polling_interval'),
|
polling_interval=self._model_store.variable().get_one_by_name('polling_interval'),
|
||||||
slide_animation_enabled=self._model_store.variable().get_one_by_name('slide_animation_enabled'),
|
slide_animation_enabled=self._model_store.variable().get_one_by_name('slide_animation_enabled'),
|
||||||
@ -54,5 +64,11 @@ class PlayerController(ObController):
|
|||||||
ipaddr=ipaddr if ipaddr else self._model_store.lang().map().get('common_unknown_ipaddr')
|
ipaddr=ipaddr if ipaddr else self._model_store.lang().map().get('common_unknown_ipaddr')
|
||||||
)
|
)
|
||||||
|
|
||||||
def player_playlist(self):
|
def player_playlist(self, playlist_slug_or_id: str = ''):
|
||||||
return jsonify(self._get_playlist())
|
current_playlist = self._model_store.playlist().get_one_by("slug = ? OR id = ?", {
|
||||||
|
"slug": playlist_slug_or_id,
|
||||||
|
"id": playlist_slug_or_id
|
||||||
|
})
|
||||||
|
playlist_id = current_playlist.id if current_playlist else None
|
||||||
|
|
||||||
|
return jsonify(self._get_playlist(playlist_id=playlist_id))
|
||||||
|
|||||||
68
src/controller/PlaylistController.py
Normal file
68
src/controller/PlaylistController.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from flask import Flask, render_template, redirect, request, url_for, jsonify
|
||||||
|
from src.service.ModelStore import ModelStore
|
||||||
|
from src.model.entity.Playlist import Playlist
|
||||||
|
from src.interface.ObController import ObController
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistController(ObController):
|
||||||
|
|
||||||
|
def guard_playlist(self, f):
|
||||||
|
def decorated_function(*args, **kwargs):
|
||||||
|
if not self._model_store.variable().map().get('playlist_enabled').as_bool():
|
||||||
|
return redirect(url_for('manage'))
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
|
||||||
|
return decorated_function
|
||||||
|
|
||||||
|
def register(self):
|
||||||
|
self._app.add_url_rule('/playlist/list', 'playlist_list', self.guard_playlist(self._auth(self.playlist_list)), methods=['GET'])
|
||||||
|
self._app.add_url_rule('/playlist/add', 'playlist_add', self.guard_playlist(self._auth(self.playlist_add)), methods=['POST'])
|
||||||
|
self._app.add_url_rule('/playlist/edit', 'playlist_edit', self.guard_playlist(self._auth(self.playlist_edit)), methods=['POST'])
|
||||||
|
self._app.add_url_rule('/playlist/toggle', 'playlist_toggle', self.guard_playlist(self._auth(self.playlist_toggle)), methods=['POST'])
|
||||||
|
self._app.add_url_rule('/playlist/delete', 'playlist_delete', self.guard_playlist(self._auth(self.playlist_delete)), methods=['DELETE'])
|
||||||
|
|
||||||
|
def playlist(self):
|
||||||
|
return render_template(
|
||||||
|
'playlist/playlist.jinja.html',
|
||||||
|
playlists=self._model_store.playlist().get_enabled_playlists(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def playlist_list(self):
|
||||||
|
durations = self._model_store.playlist().get_durations_by_playlists()
|
||||||
|
return render_template(
|
||||||
|
'playlist/list.jinja.html',
|
||||||
|
enabled_playlists=self._model_store.playlist().get_enabled_playlists(with_default=True),
|
||||||
|
disabled_playlists=self._model_store.playlist().get_disabled_playlists(),
|
||||||
|
durations=durations
|
||||||
|
)
|
||||||
|
|
||||||
|
def playlist_add(self):
|
||||||
|
playlist = Playlist(
|
||||||
|
name=request.form['name'],
|
||||||
|
)
|
||||||
|
|
||||||
|
self._model_store.playlist().add_form(playlist)
|
||||||
|
|
||||||
|
return redirect(url_for('playlist_list'))
|
||||||
|
|
||||||
|
def playlist_edit(self):
|
||||||
|
self._model_store.playlist().update_form(
|
||||||
|
id=request.form['id'],
|
||||||
|
name=request.form['name'],
|
||||||
|
)
|
||||||
|
return redirect(url_for('playlist_list'))
|
||||||
|
|
||||||
|
def playlist_toggle(self):
|
||||||
|
data = request.get_json()
|
||||||
|
self._model_store.playlist().update_enabled(data.get('id'), data.get('enabled'))
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
|
|
||||||
|
def playlist_delete(self):
|
||||||
|
data = request.get_json()
|
||||||
|
id = data.get('id')
|
||||||
|
if self._model_store.slide().count_slides_for_playlist(id) > 0:
|
||||||
|
return jsonify({'status': 'error', 'message': self.t('playlist_delete_has_slides')}), 400
|
||||||
|
self._model_store.playlist().delete(id)
|
||||||
|
return jsonify({'status': 'ok'})
|
||||||
@ -33,7 +33,7 @@ class SettingsController(ObController):
|
|||||||
if variable.name == 'slide_upload_limit':
|
if variable.name == 'slide_upload_limit':
|
||||||
self.reload_web_server()
|
self.reload_web_server()
|
||||||
|
|
||||||
if variable.name == 'fleet_enabled':
|
if variable.name == 'fleet_composer_enabled':
|
||||||
self.reload_web_server()
|
self.reload_web_server()
|
||||||
|
|
||||||
if variable.name == 'auth_enabled':
|
if variable.name == 'auth_enabled':
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from src.service.ModelStore import ModelStore
|
|||||||
from src.model.entity.Slide import Slide
|
from src.model.entity.Slide import Slide
|
||||||
from src.model.enum.SlideType import SlideType
|
from src.model.enum.SlideType import SlideType
|
||||||
from src.interface.ObController import ObController
|
from src.interface.ObController import ObController
|
||||||
from src.utils import str_to_enum, get_optional_string
|
from src.utils import str_to_enum, get_optional_string, randomize_filename
|
||||||
|
|
||||||
|
|
||||||
class SlideshowController(ObController):
|
class SlideshowController(ObController):
|
||||||
@ -16,6 +16,7 @@ class SlideshowController(ObController):
|
|||||||
def register(self):
|
def register(self):
|
||||||
self._app.add_url_rule('/manage', 'manage', self.manage, methods=['GET'])
|
self._app.add_url_rule('/manage', 'manage', self.manage, methods=['GET'])
|
||||||
self._app.add_url_rule('/slideshow', 'slideshow_slide_list', self._auth(self.slideshow), methods=['GET'])
|
self._app.add_url_rule('/slideshow', 'slideshow_slide_list', self._auth(self.slideshow), methods=['GET'])
|
||||||
|
self._app.add_url_rule('/slideshow/playlist/set/<playlist_id>', 'slideshow_slide_list_playlist_use', self._auth(self.slideshow), methods=['GET'])
|
||||||
self._app.add_url_rule('/slideshow/slide/add', 'slideshow_slide_add', self._auth(self.slideshow_slide_add), methods=['POST'])
|
self._app.add_url_rule('/slideshow/slide/add', 'slideshow_slide_add', self._auth(self.slideshow_slide_add), methods=['POST'])
|
||||||
self._app.add_url_rule('/slideshow/slide/edit', 'slideshow_slide_edit', self._auth(self.slideshow_slide_edit), methods=['POST'])
|
self._app.add_url_rule('/slideshow/slide/edit', 'slideshow_slide_edit', self._auth(self.slideshow_slide_edit), methods=['POST'])
|
||||||
self._app.add_url_rule('/slideshow/slide/toggle', 'slideshow_slide_toggle', self._auth(self.slideshow_slide_toggle), methods=['POST'])
|
self._app.add_url_rule('/slideshow/slide/toggle', 'slideshow_slide_toggle', self._auth(self.slideshow_slide_toggle), methods=['POST'])
|
||||||
@ -26,11 +27,15 @@ class SlideshowController(ObController):
|
|||||||
def manage(self):
|
def manage(self):
|
||||||
return redirect(url_for('slideshow_slide_list'))
|
return redirect(url_for('slideshow_slide_list'))
|
||||||
|
|
||||||
def slideshow(self):
|
def slideshow(self, playlist_id: int = 0):
|
||||||
|
current_playlist = self._model_store.playlist().get(playlist_id)
|
||||||
|
playlist_id = current_playlist.id if current_playlist else None
|
||||||
return render_template(
|
return render_template(
|
||||||
'slideshow/list.jinja.html',
|
'slideshow/list.jinja.html',
|
||||||
enabled_slides=self._model_store.slide().get_enabled_slides(),
|
current_playlist=current_playlist,
|
||||||
disabled_slides=self._model_store.slide().get_disabled_slides(),
|
playlists=self._model_store.playlist().get_enabled_playlists(),
|
||||||
|
enabled_slides=self._model_store.slide().get_slides(playlist_id=playlist_id, enabled=True),
|
||||||
|
disabled_slides=self._model_store.slide().get_slides(playlist_id=playlist_id, enabled=False),
|
||||||
var_last_restart=self._model_store.variable().get_one_by_name('last_restart'),
|
var_last_restart=self._model_store.variable().get_one_by_name('last_restart'),
|
||||||
var_external_url=self._model_store.variable().get_one_by_name('external_url'),
|
var_external_url=self._model_store.variable().get_one_by_name('external_url'),
|
||||||
enum_slide_type=SlideType
|
enum_slide_type=SlideType
|
||||||
@ -41,6 +46,7 @@ class SlideshowController(ObController):
|
|||||||
name=request.form['name'],
|
name=request.form['name'],
|
||||||
type=str_to_enum(request.form['type'], SlideType),
|
type=str_to_enum(request.form['type'], SlideType),
|
||||||
duration=request.form['duration'],
|
duration=request.form['duration'],
|
||||||
|
playlist=request.form['playlist'] if 'playlist' in request.form else None,
|
||||||
cron_schedule=get_optional_string(request.form['cron_schedule']),
|
cron_schedule=get_optional_string(request.form['cron_schedule']),
|
||||||
cron_schedule_end=get_optional_string(request.form['cron_schedule_end']),
|
cron_schedule_end=get_optional_string(request.form['cron_schedule_end']),
|
||||||
)
|
)
|
||||||
@ -55,7 +61,7 @@ class SlideshowController(ObController):
|
|||||||
return redirect(request.url)
|
return redirect(request.url)
|
||||||
|
|
||||||
if object:
|
if object:
|
||||||
object_name = secure_filename(object.filename)
|
object_name = randomize_filename(object.filename)
|
||||||
object_path = os.path.join(self._app.config['UPLOAD_FOLDER'], object_name)
|
object_path = os.path.join(self._app.config['UPLOAD_FOLDER'], object_name)
|
||||||
object.save(object_path)
|
object.save(object_path)
|
||||||
slide.location = object_path
|
slide.location = object_path
|
||||||
@ -65,10 +71,13 @@ class SlideshowController(ObController):
|
|||||||
self._model_store.slide().add_form(slide)
|
self._model_store.slide().add_form(slide)
|
||||||
self._post_update()
|
self._post_update()
|
||||||
|
|
||||||
|
if slide.playlist:
|
||||||
|
return redirect(url_for('slideshow_slide_list_playlist_use', playlist_id=slide.playlist))
|
||||||
|
|
||||||
return redirect(url_for('slideshow_slide_list'))
|
return redirect(url_for('slideshow_slide_list'))
|
||||||
|
|
||||||
def slideshow_slide_edit(self):
|
def slideshow_slide_edit(self):
|
||||||
self._model_store.slide().update_form(
|
slide = self._model_store.slide().update_form(
|
||||||
id=request.form['id'],
|
id=request.form['id'],
|
||||||
name=request.form['name'],
|
name=request.form['name'],
|
||||||
duration=request.form['duration'],
|
duration=request.form['duration'],
|
||||||
@ -77,6 +86,10 @@ class SlideshowController(ObController):
|
|||||||
location=request.form['location'] if 'location' in request.form else None
|
location=request.form['location'] if 'location' in request.form else None
|
||||||
)
|
)
|
||||||
self._post_update()
|
self._post_update()
|
||||||
|
|
||||||
|
if slide.playlist:
|
||||||
|
return redirect(url_for('slideshow_slide_list_playlist_use', playlist_id=slide.playlist))
|
||||||
|
|
||||||
return redirect(url_for('slideshow_slide_list'))
|
return redirect(url_for('slideshow_slide_list'))
|
||||||
|
|
||||||
def slideshow_slide_toggle(self):
|
def slideshow_slide_toggle(self):
|
||||||
|
|||||||
@ -63,7 +63,7 @@ class SysinfoController(ObController):
|
|||||||
os.execl(python, python, *sys.argv)
|
os.execl(python, python, *sys.argv)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
subprocess.run(["sudo", "systemctl", "restart", 'obscreen-manager'], check=True, timeout=10, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
subprocess.run(["sudo", "systemctl", "restart", 'obscreen-composer'], check=True, timeout=10, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
pass
|
pass
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import json
|
import json
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import logging
|
import logging
|
||||||
@ -7,6 +8,7 @@ from sqlite3 import Cursor
|
|||||||
from src.utils import wrap_if, is_wrapped_by
|
from src.utils import wrap_if, is_wrapped_by
|
||||||
from typing import Optional, Dict
|
from typing import Optional, Dict
|
||||||
|
|
||||||
|
|
||||||
class DatabaseManager:
|
class DatabaseManager:
|
||||||
|
|
||||||
DB_FILE: str = "data/db/obscreen.db"
|
DB_FILE: str = "data/db/obscreen.db"
|
||||||
@ -28,10 +30,19 @@ class DatabaseManager:
|
|||||||
self._conn.row_factory = sqlite3.Row
|
self._conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
def open(self, table_name: str, table_model: list):
|
def open(self, table_name: str, table_model: list):
|
||||||
self.execute_write_query('''CREATE TABLE IF NOT EXISTS {} (
|
new_table_definition = '''CREATE TABLE IF NOT EXISTS {} (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
{}
|
{}
|
||||||
)'''.format(table_name, ", ".join(table_model)))
|
)'''.format(table_name, ", ".join(table_model))
|
||||||
|
self.execute_write_query(new_table_definition)
|
||||||
|
|
||||||
|
old_table_definition = self.execute_read_query("select sql from sqlite_master where tbl_name = ?", (table_name,))
|
||||||
|
old_table_definition = old_table_definition[0]['sql']
|
||||||
|
|
||||||
|
delta_queries = self.generate_delta_queries(old_table_definition, new_table_definition)
|
||||||
|
|
||||||
|
for delta_query in delta_queries:
|
||||||
|
self.execute_write_query(delta_query)
|
||||||
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@ -95,18 +106,19 @@ class DatabaseManager:
|
|||||||
query="select * from {} {}".format(table_name, "ORDER BY {} ASC".format(sort) if sort else "")
|
query="select * from {} {}".format(table_name, "ORDER BY {} ASC".format(sort) if sort else "")
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_by_query(self, table_name: str, query: str = "1=1", sort: Optional[str] = None) -> list:
|
def get_by_query(self, table_name: str, query: str = "1=1", sort: Optional[str] = None, values: dict = {}) -> list:
|
||||||
return self.execute_read_query(
|
return self.execute_read_query(
|
||||||
query="select * from {} where {} {}".format(
|
query="select * from {} where {} {}".format(
|
||||||
table_name,
|
table_name,
|
||||||
query,
|
query,
|
||||||
"ORDER BY {} ASC".format(sort) if sort else ""
|
"ORDER BY {} ASC".format(sort) if sort else ""
|
||||||
)
|
),
|
||||||
|
params=tuple(v for v in values.values())
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_one_by_query(self, table_name: str, query: str = "1=1", sort: Optional[str] = None) -> list:
|
def get_one_by_query(self, table_name: str, query: str = "1=1", sort: Optional[str] = None, values: dict = {}) -> list:
|
||||||
query = "select * from {} where {} {}".format(table_name, query, "ORDER BY {} ASC".format(sort) if sort else "")
|
query = "select * from {} where {} {}".format(table_name, query, "ORDER BY {} ASC".format(sort) if sort else "")
|
||||||
lines = self.execute_read_query(query=query)
|
lines = self.execute_read_query(query=query, params=tuple(v for v in values.values()))
|
||||||
count = len(lines)
|
count = len(lines)
|
||||||
|
|
||||||
if count > 1:
|
if count > 1:
|
||||||
@ -142,3 +154,55 @@ class DatabaseManager:
|
|||||||
|
|
||||||
def delete_by_id(self, table_name: str, id: int) -> None:
|
def delete_by_id(self, table_name: str, id: int) -> None:
|
||||||
self.execute_write_query("DELETE FROM {} WHERE id = ?".format(table_name), params=(id,))
|
self.execute_write_query("DELETE FROM {} WHERE id = ?".format(table_name), params=(id,))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_create_table_query(query: str):
|
||||||
|
table_name_pattern = re.compile(r'CREATE TABLE\s+(IF NOT EXISTS\s+)?["]?(\w+)["]?', re.IGNORECASE)
|
||||||
|
columns_pattern = re.compile(r'\((.*)\)', re.DOTALL)
|
||||||
|
|
||||||
|
table_name_match = table_name_pattern.search(query)
|
||||||
|
columns_match = columns_pattern.search(query)
|
||||||
|
|
||||||
|
if not table_name_match or not columns_match:
|
||||||
|
raise ValueError("Invalid CREATE TABLE query.")
|
||||||
|
|
||||||
|
table_name = table_name_match.group(2)
|
||||||
|
columns_part = columns_match.group(1)
|
||||||
|
|
||||||
|
# Split columns_part by commas but ignore commas inside parentheses
|
||||||
|
columns = re.split(r',\s*(?![^()]*\))', columns_part)
|
||||||
|
|
||||||
|
# Extract column names and their definitions
|
||||||
|
column_definitions = {}
|
||||||
|
for column in columns:
|
||||||
|
column_parts = column.strip().split(maxsplit=1)
|
||||||
|
column_name = column_parts[0]
|
||||||
|
column_definition = column.strip()
|
||||||
|
column_definitions[column_name] = column_definition
|
||||||
|
|
||||||
|
return table_name, column_definitions
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_delta_queries(old_query: str, new_query: str) -> list:
|
||||||
|
old_table_name, old_columns = DatabaseManager.parse_create_table_query(old_query)
|
||||||
|
new_table_name, new_columns = DatabaseManager.parse_create_table_query(new_query)
|
||||||
|
|
||||||
|
if old_table_name != new_table_name:
|
||||||
|
raise ValueError("Table names do not match.")
|
||||||
|
|
||||||
|
old_column_names = set(old_columns.keys())
|
||||||
|
new_column_names = set(new_columns.keys())
|
||||||
|
|
||||||
|
columns_to_add = new_column_names - old_column_names
|
||||||
|
columns_to_remove = old_column_names - new_column_names
|
||||||
|
|
||||||
|
delta_queries = []
|
||||||
|
|
||||||
|
for column in columns_to_add:
|
||||||
|
delta_queries.append(f'ALTER TABLE {old_table_name} ADD COLUMN {new_columns[column]}')
|
||||||
|
|
||||||
|
for column in columns_to_remove:
|
||||||
|
delta_queries.append(f'ALTER TABLE {old_table_name} DROP COLUMN {column}')
|
||||||
|
|
||||||
|
return delta_queries
|
||||||
|
|||||||
152
src/manager/PlaylistManager.py
Normal file
152
src/manager/PlaylistManager.py
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from typing import Dict, Optional, List, Tuple, Union
|
||||||
|
|
||||||
|
from src.model.entity.Playlist import Playlist
|
||||||
|
from src.utils import get_optional_string, get_yt_video_id, slugify
|
||||||
|
from src.manager.DatabaseManager import DatabaseManager
|
||||||
|
from src.manager.LangManager import LangManager
|
||||||
|
from src.manager.UserManager import UserManager
|
||||||
|
from src.service.ModelManager import ModelManager
|
||||||
|
|
||||||
|
|
||||||
|
class PlaylistManager(ModelManager):
|
||||||
|
|
||||||
|
TABLE_NAME = "playlist"
|
||||||
|
TABLE_MODEL = [
|
||||||
|
"name CHAR(255)",
|
||||||
|
"slug CHAR(255)",
|
||||||
|
"enabled INTEGER DEFAULT 0",
|
||||||
|
"created_by CHAR(255)",
|
||||||
|
"updated_by CHAR(255)",
|
||||||
|
"created_at INTEGER",
|
||||||
|
"updated_at INTEGER"
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, lang_manager: LangManager, database_manager: DatabaseManager, user_manager: UserManager):
|
||||||
|
super().__init__(lang_manager, database_manager, user_manager)
|
||||||
|
self._db = database_manager.open(self.TABLE_NAME, self.TABLE_MODEL)
|
||||||
|
|
||||||
|
def hydrate_object(self, raw_playlist: dict, id: int = None) -> Playlist:
|
||||||
|
if id:
|
||||||
|
raw_playlist['id'] = id
|
||||||
|
|
||||||
|
[raw_playlist, user_tracker_edits] = self.user_manager.initialize_user_trackers(raw_playlist)
|
||||||
|
|
||||||
|
if len(user_tracker_edits) > 0:
|
||||||
|
self._db.update_by_id(self.TABLE_NAME, raw_playlist['id'], user_tracker_edits)
|
||||||
|
|
||||||
|
return Playlist(**raw_playlist)
|
||||||
|
|
||||||
|
def hydrate_list(self, raw_playlists: list) -> List[Playlist]:
|
||||||
|
return [self.hydrate_object(raw_playlist) for raw_playlist in raw_playlists]
|
||||||
|
|
||||||
|
def get(self, id: Optional[int]) -> Optional[Playlist]:
|
||||||
|
if not id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
object = self._db.get_by_id(self.TABLE_NAME, id)
|
||||||
|
return self.hydrate_object(object, id) if object else None
|
||||||
|
|
||||||
|
def get_by(self, query, sort: Optional[str] = None, values: dict = {}) -> List[Playlist]:
|
||||||
|
return self.hydrate_list(self._db.get_by_query(self.TABLE_NAME, query=query, sort=sort, values=values))
|
||||||
|
|
||||||
|
def get_one_by(self, query, values: dict = {}) -> Optional[Playlist]:
|
||||||
|
object = self._db.get_one_by_query(self.TABLE_NAME, query=query, values=values)
|
||||||
|
|
||||||
|
if not object:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self.hydrate_object(object)
|
||||||
|
|
||||||
|
def get_durations_by_playlists(self):
|
||||||
|
durations = self._db.execute_read_query("select playlist, sum(duration) as total_duration from slideshow where cron_schedule is null group by playlist")
|
||||||
|
map = {}
|
||||||
|
for duration in durations:
|
||||||
|
map[duration['playlist']] = duration['total_duration']
|
||||||
|
return map
|
||||||
|
|
||||||
|
def get_all(self) -> List[Playlist]:
|
||||||
|
return self.hydrate_list(self._db.get_all(self.TABLE_NAME))
|
||||||
|
|
||||||
|
def get_enabled_playlists(self, with_default: bool = False) -> List[Playlist]:
|
||||||
|
playlists = self.get_by(query="enabled = 1")
|
||||||
|
|
||||||
|
if not with_default:
|
||||||
|
return playlists
|
||||||
|
|
||||||
|
return [Playlist(id=None, name=self.t('slideshow_playlist_panel_item_default'))] + playlists
|
||||||
|
|
||||||
|
def get_disabled_playlists(self) -> List[Playlist]:
|
||||||
|
return self.get_by(query="enabled = 0")
|
||||||
|
|
||||||
|
def update_enabled(self, id: int, enabled: bool) -> None:
|
||||||
|
self._db.update_by_id(self.TABLE_NAME, id, {"enabled": enabled})
|
||||||
|
|
||||||
|
def forget_user(self, user_id: int):
|
||||||
|
playlists = self.get_by("created_by = '{}' or updated_by = '{}'".format(user_id, user_id))
|
||||||
|
edits_playlists = self.user_manager.forget_user(playlists, user_id)
|
||||||
|
|
||||||
|
for playlist_id, edits in edits_playlists.items():
|
||||||
|
self._db.update_by_id(self.TABLE_NAME, playlist_id, edits)
|
||||||
|
|
||||||
|
def pre_add(self, playlist: Dict) -> Dict:
|
||||||
|
playlist["slug"] = slugify(playlist["name"])
|
||||||
|
self.user_manager.track_user_on_create(playlist)
|
||||||
|
self.user_manager.track_user_on_update(playlist)
|
||||||
|
return playlist
|
||||||
|
|
||||||
|
def pre_update(self, playlist: Dict) -> Dict:
|
||||||
|
playlist["slug"] = slugify(playlist["name"])
|
||||||
|
self.user_manager.track_user_on_update(playlist)
|
||||||
|
return playlist
|
||||||
|
|
||||||
|
def pre_delete(self, playlist_id: str) -> str:
|
||||||
|
return playlist_id
|
||||||
|
|
||||||
|
def post_add(self, playlist_id: str) -> str:
|
||||||
|
return playlist_id
|
||||||
|
|
||||||
|
def post_update(self, playlist_id: str) -> str:
|
||||||
|
return playlist_id
|
||||||
|
|
||||||
|
def post_updates(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def post_delete(self, playlist_id: str) -> str:
|
||||||
|
return playlist_id
|
||||||
|
|
||||||
|
def update_form(self, id: int, name: str) -> None:
|
||||||
|
playlist = self.get(id)
|
||||||
|
|
||||||
|
if not playlist:
|
||||||
|
return
|
||||||
|
|
||||||
|
form = {
|
||||||
|
"name": name
|
||||||
|
}
|
||||||
|
|
||||||
|
self._db.update_by_id(self.TABLE_NAME, id, self.pre_update(form))
|
||||||
|
self.post_update(id)
|
||||||
|
|
||||||
|
def add_form(self, playlist: Union[Playlist, Dict]) -> None:
|
||||||
|
form = playlist
|
||||||
|
|
||||||
|
if not isinstance(playlist, dict):
|
||||||
|
form = playlist.to_dict()
|
||||||
|
del form['id']
|
||||||
|
|
||||||
|
self._db.add(self.TABLE_NAME, self.pre_add(form))
|
||||||
|
self.post_add(playlist.id)
|
||||||
|
|
||||||
|
def delete(self, id: int) -> None:
|
||||||
|
playlist = self.get(id)
|
||||||
|
|
||||||
|
if playlist:
|
||||||
|
self.pre_delete(id)
|
||||||
|
self._db.delete_by_id(self.TABLE_NAME, id)
|
||||||
|
self.post_delete(id)
|
||||||
|
|
||||||
|
def to_dict(self, playlists: List[Playlist]) -> List[Dict]:
|
||||||
|
return [playlist.to_dict() for playlist in playlists]
|
||||||
|
|
||||||
@ -12,7 +12,7 @@ class ScreenManager(ModelManager):
|
|||||||
TABLE_NAME = "fleet"
|
TABLE_NAME = "fleet"
|
||||||
TABLE_MODEL = [
|
TABLE_MODEL = [
|
||||||
"name CHAR(255)",
|
"name CHAR(255)",
|
||||||
"enabled INTEGER",
|
"enabled INTEGER DEFAULT 0",
|
||||||
"position INTEGER",
|
"position INTEGER",
|
||||||
"host CHAR(255)",
|
"host CHAR(255)",
|
||||||
"port INTEGER"
|
"port INTEGER"
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import os
|
|||||||
from typing import Dict, Optional, List, Tuple, Union
|
from typing import Dict, Optional, List, Tuple, Union
|
||||||
|
|
||||||
from src.model.entity.Slide import Slide
|
from src.model.entity.Slide import Slide
|
||||||
|
from src.model.entity.Playlist import Playlist
|
||||||
from src.model.enum.SlideType import SlideType
|
from src.model.enum.SlideType import SlideType
|
||||||
from src.utils import get_optional_string, get_yt_video_id
|
from src.utils import get_optional_string, get_yt_video_id
|
||||||
from src.manager.DatabaseManager import DatabaseManager
|
from src.manager.DatabaseManager import DatabaseManager
|
||||||
@ -17,7 +18,8 @@ class SlideManager(ModelManager):
|
|||||||
TABLE_MODEL = [
|
TABLE_MODEL = [
|
||||||
"name CHAR(255)",
|
"name CHAR(255)",
|
||||||
"type CHAR(30)",
|
"type CHAR(30)",
|
||||||
"enabled INTEGER",
|
"enabled INTEGER DEFAULT 0",
|
||||||
|
"playlist INTEGER",
|
||||||
"duration INTEGER",
|
"duration INTEGER",
|
||||||
"position INTEGER",
|
"position INTEGER",
|
||||||
"location TEXT",
|
"location TEXT",
|
||||||
@ -72,11 +74,14 @@ class SlideManager(ModelManager):
|
|||||||
for slide_id, edits in edits_slides.items():
|
for slide_id, edits in edits_slides.items():
|
||||||
self._db.update_by_id(self.TABLE_NAME, slide_id, edits)
|
self._db.update_by_id(self.TABLE_NAME, slide_id, edits)
|
||||||
|
|
||||||
def get_enabled_slides(self) -> List[Slide]:
|
def get_slides(self, playlist_id: Optional[int] = None, enabled: bool = True) -> List[Slide]:
|
||||||
return self.get_by(query="enabled = 1", sort="position")
|
query = "enabled = {}".format("1" if enabled else "0")
|
||||||
|
if playlist_id:
|
||||||
|
query = "{} {}".format(query, "AND playlist = {}".format(playlist_id))
|
||||||
|
else:
|
||||||
|
query = "{} {}".format(query, "AND playlist is NULL")
|
||||||
|
|
||||||
def get_disabled_slides(self) -> List[Slide]:
|
return self.get_by(query=query, sort="position")
|
||||||
return self.get_by(query="enabled = 0", sort="position")
|
|
||||||
|
|
||||||
def pre_add(self, slide: Dict) -> Dict:
|
def pre_add(self, slide: Dict) -> Dict:
|
||||||
self.user_manager.track_user_on_create(slide)
|
self.user_manager.track_user_on_create(slide)
|
||||||
@ -110,7 +115,7 @@ class SlideManager(ModelManager):
|
|||||||
for slide_id, slide_position in positions.items():
|
for slide_id, slide_position in positions.items():
|
||||||
self._db.update_by_id(self.TABLE_NAME, slide_id, {"position": slide_position})
|
self._db.update_by_id(self.TABLE_NAME, slide_id, {"position": slide_position})
|
||||||
|
|
||||||
def update_form(self, id: int, name: str, duration: int, cron_schedule: Optional[str] = '', cron_schedule_end: Optional[str] = '', location: Optional[str] = None) -> None:
|
def update_form(self, id: int, name: str, duration: int, cron_schedule: Optional[str] = '', cron_schedule_end: Optional[str] = '', location: Optional[str] = None) -> Slide:
|
||||||
slide = self.get(id)
|
slide = self.get(id)
|
||||||
|
|
||||||
if not slide:
|
if not slide:
|
||||||
@ -131,6 +136,7 @@ class SlideManager(ModelManager):
|
|||||||
|
|
||||||
self._db.update_by_id(self.TABLE_NAME, id, self.pre_update(form))
|
self._db.update_by_id(self.TABLE_NAME, id, self.pre_update(form))
|
||||||
self.post_update(id)
|
self.post_update(id)
|
||||||
|
return self.get(id)
|
||||||
|
|
||||||
def add_form(self, slide: Union[Slide, Dict]) -> None:
|
def add_form(self, slide: Union[Slide, Dict]) -> None:
|
||||||
form = slide
|
form = slide
|
||||||
@ -162,3 +168,5 @@ class SlideManager(ModelManager):
|
|||||||
def to_dict(self, slides: List[Slide]) -> List[Dict]:
|
def to_dict(self, slides: List[Slide]) -> List[Dict]:
|
||||||
return [slide.to_dict() for slide in slides]
|
return [slide.to_dict() for slide in slides]
|
||||||
|
|
||||||
|
def count_slides_for_playlist(self, id: int) -> int:
|
||||||
|
return len(self.get_slides(playlist_id=id))
|
||||||
@ -15,7 +15,7 @@ class UserManager:
|
|||||||
TABLE_MODEL = [
|
TABLE_MODEL = [
|
||||||
"username CHAR(255)",
|
"username CHAR(255)",
|
||||||
"password CHAR(255)",
|
"password CHAR(255)",
|
||||||
"enabled INTEGER"
|
"enabled INTEGER DEFAULT 1"
|
||||||
]
|
]
|
||||||
|
|
||||||
def __init__(self, lang_manager: LangManager, database_manager: DatabaseManager, on_user_delete):
|
def __init__(self, lang_manager: LangManager, database_manager: DatabaseManager, on_user_delete):
|
||||||
|
|||||||
@ -104,7 +104,8 @@ class VariableManager(ModelManager):
|
|||||||
### General
|
### General
|
||||||
{"name": "lang", "section": self.t(VariableSection.GENERAL), "value": "en", "type": VariableType.SELECT_SINGLE, "editable": True, "description": self.t('settings_variable_desc_lang'), "selectables": self.t(ApplicationLanguage), "refresh_player": False},
|
{"name": "lang", "section": self.t(VariableSection.GENERAL), "value": "en", "type": VariableType.SELECT_SINGLE, "editable": True, "description": self.t('settings_variable_desc_lang'), "selectables": self.t(ApplicationLanguage), "refresh_player": False},
|
||||||
{"name": "auth_enabled", "section": self.t(VariableSection.GENERAL), "value": False, "type": VariableType.BOOL, "editable": True, "description": self.t('settings_variable_desc_auth_enabled'), "description_edition": self.t('settings_variable_desc_edition_auth_enabled'), "refresh_player": False},
|
{"name": "auth_enabled", "section": self.t(VariableSection.GENERAL), "value": False, "type": VariableType.BOOL, "editable": True, "description": self.t('settings_variable_desc_auth_enabled'), "description_edition": self.t('settings_variable_desc_edition_auth_enabled'), "refresh_player": False},
|
||||||
{"name": "fleet_enabled", "section": self.t(VariableSection.GENERAL), "value": False, "type": VariableType.BOOL, "editable": True, "description": self.t('settings_variable_desc_fleet_enabled'), "refresh_player": False},
|
{"name": "fleet_composer_enabled", "section": self.t(VariableSection.GENERAL), "value": False, "type": VariableType.BOOL, "editable": True, "description": self.t('settings_variable_desc_fleet_composer_enabled'), "refresh_player": False},
|
||||||
|
{"name": "playlist_enabled", "section": self.t(VariableSection.GENERAL), "value": False, "type": VariableType.BOOL, "editable": True, "description": self.t('settings_variable_desc_playlist_enabled'), "refresh_player": False},
|
||||||
{"name": "external_url", "section": self.t(VariableSection.GENERAL), "value": "", "type": VariableType.STRING, "editable": True, "description": self.t('settings_variable_desc_external_url'), "refresh_player": False},
|
{"name": "external_url", "section": self.t(VariableSection.GENERAL), "value": "", "type": VariableType.STRING, "editable": True, "description": self.t('settings_variable_desc_external_url'), "refresh_player": False},
|
||||||
{"name": "slide_upload_limit", "section": self.t(VariableSection.GENERAL), "value": 32, "unit": VariableUnit.MEGABYTE, "type": VariableType.INT, "editable": True, "description": self.t('settings_variable_desc_slide_upload_limit'), "refresh_player": False},
|
{"name": "slide_upload_limit", "section": self.t(VariableSection.GENERAL), "value": 32, "unit": VariableUnit.MEGABYTE, "type": VariableType.INT, "editable": True, "description": self.t('settings_variable_desc_slide_upload_limit'), "refresh_player": False},
|
||||||
|
|
||||||
|
|||||||
111
src/model/entity/Playlist.py
Normal file
111
src/model/entity/Playlist.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from typing import Optional, Union
|
||||||
|
|
||||||
|
|
||||||
|
class Playlist:
|
||||||
|
|
||||||
|
def __init__(self, name: str = 'Untitled', slug: str = 'untitled', id: Optional[int] = None, enabled: bool = False, created_by: Optional[str] = None, updated_by: Optional[str] = None, created_at: Optional[int] = None, updated_at: Optional[int] = None):
|
||||||
|
self._id = id if id else None
|
||||||
|
self._name = name
|
||||||
|
self._slug = slug
|
||||||
|
self._enabled = enabled
|
||||||
|
self._created_by = created_by if created_by else None
|
||||||
|
self._updated_by = updated_by if updated_by else None
|
||||||
|
self._created_at = int(created_at if created_at else time.time())
|
||||||
|
self._updated_at = int(updated_at if updated_at else time.time())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self) -> Optional[int]:
|
||||||
|
return self._id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return bool(self._enabled)
|
||||||
|
|
||||||
|
@enabled.setter
|
||||||
|
def enabled(self, value: bool):
|
||||||
|
self._enabled = bool(value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def created_by(self) -> str:
|
||||||
|
return self._created_by
|
||||||
|
|
||||||
|
@created_by.setter
|
||||||
|
def created_by(self, value: str):
|
||||||
|
self._created_by = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def updated_by(self) -> str:
|
||||||
|
return self._updated_by
|
||||||
|
|
||||||
|
@updated_by.setter
|
||||||
|
def updated_by(self, value: str):
|
||||||
|
self._updated_by = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def created_at(self) -> int:
|
||||||
|
return self._created_at
|
||||||
|
|
||||||
|
@created_at.setter
|
||||||
|
def created_at(self, value: int):
|
||||||
|
self._created_at = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def updated_at(self) -> int:
|
||||||
|
return self._updated_at
|
||||||
|
|
||||||
|
@updated_at.setter
|
||||||
|
def updated_at(self, value: int):
|
||||||
|
self._updated_at = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self) -> str:
|
||||||
|
return self._name
|
||||||
|
|
||||||
|
@name.setter
|
||||||
|
def name(self, value: str):
|
||||||
|
self._name = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def slug(self) -> str:
|
||||||
|
return self._slug
|
||||||
|
|
||||||
|
@slug.setter
|
||||||
|
def slug(self, value: str):
|
||||||
|
self._slug = value
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"Playlist(" \
|
||||||
|
f"id='{self.id}',\n" \
|
||||||
|
f"name='{self.name}',\n" \
|
||||||
|
f"nameslug='{self.slug}',\n" \
|
||||||
|
f"enabled='{self.enabled}',\n" \
|
||||||
|
f"created_by='{self.created_by}',\n" \
|
||||||
|
f"updated_by='{self.updated_by}',\n" \
|
||||||
|
f"created_at='{self.created_at}',\n" \
|
||||||
|
f"updated_at='{self.updated_at}',\n" \
|
||||||
|
f")"
|
||||||
|
|
||||||
|
def to_json(self, edits: dict = {}) -> str:
|
||||||
|
obj = self.to_dict()
|
||||||
|
|
||||||
|
for k,v in edits.items():
|
||||||
|
obj[k] = v
|
||||||
|
|
||||||
|
return json.dumps(obj)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
playlist = {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"slug": self.slug,
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"created_by": self.created_by,
|
||||||
|
"updated_by": self.updated_by,
|
||||||
|
"created_at": self.created_at,
|
||||||
|
"updated_at": self.updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
return playlist
|
||||||
@ -8,9 +8,10 @@ from src.utils import str_to_enum
|
|||||||
|
|
||||||
class Slide:
|
class Slide:
|
||||||
|
|
||||||
def __init__(self, location: str = '', duration: int = 3, type: Union[SlideType, str] = SlideType.URL, enabled: bool = False, name: str = 'Untitled', position: int = 999, id: Optional[int] = None, cron_schedule: Optional[str] = None, cron_schedule_end: Optional[str] = None, created_by: Optional[str] = None, updated_by: Optional[str] = None, created_at: Optional[int] = None, updated_at: Optional[int] = None):
|
def __init__(self, location: str = '', playlist: Optional[int] = None, duration: int = 3, type: Union[SlideType, str] = SlideType.URL, enabled: bool = False, name: str = 'Untitled', position: int = 999, id: Optional[int] = None, cron_schedule: Optional[str] = None, cron_schedule_end: Optional[str] = None, created_by: Optional[str] = None, updated_by: Optional[str] = None, created_at: Optional[int] = None, updated_at: Optional[int] = None):
|
||||||
self._id = id if id else None
|
self._id = id if id else None
|
||||||
self._location = location
|
self._location = location
|
||||||
|
self._playlist = playlist
|
||||||
self._duration = duration
|
self._duration = duration
|
||||||
self._type = str_to_enum(type, SlideType) if isinstance(type, str) else type
|
self._type = str_to_enum(type, SlideType) if isinstance(type, str) else type
|
||||||
self._enabled = enabled
|
self._enabled = enabled
|
||||||
@ -75,6 +76,14 @@ class Slide:
|
|||||||
def type(self, value: SlideType):
|
def type(self, value: SlideType):
|
||||||
self._type = value
|
self._type = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def playlist(self) -> Optional[int]:
|
||||||
|
return self._playlist
|
||||||
|
|
||||||
|
@playlist.setter
|
||||||
|
def playlist(self, value: Optional[int]):
|
||||||
|
self._playlist = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cron_schedule(self) -> Optional[str]:
|
def cron_schedule(self) -> Optional[str]:
|
||||||
return self._cron_schedule
|
return self._cron_schedule
|
||||||
@ -136,6 +145,7 @@ class Slide:
|
|||||||
f"updated_by='{self.updated_by}',\n" \
|
f"updated_by='{self.updated_by}',\n" \
|
||||||
f"created_at='{self.created_at}',\n" \
|
f"created_at='{self.created_at}',\n" \
|
||||||
f"updated_at='{self.updated_at}',\n" \
|
f"updated_at='{self.updated_at}',\n" \
|
||||||
|
f"playlist='{self.playlist}',\n" \
|
||||||
f"cron_schedule='{self.cron_schedule}',\n" \
|
f"cron_schedule='{self.cron_schedule}',\n" \
|
||||||
f"cron_schedule_end='{self.cron_schedule_end}',\n" \
|
f"cron_schedule_end='{self.cron_schedule_end}',\n" \
|
||||||
f")"
|
f")"
|
||||||
@ -161,6 +171,7 @@ class Slide:
|
|||||||
"updated_by": self.updated_by,
|
"updated_by": self.updated_by,
|
||||||
"created_at": self.created_at,
|
"created_at": self.created_at,
|
||||||
"updated_at": self.updated_at,
|
"updated_at": self.updated_at,
|
||||||
|
"playlist": self.playlist,
|
||||||
"cron_schedule": self.cron_schedule,
|
"cron_schedule": self.cron_schedule,
|
||||||
"cron_schedule_end": self.cron_schedule_end,
|
"cron_schedule_end": self.cron_schedule_end,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,11 @@ class HookType(Enum):
|
|||||||
H_FLEET_CSS = 'h_fleet_css'
|
H_FLEET_CSS = 'h_fleet_css'
|
||||||
H_FLEET_JAVASCRIPT = 'h_fleet_javascript'
|
H_FLEET_JAVASCRIPT = 'h_fleet_javascript'
|
||||||
|
|
||||||
|
H_PLAYLIST_TOOLBAR_ACTIONS_START = 'h_playlist_toolbar_actions_start'
|
||||||
|
H_PLAYLIST_TOOLBAR_ACTIONS_END = 'h_playlist_toolbar_actions_end'
|
||||||
|
H_PLAYLIST_CSS = 'h_playlist_css'
|
||||||
|
H_PLAYLIST_JAVASCRIPT = 'h_playlist_javascript'
|
||||||
|
|
||||||
H_AUTH_TOOLBAR_ACTIONS_START = 'h_auth_toolbar_actions_start'
|
H_AUTH_TOOLBAR_ACTIONS_START = 'h_auth_toolbar_actions_start'
|
||||||
H_AUTH_TOOLBAR_ACTIONS_END = 'h_auth_toolbar_actions_end'
|
H_AUTH_TOOLBAR_ACTIONS_END = 'h_auth_toolbar_actions_end'
|
||||||
H_AUTH_CSS = 'h_auth_css'
|
H_AUTH_CSS = 'h_auth_css'
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
from src.manager.PlaylistManager import PlaylistManager
|
||||||
from src.manager.SlideManager import SlideManager
|
from src.manager.SlideManager import SlideManager
|
||||||
from src.manager.ScreenManager import ScreenManager
|
from src.manager.ScreenManager import ScreenManager
|
||||||
from src.manager.UserManager import UserManager
|
from src.manager.UserManager import UserManager
|
||||||
@ -26,6 +27,7 @@ class ModelStore:
|
|||||||
|
|
||||||
# Model
|
# Model
|
||||||
self._screen_manager = ScreenManager(lang_manager=self._lang_manager, database_manager=self._database_manager, user_manager=self._user_manager)
|
self._screen_manager = ScreenManager(lang_manager=self._lang_manager, database_manager=self._database_manager, user_manager=self._user_manager)
|
||||||
|
self._playlist_manager = PlaylistManager(lang_manager=self._lang_manager, database_manager=self._database_manager, user_manager=self._user_manager)
|
||||||
self._slide_manager = SlideManager(lang_manager=self._lang_manager, database_manager=self._database_manager, user_manager=self._user_manager)
|
self._slide_manager = SlideManager(lang_manager=self._lang_manager, database_manager=self._database_manager, user_manager=self._user_manager)
|
||||||
self._variable_manager.reload()
|
self._variable_manager.reload()
|
||||||
|
|
||||||
@ -44,6 +46,9 @@ class ModelStore:
|
|||||||
def slide(self) -> SlideManager:
|
def slide(self) -> SlideManager:
|
||||||
return self._slide_manager
|
return self._slide_manager
|
||||||
|
|
||||||
|
def playlist(self) -> PlaylistManager:
|
||||||
|
return self._playlist_manager
|
||||||
|
|
||||||
def screen(self) -> ScreenManager:
|
def screen(self) -> ScreenManager:
|
||||||
return self._screen_manager
|
return self._screen_manager
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ from src.model.hook.HookRegistration import HookRegistration
|
|||||||
from src.model.hook.StaticHookRegistration import StaticHookRegistration
|
from src.model.hook.StaticHookRegistration import StaticHookRegistration
|
||||||
from src.model.hook.FunctionalHookRegistration import FunctionalHookRegistration
|
from src.model.hook.FunctionalHookRegistration import FunctionalHookRegistration
|
||||||
from src.constant.WebDirConstant import WebDirConstant
|
from src.constant.WebDirConstant import WebDirConstant
|
||||||
from src.utils import get_safe_cron_descriptor, is_validate_cron_date_time
|
from src.utils import get_safe_cron_descriptor, is_validate_cron_date_time, seconds_to_hhmmss
|
||||||
|
|
||||||
|
|
||||||
class TemplateRenderer:
|
class TemplateRenderer:
|
||||||
@ -26,14 +26,16 @@ class TemplateRenderer:
|
|||||||
globals = dict(
|
globals = dict(
|
||||||
STATIC_PREFIX="/{}/{}/".format(WebDirConstant.FOLDER_STATIC, WebDirConstant.FOLDER_STATIC_WEB_ASSETS),
|
STATIC_PREFIX="/{}/{}/".format(WebDirConstant.FOLDER_STATIC, WebDirConstant.FOLDER_STATIC_WEB_ASSETS),
|
||||||
SECRET_KEY=self._model_store.config().map().get('secret_key'),
|
SECRET_KEY=self._model_store.config().map().get('secret_key'),
|
||||||
FLEET_ENABLED=self._model_store.variable().map().get('fleet_enabled').as_bool(),
|
FLEET_COMPOSER_ENABLED=self._model_store.variable().map().get('fleet_composer_enabled').as_bool(),
|
||||||
AUTH_ENABLED=self._model_store.variable().map().get('auth_enabled').as_bool(),
|
AUTH_ENABLED=self._model_store.variable().map().get('auth_enabled').as_bool(),
|
||||||
|
PLAYLIST_ENABLED=self._model_store.variable().map().get('playlist_enabled').as_bool(),
|
||||||
track_created=self._model_store.user().track_user_created,
|
track_created=self._model_store.user().track_user_created,
|
||||||
track_updated=self._model_store.user().track_user_updated,
|
track_updated=self._model_store.user().track_user_updated,
|
||||||
VERSION=self._model_store.config().map().get('version'),
|
VERSION=self._model_store.config().map().get('version'),
|
||||||
LANG=self._model_store.variable().map().get('lang').as_string(),
|
LANG=self._model_store.variable().map().get('lang').as_string(),
|
||||||
HOOK=self._render_hook,
|
HOOK=self._render_hook,
|
||||||
cron_descriptor=self.cron_descriptor,
|
cron_descriptor=self.cron_descriptor,
|
||||||
|
seconds_to_hhmmss=seconds_to_hhmmss,
|
||||||
is_validate_cron_date_time=is_validate_cron_date_time,
|
is_validate_cron_date_time=is_validate_cron_date_time,
|
||||||
l=self._model_store.lang().map(),
|
l=self._model_store.lang().map(),
|
||||||
t=self._model_store.lang().translate,
|
t=self._model_store.lang().translate,
|
||||||
|
|||||||
@ -12,6 +12,7 @@ from src.service.TemplateRenderer import TemplateRenderer
|
|||||||
from src.controller.PlayerController import PlayerController
|
from src.controller.PlayerController import PlayerController
|
||||||
from src.controller.SlideshowController import SlideshowController
|
from src.controller.SlideshowController import SlideshowController
|
||||||
from src.controller.FleetController import FleetController
|
from src.controller.FleetController import FleetController
|
||||||
|
from src.controller.PlaylistController import PlaylistController
|
||||||
from src.controller.AuthController import AuthController
|
from src.controller.AuthController import AuthController
|
||||||
from src.controller.SysinfoController import SysinfoController
|
from src.controller.SysinfoController import SysinfoController
|
||||||
from src.controller.SettingsController import SettingsController
|
from src.controller.SettingsController import SettingsController
|
||||||
@ -106,6 +107,7 @@ class WebServer:
|
|||||||
SettingsController(self, self._app, auth_required, self._model_store, self._template_renderer)
|
SettingsController(self, self._app, auth_required, self._model_store, self._template_renderer)
|
||||||
SysinfoController(self, self._app, auth_required, self._model_store, self._template_renderer)
|
SysinfoController(self, self._app, auth_required, self._model_store, self._template_renderer)
|
||||||
FleetController(self, self._app, auth_required, self._model_store, self._template_renderer)
|
FleetController(self, self._app, auth_required, self._model_store, self._template_renderer)
|
||||||
|
PlaylistController(self, self._app, auth_required, self._model_store, self._template_renderer)
|
||||||
AuthController(self, self._app, auth_required, self._model_store, self._template_renderer)
|
AuthController(self, self._app, auth_required, self._model_store, self._template_renderer)
|
||||||
|
|
||||||
def _setup_web_globals(self) -> None:
|
def _setup_web_globals(self) -> None:
|
||||||
|
|||||||
21
src/utils.py
21
src/utils.py
@ -1,7 +1,9 @@
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import uuid
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import unicodedata
|
||||||
import platform
|
import platform
|
||||||
|
|
||||||
|
|
||||||
@ -200,3 +202,22 @@ def get_yt_video_id(url: str) -> str:
|
|||||||
YouTube video id.
|
YouTube video id.
|
||||||
"""
|
"""
|
||||||
return regex_search(r"(?:v=|\/)([0-9A-Za-z_-]{11}).*", url, group=1)
|
return regex_search(r"(?:v=|\/)([0-9A-Za-z_-]{11}).*", url, group=1)
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(value):
|
||||||
|
value = unicodedata.normalize('NFKD', value).encode('ascii', 'ignore').decode('ascii')
|
||||||
|
value = re.sub('[^\w\s-]', '', value).strip().lower()
|
||||||
|
return re.sub('[-\s]+', '-', value)
|
||||||
|
|
||||||
|
|
||||||
|
def seconds_to_hhmmss(seconds):
|
||||||
|
hours = seconds // 3600
|
||||||
|
minutes = (seconds % 3600) // 60
|
||||||
|
secs = seconds % 60
|
||||||
|
return f"{hours:02}:{minutes:02}:{secs:02}"
|
||||||
|
|
||||||
|
|
||||||
|
def randomize_filename(old_filename: str) -> str:
|
||||||
|
new_uuid = str(uuid.uuid4())
|
||||||
|
_, extension = os.path.splitext(old_filename)
|
||||||
|
return f"{new_uuid}{extension}"
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=Obscreen Manager
|
Description=Obscreen Composer
|
||||||
After=network.target
|
After=network.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
@ -19,7 +19,9 @@
|
|||||||
<tr class="user-item" data-level="{{ user.id }}" data-entity="{{ user.to_json() }}">
|
<tr class="user-item" data-level="{{ user.id }}" data-entity="{{ user.to_json() }}">
|
||||||
<td class="infos">
|
<td class="infos">
|
||||||
<div class="inner">
|
<div class="inner">
|
||||||
|
<div class="badge"><i class="fa fa-key icon-left"></i> {{ user.id }}</div>
|
||||||
<i class="fa fa-user icon-left"></i>
|
<i class="fa fa-user icon-left"></i>
|
||||||
|
|
||||||
{{ user.username }}
|
{{ user.username }}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -9,7 +9,7 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block add_js %}
|
{% block add_js %}
|
||||||
<script src="{{ STATIC_PREFIX }}js/auth.js"></script>
|
<script src="{{ STATIC_PREFIX }}js/auth/users.js"></script>
|
||||||
{{ HOOK(H_AUTH_JAVASCRIPT) }}
|
{{ HOOK(H_AUTH_JAVASCRIPT) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@ -34,7 +34,7 @@
|
|||||||
{{ HOOK(H_ROOT_CSS) }}
|
{{ HOOK(H_ROOT_CSS) }}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container {% block container_class %}{% endblock %}">
|
||||||
{% set fleet_mode = request.args.get('fleet_mode') == '1' %}
|
{% set fleet_mode = request.args.get('fleet_mode') == '1' %}
|
||||||
|
|
||||||
{% block header %}
|
{% block header %}
|
||||||
@ -56,7 +56,14 @@
|
|||||||
<i class="fa-regular fa-clock"></i> {{ l.slideshow_page_title }}
|
<i class="fa-regular fa-clock"></i> {{ l.slideshow_page_title }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% if FLEET_ENABLED %}
|
{% if PLAYLIST_ENABLED %}
|
||||||
|
<li class="{{ 'active' if request.url_rule.endpoint == 'playlist_list' }}">
|
||||||
|
<a href="{{ url_for('playlist_list') }}">
|
||||||
|
<i class="fa fa-bars-staggered"></i> {{ l.playlist_page_title }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% if FLEET_COMPOSER_ENABLED %}
|
||||||
<li class="{{ 'active' if request.url_rule.endpoint == 'fleet_screen_list' }}">
|
<li class="{{ 'active' if request.url_rule.endpoint == 'fleet_screen_list' }}">
|
||||||
<a href="{{ url_for('fleet_screen_list') }}">
|
<a href="{{ url_for('fleet_screen_list') }}">
|
||||||
<i class="fa fa-tv"></i> {{ l.fleet_page_title }}
|
<i class="fa fa-tv"></i> {{ l.fleet_page_title }}
|
||||||
@ -115,7 +122,8 @@
|
|||||||
<script>
|
<script>
|
||||||
var secret_key = '{{ SECRET_KEY }}';
|
var secret_key = '{{ SECRET_KEY }}';
|
||||||
var l = {
|
var l = {
|
||||||
'js_slideshow_slide_delete_confirmation': '{{ l.slideshow_slide_delete_confirmation }}',
|
'js_playlist_delete_confirmation': '{{ l.js_playlist_delete_confirmation }}',
|
||||||
|
'js_slideshow_slide_delete_confirmation': '{{ l.js_slideshow_slide_delete_confirmation }}',
|
||||||
'js_fleet_screen_delete_confirmation': '{{ l.js_fleet_screen_delete_confirmation }}',
|
'js_fleet_screen_delete_confirmation': '{{ l.js_fleet_screen_delete_confirmation }}',
|
||||||
'js_auth_user_delete_confirmation': '{{ l.js_auth_user_delete_confirmation }}',
|
'js_auth_user_delete_confirmation': '{{ l.js_auth_user_delete_confirmation }}',
|
||||||
'js_sysinfo_restart_confirmation': '{{ l.js_sysinfo_restart_confirmation }}',
|
'js_sysinfo_restart_confirmation': '{{ l.js_sysinfo_restart_confirmation }}',
|
||||||
|
|||||||
@ -24,6 +24,9 @@
|
|||||||
<a href="javascript:void(0);" class="item-sort screen-sort">
|
<a href="javascript:void(0);" class="item-sort screen-sort">
|
||||||
<i class="fa fa-sort icon-left"></i>
|
<i class="fa fa-sort icon-left"></i>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<div class="badge"><i class="fa fa-key icon-left"></i> {{ screen.id }}</div>
|
||||||
|
|
||||||
<i class="fa fa-tv icon-left"></i>
|
<i class="fa fa-tv icon-left"></i>
|
||||||
{{ screen.name }}
|
{{ screen.name }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -9,8 +9,8 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block add_js %}
|
{% block add_js %}
|
||||||
<script src="{{ STATIC_PREFIX }}js/tablednd-fixed.js"></script>
|
<script src="{{ STATIC_PREFIX }}js/lib/tablednd-fixed.js"></script>
|
||||||
<script src="{{ STATIC_PREFIX }}js/fleet.js"></script>
|
<script src="{{ STATIC_PREFIX }}js/fleet/screens.js"></script>
|
||||||
{{ HOOK(H_FLEET_JAVASCRIPT) }}
|
{{ HOOK(H_FLEET_JAVASCRIPT) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
<meta name="google" content="notranslate">
|
<meta name="google" content="notranslate">
|
||||||
<link rel="shortcut icon" href="{{ STATIC_PREFIX }}/favicon.ico">
|
<link rel="shortcut icon" href="{{ STATIC_PREFIX }}/favicon.ico">
|
||||||
{% if slide_animation_enabled.eval() %}
|
{% if slide_animation_enabled.eval() %}
|
||||||
<link rel="stylesheet" href="{{ STATIC_PREFIX }}css/animate.min.css" />
|
<link rel="stylesheet" href="{{ STATIC_PREFIX }}css/lib/animate.min.css" />
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<style>
|
<style>
|
||||||
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background-color: white; display: flex; flex-direction: row; justify-content: center; align-items: center; }
|
html, body { margin: 0; padding: 0; height: 100%; overflow: hidden; background-color: white; display: flex; flex-direction: row; justify-content: center; align-items: center; }
|
||||||
@ -15,7 +15,7 @@
|
|||||||
.slide iframe { background: white; }
|
.slide iframe { background: white; }
|
||||||
.slide img { height: 100%; }
|
.slide img { height: 100%; }
|
||||||
</style>
|
</style>
|
||||||
<script type="application/javascript" src="{{ STATIC_PREFIX }}js/is-cron-now.js"></script>
|
<script type="application/javascript" src="{{ STATIC_PREFIX }}js/lib/is-cron-now.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="FirstSlide" class="slide" style="z-index: 1000;">
|
<div id="FirstSlide" class="slide" style="z-index: 1000;">
|
||||||
@ -69,12 +69,13 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
var cronTick = function() {
|
var cronTick = function() {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
if ((new Date()).getSeconds() != 0) {
|
if ((new Date()).getSeconds() != 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('Cron Tick');
|
|
||||||
|
|
||||||
for (var i = 0; i < items.cron.length; i++) {
|
for (var i = 0; i < items.cron.length; i++) {
|
||||||
var item = items.cron[i];
|
var item = items.cron[i];
|
||||||
|
|
||||||
@ -98,13 +99,56 @@
|
|||||||
loadContent(curSlide, callbackReady, item);
|
loadContent(curSlide, callbackReady, item);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
preloadSlide('SecondSlide', items.loop[curItemIndex])
|
preloadSlide('SecondSlide', items.loop[curItemIndex])
|
||||||
cronState.interval = setInterval(cronTick, 1000);
|
cronState.interval = setInterval(cronTick, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preloadSlide(slide, item) {
|
||||||
|
var element = document.getElementById(slide);
|
||||||
|
var callbackReady = function (onSlideStart) {
|
||||||
|
var move = function () {
|
||||||
|
if (nextReady && !cronState.active) {
|
||||||
|
moveToSlide(slide, item);
|
||||||
|
onSlideStart();
|
||||||
|
} else {
|
||||||
|
setTimeout(move, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(move, duration);
|
||||||
|
};
|
||||||
|
|
||||||
|
loadContent(element, callbackReady, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveToSlide(slide, item) {
|
||||||
|
curSlide = document.getElementById(slide);
|
||||||
|
previousSlide = curSlide == firstSlide ? secondSlide : firstSlide;
|
||||||
|
|
||||||
|
duration = item.duration * 1000;
|
||||||
|
curItemIndex = (curItemIndex + 1) === items.loop.length ? 0 : curItemIndex + 1;
|
||||||
|
|
||||||
|
curSlide.style.zIndex = 1000;
|
||||||
|
previousSlide.style.zIndex = 500;
|
||||||
|
|
||||||
|
if (animate) {
|
||||||
|
curSlide.classList.add('animate__animated', animate_transitions[0], animate_speed);
|
||||||
|
curSlide.onanimationend = () => {
|
||||||
|
curSlide.classList.remove(animate_transitions[0], animate_speed);
|
||||||
|
preloadSlide(previousSlide.attributes['id'].value, items.loop[curItemIndex]);
|
||||||
|
};
|
||||||
|
previousSlide.classList.add('animate__animated', animate_transitions[1], animate_speed);
|
||||||
|
previousSlide.onanimationend = () => {
|
||||||
|
previousSlide.classList.remove(animate_transitions[1], animate_speed);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
preloadSlide(previousSlide.attributes['id'].value, items.loop[curItemIndex]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function loadContent(element, callbackReady, item) {
|
function loadContent(element, callbackReady, item) {
|
||||||
switch (item.type) {
|
switch (item.type) {
|
||||||
case 'url':
|
case 'url':
|
||||||
@ -164,49 +208,6 @@
|
|||||||
callbackReady(onSlideStart);
|
callbackReady(onSlideStart);
|
||||||
}
|
}
|
||||||
|
|
||||||
function preloadSlide(slide, item) {
|
|
||||||
var element = document.getElementById(slide);
|
|
||||||
var callbackReady = function (onSlideStart) {
|
|
||||||
var move = function () {
|
|
||||||
if (nextReady && !cronState.active) {
|
|
||||||
moveToSlide(slide, item);
|
|
||||||
onSlideStart();
|
|
||||||
} else {
|
|
||||||
setTimeout(move, 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(move, duration);
|
|
||||||
};
|
|
||||||
|
|
||||||
loadContent(element, callbackReady, item);
|
|
||||||
}
|
|
||||||
|
|
||||||
function moveToSlide(slide, item) {
|
|
||||||
curSlide = document.getElementById(slide);
|
|
||||||
previousSlide = curSlide == firstSlide ? secondSlide : firstSlide;
|
|
||||||
|
|
||||||
duration = item.duration * 1000;
|
|
||||||
curItemIndex = (curItemIndex + 1) === items.loop.length ? 0 : curItemIndex + 1;
|
|
||||||
|
|
||||||
curSlide.style.zIndex = 1000;
|
|
||||||
previousSlide.style.zIndex = 500;
|
|
||||||
|
|
||||||
if (animate) {
|
|
||||||
curSlide.classList.add('animate__animated', animate_transitions[0], animate_speed);
|
|
||||||
curSlide.onanimationend = () => {
|
|
||||||
curSlide.classList.remove(animate_transitions[0], animate_speed);
|
|
||||||
preloadSlide(previousSlide.attributes['id'].value, items.loop[curItemIndex]);
|
|
||||||
};
|
|
||||||
previousSlide.classList.add('animate__animated', animate_transitions[1], animate_speed);
|
|
||||||
previousSlide.onanimationend = () => {
|
|
||||||
previousSlide.classList.remove(animate_transitions[1], animate_speed);
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
preloadSlide(previousSlide.attributes['id'].value, items.loop[curItemIndex]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main();
|
main();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
56
views/playlist/component/table.jinja.html
Normal file
56
views/playlist/component/table.jinja.html
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
<table class="{{ tclass }}-playlists">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ l.playlist_panel_th_name }}</th>
|
||||||
|
<th class="tac">{{ l.playlist_panel_th_enabled }}</th>
|
||||||
|
<th class="tac">{{ l.playlist_panel_th_duration }}</th>
|
||||||
|
<th class="tac">{{ l.playlist_panel_th_activity }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr class="empty-tr {% if playlists|length != 0 %}hidden{% endif %}">
|
||||||
|
<td colspan="4">
|
||||||
|
{{ l.playlist_panel_empty|replace(
|
||||||
|
'%link%',
|
||||||
|
('<a href="javascript:void(0);" class="item-add playlist-add">'~l.playlist_button_add~'</a>')|safe
|
||||||
|
) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% for playlist in playlists %}
|
||||||
|
<tr class="playlist-item" data-level="{{ playlist.id }}" data-entity="{{ playlist.to_json() }}">
|
||||||
|
<td class="infos">
|
||||||
|
<div class="inner">
|
||||||
|
{% if playlist.id %}
|
||||||
|
<div class="badge"><i class="fa fa-key icon-left"></i> {{ playlist.id }}</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="badge"><i class="fa fa-lock"></i></div>
|
||||||
|
{% endif %}
|
||||||
|
<i class="fa fa-bars-staggered icon-left"></i>
|
||||||
|
|
||||||
|
{{ playlist.name }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="tac">
|
||||||
|
{% if playlist.id %}
|
||||||
|
<label class="pure-material-switch">
|
||||||
|
<input type="checkbox" {% if playlist.enabled %}checked="checked"{% endif %}><span></span>
|
||||||
|
</label>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="tac">
|
||||||
|
{{ seconds_to_hhmmss(durations[playlist.id]) }}
|
||||||
|
</td>
|
||||||
|
<td class="actions tac">
|
||||||
|
{% if playlist.id %}
|
||||||
|
<a href="javascript:void(0);" class="item-edit playlist-edit">
|
||||||
|
<i class="fa fa-pencil"></i>
|
||||||
|
</a>
|
||||||
|
<a href="javascript:void(0);" class="item-delete playlist-delete">
|
||||||
|
<i class="fa fa-trash"></i>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
61
views/playlist/list.jinja.html
Normal file
61
views/playlist/list.jinja.html
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
{% extends 'base.jinja.html' %}
|
||||||
|
|
||||||
|
{% block page_title %}
|
||||||
|
{{ l.playlist_page_title }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block add_css %}
|
||||||
|
{{ HOOK(H_PLAYLIST_CSS) }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block add_js %}
|
||||||
|
<script src="{{ STATIC_PREFIX }}js/lib/tablednd-fixed.js"></script>
|
||||||
|
<script src="{{ STATIC_PREFIX }}js/playlist/playlists.js"></script>
|
||||||
|
{{ HOOK(H_PLAYLIST_JAVASCRIPT) }}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block page %}
|
||||||
|
<div class="toolbar">
|
||||||
|
<h2>{{ l.playlist_page_title }}</h2>
|
||||||
|
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
{{ HOOK(H_PLAYLIST_TOOLBAR_ACTIONS_START) }}
|
||||||
|
<button class="purple playlist-add item-add"><i class="fa fa-plus icon-left"></i>{{ l.playlist_button_add }}</button>
|
||||||
|
{{ HOOK(H_PLAYLIST_TOOLBAR_ACTIONS_END) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-error hidden">
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-body">
|
||||||
|
<h3>{{ l.playlist_panel_active }}</h3>
|
||||||
|
|
||||||
|
{% with tclass='active', playlists=enabled_playlists %}
|
||||||
|
{% include 'playlist/component/table.jinja.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-inactive">
|
||||||
|
<div class="panel-body">
|
||||||
|
<h3>{{ l.playlist_panel_inactive }}</h3>
|
||||||
|
|
||||||
|
{% with tclass='inactive', playlists=disabled_playlists %}
|
||||||
|
{% include 'playlist/component/table.jinja.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="modals hidden">
|
||||||
|
<div class="modals-outer">
|
||||||
|
<a href="javascript:void(0);" class="modal-close">
|
||||||
|
<i class="fa fa-close"></i>
|
||||||
|
</a>
|
||||||
|
<div class="modals-inner">
|
||||||
|
{% include 'playlist/modal/add.jinja.html' %}
|
||||||
|
{% include 'playlist/modal/edit.jinja.html' %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
25
views/playlist/modal/add.jinja.html
Normal file
25
views/playlist/modal/add.jinja.html
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<div class="modal modal-playlist-add modal-playlist">
|
||||||
|
<h2>
|
||||||
|
{{ l.playlist_form_add_title }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<form action="/playlist/add" method="POST" enctype="multipart/form-data">
|
||||||
|
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="playlist-add-name">{{ l.playlist_form_label_name }}</label>
|
||||||
|
<div class="widget">
|
||||||
|
<input name="name" type="text" id="playlist-add-name" required="required" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn-normal modal-close">
|
||||||
|
{{ l.playlist_form_button_cancel }}
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="green">
|
||||||
|
<i class="fa fa-plus icon-left"></i> {{ l.playlist_form_add_submit }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
27
views/playlist/modal/edit.jinja.html
Normal file
27
views/playlist/modal/edit.jinja.html
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
<div class="modal modal-playlist-edit modal-playlist hidden">
|
||||||
|
<h2>
|
||||||
|
{{ l.playlist_form_edit_title }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
|
||||||
|
<form action="/playlist/edit" method="POST">
|
||||||
|
|
||||||
|
<input type="hidden" name="id" id="playlist-edit-id" />
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="playlist-edit-name">{{ l.playlist_form_label_name }}</label>
|
||||||
|
<div class="widget">
|
||||||
|
<input type="text" name="name" id="playlist-edit-name" required="required" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="btn-normal modal-close">
|
||||||
|
{{ l.playlist_form_button_cancel }}
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="green">
|
||||||
|
<i class="fa fa-save icon-left"></i>{{ l.playlist_form_edit_submit }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
@ -5,18 +5,20 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block add_css %}
|
{% block add_css %}
|
||||||
<link rel="stylesheet" href="{{ STATIC_PREFIX }}css/flatpickr.min.css" />
|
<link rel="stylesheet" href="{{ STATIC_PREFIX }}css/lib/flatpickr.min.css" />
|
||||||
{{ HOOK(H_SLIDESHOW_CSS) }}
|
{{ HOOK(H_SLIDESHOW_CSS) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block add_js %}
|
{% block add_js %}
|
||||||
<script src="{{ STATIC_PREFIX }}js/flatpickr.min.js"></script>
|
<script src="{{ STATIC_PREFIX }}js/lib/flatpickr.min.js"></script>
|
||||||
<script src="{{ STATIC_PREFIX }}js/tablednd-fixed.js"></script>
|
<script src="{{ STATIC_PREFIX }}js/lib/tablednd-fixed.js"></script>
|
||||||
<script src="{{ STATIC_PREFIX }}js/slideshow.js"></script>
|
<script src="{{ STATIC_PREFIX }}js/slideshow/slides.js"></script>
|
||||||
<script src="{{ STATIC_PREFIX }}js/restart.js"></script>
|
<script src="{{ STATIC_PREFIX }}js/restart.js"></script>
|
||||||
{{ HOOK(H_SLIDESHOW_JAVASCRIPT) }}
|
{{ HOOK(H_SLIDESHOW_JAVASCRIPT) }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block container_class %}{% if PLAYLIST_ENABLED %}expand{% endif %}{% endblock %}
|
||||||
|
|
||||||
{% block page %}
|
{% block page %}
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<h2>{{ l.slideshow_page_title }}</h2>
|
<h2>{{ l.slideshow_page_title }}</h2>
|
||||||
@ -28,7 +30,7 @@
|
|||||||
{{ HOOK(H_FLEETMODE_SLIDESHOW_TOOLBAR_ACTIONS) }}
|
{{ HOOK(H_FLEETMODE_SLIDESHOW_TOOLBAR_ACTIONS) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<a href="/" target="_blank" class="btn" title="{{ l.slideshow_goto_player }}">
|
<a href="{% if current_playlist %}{{ url_for('player_use', playlist_slug_or_id=current_playlist.slug) }}{% else %}{{ url_for('player') }}{% endif %}" target="_blank" class="btn" title="{{ l.slideshow_goto_player }}">
|
||||||
<i class="fa fa-play"></i>
|
<i class="fa fa-play"></i>
|
||||||
</a>
|
</a>
|
||||||
<a href="{{ url_for('slideshow_player_refresh') }}" class="btn" title="{{ l.slideshow_refresh_player }}">
|
<a href="{{ url_for('slideshow_player_refresh') }}" class="btn" title="{{ l.slideshow_refresh_player }}">
|
||||||
@ -36,6 +38,7 @@
|
|||||||
</a>
|
</a>
|
||||||
|
|
||||||
<button class="purple slide-add item-add"><i class="fa fa-plus icon-left"></i>{{ l.slideshow_slide_button_add }}</button>
|
<button class="purple slide-add item-add"><i class="fa fa-plus icon-left"></i>{{ l.slideshow_slide_button_add }}</button>
|
||||||
|
|
||||||
{{ HOOK(H_SLIDESHOW_TOOLBAR_ACTIONS_END) }}
|
{{ HOOK(H_SLIDESHOW_TOOLBAR_ACTIONS_END) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -47,22 +50,57 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="panel">
|
<div class="explorer">
|
||||||
<div class="panel-body">
|
{% if PLAYLIST_ENABLED %}
|
||||||
<h3>{{ l.slideshow_slide_panel_active }}</h3>
|
<div class="left">
|
||||||
|
<div class="panel panel-menu">
|
||||||
{% with tclass='active', slides=enabled_slides %}
|
<div class="panel-body">
|
||||||
{% include 'slideshow/component/table.jinja.html' %}
|
<h3>
|
||||||
{% endwith %}
|
{{ l.slideshow_playlist_panel_title }}
|
||||||
|
</h3>
|
||||||
|
<ul>
|
||||||
|
<li class="{% if not current_playlist %}active{% endif %}">
|
||||||
|
<a href="{{ url_for('slideshow_slide_list') }}" class="select-playlist">
|
||||||
|
{% if not current_playlist %}
|
||||||
|
<i class="fa fa-play icon-left"></i>
|
||||||
|
{% endif %}
|
||||||
|
{{ l.slideshow_playlist_panel_item_default }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% for playlist in playlists %}
|
||||||
|
<li class="{% if current_playlist %}active{% endif %}">
|
||||||
|
<a href="{{ url_for('slideshow_slide_list_playlist_use', playlist_id=playlist.id) }}" class="select-playlist">
|
||||||
|
{% if current_playlist %}
|
||||||
|
<i class="fa fa-play icon-left"></i>
|
||||||
|
{% endif %}
|
||||||
|
{{ playlist.name }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
{% endif %}
|
||||||
<div class="panel panel-inactive">
|
<div class="right">
|
||||||
<div class="panel-body">
|
<div class="panel {% if PLAYLIST_ENABLED %}panel-active{% endif %}">
|
||||||
<h3>{{ l.slideshow_slide_panel_inactive }}</h3>
|
<div class="panel-body">
|
||||||
|
<h3>{{ l.slideshow_slide_panel_active }}</h3>
|
||||||
|
|
||||||
{% with tclass='inactive', slides=disabled_slides %}
|
{% with tclass='active', slides=enabled_slides %}
|
||||||
{% include 'slideshow/component/table.jinja.html' %}
|
{% include 'slideshow/component/table.jinja.html' %}
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel panel-inactive">
|
||||||
|
<div class="panel-body">
|
||||||
|
<h3>{{ l.slideshow_slide_panel_inactive }}</h3>
|
||||||
|
|
||||||
|
{% with tclass='inactive', slides=disabled_slides %}
|
||||||
|
{% include 'slideshow/component/table.jinja.html' %}
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,10 @@
|
|||||||
{{ l.slideshow_slide_form_section_content }}
|
{{ l.slideshow_slide_form_section_content }}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
|
{% if current_playlist %}
|
||||||
|
<input name="playlist" type="text" id="slide-add-playlist" value="{{ current_playlist.id }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="slide-add-name">{{ l.slideshow_slide_form_label_name }}</label>
|
<label for="slide-add-name">{{ l.slideshow_slide_form_label_name }}</label>
|
||||||
<div class="widget">
|
<div class="widget">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user