Control the resolution of the projector

This commit is contained in:
Finn Stutzenstein 2016-08-25 16:40:34 +02:00
parent 5afffaba84
commit a8dcc2abdc
17 changed files with 348 additions and 20 deletions

View File

@ -4,13 +4,15 @@ from django.utils.translation import ugettext as _
from .exceptions import ConfigError, ConfigNotFound from .exceptions import ConfigError, ConfigNotFound
from .models import ConfigStore from .models import ConfigStore
# remove resolution when changing to multiprojector
INPUT_TYPE_MAPPING = { INPUT_TYPE_MAPPING = {
'string': str, 'string': str,
'text': str, 'text': str,
'integer': int, 'integer': int,
'boolean': bool, 'boolean': bool,
'choice': str, 'choice': str,
'colorpicker': str} 'colorpicker': str,
'resolution': dict}
class ConfigHandler: class ConfigHandler:
@ -86,6 +88,16 @@ class ConfigHandler:
except DjangoValidationError as e: except DjangoValidationError as e:
raise ConfigError(e.messages[0]) raise ConfigError(e.messages[0])
# remove this block when changing to multiprojector
if config_variable.input_type == 'resolution':
if value.get('width') is None or value.get('height') is None:
raise ConfigError(_('A width and a height have to be given.'))
if not isinstance(value['width'], int) or not isinstance(value['height'], int):
raise ConfigError(_('Data has to be integers.'))
if (value['width'] < 800 or value['width'] > 3840 or
value['height'] < 600 or value['height'] > 2160):
raise ConfigError(_('The Resolution have to be between 800x600 and 3840x2160.'))
# Save the new value to the database. # Save the new value to the database.
ConfigStore.objects.update_or_create(key=key, defaults={'value': value}) ConfigStore.objects.update_or_create(key=key, defaults={'value': value})

View File

@ -155,3 +155,12 @@ def get_config_variables():
label='Default countdown', label='Default countdown',
weight=185, weight=185,
group='Projector') group='Projector')
# set the resolution for one projector. It can be removed with the multiprojector feature.
yield ConfigVariable(
name='projector_resolution',
default_value={'width': 1024, 'height': 768},
input_type='resolution',
label='Projector Resolution',
weight=200,
group='Projector')

View File

@ -0,0 +1,25 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.9 on 2016-08-25 11:56
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0003_auto_20160815_1911'),
]
operations = [
migrations.AddField(
model_name='projector',
name='height',
field=models.PositiveIntegerField(default=768),
),
migrations.AddField(
model_name='projector',
name='width',
field=models.PositiveIntegerField(default=1024),
),
]

View File

@ -66,6 +66,11 @@ class Projector(RESTModelMixin, models.Model):
scroll = models.IntegerField(default=0) scroll = models.IntegerField(default=0)
# currently unused, but important for the multiprojector.
width = models.PositiveIntegerField(default=1024)
height = models.PositiveIntegerField(default=768)
class Meta: class Meta:
""" """
Contains general permissions that can not be placed in a specific app. Contains general permissions that can not be placed in a specific app.

View File

@ -30,7 +30,7 @@ class ProjectorSerializer(ModelSerializer):
class Meta: class Meta:
model = Projector model = Projector
fields = ('id', 'config', 'elements', 'scale', 'scroll', ) fields = ('id', 'config', 'elements', 'scale', 'scroll', 'width', 'height',)
class CustomSlideSerializer(ModelSerializer): class CustomSlideSerializer(ModelSerializer):

View File

