Commit 56aaa8dd authored by Dan Pascu's avatar Dan Pascu

Added video support

parent e92659fe
Video fixes
-----------
- global screenshots/download directory (for all sections)
- consider if we should always show the mute/hold/close buttons (even in
attached mode)?
- run the preview at the normal framerate before we connect (while big)?
- hide scrollbar in chat widget when video is overlayed on it?
- right click on camera preview bring up context menu to select camera
- make detaching animation have a duration that is proportional with the
distance traveled, so that it appears to be similarly fast no matter
how far it detaches
- maybe don't show the camera preview if the video device is None
- hide preview (and buttons?) while we animate?
- preview limited to parent (resize still has issues)
- double click to restore default size for preview? (might be problematic)
- if audio is removed blink-qt puts the session on hold 5 seconds later
when the AudioSessionItem is destroyed
- custom icons for each window (chat, video, file transfer, ...)
Code refactoring
----------------
......@@ -60,8 +82,59 @@ Ideas:
900x550 (230 splitter), 925x550 (240 splitter), 950x550 (250 splitter)
CPU usage increases for video:
------------------------------
painting camera preview @10fps (static image no producer connected)
in chat: +4-5%
detached: +3-4%
fullscren: +4-7%
producer @25fps connected, not paining
in chat: +8-9%
detached: +8-9%
fullscreen: +6-7%
producer @25fps connected, paining @10fps
in chat: +13-14%
detached: +12%
fullscreen: +12-15%
- exceptions:
error: Exception occured in observer <blink.chatwindow.ChatWindow object at 0xab33d5cc> while handling notification 'BlinkSessionInfoUpdated'
Traceback (most recent call last):
File "/usr/lib/python2.7/dist-packages/application/notification.py", line 216, in post_notification
observer.handle_notification(notification)
File "<string>", line 1, in handle_notification
File "/home/dan/work/voip/blink-qt/blink/util.py", line 36, in wrapper
function(*args, **kw)
File "/home/dan/work/voip/blink-qt/blink/chatwindow.py", line 1653, in handle_notification
handler(notification)
File "/home/dan/work/voip/blink-qt/blink/chatwindow.py", line 1767, in _NH_BlinkSessionInfoUpdated
self._update_session_info_panel(elements=notification.data.elements)
File "/home/dan/work/voip/blink-qt/blink/chatwindow.py", line 1481, in _update_session_info_panel
self.video_value_label.setText(video_info.codec or 'N/A')
File "/home/dan/work/voip/blink-qt/blink/sessions.py", line 158, in codec
return '{0.codec_name} {0.framerate:.3g}fps'.format(self) if self.codec_name else None
ValueError: Unknown format code 'g' for object of type 'str'
error: Exception occured in observer <blink.sessions.AudioSessionListView object at 0xab300a04> while handling notification 'BlinkActiveSessionDidChange'
Traceback (most recent call last):
File "/usr/lib/python2.7/dist-packages/application/notification.py", line 216, in post_notification
observer.handle_notification(notification)
File "/home/dan/work/voip/blink-qt/blink/sessions.py", line 2466, in handle_notification
handler(notification)
File "/home/dan/work/voip/blink-qt/blink/sessions.py", line 2480, in _NH_BlinkActiveSessionDidChange
position = model.sessions.index(notification.data.active_session.items.audio)
ValueError: None is not in list
error: Exception occured in observer <sipsimple.streams.msrp.ScreenSharingStream object at 0x7fa65c2a3550> while handling notification 'MediaStreamWillEnd'
Traceback (most recent call last):
File "/usr/lib/python2.7/dist-packages/application/notification.py", line 216, in post_notification
......
......@@ -19,6 +19,8 @@ sip.setapi('QVariant', 2)
from PyQt4.QtCore import Qt, QEvent
from PyQt4.QtGui import QApplication
QApplication.setAttribute(Qt.AA_X11InitThreads, True)
from application import log
from application.notification import IObserver, NotificationCenter, NotificationData
from application.python import Null
......
# Copyright (C) 2013 AG Projects. See LICENSE for details.
#
from __future__ import division
__all__ = ['ChatWindow']
import os
import re
from PyQt4 import uic
from PyQt4.QtCore import Qt, QEasingCurve, QEvent, QPointF, QPropertyAnimation, QRect, QSettings, QTimer, pyqtSignal
from PyQt4.QtGui import QAction, QBrush, QColor, QIcon, QLabel, QLinearGradient, QListView, QMenu, QPainter, QPalette, QPen, QPixmap, QPolygonF, QTextCursor, QTextDocument, QTextEdit
from PyQt4.QtCore import Qt, QEasingCurve, QEvent, QPoint, QPointF, QPropertyAnimation, QRect, QRectF, QSettings, QSize, QSizeF, QTimer, QUrl, pyqtSignal
from PyQt4.QtGui import QAction, QBrush, QColor, QIcon, QLabel, QLinearGradient, QListView, QMenu, QPainter, QPalette, QPen, QPixmap, QPolygonF, QTextCursor, QTextDocument, QTextEdit, QToolButton
from PyQt4.QtGui import QApplication, QDesktopServices
from PyQt4.QtWebKit import QWebPage, QWebSettings, QWebView
from abc import ABCMeta, abstractmethod
from application.notification import IObserver, NotificationCenter
from application.python import Null
from application.notification import IObserver, NotificationCenter, ObserverWeakrefProxy
from application.python import Null, limit
from application.python.types import MarkerType
from application.system import makedirs
from collections import MutableSet
from datetime import datetime, timedelta
from itertools import count
from lxml import etree, html
from lxml.html.clean import autolink
from weakref import proxy
......@@ -27,6 +31,7 @@ from sipsimple.account import AccountManager
from sipsimple.application import SIPApplication
from sipsimple.audio import WavePlayer
from sipsimple.configuration.settings import SIPSimpleSettings
from sipsimple.threading import run_in_thread
from blink.configuration.datatypes import FileURL, GraphTimeScale
from blink.configuration.settings import BlinkSettings
......@@ -36,7 +41,8 @@ from blink.sessions import ChatSessionModel, ChatSessionListView, StreamDescript
from blink.util import run_in_gui_thread
from blink.widgets.color import ColorHelperMixin
from blink.widgets.graph import Graph
from blink.widgets.util import ContextMenuActions
from blink.widgets.util import ContextMenuActions, QtDynamicProperty
from blink.widgets.video import VideoSurface
# Chat style classes
......@@ -623,6 +629,546 @@ class ChatWidget(base_class, ui_class):
del ui_class, base_class
class VideoToolButton(QToolButton):
active = QtDynamicProperty('active', bool)
def event(self, event):
if event.type() == QEvent.DynamicPropertyChange and event.propertyName() == 'active':
self.setVisible(self.active)
return super(VideoToolButton, self).event(event)
ui_class, base_class = uic.loadUiType(Resources.get('video_widget.ui'))
class VideoWidget(VideoSurface, ui_class):
implements(IObserver)
def __init__(self, session_item, parent=None):
super(VideoWidget, self).__init__(parent)
with Resources.directory:
self.setupUi()
self.session_item = session_item
self.blink_session = session_item.blink_session
self.parent_widget = parent
self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.fullscreen_button.clicked.connect(self._SH_FullscreenButtonClicked)
self.screenshot_button.clicked.connect(self._SH_ScreenshotButtonClicked)
self.detach_button.clicked.connect(self._SH_DetachButtonClicked)
self.mute_button.clicked.connect(self._SH_MuteButtonClicked)
self.hold_button.clicked.connect(self._SH_HoldButtonClicked)
self.close_button.clicked.connect(self._SH_CloseButtonClicked)
self.screenshots_folder_action.triggered.connect(self._SH_ScreenshotsFolderActionTriggered)
self.screenshot_button.customContextMenuRequested.connect(self._SH_ScreenshotButtonContextMenuRequested)
self.camera_preview.adjusted.connect(self._SH_CameraPreviewAdjusted)
self.detach_animation.finished.connect(self._SH_DetachAnimationFinished)
self.preview_animation.finished.connect(self._SH_PreviewAnimationFinished)
self.idle_timer.timeout.connect(self._SH_IdleTimerTimeout)
if parent is not None:
parent.installEventFilter(self)
self.setGeometry(self.geometryHint())
self.setVisible('video' in session_item.blink_session.streams)
settings = SIPSimpleSettings()
notification_center = NotificationCenter()
notification_center.add_observer(ObserverWeakrefProxy(self), sender=session_item.blink_session)
notification_center.add_observer(ObserverWeakrefProxy(self), name='CFGSettingsObjectDidChange', sender=settings)
notification_center.add_observer(ObserverWeakrefProxy(self), name='VideoStreamRemoteFormatDidChange')
notification_center.add_observer(ObserverWeakrefProxy(self), name='VideoStreamReceivedKeyFrame')
notification_center.add_observer(ObserverWeakrefProxy(self), name='VideoDeviceDidChangeCamera')
def setupUi(self):
super(VideoWidget, self).setupUi(self)
self.no_flicker_widget = QLabel()
self.no_flicker_widget.setWindowFlags(Qt.FramelessWindowHint)
#self.no_flicker_widget.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.camera_preview = VideoSurface(self, framerate=10)
self.camera_preview.interactive = True
self.camera_preview.mirror = True
self.camera_preview.setMinimumHeight(45)
self.camera_preview.setMaximumHeight(135)
self.camera_preview.setGeometry(QRect(0, 0, self.camera_preview.width_for_height(81), 81))
self.camera_preview.lower()
self.camera_preview.scale_factor = 1.0
self.detach_animation = QPropertyAnimation(self, 'geometry')
self.detach_animation.setDuration(200)
#self.detach_animation.setEasingCurve(QEasingCurve.Linear)
self.preview_animation = QPropertyAnimation(self.camera_preview, 'geometry')
self.preview_animation.setDuration(500)
self.preview_animation.setDirection(QPropertyAnimation.Forward)
self.preview_animation.setEasingCurve(QEasingCurve.OutQuad)
self.idle_timer = QTimer()
self.idle_timer.setSingleShot(True)
self.idle_timer.setInterval(3000)
for button in self.tool_buttons:
button.setCursor(Qt.ArrowCursor)
button.installEventFilter(self)
button.active = False
# fix the SVG icons, as the generated code loads them as pixmaps, losing their ability to scale -Dan
fullscreen_icon = QIcon()
fullscreen_icon.addFile(Resources.get('icons/fullscreen.svg'), mode=QIcon.Normal, state=QIcon.Off)
fullscreen_icon.addFile(Resources.get('icons/fullscreen-exit.svg'), mode=QIcon.Normal, state=QIcon.On)
fullscreen_icon.addFile(Resources.get('icons/fullscreen-exit.svg'), mode=QIcon.Active, state=QIcon.On)
fullscreen_icon.addFile(Resources.get('icons/fullscreen-exit.svg'), mode=QIcon.Disabled, state=QIcon.On)
fullscreen_icon.addFile(Resources.get('icons/fullscreen-exit.svg'), mode=QIcon.Selected, state=QIcon.On)
detach_icon = QIcon()
detach_icon.addFile(Resources.get('icons/detach.svg'), mode=QIcon.Normal, state=QIcon.Off)
detach_icon.addFile(Resources.get('icons/attach.svg'), mode=QIcon.Normal, state=QIcon.On)
detach_icon.addFile(Resources.get('icons/attach.svg'), mode=QIcon.Active, state=QIcon.On)
detach_icon.addFile(Resources.get('icons/attach.svg'), mode=QIcon.Disabled, state=QIcon.On)
detach_icon.addFile(Resources.get('icons/attach.svg'), mode=QIcon.Selected, state=QIcon.On)
mute_icon = QIcon()
mute_icon.addFile(Resources.get('icons/mic-on.svg'), mode=QIcon.Normal, state=QIcon.Off)
mute_icon.addFile(Resources.get('icons/mic-off.svg'), mode=QIcon.Normal, state=QIcon.On)
mute_icon.addFile(Resources.get('icons/mic-off.svg'), mode=QIcon.Active, state=QIcon.On)
mute_icon.addFile(Resources.get('icons/mic-off.svg'), mode=QIcon.Disabled, state=QIcon.On)
mute_icon.addFile(Resources.get('icons/mic-off.svg'), mode=QIcon.Selected, state=QIcon.On)
hold_icon = QIcon()
hold_icon.addFile(Resources.get('icons/pause.svg'), mode=QIcon.Normal, state=QIcon.Off)
hold_icon.addFile(Resources.get('icons/paused.svg'), mode=QIcon.Normal, state=QIcon.On)
hold_icon.addFile(Resources.get('icons/paused.svg'), mode=QIcon.Active, state=QIcon.On)
hold_icon.addFile(Resources.get('icons/paused.svg'), mode=QIcon.Disabled, state=QIcon.On)
hold_icon.addFile(Resources.get('icons/paused.svg'), mode=QIcon.Selected, state=QIcon.On)
screenshot_icon = QIcon()
screenshot_icon.addFile(Resources.get('icons/screenshot.svg'), mode=QIcon.Normal, state=QIcon.Off)
close_icon = QIcon()
close_icon.addFile(Resources.get('icons/close.svg'), mode=QIcon.Normal, state=QIcon.Off)
close_icon.addFile(Resources.get('icons/close-active.svg'), mode=QIcon.Active, state=QIcon.Off)
self.fullscreen_button.setIcon(fullscreen_icon)
self.screenshot_button.setIcon(screenshot_icon)
self.detach_button.setIcon(detach_icon)
self.mute_button.setIcon(mute_icon)
self.hold_button.setIcon(hold_icon)
self.close_button.setIcon(close_icon)
self.screenshot_button_menu = QMenu(self)
self.screenshots_folder_action = self.screenshot_button_menu.addAction('Open screenshots folder')
@property
def interactive(self):
return self.parent() is None and not self.isFullScreen()
@property
def tool_buttons(self):
return tuple(attr for attr in vars(self).itervalues() if isinstance(attr, VideoToolButton))
@property
def active_tool_buttons(self):
return tuple(button for button in self.tool_buttons if button.active)
def eventFilter(self, watched, event):
event_type = event.type()
if watched is self.parent():
if event_type == QEvent.Resize:
self.setGeometry(self.geometryHint())
elif event_type == QEvent.Enter:
self.idle_timer.stop()
cursor = self.cursor()
cursor_pos = cursor.pos()
if not watched.rect().translated(watched.mapToGlobal(QPoint(0, 0))).contains(cursor_pos):
# sometimes we get invalid enter events for the fullscreen_button after we switch to fullscreen.
# simulate a mouse move in and out of the button to force qt to update the button state.
cursor.setPos(self.mapToGlobal(watched.geometry().center()))
cursor.setPos(cursor_pos)
elif event_type == QEvent.Leave:
self.idle_timer.start()
return False
def mousePressEvent(self, event):
super(VideoWidget, self).mousePressEvent(event)
if self._interaction.active:
for button in self.active_tool_buttons:
button.show() # show or hide the toolbuttons while we move/resize? -Dan
self.idle_timer.stop()
def mouseReleaseEvent(self, event):
if self._interaction.active:
for button in self.active_tool_buttons:
button.show()
self.idle_timer.start()
super(VideoWidget, self).mouseReleaseEvent(event)
def mouseMoveEvent(self, event):
super(VideoWidget, self).mouseMoveEvent(event)
if self._interaction.active:
return
if not self.idle_timer.isActive():
for button in self.active_tool_buttons:
button.show()
self.setCursor(Qt.ArrowCursor)
self.idle_timer.start()
def resizeEvent(self, event):
if self.preview_animation.state() == QPropertyAnimation.Running:
return
if not event.oldSize().isValid():
return
if self.camera_preview.size() == event.oldSize():
self.camera_preview.resize(event.size())
return
old_size = QSizeF(event.oldSize())
new_size = QSizeF(event.size())
ratio = new_size.height() / old_size.height()
if ratio == 1:
return
scaled_preview_geometry = QRectF(QPointF(self.camera_preview.geometry().topLeft()) * ratio, QSizeF(self.camera_preview.size()) * ratio)
preview_center = scaled_preview_geometry.center()
ideal_geometry = scaled_preview_geometry.toAlignedRect()
if ideal_geometry.right() > self.rect().right():
ideal_geometry.moveRight(self.rect().right())
if ideal_geometry.bottom() > self.rect().bottom():
ideal_geometry.moveBottom(self.rect().bottom())
new_height = limit((new_size.height() + 117) / 6 * self.camera_preview.scale_factor, min=self.camera_preview.minimumHeight(), max=self.camera_preview.maximumHeight())
preview_geometry = QRect(0, 0, self.width_for_height(new_height), new_height)
quadrant = QRectF(QPointF(0, 0), new_size/3)
if quadrant.translated(0, 0).contains(preview_center): # top left gravity
preview_geometry.moveTopLeft(ideal_geometry.topLeft())
elif quadrant.translated(quadrant.width(), 0).contains(preview_center): # top gravity
preview_geometry.moveCenter(ideal_geometry.center())
preview_geometry.moveTop(ideal_geometry.top())
elif quadrant.translated(2*quadrant.width(), 0).contains(preview_center): # top right gravity
preview_geometry.moveTopRight(ideal_geometry.topRight())
elif quadrant.translated(0, quadrant.height()).contains(preview_center): # left gravity
preview_geometry.moveCenter(ideal_geometry.center())
preview_geometry.moveLeft(ideal_geometry.left())
elif quadrant.translated(quadrant.width(), quadrant.height()).contains(preview_center): # center gravity
preview_geometry.moveCenter(ideal_geometry.center())
elif quadrant.translated(2*quadrant.width(), quadrant.height()).contains(preview_center): # right gravity
preview_geometry.moveCenter(ideal_geometry.center())
preview_geometry.moveRight(ideal_geometry.right())
elif quadrant.translated(0, 2*quadrant.height()).contains(preview_center): # bottom left gravity
preview_geometry.moveBottomLeft(ideal_geometry.bottomLeft())
elif quadrant.translated(quadrant.width(), 2*quadrant.height()).contains(preview_center): # bottom gravity
preview_geometry.moveCenter(ideal_geometry.center())
preview_geometry.moveBottom(ideal_geometry.bottom())
elif quadrant.translated(2*quadrant.width(), 2*quadrant.height()).contains(preview_center): # bottom right gravity
preview_geometry.moveBottomRight(ideal_geometry.bottomRight())
self.camera_preview.setGeometry(preview_geometry)
def setParent(self, parent):
old_parent = self.parent()
if old_parent is not None:
old_parent.removeEventFilter(self)
super(VideoWidget, self).setParent(parent)
if parent is not None:
parent.installEventFilter(self)
self.setGeometry(self.geometryHint())
def setVisible(self, visible):
if visible == False and self.isFullScreen():
self.showNormal()
if not self.detach_button.isChecked():
self.setParent(self.parent_widget)
self.setGeometry(self.parent().rect())
self.fullscreen_button.setChecked(False)
super(VideoWidget, self).setVisible(visible)
def geometryHint(self, parent=None):
parent = parent or self.parent()
if parent is not None:
origin = QPoint(0, 0)
size = QSize(parent.width(), min(self.height_for_width(parent.width()), parent.height() - 175))
else:
origin = self.geometry().topLeft()
size = QSize(self.width_for_height(self.height()), self.height())
return QRect(origin, size)
@run_in_gui_thread
def handle_notification(self, notification):
handler = getattr(self, '_NH_%s' % notification.name, Null)
handler(notification)
def _NH_BlinkSessionWillConnect(self, notification):
if 'video' in notification.sender.streams:
self.setParent(self.parent_widget)
self.setGeometry(self.geometryHint())
self.detach_button.setChecked(False)
for button in self.tool_buttons:
button.active = False
self.camera_preview.setMaximumHeight(16777215)
self.camera_preview.setGeometry(self.rect())
self.camera_preview.setCursor(Qt.ArrowCursor)
self.camera_preview.interactive = False
self.camera_preview.scale_factor = 1.0
self.camera_preview.producer = SIPApplication.video_device.producer
self.setCursor(Qt.ArrowCursor)
self.show()
def _NH_BlinkSessionDidConnect(self, notification):
video_stream = notification.sender.streams.get('video')
if video_stream is not None:
if self.parent() is None:
self.setParent(self.parent_widget)
self.setGeometry(self.geometryHint())
self.detach_button.setChecked(False)
for button in self.tool_buttons:
button.active = False
self.camera_preview.setMaximumHeight(16777215)
self.camera_preview.setGeometry(self.rect())
self.camera_preview.setCursor(Qt.ArrowCursor)
self.camera_preview.interactive = False
self.camera_preview.scale_factor = 1.0
self.camera_preview.producer = SIPApplication.video_device.producer
self.producer = video_stream.producer
self.setCursor(Qt.ArrowCursor)
self.show()
else:
self.hide()
self.producer = None
self._image = None
self.camera_preview.producer = None
self.camera_preview._image = None
def _NH_BlinkSessionWillAddStream(self, notification):
if notification.data.stream.type == 'video':
self.setParent(self.parent_widget)
self.setGeometry(self.geometryHint())
self.detach_button.setChecked(False)
for button in self.tool_buttons:
button.active = False
self.camera_preview.setMaximumHeight(16777215)
self.camera_preview.setGeometry(self.rect())
self.camera_preview.setCursor(Qt.ArrowCursor)
self.camera_preview.interactive = False
self.camera_preview.scale_factor = 1.0
self.camera_preview.producer = SIPApplication.video_device.producer
self.setCursor(Qt.ArrowCursor)
self.show()
def _NH_BlinkSessionDidAddStream(self, notification):
if notification.data.stream.type == 'video':
self.producer = notification.data.stream.producer
def _NH_BlinkSessionDidNotAddStream(self, notification):
if notification.data.stream.type == 'video':
self.hide()
self.producer = None
self._image = None
self.camera_preview.producer = None
self.camera_preview._image = None
def _NH_BlinkSessionDidRemoveStream(self, notification):
if notification.data.stream.type == 'video':
self.hide()
self.producer = None
self._image = None
self.camera_preview.producer = None
self.camera_preview._image = None
def _NH_BlinkSessionDidEnd(self, notification):
self.hide()
self.producer = None
self._image = None
self.camera_preview.producer = None
self.camera_preview._image = None
def _NH_BlinkSessionWasDeleted(self, notification):
self.stop()
self.setParent(None)
self.session_item = None
self.blink_session = None
self.parent_widget = None
self.detach_animation = None
self.preview_animation = None
def _NH_BlinkSessionDidChangeHoldState(self, notification):
self.hold_button.setChecked(notification.data.local_hold)
def _NH_VideoStreamRemoteFormatDidChange(self, notification):
if notification.sender.blink_session is self.blink_session and not self.isFullScreen():
self.setGeometry(self.geometryHint())
def _NH_VideoStreamReceivedKeyFrame(self, notification):
if notification.sender.blink_session is self.blink_session and self.preview_animation.state() != QPropertyAnimation.Running and self.camera_preview.size() == self.size():
self.preview_animation.setStartValue(self.rect())
self.preview_animation.setEndValue(QRect(0, 0, self.camera_preview.width_for_height(81), 81))
self.preview_animation.start()
def _NH_VideoDeviceDidChangeCamera(self, notification):
#self.camera_preview.producer = SIPApplication.video_device.producer
self.camera_preview.producer = notification.data.new_camera
def _NH_CFGSettingsObjectDidChange(self, notification):
settings = SIPSimpleSettings()
if 'audio.muted' in notification.data.modified:
self.mute_button.setChecked(settings.audio.muted)
def _SH_CameraPreviewAdjusted(self, old_geometry, new_geometry):
print old_geometry, new_geometry
if new_geometry.size() != old_geometry.size():
default_height_for_size = (self.height() + 117) / 6
self.camera_preview.scale_factor = new_geometry.height() / default_height_for_size
def _SH_IdleTimerTimeout(self):
for button in self.active_tool_buttons:
button.hide()
self.setCursor(Qt.BlankCursor)
def _SH_FullscreenButtonClicked(self, checked):
if checked:
if not self.detach_button.isChecked():
geometry = self.rect().translated(self.mapToGlobal(QPoint(0, 0)))
self.setParent(None)
self.setGeometry(geometry)
self.show() # without this, showFullScreen below doesn't work properly
self.detach_button.active = False
self.mute_button.active = True
self.hold_button.active = True
self.close_button.active = True
self.showFullScreen()
self.fullscreen_button.hide() # it seems the leave event after the button is pressed doesn't register and starting the idle timer here doesn't work well either -Dan
self.fullscreen_button.show()
else:
if not self.detach_button.isChecked():
self.setGeometry(self.geometryHint(self.parent_widget)) # force a geometry change before reparenting, else we will get a change from (-1, -1) to the parent geometry hint
self.setParent(self.parent_widget) # this is probably because since it unmaps when it's reparented, the geometry change won't appear from fullscreen
self.setGeometry(self.geometryHint()) # to the new size, since we changed the geometry after returning from fullscreen, while invisible
self.mute_button.active = False
self.hold_button.active = False
self.close_button.active = False
self.detach_button.active = True
self.showNormal()
self.window().show()
self.setCursor(Qt.ArrowCursor)
def _SH_DetachButtonClicked(self, checked):
if checked:
if self.isFullScreen():
self.showNormal()
desktop = QApplication.desktop()
screen_area = desktop.availableGeometry(self)
start_rect = self.rect()
final_rect = QRect(0, 0, self.width_for_height(261), 261)
start_geometry = start_rect.translated(self.mapToGlobal(QPoint(0, 0)))
final_geometry = final_rect.translated(screen_area.topRight() - final_rect.topRight() + QPoint(-10, 10))
pixmap = QPixmap.grabWidget(self)
self.no_flicker_widget.resize(pixmap.size())
self.no_flicker_widget.setPixmap(pixmap)
self.no_flicker_widget.setGeometry(self.rect().translated(self.mapToGlobal(QPoint(0, 0))))
self.no_flicker_widget.show()
self.no_flicker_widget.raise_()
self.setParent(None)
self.setGeometry(start_geometry)
self.show()
self.no_flicker_widget.hide()
self.detach_animation.setDirection(QPropertyAnimation.Forward)
self.detach_animation.setEasingCurve(QEasingCurve.OutQuad)
self.detach_animation.setStartValue(start_geometry)
self.detach_animation.setEndValue(final_geometry)
self.detach_animation.start()
else:
start_geometry = self.geometry()
final_geometry = self.geometryHint(self.parent_widget).translated(self.parent_widget.mapToGlobal(QPoint(0, 0)))
# do this early or late? -Dan
self.parent_widget.window().show()
self.detach_animation.setDirection(QPropertyAnimation.Backward)
self.detach_animation.setEasingCurve(QEasingCurve.InQuad)
self.detach_animation.setStartValue(final_geometry) # start and end are reversed because we go backwards
self.detach_animation.setEndValue(start_geometry)
self.detach_animation.start()
self.fullscreen_button.setChecked(False)
def _SH_ScreenshotButtonClicked(self):
screenshot = VideoScreenshot(self)
screenshot.capture()
screenshot.save()
def _SH_MuteButtonClicked(self, checked):
settings = SIPSimpleSettings()
settings.audio.muted = checked
settings.save()
def _SH_HoldButtonClicked(self, checked):
if checked:
self.blink_session.hold()
else:
self.blink_session.unhold()
def _SH_CloseButtonClicked(self):
if 'screen-sharing' in self.blink_session.streams:
self.blink_session.remove_stream(self.session_item.video_stream)
else:
self.session_item.end()
def _SH_ScreenshotButtonContextMenuRequested(self, pos):
if not self.isFullScreen():
self.screenshot_button_menu.exec_(self.screenshot_button.mapToGlobal(pos))
def _SH_ScreenshotsFolderActionTriggered(self, pos):
settings = BlinkSettings()
QDesktopServices.openUrl(QUrl.fromLocalFile(settings.video.screenshots_directory.normalized))
def _SH_DetachAnimationFinished(self):
if self.detach_animation.direction() == QPropertyAnimation.Backward:
pixmap = QPixmap.grabWidget(self)
self.no_flicker_widget.resize(pixmap.size())
self.no_flicker_widget.setPixmap(pixmap)
self.no_flicker_widget.setGeometry(self.geometry())
self.no_flicker_widget.show()
self.no_flicker_widget.raise_()
#self.no_flicker_widget.repaint()
#self.repaint()
self.setParent(self.parent_widget)
self.setGeometry(self.geometryHint())
self.show() # solve the flicker -Dan
#self.repaint()
#self.no_flicker_widget.lower()
self.no_flicker_widget.hide()
#self.window().show()
self.mute_button.active = False
self.hold_button.active = False
self.close_button.active = False
else:
self.detach_button.hide() # it seems the leave event after the button is pressed doesn't register and starting the idle timer here doesn't work well either -Dan
self.detach_button.show()
self.mute_button.active = True
self.hold_button.active = True
self.close_button.active = True
self.setCursor(Qt.ArrowCursor)
def _SH_PreviewAnimationFinished(self):
self.camera_preview.setMaximumHeight(135)
self.camera_preview.interactive = True
self.setCursor(Qt.ArrowCursor)
self.detach_button.active = True
self.fullscreen_button.active = True
self.screenshot_button.active = True
self.idle_timer.start()
del ui_class, base_class
class NoSessionsLabel(QLabel):
def __init__(self, chat_window):
super(NoSessionsLabel, self).__init__(chat_window.session_panel)
......@@ -739,9 +1285,12 @@ class ChatWindow(base_class, ui_class, ColorHelperMixin):
self.control_button.actions = ContextMenuActions()
self.control_button.actions.connect = QAction("Connect", self, triggered=self._AH_Connect)
self.control_button.actions.connect_with_audio = QAction("Connect with audio", self, triggered=self._AH_ConnectWithAudio)
self.control_button.actions.connect_with_video = QAction("Connect with video", self, triggered=self._AH_ConnectWithVideo)
self.control_button.actions.disconnect = QAction("Disconnect", self, triggered=self._AH_Disconnect)
self.control_button.actions.add_audio = QAction("Add audio", self, triggered=self._AH_AddAudio)
self.control_button.actions.remove_audio = QAction("Remove audio", self, triggered=self._AH_RemoveAudio)
self.control_button.actions.add_video = QAction("Add video", self, triggered=self._AH_AddVideo)
self.control_button.actions.remove_video = QAction("Remove video", self, triggered=self._AH_RemoveVideo)
self.control_button.actions.share_my_screen = QAction("Share my screen", self, triggered=self._AH_ShareMyScreen)
self.control_button.actions.request_screen = QAction("Request screen", self, triggered=self._AH_RequestScreen)
self.control_button.actions.end_screen_sharing = QAction("End screen sharing", self, triggered=self._AH_EndScreenSharing)
......@@ -762,9 +1311,9 @@ class ChatWindow(base_class, ui_class, ColorHelperMixin):
self.session_details.animationEasingCurve = QEasingCurve.OutCirc
self.audio_latency_graph = Graph([], color=QColor(0, 100, 215), over_boundary_color=QColor(255, 0, 100))
self.video_latency_graph = Graph([], color=QColor(0, 215, 100), over_boundary_color=QColor(255, 100, 0))
self.video_latency_graph = Graph([], color=QColor(0, 215, 100), over_boundary_color=QColor(255, 100, 0), enabled=False) # disable for now
self.audio_packet_loss_graph = Graph([], color=QColor(0, 100, 215), over_boundary_color=QColor(255, 0, 100))
self.video_packet_loss_graph = Graph([], color=QColor(0, 215, 100), over_boundary_color=QColor(255, 100, 0))
self.video_packet_loss_graph = Graph([], color=QColor(0, 215, 100), over_boundary_color=QColor(255, 100, 0), enabled=False) # disable for now
self.incoming_traffic_graph = Graph([], color=QColor(255, 50, 50))
self.outgoing_traffic_graph = Graph([], color=QColor(0, 100, 215))
......@@ -862,6 +1411,7 @@ class ChatWindow(base_class, ui_class, ColorHelperMixin):
if state not in ('connecting/*', 'connected/*'):
menu.addAction(self.control_button.actions.connect)
menu.addAction(self.control_button.actions.connect_with_audio)
menu.addAction(self.control_button.actions.connect_with_video)
else:
menu.addAction(self.control_button.actions.disconnect)
if state == 'connected':
......@@ -870,6 +1420,10 @@ class ChatWindow(base_class, ui_class, ColorHelperMixin):
menu.addAction(self.control_button.actions.add_audio)
elif stream_types != {'audio'} and not stream_types.intersection({'screen-sharing', 'video'}):
menu.addAction(self.control_button.actions.remove_audio)
if 'video' not in stream_types:
menu.addAction(self.control_button.actions.add_video)
elif stream_types != {'video'}:
menu.addAction(self.control_button.actions.remove_video)
if 'screen-sharing' not in stream_types:
menu.addAction(self.control_button.actions.request_screen)
menu.addAction(self.control_button.actions.share_my_screen)
......@@ -892,6 +1446,7 @@ class ChatWindow(base_class, ui_class, ColorHelperMixin):
self.account_value_label.setEnabled(have_session)
self.remote_agent_value_label.setEnabled(have_session)
self.audio_value_widget.setEnabled('audio' in blink_session.streams)
self.video_value_widget.setEnabled('video' in blink_session.streams)
self.chat_value_widget.setEnabled('chat' in blink_session.streams)
self.screen_value_widget.setEnabled('screen-sharing' in blink_session.streams)
......@@ -928,7 +1483,6 @@ class ChatWindow(base_class, ui_class, ColorHelperMixin):
if 'media' in elements:
self.audio_value_label.setText(audio_info.codec or 'N/A')
if audio_info.ice_status == 'succeeded':
if 'relay' in {candidate.type.lower() for candidate in (audio_info.local_rtp_candidate, audio_info.remote_rtp_candidate)}:
self.audio_connection_label.setPixmap(self.relay_connection_pixmap)
......@@ -949,10 +1503,33 @@ class ChatWindow(base_class, ui_class, ColorHelperMixin):
else:
self.audio_connection_label.setPixmap(self.unknown_connection_pixmap)
self.audio_connection_label.setToolTip(u'Negotiating ICE')
self.audio_connection_label.setVisible(audio_info.remote_address is not None)
self.audio_encryption_label.setVisible(audio_info.encryption is not None)
self.video_value_label.setText(video_info.codec or 'N/A')
if video_info.ice_status == 'succeeded':
if 'relay' in {candidate.type.lower() for candidate in (video_info.local_rtp_candidate, video_info.remote_rtp_candidate)}:
self.video_connection_label.setPixmap(self.relay_connection_pixmap)
self.video_connection_label.setToolTip(u'Using relay')
else:
self.video_connection_label.setPixmap(self.direct_connection_pixmap)
self.video_connection_label.setToolTip(u'Peer to peer')
elif video_info.ice_status == 'failed':
self.video_connection_label.setPixmap(self.unknown_connection_pixmap)
self.video_connection_label.setToolTip(u"Couldn't negotiate ICE")
elif video_info.ice_status == 'disabled':
if blink_session.contact.type == 'bonjour':
self.video_connection_label.setPixmap(self.direct_connection_pixmap)
self.video_connection_label.setToolTip(u'Peer to peer')
else:
self.video_connection_label.setPixmap(self.unknown_connection_pixmap)
self.video_connection_label.setToolTip(u'ICE is disabled')
else:
self.video_connection_label.setPixmap(self.unknown_connection_pixmap)
self.video_connection_label.setToolTip(u'Negotiating ICE')
self.video_connection_label.setVisible(video_info.remote_address is not None)
self.video_encryption_label.setVisible(video_info.encryption is not None)
if any(len(path) > 1 for path in (chat_info.full_local_path, chat_info.full_remote_path)):
self.chat_value_label.setText(u'Using relay')
self.chat_connection_label.setPixmap(self.relay_connection_pixmap)
......@@ -1375,7 +1952,7 @@ class ChatWindow(base_class, ui_class, ColorHelperMixin):
else:
self.selected_session.blink_session.stop_recording()
def _SH_ControlButtonClicked(self, checked):
def _SH_ControlButtonClicked(self):
# this is only called if the control button doesn't have a menu attached
if self.selected_session.blink_session.state == 'connected/sent_proposal':
self.selected_session.blink_session.sip_session.cancel_proposal()
......@@ -1386,6 +1963,7 @@ class ChatWindow(base_class, ui_class, ColorHelperMixin):
model = self.session_model
position = model.sessions.index(session)
session.chat_widget = ChatWidget(session, self.tab_widget)
session.video_widget = VideoWidget(session, session.chat_widget)
session.active_panel = self.info_panel
self.tab_widget.insertTab(position, session.chat_widget, session.name)
self.no_sessions_label.hide()
......@@ -1397,6 +1975,7 @@ class ChatWindow(base_class, ui_class, ColorHelperMixin):
def _SH_SessionModelSessionRemoved(self, session):
self.tab_widget.removeTab(self.tab_widget.indexOf(session.chat_widget))
session.chat_widget = None
session.video_widget = None
session.active_panel = None
if not self.session_model.sessions:
self.close()
......@@ -1440,6 +2019,12 @@ class ChatWindow(base_class, ui_class, ColorHelperMixin):
blink_session.init_outgoing(blink_session.account, blink_session.contact, blink_session.contact_uri, stream_descriptions=stream_descriptions, reinitialize=True)
blink_session.connect()
def _AH_ConnectWithVideo(self):
stream_descriptions = [StreamDescription('audio'), StreamDescription('video'), StreamDescription('chat')]
blink_session = self.selected_session.blink_session
blink_session.init_outgoing(blink_session.account, blink_session.contact, blink_session.contact_uri, stream_descriptions=stream_descriptions, reinitialize=True)
blink_session.connect()
def _AH_Disconnect(self):
self.selected_session.end()
......@@ -1449,6 +2034,15 @@ class ChatWindow(base_class, ui_class, ColorHelperMixin):
def _AH_RemoveAudio(self):
self.selected_session.blink_session.remove_stream(self.selected_session.blink_session.streams.get('audio'))
def _AH_AddVideo(self):
if 'audio' in self.selected_session.blink_session.streams:
self.selected_session.blink_session.add_stream(StreamDescription('video'))
else:
self.selected_session.blink_session.add_streams([StreamDescription('video'), StreamDescription('audio')])
def _AH_RemoveVideo(self):
self.selected_session.blink_session.remove_stream(self.selected_session.blink_session.streams.get('video'))
def _AH_RequestScreen(self):
if 'audio' in self.selected_session.blink_session.streams:
self.selected_session.blink_session.add_stream(StreamDescription('screen-sharing', mode='viewer'))
......@@ -1534,3 +2128,34 @@ class TrafficNormalizer(object):
return format % (value/divisor, 'bp' if bits_per_second else 'B/')
class VideoScreenshot(object):
def __init__(self, surface):
self.surface = surface
self.image = None
@classmethod
def filename_generator(cls):
settings = BlinkSettings()
name = os.path.join(settings.video.screenshots_directory.normalized, 'VideoCall-{:%Y%m%d-%H.%M.%S}'.format(datetime.now()))
yield '%s.png' % name
for x in count(1):
yield "%s-%d.png" % (name, x)
def capture(self):
try:
self.image = self.surface._image.copy()
except AttributeError:
pass
else:
player = WavePlayer(SIPApplication.alert_audio_bridge.mixer, Resources.get('sounds/screenshot.wav'), volume=30)
SIPApplication.alert_audio_bridge.add(player)
player.start()
@run_in_thread('file-io')
def save(self):
if self.image is not None:
filename = next(filename for filename in self.filename_generator() if not os.path.exists(filename))
makedirs(os.path.dirname(filename))
self.image.save(filename)
......@@ -7,7 +7,7 @@ __all__ = ['AccountExtension', 'BonjourAccountExtension']
from sipsimple.account import BonjourMSRPSettings, MessageSummarySettings, MSRPSettings, PresenceSettings, RTPSettings, SIPSettings, TLSSettings, XCAPSettings
from sipsimple.configuration import Setting, SettingsGroup, SettingsObjectExtension, RuntimeSetting
from sipsimple.configuration.datatypes import AudioCodecList, Hostname, MSRPConnectionModel, MSRPTransport, NonNegativeInteger, SIPTransportList, SRTPEncryption
from sipsimple.configuration.datatypes import AudioCodecList, Hostname, MSRPConnectionModel, MSRPTransport, NonNegativeInteger, SIPTransportList, SRTPEncryption, VideoCodecList
from sipsimple.util import user_info
from blink.configuration.datatypes import ApplicationDataPath, HTTPURL, IconDescriptor, SoundFile
......@@ -42,6 +42,7 @@ class PSTNSettings(SettingsGroup):
class RTPSettingsExtension(RTPSettings):
audio_codec_order = Setting(type=AudioCodecList, default=None, nillable=True)
video_codec_order = Setting(type=VideoCodecList, default=None, nillable=True)
inband_dtmf = Setting(type=bool, default=True)
srtp_encryption = Setting(type=SRTPEncryption, default='optional')
use_srtp_without_tls = Setting(type=bool, default=True)
......
......@@ -9,7 +9,7 @@ import platform
import sys
from sipsimple.configuration import Setting, SettingsGroup, SettingsObject, SettingsObjectExtension
from sipsimple.configuration.datatypes import AudioCodecList, NonNegativeInteger, PositiveInteger, Path, SampleRate
from sipsimple.configuration.datatypes import AudioCodecList, NonNegativeInteger, PositiveInteger, Path, SampleRate, VideoCodecList
from sipsimple.configuration.settings import AudioSettings, ChatSettings, EchoCancellerSettings, FileTransferSettings, LogsSettings, RTPSettings, TLSSettings
from blink import __version__
......@@ -60,6 +60,7 @@ class LogsSettingsExtension(LogsSettings):
class RTPSettingsExtension(RTPSettings):
audio_codec_order = Setting(type=AudioCodecList, default=AudioCodecList(('opus', 'G722', 'speex', 'GSM', 'iLBC', 'PCMU', 'PCMA')))
video_codec_order = Setting(type=VideoCodecList, default=VideoCodecList(('H264',)))
class ServerSettings(SettingsGroup):
......@@ -120,6 +121,10 @@ class BlinkScreenSharingSettings(SettingsGroup):
open_viewonly = Setting(type=bool, default=False)
class BlinkVideoSettings(SettingsGroup):
screenshots_directory = Setting(type=Path, default=Path('~/Downloads'))
class BlinkPresenceSettings(SettingsGroup):
current_state = Setting(type=PresenceState, default=PresenceState('Available'))
state_history = Setting(type=PresenceStateList, default=PresenceStateList())
......@@ -133,4 +138,5 @@ class BlinkSettings(SettingsObject):
chat_window = ChatWindowSettings
presence = BlinkPresenceSettings
screen_sharing = BlinkScreenSharingSettings
video = BlinkVideoSettings
......@@ -216,6 +216,19 @@ class AllContactsGroup(VirtualGroup):
notification.center.post_notification('VirtualGroupDidRemoveContact', sender=self, data=NotificationData(contact=contact))
class PreferredMedia(str):
@property
def stream_descriptions(self):
streams = set(self.split('+'))
if 'video' in streams:
streams.add('audio')
return [StreamDescription(stream) for stream in streams]
@property
def autoconnect(self):
return self != 'chat'
class BonjourNeighbourID(str):
pass
......@@ -297,7 +310,7 @@ class BonjourNeighbour(object):
self.hostname = hostname
self.uris = BonjourNeighbourURIList(uris)
self.presence = presence or BonjourPresence()
self.preferred_media = 'audio'
self.preferred_media = PreferredMedia('audio')
class BonjourNeighboursList(object):
......@@ -486,7 +499,7 @@ class GoogleContact(object):
self.icon = icon
self.uris = GoogleContactURIList(uris)
self.presence = GooglePresence()
self.preferred_media = 'audio'
self.preferred_media = PreferredMedia('audio')
def __reduce__(self):
return (self.__class__, (self.id, self.name, self.company, self.icon, self.uris))
......@@ -863,7 +876,7 @@ class DummyContact(object):
self.name = name
self.uris = DummyContactURIList(uris)
self.presence = DummyPresence()
self.preferred_media = 'audio'
self.preferred_media = PreferredMedia('audio')
def __reduce__(self):
return (self.__class__, (self.name, self.uris))
......@@ -1114,7 +1127,7 @@ class Contact(object):
@property
def preferred_media(self):
return self.settings.preferred_media
return PreferredMedia(self.settings.preferred_media)
@property
def icon(self):
......@@ -1274,7 +1287,7 @@ class ContactDetail(object):
@property
def preferred_media(self):
return self.settings.preferred_media
return PreferredMedia(self.settings.preferred_media)
@property
def icon(self):
......@@ -2986,6 +2999,7 @@ class ContactListView(QListView):
self.actions.delete_selection = QAction("Delete Selection", self, triggered=self._AH_DeleteSelection)
self.actions.undo_last_delete = QAction("Undo Last Delete", self, triggered=self._AH_UndoLastDelete)
self.actions.start_audio_call = QAction("Start Audio Call", self, triggered=self._AH_StartAudioCall)
self.actions.start_video_call = QAction("Start Video Call", self, triggered=self._AH_StartVideoCall)
self.actions.start_chat_session = QAction("Start Chat Session", self, triggered=self._AH_StartChatSession)
self.actions.send_sms = QAction("Send SMS", self, triggered=self._AH_SendSMS)
self.actions.send_files = QAction("Send File(s)...", self, triggered=self._AH_SendFiles)
......@@ -3057,6 +3071,7 @@ class ContactListView(QListView):
else:
contact = selected_items[0]
menu.addAction(self.actions.start_audio_call)
menu.addAction(self.actions.start_video_call)
menu.addAction(self.actions.start_chat_session)
#menu.addAction(self.actions.send_sms)
menu.addAction(self.actions.send_files)
......@@ -3072,6 +3087,7 @@ class ContactListView(QListView):
account_manager = AccountManager()
default_account = account_manager.default_account
self.actions.start_audio_call.setEnabled(default_account is not None)
self.actions.start_video_call.setEnabled(default_account is not None)
self.actions.start_chat_session.setEnabled(default_account is not None)
self.actions.send_sms.setEnabled(default_account is not None)
self.actions.send_files.setEnabled(default_account is not None)
......@@ -3091,7 +3107,7 @@ class ContactListView(QListView):
item = selected_indexes[0].data(Qt.UserRole) if len(selected_indexes)==1 else None
if isinstance(item, Contact):
session_manager = SessionManager()
session_manager.create_session(item, item.uri, [StreamDescription(media) for media in item.preferred_media.split('+')], connect=('audio' in item.preferred_media))
session_manager.create_session(item, item.uri, item.preferred_media.stream_descriptions, connect=item.preferred_media.autoconnect)
elif event.key() == Qt.Key_Space:
selected_indexes = self.selectionModel().selectedIndexes()
item = selected_indexes[0].data(Qt.UserRole) if len(selected_indexes)==1 else None
......@@ -3290,6 +3306,11 @@ class ContactListView(QListView):
session_manager = SessionManager()
session_manager.create_session(contact, contact.uri, [StreamDescription('audio')])
def _AH_StartVideoCall(self):
contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
session_manager = SessionManager()
session_manager.create_session(contact, contact.uri, [StreamDescription('audio'), StreamDescription('video')])
def _AH_StartChatSession(self):
contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
session_manager = SessionManager()
......@@ -3384,7 +3405,7 @@ class ContactListView(QListView):
item = index.data(Qt.UserRole)
if isinstance(item, Contact):
session_manager = SessionManager()
session_manager.create_session(item, item.uri, [StreamDescription(media) for media in item.preferred_media.split('+')], connect=('audio' in item.preferred_media))
session_manager.create_session(item, item.uri, item.preferred_media.stream_descriptions, connect=item.preferred_media.autoconnect)
class ContactSearchListView(QListView):
......@@ -3403,6 +3424,7 @@ class ContactSearchListView(QListView):
self.actions.delete_selection = QAction("Delete Selection", self, triggered=self._AH_DeleteSelection)
self.actions.undo_last_delete = QAction("Undo Last Delete", self, triggered=self._AH_UndoLastDelete)
self.actions.start_audio_call = QAction("Start Audio Call", self, triggered=self._AH_StartAudioCall)
self.actions.start_video_call = QAction("Start Video Call", self, triggered=self._AH_StartVideoCall)
self.actions.start_chat_session = QAction("Start Chat Session", self, triggered=self._AH_StartChatSession)
self.actions.send_sms = QAction("Send SMS", self, triggered=self._AH_SendSMS)
self.actions.send_files = QAction("Send File(s)...", self, triggered=self._AH_SendFiles)
......@@ -3460,6 +3482,7 @@ class ContactSearchListView(QListView):
else:
contact = selected_items[0]
menu.addAction(self.actions.start_audio_call)
menu.addAction(self.actions.start_video_call)
menu.addAction(self.actions.start_chat_session)
#menu.addAction(self.actions.send_sms)
menu.addAction(self.actions.send_files)
......@@ -3473,6 +3496,7 @@ class ContactSearchListView(QListView):
account_manager = AccountManager()
default_account = account_manager.default_account
self.actions.start_audio_call.setEnabled(default_account is not None)
self.actions.start_video_call.setEnabled(default_account is not None)
self.actions.start_chat_session.setEnabled(default_account is not None)
self.actions.send_sms.setEnabled(default_account is not None)
self.actions.send_files.setEnabled(default_account is not None)
......@@ -3499,7 +3523,7 @@ class ContactSearchListView(QListView):
item = selected_indexes[0].data(Qt.UserRole) if len(selected_indexes)==1 else None
if isinstance(item, Contact):
session_manager = SessionManager()
session_manager.create_session(item, item.uri, [StreamDescription(media) for media in item.preferred_media.split('+')], connect=('audio' in item.preferred_media))
session_manager.create_session(item, item.uri, item.preferred_media.stream_descriptions, connect=item.preferred_media.autoconnect)
elif event.key() == Qt.Key_Escape:
QApplication.instance().main_window.search_box.clear()
elif event.key() == Qt.Key_Space:
......@@ -3627,6 +3651,11 @@ class ContactSearchListView(QListView):
session_manager = SessionManager()
session_manager.create_session(contact, contact.uri, [StreamDescription('audio')])
def _AH_StartVideoCall(self):
contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
session_manager = SessionManager()
session_manager.create_session(contact, contact.uri, [StreamDescription('audio'), StreamDescription('video')])
def _AH_StartChatSession(self):
contact = self.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
session_manager = SessionManager()
......@@ -3667,7 +3696,7 @@ class ContactSearchListView(QListView):
item = index.data(Qt.UserRole)
if isinstance(item, Contact):
session_manager = SessionManager()
session_manager.create_session(item, item.uri, [StreamDescription(media) for media in item.preferred_media.split('+')], connect=('audio' in item.preferred_media))
session_manager.create_session(item, item.uri, item.preferred_media.stream_descriptions, connect=item.preferred_media.autoconnect)
class ContactDetailView(QListView):
......@@ -3695,6 +3724,7 @@ class ContactDetailView(QListView):
self.actions.edit_contact = QAction("Edit Contact", self, triggered=self._AH_EditContact)
self.actions.make_uri_default = QAction("Set Address As Default", self, triggered=self._AH_MakeURIDefault)
self.actions.start_audio_call = QAction("Start Audio Call", self, triggered=self._AH_StartAudioCall)
self.actions.start_video_call = QAction("Start Video Call", self, triggered=self._AH_StartVideoCall)
self.actions.start_chat_session = QAction("Start Chat Session", self, triggered=self._AH_StartChatSession)
self.actions.send_sms = QAction("Send SMS", self, triggered=self._AH_SendSMS)
self.actions.send_files = QAction("Send File(s)...", self, triggered=self._AH_SendFiles)
......@@ -3741,6 +3771,7 @@ class ContactDetailView(QListView):
menu = self.context_menu
menu.clear()
menu.addAction(self.actions.start_audio_call)
menu.addAction(self.actions.start_video_call)
menu.addAction(self.actions.start_chat_session)
#menu.addAction(self.actions.send_sms)
menu.addAction(self.actions.send_files)
......@@ -3753,6 +3784,7 @@ class ContactDetailView(QListView):
menu.addAction(self.actions.edit_contact)
menu.addAction(self.actions.delete_contact)
self.actions.start_audio_call.setEnabled(account_manager.default_account is not None and contact_has_uris)
self.actions.start_video_call.setEnabled(account_manager.default_account is not None and contact_has_uris)
self.actions.start_chat_session.setEnabled(account_manager.default_account is not None and contact_has_uris)
self.actions.send_sms.setEnabled(account_manager.default_account is not None and contact_has_uris)
self.actions.send_files.setEnabled(account_manager.default_account is not None and contact_has_uris)
......@@ -3767,7 +3799,15 @@ class ContactDetailView(QListView):
def keyPressEvent(self, event):
if event.key() in (Qt.Key_Enter, Qt.Key_Return):
self._AH_StartAudioCall()
contact = self.contact_list.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
selected_indexes = self.selectionModel().selectedIndexes()
item = selected_indexes[0].data(Qt.UserRole) if selected_indexes else None
if isinstance(item, ContactURI):
selected_uri = item.uri
else:
selected_uri = contact.uri
session_manager = SessionManager()
session_manager.create_session(contact, selected_uri, contact.preferred_media.stream_descriptions, connect=contact.preferred_media.autoconnect)
elif event.key() == Qt.Key_Escape:
self.animation.setDirection(QPropertyAnimation.Backward)
self.animation.start()
......@@ -3854,6 +3894,17 @@ class ContactDetailView(QListView):
session_manager = SessionManager()
session_manager.create_session(contact, selected_uri, [StreamDescription('audio')])
def _AH_StartVideoCall(self):
contact = self.contact_list.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
selected_indexes = self.selectionModel().selectedIndexes()
item = selected_indexes[0].data(Qt.UserRole) if selected_indexes else None
if isinstance(item, ContactURI):
selected_uri = item.uri
else:
selected_uri = contact.uri
session_manager = SessionManager()
session_manager.create_session(contact, selected_uri, [StreamDescription('audio'), StreamDescription('video')])
def _AH_StartChatSession(self):
contact = self.contact_list.selectionModel().selectedIndexes()[0].data(Qt.UserRole)
selected_indexes = self.selectionModel().selectedIndexes()
......@@ -3942,7 +3993,7 @@ class ContactDetailView(QListView):
else:
selected_uri = contact.uri
session_manager = SessionManager()
session_manager.create_session(contact, selected_uri, [StreamDescription(media) for media in contact.preferred_media.split('+')], connect=('audio' in contact.preferred_media))
session_manager.create_session(contact, selected_uri, contact.preferred_media.stream_descriptions, connect=contact.preferred_media.autoconnect)
# The contact editor dialog
......@@ -4260,8 +4311,9 @@ class ContactEditorDialog(base_class, ui_class):
def setupUi(self, contact_editor):
super(ContactEditorDialog, self).setupUi(contact_editor)
self.preferred_media.setItemData(0, 'audio')
self.preferred_media.setItemData(1, 'chat')
self.preferred_media.setItemData(2, 'audio+chat')
self.preferred_media.setItemData(1, 'video')
self.preferred_media.setItemData(2, 'chat')
self.preferred_media.setItemData(3, 'audio+chat')
self.addresses_table.verticalHeader().setDefaultSectionSize(URITypeComboBox().sizeHint().height())
def open_for_add(self, sip_address=u'', target_group=None):
......
......@@ -139,6 +139,7 @@ class MainWindow(base_class, ui_class):
self.add_contact_button.clicked.connect(self._SH_AddContactButtonClicked)
self.add_search_contact_button.clicked.connect(self._SH_AddContactButtonClicked)
self.audio_call_button.clicked.connect(self._SH_AudioCallButtonClicked)
self.video_call_button.clicked.connect(self._SH_VideoCallButtonClicked)
self.chat_session_button.clicked.connect(self._SH_ChatSessionButtonClicked)
self.back_to_contacts_button.clicked.connect(self.search_box.clear) # this can be set in designer -Dan
self.conference_button.makeConference.connect(self._SH_MakeConference)
......@@ -192,6 +193,7 @@ class MainWindow(base_class, ui_class):
self.output_devices_group.triggered.connect(self._AH_AudioOutputDeviceChanged)
self.input_devices_group.triggered.connect(self._AH_AudioInputDeviceChanged)
self.alert_devices_group.triggered.connect(self._AH_AudioAlertDeviceChanged)
self.video_devices_group.triggered.connect(self._AH_VideoDeviceChanged)
self.mute_action.triggered.connect(self._SH_MuteButtonClicked)
self.silent_action.triggered.connect(self._SH_SilentButtonClicked)
......@@ -216,6 +218,7 @@ class MainWindow(base_class, ui_class):
self.output_devices_group = QActionGroup(self)
self.input_devices_group = QActionGroup(self)
self.alert_devices_group = QActionGroup(self)
self.video_devices_group = QActionGroup(self)
self.request_screen_action = QAction('Request screen', self, triggered=self._AH_RequestScreenActionTriggered)
self.share_my_screen_action = QAction('Share my screen', self, triggered=self._AH_ShareMyScreenActionTriggered)
......@@ -256,6 +259,7 @@ class MainWindow(base_class, ui_class):
def enable_call_buttons(self, enabled):
self.audio_call_button.setEnabled(enabled)
self.video_call_button.setEnabled(enabled)
self.chat_session_button.setEnabled(enabled)
self.screen_sharing_button.setEnabled(enabled)
......@@ -310,6 +314,25 @@ class MainWindow(base_class, ui_class):
if settings.audio.alert_device == action.data():
action.setChecked(True)
def load_video_devices(self):
settings = SIPSimpleSettings()
action = QAction(u'System default', self.video_devices_group)
action.setData(u'system_default')
self.video_camera_menu.addAction(action)
self.video_camera_menu.addSeparator()
for device in SIPApplication.engine.video_devices:
action = QAction(device, self.video_devices_group)
action.setData(device)
self.video_camera_menu.addAction(action)
action = QAction(u'None', self.video_devices_group)
action.setData(None)
self.video_camera_menu.addAction(action)
for action in self.video_devices_group.actions():
action.setCheckable(True)
if settings.video.device == action.data():
action.setChecked(True)
def _AH_AccountActionTriggered(self, action, enabled):
account = action.data()
account.enabled = enabled
......@@ -330,6 +353,11 @@ class MainWindow(base_class, ui_class):
settings.audio.output_device = action.data()
settings.save()
def _AH_VideoDeviceChanged(self, action):
settings = SIPSimpleSettings()
settings.video.device = action.data()
settings.save()
def _AH_AutoAcceptChatActionTriggered(self, checked):
settings = SIPSimpleSettings()
settings.chat.auto_accept = checked
......@@ -495,6 +523,20 @@ class MainWindow(base_class, ui_class):
session_manager = SessionManager()
session_manager.create_session(contact, contact_uri, [StreamDescription('audio')])
def _SH_VideoCallButtonClicked(self):
list_view = self.contact_list if self.contacts_view.currentWidget() is self.contact_list_panel else self.search_list
if list_view.detail_view.isVisible():
list_view.detail_view._AH_StartVideoCall()
else:
selected_indexes = list_view.selectionModel().selectedIndexes()
if selected_indexes:
contact = selected_indexes[0].data(Qt.UserRole)
contact_uri = contact.uri
else:
contact, contact_uri = URIUtils.find_contact(self.search_box.text())
session_manager = SessionManager()
session_manager.create_session(contact, contact_uri, [StreamDescription('audio'), StreamDescription('video')])
def _SH_ChatSessionButtonClicked(self):
list_view = self.contact_list if self.contacts_view.currentWidget() is self.contact_list_panel else self.search_list
if list_view.detail_view.isVisible():
......@@ -720,6 +762,7 @@ class MainWindow(base_class, ui_class):
def _NH_SIPApplicationDidStart(self, notification):
self.load_audio_devices()
self.load_video_devices()
notification.center.add_observer(self, name='CFGSettingsObjectDidChange')
notification.center.add_observer(self, name='AudioDevicesDidChange')
blink_settings = BlinkSettings()
......@@ -769,6 +812,9 @@ class MainWindow(base_class, ui_class):
if 'audio.alert_device' in notification.data.modified:
action = (action for action in self.alert_devices_group.actions() if action.data() == settings.audio.alert_device).next()
action.setChecked(True)
if 'video.device' in notification.data.modified:
action = (action for action in self.video_devices_group.actions() if action.data() == settings.video.device).next()
action.setChecked(True)
if 'answering_machine.enabled' in notification.data.modified:
self.answering_machine_action.setChecked(settings.answering_machine.enabled)
if 'chat.auto_accept' in notification.data.modified:
......
......@@ -7,7 +7,7 @@ import os
import urlparse
from PyQt4 import uic
from PyQt4.QtCore import Qt, QRegExp
from PyQt4.QtCore import Qt, QEvent, QRegExp
from PyQt4.QtGui import QActionGroup, QButtonGroup, QFileDialog, QFont, QListView, QListWidgetItem, QMessageBox, QRegExpValidator, QSpinBox, QStyle, QStyleOptionComboBox, QStyledItemDelegate, QValidator
from application import log
......@@ -20,7 +20,7 @@ from zope.interface import implements
from sipsimple.account import AccountManager, BonjourAccount
from sipsimple.application import SIPApplication
from sipsimple.configuration import DefaultValue
from sipsimple.configuration.datatypes import MSRPRelayAddress, Path, PortRange, SIPProxyAddress
from sipsimple.configuration.datatypes import H264Profile, MSRPRelayAddress, Path, PortRange, SIPProxyAddress
from sipsimple.configuration.settings import SIPSimpleSettings
from sipsimple.threading import run_in_thread
......@@ -193,6 +193,8 @@ class PreferencesWindow(base_class, ui_class):
self.account_list.setModel(account_model)
self.delete_account_button.setEnabled(False)
self.camera_preview.installEventFilter(self)
notification_center = NotificationCenter()
notification_center.add_observer(self, name='SIPApplicationDidStart')
......@@ -217,6 +219,9 @@ class PreferencesWindow(base_class, ui_class):
self.account_audio_codecs_list.itemChanged.connect(self._SH_AccountAudioCodecsListItemChanged)
self.account_audio_codecs_list.model().rowsMoved.connect(self._SH_AccountAudioCodecsListModelRowsMoved)
self.reset_account_audio_codecs_button.clicked.connect(self._SH_ResetAudioCodecsButtonClicked)
self.account_video_codecs_list.itemChanged.connect(self._SH_AccountVideoCodecsListItemChanged)
self.account_video_codecs_list.model().rowsMoved.connect(self._SH_AccountVideoCodecsListModelRowsMoved)
self.reset_account_video_codecs_button.clicked.connect(self._SH_ResetVideoCodecsButtonClicked)
self.inband_dtmf_button.clicked.connect(self._SH_InbandDTMFButtonClicked)
self.srtp_encryption_button.activated[str].connect(self._SH_SRTPEncryptionButtonActivated)
......@@ -267,6 +272,17 @@ class PreferencesWindow(base_class, ui_class):
self.answer_delay.valueChanged[int].connect(self._SH_AnswerDelayValueChanged)
self.max_recording.valueChanged[int].connect(self._SH_MaxRecordingValueChanged)
# Video devices
self.video_camera_button.activated[int].connect(self._SH_VideoCameraButtonActivated)
self.video_resolution_button.activated[int].connect(self._SH_VideoResolutionButtonActivated)
self.video_framerate_button.activated[int].connect(self._SH_VideoFramerateButtonActivated)
# Video codecs
self.video_codecs_list.itemChanged.connect(self._SH_VideoCodecsListItemChanged)
self.video_codecs_list.model().rowsMoved.connect(self._SH_VideoCodecsListModelRowsMoved)
self.video_codec_bitrate_button.activated[int].connect(self._SH_VideoCodecBitrateButtonActivated)
self.h264_profile_button.activated[int].connect(self._SH_H264ProfileButtonActivated)
# Chat and SMS
self.style_view.sizeChanged.connect(self._SH_StyleViewSizeChanged)
self.style_view.page().mainFrame().contentsSizeChanged.connect(self._SH_StyleViewFrameContentsSizeChanged)
......@@ -326,6 +342,8 @@ class PreferencesWindow(base_class, ui_class):
def setupUi(self):
super(PreferencesWindow, self).setupUi(self)
# Audio
# Hide the tail_length slider as it is only useful for debugging -Dan
self.tail_length_label.hide()
self.tail_length_slider.hide()
......@@ -335,6 +353,31 @@ class PreferencesWindow(base_class, ui_class):
self.answering_machine_group_box.hide()
self.sms_replication_button.hide()
# Video
size_policy = self.camera_preview.sizePolicy()
size_policy.setHeightForWidth(True)
self.camera_preview.setSizePolicy(size_policy)
self.camera_preview.mirror = True
self.video_resolution_button.clear()
self.video_resolution_button.addItem('HD 720p', '1280x720')
self.video_resolution_button.addItem('VGA', '640x480')
self.h264_level_map = {'1280x720': '3.1', '640x480': '3.0'}
self.video_framerate_button.clear()
for rate in range(10, 31, 5):
self.video_framerate_button.addItem('%d fps' % rate, rate)
self.video_codec_bitrate_button.clear()
self.video_codec_bitrate_button.addItem('automatic', None)
for bitrate in (1.0, 2.0, 4.0):
self.video_codec_bitrate_button.addItem('%g Mbps' % bitrate, bitrate)
self.h264_profile_button.clear()
for profile in H264Profile.valid_values:
self.h264_profile_button.addItem(profile, profile)
# Chat
self.style_view.template = open(Resources.get('chat/template.html')).read()
self.style_button.clear()
......@@ -432,6 +475,15 @@ class PreferencesWindow(base_class, ui_class):
self.audio_sample_rate_button.setStyleSheet("""QComboBox { padding: 4px 4px 4px 4px; }""")
self.unavailable_message_button.setStyleSheet("""QComboBox { padding: 4px 4px 4px 4px; }""")
def eventFilter(self, watched, event):
if watched is self.camera_preview:
event_type = event.type()
if event_type == QEvent.Show:
self.camera_preview.producer = SIPApplication.video_device.producer
elif event_type == QEvent.Hide:
self.camera_preview.producer = None
return False
def closeEvent(self, event):
super(PreferencesWindow, self).closeEvent(event)
self.add_account_dialog.close()
......@@ -462,31 +514,30 @@ class PreferencesWindow(base_class, ui_class):
def _sync_defaults(self):
settings = SIPSimpleSettings()
account_manager = AccountManager()
default_order = SIPSimpleSettings.rtp.audio_codec_order.default
default_list = SIPSimpleSettings.rtp.audio_codec_list.default
if settings.rtp.audio_codec_order is not default_order:
if settings.rtp.audio_codec_order is not SIPSimpleSettings.rtp.audio_codec_order.default or settings.rtp.audio_codec_list is not SIPSimpleSettings.rtp.audio_codec_list.default:
# user has a non-default codec order, we need to sync with the new settings
added_codecs = set(default_order).difference(settings.rtp.audio_codec_order)
removed_codecs = set(settings.rtp.audio_codec_order).difference(default_order)
added_codecs = set(SIPSimpleSettings.rtp.audio_codec_order.default).difference(settings.rtp.audio_codec_order)
removed_codecs = set(settings.rtp.audio_codec_order).difference(SIPSimpleSettings.rtp.audio_codec_order.default)
if added_codecs:
settings.rtp.audio_codec_order = DefaultValue # reset codec order
settings.rtp.audio_codec_list = DefaultValue # reset codec list
settings.save()
elif removed_codecs:
codec_order = [codec for codec in settings.rtp.audio_codec_order if codec not in removed_codecs]
codec_list = [codec for codec in settings.rtp.audio_codec_list if codec not in removed_codecs]
if codec_order == default_order:
if codec_order == SIPSimpleSettings.rtp.audio_codec_order.default:
codec_order = DefaultValue
if codec_list == default_list:
if codec_list == SIPSimpleSettings.rtp.audio_codec_list.default:
codec_list = DefaultValue
settings.rtp.audio_codec_order = codec_order
settings.rtp.audio_codec_order = codec_list
settings.rtp.audio_codec_list = codec_list
settings.save()
for account in (account for account in account_manager.iter_accounts() if account.rtp.audio_codec_order is not None):
# user has a non-default codec order, we need to sync with the new settings
added_codecs = set(default_order).difference(account.rtp.audio_codec_order)
removed_codecs = set(account.rtp.audio_codec_order).difference(default_order)
added_codecs = set(SIPSimpleSettings.rtp.audio_codec_order.default).difference(account.rtp.audio_codec_order)
removed_codecs = set(account.rtp.audio_codec_order).difference(SIPSimpleSettings.rtp.audio_codec_order.default)
if added_codecs:
account.rtp.audio_codec_order = DefaultValue # reset codec order
account.rtp.audio_codec_list = DefaultValue # reset codec list
......@@ -494,11 +545,48 @@ class PreferencesWindow(base_class, ui_class):
elif removed_codecs:
codec_order = [codec for codec in account.rtp.audio_codec_order if codec not in removed_codecs]
codec_list = [codec for codec in account.rtp.audio_codec_list if codec not in removed_codecs]
if codec_order == default_order and codec_list == default_list:
if codec_order == SIPSimpleSettings.rtp.audio_codec_order.default and codec_list == SIPSimpleSettings.rtp.audio_codec_list.default:
codec_order = DefaultValue
codec_list = DefaultValue
account.rtp.audio_codec_order = codec_order
account.rtp.audio_codec_order = codec_list
account.rtp.audio_codec_list = codec_list
account.save()
if settings.rtp.video_codec_order is not SIPSimpleSettings.rtp.video_codec_order.default or settings.rtp.video_codec_list is not SIPSimpleSettings.rtp.video_codec_list.default:
# user has a non-default codec order, we need to sync with the new settings
added_codecs = set(SIPSimpleSettings.rtp.video_codec_order.default).difference(settings.rtp.video_codec_order)
removed_codecs = set(settings.rtp.video_codec_order).difference(SIPSimpleSettings.rtp.video_codec_order.default)
if added_codecs:
settings.rtp.video_codec_order = DefaultValue # reset codec order
settings.rtp.video_codec_list = DefaultValue # reset codec list
settings.save()
elif removed_codecs:
codec_order = [codec for codec in settings.rtp.video_codec_order if codec not in removed_codecs]
codec_list = [codec for codec in settings.rtp.video_codec_list if codec not in removed_codecs]
if codec_order == SIPSimpleSettings.rtp.video_codec_order.default:
codec_order = DefaultValue
if codec_list == SIPSimpleSettings.rtp.video_codec_list.default:
codec_list = DefaultValue
settings.rtp.video_codec_order = codec_order
settings.rtp.video_codec_list = codec_list
settings.save()
for account in (account for account in account_manager.iter_accounts() if account.rtp.video_codec_order is not None):
# user has a non-default codec order, we need to sync with the new settings
added_codecs = set(SIPSimpleSettings.rtp.video_codec_order.default).difference(account.rtp.video_codec_order)
removed_codecs = set(account.rtp.video_codec_order).difference(SIPSimpleSettings.rtp.video_codec_order.default)
if added_codecs:
account.rtp.video_codec_order = DefaultValue # reset codec order
account.rtp.video_codec_list = DefaultValue # reset codec list
account.save()
elif removed_codecs:
codec_order = [codec for codec in account.rtp.video_codec_order if codec not in removed_codecs]
codec_list = [codec for codec in account.rtp.video_codec_list if codec not in removed_codecs]
if codec_order == SIPSimpleSettings.rtp.video_codec_order.default and codec_list == SIPSimpleSettings.rtp.video_codec_list.default:
codec_order = DefaultValue
codec_list = DefaultValue
account.rtp.video_codec_order = codec_order
account.rtp.video_codec_list = codec_list
account.save()
def load_audio_devices(self):
......@@ -533,6 +621,20 @@ class PreferencesWindow(base_class, ui_class):
self.audio_alert_device_button.addItem(u'None', None)
self.audio_alert_device_button.setCurrentIndex(self.audio_alert_device_button.findData(settings.audio.alert_device))
def load_video_devices(self):
settings = SIPSimpleSettings()
class Separator: pass
self.video_camera_button.clear()
self.video_camera_button.addItem(u'System Default', 'system_default')
self.video_camera_button.insertSeparator(1)
self.video_camera_button.setItemData(1, Separator) # prevent the separator from being selected (must have different itemData than the None device)
for device in SIPApplication.engine.video_devices:
self.video_camera_button.addItem(device, device)
self.video_camera_button.addItem(u'None', None)
self.video_camera_button.setCurrentIndex(self.video_camera_button.findData(settings.video.device))
def load_settings(self):
"""Load settings from configuration into the UI controls"""
settings = SIPSimpleSettings()
......@@ -563,6 +665,22 @@ class PreferencesWindow(base_class, ui_class):
self.max_recording.setValue(settings.answering_machine.max_recording)
# TODO: load unavailable message -Dan
# Video devices
self.load_video_devices()
self.video_resolution_button.setCurrentIndex(self.video_resolution_button.findData(unicode(settings.video.resolution)))
self.video_framerate_button.setCurrentIndex(self.video_framerate_button.findData(settings.video.framerate))
# Video codecs
with blocked_qt_signals(self.video_codecs_list):
self.video_codecs_list.clear()
for codec in settings.rtp.video_codec_order:
item = QListWidgetItem(codec, self.video_codecs_list)
item.setCheckState(Qt.Checked if codec in settings.rtp.video_codec_list else Qt.Unchecked)
self.h264_profile_button.setCurrentIndex(self.h264_profile_button.findData(unicode(settings.video.h264.profile)))
self.video_codec_bitrate_button.setCurrentIndex(self.video_codec_bitrate_button.findData(settings.video.max_bitrate))
# Chat and SMS settings
style_index = self.style_button.findText(blink_settings.chat_window.style)
if style_index == -1:
......@@ -671,9 +789,17 @@ class PreferencesWindow(base_class, ui_class):
for codec in audio_codec_order:
item = QListWidgetItem(codec, self.account_audio_codecs_list)
item.setCheckState(Qt.Checked if codec in audio_codec_list else Qt.Unchecked)
with blocked_qt_signals(self.account_video_codecs_list):
self.account_video_codecs_list.clear()
video_codec_order = account.rtp.video_codec_order or settings.rtp.video_codec_order
video_codec_list = account.rtp.video_codec_list or settings.rtp.video_codec_list
for codec in video_codec_order:
item = QListWidgetItem(codec, self.account_video_codecs_list)
item.setCheckState(Qt.Checked if codec in video_codec_list else Qt.Unchecked)
self.reset_account_audio_codecs_button.setEnabled(account.rtp.audio_codec_order is not None)
self.reset_account_video_codecs_button.setEnabled(False)
self.account_video_codecs_list.setEnabled(False)
self.reset_account_video_codecs_button.setEnabled(account.rtp.video_codec_order is not None)
self.inband_dtmf_button.setChecked(account.rtp.inband_dtmf)
self.srtp_encryption_button.setCurrentIndex(self.srtp_encryption_button.findText(account.rtp.srtp_encryption))
......@@ -955,8 +1081,8 @@ class PreferencesWindow(base_class, ui_class):
def _SH_AccountAudioCodecsListModelRowsMoved(self, source_parent, source_start, source_end, dest_parent, dest_row):
account = self.selected_account
items = [self.account_audio_codecs_list.item(row) for row in xrange(self.account_audio_codecs_list.count())]
account.rtp.audio_codec_order = [item.text() for item in items]
account.rtp.audio_codec_list = [item.text() for item in items if item.checkState()==Qt.Checked]
account.rtp.audio_codec_order = [item.text() for item in items]
account.save()
def _SH_ResetAudioCodecsButtonClicked(self, checked):
......@@ -975,6 +1101,36 @@ class PreferencesWindow(base_class, ui_class):
account.rtp.audio_codec_order = DefaultValue
account.save()
def _SH_AccountVideoCodecsListItemChanged(self, item):
account = self.selected_account
items = [self.account_video_codecs_list.item(row) for row in xrange(self.account_video_codecs_list.count())]
account.rtp.video_codec_list = [item.text() for item in items if item.checkState()==Qt.Checked]
account.rtp.video_codec_order = [item.text() for item in items]
account.save()
def _SH_AccountVideoCodecsListModelRowsMoved(self, source_parent, source_start, source_end, dest_parent, dest_row):
account = self.selected_account
items = [self.account_video_codecs_list.item(row) for row in xrange(self.account_video_codecs_list.count())]
account.rtp.video_codec_list = [item.text() for item in items if item.checkState()==Qt.Checked]
account.rtp.video_codec_order = [item.text() for item in items]
account.save()
def _SH_ResetVideoCodecsButtonClicked(self, checked):
settings = SIPSimpleSettings()
account = self.selected_account
with blocked_qt_signals(self.account_video_codecs_list):
self.account_video_codecs_list.clear()
video_codec_order = settings.rtp.video_codec_order
video_codec_list = settings.rtp.video_codec_list
for codec in video_codec_order:
item = QListWidgetItem(codec, self.account_video_codecs_list)
item.setCheckState(Qt.Checked if codec in video_codec_list else Qt.Unchecked)
account.rtp.video_codec_list = DefaultValue
account.rtp.video_codec_order = DefaultValue
account.save()
def _SH_InbandDTMFButtonClicked(self, checked):
account = self.selected_account
account.rtp.inband_dtmf = checked
......@@ -1224,6 +1380,52 @@ class PreferencesWindow(base_class, ui_class):
settings.answering_machine.max_recording = value
settings.save()
# Video devices signal handlers
def _SH_VideoCameraButtonActivated(self, index):
device = self.video_camera_button.itemData(index)
settings = SIPSimpleSettings()
settings.video.device = device
settings.save()
def _SH_VideoResolutionButtonActivated(self, index):
resolution = self.video_resolution_button.itemData(index)
settings = SIPSimpleSettings()
settings.video.resolution = resolution
settings.video.h264.level = self.h264_level_map[resolution]
settings.save()
def _SH_VideoFramerateButtonActivated(self, index):
framerate = self.video_framerate_button.itemData(index)
settings = SIPSimpleSettings()
settings.video.framerate = framerate
settings.save()
# Video codecs signal handlers
def _SH_VideoCodecsListItemChanged(self, item):
settings = SIPSimpleSettings()
item_iterator = (self.video_codecs_list.item(row) for row in xrange(self.video_codecs_list.count()))
settings.rtp.video_codec_list = [item.text() for item in item_iterator if item.checkState()==Qt.Checked]
settings.save()
def _SH_VideoCodecsListModelRowsMoved(self, source_parent, source_start, source_end, dest_parent, dest_row):
settings = SIPSimpleSettings()
items = [self.video_codecs_list.item(row) for row in xrange(self.video_codecs_list.count())]
settings.rtp.video_codec_order = [item.text() for item in items]
settings.rtp.video_codec_list = [item.text() for item in items if item.checkState()==Qt.Checked]
settings.save()
def _SH_VideoCodecBitrateButtonActivated(self, index):
bitrate = self.video_codec_bitrate_button.itemData(index)
settings = SIPSimpleSettings()
settings.video.max_bitrate = bitrate
settings.save()
def _SH_H264ProfileButtonActivated(self, index):
profile = self.h264_profile_button.itemData(index)
settings = SIPSimpleSettings()
settings.video.h264.profile = profile
settings.save()
# Chat and SMS signal handlers
def _SH_StyleViewSizeChanged(self):
self._align_style_preview(scroll=True)
......@@ -1490,11 +1692,20 @@ class PreferencesWindow(base_class, ui_class):
self._sync_defaults()
self.load_settings()
notification.center.add_observer(self, name='AudioDevicesDidChange')
notification.center.add_observer(self, name='VideoDevicesDidChange')
notification.center.add_observer(self, name='VideoDeviceDidChangeCamera')
notification.center.add_observer(self, name='CFGSettingsObjectDidChange')
def _NH_AudioDevicesDidChange(self, notification):
self.load_audio_devices()
def _NH_VideoDevicesDidChange(self, notification):
self.load_video_devices()
def _NH_VideoDeviceDidChangeCamera(self, notification):
if self.camera_preview.isVisible():
self.camera_preview.producer = SIPApplication.video_device.producer
def _NH_CFGSettingsObjectDidChange(self, notification):
settings = SIPSimpleSettings()
blink_settings = BlinkSettings()
......@@ -1517,6 +1728,8 @@ class PreferencesWindow(base_class, ui_class):
self.auto_accept_chat_button.setChecked(settings.chat.auto_accept)
if 'sounds.play_message_alerts' in notification.data.modified:
self.chat_message_alert_button.setChecked(settings.sounds.play_message_alerts)
if 'video.device' in notification.data.modified:
self.video_camera_button.setCurrentIndex(self.video_camera_button.findData(settings.video.device))
elif notification.sender is self.selected_account is not None:
account = notification.sender
if 'enabled' in notification.data.modified:
......@@ -1526,6 +1739,8 @@ class PreferencesWindow(base_class, ui_class):
self.display_name_editor.setText(account.display_name or u'')
if 'rtp.audio_codec_list' in notification.data.modified:
self.reset_account_audio_codecs_button.setEnabled(account.rtp.audio_codec_list is not None)
if 'rtp.video_codec_list' in notification.data.modified:
self.reset_account_video_codecs_button.setEnabled(account.rtp.video_codec_list is not None)
del ui_class, base_class
......
......@@ -11,6 +11,7 @@ import re
import string
import sys
from abc import ABCMeta, abstractproperty
from collections import defaultdict, deque
from datetime import datetime, timedelta
from functools import partial
......@@ -54,6 +55,8 @@ from blink.widgets.util import ContextMenuActions, QtDynamicProperty
class RTPStreamInfo(object):
__metaclass__ = ABCMeta
dataset_size = 5000
average_interval = 10
......@@ -78,9 +81,9 @@ class RTPStreamInfo(object):
self._total_packets_discarded = 0
self._average_loss_queue = deque(maxlen=self.average_interval)
@property
@abstractproperty
def codec(self):
return '%s %dkHz' % (self.codec_name, self.sample_rate/1000) if self.codec_name else None
raise NotImplementedError
def _update(self, stream):
if stream is not None:
......@@ -114,6 +117,8 @@ class RTPStreamInfo(object):
class MSRPStreamInfo(object):
__metaclass__ = ABCMeta
def __init__(self):
self.local_address = None
self.remote_address = None
......@@ -137,6 +142,33 @@ class MSRPStreamInfo(object):
self.__init__()
class AudioStreamInfo(RTPStreamInfo):
@property
def codec(self):
return '{} {}kHz'.format(self.codec_name, self.sample_rate//1000) if self.codec_name else None
class VideoStreamInfo(RTPStreamInfo):
def __init__(self):
super(VideoStreamInfo, self).__init__()
self.framerate = None
@property
def codec(self):
return '{0.codec_name} {0.framerate:.3g}fps'.format(self) if self.codec_name else None
def _update(self, stream):
super(VideoStreamInfo, self)._update(stream)
try:
self.framerate = stream.producer.framerate
except AttributeError:
pass
class ChatStreamInfo(MSRPStreamInfo):
pass
class ScreenSharingStreamInfo(MSRPStreamInfo):
def __init__(self):
super(ScreenSharingStreamInfo, self).__init__()
......@@ -152,9 +184,9 @@ class StreamsInfo(object):
__slots__ = 'audio', 'video', 'chat', 'screen_sharing'
def __init__(self):
self.audio = RTPStreamInfo()
self.video = RTPStreamInfo()
self.chat = MSRPStreamInfo()
self.audio = AudioStreamInfo()
self.video = VideoStreamInfo()
self.chat = ChatStreamInfo()
self.screen_sharing = ScreenSharingStreamInfo()
def __getitem__(self, key):
......@@ -966,6 +998,10 @@ class BlinkSession(QObject):
self.recording = False
notification.center.post_notification('BlinkSessionDidChangeRecordingState', sender=self, data=NotificationData(recording=self.recording))
def _NH_VideoStreamRemoteFormatDidChange(self, notification):
self.info.streams.video._update(notification.sender)
notification.center.post_notification('BlinkSessionInfoUpdated', sender=self, data=NotificationData(elements={'media'}))
def _NH_BlinkContactDidChange(self, notification):
notification.center.post_notification('BlinkSessionContactDidChange', sender=self)
......@@ -2664,6 +2700,14 @@ class ChatSessionItem(object):
def chat_stream(self):
return self.blink_session.streams.get('chat')
@property
def audio_stream(self):
return self.blink_session.streams.get('audio')
@property
def video_stream(self):
return self.blink_session.streams.get('video')
def _get_remote_composing(self):
return self.__dict__['remote_composing']
......
# Copyright (C) 2014 AG Projects. See LICENSE for details.
#
from __future__ import division
__all__ = ['VideoSurface']
from PyQt4.QtCore import Qt, QMetaObject, QPoint, QRect, QTimer, pyqtSignal
from PyQt4.QtGui import QColor, QCursor, QIcon, QImage, QPainter, QPixmap, QTransform, QWidget
from application.python.types import MarkerType
from math import ceil
from operator import truediv
from sipsimple.core import FrameBufferVideoRenderer
from blink.resources import Resources
class Container(object): pass
class Cursors(Container): pass
class InteractionState(object):
def __init__(self):
self.moving = False
self.resizing = False
self.resize_corner = None
self.mouse_last_position = None
self.initial_geometry = None
@property
def active(self):
return self.moving or self.resizing
def clear(self):
self.__init__()
class VideoSurface(QWidget):
class TopLeftCorner: __metaclass__ = MarkerType
class TopRightCorner: __metaclass__ = MarkerType
class BottomLeftCorner: __metaclass__ = MarkerType
class BottomRightCorner: __metaclass__ = MarkerType
adjusted = pyqtSignal(QRect, QRect) # the widget was adjusted by the user (if interactive)
interactive = False # if the widget can be interacted with (moved, resized)
mirror = False # mirror the image horizontally
def __init__(self, parent=None, framerate=None):
super(VideoSurface, self).__init__(parent)
self.setAttribute(Qt.WA_OpaquePaintEvent, True)
self.setMouseTracking(True)
self.cursors = Cursors()
self.cursors.resize_top = QCursor(QIcon(Resources.get('icons/resize-top.svg')).pixmap(16), hotX=8, hotY=0)
self.cursors.resize_bottom = QCursor(QIcon(Resources.get('icons/resize-bottom.svg')).pixmap(16), hotX=8, hotY=16)
if framerate is not None:
self._clock = QTimer()
self._clock.setInterval(1000/framerate)
self._clock.timeout.connect(self.update)
else:
self._clock = None
self._interaction = InteractionState()
self._image = None
def __getattr__(self, name):
if name == '_renderer':
return self.__dict__.setdefault(name, FrameBufferVideoRenderer(self._handle_frame))
raise AttributeError("'%s' object has no attribute '%s'" % (self.__class__.__name__, name))
def _get_producer(self):
return self._renderer.producer
def _set_producer(self, producer):
if self._clock is not None:
self._clock.stop()
self._renderer.producer = producer
if self._clock is not None and producer is not None:
self._clock.start()
producer = property(_get_producer, _set_producer)
del _get_producer, _set_producer
@property
def aspect(self):
producer = self._renderer.producer
return truediv(*producer.framesize) if producer is not None else 16/9
def width_for_height(self, height):
return int(ceil(height * self.aspect))
def height_for_width(self, width):
return int(ceil(width / self.aspect))
def heightForWidth(self, width):
return int(ceil(width * 9/16))
def stop(self):
if self._clock is not None:
self._clock.stop()
self._renderer.close()
del self._renderer
def _handle_frame(self, frame):
self._image = QImage(frame.data, frame.width, frame.height, QImage.Format_ARGB32)
if self._clock is None:
QMetaObject.invokeMethod(self, 'update', Qt.QueuedConnection)
def paintEvent(self, event):
painter = QPainter(self)
painter.fillRect(self.rect(), QColor('#101010'))
image = self._image
if image is not None:
if self.height() < 240:
fast_scaler = QTransform()
scale = 297/image.height()
if self.mirror:
fast_scaler.scale(-scale, scale)
else:
fast_scaler.scale(scale, scale)
rect = event.rect()
painter.drawPixmap(rect, QPixmap.fromImage(image).transformed(fast_scaler).scaledToHeight(self.height(), Qt.SmoothTransformation), rect)
else:
transform = QTransform()
scale = min(self.width()/image.width(), self.height()/image.height())
if self.mirror:
transform.translate((self.width() + image.width()*scale)/2, (self.height() - image.height()*scale)/2)
transform.scale(-scale, scale)
else:
transform.translate((self.width() - image.width()*scale)/2, (self.height() - image.height()*scale)/2)
transform.scale(scale, scale)
inverse_transform, invertible = transform.inverted()
rect = inverse_transform.mapRect(event.rect()).adjusted(-1, -1, 1, 1).intersected(image.rect())
painter.setTransform(transform)
if self.height() > 400:
painter.drawPixmap(rect, QPixmap.fromImage(image), rect)
else:
painter.drawImage(rect, image, rect)
painter.end()
def mousePressEvent(self, event):
if self.interactive and event.button() == Qt.LeftButton and event.modifiers() == Qt.NoModifier:
if self.rect().adjusted(0, 10, 0, -10).contains(event.pos()):
self._interaction.moving = True
else:
self._interaction.resizing = True
event_x = event.x()
event_y = event.y()
half_width = self.width() / 2
half_height = self.height() / 2
if event_x < half_width and event_y < half_height:
self._interaction.resize_corner = self.TopLeftCorner
elif event_x >= half_width and event_y < half_height:
self._interaction.resize_corner = self.TopRightCorner
elif event_x < half_width and event_y >= half_height:
self._interaction.resize_corner = self.BottomLeftCorner
else:
self._interaction.resize_corner = self.BottomRightCorner
self._interaction.mouse_last_position = event.globalPos()
self._interaction.initial_geometry = self.geometry()
def mouseReleaseEvent(self, event):
if self._interaction.active and self._interaction.initial_geometry != self.geometry():
self.adjusted.emit(self._interaction.initial_geometry, self.geometry())
self._interaction.clear()
def mouseMoveEvent(self, event):
if self._interaction.moving:
mouse_position = event.globalPos()
offset = mouse_position - self._interaction.mouse_last_position
if self.parent() is not None:
parent_rect = self.parent().rect()
old_geometry = self.geometry()
new_geometry = old_geometry.translated(offset)
if new_geometry.left() < 0:
new_geometry.moveLeft(0)
if new_geometry.top() < 0:
new_geometry.moveTop(0)
if new_geometry.right() > parent_rect.right():
new_geometry.moveRight(parent_rect.right())
if new_geometry.bottom() > parent_rect.bottom():
new_geometry.moveBottom(parent_rect.bottom())
offset = new_geometry.topLeft() - old_geometry.topLeft()
self.move(self.pos() + offset)
self._interaction.mouse_last_position += offset
elif self._interaction.resizing:
mouse_position = event.globalPos()
delta_y = mouse_position.y() - self._interaction.mouse_last_position.y()
geometry = self.geometry()
if self._interaction.resize_corner is self.TopLeftCorner:
delta_x = -(self.width_for_height(geometry.height() - delta_y) - geometry.width())
geometry.setTopLeft(geometry.topLeft() + QPoint(delta_x, delta_y))
elif self._interaction.resize_corner is self.TopRightCorner:
delta_x = (self.width_for_height(geometry.height() - delta_y) - geometry.width())
geometry.setTopRight(geometry.topRight() + QPoint(delta_x, delta_y))
elif self._interaction.resize_corner is self.BottomLeftCorner:
delta_x = -(self.width_for_height(geometry.height() + delta_y) - geometry.width())
geometry.setBottomLeft(geometry.bottomLeft() + QPoint(delta_x, delta_y))
else:
delta_x = (self.width_for_height(geometry.height() + delta_y) - geometry.width())
geometry.setBottomRight(geometry.bottomRight() + QPoint(delta_x, delta_y))
if self.minimumHeight() <= geometry.height() <= self.maximumHeight() and (self.parent() is None or self.parent().rect().contains(geometry)):
self.setGeometry(geometry)
self._interaction.mouse_last_position = mouse_position
elif self.interactive:
mouse_position = event.pos()
topbar_rect = QRect(0, 0, self.width(), 10)
if self.rect().adjusted(0, 10, 0, -10).contains(mouse_position):
self.setCursor(Qt.ArrowCursor)
elif topbar_rect.contains(mouse_position):
self.setCursor(self.cursors.resize_top)
else:
self.setCursor(self.cursors.resize_bottom)
def closeEvent(self, event):
super(VideoSurface, self).closeEvent(event)
self.stop()
......@@ -667,6 +667,35 @@ padding: 2px;</string>
</property>
</widget>
</item>
<item>
<widget class="ToolButton" name="video_call_button">
<property name="minimumSize">
<size>
<width>29</width>
<height>24</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>29</width>
<height>24</height>
</size>
</property>
<property name="toolTip">
<string>Make a video call</string>
</property>
<property name="icon">
<iconset>
<normaloff>icons/camera.svg</normaloff>icons/camera.svg</iconset>
</property>
<property name="iconSize">
<size>
<width>16</width>
<height>16</height>
</size>
</property>
</widget>
</item>
<item>
<widget class="ToolButton" name="chat_session_button">
<property name="minimumSize">
......@@ -1029,6 +1058,15 @@ padding: 2px;</string>
<normaloff>icons/bell.svg</normaloff>icons/bell.svg</iconset>
</property>
</widget>
<widget class="QMenu" name="video_camera_menu">
<property name="title">
<string>Video &amp;Camera</string>
</property>
<property name="icon">
<iconset>
<normaloff>icons/camera.svg</normaloff>icons/camera.svg</iconset>
</property>
</widget>
<addaction name="redial_action"/>
<addaction name="join_conference_action"/>
<addaction name="voicemail_menu"/>
......@@ -1037,6 +1075,7 @@ padding: 2px;</string>
<addaction name="input_device_menu"/>
<addaction name="output_device_menu"/>
<addaction name="alert_device_menu"/>
<addaction name="video_camera_menu"/>
<addaction name="separator"/>
<addaction name="mute_action"/>
<addaction name="silent_action"/>
......
......@@ -1100,10 +1100,10 @@ QToolButton:pressed {
<property name="verticalSpacing">
<number>2</number>
</property>
<item row="0" column="0">
<widget class="QLabel" name="status_title_label">
<item row="9" column="0">
<widget class="QLabel" name="screen_title_label">
<property name="text">
<string>Status</string>
<string>Screen</string>
</property>
<property name="role" stdset="0">
<string notr="true">title</string>
......@@ -1126,6 +1126,16 @@ QToolButton:pressed {
</property>
</spacer>
</item>
<item row="0" column="0">
<widget class="QLabel" name="status_title_label">
<property name="text">
<string>Status</string>
</property>
<property name="role" stdset="0">
<string notr="true">title</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="DurationLabel" name="duration_value_label">
<property name="sizePolicy">
......@@ -1145,7 +1155,7 @@ QToolButton:pressed {
</property>
</widget>
</item>
<item row="11" column="0" colspan="2">
<item row="13" column="0" colspan="2">
<spacer name="info_panel_spacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
......@@ -1239,17 +1249,7 @@ QToolButton:pressed {
</layout>
</widget>
</item>
<item row="7" column="0">
<widget class="QLabel" name="screen_title_label">
<property name="text">
<string>Screen</string>
</property>
<property name="role" stdset="0">
<string notr="true">title</string>
</property>
</widget>
</item>
<item row="7" column="1">
<item row="9" column="1">
<widget class="QWidget" name="screen_value_widget" native="true">
<property name="role" stdset="0">
<string notr="true">value</string>
......@@ -1347,7 +1347,7 @@ QToolButton:pressed {
</property>
</widget>
</item>
<item row="9" column="0" colspan="2">
<item row="11" column="0" colspan="2">
<widget class="QWidget" name="packet_loss_widget" native="true">
<layout class="QVBoxLayout" name="packet_loss_layout">
<property name="spacing">
......@@ -1420,7 +1420,7 @@ QToolButton:pressed {
</property>
</widget>
</item>
<item row="10" column="0" colspan="2">
<item row="12" column="0" colspan="2">
<widget class="QWidget" name="traffic_widget" native="true">
<layout class="QVBoxLayout" name="speed_layout">
<property name="spacing">
......@@ -1636,7 +1636,7 @@ QToolButton:pressed {
</property>
</widget>
</item>
<item row="8" column="0" colspan="2">
<item row="10" column="0" colspan="2">
<widget class="QWidget" name="latency_widget" native="true">
<layout class="QVBoxLayout" name="latency_layout">
<property name="spacing">
......@@ -1816,6 +1816,94 @@ QToolButton:pressed {
</property>
</widget>
</item>
<item row="7" column="0">
<widget class="QLabel" name="video_title_label">
<property name="text">
<string>Video</string>
</property>
<property name="role" stdset="0">
<string notr="true">title</string>
</property>
</widget>
</item>
<item row="7" column="1">
<widget class="QWidget" name="video_value_widget" native="true">
<property name="role" stdset="0">
<string notr="true">value</string>
</property>
<layout class="QHBoxLayout" name="video_value_layout">
<property name="spacing">
<number>2</number>
</property>
<property name="leftMargin">
<number>0</number>
</property>
<property name="topMargin">
<number>0</number>
</property>
<property name="rightMargin">
<number>4</number>
</property>
<property name="bottomMargin">
<number>0</number>
</property>
<item>
<widget class="ElidedLabel" name="video_value_label">
<property name="sizePolicy">
<sizepolicy hsizetype="Ignored" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>H264 25fps</string>
</property>
<property name="indent">
<number>0</number>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="video_connection_label">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string/>
</property>
<property name="pixmap">
<pixmap>icons/connection-direct.svg</pixmap>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="video_encryption_label">
<property name="sizePolicy">
<sizepolicy hsizetype="Fixed" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string/>
</property>
<property name="pixmap">
<pixmap>icons/lock-grey-12.svg</pixmap>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
</item>
......@@ -2044,7 +2132,7 @@ QToolButton:hover {
<number>0</number>
</property>
<item>
<widget class="ChatWebView" name="chat_view">
<widget class="ChatWebView" name="chat_view" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
<horstretch>0</horstretch>
......@@ -2060,7 +2148,7 @@ QToolButton:hover {
<property name="focusPolicy">
<enum>Qt::NoFocus</enum>
</property>
<property name="url">
<property name="url" stdset="0">
<url>
<string>about:blank</string>
</url>
......
......@@ -106,6 +106,11 @@
<string>Start voice calls by default</string>
</property>
</item>
<item>
<property name="text">
<string>Start video calls by default</string>
</property>
</item>
<item>
<property name="text">
<string>Start chat sessions by default</string>
......
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
sodipodi:docname="attach.svg"
inkscape:version="0.48.5 r10040"
xml:space="preserve"
width="96px"
viewBox="0 0 96 96"
version="1.1"
id="bigger"
height="96px"
enable-background="new 0 0 96 96"><metadata
id="metadata11"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
id="defs9"><linearGradient
id="linearGradient3983"><stop
style="stop-color:#00ff00;stop-opacity:1;"
offset="0"
id="stop3985" /><stop
style="stop-color:#00d000;stop-opacity:1;"
offset="1"
id="stop3987" /></linearGradient><linearGradient
id="linearGradient12315"><stop
style="stop-color:#ff0000;stop-opacity:1;"
offset="0"
id="stop12317" /><stop
style="stop-color:#d30000;stop-opacity:1;"
offset="1"
id="stop12319" /></linearGradient><linearGradient
id="linearGradient11765"><stop
style="stop-color:#ff0000;stop-opacity:1;"
offset="0"
id="stop11767" /><stop
style="stop-color:#d00000;stop-opacity:1;"
offset="1"
id="stop11769" /></linearGradient><linearGradient
id="linearGradient11102"><stop
style="stop-color:#000000;stop-opacity:0.43137255;"
offset="0"
id="stop11104" /><stop
style="stop-color:#464646;stop-opacity:1;"
offset="1"
id="stop11106" /></linearGradient><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient11765"
id="radialGradient11771"
cx="12"
cy="18"
fx="12"
fy="18"
r="29.642775"
gradientUnits="userSpaceOnUse" /><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient12315"
id="radialGradient12321"
cx="48"
cy="48"
fx="48"
fy="48"
r="44"
gradientUnits="userSpaceOnUse" /><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient3983"
id="radialGradient3989"
cx="12"
cy="18"
fx="12"
fy="18"
r="29.642775"
gradientUnits="userSpaceOnUse" /></defs><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1623"
inkscape:window-height="1075"
id="namedview7"
showgrid="true"
inkscape:zoom="9.0104167"
inkscape:cx="48"
inkscape:cy="48"
inkscape:window-x="90"
inkscape:window-y="9"
inkscape:window-maximized="0"
inkscape:current-layer="g3113"
showguides="true"
inkscape:guide-bbox="true"><inkscape:grid
type="xygrid"
id="grid2988"
empspacing="5"
visible="true"
enabled="true"
snapvisiblegridlinesonly="true" /></sodipodi:namedview><g
inkscape:label="Attach"
id="g3113"
inkscape:groupmode="layer"
style="display:inline"><rect
ry="3.8571427"
y="-48"
x="-84"
height="36"
width="36"
id="rect3117"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:1.6"
transform="scale(-1,-1)" /><path
inkscape:connector-curvature="0"
id="path3119"
d="M 48,47.999998 C 16,80 16,80 16,80"
style="fill:none;stroke:#000000;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" /><path
inkscape:connector-curvature="0"
id="path3121"
d="m 12,56 c 0,28 0,28 0,28 l 28,0"
style="fill:none;stroke:#000000;stroke-width:7.99999952;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" /></g></svg>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="16"
height="16"
id="svg4429"
version="1.1"
inkscape:version="0.48.5 r10040"
sodipodi:docname="camera.svg">
<defs
id="defs4431" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="53.8125"
inkscape:cx="8"
inkscape:cy="8"
inkscape:current-layer="layer1"
showgrid="true"
inkscape:grid-bbox="true"
inkscape:document-units="px"
showguides="true"
inkscape:guide-bbox="true"
inkscape:window-width="1665"
inkscape:window-height="1071"
inkscape:window-x="67"
inkscape:window-y="32"
inkscape:window-maximized="0">
<inkscape:grid
type="xygrid"
id="grid4437"
empspacing="10"
visible="true"
enabled="true"
snapvisiblegridlinesonly="true"
spacingx="0.5px"
spacingy="0.5px" />
</sodipodi:namedview>
<metadata
id="metadata4434">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="layer1"
inkscape:label="Camera"
inkscape:groupmode="layer">
<path
style="fill:#545454;fill-opacity:1;stroke:#333333;stroke-width:0.99999994000000003;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="m 13.5,3.5 c 0,2.4571428 0,6.542857 0,9 -1.222222,-1.228572 -2.777778,-2.7714284 -4,-4 l 0,3 -7,0 c 0,-1.638095 0,-5.3619048 0,-7 l 7,0 0,3 c 0,0 2.777778,-2.7714286 4,-4 z"
id="path4967"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccc" />
</g>
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
sodipodi:docname="detach.svg"
inkscape:version="0.48.5 r10040"
xml:space="preserve"
width="96px"
viewBox="0 0 96 96"
version="1.1"
id="bigger"
height="96px"
enable-background="new 0 0 96 96"><metadata
id="metadata11"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
id="defs9"><linearGradient
id="linearGradient3983"><stop
style="stop-color:#00ff00;stop-opacity:1;"
offset="0"
id="stop3985" /><stop
style="stop-color:#00d000;stop-opacity:1;"
offset="1"
id="stop3987" /></linearGradient><linearGradient
id="linearGradient12315"><stop
style="stop-color:#ff0000;stop-opacity:1;"
offset="0"
id="stop12317" /><stop
style="stop-color:#d30000;stop-opacity:1;"
offset="1"
id="stop12319" /></linearGradient><linearGradient
id="linearGradient11765"><stop
style="stop-color:#ff0000;stop-opacity:1;"
offset="0"
id="stop11767" /><stop
style="stop-color:#d00000;stop-opacity:1;"
offset="1"
id="stop11769" /></linearGradient><linearGradient
id="linearGradient11102"><stop
style="stop-color:#000000;stop-opacity:0.43137255;"
offset="0"
id="stop11104" /><stop
style="stop-color:#464646;stop-opacity:1;"
offset="1"
id="stop11106" /></linearGradient><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient11765"
id="radialGradient11771"
cx="12"
cy="18"
fx="12"
fy="18"
r="29.642775"
gradientUnits="userSpaceOnUse" /><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient12315"
id="radialGradient12321"
cx="48"
cy="48"
fx="48"
fy="48"
r="44"
gradientUnits="userSpaceOnUse" /><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient3983"
id="radialGradient3989"
cx="12"
cy="18"
fx="12"
fy="18"
r="29.642775"
gradientUnits="userSpaceOnUse" /></defs><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1623"
inkscape:window-height="1075"
id="namedview7"
showgrid="true"
inkscape:zoom="9.0104167"
inkscape:cx="48"
inkscape:cy="48"
inkscape:window-x="114"
inkscape:window-y="10"
inkscape:window-maximized="0"
inkscape:current-layer="g3123"
showguides="true"
inkscape:guide-bbox="true"><inkscape:grid
type="xygrid"
id="grid2988"
empspacing="5"
visible="true"
enabled="true"
snapvisiblegridlinesonly="true" /></sodipodi:namedview><g
style="display:inline"
inkscape:groupmode="layer"
id="g3123"
inkscape:label="Detach"><rect
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:7.99999857;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:1.6"
id="rect3127"
width="36"
height="36"
x="-84"
y="-48"
ry="3.8571432"
transform="scale(-1,-1)" /><path
style="fill:none;stroke:#000000;stroke-width:7.99999952;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
d="M 12,83.999996 C 40.000001,55.999997 40.000001,55.999997 40.000001,55.999997"
id="path3129"
inkscape:connector-curvature="0" /><path
style="fill:none;stroke:#000000;stroke-width:8.00000095;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
d="m 44.000001,80 c 0,-28 0,-28 0,-28 L 16,52"
id="path3131"
inkscape:connector-curvature="0" /></g></svg>
\ No newline at end of file
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="16"
height="16"
id="svg2"
version="1.1"
inkscape:version="0.48.5 r10040"
sodipodi:docname="resize-bottom.svg">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#969696"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.19607843"
inkscape:pageshadow="2"
inkscape:zoom="55.4375"
inkscape:cx="8"
inkscape:cy="8"
inkscape:document-units="px"
inkscape:current-layer="g4151"
showgrid="true"
inkscape:window-width="1688"
inkscape:window-height="1097"
inkscape:window-x="80"
inkscape:window-y="0"
inkscape:window-maximized="0">
<inkscape:grid
empspacing="10"
visible="true"
enabled="true"
snapvisiblegridlinesonly="true"
spacingx="0.5px"
spacingy="0.5px"
type="xygrid"
id="grid2985" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
style="display:inline"
inkscape:label="Resize bottom"
inkscape:groupmode="layer"
id="g4151"
transform="translate(0,-1036.3622)">
<path
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 8.0002178,1047.5618 c 0,-9.3996 0,-9.3996 0,-9.3996"
id="path4153"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path4155"
d="m 12.20022,1044.1622 c -4.2000022,4.4 -4.2000022,4.4 -4.2000022,4.4 l -4.2000029,-4.4"
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
inkscape:connector-curvature="0"
id="path4157"
d="m 14.000218,1050.3623 -12.0004361,0"
style="fill:none;stroke:#ffffff;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
inkscape:connector-curvature="0"
id="path4159"
d="m 8.0002178,1047.5618 c 0,-9.3996 0,-9.3996 0,-9.3996"
style="fill:none;stroke:#000000;stroke-width:1.59999442;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
style="fill:none;stroke:#000000;stroke-width:1.59999478;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 12.200221,1044.1622 c -4.2000032,4.4 -4.2000032,4.4 -4.2000032,4.4 l -4.2000029,-4.4"
id="path4161"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#000000;stroke-width:1.99999964;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 14.000218,1050.3623 -12.0004361,0"
id="path4163"
inkscape:connector-curvature="0" />
</g>
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="16"
height="16"
id="svg2"
version="1.1"
inkscape:version="0.48.5 r10040"
sodipodi:docname="resize-top.svg">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#969696"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.19607843"
inkscape:pageshadow="2"
inkscape:zoom="55.4375"
inkscape:cx="8"
inkscape:cy="8"
inkscape:document-units="px"
inkscape:current-layer="g3154"
showgrid="true"
inkscape:window-width="1688"
inkscape:window-height="1097"
inkscape:window-x="80"
inkscape:window-y="0"
inkscape:window-maximized="0">
<inkscape:grid
empspacing="10"
visible="true"
enabled="true"
snapvisiblegridlinesonly="true"
spacingx="0.5px"
spacingy="0.5px"
type="xygrid"
id="grid2985" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
transform="translate(0,-1036.3622)"
id="g3154"
inkscape:groupmode="layer"
inkscape:label="Resize top thick"
style="display:inline">
<path
inkscape:connector-curvature="0"
id="path4068-0"
d="m 7.9997821,1041.1627 c 0,9.3996 0,9.3996 0,9.3996"
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 3.7997795,1044.5623 c 4.2000026,-4.4 4.2000026,-4.4 4.2000026,-4.4 l 4.2000029,4.4"
id="path4070-1"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#ffffff;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 1.9997819,1038.3622 12.0004361,0"
id="path4072-4"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#000000;stroke-width:1.59999442;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 7.9997821,1041.1627 c 0,9.3996 0,9.3996 0,9.3996"
id="path4036-2"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path4038-2"
d="m 3.7997791,1044.5623 c 4.200003,-4.4 4.200003,-4.4 4.200003,-4.4 l 4.2000029,4.4"
style="fill:none;stroke:#000000;stroke-width:1.59999478;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
inkscape:connector-curvature="0"
id="path4040-3"
d="m 1.9997819,1038.3622 12.0004361,0"
style="fill:none;stroke:#000000;stroke-width:1.99999964;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
</g>
</svg>
......@@ -9,7 +9,7 @@
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
sodipodi:docname="actions.svg"
inkscape:version="0.48.4 r9939"
inkscape:version="0.48.5 r10040"
xml:space="preserve"
width="96px"
viewBox="0 0 96 96"
......@@ -318,6 +318,70 @@
d="M 15.999999,79.999996 C 39.999998,56 39.999998,56 39.999998,56"
style="fill:none;stroke:#000000;stroke-width:7.99999952;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" /></g><g
inkscape:groupmode="layer"
id="g3096"
inkscape:label="Roll down"
style="display:none"><path
inkscape:connector-curvature="0"
id="path3102"
d="m 48.000004,78.000001 c 0,-61.000109 0,-61.000109 0,-61.000109"
style="fill:none;stroke:#000000;stroke-width:7.99999714;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" /><path
style="fill:none;stroke:#000000;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
d="M 66.000001,64.000001 C 48,84.000001 48,84.000001 48,84.000001 l -18.000001,-20"
id="path3104"
inkscape:connector-curvature="0" /><path
style="fill:none;stroke:#000000;stroke-width:12.00000095;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
d="m 13.999872,14 68.000256,0"
id="path3885"
inkscape:connector-curvature="0" /></g><g
style="display:none"
inkscape:label="Roll up"
id="g3910"
inkscape:groupmode="layer"><path
style="fill:none;stroke:#000000;stroke-width:7.99999762;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
d="m 48,27.99993 c 0,56.000071 0,56.000071 0,56.000071"
id="path3912"
inkscape:connector-curvature="0" /><path
inkscape:connector-curvature="0"
id="path3914"
d="M 29.999999,43.999929 C 48,23.999929 48,23.999929 48,23.999929 l 18.000001,20"
style="fill:none;stroke:#000000;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" /><path
inkscape:connector-curvature="0"
id="path3916"
d="m 13.999872,14 68.000256,0"
style="fill:none;stroke:#000000;stroke-width:12.00000095;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" /></g><g
inkscape:groupmode="layer"
id="g3104"
inkscape:label="Slide right"
style="display:none"><path
inkscape:connector-curvature="0"
id="path3106"
d="M 68.00007,48 C 11.999999,48 11.999999,48 11.999999,48"
style="fill:none;stroke:#000000;stroke-width:7.99999762;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" /><path
style="fill:none;stroke:#000000;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
d="m 52.000071,29.999999 c 20,18.000001 20,18.000001 20,18.000001 l -20,18.000001"
id="path3108"
inkscape:connector-curvature="0" /><path
style="fill:none;stroke:#000000;stroke-width:12.00000095;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
d="m 82,13.999872 0,68.000256"
id="path3110"
inkscape:connector-curvature="0" /></g><g
style="display:none"
inkscape:label="Slide left"
id="g3112"
inkscape:groupmode="layer"><path
style="fill:none;stroke:#000000;stroke-width:7.99999762;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
d="m 27.999931,48 c 56.000071,0 56.000071,0 56.000071,0"
id="path3114"
inkscape:connector-curvature="0" /><path
inkscape:connector-curvature="0"
id="path3116"
d="M 43.99993,66.000001 C 23.99993,48 23.99993,48 23.99993,48 l 20,-18.000001"
style="fill:none;stroke:#000000;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" /><path
inkscape:connector-curvature="0"
id="path3118"
d="m 14.000001,82.000128 0,-68.000256"
style="fill:none;stroke:#000000;stroke-width:12.00000095;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" /></g><g
inkscape:groupmode="layer"
id="layer1"
inkscape:label="Rectangle expand"
style="display:none"><path
......@@ -439,6 +503,46 @@
d="m 55,23 c 0,18 0,18 0,18 l 18,0"
id="path5361"
inkscape:connector-curvature="0" /></g><g
style="display:none"
inkscape:groupmode="layer"
id="g3123"
inkscape:label="Detach"><rect
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:7.99999857;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:1.6"
id="rect3127"
width="36"
height="36"
x="-84"
y="-48"
ry="3.8571432"
transform="scale(-1,-1)" /><path
style="fill:none;stroke:#000000;stroke-width:7.99999952;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
d="M 12,83.999996 C 40.000001,55.999997 40.000001,55.999997 40.000001,55.999997"
id="path3129"
inkscape:connector-curvature="0" /><path
style="fill:none;stroke:#000000;stroke-width:8.00000095;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
d="m 44.000001,80 c 0,-28 0,-28 0,-28 L 16,52"
id="path3131"
inkscape:connector-curvature="0" /></g><g
inkscape:label="Attach"
id="g3113"
inkscape:groupmode="layer"
style="display:none"><rect
ry="3.8571427"
y="-48"
x="-84"
height="36"
width="36"
id="rect3117"
style="fill:#000000;fill-opacity:1;stroke:#000000;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;stroke-dashoffset:1.6"
transform="scale(-1,-1)" /><path
inkscape:connector-curvature="0"
id="path3119"
d="M 48,47.999998 C 16,80 16,80 16,80"
style="fill:none;stroke:#000000;stroke-width:8;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" /><path
inkscape:connector-curvature="0"
id="path3121"
d="m 12,56 c 0,28 0,28 0,28 l 28,0"
style="fill:none;stroke:#000000;stroke-width:7.99999952;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none" /></g><g
inkscape:groupmode="layer"
id="g8714"
inkscape:label="Screenshot"
......
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="16"
height="16"
id="svg4429"
version="1.1"
inkscape:version="0.48.5 r10040"
sodipodi:docname="camera.svg">
<defs
id="defs4431" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="53.8125"
inkscape:cx="8"
inkscape:cy="8"
inkscape:current-layer="layer1"
showgrid="true"
inkscape:grid-bbox="true"
inkscape:document-units="px"
showguides="true"
inkscape:guide-bbox="true"
inkscape:window-width="1665"
inkscape:window-height="1071"
inkscape:window-x="67"
inkscape:window-y="32"
inkscape:window-maximized="0">
<inkscape:grid
type="xygrid"
id="grid4437"
empspacing="10"
visible="true"
enabled="true"
snapvisiblegridlinesonly="true"
spacingx="0.5px"
spacingy="0.5px" />
<sodipodi:guide
orientation="0,1"
position="11.5,13"
id="guide4947" />
<sodipodi:guide
orientation="1,0"
position="2,10"
id="guide4949" />
<sodipodi:guide
orientation="1,0"
position="14,11.5"
id="guide4951" />
<sodipodi:guide
orientation="0,1"
position="11,3"
id="guide4953" />
<sodipodi:guide
orientation="0,1"
position="1.5,8"
id="guide4955" />
<sodipodi:guide
orientation="1,0"
position="10,12.5"
id="guide4957" />
<sodipodi:guide
orientation="0,1"
position="11,12"
id="guide4959" />
<sodipodi:guide
orientation="0,1"
position="10.5,4"
id="guide4961" />
</sodipodi:namedview>
<metadata
id="metadata4434">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
id="layer1"
inkscape:label="Camera"
inkscape:groupmode="layer">
<path
style="fill:#545454;fill-opacity:1;stroke:#333333;stroke-width:0.99999994000000003;stroke-linecap:round;stroke-linejoin:round;stroke-opacity:1"
d="m 13.5,3.5 c 0,2.4571428 0,6.542857 0,9 -1.222222,-1.228572 -2.777778,-2.7714284 -4,-4 l 0,3 -7,0 c 0,-1.638095 0,-5.3619048 0,-7 l 7,0 0,3 c 0,0 2.777778,-2.7714286 4,-4 z"
id="path4967"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccccccc" />
</g>
</svg>
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="16"
height="16"
id="svg2"
version="1.1"
inkscape:version="0.48.5 r10040"
sodipodi:docname="cursors.svg">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#969696"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.19607843"
inkscape:pageshadow="2"
inkscape:zoom="55.4375"
inkscape:cx="8"
inkscape:cy="8"
inkscape:document-units="px"
inkscape:current-layer="g3154"
showgrid="true"
inkscape:window-width="1688"
inkscape:window-height="1097"
inkscape:window-x="80"
inkscape:window-y="0"
inkscape:window-maximized="0">
<inkscape:grid
empspacing="10"
visible="true"
enabled="true"
snapvisiblegridlinesonly="true"
spacingx="0.5px"
spacingy="0.5px"
type="xygrid"
id="grid2985" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
style="display:none"
inkscape:label="Resize right"
inkscape:groupmode="layer"
id="g4179"
transform="translate(0,-1036.3622)">
<path
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 11.19955,1044.362 c -9.3996,0 -9.3996,0 -9.3996,0"
id="path4181"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path4183"
d="m 7.79995,1040.162 c 4.4,4.2 4.4,4.2 4.4,4.2 l -4.4,4.2"
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
inkscape:connector-curvature="0"
id="path4185"
d="m 14.00005,1038.362 0,12.0005"
style="fill:none;stroke:#ffffff;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
inkscape:connector-curvature="0"
id="path4187"
d="m 11.19955,1044.362 c -9.3996,0 -9.3996,0 -9.3996,0"
style="fill:none;stroke:#000000;stroke-width:1.59999442;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
style="fill:none;stroke:#000000;stroke-width:1.59999478;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 7.79995,1040.162 c 4.4,4.2 4.4,4.2 4.4,4.2 l -4.4,4.2"
id="path4189"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#000000;stroke-width:1.99999964;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 14.00005,1038.362 0,12.0005"
id="path4191"
inkscape:connector-curvature="0" />
</g>
<g
transform="translate(0,-1036.3622)"
id="g4165"
inkscape:groupmode="layer"
inkscape:label="Resize left"
style="display:none">
<path
inkscape:connector-curvature="0"
id="path4167"
d="m 4.80045,1044.3625 c 9.3996,0 9.3996,0 9.3996,0"
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 8.20005,1048.5625 c -4.4,-4.2 -4.4,-4.2 -4.4,-4.2 l 4.4,-4.2"
id="path4169"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#ffffff;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 1.99995,1050.3625 0,-12.0005"
id="path4171"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#000000;stroke-width:1.59999442;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 4.80045,1044.3625 c 9.3996,0 9.3996,0 9.3996,0"
id="path4173"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path4175"
d="m 8.20005,1048.5625 c -4.4,-4.2 -4.4,-4.2 -4.4,-4.2 l 4.4,-4.2"
style="fill:none;stroke:#000000;stroke-width:1.59999478;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
inkscape:connector-curvature="0"
id="path4177"
d="m 1.99995,1050.3625 0,-12.0005"
style="fill:none;stroke:#000000;stroke-width:1.99999964;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
</g>
<g
style="display:none"
inkscape:label="Resize bottom"
inkscape:groupmode="layer"
id="g4151"
transform="translate(0,-1036.3622)">
<path
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 8.0002178,1047.5618 c 0,-9.3996 0,-9.3996 0,-9.3996"
id="path4153"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path4155"
d="m 12.20022,1044.1622 c -4.2000022,4.4 -4.2000022,4.4 -4.2000022,4.4 l -4.2000029,-4.4"
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
inkscape:connector-curvature="0"
id="path4157"
d="m 14.000218,1050.3623 -12.0004361,0"
style="fill:none;stroke:#ffffff;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
inkscape:connector-curvature="0"
id="path4159"
d="m 8.0002178,1047.5618 c 0,-9.3996 0,-9.3996 0,-9.3996"
style="fill:none;stroke:#000000;stroke-width:1.59999442;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
style="fill:none;stroke:#000000;stroke-width:1.59999478;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 12.200221,1044.1622 c -4.2000032,4.4 -4.2000032,4.4 -4.2000032,4.4 l -4.2000029,-4.4"
id="path4161"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#000000;stroke-width:1.99999964;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 14.000218,1050.3623 -12.0004361,0"
id="path4163"
inkscape:connector-curvature="0" />
</g>
<g
transform="translate(0,-1036.3622)"
id="g3154"
inkscape:groupmode="layer"
inkscape:label="Resize top"
style="display:inline">
<path
inkscape:connector-curvature="0"
id="path4068-0"
d="m 7.9997821,1041.1627 c 0,9.3996 0,9.3996 0,9.3996"
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 3.7997795,1044.5623 c 4.2000026,-4.4 4.2000026,-4.4 4.2000026,-4.4 l 4.2000029,4.4"
id="path4070-1"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#ffffff;stroke-width:4;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 1.9997819,1038.3622 12.0004361,0"
id="path4072-4"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#000000;stroke-width:1.59999442;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 7.9997821,1041.1627 c 0,9.3996 0,9.3996 0,9.3996"
id="path4036-2"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path4038-2"
d="m 3.7997791,1044.5623 c 4.200003,-4.4 4.200003,-4.4 4.200003,-4.4 l 4.2000029,4.4"
style="fill:none;stroke:#000000;stroke-width:1.59999478;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
inkscape:connector-curvature="0"
id="path4040-3"
d="m 1.9997819,1038.3622 12.0004361,0"
style="fill:none;stroke:#000000;stroke-width:1.99999964;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
</g>
<g
transform="translate(0,-1036.3622)"
id="g5240"
inkscape:groupmode="layer"
inkscape:label="Resize top thin"
style="display:none">
<path
inkscape:connector-curvature="0"
id="path5252-4"
d="m 1.5000948,1037.8622 12.9998102,0"
style="fill:none;stroke:#ffffff;stroke-width:3;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
inkscape:connector-curvature="0"
id="path5244"
d="m 7.9997821,1041.1627 c 0,9.3996 0,9.3996 0,9.3996"
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 3.7997795,1044.5623 c 4.2000026,-4.4 4.2000026,-4.4 4.2000026,-4.4 l 4.2000029,4.4"
id="path5246"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#000000;stroke-width:1.59999442;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 7.9997821,1041.1627 c 0,9.3996 0,9.3996 0,9.3996"
id="path5248"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path5250"
d="m 3.7997791,1044.5623 c 4.200003,-4.4 4.200003,-4.4 4.200003,-4.4 l 4.2000029,4.4"
style="fill:none;stroke:#000000;stroke-width:1.59999478;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
inkscape:connector-curvature="0"
id="path5252"
d="m 1.5000946,1037.8622 12.9998104,0"
style="fill:none;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
</g>
<g
style="display:none"
inkscape:label="Resize top thin (no gap)"
inkscape:groupmode="layer"
id="g5183"
transform="translate(0,-1036.3622)">
<path
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 7.9997821,1040.1625 c 0,10.3997 0,10.3997 0,10.3997"
id="path5185"
inkscape:connector-curvature="0" />
<path
inkscape:connector-curvature="0"
id="path5187"
d="m 3.7997795,1043.5622 c 4.2000026,-4.4 4.2000026,-4.4 4.2000026,-4.4 l 4.2000029,4.4"
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
inkscape:connector-curvature="0"
id="path5189"
d="m 1.4999999,1037.8622 12.9997491,0"
style="fill:none;stroke:#ffffff;stroke-width:2.99999976;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
inkscape:connector-curvature="0"
id="path5191"
d="m 7.9997821,1040.1626 c 0,10.3996 0,10.3996 0,10.3996"
style="fill:none;stroke:#000000;stroke-width:1.5999943;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
style="fill:none;stroke:#000000;stroke-width:1.59999478;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 3.7997791,1043.5622 c 4.200003,-4.4 4.200003,-4.4 4.200003,-4.4 l 4.2000029,4.4"
id="path5193"
inkscape:connector-curvature="0" />
<path
style="fill:none;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 1.500197,1037.8622 12.999606,0"
id="path5195"
inkscape:connector-curvature="0" />
</g>
<g
transform="translate(0,-1036.3622)"
id="g4242"
inkscape:groupmode="layer"
inkscape:label="Resize top-left thin"
style="display:none">
<path
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 5.7999972,1042.1621 c 6.4000058,6.4001 6.4000058,6.4001 6.4000058,6.4001"
id="path4244"
inkscape:connector-curvature="0" />
<path
sodipodi:nodetypes="ccc"
inkscape:connector-curvature="0"
id="path4248"
d="m 4.8,1047.5622 0,-6.4 6.4,0"
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
inkscape:connector-curvature="0"
id="path4250"
d="m 5.7999978,1042.162 c 6.4000062,6.4001 6.4000062,6.4001 6.4000062,6.4001"
style="fill:none;stroke:#000000;stroke-width:1.59999442;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
style="fill:none;stroke:#000000;stroke-width:1;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 1.500035,1050.8621 -3.46e-5,-12.9999 12.9999996,0"
id="path4252"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccc" />
<path
style="fill:none;stroke:#000000;stroke-width:1.60000002;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 4.8000006,1047.5621 0,-6.4 6.4000004,0"
id="path4254"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccc" />
<path
style="fill:none;stroke:#ffffff;stroke-width:1;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="M 0.5,1050.8622 0.4999654,1036.8623 14.5,1036.8622"
id="path4252-3"
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccc" />
</g>
<g
transform="translate(0,-1036.3622)"
id="g5038"
inkscape:groupmode="layer"
inkscape:label="Resize top-left"
style="display:none">
<path
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 5.7999972,1042.1622 c 6.4000058,6.4001 6.4000058,6.4001 6.4000058,6.4001"
id="path4902-8"
inkscape:connector-curvature="0" />
<path
sodipodi:nodetypes="ccc"
inkscape:connector-curvature="0"
id="path4904-5"
d="M 1.7500333,1050.6122 1.75,1038.1123 l 12.5,0"
style="fill:none;stroke:#ffffff;stroke-width:3.5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
sodipodi:nodetypes="ccc"
inkscape:connector-curvature="0"
id="path4906-8"
d="m 4.8,1047.5623 0,-6.4 6.4,0"
style="fill:none;stroke:#ffffff;stroke-width:3.5999999;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
style="fill:none;stroke:#000000;stroke-width:1.59999442;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline"
d="m 5.7999972,1042.1621 c 6.4000058,6.4001 6.4000058,6.4001 6.4000058,6.4001"
id="path5040"
inkscape:connector-curvature="0" />
<path
sodipodi:nodetypes="ccc"
inkscape:connector-curvature="0"
id="path5042"
d="M 1.7500333,1050.6121 1.75,1038.1122 l 12.5,0"
style="fill:none;stroke:#000000;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
<path
sodipodi:nodetypes="ccc"
inkscape:connector-curvature="0"
id="path5044"
d="m 4.8,1047.5622 0,-6.4 6.4,0"
style="fill:none;stroke:#000000;stroke-width:1.60000002;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none;display:inline" />
</g>
</svg>
......@@ -269,6 +269,14 @@
<property name="dragDropMode">
<enum>QAbstractItemView::InternalMove</enum>
</property>
<item>
<property name="text">
<string>H264</string>
</property>
<property name="checkState">
<enum>Checked</enum>
</property>
</item>
</widget>
</item>
<item row="0" column="0">
......@@ -1914,6 +1922,292 @@
</item>
</layout>
</widget>
<widget class="QWidget" name="video_page">
<layout class="QGridLayout" name="video_layout" columnstretch="1,0">
<property name="margin">
<number>0</number>
</property>
<item row="0" column="1">
<widget class="QGroupBox" name="video_codecs_group_box">
<property name="title">
<string>Video Codecs</string>
</property>
<layout class="QGridLayout" name="video_codecs_layout" columnstretch="0,1">
<item row="0" column="0" colspan="2">
<widget class="QListWidget" name="video_codecs_list">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="horizontalScrollBarPolicy">
<enum>Qt::ScrollBarAlwaysOff</enum>
</property>
<property name="dragEnabled">
<bool>true</bool>
</property>
<property name="dragDropMode">
<enum>QAbstractItemView::InternalMove</enum>
</property>
<item>
<property name="text">
<string>H264</string>
</property>
<property name="checkState">
<enum>Checked</enum>
</property>
</item>
</widget>
</item>
<item row="5" column="0" colspan="2">
<spacer name="video_codecs_spacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="3" column="0">
<widget class="QLabel" name="h264_profile_label">
<property name="text">
<string>H264 profile:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QComboBox" name="h264_profile_button">
<item>
<property name="text">
<string>baseline</string>
</property>
</item>
<item>
<property name="text">
<string>main</string>
</property>
</item>
<item>
<property name="text">
<string>high</string>
</property>
</item>
</widget>
</item>
<item row="2" column="0" colspan="2">
<spacer name="video_codecs_spacer_1">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="1" column="0" colspan="2">
<widget class="QLabel" name="video_codecs_note">
<property name="text">
<string>Drag codecs to change their order</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QComboBox" name="video_codec_bitrate_button">
<item>
<property name="text">
<string>automatic</string>
</property>
</item>
<item>
<property name="text">
<string>1 Mbps</string>
</property>
</item>
<item>
<property name="text">
<string>2 Mbps</string>
</property>
</item>
<item>
<property name="text">
<string>4 Mbps</string>
</property>
</item>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="video_codec_bitrate_label">
<property name="text">
<string>Bitrate:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="0" column="0">
<widget class="QGroupBox" name="video_camera_group_box">
<property name="title">
<string>Video Camera</string>
</property>
<layout class="QGridLayout" name="video_camera_layout">
<item row="5" column="0" colspan="3">
<spacer name="video_spacer_3">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="3" column="0">
<widget class="QLabel" name="video_resolution_label">
<property name="text">
<string>Resolution:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="0" column="0" colspan="3">
<widget class="VideoSurface" name="camera_preview" native="true">
<property name="minimumSize">
<size>
<width>0</width>
<height>240</height>
</size>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="video_camera_label">
<property name="text">
<string>Camera:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="2" column="2">
<spacer name="video_spacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>10</width>
<height>22</height>
</size>
</property>
</spacer>
</item>
<item row="2" column="1">
<widget class="QComboBox" name="video_camera_button">
<property name="sizePolicy">
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QComboBox" name="video_resolution_button">
<item>
<property name="text">
<string>HD 720p</string>
</property>
</item>
<item>
<property name="text">
<string>VGA</string>
</property>
</item>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="video_framerate_label">
<property name="text">
<string>Framerate:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="1" column="0" colspan="3">
<spacer name="video_spacer_1">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeType">
<enum>QSizePolicy::Fixed</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>10</height>
</size>
</property>
</spacer>
</item>
<item row="4" column="1">
<widget class="QComboBox" name="video_framerate_button">
<item>
<property name="text">
<string>10 fps</string>
</property>
</item>
<item>
<property name="text">
<string>15 fps</string>
</property>
</item>
<item>
<property name="text">
<string>20 fps</string>
</property>
</item>
<item>
<property name="text">
<string>25 fps</string>
</property>
</item>
<item>
<property name="text">
<string>30 fps</string>
</property>
</item>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<widget class="QWidget" name="chat_page">
<layout class="QGridLayout" name="chat_layout" rowstretch="1,0" rowminimumheight="0,150">
<property name="margin">
......@@ -2000,8 +2294,8 @@
</widget>
</item>
<item row="0" column="0" colspan="8">
<widget class="ChatWebView" name="style_view">
<property name="url">
<widget class="ChatWebView" name="style_view" native="true">
<property name="url" stdset="0">
<url>
<string>about:blank</string>
</url>
......@@ -2150,30 +2444,6 @@
<property name="horizontalSpacing">
<number>3</number>
</property>
<item row="0" column="1">
<widget class="QLineEdit" name="screenshots_directory_editor">
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QPushButton" name="screenshots_directory_browse_button">
<property name="text">
<string>Browse</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="screenshots_directory_label">
<property name="text">
<string>Screenshots Directory:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="4" column="0" colspan="3">
<spacer name="screen_sharing_spacer">
<property name="orientation">
......@@ -2208,6 +2478,30 @@
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLineEdit" name="screenshots_directory_editor">
<property name="readOnly">
<bool>true</bool>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QLabel" name="screenshots_directory_label">
<property name="text">
<string>Screenshots Directory:</string>
</property>
<property name="alignment">
<set>Qt::AlignRight|Qt::AlignTrailing|Qt::AlignVCenter</set>
</property>
</widget>
</item>
<item row="0" column="2">
<widget class="QPushButton" name="screenshots_directory_browse_button">
<property name="text">
<string>Browse</string>
</property>
</widget>
</item>
</layout>
</widget>
</item>
......@@ -2823,6 +3117,7 @@
<addaction name="accounts_action"/>
<addaction name="separator"/>
<addaction name="audio_action"/>
<addaction name="video_action"/>
<addaction name="chat_action"/>
<addaction name="screen_sharing_action"/>
<addaction name="file_transfer_action"/>
......@@ -2853,6 +3148,18 @@
<string>Audio</string>
</property>
</action>
<action name="video_action">
<property name="checkable">
<bool>true</bool>
</property>
<property name="icon">
<iconset>
<normaloff>icons/video.png</normaloff>icons/video.png</iconset>
</property>
<property name="text">
<string>Video</string>
</property>
</action>
<action name="chat_action">
<property name="checkable">
<bool>true</bool>
......@@ -2865,6 +3172,18 @@
<string>Chat</string>
</property>
</action>
<action name="screen_sharing_action">
<property name="checkable">
<bool>true</bool>
</property>
<property name="icon">
<iconset>
<normaloff>icons/screensharing.png</normaloff>icons/screensharing.png</iconset>
</property>
<property name="text">
<string>Screen Sharing</string>
</property>
</action>
<action name="file_transfer_action">
<property name="checkable">
<bool>true</bool>
......@@ -2904,18 +3223,6 @@
<string>Advanced</string>
</property>
</action>
<action name="screen_sharing_action">
<property name="checkable">
<bool>true</bool>
</property>
<property name="icon">
<iconset>
<normaloff>icons/screensharing.png</normaloff>icons/screensharing.png</iconset>
</property>
<property name="text">
<string>Screen Sharing</string>
</property>
</action>
</widget>
<customwidgets>
<customwidget>
......@@ -2923,16 +3230,16 @@
<extends>QWidget</extends>
<header>QtWebKit/QWebView</header>
</customwidget>
<customwidget>
<class>LineEdit</class>
<extends>QLineEdit</extends>
<header>blink.widgets.lineedit</header>
</customwidget>
<customwidget>
<class>ChatWebView</class>
<extends>QWebView</extends>
<header>blink.chatwindow</header>
</customwidget>
<customwidget>
<class>LineEdit</class>
<extends>QLineEdit</extends>
<header>blink.widgets.lineedit</header>
</customwidget>
<customwidget>
<class>SIPPortEditor</class>
<extends>QSpinBox</extends>
......@@ -2948,6 +3255,12 @@
<extends>QListView</extends>
<header>blink.preferences</header>
</customwidget>
<customwidget>
<class>VideoSurface</class>
<extends>QWidget</extends>
<header>blink.widgets.video</header>
<container>1</container>
</customwidget>
</customwidgets>
<resources/>
<connections>
......
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>video_widget</class>
<widget class="QWidget" name="video_widget">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>906</width>
<height>587</height>
</rect>
</property>
<property name="minimumSize">
<size>
<width>0</width>
<height>240</height>
</size>
</property>
<property name="mouseTracking">
<bool>true</bool>
</property>
<property name="windowTitle">
<string>Blink Video</string>
</property>
<property name="windowIcon">
<iconset>
<normaloff>icons/blink48.png</normaloff>icons/blink48.png</iconset>
</property>
<layout class="QGridLayout" name="video_widget_layout">
<property name="margin">
<number>6</number>
</property>
<item row="6" column="1">
<spacer name="spacer_buttons">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="1">
<widget class="VideoToolButton" name="fullscreen_button">
<property name="minimumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="styleSheet">
<string notr="true">QToolButton {
background: #e4e1df;
border: 2px solid #808080;
border-radius: 4px;
margin: 0px;
padding: 1px;
}
QToolButton:hover {
border: 2px solid #007fff;
}
QToolButton:pressed {
border: 2px solid #007fff;
background: qradialgradient(cx: 0.5, cy: 0.5, radius: 1, fx:0.5, fy:0.5, stop:0.2 #e4e1df, stop:1 #404040);
background-origin: border;
}
</string>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset>
<normaloff>icons/fullscreen.svg</normaloff>
<normalon>icons/fullscreen-exit.svg</normalon>
<disabledon>icons/fullscreen-exit.svg</disabledon>
<activeon>icons/fullscreen-exit.svg</activeon>
<selectedon>icons/fullscreen-exit.svg</selectedon>icons/fullscreen.svg</iconset>
</property>
<property name="iconSize">
<size>
<width>24</width>
<height>24</height>
</size>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="active" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="VideoToolButton" name="detach_button">
<property name="minimumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="styleSheet">
<string notr="true">QToolButton {
background: #e4e1df;
border: 2px solid #808080;
border-radius: 4px;
margin: 0px;
padding: 1px;
}
QToolButton:hover {
border: 2px solid #007fff;
}
QToolButton:pressed {
border: 2px solid #007fff;
background: qradialgradient(cx: 0.5, cy: 0.5, radius: 1, fx:0.5, fy:0.5, stop:0.2 #e4e1df, stop:1 #404040);
background-origin: border;
}
</string>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset>
<normaloff>icons/detach.svg</normaloff>
<normalon>icons/attach.svg</normalon>
<disabledon>icons/attach.svg</disabledon>
<activeon>icons/attach.svg</activeon>
<selectedon>icons/attach.svg</selectedon>icons/detach.svg</iconset>
</property>
<property name="iconSize">
<size>
<width>24</width>
<height>24</height>
</size>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="autoRaise">
<bool>true</bool>
</property>
<property name="active" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="VideoToolButton" name="hold_button">
<property name="minimumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="styleSheet">
<string notr="true">QToolButton {
background: #e4e1df;
border: 2px solid #808080;
border-radius: 4px;
margin: 0px;
padding: 1px;
}
QToolButton:hover {
border: 2px solid #007fff;
}
QToolButton:pressed {
border: 2px solid #007fff;
background: qradialgradient(cx: 0.5, cy: 0.5, radius: 1, fx:0.5, fy:0.5, stop:0.2 #e4e1df, stop:1 #404040);
background-origin: border;
}
</string>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset>
<normaloff>icons/pause.svg</normaloff>
<normalon>icons/paused.svg</normalon>
<disabledon>icons/paused.svg</disabledon>
<activeon>icons/paused.svg</activeon>
<selectedon>icons/paused.svg</selectedon>icons/pause.svg</iconset>
</property>
<property name="iconSize">
<size>
<width>24</width>
<height>24</height>
</size>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="active" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="VideoToolButton" name="mute_button">
<property name="minimumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="styleSheet">
<string notr="true">QToolButton {
background: #e4e1df;
border: 2px solid #808080;
border-radius: 4px;
margin: 0px;
padding: 1px;
}
QToolButton:hover {
border: 2px solid #007fff;
}
QToolButton:pressed {
border: 2px solid #007fff;
background: qradialgradient(cx: 0.5, cy: 0.5, radius: 1, fx:0.5, fy:0.5, stop:0.2 #e4e1df, stop:1 #404040);
background-origin: border;
}
</string>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset>
<normaloff>icons/mic-on.svg</normaloff>
<normalon>icons/mic-off.svg</normalon>
<disabledon>icons/mic-off.svg</disabledon>
<activeon>icons/mic-off.svg</activeon>
<selectedon>icons/mic-off.svg</selectedon>icons/mic-on.svg</iconset>
</property>
<property name="iconSize">
<size>
<width>24</width>
<height>24</height>
</size>
</property>
<property name="checkable">
<bool>true</bool>
</property>
<property name="active" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="VideoToolButton" name="screenshot_button">
<property name="minimumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="contextMenuPolicy">
<enum>Qt::CustomContextMenu</enum>
</property>
<property name="styleSheet">
<string notr="true">QToolButton {
background: #e4e1df;
border: 2px solid #808080;
border-radius: 4px;
margin: 0px;
padding: 1px;
}
QToolButton:hover {
border: 2px solid #007fff;
}
QToolButton:pressed {
border: 2px solid #007fff;
background: qradialgradient(cx: 0.5, cy: 0.5, radius: 1, fx:0.5, fy:0.5, stop:0.2 #e4e1df, stop:1 #404040);
background-origin: border;
}
</string>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset>
<normaloff>icons/screenshot.svg</normaloff>icons/screenshot.svg</iconset>
</property>
<property name="iconSize">
<size>
<width>24</width>
<height>24</height>
</size>
</property>
<property name="active" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="5" column="1">
<widget class="VideoToolButton" name="close_button">
<property name="minimumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="maximumSize">
<size>
<width>32</width>
<height>32</height>
</size>
</property>
<property name="styleSheet">
<string notr="true">QToolButton {
background: #e4e1df;
border: 2px solid #808080;
border-radius: 4px;
margin: 0px;
padding: 1px;
}
QToolButton:hover {
border: 2px solid #007fff;
}
QToolButton:pressed {
border: 2px solid #007fff;
background: qradialgradient(cx: 0.5, cy: 0.5, radius: 1, fx:0.5, fy:0.5, stop:0.2 #e4e1df, stop:1 #404040);
background-origin: border;
}
</string>
</property>
<property name="text">
<string/>
</property>
<property name="icon">
<iconset>
<normaloff>icons/close.svg</normaloff>
<activeoff>icons/close-active.svg</activeoff>icons/close.svg</iconset>
</property>
<property name="iconSize">
<size>
<width>24</width>
<height>24</height>
</size>
</property>
<property name="autoRaise">
<bool>true</bool>
</property>
<property name="active" stdset="0">
<bool>false</bool>
</property>
</widget>
</item>
<item row="0" column="0">
<spacer name="spacer_top">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>VideoToolButton</class>
<extends>QToolButton</extends>
<header>blink.chatwindow</header>
</customwidget>
</customwidgets>
<resources/>
<connections/>
</ui>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment