add playlist management

This commit is contained in:
jr-k 2024-05-21 20:35:46 +02:00
parent dc0c975a36
commit a7c986e1b4
48 changed files with 1109 additions and 139 deletions

View File

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

View File

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

View File

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

View File

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

View 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();
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Obscreen Manager Description=Obscreen Composer
After=network.target After=network.target
[Service] [Service]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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