@ -322,6 +322,14 @@ img {
margin-left: 20px; margin-left: 20px;
} }
/* .resolution can be removed with the multiprojector, but maybe
* it could be reused for the settings in the projectormanage-view */
.col1 .input-group .resolution {
float: none;
display: inline-block;
width: 80px;
border-radius: 4px !important;
}
/* Toolbar to save motion in inline editing mode */ /* Toolbar to save motion in inline editing mode */
.motion-save-toolbar { .motion-save-toolbar {
@ -633,23 +641,14 @@ img {
/* iframe for live view */ /* iframe for live view */
.col2 #iframe { .col2 #iframe {
width: 1024px;
height: 768px;
-moz-transform-origin: 0 0; -moz-transform-origin: 0 0;
-webkit-transform-origin: 0 0; -webkit-transform-origin: 0 0;
-o-transform-origin: 0 0; -o-transform-origin: 0 0;
transform-origin: 0 0 0; transform-origin: 0 0 0;
-moz-transform: scale(0.25);
-webkit-transform: scale(0.25);
-o-transform: scale(0.25);
transform: scale(0.25);
/* IE8+ - must be on one line, unfortunately */
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.25, M12=0, M21=0, M22=0.25, SizingMethod='auto expand')";
} }
.col2 #iframewrapper { .col2 #iframewrapper {
width: 256px; width: 256px;
height: 192px;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
border: 1px solid #D5D5D5; border: 1px solid #D5D5D5;
@ -658,7 +657,6 @@ img {
.col2 #iframeoverlay { .col2 #iframeoverlay {
width: 256px; width: 256px;
height: 192px;
position: absolute; position: absolute;
top: 0px; top: 0px;
left: 0px; left: 0px;
@ -666,9 +664,7 @@ img {
z-index: 1; z-index: 1;
} }
/** Footer **/ /** Footer **/
#footer { #footer {
float: left; float: left;
height: 50px; height: 50px;
@ -684,6 +680,16 @@ img {
background-color: #317796; background-color: #317796;
} }
.dropdown-entries {
white-space: nowrap;
}
.dropdown-entries > li {
padding: 5px 10px 5px 10px;
width: 100%;
cursor: pointer;
}
.slimDropDown { .slimDropDown {
padding-left: 4px !important; padding-left: 4px !important;
padding-right: 4px !important; padding-right: 4px !important;

View File

@ -11,6 +11,44 @@ body{
color: #222; color: #222;
} }
/*** ProjectorContainer ***/
.pContainer {
background-color: #222;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
display: table;
}
.pContainer > div {
display: table-cell;
vertical-align: middle;
}
.pContainer #iframe {
-moz-transform-origin: 0 0;
-webkit-transform-origin: 0 0;
-o-transform-origin: 0 0;
transform-origin: 0 0 0;
}
.pContainer #iframewrapper {
position: relative;
overflow: hidden;
margin-left: auto;
margin-right: auto;
}
.pContainer #iframeoverlay {
position: absolute;
top: 0px;
left: 0px;
display: block;
z-index: 1;
}
/*** HEADER ***/ /*** HEADER ***/
#header { #header {
box-shadow: 0 0 7px rgba(0,0,0,0.6); box-shadow: 0 0 7px rgba(0,0,0,0.6);

View File

@ -286,7 +286,6 @@ angular.module('OpenSlidesApp.core', [
} }
]) ])
.factory('jsDataModel', [ .factory('jsDataModel', [
'$http', '$http',
'Projector', 'Projector',

View File

@ -57,6 +57,65 @@ angular.module('OpenSlidesApp.core.projector', ['OpenSlidesApp.core'])
} }
]) ])
// Projector Container Controller
.controller('ProjectorContainerCtrl', [
'$scope',
'Config',
function($scope, Config) {
// watch for changes in Config
var last_conf;
$scope.$watch(function () {
return Config.lastModified();
}, function () {
var conf = Config.get('projector_resolution').value;
// With multiprojector, get the resolution from Prjector.get(pk).{width; height}
if(!last_conf || last_conf.width != conf.width || last-conf.height != conf.height) {
last_conf = conf;
$scope.projectorWidth = conf.width;
$scope.projectorHeight = conf.height;
$scope.recalculateIframe();
}
});
// recalculate the actual Iframesize and scale
$scope.recalculateIframe = function () {
var scale_width = window.innerWidth / $scope.projectorWidth;
var scale_height = window.innerHeight / $scope.projectorHeight;
if (scale_width > 1 && scale_height > 1) {
// Iframe fits in full size in the window
$scope.scale = 1;
$scope.iframeWidth = $scope.projectorWidth;
$scope.iframeHeight = $scope.projectorHeight;
} else {
// Iframe has to be scaled down
if (scale_width <= scale_height) {
// width is the reference
$scope.iframeWidth = window.innerWidth;
$scope.scale = scale_width;
$scope.iframeHeight = $scope.projectorHeight * scale_width;
} else {
// height is the reference
$scope.iframeHeight = window.innerHeight;
$scope.scale = scale_height;
$scope.iframeWidth = $scope.projectorWidth * scale_height;
}
}
};
// watch for changes in the windowsize
$(window).on("resize.doResize", function () {
$scope.$apply(function() {
$scope.recalculateIframe();
});
});
$scope.$on("$destroy",function (){
$(window).off("resize.doResize");
});
}
])
.controller('ProjectorCtrl', [ .controller('ProjectorCtrl', [
'$scope', '$scope',
'Projector', 'Projector',

View File

@ -602,6 +602,15 @@ angular.module('OpenSlidesApp.core.site', [
}) })
.state('projector', { .state('projector', {
url: '/projector', url: '/projector',
templateUrl: 'static/templates/projector-container.html',
data: {extern: true},
onEnter: function($window) {
$window.location.href = this.url;
}
})
.state('real-projector', {
url: '/real-projector',
templateUrl: 'static/templates/projector.html',
data: {extern: true}, data: {extern: true},
onEnter: function($window) { onEnter: function($window) {
$window.location.href = this.url; $window.location.href = this.url;
@ -802,6 +811,7 @@ angular.module('OpenSlidesApp.core.site', [
'Config', 'Config',
'gettextCatalog', 'gettextCatalog',
function($parse, Config, gettextCatalog) { function($parse, Config, gettextCatalog) {
// remove resolution when changing to multiprojector
function getHtmlType(type) { function getHtmlType(type) {
return { return {
string: 'text', string: 'text',
@ -810,6 +820,7 @@ angular.module('OpenSlidesApp.core.site', [
boolean: 'checkbox', boolean: 'checkbox',
choice: 'choice', choice: 'choice',
colorpicker: 'colorpicker', colorpicker: 'colorpicker',
resolution: 'resolution',
}[type]; }[type];
} }
@ -1076,6 +1087,22 @@ angular.module('OpenSlidesApp.core.site', [
}); });
// watch for changes in Config
var last_conf;
$scope.$watch(function () {
return Config.lastModified();
}, function () {
var conf = Config.get('projector_resolution').value;
// With multiprojector, get the resolution from Prjector.get(pk).{width; height}
if(!last_conf || last_conf.width != conf.width || last-conf.height != conf.height) {
last_conf = conf;
$scope.projectorWidth = conf.width;
$scope.projectorHeight = conf.height;
$scope.scale = 256.0 / $scope.projectorWidth;
$scope.iframeHeight = $scope.scale * $scope.projectorHeight;
}
});
// *** countdown functions *** // *** countdown functions ***
$scope.calculateCountdownTime = function (countdown) { $scope.calculateCountdownTime = function (countdown) {
countdown.seconds = Math.floor( countdown.countdown_time - Date.now() / 1000 + $scope.serverOffset ); countdown.seconds = Math.floor( countdown.countdown_time - Date.now() / 1000 + $scope.serverOffset );

View File

@ -11,6 +11,26 @@
id="{{ key }}" id="{{ key }}"
type="{{ type }}"> type="{{ type }}">
<!-- resolution -->
<!-- Can be removed with multiprojector, but maybe it could be reused in the projectormanage-view -->
<!-- if removed, remember to delete the class resolution -->
<span ng-if="type == 'resolution'">
<translate>Width</translate>:
<input ng-model="$parent.value.width"
ng-model-option="{debounce: 1000}"
ng-change="save(configOption.key, $parent.value)"
class="form-control resolution"
id="{{ key }}_width"
type="number">
<translate>Height</translate>:
<input ng-model="$parent.value.height"
ng-model-option="{debounce: 1000}"
ng-change="save(configOption.key, $parent.value)"
class="form-control resolution"
id="{{ key }}_height"
type="number">
</span>
<!-- colorpicker --> <!-- colorpicker -->
<input ng-if="type == 'colorpicker'" <input ng-if="type == 'colorpicker'"
colorpicker colorpicker

View File

@ -9,9 +9,30 @@
<h4 translate>Live view</h4> <h4 translate>Live view</h4>
</a> </a>
<div uib-collapse="isLiveViewClosed" ng-cloak> <div uib-collapse="isLiveViewClosed" ng-cloak>
<style>
/* iframe for live view */
.col2 #iframe {
width: {{ projectorWidth }}px;
height: {{ projectorHeight }}px;
-moz-transform: scale({{ scale }});
-webkit-transform: scale({{ scale }});
-o-transform: scale({{ scale }});
transform: scale({{ scale }});
/* IE8+ - must be on one line, unfortunately */
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11={{ scale }}, M12=0, M21=0, M22={{ scale }}, SizingMethod='auto expand')";
}
.col2 #iframewrapper {
height: {{ iframeHeight }}px;
}
.col2 #iframeoverlay {
height: {{ iframeHeight }}px;
}
</style>
<a ui-sref="projector" target="_blank"> <a ui-sref="projector" target="_blank">
<div id="iframewrapper"> <div id="iframewrapper">
<iframe id="iframe" src="/projector" frameborder="0"></iframe> <iframe id="iframe" src="/real-projector" frameborder="0"></iframe>
<div id="iframeoverlay"></div> <div id="iframeoverlay"></div>
</div> </div>
</a> </a>

View File

@ -0,0 +1,45 @@
<!DOCTYPE html>
<html lang="en" class="no-js">
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<base href="/">
<title>OpenSlides &ndash; Projector</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0;">
<link rel="stylesheet" href="static/css/openslides-libs.css">
<link rel="stylesheet" href="static/css/projector.css">
<link rel="icon" href="/static/img/favicon.png">
<script src="static/js/openslides-libs.js"></script>
<div ng-controller="ProjectorContainerCtrl" class="pContainer">
<div>
<style>
.pContainer #iframe {
width: {{ projectorWidth }}px;
height: {{ projectorHeight }}px;
-moz-transform: scale({{ scale }});
-webkit-transform: scale({{ scale }});
-o-transform: scale({{ scale }});
transform: scale({{ scale }});
/* IE8+ - must be on one line, unfortunately */
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11={{ scale }}, M12=0, M21=0, M22={{ scale }}, SizingMethod='auto expand')";
}
.pContainer #iframewrapper {
width: {{ iframeWidth }}px;
height: {{ iframeHeight }}px;
}
.pContainer #iframeoverlay {
width: {{ iframeWidth }}px;
height: {{ iframeHeight }}px;
}
</style>
<div id="iframewrapper">
<iframe id="iframe" src="/real-projector" frameborder="0"></iframe>
<div id="iframeoverlay"></div>
</div>
</div>
</div>
<script src="/angular_js/projector/"></script>

View File

@ -1,6 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" class="no-js"> <html lang="en" class="no-js">
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<base href="/"> <base href="/">
<title>OpenSlides &ndash; Projector</title> <title>OpenSlides &ndash; Projector</title>
<link rel="stylesheet" href="static/css/openslides-libs.css"> <link rel="stylesheet" href="static/css/openslides-libs.css">

View File

@ -27,9 +27,12 @@ urlpatterns = [
views.AppsJsView.as_view(), views.AppsJsView.as_view(),
name='core_apps_js'), name='core_apps_js'),
# View for the projectors are handelt by angular. # View for the projectors are handled by angular.
url(r'^projector.*$', views.ProjectorView.as_view()), url(r'^projector.*$', views.ProjectorView.as_view()),
# Original view without resolutioncontrol for the projectors are handled by angular.
url(r'^real-projector.*$', views.RealProjectorView.as_view()),
# Main entry point for all angular pages. # Main entry point for all angular pages.
# Has to be the last entry in the urls.py # Has to be the last entry in the urls.py
url(r'^.*$', views.IndexView.as_view()), url(r'^.*$', views.IndexView.as_view()),

View File

@ -66,6 +66,20 @@ class ProjectorView(utils_views.View):
""" """
The primary view for OpenSlides projector using AngularJS. The primary view for OpenSlides projector using AngularJS.
The projector container template is 'openslides/core/static/templates/projector-container.html'.
This container is for controlling the projector resolution.
"""
def get(self, *args, **kwargs):
with open(finders.find('templates/projector-container.html')) as f:
content = f.read()
return HttpResponse(content)
class RealProjectorView(utils_views.View):
"""
The original view without resolutioncontrol for OpenSlides projector using AngularJS.
The default base template is 'openslides/core/static/templates/projector.html'. The default base template is 'openslides/core/static/templates/projector.html'.
You can override it by simply adding a custom 'templates/projector.html' You can override it by simply adding a custom 'templates/projector.html'
file to the custom staticfiles directory. See STATICFILES_DIRS in file to the custom staticfiles directory. See STATICFILES_DIRS in
@ -168,7 +182,7 @@ class ProjectorViewSet(ReadOnlyModelViewSet):
elif self.action in ('metadata', 'list'): elif self.action in ('metadata', 'list'):
result = self.request.user.has_perm('core.can_see_projector') result = self.request.user.has_perm('core.can_see_projector')
elif self.action in ('activate_elements', 'prune_elements', 'update_elements', elif self.action in ('activate_elements', 'prune_elements', 'update_elements',
'deactivate_elements', 'clear_elements', 'control_view'): 'deactivate_elements', 'clear_elements', 'control_view', 'set_resolution'):
result = (self.request.user.has_perm('core.can_see_projector') and result = (self.request.user.has_perm('core.can_see_projector') and
self.request.user.has_perm('core.can_manage_projector')) self.request.user.has_perm('core.can_manage_projector'))
else: else:
@ -314,6 +328,46 @@ class ProjectorViewSet(ReadOnlyModelViewSet):
serializer.save() serializer.save()
return Response(serializer.data) return Response(serializer.data)
@detail_route(methods=['post'])
def set_resolution(self, request, pk):
"""
REST API operation to set the resolution.
It is actually unused, because the resolution is currently set in the config.
But with the multiprojector feature this will become importent to set the
resolution per projector individually.
It expects a POST request to
/rest/core/projector/<pk>/set_resolution/ with a dictionary with the width
and height and the values.
Example:
{
"width": "1024",
"height": "768"
}
"""
if not isinstance(request.data, dict):
raise ValidationError({'detail': 'Data must be a dictionary.'})
if request.data.get('width') is None or request.data.get('height') is None:
raise ValidationError({'detail': 'A width and a height have to be given.'})
if not isinstance(request.data['width'], int) or not isinstance(request.data['height'], int):
raise ValidationError({'detail': 'Data has to be integers.'})
if (request.data['width'] < 800 or request.data['width'] > 3840 or
request.data['height'] < 600 or request.data['height'] > 2160):
raise ValidationError({'detail': 'The Resolution have to be between 800x600 and 3840x2160.'})
projector_instance = self.get_object()
projector_instance.width = request.data['width']
projector_instance.height = request.data['height']
projector_instance.save()
message = 'Changing resolution to {width}x{height} was successful.'.format(
width=request.data['width'],
height=request.data['height'])
return Response({'detail': message})
@detail_route(methods=['post']) @detail_route(methods=['post'])
def control_view(self, request, pk): def control_view(self, request, pk):
""" """

View File

@ -34,7 +34,9 @@ class ProjectorAPI(TestCase):
'uuid': 'aae4a07b26534cfb9af4232f361dce73', 'uuid': 'aae4a07b26534cfb9af4232f361dce73',
'name': 'core/customslide'}}, 'name': 'core/customslide'}},
'scale': 0, 'scale': 0,
'scroll': 0}) 'scroll': 0,
'width': 1024,
'height': 768})
def test_invalid_slide_on_default_projector(self): def test_invalid_slide_on_default_projector(self):
self.client.login(username='admin', password='admin') self.client.login(username='admin', password='admin')
@ -54,7 +56,9 @@ class ProjectorAPI(TestCase):
'uuid': 'fc6ef43b624043068c8e6e7a86c5a1b0', 'uuid': 'fc6ef43b624043068c8e6e7a86c5a1b0',
'error': 'Projector element does not exist.'}}, 'error': 'Projector element does not exist.'}},
'scale': 0, 'scale': 0,
'scroll': 0}) 'scroll': 0,
'width': 1024,
'height': 768})
class VersionView(TestCase): class VersionView(TestCase):